Files
SeedPass/docs/entropy_audit_2026-07-31.md
T
thePR0M3TH3ANandClaude Opus 5 3192c9f306 Offer a 24-word master seed, and put the entropy draw beyond reach of any handler
L2: generate_bip85_seed and create_profile_from_generated_seed now take
words_num, accepting 12 or 24 and rejecting anything else with a clear
message. The default stays 12 -- every existing profile uses it, 128 bits is
entirely adequate, and moving the default would change nothing about
existing vaults while surprising anyone creating a new one. 24 is offered
because the master seed protects the whole vault and was the one secret
shorter than the seeds derived beneath it, which already default to 24.

L3: fixed structurally rather than by narrowing exception types.
os.urandom(32) now sits OUTSIDE the try block, so no handler can stand
between the entropy draw and the caller -- an entropy failure is unreachable
by anything that could substitute a value. Also removes a redundant
`except Exception` whose body was identical to the `Bip85Error` branch above
it.

The structural form is stronger than an exception list, and the test says so
precisely: it asserts the OSError reaching the caller is the SAME OBJECT
os.urandom threw, which holds only if nothing caught and repackaged it.
Listing exception types would still leave a handler in the path for someone
to widen later.

Adds five tests: the identity assertion above, 12- and 24-word generation,
the default staying 12, and rejection of 0/11/18/25/-12.

Determinism gate green (89 tests, up from 84). The seed/profile regression
subset is 143 passed with one failure --
test_profile_export_import_round_trip -- confirmed against the pre-change
baseline as pre-existing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01HtNMsKzzFTPURemX7tT19u
2026-07-31 17:43:24 -04:00

21 KiB
Raw Blame History

SeedPass — randomness and RNG-integration audit, 2026-07-31

Audit of every security-sensitive random value in src/, against a Coldcard-class checklist: where each bit of unpredictability originates, whether that source is present in the production artifact, and what happens when it fails.

Verdict

No predictable-fallback defect exists. Every non-deterministic secret comes from an OS CSPRNG and fails closed:

  • Master seed — core/manager.py:1810, os.urandom(32)
  • AES-GCM nonces — core/encryption.py:106, nostr/snapshot.py:57, core/memory_protection.py:21
  • TOTP secrets — core/totp.py:22, os.urandom(20)
  • Argon2 salts — utils/key_derivation.py:67, core/agent_secret_isolation.py:92
  • API tokens, agent leases and approvals — secrets.token_urlsafe / secrets.token_bytes in seedpass/api.py:201,340, cli/agent.py:720,804, core/agent_approval.py:65, core/agent_secret_lease.py:71, core/agent_export_policy.py:74, core/agent_recovery.py:168

The random module is not imported anywhere in non-test source. I checked explicitly; there are no hits outside src/tests/. Nothing seeds a CSPRNG manually, and nothing uses timestamps, PIDs or device identifiers as entropy. Master-seed generation re-raises on failure rather than substituting a value.

The deterministic derivation in core/password_generation.py is intentional and correct in kind — deriving passwords reproducibly from a BIP-85 seed is the product. The findings below are about how much entropy survives that derivation, not about whether it should be deterministic.


The compatibility constraint that governs this whole document

Passwords are never stored. _generate_password_for_entry (core/manager.py:3140) re-derives them on demand from (seed, index, length, policy, gen_version) every time an entry is displayed.

At the time of this audit an entry record held index, length, type and an optional policy override — and no algorithm version. Any change to generate_password therefore silently changed every existing password in every vault, with no record of the old value and no way for a user to recover it. This is the same class of constraint BitLogin documents as its "derivation chain is a compatibility contract" standing rule.

As of 2026-07-31 entries carry gen_version, and the parameter defaults to 1 so an absent field means v1. The rule this constraint implies is permanent: no change may ever alter the output of a version that already exists in the wild. v1 is frozen forever; a fix that changes derivation goes into a new version, never into an old one. A patch that "just fixes the bias" in place is a data-loss event, not an improvement.

src/tests/test_entropy_integrity.py enforces this with 18 v1 vectors. A failure there is never "update the expected value".


Status, 2026-07-31

