From 9b5123d615063d8108407c8277c60b50bf97893c Mon Sep 17 00:00:00 2001 From: Samuel Carson Date: Wed, 8 Apr 2026 00:21:19 -0500 Subject: [PATCH] feat(crypto): bind GCM ciphertext to entity context via AAD Add Additional Authenticated Data (AAD) to AES-256-GCM encrypt/decrypt, preventing ciphertext relocation between database rows. SSO client secrets are bound to org_id, MFA TOTP secrets to user_id, and the doctor encryption sentinel to a fixed label. Also adds dev/specs/sso-secret-storage.md documenting the full encryption architecture for external sharing. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/cvert-ops/rotate.go | 13 +-- cmd/cvert-ops/rotate_test.go | 8 +- dev/specs/sso-secret-storage.md | 93 +++++++++++++++++++++ internal/api/auth_mfa.go | 8 +- internal/api/auth_mfa_test.go | 2 +- internal/api/oauth_oidc.go | 2 +- internal/api/sso.go | 4 +- internal/crypto/aes.go | 24 +++--- internal/crypto/aes_test.go | 140 +++++++++++++++++++++++++++----- internal/doctor/checks.go | 8 +- 10 files changed, 248 insertions(+), 54 deletions(-) create mode 100644 dev/specs/sso-secret-storage.md diff --git a/cmd/cvert-ops/rotate.go b/cmd/cvert-ops/rotate.go index 6eefecbf..e1153858 100644 --- a/cmd/cvert-ops/rotate.go +++ b/cmd/cvert-ops/rotate.go @@ -100,21 +100,22 @@ func rotateEncryptionKeys(ctx context.Context, pool *pgxpool.Pool, currentKey, p return 0, fmt.Errorf("set bypass_rls: %w", err) } - rows, err := tx.Query(ctx, "SELECT id, client_secret_enc FROM sso_connections") + rows, err := tx.Query(ctx, "SELECT id, org_id, client_secret_enc FROM sso_connections") if err != nil { return 0, fmt.Errorf("query sso_connections: %w", err) } defer rows.Close() type pending struct { - id string - enc []byte + id string + orgID [16]byte + enc []byte } var updates []pending for rows.Next() { var p pending - if err := rows.Scan(&p.id, &p.enc); err != nil { + if err := rows.Scan(&p.id, &p.orgID, &p.enc); err != nil { return 0, fmt.Errorf("scan row: %w", err) } updates = append(updates, p) @@ -125,12 +126,12 @@ func rotateEncryptionKeys(ctx context.Context, pool *pgxpool.Pool, currentKey, p count := 0 for _, u := range updates { - plaintext, err := crypto.DecryptWithFallback(currentKey, previousKey, u.enc) + plaintext, err := crypto.DecryptWithFallback(currentKey, previousKey, u.enc, u.orgID[:]) if err != nil { return 0, fmt.Errorf("decrypt row %s: %w", u.id, err) } - newEnc, err := crypto.Encrypt(currentKey, plaintext) + newEnc, err := crypto.Encrypt(currentKey, plaintext, u.orgID[:]) if err != nil { return 0, fmt.Errorf("re-encrypt row %s: %w", u.id, err) } diff --git a/cmd/cvert-ops/rotate_test.go b/cmd/cvert-ops/rotate_test.go index fc9acd0d..b851a37c 100644 --- a/cmd/cvert-ops/rotate_test.go +++ b/cmd/cvert-ops/rotate_test.go @@ -29,9 +29,9 @@ func TestRotateEncryptionKey_ReEncryptsAllValues(t *testing.T) { } orgID := org.ID - // Encrypt a secret with the old key. + // Encrypt a secret with the old key, bound to the org. secret := []byte("my-client-secret") - enc, err := crypto.Encrypt(oldKey, secret) + enc, err := crypto.Encrypt(oldKey, secret, orgID[:]) if err != nil { t.Fatalf("encrypt with old key: %v", err) } @@ -64,7 +64,7 @@ func TestRotateEncryptionKey_ReEncryptsAllValues(t *testing.T) { t.Fatalf("read re-encrypted value: %v", err) } - plaintext, err := crypto.Decrypt(newKey, reEncrypted) + plaintext, err := crypto.Decrypt(newKey, reEncrypted, orgID[:]) if err != nil { t.Fatalf("decrypt with new key failed: %v", err) } @@ -73,7 +73,7 @@ func TestRotateEncryptionKey_ReEncryptsAllValues(t *testing.T) { } // Verify old key alone no longer works. - _, err = crypto.Decrypt(oldKey, reEncrypted) + _, err = crypto.Decrypt(oldKey, reEncrypted, orgID[:]) if err == nil { t.Error("decrypt with old key should fail on re-encrypted data, but succeeded") } diff --git a/dev/specs/sso-secret-storage.md b/dev/specs/sso-secret-storage.md new file mode 100644 index 00000000..06692346 --- /dev/null +++ b/dev/specs/sso-secret-storage.md @@ -0,0 +1,93 @@ +# SSO Secret Storage Architecture + +This document describes how CVErt Ops stores and manages user-provided secrets (specifically, OAuth/OIDC client secrets for enterprise SSO connections) in the production/SaaS configuration. + +## What's Encrypted + +The **only** user-input secret encrypted at rest in the database is `sso_connections.client_secret_enc` — the OIDC client secret that tenants provide when configuring enterprise SSO. It is stored as `BYTEA` in Postgres (migration `000028_sso_connections.up.sql`). + +## Encryption Scheme + +**AES-256-GCM** with random 12-byte nonces, implemented in `internal/crypto/aes.go`. + +- **Ciphertext format:** `nonce (12 bytes) || ciphertext + GCM authentication tag` +- **Nonce source:** `crypto/rand.Reader` (OS CSPRNG) +- **Library:** Go stdlib `crypto/aes` and `crypto/cipher` — no external crypto dependencies + +AES-256-GCM provides both confidentiality and integrity (authenticated encryption). An attacker who obtains a database dump cannot read or tamper with the client secrets without also possessing the encryption key. + +## Key Sourcing + +The encryption key is a raw 32-byte value provided as 64 hex characters via: + +1. **Startup:** The `SSO_ENCRYPTION_KEY` environment variable, parsed by `internal/config/reloadable.go` +2. **Hot-reload:** A secrets file (one `KEY=VALUE` per line) can be reloaded at runtime via `SIGHUP` signal or the admin API reload endpoint. The key is swapped atomically using `atomic.Pointer` in `config.Holder`, so in-flight requests are never disrupted + +The API handler reads the active key via `srv.ssoEncryptionKey()` in `internal/api/sso.go`, which prefers the hot-reloadable config, falling back to the startup config value. + +## Key Rotation + +Key rotation uses a **dual-key** strategy with zero downtime: + +1. **Operator** generates a new 32-byte key (`openssl rand -hex 32`) +2. **Operator** moves the current `SSO_ENCRYPTION_KEY` value to `SSO_ENCRYPTION_KEY_PREVIOUS` and sets the new key as `SSO_ENCRYPTION_KEY` in the secrets file +3. **Operator** reloads config (SIGHUP or admin API) +4. **During the transition window**, all decryption uses `crypto.DecryptWithFallback()` — tries the current key first, then falls back to the previous key on GCM authentication failure. Structural errors (truncated ciphertext, invalid key length) fail fast without attempting fallback +5. **Operator** runs `cvert-ops rotate-encryption-key`, which re-encrypts every `sso_connections.client_secret_enc` row in a single Postgres transaction: decrypt with fallback, re-encrypt with current key +6. **After re-encryption succeeds**, the operator removes `SSO_ENCRYPTION_KEY_PREVIOUS` and reloads config + +The re-encryption command is transactional — if it fails partway through, the transaction rolls back and all rows remain encrypted with the original key. Safe to retry. + +The full step-by-step procedure is documented in `docs/deployment/runbooks/secret-rotation.md`. + +## Security Boundaries and Assumptions + +| Boundary | Status | +|----------|--------| +| **Encryption at rest** | AES-256-GCM. Protects against database dump or backup theft | +| **Tenant isolation** | Row-Level Security (RLS) on `sso_connections` + `org_id` scoping. One tenant cannot read another's encrypted secret | +| **Key storage** | The encryption key lives in an environment variable or secrets file on the host. There is no KMS or HSM wrapping — compromise of the application server's environment means compromise of the key | +| **Memory exposure** | The key is held in process memory as a `[32]byte`. Standard Go runtime — no `mlock` or secure memory wipe. Acceptable for non-HSM deployments | +| **Rotation atomicity** | The `rotate-encryption-key` command runs in a single DB transaction. Failure leaves all rows encrypted with the old key (safe to retry) | +| **No envelope encryption** | There is no KMS-wrapped DEK/KEK split. `SSO_ENCRYPTION_KEY` is the data encryption key directly. Key rotation therefore requires re-encrypting every row (currently only `sso_connections`, so the blast radius is small) | + +### Deployment expectation + +The security model assumes that the deployment environment adequately protects the `SSO_ENCRYPTION_KEY` value. In practice this means: + +- **Container deployments:** Use the platform's native secret injection (Kubernetes Secrets, Docker Swarm secrets, ECS task definition secrets, etc.) +- **Cloud VMs:** Use a cloud secret manager (AWS Secrets Manager, GCP Secret Manager, Azure Key Vault) to inject the value into the environment at startup +- **Self-hosted:** Ensure the secrets file has restrictive file permissions and is excluded from backups and version control + +If CVErt Ops later needs to support a managed SaaS model where the operator controls infrastructure, the natural upgrade path would be envelope encryption with a cloud KMS wrapping the SSO encryption key. + +## What's NOT Encrypted at Rest + +These values are **not** stored in the database — they live only in environment variables or the secrets file: + +- OAuth provider secrets (`GITHUB_CLIENT_SECRET`, `GOOGLE_CLIENT_SECRET`) — app-level config, not tenant-provided +- JWT signing secrets (`JWT_SECRET`, `JWT_SECRET_PREVIOUS`) +- SMTP credentials (`SMTP_PASSWORD`) + +These values are stored in the database but use **hashing, not encryption** (correct approach — they never need to be recovered in plaintext): + +- User passwords — argon2id +- API key hashes + +## Key Files + +| File | Role | +|------|------| +| `internal/crypto/aes.go` | AES-256-GCM Encrypt / Decrypt / DecryptWithFallback | +| `internal/config/reloadable.go` | Hot-reloadable config with atomic key swap | +| `internal/api/sso.go` | SSO handler — encrypts on write, decrypts on read | +| `cmd/cvert-ops/rotate.go` | CLI re-encryption command | +| `migrations/000028_sso_connections.up.sql` | Schema with `client_secret_enc BYTEA` column + RLS | +| `internal/store/queries/sso.sql` | sqlc queries (encrypted column passed as opaque bytes) | +| `docs/deployment/runbooks/secret-rotation.md` | Operator-facing rotation procedures | + +## Dependencies + +- **Go stdlib crypto** (`crypto/aes`, `crypto/cipher`, `crypto/rand`) — no third-party crypto libraries +- **pgx** for the rotation transaction +- **Operator-managed key** — no external secrets manager SDK dependency diff --git a/internal/api/auth_mfa.go b/internal/api/auth_mfa.go index 401590b1..0ff86a11 100644 --- a/internal/api/auth_mfa.go +++ b/internal/api/auth_mfa.go @@ -374,7 +374,7 @@ func (srv *Server) verifyTOTP(ctx context.Context, userID uuid.UUID, code string return false, fmt.Errorf("encryption key: %w", err) } prevKey := srv.ssoEncryptionKeyPrevious() - secretBytes, err := crypto.DecryptWithFallback(encKey, prevKey, cred.SecretEnc) + secretBytes, err := crypto.DecryptWithFallback(encKey, prevKey, cred.SecretEnc, userID[:]) if err != nil { return false, fmt.Errorf("decrypt TOTP secret: %w", err) } @@ -580,7 +580,7 @@ func (srv *Server) mfaTOTPSetupHandler(ctx context.Context, input *mfaTOTPSetupI slog.ErrorContext(ctx, "totp-setup: encryption key", "error", err) return nil, huma.Error500InternalServerError("encryption key not configured") } - secretEnc, err := crypto.Encrypt(encKey, []byte(key.Secret())) + secretEnc, err := crypto.Encrypt(encKey, []byte(key.Secret()), userID[:]) if err != nil { slog.ErrorContext(ctx, "totp-setup: encrypt secret", "error", err) return nil, huma.Error500InternalServerError("internal error") @@ -645,7 +645,7 @@ func (srv *Server) mfaTOTPConfirmHandler(ctx context.Context, input *mfaTOTPConf return nil, huma.Error500InternalServerError("internal error") } prevKey := srv.ssoEncryptionKeyPrevious() - secretBytes, err := crypto.DecryptWithFallback(encKey, prevKey, enrollClaims.SecretEnc) + secretBytes, err := crypto.DecryptWithFallback(encKey, prevKey, enrollClaims.SecretEnc, userID[:]) if err != nil { slog.ErrorContext(ctx, "totp-confirm: decrypt secret", "error", err) return nil, huma.Error500InternalServerError("internal error") @@ -677,7 +677,7 @@ func (srv *Server) mfaTOTPConfirmHandler(ctx context.Context, input *mfaTOTPConf // Re-encrypt secret for DB storage (enrollment cookie used same key, but // re-encrypt to get a fresh nonce for defense in depth). - secretEncDB, err := crypto.Encrypt(encKey, secretBytes) + secretEncDB, err := crypto.Encrypt(encKey, secretBytes, userID[:]) if err != nil { slog.ErrorContext(ctx, "totp-confirm: re-encrypt secret", "error", err) return nil, huma.Error500InternalServerError("internal error") diff --git a/internal/api/auth_mfa_test.go b/internal/api/auth_mfa_test.go index 0fa9b817..f4531871 100644 --- a/internal/api/auth_mfa_test.go +++ b/internal/api/auth_mfa_test.go @@ -55,7 +55,7 @@ func enrollTOTP(t *testing.T, ctx context.Context, srv *Server, userID uuid.UUID if err != nil { t.Fatalf("enrollTOTP: encryption key: %v", err) } - secretEnc, err := crypto.Encrypt(encKey, []byte(secret)) + secretEnc, err := crypto.Encrypt(encKey, []byte(secret), userID[:]) if err != nil { t.Fatalf("enrollTOTP: encrypt: %v", err) } diff --git a/internal/api/oauth_oidc.go b/internal/api/oauth_oidc.go index 1b32e807..245764a0 100644 --- a/internal/api/oauth_oidc.go +++ b/internal/api/oauth_oidc.go @@ -53,7 +53,7 @@ func (srv *Server) oidcBuildOAuthConfig(ctx context.Context, conn *store.SSOConn if err != nil { return nil, nil, fmt.Errorf("encryption key: %w", err) } - secret, err := crypto.DecryptWithFallback(key, srv.ssoEncryptionKeyPrevious(), conn.ClientSecretEnc) + secret, err := crypto.DecryptWithFallback(key, srv.ssoEncryptionKeyPrevious(), conn.ClientSecretEnc, conn.OrgID[:]) if err != nil { return nil, nil, fmt.Errorf("decrypt secret: %w", err) } diff --git a/internal/api/sso.go b/internal/api/sso.go index 7a5a657a..bc9af9ef 100644 --- a/internal/api/sso.go +++ b/internal/api/sso.go @@ -171,7 +171,7 @@ func (srv *Server) createSSOHandler(w http.ResponseWriter, r *http.Request) { writeProblem(w, http.StatusInternalServerError, "server configuration error") return } - encSecret, err := crypto.Encrypt(key, []byte(req.ClientSecret)) + encSecret, err := crypto.Encrypt(key, []byte(req.ClientSecret), orgID[:]) if err != nil { slog.ErrorContext(r.Context(), "sso create: encrypt secret", "error", err) writeProblem(w, http.StatusInternalServerError, "encryption error") @@ -347,7 +347,7 @@ func (srv *Server) patchSSOHandler(w http.ResponseWriter, r *http.Request) { writeProblem(w, http.StatusInternalServerError, "server configuration error") return } - secretEnc, err = crypto.Encrypt(key, []byte(*req.ClientSecret)) + secretEnc, err = crypto.Encrypt(key, []byte(*req.ClientSecret), orgID[:]) if err != nil { slog.ErrorContext(r.Context(), "sso patch: encrypt secret", "error", err) writeProblem(w, http.StatusInternalServerError, "encryption error") diff --git a/internal/crypto/aes.go b/internal/crypto/aes.go index 2d88fe65..97d801a8 100644 --- a/internal/crypto/aes.go +++ b/internal/crypto/aes.go @@ -15,9 +15,10 @@ import ( // authentication fails and previousKey is non-zero, it retries with // previousKey. This supports seamless encryption key rotation. // Structural errors (truncated ciphertext, invalid key) fail immediately -// without attempting fallback. -func DecryptWithFallback(currentKey, previousKey [32]byte, data []byte) ([]byte, error) { - plaintext, err := Decrypt(currentKey, data) +// without attempting fallback. The aad (additional authenticated data) is +// passed through to GCM and must match the value used during encryption. +func DecryptWithFallback(currentKey, previousKey [32]byte, data []byte, aad []byte) ([]byte, error) { + plaintext, err := Decrypt(currentKey, data, aad) if err == nil { return plaintext, nil } @@ -25,7 +26,7 @@ func DecryptWithFallback(currentKey, previousKey [32]byte, data []byte) ([]byte, // Only fall back on GCM authentication failure (wrong key). // Structural errors (truncated ciphertext, invalid key) fail fast. if previousKey != [32]byte{} && isGCMAuthError(err) { - plaintext, err2 := Decrypt(previousKey, data) + plaintext, err2 := Decrypt(previousKey, data, aad) if err2 == nil { return plaintext, nil } @@ -42,8 +43,10 @@ func isGCMAuthError(err error) bool { } // Encrypt encrypts plaintext using AES-256-GCM with a random nonce. -// Returns nonce || ciphertext. -func Encrypt(key [32]byte, plaintext []byte) ([]byte, error) { +// Returns nonce || ciphertext. The aad (additional authenticated data) is +// mixed into the GCM authentication tag, binding the ciphertext to a context +// (e.g., an org_id or user_id). Pass nil for context-free encryption. +func Encrypt(key [32]byte, plaintext []byte, aad []byte) ([]byte, error) { block, err := aes.NewCipher(key[:]) if err != nil { return nil, fmt.Errorf("aes new cipher: %w", err) @@ -60,12 +63,13 @@ func Encrypt(key [32]byte, plaintext []byte) ([]byte, error) { } // Seal appends ciphertext to nonce, so result is nonce || ciphertext. - return gcm.Seal(nonce, nonce, plaintext, nil), nil + return gcm.Seal(nonce, nonce, plaintext, aad), nil } // Decrypt decrypts AES-256-GCM ciphertext produced by Encrypt. -// Expects nonce (12 bytes) || ciphertext. -func Decrypt(key [32]byte, data []byte) ([]byte, error) { +// Expects nonce (12 bytes) || ciphertext. The aad must match the value +// used during encryption; a mismatch causes an authentication failure. +func Decrypt(key [32]byte, data []byte, aad []byte) ([]byte, error) { block, err := aes.NewCipher(key[:]) if err != nil { return nil, fmt.Errorf("aes new cipher: %w", err) @@ -82,7 +86,7 @@ func Decrypt(key [32]byte, data []byte) ([]byte, error) { } nonce, ciphertext := data[:nonceSize], data[nonceSize:] - plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + plaintext, err := gcm.Open(nil, nonce, ciphertext, aad) if err != nil { return nil, fmt.Errorf("gcm decrypt: %w", err) } diff --git a/internal/crypto/aes_test.go b/internal/crypto/aes_test.go index 1c5acf1a..2537ce87 100644 --- a/internal/crypto/aes_test.go +++ b/internal/crypto/aes_test.go @@ -23,12 +23,12 @@ func TestAESGCM_RoundTrip(t *testing.T) { key := testKey(t) plaintext := []byte("secret webhook signing key 🔑") - ciphertext, err := Encrypt(key, plaintext) + ciphertext, err := Encrypt(key, plaintext, nil) if err != nil { t.Fatalf("Encrypt: %v", err) } - got, err := Decrypt(key, ciphertext) + got, err := Decrypt(key, ciphertext, nil) if err != nil { t.Fatalf("Decrypt: %v", err) } @@ -37,16 +37,73 @@ func TestAESGCM_RoundTrip(t *testing.T) { } } +func TestAESGCM_RoundTrip_WithAAD(t *testing.T) { + t.Parallel() + key := testKey(t) + plaintext := []byte("org-scoped secret") + aad := []byte("org-id-abc-123") + + ciphertext, err := Encrypt(key, plaintext, aad) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + got, err := Decrypt(key, ciphertext, aad) + if err != nil { + t.Fatalf("Decrypt: %v", err) + } + if !bytes.Equal(got, plaintext) { + t.Errorf("round-trip mismatch: got %q, want %q", got, plaintext) + } +} + +func TestAESGCM_AADMismatch_Rejected(t *testing.T) { + t.Parallel() + key := testKey(t) + plaintext := []byte("bound to org A") + aadA := []byte("org-A") + aadB := []byte("org-B") + + ciphertext, err := Encrypt(key, plaintext, aadA) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + // Decrypting with different AAD must fail (ciphertext relocation attack). + _, err = Decrypt(key, ciphertext, aadB) + if err == nil { + t.Error("Decrypt succeeded with wrong AAD, want authentication failure") + } +} + +func TestAESGCM_AADVsNilAAD_Rejected(t *testing.T) { + t.Parallel() + key := testKey(t) + plaintext := []byte("has AAD binding") + aad := []byte("some-context") + + ciphertext, err := Encrypt(key, plaintext, aad) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + // Encrypted with AAD, decrypted without — must fail. + _, err = Decrypt(key, ciphertext, nil) + if err == nil { + t.Error("Decrypt with nil AAD succeeded on AAD-encrypted data, want failure") + } +} + func TestAESGCM_UniqueNonce(t *testing.T) { t.Parallel() key := testKey(t) plaintext := []byte("same input") - ct1, err := Encrypt(key, plaintext) + ct1, err := Encrypt(key, plaintext, nil) if err != nil { t.Fatalf("Encrypt 1: %v", err) } - ct2, err := Encrypt(key, plaintext) + ct2, err := Encrypt(key, plaintext, nil) if err != nil { t.Fatalf("Encrypt 2: %v", err) } @@ -60,7 +117,7 @@ func TestAESGCM_TamperedCiphertext(t *testing.T) { t.Parallel() key := testKey(t) - ciphertext, err := Encrypt(key, []byte("tamper me")) + ciphertext, err := Encrypt(key, []byte("tamper me"), nil) if err != nil { t.Fatalf("Encrypt: %v", err) } @@ -70,7 +127,7 @@ func TestAESGCM_TamperedCiphertext(t *testing.T) { copy(tampered, ciphertext) tampered[len(tampered)-1] ^= 0xff - _, err = Decrypt(key, tampered) + _, err = Decrypt(key, tampered, nil) if err == nil { t.Error("Decrypt succeeded on tampered ciphertext, want error") } @@ -81,12 +138,12 @@ func TestAESGCM_WrongKey(t *testing.T) { key1 := testKey(t) key2 := testKey(t) - ciphertext, err := Encrypt(key1, []byte("wrong key test")) + ciphertext, err := Encrypt(key1, []byte("wrong key test"), nil) if err != nil { t.Fatalf("Encrypt: %v", err) } - _, err = Decrypt(key2, ciphertext) + _, err = Decrypt(key2, ciphertext, nil) if err == nil { t.Error("Decrypt succeeded with wrong key, want error") } @@ -96,12 +153,12 @@ func TestAESGCM_EmptyPlaintext(t *testing.T) { t.Parallel() key := testKey(t) - ciphertext, err := Encrypt(key, []byte{}) + ciphertext, err := Encrypt(key, []byte{}, nil) if err != nil { t.Fatalf("Encrypt empty: %v", err) } - got, err := Decrypt(key, ciphertext) + got, err := Decrypt(key, ciphertext, nil) if err != nil { t.Fatalf("Decrypt empty: %v", err) } @@ -116,7 +173,7 @@ func TestAESGCM_ShortCiphertext(t *testing.T) { // ciphertext too short to contain a nonce is rejected at runtime. key := testKey(t) - _, err := Decrypt(key, []byte("short")) + _, err := Decrypt(key, []byte("short"), nil) if err == nil { t.Error("Decrypt succeeded on too-short ciphertext, want error") } @@ -130,12 +187,12 @@ func TestDecryptWithFallback_CurrentKeyWorks(t *testing.T) { previousKey := testKey(t) plaintext := []byte("current key decryption") - ciphertext, err := Encrypt(currentKey, plaintext) + ciphertext, err := Encrypt(currentKey, plaintext, nil) if err != nil { t.Fatalf("Encrypt: %v", err) } - got, err := DecryptWithFallback(currentKey, previousKey, ciphertext) + got, err := DecryptWithFallback(currentKey, previousKey, ciphertext, nil) if err != nil { t.Fatalf("DecryptWithFallback: %v", err) } @@ -150,13 +207,13 @@ func TestDecryptWithFallback_PreviousKeyWorks(t *testing.T) { newKey := testKey(t) plaintext := []byte("encrypted with old key") - ciphertext, err := Encrypt(oldKey, plaintext) + ciphertext, err := Encrypt(oldKey, plaintext, nil) if err != nil { t.Fatalf("Encrypt: %v", err) } // newKey as current fails GCM auth; oldKey as previous succeeds. - got, err := DecryptWithFallback(newKey, oldKey, ciphertext) + got, err := DecryptWithFallback(newKey, oldKey, ciphertext, nil) if err != nil { t.Fatalf("DecryptWithFallback: %v", err) } @@ -172,12 +229,12 @@ func TestDecryptWithFallback_BothKeysWrong(t *testing.T) { keyC := testKey(t) plaintext := []byte("neither key works") - ciphertext, err := Encrypt(keyA, plaintext) + ciphertext, err := Encrypt(keyA, plaintext, nil) if err != nil { t.Fatalf("Encrypt: %v", err) } - _, err = DecryptWithFallback(keyB, keyC, ciphertext) + _, err = DecryptWithFallback(keyB, keyC, ciphertext, nil) if err == nil { t.Error("DecryptWithFallback succeeded with both wrong keys, want error") } @@ -189,13 +246,13 @@ func TestDecryptWithFallback_NoPreviousKey(t *testing.T) { var zeroKey [32]byte plaintext := []byte("no previous key") - ciphertext, err := Encrypt(currentKey, plaintext) + ciphertext, err := Encrypt(currentKey, plaintext, nil) if err != nil { t.Fatalf("Encrypt: %v", err) } // Zero previous key → only current key tried. - got, err := DecryptWithFallback(currentKey, zeroKey, ciphertext) + got, err := DecryptWithFallback(currentKey, zeroKey, ciphertext, nil) if err != nil { t.Fatalf("DecryptWithFallback: %v", err) } @@ -212,7 +269,7 @@ func TestDecryptWithFallback_TruncatedCiphertext_NoFallback(t *testing.T) { previousKey := [32]byte{2} shortData := []byte("short") - _, err := DecryptWithFallback(currentKey, previousKey, shortData) + _, err := DecryptWithFallback(currentKey, previousKey, shortData, nil) if err == nil { t.Fatal("DecryptWithFallback succeeded on truncated ciphertext, want error") } @@ -231,14 +288,53 @@ func TestDecryptWithFallback_NoPreviousKeyCurrentFails(t *testing.T) { keyB := testKey(t) var zeroKey [32]byte - ciphertext, err := Encrypt(keyA, []byte("no previous key fails")) + ciphertext, err := Encrypt(keyA, []byte("no previous key fails"), nil) if err != nil { t.Fatalf("Encrypt: %v", err) } // Wrong current key, zero previous → returns error without panic. - _, err = DecryptWithFallback(keyB, zeroKey, ciphertext) + _, err = DecryptWithFallback(keyB, zeroKey, ciphertext, nil) if err == nil { t.Error("DecryptWithFallback succeeded with wrong current and zero previous, want error") } } + +func TestDecryptWithFallback_WithAAD(t *testing.T) { + t.Parallel() + currentKey := testKey(t) + previousKey := testKey(t) + plaintext := []byte("aad-bound secret") + aad := []byte("org-id-bytes") + + ciphertext, err := Encrypt(currentKey, plaintext, aad) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + got, err := DecryptWithFallback(currentKey, previousKey, ciphertext, aad) + if err != nil { + t.Fatalf("DecryptWithFallback: %v", err) + } + if !bytes.Equal(got, plaintext) { + t.Errorf("plaintext mismatch: got %q, want %q", got, plaintext) + } +} + +func TestDecryptWithFallback_AADMismatch_Rejected(t *testing.T) { + t.Parallel() + currentKey := testKey(t) + var zeroKey [32]byte + plaintext := []byte("bound to org A") + + ciphertext, err := Encrypt(currentKey, plaintext, []byte("org-A")) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + + // Correct key but wrong AAD must fail. + _, err = DecryptWithFallback(currentKey, zeroKey, ciphertext, []byte("org-B")) + if err == nil { + t.Error("DecryptWithFallback succeeded with wrong AAD, want error") + } +} diff --git a/internal/doctor/checks.go b/internal/doctor/checks.go index 4f2bbf57..5593812e 100644 --- a/internal/doctor/checks.go +++ b/internal/doctor/checks.go @@ -182,7 +182,7 @@ func (c *EncryptionSentinelCheck) Run(ctx context.Context) (string, string, erro return StatusFail, fmt.Sprintf("query system_settings: %v", err), nil } - _, err = crypto.DecryptWithFallback(c.Key, c.PreviousKey, value) + _, err = crypto.DecryptWithFallback(c.Key, c.PreviousKey, value, []byte("encryption_sentinel")) if err != nil { return StatusFail, fmt.Sprintf("sentinel decryption failed: %v — encryption key may have changed", err), nil } @@ -344,8 +344,8 @@ func (c *SecurityHeadersCheck) Run(ctx context.Context) (string, string, error) required := map[string]string{ "X-Content-Type-Options": "nosniff", - "X-Frame-Options": "DENY", - "Referrer-Policy": "strict-origin-when-cross-origin", + "X-Frame-Options": "DENY", + "Referrer-Policy": "strict-origin-when-cross-origin", } var missing []string @@ -444,7 +444,7 @@ type StandardChecksConfig struct { SMTPHost string SMTPPort int SMTPUsername string - CORSAllowedOrigins string + CORSAllowedOrigins string CookieAuth bool ServerAddr string // empty in CLI mode, "http://localhost:{port}" in API mode }