Never let a degraded RNG path succeed quietly - #470
Conversation
WalkthroughChangesCryptographic hygiene enforcement
Estimated code review effort: 4 (Complex) | ~45 minutes Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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 `@scripts/check-rng-hygiene.sh`:
- Around line 87-150: Update the quote-handling branch in the awk strip function
so entering a string always sets instr = 1, including when keepstrings is
enabled. Preserve string contents in keepstrings mode, while only emitting the
masked escaped-quote output when keepstrings is disabled, ensuring later comment
delimiters inside strings are ignored.
🪄 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: 951ec5fc-f8e4-45ff-9aed-116ad459f44f
📒 Files selected for processing (3)
.github/workflows/ci.ymlapp/src/main/kotlin/io/privkey/keep/storage/AndroidKeystoreStorage.ktscripts/check-rng-hygiene.sh
Summary
java.security.SecureRandomorUUID.randomUUID(): the NIP-55 permission-database key, the Keystore HMAC key, the PIN PBKDF2 salt, the recovery-screen hex, and the relay reconnect jitter. Nokotlin.random, nojava.util.Random, noMath.random(), nosetSeed, noSHA1PRNG, andminSdk = 33puts the app well past the Android versions with weakSecureRandomseeding.AndroidKeystoreStoragehad a singleinitCipherWithKey(key, mode, ivBase64)used for both directions, which would happily initialise an AES-GCM cipher for encryption with a caller-supplied IV. Reusing a GCM IV under one key destroys confidentiality and authenticity. (See the review round for a correction to how this PR originally described the runtime behaviour of that mistake on Keystore-backed keys.)What changed
Every call site happened to pass
nullon the encrypt path, so nothing was broken. But "happened to" is the property this audit exists to remove. Encryption and decryption are now separate functions:initCipherForEncryption(key)takes no IV and lets the provider draw a fresh one,initCipherForDecryption(key, ivBase64)requires one. Passing an IV to the encrypt path is a compile error rather than a convention, and the shared error mapping (including theKeyPermanentlyInvalidatedExceptionre-import message) is preserved in one helper.scripts/check-rng-hygiene.shpins three rules in CI: non-cryptographic generators in production code,SecureRandomweakened at the call site (setSeed, an explicitly requestedSHA1PRNG), and anyCipher.initwith three or more arguments that is not provably a decrypt (see the review round for why it is phrased that way and not in terms ofENCRYPT_MODE). It runs as a step in the existingbuildjob alongsidecheck-toolchain-pins.shand the other guards, before the SDK/NDK/Rust setup, so it fails in seconds rather than after a toolchain install.Review notes
The guard is written to survive the refactors that defeat a naive grep. It matches the RNG token rather than a receiver shape, because pinning
"0123..".random()is bypassed by hoisting the alphabet into aval— which is the same code, one refactor away. It parses//and/* */comments with real state tracking and blanks string literals, so prose naming a banned symbol is not a finding and a log message containing "Random" is not either. It joins wrapped calls into one statement, so a rustfmt-style four-linecipher.init(...)is judged whole instead of as four innocuous lines. And it makes two passes: rules about code shape see literals blanked, while the rule about an argument value (SHA1PRNG) sees them intact.It also fails closed. A scanner error, a run outside a git work tree, or an empty file list is reported as a failure, never as a pass — a guard that prints "OK" when it scanned nothing is worse than no guard, because it gets trusted. Both cases are verified below.
Decision log
Hardest decision: whether to fix the cipher helper at all, given no call site was wrong. A
require(mode != ENCRYPT_MODE || ivBase64 == null)would have been one line, but it defends at runtime against a mistake the type system can prevent outright, and it needs a test that cannot run without a device. Splitting the function costs a few more lines and makes the class of mistake unrepresentable.Alternatives rejected:
rng-hygieneworkflow, matching the two sibling repos. This repo already has ascripts/check-*.shconvention running inside thebuildjob, and those steps sit before the heavy setup, so they already fail fast. Consistency won.docs/SECURITY.md. There is no such file in this repo and no security-doc convention to slot into; the invariant is documented where it is enforced (the guard header) and where it can be violated (the comment above the split functions).Least confident about: rule 1 flags
.random()on any receiver, so a future legitimatelist.random()for UI flavour will trip it. That is a deliberate false-positive bias — the author either goes throughSecureRandomor adds an// rng-hygiene: okmarker consciously — but it will annoy someone eventually.What is not verified
The split cipher functions have no executing test. They are private, they need the Android Keystore, and the existing coverage for this class is instrumented (
KeystoreEncryptedPrefsBindingTest), which needs a device or emulator this workspace does not have. Theinstrumented-testsCI job is the first place it actually runs.verifyKeepVersionwas skipped locally because thekeeppath is a symlink to the main keep repo, which is currently on a different branch; CI checks out the pinned SHA itself and will verify it properly.Test plan
./gradlew compileDebugKotlin testDebugUnitTest lintDebug(JDK 21): BUILD SUCCESSFUL, 171 unit tests passed, 0 failed, lint clean with 0 errors and 0 warningsscripts/check-rng-hygiene.shexits 0 on the treeinstrumented-testsjob covers the Keystore pathReview round
A security review ran over the branch with the explicit job of falsifying the "randomness was already clean" claim. The claim held. It traced every RNG draw in all 109 production Kotlin files and every AES-GCM IV, confirmed the cipher split is behaviour-preserving call site by call site (including the legacy-IV migration path and the error-message mapping), and found no non-CSPRNG source, no reused IV, and no degraded-but-succeeding path.
The findings were in the guard, and two of them were blockers.
Rule 3 did not catch the shape this very PR removes. Checking out
main'sAndroidKeystoreStorage.ktinto a scratch tree and running the guard printedRNG hygiene: OK. The rule requiredENCRYPT_MODEand the spec constructor in the same statement, and the oldcipher.init(mode, key, spec)had both hoisted into variables. Hoisting the spec, hoisting the mode, or passing a numeric opmode each bypassed it.The rule is now structural instead of a token pair, and the reformulation is better than the original intent: the invariant never mentioned ENCRYPT at all.
Cipher.inittakes a parameter spec only when decrypting, so anyinitwith three or more arguments has to be provably a decrypt. Argument counting is done at paren depth so nested calls do not inflate it. All four demonstrated bypasses are caught, including a straight revert of this PR's split.The guard could print OK on a failing scan.
preprocess's exit status was checked butscan/scan_with_stringswere bare command substitutions, so an ERE the local awk rejects (the BSD-awk case the header itself raises) yielded an empty result that read as "no violations". Both now abort the run.Also fixed, each verified by reintroduction:
"*/*"atBackupRestoreScreen.kt:304opened an unterminated block comment and hid the rest of that file from theSHA1PRNGrule. String state is tracked in both modes now; aSHA1PRNGappended to that specific file is detected.SplittableRandom, a fully-qualifiedjava.util.concurrent.ThreadLocalRandom.current()(the leading.defeated the token anchor), and a seededSecureRandom(byteArray)constructor.// trust meescape hatch. The marker still works for rules 1 and 2, where UI jitter is a real use.app/src/debug/**was excluded from the scan despite shipping in debug builds. No such directory exists today, so this closed a future hole rather than a live one.\(from an-vvariable and then rejects the resulting unbalanced regex; that broke the sibling repo's guard in CI, where it correctly failed closed rather than passing.One correction to this PR's own framing. The comment I wrote said an encrypt with a supplied IV "succeeds and the ciphertext looks fine". That is true of AES-GCM in general but probably not of this call path: Keystore keys are created with randomized encryption required by default, which is expected to reject a caller-supplied IV at init. I could not confirm the platform behaviour from this workspace, so the comment now says what is actually known: the platform backstop comes from another component, does not apply to a non-Keystore key, and is not visible at this call site. The split is still worth having for those reasons, not because the runtime would have stayed quiet.
Filed, not fixed here: the review found a pre-existing defect of exactly this PR's thesis in
nip55/PermissionDatabase.kt:143-160.KeystoreEncryptedPrefs.getTypedValueswallows any decrypt exception and returns the default, so "unreadable" and "absent" are indistinguishable at that call site, and a transient Keystore fault makes it mint and store a new SQLCipher passphrase, orphaning the permissions database and the entire signing audit log. It logs nothing. That is a separate bug in a separate subsystem and belongs in its own change.Test plan (updated)
./gradlew compileDebugKotlin testDebugUnitTest lintDebug(JDK 21): BUILD SUCCESSFUL, 171 unit tests passed, lint clean with 0 errors and 0 warnings; recompiled clean after the comment correctionkotlin.random, a hoisted-alphabet.random(),java.util.Random,Math.random(),SplittableRandom, fully-qualifiedThreadLocalRandom,SecureRandom.setSeed, a seededSecureRandomconstructor, and an explicitSHA1PRNGproviderSecureRandom, an opt-out marker on rules 1 and 2, a comment naming banned symbols, and a string literal mentioningjava.util.Randomawkexiting 2 during preprocessing and during scanning, and a run outside a git work treeSHA1PRNGappended toBackupRestoreScreen.kt(the file with the"*/*"literal) is now detected, confirming the string-state fixinstrumented-testsjob covers the Keystore path