Phase log 28: 2026-09-10
Closing out Phases 21 and 22: the gaps left by phase log 27, and the three defects found while closing them.
Goal
The session opened with a question - is 2FA/TOTP fully established UI-end? - and the answer was "yes, with two gaps", so the goal became closing them:
- Phases 21 and 22 lived only in the worktree
worktree-phase-38-totp-secret, uncommitted. Nothing was inmain. - The IPC path had been exercised by nothing.
inTauriis a module-scope const instate.ts, so every existing test oftotp.tscould only reach the browser branch - the one that prints- - -. Everything on the other side of it (argument names, seed normalisation on the way out, the cache, the countdown, the ticker) was covered by exactly zero tests.
Closing (2) meant running the real binary against a real vault, which is where the three defects came from. None of them were reachable by reading the code.
The bugs that mattered
Ranked by consequence.
1. 🚨 envv get X --field totp_secret printed the seed in clear
The whole-entry path masked it; the single-field path did not. So
envv get GitHub --json → "totp_secret": {redacted, fingerprint…}
envv get GitHub --field totp_secret --json → "value": "JBSWY3DPEHPK3PXP"
and the second is the form a script or an agent reaches for. A stored authenticator seed grants a login on its own and nothing about it expires, so this is worse than an API key leaking: there is no rotation clock behind it.
Mechanism. There were two lists of "which fields hold secret material" -
out::SECRET_FIELDS, used by redact_entry, and a second copy
entries::SECRET_FIELD_NAMES used by cmd_get --field and by
pool::cmd_next --field. Phase 22 added totp_secret to the first and not the
second. The second list's own doc comment read "a second private copy of the
list is a second thing to forget to update" - it was right, and it was the copy
that went stale.
Fix. out::SECRET_FIELDS is now pub and is the only list;
entries::SECRET_FIELD_NAMES is deleted and both callers point at the survivor.
The regression test pins the property, not the list - every entry of
SECRET_FIELDS must still be recognised after canonical_field(), which is the
exact predicate the single-field path evaluates - so re-introducing a copy fails
here too. Verified by dropping totp_secret from the list and watching three
tests fail.
2. One vault field, three readers, three answers
totp_algorithm / totp_digits / totp_period were read in three places with
three different clamping rules:
| Reader | Out-of-range digits: 99 |
|---|---|
envv totp code (seed_of) | clamped to 6 - a working code |
envv totp ls | printed raw - listed as a 99-digit credential |
entry_totp_code (the app's IPC command) | neither - Err("digits must be between 6 and 10") |
So the same entry listed as one thing, produced another, and handed the app an
error instead of a code. The app only escaped because totpParamsOf in
src/ts/totp.ts clamps before it calls - the app was relying on a caller-side
guard for a rule the command claimed to enforce itself.
Fix. vault_core::totp::Params::from_fields is the single reader, and all
three sites call it. It is now a third parity table - the params section of
tests/fixtures/parity/totp-seeds.json, 18 cases, asserted from
tests/totp.test.ts (totpParamsOf) and envv-cli/tests/totp.rs
(Params::from_fields), the same arrangement as parse and uri. Verified by
removing the digits clamp and watching the Rust half fail on {"totp_digits":11}.
3. envv totp ls columns ran together
{:>6} on a serde_json::Value is silently ignored - serde_json's Display
honours neither width nor alignment - so a header promising two right-aligned
numeric columns printed SHA1 6 30. Unwrapped to u64 at the call site.
What was added
tests/totp-ipc.test.ts(12 tests) - the desktop branch, loaded withvi.resetModules()and a stubbed__TAURI__bridge, becauseinTauriis read once at module scope and a normal import can never reach this half. Asserts the command name, the four argument keys (a misspelling here is the "missing required key" failure that broke the whole users panel in June), the normalised seed, clamped parameters, the cache being keyed by seed and parameters rather than by entry id, the in-flight guard, that a failed call returnsnullrather than rejecting once a second inside a renderer, the painted digits / countdown /data-totp-code, theexpiringclass, a deleted entry's slot hiding rather than painting its neighbour (invariant 1), and the ticker being idempotent by assignment (invariant 9).tests/ipc-contract.test.ts(26 tests) - every string literal passed toinvoke()anywhere insrc/tsmust appear ingenerate_handler!. This is the half of the IPC contract a test process can see, and it covers all 23 frontend command names, not only TOTP's nine. Verified by unregisteringentry_totp_code.- The
paramsparity table above, and two redaction regressions inenvv-cli/tests/totp.rs.
Verification
cargo test --workspace 257 passed, 0 failed
npm run check 46 files, 1051 tests passed; version, format, lint, typecheck clean
cargo clippy --workspace --all-targets clean
cargo fmt --all --check clean
End to end against a real vault, which nothing had done before this session:
envv --db-path … --init entry add GitHub --key dummy --totp 'jbsw y3dp ehpk 3pxp'
envv totp code GitHub --json → 549796
python3 -c '<RFC 6238 reference>' → 549796
The seed was typed grouped and lowercase and still matched an independent
implementation, so normalisation, base32 decoding and the HMAC all agree with
the spec rather than merely with themselves. Also confirmed by hand: the
boundary refuses --totp-digits 99 (exit 10, naming the range), totp uri
refuses stdout without --reveal (exit 9), and --field totp_secret now masks
while --reveal opts back in.
What worked
- Running the binary. All three defects came from ten minutes of driving the real CLI against a temp vault. The two parity fixtures, 1000 frontend tests and 257 Rust tests had all passed over the top of a seed printing in clear.
- Reverting each fix to check the test fails. Four separate reverts, four failures, no test that passed for the wrong reason.
- Comparing against an outside implementation. A twelve-line Python TOTP is a genuinely independent check; two of our own halves agreeing is not.
What didn't
- The two-lists shape defeated review.
out::SECRET_FIELDScarries a paragraph explaining whytotp_secretbelongs in it. The stale copy carries a paragraph explaining why a second copy is dangerous. Both were read during Phase 22 and the value still leaked. Pin the property, delete the copy. - A GUI check is still not something this session could do (see below).
Next steps
- Commit - the user's to run. The pwsh one-liner was handed over in the
session.
worktree-phase-38-totp-secretis a fast-forward ontomain(both atcf6f676); the branch name predates two renumberings and means nothing.CLAUDE.mdandthe session records/are gitignored and were edited in the main checkout, so they do not travel with the branch. - The one check that still needs a human at a window is now narrow: open the
app, confirm the Authenticator section shows the same six digits
envv totp code GitHubprints. Everything either side of the boundary is covered - the generator against RFC 6238, the command name againstgenerate_handler!, the arguments and the painting against a stubbed bridge - so what remains unproven is only that a real Tauri window delivers the call. - Phase 23 is next, unchanged.
Notes for whoever picks this up
inTauri being a module-scope const is the reason half of totp.ts looked
covered and was not. Any module that branches on it needs the
vi.resetModules() + dynamic-import pattern in tests/totp-ipc.test.ts to reach
its other half; pools.ts has the same shape and the same blind spot.