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
107 lines
3.2 KiB
Go
107 lines
3.2 KiB
Go
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")
|
|
}
|
|
}
|