You've already forked agentic-coding-workflow
164 lines
5.7 KiB
Go
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
|
|
}
|