-
Notifications
You must be signed in to change notification settings - Fork 0
Wait for the CRNG before minting the vault volume passphrase #104
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,217 @@ | ||
| #!/usr/bin/env bash | ||
| # Fail if the appliance can generate key material from an entropy source that | ||
| # might not be ready. | ||
| # | ||
| # Motivated by the COLDCARD firmware disclosure (Block, 2026-07): | ||
| # https://engineering.block.xyz/blog/predictable-rng-fallback-and-32-bit-reseed-in-coldcard-firmware | ||
| # A guard that checked only whether a macro was *defined* -- not whether it was | ||
| # *enabled* -- silently bound wallet seed generation to a non-cryptographic | ||
| # fallback PRNG. Nothing crashed, nothing logged, and the firmware shipped that | ||
| # way for years. The lesson is not "use a CSPRNG"; every codebase already | ||
| # intends to. The lesson is that a degraded RNG path must not be able to succeed | ||
| # quietly, and that only a mechanical check keeps it that way. | ||
| # | ||
| # The shape that bug takes in an appliance image: | ||
| # | ||
| # 1. /dev/urandom never blocks. Read before the kernel CRNG is initialised it | ||
| # returns output anyway, with nothing but a kernel log line to say so. This | ||
| # box generates the LUKS passphrase for the vault volume on the FIRST boot | ||
| # of a freshly imaged disk, before any seed file exists, which is precisely | ||
| # the window where that matters. /dev/random on Linux >= 5.6 blocks only | ||
| # until the CRNG is up and never afterwards, so it is strictly better here | ||
| # at no cost. Rule 1 requires it. | ||
| # 2. $RANDOM is bash's seeded PRNG. Fine for retry jitter, never for a key. | ||
| # Rule 2 requires an explicit marker. | ||
| # | ||
| # Deliberate non-crypto randomness is allowed with an inline opt-out on the same | ||
| # line or in the comment block directly above: | ||
| # | ||
| # sleep $(( 10 + RANDOM % 5 )) # rng-hygiene: ok - retry jitter | ||
| # | ||
| # This guard fails CLOSED. A scanner error, a run outside a git work tree, or an | ||
| # empty file list is reported as a failure rather than silently passing: a guard | ||
| # that prints "OK" when it scanned nothing is worse than no guard, because it | ||
| # gets trusted. | ||
| # | ||
| # Scope: TRACKED Nix, shell, and Python under nixos/ and the repo root. tests/ | ||
| # is excluded -- test fixtures are deterministic on purpose, and a VM test that | ||
| # reads /dev/urandom is not provisioning a real box. | ||
| # | ||
| # What this does NOT cover: it is a grep, so it catches the source, not what the | ||
| # value becomes. It cannot see into the keep binaries the appliance runs (those | ||
| # have their own guard in the keep repo), into nixpkgs, or into systemd's own | ||
| # seeding. And "reads /dev/random" is not proof the CRNG had real entropy behind | ||
| # it on a given piece of hardware, only that the read waited for the kernel to | ||
| # say it was ready. | ||
| # | ||
| # Portable to BSD awk. Run from anywhere; exits non-zero with the offending lines. | ||
|
|
||
| set -uo pipefail | ||
| cd "$(dirname "$0")/.." || exit 1 | ||
|
|
||
| status=0 | ||
| fail() { printf '\n\033[31mFAIL\033[0m %s\n' "$1"; status=1; } | ||
|
|
||
| OPT_OUT='rng-hygiene: ok' | ||
| # This script is a .sh file and its rules name the very tokens they ban, so it | ||
| # would report itself. Excluded by path rather than by opt-out markers, which | ||
| # would blunt the markers' signal. Caught only after committing: run untracked, | ||
| # `git ls-files` did not list it and it passed locally while failing in CI. | ||
| SELF='scripts/check-rng-hygiene.sh' | ||
|
|
||
| git rev-parse --is-inside-work-tree >/dev/null 2>&1 || { | ||
| printf '\n\033[31mFAIL\033[0m not inside a git work tree; this guard scans tracked files only\n' | ||
| exit 1 | ||
| } | ||
|
|
||
| list_sources() { | ||
| git ls-files '*.nix' '*.sh' '*.py' 'justfile' \ | ||
| | grep -vE '^tests/' \ | ||
| | grep -vxF "$SELF" | ||
| } | ||
|
|
||
| SOURCES=$(list_sources) | ||
| if [ -z "$SOURCES" ]; then | ||
| printf '\n\033[31mFAIL\033[0m no sources found to scan; the file list is broken\n' | ||
| exit 1 | ||
| fi | ||
|
|
||
| # Emit "file:line:code" for live code lines, dropping `#` comments (Nix, shell | ||
| # and Python all use them) and honouring the opt-out marker. String literals are | ||
| # left intact: the values this guard cares about -- a device path like | ||
| # "/dev/urandom" -- live inside them. | ||
| preprocess() { | ||
| local f rc out | ||
| rc=0 | ||
| for f in $SOURCES; do | ||
| out=$(awk -v fname="$f" -v optout="$OPT_OUT" ' | ||
| function trim(s) { sub(/^[ \t]*/, "", s); sub(/[ \t]*$/, "", s); return s } | ||
|
|
||
| # Split a line into code and comment, honouring quoting. A naive | ||
| # index($0, "#") splits `''${#pass}` and, worse, treats a `#` inside a | ||
| # string as a comment start -- which let `printf "#x"; pass=/dev/urandom` | ||
| # hide the read, and let an opt-out marker inside a string literal exempt | ||
| # a line with no comment a reviewer could see. | ||
| function split_line(s, i, c, n, q, out) { | ||
| out = ""; cmt = ""; q = "" | ||
| n = length(s) | ||
| for (i = 1; i <= n; i++) { | ||
| c = substr(s, i, 1) | ||
| if (q != "") { | ||
| if (c == "\\") { out = out c substr(s, i + 1, 1); i++; continue } | ||
| if (c == q) q = "" | ||
| out = out c | ||
| continue | ||
| } | ||
| if (c == "\"" || c == "'"'"'") { q = c; out = out c; continue } | ||
| if (c == "#") { cmt = substr(s, i); return out } | ||
| out = out c | ||
| } | ||
| return out | ||
| } | ||
|
|
||
| BEGIN { blockopt = 0; prose = 0 } | ||
| { | ||
| raw = $0 | ||
| code = trim(split_line(raw)) | ||
|
|
||
| # Nix option documentation is prose, not code. Without this, a | ||
| # description mentioning /dev/urandom is a finding, and the only way to | ||
| # silence it is a marker that renders as literal text in the rendered | ||
| # option docs. | ||
| if (prose) { | ||
| if (raw ~ /(\x27\x27;|\x27\x27[ \t]*$)/) prose = 0 | ||
| next | ||
| } | ||
| if (code ~ /(description|example|longDescription)[ \t]*=[ \t]*\x27\x27/ && code !~ /\x27\x27.*\x27\x27[ \t]*;/) { | ||
| prose = 1 | ||
| next | ||
| } | ||
|
|
||
| # A blank line ends the comment block, so a marker cannot carry across | ||
| # unrelated lines to exempt something far below it. | ||
| if (code == "" && cmt == "") { blockopt = 0; next } | ||
| if (code == "") { blockopt = (index(cmt, optout) ? 1 : blockopt); next } | ||
|
Comment on lines
+90
to
+134
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Description: Reproduce the ''${ misparse in isolation with the file's own split_line logic.
set -euo pipefail
awk '
function split_line(s, i, c, n, q, out) {
out = ""; cmt = ""; q = ""
n = length(s)
for (i = 1; i <= n; i++) {
c = substr(s, i, 1)
if (q != "") {
if (c == "\\") { out = out c substr(s, i + 1, 1); i++; continue }
if (c == q) q = ""
out = out c
continue
}
if (c == "\"" || c == "'"'"'") { q = c; out = out c; continue }
if (c == "#") { cmt = substr(s, i); return out }
out = out c
}
return out
}
{ print "code=[" split_line($0) "] cmt=[" cmt "]" }
' <<< $'n=\x27\x27${`#pass`}; leaked=/dev/urandom'Repository: privkeyio/keep-node Length of output: 205 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== File context =="
wc -l scripts/check-rng-hygiene.sh
sed -n '1,240p' scripts/check-rng-hygiene.sh | cat -n
echo "== References to files =="
rg -n "nixos/frost-gate\\.nix|/dev/(u)?random|\\$RANDOM|split_line|BLOCKOPT|SELF|list_sources" scripts/check-rng-hygiene.sh
echo "== Focused Nix path if present =="
if [ -f nixos/frost-gate.nix ]; then
rg -n -C 4 "''\\$\\{`#pass`\\}|/dev/(u)?random|base64|LUKS" nixos/frost-gate.nix
fi
echo "== POSIX awk availability =="
command -v awk nawk gawk mawk 2>/dev/null || trueRepository: privkeyio/keep-node Length of output: 11372 🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
echo "== search relevant paths =="
rg -E 'nixos/frost-gate\.nix|/dev/(u)?random|split_line|SELF|list_sources|RANDOM' scripts/check-rng-hygiene.sh
echo "== nixos/frost-gate.nix excerpt if present =="
if [ -f nixos/frost-gate.nix ]; then
wc -l nixos/frost-gate.nix
rg -n -C 6 "''\\$\\{`#pass`\\}|head -c 32 /dev/random|-eq 44|base64" nixos/frost-gate.nix || true
else
echo "nixos/frost-gate.nix not present"
fi
echo "== awk output for escaped-outer-quote ''${ and escaped outer comment =="
awk '
function split_line(s, i, c, n, q, out) {
out = ""; cmt = ""; q = ""
n = length(s)
for (i = 1; i <= n; i++) {
c = substr(s, i, 1)
if (q != "") {
if (c == "\\") { out = out c substr(s, i + 1, 1)";i++"; continue }
if (c == q) q = ""
out = out c
continue
}
if (c == "\"" || c == "'"'"'") { q = c; out = out c; continue }
if (c == "#") { cmt = substr(s, i); return out }
out = out c
}
return out
}
{ print "raw=[" NR "] code=[" split_line($0) "] cmt=[" cmt "]" }
' <<'EOF'
n=''${`#pass`}; leaked=/dev/urandom
printf "`#not-a-comment`"; read /dev/random
EOFRepository: privkeyio/keep-node Length of output: 323 🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
echo "== search relevant paths =="
grep -nE 'nixos/frost-gate\.nix|/dev/(u)?random|split_line|SELF|list_sources|RANDOM' scripts/check-rng-hygiene.sh
echo "== nixos/frost-gate.nix excerpt if present =="
if [ -f nixos/frost-gate.nix ]; then
wc -l nixos/frost-gate.nix
grep -n -C 6 "''\${`#pass`}\|head -c 32 /dev/random|-eq 44|base64" nixos/frost-gate.nix || true
else
echo "nixos/frost-gate.nix not present"
fi
echo "== awk output for escaped-outer-quote ''${ and escaped outer comment =="
awk '
function split_line(s, i, c, n, q, out) {
out = ""; cmt = ""; q = ""
n = length(s)
for (i = 1; i <= n; i++) {
c = substr(s, i, 1)
if (q != "") {
if (c == "\\") { out = out c substr(s, i + 1, 1)";i++"; continue }
if (c == q) q = ""
out = out c
continue
}
if (c == "\"" || c == "'"'"'") { q = c; out = out c; continue }
if (c == "#") { cmt = substr(s, i); return out }
out = out c
}
return out
}
{ print "raw=[" NR "] code=[" split_line($0) "] cmt=[" cmt "]" }
' <<'EOF'
n=''${`#pass`}; leaked=/dev/urandom
printf "`#not-a-comment`"; read /dev/random
EOFRepository: privkeyio/keep-node Length of output: 3366 Don’t treat Nix
🤖 Prompt for AI Agents |
||
| if (index(cmt, optout) || blockopt) { blockopt = 0; next } | ||
| blockopt = 0 | ||
| printf "%s:%d:%s\n", fname, FNR, code | ||
| } | ||
| ' "$f") || rc=2 | ||
| [ -n "$out" ] && printf '%s\n' "$out" | ||
| done | ||
| return "$rc" | ||
| } | ||
|
|
||
| CODE=$(preprocess) || { | ||
| printf '\n\033[31mFAIL\033[0m the scanner itself failed; refusing to report a clean tree\n' | ||
| exit 1 | ||
| } | ||
| if [ -z "$CODE" ]; then | ||
| printf '\n\033[31mFAIL\033[0m preprocessor produced no code lines; the guard scanned nothing\n' | ||
| exit 1 | ||
| fi | ||
|
|
||
| # Called from the CALLER, never from inside the command substitution: an `exit` | ||
| # in a substitution terminates only the subshell, so `x=$(scan ...) ` would have | ||
| # swallowed the failure and reported a clean tree. That is the fail-open shape | ||
| # this guard exists to reject. | ||
| scanner_died() { | ||
| printf '\n\033[31mFAIL\033[0m the scanner itself failed; refusing to report a clean tree\n' >&2 | ||
| exit 1 | ||
| } | ||
|
|
||
| scan() { # $1 = ERE | ||
| printf '%s\n' "$CODE" | awk -v pat="$1" '{ | ||
| line = $0; sub(/^[^:]*:[0-9]+:/, "", line) | ||
| if (line ~ pat) print $0 | ||
| }' | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| report() { # $1 = findings, $2 = headline, $3.. = hints | ||
| [ -z "$1" ] && return 0 | ||
| fail "$2" | ||
| printf '%s\n' "$1" | sed 's/^/ /' | ||
| shift 2 | ||
| for hint in "$@"; do echo " → $hint"; done | ||
| } | ||
|
|
||
| # ------------------------------------------ 1. no non-blocking entropy read ---- | ||
| urandom_bad=$(scan '/dev/urandom') || scanner_died | ||
| report "$urandom_bad" "reads /dev/urandom, which never waits for the kernel CRNG:" \ | ||
| 'use /dev/random. On Linux >= 5.6 it blocks only until the CRNG is initialised' \ | ||
| 'and never afterwards, which is the guarantee a first-boot appliance needs.' \ | ||
| "Mark a deliberate non-key-material read: # $OPT_OUT - <reason>" | ||
|
|
||
| # ------------------------------------------------- 2. no bash seeded PRNG ---- | ||
| bashrandom_bad=$(scan '(^|[^a-zA-Z0-9_])RANDOM([^a-zA-Z0-9_]|$)') || scanner_died | ||
| report "$bashrandom_bad" "uses bash \$RANDOM, a seeded PRNG:" \ | ||
| 'fine for retry jitter, never for key material.' \ | ||
| "Mark the jitter case: # $OPT_OUT - <reason>" | ||
|
|
||
| # ------------------ 3. the bootstrap passphrase draw keeps both its guarantees -- | ||
| # The repo-specific invariant, and the one rule that has to check for something | ||
| # being PRESENT rather than absent. Rule 1 only bans /dev/urandom; on its own, | ||
| # replacing the whole draw with `date +%s | sha256sum` leaves CI green. | ||
| # | ||
| # Pinned structurally, not by error message: an earlier version grepped for the | ||
| # text "refusing to format", so deleting the check while leaving the words in a | ||
| # comment passed. | ||
| if [ ! -f nixos/frost-gate.nix ]; then | ||
| fail "nixos/frost-gate.nix not found; rule 3 cannot run (was the module moved?)" | ||
| else | ||
| if ! grep -qE 'head -c 32 /dev/random' nixos/frost-gate.nix; then | ||
| fail "the LUKS bootstrap passphrase no longer reads 32 bytes from /dev/random:" | ||
| echo " → this is the only key material this repo mints itself. It must come from" | ||
| echo " the source that waits for the kernel CRNG, not from a derived value." | ||
| fi | ||
| if ! grep -qE '\-eq 44' nixos/frost-gate.nix; then | ||
| fail "nixos/frost-gate.nix no longer length-checks the LUKS bootstrap passphrase:" | ||
| echo " → a short entropy read must abort provisioning, not format the volume" | ||
| echo " under a truncated passphrase. base64 of 32 bytes is exactly 44 chars." | ||
| fi | ||
| fi | ||
|
|
||
| if [ "$status" -eq 0 ]; then | ||
| echo "RNG hygiene: OK (no /dev/urandom, no bash \$RANDOM outside marked jitter, bootstrap passphrase drawn from /dev/random and length-checked)" | ||
| fi | ||
| exit "$status" | ||
There was a problem hiding this comment.
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
Disable credential persistence for this checkout.
The job only reads tracked files. It never pushes.
actions/checkoutwrites the job token into.git/configby default, which leaves it readable by any later step or tool in the job.Also confirm that
rng-hygieneis a required status check in branch protection. Otherwise a failing guard does not block a merge.🔒 Proposed fix
📝 Committable suggestion
🧰 Tools
🪛 zizmor (1.28.0)
[warning] 59-59: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false
(artipacked)
🤖 Prompt for AI Agents
Source: Linters/SAST tools