I asked Fable5 to audit seedsigner's entropy paths. I don't...

cloud fodder

npub10npj3gydmv40m70ehemmal6vsdyfl7tewgvz043g54p0x23y0s8qzztl5h

hex

233d7d767a3c43e35467840bdb66f5b0d39a93410e87605c9bd4772d49b9556d

nevent

nevent1qqszx0tawearcslr23ncgz7mvm6mp5u6jdqsapmqtjdagaedfxu42mgprpmhxue69uhhyetvv9ujuem4d36kwatvw5hx6mm9qgs8eseg5zxak2hal8umuaa7laxgxjyll9uhyxp86c522shn9gj8crspc5z5y

Kind-1 (TextNote)

2026-07-31T14:26:25Z

I asked Fable5 to audit seedsigner's entropy paths. I don't use seedsigner yet but I probably will try it out at some point. This did produce one good recommendation in hardening the code to prevent future 'mistakes' in the UI entropy gate, but, other than that, no flaws found. Seems like a decent way to accompany dice rolling or etc.

Fable5:

I'll audit SeedSigner's entropy generation and key derivation paths. Let me start by mapping out where entropy enters the system — dice rolls, coin flips, camera-based entropy, and any use of RNGs — then closely review each path.

The audit is complete. Here's my full report.

Verdict

I found no flaws that could lead to a low-entropy key in normal use. SeedSigner's design deliberately avoids trusting any onboard RNG — every seed-generation path is driven by external entropy (camera image, dice, coin flips), hashed through SHA-256, and fed to a well-reviewed BIP-39 implementation (embit 0.8.0, pinned in requirements.txt). The things I'd normally hunt for in this kind of audit — random module use in key paths, reused/aliased entropy buffers, early-exit paths that accept short input, silent truncation — all check out clean.

What I verified, path by path

Camera image entropy (tools_views.py:120-198): The final entropy is SHA256(chain) where the chain folds in the CPU serial number, time.time(), up to 50 live preview frames, and a final full-resolution photo (at ≥2× screen dimensions). Two things I specifically confirmed:

  • Each preview frame is an independent copy — camera.read_video_stream(as_image=True) does frame.astype('uint8') + Image.fromarray(...) (camera.py:52), so the 50 frames aren't 50 references to one reused buffer.
  • The weak inputs (serial number, wall time) are only ever added to the hash chain; hashing extra low-entropy data can't reduce the entropy contributed by the image itself.

Dice rolls (mnemonic_generation.py:64-81): 50 rolls for 12 words (~129 bits, ≥128 ✓) or 99 rolls for 24 words (~256 bits ✓), hashed as an ASCII string via SHA-256 — the iancoleman.io "Base 10" approach. The entry keyboard only offers digits 1–6, backspace is handled correctly, and the screen returns only when exactly return_after_n_chars characters are entered (screen.py:1289). I checked the one early-return path in KeyboardScreen (the save button at screen.py:1237) — it's gated behind show_save_button, which the dice and coin-flip screens never enable, so you cannot submit fewer rolls than required.

Coin flips: Same structure, exactly 128/256 flips enforced, charset limited to "0"/"1". Sound.

Final-word calculation: Contributes only 7 bits (12-word) or 3 bits (24-word) plus checksum — but that's the correct math for this feature, and the UI states it explicitly. The "finalize with zeros" option is a deliberate, labeled choice.

Key derivation (models/seed.py): Straight bip39.mnemonic_to_seed (PBKDF2-HMAC-SHA512, standard) with NFKD normalization. The Electrum variant uses the spec's 2048 rounds. No custom crypto.

PRNG usage: The only uses of Python's non-cryptographic random are the screensaver animation, the backup-quiz decoy words (seed_views.py:1284-1289 — doesn't touch key material; the random.seed() call there is screenshot-generator-only), and the CLI tool's demo mode, which prints "This is a demo seed. Do not use it to store funds!!!". No os.urandom or PRNG anywhere in a key-generation path.

