Skip to content

Stage an owner role's SCRAM-SHA-256 verifier, never its password - #156

Merged
shipmill-romamo[bot] merged 1 commit into
mainfrom
fix/140-scram-verifier
Oct 30, 2026
Merged

shipmill-romamo[bot] merged 1 commit into
mainfrom
fix/140-scram-verifier

Conversation

@shipmill-romamo

Copy link
Copy Markdown
Contributor

Summary

cloudfall_postgresql staged an owner role's plaintext password on the host and ran ALTER ROLE ... PASSWORD :'cloudfall_password' with it. psql substitutes the variable into the statement text, so the server received the plaintext and could write it to its log (log_statement = 'ddl'/'all', or a failing statement) or show it in pg_stat_activity.

The controller now stages the password's SCRAM-SHA-256 verifier instead (D-9, pending in #155):

  • cloudfall.secrets.scram_sha256_verifier(password, *, salt=None) computes PostgreSQL's stored format, SCRAM-SHA-256$4096:<salt>$<StoredKey>:<ServerKey>, with the standard library: a fresh 16-byte secrets.token_bytes salt, PBKDF2-HMAC-SHA256 at 4096 iterations, and the password prepared with SASLprep the way pg_saslprep does it (ASCII passwords as they are; for others, non-ASCII spaces map to a space, map-to-nothing characters drop, NFKC; a prohibited, unassigned or bidi-invalid result falls back to the password as given, as PostgreSQL does), so a client still logs in with a non-ASCII password
  • staged_password_files writes that verifier to the same 0600 file in the same 0700 private temp dir, removed when the run ends. Every path that runs services.yml (the converge_services MCP tool, cloudfall migrate, operator approve/run, cloudfall-engine playbook run services.yml) goes through it, so all switch
  • owner_password.yml is unchanged in what it runs: psql reads the staged file and sends ALTER ROLE ... PASSWORD with the verifier, which PostgreSQL stores as it is. The comment and two task names now say it stages the verifier. SET password_encryption is left in place: it no longer matters, and it does no harm
  • Answers and receipts still name the reference, the key, and the password's SHA-256 (S-006-6 unchanged)
  • Spec 006 prose and docs/secrets-guide.md now say only the verifier reaches the host

Plan

  • Serves: quality bar
  • Files: sdk/src/cloudfall/secrets.py, sdk/tests/test_secrets.py, sdk/tests/test_owned_postgresql_converge.py, engine/ansible/roles/cloudfall_postgresql/tasks/owner_password.yml, engine/tests/test_postgresql_owner_role.py, docs/specs/006-standalone-postgresql.md, docs/secrets-guide.md, changelog.d/140-scram-verifier.md
  • Order: failing tests first (the staged file holds a SCRAM-SHA-256 verifier of the password and never the password; the verifier matches a known vector with an injected salt), then scram_sha256_verifier in secrets.py and staged_password_files writing it, then the task's comments and names, the spec text, and the changelog fragment
  • Proof: sdk/tests/test_secrets.py (verifier format, known vector, verifies the password), sdk/tests/test_owned_postgresql_converge.py (every converge path stages the verifier, S-006-6), engine/tests/test_postgresql_owner_role.py (psql reads the verifier from the removed 0600 file, S-006-5, S-006-6)

Test plan

  • New sdk/tests/test_secrets.py tests: the verifier with RFC 7677's salt for password pencil authenticates RFC 7677's published SCRAM-SHA-256 exchange (the proof recovers a ClientKey hashing to StoredKey, and ServerKey reproduces the RFC's ServerSignature), an independent known vector; a fresh salt per call, 16 bytes, recomputing from the verifier's salt and iterations verifies the password and not another, and the verifier never contains the password; SASLprep cases (no-break space, zero-width space, soft hyphen, NFKC, a prohibited character kept, ASCII control kept); an empty salt is refused. These failed on main (import error) before the fix
  • sdk/tests/test_owned_postgresql_converge.py: the converge, operator approve and migrate paths now assert the staged file holds a verifier of the password, never the password (they asserted the plaintext before)
  • engine/tests/test_postgresql_owner_role.py: the localhost replay stages a verifier; psql reads exactly it from the 0600 file, the file is removed, and neither the password nor the verifier is in Ansible output, psql's stdin or argv
  • Ran locally: uv run ruff check ., uv run mypy, uv run pytest engine/tests/test_postgresql_owner_role.py engine/tests/test_services_passwords.py sdk/tests/ (662 passed), ANSIBLE_CONFIG=engine/ansible/ansible.cfg ansible-lint engine/ansible (0 failures). The full suite is left to CI
  • Not verified: a live PostgreSQL accepting the verifier and a login with the password. The format is checked against RFC 7677, not against a server

Fixes #140

🤖 Generated with Claude Code

The cloudfall_postgresql role staged the plaintext owner password on the
host and sent ALTER ROLE ... PASSWORD with it; psql substitutes the value
into the statement text, so the server received the plaintext and could
log it (log_statement ddl or all, a failing statement) or show it in
pg_stat_activity.

The controller now computes the SCRAM-SHA-256 verifier with the standard
library (fresh 16-byte salt, 4096 iterations, SASLprep as pg_saslprep
does) in PostgreSQL's stored format and stages it in the same 0600 file;
PostgreSQL stores a verifier as it is. Every path that runs services.yml
goes through staged_password_files, so all switch. Answers and receipts
still name the reference, the key and the password's SHA-256 (D-9).

Fixes #140

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@shipmill-romamo
shipmill-romamo Bot merged commit b019cab into main Oct 30, 2026
1 check passed
@shipmill-romamo
shipmill-romamo Bot deleted the fix/140-scram-verifier branch October 30, 2026 13:07
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.

The owner password reaches PostgreSQL in plaintext inside ALTER ROLE

0 participants