Skip to content

Managed role provisioning sends SCRAM-SHA-256 verifiers, never a password, and only when one changes (#3910) - #3940

Merged
erikdarlingdata merged 1 commit into
devfrom
feature/3910-scram-verifier
Sep 22, 2026
Merged

erikdarlingdata merged 1 commit into
devfrom
feature/3910-scram-verifier

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Closes #3910.

CHANGELOG: this PR carries no CHANGELOG edit. Its entry lands with the batched splice.

Why

Managed role provisioning sent every role's plaintext password on every service start: CREATE ROLE ... PASSWORD '<pw>' inside the guard's DO block, then ALTER ROLE admin/viewer/mcp LOGIN NOSUPERUSER PASSWORD '<pw>'. Any surface that records statement text could capture the live credentials of all three login roles.

The concrete path: an unmarked role with the same name (an operator-created mcp, say) makes the DO block raise on every start. The default log_min_error_statement = error then writes STATEMENT: and the whole batch, all three passwords included, to the store's own log. The store-log sweep kept that for 400 days, and the viewer role reads it through get_store_log. #3899's track_utility = off and #3920's log masking each close one surface. This fix removes the plaintext at the source.

What changes

  • ScramSha256Verifier computes PostgreSQL's SCRAM-SHA-256 verifier client-side: PBKDF2-HMAC-SHA-256 plus the RFC 5802/7677 keys, a 16-byte random salt, and 4096 iterations. PostgreSQL stores a string already in that format as-is, which is what psql's \password does. The password never appears in any statement. A leaked verifier is not a login: the passwords are 32 random alphanumerics.
  • BuildProvisioningSql takes verifiers and refuses anything else, a plain password first of all. That is where "never the password" is enforced, not just hoped for; the verifier's shape admits no quote, so the interpolation stays escaping-safe.
  • Steady state sends no PASSWORD clause. EnsureProvisionedAsync reads pg_authid.rolpassword (it is the superuser owner) and re-asserts a role's password only when the stored verifier no longer accepts the credential file. That happens for a missing role, an MD5 hash from an older store, or a regenerated file. The CREATE branch still carries a fresh verifier, for a role that does not exist yet. The start log says which roles were re-asserted.
  • Docs: the README's managed-provisioning paragraph; provision-roles.sql, which now recommends a verifier or psql's \password so a BYO store's passwords stay out of every statement too; and the Field stores cannot tell us which query is slow: preload pg_stat_statements in the managed store and surface it by role #3899 comments that said provisioning sends passwords every start.

Test plan

  • RFC 7677's worked exchange: ServerSignature over the RFC's own AuthMessage matches v=, and the server's client check (SHA-256 of ClientProof XOR ClientSignature equals StoredKey) holds for the derived keys.
  • The verifier round-trips and a fresh salt is used each time. Non-verifiers (MD5, plaintext, a truncated verifier, one carrying a quote, iterations 0) verify nothing.
  • The builder refuses seven non-verifier shapes, carries only verifiers of the generated passwords (never the passwords), and re-asserts only the roles it is told. The re-assert plan is pinned for a stored match, a different password, MD5, and a missing role.
  • Live on the bundled runtime (DARLING_TEST_PGRUNTIME), the real EnsureProvisionedAsync on a migrated cluster:
    • our verifier logs in and a wrong password is refused;
    • a verifier PostgreSQL computed itself verifies here;
    • the unmarked-role failure path's log carries verifiers and none of the three passwords;
    • every role logs in with its credential file;
    • a second start re-asserts nothing and leaves the stored verifiers byte-identical.
  • Full Darling.Tests (12,503) and Lite.Tests (5,124) after rebasing onto The store's statement text and store log keep no literal: a one-pass SQL lexer, an allowlist reader, masking for every retained log class, and stricter conf parsing (#3899) #3920 and Store-object convergence re-runs hourly on a plain-PostgreSQL store too, not only at restart (#3913) #3932. The only Darling failures are the three runtime-version pins my local rig (18.4) can't meet against dev's 18.6 pin. The one Lite failure was the known AnalysisPassTokenThreadingTests flake, which passes on re-run; this PR touches no Lite code.
  • After merge: DARLING01's first start with this build logs "Role passwords: unchanged" (its PostgreSQL-computed verifiers already accept the credential files), and the Viewer, the web dashboard and MCP still log in.

🤖 Generated with Claude Code

https://claude.ai/code/session_018Z4T51J4E6L8fjriPHpsif

…d only when one changes (#3910)

Managed provisioning interpolated every role's plaintext password into CREATE ROLE and ALTER ROLE ... PASSWORD
literals on every start, so any surface that records statement text could capture the live credentials: an
unmarked same-named role made the DO block raise every start, and log_min_error_statement wrote the whole
batch, passwords included, to the store's own log, which the store-log sweep keeps for 400 days and the viewer
role reads through get_store_log.

- ScramSha256Verifier computes PostgreSQL's SCRAM-SHA-256 verifier client-side (PBKDF2-HMAC-SHA-256 plus the
  RFC 5802/7677 keys). PostgreSQL stores a verifier as-is, which is what psql's \password does.
- BuildProvisioningSql takes verifiers and REFUSES anything else, a plain password first of all, so no future
  caller can send one again.
- EnsureProvisionedAsync reads pg_authid and re-asserts a role's password only when its stored verifier no longer
  accepts the credential file, so a steady-state start sends no PASSWORD clause at all.
- Docs: the README, provision-roles.sql (verifier or \password for BYO) and the #3899 comments that still said
  provisioning sends passwords every start.

Tests: the RFC 7677 worked exchange; the builder's refusals and per-role re-assert; the re-assert plan; and a live
test on the bundled runtime that runs the real provisioning twice. It checks the derivation against the server in
both directions and the unmarked-role failure path (the log carries verifiers, never a password). It also checks
that every role logs in with its credential file, and that a second start leaves the stored verifiers
byte-identical.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Z4T51J4E6L8fjriPHpsif
@erikdarlingdata
erikdarlingdata enabled auto-merge (squash) September 22, 2026 23:50
@erikdarlingdata
erikdarlingdata merged commit 9f47617 into dev Sep 22, 2026
15 of 16 checks passed
@erikdarlingdata
erikdarlingdata deleted the feature/3910-scram-verifier branch September 22, 2026 23:57
erikdarlingdata added a commit that referenced this pull request Sep 23, 2026
…3920, #3927, #3931, #3932, #3940, #3942, #3946, #3947, #3950, #3952, #3955, #3956, #3957, #3964, #3965, #3966, #3968, #3972, #3975, #3979, #3980, #3981, #3983, #3984, #3985) (#3989)

The wave's fix PRs deliberately carried no CHANGELOG edits (parallel-agent hot-spot protocol); each agent reported its entry and this commit lands them together, byte-verified against origin/dev.


Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant