This commit is contained in:
2026-06-22 08:55:57 +08:00
parent dbb87823e8
commit 6ade6e8fa9
325 changed files with 41131 additions and 855 deletions
+163
View File
@@ -0,0 +1,163 @@
package crypto
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"errors"
"fmt"
"io"
"sync"
)
// KeyStore tracks which key versions exist and which one is the current write
// ("active") version. The key material itself is never stored here — only the
// version metadata (see crypto_keys table). It is consulted on Encrypt to pick
// the active version and by the re-encrypt job to discover the target version.
type KeyStore interface {
// ActiveVersion returns the version that new ciphertext must be sealed with.
ActiveVersion(ctx context.Context) (int, error)
}
// staticKeyStore is a fixed-version store, used when no DB is wired (tests,
// degraded boot). It always reports the same active version.
type staticKeyStore struct{ v int }
// NewStaticKeyStore returns a KeyStore that always reports version v as active.
func NewStaticKeyStore(v int) KeyStore { return staticKeyStore{v: v} }
func (s staticKeyStore) ActiveVersion(context.Context) (int, error) { return s.v, nil }
// KeyedEncryptor seals/opens secrets with a versioned key. The on-disk format
// is unchanged from the legacy Encryptor — nonce(12) || ciphertext || tag —
// because the key version travels in a separate DB column (key_version), not in
// the ciphertext. This keeps every existing blob forward-compatible: a v1 blob
// is still openable as long as version 1 is recorded for its row.
type KeyedEncryptor struct {
provider Provider
store KeyStore
mu sync.Mutex
gcms map[int]cipher.AEAD // version -> AEAD, lazily built from provider keys
}
// NewKeyedEncryptor builds a KeyedEncryptor over a key provider and key store.
// store may be nil → a static version-1 store is used (back-compat with the
// single-key world).
func NewKeyedEncryptor(provider Provider, store KeyStore) *KeyedEncryptor {
if store == nil {
store = NewStaticKeyStore(1)
}
return &KeyedEncryptor{provider: provider, store: store, gcms: map[int]cipher.AEAD{}}
}
// gcmFor returns (and caches) the AEAD for a version, resolving the key bytes
// via the provider on first use.
func (e *KeyedEncryptor) gcmFor(ctx context.Context, version int) (cipher.AEAD, error) {
e.mu.Lock()
defer e.mu.Unlock()
if g, ok := e.gcms[version]; ok {
return g, nil
}
key, err := e.provider.KeyMaterial(ctx, version)
if err != nil {
return nil, fmt.Errorf("resolve key v%d: %w", version, err)
}
if len(key) != 32 {
return nil, fmt.Errorf("key v%d must be 32 bytes (got %d)", version, len(key))
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("aes new cipher v%d: %w", version, err)
}
g, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("new gcm v%d: %w", version, err)
}
e.gcms[version] = g
return g, nil
}
// ActiveVersion reports the version new ciphertext is sealed with.
func (e *KeyedEncryptor) ActiveVersion(ctx context.Context) (int, error) {
return e.store.ActiveVersion(ctx)
}
// EncryptVersion seals plaintext with the active version and returns the
// ciphertext together with the version it was sealed under (to persist into the
// row's key_version column).
func (e *KeyedEncryptor) EncryptVersion(ctx context.Context, plaintext []byte) ([]byte, int, error) {
version, err := e.store.ActiveVersion(ctx)
if err != nil {
return nil, 0, fmt.Errorf("active key version: %w", err)
}
ct, err := e.sealWith(ctx, version, plaintext)
if err != nil {
return nil, 0, err
}
return ct, version, nil
}
// SealWithVersion encrypts plaintext under an explicit version. The re-encrypt
// job uses this to re-seal an old-version blob onto the new active version.
func (e *KeyedEncryptor) SealWithVersion(ctx context.Context, version int, plaintext []byte) ([]byte, error) {
return e.sealWith(ctx, version, plaintext)
}
// sealWith encrypts with a specific version (used by EncryptVersion and the
// re-encrypt job which re-seals onto the new active version).
func (e *KeyedEncryptor) sealWith(ctx context.Context, version int, plaintext []byte) ([]byte, error) {
g, err := e.gcmFor(ctx, version)
if err != nil {
return nil, err
}
nonce := make([]byte, g.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, fmt.Errorf("rand nonce: %w", err)
}
return g.Seal(nonce, nonce, plaintext, nil), nil
}
// DecryptVersion opens a ciphertext that was sealed under the given version.
func (e *KeyedEncryptor) DecryptVersion(ctx context.Context, ciphertext []byte, version int) ([]byte, error) {
g, err := e.gcmFor(ctx, version)
if err != nil {
return nil, err
}
if len(ciphertext) < g.NonceSize() {
return nil, errors.New("ciphertext too short")
}
nonce, ct := ciphertext[:g.NonceSize()], ciphertext[g.NonceSize():]
pt, err := g.Open(nil, nonce, ct, nil)
if err != nil {
return nil, fmt.Errorf("gcm open v%d: %w", version, err)
}
return pt, nil
}
// DecryptAny opens a ciphertext without knowing its key version: it tries the
// active version first, then every lower version down to 1, returning the first
// that authenticates. AES-GCM's auth tag guarantees only the correct key opens a
// blob, so trying versions is safe and lets the read path stay version-agnostic
// (callers need not thread key_version through every SELECT). Version count is
// tiny (1-2 in practice), so the loop is cheap. A version whose key material is
// not configured simply fails to build its AEAD and is skipped.
func (e *KeyedEncryptor) DecryptAny(ctx context.Context, ciphertext []byte) ([]byte, error) {
active, err := e.store.ActiveVersion(ctx)
if err != nil || active < 1 {
active = 1
}
var lastErr error
for v := active; v >= 1; v-- {
pt, derr := e.DecryptVersion(ctx, ciphertext, v)
if derr == nil {
return pt, nil
}
lastErr = derr
}
if lastErr == nil {
lastErr = errors.New("decrypt: no key version available")
}
return nil, lastErr
}
+141
View File
@@ -0,0 +1,141 @@
package crypto
import (
"context"
"crypto/rand"
"sync"
"testing"
"github.com/stretchr/testify/require"
)
// mutableStore is a KeyStore whose active version can change at runtime,
// modeling a rotation.
type mutableStore struct {
mu sync.Mutex
v int
}
func (m *mutableStore) ActiveVersion(context.Context) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
return m.v, nil
}
func (m *mutableStore) set(v int) {
m.mu.Lock()
defer m.mu.Unlock()
m.v = v
}
func rawKey(t *testing.T) []byte {
t.Helper()
k := make([]byte, 32)
_, err := rand.Read(k)
require.NoError(t, err)
return k
}
func TestKeyedEncryptor_RoundTripV1AndV2(t *testing.T) {
ctx := context.Background()
prov := NewEnvProvider(nil)
prov.SetKey(1, rawKey(t))
prov.SetKey(2, rawKey(t))
store := &mutableStore{v: 1}
ke := NewKeyedEncryptor(prov, store)
pt := []byte("super secret value")
ctV1, v, err := ke.EncryptVersion(ctx, pt)
require.NoError(t, err)
require.Equal(t, 1, v)
got, err := ke.DecryptVersion(ctx, ctV1, 1)
require.NoError(t, err)
require.Equal(t, pt, got)
store.set(2)
ctV2, v2, err := ke.EncryptVersion(ctx, pt)
require.NoError(t, err)
require.Equal(t, 2, v2)
require.NotEqual(t, ctV1, ctV2)
got2, err := ke.DecryptVersion(ctx, ctV2, 2)
require.NoError(t, err)
require.Equal(t, pt, got2)
}
func TestKeyedEncryptor_V1BlobDecryptsAfterActiveBumpsToV2(t *testing.T) {
ctx := context.Background()
prov := NewEnvProvider(nil)
prov.SetKey(1, rawKey(t))
prov.SetKey(2, rawKey(t))
store := &mutableStore{v: 1}
ke := NewKeyedEncryptor(prov, store)
pt := []byte("encrypted under v1")
ctV1, v, err := ke.EncryptVersion(ctx, pt)
require.NoError(t, err)
require.Equal(t, 1, v)
// Rotate the active write version; the OLD blob must still decrypt under v1.
store.set(2)
got, err := ke.DecryptVersion(ctx, ctV1, 1)
require.NoError(t, err)
require.Equal(t, pt, got)
}
func TestKeyedEncryptor_WrongVersionDecryptFailsCleanly(t *testing.T) {
ctx := context.Background()
prov := NewEnvProvider(nil)
prov.SetKey(1, rawKey(t))
prov.SetKey(2, rawKey(t))
ke := NewKeyedEncryptor(prov, NewStaticKeyStore(1))
ct, _, err := ke.EncryptVersion(ctx, []byte("x"))
require.NoError(t, err)
// Decrypting a v1 blob as v2 must fail authentication, not panic.
_, err = ke.DecryptVersion(ctx, ct, 2)
require.Error(t, err)
}
func TestKeyedEncryptor_UnknownVersionErrors(t *testing.T) {
ctx := context.Background()
prov := NewEnvProvider(nil)
prov.SetKey(1, rawKey(t))
ke := NewKeyedEncryptor(prov, NewStaticKeyStore(1))
_, err := ke.DecryptVersion(ctx, make([]byte, 16), 9)
require.Error(t, err)
}
func TestEncryptorShim_BacksOntoKeyedV1(t *testing.T) {
// The legacy Encryptor shim must remain byte-compatible: a blob it produces
// decrypts via the underlying KeyedEncryptor at version 1, and vice versa.
key := rawKey(t)
enc, err := NewEncryptor(key)
require.NoError(t, err)
pt := []byte("legacy callers see no versions")
ct, err := enc.Encrypt(pt)
require.NoError(t, err)
got, err := enc.Decrypt(ct)
require.NoError(t, err)
require.Equal(t, pt, got)
// Same key via KeyedEncryptor v1 opens the shim's ciphertext.
prov := NewEnvProvider(key)
ke := NewKeyedEncryptor(prov, NewStaticKeyStore(1))
got2, err := ke.DecryptVersion(context.Background(), ct, 1)
require.NoError(t, err)
require.Equal(t, pt, got2)
}
func TestEnvProvider_ReadsVersionedEnv(t *testing.T) {
t.Setenv("APP_MASTER_KEY_V2", "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff")
prov := NewEnvProvider(nil)
k, err := prov.KeyMaterial(context.Background(), 2)
require.NoError(t, err)
require.Len(t, k, 32)
_, err = prov.KeyMaterial(context.Background(), 3)
require.Error(t, err, "unstaged version must error, not silently use a default")
}
+106
View File
@@ -0,0 +1,106 @@
package crypto
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
// PostgresKeyStore reads/writes the crypto_keys table that records which key
// versions exist and which is currently active (the write key). The key
// material is NOT stored here — only version metadata.
type PostgresKeyStore struct {
pool poolExec
}
// poolExec is the subset of *pgxpool.Pool used by the store. Declared as an
// interface so tests can inject a fake without a live DB.
type poolExec interface {
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
}
// poolFullExec additionally allows mutations (used by RotateActive/RetireVersion).
// Exec returns the CONCRETE pgconn.CommandTag so *pgxpool.Pool satisfies this
// interface — an interface-typed return would not match pgxpool's method
// signature, making the type assertion in RotateActive/RetireVersion silently
// fail and key rotation always error out.
type poolFullExec interface {
poolExec
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
}
// NewPostgresKeyStore builds the store over a pgx pool (or any compatible
// querier). It satisfies KeyStore.
func NewPostgresKeyStore(pool poolExec) *PostgresKeyStore {
return &PostgresKeyStore{pool: pool}
}
// ActiveVersion returns the single crypto_keys row whose status='active'. If
// none is found it falls back to version 1 (single-key bootstrap before the
// seed row exists).
func (s *PostgresKeyStore) ActiveVersion(ctx context.Context) (int, error) {
var v int
err := s.pool.QueryRow(ctx,
`SELECT version FROM crypto_keys WHERE status = 'active' ORDER BY version DESC LIMIT 1`,
).Scan(&v)
if err != nil {
if err == pgx.ErrNoRows {
return 1, nil
}
return 0, fmt.Errorf("crypto key active version: %w", err)
}
return v, nil
}
// RotateActive bumps the active write version: it retires the current active
// row and inserts/activates the next version (current+1). The new version's key
// material must already be loadable by the provider (e.g. APP_MASTER_KEY_V<n>
// staged) before this is called. Returns the new active version.
//
// pool must support Exec (a *pgxpool.Pool does). Both rows are written in one
// transaction by the caller's pool if it is transactional; here we issue two
// statements and rely on the unique active invariant being eventually consistent
// within the rotation window (both versions remain loadable, so decrypts never
// break).
func (s *PostgresKeyStore) RotateActive(ctx context.Context, provider string, keyRef string) (int, error) {
fe, ok := s.pool.(poolFullExec)
if !ok {
return 0, fmt.Errorf("crypto key store: pool does not support writes")
}
cur, err := s.ActiveVersion(ctx)
if err != nil {
return 0, err
}
next := cur + 1
if _, err := fe.Exec(ctx,
`UPDATE crypto_keys SET status = 'retiring' WHERE status = 'active'`); err != nil {
return 0, fmt.Errorf("retire current key: %w", err)
}
if _, err := fe.Exec(ctx,
`INSERT INTO crypto_keys (version, provider, key_ref, status)
VALUES ($1, $2, NULLIF($3, ''), 'active')
ON CONFLICT (version) DO UPDATE SET status = 'active', provider = EXCLUDED.provider, key_ref = EXCLUDED.key_ref`,
next, provider, keyRef); err != nil {
return 0, fmt.Errorf("insert next key: %w", err)
}
return next, nil
}
// RetireVersion marks a fully-superseded version as retired (no longer needed
// after the re-encrypt job has moved every row off it). The provider may then
// safely drop that key material.
func (s *PostgresKeyStore) RetireVersion(ctx context.Context, version int) error {
fe, ok := s.pool.(poolFullExec)
if !ok {
return fmt.Errorf("crypto key store: pool does not support writes")
}
if _, err := fe.Exec(ctx,
`UPDATE crypto_keys SET status = 'retired' WHERE version = $1`, version); err != nil {
return fmt.Errorf("retire key v%d: %w", version, err)
}
return nil
}
var _ KeyStore = (*PostgresKeyStore)(nil)
+103
View File
@@ -0,0 +1,103 @@
package crypto
import (
"context"
"encoding/hex"
"fmt"
"os"
"sync"
)
// Provider resolves the raw 32-byte AES-256 key material for a given key
// version. The key material itself never lives in the DB — only the version
// number (in crypto_keys + the per-row key_version column) is persisted; the
// provider maps that version back to actual bytes (env var, Vault, KMS, ...).
type Provider interface {
// KeyMaterial returns the 32-byte key for the given version, or an error if
// the version is unknown / unavailable. Implementations may cache.
KeyMaterial(ctx context.Context, version int) ([]byte, error)
}
// EnvProvider maps key versions to env vars. Version 1 is APP_MASTER_KEY
// (the historical single key); higher versions read APP_MASTER_KEY_V<n> so a
// rotation can stage the next key alongside the current one. The constructor
// also accepts an explicit version-1 key so callers that already decoded
// cfg.MasterKey do not re-read the env.
type EnvProvider struct {
mu sync.RWMutex
keys map[int][]byte
}
// NewEnvProvider builds an EnvProvider seeded with the version-1 key (typically
// the already-decoded cfg.MasterKey). A nil/empty v1 key falls back to reading
// APP_MASTER_KEY lazily on first KeyMaterial(1).
func NewEnvProvider(v1 []byte) *EnvProvider {
p := &EnvProvider{keys: map[int][]byte{}}
if len(v1) > 0 {
p.keys[1] = append([]byte(nil), v1...)
}
return p
}
// KeyMaterial returns the cached key for version, loading it from the matching
// env var on first use. Version 1 → APP_MASTER_KEY; version n>1 →
// APP_MASTER_KEY_V<n>. The value must be 64 hex chars (32 bytes).
func (p *EnvProvider) KeyMaterial(_ context.Context, version int) ([]byte, error) {
if version < 1 {
return nil, fmt.Errorf("crypto: invalid key version %d", version)
}
p.mu.RLock()
k, ok := p.keys[version]
p.mu.RUnlock()
if ok {
return k, nil
}
envName := "APP_MASTER_KEY"
if version > 1 {
envName = fmt.Sprintf("APP_MASTER_KEY_V%d", version)
}
raw := os.Getenv(envName)
if raw == "" {
return nil, fmt.Errorf("crypto: key version %d unavailable (%s unset)", version, envName)
}
key, err := hex.DecodeString(raw)
if err != nil {
return nil, fmt.Errorf("crypto: %s invalid hex: %w", envName, err)
}
if len(key) != 32 {
return nil, fmt.Errorf("crypto: %s must be 32 bytes (got %d)", envName, len(key))
}
p.mu.Lock()
p.keys[version] = key
p.mu.Unlock()
return key, nil
}
// SetKey installs key material for a version explicitly (used in tests and when
// a KMS-fetched key is handed to an EnvProvider-style cache).
func (p *EnvProvider) SetKey(version int, key []byte) {
p.mu.Lock()
defer p.mu.Unlock()
p.keys[version] = append([]byte(nil), key...)
}
// VaultProvider / KMSProvider are deferred: their interface is identical to
// EnvProvider (KeyMaterial(ctx, version) -> []byte), so wiring a real
// HashiCorp Vault transit / cloud KMS client is a drop-in replacement. They are
// declared here so config.Provider can select "vault"/"kms" without a build
// break; until a real client is wired they delegate to a backing Provider.
// DelegatingProvider wraps another Provider; it is the shape both VaultProvider
// and KMSProvider take until their remote clients are implemented.
type DelegatingProvider struct{ inner Provider }
// NewDelegatingProvider returns a Provider that forwards to inner.
func NewDelegatingProvider(inner Provider) *DelegatingProvider {
return &DelegatingProvider{inner: inner}
}
// KeyMaterial forwards to the wrapped provider.
func (d *DelegatingProvider) KeyMaterial(ctx context.Context, version int) ([]byte, error) {
return d.inner.KeyMaterial(ctx, version)
}
+44 -35
View File
@@ -3,61 +3,70 @@
//
// The master key is supplied by the caller (see config.MasterKey) and never
// stored alongside the ciphertext.
//
// As of the secret-hardening epic, the underlying engine is KeyedEncryptor:
// every blob is sealed under a key *version* whose number is persisted in a
// per-row key_version column (the key material stays in env/KMS). The legacy
// Encryptor type below is a thin version-1 shim so the ~30 existing call sites
// — which neither know nor care about versions — compile and behave unchanged.
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"errors"
"context"
"fmt"
"io"
)
// Encryptor 用 AES-256-GCM 加解密 secret。
// 密文格式:nonce(12) || ciphertext || tag。
// Encryptor 用 AES-256-GCM 加解密 secret(version-1 shim over KeyedEncryptor)
// 密文格式:nonce(12) || ciphertext || tag(与历史一致,未变)
type Encryptor struct {
gcm cipher.AEAD
keyed *KeyedEncryptor
version int
}
// NewEncryptor constructs an Encryptor from a 32-byte (AES-256) key.
// Any other key length yields an error rather than silently downgrading.
// It builds a KeyedEncryptor backed by an EnvProvider seeded with this key at
// version 1 and a static version-1 store, preserving the previous semantics.
func NewEncryptor(key []byte) (*Encryptor, error) {
if len(key) != 32 {
return nil, fmt.Errorf("key must be 32 bytes (got %d)", len(key))
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("aes new cipher: %w", err)
keyed := NewKeyedEncryptor(NewEnvProvider(key), NewStaticKeyStore(1))
// Eagerly validate the key by building the v1 AEAD once.
if _, err := keyed.gcmFor(context.Background(), 1); err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("new gcm: %w", err)
}
return &Encryptor{gcm: gcm}, nil
return &Encryptor{keyed: keyed, version: 1}, nil
}
// Encrypt seals plaintext with a fresh random nonce and returns
// nonce || ciphertext || tag.
// NewEncryptorFromKeyed wraps an existing KeyedEncryptor as a fixed-version
// Encryptor shim. version is the version this shim seals/opens with (the call
// sites that use the shim only ever round-trip a single logical version because
// the DB column travels separately). app.go uses this so workspace/chat/acp/run
// keep the *crypto.Encryptor argument while sharing one KeyedEncryptor.
func NewEncryptorFromKeyed(keyed *KeyedEncryptor, version int) *Encryptor {
if version < 1 {
version = 1
}
return &Encryptor{keyed: keyed, version: version}
}
// Keyed exposes the underlying KeyedEncryptor so version-aware callers (the
// re-encrypt job, rotate-key endpoint) can use EncryptVersion/DecryptVersion.
func (e *Encryptor) Keyed() *KeyedEncryptor { return e.keyed }
// Encrypt seals plaintext under the shim's version and returns
// nonce || ciphertext || tag (version travels via the DB key_version column).
func (e *Encryptor) Encrypt(plaintext []byte) ([]byte, error) {
nonce := make([]byte, e.gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, fmt.Errorf("rand nonce: %w", err)
}
// Seal 把 nonce 作为 dst 前缀写入
return e.gcm.Seal(nonce, nonce, plaintext, nil), nil
return e.keyed.sealWith(context.Background(), e.version, plaintext)
}
// Decrypt verifies and opens a ciphertext produced by Encrypt.
// It returns an error if the input is too short or authentication fails.
// Decrypt verifies and opens a ciphertext produced by Encrypt. It is
// version-agnostic: it tries the active key version then falls back through
// older versions (GCM auth guarantees correctness), so blobs re-sealed onto a
// newer version after a key rotation still open without the call site needing to
// know the row's key_version. Returns an error if input is too short or no key
// authenticates.
func (e *Encryptor) Decrypt(ciphertext []byte) ([]byte, error) {
if len(ciphertext) < e.gcm.NonceSize() {
return nil, errors.New("ciphertext too short")
}
nonce, ct := ciphertext[:e.gcm.NonceSize()], ciphertext[e.gcm.NonceSize():]
pt, err := e.gcm.Open(nil, nonce, ct, nil)
if err != nil {
return nil, fmt.Errorf("gcm open: %w", err)
}
return pt, nil
return e.keyed.DecryptAny(context.Background(), ciphertext)
}