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

104 lines
3.6 KiB
Go

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