model access B: refreshable-grant machinery — manager, at-rest refresh token, refresh flow
The ADR 0050 carve-out, built generic and vendor-neutral. A refreshable-grant licence records one manager node; that node holds the refresh token encrypted at rest, access tokens are still sealed per holder, and the refresh token is never in a holder's delivery. Bounded on the three stated axes: refreshable-grant vendors only, the refresh token only, the manager node only. Anthropic's actual OAuth refresh stays a Phase-C plug-in behind a clean seam. - New at-rest crypto (secrets.SealAtRest/OpenAtRest): envelope encryption distinct from the per-holder anonymous-box seal. The refresh token is under a symmetric data key (secretbox); the data key is wrapped to the manager node's public sealing key. The database alone holds ciphertext and a wrapped key with no private half to open either — only the manager node reads it back. - Refreshable-grant adapter dispatch: anthropic is now refreshable-grant, anthropic-api-key the static-key second case. The adapter implements the Refresher seam by delegating to an injected VendorRefresher (the Phase-C plug, none shipped). static-key is untouched. The type assertion to Refresher is what gates the carve-out to refreshable-grant vendors. - Refresh lease/rotate/publish flow (Licences.Refresh): a transaction-scoped advisory lock is the single-refresher lease; the new access token comes from the vendor refresh, is sealed per holder (secrets.Seal, as Accept does) and delivered on the next push — doc 13's reseal-and-publish half, all-or-nothing. The refresh token stays put, re-encrypted at rest only if the vendor rotated it. - Manager and refresh_grant schema: consolidated into migrations/0001 and carried by a new incremental 0003 (the dual-write rule). - 17 new tests, including the four security checks: KeyFor never carries the refresh token, a static key has no manager and cannot be refreshed, the at-rest token needs the manager's key, and a refresh delivers a new sealed access token. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user