From 5ca3876beacc4f900282423ba7d17e5274b7f23c Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 23:13:31 +0100 Subject: [PATCH] M-7: every grant and access token lives in the vault MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manager hands what the vendor answers to licence-put, the grant first and then the access, at the licence's next generation, and reads a grant with get for one act; it keeps no token at rest (ADR 0550 §3). The sealing at rest goes; opening stays for the carry-over. Builds: design 39 bus-and-switch §15, §16, §20, §45, B15 Claude-Session: https://claude.ai/code/session_01NcbgfHb9d4T9nq5Q6WqdiJ --- .../cmd/claude-licence-manager/crypt.go | 15 +--- .../cmd/claude-licence-manager/main.go | 1 + .../cmd/claude-licence-manager/manager.go | 78 +++++++++++++----- .../claude-licence-manager/manager_test.go | 51 ++++++++++-- .../cmd/claude-licence-manager/pgstore.go | 11 +-- .../cmd/claude-licence-manager/store.go | 3 +- .../cmd/claude-licence-manager/store_test.go | 21 ++++- .../cmd/claude-licence-manager/vaultgrants.go | 79 +++++++++++++++++++ .../vaultgrants_test.go | 43 ++++++++++ modules/claude-licence-manager/go.mod | 2 +- 10 files changed, 255 insertions(+), 49 deletions(-) create mode 100644 modules/claude-licence-manager/cmd/claude-licence-manager/vaultgrants.go create mode 100644 modules/claude-licence-manager/cmd/claude-licence-manager/vaultgrants_test.go diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/crypt.go b/modules/claude-licence-manager/cmd/claude-licence-manager/crypt.go index 78cc180a3..2857e5f97 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/crypt.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/crypt.go @@ -5,7 +5,6 @@ package main // key derived from the vault's secret by SHA-256 so a secret of any length serves. import ( - "crypto/rand" "crypto/sha256" "encoding/base64" "errors" @@ -13,7 +12,7 @@ import ( "strings" ) -// Crypt seals and opens what the store keeps. +// Crypt opens what today's database keeps at rest. type Crypt struct{ key []byte } // NewCrypt is the store's cipher from the vault's secret. @@ -35,16 +34,8 @@ func CryptFromFile(path string) (*Crypt, error) { return NewCrypt(string(raw)) } -// Seal encrypts a plaintext as `v1..`. -func (c *Crypt) Seal(plaintext string) string { - gcm, _ := newGCM(c.key) - iv := make([]byte, 12) - _, _ = rand.Read(iv) - b64 := base64.StdEncoding.EncodeToString - return "v1." + b64(iv) + "." + b64(gcm.Seal(nil, iv, []byte(plaintext), nil)) -} - -// Open decrypts what Seal made with the same key. +// Open decrypts a grant today's database keeps at rest, `v1..`: the carry-over's alone, since +// nothing seals a grant at rest any more (design 39 bus-and-switch §20, B15). func (c *Crypt) Open(sealed string) (string, error) { parts := strings.Split(sealed, ".") if len(parts) != 3 || parts[0] != "v1" { 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 1bb5cfe0c..4b0dd45dd 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/main.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/main.go @@ -76,6 +76,7 @@ func manager() (*Manager, error) { Store: store, Vendor: LiveVendor(), Crypt: crypt, + Grants: VaultGrants{Keys: keys}, Keys: keys, AskGrant: func(_ context.Context, node, publicKey string) (GrantAnswer, error) { var a GrantAnswer 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 98a960d8d..2d35d6220 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/manager.go @@ -160,7 +160,10 @@ type GrantAnswer struct { type Manager struct { Store Store Vendor Vendor - Crypt *Crypt + // Crypt opens the grants today's database keeps at rest, for the carry-over alone (design 39 bus-and-switch §12, A3). + Crypt *Crypt + // Grants is the vault, which holds every licence's grant and access token (§15, §16). + Grants Grants Keys KeyPair // AskGrant asks a node's agent module for the grant a login left there, sealed to publicKey. An error // is the node not answering; a nil Sealed is no login waiting. @@ -395,7 +398,7 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings) l := Licence{Name: NameFor(c.Identity), Kind: "subscription", AccountUUID: c.Identity.AccountUUID, Email: c.Identity.EmailAddress, OrganizationUUID: c.Identity.OrganizationUUID} if held != nil { - l.Name, l.NotifiedAt = held.Name, held.NotifiedAt + l.Name, l.NotifiedAt, l.Generation = held.Name, held.NotifiedAt, held.Generation if l.Email == "" { l.Email = held.Email } @@ -403,7 +406,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings) l.OrganizationUUID = held.OrganizationUUID } } - m.keep(&l, r.Grant) + if err := m.keep(ctx, &l, r.Grant); err != nil { + return "", err + } l.AdoptedAt = max(now, c.ChangedAt) if err := m.Store.SaveLicence(ctx, l); err != nil { return "", err @@ -454,10 +459,16 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings) return l.Name, nil } -// keep stores a grant on its licence: encrypted, fingerprinted, its expiries, fresh. -func (m *Manager) keep(l *Licence, g FullGrant) { - raw, _ := json.Marshal(g) - l.Sealed = m.Crypt.Seal(string(raw)) +// keep hands a grant to the vault at the licence's next generation, the grant first and then its access (design 39 +// bus-and-switch §15), and keeps on the licence its fingerprint and expiries alone: no token once it returns. +func (m *Manager) keep(ctx context.Context, l *Licence, g FullGrant) error { + grant, _ := json.Marshal(g) + access, _ := json.Marshal(map[string]any{"accessToken": g.AccessToken, "expiresAt": g.ExpiresAt, + "refreshTokenExpiresAt": g.RefreshTokenExpiresAt, "scopes": g.Scopes, + "subscriptionType": g.SubscriptionType, "rateLimitTier": g.RateLimitTier}) + if err := m.handOver(ctx, l, string(grant), string(access)); err != nil { + return err + } l.RefreshFingerprint = Fingerprint(g.RefreshToken) l.AccessExpiresAt = g.ExpiresAt if g.RefreshTokenExpiresAt != nil { @@ -465,6 +476,29 @@ func (m *Manager) keep(l *Licence, g FullGrant) { } l.Failures = 0 l.RotatedAt = m.Now().UnixMilli() + return nil +} + +// handOver puts a licence's two secrets in the vault at its next generation, the grant first (§15). +func (m *Manager) handOver(ctx context.Context, l *Licence, grant, access string) error { + generation := l.Generation + 1 + for _, part := range []struct{ name, value string }{{PartGrant, grant}, {PartAccess, access}} { + if err := m.Grants.Put(ctx, KeyFor(l.Name), part.name, generation, part.value); err != nil { + return fmt.Errorf("the vault did not take the %s of %s: %w", part.name, l.Name, err) + } + } + l.Generation = generation + return nil +} + +// grantOf reads a subscription's grant from the vault for one act (§16); the caller drops it when the act ends. +func (m *Manager) grantOf(ctx context.Context, l Licence) (FullGrant, error) { + var g FullGrant + plain, err := m.Grants.Get(ctx, KeyFor(l.Name), PartGrant) + if err != nil { + return g, fmt.Errorf("the grant of %s cannot be read from the vault: %w", l.Name, err) + } + return g, json.Unmarshal([]byte(plain), &g) } // publishAdvance gives every consumer of a licence a new generation, and tells each in the state with the @@ -488,7 +522,7 @@ func (m *Manager) publishAdvance(ctx context.Context, l Licence) error { // Due says whether a licence needs a refresh now: near expiry, or older than the cadence. func Due(l Licence, now time.Time, s Settings) bool { - if l.Kind != "subscription" || l.Sealed == "" { + if l.Kind != "subscription" || l.Generation == 0 { return false } ms := now.UnixMilli() @@ -518,22 +552,19 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri if l == nil { return map[string]any{"licence": name, "refreshed": false, "reason": "no such licence"}, nil } - if l.Kind != "subscription" || l.Sealed == "" { + if l.Kind != "subscription" || l.Generation == 0 { return map[string]any{"licence": name, "refreshed": false, "reason": "an API key does not refresh"}, nil } now := m.Now() if !force && !Due(*l, now, m.Settings) { return map[string]any{"licence": name, "refreshed": false, "reason": "not due"}, nil } - plain, err := m.Crypt.Open(l.Sealed) + g, err := m.grantOf(ctx, *l) if err != nil { return nil, err } - var g FullGrant - if err := json.Unmarshal([]byte(plain), &g); err != nil { - return nil, err - } r := m.Vendor.Refresh(ctx, g) + g = FullGrant{} if !r.OK { l.Failures++ m.Log("%s did not refresh (%d in a row): %d %s", name, l.Failures, r.Status, r.Reason) @@ -544,7 +575,9 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri } return map[string]any{"licence": name, "refreshed": false, "reason": fmt.Sprintf("%d %s", r.Status, r.Reason), "failures": l.Failures}, nil } - m.keep(l, r.Grant) + if err := m.keep(ctx, l, r.Grant); err != nil { + return nil, err + } if l.RefreshExpiresAt != 0 { left := time.Duration(l.RefreshExpiresAt-now.UnixMilli()) * time.Millisecond m.notify(l, fmt.Sprintf("its refresh token expires in %.1f day(s): a person must log in again", left.Hours()/24), @@ -611,16 +644,15 @@ func (m *Manager) ReadUsage(ctx context.Context) error { return err } for _, l := range all { - if l.Kind != "subscription" || l.Sealed == "" { + if l.Kind != "subscription" || l.Generation == 0 { continue } - plain, err := m.Crypt.Open(l.Sealed) + g, err := m.grantOf(ctx, l) if err != nil { return err } - var g FullGrant - _ = json.Unmarshal([]byte(plain), &g) raw, err := m.Vendor.Usage(ctx, g.AccessToken) + g = FullGrant{} if err != nil || raw == nil { continue } @@ -789,7 +821,13 @@ func (m *Manager) adoptKey(ctx context.Context, name, key, from string) (map[str _ = m.Emit("licence.refused", map[string]any{"licence": name, "from": from, "reason": why}) return nil, fmt.Errorf("%s is refused: %s", name, why) } - l := Licence{Name: name, Kind: "api-key", Sealed: m.Crypt.Seal(key), AdoptedAt: m.Now().UnixMilli()} + l := Licence{Name: name, Kind: "api-key", AdoptedAt: m.Now().UnixMilli()} + if held != nil { + l.Generation = held.Generation + } + if err := m.handOver(ctx, &l, key, key); err != nil { + return nil, err + } if err := m.Store.SaveLicence(ctx, l); err != nil { return nil, err } 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 e8b808d7a..ae6f11d9a 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 @@ -47,7 +47,42 @@ func (v *stubVendor) Usage(context.Context, string) (map[string]any, error) { return map[string]any{"five_hour": map[string]any{"utilization": 12.0}}, nil } +// memGrants is the vault as the manager sees it: every licence's two secrets by name, the newest generation kept. +type memGrants struct { + mu sync.Mutex + held map[string]heldGrant + puts []string +} + +type heldGrant struct { + generation int64 + value string +} + +func (g *memGrants) Put(_ context.Context, key, part string, generation int64, value string) error { + g.mu.Lock() + defer g.mu.Unlock() + name := SecretName(key, part) + if h, ok := g.held[name]; ok && h.generation >= generation { + return fmt.Errorf("%s is held at generation %d, and generation %d is not newer", name, h.generation, generation) + } + g.held[name] = heldGrant{generation, value} + g.puts = append(g.puts, fmt.Sprintf("%s@%d", name, generation)) + return nil +} + +func (g *memGrants) Get(_ context.Context, key, part string) (string, error) { + g.mu.Lock() + defer g.mu.Unlock() + h, ok := g.held[SecretName(key, part)] + if !ok { + return "", fmt.Errorf("the vault holds no secret named %s", SecretName(key, part)) + } + return h.value, nil +} + type miniMesh struct { + grants *memGrants m *Manager store *MemoryStore vendor *stubVendor @@ -61,14 +96,15 @@ type miniMesh struct { func newMesh(t *testing.T) *miniMesh { t.Helper() - mm := &miniMesh{logins: map[string]FullGrant{}, state: map[string]BindingState{}, now: t0} + mm := &miniMesh{logins: map[string]FullGrant{}, state: map[string]BindingState{}, now: t0, + grants: &memGrants{held: map[string]heldGrant{}}} now := func() time.Time { return mm.now } mm.store = NewMemoryStore(now) mm.vendor = &stubVendor{live: map[string]bool{}, now: now} crypt, _ := NewCrypt("a key the vault made") mm.keys, _ = GenerateKeyPair() mm.m = &Manager{ - Store: mm.store, Vendor: mm.vendor, Crypt: crypt, Keys: mm.keys, + Store: mm.store, Vendor: mm.vendor, Crypt: crypt, Grants: mm.grants, Keys: mm.keys, AskGrant: func(_ context.Context, node, publicKey string) (GrantAnswer, error) { if mm.askDown { return GrantAnswer{}, fmt.Errorf("503 no responders") @@ -304,8 +340,8 @@ func TestNothingTheManagerPublishesKeepsOrListsCarriesAToken(t *testing.T) { } } row, _ := mm.store.Licence(ctx, licence1) - if strings.Contains(row.Sealed, "rt-") { - t.Fatal("the grant is stored in the clear") + if raw, _ := json.Marshal(row); strings.Contains(string(raw), "rt-") || strings.Contains(string(raw), "at-") { + t.Fatalf("the licence keeps a token: %s", raw) } } @@ -420,9 +456,10 @@ func TestAnAPIKeySealedByANodeIsAdopted(t *testing.T) { 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-0123456789abcdefghij" { - t.Fatalf("stored %q", plain) + for _, part := range []string{PartGrant, PartAccess} { + if plain, _ := mm.grants.Get(ctx, "api", part); plain != "sk-ant-api03-test-0123456789abcdefghij" { + t.Fatalf("the vault holds %q as the %s", plain, part) + } } other, _ := GenerateKeyPair() wrong, _ := Seal("sk", other.PublicKey) diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/pgstore.go b/modules/claude-licence-manager/cmd/claude-licence-manager/pgstore.go index 06e1bd1b3..7c544d6ab 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/pgstore.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/pgstore.go @@ -102,7 +102,8 @@ const licenceColumns = `name, kind, coalesce(account_uuid,''), coalesce(email,'' func scanLicence(row pgx.Row) (*Licence, error) { var l Licence - err := row.Scan(&l.Name, &l.Kind, &l.AccountUUID, &l.Email, &l.OrganizationUUID, &l.Sealed, &l.RefreshFingerprint, + var sealed string // the grant at rest, read by the carry-over alone (design 39 bus-and-switch §12) + err := row.Scan(&l.Name, &l.Kind, &l.AccountUUID, &l.Email, &l.OrganizationUUID, &sealed, &l.RefreshFingerprint, &l.AccessExpiresAt, &l.RefreshExpiresAt, &l.Failures, &l.NotifiedAt, &l.AdoptedAt, &l.RotatedAt) if errors.Is(err, pgx.ErrNoRows) { return nil, nil @@ -150,15 +151,15 @@ func nullableInt(v int64) any { } func (s *PgStore) SaveLicence(ctx context.Context, l Licence) error { - _, err := s.pool.Exec(ctx, `insert into licence (name, kind, account_uuid, email, organization_uuid, sealed, refresh_fingerprint, + _, err := s.pool.Exec(ctx, `insert into licence (name, kind, account_uuid, email, organization_uuid, refresh_fingerprint, access_expires_at, refresh_expires_at, failures, notified_at, adopted_at, rotated_at) - values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) + values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) on conflict (name) do update set kind = excluded.kind, account_uuid = excluded.account_uuid, email = excluded.email, - organization_uuid = excluded.organization_uuid, sealed = excluded.sealed, refresh_fingerprint = excluded.refresh_fingerprint, + organization_uuid = excluded.organization_uuid, refresh_fingerprint = excluded.refresh_fingerprint, access_expires_at = excluded.access_expires_at, refresh_expires_at = excluded.refresh_expires_at, failures = excluded.failures, notified_at = excluded.notified_at, adopted_at = excluded.adopted_at, rotated_at = excluded.rotated_at`, - l.Name, l.Kind, nullable(l.AccountUUID), nullable(l.Email), nullable(l.OrganizationUUID), nullable(l.Sealed), + l.Name, l.Kind, nullable(l.AccountUUID), nullable(l.Email), nullable(l.OrganizationUUID), nullable(l.RefreshFingerprint), nullableInt(l.AccessExpiresAt), nullableInt(l.RefreshExpiresAt), l.Failures, nullableInt(l.NotifiedAt), l.AdoptedAt, nullableInt(l.RotatedAt)) return err diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/store.go b/modules/claude-licence-manager/cmd/claude-licence-manager/store.go index b21a3eaa3..5436c5d38 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/store.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/store.go @@ -19,7 +19,6 @@ type Licence struct { AccountUUID string `json:"accountUuid,omitempty"` Email string `json:"email,omitempty"` OrganizationUUID string `json:"organizationUuid,omitempty"` - Sealed string `json:"-"` // the grant or the key, encrypted at rest RefreshFingerprint string `json:"-"` AccessExpiresAt int64 `json:"accessExpiresAt,omitempty"` RefreshExpiresAt int64 `json:"refreshExpiresAt,omitempty"` @@ -27,6 +26,8 @@ type Licence struct { NotifiedAt int64 `json:"-"` AdoptedAt int64 `json:"adoptedAt"` RotatedAt int64 `json:"rotatedAt,omitempty"` + // Generation is the generation of the licence's secrets in the vault (design 39 bus-and-switch §2, §15). + Generation int64 `json:"generation,omitempty"` } // Binding is one consumer's binding and the generation it was last given (ADR 0206). diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/store_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/store_test.go index 30182eebe..4ff3b785b 100644 --- a/modules/claude-licence-manager/cmd/claude-licence-manager/store_test.go +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/store_test.go @@ -1,6 +1,8 @@ package main import ( + "crypto/rand" + "encoding/base64" "context" "os" "strings" @@ -33,7 +35,7 @@ func TestTheStoreOnPostgres(t *testing.T) { t.Fatalf("migration %d: %v", i+1, err) } } - l := Licence{Name: "a@example.org", Kind: "subscription", AccountUUID: "u-1", Email: "a@example.org", Sealed: "v1.x.y", + l := Licence{Name: "a@example.org", Kind: "subscription", AccountUUID: "u-1", Email: "a@example.org", RefreshFingerprint: "sha256:1", AccessExpiresAt: 1, RefreshExpiresAt: 2, AdoptedAt: 3} if err := s.SaveLicence(ctx, l); err != nil { t.Fatal(err) @@ -85,7 +87,7 @@ func TestTheStoreOnPostgres(t *testing.T) { t.Fatal("unbind") } all, _ := s.Licences(ctx) - if len(all) != 1 || !strings.HasPrefix(all[0].Sealed, "v1.") { + if len(all) != 1 { t.Fatalf("%v", all) } } @@ -109,7 +111,7 @@ func TestARefreshThatDoesNotRotateKeepsTheRefreshToken(t *testing.T) { func TestAGrantAtRestOpensOnlyWithItsKey(t *testing.T) { a, _ := NewCrypt("one key") b, _ := NewCrypt("another key") - sealed := a.Seal(`{"refreshToken":"rt"}`) + sealed := sealAtRest(t, a, `{"refreshToken":"rt"}`) // The plaintext's own words, not two letters a random ciphertext holds one time in about seventy. if strings.Contains(sealed, "refreshToken") { t.Fatal("stored in the clear") @@ -124,3 +126,16 @@ func TestAGrantAtRestOpensOnlyWithItsKey(t *testing.T) { t.Fatal("an empty key was accepted") } } + +// sealAtRest seals a grant as today's database keeps it, `v1..`: the rows the carry-over reads. +func sealAtRest(t *testing.T, c *Crypt, plaintext string) string { + t.Helper() + gcm, err := newGCM(c.key) + if err != nil { + t.Fatal(err) + } + iv := make([]byte, 12) + _, _ = rand.Read(iv) + b64 := base64.StdEncoding.EncodeToString + return "v1." + b64(iv) + "." + b64(gcm.Seal(nil, iv, []byte(plaintext), nil)) +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/vaultgrants.go b/modules/claude-licence-manager/cmd/claude-licence-manager/vaultgrants.go new file mode 100644 index 000000000..655c06ab2 --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/vaultgrants.go @@ -0,0 +1,79 @@ +package main + +// The vault holds every licence's grant and access token (design 39 bus-and-switch §15, §16, B4, B5): the manager hands +// what it gets to `licence-put` in two steps, with a key the vault made for the call, and reads a grant with `get`, +// sealed to its own public key, for one act. + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + + stdio "git.novox.be/novox/mesh-sdk/go" + "golang.org/x/crypto/nacl/box" +) + +// The parts of a licence the vault holds (§15). +const ( + PartGrant = "grant" + PartAccess = "access" +) + +// Grants is where a licence's two secrets are kept. +type Grants interface { + // Put hands one part of a licence, by its key, to the vault at a generation; an older one is refused. + Put(ctx context.Context, key, part string, generation int64, value string) error + // Get reads one part of a licence from the vault. + Get(ctx context.Context, key, part string) (string, error) +} + +// SecretName is the name of one part of a licence in the vault. +func SecretName(key, part string) string { return "licence." + key + "." + part } + +// VaultGrants is the vault on the bus, asked as the seat mesh-vault. +type VaultGrants struct{ Keys KeyPair } + +// Put asks licence-put's two steps. +func (v VaultGrants) Put(_ context.Context, key, part string, generation int64, value string) error { + raw, err := stdio.Ask("seat:mesh-vault.licence-put", map[string]any{"licence": key, "part": part, "generation": generation}) + if err != nil { + return err + } + var opened struct { + Call string `json:"call"` + SealTo string `json:"seal_to"` + } + if err := json.Unmarshal(raw, &opened); err != nil { + return err + } + pub, err := base64.StdEncoding.DecodeString(opened.SealTo) + if err != nil || len(pub) != 32 || opened.Call == "" { + return fmt.Errorf("licence-put answered no call and key to seal to: %s", raw) + } + var to [32]byte + copy(to[:], pub) + sealed, err := box.SealAnonymous(nil, []byte(value), &to, rand.Reader) + if err != nil { + return err + } + _, err = stdio.Ask("seat:mesh-vault.licence-put", map[string]any{"call": opened.Call, + "sealed": base64.StdEncoding.EncodeToString(sealed)}) + return err +} + +// Get asks get, sealed to the manager's public key, and opens the answer. +func (v VaultGrants) Get(_ context.Context, key, part string) (string, error) { + raw, err := stdio.Ask("seat:mesh-vault.get", map[string]any{"name": SecretName(key, part), "public_key": v.Keys.PublicKey}) + if err != nil { + return "", err + } + var a struct { + Sealed SealedBox `json:"sealed"` + } + if err := json.Unmarshal(raw, &a); err != nil { + return "", err + } + return Open(a.Sealed, v.Keys.PrivateKey) +} diff --git a/modules/claude-licence-manager/cmd/claude-licence-manager/vaultgrants_test.go b/modules/claude-licence-manager/cmd/claude-licence-manager/vaultgrants_test.go new file mode 100644 index 000000000..2205ba5a2 --- /dev/null +++ b/modules/claude-licence-manager/cmd/claude-licence-manager/vaultgrants_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +// design 39 bus-and-switch §15, §16, §20: a rotation reads the grant from the vault and hands it back, the grant first +// and then the access, at the next generation; the licence keeps no token once the call returns. +func TestARotationPutsTwiceAndKeepsNoToken(t *testing.T) { + mm := newMesh(t) + ctx := context.Background() + if _, err := mm.m.Consider(ctx, []Holdings{mm.login("laptop", "rt-a", true, t0)}); err != nil { + t.Fatal(err) + } + key := KeyFor(licence1) + mm.grants.puts = nil + if r, err := mm.m.Rotate(ctx, licence1, true); err != nil || r["refreshed"] != true { + t.Fatalf("%v %v", r, err) + } + want := []string{SecretName(key, PartGrant) + "@2", SecretName(key, PartAccess) + "@2"} + if strings.Join(mm.grants.puts, " ") != strings.Join(want, " ") { + t.Fatalf("the rotation put %v, want %v", mm.grants.puts, want) + } + if g, _ := mm.grants.Get(ctx, key, PartGrant); !strings.Contains(g, `"refreshToken":"rt-2"`) { + t.Fatalf("the vault holds the grant %s", g) + } + if a, _ := mm.grants.Get(ctx, key, PartAccess); !strings.Contains(a, `"accessToken":"at-2"`) || strings.Contains(a, "refreshToken\":\"rt") { + t.Fatalf("the vault holds the access %s", a) + } + l, _ := mm.store.Licence(ctx, licence1) + raw, _ := json.Marshal(l) + for _, token := range []string{"rt-", "at-"} { + if strings.Contains(string(raw), token) { + t.Fatalf("the licence keeps %s: %s", token, raw) + } + } + if l.Generation != 2 { + t.Fatalf("the licence's generation is %d, want 2", l.Generation) + } +} diff --git a/modules/claude-licence-manager/go.mod b/modules/claude-licence-manager/go.mod index 8d928ca73..4b74e0437 100644 --- a/modules/claude-licence-manager/go.mod +++ b/modules/claude-licence-manager/go.mod @@ -5,6 +5,7 @@ go 1.26.0 require ( git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 github.com/jackc/pgx/v5 v5.11.0 + golang.org/x/crypto v0.57.0 ) require ( @@ -15,7 +16,6 @@ require ( github.com/nats-io/nats.go v1.54.0 // indirect github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nuid v1.0.1 // indirect - golang.org/x/crypto v0.57.0 // indirect golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.48.0 // indirect golang.org/x/text v0.42.0 // indirect