Files
2026-06-22 08:55:57 +08:00

164 lines
5.7 KiB
Go

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
}