Skip to content

Never let a degraded RNG path succeed quietly - #932

Merged
kwsantiago merged 3 commits into
mainfrom
rng-hygiene-guard
Aug 1, 2026
Merged

Never let a degraded RNG path succeed quietly#932
kwsantiago merged 3 commits into
mainfrom
rng-hygiene-guard

Conversation

@kwsantiago

@kwsantiago kwsantiago commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Audit of the workspace against the bug class in Block's COLDCARD predictable-RNG disclosure: a degraded RNG path that succeeds silently.
  • The Rust crypto paths came back clean, and that is the main result. Every FROST nonce, key, salt, and AEAD nonce reaches the OS RNG through a path that fails loudly: rand::rng() panics on init failure and (since rand 0.9.3) on reseed failure, rand_core::OsRng::fill_bytes panics, getrandom::fill results are propagated with ? at every one of the sixteen production call sites, and keep-core::entropy gates every public entry on ensure_entropy_health(), re-validated on pid change and every 4096 generations. No #ifndef-on-a-value equivalent, no seeded PRNG standing in for the OS RNG, no zeroed-buffer fallback, and nothing security-relevant in keep-agent-py, keep-agent-ts, keep-web/ui, or deploy/.
  • What the audit did find is two attested-hardware entropy sources that could vanish without a word, and three mock sites that continued with a zero-filled buffer.

What changed

The NSM fallbacks are no longer silent. keep-agent::get_entropy returns OS randomness when a Nitro Secure Module request fails, and the enclave binary's getrandom does the same when the NSM device is not there at all. Neither is cryptographically weak (an enclave's OS RNG is itself NSM-seeded) and neither should become a hard error, because outside an enclave the fallback is the only path that works and dev boxes are target_os = "linux" too. But losing the attested source while still handing back bytes is exactly the shape this audit is about, so both now say so: tracing::error! on the per-request failure, and a Once-guarded warning for the missing device, since key generation calls it in a loop.

keep-enclave-host's mock backend no longer hands back constants. create_mock_attestation_document, create_mock_certificate, and rand_u64 each warned and continued on an RNG failure, leaving their [0u8; N] buffers untouched: a well-formed attestation document whose signature, certificate, and module id are all fixed. MockEnclaveClient is exported from the crate's public API, and its caller already had an error branch, so all three now return a Result into it.

The CI guard. scripts/check-rng-hygiene.sh plus an rng-hygiene workflow pin four shapes: an unhandled getrandom/try_fill_bytes error, an RNG failure collapsed into a value (.ok(), unwrap_or, unwrap_or_default), a seeded PRNG in production code, and any public entry point in keep-core/src/entropy.rs that reaches the unchecked mixer without ensure_entropy_health()?. That last rule is the load-bearing one: the mixer folds timing jitter and process context in with the OS bytes, so its output looks random even when a source has degraded. (The review round below found the gate itself had that same blind spot, and fixes it.) A docs/SECURITY.md Randomness section states the rule and the guard's blind spots.

Review notes

The guard is deliberately more forgiving than a ?-only rule. if let Err(e) = getrandom::fill(..) { return Err(..) } and match rng() { .., Err(e) => return .. } handle a failure just as well, and flagging them would push people to rewrite correct code to satisfy a grep. Deciding that needs the block body, so the script slurps each file and scans forward for an exit before judging. #[cfg(test)] bodies are skipped by brace depth, not by filename, because unit tests live inline in src/ throughout this workspace and a filename filter would either miss them or exclude the production code sitting beside them.

Decision log

Hardest decision: whether to make the NSM fallbacks fail closed. They should not. The enclave binary compiles and runs on ordinary Linux where nsm_init() legitimately fails, so an error there breaks every local test and dev run to defend against a case the type system cannot distinguish. Logging is the honest fix: the fallback is sound, the silence was not.