M4, L1 and M1/M2/M3 have landed. generate_password now takes a gen_version argument defaulting to 1; v1 is frozen and pinned by 18 vectors, and new password entries are stamped gen_version: 2.

Property (length 16, 200 samples) v1 v2 ceiling
distinct class compositions 1 82 —
per-character entropy 6.42 b 6.53 b 6.555 b
chi-square over the 94-char alphabet biased 96.1 (df=93) crit ~147
lag-32 repeat rate repeating pad 0.0110 chance 0.0106

Existing entries are untouched and stay on v1 until the opt-in upgrade ships. The remaining open items are L2, L4, L5 and that upgrade UX.

Open items

M1 — Every generated password has an identical character-class composition — DONE 2026-07-31

Fixed in v2 by deleting the _balance_distribution call from the v2 path and keeping only _enforce_minimum_counts-style policy minima. v1 retains it, frozen. The original analysis follows.

This is the main finding.

_balance_distribution (core/password_generation.py:367-408) partitions the password into one segment per character class and overwrites every position in each segment with a character of that class. The subsequent HMAC-Fisher-Yates shuffle randomizes the arrangement but cannot change the multiset.

Measured, 300 passwords per length against a fixed seed:

len=  16   distinct class compositions = 1   {U:4,  l:4,  d:4,  s:4}   x300/300
len=  24   distinct class compositions = 1   {U:6,  l:6,  d:6,  s:6}   x300/300
len=  32   distinct class compositions = 1   {U:8,  l:8,  d:8,  s:8}   x300/300
len=  64   distinct class compositions = 1   {U:16, l:16, d:16, s:16}  x300/300
len= 128   distinct class compositions = 1   {U:32, l:32, d:32, s:32}  x300/300

Exactly 25% of each class, every time, zero variance. Measured per-character entropy was 6.42 bits against a 94-character alphabet. The analytic prediction for a forced 25/25/25/25 split is 0.25·log₂(26)·2 + 0.25·log₂(10) + 0.25·log₂(32) + 2 = 6.43 bits — the measurement matches, confirming the constraint fully accounts for the deficit.

Cost against an unconstrained draw from the same alphabet:

length unconstrained actual loss
8 52.4 b 46.7 b 5.7 b
16 (default) 104.9 b 96.8 b 8.1 b
24 157.3 b 147.4 b 9.9 b
32 209.7 b 198.2 b 11.5 b

Two things to weigh, honestly. The entropy loss alone is not alarming — 96.8 bits at the default length is far beyond brute-force reach, and nothing in the docs or code advertises a bits figure that this contradicts. The more material issue is that the invariant is a fingerprint: a password containing exactly L/4 of each class is a strong signal it came from SeedPass, and it tells an attacker who has one leaked hash to search the 96.8-bit structured space rather than the 104.9-bit unstructured one. Unlinkability, not raw strength, is what this costs.

_balance_distribution also buys nothing. _enforce_minimum_counts (password_generation.py:300) already guarantees the policy minima — 2 upper, 2 lower, 2 digits, 2 special by default. Forcing the remaining ~92% of positions into a rigid quota adds no compliance value that the minima do not already provide.

Fix.

  1. Add a gen_version: int field to password entries, defaulting to 1 when absent. Thread it through _generate_password_for_entry alongside the existing policy override.
  2. Branch in generate_password. Version 1 must be byte-identical to today's behaviour — do not refactor it, do not "clean it up", pin it with vectors (see M4).
  3. Version 2: keep _enforce_minimum_counts, delete the _balance_distribution call, and fold in the M2/M3 fixes. New entries get gen_version: 2.
  4. Offer an explicit, opt-in per-entry upgrade in the TUI/CLI that makes the consequence unmissable: "This will change this password. Update it at the site first, then confirm." Never bulk-migrate.

Acceptance. Regenerating any pre-existing entry yields the identical password it yielded before the change (v1 vectors green). A fresh entry produces varied class compositions — assert that 300 v2 passwords at length 16 yield more than 20 distinct compositions, against exactly 1 today.


M2 — Modulo bias in character and index selection — DONE 2026-07-31

_map_entropy_to_chars (password_generation.py:160):

password = "".join(alphabet[byte % len(alphabet)] for byte in dk)

