Build and release image / build (push) Successful in 1m9s
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.
449 lines
20 KiB
Markdown
449 lines
20 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
|
|
|
|
Pushing a version tag builds, smoke-tests, pushes and releases — see
|
|
[Automation](#automation) below. To do it by hand instead:
|
|
|
|
```bash
|
|
GITEA_TOKEN=<token with write:package> ./scripts/build-and-publish-unraid.sh
|
|
```
|
|
|
|
### Automation
|
|
|
|
Two workflows under `.gitea/workflows/`, both needing a repository secret named
|
|
`REGISTRY_TOKEN` with the `write:package` scope (Settings → Actions → Secrets).
|
|
Gitea reserves the `GITEA_` prefix for secret names and rejects it, which is why
|
|
the secret is not called `GITEA_TOKEN`.
|
|
|
|
**`release.yml`** runs when a `vX.Y.Z` tag is pushed. It builds the image,
|
|
starts it on a fresh root-owned volume, waits for `/healthz`, checks the binary
|
|
reports the tagged version, pushes to the registry, and only then creates the
|
|
release. The order matters: a release announcing an image that isn't in the
|
|
registry yet is worse than no release, and a broken build must not overwrite a
|
|
tag that already works. The workflow also asserts the tag and `Cargo.toml`
|
|
agree, which is what catches a tag pushed without a version bump.
|
|
|
|
Release notes live in `docs/releases/<version>.md` and are carried into the
|
|
release through the tag annotation, which is the one thing the runner provably
|
|
receives — Gitea's shallow checkout has intermittently omitted files from the
|
|
worktree. So tag with `--cleanup=verbatim`, which stops git treating every `#`
|
|
line in the notes as a comment:
|
|
|
|
```bash
|
|
# cut a release
|
|
sed -i 's/^version = "0.1.3"/version = "0.1.4"/' Cargo.toml # and Cargo.lock
|
|
git commit -am "v0.1.4: ..."
|
|
NOTES=docs/releases/v0.1.4.md
|
|
{ echo "v0.1.4: one-line summary"; echo; cat "$NOTES"; } > /tmp/tagmsg
|
|
git tag -a v0.1.4 -F /tmp/tagmsg --cleanup=verbatim
|
|
git push origin main v0.1.4
|
|
```
|
|
|
|
**`dependency-check.yml`** runs every six hours and reports drifted crates, the
|
|
Rust pin and the Docker base images in a single issue titled "Dependency
|
|
updates" — rewritten in place rather than commented on, and closed
|
|
automatically when nothing is out of date. `.gitea/scripts/dependency-report.mjs`
|
|
does the work and is shared verbatim with the sibling Rust repos.
|
|
|
|
Nothing is upgraded automatically: the report separates what `cargo update`
|
|
covers from what crosses a breaking boundary, and leaves the decision to a
|
|
human. A service holding someone's second factor is the wrong place for
|
|
unreviewed dependency bumps.
|
|
|
|
---
|
|
|
|
## 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, the message names exactly which situation you are in,
|
|
because they need different responses:
|
|
|
|
- **"there is no backup at …"** — no `.bak` was ever created, which means this
|
|
vault has not completed a single write since backups were introduced. Nothing
|
|
on disk is recoverable; the unreadable file is all there is.
|
|
- **"exists but could not be parsed either"** — both copies are gone.
|
|
- **"is empty as well"** — both are zero bytes.
|
|
|
|
Either way, the service will not boot with an empty vault on its own, because
|
|
that looks identical to a vault you emptied on purpose. Two ways forward:
|
|
|
|
```bash
|
|
# 1. by hand
|
|
cd /mnt/user/appdata/discord-authenticator
|
|
mv vault.json "vault.json.broken-$(date +%s)"
|
|
docker restart Discord-Authenticator
|
|
```
|
|
|
|
Or set `DA_VAULT_RESET=1` in the Unraid template (Docker → the container → Add
|
|
another Path, Variable, Port or Label → Variable, Key `DA_VAULT_RESET`, Value
|
|
`1`) and restart. The service boots empty and moves the unreadable file to
|
|
`vault.json.corrupt-<timestamp>` before writing a fresh vault, so nothing is
|
|
overwritten. **Remove the variable afterwards** — it only has an effect on the
|
|
failure path, but leaving it set means the next genuinely-broken vault boots
|
|
empty instead of telling you about it.
|
|
|
|
Then re-import your accounts from your own backups (`otpauth://` links or a
|
|
Google Authenticator export). Keep `vault.key` — without it, nothing in an old
|
|
vault file can be decrypted even if the bytes turn up 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`).
|