Replace IsEncrypted heuristic with explicit enc:v1: ciphertext marker (fixes auth-row bricking) - #353
Conversation
Adversarial review — PR #353 (issue #335)Verdict: CHANGES NEEDED Independently verified against a detached checkout of BlockingF1. HIGH — A user password beginning with F2. HIGH — F3. MEDIUM — Unconditional heal write-back can silently revert a concurrent legitimate credential update. Non-blockingF4. MEDIUM (pre-existing, but contradicts the issue's acceptance criteria) — rotation likely fails at runtime on postgres/mysql. F5. LOW — read-path silent wrong-key on legacy rows. With a misconfigured server key and legacy rows, F6. LOW — guard caveats. F7. NIT — Verified good (adversarial checks that passed)
F1-F3 are all small, well-localized fixes; happy to re-review quickly. 🤖 Posted on behalf of |
Fixes from independent review of PR #353: - HIGH: write hooks now ALWAYS encrypt user input; only interceptor self-heal write-backs (tagged via a context sentinel) skip re-encryption. Previously a password literally starting with "enc:v1:" was stored as plaintext and every read hard-errored on marked-decrypt failure — the same bricking failure mode, reachable from a login form. Marked-decrypt failure on read is now provably a wrong key or corruption (never user data) and still errors loudly. - HIGH: rotation hard-aborts when no stored value positively verifies the old key (found && !verified). On an all-legacy database a wrong old key previously wrapped garbage under the new key and printed "Verification: PASSED", corrupting all credentials. New --force flag is the explicit escape hatch for genuinely-plaintext databases. - MEDIUM: self-heal write-backs are now compare-and-swap updates (Where(id, field == stored raw value)) so a concurrent legitimate credential update is never clobbered by a stale heal. - Added slog.Warn on the legacy-GCM-failure read path — the operator's only wrong-key signal on legacy rows. - reencryptAll buffers all rows before issuing UPDATEs: with the pool pinned to one connection, mysql/lib/pq cannot exec while a query result set is open (SQLite tolerated it). - Documented guard caveats: MySQL processlist needs the PROCESS privilege; the advisory lock is lost if the pinned conn reconnects. Tests: marker-prefixed password round-trip, stale-heal CAS guard, rotation abort on unverified old key (data untouched), --force path. go test -race clean on crypto/database/cmd-admin; full suite green. Implements #335. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Pushed f7d85b2 addressing all review findings from #353 (comment). BLOCKING
RECOMMENDED
Verification: 🤖 Posted on behalf of |
Re-review of f7d85b2 — delta from #353 (comment)Verdict: LGTM Re-verified empirically on a fresh detached checkout of f7d85b2, re-running my original attack reproductions against the fixed code (all now defeated) plus regression probes.
Nice work — the sentinel approach is the right fix, and the abort-by-default rotation is exactly the posture this PR needed. Good to merge from my side (pending the known #352 rebase, which remains non-conflicting). 🤖 Posted on behalf of |
Fixes from independent review of PR #353: - HIGH: write hooks now ALWAYS encrypt user input; only interceptor self-heal write-backs (tagged via a context sentinel) skip re-encryption. Previously a password literally starting with "enc:v1:" was stored as plaintext and every read hard-errored on marked-decrypt failure — the same bricking failure mode, reachable from a login form. Marked-decrypt failure on read is now provably a wrong key or corruption (never user data) and still errors loudly. - HIGH: rotation hard-aborts when no stored value positively verifies the old key (found && !verified). On an all-legacy database a wrong old key previously wrapped garbage under the new key and printed "Verification: PASSED", corrupting all credentials. New --force flag is the explicit escape hatch for genuinely-plaintext databases. - MEDIUM: self-heal write-backs are now compare-and-swap updates (Where(id, field == stored raw value)) so a concurrent legitimate credential update is never clobbered by a stale heal. - Added slog.Warn on the legacy-GCM-failure read path — the operator's only wrong-key signal on legacy rows. - reencryptAll buffers all rows before issuing UPDATEs: with the pool pinned to one connection, mysql/lib/pq cannot exec while a query result set is open (SQLite tolerated it). - Documented guard caveats: MySQL processlist needs the PROCESS privilege; the advisory lock is lost if the pinned conn reconnects. Tests: marker-prefixed password round-trip, stale-heal CAS guard, rotation abort on unverified old key (data untouched), --force path. go test -race clean on crypto/database/cmd-admin; full suite green. Implements #335. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
f7d85b2 to
a066428
Compare
Implements #335. Governing: ADR-0006, ADR-0021. The old IsEncrypted() treated any std-base64-decodable string with >= 29 decoded bytes as ciphertext. Plaintext such as a 40-char alphanumeric password false-positived: the write hook skipped encryption, the read interceptor then failed GCM authentication, and the whole query errored, bricking the NavidromeAuth/SpotifyAuth/LastFMAuth row. - New ciphertext is written as enc:v1: + base64(nonce||ct||tag); IsEncrypted() is now an exact marker check. The old heuristic survives as LooksLikeLegacyCiphertext() for migration paths only. - Read interceptors self-heal legacy rows: marked -> decrypt; legacy base64 shape -> attempt decrypt, on GCM failure treat as plaintext; either way the row is re-encrypted with the marker (best-effort, never fails the query). Plain plaintext rows keep the existing encrypt-on-next-write behavior. - rotate-key handles all three stored formats (marked, legacy, plaintext) and always writes back the marked format under the new key. Ambiguous values that do not decrypt with the old key are treated as plaintext with a per-row warning instead of aborting. - ADR-0021 gap: postgres/mysql now get a session-scoped advisory lock (pg_try_advisory_lock / GET_LOCK) plus an other-sessions check as the "server not running" pre-rotation guard; the pool is pinned to one connection so session state holds. SQLite keeps the BEGIN EXCLUSIVE probe. - Added governing ADR-0006/ADR-0021 comments to internal/crypto. Tests: marker round-trip for a 40-char base64-alphabet password, read-path self-healing for plaintext lookalikes and legacy ciphertext, and rotation across marked+legacy+plaintext rows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixes from independent review of PR #353: - HIGH: write hooks now ALWAYS encrypt user input; only interceptor self-heal write-backs (tagged via a context sentinel) skip re-encryption. Previously a password literally starting with "enc:v1:" was stored as plaintext and every read hard-errored on marked-decrypt failure — the same bricking failure mode, reachable from a login form. Marked-decrypt failure on read is now provably a wrong key or corruption (never user data) and still errors loudly. - HIGH: rotation hard-aborts when no stored value positively verifies the old key (found && !verified). On an all-legacy database a wrong old key previously wrapped garbage under the new key and printed "Verification: PASSED", corrupting all credentials. New --force flag is the explicit escape hatch for genuinely-plaintext databases. - MEDIUM: self-heal write-backs are now compare-and-swap updates (Where(id, field == stored raw value)) so a concurrent legitimate credential update is never clobbered by a stale heal. - Added slog.Warn on the legacy-GCM-failure read path — the operator's only wrong-key signal on legacy rows. - reencryptAll buffers all rows before issuing UPDATEs: with the pool pinned to one connection, mysql/lib/pq cannot exec while a query result set is open (SQLite tolerated it). - Documented guard caveats: MySQL processlist needs the PROCESS privilege; the advisory lock is lost if the pinned conn reconnects. Tests: marker-prefixed password round-trip, stale-heal CAS guard, rotation abort on unverified old key (data untouched), --force path. go test -race clean on crypto/database/cmd-admin; full suite green. Implements #335. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CI golangci-lint (goconst) flagged 'postgres' with 3 occurrences in cmd/admin/main.go. internal/config exports no driver constants, so the existing local driverSQLite3 const is extended to a block with driverPostgres and driverMySQL. Also extracted the repeated "SELECT id, %s FROM %s" Sprintf format (3 occurrences after the verifyOldKey rework) as selectEncryptedFieldSQL to stay ahead of the same check. Verified with standalone goconst (min-occurrences 3) on cmd/admin, internal/crypto, internal/database: no findings. The CI-pinned golangci-lint v1.64.8 binary cannot typecheck against the local go1.26 stdlib, so goconst was run directly. Implements #335. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
a066428 to
933208c
Compare
Implements the highest-severity finding of the remediation review. Part of epic #319. Governing: ADR-0006 (application-layer AES-256-GCM encryption), ADR-0021 (encryption key rotation).
What / Why
IsEncrypted()treated any std-base64-decodable string with >= 29 decoded bytes as ciphertext. A 40-char alphanumeric password (or base64-alphabet API token) false-positived: the write hook skipped encryption (plaintext stored), then every read hit the decrypt interceptor, GCM authentication failed, and the query itself errored — the auth row was bricked until manual DB surgery.Changes
internal/crypto/encrypt.go): new ciphertext isenc:v1:+ base64(nonce||ct||tag).IsEncrypted()is now an exact marker-prefix check — never a heuristic. The marker alphabet (:) is disjoint from std base64, so marked values can never be confused with legacy ciphertext or plaintext. The old heuristic survives only asLooksLikeLegacyCiphertext(), explicitly documented as attempt-decryption-only, for the migration paths.Decrypt()accepts both marked and legacy (bare base64) input. Governing ADR-0006/ADR-0021 comments added throughout (previously the only major subsystem with zero).internal/database/hooks.go): interceptors resolve each stored value viaresolveEncryptedField()— marker → decrypt (failure is a real error: wrong key/corruption); legacy base64 shape → attempt decrypt, on GCM failure treat as plaintext; in both legacy cases the row is best-effort re-encrypted with the marker (write hook sees the marker and stores it as-is). This path never errors the query. Plain plaintext keeps the existing encrypt-on-next-write behavior.cmd/admin/main.go):rotate-keynow resolves marked, legacy, and plaintext values and always writes back the marked format under the new key, so a rotated database is uniformlyenc:v1:. Marked values that fail old-key decryption abort (definitive wrong key); ambiguous legacy-shaped values fall back to plaintext with a per-row warning. Pre-rotation old-key verification scans for a marked value first (definitive), then a decryptable legacy value.cmd/admin/main.go): the "server not running" pre-rotation check was SQLite-only (BEGIN EXCLUSIVE probe). Postgres/MySQL now acquire a session-scoped advisory lock (pg_try_advisory_lock/GET_LOCK) to serialize rotations and refuse to run while other sessions are connected to the database. The pool is pinned to one connection (SetMaxOpenConns(1)) so session-scoped locks hold for the process lifetime. The connection check is documented as best-effort — stopping the server first remains mandatory per ADR-0021.Test evidence
make test: all 27 packages pass, zero failures.gofmt -lclean oninternal/crypto,internal/database,cmd/admin.TestBase64LookalikePasswordRoundTrip— a 40-char base64-alphabet password is stored encrypted (marker verified via raw SQL) and round-trips (acceptance a).TestPlaintextLookalikeSelfHealsOnRead— a pre-existing plaintext-that-looks-like-base64 row reads back correctly instead of erroring, and the stored value self-heals to marked ciphertext (acceptance b).TestLegacyCiphertextSelfHealsOnRead— pre-marker ciphertext decrypts on read and migrates to the marked format.TestRunMixedMarkedLegacyAndPlaintextRows/TestRunLegacyCiphertextRotation— rotation across marked + legacy + plaintext rows on SQLite (acceptance c); the postgres/mysql guard is exercised structurally and documented (no pg/mysql instance in CI — see code comments inacquireRotationGuard).TestEncryptProducesMarker,TestDecryptLegacyUnmarkedCiphertext,TestLooksLikeLegacyCiphertext, plusTestIsEncryptedregression cases for the false-positive inputs.Deferred Design Doc Updates
docs/adrs/is a protected path in this remediation wave, so the issue's "Amend ADR-0006 to document the marker format" requirement is deferred. Proposed amendments for a follow-up:enc:v1:versioned marker format (enc:v1:+ base64(nonce||ct||tag)); update the "IsEncrypted() heuristic" consequence bullets to reflect that detection is now an exact marker check, withLooksLikeLegacyCiphertext()reserved for the self-healing migration path; describe the read-path self-heal (legacy ciphertext and heuristic-lookalike plaintext both re-encrypted with marker on read).Closes #335
🤖 Posted on behalf of
@joestumpby Claude.