With the default 94-character alphabet, 256 % 94 = 68, so 68 characters draw with probability 3/256 and 26 draw with 2/256. DeterministicStream consumers have the same shape: % 26 for letters (256 % 26 = 22), % 10 for digits. The 32-character special set is unbiased only by luck (256 % 32 = 0).

The effect is small — roughly 0.02 bits per character — and largely masked by M1, which overwrites most positions anyway. It is worth fixing because it is exactly the defect BitLogin's randomUniformInt was written to avoid, and because once M1 lands it stops being masked.

Fix (v2 only). Rejection-sample against the derived stream:

def _uniform_index(stream: "DeterministicStream", max_exclusive: int) -> int:
    """Rejection sampling — no modulo bias. Mirrors BitLogin's randomUniformInt."""
    limit = 256 - (256 % max_exclusive)
    while True:
        value = stream.get_value()
        if value < limit:
            return value % max_exclusive

Rejection consumes a variable number of stream bytes, which makes M3 a prerequisite rather than an optional companion — the stream must not run short.

Acceptance. Chi-square over 200k v2 draws at modulus 94 is below the p<0.001 critical value (~147 for df=93). v1 vectors unchanged.


M3 — DeterministicStream cycles a 32-byte key — DONE 2026-07-31

password_generation.py:87:

value = self.dk[self.index % self.length]

dk is 32 bytes. MAX_PASSWORD_LENGTH is 128. For any password longer than 32 characters, _balance_distribution consumes the same 32 bytes cyclically, so positions j and j+32 within a segment are driven by the identical byte. Wrapping a fixed key is not a stream cipher — it is a repeating pad.

_add_additional_symbols already senses the problem and works around it by breaking when stream.current_index >= stream.length (password_generation.py:360), which is a symptom, not a fix.

Fix (v2 only). Replace the wrap with a proper expansion. HKDF-Expand dk to the number of bytes actually needed — generously, since M2's rejection sampling consumes a variable amount — or make DeterministicStream generate on demand:

class DeterministicStream:
    def __init__(self, dk: bytes) -> None:
        self._key, self._block, self._pos, self._counter = dk, b"", 0, 0

    def get_value(self) -> int:
        if self._pos >= len(self._block):
            self._block = hmac.new(
                self._key, self._counter.to_bytes(4, "big"), hashlib.sha256
            ).digest()
            self._counter += 1
            self._pos = 0
        value = self._block[self._pos]
        self._pos += 1
        return value

This never repeats within any practical length and keeps the derivation fully deterministic. Delete the current_index >= length break in _add_additional_symbols once it lands.

Acceptance. A 128-character v2 password shows no period-32 correlation: assert positional autocorrelation at lag 32 is within noise across 1,000 samples. v1 vectors unchanged.


M4 — No RNG failure tests, and no v1 pinning vectors — DONE 2026-07-31

Landed as src/tests/test_entropy_integrity.py: 26 tests, inside the --determinism-only gate that scripts/run_ci_tests.sh runs before anything else. 18 frozen vectors span every policy in POLICIES and both sides of the 32-byte stream-wrap boundary (lengths 8/16/20/32/33/40/64/128).

Each guard was mutation-verified rather than assumed:

Mutation Result
Remove the _balance_distribution call (the M1 change) all 18 vectors red
Perturb the DeterministicStream wrap (the M3 change) all 18 vectors red
Change alphabet[byte % len(alphabet)] (the M2 change) all 18 vectors red
Add a sha256(time.time()) fallback to generate_bip85_seed fail-closed test red ("DID NOT RAISE")

test_all_zero_entropy_is_not_currently_detected deliberately pins a gap rather than a guarantee: SeedPass would accept an all-zero os.urandom return and mint a valid-looking mnemonic from it. Read that test's docstring before "fixing" it — the point is that closing the gap should be a visible, deliberate change.

M1, M2 and M3 are now safe to attempt. The original problem statement follows.

Two gaps, one fix.

Nothing asserts the RNG fails closed. No test makes os.urandom raise and checks that seed generation aborts rather than returning something. The current behaviour is correct — generate_bip85_seed re-raises — but nothing in CI would report it if a future edit added a fallback. A deterministic stream passes every self-consistency test in the suite; that is precisely how the Coldcard defect survived its own health check.

