No change to how the vault is written or recovered. This fixes what the service
says when recovery is not possible, and adds a way out that is not "edit files
inside a container that will not start".
The refusal message was wrong in the case that actually matters. It said the
backup "could not be used either" whether the backup was unparseable, empty, or
simply absent — and absent is the common one, because a vault that has not
completed a single write since v0.1.3 introduced backups has no .bak at all.
That wording reads as though a file exists, and sends the operator hunting for
it. The message now names which of the three it is, and lists any
vault.json.corrupt-* files left by earlier recoveries.
DA_VAULT_RESET=1 boots an unreadable vault as empty. It is checked only on the
failure path, so it cannot affect a vault that loads, and it quarantines the
unreadable file *before* writing the fresh one — the first draft of this logged
that the file was preserved while persist would in fact have overwritten it.
Refusing by default is still right: booting empty is indistinguishable from a
vault emptied on purpose. But a refusal with no supported way forward is not a
safety feature, it is a dead end.
149 tests.
The vault was being written with `fs::write(tmp)` → `rename(tmp, path)`. The
rename is atomic, so readers never saw a half-written file — but atomicity is
not durability. Nothing fsynced the new bytes before the rename, so on ext4
(`data=ordered`, the default) a crash or hard power-off could commit the
rename while the data it published never landed. The result is precisely the
failure that stopped the container booting: `vault.json` present, zero bytes,
"EOF while parsing a value at line 1 column 0".
Two things made it worse. The write happened on *every* push tick, so a
ten-account vault was rewriting all of its secrets thousands of times a day,
and there was no backup to fall back on — a damaged file meant a crash loop
with the web UI locked behind the service that would not start.
Storage
* `store_io::write_atomic` fsyncs the new bytes, renames, then fsyncs the
directory. That ordering is the whole fix; the module docs explain why.
* Every vault write refreshes `vault.json.bak`, taken *after* the new file is
safely in place so the two always agree. Backing up first would leave the
backup a full change behind and quietly lose the most recent edit.
* `Vault::load` falls back to the backup when `vault.json` is unparseable,
moves the damaged file to `vault.json.corrupt-<ts>` rather than deleting
it, and writes the recovered accounts back so the next boot is ordinary.
With no usable backup it still refuses to start, as before.
Write volume
* Discord bookkeeping — message ids, last codes, last errors — moves to its
own `push-state.json`, batched and flushed at most every five seconds, with
a final flush on shutdown. Codes roll constantly; accounts change only when
the operator edits them, and keeping the two together was what put the
secrets in the path of every rotation.
* `push-state.json` is a cache by construction: losing or corrupting it costs
a reposted tile, never a secret, so it is never a reason to refuse to boot.
Tests: recovery from a zero-length vault, backup retention, write-back on
recovery, refusal when both files are bad, reload of tile ids, batching, and
that a rotation leaves `vault.json` byte-identical. 143 passing.
Fixes a bug that shipped in v0.1: the per-account Edit sheet never opened at
all. Its Cancel button used `onclick: dlg.close`, evaluated while building the
arguments to openModal() and therefore before `const dlg` was initialised — a
temporal-dead-zone ReferenceError that aborted the whole handler. Every other
button in that sheet was already a closure, which is why it went unnoticed:
browser testing had covered add, import and settings, but never opened this
sheet.
Renames now reach Discord almost immediately. The push loop skips any account
whose current code it has already sent, so a rename previously stayed invisible
until the code next rolled — up to a full period. update() now clears the
last-sent code when the naming actually changes, so the next tick treats the
tile as stale and rewrites it in about a second. Toggling push alone is not a
rename and still costs nothing.
- webui: pencil icon and "Edit account" title so the row action reads as edit
rather than settings; both fields labelled, with the second one explained as
the username/email and left untouched unless changed
- webui: topbar is a three-column grid so the search sits on the same centre
line as the account tiles. Below 820px there isn't room for brand + 420px
search + actions, so it falls back to a row rather than clipping the
wordmark; the brand now reads "Discord Authenticator" in full
- webui: the add-account FAB pins to the viewport corner instead of tracking
the content column edge
- brand: docs/brand/icon.svg plus PNGs rendered from it by da-render, so the
README image, Gitea avatar and Unraid template icon all derive from one
source rather than drifting. Unraid's <Icon> now points at a PNG, which it
renders more reliably than SVG
126 tests (was 124).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Google's "Transfer accounts -> Export accounts" produces an
otpauth-migration:// link wrapping a base64 protobuf that holds many accounts
at once, and splits large exports across several QR codes. v0.1 rejected these
outright; now they import.
The protobuf is decoded by hand rather than pulling prost: the schema is four
messages that have not changed since 2020, and the wire format needed is two
types (varint, length-delimited). Unknown fields are skipped properly, so a
future Google addition degrades rather than corrupting the parse.
Two details in this format silently produce plausible-but-wrong codes if taken
backwards, so both are pinned by tests: the `secret` field is RAW BYTES, not
base32, and the payload carries NO period field -- every Google export is
30-second TOTP.
- core/migration.rs: protobuf decode, lenient base64 (all four alphabet and
padding spellings), issuer reconciliation between the `name` prefix and the
dedicated `issuer` field, and batch metadata surfaced as "Part 1 of 2"
- vault: add_many() with duplicate detection, persisting once per batch rather
than once per account; duplicates match on secret AND naming, so keeping one
secret under two labels stays possible
- http-api: preview returns a list flagged with what is already stored; import
takes the link plus selected indices and re-parses server-side, so secrets
never travel out to the browser and back. /api/qr/decode now returns every
payload in an image, not just the first
- webui: multi-account import checklist with select all/none, per-entry
algorithm and digit chips, already-present entries pre-unticked, and skipped
entries named with their reason (HOTP, MD5) instead of silently dropped
Verified end to end against real QR images: upload -> decode -> preview ->
import, with the resulting codes cross-checked against an independent RFC 6238
implementation, including an 8-digit account.
124 tests (was 101).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Rust workspace serving a Google/Microsoft-Authenticator-style web UI, with
each account's rotating code mirrored into Discord as a message that
rewrites itself in place as the code rolls.
The live mechanism is webhook message editing: POST ?wait=true returns the
message id, PATCH .../messages/{id} rewrites it thereafter. Two approaches
were evaluated and rejected — an externally-hosted image URL is cached by
Discord's media proxy and, worse, must be anonymously reachable, which would
publish live codes on an unauthenticated endpoint; and an animated countdown
drifts out of phase because playback starts at decode time, not on a wall
clock. The countdown instead uses Discord's <t:unix:R> markup, which the
client re-renders every second for free.
Codes are also sent as text in a fenced block so they stay copyable, with
the grouped, readable form on a rendered WebP tile.
- core: RFC 6238 TOTP over HMAC, lenient otpauth:// parsing (issuer conflicts
and non-canonical base32 both occur in the wild), XChaCha20-Poly1305 vault,
Argon2id admin auth
- discord: webhook client with a serialising rate limiter (~2 req/s) so
globally-aligned rollovers don't burst, and error classification that
distinguishes a deleted message (repost) from a dead webhook (stop)
- render: SVG -> WebP tiles via resvg with embedded JetBrains Mono
- http-api: axum, session auth, QR decode with a rescale/contrast/rotation
retry ladder
- webui: offline vanilla JS, Beautiful UI design language, light and dark
Ships as a 27 MB Alpine image (about 8 MB to pull); the binary is fully static musl with no
OpenSSL and no C dependencies, cross-compiled via cargo-zigbuild so building
linux/amd64 on Apple Silicon needs no QEMU.
101 tests, including the RFC 6238 vectors, QR round-trips, and the live push
cycle exercised against a mock Discord.
Co-Authored-By: Claude Opus 4.8 <[email protected]>