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.
387 lines
17 KiB
Markdown
387 lines
17 KiB
Markdown
<p align="center">
|
|
<img src="docs/brand/icon-256.png" width="128" height="128" alt="Discord Authenticator" />
|
|
</p>
|
|
|
|
<h1 align="center">Discord Authenticator</h1>
|
|
|
|
<p align="center">
|
|
A self-hosted TOTP authenticator whose codes appear in Discord as
|
|
<strong>live messages that rewrite themselves</strong> as each code rolls.
|
|
</p>
|
|
|
|
The web UI is what you'd expect from Google or Microsoft Authenticator: a
|
|
searchable list of accounts, each with its six digits and a countdown ring.
|
|
The part that isn't ordinary is the delivery — if you have Cloudflare, Plex and
|
|
Ubisoft in the vault, your Discord channel holds three messages, each one
|
|
staying current on its own.
|
|
|
|
Written in Rust. One static binary in a 27 MB Alpine image (about 8 MB to
|
|
pull), no database, no external services beyond Discord itself.
|
|
|
|
---
|
|
|
|
## Contents
|
|
|
|
- [How the live delivery works](#how-the-live-delivery-works)
|
|
- [Security: read this before you use it](#security-read-this-before-you-use-it)
|
|
- [Running it](#running-it)
|
|
- [Using it](#using-it)
|
|
- [Configuration](#configuration)
|
|
- [Architecture](#architecture)
|
|
- [Troubleshooting](#troubleshooting)
|
|
- [Development](#development)
|
|
|
|
---
|
|
|
|
## How the live delivery works
|
|
|
|
The obvious approaches don't survive contact with Discord, and it's worth
|
|
saying why, because the design is a direct consequence.
|
|
|
|
**A live-updating image URL doesn't work.** Pointing an embed at an image your
|
|
server renders on demand fails twice over. Discord fetches the URL once at post
|
|
time and stores a snapshot; clients then render a cached copy from
|
|
`media.discordapp.net`, so new bytes at the same URL are never seen. And more
|
|
seriously, Discord's crawler must reach that URL anonymously — which would mean
|
|
publishing your live TOTP codes on an unauthenticated public endpoint. That
|
|
alone disqualifies it.
|
|
|
|
**An animated image doesn't work either.** Animation playback starts whenever
|
|
the client decodes the file, not on a wall clock. A 30-second countdown baked
|
|
into frames drifts out of phase with the real window and ends up confidently
|
|
displaying the wrong time remaining — worse than showing nothing.
|
|
|
|
**What does work is editing the message.** Posting to a webhook with
|
|
`?wait=true` returns the created message's id; `PATCH .../messages/{id}` then
|
|
rewrites that message in place, and every client sees the change at once. So
|
|
each account owns one message for its lifetime, and the app rewrites it once
|
|
per period.
|
|
|
|
Two details make this pleasant rather than merely functional:
|
|
|
|
- **The countdown costs nothing.** Discord renders `<t:1756402860:R>` as
|
|
"in 24 seconds" and re-renders it every second *client-side*. So the ticking
|
|
countdown needs no requests at all — only the digits do.
|
|
- **The digits stay copyable.** The code goes into a fenced code block, which
|
|
Discord gives a copy button, containing the bare digits (`482913`, not
|
|
`482 913`) so what lands on your clipboard is directly pasteable. The
|
|
human-readable grouped form lives on the rendered tile.
|
|
|
|
Each tile is a WebP rendered by the app — issuer, account, the code at a size
|
|
you can read across a room, and a per-issuer accent colour that matches the row
|
|
in the web UI.
|
|
|
|
### Rate limiting
|
|
|
|
TOTP windows are globally aligned, so every account's code rolls at the same
|
|
instant. Left alone, ten accounts would fire a ten-request burst into a bucket
|
|
that allows roughly five per second. Every call is therefore serialised through
|
|
a limiter that spaces requests about 500 ms apart, spreading ten edits across
|
|
five seconds of a thirty-second window, and backing off on `429` using the
|
|
`retry_after` from the response body.
|
|
|
|
Failures are classified rather than blindly retried, because Discord bans an IP
|
|
after 10,000 4xx responses in ten minutes:
|
|
|
|
| Discord says | Meaning | What happens |
|
|
|---|---|---|
|
|
| `10008` Unknown Message | You deleted the tile | Post a fresh one automatically |
|
|
| `10015` Unknown Webhook | Webhook deleted | Stop, and show you the reason |
|
|
| `50027` Invalid token | URL rotated | Stop, and show you the reason |
|
|
| `50013` Missing permission | Lost channel access | Stop, and show you the reason |
|
|
| `429` | Rate limited | Back off, skip the stale edit |
|
|
|
|
Late edits are dropped rather than queued — if an edit is overdue the code has
|
|
already changed, so the current one is sent instead of replaying a backlog.
|
|
|
|
---
|
|
|
|
## Security: read this before you use it
|
|
|
|
**Anyone who can read that Discord channel can read your codes.** That is most
|
|
of what a second factor is for. This tool deliberately trades some of that
|
|
security for convenience, and you should decide knowingly:
|
|
|
|
- Use a **private channel in a server only you are in**.
|
|
- Treat the **webhook URL itself as a secret** — it is an unauthenticated write
|
|
capability for that channel.
|
|
- Anything with access to your Discord account — another logged-in device, a
|
|
session token, a member of that server — inherits your second factor.
|
|
- Consider leaving push **off** for your highest-value accounts. It's a
|
|
per-account toggle, so a mixed setup is fine.
|
|
|
|
What the app does hold up its end on:
|
|
|
|
- TOTP secrets are encrypted at rest with **XChaCha20-Poly1305**; `vault.json`
|
|
contains ciphertext only, and the metadata beside it is deliberately readable.
|
|
- `vault.json` is written atomically *and* durably — new bytes are fsynced
|
|
before the rename that publishes them, and the directory is fsynced after —
|
|
so an unclean shutdown can't leave a truncated file behind. The previous
|
|
contents are kept in `vault.json.bak`, and startup falls back to it if
|
|
`vault.json` is ever unreadable, moving the damaged file aside as
|
|
`vault.json.corrupt-<timestamp>` rather than refusing to boot.
|
|
- The admin password is hashed with **Argon2id**.
|
|
- The vault key lives in `vault.key` (mode `0600`), or set `DA_MASTER_KEY` to
|
|
keep it off the data volume entirely — then a leaked copy of that volume is
|
|
useless on its own.
|
|
- Everything under `/api/*` requires a session. There is no public read path.
|
|
- The UI is fully offline: no CDN, no remote fonts, no external requests. The
|
|
only outbound traffic the app ever makes is to Discord.
|
|
|
|
**Why the key can't just be your password:** the app has to mint codes
|
|
unattended to keep the tiles live, so nothing can require a human to type a
|
|
passphrase after a restart. The key is therefore readable by the process at
|
|
boot. That protects against a leaked backup of the vault file — the realistic
|
|
homelab accident — but not against someone who can read the whole data
|
|
directory. `DA_MASTER_KEY` closes that second gap if you need it closed.
|
|
|
|
---
|
|
|
|
## Running it
|
|
|
|
### Docker Compose
|
|
|
|
```bash
|
|
docker compose up -d
|
|
```
|
|
|
|
Then open `http://<host>:8710/`.
|
|
|
|
### Unraid
|
|
|
|
Copy `deploy/unraid/discord-authenticator.xml` into
|
|
`/boot/config/plugins/dockerMan/templates-user/` and add the container from the
|
|
Docker tab, or point the Repository field at:
|
|
|
|
```
|
|
gitea.milesward.dev/mward4/discord-authenticator:latest
|
|
```
|
|
|
|
Bridge networking is correct — this makes outbound HTTPS calls and serves one
|
|
port. Map `/var/lib/discord-authenticator` to somewhere on `appdata` and **back
|
|
that path up**: losing it loses every stored secret.
|
|
|
|
### What lives in the data directory
|
|
|
|
| File | Holds | If it's lost |
|
|
| --- | --- | --- |
|
|
| `vault.json` | Every account, with its secret sealed | Everything — this is the file to back up |
|
|
| `vault.json.bak` | The same accounts, as of the last change | Nothing, unless `vault.json` is damaged |
|
|
| `vault.key` | The vault key (unless `DA_MASTER_KEY` is set) | Every secret becomes unreadable |
|
|
| `push-state.json` | Discord message ids and last-known push errors | Nothing — tiles are reposted on the next tick |
|
|
| `auth.json`, `settings.json` | Admin login and webhook config | You redo first-run setup |
|
|
|
|
`push-state.json` is deliberately separate from the vault. Codes roll every 30
|
|
seconds, so the bookkeeping changes constantly while the accounts themselves
|
|
change only when you edit them; keeping them together meant every rotation
|
|
rewrote all the secrets.
|
|
|
|
### Building and publishing the image
|
|
|
|
```bash
|
|
GITEA_TOKEN=<token with write:package> ./scripts/build-and-publish-unraid.sh
|
|
```
|
|
|
|
---
|
|
|
|
## Using it
|
|
|
|
**First run** asks for a username, a password, and (optionally) your Discord
|
|
webhook URL. Get the webhook from Discord: Server Settings → Integrations →
|
|
Webhooks → New Webhook → Copy Webhook URL. All three are changeable later from
|
|
Settings.
|
|
|
|
**Adding an account** offers three routes:
|
|
|
|
- **Upload** a QR code image — PNG, JPEG, WebP, GIF, BMP or TIFF. Screenshots,
|
|
photos of a screen, tiny crops and rotated images all work; the decoder walks
|
|
a ladder of rescale, contrast and rotation attempts before giving up. (HEIC
|
|
is rejected with a note to screenshot instead — iPhone screenshots are PNG.)
|
|
If the image holds several QR codes, all of them are read.
|
|
- **Camera** scanning, using the browser's native barcode detector where it
|
|
exists and falling back to server-side decoding where it doesn't, which
|
|
covers Safari and Firefox. Note that browsers only grant camera access over
|
|
HTTPS or on `localhost`.
|
|
- **Paste** an `otpauth://` link, or type a secret by hand.
|
|
|
|
### Importing from Google Authenticator
|
|
|
|
Google Authenticator's **Transfer accounts → Export accounts** produces a QR
|
|
code holding *many* accounts at once, not one — it's an
|
|
`otpauth-migration://` link wrapping a base64 protobuf. Upload or paste it and
|
|
you get a checklist of everything inside, with each entry's algorithm, digit
|
|
count and period shown, and an **Add N accounts** button.
|
|
|
|
Details worth knowing:
|
|
|
|
- **Large exports are split across several QR codes.** Google shows them as
|
|
separate screens. Each one reports its position, so the UI tells you
|
|
"Part 1 of 2" and prompts you to upload the rest. Import them in any order.
|
|
- **Re-importing is safe.** Anything already in your vault is detected and
|
|
pre-unticked, so scanning an export twice adds nothing. Duplicates are
|
|
matched on the secret *and* the naming, so deliberately keeping one secret
|
|
under two labels still works.
|
|
- **Entries that can't be imported are named, not silently dropped** — a
|
|
counter-based (HOTP) account or an MD5 one is listed with the reason.
|
|
- **Secrets never reach the browser.** The preview returns names and
|
|
parameters only; on import the client sends back the link plus the indices
|
|
it selected, and the server re-parses it.
|
|
|
|
Two traps this format sets, both of which silently produce *plausible but
|
|
wrong* codes if you get them backwards: the protobuf `secret` field is **raw
|
|
bytes**, not base32, and the payload carries **no period field** — every Google
|
|
export is 30-second TOTP.
|
|
|
|
The parser is deliberately forgiving of real-world URIs: it accepts lowercase
|
|
and `=`-padded secrets, and where the issuer in the path disagrees with the
|
|
`?issuer=` parameter — which Microsoft does — it takes the query parameter
|
|
rather than refusing the account.
|
|
|
|
**In the list**, click any code to copy it. `/` focuses the search box.
|
|
|
|
The pencil on a row opens **Edit account**. The *Name* is what shows in the
|
|
list and on the Discord tile; the *Account* field underneath (usually the
|
|
username or email the code belongs to) is left alone unless you change it —
|
|
it's editable, since providers sometimes put something unhelpful there. The
|
|
same sheet pauses Discord push for that account, or removes it entirely.
|
|
|
|
A rename reaches Discord within a second or two. It doesn't wait for the code
|
|
to roll: the push loop normally skips any account whose current code it has
|
|
already sent, so renaming clears that marker to force one refresh.
|
|
|
|
---
|
|
|
|
## Configuration
|
|
|
|
All settings are environment variables (or CLI flags of the same name).
|
|
|
|
| Variable | Default | Meaning |
|
|
|---|---|---|
|
|
| `DA_HTTP_ADDR` | `0.0.0.0:8710` | Listen address for UI and API |
|
|
| `DA_WORK_DIR` | `/var/lib/discord-authenticator` | Vault, key, settings, admin account |
|
|
| `DA_MASTER_KEY` | *(unset)* | Base64 of 32 bytes. Overrides `vault.key`; nothing is written to disk |
|
|
| `DA_LOG` | `info,da=info` | `tracing-subscriber` filter |
|
|
|
|
Generate a master key with `openssl rand -base64 32`. Setting it *after*
|
|
secrets exist makes them unreadable — export first if you're migrating.
|
|
|
|
**Message style** (Settings → Discord) chooses what each tile contains:
|
|
|
|
- **Embed** — code as text only. One JSON edit per period, nothing uploaded.
|
|
The lightest option by a wide margin.
|
|
- **Image** — the rendered WebP tile only.
|
|
- **Both** *(default)* — tile plus copyable text.
|
|
|
|
The image styles re-upload a tile on every roll, which is roughly 2,880
|
|
attachments per account per day. Harmless, but if you run many accounts and
|
|
want the traffic minimal, Embed is the frugal choice.
|
|
|
|
---
|
|
|
|
## Architecture
|
|
|
|
```
|
|
crates/
|
|
core/ TOTP, vault, crypto, settings, admin auth — no network
|
|
(incl. Google Authenticator export parsing)
|
|
render/ SVG → WebP tile rendering (resvg + embedded fonts)
|
|
discord/ Webhook client, rate limiter, live-tile push engine
|
|
webui/ Embedded HTML/CSS/JS, compiled into the binary
|
|
http-api/ axum router, sessions, QR decoding
|
|
discord-authenticator/ The binary
|
|
```
|
|
|
|
`core` is deliberately network-free so the security-critical parts are testable
|
|
without a socket. TOTP is implemented directly over HMAC (about thirty lines)
|
|
rather than pulling a crate — the deciding factor was that real `otpauth://`
|
|
URIs need the lenient parsing described above, which the available crates
|
|
reject, so the normalisation layer would have had to be written anyway.
|
|
|
|
Everything is pure Rust: no OpenSSL, no `-sys` crates, no C. TLS roots are
|
|
compiled in via `webpki-roots`, which is why the runtime image needs no
|
|
`ca-certificates` and the binary is genuinely standalone.
|
|
|
|
The UI follows [Beautiful UI](https://www.beautifului.dev/) — OKLCH colour
|
|
ramps, hairline-ring shadows, inset fields against raised buttons, Inter and
|
|
JetBrains Mono. Light and dark both ship.
|
|
|
|
### Testing
|
|
|
|
143 tests, including:
|
|
|
|
- The **RFC 6238 test vectors** for SHA-1, SHA-256 and SHA-512.
|
|
- Real **QR round-trips** — clean, 1-pixel-per-module, oversized, rotated, JPEG,
|
|
and two codes stacked in one screenshot.
|
|
- **Google export decoding** driven from real protobuf wire bytes: multi-account
|
|
payloads, batch metadata, every algorithm and digit variant, all four base64
|
|
spellings, unknown-field forward compatibility, and truncated input. One test
|
|
asserts the raw-bytes secret handling against the RFC 6238 vector, because
|
|
that is the mistake that would otherwise ship working-looking wrong codes.
|
|
- The **live push cycle against a mock Discord**: that the first call carries
|
|
`?wait=true`, that later passes PATCH the same message rather than posting
|
|
again, that a deleted tile is reposted, that a dead webhook is surfaced
|
|
instead of hammered, that concurrent rollovers are paced apart, and that a
|
|
rename refreshes the tile immediately without re-posting it or looping.
|
|
- **Crash recovery**: that a zero-length `vault.json` is recovered from the
|
|
backup, that the damaged file is kept rather than deleted, that the recovered
|
|
accounts are written back so the next boot is ordinary, that an unusable
|
|
backup still refuses to boot rather than starting empty, and that a code
|
|
rotation does not rewrite the file holding the secrets.
|
|
|
|
```bash
|
|
cargo test --workspace
|
|
cargo clippy --workspace --all-targets
|
|
```
|
|
|
|
---
|
|
|
|
## Troubleshooting
|
|
|
|
### The container won't start: "could not load the vault"
|
|
|
|
The app refuses to start on an unreadable `vault.json` because the alternative
|
|
— booting with an empty vault — invites you to re-add accounts you already have.
|
|
|
|
Since v0.1.3 it recovers on its own first: if `vault.json.bak` parses, the
|
|
accounts are loaded from it, the damaged file is moved to
|
|
`vault.json.corrupt-<timestamp>`, and a fresh `vault.json` is written. You will
|
|
see one `ERROR` line saying so, then normal startup.
|
|
|
|
If it still refuses, both files are unreadable and there is nothing to recover
|
|
from automatically. Get the file out of the way and restart:
|
|
|
|
```bash
|
|
cd /mnt/user/appdata/discord-authenticator
|
|
mv vault.json "vault.json.broken-$(date +%s)"
|
|
docker restart Discord-Authenticator
|
|
```
|
|
|
|
The service will start with an empty vault, so re-import your accounts from
|
|
your own backups (`otpauth://` links or a Google Authenticator export). Keep
|
|
`vault.key` — without it, nothing in the old file can be decrypted even if the
|
|
bytes are recovered later.
|
|
|
|
**If you hit this, upgrading matters more than the workaround.** The cause was
|
|
a rename being journalled ahead of the data it published, which is fixed by
|
|
fsyncing before the rename; see `crates/core/src/store_io.rs` for the ordering
|
|
and why it is the whole trick.
|
|
|
|
---
|
|
|
|
## Development
|
|
|
|
```bash
|
|
cargo run -p discord-authenticator -- --work-dir ./data/work --http-addr 127.0.0.1:8710
|
|
```
|
|
|
|
Preview the Discord tile without a webhook:
|
|
|
|
```bash
|
|
cargo run -p da-render --example preview -- /tmp
|
|
```
|
|
|
|
## Licence
|
|
|
|
MIT OR Apache-2.0. JetBrains Mono is bundled under the SIL Open Font License
|
|
1.1 (`crates/render/assets/JetBrainsMono-OFL.txt`).
|