M-7: every grant and access token lives in the vault

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
This commit is contained in:
2026-11-11 23:13:31 +01:00
parent f29a9ad563
commit 5ca3876bea
10 changed files with 255 additions and 49 deletions
@@ -5,7 +5,6 @@ package main
// key derived from the vault's secret by SHA-256 so a secret of any length serves. // key derived from the vault's secret by SHA-256 so a secret of any length serves.
import ( import (
"crypto/rand"
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
"errors" "errors"
@@ -13,7 +12,7 @@ import (
"strings" "strings"
) )
// Crypt seals and opens what the store keeps. // Crypt opens what today's database keeps at rest.
type Crypt struct{ key []byte } type Crypt struct{ key []byte }
// NewCrypt is the store's cipher from the vault's secret. // 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)) return NewCrypt(string(raw))
} }
// Seal encrypts a plaintext as `v1.<iv>.<ciphertext and tag>`. // Open decrypts a grant today's database keeps at rest, `v1.<iv>.<ciphertext and tag>`: the carry-over's alone, since
func (c *Crypt) Seal(plaintext string) string { // nothing seals a grant at rest any more (design 39 bus-and-switch §20, B15).
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.
func (c *Crypt) Open(sealed string) (string, error) { func (c *Crypt) Open(sealed string) (string, error) {
parts := strings.Split(sealed, ".") parts := strings.Split(sealed, ".")
if len(parts) != 3 || parts[0] != "v1" { if len(parts) != 3 || parts[0] != "v1" {
@@ -76,6 +76,7 @@ func manager() (*Manager, error) {
Store: store, Store: store,
Vendor: LiveVendor(), Vendor: LiveVendor(),
Crypt: crypt, Crypt: crypt,
Grants: VaultGrants{Keys: keys},
Keys: keys, Keys: keys,
AskGrant: func(_ context.Context, node, publicKey string) (GrantAnswer, error) { AskGrant: func(_ context.Context, node, publicKey string) (GrantAnswer, error) {
var a GrantAnswer var a GrantAnswer
@@ -160,7 +160,10 @@ type GrantAnswer struct {
type Manager struct { type Manager struct {
Store Store Store Store
Vendor Vendor Vendor Vendor
// Crypt opens the grants today's database keeps at rest, for the carry-over alone (design 39 bus-and-switch §12, A3).
Crypt *Crypt Crypt *Crypt
// Grants is the vault, which holds every licence's grant and access token (§15, §16).
Grants Grants
Keys KeyPair Keys KeyPair
// AskGrant asks a node's agent module for the grant a login left there, sealed to publicKey. An error // 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. // 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, l := Licence{Name: NameFor(c.Identity), Kind: "subscription", AccountUUID: c.Identity.AccountUUID,
Email: c.Identity.EmailAddress, OrganizationUUID: c.Identity.OrganizationUUID} Email: c.Identity.EmailAddress, OrganizationUUID: c.Identity.OrganizationUUID}
if held != nil { 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 == "" { if l.Email == "" {
l.Email = held.Email l.Email = held.Email
} }
@@ -403,7 +406,9 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
l.OrganizationUUID = held.OrganizationUUID 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) l.AdoptedAt = max(now, c.ChangedAt)
if err := m.Store.SaveLicence(ctx, l); err != nil { if err := m.Store.SaveLicence(ctx, l); err != nil {
return "", err return "", err
@@ -454,10 +459,16 @@ func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings)
return l.Name, nil return l.Name, nil
} }
// keep stores a grant on its licence: encrypted, fingerprinted, its expiries, fresh. // keep hands a grant to the vault at the licence's next generation, the grant first and then its access (design 39
func (m *Manager) keep(l *Licence, g FullGrant) { // bus-and-switch §15), and keeps on the licence its fingerprint and expiries alone: no token once it returns.
raw, _ := json.Marshal(g) func (m *Manager) keep(ctx context.Context, l *Licence, g FullGrant) error {
l.Sealed = m.Crypt.Seal(string(raw)) 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.RefreshFingerprint = Fingerprint(g.RefreshToken)
l.AccessExpiresAt = g.ExpiresAt l.AccessExpiresAt = g.ExpiresAt
if g.RefreshTokenExpiresAt != nil { if g.RefreshTokenExpiresAt != nil {
@@ -465,6 +476,29 @@ func (m *Manager) keep(l *Licence, g FullGrant) {
} }
l.Failures = 0 l.Failures = 0
l.RotatedAt = m.Now().UnixMilli() 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 // 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. // 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 { 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 return false
} }
ms := now.UnixMilli() ms := now.UnixMilli()
@@ -518,22 +552,19 @@ func (m *Manager) Rotate(ctx context.Context, name string, force bool) (map[stri
if l == nil { if l == nil {
return map[string]any{"licence": name, "refreshed": false, "reason": "no such licence"}, 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 return map[string]any{"licence": name, "refreshed": false, "reason": "an API key does not refresh"}, nil
} }
now := m.Now() now := m.Now()
if !force && !Due(*l, now, m.Settings) { if !force && !Due(*l, now, m.Settings) {
return map[string]any{"licence": name, "refreshed": false, "reason": "not due"}, nil 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 { if err != nil {
return nil, err return nil, err
} }
var g FullGrant
if err := json.Unmarshal([]byte(plain), &g); err != nil {
return nil, err
}
r := m.Vendor.Refresh(ctx, g) r := m.Vendor.Refresh(ctx, g)
g = FullGrant{}
if !r.OK { if !r.OK {
l.Failures++ l.Failures++
m.Log("%s did not refresh (%d in a row): %d %s", name, l.Failures, r.Status, r.Reason) 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 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 { if l.RefreshExpiresAt != 0 {
left := time.Duration(l.RefreshExpiresAt-now.UnixMilli()) * time.Millisecond 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), 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 return err
} }
for _, l := range all { for _, l := range all {
if l.Kind != "subscription" || l.Sealed == "" { if l.Kind != "subscription" || l.Generation == 0 {
continue continue
} }
plain, err := m.Crypt.Open(l.Sealed) g, err := m.grantOf(ctx, l)
if err != nil { if err != nil {
return err return err
} }
var g FullGrant
_ = json.Unmarshal([]byte(plain), &g)
raw, err := m.Vendor.Usage(ctx, g.AccessToken) raw, err := m.Vendor.Usage(ctx, g.AccessToken)
g = FullGrant{}
if err != nil || raw == nil { if err != nil || raw == nil {
continue 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}) _ = m.Emit("licence.refused", map[string]any{"licence": name, "from": from, "reason": why})
return nil, fmt.Errorf("%s is refused: %s", name, 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 { if err := m.Store.SaveLicence(ctx, l); err != nil {
return nil, err return nil, err
} }
@@ -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 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 { type miniMesh struct {
grants *memGrants
m *Manager m *Manager
store *MemoryStore store *MemoryStore
vendor *stubVendor vendor *stubVendor
@@ -61,14 +96,15 @@ type miniMesh struct {
func newMesh(t *testing.T) *miniMesh { func newMesh(t *testing.T) *miniMesh {
t.Helper() 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 } now := func() time.Time { return mm.now }
mm.store = NewMemoryStore(now) mm.store = NewMemoryStore(now)
mm.vendor = &stubVendor{live: map[string]bool{}, now: now} mm.vendor = &stubVendor{live: map[string]bool{}, now: now}
crypt, _ := NewCrypt("a key the vault made") crypt, _ := NewCrypt("a key the vault made")
mm.keys, _ = GenerateKeyPair() mm.keys, _ = GenerateKeyPair()
mm.m = &Manager{ 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) { AskGrant: func(_ context.Context, node, publicKey string) (GrantAnswer, error) {
if mm.askDown { if mm.askDown {
return GrantAnswer{}, fmt.Errorf("503 no responders") return GrantAnswer{}, fmt.Errorf("503 no responders")
@@ -304,8 +340,8 @@ func TestNothingTheManagerPublishesKeepsOrListsCarriesAToken(t *testing.T) {
} }
} }
row, _ := mm.store.Licence(ctx, licence1) row, _ := mm.store.Licence(ctx, licence1)
if strings.Contains(row.Sealed, "rt-") { if raw, _ := json.Marshal(row); strings.Contains(string(raw), "rt-") || strings.Contains(string(raw), "at-") {
t.Fatal("the grant is stored in the clear") t.Fatalf("the licence keeps a token: %s", raw)
} }
} }
@@ -420,9 +456,10 @@ func TestAnAPIKeySealedByANodeIsAdopted(t *testing.T) {
if err != nil || r["adopted"] != true { if err != nil || r["adopted"] != true {
t.Fatalf("%v %v", r, err) t.Fatalf("%v %v", r, err)
} }
l, _ := mm.store.Licence(ctx, "api") for _, part := range []string{PartGrant, PartAccess} {
if plain, _ := mm.m.Crypt.Open(l.Sealed); plain != "sk-ant-api03-test-0123456789abcdefghij" { if plain, _ := mm.grants.Get(ctx, "api", part); plain != "sk-ant-api03-test-0123456789abcdefghij" {
t.Fatalf("stored %q", plain) t.Fatalf("the vault holds %q as the %s", plain, part)
}
} }
other, _ := GenerateKeyPair() other, _ := GenerateKeyPair()
wrong, _ := Seal("sk", other.PublicKey) wrong, _ := Seal("sk", other.PublicKey)
@@ -102,7 +102,8 @@ const licenceColumns = `name, kind, coalesce(account_uuid,''), coalesce(email,''
func scanLicence(row pgx.Row) (*Licence, error) { func scanLicence(row pgx.Row) (*Licence, error) {
var l Licence 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) &l.AccessExpiresAt, &l.RefreshExpiresAt, &l.Failures, &l.NotifiedAt, &l.AdoptedAt, &l.RotatedAt)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return nil, nil return nil, nil
@@ -150,15 +151,15 @@ func nullableInt(v int64) any {
} }
func (s *PgStore) SaveLicence(ctx context.Context, l Licence) error { 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) 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, 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, 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, failures = excluded.failures, notified_at = excluded.notified_at, adopted_at = excluded.adopted_at,
rotated_at = excluded.rotated_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, nullable(l.RefreshFingerprint), nullableInt(l.AccessExpiresAt), nullableInt(l.RefreshExpiresAt), l.Failures,
nullableInt(l.NotifiedAt), l.AdoptedAt, nullableInt(l.RotatedAt)) nullableInt(l.NotifiedAt), l.AdoptedAt, nullableInt(l.RotatedAt))
return err return err
@@ -19,7 +19,6 @@ type Licence struct {
AccountUUID string `json:"accountUuid,omitempty"` AccountUUID string `json:"accountUuid,omitempty"`
Email string `json:"email,omitempty"` Email string `json:"email,omitempty"`
OrganizationUUID string `json:"organizationUuid,omitempty"` OrganizationUUID string `json:"organizationUuid,omitempty"`
Sealed string `json:"-"` // the grant or the key, encrypted at rest
RefreshFingerprint string `json:"-"` RefreshFingerprint string `json:"-"`
AccessExpiresAt int64 `json:"accessExpiresAt,omitempty"` AccessExpiresAt int64 `json:"accessExpiresAt,omitempty"`
RefreshExpiresAt int64 `json:"refreshExpiresAt,omitempty"` RefreshExpiresAt int64 `json:"refreshExpiresAt,omitempty"`
@@ -27,6 +26,8 @@ type Licence struct {
NotifiedAt int64 `json:"-"` NotifiedAt int64 `json:"-"`
AdoptedAt int64 `json:"adoptedAt"` AdoptedAt int64 `json:"adoptedAt"`
RotatedAt int64 `json:"rotatedAt,omitempty"` 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). // Binding is one consumer's binding and the generation it was last given (ADR 0206).
@@ -1,6 +1,8 @@
package main package main
import ( import (
"crypto/rand"
"encoding/base64"
"context" "context"
"os" "os"
"strings" "strings"
@@ -33,7 +35,7 @@ func TestTheStoreOnPostgres(t *testing.T) {
t.Fatalf("migration %d: %v", i+1, err) 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} RefreshFingerprint: "sha256:1", AccessExpiresAt: 1, RefreshExpiresAt: 2, AdoptedAt: 3}
if err := s.SaveLicence(ctx, l); err != nil { if err := s.SaveLicence(ctx, l); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -85,7 +87,7 @@ func TestTheStoreOnPostgres(t *testing.T) {
t.Fatal("unbind") t.Fatal("unbind")
} }
all, _ := s.Licences(ctx) all, _ := s.Licences(ctx)
if len(all) != 1 || !strings.HasPrefix(all[0].Sealed, "v1.") { if len(all) != 1 {
t.Fatalf("%v", all) t.Fatalf("%v", all)
} }
} }
@@ -109,7 +111,7 @@ func TestARefreshThatDoesNotRotateKeepsTheRefreshToken(t *testing.T) {
func TestAGrantAtRestOpensOnlyWithItsKey(t *testing.T) { func TestAGrantAtRestOpensOnlyWithItsKey(t *testing.T) {
a, _ := NewCrypt("one key") a, _ := NewCrypt("one key")
b, _ := NewCrypt("another 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. // The plaintext's own words, not two letters a random ciphertext holds one time in about seventy.
if strings.Contains(sealed, "refreshToken") { if strings.Contains(sealed, "refreshToken") {
t.Fatal("stored in the clear") t.Fatal("stored in the clear")
@@ -124,3 +126,16 @@ func TestAGrantAtRestOpensOnlyWithItsKey(t *testing.T) {
t.Fatal("an empty key was accepted") t.Fatal("an empty key was accepted")
} }
} }
// sealAtRest seals a grant as today's database keeps it, `v1.<iv>.<ciphertext and tag>`: 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))
}
@@ -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)
}
@@ -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)
}
}
+1 -1
View File
@@ -5,6 +5,7 @@ go 1.26.0
require ( require (
git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
github.com/jackc/pgx/v5 v5.11.0 github.com/jackc/pgx/v5 v5.11.0
golang.org/x/crypto v0.57.0
) )
require ( require (
@@ -15,7 +16,6 @@ require (
github.com/nats-io/nats.go v1.54.0 // indirect github.com/nats-io/nats.go v1.54.0 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // 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/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect golang.org/x/text v0.42.0 // indirect