Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. Phases 21 and 22 lived only in the worktree worktree-phase-38-totp-secret, uncommitted. Nothing was in main.
  2. The IPC path had been exercised by nothing. inTauri is a module-scope const in state.ts, so every existing test of totp.ts could 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:

ReaderOut-of-range digits: 99
envv totp code (seed_of)clamped to 6 - a working code
envv totp lsprinted 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 with vi.resetModules() and a stubbed __TAURI__ bridge, because inTauri is 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 returns null rather than rejecting once a second inside a renderer, the painted digits / countdown / data-totp-code, the expiring class, 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 to invoke() anywhere in src/ts must appear in generate_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 unregistering entry_totp_code.
  • The params parity table above, and two redaction regressions in envv-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_FIELDS carries a paragraph explaining why totp_secret belongs 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

  1. Commit - the user's to run. The pwsh one-liner was handed over in the session. worktree-phase-38-totp-secret is a fast-forward onto main (both at cf6f676); the branch name predates two renumberings and means nothing. CLAUDE.md and the session records/ are gitignored and were edited in the main checkout, so they do not travel with the branch.
  2. 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 GitHub prints. Everything either side of the boundary is covered - the generator against RFC 6238, the command name against generate_handler!, the arguments and the painting against a stubbed bridge - so what remains unproven is only that a real Tauri window delivers the call.
  3. 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.