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 29: 2026-09-11

Phase 22.2, a reported seed leak that turned out to be a stale binary and a real bug class underneath it, AppImage metadata, and the app learning to notice writes from outside its own window.

Goal

Four items, arriving as separate requests during the session:

  1. Phase 22.2 - HOTP, Steam, a next-code preview, and an Authenticator screen on the activity bar beside RBAC/Remote/Tools.
  2. AppImage metadata - the description and packaging fields.
  3. A reported security bug - envv list --json printing totp_secret in clear while every other secret redacted.
  4. A reported UX bug - the app not showing changes the CLI made underneath it.

The security report, and what it actually was

Reported with two real seeds already in a transcript. Reproduced in ten minutes, and the cause was not the current tree:

  "api_key": { "fingerprint": …, "redacted": true }
  "totp_secret": "JBSWY3DPEHPK3PXP"

redact_entry masked an allow-list of field names. That binary predates Phase 22, so totp_secret was a field it had never heard of, and an unknown field was printed verbatim. The worktree build redacts correctly - but that is not much of a defence: a binary older than the vault it reads is the ordinary case on a machine with a main checkout and a worktree, and every future secret field has the same hole waiting.

So the allow-list was inverted. PUBLIC_FIELDS now lists what is known safe and every other string field is masked. An old binary reading a new vault loses visibility of a new metadata field - recoverable with --reveal - instead of printing a credential. Pinned in envv-cli/tests/agent.rs against two invented field names, so a future field is covered before it is written.

The seeds that were exposed still need re-enrolling. A TOTP seed has no expiry and no rotation clock; nothing retires it but re-enrolment. That advice stands regardless of where the leak came from.

Phase 22.2

Design and rationale are in CLAUDE.md; the short version:

  • Three kinds (totp/hotp/steam) differing only in where the counter comes from. counter_at() is the sole branch before the HMAC.
  • Steam's shape is forced on read, not validated - a generic exporter writes digits: 6 beside a Steam seed, and six characters is not what Steam accepts.
  • Reading a counter-based code never advances it. envv totp advance and a card button are the explicit action. Advancing on every read walks the vault past the service the first time somebody looks at a card twice, and the failure is indistinguishable from a wrong seed.
  • The next code is opt-in everywhere - it is a second live credential with a longer life than the one on screen.
  • The Authenticator screen reverses a Phase 22 decision, on request. Phase 22 argued against a fifth panel; three kinds, a hand-advanced counter and a second code do not fit in a one-line sidebar row. The sidebar section stays. The reversal is written into CLAUDE.md and into auth-panel.ts's header.

Everything exists on both sides (invariant 10), including a Kind select and a Counter box on the form, so the app can create what the CLI can.

The bugs that mattered

Ranked by consequence.

  1. The redaction hole above. Class, not instance: an allow-list of secrets leaks every field the build has not heard of.
  2. envv entry set X --totp-counter 5 reported success and wrote nothing. The call site enumerated the seed flags to decide whether to apply them, and the two new ones were never added. A silent no-op is the worst shape a write can fail in; for a counter it means a second factor quietly out of step with its service. Fixed with one named touches_totp() beside the flags. Found by comparing against an independent RFC 4226 implementation - not by any test, which is the point of checking against something other than yourself.
  3. The app never noticed writes from outside its window. It reads the vault at unlock and holds it in memory, so a terminal running envv, or a LAN peer, changed the database underneath it. The next save then either overwrote that work or failed with a VAULT_CONFLICT the user could not have predicted.

What was added

  • src/ts/vault-watch.ts - polls vault_meta.data_hash every 3s through a new vault_version command. That marker is written in the same transaction as the data and is already what the compare-and-swap compares, so there is no second definition of "changed" to keep in step. It refuses to reload over an open editor, never touches view state (the vault is updated, not replaced), and watches local vaults only - the remote exemption is written down.
  • src/ts/auth-panel.ts + src/css/auth-panel.css + the panel markup.
  • A third Kind/counter dimension across parity/totp-seeds.json's parse and params tables, asserted from both sides.
  • Steam vectors computed by a separate implementation, pinned in vault-core.
  • tests/auth-panel.test.ts (11), tests/vault-watch.test.ts (6), plus HOTP/Steam/next-code cases in tests/totp-ipc.test.ts.

AppImage metadata

shortDescription was "Structured API key manager" and longDescription mentioned .env and YAML export - the app as it stood around Phase 3. Both rewritten to what it now is. Added publisher, homepage, license, licenseFile, copyright; category moved DeveloperTool → Utility (a credential manager is not an IDE); deb section/priority/recommends/ provides and the rpm equivalents; bundleMediaFramework: false so the AppImage stops carrying GStreamer for an app that plays nothing.

Not done: a custom desktopTemplate for Keywords, MimeType and StartupWMClass. tauri-bundler is not vendored in the local registry, so its template variables could not be verified, and guessing them produces a malformed .desktop that only a real bundle run would reveal. Left as a follow-up that needs one AppImage build to confirm.

Verification

npm run check              48 files, 1090 tests passed; version, format, lint, typecheck clean
cargo test --workspace     313 passed, 0 failed
cargo clippy --workspace --all-targets   clean
cargo fmt --all --check    clean
cd src-tauri && cargo check              clean (validates tauri.conf.json)

End to end against a real vault, all three kinds:

envv totp ls                → totp / hotp / steam, with #counter or period
envv totp code SteamAcct    → DVVG5   (5 chars, Steam's alphabet)
envv totp code Counter --next → 282 760, next 996 554
envv totp advance Counter   → 0 → 1
envv totp code Counter      → 996 554   (the next code, now current)

Steam and HOTP both checked against independent implementations at pinned counters, not against a second run of our own code.

What worked

  • Checking against something external. Two of our halves agreeing proves nothing about two identical mistakes; the independent RFC 4226 run is what found the silent --totp-counter no-op.
  • Reproducing the security report before fixing it. The report blamed the current tree; the cause was a stale binary, and the real fix - inverting the allow-list - is one neither the report nor a patch to SECRET_FIELDS would have reached.

What didn't

  • Editing test files immediately after Prettier reflowed them: two scripted replacements failed silently against text that had already changed shape. Format last, or match on something short.

Next steps

  1. Commit and merge. Main is at 8aa2321; this work is on worktree-phase-38-totp-secret. The pwsh one-liner was handed over in the session.
  2. Re-enrol the two exposed seeds (Neptun, inf) if that has not been done.
  3. Delete the stale target/debug/envv in the main checkout, or rebuild it - it is a 0.20.0 binary that prints seeds, and it is still on the machine.
  4. The desktopTemplate follow-up above.
  5. Phase 23 is still next.

Notes for whoever picks this up

The counter is the only number in a seed that is state. Every decision around it follows from that: it is written at zero, it is part of the cache key, it is never moved by a read, and --by > 1 asks first.

1 line(s) about the maintainer's own machines were left out of this page.