Python builds its PasswordGenerator with
`policy=self.config_manager.get_password_policy()` and merges an entry's own
`policy` block over that base per derivation. The port merged the entry block
over hardcoded defaults and never read the profile config at all, so every
entry in a profile with a non-default policy derived a *different password*
than Python. Nothing failed anywhere; it reached the user as a wrong password,
which is indistinguishable from data loss at the point of use.
materializeSecret now takes a `basePolicy` and merges the entry block over it.
All three callers supply the profile config's policy:
- program.ts via basePolicyForOptions, resolved lazily so `entry list` and
friends don't pay a second BIP-39 seed derivation for a policy they never
use. `--vault` names an index file rather than a profile, so the config is
looked for beside it and falls back to defaults when absent.
- agent.ts from join(appDir, fingerprint), so a token-scoped reveal returns
what the owner sees.
- the TUI through a new sessionSecret helper that every menu path goes
through, so a future call site cannot silently omit the base.
`util generate-password` is deliberately unchanged: it has no entry and no
profile, so its flags are the whole policy. It now says so in its own help
text rather than leaving the reader to infer it.
Evidence: js/packages/cli/test/passwordPolicy.test.ts covers config-only v2,
an entry overriding exactly one field, config-only v1 and exclude_ambiguous,
against values computed by the Python implementation — each also asserted
unequal to what the pre-fix code produced, so a regression fails loudly rather
than needing someone to notice a changed constant. Mutation-verified twice:
dropping basePolicy from the merge in secrets.ts, and making
basePolicyForOptions return {}, each turn all four red.
Found by the 2026-08-18 independent audit (js/SECURITY_AUDIT.md). The parity
fixture suite could not have caught this: those fixtures call generatePassword
with one already-resolved policy, so they prove the algorithm and say nothing
about where the policy came from.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
26 KiB
SeedPass TODO
This file tracks the remaining work from the latest bug search and security evaluation.
Entropy audit (2026-07-31)
docs/entropy_audit_2026-07-31.md — RNG and
randomness-integration audit. No predictable-fallback defect: every non-deterministic secret
uses os.urandom / secrets, and the random module is not imported anywhere in non-test source.
Read the compatibility constraint in that document before touching
core/password_generation.py. Passwords are never stored — they are re-derived on demand from
(seed, index, length, policy, gen_version). Entries now carry gen_version, and an absent field
means v1. The rule is permanent: never alter the output of a version that already exists in the
wild. v1 is frozen; a fix that changes derivation goes into a new version, never into an old one.
src/tests/test_entropy_integrity.py enforces this with 18 v1 vectors — a failure there is never
"update the expected value".
-
M4 (done) — v1 password vectors frozen, plus fail-closed RNG tests, in
src/tests/test_entropy_integrity.py(26 tests, inside the--determinism-onlyCI gate). 18 vectors cover every policy and both sides of the 32-byte stream-wrap boundary. Mutation-verified: removing_balance_distribution(M1), altering theDeterministicStreamwrap (M3) and changing the character mapping (M2) each turn all 18 red; adding a hash-of-timestamp fallback togenerate_bip85_seedturns the fail-closed test red. M1/M2/M3 are now safe to attempt. -
L1 (done) — deleted the dead HKDF block in
_derive_password_entropy, dropped the three now-unused imports (HKDF,hashes,default_backend), and corrected the docstrings that named HKDF instead of PBKDF2. Verified as a negative control: all 18 v1 vectors stayed green, confirming the harness does not produce false alarms on behaviour-preserving edits. -
M1 / M2 / M3 (done) — password generation is now versioned.
gen_versiondefaults to 1 everywhere, so existing entries and every untouched caller keep deriving v1 byte-identically; new password entries are stampedgen_version: 2. v2 drops the forced equal-quarters class quota (M1), draws by rejection sampling (M2) from an unbounded HMAC-expanded stream (M3), and enforces only the policy minima — choosing donor positions from classes that have a surplus, so raising one minimum can no longer break another.Measured at length 16 over 200 samples: v1 produced **1** distinct class composition, v2 produces **82**. Per-character entropy 6.42 → 6.53 bits against a 6.555 ceiling. Chi-square 96.1 (df=93) confirms the modulo bias is gone; lag-32 repeat rate sits within one standard error of chance, confirming the repeating pad is gone. -
Opt-in upgrade UX for existing entries — a per-entry action that re-derives at v2, with a confirmation making the consequence unmissable ("this changes the password; update it at the site first"). Never bulk-migrate. Until this ships, existing entries stay on v1 by design.
-
L2 (done) —
generate_bip85_seed(words_num=...)accepts 12 or 24 and rejects anything else; threaded throughcreate_profile_from_generated_seed. Default stays 12 so existing profiles and callers are unaffected. -
L3 (done for the RNG path) —
os.urandom(32)moved outside the try block ingenerate_bip85_seed, so no handler can stand between the entropy draw and the caller, and the redundant duplicateexcept Exceptionis gone. A test asserts theOSErrorarrives as the same object that was thrown, which is only true if nothing repackaged it. -
L4 (code fixes done) —
torch/temp filename now usesrandomBytes(8); theMath.randomvalue misnamednonceinrelay-health.mjsis nowprobeSuffixfromrandomBytes(6). -
⚠️ Decision needed:
torch/_backups/holds 5240 tracked files across six dated snapshots. Untracking is a large deletion, so it is not an audit side effect. Costs today: duplicates every security grep hit, and barenode --testintorch/discovers them and fails 18 tests from stale snapshot code (npm testuses explicit paths, so CI is unaffected). Separately:torch/has notest/directory in the repo, so itsnpm testcannot run as written. -
L5 (done) —
dependency-audit.ymlgenerates and uploads a CycloneDX SBOM fromrequirements.lock. Verified with the real tool: CycloneDX 1.6, 96 components, crypto chain recorded at exact versions.
The remaining broad except Exception handlers in password_generation.py wrap deterministic
derivation with no RNG in it and re-raise rather than substituting; they belong to the Robustness
item further down, not to this audit.
Security
- Complete checklist item #8 (Supply chain and release integrity):
- run one tagged release through
.github/workflows/release-integrity.ymland link evidence indocs/security_readiness_checklist.md - resolve or formally time-box/own the
GHSA-wj6h-64fc-37mpexception (docs/supply_chain_release_integrity.md) - enforce release/tag protections requiring
Release Integrity+ dependency audit checks - publish maintainer/consumer verification runbook (checksum + cosign verification with expected issuer/identity) ✅
docs/release_verification_runbook.md
- run one tagged release through
- Upgrade vulnerable runtime dependencies in lockfiles and packaging inputs:
aiohttp->>=3.13.3cryptography->>=46.0.5starlette->>=0.49.1python-multipart->>=0.0.22urllib3->>=2.6.3pynacl->>=1.6.2pillow->>=12.1.1orjson,pyasn1,cbor2and otherpip-auditfindings
- Regenerate
requirements.lockafter upgrades and re-run the full test suite. - Re-run
pip-audit -r requirements.lockand ensure zero actionable runtime vulnerabilities. - Evaluate the
Crypto.*imports insrc/seedpass/core/password_generation.pyand either:- keep
pycryptodomeintentionally with explicit documentation, or - migrate RSA generation to
cryptographyAPIs.
- keep
Robustness
- Replace broad
except Exception: passblocks with explicit exceptions + logging:src/main.pysrc/seedpass/core/manager.pysrc/seedpass/core/menu_handler.py
- Add telemetry-safe logs for swallowed error paths without exposing secrets.
- Harden startup/restore prompt flows against
EOFError/ cancellation loops:- startup options (
Continue/Restore from backup) - restore-backup fingerprint/path prompts
- ensure graceful return-to-menu instead of process-level failure.
- startup options (
Tests To Add
- Add deterministic tests for
derive_pgp_key(..., key_type="rsa")covering:- stable output across repeated runs
- key fingerprint format and validity
- Add API rate-limit tests for:
- per-client isolation
- window expiry behavior
- restart/reset behavior after
start_server()
- Add API import tests for boundary conditions:
- exactly-at-limit upload size
- malformed multipart payload
- invalid JSON body for path-based import
- Add regression tests for manager notification/logging fallback paths currently guarded by broad exception handling.
Documentation
- Document API import upload limit (
SEEDPASS_MAX_IMPORT_BYTES) in user docs. - Add a security maintenance section describing dependency-audit cadence and update policy.
TypeScript port (port/typescript-web-extension)
Branch state as of 2026-08-17: core + CLI + interactive TUI are feature-complete for daily use and cross-verified against Python on every change. What follows is everything still open, in the order it should be tackled.
Blockers before real secrets
- Independent security review of the TypeScript branch. Review
strategy (decided 2026-08-18): two AI families plus vectors, because
they fail differently. (1)
/code-review ultra— blocked: PR #989 spans main→port including beta's 150 commits (6795 files/720k lines), over ultra's size limit; fallback options are a review-only PR containing just thejs/tree against main, or a narrower local-diff target. (2) A GPT-family pass (Codex) over the four ranked targets in the PR body, briefed to REFUTE the security claims in file comments, not summarize them. (3) Third-party ground truth where possible: the official BIP-85 spec vectors now run directly against the TS core (test/bip85SpecVectors.test.ts; Python has had them all along), which closes the both-implementations-agree-and-are-both-wrong hole for the root derivation. Triage rule for findings: flagged by both families = almost certainly real; flagged by one = verify against code, arbiter is a reproduction test or the cross-impl suite, never the author's opinion alone. No one but the authoring agent has read this code. Two earlier review rounds on this project each found criticals that self-review missed, so a green suite and an author's sign-off are not evidence of much. Scope it tojs/packages/core/src/crypto,js/packages/core/src/derive, andjs/packages/cli/src/agent.tsfirst: a derivation bug does not throw, it quietly produces a secret that cannot be recovered. Blocks the merge below. - Fix the findings from the 2026-08-17 self-review (next section). Do these before handing the branch to an outside reviewer so their time goes on what an author cannot see.
Findings from the 2026-08-17 self-review
Found by reading and probing, not by the suite — all 128 CLI tests passed before and after each was confirmed. Ordered by severity.
- (fixed 2026-08-17) The agent can be left holding a parent seed
forever, after reporting failure.
AgentDaemon.handle'sputwrites tothis.heldbefore awaiting the audit append. A non-numericttlmakesexpiresAtNaN; the audit write then throws on the non-finite number, so the caller is told the unlock failed — but the seed is resident,expire()'sheld.expiresAt <= nowis false forever, and no audit record exists saying the vault was ever unlocked. Confirmed:owner-mnemonicreturned the full phrase 3.5s after a "failed" put on an agent with a 1s TTL. Fix: validatettl/uses/expires_atas finite positive integers at the daemon boundary, and order every op so state is committed only after its audit record lands. The CLI'sparseIntOptionalready blocks this path, which is exactly why the daemon must not rely on it —agent.ts:1-22states the CLI is untrusted, and the browser extension will be the next thing speaking this protocol. - (fixed 2026-08-17) A
use-scoped token holder can kill the agent and drop every held seed.stdinSinkwrites tochild.stdinwith noerrorlistener, so a command that exits before draining stdin raises an unhandled EPIPE. Confirmed as an uncaught exception inside the agent process via ause-sinkrequest naming/bin/true;main.tsinstalls nouncaughtExceptionhandler, so the realagent startprocess exits. The exec allowlist does not help — any allowlisted command that exits early does it. This is the least-privileged principal in the model taking down the enforcement point. Fix: handlestdinerrors the wayfeedClipboardToolalready does, and treat EPIPE as a delivery failure. - (fixed 2026-08-17) Secrets sent to the clipboard are never cleared. Python's
copy_to_clipboard(text, timeout)starts a timer and clears the clipboard if the value is unchanged; the TypeScriptclipboardSinkjust writes and returns.clipboard_clear_delay: 45is in the default config, is settable, and is read by nothing. This makes TUI Secret Mode — whose entire purpose is to route secrets away from the screen — strictly less safe than Python's, because it parks them on a session-wide clipboard indefinitely. - (fixed 2026-08-17)
kdf_iterationsis inert. Settings displays it, stores it, and tells the user "use Change password to re-wrap this profile's seed at the new strength".changePasswordtakes aniterationsparameter and the TUI never passes it, andcreateProfilehardcodesDEFAULT_PBKDF2_ITERATIONS. Raising it changes nothing anywhere. Either wire it through both paths or remove the setting — a security control that silently does nothing is worse than an absent one. - (fixed 2026-08-17)
inactivity_timeoutis inert. Stored, displayed, settable as Settings item 12, enforced nowhere. Python's TUI locks the vault after it lapses; the TypeScript TUI leaves an unattended terminal unlocked indefinitely. - (fixed 2026-08-17) TUI file exports do not get the 0600 they claim.
menus.tswrites document exports, database exports and the 2FA export withwriteFile(..., {mode: 0o600}).modeapplies only at creation, so writing over an existing 0644 file leaves it 0644 — verified. The 2FA export is every TOTP secret in the vault in plaintext, and its comment asserts the 0600. The CLI already does this correctly withatomicWrite(fresh inode + rename, symlink-safe) and explains why; the TUI regressed it. The TUI exports also silently overwrite, where the CLI requires--overwrite. - (fixed 2026-08-17) Audit-log truncation detection is defeated by deleting one more file.
AuditLog.verifyskips the count check entirely whenaudit.log.headis absent, so removing the head and truncating the log verifies clean. The head sits beside the log with the same permissions, so anyone who can alter one can remove the other. The doc comment claims the head "pins the expected length" against exactly this attack. Either require the head once the log exists, or keep it somewhere the log's writer cannot reach. - (fixed 2026-08-17) Unvalidated token constraint shapes.
token-issuecastskindsandscopeswithout checking they are arrays.kinds: "totp"makesArray.includesbecomeString.includes, turning an exact kind match into a substring match — inert today only because no SeedPass kind is a substring of another.scopes: "reveal"throws a raw TypeError back to the caller. Owner-gated, so this is hardening, not a live hole. - (fixed 2026-08-17) Dead code that reads as a control.
authorize()'sif (entry)branch is never reached (both call sites omit the argument; the real check istokenMaySee), andsinkEnv'sFORBIDDEN_ENVdelete loop runs against an allowlist that never contains those keys. Both look like defenses on inspection. Remove them or make them load-bearing. - (resolved 2026-08-17: confirmed deliberate, documented in authorize()) A denied entry lookup still burns a token use.
resolveForTokenconsumes a use at pre-auth, before the entry is known, so probing for non-existent ids exhausts a token. That ordering is deliberate anti-enumeration; confirm it is the trade wanted and write it down.
Findings from the 2026-08-18 independent audit
Full report: js/SECURITY_AUDIT.md (Fable 5, line-by-line read
of every non-test source file at e9d8908). Verdict: no criticals or highs. This is one
AI family; the GPT-family pass in the blocker above is still outstanding and is what makes
the review independent in the sense that matters.
- (fixed 2026-08-18) M-2 — the profile config's password policy was ignored.
Python bases derivation on
config_manager.get_password_policy()and merges the entry'spolicyblock over it; the port merged the entry block over hardcoded defaults and never read the config. Any profile with a non-default policy therefore derived different passwords in TS than in Python, for every entry — reaching the user as a wrong password, i.e. as data loss, with nothing failing anywhere.materializeSecretnow takes abasePolicy; the CLI, the agent and the TUI all supply the profile config's policy, the TUI through asessionSecrethelper so no menu path can omit it. Evidence:js/packages/cli/test/passwordPolicy.test.ts, four cases against Python-computed values, each also asserted unequal to the pre-fix output; mutation-verified (dropbasePolicyfrom the merge and all four go red). - M-1 — restore can return a stale snapshot when two syncs land in the same second.
Manifests sort by
created_at(whole-second Nostr resolution) with the event id as tie-break, which is arbitrary with respect to time, so the older of two same-second snapshots wins roughly half the time andnostr restoresilently drops the entries created between them. Reproduces as the intermittent failure of "syncs the vault to the relay and restores after local destruction" (~1 in 4 runs). This one is a protocol change, not a local fix: the fix is a monotonic sequence number inside the signed manifest, used as the secondary sort key, and it has to land in Python at the same time or the two implementations will disagree about which snapshot is latest. Decide the wire format before writing code. - L-1 — BIP-85 app-32 path is shared across password, SSH and PGP derivation. Protocol-level, both implementations, needs versioning. Same care as M-1.
- L-2 / L-3 — merge-level data loss that is silent rather than wrong: concurrent same-id creation on two replicas discards one entry at merge; the 2048-entry tombstone cap allows deletion replay. Both are arguably working as designed; the fix for each is to surface the event rather than to change the merge.
- L-4 — remaining inert settings.
additional_backup_pathis stored and confirmed by the TUI and used by nothing. Same class as the five fixed on 2026-08-17: either wire it up or have the menu say it is unavailable. - L-5 / L-6 / L-7 — agent boundary hardening. Anchor
label_regex(unanchored today, so a token's scope is substring-matched and wider than it reads); document that an exec allowlist constrains the command word and not its arguments; validate the wire-suppliedfingerprintin the daemon'sputbefore using it as a path component. All owner-gated, none live holes. - L-8 — plaintext backup import is not bound to a profile.
Cutover
- Fold the CLI bundle's
.sha256into therelease-integritysigning workflow (the remaining item of cutover gate 6). - Merge to
mainand move the Python implementation tolegacy/. Blocked by the independent review above. Merge-day checklist (do these in the same window, so main never advertises a stale product):- README rewrite, TS-first. Install = the
seedpass-jsbundle; features/architecture describe the TypeScript implementation; the Python content moves tolegacy/README.mdwith a pointer. The 🚧 bridge section added 2026-08-18 comes out (it exists precisely because the rewrite would have been premature before review). - Landing page (
landing/). Install command switches off the Pythoninstall.sh(or install.sh itself learns to install the TS bundle); features/architecture copy updated; main/beta branch toggle reconsidered (post-cleanup there is one line of development); drop the_pgbackup/_pginfoPinegrow artifacts. Verify where the live site deploys from before assuming edits take effect. - Docs triage. Python-era planning docs (the 14 bannered
2026-08-18: tui_v2/v3, index0/atlas, semantic) move under
docs/legacy/or keep their banners; living docs (vault/identity spec, migration guide, security model, cutover plan) stay top-level;docs/README.md/index updated to say which is which. - Branch/repo hygiene. Fast-forward or delete
beta(it is fully contained in the merged history); revisitdependabot.yml(its pip entries target the retiring Python deps); CHANGELOG entry for the cutover. - CI.
python-ci/tests/briefcaseworkflows follow the Python code tolegacy/(or are scoped down);ts-parityandrelease-integritybecome the primary gates.
- README rewrite, TS-first. Install = the
Unbuilt milestones
- Milestone 6 (static/PWA web app) and Milestone 7 (browser
extension) of
docs/typescript_web_extension_port_plan.mdare not started —js/packages/holdscore,cliandtest-vectorsonly. The branch is named for a web extension that does not exist yet; the CLI was the proving ground for the core. - Decide whether the
api(FastAPI) surface gets a TypeScript port at all. It is currently excluded from cutover gate 5 as "a separate surface, not vault behavior", and the session agent (unix socket, 0600, scoped tokens) covers agent automation without binding a port — a smaller attack surface for a process holding unlocked seeds.src/seedpass/api.pyis 2093 lines and much of it hangs off features that are deliberately not ported (high-risk partitions, agent job profiles, recovery split, semantic). A faithful port means porting those first; the genuinely useful subset is entry CRUD, search, lock/unlock and config. Options: leave it Python-only, port the subset, or drop it. Decide before the extension lands, since the extension needs some transport.
Shared vault/identity spec (BitLogin groundwork)
- (done 2026-08-18) Entry-id allocation watermark — ids are never
reused after deletion;
_sync_meta.next_index, merged as max, in both implementations with cross-impl parity. The precondition for "account #N" as a recovery coordinate. - (done 2026-08-18)
docs/seedpass_vault_identity_spec.md— v1 draft extracted from working code: canonicalization, containers, derivation table, the two index namespaces, allocation rule, interop rules (preservation, namespacing, no-translation-table, round-trip conformance), capability profiles, sync/backup, and an honest gap ledger. - (done 2026-08-18) Unknown-kind passthrough (spec §8.2/§12) — TS
parses unrecognized kinds as opaque records (verbatim, excluded from
typed operations, malformed KNOWN kinds still fail); CLI listing
redacts foreign fields as
has_*and reveal refuses cleanly; Python tolerance pinned by test. Discovered en route: Python backfills base defaults on foreign records where TS carries verbatim — a benign but real canonicalization divergence, now in the spec gap ledger, to align before foreign kinds actually sync. - (done 2026-08-18) Round-trip conformance check as CI (spec §8.6) —
cross-impl Phase L: a vault holding a foreign record (unknown kind,
trap fields, nested data) plus an unknown top-level index key survives
Python→TS→Python read-modify-write byte-for-byte; allocation skips the
foreign id; TS refuses to reveal it. Landing it forced the
normalization decision: Python's legacy renames now skip unknown kinds
(they would have reinterpreted foreign data — verified: the old code
turns a foreign
blacklistedstring into ourarchivedflag, and Phase L catches it). 38/38 cross-impl checks.
Post-BitLogin and backup/UX ideas (logged 2026-08-18)
- Capsule unlock ("sign in with BitLogin") — spec §11.1 added with the flow and the two normative rules (independence: an unlocking key must not derive from the vault it unlocks; rank: a managed identity must never unlock the root that provisioned it). Implementation is post-BitLogin by definition: it slots in as a fourth seed resolver (env → agent → capsule → password) plus a capsule file format, zero core-crypto changes. Scope first release to delegated org subtrees; personal vaults keep password-primary with capsule as opt-in for self-custodial keys.
- Blossom server support for index backups — store the encrypted snapshot as a single content-addressed blob on N Blossom servers (auth-signed by the existing app-1237 sync identity), with the relay manifest pointing at blob hash + server list. Keeps relay sync as-is (deltas + coordination, works today); Blossom removes the chunking ceiling that big vaults (documents!) hit on relays. Needs: manifest field tolerance check in both implementations, retention/mirror policy, and the same metadata-leakage analysis as relays (a pubkey's blob list is public).
- (phase 1 done 2026-08-18)
.seedpassfile extension — CLIvault export <dir>and the TUI database export generateseedpass-<fingerprint>-<date>.seedpass; extensionless TUI input gets the suffix; explicit paths are used exactly as typed (scripts unaffected); imports never cared about extensions. Zero byte-format change. Remaining (deliberately later): phase 2 format_version 2 with a magic header (versioned, both implementations), and OS file association + icon fromlogo/svg/(XDG MIME via install.sh, registry via install.ps1) — installer work, after #34/#36.
CI health (noticed 2026-08-18 while preparing PR #989)
- ts-parity was red for 33 straight runs on an environment-coupling
bug local runs masked (
vault import --vaultdemanded a default profile; fixed incf433e3with a hermetic SEEDPASS_APP_DIR in the CLI test harness). Standing lesson: CI results were never checked because local suites were green — check the workflow dashboard when a branch is long-lived. - Pre-existing red checks on PR #989, not yet diagnosed (they
predate this week's work):
testsworkflow failures on macOS (3.10/3.11/3.12), thebriefcasemacOS build, and the Netlify docs-seedpass deploy preview. All Python/docs-site era. Triage before merge so #989's required checks are meaningful — either fix or explicitly scope them out of the merge gate.