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
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
A portable backup is an encrypted, checksummed, cross-impl-verified file;
its only sin was landing on disk as an anonymous .json. Exports now
self-identify: point `vault export` (or the TUI database export) at a
directory and the file is named seedpass-<fingerprint>-<date>.seedpass --
tied to the profile it restores and sortable by date. The TUI also appends
.seedpass to an extensionless destination, since interactive input has no
script-compatibility concerns.
The zero-risk boundary, kept deliberately: an explicit file path is used
exactly as typed -- a script that exports to backup.json still gets
backup.json -- and imports sniff content, never extensions, so any old
export restores unchanged. No byte of the format moved, which is what makes
this safe to ship ahead of the review gate while format_version 2 (magic
header) and the OS icon association stay queued behind it.
Tests pin all three behaviours in both surfaces: directory target gets the
generated name, extensionless TUI input gets the suffix, explicit paths pass
through untouched. 158 CLI tests pass.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8
The 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
inactivity_timeout was stored, displayed as Settings item 12, and enforced
nowhere: an unattended terminal stayed unlocked indefinitely. Python's legacy
TUI checks it at the top of its main loop and locks; this ports that.
The main menu now measures how long the user took to answer. If that exceeds
the configured timeout, the vault is locked -- the seed is dropped and
re-derived through the normal relock path (password, or the session agent) --
and the keystroke that arrived after the timeout is discarded rather than
acted on against a stale unlocked screen. 0 disables it, matching Python.
Scope, stated plainly: like Python's, this fires when the user returns to the
main menu, not via a background timer, so idling mid-submenu does not lock
until they surface. A true idle-anywhere auto-lock would mean interrupting a
readline mid-line and is a larger change; this matches the reference.
The millisecond clock is injected through the Session (defaults to Date.now),
so two tests drive it deterministically: idling past the timeout locks and
demands the password a second time, and answering within it does not. Verified
as a negative control -- forcing the timeout to 0 turns the locking test red.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8
kdf_iterations was displayed, stored, and read by nothing. changePassword
accepted an iterations argument the TUI never passed, and createProfile
hardcoded the default -- so raising the setting, then following the screen's
own instruction to "use Change password to re-wrap this profile's seed at the
new strength", changed nothing. A security control that silently does nothing
is worse than an absent one.
Both effect paths now honour the setting: Change password re-wraps the
current profile's parent seed at the configured iteration count, and a new
profile is wrapped at the active profile's setting. createProfile takes an
optional iterations argument (default unchanged), so the CLI and existing
callers are unaffected.
The count is validated: any value at or above the settings screen's 50k floor
is honoured -- including one below the 200k default, which is a downgrade the
user chose deliberately -- and only garbage falls back to the default, so a
legitimate value is never silently ignored.
Two TUI-driven tests read parent_seed.enc back: Change password records the
new iteration count and the new password opens the re-wrapped seed, and a
generated second profile inherits the active profile's strength. Verified as
a negative control -- reverting the changePassword call site turns the first
test red.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8
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]>