Files
LambdaMail/services/protocols/internal/infrastructure/postgres/dkim_repository.go
T
Lucas RayanandClaude Opus 5 30014f0f41 #21: fix the DKIM, onboarding and console defects found reviewing the new work
DKIM rotation broke the domain it was run on. It retired the live key and
inserted a placeholder as ACTIVE - private_key_enc was the literal string
"dummy-encrypted-key" - so FindActiveKeys then failed for the whole
domain and outbound mail stopped being signed, with the real key already
gone. Reproduced against a database before changing anything.

The root cause runs deeper: the auth service derives its encryption key
with a plain SHA-256 of the master key while the protocols service uses
HKDF with its own info string, so nothing the auth service seals can ever
be opened by the signer. Rotation therefore moves to the protocols
service, which owns that vault and the real key generator. It retires the
previous key with a seven-day overlap in the same transaction rather than
deleting it, because mail already sent under it is still being verified.

Domain onboarding had three defects in one function. It built the alias
destination by interpolating the domain name into the SQL text, which was
injectable; it reused one placeholder for a CITEXT and a VARCHAR column,
which made the statement fail and happened to hide the injection; and it
ignored its scope argument entirely, so any DOMAIN_ADMIN could create
domains. It also provisioned postmaster, abuse and security, missing the
dmarc and tlsrpt aliases that the published records point at - PLAN.md
section 7.4b is explicit that reports bounce without them.

The TLS panel returned fixed values: acme_status "VALID" and 72 days to
expiry, whatever the process actually held. It now reads the certificate
watcher, and a compile-time assertion keeps the wiring from silently
regressing into "disabled". The audit log and the readiness scorecard were
fixtures too, and their field names did not match the API they now read.

Two tests failed and a third asserted a fabricated constant: one depended
on test order through a shared fixture that had a second factor enrolled,
one used a fixed domain name and so passed once per database, and one
expected "records: 13" from a function that never touches DNS. All three
are fixed, and the suite now passes twice in a row against one database.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 13:33:33 -03:00

211 lines
6.9 KiB
Go