Nothing pins v1 output. Without vectors, M1–M3 cannot be landed safely at all, because there is no way to prove v1 still derives what it derived before.

Fix. Add src/tests/test_entropy_integrity.py:

def test_seed_generation_aborts_when_urandom_fails(monkeypatch):
    monkeypatch.setattr(os, "urandom", _raise(OSError("entropy pool unavailable")))
    with pytest.raises((SeedPassError, OSError)):
        manager.generate_bip85_seed()          # must NOT return a value

def test_encryption_nonce_is_fresh_per_call():
    a, b = em.encrypt_data(b"same"), em.encrypt_data(b"same")
    assert a[3:15] != b[3:15]                  # V3| + 12-byte nonce

def test_v1_password_vectors_are_frozen():
    """Compatibility contract: these outputs are what live vaults re-derive.
    A failure here is never 'update the expected value'."""
    for idx, length, expected in V1_VECTORS:
        assert pg.generate_password(length=length, index=idx) == expected

Generate V1_VECTORS from the current main before touching anything — several indices, several lengths, at least one over 32 characters to cover M3's territory, and one for each non-default policy (exclude_ambiguous, special_mode="safe", include_special_chars=False).

Acceptance. All three pass on main today. Deliberately breaking generate_bip85_seed to swallow the error must turn the first red — verify locally, then revert.


L1 — Dead HKDF, and a docstring that documents the wrong algorithm — DONE 2026-07-31

_derive_password_entropy (password_generation.py:144-156) builds an HKDF instance, derives hkdf_derived, and never uses it — the function returns the PBKDF2 output. generate_password's docstring nonetheless states "Use HKDF-HMAC-SHA256 to derive a key from entropy."

Not a vulnerability: PBKDF2-HMAC-SHA256 over 64 bytes of BIP-85 entropy is sound, and 100,000 iterations over an already-high-entropy input is harmless overkill. It is recorded because it is exactly the doc-vs-code drift the checklist's §5 warns about — a reassuring name in front of a different implementation is how the Coldcard chain hid.

Fix. Delete the unused HKDF block and correct the docstring to describe PBKDF2. Removing dead code cannot change output, so this one is safe to land independently of the gen_version work — but add it to the v1 vector run anyway, to prove that.


L2 — Master seed entropy accounting — DONE 2026-07-31

generate_bip85_seed and create_profile_from_generated_seed now take words_num, accepting 12 or 24 and rejecting anything else. The default stays 12: every existing profile uses it, it is entirely adequate, and moving the default would surprise without improving any existing vault. 24 is now available for a 256-bit master seed, which removes the oddity that the secret protecting the whole vault was shorter than the seeds derived beneath it (those default to 24).

The original note follows.

L2 (original) — Master seed entropy accounting

generate_bip85_seed (core/manager.py:1810-1812) draws os.urandom(32) — 256 bits — then derives a 12-word mnemonic, which encodes 128. The word count is hardcoded, while derived seed phrases (derive_seed_phrase, password_generation.py:519) default to 24.

128 bits is entirely adequate and this is not a defect. It is recorded because §6 asks for the accounting to be written down, and because the asymmetry is surprising: the master secret protecting the whole vault is shorter than the seeds derived beneath it.

Fix (optional). Offer 24 words at profile creation. It costs one parameter and gives users who want a 256-bit master the option. Existing profiles are unaffected — this only changes what new seeds look like.


L3 — Broad except Exception around crypto — DONE 2026-07-31 (for the RNG path)

Fixed structurally rather than by narrowing exception types. os.urandom(32) now sits outside the try block in generate_bip85_seed, so no handler can stand between the entropy draw and the caller. The redundant duplicate except Exception — whose body was identical to the Bip85Error branch above it — is gone.

This is stronger than listing which exceptions to catch: an entropy failure is now unreachable by any handler that could substitute a value, and test_entropy_draw_sits_outside_the_try_block asserts the raised OSError arrives as the same object that os.urandom threw, which is only true if nothing caught and repackaged it.

