Commit Graph
54 Commits
Author SHA1 Message Date
thePR0M3TH3ANandClaude Opus 5 ae3889bfca Stop the config API accepting any key and any value
Adversarial pass, first finding. `PUT /api/v1/config/:key` took the key from
the URL and the value from the body with no validation beyond blocking two
credential verifiers. Two ways that goes wrong, both silent:

A typo is stored forever. `inactivity_timout` was accepted, written into the
encrypted config, and answered ok — while the setting it was meant to change
never moved. Nothing downstream reads an unknown key, so this failure can
only ever be silent; the caller has no way to find out.

A wrong type is stored verbatim. `inactivity_timeout: "soon"` turns every
later comparison into NaN, which compares false, so the vault simply never
times out. That is a security setting failing open because a string reached
a numeric field.

There is now an allowlist of 23 keys, each with a normalizer that rejects
values it cannot make sense of: negative delays, a zero inactivity timeout
(indistinguishable from a broken vault), an empty relay list (not a
configuration, an outage), a string where an array belongs. Unknown keys get
Python's 400 "Unknown key"; the credential verifiers move from 400 to 403,
matching Python, since the key is real and the refusal is about authority.

The allowlist is deliberately a SUPERSET of Python's, which cannot set the
password-policy keys this implementation honours. Copying Python exactly
would have deleted working behaviour to reproduce a limitation. Recorded in
TODO.md as a divergence to settle rather than left as an accident.

Separately: the shared API test fixture was inheriting the default 100
requests/minute budget and had grown past it, so unrelated tests failed with
429 depending on how many requests ran before them. Rate limiting is tested
on its own servers with explicit budgets, so the fixture no longer enforces
one.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-19 19:34:23 -04:00
thePR0M3TH3ANandClaude Opus 5 e1ea9c2b48 Teach the mutation harness two blind spots it had, and close what they found
The harness only mutated block-form guards (`if (cond) {`), so every guard
written as a one-liner was invisible to it — which turned out to be most of
the load-bearing ones. src/highRisk.ts generated ZERO mutants and printed as
a clean sweep. Three changes to the tool:

- Mutate single-statement guards too (`if (cond) return ...;`). This alone
  surfaced 27 previously unreachable mutants, including the entire token
  validation chain: revoked, expired, exhausted, scope-not-granted.
- Report a target that generates no mutants as NOT MEASURED. Zero killed and
  zero survived prints exactly like a clean sweep, and it is the one result
  that must never be read as a pass.
- Add `kind`, `mnemonic` and `seed` to the security hints. A token's `kinds`
  list is half of what it may reach, and leaving the word out hid the shape
  check that stops a string `kinds` becoming a substring match.

Also adds `--kind` so a new mutation rule can be swept across every target
without re-judging the mutants already settled.

What the new mutants found, all of it previously unasserted:

- A token's `kinds` restriction was never tested. This file's own header
  claimed kinds and labels were both covered; only labels were. Removing the
  check let a token scoped to one kind read every other kind's secrets.
- `token-issue` would mint a token for a profile the agent is not holding.
  The seed being resident is the only proof the caller had authority over
  that vault.
- `token-issue`'s array shape checks for `scopes` and `kinds`. A string
  `kinds` turns tokenMaySee's exact match into String.includes.
- An empty high-risk factor. Refused in both implementations, tested in
  neither; it would wrap the partition key under nothing while
  `factorConfigured` still answered true.
- POST /api/v1/fingerprint/select accepting a profile that does not exist —
  the caller's string becomes ctx.fingerprint, which later requests join onto
  a path. AppDir repeats the check, so the guard's real value is answering
  404 instead of an unhandled 500.
- The API's requiresPassword flag is redundant for every route today, so
  disabling it changed nothing. Its purpose is the route that does not exist
  yet, whose author sets the flag and forgets the call — so the test now
  registers exactly that route and confirms the server layer stops it before
  the handler runs.
- sinks.ts drops SEEDPASS_-prefixed variables from a child's environment.
  Nothing on the allowlist starts with SEEDPASS_, so the backstop cannot fire
  against the real list. sinkEnv now takes the allowlist as a defaulted
  parameter purely so that future-edit case can be exercised.