Alternatives rejected:

  • A rule banning the panicking keep_core::crypto::random_bytes() in production. There are nine production call sites, and the migration to try_random_bytes is separately tracked (keep-core: RNG health-check failure panics in key/secret generation instead of returning Result #685). A panic is fail-loud, so those sites are the wrong bug class for this PR; folding a nine-site migration in would bury the actual findings.
  • Reusing the upstream statement-scoped grep unchanged. It treats anything without ?/expect/unwrap as a defect, which flags three correct sites in this workspace, and a guard that cries wolf on correct code is a guard people learn to skip.

Least confident about: rule 4 is structural, not semantic. It checks that ensure_entropy_health()? is called before the mixer is reached, not that the health check itself is sound. That gap was not hypothetical -- see the review round -- and the answer was a test rather than a smarter grep. Stated in the script header and in docs/SECURITY.md rather than left implied.

What is not verified

The three fixed mock sites have no test that pins them: getrandom::fill cannot be made to fail without injecting a seam, so the old code passes the suite too. That is the defect being fixed, not an excuse. The #[cfg(feature = "tracing")] arm of the enclave warning was compile-checked, not run, and no test observes either log line.

Test plan

  • cargo check/clippy -p keep-enclave-host -p keep-agent --all-targets: clean, no new warnings
  • cargo check -p keep-agent --features enclave: clean (needs a newer toolchain than the 1.89 MSRV pin, because a transitive nsm-api dependency requires it; verified on 1.96)
  • keep-enclave/enclave (workspace-excluded) checks and clippies clean with and without the tracing feature
  • cargo test -p keep-enclave-host -p keep-agent: 61 passed, 0 failed
  • cargo fmt --check clean
  • scripts/check-rng-hygiene.sh exits 0 on the tree
  • Guard verified by reintroduction (superseded by the fuller matrix in the review round below)

Review round

A security review and a production review ran over the branch. The security review's job was to falsify the "everything else is already clean" claim above, and it half succeeded: the narrow claim held (no zeroed-buffer fallback, no seeded PRNG, no time-derived key material anywhere in production Rust), but it found the claim's justification was wrong, and a separate signing-path defect.

The entropy gate could not see a degraded OS source. check_entropy_health_internal sampled random_bytes_mixed_internal(), the post-mix output. Every mix folds in a monotonically incrementing counter, 64 timing deltas, and live heap and stack addresses, so three post-mix samples come out non-zero, distinct, and roughly 128 bits apart no matter what the OS pool contained. Substituting a constant for the OS entropy left every criterion satisfied. The gate was checking the combiner, not the source, which is the same shape as the bug the check exists to catch, and the docs/SECURITY.md paragraph I wrote asserted the opposite.

check_entropy_health_internal now samples gather_os_entropy directly, before anything is mixed in, and checks the mixed output as well (that catches a broken mixer rather than a broken source). mixing_hides_a_dead_os_source pins the masking: it mixes a deliberately all-zero OS pool with the real jitter and context and asserts the result still passes every criterion, so if anyone reverts the pre-mix check the reason it exists is written down in an executing test.

The NSM-unavailable case this PR set out to report was still silent. is_nitro_enclave() is defined as "nsm_init() succeeded", cached for the process lifetime, so the likelier fault -- device node missing, or the open failing under fd pressure -- made it return false, skipped the enclave branch entirely, and used the OS RNG with nothing logged. Detecting enclave-ness by probing the very device whose loss you want to hear about cannot distinguish the two; building with the enclave feature is the statement that an NSM is expected, so a failed probe now warns once from there. The per-draw tracing::error! stays per-draw and says why in a comment: unlike a missing device, a failing request is transient and its rate is the signal.

A partial NSM fill was topping up from the OS RNG in silence. getrandom in the enclave binary copied a short GetRandom response and filled the remainder from ::getrandom, with no report -- a partial downgrade of the attested entropy on every draw larger than one response, which is every 32-byte key. It now loops the NSM for the remainder, matching what keep-agent's fill_from_nsm already did, with an empty response treated as a failure rather than an infinite loop.

Five working bypasses in the guard, plus two false positives, each now covered and verified. These mattered more than they look, because a guard nobody can trust is the thing this PR is arguing against:

  • match rng() { Ok(()) => return k, Err(_) => {} } read as handled, because the block scan looked at the whole block including the Ok arm. It now narrows to the error arm, splitting single-line match arms at the =>.
  • An exit keyword inside a log string counted as an error branch. String literals are blanked before matching now.
  • let _ = rng(&mut k).map_err(|e| f(n)?) passed on the nested ?. Propagation now has to survive stripping balanced paren groups, so it must belong to the RNG call.
  • try_random_bytes()\n .unwrap_or_default() escaped rule 2 because it was a single-line grep. Rule 2 now detects on the call name and judges the assembled statement.
  • fn getrandom(buf) { let _ = ::getrandom::fill(buf); } passed, because the fn-declaration filter dropped the whole line. It now skips past the declaration and keeps searching, which is what its comment always claimed.
  • A #[cfg(test)] on a brace-less item latched the skip onto the next brace block. There is a live instance of that shape at keep-enclave/host/src/mock.rs:16; today it only swallows a struct, but it would have exempted an impl block wholesale.
  • The guard failed open: awk's stderr went to /dev/null behind || true, so a broken awk reported a clean tree, and xargs -r is GNU-only so rule 2 was a silent no-op on macOS. Verified by running with a stub awk that exits 2. It now fails closed on a scanner error, a non-git tree, or an empty file list, and rule 4 additionally fails if it finds no gated entry point at all, which is what happens when the module is reshaped and the rule quietly stops meaning anything.
  • keep-web/src/bunker.rs:56 is correct fail-closed code (.ok() into an Option the caller refuses on) that only escaped the old rule because rustfmt split the line. It now carries an explicit // rng-hygiene: ok marker naming the reason, rather than passing by accident.

Also from the reviews: the workflow now pins actions/checkout by commit SHA and sets permissions: contents: read, matching ci.yml and release.yml; two ciborium::into_writer(..).unwrap_or_default() calls in the same mock function -- the identical error-into-default shape this PR removes from the RNG calls beside them -- now propagate; and docs/SECURITY.md names the keep_core::crypto::random_bytes exception (it panics rather than returning a Result, nine production call sites, migration tracked in #685) instead of asserting a convention the code does not fully follow.

Filed separately, not in this PR: the security review found that NonceStore::check_and_add_nonce could never reject a reused FROST nonce commitment, because its reject branch tested a flag nothing ever set. That is a different bug class in a different subsystem and it is #933.

Test plan (updated)

  • cargo test -p keep-core --lib entropy: 12 passed, including the two new ones
  • cargo test -p keep-enclave-host -p keep-agent -p keep-web: 115 passed, 0 failed
  • cargo clippy clean on the changed crates, cargo fmt --check clean, keep-enclave/enclave (workspace-excluded) checks clean with and without the tracing feature
  • cargo check -p keep-agent --features enclave clean (needs a newer toolchain than the 1.89 MSRV pin because of a transitive nsm-api dependency; verified on 1.96)
  • Guard: 16-case matrix, all passing. Flags the five demonstrated bypasses, a multi-line match whose Err arm only logs, the original warn-and-continue defect, let _ =, .ok(), and StdRng::seed_from_u64. Stays clean on if let Err(e) = .. { return Err(..) }, both one-line and multi-line match with a returning Err arm, .map_err(..)?, an opt-out marker, and code inside #[cfg(test)]
  • Guard fails closed: stub awk exiting 2, and running outside a git work tree, both report failure instead of OK

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@kwsantiago, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 20 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: cd6b58cd-ea7c-44a6-b9b7-ea8144718b41

📥 Commits

Reviewing files that changed from the base of the PR and between f700500 and 5962175.

📒 Files selected for processing (8)
  • .github/workflows/rng-hygiene.yml
  • docs/SECURITY.md
  • keep-agent/src/entropy.rs
  • keep-core/src/entropy.rs
  • keep-enclave/enclave/src/signer.rs
  • keep-enclave/host/src/mock.rs
  • keep-web/src/bunker.rs
  • scripts/check-rng-hygiene.sh

Walkthrough

This PR reports NSM entropy fallbacks, propagates RNG failures in mock attestation generation, and adds a tracked-source scanner with GitHub Actions enforcement and security documentation.

Changes

RNG hygiene and failure handling

Layer / File(s) Summary
Runtime entropy fallback reporting
keep-agent/src/entropy.rs, keep-enclave/enclave/src/signer.rs
NSM entropy failures now produce explicit, one-time reports before OS RNG fallback.
Mock attestation error propagation
keep-enclave/host/src/mock.rs
Mock attestation, certificate, signature, module-ID, and integer generation now return RNG failures.
Hygiene enforcement and documentation
scripts/check-rng-hygiene.sh, .github/workflows/rng-hygiene.yml, docs/SECURITY.md
The scanner detects unhandled or reproducible RNG usage and missing entropy checks. CI runs the scanner, and security documentation describes the required controls.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • privkeyio/keep#41: Introduced the entropy source later updated to report NSM failures.
  • privkeyio/keep#686: Addresses related RNG and entropy failure handling in different code paths.

Poem

A rabbit checks the random stream,
And logs each fallback bright;
Mock errors now hop upward,
No zero bytes in sight.
CI guards the burrow tight.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the primary change: preventing degraded RNG paths from succeeding without reporting failures.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch rng-hygiene-guard

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/rng-hygiene.yml:
- Around line 22-29: Harden the check job around the actions/checkout step and
execution of check-rng-hygiene.sh by disabling persisted checkout credentials
and declaring an explicit least-privilege permissions block for the job, with
all token permissions denied. Keep the existing checkout and script execution
behavior unchanged.

In `@scripts/check-rng-hygiene.sh`:
- Around line 172-186: Update the rule 2 `swallow_bad` scan to reuse or extract
the statement-joining logic from `scan_rust`, ensuring `fill_bytes` and
`random_bytes` calls followed by `unwrap_or`, `unwrap_or_default`,
`unwrap_or_else`, or `ok` on later lines are detected. Preserve the existing
comment filtering, opt-out filtering, and failure reporting.
- Around line 46-47: Guard the directory change immediately after the shell
options so a failed cd "$(dirname "$0")/.." terminates the script with a nonzero
status. Keep the existing source-listing and rule-check logic unchanged,
ensuring the script cannot report success from the wrong working directory.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7d317635-16d1-40b0-aa1f-0c6d612bec68

📥 Commits

Reviewing files that changed from the base of the PR and between 9cfe24a and f700500.

📒 Files selected for processing (6)
  • .github/workflows/rng-hygiene.yml
  • docs/SECURITY.md
  • keep-agent/src/entropy.rs
  • keep-enclave/enclave/src/signer.rs
  • keep-enclave/host/src/mock.rs
  • scripts/check-rng-hygiene.sh

Comment on lines +22 to +29
check:
name: No predictable-RNG fallbacks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Check for silent RNG fallbacks
run: ./scripts/check-rng-hygiene.sh

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Restrict the token available to a job that runs PR-branch-controlled code.

This job checks out and executes scripts/check-rng-hygiene.sh from the PR's own branch. A PR author can modify that script. actions/checkout@v4 defaults to persist-credentials: true, leaving the ambient GITHUB_TOKEN in .git/config for that PR-controlled script to read. No permissions: block is set either, so the default token scope applies to the whole job.

🔒️ Proposed fix
 jobs:
   check:
     name: No predictable-RNG fallbacks
     runs-on: ubuntu-latest
+    permissions:
+      contents: read
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@v4
+        with:
+          persist-credentials: false

       - name: Check for silent RNG fallbacks
         run: ./scripts/check-rng-hygiene.sh
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
check:
name: No predictable-RNG fallbacks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check for silent RNG fallbacks
run: ./scripts/check-rng-hygiene.sh
check:
name: No predictable-RNG fallbacks
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Check for silent RNG fallbacks
run: ./scripts/check-rng-hygiene.sh
🧰 Tools
🪛 zizmor (1.28.0)

[warning] 26-26: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)


[warning] 22-30: overly broad permissions (excessive-permissions): default permissions used due to no permissions: block

(excessive-permissions)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/rng-hygiene.yml around lines 22 - 29, Harden the check job
around the actions/checkout step and execution of check-rng-hygiene.sh by
disabling persisted checkout credentials and declaring an explicit
least-privilege permissions block for the job, with all token permissions
denied. Keep the existing checkout and script execution behavior unchanged.

Source: Linters/SAST tools

Comment thread scripts/check-rng-hygiene.sh Outdated
Comment thread scripts/check-rng-hygiene.sh Outdated
@kwsantiago
kwsantiago merged commit 95e7626 into main Aug 1, 2026
11 checks passed
@kwsantiago
kwsantiago deleted the rng-hygiene-guard branch August 1, 2026 01:51
@coderabbitai coderabbitai Bot mentioned this pull request Aug 2, 2026
6 tasks
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