The remaining broad handlers in password_generation.py (lines 280, 485) wrap deterministic derivation with no RNG in it, and they log and re-raise rather than substituting. They are covered by the general "replace broad except Exception" item in TODO.md rather than being an entropy concern.

The original note follows.

L3 (original) — Broad except Exception around crypto

password_generation.py:280, :485, core/manager.py:1817. All of these log-and-re-raise, so there is no silent fallback — the operation aborts, as it should. Recorded because §13 flags the pattern: a bare except Exception wrapped around key generation is one careless edit away from becoming a fallback, and it is where the reviewer's eye should go first.

Overlaps with the existing "Replace broad except Exception: pass blocks" item in ../TODO.md; these three are the crypto-path instances and should be narrowed to the specific exceptions each path can raise.


L4 — torch/ subsystem — PARTLY DONE 2026-07-31

Fixed: the temp filename in services/memory/index.js now uses randomBytes(8) instead of Date.now() + Math.random(), and the Math.random-derived value in relay-health.mjs that was called a nonce is renamed probeSuffix and drawn from randomBytes(6) — it is a public probe tag, but the name invited reuse of the pattern somewhere it would matter.

Still open, and it needs a decision: torch/_backups/ holds 5240 tracked files across six dated snapshots. Untracking them is a large deletion, so it is not something to do as a side effect of an entropy audit. Two concrete costs today: they duplicate every security-relevant grep hit, and bare node --test in torch/ discovers them and fails 18 tests from stale snapshot code. (The npm test script uses explicit paths, so CI is unaffected.)

Separately noted: torch/ has no test/ directory in the repo at all, so npm test there cannot run as written. Out of scope here, but worth knowing.

The original note follows.

L4 (original) — torch/ subsystem

  • torch/src/services/memory/index.js:60 builds a temp filename from Date.now() + Math.random() before an atomic rename. Predictable name; low impact in a user-owned directory, but §1 lists unguessable temp names. Use crypto.randomBytes(8).toString('hex') — a one-line change.
  • torch/src/relay-health.mjs:95 names a Math.random-derived value a nonce. It is a probe tag, not a cryptographic nonce, and is fine as-is — but the naming invites a future mistake. Rename to probeSuffix.
  • torch/_backups/ contains three stale in-tree copies of this code (backup_2026-02-25T20-04-27-295Z, backup_2026-02-26T02-09-18-956Z, backup_2026-02-26T13-32-51-188Z). They pollute every security grep with duplicate hits. Move out of the working tree or add to .gitignore.

L5 — No SBOM — DONE 2026-07-31

dependency-audit.yml now generates a CycloneDX SBOM from requirements.lock and uploads it. Verified locally with the real tool rather than assumed: the first flag spelling I wrote (--output-format / --outfile) does not exist in cyclonedx-py and would have failed in CI. The working form is --of json --output-reproducible -o <file>, which emits CycloneDX 1.6 with 96 components and records the crypto chain at exact versions — [email protected], [email protected], [email protected], [email protected], [email protected], [email protected], [email protected].

The original note follows.

L5 (original) — No SBOM

dependency-audit.yml and release-integrity.yml cover part of §11/§12, but no workflow emits a dependency manifest artifact. Add CycloneDX generation (pip install cyclonedx-bom && cyclonedx-py requirements requirements.lock) and upload it alongside the existing release-integrity artifacts.

Folds naturally into the open supply-chain item in ../TODO.md.


Sequencing

The order matters more here than in the sibling repos, because the compatibility constraint makes the wrong order destructive.

  1. M4 first, and specifically the v1 vectors. Nothing else can land safely until v1 output is pinned. Generate them from current main.
  2. L1 — dead-code deletion, proves the vector harness works.
  3. M1's scaffolding — the gen_version field, defaulted to 1, with v2 not yet differing. Confirm every existing entry still derives identically.
  4. M3, then M2 into v2 (stream expansion before rejection sampling — the latter needs an unbounded stream).
  5. M1's substance — drop _balance_distribution from v2.
  6. The opt-in upgrade UX. Ship last, and only once v2 is settled; a user who upgrades an entry has changed a live password at a real site.
  7. L2, L4, L5 whenever convenient — none interact with the derivation chain.