exitOverride() makes commander throw for everything it would otherwise exit
on -- displaying help and its own usage errors included. The handler only
recognised two of its codes, so running `seedpass-js` with no arguments
printed the help and followed it with `error: (outputHelp)`, and unknown
commands had their message printed twice.
Any commander.* code is now commander's business: honour its exit code and
let it own the message. SeedPass's own errors still print as before.
Found by running the command.
Co-Authored-By: Claude Fable 5 <[email protected]>
It moved out of the app directory in the previous commit; the principals
table still described the old location.
Co-Authored-By: Claude Fable 5 <[email protected]>
The reviewer that took longest found the most, including one bug that lies
to the user about a security-relevant outcome.
- `agent stop` never sent the capability and never checked the reply. The
daemon refused the shutdown, the CLI printed {"agent":"stopped"} anyway,
and the seed stayed in memory and still served owner-mnemonic. The command
documents itself as wiping held seeds. It now authenticates and reports
what actually happened.
- The owner capability lived at <appDir>/agent.sock.cap -- inside the very
directory a scoped agent must be given to find fingerprints.json and the
socket. A token holder could `unset SEEDPASS_TOKEN` and the CLI would read
the capability on its behalf. It now lives under XDG_RUNTIME_DIR in a 0700
parent, created with wx so a pre-planted file is never adopted. This raises
the bar; it does not make a token a sandbox, and capabilities.ts no longer
claims otherwise.
- Redaction was a three-key denylist over deliberately loose schemas, so
`custom_fields` -- including values Python masks as is_hidden -- went to
any read-scoped token verbatim. It is now an allowlist that recurses into
custom_fields, reporting labels and presence but never values.
- Authorization ran after the vault was decrypted and after the entry
existence check, giving an unauthenticated caller an entry-id enumeration
oracle, no audit record for the misses, and a full PBKDF2 unlock per probe.
Tokens are now checked first, denials are indistinguishable for missing
and forbidden entries, and every denial is audited.
- The audit chain had no anchor: deleting the log, or a suffix of it, left
something that verified perfectly. A signed head now pins the record count
and tail signature. Separately, `loaded` was latched before the read that
could throw, so one transient EACCES made every later append sign from
genesis and broke the chain permanently.
- The exec allowlist -- documented as the only real containment for a `use`
grant -- was bypassable via the clipboard (a session-wide channel) and had
no CLI flag at all, so no shipped token could carry one. Both fixed.
- Unauthenticated connections could buffer without bound, growing to
gigabytes while each chunk rescanned the accumulated string. Line size and
concurrent connections are now capped.
- A token-supplied TOTP timestamp was unbounded, so one use minted codes
valid decades ahead, outliving the token's TTL. Clamped to a small window.
- Prototype keys ("__proto__", "constructor") resolved to inherited
properties, passed constraint checks against undefined values and burned a
use; now an own-property check. The socket was created with the umask mode
and chmod'd afterwards, leaving a connectable window; it is now created
under a restrictive umask. TTL was enforced only when a request arrived, so
an idle agent held the seed indefinitely past expiry; there is now a timer.
220 core + 78 CLI tests green; 36/36 cross-implementation checks green.
Co-Authored-By: Claude Fable 5 <[email protected]>
Four independent reviewers went over the branch. The worst findings were in
code I had just written and reviewed myself, which is the argument for
having done this.
CRITICAL
- A mnemonic was validated in one form and derived from in another. @scure's
normalize only counts words, so a phrase with a trailing newline passed
validation and then derived a completely different seed, while Python
canonicalizes and derives the correct one. The result was a vault encrypted
under a key that neither implementation -- nor the user's written-down
phrase -- could ever reproduce. Mnemonics are now canonicalized (NFKD,
lowercase, single-space) once, and every derivation site uses that form:
Bip85.fromMnemonic, deriveIndexKeyBytes, deriveTotpSecret, deriveKeyIndex
and generateFingerprint.
- atomicWrite ignored short writes. `handle.write()` may write fewer bytes
than asked (ENOSPC, RLIMIT_FSIZE, EINTR); the truncated temp file was then
fsynced and renamed over the only copy of the vault, with exit code 0 and
no stderr -- inside the function whose comment promised crash safety. It
now loops until every byte lands, fails loudly otherwise, and removes the
temp file on any failure.
- `fingerprint add` overwrote an existing profile. It consulted only
fingerprints.json, which readFingerprints returns empty for whenever the
file is corrupt or unreadable, then rewrote parent_seed.enc and the index.
Restoring a profile directory from backup and re-registering it -- the
canonical recovery gesture -- destroyed the vault. It now refuses if the
seed or index file already exists.
- Entry-id allocation could collide. A non-numeric or above-2^53 key (which
can arrive through import or a merge) made nextIndex return NaN or stop
incrementing, and insert overwrote blindly, so each new entry destroyed the
previous one. Both are now rejected, and insert refuses to overwrite.
HIGH
- RelayPool accepted any event a relay chose to send: verifyEvent only proves
an event is internally consistent, never that it came from the pubkey,
kind, or id we asked for. Events are now structurally validated (including
created_at being a finite safe integer -- "1e999" parsed to Infinity and
sorted ahead of everything forever) and matched against the filter.
- Snapshot handling had no resource budget: a decompression bomb turned 20 KB
of relay data into 20 MB, unbounded. Chunk count, chunk size, total
compressed and total decompressed bytes are now capped, the gunzip is
aborted mid-stream when it exceeds the budget, and each chunk must match
the size its signed manifest declares.
- Manifest tie-breaking used relay arrival order, letting a relay pick which
of two same-second snapshots won; it now breaks ties on event id, which no
party can steer. Falling back to an older manifest is recorded so callers
can surface a downgrade.
- Argon2/PBKDF2 parameters were read unbounded from the kdf block, which sits
outside the AEAD and is writable by anyone with directory access: 1e9 time
cost hung indefinitely, and this core targets browsers too. Now clamped.
- Config and fingerprint-registry writes held no lock at all, losing 4 of 6
concurrent updates in testing. Both now use a read-modify-write lock, and
the vault lock itself only breaks a lock whose owner pid is gone, steals it
atomically by rename, and releases only if it still owns it.
- Plaintext egress (document export, vault export) used writeFile's mode,
which applies only on creation, and followed symlinks -- an existing
world-readable file or a planted symlink received the secret. Both now go
through atomicWrite, which creates a fresh 0600 inode.
MEDIUM
- canonicalJson did not match Python. Exponent padding (1e-7 vs 1e-07), the
1e16 threshold, -0, and key ordering (UTF-16 code units vs code points) all
differed, and the code comment claiming they agreed was false. Since entry
hashes decide merge winners, two clients could keep different versions of
an entry forever. Now matched, with 24 differential fixtures generated from
CPython, and values above 2^53 refused rather than guessed at.
- "__proto__" as an entry or tombstone key silently vanished and polluted the
prototype; containers are now null-prototype. Python truthiness differs
from JS on empty containers, so `"_deleted": []` tombstoned an entry in JS
and kept it in Python; now matched.
- Policy blocks with JSON null produced the literal string "null" as the
special-character alphabet. TOTP secrets that pyotp accepts (lowercase,
unpadded) were rejected. Both fixed.
- hexToBytes accepted malformed input ("1z" -> 0x01, "-1" -> 0xff) where
Python raises; relay-supplied ids and signatures reach it.
- Password/fingerprint normalization used JS trim(), which strips a different
set than Python's strip(), changing derived keys; now uses Python's set.
Also removes a scratch file a reviewer's session left committed.
220 core + 71 CLI tests green; 36/36 cross-implementation checks green.
Co-Authored-By: Claude Fable 5 <[email protected]>
Written after a security review had to infer the intended boundary from the
code. Records the principals and how each proves itself, what the capability
file does and does not defend against (nothing, against a same-uid
attacker), what each token scope grants, and the honest limit of the `use`
scope: a holder that chooses the sink command can read the secret, so `use`
buys containment only with an exec allowlist, and accountability always.
Also names the limits rather than burying them: no memory scrubbing, and the
two protocol-level issues shared with Python that cannot be fixed in one
implementation without breaking convergence.
Co-Authored-By: Claude Fable 5 <[email protected]>
Found by running attacks against the socket rather than reading the code:
a token supplied as a single-element array was accepted, because
String(["abc"]) is "abc" and the comparison went through String() coercion.
No privilege was gained -- the attacker still needed the real token -- but
type coercion on a credential path is the kind of sloppiness that becomes a
bug the moment the surrounding code changes. The handler now requires op,
cap, token, fingerprint, id and sink to be strings, and command to be an
array of strings, rejecting anything else outright.
The same run confirmed the rest of the boundary behaves: by-id access to
entries excluded by a token's label regex or kind constraint is refused
server-side with the correct reason, reveal without the reveal scope is
refused, denials do not consume uses, concurrent requests cannot overrun a
one-use token, and a sink child spawned by the agent sees exactly one
SEEDPASS_ variable -- the secret it was given.
Co-Authored-By: Claude Fable 5 <[email protected]>
Secret input:
- Values that are secrets can now be read from stdin: `entry add key-value
--stdin`, `entry add totp --secret-stdin`, `entry add document --stdin`,
and `entry modify --value-stdin`. Arguments are visible in shell history
and process listings, so the positional forms now warn (without echoing
the secret they are warning about) rather than silently accepting.
Packaging (the published package was broken):
- `exports` pointed at src/index.ts while `files` shipped only dist/, so an
installed package had no entry point; exports now points at the bundle.
- A `workspace:*` runtime dependency would have failed any real install. The
bundle inlines everything, so the package now declares no runtime
dependencies at all. Verified by packing the tarball, installing it into a
clean project and running the binary -- and CI now does the same on every
change, because this class of mistake is invisible to build and test.
- The workspace globs cover apps/* so a future extension cannot sit outside
CI.
- @types/node aligned with the advertised Node 22 runtime.
Release and supply chain:
- release-integrity now builds the CLI bundle into dist/ before checksums
are generated and signed, so the executable TypeScript artifact is covered
by the same manifest and cosign signature as the Python ones.
- A new javascript-supply-chain job runs `pnpm audit` and produces a
CycloneDX SBOM; the JS tree previously had neither.
Also documents two findings that are protocol-level and shared with Python,
where changing the port alone would break convergence: tombstone retention
allowing deletion replay after the 2048-entry cap, and SSH/PGP sharing a
derivation domain (same index yields the same Ed25519 key). Both need a
versioned change in both implementations and are recorded in the
compatibility matrix rather than silently patched here.
193 core + 70 CLI tests green; pnpm audit clean.
Co-Authored-By: Claude Fable 5 <[email protected]>
Continues the security-review fixes.
Durability and concurrency:
- Vault, config, fingerprint-registry and parent-seed writes are now atomic:
temp file in the same directory, fsync, rename, then an fsync of the
directory. Previously the target was truncated and rewritten in place, so
a crash could destroy the vault.
- The lock now spans the whole read-modify-write cycle, not just the write.
Serializing writes alone still lost changes -- two commands each read the
same index, mutated their copy, and wrote in turn. Every mutating command
goes through mutateVault(), which holds the lock from open to save. A test
runs five concurrent writers and asserts all five entries survive.
- 0600 is now enforced on existing files too: writeFile's mode argument only
applies on creation, so a loosened vault stayed loosened.
- The index is schema-validated before it can reach disk.
Path handling:
- Profile fingerprints are validated (16 uppercase hex) before being used in
any path, and removeProfile additionally checks the resolved directory
still sits directly under the app directory. A tampered registry could
otherwise have steered a recursive delete elsewhere.
- Malformed entries in fingerprints.json are dropped on read.
Restore semantics:
- `nostr restore` merges remote state into the local vault instead of
replacing it. Replacing silently discarded entries created since the last
sync; that now requires --replace --yes, and any overwritten vault is
copied aside first.
- Deltas are bound to the manifest they extend (filtered on the e tag, and
re-checked locally), so a relay cannot replay a validly-signed delta from
another snapshot lineage into a restore.
- Manifest selection breaks created_at ties by relay order, so a same-second
republish cannot leave a restore on a stale snapshot.
- mergeIndexPayloads gained an index0 option. The default still refuses to
merge populated Python atlas state rather than drop it; restore uses
preserve-current, keeping the local block verbatim, because Python
recomputes canonical views on load and the default would block every
restore into a Python-created profile.
193 core + 69 CLI tests green; 36/36 cross-implementation checks green.
Co-Authored-By: Claude Fable 5 <[email protected]>
A security review found the agent's authorization model was enforced in the
client, not the server: any local process that could open the 0600 socket
could retrieve the parent seed, mint tokens, unlock/lock, or shut the agent
down, because the daemon accepted privileged operations unauthenticated. It
also found that scoped tokens received far more than their grant, and that
sink children inherited the caller's entire environment.
Server-side authorization:
- Privileged operations (put, owner-mnemonic, token issue/list/revoke, lock,
status, shutdown) now require a capability secret written to a 0600 file
beside the socket at startup, compared in constant time. Socket permissions
alone cannot distinguish the owner's CLI from a scoped agent holding a
token. The residual risk is documented in the code: a same-uid attacker who
can read that file, or ptrace the daemon, still wins -- that needs an
OS-level boundary this process cannot provide.
- The mnemonic-returning op is renamed owner-mnemonic and is owner-gated. It
is unreachable with a token.
Token containment:
- vault-index returned the decrypted index to any read-scoped token, handing
over every imported TOTP secret, key-value value and document body. It now
returns redacted metadata, filtered to entries the token's kind and label
constraints actually cover, so out-of-scope entries are not even disclosed.
- The `secret` op now requires the reveal scope specifically. A use-scoped
token can no longer pull plaintext back across the socket.
- `use` is served by a new use-sink op: the agent runs the sink itself and
returns a receipt, so plaintext never crosses back to the token holder.
Tokens gained an optional exec allowlist, because a holder who chooses the
command can always read the secret from inside it -- the allowlist is what
contains a `use` grant, the audit record is what makes it accountable.
Secret hygiene and state:
- Sink children get a minimal allowlisted environment. SEEDPASS_MNEMONIC,
SEEDPASS_PASSWORD, SEEDPASS_TOKEN, the agent socket and app dir are never
inherited: a helper invoked to receive one password could previously have
taken the whole vault.
- Locking (and TTL expiry) now clears that profile's tokens and its audit
logger, so tokens cannot spring back to life after a later unlock and the
seed-derived audit key does not stay resident.
- Audit appends are serialized, so concurrent requests cannot interleave and
break the HMAC chain.
- Numeric CLI options are validated before anything is persisted. A single
`--length not-a-number` previously stored NaN as JSON null and made every
later vault open fail schema validation.
test/agentSecurity.test.ts speaks the wire protocol directly, bypassing the
CLI, and asserts each of the review's reproductions now fails: no seed, no
token minting, no lock/shutdown/status/put, no raw index for a read token, no
plaintext for a use token, no owner ops via a token, and no SEEDPASS_* in a
sink child. Also verifies the capability file is 0600 and that tokens die
with a lock.
193 core + 60 CLI tests green; 36/36 cross-implementation checks green.
Co-Authored-By: Claude Fable 5 <[email protected]>
Gate 5 (feature parity) -- document import/export ported: `entry
import-document` reads a text file (label defaults to the file stem, type
from the extension) and `entry export-document` writes one back, reproducing
Python's filename sanitization, directory-vs-file destination handling and
overwrite refusal. That was the last concrete item; the three remaining
exclusions (PGP RSA, semantic, api) are now documented decisions rather than
open work, and none of them blocks cutover.
Gate 6 (packaging) -- js/packages/cli/build.mjs bundles the CLI into a
single self-contained ESM file with no runtime dependencies beyond Node 22,
writes a .sha256 beside it, and is wired up as the package `bin` and
`prepack` so npm install/pack produce a working command. --version now
reports a real version stamped at build time. CI builds the bundle and
smoke-tests it from a directory with no node_modules -- version,
capabilities, profile creation, entry creation, reveal -- then uploads it.
Verified locally that the bundle runs standalone outside the workspace.
Gate 7 (migration and rollback) -- docs/typescript_migration_guide.md: the
migration is a no-op (same profiles), so the guide covers what to back up,
how to rehearse against a copy with SEEDPASS_APP_DIR, the two behavioral
differences users will notice, the known exclusions, and seed-only recovery.
Rollback is proven rather than asserted: new cross-impl phase K drives a
profile through the TS CLI -- several entry kinds created, one edited, one
archived -- then checks Python reopens it, sees every change, derives the
same secrets, and can still write to it afterwards.
Also adds js/packages/cli/README.md documenting the reference-first output
model and the scoped-token workflow.
193 core + 49 CLI tests green; 36/36 cross-implementation checks green.
All eight cutover gates are now green.
Co-Authored-By: Claude Fable 5 <[email protected]>
test_profile_init_integration failed on any machine with a generated test
profile already present. Two causes, both real bugs rather than test noise:
- scripts/generate_test_profile.py mutated constants.APP_DIR at import time
to append "tests". The value was captured when constants was first
imported -- long before a test can monkeypatch Path.home -- so the script
read the developer's real ~/.seedpass/tests, and a reload appended another
"tests" segment. The mutation also leaked into every other importer of
constants in the same process. The tests directory is now derived per call
from constants.APP_DIR instead.
- initialize_profile assumed add_fingerprint returns None for an existing
profile, which is what its docstring still promises; it actually raises
ValueError. Re-running the generator against an existing profile therefore
crashed. It now treats "already exists" as the ordinary case.
test_profile_init_integration also repoints constants.APP_DIR so it no
longer depends on the developer's home directory, and
test_generate_test_profile asserts against the derived tests directory
rather than the mutated global.
Verified hermetic: the test passes repeatedly and touches nothing under the
real ~/.seedpass. Full Python suite: 1178 passed, 16 skipped, 0 failed.
Co-Authored-By: Claude Fable 5 <[email protected]>
The last unported derivation, and the one the plan flagged as highest risk.
It turned out to be fully reproducible: the key material is deterministic,
EdDSA signatures are deterministic (RFC 8032), and the packet layout is
fixed -- so the armored blocks match exactly, with no OpenPGP dependency.
core/derive/pgp.ts emits the packets directly: new-format headers, the
secret-key packet (unencrypted MPI plus 16-bit checksum), the user-id
packet, and the self-certification signature with PGPy's exact subpacket
set and order, wrapped in ASCII armor with a CRC-24 footer. Verified
against PGPy across two seeds, three indices and three user-id shapes --
armored private key, armored public key, and fingerprint.
RSA is deliberately unsupported: PyCryptodome's seeded prime search is not
reproducible, so the TS port refuses the key type rather than silently
deriving a different key.
Two things worth recording from the build: the v4 fingerprint reproduces
from first principles (SHA-1 over the public-key packet), which is what
made byte-parity look feasible before committing to it; and the only
mismatch in the first run was packet framing -- PGPy uses new-format
headers where I had emitted old-format.
CLI: `entry add pgp`, reveal (armored private key), `entry pgp-public`.
Cross-impl phases A/B now cover a pgp entry in both directions, completing
coverage of every creatable entry kind.
193 core + 47 CLI tests green; 30/30 cross-implementation checks green.
Co-Authored-By: Claude Fable 5 <[email protected]>
TotpManager built pyotp.TOTP(secret) with library defaults (6 digits, 30s)
and ignored what the entry recorded, so any imported or derived entry using
other values produced codes the issuing service rejects. The service was
configured from the entry's values -- those govern the code.
- current_code_from_secret and current_code take period/digits
- EntryManager.get_totp_code passes the entry's values for both the
derived and imported paths
- display_service, menu_handler (totp_list now carries digits) and the GUI
updated at every call site
- regression tests for imported and deterministic entries with custom
parameters, asserting against pyotp with the matching interval/digits,
and confirming default entries are byte-identical to before
Found by scripts/cross_impl_check.py, which flagged it as a divergence
against the TypeScript port. That allowance is now removed: the suite
asserts TOTP equality and reports 30/30 with zero divergences.
Co-Authored-By: Claude Fable 5 <[email protected]>
Expanded the cross-implementation suite from 19 to 30 checks so every
feature is exercised against the other implementation, not just the ones
that were convenient:
G edits (modify / archive / links) made by either side are seen by the other
H deterministic conflict merge produces byte-identical output on both sides
I an Argon2id-protected Python profile unlocks in TS
J the encrypted config file interoperates both directions
Entry coverage in phases A/B now spans every creatable kind, including a
policy-constrained password and an imported (non-deterministic) TOTP.
Two real bugs this surfaced, both fixed:
- Password entries carrying a `policy` block derived the WRONG password in
TS. Python's _generate_password_for_entry merges the entry's overrides
onto the base policy before deriving; TS ignored the block entirely, so
any entry created with policy flags produced a different password.
core now exposes passwordPolicyFromRecord and the CLI applies it.
- TS could not read a Python-written config file: Python wraps it in the
kdf/ct JSON envelope (same class as the snapshot bug fixed last commit).
loadConfig now parses the envelope; regression test included.
One behavioral divergence found and deliberately NOT papered over:
Python generates TOTP codes with pyotp defaults (6 digits / 30s) even when
the entry records different values, so an imported 8-digit/45s secret
yields different codes in each implementation -- and Python's would be
rejected by the issuing service. The suite now reports it as a [DIVERGE]
line rather than a pass or a failure, and the matrix records it as awaiting
a product decision. TS behavior is believed correct.
173 core + 47 CLI tests green; 30/30 cross-implementation checks green
with 2 documented divergences.
Co-Authored-By: Claude Fable 5 <[email protected]>
Phase F starts the in-process NIP-01 relay, has the real Python NostrClient
publish a chunked snapshot to it, then restores that snapshot with the TS
CLI on a clean profile and checks the secrets survive.
It immediately caught a genuine restore bug: Python's index files wrap the
ciphertext in a kdf/ct JSON envelope, and a published snapshot carries
whatever the local file held. TS decrypted the fetched bytes directly, so
it failed on anything Python published -- invisible to the TS-only tests
because TS writes bare ciphertext. Restore (and delta replay) now parse the
wrapper first, exactly as decrypt_and_save_index_from_nostr does.
Regression test in relay.test.ts publishes a wrapper-format payload through
a live relay and restores it.
Cutover plan updated: gate 4 (sync protocol) now cites this end-to-end
evidence, and gate 5's remaining list drops SSH and migrations.
171 core + 46 CLI tests green; 19/19 cross-implementation checks green.
Co-Authored-By: Claude Fable 5 <[email protected]>
- core/derive/ssh.ts: BIP-85 app 32 entropy used directly as the Ed25519
private key, serialized as PKCS#8 and SubjectPublicKeyInfo PEM. Both DER
shapes are fixed for Ed25519, so they are emitted from literal prefixes
rather than adding an ASN.1 dependency to the crypto path.
- Output matches Python's `cryptography` byte-for-byte across two seeds and
three indices, including PEM line wrapping.
- CLI: `entry add ssh`, reveal (private key PEM), and `entry ssh-public`
which prints the public key without touching secret material. The
OpenSSH authorized_keys line is a TS-only convenience -- Python emits
only PEM, which ssh(1) cannot consume directly -- and is marked as such
in the compatibility matrix.
- Cross-impl phases A and B now include an ssh entry in both directions.
One harness fix: PEM values carry a trailing newline that the CLI capture
strips, so the cross-impl comparison normalizes trailing whitespace. The
unit tests still compare exactly.
170 core + 46 CLI tests green; 16/16 cross-implementation checks green.
Co-Authored-By: Claude Fable 5 <[email protected]>
This was the one remaining gap that could strand a real user: TS refused
any index below schema_version 4, which is every profile written before
tags were added.
- core/schema/migrations.ts ports seedpass.core.migrations exactly,
including the quirks: v1->v2 copies `website` to `label` and drops it
only for password entries; `kind` is NOT backfilled (Python leaves
pre-v2 entries `type`-only and falls back on read). Migration works on a
copy, so a failure can never leave a half-converted vault.
- parseVaultIndex now migrates on open and still refuses future versions;
pass {migrate:false} where silent upgrades are unwanted.
- Two bugs this surfaced: the version probe could not read a v0 index (no
schema_version field at all), and migrated legacy entries failed schema
validation because the discriminator `kind` was missing. The parser now
fills `kind` from `type` at validation time -- the same shape Python
writes for new entries, documented in the matrix.
Evidence: fixtures generated by running Python's own apply_migrations over
v0/v1/v2/v3 payloads, plus cross-impl phase E, which writes a real legacy
v2 index into a Python profile, opens it with the TS CLI, reveals a
migrated secret, and confirms Python still reads the profile afterwards.
162 core + 46 CLI tests green; 16/16 cross-implementation checks green.
Co-Authored-By: Claude Fable 5 <[email protected]>
Fixtures show both implementations compute the same values. This adds the
missing half: proof they can read each other's on-disk profiles and
backups, using each side's real code paths.
scripts/cross_impl_check.py (12 checks, wired into CI):
A. Python creates a profile -> TS opens the index, derives every secret
identically, and unlocks it with the master password alone
B. TS creates a profile -> Python decrypts parent_seed.enc and the index,
recovers the same seed, derives the same secrets
C. Portable backups round-trip both directions (Python verifies the
checksum of a TS-written export)
D. Both implementations reject a bad-checksum mnemonic
Two real compatibility bugs it caught, both fixed:
- TS-created profiles were unopenable by Python. Python only honors the
recorded parent-seed KDF parameters when kdf.name is exactly "pbkdf2";
TS wrote "pbkdf2-sha256" with an empty salt and 100k iterations, so
Python fell back to its config defaults (200k, fingerprint salt) and
derived a different key. TS now writes exactly what
PasswordManager._build_seed_kdf_config does, and its own unlock path
honors the recorded salt and iteration count.
- TS accepted invalid BIP-39 mnemonics. Seed derivation takes any string,
so a typo'd phrase silently produced a *different* vault -- unopenable
by Python and unrecoverable by the user. Mnemonics are now validated
wherever they enter (profile creation and SEEDPASS_MNEMONIC), with an
error that says why it matters.
Also: docs/typescript_cutover_plan.md records the gate list for promoting
TS to main and retiring Python to legacy/reference -- P0 gates 1-4 are
green, 5-7 (feature parity tail, packaging, migration/rollback) are not.
The Python tree stays in CI after cutover as the compatibility oracle.
Regression tests for both bugs. 151 core + 46 CLI tests green,
12/12 cross-implementation checks green.
Co-Authored-By: Claude Fable 5 <[email protected]>
Ran the full CLI surface against a freshly generated throwaway seed
(profile -> agent unlock -> all 8 entry kinds -> reveal/sinks -> tokens ->
audit -> backup -> relay sync -> seed-only recovery). Four defects that no
unit test had caught:
- clipboard sink hung the CLI forever. xclip/wl-copy fork a resident
process to own the X/Wayland selection; it inherited our stdio, so any
reader of the CLI's stdout blocked indefinitely, and waiting for process
exit never returned. Now detached with ignored stdio, success declared
once stdin is flushed.
- 'vault import' read the mnemonic straight from the env, so it failed
whenever the vault was unlocked through the agent -- the normal path.
- 'vault import' never wrote anything; it only printed a summary. It now
restores into the active vault, refuses to overwrite a non-empty vault
without --yes, and keeps the old behavior behind --inspect.
- A corrupt/foreign vault file surfaced the raw codec error "invalid token
encoding". It now names the actual likely causes.
Also documented the variadic-option limitation that a trailing flag is
lost (commander stops collecting at the next dash-prefixed token): a
quoted command spec now splits on whitespace, no shell involved.
Regression tests for all four. 151 core + 44 CLI tests green.
Co-Authored-By: Claude Fable 5 <[email protected]>
The section-9.3 permission model is now working code, enforced where the
keys live:
- Token model (aligned with the Python agent surface): bearer tokens with
read/use/reveal scopes, entry-kind and label-regex constraints, TTL and
use counts. Issued and held by the session agent — hash-stored, plaintext
shown exactly once at issuance; list/revoke commands.
- Token mode: with SEEDPASS_TOKEN set (and no mnemonic), the CLI reads the
decrypted index and materializes secrets AGENT-SIDE. The constrained
process never receives the mnemonic, and resolveMnemonic refuses owner
fallback in token mode — a token can never silently escalate (the test
suite caught exactly this hole and it is now closed).
- Denials are precise and audited: wrong scope, wrong kind, unmatched
label, expired, exhausted, revoked. Reads don't consume uses; secret
deliveries do.
- Audit chain in Python AuditLogger's scheme: sig = HMAC-SHA256(key_index,
prev_sig + canonical_payload), genesis 64 zeros, per-profile audit.log;
agent records unlocks, token lifecycle, index reads, deliveries and
denials (never secret values). audit-verify/audit-tail commands; a forged
record breaks the chain in tests.
- 151 core + 41 CLI tests green.
Co-Authored-By: Claude Fable 5 <[email protected]>
- core entryMod: modify_entry port with the per-kind allowed-field matrix,
policy merging, blacklisted-field cleanup and timestamp touching;
archive/restore; typed links (add/remove/get with Python's normalization,
dedupe on target/relation/note, self-link and missing-target refusal).
- Parity fixture entry_mods.json: the real Python EntryManager applies a
10-op modification sequence at a later pinned clock; TS replays the same
ops from the creation state and matches byte-for-byte.
- CLI: entry modify/archive/unarchive/links/link-add/link-remove,
entry totp-codes (explicit egress, honors --at and archived state),
util generate-password (Python parity: index 0 + gen v1 defaults, full
policy flags — v1/v2 outputs verified against password fixtures).
- capabilities: egress command list updated.
- 148 core parity tests (Node + jsdom) + 22 CLI tests green.
Co-Authored-By: Claude Fable 5 <[email protected]>
- NIP-01 event serialization/id computation, BIP-340 signing and
verification via @noble/curves, SeedPass event builders (manifest 30070,
chunk 30071, delta 30072 with d/e tags), and REQ/EVENT/EOSE/OK/NOTICE/
CLOSED client-relay framing. Transport-agnostic; the WebSocket adapter
lands with the interface milestones.
- Cross-implementation fixtures generated through the Python side's
nostr_sdk (rust-nostr) with pinned created_at: TS ids match theirs
byte-for-byte, their signatures verify in TS, and TS-signed events
reproduce identical ids. The generator caches signatures across runs
(BIP-340 aux randomness) so fixture output stays byte-deterministic and
the CI drift job holds.
- 143 core parity tests (Node + jsdom) + 16 CLI tests green.
Co-Authored-By: Claude Fable 5 <[email protected]>
- core entryOps: add* functions for all creatable kinds rebuild the Python
EntryManager fixture index byte-for-byte under a pinned clock — field
shapes, id allocation, independent TOTP derivation-index allocation, and
Python's ISO timestamp format all verified.
- CLI 'entry add password/totp/key-value/document/seed/managed-account/
nostr': provision-blind — returns the reference and metadata, never the
secret the entry represents; tests assert the derived password / imported
secret / child mnemonic appear nowhere in provisioning output.
- Parity bug found and fixed by the new entry_secrets fixtures: nostr ENTRY
keys derive via the default BIP-85 app 39 path (EntryManager), not app
1237 (sync client identity); the CLI had used 1237. Reveal values for
password/TOTP/nostr/seed/managed-account now match Python retrieval.
- 129 core parity tests (Node + jsdom) + 16 CLI tests green.
Co-Authored-By: Claude Fable 5 <[email protected]>
- Portable backup (format_version 1): TS imports Python exports, re-exports
byte-identically under pinned nonce/timestamp, verifies the canonical
checksum, rejects tampering and unknown versions.
- .github/workflows/ts-parity.yml: typecheck + Node/jsdom parity suite on
js/ changes, plus a fixture-drift job that regenerates fixtures from
Python and fails if the committed ones don't match (manifest commit field
excluded by construction).
- packages/cli — seedpass-js MVP implementing plan section 9.3:
* reference-first output: entry list/get/search emit sp://entry/<id> refs
and metadata; secret-bearing fields become has_* flags
* 'entry reveal' is the only plaintext egress command
* 'use --clipboard|--exec|--stdin-to' delivers secrets to sinks; --exec
injects SEEDPASS_SECRET into the child env, never argv or stdout
* vault export/import over portable backups; capabilities --format json
* mnemonic comes from SEEDPASS_MNEMONIC env, never the command line
- CLI tests prove the blindness properties: derived password matches the v2
fixture, TOTP at pinned time, secrets absent from all default output and
from sink-delivery confirmations.
- 125 core parity tests (Node + jsdom) + 11 CLI tests green.
Co-Authored-By: Claude Fable 5 <[email protected]>
- Snapshot model parity (kinds 30070/30071/30072): gzip chunking via
platform CompressionStream, chunk-hash verification, reassembly of
Python-produced chunks, manifest JSON schema, manifest id
HMAC-SHA256(key_index, 'manifest|'+nonce) with the key_index HKDF chain.
- Full merge_index_payloads port (modified_ts_hash_tombstone_v2):
Python-compatible canonical JSON (sort_keys + ensure_ascii) for hash
parity, equal-ts field-level union, tombstone create/prefer/apply/cap,
_sync_meta bookkeeping. Populated _system.index0 content refuses to merge
loudly (atlas-milestone work) instead of silently dropping data.
- Delta replay: encrypted V3 deltas decrypt and merge in order with
source_tag = sha256(payload)[:16]; final state matches Python.
- New fixtures: nostr_snapshot.json, sync_merge.json (8 scenarios),
delta_replay.json; generator remains byte-deterministic.
- 117 parity tests green in Node and jsdom.
Co-Authored-By: Claude Fable 5 <[email protected]>
- scripts/generate_ts_port_fixtures.py: deterministic fixtures from public
test mnemonics (passwords v1+v2, BIP-39/85, TOTP, Nostr keys, managed
seeds, fingerprints, index keys, entry schemas, V3 vault payload).
Byte-identical across runs; v1 values match the frozen entropy-integrity
vectors.
- js/: pnpm workspace with @seedpass/core and @seedpass/test-vectors.
Core implements BIP-85, password gen v1 (frozen) + v2, TOTP, Nostr keys,
fingerprints, vault index key, and V3 AES-GCM decrypt using only
noble/scure crypto + WebCrypto. Strict TS, no Node/DOM deps in core.
- 82 parity tests pass in Node and a jsdom browser-like environment.
- docs/typescript_port_compatibility_matrix.md tracks per-area status.
Co-Authored-By: Claude Fable 5 <[email protected]>
Agents provision entries and get back references, never values; use goes
through one-time leases to sinks (clipboard/exec/stdin/extension fill);
reveal is a separate high-risk grant. Reference-first JSON output becomes a
design constraint on the Milestone 5 CLI.
Co-Authored-By: Claude Fable 5 <[email protected]>
1176 passed, 0 failed. These were all reachable only after 9e0f045 stopped
the runner exiting early, and every one had a distinct cause.
One real bug, in index0.py. _build_children_view sorted entry ids with
int(str(id)), which raised ValueError on any id that is not a decimal
string. That happens inside view building during index load, so a single
malformed id made the whole vault fail to open -- for a password manager
that is indistinguishable from data loss. Non-numeric ids now sort after
numeric ones rather than crashing; a bad id should degrade the ordering, not
brick the index.
Eight stale assertions against a schema that moved on. The index now carries
a machine-managed _system.index0 block (canonical views, writer ids, scope
paths) added on load, so tests that saved a literal dict and compared it
back always differed by exactly that key -- the round trips themselves were
correct the whole time. Adds helpers.user_data() to drop it, and uses it in
the export/import, legacy-migration and nostr-fallback tests. Those tests
are about user data surviving; asserting on _system would pin an
implementation detail they are not about.
Three checksum tests patched seams that no longer exist. They stubbed
manager.verify_checksum (gone) and manager.calculate_checksum, which
handle_verify_checksum no longer reaches -- it now asks
_script_checksum_status, which returns early on a launch-target mismatch
before any checksum is computed. Rewritten against that seam, plus a new
case for the target-mismatch verdict, which nothing covered.
Two tests stubbed builtins.input but not sys.stdin.isatty. Both code paths
refuse a non-TTY before ever calling input, so they returned early under
pytest. Uses the isatty stub the rest of the suite already uses.
One test asserted the opposite of the intended contract.
test_setup_encryption_manager_rejects_wrong_argon2_params expected a seed to
be UNREADABLE when the profile's argon2_time_cost differs from the one it
was written with. But encrypt_parent_seed stores its KdfConfig and
setup_encryption_manager tries those stored params first, deliberately: the
profile config says how to derive NEW keys, and refusing an existing seed
because a preference changed would lock a user out of their own vault.
Renamed and inverted to assert the real behaviour.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01HtNMsKzzFTPURemX7tT19u
Deletes torch/ (5947 tracked files, 86M) and everything that existed only to
wire it up: torch-config.json, the root package.json and package-lock.json
-- whose sole dependency was torch-lock and whose six scripts were all
torch:* -- and the TORCH Memory Integration sections of CLAUDE.md and
AGENTS.md.
Nothing functional referenced it. No CI workflow mentions torch, no Python
or JS source imports torch-lock, and the only remaining occurrence outside
historical logs is a stray comment in perf/perf_agent.py. The determinism
gate is green after removal.
Two entropy-audit findings disappear with it rather than needing fixes: the
predictable temp filename and the Math.random value misnamed `nonce` (L4,
already fixed in 7047a5d but now moot), and the 18 test failures that bare
`node --test` produced by discovering stale code under torch/_backups/.
Kept deliberately: .scheduler-memory/, memory-update.md and memory-updates/.
Those are accumulated notes rather than tooling, and deleting someone's
notes is a different decision from deleting the tool that wrote them. They
can go separately if wanted.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01HtNMsKzzFTPURemX7tT19u
`pytest src/tests` died at 123 of 1170 tests with exit code 0 -- no summary,
no coverage report, and a shell that saw success. Four tests reach code
paths that call os._exit(0), which skips interpreter finalization and takes
pytest with it.
The os._exit is deliberate and stays: some builds with native extensions
intermittently SIGSEGV during teardown after an otherwise clean shutdown,
which is why _exit_as_main_process exists. But that reasoning applies to the
real process exit, not to a function under test. Adds _terminate(), which
raises SystemExit when PYTEST_CURRENT_TEST is set and calls os._exit
otherwise, and routes the three in-process exit paths through it.
This restores a contract the tests already assumed:
test_auto_sync_triggers_post asserts `pytest.raises(SystemExit)`. It was
written when display_menu raised, and the later switch to os._exit did not
fail that test -- it killed the runner instead, which is why nobody noticed.
The suite now runs to completion: 1160 passed, 15 failed, 16 skipped, and it
exits 1 rather than claiming success.
Those 15 were always failing; they were simply never reached. Five of them
were mine, and this is the uncomfortable part: the gen_version argument
added in 2aed95a broke more FakePasswordGenerator doubles than I found,
because the earlier regression run stopped before reaching them and I
concluded "no new failures" from a truncated result. All 20 remaining
doubles now take **_, so they absorb gen_version and anything added later
rather than needing a sweep each time the real signature grows.
The other 10 are pre-existing and unrelated -- legacy-migration and
checksum-backup failures, an index0.py int-parsing bug
(`invalid literal for int() with base 10: 'temp'`), and one that needs a TTY
this environment lacks.
Determinism gate still green.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01HtNMsKzzFTPURemX7tT19u
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
The intro still said entries carry no algorithm version, which stopped being
true when gen_version landed. Restates the rule in its permanent form -- never
alter the output of a version already in the wild -- rather than as a
description of a gap that is now closed.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01HtNMsKzzFTPURemX7tT19u
L5: dependency-audit.yml now generates a CycloneDX SBOM from
requirements.lock and uploads it. pip-audit answers "are there known CVEs
today"; an SBOM answers "which exact versions were in this build", which is
the question that arises when something is disclosed later against a release
already in the wild.
Verified against the real tool rather than written from memory -- the first
flag spelling (--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 [email protected], [email protected],
[email protected], [email protected], [email protected], [email protected] and
[email protected].0 at exact versions.
L4: torch/services/memory/index.js built its temp filename from Date.now()
plus Math.random() before an atomic rename -- predictable enough to
pre-create or symlink between the write and the rename. Now randomBytes(8).
Also renames the Math.random-derived value in relay-health.mjs from `nonce`
to `probeSuffix` and draws it from randomBytes(6). It is a public probe tag
used to correlate a publish with its readback, not a cryptographic nonce,
and nothing was wrong with it -- but the name invited someone to copy the
pattern somewhere it would matter.
torch has no test/ directory in the repo, so there is no suite to run for
these two files; both were syntax-checked and confirmed to import cleanly.
The SeedPass determinism gate is green (84 tests).
Not done, and recorded as a decision in TODO.md instead: torch/_backups/
holds 5240 tracked files across six dated snapshots. Untracking them is a
large deletion and not something to do as a side effect of an entropy audit.
They currently duplicate every security-relevant grep hit, and bare
`node --test` in torch/ discovers them and fails 18 tests from stale
snapshot code.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01HtNMsKzzFTPURemX7tT19u
Passwords are never stored -- they are re-derived on demand from
(seed, index, length, policy) -- and entries carried no algorithm version.
Any change to generate_password therefore changed every existing password in
every vault, unrecoverably. This adds the version field that makes such
changes possible at all, then makes three.
Safety net first (M4). test_entropy_integrity.py freezes 18 v1 vectors
spanning every policy and both sides of the 32-byte stream-wrap boundary,
and asserts the RNG fails closed: os.urandom raising must abort seed
generation, short reads must abort, AES-GCM nonces must be fresh. Each guard
was mutation-verified rather than assumed -- removing _balance_distribution,
perturbing the stream wrap, or altering the character mapping each turns all
18 vectors red, and adding a hash-of-timestamp fallback to
generate_bip85_seed turns the fail-closed test red.
generate_password now takes gen_version, defaulting to 1. Existing entries
carry no field and keep deriving v1 byte-identically; the argument is only
forwarded when non-legacy, so all 13 call sites across CLI, API and both
TUIs are untouched. New password entries are stamped gen_version 2. An
unreadable version raises rather than guessing which algorithm produced a
password.
v2 fixes three findings:
- M1: drops the forced equal-quarters class quota. 1 -> 82 distinct class
compositions at length 16 over 200 samples; per-character entropy
6.42 -> 6.53 bits against a 6.555 ceiling.
- M2: rejection sampling instead of byte % len(alphabet). Chi-square 96.1
against df=93 (p<0.001 critical value ~147).
- M3: an unbounded HMAC-expanded stream instead of cycling a 32-byte key,
which was a repeating pad for passwords over 32 characters. Lag-32 repeat
rate now sits within one standard error of chance.
Dropping the quota removed an accidental backstop: v1's minimum enforcement
overwrites uniformly random positions, so raising one class minimum could
destroy another, and only the quota's near-total rewrite hid it. v2 chooses
donor positions solely from classes that currently have a surplus, and
rejects a policy whose minima cannot fit the requested length.
Also removes a dead HKDF derivation whose result was discarded while the
docstrings named it as the algorithm in use (L1); PBKDF2 was and remains the
real one. Verified as a negative control -- the vectors stayed green.
Six test doubles are updated to the real generate_password signature; they
began receiving gen_version once new entries carried it.
Existing entries stay on v1 by design. An opt-in per-entry upgrade, with a
confirmation making the consequence unmissable, is still to come.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01HtNMsKzzFTPURemX7tT19u
Audits 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.
No predictable-fallback defect. Master seed, AES-GCM nonces, TOTP secrets,
Argon2 salts, API tokens and agent leases all come from os.urandom or
secrets and fail closed. The random module is not imported anywhere in
non-test source.
The findings are about how much entropy survives the deterministic
derivation in core/password_generation.py, not about whether it should be
deterministic. Chief among them: _balance_distribution forces exactly L/4 of
each character class into every password, verified 300/300 at five lengths.
That costs 5.7-11.5 bits and, more materially, fingerprints the generator --
a password with exactly a quarter of each class is a strong signal it came
from SeedPass.
Records the compatibility constraint that governs all of it: passwords are
never stored, they are re-derived on demand, and entries carried no
algorithm version. Any in-place change to generate_password was therefore a
data-loss event, not an improvement.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01HtNMsKzzFTPURemX7tT19u
- SearchService.graph_pivot_results(): wraps linked_neighbors() output
in SearchResult model with match_reasons (graph_pivot, relation:X,
direction:X, atlas:scope), scores (hop-distance based), and relation
meta string for UI surfaces
- AtlasWayfinderScreen: propagates atlas_source_scope to LinkedItemsPanel
when opening entries or recent items from the wayfinder
- LinkedItemsPanel: adds #linked-atlas-context label (◈ Atlas context:
scope_path) that refreshes via property setter; atlas scope is
preserved across linked-panel pivots; _update_atlas_context_label()
helper keeps display in sync
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- Add PivotBreadcrumbBar widget to inspector.py showing multi-hop path
- Add _pivot_history/pivot_to_entry/action_pivot_back to app.py
- LinkedItemsPanel: group linked items by relation type with section headers
- LinkedItemsPanel: b key for back navigation, breadcrumb rendered from app history
- External entry selection (grid) clears pivot history; pivot chain preserved on linked nav