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
21 KiB
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_bytesinseedpass/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.
- Add a
gen_version: intfield to password entries, defaulting to1when absent. Thread it through_generate_password_for_entryalongside the existingpolicyoverride. - 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). - Version 2: keep
_enforce_minimum_counts, delete the_balance_distributioncall, and fold in the M2/M3 fixes. New entries getgen_version: 2. - 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:60builds a temp filename fromDate.now()+Math.random()before an atomic rename. Predictable name; low impact in a user-owned directory, but §1 lists unguessable temp names. Usecrypto.randomBytes(8).toString('hex')— a one-line change.torch/src/relay-health.mjs:95names aMath.random-derived value anonce. It is a probe tag, not a cryptographic nonce, and is fine as-is — but the naming invites a future mistake. Rename toprobeSuffix.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.
- M4 first, and specifically the v1 vectors. Nothing else can land safely
until v1 output is pinned. Generate them from current
main. - L1 — dead-code deletion, proves the vector harness works.
- M1's scaffolding — the
gen_versionfield, defaulted to 1, with v2 not yet differing. Confirm every existing entry still derives identically. - M3, then M2 into v2 (stream expansion before rejection sampling — the latter needs an unbounded stream).
- M1's substance — drop
_balance_distributionfrom v2. - 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.
- L2, L4, L5 whenever convenient — none interact with the derivation chain.