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 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. 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) }