Commit Graph
8 Commits
Author SHA1 Message Date
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 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 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 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 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 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 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 841d0984b3 TS port slice 8: profiles, config, and the session agent
- Python-compatible ~/.seedpass layout: fingerprints.json (same shape,
  indent=4), per-profile dirs with parent_seed.enc (kdf/ct wrapper; unlock
  honors argon2id metadata and falls back across pbkdf2 iteration counts),
  index-key-encrypted seedpass_config.json.enc with ConfigManager defaults.
- CLI: fingerprint list/add/switch/remove (remove requires --yes),
  config get/set (JSON-parsed values, pin/password hashes redacted on read),
  profile-aware vault resolution (--vault override, else active profile).
- Session agent (the chosen unlock model): unix-socket daemon holding
  unlocked seeds in memory with TTL, 0600 socket; agent start/status/stop,
  vault unlock (password -> parent seed -> agent, never argv) and vault
  lock. Seed resolution order: SEEDPASS_MNEMONIC env, then agent. This
  daemon is the designed enforcement point for the section-9.3 lease/token
  layer.
- Tests cover the full no-env-mnemonic lifecycle: locked error -> unlock
  with password only -> provision/reveal through the agent -> status ->
  lock -> locked again. 148 core + 29 CLI tests green.

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