From 33fd28ffa6f56de08339d637126b18d54429df58 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 7 Sep 2026 01:55:08 +0200 Subject: [PATCH] licences: deliver the refresh token by the ordinary sealed path, not a bespoke envelope The refreshable-grant refresh token no longer rides a custom at-rest envelope that a module opens with a node private key. A module is never given a node's private sealing key, so that path could not exist -- the gap Phase C hit. Instead the refresh token is a credential sealed to the MANAGER holder with the same anonymous box (secrets.Seal / crypto_box_seal) every credential uses, stored as one sealed blob, and delivered by the existing host-unseal-and-mount: the host opens it with the node's real key and mounts the cleartext at the manager module's bound path, exactly as a consumer's db password is delivered. - refresh_grant now stores { sealed, manager_key }, dropping the AtRest token/wrapped_key columns; internal/secrets/atrest.go is retired (nothing else used it). - the licence records its manager as (node, module); KeyFor delivers the refresh token to the manager holder and the access token to consumers, disambiguated by module so the two can co-locate. Accept and the reseal skip the manager holder. - the manager holder is delivered the node's PUBLIC sealing key in its bound facts, so the module can re-seal a rotated refresh token with no private key of its own; the declaration tolerates its empty pre-adoption secret rather than refusing. - SubmitRefresh / set-grant take a sealed blob, never a refresh token in the clear. The invariant holds unchanged: the control plane never reads the refresh token, and no node but the manager holds it. A committed cross-language test proves the TypeScript module seal opens under Go box.OpenAnonymous (the host's Unseal) -- both are NaCl crypto_box_seal. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- cmd/mesh-control/licence.go | 156 +++++-------- cmd/mesh-control/plan.go | 43 ++++ internal/catalogue/declaration.go | 8 +- internal/catalogue/resolve.go | 6 + internal/licences/adapters/adapters.go | 38 ++-- internal/licences/adapters/adapters_test.go | 10 +- internal/licences/licences.go | 192 ++++++++++------ .../0001-a-licence-is-a-named-thing.sql | 39 ++-- .../0003-a-manager-and-its-refresh-token.sql | 32 ++- internal/licences/refresh_test.go | 208 +++++++++++------- internal/licences/submitrefresh_test.go | 72 +++--- internal/secrets/atrest.go | 160 -------------- internal/secrets/atrest_test.go | 106 --------- internal/secrets/sealedbox_xcheck_test.go | 79 +++++++ .../testdata/module-sealedbox-fixture.json | 7 + 15 files changed, 563 insertions(+), 593 deletions(-) delete mode 100644 internal/secrets/atrest.go delete mode 100644 internal/secrets/atrest_test.go create mode 100644 internal/secrets/sealedbox_xcheck_test.go create mode 100644 internal/secrets/testdata/module-sealedbox-fixture.json diff --git a/cmd/mesh-control/licence.go b/cmd/mesh-control/licence.go index 23f417c..25d12ef 100644 --- a/cmd/mesh-control/licence.go +++ b/cmd/mesh-control/licence.go @@ -10,8 +10,6 @@ import ( "io" "os" "strings" - - "github.com/novox/mesh-control/internal/secrets" ) // licenceCommand is everything about model access the mesh holds. @@ -22,7 +20,7 @@ import ( func licenceCommand(ctx context.Context, args []string) error { if len(args) == 0 { return errors.New( - "licence add|list|use|release|key|manager|grant|set-grant|refresh|submit-refresh|forget") + "licence add|list|use|release|key|manager|set-grant|refresh|submit-refresh|forget") } switch args[0] { case "add": @@ -37,8 +35,6 @@ func licenceCommand(ctx context.Context, args []string) error { return licenceKey(ctx, args[1:]) case "manager": return licenceManager(ctx, args[1:]) - case "grant": - return licenceGrant(ctx, args[1:]) case "set-grant": return licenceSetGrant(ctx, args[1:]) case "refresh": @@ -49,7 +45,7 @@ func licenceCommand(ctx context.Context, args []string) error { return licenceForget(ctx, args[1:]) } return fmt.Errorf( - "licence %q; it is add, list, use, release, key, manager, grant, set-grant, refresh, "+ + "licence %q; it is add, list, use, release, key, manager, set-grant, refresh, "+ "submit-refresh or forget", args[0]) } @@ -248,50 +244,42 @@ func licenceKey(ctx context.Context, args []string) error { // nothing — the absent manager is part of what keeps a static key from ever holding a value readably // at rest. func licenceManager(ctx context.Context, args []string) error { - if len(args) != 2 { - return errors.New("licence manager ") + if len(args) != 3 { + return errors.New("licence manager ") } - name, node := args[0], args[1] + name, node, module := args[0], args[1], args[2] held, err := openLicences(ctx) if err != nil { return err } defer held.Close() - if err := held.SetManager(ctx, name, node); err != nil { + if err := held.SetManager(ctx, name, node, module); err != nil { return err } - fmt.Printf("%s holds and refreshes %s.\n"+ - " Its refresh token is kept encrypted at rest, readable by %s alone — no other node, and "+ - "not this database on its own.\n", node, name, node) + fmt.Printf("%s on %s holds and refreshes %s.\n"+ + " Its refresh token is sealed to %s's key — readable by that node alone, not by any other "+ + "node and not by this database. Put %s on the licence too so it is delivered the token:\n"+ + " licence use %s %s %s\n", module, node, name, node, module, name, node, module) return nil } -// envelopeJSON is the wire shape of a refresh-token at-rest envelope on this command surface: the -// three opaque parts of secrets.AtRest and nothing else. +// sealedGrantJSON is the wire shape of a sealed refresh token on this command surface: an anonymous +// sealed box and the public key it was sealed to, and nothing else. // -// **Every field of it is ciphertext or a public key.** `token` is the refresh token under a data -// key, `wrapped_key` is that data key sealed to the manager node, `manager_key` is the manager's -// public sealing key. None of them is the refresh token in the clear — which is why this surface may -// print one out (`grant`) and read one in (`set-grant`, `submit-refresh`) without the control plane -// ever holding a refresh token it could read. The manager runtime, on the manager node, is the only -// place these open (novox/hq ADR 0050, Phase C). -type envelopeJSON struct { - Token string `json:"token"` - WrappedKey string `json:"wrapped_key"` +// **Every field of it is ciphertext or a public key.** `sealed` is the refresh token as a +// `crypto_box_seal` to the manager node's public key; `manager_key` is that public key. Neither is +// the refresh token in the clear — which is why this surface may read one in (`set-grant`, +// `submit-refresh`) without the control plane ever holding a refresh token it could read. The manager +// module, on the manager node, seals it; the HOST, on that node, unseals it to deliver cleartext. This +// database, and this surface, only ever forward the box (novox/hq ADR 0050). +type sealedGrantJSON struct { + Sealed string `json:"sealed"` ManagerKey string `json:"manager_key"` } -func (e envelopeJSON) atRest() secrets.AtRest { - return secrets.AtRest{Token: e.Token, WrappedKey: e.WrappedKey, ManagerKey: e.ManagerKey} -} - -func envelopeOf(a secrets.AtRest) envelopeJSON { - return envelopeJSON{Token: a.Token, WrappedKey: a.WrappedKey, ManagerKey: a.ManagerKey} -} - -// readEnvelope reads an at-rest envelope from a file or standard input as JSON. -func readEnvelope(from string) (envelopeJSON, error) { +// readSealedGrant reads a sealed refresh token from a file or standard input as JSON. +func readSealedGrant(from string) (sealedGrantJSON, error) { var raw []byte var err error if from != "" { @@ -300,17 +288,17 @@ func readEnvelope(from string) (envelopeJSON, error) { raw, err = readAllStdin() } if err != nil { - return envelopeJSON{}, err + return sealedGrantJSON{}, err } - var env envelopeJSON - if err := json.Unmarshal(raw, &env); err != nil { - return envelopeJSON{}, fmt.Errorf("the refresh-token envelope is not JSON: %w", err) + var g sealedGrantJSON + if err := json.Unmarshal(raw, &g); err != nil { + return sealedGrantJSON{}, fmt.Errorf("the sealed refresh token is not JSON: %w", err) } - if env.Token == "" || env.WrappedKey == "" || env.ManagerKey == "" { - return envelopeJSON{}, errors.New( - "an at-rest envelope is {token, wrapped_key, manager_key}, and one part is missing") + if g.Sealed == "" || g.ManagerKey == "" { + return sealedGrantJSON{}, errors.New( + "a sealed refresh token is {sealed, manager_key}, and one part is missing") } - return env, nil + return g, nil } func readAllStdin() ([]byte, error) { @@ -318,55 +306,17 @@ func readAllStdin() ([]byte, error) { return io.ReadAll(reader) } -// licenceGrant prints a licence's refresh-token envelope, so the manager runtime can fetch the -// opaque thing it will open on the manager node (novox/hq ADR 0050, Phase C). +// licenceSetGrant stores a sealed refresh token the manager module produced — adoption, and the +// re-seal after a rotation done outside this process (novox/hq ADR 0050). // -// **What is printed is ciphertext.** The envelope is the refresh token sealed at rest to the manager -// node's key; it opens nowhere but that node. Printing it here is how the manager runtime — which -// does not read this database directly — is handed the envelope to open, and it discloses nothing a -// copy of the store did not already hold. -func licenceGrant(ctx context.Context, args []string) error { - if len(args) != 1 { - return errors.New("licence grant ") - } - name := args[0] - - held, err := openLicences(ctx) - if err != nil { - return err - } - defer held.Close() - - at, ok, err := held.RefreshGrant(ctx, name) - if err != nil { - return err - } - if !ok { - // Said, not printed as an empty object: a licence with no grant and a failed read must not - // look the same to whatever parses this. - return fmt.Errorf( - "%q has no refresh token stored; its manager adopts one first with `licence set-grant %s`", - name, name) - } - out, err := json.Marshal(envelopeOf(at)) - if err != nil { - return err - } - fmt.Println(string(out)) - return nil -} - -// licenceSetGrant stores a refresh-token envelope the manager runtime produced — adoption, and the -// re-seal after a rotation done outside this process (novox/hq ADR 0050, Phase C). -// -// **It takes an envelope, never a refresh token.** The manager node reads the operator's refresh -// token, seals it at rest to its own key, and hands the sealed envelope here. So the one moment a -// refresh token is in the clear is on the manager node, never in the control plane — the same bound -// the whole carve-out keeps. This surface refuses anything that is not a complete envelope rather -// than storing half of one. +// **It takes a sealed box, never a refresh token.** The manager module, on the manager node, reads +// the operator's refresh token, seals it to that node's own public key, and hands the box here. So the +// one moment a refresh token is in the clear is on the manager node, never in the control plane — the +// same bound the whole carve-out keeps. This surface refuses anything that is not a complete sealed +// grant rather than storing half of one. func licenceSetGrant(ctx context.Context, args []string) error { set := flag.NewFlagSet("licence set-grant", flag.ContinueOnError) - from := set.String("file", "", "read the envelope from a file instead of standard input") + from := set.String("file", "", "read the sealed refresh token from a file instead of standard input") positionals, err := parseAround(set, args) if err != nil { return err @@ -376,7 +326,7 @@ func licenceSetGrant(ctx context.Context, args []string) error { } name := positionals[0] - env, err := readEnvelope(*from) + grant, err := readSealedGrant(*from) if err != nil { return err } @@ -386,11 +336,11 @@ func licenceSetGrant(ctx context.Context, args []string) error { return err } defer held.Close() - if err := held.SetRefreshGrant(ctx, name, env.atRest()); err != nil { + if err := held.SetRefreshGrant(ctx, name, grant.Sealed, grant.ManagerKey); err != nil { return err } - fmt.Printf("%s now holds a refresh token for %s, encrypted at rest and readable by that node "+ - "alone.\n the control plane stored the envelope without opening it\n", + fmt.Printf("%s now holds a refresh token for %s, sealed to that node's key and readable by it "+ + "alone.\n the control plane stored the box without opening it; run `push` to deliver it\n", "the manager", name) return nil } @@ -410,7 +360,7 @@ func licenceSubmitRefresh(ctx context.Context, args []string) error { accessFrom := set.String("access-file", "", "read the new access token from a file instead of standard input") grantFrom := set.String("grant-file", "", - "the rotated refresh-token envelope, if the vendor rotated it; omit if it did not") + "the rotated sealed refresh token, if the vendor rotated it; omit if it did not") positionals, err := parseAround(set, args) if err != nil { return err @@ -439,15 +389,14 @@ func licenceSubmitRefresh(ctx context.Context, args []string) error { return errors.New("no access token was given, so there is nothing to seal") } - // The rotated envelope is optional: absent, the stored refresh token is left exactly as it was. - var rotated *secrets.AtRest + // The rotated sealed token is optional: absent, the stored refresh token is left exactly as it was. + var newSealed, newManagerKey string if *grantFrom != "" { - env, err := readEnvelope(*grantFrom) + grant, err := readSealedGrant(*grantFrom) if err != nil { return err } - at := env.atRest() - rotated = &at + newSealed, newManagerKey = grant.Sealed, grant.ManagerKey } open, err := openStores(ctx) @@ -461,15 +410,16 @@ func licenceSubmitRefresh(ctx context.Context, args []string) error { } inv := open.inventory - sealed, err := held.SubmitRefresh(ctx, name, accessToken, rotated, func(node string) (string, error) { - return inv.SealingKeyOf(ctx, node) - }) + sealed, err := held.SubmitRefresh(ctx, name, accessToken, newSealed, newManagerKey, + func(node string) (string, error) { + return inv.SealingKeyOf(ctx, node) + }) if err != nil { return err } rotatedNote := "the refresh token was left with its manager unchanged" - if rotated != nil { - rotatedNote = "the rotated refresh token replaced the stored envelope, still readable by the " + + if newSealed != "" { + rotatedNote = "the rotated refresh token replaced the stored box, still readable by the " + "manager node alone" } fmt.Printf("submitted a refresh for %s: a new access token sealed to %d holder(s), and %s.\n"+ diff --git a/cmd/mesh-control/plan.go b/cmd/mesh-control/plan.go index dcf9d1d..e41a1d6 100644 --- a/cmd/mesh-control/plan.go +++ b/cmd/mesh-control/plan.go @@ -89,6 +89,25 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso return catalogue.Resolution{}, nil, err } resolved.Needs[i].Sealed = sealed + // If this holder is the licence's manager, hand it the manager node's PUBLIC sealing key + // in its bound facts (novox/hq ADR 0050). It is safe to disclose — a public key — and it + // is what the manager module needs to re-seal a rotated refresh token to this same node, + // having been given no private key of its own. A consumer holder gets none. + pub, err := managerPublicKeyFor(ctx, open, inv, n.From, nodeName, n.For) + if err != nil { + return catalogue.Resolution{}, nil, err + } + if pub != "" { + serves := map[string]any{} + for k, v := range resolved.Needs[i].Serves { + serves[k] = v + } + serves["manager_public_key"] = pub + resolved.Needs[i].Serves = serves + // The manager holder: its empty pre-adoption refresh token is a waiting state, not a + // missing consumer key, so the declaration tolerates it rather than refusing. + resolved.Needs[i].Manager = true + } continue } secret, err := inv.SecretFor(ctx, n.Name, nodeName, n.For, n.From) @@ -685,6 +704,30 @@ func keyFor(ctx context.Context, open *stores, licence, node, module string) (st return held.KeyFor(ctx, licence, node, module) } +// managerPublicKeyFor is the manager node's public sealing key, but only when (node, module) is the +// licence's manager holder — empty otherwise. +// +// It is delivered to the manager module in its bound facts so it can re-seal a rotated refresh token +// to this node (novox/hq ADR 0050). Public, so it travels in the clear like any other bound fact; and +// scoped to the manager holder alone, so a consumer never receives it and nothing invites a consumer +// to seal anything. +func managerPublicKeyFor( + ctx context.Context, open *stores, inv *inventory.Inventory, licence, node, module string, +) (string, error) { + held, err := open.Licences(ctx) + if err != nil { + return "", err + } + managerNode, managerModule, err := held.ManagerOf(ctx, licence) + if err != nil { + return "", err + } + if managerNode == "" || node != managerNode || module != managerModule { + return "", nil + } + return inv.SealingKeyOf(ctx, node) +} + // portsOn is one module's assignments on one machine, by the port the software uses. func portsOn( ctx context.Context, inv *inventory.Inventory, node, module string, diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index c02669e..a6741bc 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -237,12 +237,18 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) { found = &r.Needs[i] } } - if found != nil && found.ByRecord && found.Sealed == "" { + if found != nil && found.ByRecord && found.Sealed == "" && !found.Manager { // Answered by a record whose key has not been supplied since this consumer was // put on it. **Refused, not skipped.** The mesh discarded the plaintext when the // key was accepted and cannot seal another, so a machine that resolved cleanly // would receive no file at all and fail at whatever tried to read it — which is // the outcome ADR 0024 exists to avoid, arrived at politely. + // + // The manager holder is the one exception (novox/hq ADR 0050): an empty refresh token + // is a licence whose manager has not adopted one yet, a real waiting state rather than + // a lost key. It falls through to the skip below — its bound facts (carrying the + // manager's public key) are still delivered, which is what adoption needs to seal the + // first refresh token. return nil, fmt.Errorf( "%s on this machine uses the licence %q and no key has been sealed to it. "+ "The mesh cannot make one; supply it again with `licence key %s`", diff --git a/internal/catalogue/resolve.go b/internal/catalogue/resolve.go index 1ec6e01..d046a9a 100644 --- a/internal/catalogue/resolve.go +++ b/internal/catalogue/resolve.go @@ -134,6 +134,12 @@ type Needed struct { Sealed string // For is the module that wanted it. For string + // Manager is set when this holder is a refreshable-grant licence's MANAGER, delivered the refresh + // token rather than an access token (novox/hq ADR 0050). It changes one thing downstream: an empty + // Sealed is tolerated — the manager has not adopted a refresh token yet, which is a real waiting + // state, not a consumer missing its key. Set by the plan, which is the only layer that knows a + // licence's manager; empty for every consumer. + Manager bool } // Refusal is why a set of assignments cannot become a declaration. diff --git a/internal/licences/adapters/adapters.go b/internal/licences/adapters/adapters.go index 294d891..a60dc7d 100644 --- a/internal/licences/adapters/adapters.go +++ b/internal/licences/adapters/adapters.go @@ -60,28 +60,32 @@ type Adapter interface { // RefreshInput is what producing a new access token needs, and all a refresh is given. // -// It carries the refresh token **only as its at-rest envelope** — the caller (the control plane) -// never holds the refresh token in the clear, because it cannot open the envelope. Opening it, and -// the vendor call that follows, happen where the manager node's private key is (ADR 0050, Phase C). +// It carries the refresh token **only as its sealed blob** — the caller (the control plane) never +// holds the refresh token in the clear, because it cannot open the box. Opening it, and the vendor +// call that follows, happen where the manager node's private key is (ADR 0050, Phase C). type RefreshInput struct { Licence string - // Manager is the node that holds the refresh token readably — the one place the envelope opens. + // Manager is the node that holds the refresh token readably — the one place the box opens. Manager string - // AtRest is the refresh token encrypted at rest under the manager's key. Opaque to the control - // plane; meaningful only to the manager node that produced it. - AtRest secrets.AtRest + // Sealed is the refresh token as an anonymous sealed box to the manager's key. Opaque to the + // control plane; openable only by the manager node's private half. + Sealed string + // ManagerKey is the manager's public sealing key the token was sealed to. + ManagerKey string } // RefreshResult is what a refresh produced: a new access token to seal per holder, and — only if the -// vendor rotated it — the refresh token re-encrypted at rest, ready to replace the stored envelope. +// vendor rotated it — the refresh token re-sealed to the manager, ready to replace the stored blob. type RefreshResult struct { // AccessToken is the new access token, in the clear. The mesh seals it per holder and discards // it, exactly as it does an accepted key. It is never the refresh token. AccessToken string - // NewAtRest is the refresh token re-sealed at rest, present only when the vendor rotated the - // refresh token too. Nil leaves the stored envelope untouched. Already encrypted, so the control - // plane stores it without ever seeing the refresh token in the clear. - NewAtRest *secrets.AtRest + // NewSealed is the refresh token re-sealed to the manager node, present only when the vendor + // rotated the refresh token too. Empty leaves the stored blob untouched. Already sealed, so the + // control plane stores it without ever seeing the refresh token in the clear. + NewSealed string + // NewManagerKey is the key NewSealed was sealed to, carried with it. + NewManagerKey string } // Refresher is implemented only by a refreshable-grant adapter (ADR 0050): the vendor-neutral half @@ -229,11 +233,11 @@ func (s staticKey) Deliver(sealed string) string { return sealed } // vendor's actual OAuth call is the injected refresher. // // **What makes this the carve-out and not a second static key.** The refresh token never touches -// this adapter and never touches a holder. It lives in the licences context's own at-rest store, -// keyed by licence, encrypted to the manager node (secrets.AtRest). What Accept seals and Deliver -// hands out is the ACCESS token, per holder, exactly as a static key's value is — so "the refresh -// token is stripped on delivery" is structural here: there is nothing in a holder's row to strip, -// because the refresh token was never put there. +// this adapter. It lives in the licences context's own refresh_grant store, keyed by licence, sealed +// to the manager node (secrets.Seal) and delivered to the manager holder alone. What Accept seals and +// Deliver hands out to a CONSUMER is the ACCESS token, per holder, exactly as a static key's value is +// — so "a consumer is never delivered the refresh token" is structural here: a consumer's row never +// holds it, because the refresh token is a different holder's credential entirely. type refreshableGrant struct { vendor string refresher VendorRefresher diff --git a/internal/licences/adapters/adapters_test.go b/internal/licences/adapters/adapters_test.go index 25b87d8..e8e46a1 100644 --- a/internal/licences/adapters/adapters_test.go +++ b/internal/licences/adapters/adapters_test.go @@ -4,8 +4,6 @@ import ( "context" "strings" "testing" - - "github.com/novox/mesh-control/internal/secrets" ) func TestTheTwoShapesAreSelectedByVendor(t *testing.T) { @@ -63,8 +61,8 @@ func (f *fakeVendor) Refresh(_ context.Context, in RefreshInput) (RefreshResult, return f.result, nil } -// A plugged-in refresher is dispatched to, and is handed the at-rest envelope (never a plaintext -// refresh token) plus which node is the manager. +// A plugged-in refresher is dispatched to, and is handed the sealed refresh token (never a plaintext +// one) plus which node is the manager. func TestAPluggedInRefresherIsDispatchedTo(t *testing.T) { fake := &fakeVendor{result: RefreshResult{AccessToken: "at-new"}} RegisterRefresher("anthropic", fake) @@ -74,7 +72,7 @@ func TestAPluggedInRefresherIsDispatchedTo(t *testing.T) { r := grant.(Refresher) in := RefreshInput{ Licence: "personal", Manager: "workstation", - AtRest: secrets.AtRest{Token: "tok", WrappedKey: "wk", ManagerKey: "mk"}, + Sealed: "sealed-box", ManagerKey: "mk", } out, err := r.Refresh(context.Background(), in) if err != nil { @@ -83,7 +81,7 @@ func TestAPluggedInRefresherIsDispatchedTo(t *testing.T) { if out.AccessToken != "at-new" { t.Fatalf("the dispatched result did not come back: %q", out.AccessToken) } - if fake.got.Manager != "workstation" || fake.got.AtRest.Token != "tok" { + if fake.got.Manager != "workstation" || fake.got.Sealed != "sealed-box" { t.Fatalf("the refresher was handed the wrong input: %+v", fake.got) } } diff --git a/internal/licences/licences.go b/internal/licences/licences.go index 4664798..face562 100644 --- a/internal/licences/licences.go +++ b/internal/licences/licences.go @@ -206,8 +206,31 @@ func (l *Licences) Chosen(ctx context.Context, node, module string) (string, err // for today's vendors. An unregistered vendor is not consulted: a static-key blob delivers as it is, // and a licence whose key was accepted at all necessarily had a registered adapter. func (l *Licences) KeyFor(ctx context.Context, licence, node, module string) (string, error) { + // The manager holder is delivered the REFRESH token, not an access token: it is the one holder + // that refreshes rather than consumes (novox/hq ADR 0050). It is sealed to this same node's key + // with the very same anonymous box a consumer's credential is, so it rides the identical + // host-unseal-and-mount path — the host opens it, the manager module reads cleartext, and the + // module is never handed a private key. Nothing to strip on the consumer side and nothing bespoke + // on this one: the refresh token is simply the credential this particular holder receives. + managerNode, managerModule, err := l.ManagerOf(ctx, licence) + if err != nil { + return "", err + } + if managerNode != "" && node == managerNode && module == managerModule { + sealed, _, ok, err := l.RefreshGrant(ctx, licence) + if err != nil { + return "", err + } + if !ok { + // No refresh token adopted yet — empty, exactly as a consumer with no key. The + // declaration refuses that by name, where the module and path are both in view. + return "", nil + } + return sealed, nil + } + var sealed *string - err := l.store.Pool().QueryRow(ctx, + err = l.store.Pool().QueryRow(ctx, `select sealed from licence_holder where licence = $1 and node = $2 and module = $3`, licence, node, module).Scan(&sealed) if errors.Is(err, pgx.ErrNoRows) || sealed == nil { @@ -279,8 +302,18 @@ func (l *Licences) Accept(ctx context.Context, licence, value string, keys Seali "Put a consumer on it first, then supply the key", licence) } + // The manager holder is delivered the refresh token, not an operator-supplied access key — its + // row is fed by adoption and refresh, not by this. Skipped so an accepted value never clobbers it. + managerNode, managerModule, err := l.ManagerOf(ctx, licence) + if err != nil { + return 0, err + } + sealed := 0 for _, h := range holders { + if managerNode != "" && h.Node == managerNode && h.Module == managerModule { + continue + } key, err := keys(h.Node) if err != nil { return sealed, err @@ -305,35 +338,47 @@ func (l *Licences) Accept(ctx context.Context, licence, value string, keys Seali return sealed, nil } -// ManagerOf is the node that holds a licence's refresh token readably, empty if none is named. +// ManagerOf is the node and module that hold a licence's refresh token readably, both empty if none +// is named. // // Empty for every static-key licence, which has nothing to refresh, and for a refreshable-grant one -// before its manager is set (novox/hq ADR 0050). -func (l *Licences) ManagerOf(ctx context.Context, licence string) (string, error) { - var manager *string - err := l.store.Pool().QueryRow(ctx, - `select manager from licence where name = $1`, licence).Scan(&manager) +// before its manager is set (novox/hq ADR 0050). The module is returned alongside the node because a +// node may run the manager module and a consuming module of the same licence at once, and which +// holder is delivered the refresh token turns on the module, not the node alone. +func (l *Licences) ManagerOf(ctx context.Context, licence string) (node, module string, err error) { + var mgr, mod *string + err = l.store.Pool().QueryRow(ctx, + `select manager, manager_module from licence where name = $1`, licence).Scan(&mgr, &mod) if errors.Is(err, pgx.ErrNoRows) { - return "", fmt.Errorf("this mesh has no licence called %q", licence) + return "", "", fmt.Errorf("this mesh has no licence called %q", licence) } if err != nil { - return "", err + return "", "", err } - if manager == nil { - return "", nil + if mgr == nil { + return "", "", nil } - return *manager, nil + if mod == nil { + return *mgr, "", nil + } + return *mgr, *mod, nil } -// SetManager names the one node that holds a licence's refresh token and refreshes it centrally. +// SetManager names the one node, and the module on it, that hold a licence's refresh token and +// refresh it centrally. // // **Only a refreshable-grant licence has one.** A static-key licence has no refresh token, so naming // a manager for it is refused rather than kept — the absent manager is part of what keeps a static // key from ever growing a value something holds readably at rest (novox/hq ADR 0050). The bound -// "the manager node only" starts here, at the one place a manager is written. -func (l *Licences) SetManager(ctx context.Context, licence, node string) error { - if strings.TrimSpace(node) == "" { - return errors.New("a manager needs a node") +// "the manager module only" starts here, at the one place a manager is written. +// +// **The module is named too, and it is the holder that is delivered the refresh token.** The manager +// module must also be put on the licence as a holder (`Use`), so the plan resolves its model-access +// requirement; naming it here is what tells delivery to hand THAT holder the refresh token rather than +// an access token. +func (l *Licences) SetManager(ctx context.Context, licence, node, module string) error { + if strings.TrimSpace(node) == "" || strings.TrimSpace(module) == "" { + return errors.New("a manager needs a node and the module on it that refreshes") } vendor, err := l.vendorOf(ctx, licence) if err != nil { @@ -349,7 +394,7 @@ func (l *Licences) SetManager(ctx context.Context, licence, node string) error { "has a refresh token to hold", licence, adapter.Shape()) } tag, err := l.store.Pool().Exec(ctx, - `update licence set manager = $2 where name = $1`, licence, node) + `update licence set manager = $2, manager_module = $3 where name = $1`, licence, node, module) if err != nil { return err } @@ -359,42 +404,42 @@ func (l *Licences) SetManager(ctx context.Context, licence, node string) error { return nil } -// SetRefreshGrant stores, or replaces, a licence's refresh token as its at-rest envelope. +// SetRefreshGrant stores, or replaces, a licence's refresh token as one sealed blob. // -// **The envelope is opaque here.** It was produced by the manager node — the only place the refresh -// token is ever in the clear (novox/hq ADR 0050, Phase C) — and this context keeps it and forwards -// it to a refresh without opening it. The control plane holds no key that could, which is the whole -// point of where the carve-out draws the line. -func (l *Licences) SetRefreshGrant(ctx context.Context, licence string, at secrets.AtRest) error { - if at.Token == "" || at.WrappedKey == "" || at.ManagerKey == "" { - return errors.New("an incomplete refresh-token envelope is not one to keep") +// **The blob is opaque here, and it is an ordinary sealed box.** It was produced where the refresh +// token was in the clear — the manager node, at adoption or after a rotation — sealed to that node's +// public sealing key with the same `crypto_box_seal` every credential uses (novox/hq ADR 0050). This +// context keeps it and delivers it without opening it: the control plane holds no private key that +// could, which is the whole point of where the carve-out draws the line. +func (l *Licences) SetRefreshGrant(ctx context.Context, licence, sealed, managerKey string) error { + if strings.TrimSpace(sealed) == "" || strings.TrimSpace(managerKey) == "" { + return errors.New("an incomplete refresh-token grant is not one to keep") } _, err := l.store.Pool().Exec(ctx, - `insert into refresh_grant (licence, token, wrapped_key, manager_key) - values ($1, $2, $3, $4) + `insert into refresh_grant (licence, sealed, manager_key) + values ($1, $2, $3) on conflict (licence) do update set - token = excluded.token, wrapped_key = excluded.wrapped_key, - manager_key = excluded.manager_key, updated_at = now()`, - licence, at.Token, at.WrappedKey, at.ManagerKey) + sealed = excluded.sealed, manager_key = excluded.manager_key, updated_at = now()`, + licence, sealed, managerKey) if err != nil && strings.Contains(err.Error(), "refresh_grant_licence_fkey") { return fmt.Errorf("this mesh has no licence called %q", licence) } return err } -// RefreshGrant is a licence's refresh token as its at-rest envelope, and whether one is stored. -func (l *Licences) RefreshGrant(ctx context.Context, licence string) (secrets.AtRest, bool, error) { - var at secrets.AtRest - err := l.store.Pool().QueryRow(ctx, - `select token, wrapped_key, manager_key from refresh_grant where licence = $1`, licence). - Scan(&at.Token, &at.WrappedKey, &at.ManagerKey) +// RefreshGrant is a licence's sealed refresh token, the key it was sealed to, and whether one is +// stored. +func (l *Licences) RefreshGrant(ctx context.Context, licence string) (sealed, managerKey string, ok bool, err error) { + err = l.store.Pool().QueryRow(ctx, + `select sealed, manager_key from refresh_grant where licence = $1`, licence). + Scan(&sealed, &managerKey) if errors.Is(err, pgx.ErrNoRows) { - return secrets.AtRest{}, false, nil + return "", "", false, nil } if err != nil { - return secrets.AtRest{}, false, err + return "", "", false, err } - return at, true, nil + return sealed, managerKey, true, nil } // Refresh mints a new access token for a refreshable-grant licence, seals it to every holder, and @@ -459,10 +504,10 @@ func (l *Licences) Refresh(ctx context.Context, licence string, keys SealingKeys " licence manager %s ", licence, licence) } - var at secrets.AtRest + var sealedRefresh, managerKey string err = tx.QueryRow(ctx, - `select token, wrapped_key, manager_key from refresh_grant where licence = $1`, licence). - Scan(&at.Token, &at.WrappedKey, &at.ManagerKey) + `select sealed, manager_key from refresh_grant where licence = $1`, licence). + Scan(&sealedRefresh, &managerKey) if errors.Is(err, pgx.ErrNoRows) { return 0, fmt.Errorf( "%q has no refresh token stored yet; its manager %s adopts one first "+ @@ -473,7 +518,9 @@ func (l *Licences) Refresh(ctx context.Context, licence string, keys SealingKeys } result, err := refresher.Refresh(ctx, - adapters.RefreshInput{Licence: licence, Manager: *manager, AtRest: at}) + adapters.RefreshInput{ + Licence: licence, Manager: *manager, Sealed: sealedRefresh, ManagerKey: managerKey, + }) if err != nil { return 0, err } @@ -483,9 +530,10 @@ func (l *Licences) Refresh(ctx context.Context, licence string, keys SealingKeys } // The reseal-and-publish half, shared with SubmitRefresh: the new access token is sealed to every - // holder that exists now, and a rotated refresh token replaces the stored envelope — never seen - // in the clear either way. - sealed, err := resealAndPublish(ctx, tx, licence, result.AccessToken, result.NewAtRest, keys) + // consumer holder that exists now, and a rotated refresh token replaces the stored sealed blob — + // never seen in the clear either way. + sealed, err := resealAndPublish( + ctx, tx, licence, result.AccessToken, result.NewSealed, result.NewManagerKey, keys) if err != nil { return 0, err } @@ -513,7 +561,7 @@ func (l *Licences) Refresh(ctx context.Context, licence string, keys SealingKeys // `Refresh`; the only difference is where the access token came from — a module on the manager node // rather than a plug-in in this process. func (l *Licences) SubmitRefresh( - ctx context.Context, licence, accessToken string, newAtRest *secrets.AtRest, keys SealingKeys, + ctx context.Context, licence, accessToken, newSealed, newManagerKey string, keys SealingKeys, ) (int, error) { if strings.TrimSpace(accessToken) == "" { return 0, fmt.Errorf( @@ -561,7 +609,7 @@ func (l *Licences) SubmitRefresh( " licence manager %s ", licence, licence) } - sealed, err := resealAndPublish(ctx, tx, licence, accessToken, newAtRest, keys) + sealed, err := resealAndPublish(ctx, tx, licence, accessToken, newSealed, newManagerKey, keys) if err != nil { return 0, err } @@ -572,22 +620,41 @@ func (l *Licences) SubmitRefresh( return sealed, nil } -// resealAndPublish seals a new access token to every current holder and, if one is given, replaces -// the stored refresh envelope with a rotated one. It is the half `Refresh` and `SubmitRefresh` share: -// the value's source differs, what is done with it does not. +// resealAndPublish seals a new access token to every CONSUMER holder and, if one is given, replaces +// the stored sealed refresh token with a rotated one. It is the half `Refresh` and `SubmitRefresh` +// share: the value's source differs, what is done with it does not. // -// The refresh token is never touched here — a rotated one arrives already re-sealed at rest, and is -// stored as the opaque envelope it is. `KeyFor` can therefore only ever deliver the access token. +// **The manager holder is skipped.** It is delivered the refresh token, not an access token (`KeyFor`); +// sealing an access token into its row would be a value nothing reads, and — worse — would overwrite +// the delivery bookkeeping for the one holder whose credential is the refresh token. So the reseal +// walks consumer holders only, and the count it returns is the number of consumers a push will carry +// the new access token to. +// +// The refresh token is never in the clear here — a rotated one arrives already sealed to the manager +// node, and is stored as the opaque blob it is. A consumer's `KeyFor` reads licence_holder, so it can +// only ever deliver an access token. func resealAndPublish( - ctx context.Context, tx pgx.Tx, licence, accessToken string, newAtRest *secrets.AtRest, + ctx context.Context, tx pgx.Tx, licence, accessToken, newSealed, newManagerKey string, keys SealingKeys, ) (int, error) { + var managerNode, managerModule *string + if err := tx.QueryRow(ctx, + `select manager, manager_module from licence where name = $1`, licence). + Scan(&managerNode, &managerModule); err != nil { + return 0, err + } + holders, err := holdersTx(ctx, tx, licence) if err != nil { return 0, err } sealed := 0 for _, h := range holders { + if managerNode != nil && managerModule != nil && + h.Node == *managerNode && h.Module == *managerModule { + // The manager holder receives the refresh token, not this access token. Left untouched. + continue + } key, err := keys(h.Node) if err != nil { return 0, err @@ -609,17 +676,18 @@ func resealAndPublish( sealed++ } - // The refresh token stays put unless the vendor rotated it, in which case it arrived already - // re-encrypted at rest — replaced here without ever being seen in the clear. - if newAtRest != nil { - if newAtRest.Token == "" || newAtRest.WrappedKey == "" || newAtRest.ManagerKey == "" { + // The refresh token stays put unless the vendor rotated it, in which case the manager sealed the + // new one to its own node key before submitting — replaced here without ever being seen in the + // clear. + if newSealed != "" { + if newManagerKey == "" { return 0, fmt.Errorf( - "the refresh returned an incomplete re-sealed refresh token for %q", licence) + "the refresh returned a re-sealed refresh token for %q with no manager key", licence) } if _, err := tx.Exec(ctx, - `update refresh_grant set token = $2, wrapped_key = $3, manager_key = $4, updated_at = now() + `update refresh_grant set sealed = $2, manager_key = $3, updated_at = now() where licence = $1`, - licence, newAtRest.Token, newAtRest.WrappedKey, newAtRest.ManagerKey); err != nil { + licence, newSealed, newManagerKey); err != nil { return 0, err } } diff --git a/internal/licences/migrations/0001-a-licence-is-a-named-thing.sql b/internal/licences/migrations/0001-a-licence-is-a-named-thing.sql index 389567d..dfec8e5 100644 --- a/internal/licences/migrations/0001-a-licence-is-a-named-thing.sql +++ b/internal/licences/migrations/0001-a-licence-is-a-named-thing.sql @@ -25,6 +25,11 @@ create table licence ( -- A name, not a foreign key: nodes live in another context this one may not join across -- (novox/hq ADR 0008). manager text, + -- The module ON the manager node that runs the refresh -- the manager holder. Named alongside + -- the manager node because a node may run the manager module AND a consuming module of the same + -- licence (the lab co-locates both), and which holder is delivered the refresh token rather than + -- an access token turns on the module, not the node alone. Null exactly when `manager` is. + manager_module text, added_at timestamptz not null default now() ); @@ -48,29 +53,33 @@ create table licence_holder ( primary key (licence, node, module) ); --- The refresh token, encrypted at rest under the manager node's key. +-- The refresh token, sealed to the manager node's key -- the same anonymous box every credential +-- the mesh delivers uses. -- --- **novox/hq ADR 0050's carve-out, and its one home.** A `refreshable-grant` licence cannot be both --- sealed so the mesh cannot read it and rotated centrally, because rotating means a node reads the --- refresh token back. So exactly one node -- the licence's manager -- holds it readably, and it is --- kept here as an envelope only that node can open: a symmetric data key encrypts the token --- (secretbox), and the data key is sealed to the manager's public key. This database on its own --- holds ciphertext and a wrapped key with no private half to open either (novox/hq ADR 0004). +-- **novox/hq ADR 0050's carve-out, delivered the way the mesh delivers everything else.** A +-- `refreshable-grant` licence cannot be both sealed so the mesh cannot read it and rotated centrally, +-- because rotating means a node reads the refresh token back. So exactly one node -- the licence's +-- manager -- reads it. But the earlier attempt at a bespoke at-rest envelope, and the module holding +-- the node's private key to open it, hit a wall the mesh's own design forbids: a module is never +-- given a node's private sealing key. So the refresh token rides the *ordinary* path instead -- it is +-- an anonymous sealed box (secrets.Seal, `crypto_box_seal`) to the manager node's public sealing key, +-- exactly like a consumer's db password, and the HOST unseals it and mounts the cleartext at the +-- manager module's bound path. This database on its own holds a sealed box with no private half to +-- open it (novox/hq ADR 0004), the same guarantee as every other sealed value here. -- -- **Separate from the access tokens.** licence_holder.sealed is the ACCESS token, sealed per holder --- and delivered. This is the REFRESH token, one per licence, never delivered to anybody. Keeping --- them in different tables is what makes "the refresh token is stripped on delivery" structural: --- delivery reads licence_holder, and the refresh token is not in it. +-- and delivered to consumers. This is the REFRESH token, one per licence, delivered to the manager +-- holder alone. Keeping them apart is what makes "a consumer is never delivered the refresh token" +-- structural: a consumer's delivery reads licence_holder, and the refresh token is not there. create table refresh_grant ( -- One refresh token per licence. On delete cascade: forgetting a licence forgets its refresh -- token with it, the same way it forgets its holders. licence text primary key references licence(name) on delete cascade, - -- base64( nonce || secretbox(data_key, refresh_token) ) -- the token under the symmetric key. - token text not null, - -- base64( anonymous-box(manager_key, data_key) ) -- the data key closed to the manager node. - wrapped_key text not null, - -- The manager's public sealing key the data key was wrapped to. Kept so a manager that + -- base64( anonymous-box( manager sealing key, refresh_token ) ) -- the refresh token sealed to + -- the manager node, openable only by that node's private half, which the mesh never holds. + sealed text not null, + -- The manager's public sealing key the refresh token was sealed to. Kept so a manager that -- regenerated its key can be told it can no longer open this, rather than discovering it as a -- refresh that will not decrypt (the same reason licence_holder.node_key is kept). manager_key text not null, diff --git a/internal/licences/migrations/0003-a-manager-and-its-refresh-token.sql b/internal/licences/migrations/0003-a-manager-and-its-refresh-token.sql index 15c754d..a78d5df 100644 --- a/internal/licences/migrations/0003-a-manager-and-its-refresh-token.sql +++ b/internal/licences/migrations/0003-a-manager-and-its-refresh-token.sql @@ -1,19 +1,33 @@ --- A manager, and the refresh token it holds encrypted at rest. +-- A manager, and the refresh token it holds -- sealed to that node, the way every credential is. -- --- novox/hq ADR 0050, Phase B. The consolidated schema (0001) now creates the `manager` column and --- the `refresh_grant` table; this migration carries an existing database the same distance, so a --- database that predates the carve-out gains exactly what a fresh one is created with. +-- novox/hq ADR 0050. The consolidated schema (0001) creates the `manager` and `manager_module` +-- columns and the `refresh_grant` table in its final shape; this migration carries an existing +-- database the same distance, so a database that predates the carve-out gains exactly what a fresh +-- one is created with. -- --- **Guarded, so it is a no-op on a database created after 0001 was updated.** A fresh database --- already has both, and re-adding them would fail; `if not exists` on both makes the two paths -- --- fresh and pre-existing -- end at the same schema. +-- **Idempotent, and it converges rather than assumes.** An early cut of this carve-out kept the +-- refresh token as a bespoke at-rest envelope (`token` + `wrapped_key`) so the manager MODULE could +-- open it with the node's private key. That was retired before release: a module is never given a +-- node's private sealing key, so the refresh token now rides the ordinary sealed-delivery path -- +-- one anonymous sealed box to the manager node's public key, unsealed by the HOST. This migration +-- therefore also drops those columns and adds `sealed` for any database that ran the earlier shape, +-- so both a pristine database and one carried through the early cut end at the same schema. alter table licence add column if not exists manager text; +alter table licence add column if not exists manager_module text; create table if not exists refresh_grant ( licence text primary key references licence(name) on delete cascade, - token text not null, - wrapped_key text not null, + sealed text not null, manager_key text not null, updated_at timestamptz not null default now() ); + +-- Converge a database that created refresh_grant in the retired at-rest shape. There is nothing to +-- preserve: an unreleased carve-out held no production refresh tokens, and a refresh token cannot be +-- re-derived from a wrapped envelope this migration cannot open. The manager re-adopts. +alter table refresh_grant add column if not exists sealed text; +alter table refresh_grant drop column if exists token; +alter table refresh_grant drop column if exists wrapped_key; +update refresh_grant set sealed = '' where sealed is null; +alter table refresh_grant alter column sealed set not null; diff --git a/internal/licences/refresh_test.go b/internal/licences/refresh_test.go index 4819a4e..6478fda 100644 --- a/internal/licences/refresh_test.go +++ b/internal/licences/refresh_test.go @@ -39,33 +39,56 @@ func nodeKeyPair(t *testing.T) (public string, open func(string) ([]byte, error) } } -// managerPair is like nodeKeyPair but also returns the private key string, because the manager needs -// to OpenAtRest its own refresh token — the one node that reads it back. -func managerPair(t *testing.T) (public, private string) { +// managerPair is like nodeKeyPair but returns the private key string too, because the MANAGER opens +// its own refresh token — the one node that reads it back — and the host on that node does so with +// box.OpenAnonymous, exactly as it opens any sealed credential. +func managerPair(t *testing.T) (public, private string, open func(string) ([]byte, error)) { t.Helper() priv, err := ecdh.X25519().GenerateKey(rand.Reader) if err != nil { t.Fatal(err) } + var pub, sk [32]byte + copy(pub[:], priv.PublicKey().Bytes()) + copy(sk[:], priv.Bytes()) return base64.StdEncoding.EncodeToString(priv.PublicKey().Bytes()), - base64.StdEncoding.EncodeToString(priv.Bytes()) + base64.StdEncoding.EncodeToString(priv.Bytes()), + func(sealed string) ([]byte, error) { + blob, err := base64.StdEncoding.DecodeString(sealed) + if err != nil { + return nil, err + } + out, ok := box.OpenAnonymous(nil, blob, &pub, &sk) + if !ok { + return nil, context.Canceled + } + return out, nil + } } type fakeRefresher struct { - access string - newAtRest *secrets.AtRest - got adapters.RefreshInput + access string + newSealed string + newManagerKey string + got adapters.RefreshInput } func (f *fakeRefresher) Refresh(_ context.Context, in adapters.RefreshInput) (adapters.RefreshResult, error) { f.got = in - return adapters.RefreshResult{AccessToken: f.access, NewAtRest: f.newAtRest}, nil + return adapters.RefreshResult{ + AccessToken: f.access, NewSealed: f.newSealed, NewManagerKey: f.newManagerKey, + }, nil } -// A refreshable-grant licence set up end to end: a manager, a refresh token sealed at rest to it, two -// holders each with a sealing key, and a fake vendor refresher plugged in. +// A refreshable-grant licence set up end to end: a manager node running the manager module (a holder +// delivered the refresh token), the refresh token sealed to it, two CONSUMER holders — one of them on +// the manager node itself, to exercise co-location — and a fake vendor refresher plugged in. +// +// The manager node is "workstation" and its manager module is "manager"; the consuming module is +// "assistant", present on both "workstation" and "laptop". func aRefreshableLicence(t *testing.T, held *Licences, ctx context.Context, fake *fakeRefresher) ( - managerPub, managerPriv string, holders map[string]func(string) ([]byte, error), keys SealingKeys, + managerPub, managerPriv string, managerOpen func(string) ([]byte, error), + holders map[string]func(string) ([]byte, error), keys SealingKeys, ) { t.Helper() adapters.RegisterRefresher("anthropic", fake) @@ -74,45 +97,52 @@ func aRefreshableLicence(t *testing.T, held *Licences, ctx context.Context, fake if err := held.Add(ctx, "personal", "anthropic", map[string]any{"model": "a-model"}); err != nil { t.Fatal(err) } - if err := held.SetManager(ctx, "personal", "workstation"); err != nil { + if err := held.SetManager(ctx, "personal", "workstation", "manager"); err != nil { t.Fatal(err) } - managerPub, managerPriv = managerPair(t) - at, err := secrets.SealAtRest("rt-the-refresh-token", managerPub) + managerPub, managerPriv, managerOpen = managerPair(t) + sealedRefresh, err := secrets.Seal(managerPub, []byte("rt-the-refresh-token")) if err != nil { t.Fatal(err) } - if err := held.SetRefreshGrant(ctx, "personal", at); err != nil { + if err := held.SetRefreshGrant(ctx, "personal", sealedRefresh, managerPub); err != nil { + t.Fatal(err) + } + // The manager module is a holder too, on the manager node, so resealAndPublish has it to skip. + if err := held.Use(ctx, "personal", "workstation", "manager"); err != nil { t.Fatal(err) } holders = map[string]func(string) ([]byte, error){} - pub := map[string]string{} + pub := map[string]string{"workstation": managerPub} + _, holders["workstation"] = "", managerOpen // consumer on the manager node shares its key for _, node := range []string{"workstation", "laptop"} { - p, open := nodeKeyPair(t) - pub[node], holders[node] = p, open + if node == "laptop" { + p, open := nodeKeyPair(t) + pub[node], holders[node] = p, open + } if err := held.Use(ctx, "personal", node, "assistant"); err != nil { t.Fatal(err) } } keys = func(node string) (string, error) { return pub[node], nil } - return managerPub, managerPriv, holders, keys + return managerPub, managerPriv, managerOpen, holders, keys } -// The point of the phase, in one test: a refresh seals the ACCESS token to every holder, and the -// refresh token is nowhere a holder can reach it. +// The point of the phase, in one test: a refresh seals the ACCESS token to every CONSUMER holder, the +// manager holder is delivered the refresh token, and the refresh token is nowhere a consumer reaches. func TestARefreshDeliversTheAccessTokenAndNeverTheRefreshToken(t *testing.T) { held, ctx := fresh(t) fake := &fakeRefresher{access: "at-brand-new-access-token"} - _, _, holders, keys := aRefreshableLicence(t, held, ctx, fake) + _, _, managerOpen, holders, keys := aRefreshableLicence(t, held, ctx, fake) sealed, err := held.Refresh(ctx, "personal", keys) if err != nil { t.Fatal(err) } if sealed != 2 { - t.Fatalf("%d holder(s) were resealed, expected 2", sealed) + t.Fatalf("%d consumer holder(s) were resealed, expected 2", sealed) } for node, open := range holders { @@ -130,26 +160,38 @@ func TestARefreshDeliversTheAccessTokenAndNeverTheRefreshToken(t *testing.T) { if string(got) != "at-brand-new-access-token" { t.Fatalf("%s was delivered %q, not the access token", node, got) } - // The refresh token is not in the holder's delivery, opened or sealed. if string(got) == "rt-the-refresh-token" || strings.Contains(blob, "rt-the-refresh-token") { t.Fatalf("%s was delivered the refresh token", node) } } + + // The manager holder is delivered the refresh token, and opens it with the node's own key. + mgrBlob, err := held.KeyFor(ctx, "personal", "workstation", "manager") + if err != nil { + t.Fatal(err) + } + got, err := managerOpen(mgrBlob) + if err != nil { + t.Fatal("the manager cannot open the refresh token delivered to it") + } + if string(got) != "rt-the-refresh-token" { + t.Fatalf("the manager was delivered %q, not the refresh token", got) + } } -// KeyFor delivers the access token and cannot deliver the refresh token, because the refresh token -// is not in licence_holder at all — the stripping is structural. -func TestKeyForNeverCarriesTheRefreshToken(t *testing.T) { +// A CONSUMER holder is never delivered the refresh token, because a consumer's row never holds it and +// KeyFor for a consumer reads licence_holder — the separation is structural. +func TestKeyForNeverCarriesTheRefreshTokenToAConsumer(t *testing.T) { held, ctx := fresh(t) fake := &fakeRefresher{access: "at-access"} - _, _, _, keys := aRefreshableLicence(t, held, ctx, fake) + _, _, _, _, keys := aRefreshableLicence(t, held, ctx, fake) if _, err := held.Refresh(ctx, "personal", keys); err != nil { t.Fatal(err) } - // Every holder row, straight from the store: none of them holds the refresh token in any form. + // Every CONSUMER holder row, straight from the store: none holds the refresh token in any form. rows, err := held.store.Pool().Query(ctx, - `select coalesce(sealed, '') from licence_holder where licence = 'personal'`) + `select coalesce(sealed, '') from licence_holder where licence = 'personal' and module = 'assistant'`) if err != nil { t.Fatal(err) } @@ -160,57 +202,51 @@ func TestKeyForNeverCarriesTheRefreshToken(t *testing.T) { t.Fatal(err) } if strings.Contains(sealed, "rt-the-refresh-token") { - t.Fatal("a holder row carries the refresh token") + t.Fatal("a consumer holder row carries the refresh token") } } } -// The refresh token at rest is not readable from the database alone: the row holds ciphertext and a -// wrapped key, and only the manager node's private half opens it. -func TestTheRefreshTokenAtRestNeedsTheManagersKey(t *testing.T) { +// The refresh token is not readable from the database alone: the row holds a sealed box, and only the +// manager node's private half opens it — the same guarantee every sealed credential here has. +func TestTheRefreshTokenNeedsTheManagersKey(t *testing.T) { held, ctx := fresh(t) fake := &fakeRefresher{access: "at-access"} - managerPub, managerPriv, _, _ := aRefreshableLicence(t, held, ctx, fake) + managerPub, managerPriv, _, _, _ := aRefreshableLicence(t, held, ctx, fake) - // What the database holds, read straight from the row. - var token, wrapped, managerKey string + var sealed, managerKey string if err := held.store.Pool().QueryRow(ctx, - `select token, wrapped_key, manager_key from refresh_grant where licence = 'personal'`). - Scan(&token, &wrapped, &managerKey); err != nil { + `select sealed, manager_key from refresh_grant where licence = 'personal'`). + Scan(&sealed, &managerKey); err != nil { t.Fatal(err) } - if strings.Contains(token, "rt-the-refresh-token") || strings.Contains(wrapped, "rt-the-refresh-token") { + if strings.Contains(sealed, "rt-the-refresh-token") { t.Fatal("the refresh token is in the row in the clear") } - - // The manager, holding its private key, reads it back. - got, err := secrets.OpenAtRest( - secrets.AtRest{Token: token, WrappedKey: wrapped, ManagerKey: managerKey}, - managerPub, managerPriv) - if err != nil { - t.Fatal(err) + if managerKey != managerPub { + t.Fatal("the stored manager key is not the manager's public key") } - if got != "rt-the-refresh-token" { + + // The manager, holding its private key, reads it back with box.OpenAnonymous (as the host does). + got := openAnon(t, sealed, managerPub, managerPriv) + if string(got) != "rt-the-refresh-token" { t.Fatalf("the manager read back %q", got) } // Another node cannot, which is the whole of "the manager node only". - otherPub, otherPriv := managerPair(t) - if _, err := secrets.OpenAtRest( - secrets.AtRest{Token: token, WrappedKey: wrapped, ManagerKey: managerKey}, - otherPub, otherPriv); err == nil { + otherPub, otherPriv, _ := managerPair(t) + if _, ok := tryOpenAnon(sealed, otherPub, otherPriv); ok { t.Fatal("a node that is not the manager opened the refresh token") } } -// After a refresh, holders hold a NEW access token, and the refresh token that was not rotated is -// unchanged — never delivered either way. -func TestAfterRefreshHoldersHoldANewAccessTokenAndTheGrantIsUnchanged(t *testing.T) { +// After a refresh, consumers hold a NEW access token, and the refresh token that was not rotated is +// unchanged — never delivered to a consumer either way. +func TestAfterRefreshConsumersHoldANewAccessTokenAndTheGrantIsUnchanged(t *testing.T) { held, ctx := fresh(t) fake := &fakeRefresher{access: "at-first"} - _, _, holders, keys := aRefreshableLicence(t, held, ctx, fake) + _, _, _, holders, keys := aRefreshableLicence(t, held, ctx, fake) - // A prior access token, so we can see it change. if _, err := held.Refresh(ctx, "personal", keys); err != nil { t.Fatal(err) } @@ -224,7 +260,6 @@ func TestAfterRefreshHoldersHoldANewAccessTokenAndTheGrantIsUnchanged(t *testing } grantBefore := grantRow(t, held, ctx) - // A second refresh with a different access token and no rotation of the refresh token. fake.access = "at-second" if _, err := held.Refresh(ctx, "personal", keys); err != nil { t.Fatal(err) @@ -250,36 +285,36 @@ func TestAfterRefreshHoldersHoldANewAccessTokenAndTheGrantIsUnchanged(t *testing } } -// When the vendor rotates the refresh token too, the stored envelope is replaced with the -// re-encrypted one — and it is still not deliverable to a holder. -func TestARotatedRefreshTokenReplacesTheStoredEnvelope(t *testing.T) { +// When the vendor rotates the refresh token too, the stored sealed box is replaced with the re-sealed +// one — and it is still delivered only to the manager, opening only with the manager's key. +func TestARotatedRefreshTokenReplacesTheStoredBox(t *testing.T) { held, ctx := fresh(t) fake := &fakeRefresher{access: "at-access"} - managerPub, managerPriv, _, keys := aRefreshableLicence(t, held, ctx, fake) + managerPub, managerPriv, _, _, keys := aRefreshableLicence(t, held, ctx, fake) grantBefore := grantRow(t, held, ctx) - rotated, err := secrets.SealAtRest("rt-a-rotated-refresh-token", managerPub) + rotated, err := secrets.Seal(managerPub, []byte("rt-a-rotated-refresh-token")) if err != nil { t.Fatal(err) } - fake.newAtRest = &rotated + fake.newSealed, fake.newManagerKey = rotated, managerPub if _, err := held.Refresh(ctx, "personal", keys); err != nil { t.Fatal(err) } if grantRow(t, held, ctx) == grantBefore { - t.Fatal("the rotated refresh token did not replace the stored envelope") + t.Fatal("the rotated refresh token did not replace the stored box") } - at, ok, err := held.RefreshGrant(ctx, "personal") + sealed, key, ok, err := held.RefreshGrant(ctx, "personal") if err != nil || !ok { t.Fatalf("the rotated grant is not stored: ok=%v err=%v", ok, err) } - got, err := secrets.OpenAtRest(at, managerPub, managerPriv) - if err != nil { - t.Fatal(err) + if key != managerPub { + t.Fatal("the rotated grant is not sealed to the manager's key") } - if got != "rt-a-rotated-refresh-token" { + got := openAnon(t, sealed, managerPub, managerPriv) + if string(got) != "rt-a-rotated-refresh-token" { t.Fatalf("the stored grant opened to %q, not the rotated token", got) } } @@ -291,10 +326,10 @@ func TestAStaticKeyLicenceHasNoManagerAndNoRefresh(t *testing.T) { if err := held.Add(ctx, "plain", "anthropic-api-key", nil); err != nil { t.Fatal(err) } - if err := held.SetManager(ctx, "plain", "workstation"); err == nil { + if err := held.SetManager(ctx, "plain", "workstation", "manager"); err == nil { t.Fatal("a static-key licence was given a manager") } - if _, ok, err := held.RefreshGrant(ctx, "plain"); err != nil || ok { + if _, _, ok, err := held.RefreshGrant(ctx, "plain"); err != nil || ok { t.Fatalf("a static-key licence has a refresh token stored: ok=%v err=%v", ok, err) } if _, err := held.Refresh(ctx, "plain", func(string) (string, error) { return "", nil }); err == nil { @@ -317,15 +352,38 @@ func TestARefreshableLicenceWithoutAManagerIsRefused(t *testing.T) { } } -// grantRow is the whole at-rest envelope as one string, for asserting it changed or did not. +// grantRow is the whole sealed grant as one string, for asserting it changed or did not. func grantRow(t *testing.T, held *Licences, ctx context.Context) string { t.Helper() - at, ok, err := held.RefreshGrant(ctx, "personal") + sealed, key, ok, err := held.RefreshGrant(ctx, "personal") if err != nil { t.Fatal(err) } if !ok { return "" } - return at.Token + "|" + at.WrappedKey + "|" + at.ManagerKey + return sealed + "|" + key +} + +// openAnon opens an anonymous sealed box with a node's key pair — the host's Unseal, inlined for a test. +func openAnon(t *testing.T, sealed, pubB64, privB64 string) []byte { + t.Helper() + out, ok := tryOpenAnon(sealed, pubB64, privB64) + if !ok { + t.Fatal("box.OpenAnonymous failed for a value that should open") + } + return out +} + +func tryOpenAnon(sealed, pubB64, privB64 string) ([]byte, bool) { + blob, err := base64.StdEncoding.DecodeString(sealed) + if err != nil { + return nil, false + } + pubRaw, _ := base64.StdEncoding.DecodeString(pubB64) + privRaw, _ := base64.StdEncoding.DecodeString(privB64) + var pub, priv [32]byte + copy(pub[:], pubRaw) + copy(priv[:], privRaw) + return box.OpenAnonymous(nil, blob, &pub, &priv) } diff --git a/internal/licences/submitrefresh_test.go b/internal/licences/submitrefresh_test.go index 05ece6e..e12cb19 100644 --- a/internal/licences/submitrefresh_test.go +++ b/internal/licences/submitrefresh_test.go @@ -7,24 +7,24 @@ import ( "github.com/novox/mesh-control/internal/secrets" ) -// SubmitRefresh is the Phase-C boundary: a refresh a manager NODE performed is published here, and -// the control plane is given only the access token in the clear and an opaque re-sealed refresh -// envelope — never the refresh token. These tests defend that the boundary keeps its shape. +// SubmitRefresh is the boundary a manager NODE crosses to publish a refresh it performed: the control +// plane is given only the access token in the clear and an opaque re-sealed refresh box — never the +// refresh token. These tests defend that the boundary keeps its shape. -// A submitted refresh seals the access token to every holder, exactly as an in-process refresh does, -// and delivers no refresh token to anybody. +// A submitted refresh seals the access token to every CONSUMER holder, exactly as an in-process +// refresh does, and delivers no refresh token to a consumer. func TestSubmitRefreshSealsTheAccessTokenAndNeverTheRefreshToken(t *testing.T) { held, ctx := fresh(t) // No in-process refresher registered: the anthropic production path uses SubmitRefresh, not - // Refresh, precisely so nothing opens the envelope inside this process. - _, _, holders, keys := aRefreshableLicence(t, held, ctx, &fakeRefresher{}) + // Refresh, precisely so nothing opens the box inside this process. + _, _, _, holders, keys := aRefreshableLicence(t, held, ctx, &fakeRefresher{}) - sealed, err := held.SubmitRefresh(ctx, "personal", "at-from-the-manager-node", nil, keys) + sealed, err := held.SubmitRefresh(ctx, "personal", "at-from-the-manager-node", "", "", keys) if err != nil { t.Fatal(err) } if sealed != 2 { - t.Fatalf("%d holder(s) were resealed, expected 2", sealed) + t.Fatalf("%d consumer holder(s) were resealed, expected 2", sealed) } for node, open := range holders { @@ -45,68 +45,62 @@ func TestSubmitRefreshSealsTheAccessTokenAndNeverTheRefreshToken(t *testing.T) { } } -// The refresh token stored at rest is untouched by a submit that carried no rotation, and the manager -// node — and only it — still opens it. The submit path never saw the refresh token in the clear. +// The refresh token is untouched by a submit that carried no rotation, and the manager node — and +// only it — still opens it. The submit path never saw the refresh token in the clear. func TestSubmitRefreshWithoutRotationLeavesTheGrantOpenableByTheManagerAlone(t *testing.T) { held, ctx := fresh(t) - managerPub, managerPriv, _, keys := aRefreshableLicence(t, held, ctx, &fakeRefresher{}) + managerPub, managerPriv, _, _, keys := aRefreshableLicence(t, held, ctx, &fakeRefresher{}) before := grantRow(t, held, ctx) - if _, err := held.SubmitRefresh(ctx, "personal", "at-access", nil, keys); err != nil { + if _, err := held.SubmitRefresh(ctx, "personal", "at-access", "", "", keys); err != nil { t.Fatal(err) } if grantRow(t, held, ctx) != before { t.Fatal("a submit with no rotation changed the stored refresh token") } - at, ok, err := held.RefreshGrant(ctx, "personal") + sealed, _, ok, err := held.RefreshGrant(ctx, "personal") if err != nil || !ok { t.Fatalf("the grant is not stored: ok=%v err=%v", ok, err) } - got, err := secrets.OpenAtRest(at, managerPub, managerPriv) - if err != nil { - t.Fatal(err) - } - if got != "rt-the-refresh-token" { + got := openAnon(t, sealed, managerPub, managerPriv) + if string(got) != "rt-the-refresh-token" { t.Fatalf("the manager read back %q", got) } // A node that is not the manager cannot: the whole of "the manager node only". - otherPub, otherPriv := managerPair(t) - if _, err := secrets.OpenAtRest(at, otherPub, otherPriv); err == nil { + otherPub, otherPriv, _ := managerPair(t) + if _, ok := tryOpenAnon(sealed, otherPub, otherPriv); ok { t.Fatal("a node that is not the manager opened the refresh token") } } -// A submit that carries a rotated envelope replaces the stored one — and the control plane stored it +// A submit that carries a rotated box replaces the stored one — and the control plane stored it // without opening it: only the manager node reads the rotated token back. -func TestSubmitRefreshWithRotationReplacesTheEnvelopeUnopened(t *testing.T) { +func TestSubmitRefreshWithRotationReplacesTheBoxUnopened(t *testing.T) { held, ctx := fresh(t) - managerPub, managerPriv, _, keys := aRefreshableLicence(t, held, ctx, &fakeRefresher{}) + managerPub, managerPriv, _, _, keys := aRefreshableLicence(t, held, ctx, &fakeRefresher{}) before := grantRow(t, held, ctx) - // The manager node re-sealed the rotated refresh token at rest; the control plane is handed only - // this envelope. - rotated, err := secrets.SealAtRest("rt-a-rotated-refresh-token", managerPub) + // The manager node re-sealed the rotated refresh token to its own key; the control plane is handed + // only this box. + rotated, err := secrets.Seal(managerPub, []byte("rt-a-rotated-refresh-token")) if err != nil { t.Fatal(err) } - if _, err := held.SubmitRefresh(ctx, "personal", "at-access", &rotated, keys); err != nil { + if _, err := held.SubmitRefresh(ctx, "personal", "at-access", rotated, managerPub, keys); err != nil { t.Fatal(err) } if grantRow(t, held, ctx) == before { - t.Fatal("the rotated refresh token did not replace the stored envelope") + t.Fatal("the rotated refresh token did not replace the stored box") } - at, ok, err := held.RefreshGrant(ctx, "personal") + sealed, _, ok, err := held.RefreshGrant(ctx, "personal") if err != nil || !ok { t.Fatalf("the rotated grant is not stored: ok=%v err=%v", ok, err) } - got, err := secrets.OpenAtRest(at, managerPub, managerPriv) - if err != nil { - t.Fatal(err) - } - if got != "rt-a-rotated-refresh-token" { + got := openAnon(t, sealed, managerPub, managerPriv) + if string(got) != "rt-a-rotated-refresh-token" { t.Fatalf("the stored grant opened to %q, not the rotated token", got) } } @@ -115,8 +109,8 @@ func TestSubmitRefreshWithRotationReplacesTheEnvelopeUnopened(t *testing.T) { // reporting success is the failure this whole design refuses. func TestSubmitRefreshRefusesAnEmptyAccessToken(t *testing.T) { held, ctx := fresh(t) - _, _, _, keys := aRefreshableLicence(t, held, ctx, &fakeRefresher{}) - if _, err := held.SubmitRefresh(ctx, "personal", " ", nil, keys); err == nil { + _, _, _, _, keys := aRefreshableLicence(t, held, ctx, &fakeRefresher{}) + if _, err := held.SubmitRefresh(ctx, "personal", " ", "", "", keys); err == nil { t.Fatal("a refresh with no access token was published") } } @@ -131,7 +125,7 @@ func TestSubmitRefreshRefusesAStaticKeyLicence(t *testing.T) { if err := held.Use(ctx, "plain", "workstation", "assistant"); err != nil { t.Fatal(err) } - _, err := held.SubmitRefresh(ctx, "plain", "at-access", nil, + _, err := held.SubmitRefresh(ctx, "plain", "at-access", "", "", func(string) (string, error) { return ASealingKey(t), nil }) if err == nil { t.Fatal("a refresh was submitted for a static-key licence") @@ -148,7 +142,7 @@ func TestSubmitRefreshRefusesWithoutAManager(t *testing.T) { if err := held.Add(ctx, "personal", "anthropic", nil); err != nil { t.Fatal(err) } - _, err := held.SubmitRefresh(ctx, "personal", "at-access", nil, + _, err := held.SubmitRefresh(ctx, "personal", "at-access", "", "", func(string) (string, error) { return "", nil }) if err == nil { t.Fatal("a refresh was submitted for a licence with no manager") diff --git a/internal/secrets/atrest.go b/internal/secrets/atrest.go deleted file mode 100644 index d095272..0000000 --- a/internal/secrets/atrest.go +++ /dev/null @@ -1,160 +0,0 @@ -package secrets - -import ( - "crypto/rand" - "encoding/base64" - "fmt" - "strings" - - "golang.org/x/crypto/nacl/box" - "golang.org/x/crypto/nacl/secretbox" -) - -// A value the mesh keeps encrypted so ONE node — and nothing else, not this database on its own — -// can read it back. -// -// **Why this exists at all, and why it is not the seal above.** The per-holder seal (Seal / Make / -// Accept) is one-way delivery: the mesh closes a value to a node's public key, the node opens it -// once with the private half the mesh never saw, and the mesh keeps nothing it can read. That is -// the whole guarantee, and for every credential the mesh handles it is the right one — there is -// nothing to rotate, so nothing has to be read back. -// -// A `refreshable-grant` credential (novox/hq ADR 0050) breaks that, and the ADR says so in as many -// words: it cannot be *sealed so the mesh cannot read it* and *rotated centrally* at once, because -// rotating it means some node reads the refresh token back, repeatedly, every time the grant is -// refreshed. The carve-out the ADR draws is exactly and only this: the **manager node** holds the -// refresh token **encrypted at rest**, readable **by that node**, because rotation requires it. -// -// So this is a genuinely different mechanism from the anonymous-box seal, not a second caller of it: -// -// - The payload is under a **symmetric** data key (NaCl secretbox), because the same node decrypts -// it again and again — an anonymous sealed box is nonce-less one-shot delivery, not a store its -// writer reopens. -// - Only the **data key** is sealed to the manager's public sealing key, with the very same -// anonymous box the per-holder seal uses (Seal, below). This is envelope encryption: the bulk -// is symmetric so it can be reopened, the key is asymmetric so only the manager can recover it. -// -// **Why this database alone cannot read it.** What is stored is the secretbox ciphertext and the -// data key *wrapped to the manager node's public sealing key*. Recovering the data key needs the -// manager node's Curve25519 private half, which never leaves that machine (novox/hq ADR 0004) and -// which the control plane has never held. A copy of this database is therefore a directory of -// ciphertexts and wrapped keys with nothing to open either — which is the property a plain -// encrypted-at-rest column does not have, because there the key sits beside the data. -// -// **Where each half runs.** SealAtRest and OpenAtRest are the mechanism, kept here in one audited -// place. In production only the **manager node** runs them — it produces the envelope when the grant -// is first adopted, and opens it to refresh (novox/hq ADR 0050, Phase C). The control plane stores -// and forwards the envelope as an opaque blob and never calls OpenAtRest on a live path; it holds no -// private key that could. OpenAtRest lives here so the round trip and the security bounds are -// testable, and so the manager-side code has one implementation to reuse rather than a second to -// keep in step. -type AtRest struct { - // Token is base64( nonce ‖ secretbox(dataKey, plaintext) ) — the refresh token under the - // symmetric data key, the nonce carried in front of the box as its convention allows. - Token string - // WrappedKey is base64( anonymous-box(managerSealingKey, dataKey) ) — the data key closed to the - // manager node, openable only by that node's private half. - WrappedKey string - // ManagerKey is the manager's public sealing key the data key was wrapped to. Kept for the same - // reason licence_holder.node_key and module_secret.node_key are: a manager that has since - // regenerated its key can be told it can no longer open this, rather than discovering it as a - // refresh that fails to decrypt. - ManagerKey string -} - -// SealAtRest wraps a value so only the holder of managerSealingKey's private half can read it. -// -// A fresh random data key each time, so two envelopes of the same refresh token look nothing alike -// and a rotation that changed nothing is indistinguishable from one that changed everything — the -// same property the per-holder seal has, kept here deliberately. -func SealAtRest(value, managerSealingKey string) (AtRest, error) { - if strings.TrimSpace(value) == "" { - return AtRest{}, fmt.Errorf("there is nothing to seal") - } - if managerSealingKey == "" { - return AtRest{}, fmt.Errorf( - "the manager has no sealing key, so a refresh token cannot be kept for it") - } - - var dataKey [32]byte - if _, err := rand.Read(dataKey[:]); err != nil { - return AtRest{}, err - } - // Zeroed on the way out. The plaintext data key exists for the length of this call and no - // longer, which is what keeps the envelope's secrecy resting on the wrapped copy alone. - defer func() { - for i := range dataKey { - dataKey[i] = 0 - } - }() - - var nonce [24]byte - if _, err := rand.Read(nonce[:]); err != nil { - return AtRest{}, err - } - // secretbox.Seal prepends nothing; the nonce is our prefix, carried so OpenAtRest can recover it. - sealedToken := secretbox.Seal(nonce[:], []byte(value), &nonce, &dataKey) - - wrapped, err := Seal(managerSealingKey, dataKey[:]) - if err != nil { - return AtRest{}, err - } - - return AtRest{ - Token: base64.StdEncoding.EncodeToString(sealedToken), - WrappedKey: wrapped, - ManagerKey: managerSealingKey, - }, nil -} - -// OpenAtRest recovers the value, given the manager node's own key pair. -// -// This is the manager-node / test half of the mechanism (see the type comment): the control plane -// has no private key and never calls it on a live path. -func OpenAtRest(a AtRest, managerPublicKey, managerPrivateKey string) (string, error) { - pub, err := base64.StdEncoding.DecodeString(managerPublicKey) - if err != nil || len(pub) != 32 { - return "", fmt.Errorf("%q is not a sealing key", managerPublicKey) - } - priv, err := base64.StdEncoding.DecodeString(managerPrivateKey) - if err != nil || len(priv) != 32 { - return "", fmt.Errorf("the manager private key is not 32 bytes") - } - var pubArr, privArr [32]byte - copy(pubArr[:], pub) - copy(privArr[:], priv) - - wrapped, err := base64.StdEncoding.DecodeString(a.WrappedKey) - if err != nil { - return "", fmt.Errorf("the wrapped key is not base64: %w", err) - } - keyBytes, ok := box.OpenAnonymous(nil, wrapped, &pubArr, &privArr) - if !ok { - return "", fmt.Errorf("this refresh token was not wrapped to this manager's key") - } - if len(keyBytes) != 32 { - return "", fmt.Errorf("the wrapped key is the wrong length") - } - var dataKey [32]byte - copy(dataKey[:], keyBytes) - defer func() { - for i := range dataKey { - dataKey[i] = 0 - } - }() - - raw, err := base64.StdEncoding.DecodeString(a.Token) - if err != nil { - return "", fmt.Errorf("the sealed token is not base64: %w", err) - } - if len(raw) < 24 { - return "", fmt.Errorf("the sealed token is too short to hold a nonce") - } - var nonce [24]byte - copy(nonce[:], raw[:24]) - out, ok := secretbox.Open(nil, raw[24:], &nonce, &dataKey) - if !ok { - return "", fmt.Errorf("the refresh token would not open under its data key") - } - return string(out), nil -} diff --git a/internal/secrets/atrest_test.go b/internal/secrets/atrest_test.go deleted file mode 100644 index 1515298..0000000 --- a/internal/secrets/atrest_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package secrets - -import ( - "crypto/ecdh" - "crypto/rand" - "encoding/base64" - "strings" - "testing" -) - -// managerKey is the manager node's key pair, as the node would hold it: the public half reported to -// the mesh, the private half kept and used only here. -func managerKey(t *testing.T) (public, private string) { - t.Helper() - priv, err := ecdh.X25519().GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - return base64.StdEncoding.EncodeToString(priv.PublicKey().Bytes()), - base64.StdEncoding.EncodeToString(priv.Bytes()) -} - -// The whole carve-out in one test: the manager, and only the manager, reads its refresh token back. -func TestOnlyTheManagerOpensAnAtRestValue(t *testing.T) { - pub, priv := managerKey(t) - const refresh = "rt-a-real-looking-refresh-token" - - at, err := SealAtRest(refresh, pub) - if err != nil { - t.Fatal(err) - } - got, err := OpenAtRest(at, pub, priv) - if err != nil { - t.Fatal(err) - } - if got != refresh { - t.Fatalf("the refresh token did not survive: %q", got) - } - - // Another node's key pair cannot open it — the wrapped data key is closed to the manager alone. - otherPub, otherPriv := managerKey(t) - if _, err := OpenAtRest(at, otherPub, otherPriv); err == nil { - t.Fatal("a different node opened the manager's refresh token") - } -} - -// The database on its own — the ciphertext and the wrapped key, and nothing else — carries neither -// the refresh token nor the symmetric key that would open it. This is the property a plain -// encrypted-at-rest column does not have, and the reason the carve-out is bounded to the manager. -func TestTheEnvelopeAloneRevealsNothing(t *testing.T) { - pub, _ := managerKey(t) - const refresh = "rt-the-long-lived-secret" - - at, err := SealAtRest(refresh, pub) - if err != nil { - t.Fatal(err) - } - for what, field := range map[string]string{ - "the ciphertext": at.Token, - "the wrapped key": at.WrappedKey, - } { - if strings.Contains(field, refresh) { - t.Fatalf("%s holds the refresh token in the clear", what) - } - } - // Two seals of one token look nothing alike: a fresh data key and nonce each time. - again, err := SealAtRest(refresh, pub) - if err != nil { - t.Fatal(err) - } - if at.Token == again.Token { - t.Fatal("two seals of the same token are identical, so the storage says they are the same") - } -} - -// A tampered ciphertext does not open. secretbox authenticates, so a flipped byte is caught rather -// than yielding a quietly wrong token. -func TestATamperedEnvelopeIsRefused(t *testing.T) { - pub, priv := managerKey(t) - at, err := SealAtRest("rt-value", pub) - if err != nil { - t.Fatal(err) - } - - raw, err := base64.StdEncoding.DecodeString(at.Token) - if err != nil { - t.Fatal(err) - } - raw[len(raw)-1] ^= 0x01 - at.Token = base64.StdEncoding.EncodeToString(raw) - - if _, err := OpenAtRest(at, pub, priv); err == nil { - t.Fatal("a tampered refresh token opened as though it were intact") - } -} - -// Nothing to seal, and no key to seal to, are both refused rather than stored as a working envelope. -func TestSealAtRestRefusesTheEmptyCases(t *testing.T) { - pub, _ := managerKey(t) - if _, err := SealAtRest("", pub); err == nil { - t.Fatal("an empty value was sealed at rest") - } - if _, err := SealAtRest("rt-value", ""); err == nil { - t.Fatal("a refresh token was sealed to a manager with no key") - } -} diff --git a/internal/secrets/sealedbox_xcheck_test.go b/internal/secrets/sealedbox_xcheck_test.go new file mode 100644 index 0000000..cbc3dc3 --- /dev/null +++ b/internal/secrets/sealedbox_xcheck_test.go @@ -0,0 +1,79 @@ +package secrets + +import ( + "encoding/base64" + "encoding/json" + "os" + "testing" + + "golang.org/x/crypto/nacl/box" +) + +// The manager module's TypeScript seal and this package's Go seal are the SAME anonymous sealed box, +// byte for byte — the property the refreshable-grant carve-out rests on (novox/hq ADR 0050). +// +// **Why it must hold.** The refresh token is sealed to the manager node — at adoption and after each +// rotation — by the manager MODULE, in TypeScript (mesh-catalog anthropic-manager/sealedbox.ts). The +// HOST then unseals it with Go's box.OpenAnonymous (mesh-host identity.SealingKey.Unseal) to mount the +// cleartext, and mesh-control seals every other credential with box.SealAnonymous (secrets.Seal). If +// the TS seal and the Go box disagreed by a byte, the host would refuse the refresh token as a value +// it cannot open — silently, as a manager that never gets its credential. So this is load-bearing, and +// it is pinned here rather than trusted. +// +// The fixture is produced by the module's own compiled seal() over a fresh node key pair; this test +// opens it with box.OpenAnonymous — exactly what the host runs — and with secrets.Open, and recovers +// the plaintext. Regenerate it with the module's seal() if the construction ever changes; a drift +// shows up here as a fixture Go cannot open, which is the whole point. +func TestModuleSealedBoxOpensInGo(t *testing.T) { + raw, err := os.ReadFile("testdata/module-sealedbox-fixture.json") + if err != nil { + t.Fatal(err) + } + var f struct { + ManagerPublicKey string `json:"managerPublicKey"` + ManagerPrivateKey string `json:"managerPrivateKey"` + Plaintext string `json:"plaintext"` + Sealed string `json:"sealed"` + } + if err := json.Unmarshal(raw, &f); err != nil { + t.Fatal(err) + } + + pub, err := base64.StdEncoding.DecodeString(f.ManagerPublicKey) + if err != nil || len(pub) != 32 { + t.Fatalf("the fixture public key is not a 32-byte X25519 key") + } + priv, err := base64.StdEncoding.DecodeString(f.ManagerPrivateKey) + if err != nil || len(priv) != 32 { + t.Fatalf("the fixture private key is not 32 bytes") + } + blob, err := base64.StdEncoding.DecodeString(f.Sealed) + if err != nil { + t.Fatalf("the sealed value is not base64: %v", err) + } + + // The host's path: box.OpenAnonymous with the node's key pair. + var pubA, privA [32]byte + copy(pubA[:], pub) + copy(privA[:], priv) + out, ok := box.OpenAnonymous(nil, blob, &pubA, &privA) + if !ok { + t.Fatal("box.OpenAnonymous (the host's Unseal) FAILED to open the module's TS seal — " + + "the TypeScript crypto_box_seal has drifted from Go's box") + } + if string(out) != f.Plaintext { + t.Fatalf("opened to %q, expected %q", out, f.Plaintext) + } + + // And it is exactly what secrets.Seal produces: a value this package can round-trip is one the TS + // module could equally have produced, so the two are interchangeable at the seam. + roundTrip, err := Seal(f.ManagerPublicKey, []byte(f.Plaintext)) + if err != nil { + t.Fatal(err) + } + rtBlob, _ := base64.StdEncoding.DecodeString(roundTrip) + back, ok := box.OpenAnonymous(nil, rtBlob, &pubA, &privA) + if !ok || string(back) != f.Plaintext { + t.Fatal("secrets.Seal did not round-trip under box.OpenAnonymous") + } +} diff --git a/internal/secrets/testdata/module-sealedbox-fixture.json b/internal/secrets/testdata/module-sealedbox-fixture.json new file mode 100644 index 0000000..fdcae72 --- /dev/null +++ b/internal/secrets/testdata/module-sealedbox-fixture.json @@ -0,0 +1,7 @@ +{ + "_comment": "Produced by mesh-catalog anthropic-manager sealedbox.ts (crypto_box_seal). Proves that value the module seals to a node's public key opens under Go box.OpenAnonymous — the host's Unseal and mesh-control secrets.Seal/Open. Regenerate with the module's compiled seal().", + "managerPublicKey": "rJZ9OSnuCcU5MNi8iV0EK8c5nYN+Cx5A+q+miIIIoUc=", + "managerPrivateKey": "wGHx9hpbO1pyvLiw8oGwi31LBce3HscDiGhXpNU+wl4=", + "plaintext": "rt-a-refresh-token-only-the-manager-may-read", + "sealed": "yKcpcuBD68n84vFEUufVd1lFCng72BbtyT9/40NCVgjyldBa68pQSpiym0qRVthasb/K21u+HywAgk4saJDAABTpm6E3MeyATSdDoLTz/mNR2p0lcDKE2sSDEI8=" +}