You've already forked agentic-coding-workflow
bugfix
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user