package postgres
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/infrastructure/vault"
)
// DkimRepository stores DKIM key pairs with the private half sealed. The
// vault lives here rather than in the application layer so no use case ever
// handles ciphertext (PLAN.md section 9).
type DkimRepository struct {
pool *pgxpool.Pool
vault *vault.SecretVault
}
func NewDkimRepository(pool *pgxpool.Pool, v *vault.SecretVault) *DkimRepository {
return &DkimRepository{pool: pool, vault: v}
}
func (r *DkimRepository) FindActiveKeys(ctx context.Context, domainName string) ([]port.DkimSigningKey, error) {
rows, err := r.pool.Query(ctx, `
SELECT k.selector, k.algorithm, k.private_key_enc, k.private_key_nonce, k.key_version
FROM dkim_keys k
JOIN domains d ON d.id = k.domain_id
WHERE d.name = $1 AND d.is_active AND k.status = 'ACTIVE'
ORDER BY k.algorithm
`, domainName)
if err != nil {
return nil, fmt.Errorf("query active dkim keys for %s: %w", domainName, err)
}
defer rows.Close()
keys := make([]port.DkimSigningKey, 0, 2)
for rows.Next() {
var (
selector, algorithm string
enc, nonce []byte
keyVersion int
)
if err := rows.Scan(&selector, &algorithm, &enc, &nonce, &keyVersion); err != nil {
return nil, fmt.Errorf("scan dkim key: %w", err)
}
privateKey, err := r.vault.Open(enc, nonce, keyVersion)
if err != nil {
return nil, fmt.Errorf("open dkim key %s for %s: %w", selector, domainName, err)
}
keys = append(keys, port.DkimSigningKey{
DomainName: domainName,
Selector: selector,
Algorithm: algorithm,
PrivateKeyPEM: privateKey,
})
}
return keys, rows.Err()
}
// FindPublicKey returns the published half of an active key, which the DNS
// reconciler needs to build the "p=" tag. It returns an empty string when the
// domain has no active key of that algorithm.
func (r *DkimRepository) FindPublicKey(ctx context.Context, domainName, algorithm string) (string, error) {
var publicKey string
err := r.pool.QueryRow(ctx, `
SELECT k.public_key
FROM dkim_keys k
JOIN domains d ON d.id = k.domain_id
WHERE d.name = $1 AND k.algorithm = $2 AND k.status = 'ACTIVE'
`, domainName, algorithm).Scan(&publicKey)
if errors.Is(err, pgx.ErrNoRows) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("query public dkim key for %s: %w", domainName, err)
}
return publicKey, nil
}
// Insert stores a new key pair, sealing the private half. It is a no-op when
// the domain already has an active key of that algorithm, which keeps
// provisioning idempotent and upholds the "one ACTIVE key per (domain,
// algorithm)" invariant without racing the unique partial index.
func (r *DkimRepository) Insert(ctx context.Context, domainName, selector, algorithm string, privateKeyPEM []byte, publicKey string) error {
ciphertext, nonce, err := r.vault.Seal(privateKeyPEM)
if err != nil {
return fmt.Errorf("seal dkim private key: %w", err)
}
_, err = r.pool.Exec(ctx, `
INSERT INTO dkim_keys (
domain_id, selector, algorithm, private_key_enc, private_key_nonce,
key_version, public_key, status, activated_at
)
SELECT d.id, $2, $3::varchar, $4, $5, $6, $7, 'ACTIVE', NOW()
FROM domains d
WHERE d.name = $1
AND NOT EXISTS (
SELECT 1 FROM dkim_keys existing
WHERE existing.domain_id = d.id
-- Cast explicitly: without it Postgres infers this parameter
-- as text here and as varchar in the insert list, and refuses
-- the statement.
AND existing.algorithm = $3::varchar
AND existing.status = 'ACTIVE'
)
`, domainName, selector, algorithm, ciphertext, nonce, vault.CurrentKeyVersion, publicKey)
if err != nil {
return fmt.Errorf("insert dkim key for %s: %w", domainName, err)
}
return nil
}
// Ensure port interface compliance
var _ port.DkimKeyRepository = (*DkimRepository)(nil)
// DkimKeyInfo describes a key for the admin console. The private half is never
// part of this: it exists only to be used, not to be looked at.
type DkimKeyInfo struct {
ID string `json:"id"`
DomainName string `json:"domain"`
Selector string `json:"selector"`
Algorithm string `json:"algorithm"`
PublicKey string `json:"public_key"`
Status string `json:"status"`
ActivatedAt *time.Time `json:"activated_at"`
RetireAfter *time.Time `json:"retire_after"`
}
// ListKeys returns every key for a domain, newest first.
func (r *DkimRepository) ListKeys(ctx context.Context, domainName string) ([]DkimKeyInfo, error) {
rows, err := r.pool.Query(ctx, `
SELECT k.id::text, d.name, k.selector, k.algorithm, k.public_key, k.status,
k.activated_at, k.retire_after
FROM dkim_keys k JOIN domains d ON d.id = k.domain_id
WHERE d.name = $1
ORDER BY k.created_at DESC
`, domainName)
if err != nil {
return nil, fmt.Errorf("list dkim keys for %s: %w", domainName, err)
}
defer rows.Close()
keys := []DkimKeyInfo{}
for rows.Next() {
var k DkimKeyInfo
if err := rows.Scan(&k.ID, &k.DomainName, &k.Selector, &k.Algorithm,
&k.PublicKey, &k.Status, &k.ActivatedAt, &k.RetireAfter); err != nil {
return nil, err
}
keys = append(keys, k)
}
return keys, rows.Err()
}
// Rotate replaces the active key for one algorithm.
//
// The old key is retired rather than deleted, and only after the new one is in
// place, both inside one transaction. Mail already in flight was signed with
// the old selector and receivers may still be resolving it, so removing it
// immediately would fail DKIM for messages that were perfectly valid when they
// were sent (PLAN.md section 5).
func (r *DkimRepository) Rotate(
ctx context.Context, domainName, selector, algorithm string,
privateKeyPEM []byte, publicKey string, overlap time.Duration,
) error {
ciphertext, nonce, err := r.vault.Seal(privateKeyPEM)
if err != nil {
return fmt.Errorf("seal dkim private key: %w", err)
}
tx, err := r.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
var domainID string
if err := tx.QueryRow(ctx, `SELECT id::text FROM domains WHERE name = $1`, domainName).Scan(&domainID); err != nil {
return fmt.Errorf("find domain %s: %w", domainName, err)
}
if _, err := tx.Exec(ctx, `
UPDATE dkim_keys SET status = 'RETIRING', retire_after = NOW() + $3::interval
WHERE domain_id = $1 AND algorithm = $2::varchar AND status = 'ACTIVE'
`, domainID, algorithm, overlap.String()); err != nil {
return fmt.Errorf("retire current dkim key: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO dkim_keys (
domain_id, selector, algorithm, private_key_enc, private_key_nonce,
key_version, public_key, status, activated_at
) VALUES ($1, $2, $3::varchar, $4, $5, $6, $7, 'ACTIVE', NOW())
`, domainID, selector, algorithm, ciphertext, nonce, vault.CurrentKeyVersion, publicKey); err != nil {
return fmt.Errorf("insert rotated dkim key: %w", err)
}
return tx.Commit(ctx)
}