Commit Graph
2354 Commits
Author SHA1 Message Date
thePR0M3TH3ANandClaude Opus 5 fdbdb12596 docs: record the API, CLI, QR and semantic ports, and the index secret leak
Also rewrites capabilities().not_yet_ported so each remaining entry says WHY
it is not ported, since automation branches on that list: PGP RSA is a hard
constraint (seeded prime search is not reproducible, so a port would derive a
different key from the same seed), index0 is carried verbatim but not
recomputed, and the agent authorization subsystems and Textual TUIs are
choices.

Flags one thing that needs a decision: existing semantic index files written
before today still contain plaintext secrets, and the fix does not rewrite
them. The model id distinguishes them, but relying on a user to read a status
field is weak.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-19 09:55:44 -04:00
thePR0M3TH3ANandClaude Opus 5 39c4e45376 feat(semantic): port the retrieval index — and stop it writing secrets to disk
The "semantic (vector search)" entry in not_yet_ported was a misnomer. It is
not vector search and needs no model: MODEL_ID is "seedpass-token-overlap-v1",
and it scores by Jaccard overlap over tokenized metadata. That is why it could
be ported at all — there is nothing to embed.

Porting it surfaced two defects in the Python implementation.

**Stored secrets were written to a plaintext, 0664 file.** `_extract_text`
appended a key_value entry's `value` — the stored secret itself — and
`build()` wrote it to semantic_index/records.json in the clear, along with a
tokenized copy that leaked it just as thoroughly. The file was created at the
process umask, so on a default install it was group- and world-readable, next
to a vault that is encrypted and 0600. Anything that could read the directory
— a backup, a sync client, another user on a shared machine — got the secret
without the master password.

Fixed in Python (index the key NAME, never the value; chmod 0600 on both
files) and never done in TypeScript. The rule is worth keeping simple: nobody
searches for a secret they do not already know, so indexing one is pure
downside. MODEL_ID is bumped to v2 so an index built by the old code is
distinguishable — a v1 index on disk still contains the secrets and should be
rebuilt.

**Entry 0 was silently unsearchable.** The check was
`int(entry.get("id", 0) or 0) <= 0`, conflating "no id" with "id 0" — and
entry ids start at 0, so the first entry any user ever created was absent from
every search result with nothing to indicate why. Found by writing a test that
queried for it. Fixed in both implementations: skip an id that is absent,
unparseable or negative, which is what the check was reaching for.

The TypeScript port is wired into the CLI (`semantic build/status/search`),
the API (the /api/v1/semantic routes are now real rather than 501), and
writes through atomicWrite for a genuine 0600 inode. Fixtures come from
scripts/generate_ts_port_fixtures.py, which asserts the leaked value is absent
from its own output, so the fixture-drift job proves provenance the way it
does for every other fixture.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-19 09:54:53 -04:00
thePR0M3TH3ANandClaude Opus 5 51f1358da4 feat(tui): render real QR codes; verified module-for-module against the reference encoder
The TUI had a QR action that answered "QR rendering is not available in this
build". The stated reason was that a QR library is not worth a dependency in a
process holding unlocked seeds — which is true, and is why this implements the
encoder instead: byte mode, versions 1-40, all four error-correction levels,
zero dependencies.

A wrong QR code is worse than no QR code. It scans cleanly, yields the wrong
secret, and the user finds out when their 2FA stops working — so this is
verified rather than eyeballed:

  - The block-structure table was extracted programmatically from qrcode's
    RS_BLOCK_TABLE rather than transcribed. 160 rows of three-number triples
    are exactly the kind of data a human copies wrong once and never notices.
  - qr.json holds reference matrices from that library, and the tests compare
    EVERY module — 10 cases spanning all four EC levels and versions 1 to 17,
    including past version 7 where version-information blocks start being
    written.
  - Generation moved into scripts/generate_ts_port_fixtures.py, so the
    fixture-drift CI job proves the committed fixture came from the committed
    generator, like every other fixture. Regenerating produced byte-identical
    output for all pre-existing fixtures.

