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:
commit
f33effe20a
46 files changed
+11160
No files matched your search
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+115
@@ -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
|
||||
@@ -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`).
|
||||
@@ -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
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 => {}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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"] }
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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.
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\'' => out.push_str("'"),
|
||||
// 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("&"));
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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>
|
||||
@@ -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");
|
||||
@@ -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 |
@@ -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"]
|
||||
Executable
+31
@@ -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 "$@"
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "stable"
|
||||
components = ["rustfmt", "clippy"]
|
||||
@@ -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
|
||||
Reference in new issue
Block a user