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
80 lines
3.1 KiB
Go
80 lines
3.1 KiB
Go
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")
|
|
}
|
|
}
|