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

469 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`):
```python
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:
```python
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`:
```python
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:
```python
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`:
```python
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`](../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`](../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.
## Related
- [`crypto_key_management_review.md`](./crypto_key_management_review.md)
- [`security_readiness_checklist.md`](./security_readiness_checklist.md)