diff --git a/modules/claude-code/cmd/claude-code/claude_code_test.go b/modules/claude-code/cmd/claude-code/claude_code_test.go index 317ca54..790f830 100644 --- a/modules/claude-code/cmd/claude-code/claude_code_test.go +++ b/modules/claude-code/cmd/claude-code/claude_code_test.go @@ -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) + } +} diff --git a/modules/claude-code/cmd/claude-code/main.go b/modules/claude-code/cmd/claude-code/main.go index 9f7c0e1..ce692ef 100644 --- a/modules/claude-code/cmd/claude-code/main.go +++ b/modules/claude-code/cmd/claude-code/main.go @@ -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.` for every node, `.` for one.", Run: func(map[string]any) (any, error) { diff --git a/modules/claude-code/cmd/claude-code/node.go b/modules/claude-code/cmd/claude-code/node.go index 9e5626f..d6d2654 100644 --- a/modules/claude-code/cmd/claude-code/node.go +++ b/modules/claude-code/cmd/claude-code/node.go @@ -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. diff --git a/modules/claude-code/module.json b/modules/claude-code/module.json index 8bcadbf..8d2a888 100644 --- a/modules/claude-code/module.json +++ b/modules/claude-code/module.json @@ -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" diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go index 2879356..fc9ce86 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go @@ -290,7 +290,7 @@ func tools() []stdio.Tool { } return m.Bind(ctx, c, l, "bind") }), - verb("switch", "Move a consumer to another licence. Its node fetches the new licence's token at once and points the agent's account at it.", + verb("switch", "Move a consumer to another licence. Its node fetches the new licence's token at once and points the agent's account at it. A login on a node does this by itself (ADR 0209); this is for moving one without a login, or back.", map[string]any{"consumer": consumer, "licence": str("the licence to move to")}, func(ctx context.Context, m *Manager, a map[string]any) (any, error) { c, l, err := two(a, "consumer", "licence") @@ -326,15 +326,34 @@ func tools() []stdio.Tool { } return m.Store.Usage(ctx, l, limit) }), - verb("adopt", "Adopt an API key from a file on the manager's node, never as an argument. Subscriptions are adopted from the nodes' logins by themselves.", - map[string]any{"name": str("the licence's name"), "file": str("a file on the manager's node holding the key")}, + verb("adopt", "Adopt an API key — never as an argument: from a file on the manager's node (`file`), or sealed to the manager's `public_key` by a node's claude-code (`sealed`; its `claude_code_add_api_key` does this). Subscriptions are adopted from the nodes' logins by themselves.", + map[string]any{"name": str("the licence's name"), "file": str("a file on the manager's node holding the key"), + "sealed": map[string]any{"type": "object", "description": "the key sealed to the manager's public key"}, + "from": str("which node it came from, for the audit")}, func(ctx context.Context, m *Manager, a map[string]any) (any, error) { - n, f, err := two(a, "name", "file") + n, err := text(a, "name") if err != nil { return nil, err } + if raw, ok := a["sealed"]; ok && raw != nil { + b, _ := json.Marshal(raw) + var box SealedBox + if err := json.Unmarshal(b, &box); err != nil { + return nil, err + } + from, _ := a["from"].(string) + return m.AdoptSealedKey(ctx, n, box, "a node: "+from) + } + f, err := text(a, "file") + if err != nil { + return nil, errors.New("adopt takes a `file` on the manager's node, or a `sealed` key") + } return m.AdoptKey(ctx, n, f) }), + verb("public_key", "The manager's public key, PEM: what a node seals an API key or a login to before handing it over.", + nil, func(_ context.Context, m *Manager, _ map[string]any) (any, error) { + return map[string]any{"public_key": m.Keys.PublicKey}, nil + }), verb("current", "For a consumer's agent module (ADR 0206): its token, sealed to the public key it sends, with the licence, kind and generation. Null when it is bound to nothing.", map[string]any{"consumer": consumer, "public_key": str("the consumer's public key, PEM")}, func(ctx context.Context, m *Manager, a map[string]any) (any, error) { diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go index f057b7c..62eeac8 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go @@ -336,7 +336,24 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings) _ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": l.Name, "node": c.Node, "account": c.Identity.AccountUUID, "vendorNamedAccount": r.Account != ""}) - // A first binding follows the login (ADR 0206 §7): every node reporting this account and bound to nothing. + // A login moves its node (ADR 0209): the node this login came from is bound to its licence — + // switched, if it was bound to another. Then every node reporting this account and bound to nothing. + if b, err := m.Store.Binding(ctx, c.Node); err != nil { + return "", err + } else if b == nil || b.Licence != l.Name { + if _, err := m.Store.Bind(ctx, c.Node, l.Name); err != nil { + return "", err + } + from := "" + if b != nil { + from = b.Licence + } + _ = m.Store.Audit(ctx, map[bool]string{true: "switched", false: "bound"}[b != nil], + map[string]any{"consumer": c.Node, "licence": l.Name, "from": from, "by": "a login there"}) + if b != nil { + m.Log("%s moved from %s to %s: a login there", c.Node, from, l.Name) + } + } for _, rep := range reports { if rep.Identity == nil || rep.Identity.AccountUUID != c.Identity.AccountUUID { continue @@ -612,16 +629,29 @@ var licenceName = regexp.MustCompile(`^[A-Za-z0-9@._-]+$`) // AdoptKey adopts an API key from a file on this node — never an argument (design 39 §6). func (m *Manager) AdoptKey(ctx context.Context, name, file string) (map[string]any, error) { - if !licenceName.MatchString(name) { - return nil, fmt.Errorf("%q is not a licence name: letters, digits and @._-", name) - } raw, err := os.ReadFile(file) if err != nil { return nil, err } - key := strings.TrimSpace(string(raw)) + return m.adoptKey(ctx, name, strings.TrimSpace(string(raw)), "a file on "+m.Holder) +} + +// AdoptSealedKey adopts an API key sealed to this module's public key by a node's agent module (ADR 0209): +// the key crosses the bus only sealed, on request/reply. +func (m *Manager) AdoptSealedKey(ctx context.Context, name string, box SealedBox, from string) (map[string]any, error) { + key, err := Open(box, m.Keys.PrivateKey) + if err != nil { + return nil, err + } + return m.adoptKey(ctx, name, strings.TrimSpace(key), from) +} + +func (m *Manager) adoptKey(ctx context.Context, name, key, from string) (map[string]any, error) { + if !licenceName.MatchString(name) { + return nil, fmt.Errorf("%q is not a licence name: letters, digits and @._-", name) + } if key == "" { - return nil, fmt.Errorf("%s is empty", file) + return nil, errors.New("the key is empty") } held, err := m.Store.Licence(ctx, name) if err != nil { @@ -634,11 +664,11 @@ func (m *Manager) AdoptKey(ctx context.Context, name, file string) (map[string]a if err := m.Store.SaveLicence(ctx, l); err != nil { return nil, err } - _ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": "a file"}) + _ = m.Store.Audit(ctx, "adopted", map[string]any{"licence": name, "kind": "api-key", "from": from}) if err := m.publishAdvance(ctx, l); err != nil { return nil, err } - _ = m.Emit("licence.adopted", map[string]any{"licence": name, "from": "a file", "replaced": held != nil}) + _ = m.Emit("licence.adopted", map[string]any{"licence": name, "from": from, "replaced": held != nil}) return map[string]any{"licence": name, "kind": "api-key", "adopted": true, "fingerprint": Fingerprint(key)}, nil } diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go index 026a583..d4bf42f 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager_test.go @@ -357,3 +357,69 @@ func TestANodeReportingAnAdoptedAccountLaterIsBoundToIt(t *testing.T) { t.Fatal("a node already bound was bound again") } } + +// A login to another account on one node adopts it and moves that node, and only that node (ADR 0209). +func TestALoginToAnotherAccountMovesItsNodeAndNoOther(t *testing.T) { + mm := newMesh(t) + ctx := context.Background() + _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0), mm.report("server", "", t0, "acct-1")}) + if mm.state["laptop"].Licence != licence1 || mm.state["server"].Licence != licence1 { + t.Fatalf("%v", mm.state) + } + mm.now = t0.Add(time.Hour) + mm.logins["laptop"] = FullGrant{AccessToken: "local", RefreshToken: "rt-b", ExpiresAt: 1} + mm.vendor.live["rt-b"] = true + second := mm.report("laptop", "rt-b", t0.Add(time.Hour), "acct-2") + adopted, _ := mm.m.Consider(ctx, []Holdings{second, mm.report("server", "", t0, "acct-1")}) + if len(adopted) != 1 || adopted[0] != "acct-2@example.org" { + t.Fatalf("adopted %v", adopted) + } + if mm.state["laptop"].Licence != "acct-2@example.org" { + t.Fatalf("the node a login was made on stayed bound to %v", mm.state["laptop"]) + } + if mm.state["server"].Licence != licence1 { + t.Fatalf("another node moved: %v", mm.state["server"]) + } + ls, _ := mm.store.Licences(ctx) + if len(ls) != 2 { + t.Fatalf("%d licences", len(ls)) + } +} + +// A login that does not refresh moves nothing. +func TestALoginThatDoesNotRefreshMovesNothing(t *testing.T) { + mm := newMesh(t) + ctx := context.Background() + _, _ = mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}) + g := mm.state["laptop"] + mm.logins["laptop"] = FullGrant{AccessToken: "local", RefreshToken: "rt-dead", ExpiresAt: 1} + _, _ = mm.m.Consider(ctx, []Holdings{mm.report("laptop", "rt-dead", t0.Add(time.Hour), "acct-2")}) + if mm.state["laptop"] != g { + t.Fatalf("a dead login moved its node: %v", mm.state["laptop"]) + } +} + +// An API key sealed to the manager by a node is adopted, and opens only for the manager. +func TestAnAPIKeySealedByANodeIsAdopted(t *testing.T) { + mm := newMesh(t) + ctx := context.Background() + box, _ := Seal("sk-ant-api03-test\n", mm.keys.PublicKey) + r, err := mm.m.AdoptSealedKey(ctx, "api", box, "laptop") + if err != nil || r["adopted"] != true { + t.Fatalf("%v %v", r, err) + } + l, _ := mm.store.Licence(ctx, "api") + if plain, _ := mm.m.Crypt.Open(l.Sealed); plain != "sk-ant-api03-test" { + t.Fatalf("stored %q", plain) + } + other, _ := GenerateKeyPair() + wrong, _ := Seal("sk", other.PublicKey) + if _, err := mm.m.AdoptSealedKey(ctx, "api2", wrong, "laptop"); err == nil { + t.Fatal("a key sealed to another key was adopted") + } + for _, e := range mm.events { + if strings.Contains(e, "sk-ant") { + t.Fatalf("the key was published: %s", e) + } + } +}