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
+43
View File
@@ -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,