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:
- Phase 22.2 - HOTP, Steam, a next-code preview, and an Authenticator screen on the activity bar beside RBAC/Remote/Tools.
- AppImage metadata - the description and packaging fields.
- A reported security bug -
envv list --jsonprintingtotp_secretin clear while every other secret redacted. - 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: 6beside a Steam seed, and six characters is not what Steam accepts. - Reading a counter-based code never advances it.
envv totp advanceand 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.mdand intoauth-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.
- The redaction hole above. Class, not instance: an allow-list of secrets leaks every field the build has not heard of.
envv entry set X --totp-counter 5reported 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 namedtouches_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.- 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 aVAULT_CONFLICTthe user could not have predicted.
What was added
src/ts/vault-watch.ts- pollsvault_meta.data_hashevery 3s through a newvault_versioncommand. 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/counterdimension acrossparity/totp-seeds.json'sparseandparamstables, 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 intests/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-counterno-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_FIELDSwould 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
- Commit and merge. Main is at
8aa2321; this work is onworktree-phase-38-totp-secret. The pwsh one-liner was handed over in the session. - Re-enrol the two exposed seeds (Neptun, inf) if that has not been done.
- Delete the stale
target/debug/envvin the main checkout, or rebuild it - it is a 0.20.0 binary that prints seeds, and it is still on the machine. - The
desktopTemplatefollow-up above. - 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.