Full manual review of js/packages/core and js/packages/cli plus supply
chain. No critical or high findings. Two mediums: a same-second snapshot
tie-break that lets a restore return stale state (the flaky nostr CLI
test is this bug), and config-level password policy being ignored in TS
derivation, diverging from Python. Lows and informational notes cover
the app-32 path sharing, inert settings, token regex anchoring, and
plaintext-import fingerprint binding.
Co-Authored-By: Claude Fable 5 <[email protected]>
`vault import` resolved the active fingerprint unconditionally, even though
an explicit --vault target never uses it. On a machine with no ~/.seedpass
that made the command fail with "no profile selected" -- and it is why the
ts-parity workflow has been red for 33 straight runs while every local run
passed: the developer machine has a real ~/.seedpass, so the environment
coupling was invisible exactly where it mattered. The profile lookup is now
lazy, used only for the default-vault path.
The deeper fix is in the test harness: cli.test.ts now sets a hermetic
SEEDPASS_APP_DIR, so any code path that touches the default profile
directory fails locally the way it fails in CI instead of borrowing the
developer's real vault state. Verified as a negative control: with the
hermetic env and the old code, the import test reproduces CI's exact
"Unexpected end of JSON input" locally; with the fix, all 158 CLI tests
pass.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8
Every other derivation check compares this implementation against Python --
cross-verification, not ground truth. The BIP-85 specification's own test
vectors (spec master xprv, 12- and 24-word BIP-39 children at index 0) are
immune to the failure mode where both of our implementations share a
misconception and agree with each other: Python has pinned them since early
on (test_bip85_vectors.py), and now the TypeScript core runs them natively
via a new Bip85.fromExtendedKey factory (the spec vectors start from an
xprv, not a mnemonic). Python's pinned app-32 entropy and TOTP derivations
from the same master ride along as cross-family anchors.
All four pass on first contact -- an independent confirmation of the root
derivation ahead of the external review, and recorded as such in the #34
review-strategy notes in TODO.md, along with the ultrareview size blocker
(PR #989 spans 720k lines including beta's history) and its fallback
options.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8
The README had zero mentions of the TypeScript implementation -- on the
branch proposing to make it the main line, a reader (including PR #989's
reviewers) would find only the Python product. A bridge section near the top
now says what the rebuild is, its status (under independent review; do not
put irreplaceable secrets in it yet), and where the migration guide, the
vault/identity spec, and the cutover plan live. Deliberately a bridge, not a
rewrite: everything below it still accurately describes the Python version,
which is what install.sh installs today. Advertising the TS implementation
as the product before its review lands would be docs describing an
aspirational state -- the exact failure mode this branch has been stamping
out.
The 14 Python-era planning docs most likely to mislead (tui_v2/v3,
index0/atlas, semantic) carry a one-line legacy banner so a reader can tell
a historical record from a living document.
The full TS-first rewrite -- README, landing page, docs triage, CI scoping,
dependabot config -- is specced as a merge-day checklist under the cutover
item in TODO.md, so main never advertises a stale product in either
direction.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8
A portable backup is an encrypted, checksummed, cross-impl-verified file;
its only sin was landing on disk as an anonymous .json. Exports now
self-identify: point `vault export` (or the TUI database export) at a
directory and the file is named seedpass-<fingerprint>-<date>.seedpass --
tied to the profile it restores and sortable by date. The TUI also appends
.seedpass to an extensionless destination, since interactive input has no
script-compatibility concerns.
The zero-risk boundary, kept deliberately: an explicit file path is used
exactly as typed -- a script that exports to backup.json still gets
backup.json -- and imports sniff content, never extensions, so any old
export restores unchanged. No byte of the format moved, which is what makes
this safe to ship ahead of the review gate while format_version 2 (magic
header) and the OS icon association stay queued behind it.
Tests pin all three behaviours in both surfaces: directory target gets the
generated name, extensionless TUI input gets the suffix, explicit paths pass
through untouched. 158 CLI tests pass.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8
The round-trip conformance check from spec section 8.6, now enforced in CI:
a vault holding a foreign record (unknown kind, nested foreign data, and
trap fields) plus an unknown top-level index key goes through an interleaved
Python -> TypeScript -> Python read-modify-write cycle, and every foreign
byte must come back exactly as planted. Allocation must skip past the
foreign id, and TS must refuse to reveal the record rather than guess.
Landing the check forced the normalization decision it existed to force.
Python's _normalize_entry_defaults ran its legacy renames on every entry,
foreign ones included -- and those renames are migrations for OUR old
shapes: the negative control shows the old code turning a foreign
"blacklisted" string into our "archived" flag, which is not backfill but
reinterpretation of another application's data. The normalizer now skips
unknown kinds entirely, so both implementations carry foreign records
byte-for-byte untouched and produce identical canonical state -- the
property sync convergence is defined by. Entries with neither kind nor
type are pre-kind legacy Python data and still normalize as before.
The spec's gap ledger entry for the divergence is resolved, and the Python
tolerance tests are tightened from field-subset to exact equality, plus a
trap-field test pinning that blacklisted/website/words on a foreign record
mean whatever the other application says they mean.
Verified: with the normalizer gate stashed, Phase L fails on the exact
mangling ("archived": "foreign meaning, not our archive flag"); with it in
place, 38/38 cross-impl checks and 1183 Python tests pass.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8
Spec section 8.2: a record whose kind this build does not understand -- one
written by a newer build, or by another application sharing the vault, such
as a future BitLogin namespaced kind -- must be carried through untouched,
never dropped, and must not brick the whole index. TS previously failed the
entire parse on the first unrecognized kind, stranding any shared vault on
older SeedPass builds.
The entry schema is now a union: known kinds validate strictly against the
discriminated union exactly as before, and anything else parses as an opaque
loose record. The refine on the opaque branch is the load-bearing detail --
a malformed record of a KNOWN kind must still fail its own schema, never
fall through and escape validation. Records with no kind or type at all
remain rejected as garbage. Statically the entries map stays typed to the
known kinds (exhaustive switches keep their meaning); the module comment
states the runtime widening plainly.
At the CLI surface, foreign records list with unrecognized fields redacted
to has_* flags -- another application's data is not ours to hand out -- and
reveal refuses with "unsupported entry kind" instead of guessing. Allocation
counts foreign ids, so neighbours never collide with them.
Python's natural tolerance is now pinned by test, which surfaced a real
nuance: Python backfills base-field defaults (tags, links, date_modified)
onto foreign records on load, where TS carries them verbatim. No data is
lost, so both conform to the preservation rule as now worded (no loss, no
reinterpretation; base-field normalization permitted) -- but the two produce
different canonical bytes for the same foreign record, which matters once
foreign kinds sync. Recorded in the spec's gap ledger to align before any
application ships them.
Verified: 4 of the 6 new TS tests fail against the previous schema; 230
core + 152 CLI tests and the cross-impl suite pass with the change.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8
The interop contract for multiple applications operating one deterministic
account tree -- SeedPass as the reference superset, narrower identity
clients (BitLogin) implementing a subset. Extracted from working code, not
designed on paper: every derivation path, container format, and merge rule
in it is the one the cross-impl suite already enforces between Python and
TypeScript, so a new implementation tests against ground truth rather than
prose.
The load-bearing sections: the index-immutability rule (now backed by the
allocation watermark), the two index namespaces (entry id vs totp), the
interop rules that keep applications from needing each other (preservation,
namespacing, no shadow account numbers, round-trip conformance), and a
deliberately scoped-out list -- policy, orgs, payments, workflows are
application features that store data IN the format, not format features.
Includes an honest gap ledger: TS currently fails-closed on unknown entry
kinds where the spec prefers carry-through; that and a round-trip CI check
are recorded as follow-ups in TODO.md.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8
An entry id doubles as the BIP-85 derivation index for managed_account and
seed entries, which makes it a permanent cryptographic coordinate: "Alice is
account #184" is a recovery claim, and #184 must mean Alice forever. But both
implementations allocated ids as max(live ids)+1, and sync deletion removes
entries via tombstones -- so deleting #184 on one device let the next
creation reissue #184 to a NEW entry, which then re-derived the departed
identity's exact child seed and npub. Tombstone retention (capped at 2048)
made even the evidence expire.
Allocation now comes from a high-watermark persisted in _sync_meta.next_index:
never-decreasing, bumped on every insert/save (explicit-id inserts included),
merged as max(both sides' watermarks, one past any merged entry or tombstone
id), and identical in both implementations -- the merge computation must stay
byte-for-byte the same in sync_conflict.py and sync/merge.ts. _sync_meta was
chosen because both sides already round-trip it (it carries the tombstones),
so old builds preserve the field and no schema version bump is needed.
Legacy vaults heal on their next allocation: the floor folds in tombstone ids
still within retention, and the first save persists the watermark.
Python-side key parsing mirrors the TS regex exactly (ASCII digits, no
leading zeros, JS safe-integer range): str.isdigit() alone accepts Unicode
digits that Number() rejects, which would have made the two implementations
compute different watermarks from the same payload.
Fixtures regenerated from Python; the byte-for-byte entry-creation parity
test now passes with the watermark present, proving TS's insert-time bump
and Python's save-time bump converge on identical indexes. Verified: the
recycle regression tests fail on both pre-fix implementations; 1180 Python,
224 core, 149 CLI tests and all 36 cross-impl checks pass with the fix.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8
Two blocks looked load-bearing under review and never executed.
authorize() took an optional entry and checked kind/label constraints
against it -- but no call site ever passed one; the real constraint check is
tokenMaySee, after the vault opens. The parameter and branch are gone, and
the split is now documented where it matters: a use is consumed at pre-auth
even when the entry turns out missing or uncovered, deliberately, so probing
entry ids costs the prober their limited uses instead of handing back a free
enumeration budget.
sinkEnv() deleted FORBIDDEN_ENV keys from an env built off an allowlist that
never contains them -- a loop that could suggest the delete, rather than the
allowlist, is what keeps the seed out of sink children. Replaced with a
prefix guard inside the allowlist loop (nothing SEEDPASS_-prefixed rides
along even if a future edit adds one) and a comment stating plainly that the
allowlist is the control and this is the backstop. `extra` stays exempt: the
exec sink's whole job is injecting SEEDPASS_SECRET.
No behaviour change; 149 CLI + 220 core tests pass.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8
The TUI's three plaintext exports (document, database, 2FA codes) passed
mode 0o600 to writeFile and a comment asserted the result. But mode applies
only when the file is created: exporting over an existing 0644 file left it
0644 -- for the 2FA export, that is every TOTP secret in the vault sitting
world-readable -- and a pre-planted symlink at the destination could
redirect the plaintext somewhere else entirely. The CLI already avoided
both by writing through atomicWrite (fresh 0600 inode, then rename) and
explained why; the TUI regressed it.
All three exports now go through one helper that asks before overwriting --
the TUI silently clobbered where the CLI required --overwrite -- and then
writes via atomicWrite, so the mode and the symlink safety hold whether or
not the destination existed.
Tests: a pre-existing 0644 file at the destination comes back 0600 with the
export's contents, and declining the overwrite leaves the original bytes
untouched.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8
The head pins the log's length, and it sits beside the log with the same
permissions -- but verify skipped the length check entirely when the head was
absent. Truncating the log and deleting one more file verified clean, which
made the whole defense one rm away from disabled while its doc comment
claimed the opposite.
Two rules close it. A non-empty log now requires its head, at verification
AND at append: verify without a head fails naming the two possible causes (a
deleted head, or a log written by the pre-head build at 274bc0e), and append
refuses rather than writing a fresh head that would bless whatever state the
file is in -- otherwise truncate-plus-delete-head is laundered by whichever
ordinary operation logs next. The agent therefore fails closed on a tampered
chain instead of continuing to serve secrets unaudited.
One state is deliberately tolerated: a crash between append and head-write
leaves the log exactly one validly chained record past the head. Nothing
without the HMAC key can manufacture that state, and the key holder can
rewrite the chain wholesale anyway, so tolerating it costs nothing -- while
rejecting it (the old behaviour) turned an ordinary crash into a false
tamper alarm. The next successful append re-pins the head.
Also stated honestly in the module doc: an attacker who deletes the log AND
its head leaves a clean slate this scheme cannot detect. Same-uid storage
cannot pin "a log once existed"; the guarantee is that an existing log has
not been shortened.
Python is unaffected: its AuditLogger writes ~/.seedpass/audit.log with no
head, but that is a different file from the per-profile logs this class
manages, so the head requirement never sees a Python-written log.
Nine unit tests cover the matrix; five fail against the previous audit.ts,
including the truncate-plus-delete-head attack it verified clean.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8
inactivity_timeout was stored, displayed as Settings item 12, and enforced
nowhere: an unattended terminal stayed unlocked indefinitely. Python's legacy
TUI checks it at the top of its main loop and locks; this ports that.
The main menu now measures how long the user took to answer. If that exceeds
the configured timeout, the vault is locked -- the seed is dropped and
re-derived through the normal relock path (password, or the session agent) --
and the keystroke that arrived after the timeout is discarded rather than
acted on against a stale unlocked screen. 0 disables it, matching Python.
Scope, stated plainly: like Python's, this fires when the user returns to the
main menu, not via a background timer, so idling mid-submenu does not lock
until they surface. A true idle-anywhere auto-lock would mean interrupting a
readline mid-line and is a larger change; this matches the reference.
The millisecond clock is injected through the Session (defaults to Date.now),
so two tests drive it deterministically: idling past the timeout locks and
demands the password a second time, and answering within it does not. Verified
as a negative control -- forcing the timeout to 0 turns the locking test red.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8
kdf_iterations was displayed, stored, and read by nothing. changePassword
accepted an iterations argument the TUI never passed, and createProfile
hardcoded the default -- so raising the setting, then following the screen's
own instruction to "use Change password to re-wrap this profile's seed at the
new strength", changed nothing. A security control that silently does nothing
is worse than an absent one.
Both effect paths now honour the setting: Change password re-wraps the
current profile's parent seed at the configured iteration count, and a new
profile is wrapped at the active profile's setting. createProfile takes an
optional iterations argument (default unchanged), so the CLI and existing
callers are unaffected.
The count is validated: any value at or above the settings screen's 50k floor
is honoured -- including one below the 200k default, which is a downgrade the
user chose deliberately -- and only garbage falls back to the default, so a
legitimate value is never silently ignored.
Two TUI-driven tests read parent_seed.enc back: Change password records the
new iteration count and the new password opens the re-wrapped seed, and a
generated second profile inherits the active profile's strength. Verified as
a negative control -- reverting the changePassword call site turns the first
test red.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8
clipboard_clear_delay has been in the default config (45s), settable, and
read by nothing: clipboardSink copied and returned. That made TUI Secret
Mode -- whose entire purpose is to route secrets off the screen -- strictly
less safe than Python's, because it parked them on a session-wide clipboard
indefinitely.
clipboardSink now takes an optional clearAfterSeconds. When set, it schedules
a wipe after the delay, but only if the clipboard still holds the value it
put there, so a value the user copied in the meantime is left alone -- the
same guard Python's copy_to_clipboard(text, timeout) applies via a read-back.
Where the clipboard cannot be read (no paste tool), it clears unconditionally,
because leaving a secret parked is the worse outcome. The timer is unref'd:
it fires while the long-running TUI is up but never holds a process open, so
the one-shot CLI and the agent are unchanged (they pass no delay).
Both TUI clipboard paths -- Secret Mode reveals and the entry 'C' action --
now route through the profile's clipboard_clear_delay.
Tests use a fake wl-copy/wl-paste pair on PATH: the secret is wiped after the
delay, and a value copied over it in the meantime survives.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8
Both were found by the 2026-08-17 self-review and confirmed by speaking to
the socket directly. The daemon is the enforcement point for the whole token
model and treats every caller as untrusted, so neither fix leans on the CLI's
own validation.
1. A `put` reported as FAILED could leave the seed resident and unexpiring.
State was committed to `held` before the audit append was awaited. A
non-numeric ttl made `expiresAt` NaN; the audit write then threw on the
non-finite value, so the caller saw ok:false while the seed stayed in
memory forever (`NaN <= now` is always false) with no record it was ever
unlocked. Now ttl is validated as a positive integer at the boundary, and
an audit failure rolls the seed back out — ok:false means genuinely locked.
2. A `use`-scoped token could crash the whole agent. `stdinSink` wrote to the
child's stdin with no error listener, so a command that exits before
draining raised an unhandled EPIPE; the agent runs sinks in-process and
installs no uncaughtException handler, so it exited and dropped every held
seed. The least-privileged principal could take down the enforcement point
by naming a command that exits early. stdin errors are now swallowed and
the child's exit code carries the result, as feedClipboardTool already did.
Also hardens token-issue: scopes/kinds are shape-checked before the array
methods run against them (a bare-string kinds turned an exact match into a
substring match), and ttl/uses go through the same positive-integer boundary
so a token can no longer be minted with a NaN, never-expiring lifetime.
Three regression tests speak the wire protocol directly. Verified as negative
controls: with the fixes stashed, the stdin test reproduces the EPIPE uncaught
exception at sinks.ts:143 and two of the three fail; with them restored all
131 CLI tests pass.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8
Adds a TypeScript-port section to TODO.md covering three things the branch
was carrying only in conversation: the open blockers, ten findings from a
read-and-probe review of the branch, and the milestones nobody has started.
The findings matter more than their count. Every one was found by reading
the code or speaking to the agent socket directly, and all 128 CLI tests
passed before and after each was confirmed, so none of them is a
regression -- they are places the suite never looked. Two are worth calling
out here: a put the agent reports as FAILED can leave the parent seed
resident and unexpiring with no audit record, and a use-scoped token
holder can kill the agent with a stdin sink that exits early, dropping
every held seed. Both live in the daemon, which is the enforcement point
for the whole token model.
Three others are settings that do nothing: clipboard_clear_delay,
kdf_iterations and inactivity_timeout are all stored, displayed and
ignored. Python enforces the first and third. A control that silently
does nothing is worse than an absent one, because it stops people asking.
Also records the decision still owed on the api surface, which is
currently excluded from cutover and has no TypeScript equivalent beyond
the session agent.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8
Cutover plan gains a session log for 2026-08-15 and an honest status row:
every listed gate is green, and the remaining blocker is that no third party
has reviewed the code. Two rounds of subagent review found nine
criticals/highs that self-review missed, which is why that is treated as
load-bearing rather than optional.
Compatibility matrix gains rows for seed generation -- absent until this
session, so a new user could not create a vault at all -- and for the
interactive mode now that it follows Python's legacy v1 menus.
The exclusion lists in both documents now name TUI v2/v3, QR display and
script checksums, rather than leaving the earlier 'no TUI' claim standing.
Co-Authored-By: Claude Fable 5 <[email protected]>
The list-and-arrow-keys interface I wrote was not the thing to build. What
`seedpass` opens is a numbered menu tree, and navigating it is typing a
number and pressing Enter, with a blank line to go back -- so an interface
driven by arrow keys and single-letter bindings was unnavigable to anyone who
already knows SeedPass, however well it worked on its own terms.
This follows src/main.py item for item: the same eight main-menu entries in
the same order, the same nine entry types under Add Entry, the same eighteen
settings under the same numbers, the Profiles and Nostr submenus, the entry
action and edit menus with Python's letter keys. Numbering is part of the
interface, not decoration -- 7 then 1 has to still be Settings then Profiles
-- so items this build cannot perform stay in place and say why when
selected, rather than vanishing and shifting everything below them.
Line-based means no raw mode, no alternate screen, and no terminal to
restore, which removes a whole class of failure the previous version had to
defend against. Raw mode is now entered only around a password prompt, where
echo must be off.
The agent-blind rule survives the change: listings and details show metadata
with stored secrets as has_* flags, and a secret appears only through Show
Secret or Copy. Secret Mode -- a setting Python already has -- routes both to
the clipboard instead of the screen, so it means the same thing in both
implementations. The key/value field, hidden custom fields, and seed phrases
are read without echo.
Three settings needed new profile operations: changePassword re-wraps only
parent_seed.enc, since the index is keyed by the seed rather than the
password and a half-applied change would leave the two disagreeing;
writeParentSeed and setProfileName back the profile menu.
Verified through a pty against a real vault -- every menu, adding a password
entry, editing a label, revealing a secret -- and the derived values match
the CLI. 28 menu tests drive the tree through a scripted UI, including that
listings never contain a stored secret and that a non-numeric length is
refused rather than stored as NaN.
128 CLI tests, 220 core tests, 36/36 cross-impl checks. test/nostr.test.ts
is flaky under parallel load (it uses a live relay); it failed once during
this work and passed on rerun and three times in isolation.
Co-Authored-By: Claude Fable 5 <[email protected]>
Python's `seedpass` launches its TUI when invoked with no subcommand. The
port printed a help dump instead, which is why running the bare command
looked broken: nothing was wrong with it, there was simply no way in. This
adds the interactive mode that was missing.
Scope is the daily path -- browse, search, copy, reveal, add a password /
TOTP / key-value entry, archive, switch profile -- not a port of Python's v2
and v3 TUIs, which are around 20k lines of Textual between them. Piped or
redirected, the bare command still prints the help: there is no terminal to
drive.
No new runtime dependencies. Node's tty and ANSI directly, rather than a TUI
framework, because everything bundled here shares a process with unlocked
seed phrases and the CLI's runtime surface is currently commander plus the
noble/scure crypto libraries.
The agent-blind rule from plan section 9.3 carries over intact, and matters
more here than in the CLI because this screen renders the whole vault at
once: list and detail views show metadata only, `c` copies without ever
drawing the value, and `r` is the single explicit reveal -- on screen until
the next keypress, then gone from the following frame. Everything is drawn
on the alternate screen buffer, so a revealed secret cannot be recovered by
scrolling back after quitting, and never lands in a terminal log that
captures the main buffer. The key-value field is masked while being typed
for the same reason.
terminal.ts owes the user one thing above all: their shell back. Raw mode
with a hidden cursor is an unusable terminal, so restoration is idempotent
and wired to exit, SIGINT, SIGTERM, SIGHUP, and the uncaught-exception paths
-- verified through a pty for each. Holding raw mode across the whole unlock
sequence rather than toggling it per prompt also fixes a narrow leak: echo
came back on between password attempts, so a password retyped after a typo
was echoed in clear text.
Tests drive the real screens through an injected terminal, including the
redaction properties above. Two harness bugs worth recording, because both
produced confident nonsense: sampling the frame count after dispatching a
key made a synchronous redraw look like no redraw at all, and waiting only
for "the screen stopped changing" returned during the second-long PBKDF2 a
mutation performs, so the next keystroke landed on the previous screen and
typed "q" into a form field instead of quitting.
122 CLI tests, 220 core tests, 36/36 cross-impl checks.
Co-Authored-By: Claude Fable 5 <[email protected]>
Driving the CLI from an empty app directory turned up a gap that no test
covered: there was no way to create a vault. Every path assumed you already
had a seed and told you to export SEEDPASS_MNEMONIC, so a new user could not
get started at all. Python has generate_bip85_seed; the port did not.
fingerprint create fills that in with the same construction Python uses --
32 bytes of OS entropy through BIP-85 app 39 index 0. The entropy call sits
outside any try/catch on purpose, carried over from Python's entropy audit:
nothing may catch a CSPRNG failure and substitute a value.
Where the phrase goes is a deliberate choice rather than a default. This is
the one secret in SeedPass with no other copy, so:
- the destination is validated BEFORE generating, and delivery happens
BEFORE the profile is created -- a failure at either point leaves
nothing behind rather than a vault whose seed nobody has;
- --out writes 0600 with O_EXCL, so an existing file is never clobbered;
- with no destination and a non-TTY stdout it refuses outright, instead of
writing the only copy of a seed into a pipe -- a log, a transcript, an
agent's context.
Also fixed, all found the same way:
- use --exec always exited 0, so chaining a deploy command after it with
&& reported success even when the deploy failed. The sink child's exit
status is now the CLI's own.
- the sink command spec split on whitespace and ignored quotes, so
--exec 'sh -c "..."' -- the most common form there is -- handed sh the
fragments of the script and died on an unterminated string.
- --tags work,dev silently stored a single tag named "work,dev" that no
search would ever match. Commas and spaces both separate now.
Two normal states reported raw errno: a stopped agent said
'connect ENOENT /.../agent.sock', and an occupied --out path said EEXIST.
The capability map claimed ssh/pgp were unported. Both work and are at
parity with Python; automation branches on that list, so it now describes
this build.
Verified against Python on a TS-generated seed: same fingerprint, same
password, same TOTP code. 320 JS tests and 36/36 cross-impl checks pass.
Co-Authored-By: Claude Fable 5 <[email protected]>
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]>