Skip to content

Managed role provisioning re-sends every role's plaintext password each start; a raising DO block puts all three in the store log, which viewer can read #3910

Closed

Description

@erikdarlingdata

Summary

Managed-mode role provisioning re-sends 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>' (Darling/PerformanceMonitor.Darling.Service/DarlingManagedRoles.cs, sections 1 and 1b). Any surface that records statement text can therefore capture the live credentials of all three login roles.

This predates #3899 and was surfaced by the adversarial review of its PR (#3904). That PR shields its own surface (pg_stat_statements.track_utility = off, a role-DDL-refusing reader, a scrub), but the root cause is the plaintext on the wire. Every other text-capturing surface still sees it.

The concrete exposure today

  • If an unmarked role of the same name exists (for example, an operator created their own mcp), the DO block raises on every start.
  • With the default log_min_error_statement = error, PostgreSQL then writes STATEMENT: plus the whole DO text, carrying all three live passwords, to the store's own log.
  • StoreLogClassifier retains that ERROR under unclassified, continuation lines included. StoreLogSweep keeps it for 400 days.
  • A viewer seat can read it through get_store_log or /api/read/get_store_log. That is enough to log in as admin, the config writer.

Any operator-enabled statement logging on the owner role (log_statement = 'ddl', log_min_duration_statement = 0, auto_explain) produces the same capture without an error.

Fix shape

  1. Send a SCRAM-SHA-256 verifier, not the password. PostgreSQL stores a pre-hashed SCRAM-SHA-256$<iter>:<salt>$<StoredKey>:<ServerKey> value as-is when it is sent as the PASSWORD string. The verifier can be computed client-side with PBKDF2-HMAC-SHA256 and the RFC 5802/7677 key derivation; Rfc2898DeriveBytes.Pbkdf2 covers the expensive half. The passwords are 32 random alphanumerics (~190 bits), so a leaked verifier is not brute-forceable, and every capture surface then carries a hash instead of a credential.
  2. Skip an unchanged re-assert. Provisioning runs as a superuser and can read pg_authid.rolpassword, so it can check whether the stored verifier already matches the credential file, and only re-send the password when it does not.
  3. Once (1) lands, the Field stores cannot tell us which query is slow: preload pg_stat_statements in the managed store and surface it by role #3899 defenses become belt-and-braces rather than load-bearing: track_utility could even return to its default if utility statements (the collectors' COPY ingest) are wanted in the ranking.

Tests

  • Unit: the verifier's format and that it round-trips (RFC 7677 test vector).
  • Live: a role provisioned with a verifier can still log in with its password.
  • A pin that no statement BuildProvisioningSql renders contains the plaintext password.

The BYO provision-roles.sql has the same shape with operator-typed placeholders, so the README should recommend a verifier there too.

Activity

  1. erikdarlingdata commented on Sep 22, 2026

    @erikdarlingdata
    OwnerAuthor

    Closed by the watcher: delivered in PR #3940, merged to dev.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions