Skip to content

Log-event hashes are keyed with a per-store secret the service keeps outside the store, so a store reader can no longer test guesses at the literals they hide (#4004) - #4020

Merged
erikdarlingdata merged 4 commits into
devfrom
fix/4004-hmac-log-hashes
Sep 23, 2026
Merged

erikdarlingdata merged 4 commits into
devfrom
fix/4004-hmac-log-hashes

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Closes #4004.

Why

collect.pg_log_events stored two unkeyed SHA-256 values over text a store reader can mostly rebuild:

  • raw_line_hash, over the raw log entry with its literals;
  • statement_fingerprint, over RedactStatement's output, which leaves $$ bodies and DO blocks as written.

Anyone who can read the row can enumerate candidate literals offline and test each one against the hash. #3996's review recovered a four-digit PIN from a failed UPDATE in 6 ms. Erik's ruling: use HMAC-SHA-256 with a per-store key that the service generates and keeps outside the database. New rows use it, and old rows keep their hash until retention drops them. No tool or viewer returns any hash.

What changes

  • PgLogHashKey (shared collectors) computes both identities as HMAC-SHA-256, with a separate subkey for each, truncated to the same 32 hex characters as before, so columns and reads are unchanged.

    • The unkeyed PgLogTextRedactor.Fingerprint/RawLineHash and the keyless PgLogEventClassifier.Default are deleted, so the compiler rejects any path that would hash without the key.
    • The classifier stamps every event it claims. The writer that both transports share refuses an unstamped event.
  • The key is loaded once, at worker start (DarlingLogHashKeyFile). The same instance goes to every run that hashes: pg_log_events on the pg_read_file route and on the RDS route.

    Distribution Where Protection
    Managed Windows log-hash-key.dpapi beside pg-credential.dpapi DPAPI LocalMachine; DarlingFileSecurity ACL (SYSTEM, Administrators, service account; no interactive read)
    Compose log-hash-key in the darling-credentials volume 0600 at creation; The web dashboard and MCP server connect to a compose or bring-your-own store as least-privilege roles, not as its owner (#3914) #3983's directory check (re-set to 0700, judged before/after) and file check (regular file, no group/other bits)
    Bring-your-own darling-keys/log-hash-key beside darling.json (.dpapi on Windows) Directory created owner-only, so the same checks hold; the operator's config directory is left alone
  • Missing and unreadable keys.

    • A key is generated only when nothing exists at the path. It is written owner-only from creation and moved into place without overwrite.
    • A key file that cannot be trusted, read, decrypted or parsed is refused, never replaced. The service logs the file, the reason and the remedy: fix access, or delete the file to rotate. pg_log_events then refuses on both routes with PgLogHashKey.UnavailableMessage, in BuildQuery, before it pulls 4 MB of log from the target.
    • A key found in a directory that other users could write to until this start is also refused. The web dashboard and MCP server connect to a compose or bring-your-own store as least-privilege roles, not as its owner (#3914) #3983 would regenerate there, but a key is never rotated silently.
  • No surface returns a hash. statement_fingerprint is removed from get_pg_log_events, from the reader's page record and SQL (the SQL no longer selects it), and from the web log tab. raw_line_hash was already removed by Log messages show as PostgreSQL wrote them, the SQL in them stays normalized, and one statement's text never decides how another's is read (#3944) #3996. The lock-wait fact still groups on the fingerprint inside the store and emits only the count.

  • Old rows keep their unkeyed hashes until the 30-day retention drops them. The first cycle after the upgrade stores each entry still in the tail's overlap window once more, and each statement shape's fingerprint changes once. This is the one-time discontinuity PostgreSQL deadlock reports store and show their SQL normalized, and no read returns a hash of the raw report (#4005) #4013 accepted.

  • Hash census, nothing else to key.

    None of these has a hidden input.

  • Lite collects no PostgreSQL logs and hashes no raw log or statement text. Its only text hash is DuckDB hash(query_text) inside a COUNT(DISTINCT) over stored text, which is never stored or returned. So Lite gets no counterpart.

  • Docs: the README covers the key in its credential-file table and in a new paragraph, and the compose file and Dockerfile comments name it.

  • In-lane test fixes: AuroraOnlySqlIsGatedTests (Lite) and FleetIdentifierScrubTests (Darling) judged their .claude exclusion on the absolute path, so they scanned zero files when the repo was checked out under .claude/worktrees/. Both now judge the path below the repo root, and the repo's own .claude folder is still excluded. PgServerLogPathPinTests now gives its context a key.

Test plan

  • PgLogHashKeyTests (new, 11 tests):

    • the HMAC differs from the unkeyed SHA-256 and is stable for the same key material (a restart);
    • two stores' keys give different hashes, on both columns and through the classifier;
    • the PIN attack: 10,000 guesses give 0 hits with the unkeyed hash and 0 with a wrong key, and the control hits with the right key;
    • the classifier stamps events, and the writer refuses an unstamped event;
    • BuildQuery refuses without a key;
    • the key is 32 bytes, and neither ToString prints key material;
    • a missing key is generated once, owner-only (Windows ACL via DarlingFileSecurity, or Unix mode 0600), and the same key comes back after a restart; on Windows the file is a DPAPI blob;
    • an unreadable key is refused and left byte-identical, a key ordinary users can read is refused and left byte-identical, and a directory at the path is refused and not overwritten;
    • the key sits beside each distribution's other secrets.
  • Red-watch: with the raw-line hash reverted to unkeyed SHA-256, 3 of the 11 tests fail (the unkeyed-difference, two-stores and PIN-attack tests). Restored, all 11 pass.

  • The existing no-hash pin in PgLogEventsPipelineTests is extended to statement_fingerprint in the tool, the reader's row type and the web tab.

  • Targeted classes, including live ones on a PG 18.6 + TimescaleDB 2.30.1 rig: PgLogEventsPipelineTests, PgLogEventMetricsTests, PgTargetBlockingTests, PostgresEngineGateBehaviorTests, DarlingCollectorRunnerTests, ComposeStoreRolesLiveTests, DarlingStoreLoginsTests, DarlingManagedRolesTests, DarlingFileSecurityTests, ServerPageTabsTests, McpPayloadContractCensusTests, DocCommentHygiene.

  • Full Darling.Tests with DARLING_TEST_PG: 13,025 tests, 16 skipped (environment-gated), 4 failed. None of the four is caused by this change:

    • FleetIdentifierScrubTests (2): the worktree-path exclusion, fixed in this PR.
    • RefreshCeilingStalenessTests.TheSpacingFinding_IsCalledPerLightRefresh_OnTheSameWatch: it expects CRLF in DarlingWorker.cs, and my working copy was briefly LF. The committed content is unaffected.
    • TrendPayloadBudgetLiveTests: a transient Exception while reading from stream from the rig on get_pg_io_trend.

    Re-run after the fixes, those classes plus the touched ones pass 215/215.

  • Full Lite.Tests, since the change touches the shared collectors: 5,173 tests, 4 failed, all in AuroraOnlySqlIsGatedTests (the worktree-path exclusion, fixed in this PR). Re-run 9/9.

  • Both builds report 0 Warning(s).

🤖 Generated with Claude Code

https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv

erikdarlingdata and others added 4 commits September 23, 2026 07:21
…outside the store, so a store reader can no longer test guesses at the literals they hide (#4004)

raw_line_hash and statement_fingerprint were unkeyed SHA-256 over text a store reader can mostly
rebuild, an offline guessing oracle for the literals (a PIN came back in 6 ms). Both are now
HMAC-SHA-256 under a 32-byte key, with a separate subkey per identity, at the same 32-hex width.

- PgLogHashKey (shared collectors) computes both; the unkeyed PgLogTextRedactor functions and the
  keyless PgLogEventClassifier.Default are gone, so nothing can hash without the key. The classifier
  stamps every claimed event; the writer both transports use refuses an unstamped one.
- The service loads the key once at start (DarlingLogHashKeyFile): managed Windows beside
  pg-credential.dpapi as a DPAPI blob with the hardened ACL; compose in the darling-credentials
  volume, 0600, under #3983's directory and file trust checks; bring-your-own in darling-keys beside
  darling.json. Generated only when nothing is at the path; an existing key that cannot be trusted,
  read or parsed is refused, never replaced, and pg_log_events on both routes refuses with a clear
  message (before querying the target) until an operator fixes or deletes the file.
- No surface returns either hash: statement_fingerprint leaves get_pg_log_events, the reader's
  page and the web log tab.
- Old rows keep their unkeyed hashes until retention drops them: a one-time dedupe and fingerprint
  discontinuity, as #4013 accepted. Lite collects no PostgreSQL logs and hashes no raw log or
  statement text, so it has no counterpart.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv
…ges its exclusions below the repo root (#4004)

PgServerLogPathPinTests builds pg_log_events' query, which now refuses without the store's
log-hash key, so its context carries one. AuroraOnlySqlIsGatedTests excluded any path containing
a .claude directory, which is every file of a checkout that itself lives under .claude/worktrees,
so it scanned nothing and failed as "nothing names the surface"; the exclusions now apply to the
path below the repo root.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv
… so a checkout under .claude/worktrees scans its tree (#4004)

Every exclusion matched the absolute path, and the .claude one matched every file of a checkout
that itself lives under .claude/worktrees, so the sweep scanned nothing and failed as "the sweep
lost the tree". The repo's own .claude folder is still excluded.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv
erikdarlingdata added a commit that referenced this pull request Sep 23, 2026
…ne's worktree (#4021)

An isolated lane (#4020's) was refused a write to the coordinator's
scratchpad, so its report came back only as its final message. Isolated
worktrees also sit under .claude/worktrees/ inside the repo, which two
repo-scanning tests excluded (fixed in #4020).


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

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@erikdarlingdata
erikdarlingdata merged commit 960a445 into dev Sep 23, 2026
15 of 16 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/4004-hmac-log-hashes branch September 23, 2026 11:46
erikdarlingdata added a commit that referenced this pull request Sep 23, 2026
…draft while its review round runs

#4020 and #4022 were merged from the UI mid-review on 2026-09-23. A draft
can't be merged there, so the coordinator drafts the PR at review dispatch and
readies it once the review is resolved.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv
erikdarlingdata added a commit that referenced this pull request Sep 23, 2026
…h database first, and recurring CI failures get a census (#4026)

* Lane orders: the report goes in the PR body, a failure is re-run on a fresh database before it is called pre-existing, and a recurring CI failure gets a census first

- All three of the last lanes were blocked from writing a report file (one
  outside its worktree, two by a policy that subagents return text), so the
  full report now lives in the PR body, or an issue comment for a parked
  item, and the last message is 400 words or fewer.
- A lane filed an issue for a sweep-test failure that was already fixed on
  dev, and called two tests pre-existing that pass on a fresh database.
- The coordinator censuses two days of failed first attempts before fixing a
  flake, and fixes every flake found in one PR.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv

* Lane orders: a security PR or irreversible data rewrite is held as a draft while its review round runs

#4020 and #4022 were merged from the UI mid-review on 2026-09-23. A draft
can't be merged there, so the coordinator drafts the PR at review dispatch and
readies it once the review is resolved.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
erikdarlingdata added a commit that referenced this pull request Sep 23, 2026
… one after verifying it (#4033)

On 2026-09-23 three lane PRs opened ready (#4020, #4022, #4030) were merged
from the UI before the coordinator's verification finished, and #4030 put a
regression on dev (every enabled trace flag hidden; fixed in #4032). A draft
can't be merged from the UI, so a lane now opens a draft, lists anything it
didn't run as an unchecked box, and the coordinator readies it after the
checklist read (and a review round for security PRs).


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

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
erikdarlingdata added a commit that referenced this pull request Sep 23, 2026
…g_events rows from before #4020 age out per #4004), update the alert-read exemption, and name the keyed-alert tag so the state-key census does not take it for a state key (#4012)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv
erikdarlingdata added a commit that referenced this pull request Sep 23, 2026
… walks pages to its hourly budget, and a failing stage no longer blocks the rest (#4012) (#4036)

* Deadlock re-mask: a missed alert is keyed, never keeps its raw hash; each tick walks pages to its budget; stages run alerts, reports, alert keys, findings and are done only after a clean walk (#4012)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv

* Deadlock re-mask: narrow the README claim to what is rewritten (pg_log_events rows from before #4020 age out per #4004), update the alert-read exemption, and name the keyed-alert tag so the state-key census does not take it for a state key (#4012)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv

* Deadlock re-mask round 2: runs without a log-hash key (only the alert keys wait), counts each row's outcome, re-masks finding alerts, finds exemplars anywhere on the chain, retries a given-up stage daily, and binds no raw report hash (#4012, #4035)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv

* Deadlock re-mask round 2: keep FlattenInto's doc block on FlattenInto, and give TopVictimFingerprint a block body the member scan reads whole (#4012)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv

* Deadlock re-mask round-2 fixes: the day-later retry runs, and no alert is cut from its report (#4012)

- The worker gates the re-mask on RemaskProgress.Pending (a stage not done, or one that gave up), not on Done,
  which a stage that gave up satisfies: gated on Done the daily retry never ran (round-2 finding 1).
- The reports wait for the alerts to be done (clean or given up), not merely waiting out a failure, and the alert
  keys wait for a clean report walk, not a report stage that gave up (round-2 finding 2 a/b).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv

* Deadlock re-mask round-2 fixes: rebuilt incidents are marked, and a gone report's key is keyed in the alert stage's own write (#4012)

- An incident rebuilt from its raw report carries a RemaskRebuilt4012 property, its own member rather than part of
  the key, so no walk keys it while its report is still raw; the key, dedupe and every reader are unchanged
  (round-2 finding 2c).
- With the key, the alert stage keys an incident whose report is gone in the same write that normalizes its row,
  so its raw hash is never bound back to the store; only in a row no pass has rewritten, so an unmarked incident
  #4022's dev pass rebuilt waits for the alert-key stage (round-2 finding 3).
- A live test records every value the re-mask's UPDATEs bind, through triggers, and finds no raw hash or literal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv

* Deadlock re-mask round-2 fix: a finding alert is rewritten only from the run it was sent for (#4012)

The finding alert's exemplar fields other than the SQL are counts and fixed text, so another stored run of the same
story, newer but before the alert was recorded, passed as its finding and its exemplars were written over the
alert's. The alert's Diagnosis must now read the finding's severity, confidence and window as the formatter
rendered them; otherwise the SQL is withheld (round-2 finding 5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv

* Deadlock re-mask round-2 fixes: walks page by alert_time and finding_id, and the finding-alert page skips rows older than #3721 (#4012)

- The alert, alert-key and finding-alert walks page config_alert_log by alert_time WITH TIES, and the finding walk
  pages analysis_findings by finding_id WITH TIES; ctid and xmin are kept only for the write. A row copy that a
  dismissal, VACUUM FULL or CLUSTER put behind a ctid cursor was never read, and the stage was marked done over it
  (round-2 finding 4). Each row's callback moves the cursor only once its tie group is finished.
- The finding-alert page reads no row recorded before 2026-09-18 (a day before #3721's first commit, for a store
  that records local time), and that test guards the text tests, so an older row's context is never detoasted
  (round-2 finding 6).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdmA4ND1wLSqA91ax1m4xv

* Deadlock re-mask round-2: the moved-row test runs without a key, so only the alert stage can reach the row (#4012)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
erikdarlingdata added a commit that referenced this pull request Sep 23, 2026
…ntries in their sections (#4080)

Adds 42 entries and 42 link refs (#3992, #3995, #3996, #3998, #4001, #4002, #4003, #4007, #4010, #4011, #4013, #4015, #4020, #4022, #4025, #4029, #4030, #4031, #4036, #4038, #4039, #4040, #4044, #4047, #4048, #4049, #4050, #4051, #4055, #4061, #4063, #4064, #4065, #4066, #4067, #4068, #4069, #4070, #4071, #4073, #4074, #4078). Each PR's entry was buffered, and this lands every entry whose PR was merged on origin/dev when it ran.

#3989 left 26 entries under bare 'Changed' and 'Fixed' lines above '### Added'. They move into '### Changed' and '### Fixed', below the new entries, and one blank line stays under [Unreleased].


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

Co-authored-by: Claude Opus 5.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