All six targets now sweep to zero survivors.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-19 18:19:15 -04:00
thePR0M3TH3ANandClaude Opus 5 62e577a6e1 Collapse the duplicated high-risk expiry rule; close agent mutation gaps
Mutation testing carried agent.ts to zero survivors (37 mutants, all killed
or non-compiling). Four gaps, in descending order of how much they mattered:

The high-risk session's expiry was decided in two places -- the sweep in
expire() and a second comparison inside highRiskTag(). Breaking EITHER left
the whole suite green, because each covered for the other. Worse, the copy in
highRiskTag() was unreachable: handle() sweeps before it dispatches, so no
request could ever arrive with an expired session still in the map. It read
like a check and enforced nothing. highRiskTag() now sweeps instead of
re-testing expiresAt, so the rule has one home and a test can hold it to
account.

The remaining three were missing assertions, not missing checks:

- `put`'s `!fingerprint || !mnemonic` guard could become `&&` unnoticed. Both
  fields are re-validated downstream, so nothing is permitted that was not
  before -- but a blank mnemonic then reports "fingerprint does not match the
  supplied seed", sending whoever reads it after the wrong field. Tests now
  pin the message.
- Same shape on high-risk-unlock's fingerprint format guard: disabling it
  entirely still refused, via "profile not unlocked" -- a lie about a string
  that could never be a profile. It is also the guard between a
  caller-supplied string and the audit-log path join, so it should be the one
  that answers.
- highRiskStatus's client-side `expires_at === null` decode was never
  asserted; inverted, it reports 0 for a locked profile and null for a live
  one. That number is how a caller knows what is left of the second factor.

Also hoists FROZEN/TAG/daemonAt to module scope so the new tests share the
injectable clock.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-19 16:49:33 -04:00
thePR0M3TH3ANandClaude Opus 5 17b94d3e26 fix(agent): an empty exec allowlist now fails closed; test the allowlist at all
Mutation slice 2 scored 0% before this, and the reason was blunt: **the exec
allowlist had no test whatsoever.** The clipboard restriction could be
inverted, weakened, or deleted outright and the suite stayed green.

That control matters because the clipboard is readable by every process in the
session. A token restricted to `cat` that may still reach the clipboard is not
restricted to anything — the holder simply copies the secret out, and the
allowlist looks enforced while containing nothing. Now tested: a
command-restricted token is refused the clipboard, a token with no allowlist
is not (so the rule is conditional rather than a blanket ban passing as one),
an allowlisted command is permitted, and one outside the list is refused
without the sink ever running.

**A fail-open found while triaging one of the survivors.** An empty
`exec_allowlist` was DROPPED at issue time, which made the token unrestricted.
Automation that computes an allowlist and arrives at zero entries means
"permit nothing" and was silently handed "permit everything" — a fail-open in
the one field whose entire purpose is restriction. It is now refused at issue
time with an error saying which of the two readings the caller probably
wanted. Reachable only over the raw socket protocol, since commander requires
at least one value.

**An audit-accuracy gap.** A denial and a missing entry deliberately return
the SAME message, so the response cannot be used to enumerate ids — the audit
log is where the difference is supposed to be recorded, and nothing read it.
The two reasons could be swapped with every test passing. Testing it needed
two corrections of my own: the CLI resolves references locally so the request
never reaches the daemon, and the shared token in that file is revoked by an
earlier test, so both calls were refused at the token check instead. It also
needed a stronger assertion than the one I first wrote — checking that both
reasons merely APPEAR passes when they are swapped, which is exactly the
mutation. It now asserts the pairing: which id got which reason.

Three survivors are recorded as genuinely unkillable with reasons, rather than
chased: the two `length > 0` comparisons cannot differ from `>= 0` now that an
empty allowlist cannot be stored, and the capability early-return is redundant
with the length check below it.

agent slices 1-2 (mutants 1-16): 100% of viable mutants caught.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-19 16:07:25 -04:00
thePR0M3TH3ANandClaude Opus 5 12b945ca7e test(agent): pin token expiry at its exact boundary; give the daemon a clock
Mutation testing found `token.expires_at <= now` could become `<` with every
test still passing. The suite checked well before and well after expiry, which
cannot tell the two apart — so a token was free to be usable for one more
request than it claimed, which is the wrong direction for a credential.

