mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-31 09:52:03 +00:00
1ff47caf158fbbca4fb104f9ddb471bd451980d3
8
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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]> |
||
|
|
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]>
|
||
|
|
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]> |
||
|
|
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]>
|
||
|
|
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]> |