commit f33effe20acf20ea97d40078c3e21c0bc5602046 Author: Miles Ward Date: Fri Aug 28 16:02:07 2026 -0400 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 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 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..66eaea8 --- /dev/null +++ b/.dockerignore @@ -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* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..38590b4 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..7b50d89 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2789 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "axum-macros", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "multer", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "base32" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "da-core" +version = "0.1.0" +dependencies = [ + "argon2", + "base32", + "base64 0.22.1", + "chacha20poly1305", + "hmac", + "parking_lot", + "percent-encoding", + "rand 0.8.8", + "serde", + "serde_json", + "sha1", + "sha2", + "tempfile", + "thiserror", + "time", + "tracing", + "url", + "uuid", + "zeroize", +] + +[[package]] +name = "da-discord" +version = "0.1.0" +dependencies = [ + "axum", + "da-core", + "da-render", + "parking_lot", + "reqwest", + "serde", + "serde_json", + "tempfile", + "thiserror", + "tokio", + "tracing", +] + +[[package]] +name = "da-http-api" +version = "0.1.0" +dependencies = [ + "axum", + "da-core", + "da-discord", + "da-render", + "da-webui", + "image", + "parking_lot", + "qrcode", + "rqrr", + "serde", + "serde_json", + "tokio", + "tower-http", + "tracing", + "uuid", +] + +[[package]] +name = "da-render" +version = "0.1.0" +dependencies = [ + "image", + "resvg", + "thiserror", + "tiny-skia", + "tracing", + "usvg", +] + +[[package]] +name = "da-webui" +version = "0.1.0" + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "discord-authenticator" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "clap", + "da-core", + "da-discord", + "da-http-api", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontdb" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2660c5e9157bf76d2db1294e4a9feba604ef610819a3b591088d0d8392a3290f" +dependencies = [ + "log", + "slotmap", + "tinyvec", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "g2gen" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5a7e0eb46f83a20260b850117d204366674e85d3a908d90865c78df9a6b1dfc" +dependencies = [ + "g2poly", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "g2p" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "539e2644c030d3bf4cd208cb842d2ce2f80e82e6e8472390bcef83ceba0d80ad" +dependencies = [ + "g2gen", + "g2poly", +] + +[[package]] +name = "g2poly" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312d2295c7302019c395cfb90dacd00a82a2eabd700429bba9c7a3f38dbbe11b" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "harfrust" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" +dependencies = [ + "bitflags", + "bytemuck", + "read-fonts", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imagesize" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a" + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" +dependencies = [ + "image", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20 0.10.2", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "resvg" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67e3803f97b999e80cbf7c6ecdd07a8102204d92e1633cf48783720c521196bd" +dependencies = [ + "bytemuck", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + +[[package]] +name = "rqrr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffbe87d9e8db95652c25ded2418150e00b08c2fde09e23ec15896d2c470c6631" +dependencies = [ + "g2p", + "image", + "lru", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "svgtypes" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" +dependencies = [ + "kurbo", + "siphasher", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "tracing", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "usvg" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "977d0a4abdef933f424a99fe09f95576e089b90aebc6f016a3bc813762493e91" +dependencies = [ + "base64 0.23.1", + "data-url", + "fontdb", + "harfrust", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree", + "simplecss", + "siphasher", + "skrifa", + "strict-num", + "svgtypes", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..790bc60 --- /dev/null +++ b/Cargo.toml @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..9d85e25 --- /dev/null +++ b/README.md @@ -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 `` 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://: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= ./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`). diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml new file mode 100644 index 0000000..de753e9 --- /dev/null +++ b/crates/core/Cargo.toml @@ -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 diff --git a/crates/core/src/auth.rs b/crates/core/src/auth.rs new file mode 100644 index 0000000..42f417f --- /dev/null +++ b/crates/core/src/auth.rs @@ -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, +} + +/// 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, + inner: Arc>, +} + +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::(&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 { + 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 { + 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> { + 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 { + 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 { + 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()); + } +} diff --git a/crates/core/src/crypto.rs b/crates/core/src/crypto.rs new file mode 100644 index 0000000..d4d8031 --- /dev/null +++ b/crates/core/src/crypto.rs @@ -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()") + } +} + +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. `/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 { + 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 { + 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 { + 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> { + 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, 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()); + } +} diff --git a/crates/core/src/error.rs b/crates/core/src/error.rs new file mode 100644 index 0000000..b53aec8 --- /dev/null +++ b/crates/core/src/error.rs @@ -0,0 +1,64 @@ +//! One error type for the whole domain core. + +use std::fmt; + +pub type Result = std::result::Result; + +#[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()) + } + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs new file mode 100644 index 0000000..291d983 --- /dev/null +++ b/crates/core/src/lib.rs @@ -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) +} diff --git a/crates/core/src/settings.rs b/crates/core/src/settings.rs new file mode 100644 index 0000000..76ffba8 --- /dev/null +++ b/crates/core/src/settings.rs @@ -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, +} + +impl SettingsStore { + /// Load from `/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 { + let path = work_dir.join("settings.json"); + let initial = match std::fs::read_to_string(&path) { + Ok(text) => serde_json::from_str::(&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()); + } +} diff --git a/crates/core/src/totp.rs b/crates/core/src/totp.rs new file mode 100644 index 0000000..a85e582 --- /dev/null +++ b/crates/core/src/totp.rs @@ -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 = >::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 { + 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, + pub algorithm: Algorithm, + pub digits: u8, + pub period: u64, +} + +impl Totp { + pub fn new(secret: Vec, algorithm: Algorithm, digits: u8, period: u64) -> Result { + 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 = 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 { + 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> { + let cleaned: String = raw + .chars() + .filter(|c| !c.is_whitespace() && *c != '-' && *c != '=') + .collect::() + .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 { + 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:me@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Cloudflare", + ) + .unwrap(); + assert_eq!(a.issuer, "Cloudflare"); + assert_eq!(a.label, "me@example.com"); + 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:foo@bar.com?secret=JBSWY3DPEHPK3PXP&issuer=Microsoft", + ) + .unwrap(); + assert_eq!(a.issuer, "Microsoft"); + assert_eq!(a.label, "foo@bar.com"); + } + + #[test] + fn falls_back_to_the_label_issuer_then_the_account() { + let a = + parse_otpauth("otpauth://totp/Plex:me@example.com?secret=JBSWY3DPEHPK3PXP").unwrap(); + assert_eq!(a.issuer, "Plex"); + + let b = parse_otpauth("otpauth://totp/solo@example.com?secret=JBSWY3DPEHPK3PXP").unwrap(); + assert_eq!(b.issuer, "solo@example.com"); + assert_eq!(b.label, "solo@example.com"); + } + + #[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, "alice@example.com"); + } + + #[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()); + } +} diff --git a/crates/core/src/vault.rs b/crates/core/src/vault.rs new file mode 100644 index 0000000..1c63bf4 --- /dev/null +++ b/crates/core/src/vault.rs @@ -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, + /// Last delivery failure, surfaced in the UI. `None` once a push succeeds. + pub last_error: Option, + /// Code most recently written to Discord — lets the pusher skip an edit + /// when nothing actually changed. + pub last_code: Option, + #[serde(with = "time::serde::rfc3339::option")] + pub last_push_at: Option, +} + +/// 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, + #[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, + pub last_code: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct VaultFile { + #[serde(default)] + accounts: Vec, +} + +#[derive(Clone)] +pub struct Vault { + path: Arc, + key: Arc, + inner: Arc>>, +} + +#[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 `/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 { + let path = work_dir.join("vault.json"); + let accounts = match std::fs::read_to_string(&path) { + Ok(text) => { + serde_json::from_str::(&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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + ) -> Result { + 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> { + 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:me@example.com?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, "me@example.com"); + 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, "me@example.com"); + + 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); + } +} diff --git a/crates/discord-authenticator/Cargo.toml b/crates/discord-authenticator/Cargo.toml new file mode 100644 index 0000000..630ddb8 --- /dev/null +++ b/crates/discord-authenticator/Cargo.toml @@ -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 diff --git a/crates/discord-authenticator/src/main.rs b/crates/discord-authenticator/src/main.rs new file mode 100644 index 0000000..5b7f6b2 --- /dev/null +++ b/crates/discord-authenticator/src/main.rs @@ -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 => {} + } +} diff --git a/crates/discord/Cargo.toml b/crates/discord/Cargo.toml new file mode 100644 index 0000000..df54c10 --- /dev/null +++ b/crates/discord/Cargo.toml @@ -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" diff --git a/crates/discord/src/client.rs b/crates/discord/src/client.rs new file mode 100644 index 0000000..a66bb15 --- /dev/null +++ b/crates/discord/src/client.rs @@ -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 = std::result::Result; + +/// Serialises and paces requests to one Discord webhook. +#[derive(Debug)] +struct Governor { + gate: tokio::sync::Mutex, +} + +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)>, +} + +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 { + 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 { + 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 { + 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, + code: Option, + retry_after: Option, + scope: Option, +} + +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::(&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 { + 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, 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() + ); + } +} diff --git a/crates/discord/src/lib.rs b/crates/discord/src/lib.rs new file mode 100644 index 0000000..fa242c8 --- /dev/null +++ b/crates/discord/src/lib.rs @@ -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: `` 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, + client: Arc, + /// 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>, +} + +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) -> 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}"); + } + } +} diff --git a/crates/discord/src/message.rs b/crates/discord/src/message.rs new file mode 100644 index 0000000..c773024 --- /dev/null +++ b/crates/discord/src/message.rs @@ -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 ``, 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>, +) -> 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 "); + + 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", "me@example.com"), + "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("")); + } + + #[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}"); + } +} diff --git a/crates/discord/tests/live_push.rs b/crates/discord/tests/live_push.rs new file mode 100644 index 0000000..e670829 --- /dev/null +++ b/crates/discord/tests/live_push.rs @@ -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, + content_type: String, + body: Vec, +} + +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>>, + /// When set, every PATCH answers with this Discord error code. + patch_error: Arc>>, + next_id: Arc>, +} + +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, + 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>, + 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>, + 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) { + 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:me@example.com?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 "), + "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}:me@example.com?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()); +} diff --git a/crates/http-api/Cargo.toml b/crates/http-api/Cargo.toml new file mode 100644 index 0000000..195707b --- /dev/null +++ b/crates/http-api/Cargo.toml @@ -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"] } diff --git a/crates/http-api/examples/make-qr.rs b/crates/http-api/examples/make-qr.rs new file mode 100644 index 0000000..c8b2ba2 --- /dev/null +++ b/crates/http-api/examples/make-qr.rs @@ -0,0 +1,14 @@ +//! Write a QR PNG for manual testing: `cargo run -p da-http-api --example make-qr -- ` +fn main() { + let mut a = std::env::args().skip(1); + let out = a.next().expect("usage: make-qr "); + let text = a.next().expect("usage: make-qr "); + let code = qrcode::QrCode::new(text.as_bytes()).unwrap(); + let img = code + .render::>() + .module_dimensions(8, 8) + .quiet_zone(true) + .build(); + image::DynamicImage::ImageLuma8(img).save(&out).unwrap(); + println!("wrote {out}"); +} diff --git a/crates/http-api/src/app.rs b/crates/http-api/src/app.rs new file mode 100644 index 0000000..81b7a6b --- /dev/null +++ b/crates/http-api/src/app.rs @@ -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) -> 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) -> 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) -> 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, + #[serde(default)] + label: Option, + #[serde(default = "default_true")] + push_enabled: bool, +} + +fn default_true() -> bool { + true +} + +async fn add_account(State(state): State, Json(body): Json) -> 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, + #[serde(default)] + label: Option, + #[serde(default)] + push_enabled: Option, +} + +async fn update_account( + State(state): State, + Path(id): Path, + Json(body): Json, +) -> 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, Path(id): Path) -> 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) -> 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> = 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) -> 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, Json(body): Json) -> 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) -> 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() +} diff --git a/crates/http-api/src/auth.rs b/crates/http-api/src/auth.rs new file mode 100644 index 0000000..af97bd8 --- /dev/null +++ b/crates/http-api/src/auth.rs @@ -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>>, +} + +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 { + 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 { + 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, + req: Request, + 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, Json(body): Json) -> 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, Json(body): Json) -> 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, 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, 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, + #[serde(default)] + pub new_password: Option, +} + +/// 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, + Json(body): Json, +) -> 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")); + } +} diff --git a/crates/http-api/src/lib.rs b/crates/http-api/src/lib.rs new file mode 100644 index 0000000..f073775 --- /dev/null +++ b/crates/http-api/src/lib.rs @@ -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() +} diff --git a/crates/http-api/src/qr.rs b/crates/http-api/src/qr.rs new file mode 100644 index 0000000..260530f --- /dev/null +++ b/crates/http-api/src/qr.rs @@ -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 = std::result::Result; + +/// 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> { + 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 { + 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 { + let mut prepared = rqrr::PreparedImage::prepare(gray); + let mut out: Vec = 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" Vec { + let code = qrcode::QrCode::new(data.as_bytes()).unwrap(); + let img = code + .render::>() + .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" 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)); + } +} diff --git a/crates/http-api/src/state.rs b/crates/http-api/src/state.rs new file mode 100644 index 0000000..2135e26 --- /dev/null +++ b/crates/http-api/src/state.rs @@ -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, + 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() + } +} diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml new file mode 100644 index 0000000..f792071 --- /dev/null +++ b/crates/render/Cargo.toml @@ -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 diff --git a/crates/render/assets/JetBrainsMono-Bold.ttf b/crates/render/assets/JetBrainsMono-Bold.ttf new file mode 100644 index 0000000..8c93043 Binary files /dev/null and b/crates/render/assets/JetBrainsMono-Bold.ttf differ diff --git a/crates/render/assets/JetBrainsMono-OFL.txt b/crates/render/assets/JetBrainsMono-OFL.txt new file mode 100644 index 0000000..8bee414 --- /dev/null +++ b/crates/render/assets/JetBrainsMono-OFL.txt @@ -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. diff --git a/crates/render/assets/JetBrainsMono-Regular.ttf b/crates/render/assets/JetBrainsMono-Regular.ttf new file mode 100644 index 0000000..dff66cc Binary files /dev/null and b/crates/render/assets/JetBrainsMono-Regular.ttf differ diff --git a/crates/render/examples/preview.rs b/crates/render/examples/preview.rs new file mode 100644 index 0000000..7fade18 --- /dev/null +++ b/crates/render/examples/preview.rs @@ -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", "miles@milesward.dev", "482913", 30), + ("Plex", "miles@milesward.dev", "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() + ); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs new file mode 100644 index 0000000..3a926f9 --- /dev/null +++ b/crates/render/src/lib.rs @@ -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 `` 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 = std::result::Result; + +/// 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> { + 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> { + 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> { + 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> = 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##" + + + + + + + + + + + + + + + + + + {issuer} + {label} + + {code} + + {period}s +"##, + 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: "me@example.com", + 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 ", + label: "a\"b'c & so it stays put while the + // list scrolls. + const fab = h('button.fab', { type: 'button', onclick: openAddSheet }, [ + icon(ICON.plus), 'Add an account', + ]); + document.body.append(fab); + return out; +} + +function acctRow(a) { + const codeBtn = h('button.code', { + type: 'button', title: 'Click to copy', dataset: { code: a.id }, + }, ['······']); + codeBtn.addEventListener('click', async () => { + const c = state.codes.get(a.id); + if (!c) return; + const ok = await copyText(c.code); + toast(ok ? `Copied ${a.issuer} code` : 'Could not copy', ok ? 'ok' : 'err'); + }); + + const ring = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + ring.setAttribute('class', 'ring'); + ring.setAttribute('viewBox', '0 0 26 26'); + ring.innerHTML = + '' + + ''; + ring.dataset.ring = a.id; + + const avatar = h('div.avatar', { text: initials(a.issuer || a.label) }); + avatar.setAttribute('style', avatarStyle(a.issuer || a.label || a.id)); + + const pushDot = h('span.push-dot', { + title: pushTitle(a), + class: 'push-dot ' + pushClass(a), + }); + + const row = h('div.acct', { dataset: { acct: a.id } }, [ + avatar, + h('div.meta', {}, [ + h('div.issuer', { text: a.issuer || 'Unnamed' }), + h('div.label', {}, [ + pushDot, + h('span', { text: ' ' + (a.label || '—') }), + ]), + ]), + h('div.right', {}, [ + codeBtn, + ring, + h('button.icon-btn', { + type: 'button', 'aria-label': `Options for ${a.issuer}`, title: 'Options', + onclick: () => openAccountSheet(a), + }, [icon(ICON.settings, { width: 1.7 })]), + ]), + ]); + return row; +} + +function pushClass(a) { + if (!a.push_enabled) return 'paused'; + if (a.push_error) return 'error'; + return a.push_live ? 'live' : ''; +} +function pushTitle(a) { + if (!a.push_enabled) return 'Discord push paused'; + if (a.push_error) return 'Discord push error: ' + a.push_error; + return a.push_live ? 'Live in Discord' : 'Waiting for first push'; +} + +// ── Live code ticker ─────────────────────────────────────────────────────── + +let tickHandle = null; + +function startTicker() { + stopTicker(); + tickHandle = setInterval(tick, 250); + tick(); +} +function stopTicker() { + if (tickHandle) clearInterval(tickHandle); + tickHandle = null; +} + +function tick() { + const t = now(); + let needRefetch = false; + + for (const a of state.accounts) { + const c = state.codes.get(a.id); + if (!c) continue; + const period = (c.period || 30) * 1000; + + // Roll over locally the instant the period boundary passes — the next + // code is already in hand, so there's no request on the critical path. + if (t >= c.expires_at) { + if (c.next) { + c.code = c.next; + c.next = null; + c.expires_at += period; + needRefetch = true; + } else { + needRefetch = true; + } + } + + const remain = Math.max(0, c.expires_at - t); + const frac = remain / period; + + const btn = document.querySelector(`.code[data-code="${cssEscape(a.id)}"]`); + if (btn) { + const shown = btn.dataset.shown; + if (shown !== c.code) { + btn.replaceChildren(...codeSpans(c.code)); + btn.dataset.shown = c.code; + } + } + const ring = document.querySelector(`.ring[data-ring="${cssEscape(a.id)}"]`); + if (ring) { + const circ = 2 * Math.PI * 11; + const fg = ring.querySelector('.fg'); + fg.style.strokeDasharray = String(circ); + fg.style.strokeDashoffset = String(circ * (1 - frac)); + } + const rowEl = document.querySelector(`.acct[data-acct="${cssEscape(a.id)}"]`); + if (rowEl) rowEl.classList.toggle('expiring', remain <= 5000); + } + + if (needRefetch && t > state.refetchAt) { + state.refetchAt = t + 3000; // don't stampede if several roll at once + refreshCodes().catch(() => {}); + } +} + +/** CSS.escape isn't in older Safari; ids are UUID-shaped so this is enough. */ +function cssEscape(s) { + return String(s).replace(/["\\]/g, '\\$&'); +} + +// ── Data refresh ─────────────────────────────────────────────────────────── + +async function refreshAccounts() { + try { + state.accounts = await api('/api/accounts') || []; + } catch (e) { toast(e.message, 'err'); } +} + +async function refreshCodes() { + const t0 = Date.now(); + const data = await api('/api/codes'); + const rtt = Date.now() - t0; + // Assume the response was generated at the midpoint of the round trip. + state.clockOffsetMs = data.server_time - (t0 + rtt / 2); + for (const c of data.codes) { + state.codes.set(c.id, { + code: c.code, next: c.next, period: c.period, + expires_at: c.expires_at, + }); + } + // Drop codes for accounts that no longer exist. + const live = new Set(state.accounts.map((a) => a.id)); + for (const id of [...state.codes.keys()]) if (!live.has(id)) state.codes.delete(id); +} + +async function reloadAll() { + await refreshAccounts(); + await refreshCodes(); + renderView(); + tick(); +} + +// ── Modal plumbing ───────────────────────────────────────────────────────── + +function openModal(title, bodyNodes, footNodes) { + const scrim = h('div.scrim', { + onclick: (e) => { if (e.target === scrim) close(); }, + }); + function close() { + scrim.remove(); + document.removeEventListener('keydown', onKey); + } + function onKey(e) { if (e.key === 'Escape') close(); } + document.addEventListener('keydown', onKey); + + const modal = h('div.modal', {}, [ + h('div.modal-head', {}, [ + h('h2', { text: title }), + h('button.icon-btn', { type: 'button', 'aria-label': 'Close', onclick: close }, + [icon(ICON.x, { width: 2.2 })]), + ]), + h('div.modal-body', {}, bodyNodes), + footNodes ? h('div.modal-foot', {}, footNodes) : null, + ]); + scrim.append(modal); + document.body.append(scrim); + return { close, modal }; +} + +// ── Add account ──────────────────────────────────────────────────────────── + +function openAddSheet() { + let stopCamera = null; + + const panel = h('div'); + const tabs = h('div.tabs'); + const defs = [ + ['upload', 'Upload', ICON.upload], + ['camera', 'Camera', ICON.camera], + ['paste', 'Paste link', ICON.link], + ]; + + const dlg = openModal('Add an account', [tabs, panel]); + + function select(which) { + for (const b of tabs.children) b.classList.toggle('active', b.dataset.tab === which); + if (stopCamera) { stopCamera(); stopCamera = null; } + if (which === 'upload') panel.replaceChildren(uploadPane()); + else if (which === 'camera') { const p = cameraPane((f) => { stopCamera = f; }); panel.replaceChildren(p); } + else panel.replaceChildren(pastePane()); + } + + for (const [key, label, ic] of defs) { + tabs.append(h('button', { + type: 'button', dataset: { tab: key }, onclick: () => select(key), + }, [icon(ic, { width: 1.9 }), label])); + } + + // Wrap close so the camera track is always released. + const origClose = dlg.close; + dlg.close = () => { if (stopCamera) stopCamera(); origClose(); }; + dlg.modal.querySelector('.modal-head .icon-btn') + .addEventListener('click', () => { if (stopCamera) stopCamera(); }); + + select('upload'); + + // ── panes ── + + function uploadPane() { + const file = h('input', { type: 'file', accept: 'image/*', class: 'sr-only' }); + const zone = h('div.dropzone', { + role: 'button', tabindex: '0', + onclick: () => file.click(), + onkeydown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); file.click(); } }, + ondragover: (e) => { e.preventDefault(); zone.classList.add('over'); }, + ondragleave: () => zone.classList.remove('over'), + ondrop: (e) => { + e.preventDefault(); + zone.classList.remove('over'); + if (e.dataTransfer.files[0]) handleImage(e.dataTransfer.files[0]); + }, + }, [ + icon(ICON.upload, { width: 1.7 }), + h('strong', { text: 'Drop a QR code image here' }), + h('span', { text: 'or click to browse — PNG, JPEG, WebP, GIF, BMP, TIFF' }), + ]); + file.addEventListener('change', () => { if (file.files[0]) handleImage(file.files[0]); }); + return h('div', {}, [zone, file]); + } + + async function handleImage(f) { + const fd = new FormData(); + fd.append('image', f); + panel.replaceChildren(h('div.dropzone', {}, [ + icon(ICON.search, { width: 1.7, className: 'spin' }), + h('strong', { text: 'Reading QR code…' }), + ])); + try { + const res = await api('/api/qr/decode', { method: 'POST', body: fd }); + confirmPane(res.uri); + } catch (e) { + panel.replaceChildren( + h('div.alert.err', {}, [icon(ICON.alert), h('span', { text: e.message })]), + uploadPane(), + ); + } + } + + function cameraPane(registerStop) { + const video = h('video', { autoplay: true, muted: true, playsinline: true }); + const box = h('div.scanner', {}, [video, h('div.reticle')]); + const status = h('p.small.muted', { text: 'Point the camera at a QR code.' }); + status.style.marginTop = '10px'; + status.style.textAlign = 'center'; + const wrap = h('div', {}, [box, status]); + + let stopped = false; + let stream = null; + let timer = null; + const stop = () => { + stopped = true; + if (timer) clearTimeout(timer); + if (stream) for (const t of stream.getTracks()) t.stop(); + }; + registerStop(stop); + + (async () => { + try { + stream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: 'environment' }, audio: false, + }); + } catch (e) { + wrap.replaceChildren(h('div.alert.err', {}, [ + icon(ICON.alert), + h('span', { text: 'Camera unavailable: ' + e.message + + '. Browsers only grant camera access over HTTPS or on localhost.' }), + ])); + return; + } + if (stopped) { for (const t of stream.getTracks()) t.stop(); return; } + video.srcObject = stream; + + // Native detector where available; server-side decode everywhere else. + let detector = null; + if ('BarcodeDetector' in window) { + try { + const fmts = await window.BarcodeDetector.getSupportedFormats(); + if (fmts.includes('qr_code')) detector = new window.BarcodeDetector({ formats: ['qr_code'] }); + } catch (_) { /* fall through to server decode */ } + } + status.textContent = detector + ? 'Point the camera at a QR code.' + : 'Point the camera at a QR code. (Scanning on the server.)'; + + const canvas = document.createElement('canvas'); + const scan = async () => { + if (stopped) return; + if (video.readyState < 2) { timer = setTimeout(scan, 200); return; } + try { + if (detector) { + const found = await detector.detect(video); + if (found.length && found[0].rawValue) return done(found[0].rawValue); + } else { + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + canvas.getContext('2d').drawImage(video, 0, 0); + const blob = await new Promise((r) => canvas.toBlob(r, 'image/jpeg', 0.72)); + if (blob) { + const fd = new FormData(); + fd.append('image', blob, 'frame.jpg'); + try { + const res = await api('/api/qr/decode', { method: 'POST', body: fd }); + if (res && res.uri) return done(res.uri); + } catch (_) { /* no QR in this frame — keep going */ } + } + } + } catch (_) { /* transient decode error — keep going */ } + timer = setTimeout(scan, detector ? 180 : 650); + }; + const done = (uri) => { stop(); confirmPane(uri); }; + scan(); + })(); + + return wrap; + } + + function pastePane() { + const ta = h('textarea.input', { + placeholder: 'otpauth://totp/Cloudflare:me@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Cloudflare', + rows: 3, + }); + const err = h('div.alert.err.hidden'); + const manual = h('details'); + const mIssuer = h('input.input', { placeholder: 'Cloudflare' }); + const mLabel = h('input.input', { placeholder: 'me@example.com' }); + const mSecret = h('input.input', { placeholder: 'JBSWY3DPEHPK3PXP', class: 'input mono' }); + manual.append( + h('summary.small.muted', { text: 'Enter a secret manually instead' }), + h('div', { style: 'margin-top:12px' }, [ + h('div.field', {}, [h('label', { text: 'Issuer' }), mIssuer]), + h('div.field', {}, [h('label', { text: 'Account' }), mLabel]), + h('div.field', {}, [h('label', { text: 'Secret (base32)' }), mSecret]), + ]), + ); + manual.style.marginTop = '14px'; + + const go = h('button.btn.btn-primary.btn-block', { + style: 'margin-top:14px', text: 'Continue', + onclick: () => { + err.classList.add('hidden'); + const uri = ta.value.trim(); + if (uri) return confirmPane(uri); + if (mSecret.value.trim()) { + const q = new URLSearchParams({ secret: mSecret.value.trim().replace(/\s+/g, '') }); + if (mIssuer.value.trim()) q.set('issuer', mIssuer.value.trim()); + const path = encodeURIComponent( + (mIssuer.value.trim() ? mIssuer.value.trim() + ':' : '') + (mLabel.value.trim() || 'account')); + return confirmPane(`otpauth://totp/${path}?${q}`); + } + err.replaceChildren(icon(ICON.alert), h('span', { text: 'Paste an otpauth:// link or fill in the manual fields.' })); + err.classList.remove('hidden'); + }, + }); + return h('div', {}, [err, h('div.field', {}, [h('label', { text: 'otpauth:// link' }), ta]), manual, go]); + } + + /** Preview what we parsed, let the user rename, then commit. */ + async function confirmPane(uri) { + panel.replaceChildren(h('div.dropzone', {}, [ + icon(ICON.search, { width: 1.7, className: 'spin' }), + h('strong', { text: 'Checking…' }), + ])); + let parsed; + try { + parsed = await api('/api/accounts/preview', { method: 'POST', body: { uri } }); + } catch (e) { + panel.replaceChildren( + h('div.alert.err', {}, [icon(ICON.alert), h('span', { text: e.message })]), + pastePane(), + ); + return; + } + const issuer = h('input.input', { value: parsed.issuer || '' }); + const label = h('input.input', { value: parsed.label || '' }); + const push = h('input', { type: 'checkbox', checked: true }); + const save = h('button.btn.btn-primary.btn-block', { style: 'margin-top:4px', text: 'Add account' }); + + save.addEventListener('click', async () => { + save.disabled = true; save.textContent = 'Adding…'; + try { + await api('/api/accounts', { + method: 'POST', + body: { uri, issuer: issuer.value.trim(), label: label.value.trim(), push_enabled: push.checked }, + }); + dlg.close(); + toast('Account added'); + await reloadAll(); + } catch (e) { + save.disabled = false; save.textContent = 'Add account'; + toast(e.message, 'err'); + } + }); + + panel.replaceChildren(h('div', {}, [ + h('div.alert.info', {}, [icon(ICON.check), h('span', { text: 'Found a valid one-time-password secret.' })]), + h('div.field', {}, [h('label', { text: 'Issuer' }), issuer]), + h('div.field', {}, [h('label', { text: 'Account' }), label]), + h('dl', {}, [ + h('div.kv', {}, [h('dt', { text: 'Algorithm' }), h('dd', { text: parsed.algorithm })]), + h('div.kv', {}, [h('dt', { text: 'Digits' }), h('dd', { text: String(parsed.digits) })]), + h('div.kv', {}, [h('dt', { text: 'Period' }), h('dd', { text: parsed.period + 's' })]), + ]), + h('label.switch', { style: 'margin:16px 0' }, [ + push, h('span.track'), + h('span.switch-label', { text: 'Stream this code to Discord' }), + ]), + save, + ])); + } +} + +// ── Per-account sheet ────────────────────────────────────────────────────── + +function openAccountSheet(a) { + const issuer = h('input.input', { value: a.issuer || '' }); + const label = h('input.input', { value: a.label || '' }); + const push = h('input', { type: 'checkbox', checked: !!a.push_enabled }); + + const dlg = openModal(a.issuer || 'Account', [ + a.push_error ? h('div.alert.err', {}, [icon(ICON.alert), h('span', { text: a.push_error })]) : null, + h('div.field', {}, [h('label', { text: 'Issuer' }), issuer]), + h('div.field', {}, [h('label', { text: 'Account' }), label]), + h('label.switch', { style: 'margin:16px 0 4px' }, [ + push, h('span.track'), + h('span.switch-label', { text: 'Stream this code to Discord' }), + ]), + h('p.hint', { text: 'When on, this account keeps one live message in your Discord channel, edited in place every time the code rolls.' }), + ], [ + h('button.btn.btn-danger', { onclick: async () => { + if (!confirm(`Remove ${a.issuer}? Its secret is deleted permanently and its Discord message is removed.`)) return; + try { + await api('/api/accounts/' + encodeURIComponent(a.id), { method: 'DELETE' }); + dlg.close(); toast('Account removed'); await reloadAll(); + } catch (e) { toast(e.message, 'err'); } + } }, [icon(ICON.trash, { width: 1.9 }), 'Remove']), + h('div', { style: 'flex:1' }), + h('button.btn', { onclick: dlg.close, text: 'Cancel' }), + h('button.btn.btn-primary', { onclick: async () => { + try { + await api('/api/accounts/' + encodeURIComponent(a.id), { + method: 'PATCH', + body: { issuer: issuer.value.trim(), label: label.value.trim(), push_enabled: push.checked }, + }); + dlg.close(); toast('Saved'); await reloadAll(); + } catch (e) { toast(e.message, 'err'); } + }, text: 'Save' }), + ]); +} + +// ── Settings ─────────────────────────────────────────────────────────────── + +function settingsView() { + const nav = h('div.settings-nav', {}, [ + ['discord', 'Discord'], ['account', 'Account'], ['about', 'About'], + ].map(([k, lbl]) => h('button.btn' + (state.settingsTab === k ? '.btn-primary' : ''), { + onclick: () => { state.settingsTab = k; renderView(); }, text: lbl, + }))); + + const head = h('div', { style: 'display:flex;align-items:center;gap:10px;margin-bottom:16px' }, [ + h('button.icon-btn', { 'aria-label': 'Back', onclick: () => go('vault') }, [icon(ICON.back)]), + h('h1', { text: 'Settings' }), + ]); + + let body; + if (state.settingsTab === 'account') body = accountSettings(); + else if (state.settingsTab === 'about') body = aboutSettings(); + else body = discordSettings(); + + return [head, nav, body]; +} + +function discordSettings() { + const card = h('div.card'); + const body = h('div.card-body', {}, [h('p.muted.small', { text: 'Loading…' })]); + card.append(h('div.card-head', {}, [h('h2', { text: 'Discord delivery' })]), body); + + api('/api/settings').then((s) => { + const hook = h('input.input', { type: 'url', value: s.webhook_url || '', placeholder: 'https://discord.com/api/webhooks/…' }); + const mode = h('select.select'); + for (const [v, lbl] of [ + ['embed', 'Embed — code as copyable text (recommended)'], + ['image', 'Image — rendered WebP tile'], + ['both', 'Both — image with the code as text too'], + ]) mode.append(h('option', { value: v, text: lbl, selected: s.push_mode === v })); + + const enabled = h('input', { type: 'checkbox', checked: !!s.push_enabled }); + + const save = h('button.btn.btn-primary', { text: 'Save' }); + const test = h('button.btn', {}, [icon(ICON.send, { width: 1.9 }), 'Send test message']); + + save.addEventListener('click', async () => { + save.disabled = true; + try { + await api('/api/settings', { + method: 'PUT', + body: { webhook_url: hook.value.trim(), push_mode: mode.value, push_enabled: enabled.checked }, + }); + toast('Discord settings saved'); + await reloadAll(); + } catch (e) { toast(e.message, 'err'); } + save.disabled = false; + }); + test.addEventListener('click', async () => { + test.disabled = true; + try { await api('/api/settings/test', { method: 'POST' }); toast('Test message sent'); } + catch (e) { toast(e.message, 'err'); } + test.disabled = false; + }); + + body.replaceChildren( + h('div.field', {}, [ + h('label', { text: 'Webhook URL' }), hook, + h('div.hint', { text: 'Discord → Server Settings → Integrations → Webhooks → Copy Webhook URL.' }), + ]), + h('div.field', {}, [ + h('label', { text: 'Message style' }), mode, + h('div.hint', { text: 'Codes are text you can tap to copy in the embed styles. The image style renders a WebP tile instead.' }), + ]), + h('label.switch', { style: 'margin:4px 0 16px' }, [ + enabled, h('span.track'), + h('span.switch-label', { text: 'Push codes to Discord' }), + ]), + h('div', { style: 'display:flex;gap:8px' }, [save, test]), + ); + }).catch((e) => body.replaceChildren( + h('div.alert.err', {}, [icon(ICON.alert), h('span', { text: e.message })]))); + + const warn = h('div.card', {}, [h('div.card-body', {}, [ + h('div.alert.warn', {}, [icon(ICON.alert), h('span', { + html: 'Know the trade-off. 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 itself as a secret.', + })]), + ])]); + + return h('div', {}, [card, warn]); +} + +function accountSettings() { + const cur = h('input.input', { type: 'password', autocomplete: 'current-password' }); + const nu = h('input.input', { type: 'text', autocomplete: 'username', value: (state.me.user && state.me.user.username) || '' }); + const np = h('input.input', { type: 'password', autocomplete: 'new-password', placeholder: 'Leave blank to keep' }); + const np2 = h('input.input', { type: 'password', autocomplete: 'new-password', placeholder: 'Repeat new password' }); + const err = h('div.alert.err.hidden'); + + const save = h('button.btn.btn-primary', { text: 'Update credentials' }); + save.addEventListener('click', async () => { + err.classList.add('hidden'); + if (np.value && np.value !== np2.value) { + err.replaceChildren(icon(ICON.alert), h('span', { text: 'The two new passwords do not match.' })); + err.classList.remove('hidden'); + return; + } + save.disabled = true; + try { + await api('/api/credentials', { + method: 'POST', + body: { + current_password: cur.value, + new_username: nu.value.trim() || null, + new_password: np.value || null, + }, + }); + toast('Credentials updated'); + cur.value = np.value = np2.value = ''; + state.me = await api('/api/me'); + } catch (e) { + err.replaceChildren(icon(ICON.alert), h('span', { text: e.message })); + err.classList.remove('hidden'); + } + save.disabled = false; + }); + + return h('div.card', {}, [ + h('div.card-head', {}, [h('h2', { text: 'Administrator account' })]), + h('div.card-body', {}, [ + err, + h('div.field', {}, [ + h('label', { text: 'Current password' }), cur, + h('div.hint', { text: 'Required to change either field below.' }), + ]), + h('div.field', {}, [h('label', { text: 'Username' }), nu]), + h('div.row', {}, [ + h('div.field', {}, [h('label', { text: 'New password' }), np]), + h('div.field', {}, [h('label', { text: 'Confirm' }), np2]), + ]), + save, + ]), + ]); +} + +function aboutSettings() { + const card = h('div.card', {}, [ + h('div.card-head', {}, [h('h2', { text: 'About' })]), + h('div.card-body', {}, [ + h('dl', {}, [ + h('div.kv', {}, [h('dt', { text: 'Version' }), h('dd', { text: (state.me && state.me.version) || '—' })]), + h('div.kv', {}, [h('dt', { text: 'Accounts' }), h('dd', { text: String(state.accounts.length) })]), + ]), + h('p.small.muted', { style: 'margin-top:14px' , text: + 'Discord Authenticator keeps one live message per account in your Discord channel and edits it in place as each code rolls.' }), + ]), + ]); + return card; +} + +// ── Go ───────────────────────────────────────────────────────────────────── + +document.addEventListener('keydown', (e) => { + // "/" focuses search, the way every list-heavy tool does it. + if (e.key === '/' && !/^(INPUT|TEXTAREA)$/.test(document.activeElement.tagName)) { + const s = document.querySelector('.search input'); + if (s) { e.preventDefault(); s.focus(); } + } +}); + +// Refresh codes when the tab comes back — a laptop that slept has a stale +// buffer and a stale clock offset. +document.addEventListener('visibilitychange', () => { + if (!document.hidden && state.me && state.me.authenticated) { + refreshCodes().then(tick).catch(() => {}); + } +}); + +boot(); diff --git a/crates/webui/src/index.html b/crates/webui/src/index.html new file mode 100644 index 0000000..bc363a0 --- /dev/null +++ b/crates/webui/src/index.html @@ -0,0 +1,32 @@ + + + + + + + Discord Authenticator + + + + + + + +
+
+ + + diff --git a/crates/webui/src/lib.rs b/crates/webui/src/lib.rs new file mode 100644 index 0000000..ddab700 --- /dev/null +++ b/crates/webui/src/lib.rs @@ -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 (): +//! 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"); diff --git a/crates/webui/src/logo.svg b/crates/webui/src/logo.svg new file mode 100644 index 0000000..e3986ad --- /dev/null +++ b/crates/webui/src/logo.svg @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile new file mode 100644 index 0000000..b893252 --- /dev/null +++ b/deploy/docker/Dockerfile @@ -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"] diff --git a/deploy/docker/entrypoint.sh b/deploy/docker/entrypoint.sh new file mode 100755 index 0000000..cf40416 --- /dev/null +++ b/deploy/docker/entrypoint.sh @@ -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 "$@" diff --git a/deploy/unraid/discord-authenticator.xml b/deploy/unraid/discord-authenticator.xml new file mode 100644 index 0000000..a0d7954 --- /dev/null +++ b/deploy/unraid/discord-authenticator.xml @@ -0,0 +1,73 @@ + + + + Discord-Authenticator + gitea.milesward.dev/mward4/discord-authenticator:latest + https://gitea.milesward.dev/mward4/-/packages/container/discord-authenticator + bridge + + sh + false + https://gitea.milesward.dev/mward4/Discord-Authenticator/issues + https://gitea.milesward.dev/mward4/Discord-Authenticator + + 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. + + Tools:Utilities Security: + http://[IP]:[PORT:8710]/ + + https://gitea.milesward.dev/mward4/Discord-Authenticator/raw/branch/main/crates/webui/src/logo.svg + + + + + + + + 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. + + 8710 + /mnt/user/appdata/discord-authenticator + + info,da=info + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4a4f195 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,34 @@ +# Discord Authenticator — single-container deployment. +# +# docker compose up -d +# +# Then open http://: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 diff --git a/docs/PUBLISHING.md b/docs/PUBLISHING.md new file mode 100644 index 0000000..ead7c5f --- /dev/null +++ b/docs/PUBLISHING.md @@ -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='' +``` + +## 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 +``` diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..73cb934 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "stable" +components = ["rustfmt", "clippy"] diff --git a/scripts/build-and-publish-unraid.sh b/scripts/build-and-publish-unraid.sh new file mode 100644 index 0000000..91d278e --- /dev/null +++ b/scripts/build-and-publish-unraid.sh @@ -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: +# //discord-authenticator: (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