The daemon now takes an injectable clock, so the boundary can be asserted
exactly rather than raced against wall time: at T-1 the token works, at
exactly T it is refused. Every expiry decision AND every expiry assignment
goes through that clock — a token issued against real time while checked
against test time would never expire, which would have made the test
meaningless. The TOTP timestamp-skew window uses it too, since a clock that
moved one but not the other is a difference no production path should have
either.

Also pins the owner capability: an empty or absent capability is refused, and
so is one of the right length but wrong content — the latter proving the
constant-time comparison is what rejects it rather than the length check.

One survivor is now recorded as genuinely unkillable rather than chased.
`!presented || !this.capability` cannot change behaviour when flipped to `&&`,
because the length comparison below rejects an empty string either way. The
harness learned a `mutation-equivalent:` marker that must carry a REASON, so
future survivor lists mean "look at this" instead of "this again" — and the
judgement stays re-checkable rather than merely trusted.

The harness also gained `--offset`, so a long target is measured in
foreground-sized slices. That is a safety property, not a convenience: this
script writes broken security code into the working tree, and it must never
run unattended alongside commits again.

agent slice 1 (mutants 1-6): 100% of viable mutants caught.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-19 15:47:12 -04:00
thePR0M3TH3ANandClaude Opus 5 c2a6a11472 fix: revert an inverted security check I committed by accident, and make it impossible
Commit cf18ab8 included a one-line change to agent.ts that was never intended:

    -if (generateFingerprint(mnemonic) !== fingerprint) {
    +if (generateFingerprint(mnemonic) === fingerprint) {

That inverts the daemon's `put` check, so it would refuse every CORRECT
fingerprint and accept every wrong one — the exact L-7 hole this branch closed
two days ago, reopened backwards.

Cause: scripts/mutation_test.py deliberately writes broken security code into
the working tree one mutant at a time, and I ran it in the background and then
committed with `git add -A` from another shell while a mutant was live. The
script restored the file correctly when it finished; the mistake was entirely
mine, in committing during the window.

Reverted. The check is back, typecheck is clean, and all 630 tests pass.

The real fix is structural, because "remember not to commit during a mutation
run" is not a control. The script now:

  - takes a lock file at start and refuses to run if one exists, so a
    previous run that died with a mutant applied is loud rather than silent;
  - releases it on success, on failure, and on Ctrl-C;
  - VERIFIES after each target that the file it restored actually matches the
    original, and aborts loudly if not — a mutant left behind is a broken
    security check sitting in the tree waiting to be committed;
  - prints a warning at start that the tree contains deliberately broken code.

The lock file is gitignored, so its presence never itself becomes a commit.

Worth stating plainly: this was found by reading `git diff` after a test
failure looked wrong, not by any check. A CI run on that commit would have
caught it — agentSecurity.test.ts fails on that mutation — which is an
argument for the CI wiring done earlier today, not against it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-19 13:46:11 -04:00
thePR0M3TH3ANandClaude Opus 5 cf18ab8091 test: close the gaps mutation testing found in approvals, sinks, and a flaky test
Approvals scored 58%. Five survivors, all genuine:

  - `ttlSeconds` and `uses` validation was unguarded by any test. An approval
    issued with ttl 0 is expired the instant it exists; one with uses 0 can
    never be consumed. Either would look like a granted authorization to
    whoever asked for it and silently authorize nothing — the confusing
    failure rather than the safe one.
  - The expiry boundary (`<=` vs `<`) was unpinned. It now asserts that an
    approval expiring exactly now is refused, and that one millisecond earlier
    it still works, so the boundary is where it claims to be rather than
    approximately there. Python uses `<=`, so this is parity as well as the
    safer direction.

Approvals now 100%.

Sinks scored 67%. The survivor turned `&&` into `||` in the command-spec
parser, which makes a multi-element spec whose FIRST element contains
whitespace get tokenized — silently discarding every element after it. Losing
an argument changes which command runs, and dropping a `--dry-run` or a target
path is exactly the kind of silent change that matters for a sink being handed
a secret. Sinks now 100%.

Separately, the full suite caught a flaky test of my own: the recovery-split
tamper check rewrote the payload's tail and, depending on the payload's
padding, sometimes reproduced the original string exactly — asserting nothing.
It only failed when a randomized share happened to end the wrong way. Now it
flips a character and asserts the result actually differs before expecting the
throw, plus a second case that tampers at four positions rather than trusting
one. Five consecutive runs are clean.

The agent's mapping was also wrong: agent.ts holds the high-risk session, whose
tests live in highRisk.test.ts, so leaving that suite out scored the agent at
38% and blamed the code for a gap that was really a gap in the harness.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-19 13:41:55 -04:00
thePR0M3TH3ANandClaude Opus 5 c12f5de213 fix(api): password-gate live TOTP codes; add mutation testing for the un-oracled surfaces
Differential fuzzing can only reach code both implementations have. The
session agent, the API's HTTP layer, high-risk session handling and the sinks
exist only in TypeScript — and that is where the security properties live. For
those the question "would the suite notice if this broke?" has no oracle, so
it has to be answered by breaking things deliberately.

scripts/mutation_test.py does that: it flips comparison and boolean operators
and forces guards to always-allow, but only on lines that decide whether
something is permitted, expired, within a limit, or authentic. Mutating a log
line would produce a survivor that means nothing. Every mutant is typechecked
first, so a mutant that does not compile is reported as invalid rather than
counted as caught, and the original file is restored even on interrupt.

The first run on the API server found two survivors, and they were different
in kind:

**A real gap.** Disabling the rate-limit check entirely left every test
passing — nothing exercised it. Now tested: the limit refuses the request past
its budget, and unauthenticated requests consume budget too, since otherwise
the limit is bypassed by simply not sending a token.

**An equivalent mutant, which pointed at a real hole anyway.** The route-level
`requiresPassword` flag turned out to be fully redundant with the
`requirePassword` call in each handler, so disabling it changed nothing. The
redundancy is worth keeping, but nothing ENFORCED the pairing — so a future
route could set one and forget the other with the suite silent either way.
Two structural tests now pin it.

Writing the second of those tests immediately found a security regression I
had introduced: **`GET /api/v1/totp` returned live TOTP codes on a bearer
token alone.** Python gates that endpoint on the master password
(`_require_password` in api.py) and this port did not, so a leaked token
yielded working second factors for every TOTP entry in the vault. Now gated,
and tested for.

Server mutation score: 88% of viable mutants caught, the single survivor being
the equivalent one above.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-19 13:01:22 -04:00
thePR0M3TH3ANandClaude Opus 5 c6cb85e840 feat(vault): port index0/atlas, and keep it current on every mutation
index0 is the per-vault activity ledger under `_system.index0`: an append-only
event stream per writer, daily checkpoints rolling those events up, and
derived canonical views (children_of, counts_by_kind, recent_activity).

It was the last piece carried through verbatim rather than computed — which
was safe for round-tripping but meant a TypeScript client never UPDATED it. A
vault written by this port went stale the moment it was touched, and a Python
client reading it afterwards saw an activity log missing everything the TS
client had done. Worse, `nostr restore` kept the local block verbatim, so a
restore silently discarded the remote's history.

Hash parity is the entire contract here: event ids, integrity hashes,
checkpoint summaries and view hashes are SHA-256 over Python's canonical JSON,
and the merge picks winners by comparing those hashes — one byte of encoding
disagreement means the two implementations permanently disagree about which
record wins. Everything routes through the existing canonicalJson (Python's
ensure_ascii escaping, code-point key ordering, CPython number formatting)
rather than JSON.stringify, and the tests compare whole structures via
canonical JSON rather than toEqual, so they pass only if the bytes that get
hashed are identical. Verified against Python fixtures covering the append
chain, compaction, manifest metadata, views and a two-replica merge —
including non-ASCII labels and tags, a non-numeric entry id, two writers
across two days, and a managed-account scope path.

Two Python quirks are replicated deliberately rather than "fixed", because
diverging would change which events exist rather than merely how they hash:
`str(None)` is "None" (so a JSON null in a required field is a non-empty
string there), and a stored integrity_hash is preserved even when it
disagrees — this normalizes, it does not verify, and silently rewriting a
mismatched hash would hide the tampering the hash exists to reveal.

Events are emitted by DIFFING the index at the single mutation funnel each
surface goes through, rather than from each call site as Python does. That is
deliberate: a call-site emitter is something a new code path can forget, and a
silently incomplete ledger is worse than an absent one because it reads as
"nothing happened". The event vocabulary matches Python's, so a stream from
either implementation reads the same. Emission is best-effort — the atlas is
derived state, and it must never fail a committed vault write.

mergeIndexPayloads now defaults to merging index0 with the same deterministic
rules; "preserve-current" and "reject" remain for callers that want them, and
`nostr restore` no longer discards the remote's history.

Also made gen_high_risk deterministic. Fernet embeds a random IV and the
current time, so that fixture regenerated differently every run and would have
failed the fixture-drift CI job each time — it now pins both, with a note that
production must never do this.

Verified end to end: the TS binary performed four mutations, and Python read
the resulting atlas, normalized it without dropping a record, and compacted it
into checkpoints and views.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-19 11:13:15 -04:00
thePR0M3TH3ANandClaude Opus 5 7f095cba86 feat(agent): port job profiles and recovery split; fix Shamir's sub-threshold secrecy
Recovery split is Shamir over GF(257), one polynomial per secret byte, shares
encoded as sprec1:<label>:<threshold>:<total>:<index>:<digest>:<payload>.
Verified interoperable in both directions on real shares, including multi-byte
UTF-8 and payloads containing colons: Python recovers TS-produced shares and
TS recovers Python-produced ones. Confirmed end to end by splitting a live
parent seed 3-of-5 with the TS binary and recovering it in Python.

**Python's coefficients were derived from the secret, and that is now fixed.**
`_coef` HMAC'd the secret to produce every polynomial coefficient, which made
the entire share set a deterministic function of it. Shamir's defining
property is that any sub-threshold set of shares is information-theoretically
independent of the secret; deriving coefficients from the secret destroys it,
because one share becomes an offline verifier — guess a secret, re-run the
split, compare. Demonstrated recovering "hunter2" from one share of five at
threshold three, before changing anything. It also meant re-splitting produced
byte-identical shares, so a leaked share could never be rotated out without
changing the secret.

The determinism was load-bearing in the wrong way: coefficients were
recomputed inside the per-share loop, so they HAD to be reproducible or the
points would not lie on one polynomial. Fixed by hoisting them out and drawing
from `secrets`. The share token format is unchanged and `recover_secret` is
untouched — recovery is Lagrange interpolation over (x, y) pairs and never
recomputes coefficients — so old shares keep recovering and both versions
interoperate. Zero interop cost for a real security property.

TypeScript never had the defect and its test suite pins it: reintroducing
secret-derived coefficients turns three tests red, including the guessing
attack. Coefficients use rejection sampling because 257 does not divide 2^16,
and a modulo draw would bias them.

Job profiles port straight across, with policy_stamp as the security-relevant
field: sha256 of the canonical agent_policy.json at creation, so a policy
tightened afterwards shows as a mismatch instead of leaving the job running
under rules nobody reviewed it against. The canonical form matches Python's
`sort_keys=True, separators=(",",":"), ensure_ascii=True` — including the
ensure_ascii escaping, which is easy to miss and would otherwise make a
non-ASCII policy hash differently in the two implementations. Verified
byte-identical stamps for both an empty policy and one with non-ASCII values.
An unreadable policy hashes to its own distinct value rather than to the empty
policy, so jobs read as stale rather than silently current.

Recovery drills use the same HMAC-chained log design as the audit log, with
tests that an edited record, a removed record and an unsigned appended record
are all detected.

Every Python API endpoint now has a TypeScript equivalent: UNPORTED_PREFIXES
is empty. The 501 mechanism stays, because the honest answer for a dropped
subsystem is 501 naming the feature rather than 404.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-19 10:41:45 -04:00
thePR0M3TH3ANandClaude Opus 5 0c48c1f9dc feat(agent): port high-risk partitions and approval gates
High-risk partitions move ssh/pgp/seed/nostr/managed_account records out of
the vault index into a separately encrypted file, so those kinds need a SECOND
factor beyond the master password. The index keeps a stub carrying kind, label
and archived — enough to list, nothing more.

Both file formats are byte-compatible with Python and verified bidirectionally:
TS unwraps a Python-written key envelope and reads a Python-written partition,
and Python unwraps a TS-written envelope and reads a TS-written partition. That
matters more than usual here — the entries are SSH keys, PGP keys and seeds, so
a partition only one implementation can open is lost secrets, not an
inconvenience. This required a Fernet encryptor in core, which had been
decrypt-only by design; the header now says why that exception exists and that
no other Fernet payload should ever be written.

**One deliberate divergence, and it is the reason this is not a straight port.**
Python records an unlock session in agent_high_risk_unlock.json, and that record
contains the partition_key_tag — the value the partition file's encryption key
is derived from. So for the life of any Python unlock session the partition is
decryptable from disk alone, with no factor, by anything that can read the app
directory; both files live in the same directory, so "can read one" implies "can
read the other". That voids the guarantee the partition exists to provide.
Verified empirically before writing any of this.

Here the tag lives only in the session agent's memory, beside the parent seeds
it already holds, with the same TTL discipline — and in the API process's memory
for the API. Nothing derived from the factor reaches disk, asserted by a test
that scans every file in the app directory for the tag, and confirmed on a live
install. No file format or interop changes; only where the live session lives.

Other properties the tests pin:
  - a locked partition refuses reveal rather than deriving from the stub, which
    would return a plausible wrong secret
  - locking the vault drops the high-risk grant, which is scoped to it
  - the grant expires on its own clock, typically much shorter than the seed's
  - unlock is owner-gated: a scoped token cannot reach it
  - the tag is absent from the audit log, which records only that an unlock
    happened
  - the factor comes from the environment, never argv
  - factor-set refuses to replace an existing factor, since a new partition key
    would make an existing partition permanently unreadable
  - migrate writes the partition BEFORE the index, so a crash between them
    cannot leave stubs pointing at records that were never stored

Approval gates port straight across: one-shot (or N-use) authorizations for
export / reveal_parent_seed / private_key_retrieval, consumed under the store
lock so two callers cannot both spend the last use, with every failure mode
getting its own reason string matching Python's. A corrupt store reads as empty,
which fails closed.

API: /api/v1/high-risk/{status,unlock,lock} are real rather than 501. Unlock
needs the bearer token, the master password header AND the factor header; a
wrong factor gets one indistinguishable reason so probing cannot tell failures
apart.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-19 10:19:39 -04:00
thePR0M3TH3ANandClaude Opus 5 885cbe5665 fix(semantic): delete indexes written before secrets stopped being indexed
Fixing the writer does not rewrite files already on disk, so a profile that
built an index before today still has one holding stored secrets in plaintext.
Reporting a version string in a status field and expecting the user to notice
is not a remedy for that.

Both implementations now check the manifest's model_id on every read — status,
search, and the API equivalents — and delete records + manifest when it
predates the fix. Reading a stale index would also mean serving results
derived from the secrets it should never have held, so search purges before
it reads rather than after.

Deleting is safe: the index is a derived cache, rebuilt from the vault in
milliseconds, and the user is told to rebuild.

A manifest that cannot be parsed is deliberately NOT treated as stale.
"Cannot tell" must not mean "delete it", or every corrupt-manifest read would
throw away a working index; that case is tested on both sides.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
2026-08-19 10:02:31 -04:00
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 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 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 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 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 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 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 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 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 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 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
thePR0M3TH3ANandClaude Fable 5 b4f20d417a cross-impl: full feature coverage (30 checks) and two more compatibility fixes
Expanded the cross-implementation suite from 19 to 30 checks so every
feature is exercised against the other implementation, not just the ones
that were convenient:

  G  edits (modify / archive / links) made by either side are seen by the other
  H  deterministic conflict merge produces byte-identical output on both sides
  I  an Argon2id-protected Python profile unlocks in TS
  J  the encrypted config file interoperates both directions

Entry coverage in phases A/B now spans every creatable kind, including a
policy-constrained password and an imported (non-deterministic) TOTP.

Two real bugs this surfaced, both fixed:

- Password entries carrying a `policy` block derived the WRONG password in
  TS. Python's _generate_password_for_entry merges the entry's overrides
  onto the base policy before deriving; TS ignored the block entirely, so
  any entry created with policy flags produced a different password.
  core now exposes passwordPolicyFromRecord and the CLI applies it.

- TS could not read a Python-written config file: Python wraps it in the
  kdf/ct JSON envelope (same class as the snapshot bug fixed last commit).
  loadConfig now parses the envelope; regression test included.

One behavioral divergence found and deliberately NOT papered over:
Python generates TOTP codes with pyotp defaults (6 digits / 30s) even when
the entry records different values, so an imported 8-digit/45s secret
yields different codes in each implementation -- and Python's would be
rejected by the issuing service. The suite now reports it as a [DIVERGE]
line rather than a pass or a failure, and the matrix records it as awaiting
a product decision. TS behavior is believed correct.

173 core + 47 CLI tests green; 30/30 cross-implementation checks green
with 2 documented divergences.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-14 16:31:49 -04:00
thePR0M3TH3ANandClaude Fable 5 3ebaced2e3 cross-impl: live Python-publishes -> TS-restores relay round trip
Phase F starts the in-process NIP-01 relay, has the real Python NostrClient
publish a chunked snapshot to it, then restores that snapshot with the TS
CLI on a clean profile and checks the secrets survive.

It immediately caught a genuine restore bug: Python's index files wrap the
ciphertext in a kdf/ct JSON envelope, and a published snapshot carries
whatever the local file held. TS decrypted the fetched bytes directly, so
it failed on anything Python published -- invisible to the TS-only tests
because TS writes bare ciphertext. Restore (and delta replay) now parse the
wrapper first, exactly as decrypt_and_save_index_from_nostr does.

Regression test in relay.test.ts publishes a wrapper-format payload through
a live relay and restores it.

Cutover plan updated: gate 4 (sync protocol) now cites this end-to-end
evidence, and gate 5's remaining list drops SSH and migrations.

171 core + 46 CLI tests green; 19/19 cross-implementation checks green.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-14 16:24:56 -04:00
thePR0M3TH3ANandClaude Fable 5 1eee053ff0 parity: SSH key derivation (Ed25519, PEM byte-for-byte)
- core/derive/ssh.ts: BIP-85 app 32 entropy used directly as the Ed25519
  private key, serialized as PKCS#8 and SubjectPublicKeyInfo PEM. Both DER
  shapes are fixed for Ed25519, so they are emitted from literal prefixes
  rather than adding an ASN.1 dependency to the crypto path.
- Output matches Python's `cryptography` byte-for-byte across two seeds and
  three indices, including PEM line wrapping.
- CLI: `entry add ssh`, reveal (private key PEM), and `entry ssh-public`
  which prints the public key without touching secret material. The
  OpenSSH authorized_keys line is a TS-only convenience -- Python emits
  only PEM, which ssh(1) cannot consume directly -- and is marked as such
  in the compatibility matrix.
- Cross-impl phases A and B now include an ssh entry in both directions.

One harness fix: PEM values carry a trailing newline that the CLI capture
strips, so the cross-impl comparison normalizes trailing whitespace. The
unit tests still compare exactly.

170 core + 46 CLI tests green; 16/16 cross-implementation checks green.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-14 16:22:27 -04:00
thePR0M3TH3ANandClaude Fable 5 0f8ce35c47 cross-impl: prove Python/TS profile interop, fix two compatibility bugs
Fixtures show both implementations compute the same values. This adds the
missing half: proof they can read each other's on-disk profiles and
backups, using each side's real code paths.

scripts/cross_impl_check.py (12 checks, wired into CI):
  A. Python creates a profile -> TS opens the index, derives every secret
     identically, and unlocks it with the master password alone
  B. TS creates a profile -> Python decrypts parent_seed.enc and the index,
     recovers the same seed, derives the same secrets
  C. Portable backups round-trip both directions (Python verifies the
     checksum of a TS-written export)
  D. Both implementations reject a bad-checksum mnemonic

Two real compatibility bugs it caught, both fixed:

- TS-created profiles were unopenable by Python. Python only honors the
  recorded parent-seed KDF parameters when kdf.name is exactly "pbkdf2";
  TS wrote "pbkdf2-sha256" with an empty salt and 100k iterations, so
  Python fell back to its config defaults (200k, fingerprint salt) and
  derived a different key. TS now writes exactly what
  PasswordManager._build_seed_kdf_config does, and its own unlock path
  honors the recorded salt and iteration count.

- TS accepted invalid BIP-39 mnemonics. Seed derivation takes any string,
  so a typo'd phrase silently produced a *different* vault -- unopenable
  by Python and unrecoverable by the user. Mnemonics are now validated
  wherever they enter (profile creation and SEEDPASS_MNEMONIC), with an
  error that says why it matters.

Also: docs/typescript_cutover_plan.md records the gate list for promoting
TS to main and retiring Python to legacy/reference -- P0 gates 1-4 are
green, 5-7 (feature parity tail, packaging, migration/rollback) are not.
The Python tree stays in CI after cutover as the compatibility oracle.

Regression tests for both bugs. 151 core + 46 CLI tests green,
12/12 cross-implementation checks green.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-14 16:15:19 -04:00
thePR0M3TH3ANandClaude Fable 5 dcb8fc9219 fix: four real bugs found by end-to-end manual testing
Ran the full CLI surface against a freshly generated throwaway seed
(profile -> agent unlock -> all 8 entry kinds -> reveal/sinks -> tokens ->
audit -> backup -> relay sync -> seed-only recovery). Four defects that no
unit test had caught:

- clipboard sink hung the CLI forever. xclip/wl-copy fork a resident
  process to own the X/Wayland selection; it inherited our stdio, so any
  reader of the CLI's stdout blocked indefinitely, and waiting for process
  exit never returned. Now detached with ignored stdio, success declared
  once stdin is flushed.
- 'vault import' read the mnemonic straight from the env, so it failed
  whenever the vault was unlocked through the agent -- the normal path.
- 'vault import' never wrote anything; it only printed a summary. It now
  restores into the active vault, refuses to overwrite a non-empty vault
  without --yes, and keeps the old behavior behind --inspect.
- A corrupt/foreign vault file surfaced the raw codec error "invalid token
  encoding". It now names the actual likely causes.

Also documented the variadic-option limitation that a trailing flag is
lost (commander stops collecting at the next dash-prefixed token): a
quoted command spec now splits on whitespace, no shell involved.

Regression tests for all four. 151 core + 44 CLI tests green.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-14 15:56:34 -04:00
thePR0M3TH3ANandClaude Fable 5 274bc0e1b8 TS port slice 10: scoped tokens, agent-side enforcement, audit chain
The section-9.3 permission model is now working code, enforced where the
keys live:

- Token model (aligned with the Python agent surface): bearer tokens with
  read/use/reveal scopes, entry-kind and label-regex constraints, TTL and
  use counts. Issued and held by the session agent — hash-stored, plaintext
  shown exactly once at issuance; list/revoke commands.
- Token mode: with SEEDPASS_TOKEN set (and no mnemonic), the CLI reads the
  decrypted index and materializes secrets AGENT-SIDE. The constrained
  process never receives the mnemonic, and resolveMnemonic refuses owner
  fallback in token mode — a token can never silently escalate (the test
  suite caught exactly this hole and it is now closed).
- Denials are precise and audited: wrong scope, wrong kind, unmatched
  label, expired, exhausted, revoked. Reads don't consume uses; secret
  deliveries do.
- Audit chain in Python AuditLogger's scheme: sig = HMAC-SHA256(key_index,
  prev_sig + canonical_payload), genesis 64 zeros, per-profile audit.log;
  agent records unlocks, token lifecycle, index reads, deliveries and
  denials (never secret values). audit-verify/audit-tail commands; a forged
  record breaks the chain in tests.
- 151 core + 41 CLI tests green.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-14 15:06:02 -04:00
thePR0M3TH3ANandClaude Fable 5 e84a7e1c0e TS port slice 9: relay transport + nostr sync/restore — Milestone 5 complete
- core RelayPool over the platform WebSocket (global in Node 22+/browsers,
  no dependency): multi-relay publish with per-relay OK collection, fetch
  aggregation until EOSE with dedupe and signature verification on ingest,
  connect timeouts, dead-relay tolerance.
- Sync flows mirroring nostr/snapshot.py: publishSnapshot (chunk events ->
  manifest with HMAC identity + nonce), fetchLatestSnapshot (newest-first,
  per-chunk hash verify, falls through incomplete manifests), publishDelta,
  fetchDeltasSince (created_at replay order).
- CLI nostr group: get-pubkey (matches app-1237 fixture npub), list/add/
  remove-relay (config-backed, ws:// validation, last-relay guard), sync
  (publish local vault), restore (snapshot + delta merge -> local vault).
- Tested against an in-process NIP-01 mock relay: core round trip incl.
  corrupted-chunk rejection, and CLI end-to-end sync -> destroy local
  vault -> restore with secrets intact. Transport tests are Node-only
  (jsdom WebSocket can't connect; real browsers come with Milestone 6).
- 151 core + 32 CLI tests green. Milestone 5 CLI surface is complete.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-14 14:51:18 -04:00