fix(security): ship SCRAM-SHA-256 verifier instead of cleartext password
Update 2026-09-29 (Part of postgres-ai/internal#354):
- Review finding fixed (vacuous iteration test).
creation and reset honor the server SCRAM iteration countreturned early when the server had noscram_iterations. In CI that was always the case, becausecli:node:testsonly had bookworm's PostgreSQL 15. The test now starts its own server from a PostgreSQL ≥ 16 binary directory. WithPGAI_TEST_REQUIRE_PG16=1, which CI now sets, it fails when no such directory exists. The same holds for the whole integration file when the suite would otherwise be skipped.- Red/green, checked in a
node:20-bookwormcontainer that runs this job'sapt-getlines. Red, with only PostgreSQL 15:PGAI_TEST_REQUIRE_PG16=1 but no PostgreSQL >= 16 initdb/postgres found(1 fail). Green: CI also installspostgresql-17from PGDG, and all 9 integration tests pass. The other integration tests still use the first binaries found, which is PostgreSQL 15.- Merge order with !433: the two MRs used to conflict in
cli/test/init.test.ts. This MR moved its two print-sql redaction assertions after thegrant connectcheck (the assertions are unchanged), so the MRs now merge cleanly in either order. A local merge of both heads passesinit.test.tsandinit.integration.test.ts. Suggested order: !433 first, then !336.- Known trade-off: CI now trusts the PGDG apt repository, bootstrapped by Debian's
postgresql-commonscript. A pinned CI image would avoid that.
Summary
prepare-db emitted create user … with password '<cleartext>' and alter user … with password '<cleartext>'. That puts the monitoring user's cleartext password into:
- the generated SQL snippet (which ends up in runbooks, tickets, Slack pastes), and
- on execution,
pg_stat_activity, the server log, andpg_stat_statements.
Because the monitoring role holds pg_monitor (→ pg_read_all_stats), it could read its own password back out of pg_stat_statements query text.
Fix
Compute a SCRAM-SHA-256 verifier client-side and ship password 'SCRAM-SHA-256$<iters>:<salt>$<storedkey>:<serverkey>'. Postgres recognizes the prefix and stores the verifier verbatim, so the cleartext never reaches the server. The cleartext stays client-side for --print-password / collector config. The generated password is base64url (ASCII), so SASLprep is the identity transform (non-ASCII passwords would need SASLprep — noted in scram.ts).
Changes
cli/lib/scram.ts(new) —scramSha256Verifier(): PBKDF2-HMAC-SHA256 + HMAC/SHA per RFC 5802/7677, matching PostgreSQL's stored-verifier format.cli/lib/init.ts— build the verifier and use it in the CREATE/ALTER ROLE password clause; reject null bytes in the password up front.cli/test/scram.test.ts(new) — verifier shape, decoded key lengths (salt 16B, keys 32B), determinism, andStoredKey = H(HMAC(SaltedPassword,'Client Key'))consistency.cli/test/init.test.ts— assertions flip from cleartext-inlining to "verifier present, cleartext absent".cli/test/init.integration.test.ts— end-to-end proof: the provisioned user authenticates over TCPscram-sha-256with the cleartext password, andpg_authid.rolpasswordstores a SCRAM verifier, not the cleartext.
Validation
bun test test/scram.test.ts→ green (100% coverage ofscram.ts).- Updated
buildInitPlanunit tests → green. - The 21
runClisubprocess tests fail only in my local checkout because a generated build artifact (lib/checkup-dictionary-embedded.ts) is absent — pre-existing/environmental, unrelated to this change. The integration login test is the authoritative SCRAM correctness gate and runs in CI.
Related
Part of the provisioning security review. Sibling fix: platform-all generators (separate MR). Distinct from the explain_generic RCE removal (!335 (merged) / platform-all!542).
Closes #253