model-access: refreshable-grant — manager holds the refresh token, control plane never reads it #15

Merged
jschoubben merged 3 commits from feat/model-access-submit into main 2026-09-07 00:48:49 +00:00
Owner

The control-plane side of the vendor-agnostic model-access refreshable-grant (ADR 0050). A manager node is set for a licence; the operator's refresh token is sealed to that node's public key and stored as ciphertext the control plane cannot open. set-grant accepts the sealed box; the host unseals it on delivery (the ordinary sealed-credential path) and mounts the cleartext only at the manager module's secret path. submit-refresh takes back only the rotated access token (sealed to each consumer holder) plus an opaque re-sealed refresh box — the refresh token is never read in the clear by the control plane, and no node but the manager holds it.

Adapter abstraction keyed by licence.vendor with a static-key and a refreshable-grant shape; KeyFor disambiguates manager (access + refresh) from consumer (access only). Migrations 0001 (edited) + 0003 (incremental). The TS(tweetnacl-sealedbox-js)→Go(box.OpenAnonymous) cross-check fixture is regenerated and TestModuleSealedBoxOpensInGo proves interop.

Proven end to end by the mesh-lab anthropic-bed suite (green).

The control-plane side of the vendor-agnostic model-access refreshable-grant (ADR 0050). A manager node is set for a licence; the operator's refresh token is sealed to that node's public key and stored as ciphertext the control plane cannot open. `set-grant` accepts the sealed box; the host unseals it on delivery (the ordinary sealed-credential path) and mounts the cleartext only at the manager module's secret path. `submit-refresh` takes back only the rotated access token (sealed to each consumer holder) plus an opaque re-sealed refresh box — the refresh token is never read in the clear by the control plane, and no node but the manager holds it. Adapter abstraction keyed by `licence.vendor` with a static-key and a refreshable-grant shape; `KeyFor` disambiguates manager (access + refresh) from consumer (access only). Migrations 0001 (edited) + 0003 (incremental). The TS(tweetnacl-sealedbox-js)→Go(box.OpenAnonymous) cross-check fixture is regenerated and `TestModuleSealedBoxOpensInGo` proves interop. Proven end to end by the mesh-lab `anthropic-bed` suite (green).
jschoubben added 3 commits 2026-09-07 00:48:25 +00:00
Phase C of model-access (ADR 0050). Refresh above calls an in-process
VendorRefresher, which would open the at-rest envelope inside the control
plane's own process. Anthropic must not: its refresh runs on the manager
node. So add SubmitRefresh, the companion that publishes a refresh a
manager node already performed -- it is given only the new access token in
the clear (sealed per holder, as any accepted key) and an opaque re-sealed
refresh envelope (stored unopened). The refresh token in the clear never
crosses this boundary. The reseal-and-publish half is extracted and shared
with Refresh, so the sealing logic is one implementation.

CLI: licence grant (print the opaque envelope), set-grant (store a
module-produced envelope -- adoption), submit-refresh (access token +
optional rotated envelope). Tests defend that the manager alone opens the
refresh token and the control plane never holds it in the clear.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
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
The manager module's seal now runs over the audited tweetnacl-sealedbox-js (mesh-catalog
anthropic-manager) rather than a hand-transcribed NaCl. Regenerate the fixture's sealed value
from that new seal() over the same node key pair and plaintext, so the fixture is the new
library's output. crypto_box_seal is randomised, so the blob differs; the wire format does
not. TestModuleSealedBoxOpensInGo still opens it under box.OpenAnonymous and recovers the
plaintext, proving TS(tweetnacl-sealedbox-js) to Go interop.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
jschoubben merged commit d9c4818e6d into main 2026-09-07 00:48:49 +00:00
jschoubben deleted branch feat/model-access-submit 2026-09-07 00:48:50 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: novox/mesh-controller#15