Two things are pinned so the comparison is between implementations rather than
between configurations. Byte mode is forced, because the reference
auto-selects alphanumeric for an all-uppercase string — a different encoding
of the same text, not a different answer. And the mask is pinned, because the
two disagree about mask SELECTION: the spec scores the finished symbol, while
the reference scores one with its format modules blanked and its dark module
light (makeImpl(test=True, ...)), which is not a legal symbol. All eight masks
produce valid scannable codes, so the selection rule is checked as a property
instead of reproducing a known quirk to make an equality assertion pass. At a
pinned mask the matrices are identical, which is what establishes that the
data encoding, Reed-Solomon, interleaving, placement, format and version
information are all correct.

In the TUI, TOTP entries encode their otpauth:// URI so an authenticator can
consume them; everything else encodes its own value. Secret Mode suppresses
the code entirely — a QR code is the secret in a form a camera reads from
across a room, so the setting that exists to keep secrets off the screen
applies here more than anywhere — and an entry too large to encode says so
rather than looking unavailable.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-19 09:40:13 -04:00
thePR0M3TH3ANandClaude Opus 5 46ff51f3d6 feat(cli): close the remaining command-surface gaps with the Python CLI
Six commands existed in Python (and several in the TS TUI or API) but had no
TypeScript CLI equivalent:

  vault stats                — counts by kind, archived, relays; asserts no
                               secret reaches stdout
  vault change-password      — re-wraps the parent seed, honouring the
                               profile's configured kdf_iterations the way the
                               TUI does. Passwords come from the environment,
                               never argv: a password on the command line is
                               in the process list and the shell history.
  vault reveal-parent-seed   — same egress rule as `fingerprint create`: this
                               is the one secret that unlocks everything, so
                               it refuses to print into a pipe (a log, a
                               transcript, an agent's context) and writes a
                               0600 file it will not clobber.
  entry export-totp          — every 2FA secret, written through atomicWrite
                               for a genuine 0600 inode. Secrets and otpauth
                               URIs, not codes: a code expires in seconds and
                               cannot seed an authenticator, so an export of
                               codes looks correct and is useless. Same bug
                               that the API tests caught.
  config toggle-secret-mode  — `config set` could already do this, but only if
  config toggle-offline        the caller knew the key and that the value is a
                               JSON boolean. These read and flip.
  api stop                   — POSTs /shutdown; token from SEEDPASS_API_TOKEN
                               rather than argv, like every other credential.

`api` becomes a group with `start` as the default subcommand, so `api` alone
keeps working.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-19 09:27:39 -04:00
thePR0M3TH3ANandClaude Opus 5 348245e14a feat(api): port the HTTP API surface to TypeScript
Closes the open "does the api get a TypeScript port at all" decision by
porting the subset that is not built on unported subsystems: entries (list,
search, read, create, modify, archive, links, document import/export), an
explicit secret route, config, profiles, TOTP, vault lock/unlock/export/import,
change-password, relays, nostr pubkey, stats, notifications, derivation check,
and shutdown.

Built on node:http with no framework. The CLI ships as a single audited bundle
with an empty production dependency list, and adding a web framework to a
process that holds unlocked parent seeds would be the largest supply-chain
change in the project.

This surface binds a TCP port while holding an unlocked seed, which is
strictly more exposed than the agent's 0600 unix socket, so it is shaped by
what it refuses:

  - Loopback only. A non-loopback bind requires --allow-remote; an accidental
    --host 0.0.0.0 on a seed-holding process is not a mistake that should be
    recoverable after the fact.
  - Bearer token on every route, printed once, never written to disk,
    compared in constant time.
  - Routes that produce plaintext additionally require the master password in
    a header, so a token lifted from a script or a process list cannot read
    the vault on its own.
  - Path parameters resolve inside the profile directory or 400.
  - Body size capped while the request is still arriving.
  - Failed unlocks get their own much tighter budget, and exhausting it
    returns 429 even for the correct password, so the port is not a password
    oracle and lockout is not distinguishable from a wrong guess.
  - No CORS headers unless the origin was explicitly allowlisted.
  - password_hash / pin_hash are never returned and cannot be set.

Every handler goes through the CLI's own vault plumbing rather than
reimplementing it. That is the point: an API deriving secrets its own way
would be a second implementation to keep in parity, and the first place the
two would drift is which secret a given entry produces. Verified end to end
against a real running server: Python, the TS CLI and the TS API all derive
g'xH9E?3n3VS8EOmX8 for the same entry.

Routes belonging to deliberately unported subsystems (high-risk partitions,
agent job profiles, agent recovery split, semantic index) answer 501 naming
the feature rather than 404 — a 404 reads as a typo and would send an
integrator hunting for a spelling mistake that is not there.

One bug the tests caught before it shipped: the 2FA export returned
materializeSecret's value for deterministic TOTP entries, which is the current
six-digit CODE, not the base32 secret. An authenticator cannot be seeded from
a code, so the export would have looked correct and been useless. It now
re-derives the secret the way the TUI's export does, and returns the otpauth
URI alongside it.

28 API tests, most of them about what is refused rather than what works.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-19 09:23:32 -04:00
thePR0M3TH3ANandClaude Opus 5 4b64d26273 docs: all seven audit findings closed; record the deferred L-1 v2 design
Four fixed outright (M-1, M-2, L-4, L-7, L-8), one mitigated with the
derivation change deliberately deferred (L-1), two surfaced rather than
changed because their resolution is frozen for parity (L-2, L-3), and two
resolved as documented-behaviour with the reasoning written down (L-5, L-6).

The L-1 v2 design is now written up in TODO.md rather than left as a one-line
recommendation: keep v1 frozen, add derivation_version alongside the existing
gen_version pattern, domain-separate with an HMAC step rather than new BIP-85
app numbers, land both implementations together with cross-impl fixtures, and
make the upgrade opt-in per entry. Sequenced after the outside review on
purpose.

Also recorded: three defects found while fixing, none of which the audit had
reached — Python's publish_delta manifest republish, the fetch-limit downgrade
path, and merge_index_payloads mutating its argument.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-19 09:11:09 -04:00
thePR0M3TH3ANandClaude Opus 5 276aca65b6 feat(sync): report what a merge resolves silently; stop it editing its inputs (L-2, L-3)
The deterministic merge is frozen for parity -- both implementations must
reach the same vault from the same inputs -- so how it resolves is not up for
negotiation. What was missing is that nobody is told a resolution happened.

L-2: the allocation watermark is per-replica, so two devices working offline
can both allocate id N. The merge then resolves that id by timestamp then
hash, and the loser is replaced with no trace. An entry the user created is
simply absent afterwards. Worse than a plain overwrite, because an id is a
permanent BIP-85 derivation coordinate: the survivor derives from the same
coordinate the discarded entry did (see the L-1 detector).

L-3: past TOMBSTONE_RETENTION_CAP the oldest deletions are forgotten, and
merging a stale replica or old snapshot then resurrects those entries. A
documented protocol limit -- but a user cannot know the cap has actually begun
trimming their history unless something says so.

Both implementations gain an optional MergeReport collector: same-id conflicts
(with the kept and discarded entries named) and a count of evicted tombstones.
`nostr restore` reports both in its summary and on stderr, covering delta
replay as well as the final local merge.

Only genuine divergences are reported. Two replicas holding the same entry
with different timestamps is an ordinary edit and is ignored; flagging it
would bury the case that matters in noise and train the user past the warning.

The collector is write-only and cannot change the merged result -- asserted in
both implementations, because if observing could alter the outcome it would be
a parity divergence rather than an observation.

Two defects found while writing the tests:

  - Python's merge MUTATED its `current` argument. `dict(current)` shares the
    nested entries dict, so the merge wrote its result straight into the
    caller's payload: after merging, "current" had already become the merged
    value. Anything comparing before-and-after, or merging one payload twice,
    silently saw the wrong input. Now deep-copied, matching TypeScript's
    structuredClone. Found because the "reported whichever side wins" test
    failed -- the forward merge had corrupted the input for the backward one.

  - test_merge_replay_same_payload_is_idempotent was passing only because of
    that aliasing: `once` and `twice` were one object. With inputs no longer
    mutated, replaying the same payload is shown to normalize entry SHAPE --
    the equal-timestamp path unions field sets and writes explicit nulls for
    absent optional fields. TypeScript produces byte-identical output for the
    same input, so this is shared designed behaviour rather than a Python
    regression, and it converges on the second pass. The test now asserts what
    is actually true (content unchanged, shape normalized, settles at two) and
    records why the old assertion held.

Mutation-verified: removing the recording turns the reporting tests red.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-19 09:10:16 -04:00
thePR0M3TH3ANandClaude Opus 5 75e370d098 feat(vault): detect shared BIP-85 derivation coordinates; bind Python imports too (L-1, L-8)
L-1 — three entry kinds derive from BIP-85 app 32 at the entry's index:

    ssh@N       -> 32 bytes of m/83696968'/32'/N'
    pgp@N       -> 32 bytes of m/83696968'/32'/N'   (the SAME 32 bytes)
    password@N  -> 64 bytes of the same, then PBKDF2

So ssh@N and pgp@N are byte-for-byte the same Ed25519 private key, and
password@N takes that key as its entropy prefix. The password stays safe (256
unknown bits remain, PBKDF2 is one-way) but this is not key separation, and
SSH keys are exactly what gets exported to a server or uploaded to a forge.

Deliberately NOT fixing the derivation itself yet. Domain-separating the three
uses is a versioned protocol change in both implementations, i.e. a new
derivation path -- and the project's own note is that "a derivation bug does
not throw, it quietly produces a secret that cannot be recovered". Adding one
immediately before the first outside review inverts the point of the review.
The v2 design is written up in TODO.md instead, ready to implement after it.

What ships is the audit's short-term recommendation, which needs no derivation
change: notice the state and say so. findDerivationCollisions /
find_derivation_collisions report cross-kind clashes at one app-32 index,
worst first, in both implementations with identical message text -- a user
moving between the two must not be told different things about one vault.
Wired into every path the state can arrive by: vault import (CLI and TUI, both
implementations), nostr restore's merge, `vault import --inspect` before
anything is written, and a new `util check-derivation` for auditing an
existing vault (exit 1 when it finds something, for scripts).

Neither implementation can create the state -- index == vault id, ids unique,
watermark prevents reuse -- so this is about imported, foreign, and merged
data, which is also why it must never be the thing that fails an import: it
reports, never refuses, and tolerates malformed input.

The lead test in each implementation does not exercise the detector at all.
It derives the keys and compares the bytes, proving the property the detector
reports. Without that, the rest only checks that a written-down rule is
applied consistently, and a derivation change could falsify the rule with
everything still green.

L-8 (Python) — while wiring the above it turned out Python's import_backup had
the same unbound-plaintext-import gap the audit found in TypeScript: the
wrapper carries a `fingerprint` and nothing compared it. Now fixed there too,
with a new ProfileMismatchError so the TUI can offer the override instead of
reporting a generic failure.

The comparison is against the fingerprint DERIVED FROM THE SEED, not the
profile directory's name. In production they are the same string, but the
question worth asking is "was this backup made from my seed?", and only the
seed answers it -- comparing directory names refused a legitimate restore of
the same seed into a freshly named profile, which test_account_roundtrip_
restore caught immediately. It is enforced only when the wrapper records
something that is actually a fingerprint: a backup carrying some other label
offers no evidence about its source seed, and refusing on no evidence blocks
restores without making anyone safer.

All guards mutation-verified on both sides.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-19 08:58:12 -04:00
thePR0M3TH3ANandClaude Opus 5 8414deff20 fix(sync): order snapshots by signed publication time, in both implementations (M-1)
Restoring the wrong manifest is not an ordering detail -- the manifest that
wins the comparison IS the vault the user gets back. Two defects compounded:

TypeScript ordered manifests by created_at with the event id as tie-break.
created_at is whole seconds, so two snapshots published in the same second
tied, and the id tie-break -- stable and relay-independent, but arbitrary with
respect to time -- returned the OLDER vault about half the time. Entries
created between the two syncs disappeared silently; skippedNewerManifests
stayed empty because nothing was skipped, the wrong manifest simply sorted
first. It surfaced only as an intermittent test failure.

Python never ordered at all: it asked the relay for limit(1) and restored
whatever came back, handing the relay the choice of which snapshot wins.

Both now stamp published_ms -- publication time in milliseconds -- inside the
manifest JSON, so it is covered by the event signature and no relay can steer
it, and both order by it. The value is monotonic per process (max(now,
last+1)), because two back-to-back publishes can share a millisecond and would
otherwise fall back to the same arbitrary id tie-break in a narrower window.
Across machines the clock remains the only shared reference; that case is
unchanged and documented.

Backwards compatible: the field is optional, and a manifest without it orders
at the start of its created_at second. That biases a new-format manifest to
win a same-second tie against an old-format one, which is the right direction
-- old-format manifests are by definition from before the upgrade. Ordering
the unknown one last would let a pre-upgrade snapshot outrank every
post-upgrade one for that second: the same bug from the other side.

Two things found while doing it:

  - Python's publish_delta republishes the manifest with an updated
    delta_since. It needed the new field too: without it the manifest carrying
    the newest delta_since sorted BELOW the snapshot it supersedes, so readers
    fetched deltas from a stale watermark and the update never arrived. Caught
    by the existing roundtrip tests, which is why they are worth having.

  - Raising Python's fetch limit turned its vestigial fallback loop into a
    live downgrade path: a relay withholding one chunk of the newest snapshot
    could walk a client silently backwards through its history. The loop now
    skips unparseable events (relay noise must not deny service) but refuses
    to reach past the newest manifest that genuinely parses, and says why.
    A restore that quietly returns last week's vault is indistinguishable from
    one that worked.

Tests: js relay.test.ts gains a 12-round same-second case -- one pair
reproduces the bug only half the time, which is how it stayed a "flake" --
plus a legacy-manifest case built by hand in the true old format. Python gains
test_manifest_ordering.py (6 cases: order key, both fallbacks, arrival order
irrelevant, id tie-break, unparseable content). Mutation-verified on both
sides: restoring the old comparison turns the matching tests red.

The nostr CLI test now passes 6/6 consecutive runs, having failed roughly 1
in 4 before.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-19 08:42:26 -04:00
thePR0M3TH3ANandClaude Opus 5 b209bb45c8 docs: record the L-4..L-8 remediation, and what was deliberately not changed
The audit report gains a remediation table and per-finding status; TODO gains
the same detail where the rest of the work is tracked.

Two entries are closed as "resolved" rather than "fixed", and say why: L-5's
unanchored label_regex matches Python's re.search and anchoring it in one
implementation only would silently narrow every token already issued, and
L-6's argv-level containment needs a token-format change in both
implementations. Recording the reasoning matters more than the checkbox — a
future reader finding an unanchored regex should find the decision, not
rediscover the finding.

Also noted as still unported rather than silently skipped:
restore_latest_backup, nostr_max_retries and nostr_retry_delay.

M-1, L-1, L-2 and L-3 remain open; all four are protocol-level and need a
matching change in Python. No GPT-family review has run yet, so the
independent-review blocker is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-18 21:55:06 -04:00
thePR0M3TH3ANandClaude Opus 5 8653ee8006 fix(agent): validate put at the daemon boundary; state the two scope semantics (L-5, L-6, L-7)
L-7 — `put` is the only way a fingerprint enters `held`, and every later
handler joins that string onto a path (the vault it opens, the audit log it
writes). The CLI validated the format first, which is exactly why the daemon
could not rely on it: agent.ts's own header says the CLI is untrusted, and the
browser extension speaks this protocol next. `put` now requires 16 uppercase
hex, and additionally that the fingerprint actually belongs to the supplied
seed — a mismatch would unlock a vault the caller did not name and file its
audit records under the wrong profile.

Validation order matters and is now deliberate: cheap format check, then ttl,
then the seed match last, because deriving a fingerprint runs the BIP-39 KDF
and a malformed ttl should not pay for it. This keeps the existing
non-numeric-ttl test exercising the ttl path rather than short-circuiting on
the new check.

L-5 — `label_regex` is unanchored, so a token issued for `prod` also reaches
`not-prod-db`. Left as-is on purpose: Python matches with `re.search`, so
anchoring the TypeScript side alone would make one token mean two different
things depending on which implementation holds it, and would silently narrow
every token already issued. The audit asked for whichever behavior Python has,
documented and in the parity matrix — so the semantics are now stated in
--label-regex help, reported machine-readably as
capabilities().tokens.label_regex_semantics, explained at the enforcement
point, recorded in the compatibility matrix, and pinned by tests that assert
BOTH labels are visible to an unanchored token.

L-6 — the exec allowlist constrains the command word, never its arguments.
The token holder writes the argv and the child gets the secret in its
environment, so an allowlisted binary with an output-file or network flag
hands the secret straight back. Documented in the same three places rather
than changed: argument-level containment means allowlisting full argv
templates instead of command words, which is a token-format change that has to
land in both implementations together.

Tests: malformed and mismatched fingerprints refused with nothing held (path
traversal, wrong case, wrong length, trailing component); unanchored vs
anchored label_regex; capabilities reporting both semantics. Mutation-verified
— disabling either put guard turns the matching tests red.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-18 21:54:21 -04:00
thePR0M3TH3ANandClaude Opus 5 4b01ac62e0 fix(cli): real rolling backups, and imports bound to their profile (L-4, L-8)
Two findings from the 2026-08-18 audit, entangled in the same files.

L-4 — the additional backup location was inert. The TUI accepted a path and
answered "Additional backups will be written to X"; nothing ever wrote one.
That is worse than an absent setting: it is false assurance in the feature a
user leans on precisely when everything else has gone wrong. The cause was
larger than the setting — Python's BackupManager is not ported at all, so
`backup_interval` was dead for the same reason.

New backups.ts ports the write side: after every committed mutation, the
encrypted index is copied to <profile>/backups/entries_db_backup_<ts>.json.enc
at 0600, and mirrored to `additional_backup_path` as <fingerprint>_<name>.
Filenames match Python's byte for byte so the two implementations share one
backup directory. Hooked after the save in both mutation funnels, matching
Python's create_backup()-after-_save_index() order, so a snapshot is the
post-mutation state.

Two deliberate deviations, both documented in the source:
  - Throttling reads the newest snapshot on disk rather than Python's
    in-memory _last_backup_time. Every CLI command is its own process, so an
    in-memory counter would make backup_interval inert for CLI use — the same
    bug in a new place.
  - A failed mirror is reported rather than swallowed. Best-effort must not
    fail a committed vault write, but silence would restore the false
    assurance this change exists to remove.

Quick Unlock stays in the menu for item-order parity but now says it is not
implemented instead of storing a preference nothing reads. In Python the flag
never changed unlocking either — it writes an audit entry and raises a
security finding.

L-8 — plaintext backup import was not bound to a profile. A seed-only backup
is implicitly seed-bound: the wrong seed cannot decrypt it. A plaintext one
imports into any profile and is then re-derived from the TARGET seed, so every
password, SSH key, PGP key and seed silently comes out different from the one
the backup was taken to preserve. Nothing errors anywhere along the way.

Both the CLI and the TUI now compare the wrapper's fingerprint to the target
profile and require an explicit override (--allow-fingerprint-mismatch, or a
confirmation in the TUI). `vault import --inspect` reports the backup's
fingerprint and encryption mode so the mismatch is visible before committing.
The CLI also stops deciding encryption mode by regex over the whole file: new
parseBackupWrapper reads the envelope, which is the structural question being
asked.

Tests: backups.test.ts (9 cases: filename parity, 0600, verbatim ciphertext,
mirror naming, mirror-failure reporting, interval throttling, CLI end-to-end)
plus foreign-import cases in cli.test.ts and tuiMenus.test.ts using a genuine
second profile rather than a doctored file. All mutation-verified: removing
each hook or guard turns the matching tests red.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-18 21:49:26 -04:00
thePR0M3TH3ANandClaude Opus 5 063fae191f fix(derive): the profile config's password policy is the base again (M-2)
Python builds its PasswordGenerator with
`policy=self.config_manager.get_password_policy()` and merges an entry's own
`policy` block over that base per derivation. The port merged the entry block
over hardcoded defaults and never read the profile config at all, so every
entry in a profile with a non-default policy derived a *different password*
than Python. Nothing failed anywhere; it reached the user as a wrong password,
which is indistinguishable from data loss at the point of use.

materializeSecret now takes a `basePolicy` and merges the entry block over it.
All three callers supply the profile config's policy:

  - program.ts via basePolicyForOptions, resolved lazily so `entry list` and
    friends don't pay a second BIP-39 seed derivation for a policy they never
    use. `--vault` names an index file rather than a profile, so the config is
    looked for beside it and falls back to defaults when absent.
  - agent.ts from join(appDir, fingerprint), so a token-scoped reveal returns
    what the owner sees.
  - the TUI through a new sessionSecret helper that every menu path goes
    through, so a future call site cannot silently omit the base.

`util generate-password` is deliberately unchanged: it has no entry and no
profile, so its flags are the whole policy. It now says so in its own help
text rather than leaving the reader to infer it.

Evidence: js/packages/cli/test/passwordPolicy.test.ts covers config-only v2,
an entry overriding exactly one field, config-only v1 and exclude_ambiguous,
against values computed by the Python implementation — each also asserted
unequal to what the pre-fix code produced, so a regression fails loudly rather
than needing someone to notice a changed constant. Mutation-verified twice:
dropping basePolicy from the merge in secrets.ts, and making
basePolicyForOptions return {}, each turn all four red.

Found by the 2026-08-18 independent audit (js/SECURITY_AUDIT.md). The parity
fixture suite could not have caught this: those fixtures call generatePassword
with one already-resolved policy, so they prove the algorithm and say nothing
about where the policy came from.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-18 21:38:19 -04:00
thePR0M3TH3ANandClaude Fable 5 f3c3d73c3d docs: independent security audit of the TypeScript port at e9d8908
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]>
2026-08-18 14:48:41 -04:00
thePR0M3TH3ANandClaude Opus 5 e9d8908560 docs: record CI health findings from PR prep
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8
2026-08-18 14:26:49 -04:00
thePR0M3TH3ANandClaude Opus 5 cf433e3a73 fix(cli): vault import with --vault no longer demands a default profile
`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
2026-08-18 14:26:16 -04:00
thePR0M3TH3ANandClaude Opus 5 ecff57e8c6 test(core): run the official BIP-85 spec vectors directly against the TS core
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
2026-08-18 14:19:59 -04:00
thePR0M3TH3ANandClaude Opus 5 eb67dac576 docs: bridge README to the TypeScript rebuild; banner the Python-era docs
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
2026-08-18 14:07:53 -04:00
thePR0M3TH3ANandClaude Opus 5 0482bc3dfd feat(cli): .seedpass backup naming (phase 1 -- convention only, zero format change)
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
2026-08-18 13:48:36 -04:00
thePR0M3TH3ANandClaude Opus 5 7748d6b5e9 docs: capsule-unlock spec section (11.1) and backup/UX roadmap items
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8
2026-08-18 13:41:29 -04:00
thePR0M3TH3ANandClaude Opus 5 cdd74f379f feat(cross-impl): Phase L proves foreign data survives a full A->B->A cycle
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
2026-08-18 13:36:46 -04:00
thePR0M3TH3ANandClaude Opus 5 e434b0abc1 feat(schema): carry unknown entry kinds through instead of refusing the vault
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
2026-08-18 13:26:32 -04:00
thePR0M3TH3ANandClaude Opus 5 df4262e4b6 docs: draft the shared vault & identity spec (v1)
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
2026-08-18 13:17:29 -04:00
thePR0M3TH3ANandClaude Opus 5 e3b1325a2b fix(vault): entry ids are never reused -- allocate from a persisted watermark
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
2026-08-18 13:11:50 -04:00
thePR0M3TH3ANandClaude Opus 5 f634435681 docs: all ten self-review findings are now fixed or resolved
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8
2026-08-17 22:37:13 -04:00
thePR0M3TH3ANandClaude Opus 5 977fbbeec9 refactor(agent): remove dead code that read as security controls
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
2026-08-17 22:37:13 -04:00
thePR0M3TH3ANandClaude Opus 5 688a5d392a fix(tui): exports write a fresh 0600 file instead of trusting writeFile mode
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
2026-08-17 22:35:57 -04:00
thePR0M3TH3ANandClaude Opus 5 227b3f6fb5 fix(audit): a deleted head no longer disables truncation detection
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
2026-08-17 22:34:17 -04:00
thePR0M3TH3ANandClaude Opus 5 bbfc90d5ce docs: mark the three inert-setting findings fixed
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8
2026-08-17 22:28:58 -04:00
thePR0M3TH3ANandClaude Opus 5 a698255db6 feat(tui): enforce the inactivity timeout instead of only storing it
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
2026-08-17 22:28:49 -04:00
thePR0M3TH3ANandClaude Opus 5 933cad31c5 feat(tui): make the KDF-strength setting actually re-wrap the seed
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
2026-08-17 22:25:09 -04:00
thePR0M3TH3ANandClaude Opus 5 135a6268ce feat(tui): actually clear the clipboard after the configured delay
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
2026-08-17 22:22:12 -04:00
thePR0M3TH3ANandClaude Opus 5 7f6edf10cc fix(agent): close two ways a local caller could subvert the session daemon
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
2026-08-17 22:15:52 -04:00
thePR0M3TH3ANandClaude Opus 5 089d6cdb96 docs: record the TypeScript port's open work and self-review findings
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
2026-08-17 21:37:55 -04:00
thePR0M3TH3ANandClaude Fable 5 363d0edffd docs: record current state of the TypeScript port
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]>
2026-08-15 18:05:12 -04:00
thePR0M3TH3ANandClaude Fable 5 dce30d669a feat: rebuild interactive mode as Python's legacy TUI menus
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]>
2026-08-15 17:15:17 -04:00
thePR0M3TH3ANandClaude Fable 5 1eab3bf137 feat: interactive mode, so the bare command opens the vault
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]>
2026-08-15 15:02:06 -04:00
thePR0M3TH3ANandClaude Fable 5 b0e2a309a4 feat: generate a master seed, and fix three bugs found by using the CLI
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]>
2026-08-15 14:35:39 -04:00
thePR0M3TH3ANandClaude Fable 5 0dfeca8a50 fix: a bare seedpass-js printed help and then reported a bogus error
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]>
2026-08-15 14:19:38 -04:00
thePR0M3TH3ANandClaude Fable 5 3a50f57b66 docs: correct the capability location in the security model
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]>
2026-08-14 23:59:11 -04:00
thePR0M3TH3ANandClaude Fable 5 dafae50ca4 security: fix agent-boundary findings from the fourth reviewer
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]>
2026-08-14 23:58:58 -04:00
thePR0M3TH3ANandClaude Fable 5 ee330c52f7 security: fix critical findings from the second audit
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]>
2026-08-14 23:42:58 -04:00
thePR0M3TH3ANandClaude Fable 5 5873ce8127 docs: state the agent trust boundary explicitly
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]>
2026-08-14 23:26:15 -04:00
thePR0M3TH3ANandClaude Fable 5 6b862baa71 security: reject non-string credentials on the agent protocol
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]>
2026-08-14 23:24:08 -04:00
thePR0M3TH3ANandClaude Fable 5 a1df3cfde0 security: keep secrets off argv, fix packaging, add JS supply-chain CI
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]>
2026-08-14 23:13:21 -04:00
thePR0M3TH3ANandClaude Fable 5 55879eaa1b security: durable vault writes, path validation, and safe restore semantics
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]>
2026-08-14 23:10:20 -04:00
thePR0M3TH3ANandClaude Fable 5 9926b48e16 security: fix agent trust boundary and secret-environment leaks
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]>
2026-08-14 23:02:07 -04:00
thePR0M3TH3ANandClaude Fable 5 1864343084 cutover: close gates 5, 6 and 7
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]>
2026-08-14 21:33:28 -04:00
thePR0M3TH3ANandClaude Fable 5 b885573315 fix: make the test-profile generator hermetic and re-runnable
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]>
2026-08-14 21:16:24 -04:00
thePR0M3TH3ANandClaude Fable 5 c2d7306bb7 parity: PGP key derivation, byte-for-byte with PGPy
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]>
2026-08-14 16:46:24 -04:00