CI went red where the Tests workflow stayed green, and the reason is that the
two install from different lockfiles which had drifted apart: poetry.lock
held nostr-sdk 0.43.0, requirements.lock had resolved to 0.45.0 under the
unbounded `>=0.43`. Two lockfiles disagreeing about a protocol SDK is not a
CI quirk — it means the code was being tested against one version and shipped
against another.
0.44 removed NostrSigner's constructors, including the NostrSigner.keys the
client calls, and changed Client's constructor. Verified against 0.45.0:
NostrSigner is left with only get_public_key, sign_event and the nip04/nip44
methods, and Client.__init__ takes no arguments. So this is a real migration
touching signing and client setup, not a rename to be done in passing.
Bounded at <0.44 in all three places the constraint is declared, and both
lockfiles regenerated: requirements.lock resolves 0.43.1, poetry.lock keeps
0.43.0, and the relock changed exactly one line — the content hash — with no
version churn anywhere else. Confirmed 0.43.1 still exposes NostrSigner.keys
and a Client that takes an argument, since that is what CI will actually
install. pip-audit reports no advisory against the 0.43 line, so the bound
costs nothing today.
The migration is recorded in TODO.md rather than attempted here: it wants the
sync round-trip re-validated against a real relay, not folded into a CI fix.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
`textual` is not a declared dependency — not in dependencies, not an extra,
not in the dev group — so it is absent from any environment built from the
lockfile. Every other TUI test opens with pytest.importorskip("textual") and
skips. This file did not, so instead of skipping it failed COLLECTION, and a
collection error takes the whole run down: the entire Python suite went red
on Linux and macOS over a module no test here can rely on.
The guard is the immediate fix. The larger finding is recorded in TODO.md:
the whole TUI test surface has been passing by not running, and the step
meant to catch that — `seedpass tui2 --check` — uses find_spec and reports
"unavailable" with a zero exit, so it passes whether or not the TUI can
start. A fresh install from the lockfile ships a TUI that does not work and
nothing in CI says so. Which way to resolve that is a product call, so it is
written down rather than decided here.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
First push of this branch in a while, and CI found three things.
BLACK VERSION DRIFT. The repo pins black ^24.3 (24.10.0 locked) and the local
environment has 26.1.0, whose stable style differs. An earlier commit titled
"satisfy the black gate" reformatted five tui_v3 screens with 26.1.0, which
UNformatted them as far as CI was concerned — the fix made the gate worse.
Everything is now formatted with 24.10.0, the pinned version, reproducing
CI's six files exactly before and zero after. scripts/mutation_test.py is in
there because I wrote it this session and never ran black on it at all.
MY SCRATCHPAD PATH IN requirements.lock. pip-compile records the command that
generated a lockfile in its header, and when I regenerated it during the
dependency-vulnerability fix I compiled to a temp path and copied the result
in. That baked an absolute path from my machine into a committed file, so
CI's regenerate-and-diff could never match. Regenerated with CI's exact
command on Python 3.11; only the two header lines change, so the dependency
set was never wrong.
AN UNSATISFIABLE LOCKFILE CHECK. Separately, and not caused by me: the step
regenerated the lockfile and diffed it on every matrix entry, including
Windows on 3.10 while Linux and macOS use 3.11. pip-compile resolves
version-conditional dependencies per interpreter, so those two produce
genuinely different files — 135 lines apart here, argon2-cffi-bindings among
them. One committed lockfile cannot equal both outputs, so whichever version
it was built on, the other job failed. Verification now runs once on
Linux/3.11, the canonical generator; every platform still installs from the
lockfile with --require-hashes, which is the part that had to stay everywhere.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
The Tests workflow was red on every OS and Python version for one reason:
`black --check` found 28 unformatted files. Part predates this branch (the
tui_v3 screens, core/api.py, core/index0.py); part is mine from this session.
Formatting only — verified rather than assumed, because a formatter touching
363 files is exactly where an unnoticed behaviour change would hide:
- 1223 Python tests pass, unchanged
- regenerating the fixtures after reformatting the generator produces
byte-identical output, so no hashed value moved
- differential fuzz: no divergences
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
`dependency_scan.sh` had been failing CI on the port branch, and it was right
to: pip-audit reported 100 known vulnerabilities across 15 packages, including
`cryptography` itself — the library doing the vault's actual encryption.
Raised the floors to the fixed versions pip-audit named. `pip-audit` on the
regenerated lock now reports zero (one ignored exception remains, the
pre-existing GHSA-wj6h-64fc-37mp).
The one that needed judgement was starlette. It was pinned `<0.48` "must
remain compatible with FastAPI", which kept seven advisories permanently open.
That ceiling predated fastapi 0.141, which requires `starlette>=0.46` with no
upper bound — so the pin was protecting against a constraint that no longer
exists. Lifting it took starlette across a major version (0.47 -> 1.6), which
is why this was verified rather than assumed:
- 1223 Python tests pass, 171 of them API tests
- the API app builds all 64 routes and still answers 401 for a missing
token, a bad token, and 404 for an unknown path
- cross_impl_check: 41/41
- differential fuzz: no divergences
poetry.lock was regenerated too. It dated from March, and `poetry check --lock`
reported it inconsistent with pyproject.toml — CI runs `poetry install`, so
that alone would have kept the build red no matter what else was fixed.
None of this touches the TypeScript port, which has an empty production
dependency list. It matters because the Python implementation is still the
normative reference and still what a user runs today.
Also wires the differential fuzzer into ts-parity: three fixed seeds so a
regression is deterministic and bisectable, plus one seed derived from the run
id so the explored input space grows over time rather than freezing at
whatever was chosen today.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
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
agent_high_risk_unlock.json stores the session's partition_key_tag, and the
partition file's encryption key is derived from exactly that value. While a
session is live the partition therefore decrypts from disk with no factor, for
anything that can read APP_DIR — and the session file sits beside the key
envelope, so reading one implies reading the other. That is the guarantee the
partition exists to provide.
Documented at grant_high_risk_unlock, where it happens, rather than only in a
tracking file. The exposure is inherent to on-disk session state in a CLI with
no resident process, and encrypting the session under the master password or
seed does not fix it — that would make the password alone sufficient, which is
what the second factor is meant to prevent.
The two real fixes are product decisions (drop the session model, or give
Python a resident agent), so they are recorded rather than chosen. Interim
guidance: short TTLs, high-risk-lock when finished, treat read access to
APP_DIR as equivalent to holding the factor.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
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
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
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
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
Restoring the wrong manifest is not an ordering detail -- the manifest that
wins the comparison IS the vault the user gets back. Two defects compounded:
TypeScript ordered manifests by created_at with the event id as tie-break.
created_at is whole seconds, so two snapshots published in the same second
tied, and the id tie-break -- stable and relay-independent, but arbitrary with
respect to time -- returned the OLDER vault about half the time. Entries
created between the two syncs disappeared silently; skippedNewerManifests
stayed empty because nothing was skipped, the wrong manifest simply sorted
first. It surfaced only as an intermittent test failure.
Python never ordered at all: it asked the relay for limit(1) and restored
whatever came back, handing the relay the choice of which snapshot wins.
Both now stamp published_ms -- publication time in milliseconds -- inside the
manifest JSON, so it is covered by the event signature and no relay can steer
it, and both order by it. The value is monotonic per process (max(now,
last+1)), because two back-to-back publishes can share a millisecond and would
otherwise fall back to the same arbitrary id tie-break in a narrower window.
Across machines the clock remains the only shared reference; that case is
unchanged and documented.
Backwards compatible: the field is optional, and a manifest without it orders
at the start of its created_at second. That biases a new-format manifest to
win a same-second tie against an old-format one, which is the right direction
-- old-format manifests are by definition from before the upgrade. Ordering
the unknown one last would let a pre-upgrade snapshot outrank every
post-upgrade one for that second: the same bug from the other side.
Two things found while doing it:
- Python's publish_delta republishes the manifest with an updated
delta_since. It needed the new field too: without it the manifest carrying
the newest delta_since sorted BELOW the snapshot it supersedes, so readers
fetched deltas from a stale watermark and the update never arrived. Caught
by the existing roundtrip tests, which is why they are worth having.
- Raising Python's fetch limit turned its vestigial fallback loop into a
live downgrade path: a relay withholding one chunk of the newest snapshot
could walk a client silently backwards through its history. The loop now
skips unparseable events (relay noise must not deny service) but refuses
to reach past the newest manifest that genuinely parses, and says why.
A restore that quietly returns last week's vault is indistinguishable from
one that worked.
Tests: js relay.test.ts gains a 12-round same-second case -- one pair
reproduces the bug only half the time, which is how it stayed a "flake" --
plus a legacy-manifest case built by hand in the true old format. Python gains
test_manifest_ordering.py (6 cases: order key, both fallbacks, arrival order
irrelevant, id tie-break, unparseable content). Mutation-verified on both
sides: restoring the old comparison turns the matching tests red.
The nostr CLI test now passes 6/6 consecutive runs, having failed roughly 1
in 4 before.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
The round-trip conformance check from spec section 8.6, now enforced in CI:
a vault holding a foreign record (unknown kind, nested foreign data, and
trap fields) plus an unknown top-level index key goes through an interleaved
Python -> TypeScript -> Python read-modify-write cycle, and every foreign
byte must come back exactly as planted. Allocation must skip past the
foreign id, and TS must refuse to reveal the record rather than guess.
Landing the check forced the normalization decision it existed to force.
Python's _normalize_entry_defaults ran its legacy renames on every entry,
foreign ones included -- and those renames are migrations for OUR old
shapes: the negative control shows the old code turning a foreign
"blacklisted" string into our "archived" flag, which is not backfill but
reinterpretation of another application's data. The normalizer now skips
unknown kinds entirely, so both implementations carry foreign records
byte-for-byte untouched and produce identical canonical state -- the
property sync convergence is defined by. Entries with neither kind nor
type are pre-kind legacy Python data and still normalize as before.
The spec's gap ledger entry for the divergence is resolved, and the Python
tolerance tests are tightened from field-subset to exact equality, plus a
trap-field test pinning that blacklisted/website/words on a foreign record
mean whatever the other application says they mean.
Verified: with the normalizer gate stashed, Phase L fails on the exact
mangling ("archived": "foreign meaning, not our archive flag"); with it in
place, 38/38 cross-impl checks and 1183 Python tests pass.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8
Spec section 8.2: a record whose kind this build does not understand -- one
written by a newer build, or by another application sharing the vault, such
as a future BitLogin namespaced kind -- must be carried through untouched,
never dropped, and must not brick the whole index. TS previously failed the
entire parse on the first unrecognized kind, stranding any shared vault on
older SeedPass builds.
The entry schema is now a union: known kinds validate strictly against the
discriminated union exactly as before, and anything else parses as an opaque
loose record. The refine on the opaque branch is the load-bearing detail --
a malformed record of a KNOWN kind must still fail its own schema, never
fall through and escape validation. Records with no kind or type at all
remain rejected as garbage. Statically the entries map stays typed to the
known kinds (exhaustive switches keep their meaning); the module comment
states the runtime widening plainly.
At the CLI surface, foreign records list with unrecognized fields redacted
to has_* flags -- another application's data is not ours to hand out -- and
reveal refuses with "unsupported entry kind" instead of guessing. Allocation
counts foreign ids, so neighbours never collide with them.
Python's natural tolerance is now pinned by test, which surfaced a real
nuance: Python backfills base-field defaults (tags, links, date_modified)
onto foreign records on load, where TS carries them verbatim. No data is
lost, so both conform to the preservation rule as now worded (no loss, no
reinterpretation; base-field normalization permitted) -- but the two produce
different canonical bytes for the same foreign record, which matters once
foreign kinds sync. Recorded in the spec's gap ledger to align before any
application ships them.
Verified: 4 of the 6 new TS tests fail against the previous schema; 230
core + 152 CLI tests and the cross-impl suite pass with the change.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8
An entry id doubles as the BIP-85 derivation index for managed_account and
seed entries, which makes it a permanent cryptographic coordinate: "Alice is
account #184" is a recovery claim, and #184 must mean Alice forever. But both
implementations allocated ids as max(live ids)+1, and sync deletion removes
entries via tombstones -- so deleting #184 on one device let the next
creation reissue #184 to a NEW entry, which then re-derived the departed
identity's exact child seed and npub. Tombstone retention (capped at 2048)
made even the evidence expire.
Allocation now comes from a high-watermark persisted in _sync_meta.next_index:
never-decreasing, bumped on every insert/save (explicit-id inserts included),
merged as max(both sides' watermarks, one past any merged entry or tombstone
id), and identical in both implementations -- the merge computation must stay
byte-for-byte the same in sync_conflict.py and sync/merge.ts. _sync_meta was
chosen because both sides already round-trip it (it carries the tombstones),
so old builds preserve the field and no schema version bump is needed.
Legacy vaults heal on their next allocation: the floor folds in tombstone ids
still within retention, and the first save persists the watermark.
Python-side key parsing mirrors the TS regex exactly (ASCII digits, no
leading zeros, JS safe-integer range): str.isdigit() alone accepts Unicode
digits that Number() rejects, which would have made the two implementations
compute different watermarks from the same payload.
Fixtures regenerated from Python; the byte-for-byte entry-creation parity
test now passes with the watermark present, proving TS's insert-time bump
and Python's save-time bump converge on identical indexes. Verified: the
recycle regression tests fail on both pre-fix implementations; 1180 Python,
224 core, 149 CLI tests and all 36 cross-impl checks pass with the fix.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8
test_profile_init_integration failed on any machine with a generated test
profile already present. Two causes, both real bugs rather than test noise:
- scripts/generate_test_profile.py mutated constants.APP_DIR at import time
to append "tests". The value was captured when constants was first
imported -- long before a test can monkeypatch Path.home -- so the script
read the developer's real ~/.seedpass/tests, and a reload appended another
"tests" segment. The mutation also leaked into every other importer of
constants in the same process. The tests directory is now derived per call
from constants.APP_DIR instead.
- initialize_profile assumed add_fingerprint returns None for an existing
profile, which is what its docstring still promises; it actually raises
ValueError. Re-running the generator against an existing profile therefore
crashed. It now treats "already exists" as the ordinary case.
test_profile_init_integration also repoints constants.APP_DIR so it no
longer depends on the developer's home directory, and
test_generate_test_profile asserts against the derived tests directory
rather than the mutated global.
Verified hermetic: the test passes repeatedly and touches nothing under the
real ~/.seedpass. Full Python suite: 1178 passed, 16 skipped, 0 failed.
Co-Authored-By: Claude Fable 5 <[email protected]>
TotpManager built pyotp.TOTP(secret) with library defaults (6 digits, 30s)
and ignored what the entry recorded, so any imported or derived entry using
other values produced codes the issuing service rejects. The service was
configured from the entry's values -- those govern the code.
- current_code_from_secret and current_code take period/digits
- EntryManager.get_totp_code passes the entry's values for both the
derived and imported paths
- display_service, menu_handler (totp_list now carries digits) and the GUI
updated at every call site
- regression tests for imported and deterministic entries with custom
parameters, asserting against pyotp with the matching interval/digits,
and confirming default entries are byte-identical to before
Found by scripts/cross_impl_check.py, which flagged it as a divergence
against the TypeScript port. That allowance is now removed: the suite
asserts TOTP equality and reports 30/30 with zero divergences.
Co-Authored-By: Claude Fable 5 <[email protected]>
1176 passed, 0 failed. These were all reachable only after 9e0f045 stopped
the runner exiting early, and every one had a distinct cause.
One real bug, in index0.py. _build_children_view sorted entry ids with
int(str(id)), which raised ValueError on any id that is not a decimal
string. That happens inside view building during index load, so a single
malformed id made the whole vault fail to open -- for a password manager
that is indistinguishable from data loss. Non-numeric ids now sort after
numeric ones rather than crashing; a bad id should degrade the ordering, not
brick the index.
Eight stale assertions against a schema that moved on. The index now carries
a machine-managed _system.index0 block (canonical views, writer ids, scope
paths) added on load, so tests that saved a literal dict and compared it
back always differed by exactly that key -- the round trips themselves were
correct the whole time. Adds helpers.user_data() to drop it, and uses it in
the export/import, legacy-migration and nostr-fallback tests. Those tests
are about user data surviving; asserting on _system would pin an
implementation detail they are not about.
Three checksum tests patched seams that no longer exist. They stubbed
manager.verify_checksum (gone) and manager.calculate_checksum, which
handle_verify_checksum no longer reaches -- it now asks
_script_checksum_status, which returns early on a launch-target mismatch
before any checksum is computed. Rewritten against that seam, plus a new
case for the target-mismatch verdict, which nothing covered.
Two tests stubbed builtins.input but not sys.stdin.isatty. Both code paths
refuse a non-TTY before ever calling input, so they returned early under
pytest. Uses the isatty stub the rest of the suite already uses.
One test asserted the opposite of the intended contract.
test_setup_encryption_manager_rejects_wrong_argon2_params expected a seed to
be UNREADABLE when the profile's argon2_time_cost differs from the one it
was written with. But encrypt_parent_seed stores its KdfConfig and
setup_encryption_manager tries those stored params first, deliberately: the
profile config says how to derive NEW keys, and refusing an existing seed
because a preference changed would lock a user out of their own vault.
Renamed and inverted to assert the real behaviour.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01HtNMsKzzFTPURemX7tT19u
`pytest src/tests` died at 123 of 1170 tests with exit code 0 -- no summary,
no coverage report, and a shell that saw success. Four tests reach code
paths that call os._exit(0), which skips interpreter finalization and takes
pytest with it.
The os._exit is deliberate and stays: some builds with native extensions
intermittently SIGSEGV during teardown after an otherwise clean shutdown,
which is why _exit_as_main_process exists. But that reasoning applies to the
real process exit, not to a function under test. Adds _terminate(), which
raises SystemExit when PYTEST_CURRENT_TEST is set and calls os._exit
otherwise, and routes the three in-process exit paths through it.
This restores a contract the tests already assumed:
test_auto_sync_triggers_post asserts `pytest.raises(SystemExit)`. It was
written when display_menu raised, and the later switch to os._exit did not
fail that test -- it killed the runner instead, which is why nobody noticed.
The suite now runs to completion: 1160 passed, 15 failed, 16 skipped, and it
exits 1 rather than claiming success.
Those 15 were always failing; they were simply never reached. Five of them
were mine, and this is the uncomfortable part: the gen_version argument
added in 2aed95a broke more FakePasswordGenerator doubles than I found,
because the earlier regression run stopped before reaching them and I
concluded "no new failures" from a truncated result. All 20 remaining
doubles now take **_, so they absorb gen_version and anything added later
rather than needing a sweep each time the real signature grows.
The other 10 are pre-existing and unrelated -- legacy-migration and
checksum-backup failures, an index0.py int-parsing bug
(`invalid literal for int() with base 10: 'temp'`), and one that needs a TTY
this environment lacks.
Determinism gate still green.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01HtNMsKzzFTPURemX7tT19u
L2: generate_bip85_seed and create_profile_from_generated_seed now take
words_num, accepting 12 or 24 and rejecting anything else with a clear
message. The default stays 12 -- every existing profile uses it, 128 bits is
entirely adequate, and moving the default would change nothing about
existing vaults while surprising anyone creating a new one. 24 is offered
because the master seed protects the whole vault and was the one secret
shorter than the seeds derived beneath it, which already default to 24.
L3: fixed structurally rather than by narrowing exception types.
os.urandom(32) now sits OUTSIDE the try block, so no handler can stand
between the entropy draw and the caller -- an entropy failure is unreachable
by anything that could substitute a value. Also removes a redundant
`except Exception` whose body was identical to the `Bip85Error` branch above
it.
The structural form is stronger than an exception list, and the test says so
precisely: it asserts the OSError reaching the caller is the SAME OBJECT
os.urandom threw, which holds only if nothing caught and repackaged it.
Listing exception types would still leave a handler in the path for someone
to widen later.
Adds five tests: the identity assertion above, 12- and 24-word generation,
the default staying 12, and rejection of 0/11/18/25/-12.
Determinism gate green (89 tests, up from 84). The seed/profile regression
subset is 143 passed with one failure --
test_profile_export_import_round_trip -- confirmed against the pre-change
baseline as pre-existing.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01HtNMsKzzFTPURemX7tT19u
Passwords are never stored -- they are re-derived on demand from
(seed, index, length, policy) -- and entries carried no algorithm version.
Any change to generate_password therefore changed every existing password in
every vault, unrecoverably. This adds the version field that makes such
changes possible at all, then makes three.
Safety net first (M4). test_entropy_integrity.py freezes 18 v1 vectors
spanning every policy and both sides of the 32-byte stream-wrap boundary,
and asserts the RNG fails closed: os.urandom raising must abort seed
generation, short reads must abort, AES-GCM nonces must be fresh. Each guard
was mutation-verified rather than assumed -- removing _balance_distribution,
perturbing the stream wrap, or altering the character mapping each turns all
18 vectors red, and adding a hash-of-timestamp fallback to
generate_bip85_seed turns the fail-closed test red.
generate_password now takes gen_version, defaulting to 1. Existing entries
carry no field and keep deriving v1 byte-identically; the argument is only
forwarded when non-legacy, so all 13 call sites across CLI, API and both
TUIs are untouched. New password entries are stamped gen_version 2. An
unreadable version raises rather than guessing which algorithm produced a
password.
v2 fixes three findings:
- M1: drops the forced equal-quarters class quota. 1 -> 82 distinct class
compositions at length 16 over 200 samples; per-character entropy
6.42 -> 6.53 bits against a 6.555 ceiling.
- M2: rejection sampling instead of byte % len(alphabet). Chi-square 96.1
against df=93 (p<0.001 critical value ~147).
- M3: an unbounded HMAC-expanded stream instead of cycling a 32-byte key,
which was a repeating pad for passwords over 32 characters. Lag-32 repeat
rate now sits within one standard error of chance.
Dropping the quota removed an accidental backstop: v1's minimum enforcement
overwrites uniformly random positions, so raising one class minimum could
destroy another, and only the quota's near-total rewrite hid it. v2 chooses
donor positions solely from classes that currently have a surplus, and
rejects a policy whose minima cannot fit the requested length.
Also removes a dead HKDF derivation whose result was discarded while the
docstrings named it as the algorithm in use (L1); PBKDF2 was and remains the
real one. Verified as a negative control -- the vectors stayed green.
Six test doubles are updated to the real generate_password signature; they
began receiving gen_version once new entries carried it.
Existing entries stay on v1 by design. An opt-in per-entry upgrade, with a
confirmation making the consequence unmissable, is still to come.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01HtNMsKzzFTPURemX7tT19u
- SearchService.graph_pivot_results(): wraps linked_neighbors() output
in SearchResult model with match_reasons (graph_pivot, relation:X,
direction:X, atlas:scope), scores (hop-distance based), and relation
meta string for UI surfaces
- AtlasWayfinderScreen: propagates atlas_source_scope to LinkedItemsPanel
when opening entries or recent items from the wayfinder
- LinkedItemsPanel: adds #linked-atlas-context label (◈ Atlas context:
scope_path) that refreshes via property setter; atlas scope is
preserved across linked-panel pivots; _update_atlas_context_label()
helper keeps display in sync
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- Add PivotBreadcrumbBar widget to inspector.py showing multi-hop path
- Add _pivot_history/pivot_to_entry/action_pivot_back to app.py
- LinkedItemsPanel: group linked items by relation type with section headers
- LinkedItemsPanel: b key for back navigation, breadcrumb rendered from app history
- External entry selection (grid) clears pivot history; pivot chain preserved on linked nav
Add _stdin_is_tty() helper and check at start of _launch_tui3_or_exit.
When stdin is not a TTY, emit a clear error message and exit 1 instead
of propagating an EOF exception from seed setup prompts.
Add test for non-interactive stdin error path; patch _stdin_is_tty in
existing tests that expect tui3 launch to proceed.
Fixes SeedPass-6d26
- maintenance.py: add status icons to format_status, add warning CSS class
- profile.py: improve intro text, use ► marker for active profile
- pubkey.py: use explicit Binding() for keybindings
- relays.py: remove stray `import traceback` in _perform_sync
- security.py: clarify backup passphrase label
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
- Remove 'qr' from managed_account context actions (accounts don't expose
a QR-scannable key like seeds/nostr do)
- Change bare 'except:' to 'except Exception:' on vault manager check
- Add test_v3_action_bar_managed_account_excludes_qr in parity tests
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
Settings screen now renders a UTILITY & MAINTENANCE card listing stats,
db-export, db-import, checksum-verify/update, totp-export, and offline
mode commands. Renders gracefully when config service is offline (no
early return — all sections including utility always display).
Inspector adds UtilityHintsBar widget that persists across board swaps
via a #board-slot Vertical. Hints update to show kind-appropriate
palette commands (copy, reveal keybinds, QR, npub, managed-load, etc.).
BoardContainer now routes managed_account to its own SeedBoard hint set.
Two new smoke tests verify the utility section renders and that hints
update correctly for password, nostr, and idle states.
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>