v0.1: self-hosted TOTP authenticator with live Discord delivery

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]>
This commit is contained in:
Miles WardandClaude Opus 4.8 committed 2026-08-28 16:06:08 -04:00
commit f33effe20a
46 files changed
+11160

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
# Keep the Docker build context small. The multi-stage Dockerfile COPYs
# only what it needs.
target/
**/target/
# Runtime state (vault + keys live here — must never enter an image layer).
data/
/tmp/
# VCS + editor noise.
.git/
.github/
.idea/
.vscode/
.DS_Store
*.swp
# Claude / local tooling state.
.claude/
MEMORY.md
# Docs & meta.
docs/
*.md
!README.md
LICENSE*
+15
View File
@@ -0,0 +1,15 @@
/target
.DS_Store
*.swp
# Runtime state. These only ever live in the work directory, so the patterns
# are anchored there rather than matching any file with those names anywhere
# in the tree.
/data/
**/vault.key
**/vault.json
**/auth.json
**/settings.json.tmp
# Per-user Claude Code state.
.claude/
Generated
+2789
View File
File diff suppressed because it is too large. Load diff
+115
View File
@@ -0,0 +1,115 @@
[workspace]
resolver = "2"
members = [
"crates/core",
"crates/render",
"crates/discord",
"crates/webui",
"crates/http-api",
"crates/discord-authenticator",
]
[workspace.package]
version = "0.1.0"
edition = "2021"
rust-version = "1.90"
license = "MIT OR Apache-2.0"
repository = "https://gitea.milesward.dev/mward4/Discord-Authenticator"
authors = ["Discord Authenticator contributors"]
[workspace.dependencies]
tokio = { version = "1", features = ["full"] }
axum = { version = "0.8", features = ["macros", "multipart"] }
tower-http = { version = "0.6", features = ["trace", "limit"] }
# rustls, never native-tls: an OpenSSL dependency would break the static
# musl build. `multipart` carries the rendered tile up to Discord.
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "multipart"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
anyhow = "1"
thiserror = "2"
clap = { version = "4", features = ["derive", "env"] }
uuid = { version = "1", features = ["v4", "serde"] }
time = { version = "0.3", features = ["serde", "serde-human-readable", "formatting"] }
parking_lot = "0.12"
url = "2"
percent-encoding = "2"
base64 = "0.22"
# ── TOTP primitives ──
# The RFC 6238 core is ~30 lines over HMAC, and we implement it directly
# rather than pulling `totp-rs`. The deciding factor was otpauth:// parsing:
# real-world URIs routinely carry a path issuer that disagrees with the
# `?issuer=` query param (Microsoft does this), and secrets that are
# lowercase or `=`-padded (Google, Dropbox). totp-rs rejects all of those,
# so we'd be writing the normalisation and reconciliation layer anyway.
# Pinned to the mature 0.10/0.12 RustCrypto generation.
hmac = "0.12"
sha1 = "0.10"
sha2 = "0.10"
base32 = "0.5"
# ── Secrets at rest ──
# XChaCha20-Poly1305 over AES-GCM for the 192-bit nonce: large enough to
# generate randomly per record with no birthday-collision worry, and
# constant-time in software on every target without AES-NI.
chacha20poly1305 = "0.10"
argon2 = "0.5"
rand = "0.8"
zeroize = { version = "1", features = ["derive"] }
# ── Imaging ──
# default-features = false is REQUIRED: `image`'s default feature set
# enables `avif`, dragging in the whole rav1e encoder for a format we never
# touch. (Never enable `avif-native` — that one is C bindings to libdav1d
# and would break the static musl build outright.)
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "bmp", "webp"] }
rqrr = "0.10"
# Tile rendering. The tile is drawn as SVG and rasterised, rather than
# blitting glyphs by hand: text centring, rounded corners and gradients are
# all one attribute each in SVG and a pile of arithmetic otherwise.
#
# `default-features = false` matters here too — resvg defaults to
# `system-fonts` + `memmap-fonts`, and a static binary in a slim container has
# no system fonts to find. Text would silently render blank. We disable both
# and register the embedded faces explicitly.
resvg = { version = "0.48", default-features = false, features = ["text"] }
usvg = { version = "0.48", default-features = false, features = ["text"] }
tiny-skia = "0.12"
da-core = { path = "crates/core" }
da-render = { path = "crates/render" }
da-discord = { path = "crates/discord" }
da-webui = { path = "crates/webui" }
da-http-api = { path = "crates/http-api" }
[workspace.lints.rust]
unsafe_code = "deny"
rust_2018_idioms = { level = "warn", priority = -1 }
[workspace.lints.clippy]
pedantic = { level = "warn", priority = -1 }
module_name_repetitions = "allow"
missing_errors_doc = "allow"
missing_panics_doc = "allow"
must_use_candidate = "allow"
doc_markdown = "allow"
items_after_statements = "allow"
cast_possible_truncation = "allow"
cast_lossless = "allow"
cast_sign_loss = "allow"
cast_precision_loss = "allow"
similar_names = "allow"
too_many_lines = "allow"
[profile.release]
lto = "thin"
codegen-units = 1
strip = "symbols"
opt-level = 3
+275
View File
@@ -0,0 +1,275 @@
# Discord Authenticator
A self-hosted TOTP authenticator whose codes appear in Discord as **live
messages that rewrite themselves** as each code rolls.
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)
- [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.
- 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.
### 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.)
- **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.
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
gear on a row renames the account or pauses its Discord push; pausing removes
its tile from the channel.
---
## 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
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
101 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.
- 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, and that concurrent rollovers are paced apart.
```bash
cargo test --workspace
cargo clippy --workspace --all-targets
```
---
## 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`).
+37
View File
@@ -0,0 +1,37 @@
[package]
name = "da-core"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
authors.workspace = true
description = "Domain core for Discord Authenticator: TOTP, vault, settings, auth."
[dependencies]
serde.workspace = true
serde_json.workspace = true
time.workspace = true
parking_lot.workspace = true
thiserror.workspace = true
tracing.workspace = true
uuid.workspace = true
url.workspace = true
percent-encoding.workspace = true
base64.workspace = true
hmac.workspace = true
sha1.workspace = true
sha2.workspace = true
base32.workspace = true
chacha20poly1305.workspace = true
argon2.workspace = true
rand.workspace = true
zeroize.workspace = true
[dev-dependencies]
tempfile = "3"
[lints]
workspace = true
+347
View File
@@ -0,0 +1,347 @@
//! Single-admin authentication, in the Sonarr/Radarr mould.
//!
//! On a fresh install no admin exists and the UI shows a first-run form. Once
//! created, those credentials gate `/api/*`, and the admin can rotate the
//! username and password from Settings.
//!
//! Multi-user RBAC is not a goal — this is one person's vault of their own
//! second factors, so one record is enough.
//!
//! Unlike the vault, a corrupt `auth.json` falls back to "no admin
//! configured" rather than refusing to start: that puts the operator back
//! into the setup flow instead of locking them out of their own box. The
//! vault stays encrypted either way, so this is not a bypass — a fresh admin
//! still can't read secrets without the vault key.
use crate::{Error, Result};
use argon2::password_hash::{
rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString,
};
use argon2::Argon2;
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use time::OffsetDateTime;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdminAccount {
pub username: String,
/// Argon2id PHC string. The plaintext never outlives the request that set it.
pub password_hash: String,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: OffsetDateTime,
}
/// Public projection — no hash. Safe to send to the browser.
#[derive(Debug, Clone, Serialize)]
pub struct AdminPublic {
pub username: String,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: OffsetDateTime,
}
impl From<&AdminAccount> for AdminPublic {
fn from(a: &AdminAccount) -> Self {
Self {
username: a.username.clone(),
created_at: a.created_at,
updated_at: a.updated_at,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct Inner {
admin: Option<AdminAccount>,
}
/// In-memory + on-disk admin registry. Cheap to clone.
///
/// Every method here that hashes or verifies runs Argon2id, which is
/// deliberately expensive (tens of milliseconds). Callers on an async runtime
/// should invoke these from `spawn_blocking`.
#[derive(Debug, Clone)]
pub struct AdminStore {
path: Arc<PathBuf>,
inner: Arc<RwLock<Inner>>,
}
impl AdminStore {
pub fn load_or_default(work_dir: &std::path::Path) -> Self {
let path = work_dir.join("auth.json");
let inner = match std::fs::read_to_string(&path) {
Ok(text) => serde_json::from_str::<Inner>(&text).unwrap_or_else(|e| {
tracing::warn!(
target: "da::auth",
"auth.json present but unreadable ({e}); starting in setup mode"
);
Inner::default()
}),
Err(_) => Inner::default(),
};
Self {
path: Arc::new(path),
inner: Arc::new(RwLock::new(inner)),
}
}
/// Has an admin been created? Drives the first-run / login fork.
pub fn is_configured(&self) -> bool {
self.inner.read().admin.is_some()
}
pub fn snapshot(&self) -> Option<AdminPublic> {
self.inner.read().admin.as_ref().map(AdminPublic::from)
}
/// First-run setup. Refuses to run twice, so an exposed instance can't be
/// re-bootstrapped by whoever finds the URL.
pub fn bootstrap(&self, username: &str, password: &str) -> Result<AdminPublic> {
validate_username(username)?;
validate_password(password)?;
let now = OffsetDateTime::now_utc();
let admin = AdminAccount {
username: username.trim().to_string(),
password_hash: hash_password(password)?,
created_at: now,
updated_at: now,
};
{
let mut g = self.inner.write();
if g.admin.is_some() {
return Err(Error::conflict("an admin account already exists"));
}
g.admin = Some(admin.clone());
}
self.persist();
tracing::info!(target: "da::auth", username = %admin.username, "admin account created");
Ok((&admin).into())
}
/// Verify credentials. `Ok(None)` means "wrong", distinct from `Err`,
/// which means the check itself could not be performed.
pub fn verify(&self, username: &str, password: &str) -> Result<Option<AdminPublic>> {
let Some(admin) = self.inner.read().admin.clone() else {
return Ok(None);
};
if username.trim() != admin.username {
return Ok(None);
}
let parsed = PasswordHash::new(&admin.password_hash)
.map_err(|e| Error::crypto(format!("stored password hash is unreadable: {e}")))?;
match Argon2::default().verify_password(password.as_bytes(), &parsed) {
Ok(()) => Ok(Some((&admin).into())),
Err(_) => Ok(None),
}
}
/// Rotate username and/or password. `current_password` must match the
/// existing hash — proof that the person at the keyboard is the owner,
/// not just someone holding a stolen session cookie.
pub fn update_credentials(
&self,
current_password: &str,
new_username: Option<&str>,
new_password: Option<&str>,
) -> Result<AdminPublic> {
let existing = self
.inner
.read()
.admin
.clone()
.ok_or_else(|| Error::conflict("no admin account exists yet"))?;
let parsed = PasswordHash::new(&existing.password_hash)
.map_err(|e| Error::crypto(format!("stored password hash is unreadable: {e}")))?;
if Argon2::default()
.verify_password(current_password.as_bytes(), &parsed)
.is_err()
{
return Err(Error::invalid("current password is incorrect"));
}
let mut updated = existing;
if let Some(u) = new_username.map(str::trim).filter(|s| !s.is_empty()) {
validate_username(u)?;
updated.username = u.to_string();
}
if let Some(p) = new_password.filter(|s| !s.is_empty()) {
validate_password(p)?;
updated.password_hash = hash_password(p)?;
}
updated.updated_at = OffsetDateTime::now_utc();
self.inner.write().admin = Some(updated.clone());
self.persist();
tracing::info!(target: "da::auth", username = %updated.username, "admin credentials updated");
Ok((&updated).into())
}
fn persist(&self) {
let snap = self.inner.read().clone();
let body = match serde_json::to_vec_pretty(&snap) {
Ok(b) => b,
Err(e) => {
tracing::error!(target: "da::auth", "could not serialize auth.json: {e}");
return;
}
};
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let tmp = self.path.with_extension("json.tmp");
if std::fs::write(&tmp, body).is_ok() {
if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) {
tracing::error!(target: "da::auth", "could not commit auth.json: {e}");
}
}
}
}
fn hash_password(password: &str) -> Result<String> {
let salt = SaltString::generate(&mut OsRng);
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map(|h| h.to_string())
.map_err(|e| Error::crypto(format!("could not hash password: {e}")))
}
fn validate_username(u: &str) -> Result<()> {
let u = u.trim();
if u.is_empty() {
return Err(Error::invalid("username must not be empty"));
}
if u.len() > 64 {
return Err(Error::invalid("username must be 64 characters or fewer"));
}
if !u.chars().all(|c| c.is_ascii_graphic()) {
return Err(Error::invalid(
"username must use printable ASCII with no spaces",
));
}
Ok(())
}
fn validate_password(p: &str) -> Result<()> {
if p.chars().count() < 8 {
return Err(Error::invalid("password must be at least 8 characters"));
}
if p.len() > 256 {
return Err(Error::invalid("password must be 256 bytes or fewer"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn starts_unconfigured() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
assert!(!s.is_configured());
assert!(s.snapshot().is_none());
}
#[test]
fn bootstrap_then_verify() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
let p = s.bootstrap("admin", "hunter2hunter2").unwrap();
assert_eq!(p.username, "admin");
assert!(s.verify("admin", "hunter2hunter2").unwrap().is_some());
assert!(s.verify("admin", "wrong").unwrap().is_none());
assert!(s.verify("nobody", "hunter2hunter2").unwrap().is_none());
}
#[test]
fn password_is_never_stored_in_the_clear() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
s.bootstrap("admin", "hunter2hunter2").unwrap();
let raw = std::fs::read_to_string(dir.path().join("auth.json")).unwrap();
assert!(!raw.contains("hunter2hunter2"));
assert!(
raw.contains("$argon2id$"),
"expected an Argon2id PHC string"
);
}
#[test]
fn bootstrap_refuses_a_second_run() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
s.bootstrap("admin", "hunter2hunter2").unwrap();
assert!(matches!(
s.bootstrap("other", "anotherpass1"),
Err(Error::Conflict(_))
));
}
#[test]
fn survives_a_reload() {
let dir = tempdir().unwrap();
AdminStore::load_or_default(dir.path())
.bootstrap("admin", "hunter2hunter2")
.unwrap();
let s2 = AdminStore::load_or_default(dir.path());
assert!(s2.is_configured());
assert!(s2.verify("admin", "hunter2hunter2").unwrap().is_some());
}
#[test]
fn rotation_requires_the_current_password() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
s.bootstrap("admin", "hunter2hunter2").unwrap();
assert!(s
.update_credentials("nope", None, Some("newpassword1"))
.is_err());
assert!(s.verify("admin", "hunter2hunter2").unwrap().is_some());
s.update_credentials("hunter2hunter2", Some("alice"), Some("newpassword1"))
.unwrap();
assert!(s.verify("admin", "hunter2hunter2").unwrap().is_none());
assert!(s.verify("alice", "newpassword1").unwrap().is_some());
}
#[test]
fn rotation_can_change_just_one_field() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
s.bootstrap("admin", "hunter2hunter2").unwrap();
// Password only — username sticks.
s.update_credentials("hunter2hunter2", None, Some("newpassword1"))
.unwrap();
assert!(s.verify("admin", "newpassword1").unwrap().is_some());
// Username only — password sticks. Empty strings mean "unchanged".
s.update_credentials("newpassword1", Some("bob"), Some(""))
.unwrap();
assert!(s.verify("bob", "newpassword1").unwrap().is_some());
}
#[test]
fn input_validation() {
let dir = tempdir().unwrap();
let s = AdminStore::load_or_default(dir.path());
assert!(s.bootstrap("", "hunter2hunter2").is_err());
assert!(s.bootstrap("has space", "hunter2hunter2").is_err());
assert!(s.bootstrap("admin", "short").is_err());
assert!(s.bootstrap(&"a".repeat(65), "hunter2hunter2").is_err());
}
#[test]
fn corrupt_auth_file_falls_back_to_setup_mode() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("auth.json"), b"{ not json").unwrap();
assert!(!AdminStore::load_or_default(dir.path()).is_configured());
}
}
+256
View File
@@ -0,0 +1,256 @@
//! Envelope encryption for the TOTP secrets on disk.
//!
//! **Threat model, stated plainly.** This app must mint codes unattended,
//! around the clock, to keep the Discord tiles live. That rules out deriving
//! the vault key from the admin password: nobody is there to type it after a
//! container restart. So the key necessarily sits somewhere the process can
//! read at boot.
//!
//! What that buys you, and what it doesn't:
//!
//! * **Protects against** a leaked or backed-up `vault.json` — the ciphertext
//! alone is useless. That is the realistic homelab accident: an appdata
//! share browsed over SMB, a snapshot copied somewhere careless, a bind
//! mount that ends up in a backup tarball.
//! * **Does not protect against** an attacker who can read the whole work
//! directory, since `vault.key` lives there by default. For that case set
//! `DA_MASTER_KEY` and keep the key outside the mounted volume entirely.
//!
//! [`VaultKey::load_or_create`] documents the precedence.
use crate::{Error, Result};
use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng};
use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
use serde::{Deserialize, Serialize};
use std::path::Path;
use zeroize::Zeroize;
/// Filename of the generated key inside the work directory.
pub const KEY_FILE: &str = "vault.key";
/// Environment variable holding a base64 32-byte key. Takes precedence over
/// the key file so operators can keep the key off the data volume.
pub const KEY_ENV: &str = "DA_MASTER_KEY";
/// One encrypted value. Stored inline in `vault.json`.
///
/// XChaCha20-Poly1305 rather than AES-GCM specifically for the nonce width:
/// 192 bits is wide enough to pick at random for every write without any
/// birthday-collision concern, which is exactly the access pattern here.
/// It is also constant-time in software on hosts without AES-NI.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Sealed {
/// base64 (standard alphabet) 24-byte nonce.
pub nonce: String,
/// base64 ciphertext with the Poly1305 tag appended.
pub ciphertext: String,
}
#[derive(Clone)]
pub struct VaultKey {
key: Key,
}
impl std::fmt::Debug for VaultKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// Never let the key reach a log line via a derived Debug.
f.write_str("VaultKey(<redacted>)")
}
}
impl VaultKey {
/// Resolve the vault key, in precedence order:
///
/// 1. `$DA_MASTER_KEY` — base64 of exactly 32 bytes. Nothing is written
/// to disk in this mode.
/// 2. `<work_dir>/vault.key` — read if present.
/// 3. Otherwise a fresh random key is generated and written there with
/// mode 0600.
pub fn load_or_create(work_dir: &Path) -> Result<Self> {
if let Ok(encoded) = std::env::var(KEY_ENV) {
let encoded = encoded.trim();
if !encoded.is_empty() {
let mut raw = b64_decode(encoded)
.map_err(|_| Error::crypto(format!("{KEY_ENV} is not valid base64")))?;
if raw.len() != 32 {
raw.zeroize();
return Err(Error::crypto(format!(
"{KEY_ENV} must decode to exactly 32 bytes"
)));
}
let key = *Key::from_slice(&raw);
raw.zeroize();
tracing::info!(target: "da::crypto", "vault key loaded from {KEY_ENV}");
return Ok(Self { key });
}
}
let path = work_dir.join(KEY_FILE);
if path.exists() {
let mut raw = std::fs::read(&path)?;
if raw.len() != 32 {
raw.zeroize();
return Err(Error::crypto(format!(
"{} is {} bytes, expected 32 — refusing to start rather than \
generate a new key and orphan the existing vault",
path.display(),
raw.len()
)));
}
let key = *Key::from_slice(&raw);
raw.zeroize();
return Ok(Self { key });
}
// Fresh install: mint a key and persist it before anything is sealed.
let key = XChaCha20Poly1305::generate_key(&mut OsRng);
std::fs::create_dir_all(work_dir)?;
write_private(&path, key.as_slice())?;
tracing::info!(
target: "da::crypto",
path = %path.display(),
"generated a new vault key"
);
Ok(Self { key })
}
/// Build a key directly from 32 raw bytes. Test and migration helper.
pub fn from_bytes(raw: &[u8]) -> Result<Self> {
if raw.len() != 32 {
return Err(Error::crypto("vault key must be exactly 32 bytes"));
}
Ok(Self {
key: *Key::from_slice(raw),
})
}
pub fn seal(&self, plaintext: &[u8]) -> Result<Sealed> {
let cipher = XChaCha20Poly1305::new(&self.key);
let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
let ciphertext = cipher
.encrypt(&nonce, plaintext)
.map_err(|_| Error::crypto("could not encrypt secret"))?;
Ok(Sealed {
nonce: b64_encode(&nonce),
ciphertext: b64_encode(&ciphertext),
})
}
pub fn open(&self, sealed: &Sealed) -> Result<Vec<u8>> {
let nonce = b64_decode(&sealed.nonce)
.map_err(|_| Error::crypto("stored nonce is not valid base64"))?;
if nonce.len() != 24 {
return Err(Error::crypto("stored nonce has the wrong length"));
}
let ciphertext = b64_decode(&sealed.ciphertext)
.map_err(|_| Error::crypto("stored ciphertext is not valid base64"))?;
let cipher = XChaCha20Poly1305::new(&self.key);
cipher
.decrypt(XNonce::from_slice(&nonce), ciphertext.as_ref())
.map_err(|_| {
Error::crypto("could not decrypt secret — the vault key does not match this vault")
})
}
}
/// Write `bytes` to `path` readable only by the owner.
///
/// The mode is set at creation time on Unix rather than chmod-ed afterwards,
/// so the key is never briefly world-readable.
fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
use std::io::Write;
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut f = opts.open(path)?;
f.write_all(bytes)?;
f.sync_all()?;
Ok(())
}
fn b64_encode(bytes: &[u8]) -> String {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(bytes)
}
fn b64_decode(s: &str) -> std::result::Result<Vec<u8>, base64::DecodeError> {
use base64::Engine;
base64::engine::general_purpose::STANDARD.decode(s.trim())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn seal_open_round_trip() {
let k = VaultKey::from_bytes(&[7u8; 32]).unwrap();
let sealed = k.seal(b"super secret bytes").unwrap();
assert_eq!(k.open(&sealed).unwrap(), b"super secret bytes");
}
#[test]
fn each_seal_uses_a_fresh_nonce() {
let k = VaultKey::from_bytes(&[7u8; 32]).unwrap();
let a = k.seal(b"same plaintext").unwrap();
let b = k.seal(b"same plaintext").unwrap();
// Identical input must not produce identical ciphertext, or the
// vault would leak which accounts share a secret.
assert_ne!(a.nonce, b.nonce);
assert_ne!(a.ciphertext, b.ciphertext);
}
#[test]
fn a_different_key_cannot_open_it() {
let a = VaultKey::from_bytes(&[1u8; 32]).unwrap();
let b = VaultKey::from_bytes(&[2u8; 32]).unwrap();
let sealed = a.seal(b"secret").unwrap();
assert!(b.open(&sealed).is_err());
}
#[test]
fn tampered_ciphertext_is_rejected() {
let k = VaultKey::from_bytes(&[9u8; 32]).unwrap();
let mut sealed = k.seal(b"secret").unwrap();
// Flip a byte: Poly1305 must catch it rather than return garbage.
let mut raw = b64_decode(&sealed.ciphertext).unwrap();
raw[0] ^= 0xff;
sealed.ciphertext = b64_encode(&raw);
assert!(k.open(&sealed).is_err());
}
#[test]
fn key_file_is_created_once_and_reused() {
let dir = tempdir().unwrap();
let k1 = VaultKey::load_or_create(dir.path()).unwrap();
let sealed = k1.seal(b"persisted").unwrap();
// A second load must reuse the same key, or every restart would
// orphan the vault.
let k2 = VaultKey::load_or_create(dir.path()).unwrap();
assert_eq!(k2.open(&sealed).unwrap(), b"persisted");
}
#[cfg(unix)]
#[test]
fn key_file_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
let _ = VaultKey::load_or_create(dir.path()).unwrap();
let meta = std::fs::metadata(dir.path().join(KEY_FILE)).unwrap();
assert_eq!(meta.permissions().mode() & 0o777, 0o600);
}
#[test]
fn refuses_a_truncated_key_file() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join(KEY_FILE), b"too short").unwrap();
// Silently generating a fresh key here would strand every stored
// secret, so this must be a hard error.
assert!(VaultKey::load_or_create(dir.path()).is_err());
}
}
+64
View File
@@ -0,0 +1,64 @@
//! One error type for the whole domain core.
use std::fmt;
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// Caller supplied something we can't accept. Surfaces as HTTP 400 and
/// the message is written to be read by a human in a toast.
#[error("{0}")]
Invalid(String),
/// Asked for something that isn't there. Surfaces as HTTP 404.
#[error("{0} not found")]
NotFound(String),
/// Refused because it would clobber existing state. Surfaces as 409.
#[error("{0}")]
Conflict(String),
#[error("i/o error: {0}")]
Io(#[from] std::io::Error),
#[error("serialization error: {0}")]
Serde(#[from] serde_json::Error),
/// Vault could not be sealed or unsealed. Deliberately vague — the
/// detail goes to the log, not to the caller.
#[error("cryptography error: {0}")]
Crypto(String),
#[error(transparent)]
Other(#[from] anyhow_lite::Any),
}
impl Error {
pub fn invalid(msg: impl fmt::Display) -> Self {
Self::Invalid(msg.to_string())
}
pub fn not_found(what: impl fmt::Display) -> Self {
Self::NotFound(what.to_string())
}
pub fn conflict(msg: impl fmt::Display) -> Self {
Self::Conflict(msg.to_string())
}
pub fn crypto(msg: impl fmt::Display) -> Self {
Self::Crypto(msg.to_string())
}
}
/// A minimal boxed-error shim so `core` doesn't need the `anyhow` crate
/// just to carry the occasional opaque third-party error.
pub mod anyhow_lite {
#[derive(Debug, thiserror::Error)]
#[error("{0}")]
pub struct Any(pub String);
impl Any {
pub fn new(e: impl std::fmt::Display) -> Self {
Self(e.to_string())
}
}
}
+43
View File
@@ -0,0 +1,43 @@
//! Domain core for Discord Authenticator.
//!
//! Deliberately free of network I/O — this crate owns the TOTP algorithm,
//! the encrypted vault, admin credentials and settings, and nothing else.
//! Talking to Discord lives in `da-discord`, drawing tiles in `da-render`,
//! and HTTP in `da-http-api`, so the security-critical parts here can be
//! tested without a socket in sight.
#![forbid(unsafe_code)]
pub mod auth;
pub mod crypto;
pub mod error;
pub mod settings;
pub mod totp;
pub mod vault;
pub use auth::{AdminAccount, AdminPublic, AdminStore};
pub use crypto::{Sealed, VaultKey};
pub use error::{Error, Result};
pub use settings::{validate_webhook_url, PushMode, Settings, SettingsStore};
pub use totp::{decode_secret, encode_secret, parse_otpauth, Algorithm, OtpAuth, Totp};
pub use vault::{Account, AccountPublic, DiscordState, PushTarget, Vault};
/// Seconds since the Unix epoch.
///
/// TOTP is defined against wall-clock time, so this is one of the few places
/// a monotonic clock is the wrong tool: if the operator's clock is corrected,
/// the codes must follow it.
pub fn unix_now() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}
/// Milliseconds since the Unix epoch — sent to the browser so its countdown
/// tracks the server's clock rather than the viewing device's.
pub fn unix_now_millis() -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as u64)
}
+330
View File
@@ -0,0 +1,330 @@
//! Runtime-mutable settings: the Discord webhook and how tiles are rendered.
use crate::{Error, Result};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::Arc;
const MAX_URL_LEN: usize = 2048;
/// How each account's live message is composed.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum PushMode {
/// An embed whose body carries the code as text. Cheapest by far: one
/// JSON edit per period, nothing uploaded, and the digits stay
/// selectable so they can actually be copied on desktop and mobile.
Embed,
/// A rendered WebP tile, uploaded fresh on every edit.
Image,
/// The tile *and* the code as text. The default: you get the visual and
/// the code stays copyable.
#[default]
Both,
}
impl PushMode {
/// Does this mode upload an image? Drives the multipart path and, with
/// it, roughly 2,880 attachment uploads per account per day.
pub fn has_image(self) -> bool {
matches!(self, Self::Image | Self::Both)
}
/// Does the message carry the code as readable text?
pub fn has_text(self) -> bool {
matches!(self, Self::Embed | Self::Both)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Settings {
/// Master switch for Discord delivery. Off, nothing is pushed and no
/// tiles are maintained.
pub push_enabled: bool,
/// Discord incoming-webhook URL.
pub webhook_url: String,
pub push_mode: PushMode,
}
impl Default for Settings {
fn default() -> Self {
Self {
push_enabled: true,
webhook_url: String::new(),
push_mode: PushMode::default(),
}
}
}
impl Settings {
/// True when delivery is on and we have somewhere to deliver to.
pub fn is_usable(&self) -> bool {
self.push_enabled && !self.webhook_url.trim().is_empty()
}
}
/// Validate a Discord incoming-webhook URL.
///
/// Checked strictly on purpose. A webhook URL that is merely *syntactically*
/// a URL will fail later inside a background loop, where the only evidence is
/// a log line; catching the shape at the point of entry means the person who
/// pasted it finds out immediately, while they still have the right page open.
pub fn validate_webhook_url(url: &str) -> Result<()> {
let url = url.trim();
if url.is_empty() {
return Err(Error::invalid("the webhook URL is empty"));
}
if url.len() > MAX_URL_LEN {
return Err(Error::invalid(format!(
"the webhook URL exceeds the {MAX_URL_LEN}-character cap"
)));
}
let parsed = url::Url::parse(url).map_err(|_| Error::invalid("that is not a valid URL"))?;
if parsed.scheme() != "https" {
return Err(Error::invalid("the webhook URL must start with https://"));
}
let host = parsed.host_str().unwrap_or_default().to_ascii_lowercase();
let host_ok = matches!(
host.as_str(),
"discord.com" | "discordapp.com" | "ptb.discord.com" | "canary.discord.com"
) || host.ends_with(".discord.com");
if !host_ok {
return Err(Error::invalid(
"that does not look like a Discord webhook URL (expected a discord.com address)",
));
}
// Path is /api/webhooks/{id}/{token}, sometimes with an /api/v10 prefix.
let segments: Vec<_> = parsed
.path_segments()
.map(|s| s.filter(|p| !p.is_empty()).collect())
.unwrap_or_default();
let idx = segments.iter().position(|s| *s == "webhooks");
match idx {
Some(i) if segments.len() >= i + 3 => Ok(()),
_ => Err(Error::invalid(
"that Discord URL is missing the webhook id and token — copy the full \
URL from Server Settings → Integrations → Webhooks",
)),
}
}
#[derive(Debug)]
pub struct SettingsStore {
path: PathBuf,
inner: RwLock<Settings>,
}
impl SettingsStore {
/// Load from `<work_dir>/settings.json`, or start with defaults. Never
/// fails: a bad settings file is not a reason to refuse to boot.
pub fn load_or_default(work_dir: &Path) -> Arc<Self> {
let path = work_dir.join("settings.json");
let initial = match std::fs::read_to_string(&path) {
Ok(text) => serde_json::from_str::<Settings>(&text).unwrap_or_else(|e| {
tracing::warn!(
target: "da::settings",
"settings.json present but unreadable ({e}); using defaults"
);
Settings::default()
}),
Err(_) => Settings::default(),
};
Arc::new(Self {
path,
inner: RwLock::new(initial),
})
}
pub fn snapshot(&self) -> Settings {
self.inner.read().clone()
}
/// Replace settings wholesale.
///
/// Returns `true` when the webhook URL changed, which the caller must
/// treat as "every stored message id is now meaningless" — those
/// snowflakes belong to the old channel.
pub fn replace(&self, mut incoming: Settings) -> Result<(Settings, bool)> {
incoming.webhook_url = incoming.webhook_url.trim().to_string();
if !incoming.webhook_url.is_empty() {
validate_webhook_url(&incoming.webhook_url)?;
} else if incoming.push_enabled {
return Err(Error::invalid(
"a webhook URL is required to push codes to Discord",
));
}
let changed = {
let mut g = self.inner.write();
let changed = g.webhook_url != incoming.webhook_url;
*g = incoming.clone();
changed
};
self.persist();
tracing::info!(
target: "da::settings",
enabled = incoming.push_enabled,
mode = ?incoming.push_mode,
webhook_changed = changed,
"settings updated"
);
Ok((incoming, changed))
}
fn persist(&self) {
let snap = self.snapshot();
let body = match serde_json::to_vec_pretty(&snap) {
Ok(b) => b,
Err(e) => {
tracing::error!(target: "da::settings", "could not serialize settings: {e}");
return;
}
};
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let tmp = self.path.with_extension("json.tmp");
if std::fs::write(&tmp, body).is_ok() {
if let Err(e) = std::fs::rename(&tmp, &self.path) {
tracing::error!(target: "da::settings", "could not commit settings: {e}");
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
const GOOD: &str = "https://discord.com/api/webhooks/123456789012345678/abcdefTOKEN-_123";
#[test]
fn defaults_are_sane() {
let dir = tempdir().unwrap();
let s = SettingsStore::load_or_default(dir.path());
let v = s.snapshot();
assert_eq!(v.push_mode, PushMode::Both);
assert!(!v.is_usable(), "no webhook yet, so nothing to push to");
}
#[test]
fn accepts_real_webhook_shapes() {
for url in [
GOOD,
"https://discord.com/api/v10/webhooks/123/tok",
"https://discordapp.com/api/webhooks/123/tok",
"https://canary.discord.com/api/webhooks/123/tok",
] {
assert!(validate_webhook_url(url).is_ok(), "should accept {url}");
}
}
#[test]
fn rejects_the_ways_people_get_it_wrong() {
for (url, why) in [
("", "empty"),
("not a url", "not a url"),
("http://discord.com/api/webhooks/1/t", "plain http"),
("https://evil.example.com/api/webhooks/1/t", "wrong host"),
// Host ends in discord.com but is a lookalike registrable domain.
("https://notdiscord.com/api/webhooks/1/t", "lookalike host"),
("https://discord.com/api/webhooks/123", "missing token"),
("https://discord.com/channels/1/2", "not a webhook path"),
] {
assert!(
validate_webhook_url(url).is_err(),
"should reject {why}: {url}"
);
}
}
#[test]
fn enabling_without_a_url_is_refused() {
let dir = tempdir().unwrap();
let s = SettingsStore::load_or_default(dir.path());
assert!(s
.replace(Settings {
push_enabled: true,
webhook_url: String::new(),
push_mode: PushMode::Both,
})
.is_err());
// Disabled with no URL is a legitimate state.
assert!(s
.replace(Settings {
push_enabled: false,
webhook_url: String::new(),
push_mode: PushMode::Both,
})
.is_ok());
}
#[test]
fn reports_whether_the_webhook_changed() {
let dir = tempdir().unwrap();
let s = SettingsStore::load_or_default(dir.path());
let base = Settings {
push_enabled: true,
webhook_url: GOOD.into(),
push_mode: PushMode::Both,
};
let (_, changed) = s.replace(base.clone()).unwrap();
assert!(changed, "first set counts as a change");
// Same URL, different mode — message ids stay valid.
let (_, changed) = s
.replace(Settings {
push_mode: PushMode::Embed,
..base.clone()
})
.unwrap();
assert!(!changed);
let (_, changed) = s
.replace(Settings {
webhook_url: "https://discord.com/api/webhooks/999/other".into(),
..base
})
.unwrap();
assert!(changed, "a new channel invalidates every stored message id");
}
#[test]
fn persists_across_a_reload() {
let dir = tempdir().unwrap();
{
let s = SettingsStore::load_or_default(dir.path());
s.replace(Settings {
push_enabled: true,
webhook_url: GOOD.into(),
push_mode: PushMode::Image,
})
.unwrap();
}
let s2 = SettingsStore::load_or_default(dir.path());
let v = s2.snapshot();
assert_eq!(v.push_mode, PushMode::Image);
assert!(v.is_usable());
}
#[test]
fn corrupt_file_falls_back_to_defaults() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("settings.json"), b"{ nope").unwrap();
assert_eq!(
SettingsStore::load_or_default(dir.path())
.snapshot()
.push_mode,
PushMode::Both
);
}
#[test]
fn push_mode_capabilities() {
assert!(PushMode::Embed.has_text() && !PushMode::Embed.has_image());
assert!(PushMode::Image.has_image() && !PushMode::Image.has_text());
assert!(PushMode::Both.has_image() && PushMode::Both.has_text());
}
}
+512
View File
@@ -0,0 +1,512 @@
//! RFC 4226 (HOTP) / RFC 6238 (TOTP) and `otpauth://` URI parsing.
//!
//! Implemented directly over HMAC rather than pulling a TOTP crate. The
//! algorithm itself is about thirty lines; the reason to own it is the URI
//! parser. Real-world `otpauth://` URIs are messier than the spec:
//!
//! * **The issuer appears twice and the two disagree.** The spec puts it in
//! the path label (`Issuer:account`) *and* in `?issuer=`. Microsoft emits
//! URIs where those differ, and strict parsers reject the account outright.
//! We take the query parameter as authoritative and fall back to the label.
//! * **Secrets are not always canonical base32.** RFC 4648 says uppercase and
//! we accept padding, but Google has shipped lowercase secrets and several
//! providers pad (or don't) inconsistently, and users paste secrets with
//! the spaces the provider printed them with. We normalise before decoding.
//!
//! Both cases are "the QR code from a service you actually use fails to
//! import", so leniency here is the difference between working and not.
use crate::{Error, Result};
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha1::Sha1;
use sha2::{Sha256, Sha512};
/// Run HMAC-`$hash` over `$msg`, returning the raw digest bytes.
///
/// A macro rather than a generic function on purpose: expressing "any
/// RustCrypto digest usable with `Hmac`" as a where-clause drags in half a
/// dozen `digest` traits and tends to break on every RustCrypto major bump.
/// The three call sites are known and closed, so monomorphising by hand is
/// both shorter and more durable. HMAC accepts a key of any length, so
/// `new_from_slice` cannot fail for a secret already checked as non-empty.
macro_rules! mac_bytes {
($hash:ty, $key:expr, $msg:expr) => {{
let mut mac = <Hmac<$hash>>::new_from_slice($key).expect("HMAC accepts keys of any length");
mac.update($msg);
mac.finalize().into_bytes().to_vec()
}};
}
/// Hash backing the HMAC. SHA-1 is the near-universal default; the others
/// appear rarely but are part of RFC 6238 and cost nothing to support.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "UPPERCASE")]
pub enum Algorithm {
#[default]
Sha1,
Sha256,
Sha512,
}
impl Algorithm {
pub fn as_str(self) -> &'static str {
match self {
Self::Sha1 => "SHA1",
Self::Sha256 => "SHA256",
Self::Sha512 => "SHA512",
}
}
fn parse(s: &str) -> Result<Self> {
match s.trim().to_ascii_uppercase().as_str() {
"SHA1" | "SHA-1" => Ok(Self::Sha1),
"SHA256" | "SHA-256" => Ok(Self::Sha256),
"SHA512" | "SHA-512" => Ok(Self::Sha512),
other => Err(Error::invalid(format!(
"unsupported algorithm '{other}' (expected SHA1, SHA256 or SHA512)"
))),
}
}
}
/// Everything needed to produce codes for one account. The secret lives
/// here in plaintext; callers are responsible for keeping this struct out
/// of anything that gets serialized to disk unencrypted.
#[derive(Debug, Clone)]
pub struct Totp {
pub secret: Vec<u8>,
pub algorithm: Algorithm,
pub digits: u8,
pub period: u64,
}
impl Totp {
pub fn new(secret: Vec<u8>, algorithm: Algorithm, digits: u8, period: u64) -> Result<Self> {
if secret.is_empty() {
return Err(Error::invalid("the shared secret is empty"));
}
if !(6..=8).contains(&digits) {
return Err(Error::invalid(format!(
"digits must be between 6 and 8 (got {digits})"
)));
}
if period == 0 {
return Err(Error::invalid("period must be at least 1 second"));
}
Ok(Self {
secret,
algorithm,
digits,
period,
})
}
/// Which time step `unix_secs` falls in. T0 is 0 per RFC 6238 §4.1.
pub fn counter_at(&self, unix_secs: u64) -> u64 {
unix_secs / self.period
}
/// Unix second at which the code covering `unix_secs` stops being valid.
pub fn expires_at(&self, unix_secs: u64) -> u64 {
(self.counter_at(unix_secs) + 1) * self.period
}
/// Seconds remaining on the code covering `unix_secs`.
pub fn remaining_at(&self, unix_secs: u64) -> u64 {
self.expires_at(unix_secs) - unix_secs
}
/// The code for the step containing `unix_secs`.
pub fn code_at(&self, unix_secs: u64) -> String {
self.hotp(self.counter_at(unix_secs))
}
/// The code for the step *after* the one containing `unix_secs`. The UI
/// and the Discord pusher both pre-fetch this so a rollover never has a
/// request on the critical path.
pub fn next_code_at(&self, unix_secs: u64) -> String {
self.hotp(self.counter_at(unix_secs) + 1)
}
/// RFC 4226 §5.3 — HMAC, dynamic truncation, modulo.
fn hotp(&self, counter: u64) -> String {
let msg = counter.to_be_bytes();
let digest: Vec<u8> = match self.algorithm {
Algorithm::Sha1 => mac_bytes!(Sha1, &self.secret, &msg),
Algorithm::Sha256 => mac_bytes!(Sha256, &self.secret, &msg),
Algorithm::Sha512 => mac_bytes!(Sha512, &self.secret, &msg),
};
// Dynamic truncation: the low nibble of the last byte picks a
// 4-byte window; the top bit is masked off to dodge sign issues in
// the languages the RFC was written for.
let offset = (digest[digest.len() - 1] & 0x0f) as usize;
let bin = u32::from_be_bytes([
digest[offset] & 0x7f,
digest[offset + 1],
digest[offset + 2],
digest[offset + 3],
]);
let modulo = 10_u32.pow(u32::from(self.digits));
format!("{:0width$}", bin % modulo, width = usize::from(self.digits))
}
}
// ── otpauth:// parsing ────────────────────────────────────────────────────
/// A parsed `otpauth://totp/...` URI: the crypto parameters plus the
/// human-facing naming.
#[derive(Debug, Clone)]
pub struct OtpAuth {
pub totp: Totp,
pub issuer: String,
pub label: String,
}
/// Parse an `otpauth://totp/...` URI.
///
/// Deliberately lenient — see the module docs for why. `hotp://` counter-based
/// URIs are rejected with a specific message rather than a generic parse
/// failure, because a user scanning a counter-based QR deserves to know that's
/// what happened.
pub fn parse_otpauth(uri: &str) -> Result<OtpAuth> {
let uri = uri.trim();
let parsed =
url::Url::parse(uri).map_err(|e| Error::invalid(format!("not a valid URI: {e}")))?;
if !parsed.scheme().eq_ignore_ascii_case("otpauth") {
if parsed.scheme().eq_ignore_ascii_case("otpauth-migration") {
return Err(Error::invalid(
"this is a Google Authenticator export link, which contains several \
accounts at once. Export them individually, or add each account's \
own QR code.",
));
}
return Err(Error::invalid("expected a link starting with otpauth://"));
}
match parsed
.host_str()
.unwrap_or_default()
.to_ascii_lowercase()
.as_str()
{
"totp" => {}
"hotp" => {
return Err(Error::invalid(
"this is a counter-based (HOTP) code. Discord Authenticator only \
supports time-based (TOTP) codes.",
))
}
other => {
return Err(Error::invalid(format!(
"unsupported OTP type '{other}' (expected totp)"
)))
}
}
// ── label: /Issuer:Account, or just /Account ──
let raw_label = percent_decode(parsed.path().trim_start_matches('/'));
let (label_issuer, account) = match raw_label.split_once(':') {
Some((iss, acct)) => (Some(iss.trim().to_string()), acct.trim().to_string()),
None => (None, raw_label.trim().to_string()),
};
// ── query parameters ──
let mut secret_raw = None;
let mut query_issuer = None;
let mut algorithm = Algorithm::Sha1;
let mut digits: u8 = 6;
let mut period: u64 = 30;
for (k, v) in parsed.query_pairs() {
match k.as_ref().to_ascii_lowercase().as_str() {
"secret" => secret_raw = Some(v.into_owned()),
"issuer" => {
let v = v.trim().to_string();
if !v.is_empty() {
query_issuer = Some(v);
}
}
"algorithm" => algorithm = Algorithm::parse(&v)?,
"digits" => {
digits = v
.trim()
.parse()
.map_err(|_| Error::invalid(format!("digits '{v}' is not a number")))?;
}
"period" => {
period = v
.trim()
.parse()
.map_err(|_| Error::invalid(format!("period '{v}' is not a number")))?;
}
// Unknown parameters are ignored rather than fatal — providers
// append their own (`image=`, `lock=`, …).
_ => {}
}
}
let secret_raw =
secret_raw.ok_or_else(|| Error::invalid("the link has no ?secret= parameter"))?;
let secret = decode_secret(&secret_raw)?;
// Query issuer wins over the path label. When only the label has one we
// take it; when neither does, fall back to the account name so the UI
// always has something to show.
let issuer = query_issuer
.or(label_issuer)
.unwrap_or_else(|| account.clone());
Ok(OtpAuth {
totp: Totp::new(secret, algorithm, digits, period)?,
issuer,
label: account,
})
}
/// Decode a base32 shared secret, normalising the ways providers mangle it.
///
/// Accepts lowercase, `=` padding, and the spaces/hyphens services print
/// secrets with for manual entry.
pub fn decode_secret(raw: &str) -> Result<Vec<u8>> {
let cleaned: String = raw
.chars()
.filter(|c| !c.is_whitespace() && *c != '-' && *c != '=')
.collect::<String>()
.to_ascii_uppercase();
if cleaned.is_empty() {
return Err(Error::invalid("the shared secret is empty"));
}
base32::decode(base32::Alphabet::Rfc4648 { padding: false }, &cleaned)
.filter(|b| !b.is_empty())
.ok_or_else(|| {
Error::invalid(
"the shared secret is not valid base32 (expected letters A–Z and digits 2–7)",
)
})
}
/// Encode a secret back to base32 — used when re-exporting an account.
pub fn encode_secret(secret: &[u8]) -> String {
base32::encode(base32::Alphabet::Rfc4648 { padding: false }, secret)
}
fn percent_decode(s: &str) -> String {
percent_encoding::percent_decode_str(s)
.decode_utf8_lossy()
.into_owned()
}
// ── Tests ─────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
/// RFC 6238 Appendix B. The three secrets are the ASCII string
/// "12345678901234567890" truncated/repeated to each hash's block size.
fn rfc_secret(alg: Algorithm) -> Vec<u8> {
let seed = b"1234567890";
let len = match alg {
Algorithm::Sha1 => 20,
Algorithm::Sha256 => 32,
Algorithm::Sha512 => 64,
};
seed.iter().copied().cycle().take(len).collect()
}
#[test]
fn rfc6238_test_vectors() {
let times = [
59_u64,
1_111_111_109,
1_111_111_111,
1_234_567_890,
2_000_000_000,
20_000_000_000,
];
let sha1 = [
"94287082", "07081804", "14050471", "89005924", "69279037", "65353130",
];
let sha256 = [
"46119246", "68084774", "67062674", "91819424", "90698825", "77737706",
];
let sha512 = [
"90693936", "25091201", "99943326", "93441116", "38618901", "47863826",
];
for (alg, expected) in [
(Algorithm::Sha1, sha1),
(Algorithm::Sha256, sha256),
(Algorithm::Sha512, sha512),
] {
let totp = Totp::new(rfc_secret(alg), alg, 8, 30).unwrap();
for (t, want) in times.iter().zip(expected.iter()) {
assert_eq!(&totp.code_at(*t), want, "{} at t={t}", alg.as_str());
}
}
}
#[test]
fn six_digit_codes_are_the_low_six_of_the_eight() {
let totp = Totp::new(rfc_secret(Algorithm::Sha1), Algorithm::Sha1, 6, 30).unwrap();
// RFC vector at t=59 is 94287082 → the 6-digit form is its last six.
assert_eq!(totp.code_at(59), "287082");
}
#[test]
fn codes_are_zero_padded_to_width() {
// Every generated code must be exactly `digits` long, including the
// ~10% of values with a leading zero. 07081804 above already covers
// the 8-digit case; assert the invariant broadly here.
let totp = Totp::new(rfc_secret(Algorithm::Sha1), Algorithm::Sha1, 6, 30).unwrap();
for t in (0..30_000).step_by(37) {
assert_eq!(totp.code_at(t).len(), 6);
}
}
#[test]
fn period_boundaries() {
let totp = Totp::new(vec![1, 2, 3, 4], Algorithm::Sha1, 6, 30).unwrap();
assert_eq!(totp.counter_at(0), 0);
assert_eq!(totp.counter_at(29), 0);
assert_eq!(totp.counter_at(30), 1);
assert_eq!(totp.expires_at(0), 30);
assert_eq!(totp.expires_at(29), 30);
assert_eq!(totp.expires_at(30), 60);
assert_eq!(totp.remaining_at(25), 5);
// The next code at t is the current code one period later.
assert_eq!(totp.next_code_at(10), totp.code_at(40));
}
#[test]
fn rejects_bad_parameters() {
assert!(Totp::new(vec![], Algorithm::Sha1, 6, 30).is_err());
assert!(Totp::new(vec![1], Algorithm::Sha1, 5, 30).is_err());
assert!(Totp::new(vec![1], Algorithm::Sha1, 9, 30).is_err());
assert!(Totp::new(vec![1], Algorithm::Sha1, 6, 0).is_err());
}
#[test]
fn parses_a_canonical_uri() {
let a = parse_otpauth(
"otpauth://totp/Cloudflare:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=Cloudflare",
)
.unwrap();
assert_eq!(a.issuer, "Cloudflare");
assert_eq!(a.label, "[email protected]");
assert_eq!(a.totp.digits, 6);
assert_eq!(a.totp.period, 30);
assert_eq!(a.totp.algorithm, Algorithm::Sha1);
}
#[test]
fn query_issuer_wins_over_a_disagreeing_label() {
// The Microsoft case: the label says one thing, ?issuer= another.
// A strict parser rejects this outright; we take the query param.
let a = parse_otpauth(
"otpauth://totp/Issuer1:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=Microsoft",
)
.unwrap();
assert_eq!(a.issuer, "Microsoft");
assert_eq!(a.label, "[email protected]");
}
#[test]
fn falls_back_to_the_label_issuer_then_the_account() {
let a =
parse_otpauth("otpauth://totp/Plex:[email protected]?secret=JBSWY3DPEHPK3PXP").unwrap();
assert_eq!(a.issuer, "Plex");
let b = parse_otpauth("otpauth://totp/[email protected]?secret=JBSWY3DPEHPK3PXP").unwrap();
assert_eq!(b.issuer, "[email protected]");
assert_eq!(b.label, "[email protected]");
}
#[test]
fn percent_encoded_labels_survive() {
let a = parse_otpauth(
"otpauth://totp/Big%20Corp%3Aalice%40example.com?secret=JBSWY3DPEHPK3PXP",
)
.unwrap();
assert_eq!(a.issuer, "Big Corp");
assert_eq!(a.label, "[email protected]");
}
#[test]
fn honours_explicit_parameters() {
let a = parse_otpauth(
"otpauth://totp/X:y?secret=JBSWY3DPEHPK3PXP&algorithm=SHA256&digits=8&period=60",
)
.unwrap();
assert_eq!(a.totp.algorithm, Algorithm::Sha256);
assert_eq!(a.totp.digits, 8);
assert_eq!(a.totp.period, 60);
}
#[test]
fn unknown_query_parameters_are_ignored() {
assert!(parse_otpauth(
"otpauth://totp/X:y?secret=JBSWY3DPEHPK3PXP&image=https://x/y.png&lock=false"
)
.is_ok());
}
#[test]
fn secret_normalisation() {
// Lowercase (Google has shipped these).
assert_eq!(
decode_secret("jbswy3dpehpk3pxp").unwrap(),
decode_secret("JBSWY3DPEHPK3PXP").unwrap()
);
// Padded.
assert_eq!(
decode_secret("JBSWY3DPEHPK3PXP===").unwrap(),
decode_secret("JBSWY3DPEHPK3PXP").unwrap()
);
// Spaced and hyphenated, the way providers print them for typing.
assert_eq!(
decode_secret("jbsw y3dp ehpk 3pxp").unwrap(),
decode_secret("JBSWY3DPEHPK3PXP").unwrap()
);
assert_eq!(
decode_secret("JBSW-Y3DP-EHPK-3PXP").unwrap(),
decode_secret("JBSWY3DPEHPK3PXP").unwrap()
);
// Base32 excludes 0/1/8/9 — these must fail rather than silently decode.
assert!(decode_secret("01890189").is_err());
assert!(decode_secret("").is_err());
assert!(decode_secret(" ").is_err());
}
#[test]
fn secret_round_trips() {
let bytes = decode_secret("JBSWY3DPEHPK3PXP").unwrap();
assert_eq!(encode_secret(&bytes), "JBSWY3DPEHPK3PXP");
}
#[test]
fn rejects_non_totp_links_with_specific_messages() {
// HOTP must name the actual problem, not just "parse error".
let e = parse_otpauth("otpauth://hotp/X:y?secret=JBSWY3DPEHPK3PXP&counter=1")
.unwrap_err()
.to_string();
assert!(e.contains("counter-based"), "got: {e}");
let e = parse_otpauth("otpauth-migration://offline?data=abc")
.unwrap_err()
.to_string();
assert!(e.contains("export"), "got: {e}");
assert!(parse_otpauth("https://example.com").is_err());
assert!(parse_otpauth("not a uri at all").is_err());
// Missing secret.
assert!(parse_otpauth("otpauth://totp/X:y?issuer=X").is_err());
}
}
+644
View File
@@ -0,0 +1,644 @@
//! The account vault — TOTP secrets, their naming, and their Discord state.
//!
//! Storage policy mirrors the rest of the app: the in-memory copy is
//! authoritative and disk is the crash-survival cache, written atomically via
//! write-to-temp-then-rename. Secrets are sealed individually (see
//! [`crate::crypto`]) rather than the file being encrypted wholesale, so the
//! non-sensitive metadata stays readable for debugging and a single corrupt
//! record can't take the whole vault with it.
//!
//! Unlike settings and auth, a `vault.json` that won't parse is **fatal**.
//! Everywhere else falling back to defaults is the friendly move; here it
//! would silently present an empty vault and invite the operator to re-add
//! accounts they already have, so we refuse to start instead.
use crate::crypto::{Sealed, VaultKey};
use crate::totp::{Algorithm, OtpAuth, Totp};
use crate::{Error, Result};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use time::OffsetDateTime;
/// Live-message bookkeeping for one account's Discord tile.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct DiscordState {
/// Snowflake of the message we keep editing.
///
/// **A string, deliberately.** Snowflakes exceed 2^53 and would be
/// silently corrupted by any JSON layer that treats them as numbers.
pub message_id: Option<String>,
/// Last delivery failure, surfaced in the UI. `None` once a push succeeds.
pub last_error: Option<String>,
/// Code most recently written to Discord — lets the pusher skip an edit
/// when nothing actually changed.
pub last_code: Option<String>,
#[serde(with = "time::serde::rfc3339::option")]
pub last_push_at: Option<OffsetDateTime>,
}
/// One stored account. The `secret` field is ciphertext.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Account {
pub id: String,
pub issuer: String,
pub label: String,
pub algorithm: Algorithm,
pub digits: u8,
pub period: u64,
pub secret: Sealed,
#[serde(default = "default_true")]
pub push_enabled: bool,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
#[serde(default)]
pub discord: DiscordState,
}
fn default_true() -> bool {
true
}
/// Projection safe to hand to the browser: no secret, no message id.
#[derive(Debug, Clone, Serialize)]
pub struct AccountPublic {
pub id: String,
pub issuer: String,
pub label: String,
pub algorithm: Algorithm,
pub digits: u8,
pub period: u64,
pub push_enabled: bool,
/// True once a tile exists in Discord and the last push succeeded.
pub push_live: bool,
pub push_error: Option<String>,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
}
impl From<&Account> for AccountPublic {
fn from(a: &Account) -> Self {
Self {
id: a.id.clone(),
issuer: a.issuer.clone(),
label: a.label.clone(),
algorithm: a.algorithm,
digits: a.digits,
period: a.period,
push_enabled: a.push_enabled,
push_live: a.discord.message_id.is_some() && a.discord.last_error.is_none(),
push_error: a.discord.last_error.clone(),
created_at: a.created_at,
}
}
}
/// An account plus its decrypted TOTP parameters, for the push loop.
#[derive(Debug, Clone)]
pub struct PushTarget {
pub id: String,
pub issuer: String,
pub label: String,
pub totp: Totp,
pub message_id: Option<String>,
pub last_code: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct VaultFile {
#[serde(default)]
accounts: Vec<Account>,
}
#[derive(Clone)]
pub struct Vault {
path: Arc<PathBuf>,
key: Arc<VaultKey>,
inner: Arc<RwLock<Vec<Account>>>,
}
#[allow(clippy::missing_fields_in_debug)] // secrets must never reach a log line
impl std::fmt::Debug for Vault {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Vault")
.field("accounts", &self.inner.read().len())
.finish()
}
}
impl Vault {
/// Load `<work_dir>/vault.json`, or start empty if it doesn't exist yet.
///
/// A file that exists but won't parse is an error, not a fallback — see
/// the module docs.
pub fn load(work_dir: &Path, key: VaultKey) -> Result<Self> {
let path = work_dir.join("vault.json");
let accounts = match std::fs::read_to_string(&path) {
Ok(text) => {
serde_json::from_str::<VaultFile>(&text)
.map_err(|e| {
Error::invalid(format!(
"{} exists but could not be parsed ({e}). Refusing to start \
with an empty vault — move the file aside to start fresh.",
path.display()
))
})?
.accounts
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(e) => return Err(e.into()),
};
tracing::info!(target: "da::vault", count = accounts.len(), "vault loaded");
Ok(Self {
path: Arc::new(path),
key: Arc::new(key),
inner: Arc::new(RwLock::new(accounts)),
})
}
pub fn len(&self) -> usize {
self.inner.read().len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// Public list, oldest first. Stable ordering matters: it's the order
/// tiles are (re)created in Discord, so accounts don't shuffle position
/// in the channel after a restart.
pub fn list(&self) -> Vec<AccountPublic> {
let g = self.inner.read();
let mut v: Vec<_> = g.iter().collect();
v.sort_by_key(|a| a.created_at);
v.into_iter().map(AccountPublic::from).collect()
}
pub fn get(&self, id: &str) -> Option<AccountPublic> {
self.inner
.read()
.iter()
.find(|a| a.id == id)
.map(AccountPublic::from)
}
/// Decrypt one account's TOTP parameters.
pub fn totp_for(&self, id: &str) -> Result<Totp> {
let g = self.inner.read();
let a = g
.iter()
.find(|a| a.id == id)
.ok_or_else(|| Error::not_found("account"))?;
self.decrypt(a)
}
/// Every account, with secrets decrypted, in stable order — what the
/// code endpoint and the push loop both iterate.
pub fn all_with_totp(&self) -> Vec<(AccountPublic, Totp)> {
let g = self.inner.read();
let mut v: Vec<_> = g.iter().collect();
v.sort_by_key(|a| a.created_at);
v.into_iter()
.filter_map(|a| match self.decrypt(a) {
Ok(t) => Some((AccountPublic::from(a), t)),
Err(e) => {
// One unreadable record must not blank the whole list.
tracing::error!(
target: "da::vault",
id = %a.id, issuer = %a.issuer,
"could not decrypt secret: {e}"
);
None
}
})
.collect()
}
/// Push-enabled accounts with their decrypted parameters and message state.
pub fn push_targets(&self) -> Vec<PushTarget> {
let g = self.inner.read();
let mut v: Vec<_> = g.iter().filter(|a| a.push_enabled).collect();
v.sort_by_key(|a| a.created_at);
v.into_iter()
.filter_map(|a| {
let totp = self.decrypt(a).ok()?;
Some(PushTarget {
id: a.id.clone(),
issuer: a.issuer.clone(),
label: a.label.clone(),
totp,
message_id: a.discord.message_id.clone(),
last_code: a.discord.last_code.clone(),
})
})
.collect()
}
/// Message ids for accounts that are *not* pushing — their tiles need
/// removing from the channel.
pub fn orphaned_messages(&self) -> Vec<(String, String)> {
self.inner
.read()
.iter()
.filter(|a| !a.push_enabled)
.filter_map(|a| a.discord.message_id.clone().map(|m| (a.id.clone(), m)))
.collect()
}
fn decrypt(&self, a: &Account) -> Result<Totp> {
let secret = self.key.open(&a.secret)?;
Totp::new(secret, a.algorithm, a.digits, a.period)
}
/// Add an account parsed from an `otpauth://` URI. `issuer`/`label`
/// override the parsed values when non-empty, so the user can rename in
/// the confirm step without editing the URI.
pub fn add(
&self,
parsed: &OtpAuth,
issuer: Option<&str>,
label: Option<&str>,
push_enabled: bool,
) -> Result<AccountPublic> {
let issuer = issuer
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or(&parsed.issuer)
.to_string();
let label = label
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or(&parsed.label)
.to_string();
if issuer.len() > 128 || label.len() > 128 {
return Err(Error::invalid(
"issuer and account must be 128 characters or fewer",
));
}
let account = Account {
id: uuid::Uuid::new_v4().simple().to_string(),
issuer,
label,
algorithm: parsed.totp.algorithm,
digits: parsed.totp.digits,
period: parsed.totp.period,
secret: self.key.seal(&parsed.totp.secret)?,
push_enabled,
created_at: OffsetDateTime::now_utc(),
discord: DiscordState::default(),
};
let public = AccountPublic::from(&account);
self.inner.write().push(account);
self.persist();
tracing::info!(
target: "da::vault",
issuer = %public.issuer, id = %public.id,
"account added"
);
Ok(public)
}
/// Rename an account and/or toggle its Discord push.
pub fn update(
&self,
id: &str,
issuer: Option<&str>,
label: Option<&str>,
push_enabled: Option<bool>,
) -> Result<AccountPublic> {
let public = {
let mut g = self.inner.write();
let a = g
.iter_mut()
.find(|a| a.id == id)
.ok_or_else(|| Error::not_found("account"))?;
if let Some(v) = issuer.map(str::trim).filter(|s| !s.is_empty()) {
if v.len() > 128 {
return Err(Error::invalid("issuer must be 128 characters or fewer"));
}
a.issuer = v.to_string();
}
if let Some(v) = label.map(str::trim) {
if v.len() > 128 {
return Err(Error::invalid("account must be 128 characters or fewer"));
}
a.label = v.to_string();
}
if let Some(v) = push_enabled {
if v != a.push_enabled {
// Re-enabling clears a stale error so the UI doesn't keep
// showing a failure from before the account was paused.
a.discord.last_error = None;
}
a.push_enabled = v;
}
AccountPublic::from(&*a)
};
self.persist();
Ok(public)
}
/// Remove an account. Returns its Discord message id, if any, so the
/// caller can delete the tile from the channel.
pub fn remove(&self, id: &str) -> Result<Option<String>> {
let removed = {
let mut g = self.inner.write();
let idx = g
.iter()
.position(|a| a.id == id)
.ok_or_else(|| Error::not_found("account"))?;
g.remove(idx)
};
self.persist();
tracing::info!(
target: "da::vault",
issuer = %removed.issuer, id = %removed.id,
"account removed"
);
Ok(removed.discord.message_id)
}
/// Record a successful push.
pub fn mark_pushed(&self, id: &str, message_id: &str, code: &str) {
{
let mut g = self.inner.write();
let Some(a) = g.iter_mut().find(|a| a.id == id) else {
return;
};
a.discord.message_id = Some(message_id.to_string());
a.discord.last_code = Some(code.to_string());
a.discord.last_error = None;
a.discord.last_push_at = Some(OffsetDateTime::now_utc());
}
self.persist();
}
/// Record a delivery failure against one account.
pub fn mark_push_error(&self, id: &str, error: &str) {
{
let mut g = self.inner.write();
let Some(a) = g.iter_mut().find(|a| a.id == id) else {
return;
};
// Don't rewrite (and re-persist) the same error every 30 seconds
// while a webhook stays broken.
if a.discord.last_error.as_deref() == Some(error) {
return;
}
a.discord.last_error = Some(error.to_string());
}
self.persist();
}
/// Forget an account's message id — used when Discord reports the
/// message is gone (someone deleted it) so the next pass re-creates it.
pub fn clear_message_id(&self, id: &str) {
{
let mut g = self.inner.write();
let Some(a) = g.iter_mut().find(|a| a.id == id) else {
return;
};
a.discord.message_id = None;
a.discord.last_code = None;
}
self.persist();
}
/// Drop every stored message id. Called when the webhook URL changes:
/// the old ids belong to a different channel and are meaningless now.
pub fn clear_all_messages(&self) {
{
let mut g = self.inner.write();
for a in g.iter_mut() {
a.discord = DiscordState::default();
}
}
self.persist();
}
fn persist(&self) {
let snapshot = VaultFile {
accounts: self.inner.read().clone(),
};
let body = match serde_json::to_vec_pretty(&snapshot) {
Ok(b) => b,
Err(e) => {
tracing::error!(target: "da::vault", "could not serialize vault: {e}");
return;
}
};
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let tmp = self.path.with_extension("json.tmp");
if let Err(e) = std::fs::write(&tmp, body) {
tracing::error!(target: "da::vault", "could not write vault temp file: {e}");
return;
}
if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) {
tracing::error!(target: "da::vault", "could not commit vault file: {e}");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::totp::parse_otpauth;
use tempfile::tempdir;
fn sample() -> OtpAuth {
parse_otpauth(
"otpauth://totp/Cloudflare:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=Cloudflare",
)
.unwrap()
}
fn vault(dir: &Path) -> Vault {
Vault::load(dir, VaultKey::from_bytes(&[3u8; 32]).unwrap()).unwrap()
}
#[test]
fn add_list_and_decrypt() {
let dir = tempdir().unwrap();
let v = vault(dir.path());
assert!(v.is_empty());
let a = v.add(&sample(), None, None, true).unwrap();
assert_eq!(a.issuer, "Cloudflare");
assert_eq!(a.label, "[email protected]");
assert_eq!(v.len(), 1);
// The decrypted secret must produce the same codes as the original.
let totp = v.totp_for(&a.id).unwrap();
assert_eq!(totp.code_at(59), sample().totp.code_at(59));
}
#[test]
fn secrets_are_never_stored_in_the_clear() {
let dir = tempdir().unwrap();
let v = vault(dir.path());
v.add(&sample(), None, None, true).unwrap();
let raw = std::fs::read_to_string(dir.path().join("vault.json")).unwrap();
// Neither the base32 form nor the raw bytes may appear on disk.
assert!(!raw.contains("JBSWY3DPEHPK3PXP"));
assert!(!raw.contains("Hello!"));
// But the metadata should still be readable.
assert!(raw.contains("Cloudflare"));
}
#[test]
fn overrides_win_over_parsed_names() {
let dir = tempdir().unwrap();
let v = vault(dir.path());
let a = v
.add(&sample(), Some("Work CF"), Some("ops@corp"), false)
.unwrap();
assert_eq!(a.issuer, "Work CF");
assert_eq!(a.label, "ops@corp");
assert!(!a.push_enabled);
// Blank overrides fall back to the parsed values.
let b = v.add(&sample(), Some(" "), None, true).unwrap();
assert_eq!(b.issuer, "Cloudflare");
}
#[test]
fn survives_a_reload() {
let dir = tempdir().unwrap();
let id = {
let v = vault(dir.path());
v.add(&sample(), None, None, true).unwrap().id
};
let v2 = vault(dir.path());
assert_eq!(v2.len(), 1);
assert!(v2.totp_for(&id).is_ok());
}
#[test]
fn a_wrong_key_leaves_metadata_but_no_codes() {
let dir = tempdir().unwrap();
let id = {
let v = vault(dir.path());
v.add(&sample(), None, None, true).unwrap().id
};
let other = Vault::load(dir.path(), VaultKey::from_bytes(&[9u8; 32]).unwrap()).unwrap();
assert_eq!(other.len(), 1, "the record still loads");
assert!(
other.totp_for(&id).is_err(),
"but its secret must not decrypt"
);
// The list endpoint must not panic or drop the account.
assert_eq!(other.list().len(), 1);
// all_with_totp skips what it can't decrypt rather than failing.
assert!(other.all_with_totp().is_empty());
}
#[test]
fn corrupt_vault_is_fatal_rather_than_silently_empty() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("vault.json"), b"{ not json").unwrap();
let r = Vault::load(dir.path(), VaultKey::from_bytes(&[1u8; 32]).unwrap());
assert!(r.is_err());
}
#[test]
fn update_and_remove() {
let dir = tempdir().unwrap();
let v = vault(dir.path());
let a = v.add(&sample(), None, None, true).unwrap();
let up = v.update(&a.id, Some("Renamed"), None, Some(false)).unwrap();
assert_eq!(up.issuer, "Renamed");
assert!(!up.push_enabled);
// Label was not supplied, so it must be untouched.
assert_eq!(up.label, "[email protected]");
assert!(v.remove(&a.id).is_ok());
assert!(v.is_empty());
assert!(v.remove(&a.id).is_err(), "second remove is a 404");
}
#[test]
fn push_bookkeeping() {
let dir = tempdir().unwrap();
let v = vault(dir.path());
let a = v.add(&sample(), None, None, true).unwrap();
assert!(!v.get(&a.id).unwrap().push_live);
v.mark_pushed(&a.id, "123456789012345678", "111222");
let p = v.get(&a.id).unwrap();
assert!(p.push_live);
assert!(p.push_error.is_none());
v.mark_push_error(&a.id, "webhook returned 404");
let p = v.get(&a.id).unwrap();
assert!(!p.push_live);
assert_eq!(p.push_error.as_deref(), Some("webhook returned 404"));
// Message id survives an error (we keep editing the same tile), but
// clearing it forces a re-create.
assert!(v.push_targets()[0].message_id.is_some());
v.clear_message_id(&a.id);
assert!(v.push_targets()[0].message_id.is_none());
}
#[test]
fn message_ids_are_persisted_as_strings() {
// Snowflakes exceed 2^53; a JSON number would round-trip lossily.
let dir = tempdir().unwrap();
let v = vault(dir.path());
let a = v.add(&sample(), None, None, true).unwrap();
v.mark_pushed(&a.id, "1409876543210987654", "000000");
let raw = std::fs::read_to_string(dir.path().join("vault.json")).unwrap();
assert!(
raw.contains("\"1409876543210987654\""),
"must be quoted: {raw}"
);
let v2 = vault(dir.path());
assert_eq!(
v2.push_targets()[0].message_id.as_deref(),
Some("1409876543210987654")
);
}
#[test]
fn push_targets_exclude_paused_accounts_and_keep_order() {
let dir = tempdir().unwrap();
let v = vault(dir.path());
let a = v.add(&sample(), Some("First"), None, true).unwrap();
let b = v.add(&sample(), Some("Second"), None, false).unwrap();
let targets = v.push_targets();
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].issuer, "First");
// The paused one shows up as an orphan only once it has a tile.
assert!(v.orphaned_messages().is_empty());
v.mark_pushed(&b.id, "999", "000000");
assert_eq!(v.orphaned_messages().len(), 1);
// Stable ordering: oldest first, regardless of push state.
let all = v.list();
assert_eq!(all[0].id, a.id);
assert_eq!(all[1].id, b.id);
}
#[test]
fn changing_webhook_clears_every_message_id() {
let dir = tempdir().unwrap();
let v = vault(dir.path());
let a = v.add(&sample(), None, None, true).unwrap();
v.mark_pushed(&a.id, "555", "000000");
v.clear_all_messages();
assert!(v.push_targets()[0].message_id.is_none());
assert!(!v.get(&a.id).unwrap().push_live);
}
}
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "discord-authenticator"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
authors.workspace = true
description = "Self-hosted TOTP authenticator that streams live codes to Discord."
[[bin]]
name = "discord-authenticator"
path = "src/main.rs"
[dependencies]
da-core.workspace = true
da-discord.workspace = true
da-http-api.workspace = true
tokio.workspace = true
axum.workspace = true
clap.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
anyhow.workspace = true
[lints]
workspace = true
+130
View File
@@ -0,0 +1,130 @@
//! Discord Authenticator — a self-hosted TOTP vault whose codes stream to a
//! Discord channel as live, self-updating messages.
#![forbid(unsafe_code)]
use anyhow::Context;
use clap::Parser;
use da_core::{AdminStore, SettingsStore, Vault, VaultKey};
use da_discord::PushEngine;
use da_http_api::AppState;
use std::net::SocketAddr;
use std::path::PathBuf;
const VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Debug, Parser)]
#[command(
name = "discord-authenticator",
version = VERSION,
about = "Self-hosted TOTP authenticator with live Discord delivery"
)]
struct Args {
/// Address to serve the web UI and API on.
#[arg(long, env = "DA_HTTP_ADDR", default_value = "0.0.0.0:8710")]
http_addr: SocketAddr,
/// Directory holding the vault, key, settings and admin account.
#[arg(
long,
env = "DA_WORK_DIR",
default_value = "/var/lib/discord-authenticator"
)]
work_dir: PathBuf,
/// `tracing-subscriber` filter expression.
#[arg(long, env = "DA_LOG", default_value = "info,da=info")]
log: String,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args = Args::parse();
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::new(&args.log))
.with_target(true)
.init();
std::fs::create_dir_all(&args.work_dir).with_context(|| {
format!(
"could not create the work directory {}",
args.work_dir.display()
)
})?;
// The key must resolve before the vault: without it every stored secret
// is unreadable, and starting anyway would present an apparently-empty
// vault and invite the operator to re-add accounts they already have.
let key = VaultKey::load_or_create(&args.work_dir)
.context("could not load or create the vault key")?;
let vault = Vault::load(&args.work_dir, key).context("could not load the vault")?;
let admin = AdminStore::load_or_default(&args.work_dir);
let settings = SettingsStore::load_or_default(&args.work_dir);
let engine = PushEngine::new(vault.clone(), settings.clone());
tokio::spawn(engine.clone().run());
let state = AppState {
admin: admin.clone(),
vault,
settings,
sessions: da_http_api::auth::SessionStore::default(),
engine,
version: VERSION,
};
let app = da_http_api::router(state);
let listener = tokio::net::TcpListener::bind(args.http_addr)
.await
.with_context(|| format!("could not bind {}", args.http_addr))?;
tracing::info!(
target: "da",
version = VERSION,
addr = %args.http_addr,
work_dir = %args.work_dir.display(),
"Discord Authenticator started"
);
if !admin.is_configured() {
tracing::info!(
target: "da",
"no admin account yet — open the web UI to complete first-run setup"
);
}
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
.context("the HTTP server stopped unexpectedly")?;
tracing::info!(target: "da", "shutting down");
Ok(())
}
/// Wait for Ctrl-C or SIGTERM so `docker stop` exits cleanly instead of
/// waiting out the kill timeout.
async fn shutdown_signal() {
let ctrl_c = async {
let _ = tokio::signal::ctrl_c().await;
};
#[cfg(unix)]
let terminate = async {
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
Ok(mut sig) => {
sig.recv().await;
}
Err(e) => {
tracing::warn!(target: "da", "could not listen for SIGTERM: {e}");
std::future::pending::<()>().await;
}
}
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
() = ctrl_c => {}
() = terminate => {}
}
}
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "da-discord"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
authors.workspace = true
description = "Discord webhook client and the live-tile push engine."
[dependencies]
da-core.workspace = true
da-render.workspace = true
reqwest.workspace = true
tokio.workspace = true
serde.workspace = true
serde_json.workspace = true
tracing.workspace = true
thiserror.workspace = true
parking_lot.workspace = true
[lints]
workspace = true
[dev-dependencies]
axum.workspace = true
tempfile = "3"
+479
View File
@@ -0,0 +1,479 @@
//! Discord webhook transport: create, edit and delete one message, with a
//! rate limiter in front.
//!
//! ## Why the pacing matters
//!
//! TOTP windows are globally aligned — every account's code rolls on the same
//! `floor(unix / period)` boundary. So the naive implementation tries to edit
//! every tile at the same instant, and N accounts become an N-request burst
//! into a bucket that is roughly 5 requests per second per webhook.
//!
//! [`Governor`] serialises every call and spaces them, turning that burst into
//! a steady trickle across the period. Ten accounts spread over ~5 seconds of
//! a 30-second window, which is comfortable rather than borderline.
//!
//! ## Failure classification
//!
//! Discord's error codes distinguish "the message is gone, make a new one"
//! from "this webhook is dead, stop trying". Conflating them is expensive:
//! 10,000 4xx responses in ten minutes gets the host IP Cloudflare-banned, so
//! a hard failure must stop retrying rather than spin.
use std::time::Duration;
use tokio::time::Instant;
/// Minimum spacing between any two calls on one webhook (~2 requests/second),
/// against an observed bucket of about 5/second. The headroom absorbs both
/// the undocumented per-channel write limit and any other client sharing the
/// same channel.
const MIN_INTERVAL: Duration = Duration::from_millis(500);
/// Ceiling on a single request. A hung endpoint must not wedge the loop.
const REQUEST_TIMEOUT: Duration = Duration::from_secs(20);
/// Longest we will ever honour a `retry_after` before giving up on that
/// attempt. Discord should never ask for more than a few seconds here.
const MAX_BACKOFF: Duration = Duration::from_secs(60);
#[derive(Debug, thiserror::Error)]
pub enum DiscordError {
/// The message we were editing no longer exists — someone deleted it.
/// Self-healing: drop the stored id and post a fresh one.
#[error("the Discord message no longer exists")]
MessageGone,
/// The webhook itself is unusable. Needs a human: a new URL, or the
/// integration re-added. Retrying only burns toward the invalid-request
/// ban, so callers must back all the way off.
#[error("{0}")]
Fatal(String),
/// Network blip, 5xx, or a rate limit we couldn't wait out. Worth
/// retrying on the next pass.
#[error("{0}")]
Transient(String),
}
impl DiscordError {
pub fn is_fatal(&self) -> bool {
matches!(self, Self::Fatal(_))
}
}
pub type Result<T> = std::result::Result<T, DiscordError>;
/// Serialises and paces requests to one Discord webhook.
#[derive(Debug)]
struct Governor {
gate: tokio::sync::Mutex<Instant>,
}
impl Governor {
fn new() -> Self {
Self {
gate: tokio::sync::Mutex::new(Instant::now()),
}
}
/// Wait until it's this caller's turn, then hold the lock for the
/// duration of the request so nothing else overtakes.
async fn acquire(&self) -> tokio::sync::MutexGuard<'_, Instant> {
let guard = self.gate.lock().await;
let now = Instant::now();
if *guard > now {
tokio::time::sleep(*guard - now).await;
}
guard
}
}
/// One configured webhook endpoint.
#[derive(Debug, Clone)]
pub struct Webhook {
/// Base URL, trailing slash stripped: `https://discord.com/api/webhooks/{id}/{token}`
base: String,
}
impl Webhook {
pub fn new(url: &str) -> Self {
Self {
base: url.trim().trim_end_matches('/').to_string(),
}
}
/// `?wait=true` is what makes the whole design work: without it Discord
/// returns 204 with an empty body and we never learn the message id, so
/// there would be nothing to edit later.
fn create_url(&self) -> String {
format!("{}?wait=true", self.base)
}
fn message_url(&self, message_id: &str) -> String {
format!("{}/messages/{message_id}", self.base)
}
}
/// What to put in a message. Built by [`crate::message`].
#[derive(Debug, Clone)]
pub struct Payload {
/// The full JSON body. Always complete — a PATCH replaces each top-level
/// field wholesale, and since the embed holds the `attachment://` pointer
/// the entire `embeds` array has to be resent every time.
pub json: serde_json::Value,
/// Optional image to upload as `files[0]`, with the filename the embed's
/// `attachment://` URL refers to. The two must match byte for byte.
pub file: Option<(String, Vec<u8>)>,
}
pub struct DiscordClient {
http: reqwest::Client,
governor: Governor,
}
impl std::fmt::Debug for DiscordClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("DiscordClient")
}
}
impl Default for DiscordClient {
fn default() -> Self {
Self::new()
}
}
impl DiscordClient {
pub fn new() -> Self {
Self {
http: reqwest::Client::builder()
.timeout(REQUEST_TIMEOUT)
.user_agent(concat!(
"DiscordAuthenticator/",
env!("CARGO_PKG_VERSION"),
" (+https://gitea.milesward.dev/mward4/Discord-Authenticator)"
))
.build()
.expect("reqwest client builds with rustls"),
governor: Governor::new(),
}
}
/// Post a new message and return its id.
pub async fn create(&self, hook: &Webhook, payload: &Payload) -> Result<String> {
let body = self
.send(reqwest::Method::POST, &hook.create_url(), payload)
.await?;
let id = body
.get("id")
.and_then(serde_json::Value::as_str)
// Snowflakes exceed 2^53. Reading this as a number would corrupt
// it silently, so we insist on the string Discord actually sends.
.ok_or_else(|| {
DiscordError::Transient("Discord accepted the message but returned no id".into())
})?;
Ok(id.to_string())
}
/// Edit an existing message in place. This is the "live" mechanism.
pub async fn edit(&self, hook: &Webhook, message_id: &str, payload: &Payload) -> Result<()> {
self.send(
reqwest::Method::PATCH,
&hook.message_url(message_id),
payload,
)
.await
.map(|_| ())
}
/// Remove a message — used when an account is paused or deleted.
pub async fn delete(&self, hook: &Webhook, message_id: &str) -> Result<()> {
let mut guard = self.governor.acquire().await;
let url = hook.message_url(message_id);
let resp = self
.http
.delete(&url)
.send()
.await
.map_err(|e| DiscordError::Transient(format!("could not reach Discord: {e}")))?;
*guard = Instant::now() + MIN_INTERVAL;
drop(guard);
// Already gone is a success as far as the caller is concerned.
if resp.status() == reqwest::StatusCode::NOT_FOUND || resp.status().is_success() {
return Ok(());
}
Err(classify(resp.status(), &read_error(resp).await))
}
/// Issue one request, honouring rate limits, with a single retry after a
/// 429. Everything funnels through here so the pacing can't be bypassed.
async fn send(
&self,
method: reqwest::Method,
url: &str,
payload: &Payload,
) -> Result<serde_json::Value> {
let mut guard = self.governor.acquire().await;
for attempt in 0..2 {
let req = self.build(method.clone(), url, payload)?;
let resp = req
.send()
.await
.map_err(|e| DiscordError::Transient(format!("could not reach Discord: {e}")))?;
let status = resp.status();
// Respect the bucket even on success: when the remaining count
// hits zero, hold everything until it resets.
let reset_after = header_f64(resp.headers(), "x-ratelimit-reset-after");
let remaining = header_f64(resp.headers(), "x-ratelimit-remaining");
if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
let body = read_error(resp).await;
// The JSON body's retry_after is a float and more precise
// than the integer Retry-After header.
let wait = body
.retry_after
.or(reset_after)
.map_or(Duration::from_secs(1), Duration::from_secs_f64)
.min(MAX_BACKOFF);
*guard = Instant::now() + wait + MIN_INTERVAL;
if attempt == 0 {
tracing::warn!(
target: "da::discord",
wait_ms = wait.as_millis() as u64,
scope = body.scope.as_deref().unwrap_or("-"),
"rate limited by Discord; backing off"
);
tokio::time::sleep(wait).await;
continue;
}
return Err(DiscordError::Transient(format!(
"rate limited by Discord (retry in {:.1}s)",
wait.as_secs_f64()
)));
}
// Pace the next call: normally a fixed gap, but if this response
// exhausted the bucket, wait out the reset instead.
let gap = match (remaining, reset_after) {
(Some(r), Some(reset)) if r <= 0.0 => {
Duration::from_secs_f64(reset).min(MAX_BACKOFF) + MIN_INTERVAL
}
_ => MIN_INTERVAL,
};
*guard = Instant::now() + gap;
if status.is_success() {
let text = resp.text().await.unwrap_or_default();
return Ok(serde_json::from_str(&text).unwrap_or(serde_json::Value::Null));
}
// 5xx is Discord having a bad day — retry once, then report.
if status.is_server_error() && attempt == 0 {
tokio::time::sleep(Duration::from_secs(2)).await;
continue;
}
return Err(classify(status, &read_error(resp).await));
}
Err(DiscordError::Transient(
"Discord did not accept the request after a retry".into(),
))
}
fn build(
&self,
method: reqwest::Method,
url: &str,
payload: &Payload,
) -> Result<reqwest::RequestBuilder> {
let req = self.http.request(method, url);
match &payload.file {
// Multipart: `payload_json` carries everything non-file, and the
// image goes in `files[0]`, which the embed references by the
// filename set here.
Some((filename, bytes)) => {
let part = reqwest::multipart::Part::bytes(bytes.clone())
.file_name(filename.clone())
.mime_str("image/webp")
.map_err(|e| DiscordError::Transient(format!("bad attachment: {e}")))?;
let form = reqwest::multipart::Form::new()
.text("payload_json", payload.json.to_string())
.part("files[0]", part);
Ok(req.multipart(form))
}
None => Ok(req.json(&payload.json)),
}
}
}
/// The parts of a Discord error body we act on.
#[derive(Debug, Default)]
struct ApiError {
message: Option<String>,
code: Option<i64>,
retry_after: Option<f64>,
scope: Option<String>,
}
async fn read_error(resp: reqwest::Response) -> ApiError {
let scope = resp
.headers()
.get("x-ratelimit-scope")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let text = resp.text().await.unwrap_or_default();
let mut out = ApiError {
scope,
..ApiError::default()
};
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) {
out.message = v
.get("message")
.and_then(serde_json::Value::as_str)
.map(str::to_string);
out.code = v.get("code").and_then(serde_json::Value::as_i64);
out.retry_after = v.get("retry_after").and_then(serde_json::Value::as_f64);
}
if out.message.is_none() && !text.is_empty() {
out.message = Some(text.chars().take(200).collect());
}
out
}
/// Map a Discord status + JSON error code onto our three-way outcome.
///
/// The codes are the load-bearing part; the HTTP status alone can't tell
/// "message deleted" (recoverable) from "webhook deleted" (not).
fn classify(status: reqwest::StatusCode, err: &ApiError) -> DiscordError {
let detail = err
.message
.clone()
.unwrap_or_else(|| format!("HTTP {status}"));
match err.code {
// Unknown Message — the user deleted the tile. Recreate it.
Some(10008) => DiscordError::MessageGone,
// Unknown Webhook / Unknown Channel / invalid token / no permission.
Some(10015) => DiscordError::Fatal(
"the Discord webhook no longer exists — recreate it in Discord and paste the new URL"
.into(),
),
Some(10003) => {
DiscordError::Fatal("the Discord channel for this webhook no longer exists".into())
}
Some(50027) => DiscordError::Fatal(
"the Discord webhook token is invalid — copy the webhook URL again".into(),
),
Some(50013) => {
DiscordError::Fatal("the webhook lacks permission to post in that channel".into())
}
Some(40005) => {
DiscordError::Fatal("the rendered tile is too large for Discord to accept".into())
}
_ => match status.as_u16() {
401 | 403 => DiscordError::Fatal(format!("Discord rejected the webhook: {detail}")),
404 => DiscordError::Fatal(format!("Discord webhook not found: {detail}")),
400 => DiscordError::Fatal(format!("Discord rejected the message: {detail}")),
_ => DiscordError::Transient(format!("Discord returned {status}: {detail}")),
},
}
}
fn header_f64(headers: &reqwest::header::HeaderMap, name: &str) -> Option<f64> {
headers.get(name)?.to_str().ok()?.parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn webhook_urls_are_built_correctly() {
let w = Webhook::new("https://discord.com/api/webhooks/123/tok");
// Without wait=true the POST returns 204 and no id — the tile could
// never be edited afterwards.
assert_eq!(
w.create_url(),
"https://discord.com/api/webhooks/123/tok?wait=true"
);
assert_eq!(
w.message_url("987"),
"https://discord.com/api/webhooks/123/tok/messages/987"
);
}
#[test]
fn trailing_slashes_and_padding_are_tolerated() {
let w = Webhook::new(" https://discord.com/api/webhooks/123/tok/ ");
assert_eq!(
w.message_url("9"),
"https://discord.com/api/webhooks/123/tok/messages/9"
);
}
fn err(code: Option<i64>, msg: &str) -> ApiError {
ApiError {
message: Some(msg.into()),
code,
..ApiError::default()
}
}
#[test]
fn deleted_message_is_recoverable_not_fatal() {
// 10008 must NOT be fatal: the fix is to repost, automatically.
let e = classify(
reqwest::StatusCode::NOT_FOUND,
&err(Some(10008), "Unknown Message"),
);
assert!(matches!(e, DiscordError::MessageGone));
assert!(!e.is_fatal());
}
#[test]
fn dead_webhook_is_fatal() {
// Both arrive as 404; only the code distinguishes them.
for code in [10015, 10003, 50027, 50013] {
let e = classify(reqwest::StatusCode::NOT_FOUND, &err(Some(code), "nope"));
assert!(e.is_fatal(), "code {code} should be fatal");
}
}
#[test]
fn unknown_4xx_is_fatal_and_5xx_is_transient() {
assert!(classify(reqwest::StatusCode::BAD_REQUEST, &err(None, "bad")).is_fatal());
assert!(classify(reqwest::StatusCode::UNAUTHORIZED, &err(None, "no")).is_fatal());
let e = classify(reqwest::StatusCode::BAD_GATEWAY, &err(None, "oops"));
assert!(matches!(e, DiscordError::Transient(_)));
}
#[test]
fn fatal_messages_tell_the_operator_what_to_do() {
let e = classify(
reqwest::StatusCode::NOT_FOUND,
&err(Some(10015), "Unknown Webhook"),
);
let text = e.to_string();
assert!(text.contains("recreate it"), "unhelpful message: {text}");
}
#[tokio::test]
async fn governor_spaces_successive_calls() {
let g = Governor::new();
let start = Instant::now();
{
let mut guard = g.acquire().await;
*guard = Instant::now() + MIN_INTERVAL;
}
// The second acquire must wait out the interval set by the first.
let _guard = g.acquire().await;
assert!(
start.elapsed() >= MIN_INTERVAL,
"expected pacing of at least {MIN_INTERVAL:?}, took {:?}",
start.elapsed()
);
}
}
+219
View File
@@ -0,0 +1,219 @@
//! Keeps one live Discord message per account, edited in place as each code
//! rolls.
//!
//! ## How "live" actually works
//!
//! Discord has no streaming primitive for webhooks, and the obvious ideas
//! don't survive contact with it:
//!
//! * **An externally-hosted image URL** is fetched once by Discord's crawler,
//! cached hard behind `media.discordapp.net`, and — decisively — must be
//! reachable anonymously, which would mean publishing live TOTP codes on an
//! unauthenticated public endpoint. Ruled out on security grounds alone.
//! * **An animated image** can't work either: playback starts whenever the
//! client decodes it, so a 30-second countdown baked into frames drifts out
//! of phase and confidently displays the wrong time remaining.
//!
//! What does work is editing the message. `POST …?wait=true` returns the
//! message id; `PATCH …/messages/{id}` then rewrites it in place, and every
//! client sees the change immediately. So each account owns one message
//! forever, and the engine below rewrites it once per period.
//!
//! The countdown is separate: `<t:{unix}:R>` is re-rendered client-side every
//! second at no cost, so only the digits need a request.
pub mod client;
pub mod message;
pub use client::{DiscordClient, DiscordError, Payload, Webhook};
use da_core::{unix_now, SettingsStore, Vault};
use std::sync::Arc;
use std::time::Duration;
/// How often the engine wakes to look for work. Codes roll on period
/// boundaries, so a one-second cadence puts a tile's edit within a second of
/// its code changing — the remaining latency is the deliberate pacing in
/// [`client`], not this.
const TICK: Duration = Duration::from_secs(1);
/// Owns the push loop.
#[derive(Clone)]
pub struct PushEngine {
vault: Vault,
settings: Arc<SettingsStore>,
client: Arc<DiscordClient>,
/// Guarantees only one [`PushEngine::sync_once`] pass runs at a time.
///
/// Without this, two overlapping passes can both observe a new account
/// with no `message_id` and both POST, leaving two tiles for one account
/// — and only one of them ever gets updated again.
sync_lock: Arc<tokio::sync::Mutex<()>>,
}
impl std::fmt::Debug for PushEngine {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("PushEngine")
}
}
impl PushEngine {
pub fn new(vault: Vault, settings: Arc<SettingsStore>) -> Self {
Self {
vault,
settings,
client: Arc::new(DiscordClient::new()),
sync_lock: Arc::new(tokio::sync::Mutex::new(())),
}
}
/// Run forever. Spawned as a detached task at startup.
pub async fn run(self) {
tracing::info!(target: "da::push", "Discord push engine started");
let mut ticker = tokio::time::interval(TICK);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
ticker.tick().await;
self.sync_once().await;
}
}
/// One pass: bring every tile up to date and tidy away any that shouldn't
/// exist. Returns how many messages were written.
pub async fn sync_once(&self) -> usize {
// Held for the whole pass: see `sync_lock`. A pass can take several
// seconds once the rate limiter spaces things out, which is easily
// long enough for the next tick to arrive.
let _guard = self.sync_lock.lock().await;
let settings = self.settings.snapshot();
if !settings.is_usable() {
return 0;
}
let hook = Webhook::new(&settings.webhook_url);
let mut written = 0;
// Remove tiles belonging to accounts that are no longer pushing.
for (id, message_id) in self.vault.orphaned_messages() {
match self.client.delete(&hook, &message_id).await {
Ok(()) => {
self.vault.clear_message_id(&id);
tracing::info!(target: "da::push", account = %id, "removed paused account's tile");
}
Err(e) => {
tracing::warn!(target: "da::push", account = %id, "could not remove tile: {e}");
}
}
}
let now = unix_now();
for target in self.vault.push_targets() {
let code = target.totp.code_at(now);
// Nothing to do when the tile already shows this code. This is
// what keeps the loop cheap: for most of a 30-second period every
// account short-circuits here.
if target.message_id.is_some() && target.last_code.as_deref() == Some(code.as_str()) {
continue;
}
let expires_at = target.totp.expires_at(now);
// Render only when the mode actually shows an image.
let image = if settings.push_mode.has_image() {
match da_render::tile_webp(&da_render::Tile {
issuer: &target.issuer,
label: &target.label,
code: &code,
period: target.totp.period,
}) {
Ok(bytes) => Some(bytes),
Err(e) => {
// Fall back to a text-only edit rather than letting
// the tile go stale over a rendering problem.
tracing::error!(
target: "da::push",
account = %target.id,
"could not render tile: {e}"
);
None
}
}
} else {
None
};
let payload = message::build(&target, &code, expires_at, settings.push_mode, image);
let result = match &target.message_id {
Some(id) => self
.client
.edit(&hook, id, &payload)
.await
.map(|()| id.clone()),
None => self.client.create(&hook, &payload).await,
};
match result {
Ok(message_id) => {
self.vault.mark_pushed(&target.id, &message_id, &code);
written += 1;
}
Err(DiscordError::MessageGone) => {
// Someone deleted the tile. Forget the id; the next pass
// posts a fresh one, in stable account order.
tracing::info!(
target: "da::push",
account = %target.id,
"tile was deleted in Discord; will repost"
);
self.vault.clear_message_id(&target.id);
}
Err(e) => {
if e.is_fatal() {
tracing::error!(
target: "da::push",
account = %target.id, issuer = %target.issuer,
"push failed permanently: {e}"
);
} else {
tracing::warn!(
target: "da::push",
account = %target.id, issuer = %target.issuer,
"push failed, will retry: {e}"
);
}
self.vault.mark_push_error(&target.id, &e.to_string());
}
}
}
written
}
/// Post a one-off message to prove the webhook works. Backs the
/// "Send test message" button.
pub async fn send_test(&self) -> std::result::Result<(), String> {
let settings = self.settings.snapshot();
if settings.webhook_url.trim().is_empty() {
return Err("no webhook URL is configured".into());
}
let hook = Webhook::new(&settings.webhook_url);
self.client
.create(&hook, &message::test_message())
.await
.map(|_| ())
.map_err(|e| e.to_string())
}
/// Delete one tile, best-effort. Used when an account is removed.
pub async fn remove_message(&self, message_id: &str) {
let settings = self.settings.snapshot();
if settings.webhook_url.trim().is_empty() {
return;
}
let hook = Webhook::new(&settings.webhook_url);
if let Err(e) = self.client.delete(&hook, message_id).await {
tracing::warn!(target: "da::push", "could not remove tile: {e}");
}
}
}
+310
View File
@@ -0,0 +1,310 @@
//! Builds the JSON body for one account's live message.
//!
//! Two things here are load-bearing and easy to get wrong:
//!
//! 1. **The code goes in a fenced block, ungrouped.** Discord renders a copy
//! button on fenced blocks, and what lands on the clipboard is exactly what
//! it contains — so `482913`, not `482 913`. The pretty grouped form lives
//! on the rendered tile, which is for reading, not copying.
//!
//! 2. **The countdown is `<t:…:R>`, not drawn or polled.** Discord's clients
//! re-render relative timestamps every second on their own. That gives a
//! genuinely live countdown for zero API calls, and it can never drift out
//! of phase the way a timer baked into an image would.
use crate::client::Payload;
use da_core::{PushMode, PushTarget};
use std::fmt::Write as _;
/// Compose the message for `target` showing `code`, valid until `expires_at`.
pub fn build(
target: &PushTarget,
code: &str,
expires_at: u64,
mode: PushMode,
image: Option<Vec<u8>>,
) -> Payload {
let mut description = String::new();
if mode.has_text() {
// Fenced, ungrouped: tap-to-copy yields pasteable digits.
description.push_str("```\n");
description.push_str(code);
description.push_str("\n```\n");
}
if !target.label.trim().is_empty() {
description.push_str(&escape_markdown(&target.label));
description.push_str(" · ");
}
// The live half. Discord re-renders this client-side every second.
let _ = write!(description, "rolls <t:{expires_at}:R>");
let mut embed = serde_json::json!({
"title": clip(&target.issuer, 240),
"description": description,
"color": color_for(&target.issuer),
});
// Attachments must be declared exactly: the array is the complete set of
// files that should exist after the edit. Sending an empty array is what
// removes a tile left over from a previous mode — omitting the field
// entirely would instead *append* and the message would accumulate
// images forever.
let (attachments, file) = match image {
Some(bytes) if mode.has_image() => {
// Fresh filename each push. Discord mints a new attachment id (and
// therefore a new CDN URL) regardless, so this isn't strictly
// required for cache-busting — but it costs nothing and makes
// traffic far easier to read when something goes wrong.
let name = format!("code-{expires_at}.webp");
embed["image"] = serde_json::json!({ "url": format!("attachment://{name}") });
(
serde_json::json!([{ "id": "0", "filename": name }]),
Some((name, bytes)),
)
}
_ => (serde_json::json!([]), None),
};
Payload {
json: serde_json::json!({
"embeds": [embed],
"attachments": attachments,
}),
file,
}
}
/// A one-off message used by the "Send test message" button.
pub fn test_message() -> Payload {
Payload {
json: serde_json::json!({
"embeds": [{
"title": "Discord Authenticator",
"description": "Webhook connected. Live codes will appear here, \
one message per account, updated in place as each \
code rolls.",
"color": 0x4f_8a_f5,
}],
"attachments": [],
}),
file: None,
}
}
/// Stable per-issuer colour, matching the tile's accent and the web UI's
/// avatar so one account reads the same in all three places.
fn color_for(issuer: &str) -> u32 {
let mut hash: i32 = 0;
for ch in issuer.chars() {
hash = hash.wrapping_mul(31).wrapping_add(ch as i32);
}
let hue = (hash.unsigned_abs() % 360) as f32;
let (r, g, b) = hsl_rgb(hue, 0.62, 0.58);
(u32::from(r) << 16) | (u32::from(g) << 8) | u32::from(b)
}
#[allow(clippy::many_single_char_names)] // conventional names for colour maths
fn hsl_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
let hp = h / 60.0;
let x = c * (1.0 - (hp % 2.0 - 1.0).abs());
let (r1, g1, b1) = match hp as u32 {
0 => (c, x, 0.0),
1 => (x, c, 0.0),
2 => (0.0, c, x),
3 => (0.0, x, c),
4 => (x, 0.0, c),
_ => (c, 0.0, x),
};
let m = l - c / 2.0;
let to = |v: f32| ((v + m).clamp(0.0, 1.0) * 255.0).round() as u8;
(to(r1), to(g1), to(b1))
}
/// Neutralise Discord markdown in user-supplied text so an account named
/// `**ops**` renders literally instead of turning the line bold.
fn escape_markdown(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
if matches!(ch, '*' | '_' | '~' | '`' | '>' | '|' | '\\' | '[' | ']') {
out.push('\\');
}
out.push(ch);
}
out
}
fn clip(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
s.chars().take(max).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use da_core::{Algorithm, Totp};
fn target(issuer: &str, label: &str) -> PushTarget {
PushTarget {
id: "id1".into(),
issuer: issuer.into(),
label: label.into(),
totp: Totp::new(vec![1, 2, 3, 4, 5], Algorithm::Sha1, 6, 30).unwrap(),
message_id: None,
last_code: None,
}
}
fn description(p: &Payload) -> String {
p.json["embeds"][0]["description"]
.as_str()
.unwrap()
.to_string()
}
#[test]
fn embed_mode_has_copyable_digits_and_no_file() {
let p = build(
&target("Cloudflare", "[email protected]"),
"482913",
1_756_402_860,
PushMode::Embed,
None,
);
let d = description(&p);
// Fenced block containing the bare digits — what the copy button yields.
assert!(d.contains("```\n482913\n```"), "got: {d}");
assert!(!d.contains("482 913"), "grouped form is not copy-pasteable");
assert!(p.file.is_none());
// Empty attachments array clears any tile from a previous mode.
assert_eq!(p.json["attachments"], serde_json::json!([]));
assert!(p.json["embeds"][0].get("image").is_none());
}
#[test]
fn countdown_uses_a_live_discord_timestamp() {
let p = build(
&target("Plex", ""),
"111222",
1_756_402_860,
PushMode::Embed,
None,
);
// This is the whole live-countdown mechanism: the client re-renders
// it every second with no request from us.
assert!(description(&p).contains("<t:1756402860:R>"));
}
#[test]
fn image_mode_wires_the_attachment_to_the_embed() {
let bytes = vec![0u8; 32];
let p = build(
&target("Ubisoft", "u"),
"999888",
1_700_000_000,
PushMode::Image,
Some(bytes),
);
let (name, _) = p.file.as_ref().expect("a file must be attached");
// The embed's attachment:// URL and the uploaded filename must match
// exactly, or Discord shows a broken image.
assert_eq!(
p.json["embeds"][0]["image"]["url"].as_str().unwrap(),
format!("attachment://{name}")
);
assert_eq!(p.json["attachments"][0]["filename"].as_str().unwrap(), name);
// The id must be the files[n] index as a string.
assert_eq!(p.json["attachments"][0]["id"], "0");
// Image-only mode carries no code text.
assert!(!description(&p).contains("999888"));
}
#[test]
fn both_mode_carries_image_and_text() {
let p = build(
&target("X", "y"),
"424242",
1_700_000_000,
PushMode::Both,
Some(vec![0; 8]),
);
assert!(p.file.is_some());
assert!(description(&p).contains("424242"));
}
#[test]
fn image_bytes_without_an_image_mode_are_ignored() {
let p = build(
&target("X", "y"),
"424242",
1_700_000_000,
PushMode::Embed,
Some(vec![0; 8]),
);
assert!(p.file.is_none());
assert_eq!(p.json["attachments"], serde_json::json!([]));
}
#[test]
fn filename_changes_between_periods() {
let a = build(
&target("X", "y"),
"1",
100,
PushMode::Image,
Some(vec![0; 4]),
);
let b = build(
&target("X", "y"),
"2",
130,
PushMode::Image,
Some(vec![0; 4]),
);
assert_ne!(a.file.unwrap().0, b.file.unwrap().0);
}
#[test]
fn markdown_in_names_is_neutralised() {
let p = build(
&target("Corp", "**ops**_team_"),
"1",
1,
PushMode::Embed,
None,
);
let d = description(&p);
assert!(d.contains("\\*\\*ops\\*\\*"), "got: {d}");
}
#[test]
fn colour_is_stable_and_in_range() {
assert_eq!(color_for("Cloudflare"), color_for("Cloudflare"));
assert_ne!(color_for("Cloudflare"), color_for("Plex"));
assert!(color_for("Anything") <= 0xff_ff_ff);
}
#[test]
fn long_issuers_are_clipped_to_discords_title_limit() {
let long = "N".repeat(500);
let p = build(&target(&long, ""), "1", 1, PushMode::Embed, None);
assert!(
p.json["embeds"][0]["title"]
.as_str()
.unwrap()
.chars()
.count()
<= 240
);
}
#[test]
fn empty_label_does_not_leave_a_dangling_separator() {
let d = description(&build(&target("X", " "), "1", 5, PushMode::Embed, None));
assert!(!d.contains(" · "), "got: {d}");
}
}
+463
View File
@@ -0,0 +1,463 @@
//! End-to-end coverage of the live-tile mechanism against a mock Discord.
//!
//! This is the app's central claim — that each account keeps one message in a
//! channel and that message is rewritten in place as the code rolls — so it is
//! worth testing against something that speaks the real protocol rather than
//! only unit-testing the payload builder.
//!
//! The mock implements the three routes we use and records every request, so
//! the assertions can check the things that actually break in production:
//! that the first call carries `?wait=true` (without it there is no message id
//! and nothing could ever be edited), that subsequent calls are PATCHes to
//! that id rather than new posts, and that a deleted message is recovered
//! from rather than retried forever.
use axum::{
extract::{Path, Query},
routing::post,
Json, Router,
};
use da_core::{parse_otpauth, SettingsStore, Vault, VaultKey};
use da_discord::PushEngine;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
#[derive(Debug, Clone)]
struct Recorded {
method: String,
path: String,
query: HashMap<String, String>,
content_type: String,
body: Vec<u8>,
}
impl Recorded {
fn json(&self) -> serde_json::Value {
serde_json::from_slice(&self.body).unwrap_or(serde_json::Value::Null)
}
fn is_multipart(&self) -> bool {
self.content_type.starts_with("multipart/form-data")
}
/// Pull `payload_json` out of a multipart body without a full parser —
/// enough to assert on the embed the tile is attached to.
fn multipart_payload(&self) -> serde_json::Value {
let text = String::from_utf8_lossy(&self.body);
let start = text.find("{\"").or_else(|| text.find('{'));
let Some(start) = start else {
return serde_json::Value::Null;
};
let mut depth = 0usize;
let bytes = text.as_bytes();
for (i, ch) in bytes.iter().enumerate().skip(start) {
match ch {
b'{' => depth += 1,
b'}' => {
depth -= 1;
if depth == 0 {
return serde_json::from_str(&text[start..=i])
.unwrap_or(serde_json::Value::Null);
}
}
_ => {}
}
}
serde_json::Value::Null
}
}
#[derive(Clone)]
struct Mock {
log: Arc<Mutex<Vec<Recorded>>>,
/// When set, every PATCH answers with this Discord error code.
patch_error: Arc<Mutex<Option<i64>>>,
next_id: Arc<Mutex<u64>>,
}
async fn spawn_mock() -> (String, Mock) {
let mock = Mock {
log: Arc::new(Mutex::new(Vec::new())),
patch_error: Arc::new(Mutex::new(None)),
next_id: Arc::new(Mutex::new(1)),
};
fn record(
state: &Mock,
method: &str,
path: String,
query: HashMap<String, String>,
headers: &axum::http::HeaderMap,
body: &axum::body::Bytes,
) {
let content_type = headers
.get(axum::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
state.log.lock().unwrap().push(Recorded {
method: method.to_string(),
path,
query,
content_type,
body: body.to_vec(),
});
}
let create = {
let mock = mock.clone();
move |Path((id, token)): Path<(String, String)>,
Query(q): Query<HashMap<String, String>>,
headers: axum::http::HeaderMap,
body: axum::body::Bytes| {
let mock = mock.clone();
async move {
record(&mock, "POST", format!("/{id}/{token}"), q, &headers, &body);
let mut n = mock.next_id.lock().unwrap();
let msg = format!("90000000000000000{n}");
*n += 1;
// Discord returns the full message object; the id is a string.
Json(serde_json::json!({ "id": msg }))
}
}
};
let edit = {
let mock = mock.clone();
move |Path((id, token, message_id)): Path<(String, String, String)>,
Query(q): Query<HashMap<String, String>>,
headers: axum::http::HeaderMap,
body: axum::body::Bytes| {
let mock = mock.clone();
async move {
record(
&mock,
"PATCH",
format!("/{id}/{token}/messages/{message_id}"),
q,
&headers,
&body,
);
if let Some(code) = *mock.patch_error.lock().unwrap() {
return (
axum::http::StatusCode::NOT_FOUND,
Json(serde_json::json!({ "message": "Unknown Message", "code": code })),
);
}
(
axum::http::StatusCode::OK,
Json(serde_json::json!({ "id": message_id })),
)
}
}
};
let delete = {
let mock = mock.clone();
move |Path((id, token, message_id)): Path<(String, String, String)>,
headers: axum::http::HeaderMap,
body: axum::body::Bytes| {
let mock = mock.clone();
async move {
record(
&mock,
"DELETE",
format!("/{id}/{token}/messages/{message_id}"),
HashMap::new(),
&headers,
&body,
);
axum::http::StatusCode::NO_CONTENT
}
}
};
let app = Router::new()
.route("/api/webhooks/{id}/{token}", post(create))
.route(
"/api/webhooks/{id}/{token}/messages/{message_id}",
axum::routing::patch(edit).delete(delete),
)
.with_state(mock.clone());
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
(format!("http://{addr}/api/webhooks/123/tok"), mock)
}
/// Build a vault + settings pointing at the mock.
///
/// `settings.json` is written directly rather than going through
/// `SettingsStore::replace`, because that validates the URL is a real
/// discord.com address — correct in production, but it would reject the mock.
fn fixture(
dir: &std::path::Path,
webhook: &str,
mode: &str,
period: u64,
) -> (Vault, Arc<SettingsStore>) {
std::fs::write(
dir.join("settings.json"),
serde_json::json!({
"push_enabled": true,
"webhook_url": webhook,
"push_mode": mode,
})
.to_string(),
)
.unwrap();
let vault = Vault::load(dir, VaultKey::from_bytes(&[5u8; 32]).unwrap()).unwrap();
let uri = format!(
"otpauth://totp/Cloudflare:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=Cloudflare&period={period}"
);
vault
.add(&parse_otpauth(&uri).unwrap(), None, None, true)
.unwrap();
(vault, SettingsStore::load_or_default(dir))
}
#[tokio::test]
async fn first_pass_creates_then_later_passes_edit_in_place() {
let dir = tempfile::tempdir().unwrap();
let (url, mock) = spawn_mock().await;
// A one-second period so the code rolls during the test instead of
// needing a 30-second wait.
let (vault, settings) = fixture(dir.path(), &url, "embed", 1);
let engine = PushEngine::new(vault.clone(), settings);
// ── first pass: must POST, and must ask for the message id back ──
assert_eq!(engine.sync_once().await, 1);
{
let log = mock.log.lock().unwrap();
assert_eq!(log.len(), 1);
assert_eq!(log[0].method, "POST");
// Without wait=true Discord replies 204 with no body, and the tile
// could never be edited again. This is the single most important
// detail in the whole design.
assert_eq!(log[0].query.get("wait").map(String::as_str), Some("true"));
}
let message_id = vault.push_targets()[0].message_id.clone().unwrap();
assert!(vault.get(&vault.list()[0].id).unwrap().push_live);
// ── same period: nothing should be sent ──
engine.sync_once().await;
assert_eq!(
mock.log.lock().unwrap().len(),
1,
"an unchanged code must not cost a request"
);
// ── next period: must PATCH the same message, not post a new one ──
tokio::time::sleep(Duration::from_millis(1100)).await;
assert_eq!(engine.sync_once().await, 1);
{
let log = mock.log.lock().unwrap();
assert_eq!(log.len(), 2);
assert_eq!(log[1].method, "PATCH");
assert!(log[1].path.ends_with(&format!("/messages/{message_id}")));
}
// Still exactly one message for this account.
assert_eq!(
vault.push_targets()[0].message_id.as_ref(),
Some(&message_id)
);
}
#[tokio::test]
async fn embed_payload_carries_a_copyable_code_and_a_live_countdown() {
let dir = tempfile::tempdir().unwrap();
let (url, mock) = spawn_mock().await;
let (vault, settings) = fixture(dir.path(), &url, "embed", 30);
let engine = PushEngine::new(vault.clone(), settings);
engine.sync_once().await;
let log = mock.log.lock().unwrap();
let body = log[0].json();
let desc = body["embeds"][0]["description"].as_str().unwrap();
let expected = vault.push_targets()[0].last_code.clone().unwrap();
assert!(
desc.contains(&format!("```\n{expected}\n```")),
"code should be in a fenced block for tap-to-copy: {desc}"
);
assert!(
desc.contains("rolls <t:") && desc.contains(":R>"),
"countdown must use a Discord relative timestamp: {desc}"
);
assert_eq!(body["embeds"][0]["title"], "Cloudflare");
// Embed mode uploads nothing and clears any prior attachment.
assert!(!log[0].is_multipart());
assert_eq!(body["attachments"], serde_json::json!([]));
}
#[tokio::test]
async fn image_mode_uploads_a_webp_wired_to_the_embed() {
let dir = tempfile::tempdir().unwrap();
let (url, mock) = spawn_mock().await;
let (vault, settings) = fixture(dir.path(), &url, "both", 30);
let engine = PushEngine::new(vault, settings);
engine.sync_once().await;
let log = mock.log.lock().unwrap();
assert!(log[0].is_multipart(), "an image push must be multipart");
let payload = log[0].multipart_payload();
let filename = payload["attachments"][0]["filename"].as_str().unwrap();
assert_eq!(payload["attachments"][0]["id"], "0");
assert_eq!(
payload["embeds"][0]["image"]["url"].as_str().unwrap(),
format!("attachment://{filename}"),
"the embed must point at the uploaded file by its exact name"
);
// The body should contain a real RIFF/WEBP payload, not an empty part.
let body = &log[0].body;
let riff = body
.windows(4)
.position(|w| w == b"RIFF")
.expect("no WebP in body");
assert_eq!(&body[riff + 8..riff + 12], b"WEBP");
assert!(
body.len() > 2000,
"tile looks too small: {} bytes",
body.len()
);
}
#[tokio::test]
async fn a_deleted_message_is_reposted_rather_than_retried() {
let dir = tempfile::tempdir().unwrap();
let (url, mock) = spawn_mock().await;
let (vault, settings) = fixture(dir.path(), &url, "embed", 1);
let engine = PushEngine::new(vault.clone(), settings);
engine.sync_once().await;
let first_id = vault.push_targets()[0].message_id.clone().unwrap();
// Someone deletes the tile in Discord: the next edit gets 404 / 10008.
*mock.patch_error.lock().unwrap() = Some(10008);
tokio::time::sleep(Duration::from_millis(1100)).await;
engine.sync_once().await;
// The id must be dropped so the account is recreated, and this must not
// be recorded as an error state for the user to worry about.
assert!(
vault.push_targets()[0].message_id.is_none(),
"a deleted message must clear the stored id"
);
assert!(vault.list()[0].push_error.is_none());
// Next pass posts a fresh message.
*mock.patch_error.lock().unwrap() = None;
engine.sync_once().await;
let second_id = vault.push_targets()[0].message_id.clone().unwrap();
assert_ne!(second_id, first_id);
let log = mock.log.lock().unwrap();
assert_eq!(log.iter().filter(|r| r.method == "POST").count(), 2);
}
#[tokio::test]
async fn a_dead_webhook_is_surfaced_and_not_hammered() {
let dir = tempfile::tempdir().unwrap();
let (url, mock) = spawn_mock().await;
let (vault, settings) = fixture(dir.path(), &url, "embed", 1);
let engine = PushEngine::new(vault.clone(), settings);
engine.sync_once().await;
// 10015 = Unknown Webhook: needs a human, so it must be reported rather
// than silently retried into the invalid-request ban.
*mock.patch_error.lock().unwrap() = Some(10015);
tokio::time::sleep(Duration::from_millis(1100)).await;
engine.sync_once().await;
let account = &vault.list()[0];
assert!(!account.push_live);
let err = account.push_error.as_deref().unwrap_or_default();
assert!(
err.contains("recreate it"),
"operator needs a next step: {err}"
);
// The message id is kept — the tile may still exist; only the webhook died.
assert!(vault.push_targets()[0].message_id.is_some());
}
#[tokio::test]
async fn pausing_an_account_removes_its_tile() {
let dir = tempfile::tempdir().unwrap();
let (url, mock) = spawn_mock().await;
let (vault, settings) = fixture(dir.path(), &url, "embed", 30);
let engine = PushEngine::new(vault.clone(), settings);
engine.sync_once().await;
let id = vault.list()[0].id.clone();
assert!(vault.push_targets()[0].message_id.is_some());
vault.update(&id, None, None, Some(false)).unwrap();
engine.sync_once().await;
let log = mock.log.lock().unwrap();
assert_eq!(
log.iter().filter(|r| r.method == "DELETE").count(),
1,
"a paused account's tile should be taken down"
);
drop(log);
assert!(vault.orphaned_messages().is_empty());
}
#[tokio::test]
async fn several_accounts_are_paced_rather_than_bursted() {
let dir = tempfile::tempdir().unwrap();
let (url, mock) = spawn_mock().await;
let (vault, settings) = fixture(dir.path(), &url, "embed", 30);
// Codes roll simultaneously for every account, so without pacing this
// would be a 5-request burst into a ~5/s bucket.
for n in 0..4 {
let uri =
format!("otpauth://totp/Svc{n}:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=Svc{n}");
vault
.add(&parse_otpauth(&uri).unwrap(), None, None, true)
.unwrap();
}
let engine = PushEngine::new(vault, settings);
let start = std::time::Instant::now();
assert_eq!(engine.sync_once().await, 5);
let elapsed = start.elapsed();
assert_eq!(mock.log.lock().unwrap().len(), 5);
// 5 requests at ~500 ms spacing: at least 2 s of deliberate pacing.
assert!(
elapsed >= Duration::from_millis(1800),
"expected the limiter to spread 5 requests, took only {elapsed:?}"
);
}
#[tokio::test]
async fn nothing_is_sent_while_push_is_disabled() {
let dir = tempfile::tempdir().unwrap();
let (url, mock) = spawn_mock().await;
std::fs::write(
dir.path().join("settings.json"),
serde_json::json!({ "push_enabled": false, "webhook_url": url, "push_mode": "embed" })
.to_string(),
)
.unwrap();
let vault = Vault::load(dir.path(), VaultKey::from_bytes(&[5u8; 32]).unwrap()).unwrap();
vault
.add(
&parse_otpauth("otpauth://totp/X:y?secret=JBSWY3DPEHPK3PXP").unwrap(),
None,
None,
true,
)
.unwrap();
let engine = PushEngine::new(vault, SettingsStore::load_or_default(dir.path()));
assert_eq!(engine.sync_once().await, 0);
assert!(mock.log.lock().unwrap().is_empty());
}
+31
View File
@@ -0,0 +1,31 @@
[package]
name = "da-http-api"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
authors.workspace = true
description = "HTTP API and asset serving for Discord Authenticator."
[dependencies]
da-core.workspace = true
da-discord.workspace = true
da-render.workspace = true
da-webui.workspace = true
axum.workspace = true
tower-http.workspace = true
tokio.workspace = true
serde.workspace = true
serde_json.workspace = true
tracing.workspace = true
uuid.workspace = true
parking_lot.workspace = true
image.workspace = true
rqrr.workspace = true
[lints]
workspace = true
[dev-dependencies]
qrcode = { version = "0.14", default-features = false, features = ["image"] }
+14
View File
@@ -0,0 +1,14 @@
//! Write a QR PNG for manual testing: `cargo run -p da-http-api --example make-qr -- <out.png> <text>`
fn main() {
let mut a = std::env::args().skip(1);
let out = a.next().expect("usage: make-qr <out.png> <text>");
let text = a.next().expect("usage: make-qr <out.png> <text>");
let code = qrcode::QrCode::new(text.as_bytes()).unwrap();
let img = code
.render::<image::Luma<u8>>()
.module_dimensions(8, 8)
.quiet_zone(true)
.build();
image::DynamicImage::ImageLuma8(img).save(&out).unwrap();
println!("wrote {out}");
}
+361
View File
@@ -0,0 +1,361 @@
//! Router, asset serving, and the account / code / settings endpoints.
use crate::auth;
use crate::state::AppState;
use axum::{
extract::{DefaultBodyLimit, Multipart, Path, State},
http::{header, StatusCode},
response::{IntoResponse, Response},
routing::{get, post},
Json, Router,
};
use da_core::{unix_now, unix_now_millis, Settings};
use serde::Deserialize;
use serde_json::json;
/// Cap on an uploaded QR image. Generous enough for a full phone photo,
/// small enough that it can't be used to exhaust memory.
const MAX_UPLOAD: usize = 12 * 1024 * 1024;
pub fn router(state: AppState) -> Router {
let api = Router::new()
.route("/api/me", get(auth::api_me))
.route("/api/setup", post(auth::api_setup))
.route("/api/login", post(auth::api_login))
.route("/api/logout", post(auth::api_logout))
.route("/api/credentials", post(auth::api_update_credentials))
.route("/api/accounts", get(list_accounts).post(add_account))
.route("/api/accounts/preview", post(preview_account))
.route(
"/api/accounts/{id}",
axum::routing::patch(update_account).delete(delete_account),
)
.route("/api/codes", get(list_codes))
.route(
"/api/qr/decode",
post(decode_qr).layer(DefaultBodyLimit::max(MAX_UPLOAD)),
)
.route("/api/settings", get(get_settings).put(put_settings))
.route("/api/settings/test", post(test_webhook));
Router::new()
.merge(api)
.route("/", get(index))
.route("/assets/app.js", get(asset_js))
.route("/assets/app.css", get(asset_css))
.route("/assets/logo.svg", get(asset_logo))
.route("/healthz", get(|| async { "ok" }))
.layer(axum::middleware::from_fn_with_state(
state.clone(),
auth::require_auth,
))
.with_state(state)
}
// ── Assets ────────────────────────────────────────────────────────────────
/// `no-cache, must-revalidate` plus the `?v=` version on every asset URL:
/// together they bound staleness at one release, so a browser can never pair
/// last release's JavaScript with this release's API.
const CACHE: &str = "no-cache, must-revalidate";
async fn index(State(state): State<AppState>) -> Response {
(
[
(header::CONTENT_TYPE, "text/html; charset=utf-8"),
(header::CACHE_CONTROL, CACHE),
],
da_webui::index_html(state.version),
)
.into_response()
}
async fn asset_js() -> Response {
(
[
(header::CONTENT_TYPE, "text/javascript; charset=utf-8"),
(header::CACHE_CONTROL, CACHE),
],
da_webui::app_js(),
)
.into_response()
}
async fn asset_css() -> Response {
(
[
(header::CONTENT_TYPE, "text/css; charset=utf-8"),
(header::CACHE_CONTROL, CACHE),
],
da_webui::app_css(),
)
.into_response()
}
async fn asset_logo() -> Response {
(
[
(header::CONTENT_TYPE, "image/svg+xml"),
(header::CACHE_CONTROL, CACHE),
],
da_webui::logo_svg(),
)
.into_response()
}
// ── Accounts ──────────────────────────────────────────────────────────────
async fn list_accounts(State(state): State<AppState>) -> Response {
Json(state.vault.list()).into_response()
}
#[derive(Debug, Deserialize)]
struct PreviewBody {
uri: String,
}
/// Parse an `otpauth://` URI and report what it contains, without storing it.
///
/// Lets the "add account" flow show the issuer, algorithm and digits for
/// confirmation before anything touches the vault.
async fn preview_account(Json(body): Json<PreviewBody>) -> Response {
match da_core::parse_otpauth(&body.uri) {
Ok(a) => Json(json!({
"issuer": a.issuer,
"label": a.label,
"algorithm": a.totp.algorithm.as_str(),
"digits": a.totp.digits,
"period": a.totp.period,
}))
.into_response(),
Err(e) => crate::error_response(&e),
}
}
#[derive(Debug, Deserialize)]
struct AddBody {
uri: String,
#[serde(default)]
issuer: Option<String>,
#[serde(default)]
label: Option<String>,
#[serde(default = "default_true")]
push_enabled: bool,
}
fn default_true() -> bool {
true
}
async fn add_account(State(state): State<AppState>, Json(body): Json<AddBody>) -> Response {
let parsed = match da_core::parse_otpauth(&body.uri) {
Ok(p) => p,
Err(e) => return crate::error_response(&e),
};
match state.vault.add(
&parsed,
body.issuer.as_deref(),
body.label.as_deref(),
body.push_enabled,
) {
// The push loop picks the new account up on its next tick and posts
// the tile; we deliberately don't kick a sync from here, so there is
// only ever one pass in flight.
Ok(a) => (StatusCode::CREATED, Json(a)).into_response(),
Err(e) => crate::error_response(&e),
}
}
#[derive(Debug, Deserialize)]
struct UpdateBody {
#[serde(default)]
issuer: Option<String>,
#[serde(default)]
label: Option<String>,
#[serde(default)]
push_enabled: Option<bool>,
}
async fn update_account(
State(state): State<AppState>,
Path(id): Path<String>,
Json(body): Json<UpdateBody>,
) -> Response {
match state.vault.update(
&id,
body.issuer.as_deref(),
body.label.as_deref(),
body.push_enabled,
) {
Ok(a) => Json(a).into_response(),
Err(e) => crate::error_response(&e),
}
}
async fn delete_account(State(state): State<AppState>, Path(id): Path<String>) -> Response {
match state.vault.remove(&id) {
Ok(message_id) => {
// Take the tile down too, best-effort and off the response path —
// the account is already gone from the vault either way.
if let Some(mid) = message_id {
let engine = state.engine.clone();
tokio::spawn(async move { engine.remove_message(&mid).await });
}
StatusCode::NO_CONTENT.into_response()
}
Err(e) => crate::error_response(&e),
}
}
// ── Codes ─────────────────────────────────────────────────────────────────
/// Current and next code for every account, plus the server clock.
///
/// Returning the *next* code as well is what lets the browser roll over at a
/// period boundary with no request in flight — one fetch covers two periods,
/// so the digits never blank out. Shipping the server's clock lets the UI
/// drive its countdown off our time rather than the viewing device's, which
/// may be minutes off.
async fn list_codes(State(state): State<AppState>) -> Response {
let now = unix_now();
let codes: Vec<_> = state
.vault
.all_with_totp()
.into_iter()
.map(|(account, totp)| {
json!({
"id": account.id,
"code": totp.code_at(now),
"next": totp.next_code_at(now),
"period": totp.period,
// Milliseconds, to pair with the JS clock.
"expires_at": totp.expires_at(now) * 1000,
})
})
.collect();
(
[(header::CACHE_CONTROL, "no-store")],
Json(json!({ "server_time": unix_now_millis(), "codes": codes })),
)
.into_response()
}
// ── QR ────────────────────────────────────────────────────────────────────
/// Decode an uploaded image and return the first `otpauth://` payload.
///
/// This backs both the file-upload tab and the camera tab's fallback path —
/// browsers without `BarcodeDetector` (Safari and Firefox, so every iPhone)
/// post frames here instead, which keeps the front-end free of a vendored QR
/// library.
async fn decode_qr(mut multipart: Multipart) -> Response {
let mut bytes: Option<Vec<u8>> = None;
loop {
match multipart.next_field().await {
Ok(Some(field)) => {
if field.name() == Some("image") {
match field.bytes().await {
Ok(b) => bytes = Some(b.to_vec()),
Err(e) => {
return bad_request(&format!("could not read the upload: {e}"));
}
}
break;
}
}
Ok(None) => break,
Err(e) => return bad_request(&format!("could not read the upload: {e}")),
}
}
let Some(bytes) = bytes else {
return bad_request("no image was uploaded");
};
match crate::qr::decode(&bytes) {
Ok(found) => {
// An image can hold several codes; prefer an otpauth one over
// whatever else happens to be in frame (a Wi-Fi QR, a URL).
let best = found
.iter()
.find(|s| s.to_ascii_lowercase().starts_with("otpauth"))
.or_else(|| found.first());
match best {
Some(uri) => Json(json!({ "uri": uri })).into_response(),
None => bad_request("no QR code found in that image"),
}
}
Err(e) => bad_request(&e),
}
}
// ── Settings ──────────────────────────────────────────────────────────────
async fn get_settings(State(state): State<AppState>) -> Response {
let s = state.settings.snapshot();
(
// The webhook URL embeds its token, so keep it out of any shared cache.
[(header::CACHE_CONTROL, "no-store")],
Json(json!({
"push_enabled": s.push_enabled,
"webhook_url": s.webhook_url,
"push_mode": s.push_mode,
})),
)
.into_response()
}
#[derive(Debug, Deserialize)]
struct SettingsBody {
#[serde(default)]
push_enabled: bool,
#[serde(default)]
webhook_url: String,
#[serde(default)]
push_mode: da_core::PushMode,
}
async fn put_settings(State(state): State<AppState>, Json(body): Json<SettingsBody>) -> Response {
let incoming = Settings {
push_enabled: body.push_enabled,
webhook_url: body.webhook_url,
push_mode: body.push_mode,
};
match state.settings.replace(incoming) {
Ok((s, webhook_changed)) => {
if webhook_changed {
// Stored message ids belong to the old channel and mean
// nothing in the new one. Dropping them makes the next pass
// post a fresh tile per account.
//
// Tiles already in the old channel are left behind: by the
// time we hear about the change the old webhook may already
// be deleted, so there's nothing reliable to delete them with.
state.vault.clear_all_messages();
tracing::info!(
target: "da::http",
"webhook changed; every tile will be reposted in the new channel"
);
}
Json(json!({
"push_enabled": s.push_enabled,
"webhook_url": s.webhook_url,
"push_mode": s.push_mode,
}))
.into_response()
}
Err(e) => crate::error_response(&e),
}
}
async fn test_webhook(State(state): State<AppState>) -> Response {
match state.engine.send_test().await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => (StatusCode::BAD_GATEWAY, Json(json!({ "error": e }))).into_response(),
}
}
fn bad_request(msg: &str) -> Response {
(StatusCode::BAD_REQUEST, Json(json!({ "error": msg }))).into_response()
}
+413
View File
@@ -0,0 +1,413 @@
//! Forms auth: sessions, the gate over `/api/*`, and the login surface.
//!
//! Three states the front-end switches on:
//!
//! * **Unconfigured** — no admin yet. Everything passes through and `/api/me`
//! reports `setup_required`, which puts the browser into the first-run form.
//! * **Signed in** — the session cookie resolves to a live record.
//! * **Signed out** — `/api/*` returns 401 and the UI shows the login card.
//!
//! Only the auth surface itself is public once an admin exists. Unlike a PXE
//! server, nothing here needs to be readable by an unauthenticated client:
//! this app's entire job is guarding secrets, so the allowlist stays tiny.
use crate::state::AppState;
use axum::{
body::Body,
extract::{Request, State},
http::{header, HeaderValue, StatusCode},
middleware::Next,
response::{IntoResponse, Response},
Json,
};
use da_core::AdminPublic;
use parking_lot::RwLock;
use serde::Deserialize;
use serde_json::json;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
/// Sliding idle lifetime. Every authenticated request pushes it out.
const SESSION_TTL: Duration = Duration::from_secs(24 * 60 * 60);
pub const SESSION_COOKIE: &str = "da_session";
#[derive(Debug, Clone)]
struct Session {
username: String,
expires_at: Instant,
}
/// In-memory session table. Sessions deliberately do not survive a restart —
/// the process holds the vault key, so a restart is a good moment to require
/// the password again.
#[derive(Debug, Clone, Default)]
pub struct SessionStore {
inner: Arc<RwLock<HashMap<String, Session>>>,
}
impl SessionStore {
/// Mint a session token. UUIDv4 gives 122 bits of randomness, comfortably
/// above the 64–128 typical for session identifiers.
pub fn create(&self, username: &str) -> String {
let id = uuid::Uuid::new_v4().simple().to_string();
self.inner.write().insert(
id.clone(),
Session {
username: username.to_string(),
expires_at: Instant::now() + SESSION_TTL,
},
);
id
}
/// Resolve a token and refresh its idle timer. Expired entries are
/// evicted on contact so the map can't grow without bound.
pub fn touch(&self, id: &str) -> Option<String> {
let mut g = self.inner.write();
let s = g.get_mut(id)?;
if s.expires_at <= Instant::now() {
g.remove(id);
return None;
}
s.expires_at = Instant::now() + SESSION_TTL;
Some(s.username.clone())
}
pub fn revoke(&self, id: &str) {
self.inner.write().remove(id);
}
/// Invalidate everything — used after a credential change so a cookie
/// minted under the old password stops working.
pub fn revoke_all(&self) {
self.inner.write().clear();
}
pub fn len(&self) -> usize {
self.inner.read().len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
// ── Cookies ───────────────────────────────────────────────────────────────
fn cookie(value: &str, max_age: i64) -> String {
// HttpOnly keeps the token away from any injected script; SameSite=Lax
// blocks cross-site POSTs while still allowing normal navigation.
//
// No `Secure` flag: Unraid templates default to a plain-http LAN address,
// and setting Secure there would silently drop the cookie and make the
// app unusable. Behind a TLS terminator the cookie still travels
// encrypted; this only means the browser won't *enforce* it.
format!("{SESSION_COOKIE}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}")
}
fn parse_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
let raw = headers.get(header::COOKIE)?.to_str().ok()?;
for part in raw.split(';') {
if let Some(v) = part
.trim()
.strip_prefix(SESSION_COOKIE)
.and_then(|rest| rest.strip_prefix('='))
{
return Some(v.to_string());
}
}
None
}
// ── Middleware ────────────────────────────────────────────────────────────
/// Paths reachable without a session once an admin exists.
fn is_public(path: &str) -> bool {
if !path.starts_with("/api/") {
// The UI shell, its assets, and health probes. The shell itself shows
// nothing sensitive — every byte of vault data comes from /api/*.
return true;
}
matches!(
path,
"/api/setup" | "/api/login" | "/api/logout" | "/api/me"
)
}
pub async fn require_auth(
State(state): State<AppState>,
req: Request<Body>,
next: Next,
) -> Response {
// Before setup there is nobody to authenticate against. `/api/setup`
// refuses to run twice, so this window closes permanently on first use.
if !state.admin.is_configured() {
return next.run(req).await;
}
if is_public(req.uri().path()) {
return next.run(req).await;
}
let ok = parse_cookie(req.headers()).is_some_and(|t| state.sessions.touch(&t).is_some());
if ok {
return next.run(req).await;
}
(
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "authentication required" })),
)
.into_response()
}
// ── Handlers ──────────────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
pub struct SetupBody {
pub username: String,
pub password: String,
#[serde(default)]
pub webhook_url: String,
}
/// First-run setup: create the admin and, optionally, store the webhook in
/// one step so the operator lands on a working install.
pub async fn api_setup(State(state): State<AppState>, Json(body): Json<SetupBody>) -> Response {
if state.admin.is_configured() {
return (
StatusCode::CONFLICT,
Json(json!({ "error": "an admin account already exists" })),
)
.into_response();
}
// Validate the webhook before creating anything, so a typo doesn't leave
// a half-configured install that needs the password to fix.
let webhook = body.webhook_url.trim().to_string();
if !webhook.is_empty() {
if let Err(e) = da_core::validate_webhook_url(&webhook) {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "error": e.to_string() })),
)
.into_response();
}
}
// Argon2 is intentionally expensive; keep it off the async workers.
let admin = state.admin.clone();
let (username, password) = (body.username.clone(), body.password.clone());
let result = tokio::task::spawn_blocking(move || admin.bootstrap(&username, &password))
.await
.unwrap_or_else(|e| Err(da_core::Error::crypto(e)));
match result {
Ok(public) => {
if !webhook.is_empty() {
let mut s = state.settings.snapshot();
s.webhook_url = webhook;
s.push_enabled = true;
if let Err(e) = state.settings.replace(s) {
tracing::warn!(target: "da::http", "could not store webhook at setup: {e}");
}
}
let token = state.sessions.create(&public.username);
session_response(StatusCode::CREATED, &public, &token)
}
Err(e) => crate::error_response(&e),
}
}
#[derive(Debug, Deserialize)]
pub struct LoginBody {
pub username: String,
pub password: String,
}
pub async fn api_login(State(state): State<AppState>, Json(body): Json<LoginBody>) -> Response {
let admin = state.admin.clone();
let verdict = tokio::task::spawn_blocking(move || admin.verify(&body.username, &body.password))
.await
.unwrap_or_else(|e| Err(da_core::Error::crypto(e)));
match verdict {
Ok(Some(public)) => {
let token = state.sessions.create(&public.username);
session_response(StatusCode::OK, &public, &token)
}
// Deliberately vague: no distinction between "no such user" and
// "wrong password", so the endpoint can't be used to enumerate.
Ok(None) => (
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "invalid username or password" })),
)
.into_response(),
Err(e) => crate::error_response(&e),
}
}
pub async fn api_logout(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Response {
if let Some(t) = parse_cookie(&headers) {
state.sessions.revoke(&t);
}
let mut resp = StatusCode::NO_CONTENT.into_response();
resp.headers_mut().insert(
header::SET_COOKIE,
HeaderValue::from_str(&cookie("", 0)).expect("cookie header is ASCII"),
);
resp
}
/// Bootstrap status for the front-end shell.
pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Response {
let version = state.version;
if !state.admin.is_configured() {
return Json(json!({
"setup_required": true,
"authenticated": false,
"version": version,
}))
.into_response();
}
let user = parse_cookie(&headers).and_then(|t| state.sessions.touch(&t));
match user {
Some(_) => Json(json!({
"setup_required": false,
"authenticated": true,
"user": state.admin.snapshot(),
"version": version,
}))
.into_response(),
None => Json(json!({
"setup_required": false,
"authenticated": false,
"version": version,
}))
.into_response(),
}
}
#[derive(Debug, Deserialize)]
pub struct UpdateCredentialsBody {
pub current_password: String,
#[serde(default)]
pub new_username: Option<String>,
#[serde(default)]
pub new_password: Option<String>,
}
/// Rotate the admin credentials. The middleware already proved a valid
/// session; requiring the current password additionally proves the person at
/// the keyboard is the owner and not someone using a borrowed browser.
pub async fn api_update_credentials(
State(state): State<AppState>,
Json(body): Json<UpdateCredentialsBody>,
) -> Response {
let admin = state.admin.clone();
let result = tokio::task::spawn_blocking(move || {
admin.update_credentials(
&body.current_password,
body.new_username.as_deref(),
body.new_password.as_deref(),
)
})
.await
.unwrap_or_else(|e| Err(da_core::Error::crypto(e)));
match result {
Ok(public) => {
// Every prior cookie dies with the old password.
state.sessions.revoke_all();
let token = state.sessions.create(&public.username);
session_response(StatusCode::OK, &public, &token)
}
Err(e) => crate::error_response(&e),
}
}
fn session_response(status: StatusCode, user: &AdminPublic, token: &str) -> Response {
let mut resp = (status, Json(json!({ "authenticated": true, "user": user }))).into_response();
resp.headers_mut().insert(
header::SET_COOKIE,
HeaderValue::from_str(&cookie(token, SESSION_TTL.as_secs().cast_signed()))
.expect("cookie header is ASCII"),
);
resp
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn session_lifecycle() {
let s = SessionStore::default();
assert!(s.is_empty());
let t = s.create("admin");
assert_eq!(s.touch(&t).as_deref(), Some("admin"));
s.revoke(&t);
assert!(s.touch(&t).is_none(), "a revoked token must not resolve");
}
#[test]
fn revoke_all_clears_every_session() {
let s = SessionStore::default();
let a = s.create("one");
let b = s.create("two");
s.revoke_all();
assert!(s.touch(&a).is_none());
assert!(s.touch(&b).is_none());
}
#[test]
fn only_the_auth_surface_is_public() {
for p in [
"/",
"/assets/app.js",
"/healthz",
"/api/setup",
"/api/login",
"/api/me",
] {
assert!(is_public(p), "{p} should be public");
}
// Everything holding vault data must be gated — this is the whole
// point of the app, so the allowlist stays deliberately short.
for p in [
"/api/accounts",
"/api/accounts/abc",
"/api/codes",
"/api/settings",
"/api/qr/decode",
"/api/credentials",
] {
assert!(!is_public(p), "{p} must require auth");
}
}
#[test]
fn cookie_parsing_picks_the_right_value() {
let mut h = axum::http::HeaderMap::new();
h.insert(
header::COOKIE,
HeaderValue::from_str(&format!("a=b; {SESSION_COOKIE}=tok123; c=d")).unwrap(),
);
assert_eq!(parse_cookie(&h).as_deref(), Some("tok123"));
// A cookie whose name merely starts the same must not match.
let mut h2 = axum::http::HeaderMap::new();
h2.insert(
header::COOKIE,
HeaderValue::from_static("da_session_other=nope"),
);
assert!(parse_cookie(&h2).is_none());
assert!(parse_cookie(&axum::http::HeaderMap::new()).is_none());
}
#[test]
fn logout_cookie_expires_immediately() {
assert!(cookie("", 0).contains("Max-Age=0"));
assert!(cookie("x", 60).contains("HttpOnly"));
assert!(cookie("x", 60).contains("SameSite=Lax"));
}
}
+53
View File
@@ -0,0 +1,53 @@
//! HTTP surface: the JSON API, the embedded UI, and the auth gate.
#![forbid(unsafe_code)]
pub mod app;
pub mod auth;
pub mod qr;
pub mod state;
pub use app::router;
pub use state::AppState;
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
/// Map a domain error onto an HTTP response.
///
/// The message is written to be shown verbatim in a toast, so this keeps
/// whatever `da-core` said rather than substituting a generic one —
/// with the exception of crypto failures, whose detail goes to the log
/// instead of the browser.
pub fn error_response(e: &da_core::Error) -> Response {
use da_core::Error;
let (status, message) = match e {
Error::Invalid(m) => (StatusCode::BAD_REQUEST, m.clone()),
Error::NotFound(_) => (StatusCode::NOT_FOUND, e.to_string()),
Error::Conflict(m) => (StatusCode::CONFLICT, m.clone()),
Error::Crypto(detail) => {
tracing::error!(target: "da::http", "cryptography error: {detail}");
(
StatusCode::INTERNAL_SERVER_ERROR,
"the vault could not be read — check the server log".to_string(),
)
}
Error::Io(detail) => {
tracing::error!(target: "da::http", "i/o error: {detail}");
(
StatusCode::INTERNAL_SERVER_ERROR,
"could not write to disk — check the server log".to_string(),
)
}
other => {
tracing::error!(target: "da::http", "unexpected error: {other}");
(
StatusCode::INTERNAL_SERVER_ERROR,
"something went wrong — check the server log".to_string(),
)
}
};
(status, Json(serde_json::json!({ "error": message }))).into_response()
}
+345
View File
@@ -0,0 +1,345 @@
//! Decode a QR code out of an uploaded image.
//!
//! People do not hand you clean 800×800 renders. They hand you a phone photo
//! of a laptop screen, a 180×180 crop out of a support page, or a full 12 MP
//! capture where the code is a thumbnail in the corner. A single decode
//! attempt fails on most of those, so this walks a ladder of cheap
//! transformations until one sticks.
//!
//! Note we deliberately do **not** pre-binarise: `rqrr` (a port of quirc)
//! already runs its own adaptive threshold, and feeding it an
//! already-thresholded image usually makes detection worse, not better.
use image::{DynamicImage, GrayImage};
/// Upper bound on the decoded payload, so a malicious QR can't hand us a
/// megabyte of text to shuttle around.
const MAX_PAYLOAD: usize = 4096;
/// Below this, modules are likely under 2px and quirc struggles; upscaling
/// first materially improves the hit rate on screenshots.
const SMALL_EDGE: u32 = 600;
/// Above this, we're wasting time on pixels that add no information — and
/// sensor noise actively hurts. Phone photos land here.
const LARGE_EDGE: u32 = 1400;
pub type Result<T> = std::result::Result<T, String>;
/// Find the first QR payload in `bytes`.
///
/// Returns every distinct payload found, first one first — a Google
/// Authenticator export screenshot can legitimately carry several codes.
pub fn decode(bytes: &[u8]) -> Result<Vec<String>> {
if bytes.is_empty() {
return Err("the uploaded file is empty".into());
}
reject_unsupported(bytes)?;
let img = image::load_from_memory(bytes)
.map_err(|e| format!("could not read that image ({e}). Try a PNG or JPEG screenshot."))?;
let found = walk_ladder(&img);
if found.is_empty() {
return Err(
"no QR code found in that image. Crop tighter around the code, or use a \
screenshot rather than a photo of a screen."
.into(),
);
}
Ok(found)
}
/// Try progressively more aggressive preparations until something decodes.
fn walk_ladder(img: &DynamicImage) -> Vec<String> {
let gray = img.to_luma8();
let (w, h) = (gray.width(), gray.height());
let long_edge = w.max(h);
// 1. As-is. Most clean screenshots stop here.
let hits = read(gray.clone());
if !hits.is_empty() {
return hits;
}
// 2. Resize toward the range quirc handles best.
let resized = if long_edge < SMALL_EDGE {
// Integer-ish upscale so module edges stay sharp.
let factor = (SMALL_EDGE as f32 / long_edge as f32).ceil().min(6.0) as u32;
Some(image::imageops::resize(
&gray,
w * factor,
h * factor,
image::imageops::FilterType::Lanczos3,
))
} else if long_edge > LARGE_EDGE {
let scale = LARGE_EDGE as f32 / long_edge as f32;
Some(image::imageops::resize(
&gray,
((w as f32 * scale) as u32).max(1),
((h as f32 * scale) as u32).max(1),
image::imageops::FilterType::Lanczos3,
))
} else {
None
};
let base = resized.unwrap_or_else(|| gray.clone());
let hits = read(base.clone());
if !hits.is_empty() {
return hits;
}
// 3. Stretch contrast. Helps dim photos and low-contrast dark-mode
// screenshots where the "black" modules are only mid-grey.
let stretched = autocontrast(&base);
let hits = read(stretched.clone());
if !hits.is_empty() {
return hits;
}
// 4. Rotations. quirc tolerates moderate skew but not a sideways photo.
for turns in 1..4 {
let mut rotated = base.clone();
for _ in 0..turns {
rotated = image::imageops::rotate90(&rotated);
}
let hits = read(rotated);
if !hits.is_empty() {
return hits;
}
}
Vec::new()
}
/// Run the detector and collect every distinct payload.
fn read(gray: GrayImage) -> Vec<String> {
let mut prepared = rqrr::PreparedImage::prepare(gray);
let mut out: Vec<String> = Vec::new();
for grid in prepared.detect_grids() {
// A grid that fails to decode is normal (partially occluded, too
// blurry); the others in the image may still be fine.
if let Ok((_meta, content)) = grid.decode() {
let content = content.trim().to_string();
if !content.is_empty() && content.len() <= MAX_PAYLOAD && !out.contains(&content) {
out.push(content);
}
}
}
out
}
/// Linear contrast stretch over the 2nd–98th percentile.
///
/// Percentiles rather than min/max so one blown-out highlight or a single
/// dark speck can't flatten the whole image.
fn autocontrast(img: &GrayImage) -> GrayImage {
let mut hist = [0u32; 256];
for p in img.pixels() {
hist[p.0[0] as usize] += 1;
}
let total: u32 = hist.iter().sum();
if total == 0 {
return img.clone();
}
let cut = total / 50; // 2%
let mut acc = 0;
let mut lo = 0u8;
for (v, count) in hist.iter().enumerate() {
acc += count;
if acc > cut {
lo = v as u8;
break;
}
}
acc = 0;
let mut hi = 255u8;
for (v, count) in hist.iter().enumerate().rev() {
acc += count;
if acc > cut {
hi = v as u8;
break;
}
}
if hi <= lo {
return img.clone();
}
let span = f32::from(hi) - f32::from(lo);
let mut out = img.clone();
for p in out.pixels_mut() {
let v = (f32::from(p.0[0]) - f32::from(lo)) / span;
p.0[0] = (v.clamp(0.0, 1.0) * 255.0) as u8;
}
out
}
/// Give a useful message for formats we knowingly can't decode, instead of a
/// generic parse failure.
///
/// HEIC matters specifically because it is the iPhone camera default, and
/// there is no pure-Rust HEIC decoder — the C libheif would break the static
/// musl build. Telling someone to screenshot instead is a one-step fix; "could
/// not read that image" would leave them stuck.
fn reject_unsupported(bytes: &[u8]) -> Result<()> {
if bytes.len() >= 12 && &bytes[4..8] == b"ftyp" {
let brand = &bytes[8..12];
if matches!(
brand,
b"heic" | b"heix" | b"hevc" | b"hevx" | b"mif1" | b"msf1" | b"avif"
) {
return Err(
"that looks like a HEIC/AVIF image, which this app can't read. On iPhone, \
take a screenshot instead (screenshots are PNG), or export the photo as JPEG."
.into(),
);
}
}
if bytes.starts_with(b"%PDF") {
return Err("that's a PDF. Export or screenshot the QR code as an image first.".into());
}
if bytes.starts_with(b"<svg") || bytes.starts_with(b"<?xml") {
return Err(
"that's an SVG, which this app can't rasterise. Save or screenshot the QR \
code as a PNG first."
.into(),
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const URI: &str = "otpauth://totp/Test:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=Test";
/// Render `data` as a real QR PNG so these tests exercise the actual
/// detector rather than a mock. `module_px` controls how many pixels each
/// QR module occupies, which is exactly the variable that decides whether
/// the ladder's upscale step is needed.
fn qr_png(data: &str, module_px: u32) -> Vec<u8> {
let code = qrcode::QrCode::new(data.as_bytes()).unwrap();
let img = code
.render::<image::Luma<u8>>()
.module_dimensions(module_px, module_px)
.quiet_zone(true)
.build();
let mut buf = std::io::Cursor::new(Vec::new());
DynamicImage::ImageLuma8(img)
.write_to(&mut buf, image::ImageFormat::Png)
.unwrap();
buf.into_inner()
}
#[test]
fn decodes_a_clean_qr_code() {
let found = decode(&qr_png(URI, 6)).unwrap();
assert_eq!(found, vec![URI.to_string()]);
}
#[test]
fn decodes_a_tiny_qr_via_the_upscale_rung() {
// 1px modules is the small-screenshot case the ladder exists for.
let found = decode(&qr_png(URI, 1)).unwrap();
assert_eq!(found[0], URI);
}
#[test]
fn decodes_an_oversized_qr_via_the_downscale_rung() {
let found = decode(&qr_png(URI, 26)).unwrap();
assert_eq!(found[0], URI);
}
#[test]
fn decodes_a_rotated_qr() {
let png = qr_png(URI, 6);
let img = image::load_from_memory(&png).unwrap();
for turns in 1..4 {
let mut rotated = img.to_luma8();
for _ in 0..turns {
rotated = image::imageops::rotate90(&rotated);
}
let mut buf = std::io::Cursor::new(Vec::new());
DynamicImage::ImageLuma8(rotated)
.write_to(&mut buf, image::ImageFormat::Png)
.unwrap();
let found = decode(&buf.into_inner())
.unwrap_or_else(|e| panic!("rotation {turns}×90° failed: {e}"));
assert_eq!(found[0], URI);
}
}
#[test]
fn decodes_a_jpeg_screenshot() {
// JPEG is what you get from a photo or a re-shared screenshot, and
// its ringing artefacts are exactly what breaks naive decoders.
let img = image::load_from_memory(&qr_png(URI, 8)).unwrap();
let mut buf = std::io::Cursor::new(Vec::new());
img.write_to(&mut buf, image::ImageFormat::Jpeg).unwrap();
assert_eq!(decode(&buf.into_inner()).unwrap()[0], URI);
}
#[test]
fn empty_input_is_rejected() {
assert!(decode(&[]).is_err());
}
#[test]
fn heic_gets_an_actionable_message() {
// ftyp box with a HEIC brand.
let mut b = vec![0u8; 12];
b[4..8].copy_from_slice(b"ftyp");
b[8..12].copy_from_slice(b"heic");
let e = decode(&b).unwrap_err();
assert!(e.contains("screenshot"), "unhelpful: {e}");
}
#[test]
fn pdf_and_svg_get_specific_messages() {
assert!(decode(b"%PDF-1.7 ...").unwrap_err().contains("PDF"));
assert!(decode(b"<svg xmlns=...").unwrap_err().contains("SVG"));
}
#[test]
fn a_non_image_is_reported_clearly() {
let e = decode(b"this is definitely not an image at all").unwrap_err();
assert!(e.contains("could not read"), "got: {e}");
}
#[test]
fn an_image_with_no_qr_says_so() {
// Flat grey 200x200 PNG — decodes fine as an image, contains no code.
let img = GrayImage::from_pixel(200, 200, image::Luma([128]));
let mut buf = std::io::Cursor::new(Vec::new());
DynamicImage::ImageLuma8(img)
.write_to(&mut buf, image::ImageFormat::Png)
.unwrap();
let e = decode(&buf.into_inner()).unwrap_err();
assert!(e.contains("no QR code found"), "got: {e}");
}
#[test]
fn autocontrast_stretches_a_low_contrast_image() {
// All values squeezed into 100..140 should come out spanning wider.
let mut img = GrayImage::new(20, 20);
for (i, p) in img.pixels_mut().enumerate() {
p.0[0] = 100 + (i % 40) as u8;
}
let out = autocontrast(&img);
let lo = out.pixels().map(|p| p.0[0]).min().unwrap();
let hi = out.pixels().map(|p| p.0[0]).max().unwrap();
assert!(hi - lo > 140, "expected a wider range, got {lo}..{hi}");
}
#[test]
fn autocontrast_leaves_a_flat_image_alone() {
// A uniform image has no range to stretch; the guard must not divide
// by zero or blow the pixels out.
let img = GrayImage::from_pixel(10, 10, image::Luma([128]));
let out = autocontrast(&img);
assert!(out.pixels().all(|p| p.0[0] == 128));
}
}
+27
View File
@@ -0,0 +1,27 @@
//! Shared handler state.
use da_core::{AdminStore, SettingsStore, Vault};
use da_discord::PushEngine;
use std::sync::Arc;
use crate::auth::SessionStore;
#[derive(Clone)]
pub struct AppState {
pub admin: AdminStore,
pub vault: Vault,
pub settings: Arc<SettingsStore>,
pub sessions: SessionStore,
pub engine: PushEngine,
pub version: &'static str,
}
#[allow(clippy::missing_fields_in_debug)] // vault + sessions must not be dumped
impl std::fmt::Debug for AppState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AppState")
.field("accounts", &self.vault.len())
.field("configured", &self.admin.is_configured())
.finish()
}
}
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "da-render"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
authors.workspace = true
description = "Renders the live code tile pushed to Discord."
[dependencies]
image.workspace = true
resvg.workspace = true
usvg.workspace = true
tiny-skia.workspace = true
thiserror.workspace = true
tracing.workspace = true
[lints]
workspace = true
Binary file not shown.
@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
+25
View File
@@ -0,0 +1,25 @@
//! Dump sample tiles for eyeballing: `cargo run -p da-render --example preview`.
fn main() {
let out = std::env::args().nth(1).unwrap_or_else(|| ".".into());
for (issuer, label, code, period) in [
("Cloudflare", "[email protected]", "482913", 30),
("Plex", "[email protected]", "071204", 30),
("Ubisoft", "casperadmin", "93810245", 30),
] {
let t = da_render::Tile {
issuer,
label,
code,
period,
};
let png = da_render::tile_png(&t).unwrap();
let webp = da_render::tile_webp(&t).unwrap();
std::fs::write(format!("{out}/tile-{issuer}.png"), &png).unwrap();
std::fs::write(format!("{out}/tile-{issuer}.webp"), &webp).unwrap();
println!(
"{issuer:12} png {:>6} B webp {:>6} B",
png.len(),
webp.len()
);
}
}
+371
View File
@@ -0,0 +1,371 @@
//! Draws the code tile that gets attached to each Discord message.
//!
//! The tile is authored as SVG and rasterised with resvg. Hand-blitting
//! glyphs was the alternative and it loses on every axis that matters here:
//! centring text, rounded corners and the accent gradient are one attribute
//! each in SVG, and arithmetic otherwise.
//!
//! **There is deliberately no countdown drawn on the tile.** An image is a
//! snapshot; by the time someone looks at it, any timer baked into the pixels
//! is stale and confidently wrong. Discord's own `<t:…:R>` relative timestamp
//! ticks live in the client for free, so the countdown belongs in the message
//! text and the tile stays a clean statement of the current code.
#![forbid(unsafe_code)]
use image::ImageEncoder;
use std::sync::OnceLock;
/// Rendered at 2× the nominal display size so the digits stay crisp on
/// retina displays without asking Discord to upscale.
const WIDTH: u32 = 760;
const HEIGHT: u32 = 264;
#[derive(Debug, thiserror::Error)]
pub enum RenderError {
#[error("could not parse the tile SVG: {0}")]
Svg(#[from] usvg::Error),
#[error("could not allocate a {0}×{1} pixmap")]
Pixmap(u32, u32),
#[error("could not encode the tile: {0}")]
Encode(#[from] image::ImageError),
}
pub type Result<T> = std::result::Result<T, RenderError>;
/// What to draw.
#[derive(Debug, Clone)]
pub struct Tile<'a> {
pub issuer: &'a str,
pub label: &'a str,
/// The code, digits only — grouping is applied here, not by the caller.
pub code: &'a str,
pub period: u64,
}
/// Encode the tile as lossless WebP.
///
/// WebP because it's what Discord renders natively and what was asked for.
/// Note that pure-Rust WebP encoding is **lossless-only** — there is no
/// pure-Rust lossy encoder, and the lossy one lives in libwebp (C), which
/// would break the static musl build. That is fine here: the tile is flat
/// colour and large text, exactly the content lossless compresses well, and
/// the result lands in the low tens of kilobytes.
pub fn tile_webp(tile: &Tile<'_>) -> Result<Vec<u8>> {
let rgba = rasterise(tile)?;
let mut out = Vec::new();
image::codecs::webp::WebPEncoder::new_lossless(&mut out).write_image(
&rgba,
WIDTH,
HEIGHT,
image::ExtendedColorType::Rgba8,
)?;
Ok(out)
}
/// Encode the tile as PNG. Kept as a fallback and for tests.
pub fn tile_png(tile: &Tile<'_>) -> Result<Vec<u8>> {
let rgba = rasterise(tile)?;
let mut out = std::io::Cursor::new(Vec::new());
image::codecs::png::PngEncoder::new(&mut out).write_image(
&rgba,
WIDTH,
HEIGHT,
image::ExtendedColorType::Rgba8,
)?;
Ok(out.into_inner())
}
fn rasterise(tile: &Tile<'_>) -> Result<Vec<u8>> {
let svg = build_svg(tile);
let tree = usvg::Tree::from_str(&svg, options())?;
let mut pixmap =
tiny_skia::Pixmap::new(WIDTH, HEIGHT).ok_or(RenderError::Pixmap(WIDTH, HEIGHT))?;
resvg::render(&tree, tiny_skia::Transform::default(), &mut pixmap.as_mut());
// tiny-skia stores premultiplied alpha; PNG and WebP both want
// straight alpha. Skipping this makes every antialiased edge — and the
// rounded corners in particular — render subtly too dark.
let mut rgba = Vec::with_capacity((WIDTH * HEIGHT * 4) as usize);
for px in pixmap.pixels() {
let c = px.demultiply();
rgba.extend_from_slice(&[c.red(), c.green(), c.blue(), c.alpha()]);
}
Ok(rgba)
}
/// Font database and render options, built once.
///
/// `fontdb_mut()` clones the database when it's already shared, so building
/// this per render would copy both faces on every push.
fn options() -> &'static usvg::Options<'static> {
static OPTS: OnceLock<usvg::Options<'static>> = OnceLock::new();
OPTS.get_or_init(|| {
let mut opt = usvg::Options::default();
{
let db = opt.fontdb_mut();
db.load_font_data(FONT_REGULAR.to_vec());
db.load_font_data(FONT_BOLD.to_vec());
}
// Must match the family name inside the TTFs, or resvg falls back to
// its default ("Times New Roman") and finds nothing.
opt.font_family = "JetBrains Mono".to_string();
opt
})
}
const FONT_REGULAR: &[u8] = include_bytes!("../assets/JetBrainsMono-Regular.ttf");
const FONT_BOLD: &[u8] = include_bytes!("../assets/JetBrainsMono-Bold.ttf");
/// Group a code the way every authenticator app does: `123 456`, `1234 5678`.
fn group(code: &str) -> String {
let n = code.chars().count();
if n < 6 {
return code.to_string();
}
let mid = if n == 8 { 4 } else { n.div_ceil(2) };
let (a, b) = code.split_at(mid);
format!("{a} {b}")
}
/// Stable per-issuer accent hue, matching how the web UI colours avatars so
/// a tile in Discord reads as the same account as the row in the browser.
fn hue_for(seed: &str) -> u16 {
let mut hash: i32 = 0;
for ch in seed.chars() {
hash = hash.wrapping_mul(31).wrapping_add(ch as i32);
}
(hash.unsigned_abs() % 360) as u16
}
/// HSL → `#rrggbb`. Saturation and lightness are fixed so every generated
/// accent sits at the same visual weight regardless of hue.
#[allow(clippy::many_single_char_names)] // conventional names for colour maths
fn hsl_hex(h: f32, s: f32, l: f32) -> String {
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
let hp = h / 60.0;
let x = c * (1.0 - (hp % 2.0 - 1.0).abs());
let (r1, g1, b1) = match hp as u32 {
0 => (c, x, 0.0),
1 => (x, c, 0.0),
2 => (0.0, c, x),
3 => (0.0, x, c),
4 => (x, 0.0, c),
_ => (c, 0.0, x),
};
let m = l - c / 2.0;
let to = |v: f32| ((v + m).clamp(0.0, 1.0) * 255.0).round() as u8;
format!("#{:02x}{:02x}{:02x}", to(r1), to(g1), to(b1))
}
/// XML-escape text that lands inside the SVG. Issuer and label are
/// user-controlled, so an unescaped `&` or `<` would produce an SVG that
/// fails to parse — and the tile would silently stop updating.
fn esc(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&apos;"),
// Control characters are not legal XML content at all.
c if (c as u32) < 0x20 && c != '\t' => out.push(' '),
c => out.push(c),
}
}
out
}
/// Truncate to a character budget, with an ellipsis. The tile is fixed
/// width, so an over-long issuer would otherwise run off the edge.
fn clip(s: &str, max: usize) -> String {
let n = s.chars().count();
if n <= max {
return s.to_string();
}
let kept: String = s.chars().take(max.saturating_sub(1)).collect();
format!("{kept}…")
}
fn build_svg(tile: &Tile<'_>) -> String {
let hue = hue_for(if tile.issuer.is_empty() {
tile.label
} else {
tile.issuer
});
let accent = hsl_hex(f32::from(hue), 0.62, 0.58);
let accent_soft = hsl_hex(f32::from(hue), 0.55, 0.42);
let issuer = esc(&clip(tile.issuer, 26));
let label = esc(&clip(tile.label, 34));
let code = esc(&group(tile.code));
// Long codes need to shrink to stay inside the tile. 8 digits plus a
// separator is 9 glyphs against 6+1=7 for the common case.
let code_size = if tile.code.chars().count() >= 8 {
96
} else {
116
};
format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="{WIDTH}" height="{HEIGHT}" viewBox="0 0 {WIDTH} {HEIGHT}">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#1c2027"/>
<stop offset="1" stop-color="#141821"/>
</linearGradient>
<linearGradient id="rail" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="{accent}"/>
<stop offset="1" stop-color="{accent_soft}"/>
</linearGradient>
</defs>
<rect x="0" y="0" width="{WIDTH}" height="{HEIGHT}" rx="28" fill="url(#bg)"/>
<rect x="0.75" y="0.75" width="{ring_w}" height="{ring_h}" rx="27.25"
fill="none" stroke="#ffffff" stroke-opacity="0.10" stroke-width="1.5"/>
<!-- Accent rail, tinted per issuer to match the web UI's avatar colour. -->
<rect x="30" y="34" width="9" height="{rail_h}" rx="4.5" fill="url(#rail)"/>
<text x="62" y="74" font-family="JetBrains Mono" font-weight="700"
font-size="30" fill="#f2f4f8">{issuer}</text>
<text x="62" y="108" font-family="JetBrains Mono" font-weight="400"
font-size="21" fill="#98a2b3">{label}</text>
<text x="62" y="{code_y}" font-family="JetBrains Mono" font-weight="700"
font-size="{code_size}" fill="#ffffff" letter-spacing="4">{code}</text>
<text x="{meta_x}" y="{meta_y}" text-anchor="end" font-family="JetBrains Mono"
font-weight="400" font-size="18" fill="#6b7484">{period}s</text>
</svg>"##,
WIDTH = WIDTH,
HEIGHT = HEIGHT,
ring_w = WIDTH - 1,
ring_h = HEIGHT - 1,
rail_h = HEIGHT - 68,
code_y = HEIGHT - 52,
meta_x = WIDTH - 32,
meta_y = HEIGHT - 30,
period = tile.period,
)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample<'a>(issuer: &'a str, code: &'a str) -> Tile<'a> {
Tile {
issuer,
label: "[email protected]",
code,
period: 30,
}
}
#[test]
fn renders_webp_with_a_valid_header() {
let bytes = tile_webp(&sample("Cloudflare", "123456")).unwrap();
assert!(bytes.len() > 200, "suspiciously small: {}", bytes.len());
// RIFF container with a WEBP fourcc at offset 8.
assert_eq!(&bytes[0..4], b"RIFF");
assert_eq!(&bytes[8..12], b"WEBP");
}
#[test]
fn webp_round_trips_back_to_the_expected_size() {
let bytes = tile_webp(&sample("Plex", "987654")).unwrap();
let decoded = image::load_from_memory(&bytes).expect("decodes as an image");
assert_eq!(decoded.width(), WIDTH);
assert_eq!(decoded.height(), HEIGHT);
}
#[test]
fn renders_png_too() {
let bytes = tile_png(&sample("Ubisoft", "246810")).unwrap();
assert_eq!(&bytes[1..4], b"PNG");
}
#[test]
fn actually_draws_something() {
// A blank canvas would still encode cleanly, so assert the render
// produced non-background pixels — this is what catches the font
// database silently failing to load.
let rgba = rasterise(&sample("Cloudflare", "123456")).unwrap();
let white = rgba
.chunks_exact(4)
.filter(|p| p[0] > 220 && p[1] > 220 && p[2] > 220 && p[3] > 200)
.count();
assert!(
white > 500,
"expected the digits to paint many near-white pixels, saw {white}"
);
}
#[test]
fn code_grouping() {
assert_eq!(group("123456"), "123 456");
assert_eq!(group("12345678"), "1234 5678");
assert_eq!(group("1234567"), "1234 567");
assert_eq!(group("12345"), "12345");
}
#[test]
fn issuer_hue_is_stable_and_varies() {
assert_eq!(hue_for("Cloudflare"), hue_for("Cloudflare"));
assert_ne!(hue_for("Cloudflare"), hue_for("Plex"));
}
#[test]
fn hsl_conversion_spans_the_wheel() {
// Six anchor hues must land in six distinct colours, and stay in range.
let mut seen = std::collections::HashSet::new();
for h in [0.0, 60.0, 120.0, 180.0, 240.0, 300.0] {
let hex = hsl_hex(h, 0.62, 0.58);
assert_eq!(hex.len(), 7, "{hex}");
assert!(hex.starts_with('#'));
seen.insert(hex);
}
assert_eq!(seen.len(), 6);
}
#[test]
fn xml_special_characters_cannot_break_the_svg() {
// An unescaped ampersand here would make the SVG unparseable and the
// account's tile would stop updating with no obvious cause.
let tile = Tile {
issuer: "Ben & Jerry's <Corp>",
label: "a\"b'c & <script>",
code: "111222",
period: 30,
};
assert!(tile_webp(&tile).is_ok());
let svg = build_svg(&tile);
assert!(svg.contains("&amp;"));
assert!(!svg.contains("<script>"));
}
#[test]
fn over_long_names_are_clipped() {
let long = "X".repeat(200);
let tile = Tile {
issuer: &long,
label: &long,
code: "123456",
period: 30,
};
assert!(tile_webp(&tile).is_ok());
assert!(clip(&long, 26).chars().count() <= 26);
}
#[test]
fn eight_digit_and_unicode_content_render() {
assert!(tile_webp(&sample("Steam", "12345678")).is_ok());
assert!(tile_webp(&sample("Ünïcøde ✓ Ω", "123456")).is_ok());
// Empty issuer must not panic — hue falls back to the label.
assert!(tile_webp(&sample("", "123456")).is_ok());
}
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "da-webui"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
authors.workspace = true
description = "Embedded, offline web UI for Discord Authenticator."
[dependencies]
[lints]
workspace = true
+705
View File
@@ -0,0 +1,705 @@
/* Discord Authenticator — web UI.
*
* Visual language follows Beautiful UI (https://www.beautifului.dev/):
* OKLCH ramps, hairline-ring shadows, inset fields / raised buttons,
* Inter + JetBrains Mono. Tokens below were taken from that system and
* extended with the few this app needs (code display, countdown ring).
*
* Fully offline: no CDN, no webfonts fetched at runtime. The font stack
* falls back to the platform UI face, which on every OS Miles runs is
* already a well-tuned grotesque.
*/
/* ── Tokens ───────────────────────────────────────────────────────────── */
:root {
color-scheme: light;
--page: oklch(98.5% .001 286.376);
--canvas: oklch(96.1% .002 247.84);
--surface: oklch(100% 0 0);
--inset: oklch(97.9% .002 247.839);
--hover: oklch(97% .002 247.839);
--hover-2: oklch(93.3% .003 247.86);
--ink: oklch(24.7% .006 258.361);
--ink-2: oklch(50.6% .01 264.477);
--ink-3: oklch(69.5% .009 264.505);
--line: oklch(94.6% .003 264.542);
--line-strong: oklch(91.2% .005 258.326);
--line-soft: oklch(96.6% .002 264.542);
--field: oklch(96.1% .001 286.375);
--stripe: oklch(40.5% 0 0/.075);
--stripe-bg: oklch(97% 0 0);
--accent: oklch(62.6% .205 254.947);
--accent-ink: oklch(55.6% .187 255.617);
--accent-tint: oklch(96% .019 252.878);
--on-accent: oklch(100% 0 0);
--green: oklch(60.3% .155 150.883);
--green-tint: oklch(60.3% .155 150.883/.12);
--orange: oklch(68.9% .179 49.902);
--orange-tint: oklch(68.9% .179 49.902/.12);
--red: oklch(62.1% .192 23.042);
--red-tint: oklch(62.1% .192 23.042/.12);
/* Beautiful UI's signature: hairline ring + very soft graduated drop. */
--shadow-card: 0 0 0 1px oklch(94.6% .003 264.542),
0 18px 47px 0 #00000008, 0 7.5px 19px 0 #00000005,
0 4px 10.5px 0 #00000005, 0 2.3px 5.8px 0 #00000003,
0 1.2px 3.1px 0 #00000003, 0 .5px 1.3px 0 #00000003;
--shadow-btn: 0 0 0 1px oklch(91.2% .005 258.326), 0 0 4px 0 #0000000a;
--shadow-raised: 0 0 0 1px oklch(91.2% .005 258.326), 0 2px 10px #0000000f;
--shadow-overlay: 0 0 0 1px oklch(91.2% .005 258.326), 0 16px 48px #00000021;
--shadow-inset-field: inset 0 1px 2px oklch(0% 0 0/.12);
--ring: 0 0 0 3px var(--accent-tint);
--radius: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--topbar-h: 60px;
--sans: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
system-ui, sans-serif;
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas,
monospace;
--ease: cubic-bezier(.22, 1, .36, 1);
}
:root[data-theme="dark"] {
color-scheme: dark;
--page: oklch(20.9% .004 264.477);
--canvas: oklch(23.1% .004 264.487);
--surface: oklch(26% .006 271.191);
--inset: oklch(24.3% .004 264.492);
--hover: oklch(28.9% .006 271.22);
--hover-2: oklch(31.8% .007 274.747);
--ink: oklch(96.4% .002 247.839);
--ink-2: oklch(73.1% .008 260.731);
--ink-3: oklch(54.1% .01 264.484);
--line: oklch(30.8% .006 258.354);
--line-strong: oklch(35.6% .007 264.474);
--line-soft: oklch(27.8% .006 258.354);
--field: oklch(29.3% .006 271.223);
--stripe: oklch(100% 0 0/.055);
--stripe-bg: oklch(22.6% .004 264.485);
--accent: oklch(68% .173 253.301);
--accent-ink: oklch(78.8% .113 248.33);
--accent-tint: oklch(68% .173 253.301/.16);
--on-accent: oklch(20% .01 253);
--green: oklch(70.5% .154 153.814);
--green-tint: oklch(70.5% .154 153.814/.14);
--orange: oklch(74.6% .156 55.642);
--orange-tint: oklch(74.6% .156 55.642/.14);
--red: oklch(66.6% .18 21.433);
--red-tint: oklch(66.6% .18 21.433/.14);
--shadow-card: 0 0 0 1px oklch(100% 0 0/.11), 0 1px 2px oklch(0% 0 0/.2),
0 2px 6px oklch(0% 0 0/.2);
--shadow-btn: 0 0 0 1px oklch(100% 0 0/.1), 0 1px 2px oklch(0% 0 0/.3);
--shadow-raised: 0 0 0 1px oklch(100% 0 0/.13), 0 2px 10px oklch(0% 0 0/.22);
--shadow-overlay: 0 0 0 1px oklch(100% 0 0/.15), 0 8px 28px oklch(0% 0 0/.34);
--shadow-inset-field: inset 0 1px 2px oklch(0% 0 0/.4);
}
/* ── Base ─────────────────────────────────────────────────────────────── */
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: var(--sans);
font-size: 14px;
line-height: 1.5;
background: var(--page);
color: var(--ink);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
h1, h2, h3 { margin: 0; font-weight: 600; letter-spacing: -0.02em; }
h1 { font-size: 21px; }
h2 { font-size: 17px; }
h3 { font-size: 14px; }
p { margin: 0; }
a { color: var(--accent-ink); text-decoration: none; }
a:hover { text-decoration: underline; }
::selection { background: var(--accent-tint); }
:focus-visible {
outline: none;
box-shadow: var(--ring), var(--shadow-btn);
}
/* Diagonal-stripe backdrop — the Beautiful UI panel treatment. Used
behind the pre-auth screens so setup/login feel like a product, not
a bare form. */
.striped {
background-color: var(--stripe-bg);
background-image: repeating-linear-gradient(
-45deg,
var(--stripe) 0 1px,
transparent 1px 9px
);
}
/* ── Shell ────────────────────────────────────────────────────────────── */
.shell { display: flex; flex-direction: column; min-height: 100vh; }
.topbar {
position: sticky; top: 0; z-index: 30;
display: flex; align-items: center; gap: 12px;
height: var(--topbar-h);
padding: 0 16px;
background: color-mix(in oklab, var(--page) 82%, transparent);
backdrop-filter: saturate(1.6) blur(12px);
border-bottom: 1px solid var(--line);
}
.brand { display: flex; align-items: center; gap: 9px; flex: none; }
.brand svg { width: 26px; height: 26px; display: block; }
.brand strong {
font-size: 14.5px; font-weight: 600; letter-spacing: -0.01em;
white-space: nowrap;
}
@media (max-width: 560px) { .brand strong { display: none; } }
.topbar .spacer { flex: 1; }
.main {
flex: 1;
width: 100%;
max-width: 720px;
margin: 0 auto;
padding: 22px 16px 128px;
}
/* ── Search ───────────────────────────────────────────────────────────── */
.search {
position: relative;
flex: 1 1 auto;
max-width: 420px;
}
.search svg {
position: absolute; left: 10px; top: 50%; transform: translateY(-50%);
width: 15px; height: 15px; color: var(--ink-3); pointer-events: none;
}
.search input {
width: 100%; height: 34px;
padding: 0 30px 0 31px;
font: inherit; font-size: 13.5px;
color: var(--ink);
background: var(--field);
border: 1px solid var(--line-strong);
border-radius: var(--radius);
box-shadow: var(--shadow-inset-field);
transition: box-shadow .15s var(--ease), border-color .15s var(--ease);
}
.search input::placeholder { color: var(--ink-3); }
.search input:focus {
outline: none;
border-color: var(--accent);
box-shadow: var(--shadow-inset-field), var(--ring);
}
.search .clear {
position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
display: none; place-items: center;
width: 20px; height: 20px; padding: 0;
border: 0; border-radius: 5px;
background: transparent; color: var(--ink-3); cursor: pointer;
}
.search .clear:hover { background: var(--hover-2); color: var(--ink); }
.search.has-value .clear { display: grid; }
/* ── Buttons ──────────────────────────────────────────────────────────── */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
height: 34px; padding: 0 13px;
font: inherit; font-size: 13.5px; font-weight: 500;
color: var(--ink);
background: var(--surface);
border: 0; border-radius: var(--radius);
box-shadow: var(--shadow-btn);
cursor: pointer;
white-space: nowrap;
transition: background .14s var(--ease), transform .14s var(--ease),
box-shadow .14s var(--ease);
}
.btn:hover { background: var(--hover); }
.btn:active { transform: translateY(0.5px); }
.btn[disabled] { opacity: .5; pointer-events: none; }
.btn svg { width: 15px; height: 15px; flex: none; }
.btn-primary {
background: var(--accent); color: var(--on-accent);
box-shadow: 0 0 0 1px color-mix(in oklab, var(--accent) 70%, black),
0 1px 2px #00000026;
}
.btn-primary:hover { background: var(--accent-ink); }
.btn-danger { color: var(--red); }
.btn-danger:hover { background: var(--red-tint); }
.btn-ghost { background: transparent; box-shadow: none; color: var(--ink-2); }
.btn-ghost:hover { background: var(--hover-2); color: var(--ink); }
.btn-lg { height: 40px; padding: 0 18px; font-size: 14px; }
.btn-block { width: 100%; }
.icon-btn {
display: grid; place-items: center;
width: 32px; height: 32px; padding: 0;
border: 0; border-radius: var(--radius);
background: transparent; color: var(--ink-2);
cursor: pointer;
transition: background .14s var(--ease), color .14s var(--ease);
}
.icon-btn:hover { background: var(--hover-2); color: var(--ink); }
.icon-btn svg { width: 17px; height: 17px; }
/* Theme toggle shows one glyph per theme. */
.theme-toggle .t-moon { display: none; }
:root[data-theme="dark"] .theme-toggle .t-sun { display: none; }
:root[data-theme="dark"] .theme-toggle .t-moon { display: block; }
/* ── Cards & panels ───────────────────────────────────────────────────── */
.card {
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
}
.card + .card { margin-top: 14px; }
.card-head {
display: flex; align-items: center; gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--line);
}
.card-head h2 { flex: 1; }
.card-body { padding: 16px; }
.card-sub { font-size: 12.5px; color: var(--ink-2); margin-top: 3px; }
/* ── Forms ────────────────────────────────────────────────────────────── */
.field { margin-bottom: 14px; }
.field:last-child { margin-bottom: 0; }
.field label {
display: block; margin-bottom: 6px;
font-size: 12.5px; font-weight: 500; color: var(--ink-2);
}
.field .hint { margin-top: 6px; font-size: 12px; color: var(--ink-3); }
.input, .select, textarea.input {
width: 100%; height: 38px;
padding: 0 11px;
font: inherit; font-size: 13.5px;
color: var(--ink);
background: var(--field);
border: 1px solid var(--line-strong);
border-radius: var(--radius);
box-shadow: var(--shadow-inset-field);
transition: border-color .15s var(--ease), box-shadow .15s var(--ease);
}
textarea.input { height: auto; min-height: 76px; padding: 9px 11px;
resize: vertical; font-family: var(--mono); font-size: 12.5px;
line-height: 1.6; }
.input::placeholder { color: var(--ink-3); }
.input:focus, .select:focus, textarea.input:focus {
outline: none; border-color: var(--accent);
box-shadow: var(--shadow-inset-field), var(--ring);
}
.input[aria-invalid="true"] { border-color: var(--red); }
.select { appearance: none; cursor: pointer;
background-image: linear-gradient(45deg, transparent 50%, var(--ink-3) 50%),
linear-gradient(135deg, var(--ink-3) 50%, transparent 50%);
background-position: calc(100% - 15px) 17px, calc(100% - 10px) 17px;
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
padding-right: 30px;
}
.row { display: flex; gap: 10px; }
.row > * { flex: 1; min-width: 0; }
/* Switch */
.switch { display: flex; align-items: center; gap: 10px; cursor: pointer; }
.switch input { position: absolute; opacity: 0; pointer-events: none; }
.switch .track {
position: relative; flex: none;
width: 34px; height: 20px;
background: var(--hover-2);
border-radius: 999px;
box-shadow: inset 0 0 0 1px var(--line-strong);
transition: background .18s var(--ease);
}
.switch .track::after {
content: ""; position: absolute; top: 2px; left: 2px;
width: 16px; height: 16px;
background: var(--surface); border-radius: 50%;
box-shadow: 0 1px 2px #00000033;
transition: transform .18s var(--ease);
}
.switch input:checked + .track { background: var(--accent); }
.switch input:checked + .track::after { transform: translateX(14px); }
.switch input:focus-visible + .track { box-shadow: inset 0 0 0 1px var(--line-strong), var(--ring); }
.switch .switch-label { font-size: 13.5px; }
/* ── Account list ─────────────────────────────────────────────────────── */
.accounts { display: flex; flex-direction: column; gap: 10px; }
.acct {
display: grid;
grid-template-columns: 38px 1fr auto;
align-items: center;
gap: 13px;
padding: 13px 15px;
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
transition: background .14s var(--ease);
}
.acct:hover { background: var(--hover); }
.acct .avatar {
display: grid; place-items: center;
width: 38px; height: 38px;
border-radius: 10px;
font-size: 15px; font-weight: 600;
color: var(--on-accent);
background: var(--accent);
box-shadow: inset 0 0 0 1px #0000001a;
user-select: none;
}
.acct .meta { min-width: 0; }
.acct .issuer {
font-size: 13.5px; font-weight: 600; letter-spacing: -0.01em;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.acct .label {
font-size: 12px; color: var(--ink-2);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.acct .right { display: flex; align-items: center; gap: 11px; }
/* The code itself — the thing people came for. Tabular figures so the
digits don't shimmy as they change, and a word-gap in the middle the
way every authenticator app renders it. */
.code {
font-family: var(--mono);
font-size: 25px;
font-weight: 500;
letter-spacing: 0.055em;
font-variant-numeric: tabular-nums;
color: var(--ink);
cursor: pointer;
border: 0; background: none; padding: 2px 4px;
border-radius: 6px;
transition: color .2s var(--ease), background .14s var(--ease);
line-height: 1.1;
}
.code:hover { background: var(--hover-2); }
.code .gap { display: inline-block; width: 0.42em; }
/* Last 5 seconds: the code is about to roll. */
.acct.expiring .code { color: var(--orange); }
/* Countdown ring */
.ring { width: 26px; height: 26px; flex: none; transform: rotate(-90deg); }
.ring circle { fill: none; stroke-width: 2.5; }
.ring .bg { stroke: var(--line-strong); }
.ring .fg {
stroke: var(--accent); stroke-linecap: round;
transition: stroke-dashoffset .95s linear, stroke .3s var(--ease);
}
.acct.expiring .ring .fg { stroke: var(--orange); }
/* Discord push status */
.push-dot {
width: 7px; height: 7px; border-radius: 50%; flex: none;
background: var(--ink-3);
}
.push-dot.live { background: var(--green); box-shadow: 0 0 0 3px var(--green-tint); }
.push-dot.error { background: var(--red); box-shadow: 0 0 0 3px var(--red-tint); }
.push-dot.paused { background: var(--ink-3); }
/* ── Chips ────────────────────────────────────────────────────────────── */
.chip {
display: inline-flex; align-items: center; gap: 5px;
height: 22px; padding: 0 8px;
font-size: 11.5px; font-weight: 500;
color: var(--ink-2);
background: var(--inset);
border-radius: 999px;
box-shadow: inset 0 0 0 1px var(--line);
white-space: nowrap;
}
.chip.ok { color: var(--green); background: var(--green-tint); box-shadow: none; }
.chip.warn { color: var(--orange); background: var(--orange-tint); box-shadow: none; }
.chip.err { color: var(--red); background: var(--red-tint); box-shadow: none; }
/* ── Empty state ──────────────────────────────────────────────────────── */
.empty {
display: flex; flex-direction: column; align-items: center;
gap: 5px;
padding: 56px 24px 60px;
text-align: center;
border-radius: var(--radius-xl);
}
.empty .glyph {
display: grid; place-items: center;
width: 60px; height: 60px; margin-bottom: 14px;
border-radius: 18px;
color: var(--accent);
background: var(--accent-tint);
}
.empty .glyph svg { width: 28px; height: 28px; }
.empty h2 { font-size: 16px; }
.empty p { font-size: 13px; color: var(--ink-2); max-width: 330px; }
.empty .btn { margin-top: 16px; }
/* ── Floating action button ───────────────────────────────────────────── */
.fab {
position: fixed;
right: max(20px, calc(50vw - 344px));
bottom: 24px;
z-index: 25;
display: inline-flex; align-items: center; gap: 8px;
height: 46px; padding: 0 19px;
font: inherit; font-size: 14px; font-weight: 500;
color: var(--on-accent);
background: var(--accent);
border: 0; border-radius: 999px;
box-shadow: 0 0 0 1px color-mix(in oklab, var(--accent) 70%, black),
0 6px 20px color-mix(in oklab, var(--accent) 40%, transparent),
0 2px 6px #0000001f;
cursor: pointer;
transition: transform .16s var(--ease), background .16s var(--ease);
}
.fab:hover { background: var(--accent-ink); transform: translateY(-1px); }
.fab:active { transform: translateY(0); }
.fab svg { width: 17px; height: 17px; }
/* ── Modal ────────────────────────────────────────────────────────────── */
.scrim {
position: fixed; inset: 0; z-index: 40;
display: grid; place-items: center;
padding: 20px;
background: oklch(0% 0 0 / .45);
backdrop-filter: blur(3px);
animation: fade .16s var(--ease);
}
.modal {
width: 100%; max-width: 460px;
max-height: calc(100vh - 40px);
display: flex; flex-direction: column;
background: var(--surface);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-overlay);
animation: pop .2s var(--ease);
overflow: hidden;
}
.modal-head {
display: flex; align-items: center; gap: 10px;
padding: 15px 16px;
border-bottom: 1px solid var(--line);
}
.modal-head h2 { flex: 1; }
.modal-body { padding: 16px; overflow-y: auto; }
.modal-foot {
display: flex; justify-content: flex-end; gap: 8px;
padding: 13px 16px;
border-top: 1px solid var(--line);
background: var(--inset);
}
@keyframes fade { from { opacity: 0 } }
@keyframes pop { from { opacity: 0; transform: translateY(8px) scale(.98) } }
/* ── Tabs ─────────────────────────────────────────────────────────────── */
.tabs {
display: flex; gap: 3px;
padding: 3px;
background: var(--inset);
border-radius: 10px;
box-shadow: inset 0 0 0 1px var(--line);
margin-bottom: 16px;
}
.tabs button {
flex: 1;
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
height: 32px; padding: 0 8px;
font: inherit; font-size: 12.5px; font-weight: 500;
color: var(--ink-2);
background: transparent; border: 0; border-radius: 7px;
cursor: pointer;
transition: background .14s var(--ease), color .14s var(--ease);
}
.tabs button svg { width: 14px; height: 14px; }
.tabs button:hover { color: var(--ink); }
.tabs button.active {
color: var(--ink);
background: var(--surface);
box-shadow: var(--shadow-btn);
}
/* ── Dropzone (QR upload) ─────────────────────────────────────────────── */
.dropzone {
display: flex; flex-direction: column; align-items: center; gap: 4px;
padding: 34px 20px;
text-align: center;
color: var(--ink-2);
background: var(--inset);
border: 1.5px dashed var(--line-strong);
border-radius: var(--radius-lg);
cursor: pointer;
transition: border-color .16s var(--ease), background .16s var(--ease);
}
.dropzone:hover, .dropzone.over {
border-color: var(--accent);
background: var(--accent-tint);
}
.dropzone svg { width: 26px; height: 26px; color: var(--ink-3); margin-bottom: 8px; }
.dropzone.over svg { color: var(--accent); }
.dropzone strong { font-size: 13.5px; color: var(--ink); font-weight: 500; }
.dropzone span { font-size: 12px; }
/* Camera scanner */
.scanner {
position: relative;
aspect-ratio: 1;
width: 100%;
border-radius: var(--radius-lg);
overflow: hidden;
background: oklch(15% 0 0);
box-shadow: inset 0 0 0 1px var(--line);
}
.scanner video { width: 100%; height: 100%; object-fit: cover; display: block; }
.scanner .reticle {
position: absolute; inset: 16%;
border-radius: 14px;
box-shadow: 0 0 0 3px oklch(100% 0 0 / .85),
0 0 0 9999px oklch(0% 0 0 / .45);
}
/* ── Toast ────────────────────────────────────────────────────────────── */
.toasts {
position: fixed; left: 50%; bottom: 22px; z-index: 60;
transform: translateX(-50%);
display: flex; flex-direction: column; gap: 8px;
align-items: center;
pointer-events: none;
}
.toast {
display: flex; align-items: center; gap: 8px;
padding: 9px 14px;
font-size: 13px; font-weight: 500;
color: var(--ink);
background: var(--surface);
border-radius: 999px;
box-shadow: var(--shadow-overlay);
animation: toast-in .22s var(--ease);
}
.toast svg { width: 15px; height: 15px; flex: none; }
.toast.ok svg { color: var(--green); }
.toast.err svg { color: var(--red); }
@keyframes toast-in { from { opacity: 0; transform: translateY(10px) } }
/* ── Auth screens (setup / login) ─────────────────────────────────────── */
.auth-wrap {
min-height: 100vh;
display: grid; place-items: center;
padding: 24px;
}
.auth-card {
width: 100%; max-width: 400px;
padding: 28px;
background: var(--surface);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-overlay);
}
.auth-card .brand {
justify-content: center;
margin-bottom: 6px;
}
.auth-card .brand svg { width: 34px; height: 34px; }
.auth-card h1 { text-align: center; font-size: 19px; margin-top: 12px; }
.auth-card .sub {
text-align: center; font-size: 13px; color: var(--ink-2);
margin: 5px 0 22px;
}
.auth-card .btn { margin-top: 6px; }
.alert {
display: flex; align-items: flex-start; gap: 8px;
padding: 10px 12px; margin-bottom: 14px;
font-size: 12.5px; line-height: 1.45;
border-radius: var(--radius);
}
.alert svg { width: 15px; height: 15px; flex: none; margin-top: 1px; }
.alert.err { color: var(--red); background: var(--red-tint); }
.alert.warn { color: var(--orange); background: var(--orange-tint); }
.alert.info { color: var(--ink-2); background: var(--inset);
box-shadow: inset 0 0 0 1px var(--line); }
/* ── Settings ─────────────────────────────────────────────────────────── */
.settings-nav { display: flex; gap: 4px; margin-bottom: 18px; flex-wrap: wrap; }
.kv { display: flex; justify-content: space-between; gap: 16px;
padding: 9px 0; font-size: 13px; }
.kv + .kv { border-top: 1px solid var(--line-soft); }
.kv dt { color: var(--ink-2); }
.kv dd { margin: 0; font-family: var(--mono); font-size: 12.5px;
text-align: right; word-break: break-all; }
/* ── Utility ──────────────────────────────────────────────────────────── */
.mono { font-family: var(--mono); }
.muted { color: var(--ink-2); }
.small { font-size: 12.5px; }
.stack { display: flex; flex-direction: column; gap: 14px; }
.stack-s { display: flex; flex-direction: column; gap: 8px; }
.hidden { display: none !important; }
.sr-only {
position: absolute; width: 1px; height: 1px;
padding: 0; margin: -1px; overflow: hidden;
clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
.spin { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg) } }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .01ms !important;
animation-iteration-count: 1 !important;
transition-duration: .01ms !important;
}
}
File diff suppressed because it is too large. Load diff
+32
View File
@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="light dark" />
<title>Discord Authenticator</title>
<!-- `?v=…` is replaced server-side with the running version so every
upgrade ships brand-new asset URLs; combined with
`Cache-Control: no-cache, must-revalidate` on the asset handlers the
worst-case staleness window is one release. -->
<link rel="stylesheet" href="/assets/app.css?v={{ASSET_VERSION}}" />
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg?v={{ASSET_VERSION}}" />
<!-- Paint the stored theme before first paint to avoid a light→dark flash. -->
<script>
(function () {
try {
var stored = localStorage.getItem('da-theme');
var theme = stored || (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
document.documentElement.setAttribute('data-theme', theme);
} catch (e) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
</script>
</head>
<body>
<div id="root"></div>
<div class="toasts" id="toasts" aria-live="polite"></div>
<script src="/assets/app.js?v={{ASSET_VERSION}}"></script>
</body>
</html>
+38
View File
@@ -0,0 +1,38 @@
//! Offline-only web UI. Every byte the browser needs (HTML, CSS, JS, the
//! brand mark) is compiled into the binary via `include_str!`. No CDN, no
//! remote fonts, no external images — the UI renders identically on an
//! air-gapped LAN, which matters for a box holding TOTP secrets.
//!
//! Visual language follows Beautiful UI (<https://www.beautifului.dev/>):
//! OKLCH ramps, hairline-ring shadows, inset fields and raised buttons.
#![forbid(unsafe_code)]
/// Render the single-page shell.
///
/// `asset_version` is interpolated into every asset URL as `?v=…` so each
/// release ships fresh URLs and no browser or intermediary proxy can keep
/// serving the previous release's `app.js` against this release's API.
#[must_use]
pub fn index_html(asset_version: &str) -> String {
INDEX_HTML.replace("{{ASSET_VERSION}}", asset_version)
}
#[must_use]
pub fn app_js() -> &'static str {
APP_JS
}
#[must_use]
pub fn app_css() -> &'static str {
APP_CSS
}
#[must_use]
pub fn logo_svg() -> &'static str {
LOGO_SVG
}
const INDEX_HTML: &str = include_str!("index.html");
const APP_CSS: &str = include_str!("app.css");
const APP_JS: &str = include_str!("app.js");
const LOGO_SVG: &str = include_str!("logo.svg");
+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none" role="img" aria-label="Discord Authenticator">
<!-- Colours are literal, not `currentColor`: this file is loaded through
<img> and as the favicon, and in both cases `currentColor` resolves
against the SVG's own root rather than the page, which renders the
mark invisible. The blue matches the app's accent in both themes. -->
<path d="M16 2.6 27 6.3v9.1c0 6.6-4.4 12.2-11 14.1-6.6-1.9-11-7.5-11-14.1V6.3L16 2.6Z"
fill="#4f8af5" fill-opacity=".16"/>
<path d="M16 2.6 27 6.3v9.1c0 6.6-4.4 12.2-11 14.1-6.6-1.9-11-7.5-11-14.1V6.3L16 2.6Z"
stroke="#4f8af5" stroke-width="1.9" stroke-linejoin="round"/>
<!-- Open arc: reads as a countdown ring, for the rotating half. -->
<path d="M20.9 12.6a5.9 5.9 0 1 0 1 3.3"
stroke="#4f8af5" stroke-width="2.1" stroke-linecap="round"/>
<circle cx="16" cy="15.9" r="1.85" fill="#4f8af5"/>
</svg>

