checksum: digest chunks with SHA-256 instead of md5 so a FIPS-mode OpenSSL does not fail the pass - #143
Conversation
…enSSL does not fail the pass PostgreSQL built against OpenSSL routes md5() through it, and an OpenSSL in FIPS mode refuses MD5, so every chunk digest — and with it every verification pass — failed on such a host. The digest statement now hashes each row's record text as UTF-8 bytes with pg_catalog.sha256, aggregates the per-row hashes as bytes in key order, hashes the aggregate again, and renders only the chunk's hash as hex. The digest never leaves the process (a Report is in memory; nothing persists it), so the hash has no compatibility surface. Both sides still run the identical frozen statement and every function stays pg_catalog-qualified (CO-9). Tests: the frozen digest statement is re-pinned; the shadowing-search_path test's decoys are sha256, convert_to and encode in place of md5. Docs: D7 states the SHA-256 digest and why; the package map, the CO-9 decoy list and the MySQL-vs-PostgreSQL idiom table follow.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
🤖 1/2: adversarial correctness review of 0 blocking, 3 non-blocking. The swap is sound. Both sides still run one statement that differs only in the table it reads, so the hash function, the UTF-8 conversion, the Non-blocking1. Converting to UTF-8 adds a failure the md5 digest did not have: on a
The stated reason for the conversion does not hold either. Converting to the database's own encoding keeps everything the PR wants and drops the failure. Test that fails on
|
| Mutant | Caught by (behavioral) |
|---|---|
| Row hash over the key column only | 13 tests, e.g. TestVerifierLocatesAChangedShadowRow, TestCheckAbortsOnDivergenceAndLeavesTheShadowAlone |
| Shadow side aggregated in the opposite key order | 16 tests, e.g. TestVerifierReportsACompleteCopyClean |
pg_catalog. dropped from sha256 / convert_to / encode |
survives (pinned by the local search_path; frozen test only) |
COALESCE dropped |
survives, here and on main (finding 2) |
ORDER BY dropped |
survives, here and on main (heap order equals key order in the fixtures; fails closed if it diverges) |
'UTF8' → 'LATIN1' |
survives (no fixture holds non-ASCII text; finding 1's test is the first) |
| Inner per-row hash dropped | survives (equivalent: record text is self-delimiting) |
| Outer hash dropped | survives (equivalent: the bytes are compared directly) |
This review was generated by Claude Code (claude-opus-5-5).
|
🤖 2/2: OSS adoption and integration ease, at This is a good adoption change. Regulated users often run PostgreSQL on hosts with OpenSSL in FIPS mode. Before this PR, every verification pass failed for them, with an error that pointed at the database rather than at the engine's choice of hash. The change costs importers nothing: the digest stays inside 1. The test suite still generates data with The PR is right that CI cannot run a FIPS-mode OpenSSL. An adopter who wants to confirm FIPS support on their own host would point 2. "No compatibility surface" holds inside the repo. For importers, though, This review was generated by Claude Code (claude-opus-5-5). |
aparajon
left a comment
There was a problem hiding this comment.
🤖 Approving 9f4752f with 0 blocking findings. Source and shadow still run one identical statement, so the hash swap cannot hide a difference or invent one. The gate still fails closed (CO-1 upheld), every function stays pg_catalog-qualified with the decoys updated (CO-9 upheld), and no persisted state carries a digest, so no md5 digest is ever compared with a SHA-256 one. I checked this on real PostgreSQL and with mutations.
The 1/2 comment has 3 non-blocking findings:
convert_to(…, 'UTF8')makes every pass fail on aSQL_ASCIIdatabase holding a non-UTF-8 byte, where md5 worked. Usingpg_catalog.getdatabaseencoding()as the target fixes it, and a test that ran is attached.- The empty-range
COALESCEis documented but untested. A test that ran is attached. - Two doc spellings of the digest (
sha256(row::text), and a text''separator) do not run as written.
The 2/2 comment has 2 non-blocking adoption notes: test fixtures still call md5(), and the exported Digest.Hash width change deserves a release-note line.
This stamp was left by Claude Code (claude-opus-5-5).
sha256 takes bytes, and convert_to with 'UTF8' as the target validated the record text as UTF-8 on the way to them. A SQL_ASCII database stores whatever bytes a client sends, so a row holding a byte that is valid in no encoding failed every pass over its chunk with 22021. Converting to pg_catalog.getdatabaseencoding() instead performs no conversion and hashes the bytes as the server stores them, which is what md5(text) hashed; on a UTF-8 database the statement is unchanged. The CO-9 decoy set gains an impostor getdatabaseencoding, and a test on a SQL_ASCII database pins the non-UTF-8 row. Pin the empty side of a chunk: a chunk the shadow holds no rows of reports zero rows and the SHA-256 of the empty input, not a scan of NULL. Correct the two doc spellings of the digest that did not run as written: sha256 takes bytea, so the idiom needs convert_to, and the aggregate's separator is ''::bytea. Move the test fixtures that generated filler with md5() to sha256(), with every fixture's byte sizes unchanged, so the suite itself runs on a FIPS-mode server and an adopter can check the claim with PG_DSN. Release note: checksum digests are SHA-256 hex, 64 characters, where Mismatch.Source.Hash and Mismatch.Shadow.Hash were 32.
|
🤖 Adversarial review response — created by Kiran's code review agent (Amp, Claude Opus 4.6) — pull/143, follow-up commit All five non-blocking findings are fixed in this PR; nothing is deferred. Both attached tests were taken as written (the SQL_ASCII one now goes through a
Verified section (CO-1, CO-9, TM-2, CO-2/CO-3 upheld; mutation table; performance): no action. Of the mutants that survived for want of a fixture, Source: block/pg-sprite#143, review comments 5987177880 and 5987178438 and review 5409528259 at head |
Switches the checksum verifier's chunk digest from
md5to SHA-256, so a verification pass does not fail on a host whose OpenSSL runs in FIPS mode.Why
PostgreSQL built against OpenSSL routes
md5()through it, and an OpenSSL in FIPS mode refuses MD5. On such a host every chunk digest errors, so the verifier fails closed on every pass — correct behaviour for a fault, but the fault is the hash choice, not the data. The #134 review flagged it (lens 2/2, finding 3).The digest never leaves the process: a
Reportis in memory and nothing persists it, so changing the hash has no compatibility surface — no checkpoint, no stored fingerprint, no JSON contract carries it.What
pkg/checksumdigestSQL: each row's record text is turned into bytes withconvert_to(…, pg_catalog.getdatabaseencoding())— the database's own encoding as the target, so no conversion happens and the bytes hashed are the ones the server stores, asmd5(text)hashed them — and hashed withpg_catalog.sha256; the per-row hashes are aggregated as bytes (string_agg(bytea, ''::bytea ORDER BY pk)), hashed again, and only the chunk's hash isencoded as hex. Every function stayspg_catalog-qualified (CO-9). Both sides still run the identical frozen statement; only the table differs.Digest.Hashdoc: hex SHA-256; the SHA-256 of the empty input for an empty range.Tests:
TestDigestSQLIsFrozenre-pinned (TM-2);TestVerifierIgnoresTheSessionSearchPathdecoys aresha256(bytea),convert_to(text, name),getdatabaseencoding()andencode(bytea, text)in place ofmd5(text);TestDigestHashesARowThatIsNotValidUTF8digests a non-UTF-8 byte on aSQL_ASCIIdatabase (newtestutil.NewDatabaseWithEncoding);TestVerifierReportsAChunkTheShadowIsMissingEntirelypins the empty side's zero rows and empty-input hash.Test fixtures that generated filler with
md5()(testutil.WorkloadTable, the preflight index-bytes test, the concurrent-index progress test, the Supabase realtime probe) now usesha256(), with every fixture's byte sizes unchanged, so the suite itself runs on a FIPS-mode server viaPG_DSN. Nomd5()call remains under the repo.Docs: D7 in
copy-and-swap-design.mdstates the SHA-256 digest and the FIPS reason; thepkg/checksumpackage-map row, the CO-9 decoy list ininvariants.md, and the checksum idiom row inmysql-vs-postgresql.mdfollow.Decisions to veto
byteaand only the final digest is hex. Hex at both levels would also work; bytes halve the aggregate's input and keep oneencodeat the end.convert_to(…, getdatabaseencoding())rather than'UTF8'or a text→bytea cast. There is no such cast;convert_tois the catalog's way. Targeting the database's own encoding performs no conversion, so aSQL_ASCIIdatabase holding bytes that are valid in no encoding digests like any other instead of failing every pass with22021. On a UTF-8 database the two spellings are the same statement.pg_catalog. With the fixtures offmd5(), an adopter can runmake test-dbagainst a FIPS host to check the claim.sha256()is available from PostgreSQL 11 and runs on every supported major (14 → 18).Release note
Checksum digests are now SHA-256 hex (64 characters);
Mismatch.Source.HashandMismatch.Shadow.Hashwere md5 hex (32 characters). Nothing persists a digest, so no stored value changes meaning.Review round 1
Addressed in the follow-up commit:
getdatabaseencoding()as theconvert_totarget with theSQL_ASCIItest and the CO-9 decoy; the empty-chunk test; the two doc spellings (sha256overconvert_to(…),''::byteaseparator); fixtures offmd5(); the release-note line above.Verification
go test ./pkg/checksum/ ./internal/testutil/(PG16), the SQL_ASCII and empty-chunk tests on PG18, each new test shown to fail against its mutant ('UTF8'target →22021;COALESCEdropped → NULL scan), the preflight index-bytes and concurrent-index progress tests on the new fixtures,SKIP_INTEGRATION=1 go test ./...,make lint(0 issues). The Supabase realtime probe runs in CI's Supabase job.