A login moves its node; an API key is added from any node, sealed (novox/hq ADR 0209)
The manager binds the node a login was adopted from to that login's licence, switching it if it was bound to another; serves public_key; adopt takes a key sealed to it. claude-code gains claude_code_add_api_key: read a file on this node, seal, hand to adopt, remove the file, optionally switch here.
This commit is contained in:
@@ -362,3 +362,40 @@ func TestABadEntryIsRefusedBeforeAnythingIsPut(t *testing.T) {
|
||||
t.Fatal("another node's key changed this one")
|
||||
}
|
||||
}
|
||||
|
||||
// An API key goes to the manager sealed to its key, never in the clear, and its file is gone afterwards.
|
||||
func TestAnAPIKeyIsHandedOverSealedAndItsFileRemoved(t *testing.T) {
|
||||
p, _ := node(t, "laptop")
|
||||
manager, _ := GenerateKeyPair()
|
||||
file := filepath.Join(p.Home, "api-key")
|
||||
writeFile(t, file, "sk-ant-api03-secret\n")
|
||||
var sent []string
|
||||
ask := func(address string, args any) (json.RawMessage, error) {
|
||||
raw, _ := json.Marshal(args)
|
||||
sent = append(sent, address+" "+string(raw))
|
||||
switch address {
|
||||
case "seat:anthropic-licence-manager.public_key":
|
||||
return json.Marshal(map[string]any{"public_key": manager.PublicKey})
|
||||
case "seat:anthropic-licence-manager.adopt":
|
||||
box := args.(map[string]any)["sealed"].(SealedBox)
|
||||
if key, err := Open(box, manager.PrivateKey); err != nil || key != "sk-ant-api03-secret" {
|
||||
t.Fatalf("the manager opened %q, %v", key, err)
|
||||
}
|
||||
return json.Marshal(map[string]any{"adopted": true})
|
||||
case "seat:anthropic-licence-manager.switch":
|
||||
return json.Marshal(map[string]any{"licence": "api"})
|
||||
}
|
||||
t.Fatalf("asked %s", address)
|
||||
return nil, nil
|
||||
}
|
||||
out, err := AddAPIKey(p, "api", file, true, ask)
|
||||
if err != nil || out["file"] != "removed" || out["switched"] == nil {
|
||||
t.Fatalf("%v %v", out, err)
|
||||
}
|
||||
if _, err := os.Stat(file); !os.IsNotExist(err) {
|
||||
t.Fatal("the key file is still there")
|
||||
}
|
||||
if strings.Contains(strings.Join(sent, "\n"), "sk-ant") {
|
||||
t.Fatalf("the key crossed in the clear: %v", sent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,6 +177,25 @@ func tools(p Paths, servers ServerState, view *ServerView) []stdio.Tool {
|
||||
}
|
||||
return GrantFor(p, key)
|
||||
}},
|
||||
{Name: "claude_code_add_api_key",
|
||||
Description: "Add an Anthropic API key as a licence (ADR 0209): read from a file on this machine — never typed as an argument — sealed to the licence manager's key, handed over, and the file removed once taken. With use_here, this machine switches to it at once; other machines move with the manager's `switch`.",
|
||||
Input: map[string]any{
|
||||
"name": str("the licence's name, e.g. api"),
|
||||
"file": str("a file on this machine holding the key, e.g. ~/api-key (removed once the manager has it)"),
|
||||
"use_here": map[string]any{"type": "boolean", "description": "switch this machine to the new licence"},
|
||||
},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
name, _ := a["name"].(string)
|
||||
file, _ := a["file"].(string)
|
||||
if strings.TrimSpace(name) == "" || strings.TrimSpace(file) == "" {
|
||||
return nil, errors.New("name and file are required")
|
||||
}
|
||||
if strings.HasPrefix(file, "~/") {
|
||||
file = filepath.Join(p.Home, file[2:])
|
||||
}
|
||||
useHere, _ := a["use_here"].(bool)
|
||||
return AddAPIKey(p, strings.TrimSpace(name), file, useHere, ask)
|
||||
}},
|
||||
{Name: "claude_code_mcp_list",
|
||||
Description: "The MCP servers registered through this module: those that apply on this node (beside the console, `mesh`, and those set in the module's settings), and every registration on the mesh, by key — `all.<server>` for every node, `<node>.<server>` for one.",
|
||||
Run: func(map[string]any) (any, error) {
|
||||
|
||||
@@ -343,6 +343,54 @@ func Apply(p Paths, c Current, write WriteManaged) (map[string]any, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AddAPIKey adds an API key from a file on this node to the licence manager (ADR 0209): sealed to the
|
||||
// manager's public key, handed to the seat's `adopt` on request/reply, and the file removed once taken. With
|
||||
// useHere, this node is switched to the new licence. The key never crosses the bus in the clear and is
|
||||
// never an argument.
|
||||
func AddAPIKey(p Paths, name, file string, useHere bool, ask Ask) (map[string]any, error) {
|
||||
raw, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the key is read from a file on this node: %w", err)
|
||||
}
|
||||
key := strings.TrimSpace(string(raw))
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("%s is empty", file)
|
||||
}
|
||||
answer, err := ask(SeatVerb("public_key"), map[string]any{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var pk struct {
|
||||
PublicKey string `json:"public_key"`
|
||||
}
|
||||
if err := json.Unmarshal(answer, &pk); err != nil || !strings.Contains(pk.PublicKey, "PUBLIC KEY") {
|
||||
return nil, errors.New("the licence manager did not say what key to seal to")
|
||||
}
|
||||
box, err := Seal(key, pk.PublicKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
adopted, err := ask(SeatVerb("adopt"), map[string]any{"name": name, "sealed": box, "from": p.Node})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]any{"adopted": json.RawMessage(adopted)}
|
||||
// Taken: the key now lives encrypted in the manager's store alone.
|
||||
if err := os.Remove(file); err != nil {
|
||||
out["file"] = "could not be removed: " + err.Error()
|
||||
} else {
|
||||
out["file"] = "removed"
|
||||
}
|
||||
if useHere {
|
||||
switched, err := ask(SeatVerb("switch"), map[string]any{"consumer": p.Node, "licence": name})
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("adopted, and switching this node to it failed: %w", err)
|
||||
}
|
||||
out["switched"] = json.RawMessage(switched)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---- MCP servers ----------------------------------------------------------------------------------
|
||||
|
||||
// Registration is a server registered (or, with no entry, unregistered) through this module.
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"claude_code_render",
|
||||
"claude_code_pull",
|
||||
"claude_code_grant",
|
||||
"claude_code_add_api_key",
|
||||
"claude_code_mcp_list",
|
||||
"claude_code_mcp_register",
|
||||
"claude_code_mcp_unregister"
|
||||
|
||||
Reference in New Issue
Block a user