Test coverage: tests/test_mnemonic_generation.py pins fixed vectors cross-checked against iancoleman.io and Coldcard's published dice-roll verification sample, so any regression in the entropy→mnemonic math would fail CI.

Minor findings (none are exploitable in normal use)

  1. No length validation in generate_mnemonic_from_dice / generate_mnemonic_from_coin_flips (mnemonic_generation.py:64, 85). Any input that isn't exactly 50 chars silently produces a 24-word mnemonic — e.g. 10 dice rolls would yield a 24-word seed with only ~26 bits of entropy. Today the UI makes this unreachable, but it's a single-layer defense; a future caller or a UI refactor could regress it silently. An assert len(...) in [50, 99] (with an escape hatch for the Coldcard 6-roll verification vector, which currently relies on this leniency) would be cheap defense-in-depth.
  2. No degenerate-image check on camera entropy. If the sensor ever returned truly constant frames (fully dead sensor still returning data), the remaining entropy would be roughly just time.time() — guessable. In practice a disconnected camera raises CameraConnectionError, and even a lens-capped sensor produces thermal noise, so this is theoretical — but a cheap "image variance too low, reshoot" warning would close it.
  3. Cosmetic off-by-one: bip39.WORDLIST[int(random.random() * 2047)] at seed_views.py:1284-1286 can never pick the last word ("zoo") as a quiz decoy. Should be random.randrange(2048). No security impact.

Practical guidance

The strongest guarantee this codebase offers is verifiability: for dice or coin-flip generation you can independently reproduce the mnemonic from your recorded rolls via tools/mnemonic.py or iancoleman.io/bip39 ("Base 10"/"Binary" mode), which removes the need to trust the device's code at all. If maximum assurance matters to you, dice generation with external verification is the path I'd use; camera entropy is also sound but is inherently harder to audit after the fact.

Raw JSON

