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
-160
View File
@@ -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
}
-106
View File
@@ -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")
}
}
+79
View File
@@ -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")
}
}
@@ -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="
}