After

Width:  |  Height:  |  Size: 957 B

+81
View File
@@ -0,0 +1,81 @@
# syntax=docker/dockerfile:1.7
#
# Discord Authenticator — multi-stage build.
#
# build : cross-compiles to x86_64-unknown-linux-musl with cargo-zigbuild.
# runtime : Alpine + the static binary. ~15 MB total.
#
# ## Why cross-compile instead of building for the target platform
#
# The obvious `FROM --platform=linux/amd64 rust` runs the entire compiler
# under QEMU when the builder is an Apple-Silicon Mac. That is roughly 15×
# slower, and the emulated linker intermittently SIGSEGVs part-way through.
#
# Pinning this stage to $BUILDPLATFORM instead runs rustc natively at full
# speed and emits x86_64 via cargo-zigbuild, which uses `zig cc` as the
# cross-linker — zig ships a musl sysroot for every target, so there is no
# cross-gcc toolchain to assemble. Deterministic, and minutes rather than
# half an hour.
ARG RUST_VERSION=1.95
########## build ##########
FROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-bookworm AS build
WORKDIR /src
# zig (via the `ziglang` pip package — cargo-zigbuild discovers it as
# `python3 -m ziglang`) supplies the musl sysroot and the linker.
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 python3-pip \
&& rm -rf /var/lib/apt/lists/* \
&& rustup target add x86_64-unknown-linux-musl \
&& pip3 install --no-cache-dir --break-system-packages ziglang \
&& cargo install --locked cargo-zigbuild
# Deliberately NOT copying rust-toolchain.toml: it pins `channel = "stable"`
# for local development, and inside the image that makes rustup download a
# second full toolchain — slow, and enough to fill a small Colima disk.
COPY Cargo.toml Cargo.lock ./
COPY crates/ crates/
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/src/target,sharing=locked \
cargo zigbuild --release --target x86_64-unknown-linux-musl \
--bin discord-authenticator \
&& cp target/x86_64-unknown-linux-musl/release/discord-authenticator /discord-authenticator \
&& ls -l /discord-authenticator
########## runtime ##########
# Alpine, not Debian: the binary is fully static musl and shells out to
# nothing, and its TLS roots are compiled in via webpki-roots — so the image
# needs no ca-certificates package and no runtime libraries at all. What it
# does need is a shell, for the bind-mount chown in the entrypoint (see there
# for why that matters on Unraid).
FROM alpine:3.21 AS runtime
RUN apk add --no-cache tini su-exec \
&& addgroup -g 10001 -S da \
&& adduser -u 10001 -S -G da -h /var/lib/discord-authenticator da \
&& mkdir -p /var/lib/discord-authenticator \
&& chown -R da:da /var/lib/discord-authenticator
COPY --from=build /discord-authenticator /usr/local/bin/discord-authenticator
COPY deploy/docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/discord-authenticator
# NOT `USER da` — the entrypoint starts as root to fix bind-mount ownership,
# then drops to uid 10001 via su-exec. Its non-root branch handles the case
# where the platform has already assigned a uid.
WORKDIR /var/lib/discord-authenticator
ENV DA_WORK_DIR=/var/lib/discord-authenticator \
DA_HTTP_ADDR=0.0.0.0:8710 \
DA_LOG=info,da=info
EXPOSE 8710/tcp
VOLUME ["/var/lib/discord-authenticator"]
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://127.0.0.1:8710/healthz || exit 1
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/entrypoint.sh"]
+31
View File
@@ -0,0 +1,31 @@
#!/bin/sh
# Fix bind-mount ownership, then drop privileges.
#
# Bind-mounted volumes arrive owned by whatever the host says — on Unraid
# that is typically nobody:users (99:100), and on a plain `docker run -v`
# it is usually root:root. The service runs as uid 10001 and could not
# otherwise write the vault, so when we start as root we chown the data
# directory first and only then hand off.
#
# When the container is already running as non-root (some platforms assign
# their own uid), we skip the chown and exec directly — there is nothing
# useful we could do about permissions from there anyway.
set -e
DA_UID=${DA_UID:-10001}
DA_GID=${DA_GID:-10001}
DATA_DIR=${DA_WORK_DIR:-/var/lib/discord-authenticator}
if [ "$(id -u)" = "0" ]; then
mkdir -p "$DATA_DIR"
chown -R "${DA_UID}:${DA_GID}" "$DATA_DIR" 2>/dev/null || true
# The vault key is the one file that must never be group- or
# world-readable; a permissive umask on the host can otherwise leave it
# readable to anyone who can browse the appdata share.
if [ -f "$DATA_DIR/vault.key" ]; then
chmod 600 "$DATA_DIR/vault.key" 2>/dev/null || true
fi
exec su-exec "${DA_UID}:${DA_GID}" /usr/local/bin/discord-authenticator "$@"
fi
exec /usr/local/bin/discord-authenticator "$@"
+73
View File
@@ -0,0 +1,73 @@
<?xml version="1.0"?>
<!--
Unraid Docker template for Discord Authenticator.
Drop this into /boot/config/plugins/dockerMan/templates-user/ on the Unraid
box, or add the repo as a template repository under Docker → Add Container.
Unlike OpenPXE this needs no host networking — it only makes outbound HTTPS
calls to Discord and serves one HTTP port, so bridge mode is correct.
Web UI: http://<unraid-ip>:8710/
-->
<Container version="2">
<Name>Discord-Authenticator</Name>
<Repository>gitea.milesward.dev/mward4/discord-authenticator:latest</Repository>
<Registry>https://gitea.milesward.dev/mward4/-/packages/container/discord-authenticator</Registry>
<Network>bridge</Network>
<MyIP/>
<Shell>sh</Shell>
<Privileged>false</Privileged>
<Support>https://gitea.milesward.dev/mward4/Discord-Authenticator/issues</Support>
<Project>https://gitea.milesward.dev/mward4/Discord-Authenticator</Project>
<Overview>
Self-hosted TOTP authenticator with live Discord delivery.
Add accounts by uploading a QR code, scanning with the camera, or pasting
an otpauth:// link. The web UI lists every account with its rotating code,
the way Google or Microsoft Authenticator does.
The distinctive part: each account keeps ONE message in your Discord
channel, delivered through an incoming webhook and rewritten in place
every time the code rolls. Three accounts means three live tiles that
keep themselves current. The code is posted both as a rendered WebP tile
and as copyable text, and the countdown uses Discord's own relative
timestamps so it ticks in the client without any extra traffic.
SECURITY NOTE: anyone who can read that Discord channel can read these
codes, which is most of the point of a second factor. Use a private
channel in a server only you are in, and treat the webhook URL as a
secret. TOTP secrets are encrypted at rest with XChaCha20-Poly1305.
</Overview>
<Category>Tools:Utilities Security:</Category>
<WebUI>http://[IP]:[PORT:8710]/</WebUI>
<TemplateURL/>
<Icon>https://gitea.milesward.dev/mward4/Discord-Authenticator/raw/branch/main/crates/webui/src/logo.svg</Icon>
<ExtraParams/>
<PostArgs/>
<CPUset/>
<DateInstalled/>
<DonateText/>
<DonateLink/>
<Requires>
A Discord incoming-webhook URL, from Server Settings → Integrations →
Webhooks → New Webhook → Copy Webhook URL. You can add it during first-run
setup or later in Settings.
</Requires>
<Config Name="WebUI Port" Target="8710" Default="8710" Mode="tcp"
Description="Port for the web UI and API."
Type="Port" Display="always" Required="true" Mask="false">8710</Config>
<Config Name="Data" Target="/var/lib/discord-authenticator"
Default="/mnt/user/appdata/discord-authenticator"
Mode="rw"
Description="Vault, encryption key, admin account and settings. BACK THIS UP — losing it loses every stored TOTP secret."
Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/discord-authenticator</Config>
<Config Name="Master key (optional)" Target="DA_MASTER_KEY" Default=""
Mode=""
Description="Base64 of 32 random bytes. Set this to keep the vault key OFF the appdata share, so a leaked backup of the share is useless on its own. Generate with: openssl rand -base64 32. Leave blank to auto-generate a key file inside the data folder instead. WARNING: if you set this later, existing secrets become unreadable — export them first."
Type="Variable" Display="advanced" Required="false" Mask="true"></Config>
<Config Name="Log filter" Target="DA_LOG" Default="info,da=info"
Mode=""
Description="tracing-subscriber EnvFilter expression. Use info,da=debug for more detail."
Type="Variable" Display="advanced" Required="false" Mask="false">info,da=info</Config>
</Container>
+34
View File
@@ -0,0 +1,34 @@
# Discord Authenticator — single-container deployment.
#
# docker compose up -d
#
# Then open http://<this-host>:8710/ and complete first-run setup.
#
# Unlike a PXE server this needs no special networking: it makes outbound
# HTTPS calls to Discord and serves one HTTP port. Bridge mode is fine.
services:
discord-authenticator:
image: gitea.milesward.dev/mward4/discord-authenticator:latest
container_name: discord-authenticator
restart: unless-stopped
ports:
- "8710:8710"
environment:
DA_HTTP_ADDR: "0.0.0.0:8710"
DA_LOG: "info,da=info"
# Optional. Supply a base64 32-byte key to keep the vault key OFF the
# data volume — then a leaked copy of the volume is useless on its own.
# Generate one with: openssl rand -base64 32
# If unset, a key is generated into the volume as `vault.key` (0600).
# DA_MASTER_KEY: "…"
volumes:
# Vault, key, admin account and settings. Back this up — losing it
# loses every stored TOTP secret.
- ./data/work:/var/lib/discord-authenticator
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8710/healthz"]
interval: 30s
timeout: 3s
retries: 3
start_period: 5s
+75
View File
@@ -0,0 +1,75 @@
# Publishing to Gitea
Everything here needs a Gitea **personal access token**, which is why it isn't
automated: the token should only ever exist in your own shell.
Create one at **Gitea → Settings → Applications → Generate New Token** with
scopes:
- `write:repository` — to create the repo and push
- `write:package` — to push the container image
Export it once per shell session:
```bash
export GITEA_TOKEN='<paste-your-token>'
```
## 1. Create the repository
```bash
curl -s -X POST "https://gitea.milesward.dev/api/v1/user/repos" -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" -d '{"name":"Discord-Authenticator","description":"Self-hosted TOTP authenticator with live Discord delivery","private":true,"auto_init":false}'
```
`"private": true` is deliberate. The repo is harmless on its own, but this is
tooling that handles second factors — there's no reason to publish it by
default. Change it if you decide otherwise.
## 2. Push the code and the tag
```bash
cd "/Users/casperadmin/Discord Authenticator" && git -c "http.extraHeader=Authorization: token $GITEA_TOKEN" push -u origin main --follow-tags
```
That pushes `main` and the `v0.1` tag together.
## 3. Push the container image
The image is already built and tagged locally as
`gitea.milesward.dev/mward4/discord-authenticator:0.1` and `:latest`.
```bash
printf '%s' "$GITEA_TOKEN" | docker login gitea.milesward.dev --username mward4 --password-stdin && docker push gitea.milesward.dev/mward4/discord-authenticator:0.1 && docker push gitea.milesward.dev/mward4/discord-authenticator:latest && docker logout gitea.milesward.dev
```
If the image isn't built (a fresh machine, say), `scripts/build-and-publish-unraid.sh`
does the clone, build, login and push in one pass:
```bash
GITEA_TOKEN="$GITEA_TOKEN" ./scripts/build-and-publish-unraid.sh
```
## 4. Deploy on Unraid
Add a container with Repository set to:
```
gitea.milesward.dev/mward4/discord-authenticator:0.1
```
Since the package is in a private registry, add the Gitea credentials under
Unraid's Docker → Registries first, or the pull will 401.
Map `/var/lib/discord-authenticator` to `/mnt/user/appdata/discord-authenticator`,
publish port `8710`, and leave networking on bridge.
Or import `deploy/unraid/discord-authenticator.xml` into
`/boot/config/plugins/dockerMan/templates-user/` to get all of that prefilled.
## Afterwards
Confirm the token isn't left lying around in your shell history:
```bash
unset GITEA_TOKEN
```
+3
View File
@@ -0,0 +1,3 @@
[toolchain]
channel = "stable"
components = ["rustfmt", "clippy"]
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# build-and-publish-unraid.sh — build the Discord Authenticator image and push
# it to the local Gitea container registry, ready to pull from an Unraid
# template.
#
# Run this on any host that can reach Gitea and has Docker. It clones a fresh
# copy rather than building the working tree, so what ships is exactly what is
# committed.
#
# Inputs (env vars, all optional except GITEA_TOKEN):
# GITEA_TOKEN personal access token with `write:package` scope
# GITEA_HOST default: gitea.milesward.dev
# GITEA_OWNER default: mward4
# GITEA_REPO default: Discord-Authenticator
# IMAGE_NAME default: discord-authenticator
# IMAGE_TAG default: 0.1.0 (also pushed as :latest)
# PLATFORM default: linux/amd64 (Unraid is x86_64)
# GIT_REF default: v0.1 (tag/branch to build)
# WORKDIR default: /tmp/discord-authenticator-build (removed on exit)
#
# After this, set the Unraid template's Repository to:
# <GITEA_HOST>/<owner>/discord-authenticator:<tag> (or :latest)
set -euo pipefail
GITEA_HOST=${GITEA_HOST:-gitea.milesward.dev}
GITEA_OWNER=${GITEA_OWNER:-mward4}
GITEA_REPO=${GITEA_REPO:-Discord-Authenticator}
IMAGE_NAME=${IMAGE_NAME:-discord-authenticator}
IMAGE_TAG=${IMAGE_TAG:-0.1.0}
PLATFORM=${PLATFORM:-linux/amd64}
GIT_REF=${GIT_REF:-v0.1}
WORKDIR=${WORKDIR:-/tmp/discord-authenticator-build}
# OCI distribution rejects uppercase repository paths, so the image name is
# lowercased independently of the repo name.
IMAGE_NAME="$(printf '%s' "$IMAGE_NAME" | tr '[:upper:]' '[:lower:]')"
FULL_TAG="$GITEA_HOST/$GITEA_OWNER/$IMAGE_NAME:$IMAGE_TAG"
LATEST_TAG="$GITEA_HOST/$GITEA_OWNER/$IMAGE_NAME:latest"
if [[ -z "${GITEA_TOKEN:-}" ]]; then
echo "ERROR: set GITEA_TOKEN to a token with write:package scope." >&2
echo " Gitea -> Settings -> Applications -> Generate New Token." >&2
exit 2
fi
for tool in git docker; do
command -v "$tool" >/dev/null 2>&1 \
|| { echo "ERROR: '$tool' not found in PATH" >&2; exit 2; }
done
# A private docker config so the token never lands in ~/.docker/config.json.
# The trap wipes it (and the checkout) on any exit path.
DOCKER_CONFIG="$(mktemp -d)"
export DOCKER_CONFIG
trap 'rm -rf "$DOCKER_CONFIG" "$WORKDIR"' EXIT
echo
echo "==> Workspace : $WORKDIR"
echo "==> Gitea host : $GITEA_HOST"
echo "==> Building ref : $GIT_REF"
echo "==> Image to push : $FULL_TAG"
echo "==> Platform : $PLATFORM"
echo
# Step 1: shallow clone of the exact ref.
rm -rf "$WORKDIR"
git -c "http.extraHeader=Authorization: token $GITEA_TOKEN" \
clone --depth 1 --branch "$GIT_REF" \
"https://$GITEA_HOST/$GITEA_OWNER/$GITEA_REPO.git" "$WORKDIR"
cd "$WORKDIR"
# Step 2: build. --pull so a stale cached base image doesn't bake in a CVE
# that upstream has already fixed.
#
# The Dockerfile cross-compiles from the builder's native architecture, so
# this is fast even when building linux/amd64 on an arm64 host — no QEMU.
docker build \
--pull \
--platform "$PLATFORM" \
--file deploy/docker/Dockerfile \
--tag "$FULL_TAG" \
--tag "$LATEST_TAG" \
.
# Step 3: log in. --password-stdin keeps the token out of `ps` and shell history.
printf '%s' "$GITEA_TOKEN" | docker login \
--username "$GITEA_OWNER" \
--password-stdin \
"$GITEA_HOST"
# Step 4: push both tags.
docker push "$FULL_TAG"
docker push "$LATEST_TAG"
docker logout "$GITEA_HOST" || true
echo
echo "==> Done. Pull from an Unraid template with:"
echo " $FULL_TAG"
echo " $LATEST_TAG"
echo