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