Files
zooqueen 54f3bb503c store: seal a secret FOR someone, so the process that enrols one cannot read it
Seal and Open both take the same master key, so writing a secret and
reading every secret are one capability wearing two names. Wherever that
key lives — an environment variable among two hundred and sixty-nine
others, say — is where the whole store effectively lives too, and a
process that only ever needs to ENROL credentials holds the means to
read them all.

SealTo splits the capability in half. The DEK is encapsulated to a
recipient's public key rather than wrapped under a shared one, so
enrolling needs `age1pq1…`, which grants nothing and can sit anywhere,
and opening needs the identity, which lives in one place and never has
to leave it.

The payload codec does not change: same AEAD, same AAD binding
path/name/env, same bytes. Only the DEK's custody differs — one payload
format, two ways to hold the key to it — which is why a row written
either way reads back through the same three lines.

The KEM is X-Wing, hybrid X25519 + ML-KEM-768 over age's HPKE, which the
struct's own documentation has described as the intent since it was
written. Composing it rather than building it means no new cryptography
and no new dependency; luxfi/crypto was already here.

Open now refuses a recipient-sealed secret by name instead of trying the
master and failing as an authentication error two frames down, and says
which identity does open it.

The tests state the properties rather than the mechanics: the public
half opens nothing, another identity opens nothing, the master opens
nothing, and a record moved to another coordinate stops opening.
2026-08-20 11:27:02 -07:00

169 lines
4.9 KiB
Go

package store
// AES-256-GCM Seal/Open helpers wrapping the generic SecretStore.
//
// The SHARED-KEY envelope: one master key (32 bytes, unsealed at boot) wraps
// every Secret's DEK with AES-256-GCM, and the DEK seals the plaintext. Whoever
// holds that key opens every secret it ever wrapped, so the master is worth
// exactly as much as the whole store and has to live wherever the store's
// readers live.
//
// recipient.go is the other half of the pair, and the one this was always
// heading for: SealTo encapsulates the DEK to a recipient's PUBLIC key, so
// enrolling a credential and reading one become different capabilities and only
// the second needs a secret. New secrets belong there. This stays for the rows
// already written under it.
//
// Plaintext never leaves memory. The caller is responsible for zeroing
// the returned byte slice when done.
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"errors"
"fmt"
"time"
)
// envelopeVersion is the on-disk format version. Increment for breaking changes.
const envelopeVersion byte = 0x01
// ErrBadKey is returned when the master key is the wrong size.
var ErrBadKey = errors.New("crypto: master key must be 32 bytes")
// ErrBadEnvelope is returned when ciphertext layout is invalid.
var ErrBadEnvelope = errors.New("crypto: invalid envelope")
// Seal encrypts plaintext under a fresh per-secret DEK, then wraps the DEK
// under the master key. Returns a Secret ready to Put into the store.
//
// Envelope layout for Ciphertext:
//
// version(1) || nonce(12) || ciphertext_with_tag(variable)
// AAD = path + "/" + name + "/" + env
//
// WrappedDEK is AES-GCM(DEK, masterKey), same envelope shape, AAD=name.
func Seal(masterKey []byte, path, name, env string, plaintext []byte) (*Secret, error) {
if len(masterKey) != 32 {
return nil, ErrBadKey
}
// Fresh 256-bit DEK per secret.
dek := make([]byte, 32)
if _, err := rand.Read(dek); err != nil {
return nil, fmt.Errorf("crypto: rand dek: %w", err)
}
// Seal plaintext under DEK with AAD binding path/name/env.
aad := []byte(path + "/" + name + "/" + env)
ct, err := aeadSeal(dek, aad, plaintext)
if err != nil {
return nil, fmt.Errorf("crypto: seal plaintext: %w", err)
}
// Wrap DEK under master key, AAD binds the secret name (prevents swap).
wrap, err := aeadSeal(masterKey, []byte(name), dek)
if err != nil {
return nil, fmt.Errorf("crypto: wrap dek: %w", err)
}
// Zero the DEK.
for i := range dek {
dek[i] = 0
}
now := time.Now().UTC()
return &Secret{
Name: name,
Path: path,
Env: env,
Ciphertext: ct,
WrappedDEK: wrap,
Scheme: ModeStandard,
CreatedAt: now,
UpdatedAt: now,
}, nil
}
// Open inverts Seal. Returns plaintext on success.
// The caller must zero the returned slice after use.
func Open(masterKey []byte, secret *Secret) ([]byte, error) {
if len(masterKey) != 32 {
return nil, ErrBadKey
}
if secret == nil {
return nil, ErrBadEnvelope
}
// A recipient-sealed secret has no master to try. Saying so HERE is the
// difference between "no key held here will ever open this" and an
// authentication failure two frames down that reads as a damaged record. It
// is also what makes the split legible: a process holding the master learns
// from this exactly one thing, which is who to go and ask.
if secret.Scheme == ModeRecipient {
return nil, fmt.Errorf("crypto: %q is sealed to recipient %s and opens only with that identity", secret.Name, secret.KeyHandle)
}
dek, err := aeadOpen(masterKey, []byte(secret.Name), secret.WrappedDEK)
if err != nil {
return nil, fmt.Errorf("crypto: unwrap dek: %w", err)
}
defer func() {
for i := range dek {
dek[i] = 0
}
}()
aad := []byte(secret.Path + "/" + secret.Name + "/" + secret.Env)
pt, err := aeadOpen(dek, aad, secret.Ciphertext)
if err != nil {
return nil, fmt.Errorf("crypto: open plaintext: %w", err)
}
return pt, nil
}
// aeadSeal produces: version(1) || nonce(12) || ct||tag
func aeadSeal(key, aad, plaintext []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
ct := gcm.Seal(nil, nonce, plaintext, aad)
out := make([]byte, 0, 1+len(nonce)+len(ct))
out = append(out, envelopeVersion)
out = append(out, nonce...)
out = append(out, ct...)
return out, nil
}
// aeadOpen parses the envelope and returns plaintext.
func aeadOpen(key, aad, envelope []byte) ([]byte, error) {
if len(envelope) < 1+12+16 {
return nil, ErrBadEnvelope
}
if envelope[0] != envelopeVersion {
return nil, fmt.Errorf("crypto: unsupported envelope version 0x%02x", envelope[0])
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
ns := gcm.NonceSize()
nonce := envelope[1 : 1+ns]
ct := envelope[1+ns:]
return gcm.Open(nil, nonce, ct, aad)
}