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
This commit is contained in:
2026-09-07 01:55:08 +02:00
parent 8e0c22fc2e
commit 33fd28ffa6
15 changed files with 563 additions and 593 deletions
+21 -17
View File
@@ -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