{
  "kind": 1,
  "id": "233d7d767a3c43e35467840bdb66f5b0d39a93410e87605c9bd4772d49b9556d",
  "pubkey": "7cc328a08ddb2afdf9f9be77beff4c83489ff979721827d628a542f32a247c0e",
  "created_at": 1785507985,
  "tags": [],
  "content": "I asked Fable5 to audit seedsigner's entropy paths.  I don't use seedsigner *yet* but I probably will try it out at some point.  This did produce one good recommendation in hardening the code to prevent future 'mistakes' in the UI entropy gate, but, other than that, no flaws found.  Seems like a decent way to accompany dice rolling or etc.\n\nFable5:\n\nI'll audit SeedSigner's entropy generation and key derivation paths. Let me start by mapping out where entropy enters the system — dice rolls, coin flips, camera-based entropy, and any use of RNGs — then closely review each path.\n\nThe audit is complete. Here's my full report.\n\nVerdict\n\nI found no flaws that could lead to a low-entropy key in normal use. SeedSigner's design deliberately avoids trusting any onboard RNG — every seed-generation path is driven by external entropy (camera image, dice, coin flips), hashed through SHA-256, and fed to a well-reviewed BIP-39 implementation (embit 0.8.0, pinned in requirements.txt). The things I'd normally hunt for in this kind of audit — random module use in key paths, reused/aliased entropy buffers, early-exit paths that accept short input, silent truncation — all check out clean.\n\nWhat I verified, path by path\n\nCamera image entropy (tools_views.py:120-198): The final entropy is SHA256(chain) where the chain folds in the CPU serial number, time.time(), up to 50 live preview frames, and a final full-resolution photo (at ≥2× screen dimensions). Two things I specifically confirmed:\n- Each preview frame is an independent copy — camera.read_video_stream(as_image=True) does frame.astype('uint8') + Image.fromarray(...) (camera.py:52), so the 50 frames aren't 50 references to one reused buffer.\n- The weak inputs (serial number, wall time) are only ever added to the hash chain; hashing extra low-entropy data can't reduce the entropy contributed by the image itself.\n\nDice rolls (mnemonic_generation.py:64-81): 50 rolls for 12 words (~129 bits, ≥128 ✓) or 99 rolls for 24 words (~256 bits ✓), hashed as an ASCII string via SHA-256 — the iancoleman.io \"Base 10\" approach. The entry keyboard only offers digits 1–6, backspace is handled correctly, and the screen returns only when exactly return_after_n_chars characters are entered (screen.py:1289). I checked the one early-return path in KeyboardScreen (the save button at screen.py:1237) — it's gated behind show_save_button, which the dice and coin-flip screens never enable, so you cannot submit fewer rolls than required.\n\nCoin flips: Same structure, exactly 128/256 flips enforced, charset limited to \"0\"/\"1\". Sound.\n\nFinal-word calculation: Contributes only 7 bits (12-word) or 3 bits (24-word) plus checksum — but that's the correct math for this feature, and the UI states it explicitly. The \"finalize with zeros\" option is a deliberate, labeled choice.\n\nKey derivation (models/seed.py): Straight bip39.mnemonic_to_seed (PBKDF2-HMAC-SHA512, standard) with NFKD normalization. The Electrum variant uses the spec's 2048 rounds. No custom crypto.\n\nPRNG usage: The only uses of Python's non-cryptographic random are the screensaver animation, the backup-quiz decoy words (seed_views.py:1284-1289 — doesn't touch key material; the random.seed() call there is screenshot-generator-only), and the CLI tool's demo mode, which prints \"This is a demo seed. Do not use it to store funds!!!\". No os.urandom or PRNG anywhere in a key-generation path.\n\nTest coverage: tests/test_mnemonic_generation.py pins fixed vectors cross-checked against iancoleman.io and Coldcard's published dice-roll verification sample, so any regression in the entropy→mnemonic math would fail CI.\n\nMinor findings (none are exploitable in normal use)\n\n1. No length validation in generate_mnemonic_from_dice / generate_mnemonic_from_coin_flips (mnemonic_generation.py:64, 85). Any input that isn't exactly 50 chars silently produces a 24-word mnemonic — e.g. 10 dice rolls would yield a 24-word seed with only ~26 bits of entropy. Today the UI makes this unreachable, but it's a single-layer defense; a future caller or a UI refactor could regress it silently. An assert len(...) in [50, 99] (with an escape hatch for the Coldcard 6-roll verification vector, which currently relies on this leniency) would be cheap defense-in-depth.\n2. No degenerate-image check on camera entropy. If the sensor ever returned truly constant frames (fully dead sensor still returning data), the remaining entropy would be roughly just time.time() — guessable. In practice a disconnected camera raises CameraConnectionError, and even a lens-capped sensor produces thermal noise, so this is theoretical — but a cheap \"image variance too low, reshoot\" warning would close it.\n3. Cosmetic off-by-one: bip39.WORDLIST[int(random.random() * 2047)] at seed_views.py:1284-1286 can never pick the last word (\"zoo\") as a quiz decoy. Should be random.randrange(2048). No security impact.\n\nPractical guidance\n\nThe strongest guarantee this codebase offers is verifiability: for dice or coin-flip generation you can independently reproduce the mnemonic from your recorded rolls via tools/mnemonic.py or iancoleman.io/bip39 (\"Base 10\"/\"Binary\" mode), which removes the need to trust the device's code at all. If maximum assurance matters to you, dice generation with external verification is the path I'd use; camera entropy is also sound but is inherently harder to audit after the fact.",
  "sig": "aab0546dc05af46dba32b95613037af72194f31da04ab2fba2754b83fe8bf221ed908c653bd35691d087399d3f77cd7ba5473a4bc85c1a72c8062866a4824936"
}