commit ad3720edbf24f2dd05fd1b99ea7b19e74a52144b Author: Miles Ward Date: Wed Sep 9 00:21:18 2026 -0400 v0.1: OpenMFA — live TOTP delivery to Discord, Slack or Telegram Platform-neutral successor to Discord Authenticator, forked from its v0.1.2 state with fresh history. Same vault, same UI, same live-message mechanism — now behind one `Sink` that speaks three platforms. Why each platform needs what it needs (this drove the whole settings model): - Discord: an incoming-webhook URL. Webhooks can edit their own messages. - Slack: a bot token (xoxb-, scope chat:write) + channel ID. Slack incoming webhooks can post but never edit, and editing IS the live mechanism, so chat.postMessage / chat.update via a bot is the only path. - Telegram: a bot token + chat id. No webhook concept exists. The app detects the chat id itself from the bot's pending updates ("send /start, then Detect"), since users rarely know it. - core: Provider enum; settings hold all three configs so switching back and forth never re-asks for tokens; per-platform shape validation that names the common mistakes (xoxp- user token, #name instead of channel ID); a destination key that clears message ids on platform/channel change but not on token rotation - delivery: shared DeliveryError + pacing Governor; Discord client refactored onto them; new Slack client (classifies Slack's ok:false-on-HTTP-200 errors) and Telegram client ("message is not modified" is success; chat detection via getUpdates); provider-agnostic message builders — code in a copyable span everywhere, rollover time as live as each platform allows - http-api: setup accepts an optional provider; settings PUT/GET carry the full model; /api/slack/whoami, /api/telegram/whoami, /api/telegram/chats - webui: three-way optional picker on setup and in Settings → Delivery with guided per-platform fields; "Check token" (Slack) and "Detect chat" (Telegram); inline white mark via currentColor - brand: white mark on the dark plate; blue stays with the Discord sibling - docs/TEAMS.md: exploration for v0.2 — Teams connectors were disabled May 2026 and Workflows webhooks can't edit, so live tiles need a Bot Framework bot; options for the inbound endpoint on a homelab and a recommended scope Verified against mock Discord, Slack and Telegram servers speaking each platform's real protocol shape: edits target the same message rather than re-posting, deleted tiles are recovered, dead credentials are surfaced with a next step, renames refresh without a rollover, and switching platform reposts on the new one and leaves the old one alone. 138 tests. Co-Authored-By: Claude Fable 5.1 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..a0069b3 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2791 @@ +# 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 = "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 = "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 = "openmfa" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "clap", + "openmfa-core", + "openmfa-delivery", + "openmfa-http-api", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "openmfa-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 = "openmfa-delivery" +version = "0.1.0" +dependencies = [ + "axum", + "openmfa-core", + "openmfa-render", + "parking_lot", + "reqwest", + "serde", + "serde_json", + "tempfile", + "thiserror", + "time", + "tokio", + "tracing", +] + +[[package]] +name = "openmfa-http-api" +version = "0.1.0" +dependencies = [ + "axum", + "base64 0.22.1", + "image", + "openmfa-core", + "openmfa-delivery", + "openmfa-render", + "openmfa-webui", + "parking_lot", + "qrcode", + "rqrr", + "serde", + "serde_json", + "tokio", + "tower-http", + "tracing", + "uuid", +] + +[[package]] +name = "openmfa-render" +version = "0.1.0" +dependencies = [ + "image", + "resvg", + "thiserror", + "tiny-skia", + "tracing", + "usvg", +] + +[[package]] +name = "openmfa-webui" +version = "0.1.0" + +[[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..4ff32df --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,115 @@ +[workspace] +resolver = "2" +members = [ + "crates/core", + "crates/render", + "crates/delivery", + "crates/webui", + "crates/http-api", + "crates/openmfa", +] + +[workspace.package] +version = "0.1.0" +edition = "2021" +rust-version = "1.90" +license = "MIT OR Apache-2.0" +repository = "https://gitea.milesward.dev/mward4/OpenMFA" +authors = ["OpenMFA 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" + +openmfa-core = { path = "crates/core" } +openmfa-render = { path = "crates/render" } +openmfa-delivery = { path = "crates/delivery" } +openmfa-webui = { path = "crates/webui" } +openmfa-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..352f462 --- /dev/null +++ b/README.md @@ -0,0 +1,347 @@ +

+ OpenMFA +

+ +

OpenMFA

+ +

+ A self-hosted TOTP authenticator whose codes appear in + Discord, Slack or Telegram 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 chosen 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 the chat platform itself. + +OpenMFA grew out of [Discord Authenticator](https://gitea.milesward.dev/mward4/Discord-Authenticator), +which remains the Discord-only sibling; this is the platform-neutral project. + +--- + +## Contents + +- [Which platform, and what each one needs](#which-platform-and-what-each-one-needs) +- [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) +- [Roadmap](#roadmap) + +--- + +## Which platform, and what each one needs + +The three platforms authenticate differently, and the reason is the same in +each case: OpenMFA has to be allowed to *edit* a message it posted earlier. + +| Platform | You provide | Why that, and not something simpler | +|---|---|---| +| **Discord** | An incoming-webhook URL | Discord webhooks can edit and delete their own messages, so no bot is needed. | +| **Slack** | A bot token (`xoxb-…`, scope `chat:write`) and a channel ID | Slack incoming webhooks can post but **cannot edit**. Only a bot using `chat.postMessage` / `chat.update` can keep a tile live. | +| **Telegram** | A bot token from @BotFather and a chat ID | Telegram has no webhook concept; everything is a bot. `sendMessage` / `editMessageText`. | + +Setup walkthroughs are built into the app's Delivery settings, but in brief: + +- **Discord** — Server Settings → Integrations → Webhooks → New Webhook → Copy + Webhook URL. +- **Slack** — `api.slack.com/apps` → Create New App → OAuth & Permissions → add + the `chat:write` bot scope → Install to Workspace → copy the *Bot User OAuth + Token*. Then in the target channel run `/invite @YourBot`, and copy the + channel's ID (click the channel name; it's at the bottom of the details + pane). The **Check token** button confirms which workspace the token belongs + to before you save. +- **Telegram** — message **@BotFather**, send `/newbot`, copy the token. Then + send `/start` to your new bot (or add it to a group and say anything) and + click **Detect chat** — the app lists every chat the bot has heard from so + you never have to find a chat ID by hand. + +### Practical account limits per platform + +Each account costs two edits a minute (one per 30-second roll). Rate limits +are per-minute, so they cap how many accounts one destination can carry: + +| Platform | Limit that bites | Comfortable ceiling | +|---|---|---| +| Discord | ~5 req/s per webhook; ~30/min reported | ~10–12 accounts per webhook | +| Slack | `chat.update` is Tier 3, ~50/min per workspace | ~20 accounts | +| Telegram, private chat | 1 msg/s per chat | ~30 accounts | +| Telegram, group | 20 msg/min per group | **~10 accounts** | + +Past those, split across several webhooks/channels, or pause push on the +accounts you don't need live. + +--- + +## How the live delivery works + +The obvious approaches don't survive contact with any of these platforms, and +it's worth saying why, because the design is a direct consequence. + +**A live-updating image URL doesn't work.** Pointing a message at an image your +server renders on demand fails twice over. The platform fetches the URL once at +post time and serves a cached copy, so new bytes at the same URL are never seen. +And more seriously, its 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.** 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 confidently displays the wrong +time remaining — worse than showing nothing. + +**What does work is editing the message.** Post once, remember the id, rewrite +it every period. Every client sees the change at once. So each account owns one +message for its lifetime, and the app rewrites it once per period — on Discord +via `PATCH …/messages/{id}`, on Slack via `chat.update`, on Telegram via +`editMessageText`. + +Two details make this pleasant rather than merely functional: + +- **The digits stay copyable.** The code goes into a code span containing the + bare digits (`482913`, not `482 913`) so what lands on your clipboard is + directly pasteable. Discord and Slack render a copy button on fenced code; + Telegram's `` is tap-to-copy on mobile. +- **A rollover time, as live as the platform allows.** Discord's `` + ticks in the client every second for free. Slack's `` renders once in + the viewer's local time. Telegram gets a plain UTC clock. In every case the + digits themselves are refreshed by the edit at the boundary — the countdown + is a courtesy, not the mechanism. + +On Discord each message can also carry a rendered WebP tile — issuer, account, +the code at a size you can read across a room, with a per-issuer accent colour +matching the row in the web UI. Slack and Telegram receive text; see +[Roadmap](#roadmap). + +### Rate limiting and failure handling + +TOTP windows are globally aligned, so every account's code rolls at the same +instant and N accounts would fire an N-request burst. Every call is serialised +through a per-platform governor — 500 ms apart on Discord, 1 s on Slack and +Telegram — and backs off on `429` using whatever retry hint the platform +returns. + +Failures are classified rather than blindly retried, because every platform +eventually bans a client that hammers 4xx responses: + +| Meaning | Discord | Slack | Telegram | What happens | +|---|---|---|---|---| +| Message was deleted | code `10008` | `message_not_found`, `cant_update_message` | "message to edit not found" | Post a fresh one automatically | +| Credential dead | `10015`, `50027` | `invalid_auth`, `token_revoked` | 401 / 404 | Stop, and show you the reason | +| Channel gone / no access | `10003`, `50013` | `channel_not_found`, `not_in_channel` | "chat not found", "bot was blocked" | Stop, and show you the reason | +| Rate limited | 429 | 429 | 429 | Back off, skip the stale edit | +| Content identical | — | — | "message is not modified" | Treated as success | + +--- + +## Security: read this before you use it + +**Anyone who can read that 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 or workspace only you are in**. +- Treat the **webhook URL or bot token itself as a secret** — it is a write + capability for that channel. +- Anything with access to your Discord, Slack or Telegram 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 `OPENMFA_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 the chat platform you chose. + +**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. `OPENMFA_MASTER_KEY` closes that second gap if you need it closed. + +--- + +## Running it + +### Docker Compose + +```bash +docker compose up -d +``` + +Then open `http://:8720/`. + +### Unraid + +Copy `deploy/unraid/openmfa.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/openmfa:latest +``` + +Bridge networking is correct — this makes outbound HTTPS calls and serves one +port. Map `/var/lib/openmfa` 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 and password, then offers a three-way choice +of Discord, Slack or Telegram — optional, so you can wire a platform later from +Settings → Delivery. Switching platforms afterwards keeps the other platforms' +details, so you can move back and forth without re-pasting tokens. + +**Adding an account** offers three routes: + +- **Upload** a QR code image — PNG, JPEG, WebP, GIF, BMP or TIFF. Screenshots, + photos of a screen, tiny crops and rotated images all work; the decoder walks + a ladder of rescale, contrast and rotation attempts before giving up. (HEIC + is rejected with a note to screenshot instead — iPhone screenshots are PNG.) + If the image holds several QR codes, all of them are read. +- **Camera** scanning, using the browser's native barcode detector where it + exists and falling back to server-side decoding where it doesn't, which + covers Safari and Firefox. Note that browsers only grant camera access over + HTTPS or on `localhost`. +- **Paste** an `otpauth://` link, or type a secret by hand. + +### Importing from Google Authenticator + +Google Authenticator's **Transfer accounts → Export accounts** produces a QR +code holding *many* accounts at once — an `otpauth-migration://` link wrapping +a base64 protobuf. Upload or paste it and you get a checklist of everything +inside, with each entry's algorithm, digit count and period, and an **Add N +accounts** button. + +- **Large exports are split across several QR codes.** Each reports its + position, so the UI says "Part 1 of 2" and prompts you for the rest. +- **Re-importing is safe.** Anything already in your vault is detected and + pre-unticked. Duplicates match on the secret *and* the naming, so deliberately + keeping one secret under two labels still works. +- **Entries that can't be imported are named, not silently dropped** — a + counter-based (HOTP) account or an MD5 one is listed with the reason. +- **Secrets never reach the browser.** The preview returns names and parameters + only; on import the client sends back the link plus the indices it selected, + and the server re-parses it. + +Two traps this format sets, both of which silently produce *plausible but +wrong* codes: the protobuf `secret` field is **raw bytes**, not base32, and the +payload carries **no period field** — every Google export is 30-second TOTP. + +The `otpauth://` parser is deliberately forgiving of real-world URIs: it accepts +lowercase and `=`-padded secrets, and where the issuer in the path disagrees +with the `?issuer=` parameter — which Microsoft does — it takes the query +parameter rather than refusing the account. + +**In the list**, click any code to copy it. `/` focuses the search box. The +pencil on a row opens **Edit account**: the *Name* shows in the list and on the +tile; the *Account* field (usually the username or email) is left alone unless +you change it. A rename reaches the chat within a second or two — the push loop +clears its "already sent" marker so it doesn't wait for the code to roll. + +--- + +## Configuration + +All settings are environment variables (or CLI flags of the same name). + +| Variable | Default | Meaning | +|---|---|---| +| `OPENMFA_HTTP_ADDR` | `0.0.0.0:8720` | Listen address for UI and API | +| `OPENMFA_WORK_DIR` | `/var/lib/openmfa` | Vault, key, settings, admin account | +| `OPENMFA_MASTER_KEY` | *(unset)* | Base64 of 32 bytes. Overrides `vault.key`; nothing is written to disk | +| `OPENMFA_LOG` | `info,openmfa=info` | `tracing-subscriber` filter | +| `OPENMFA_SLACK_API_BASE` | `https://slack.com/api` | Override for proxies | +| `OPENMFA_TELEGRAM_API_BASE` | `https://api.telegram.org` | Override for a self-hosted Bot API server | + +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 → Delivery, Discord only) chooses what each tile +contains: **Text** (one JSON edit per period, nothing uploaded — the lightest), +**Image** (the WebP tile), or **Both** (default). The image styles re-upload a +tile on every roll — roughly 2,880 attachments per account per day. Harmless, +but Text is the frugal choice for many accounts. + +--- + +## Architecture + +``` +crates/ + core/ TOTP, vault, crypto, settings (incl. provider config), admin auth, + Google Authenticator export parsing — no network + render/ SVG → WebP tile rendering (resvg + embedded JetBrains Mono) + delivery/ Discord, Slack and Telegram clients behind one Sink; the push engine + webui/ Embedded HTML/CSS/JS, compiled into the binary + http-api/ axum router, sessions, QR decoding + openmfa/ The binary +``` + +`core` is deliberately network-free so the security-critical parts are testable +without a socket. The delivery engine is platform-blind: it resolves a `Sink` +from settings each pass and only ever calls `create`, `edit` and `delete`; each +platform client owns its own rate-limit governor and its own error +classification. + +Everything is pure Rust: no OpenSSL, no `-sys` crates, no C. TLS roots are +compiled in via `webpki-roots`, so 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 + +`cargo test --workspace` runs the suite, including the **RFC 6238 vectors**, +real **QR round-trips**, **Google export decoding** from protobuf wire bytes, +and the **live push cycle against mock Discord, Slack and Telegram servers** +that speak each platform's real error conventions — Slack's `ok:false` on HTTP +200, Telegram's `description` strings, Discord's JSON error codes — asserting +that tiles are edited in place rather than re-posted, that deleted tiles are +recovered, that dead credentials are surfaced instead of hammered, and that +renames refresh without waiting for a rollover. + +--- + +## Roadmap + +- **v0.2 — Microsoft Teams.** Harder than the other three: Teams' incoming + webhooks were retired in May 2026 and their replacement (Power Automate + Workflows) can post but not edit, so live tiles need a Bot Framework bot. + See [`docs/TEAMS.md`](docs/TEAMS.md) for the exploration. +- Image tiles on Telegram via `editMessageMedia`. +- A Slack slash command (`/openmfa `) as a pull alternative to live + tiles, in the style of Daito — for teams that would rather fetch a code than + have it on screen. +- Email → chat forwarding for services that send codes by email. + +## 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..2d8b198 --- /dev/null +++ b/crates/core/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "openmfa-core" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +description = "Domain core for OpenMFA: 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..189e114 --- /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: "openmfa::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: "openmfa::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: "openmfa::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: "openmfa::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: "openmfa::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..d89767f --- /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 +//! `OPENMFA_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 = "OPENMFA_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. `$OPENMFA_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: "openmfa::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: "openmfa::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..b3efe06 --- /dev/null +++ b/crates/core/src/lib.rs @@ -0,0 +1,66 @@ +//! Domain core for OpenMFA. +//! +//! 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 `openmfa-delivery`, drawing tiles in `openmfa-render`, +//! and HTTP in `openmfa-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 migration; +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 migration::{is_migration_uri, parse_migration, ImportBatch, Skipped}; +pub use settings::{ + validate_slack, validate_telegram, validate_webhook_url, DiscordConfig, Provider, PushMode, + Settings, SettingsStore, SlackConfig, TelegramConfig, +}; +pub use totp::{decode_secret, encode_secret, parse_otpauth, Algorithm, OtpAuth, Totp}; +pub use vault::{ + Account, AccountPublic, DeliveryState, ImportOutcome, NewAccount, PushTarget, Vault, +}; + +/// Parse any link the "add an account" flow accepts. +/// +/// A plain `otpauth://` URI yields a batch of one; a Google Authenticator +/// `otpauth-migration://` export yields however many accounts it carries. The +/// callers upstream — preview and import — therefore only ever deal in +/// batches and need no special case for the single-account path. +pub fn parse_import(uri: &str) -> Result { + if is_migration_uri(uri) { + return parse_migration(uri); + } + Ok(ImportBatch { + accounts: vec![parse_otpauth(uri)?], + ..ImportBatch::default() + }) +} + +/// 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/migration.rs b/crates/core/src/migration.rs new file mode 100644 index 0000000..b260906 --- /dev/null +++ b/crates/core/src/migration.rs @@ -0,0 +1,733 @@ +//! Google Authenticator export parsing (`otpauth-migration://offline?data=…`). +//! +//! The payload is a base64 protobuf holding many accounts at once. Google +//! splits large exports across several QR codes, each carrying `batch_index` +//! and `batch_size` so the user knows how many screens to scan. +//! +//! ## Why the protobuf is hand-decoded +//! +//! Pulling `prost` would mean either a `protoc` build dependency or a +//! generated file checked in, for a schema that is four messages long and has +//! not changed since 2020. The wire format we need is two wire types (varint +//! and length-delimited), so the reader below is short enough to audit. +//! Unknown fields are skipped properly, so a future Google addition won't +//! break parsing. +//! +//! Schema (Google Authenticator 5.10, reverse-engineered but stable): +//! +//! ```text +//! MigrationPayload { otp_parameters=1 (repeated), version=2, +//! batch_size=3, batch_index=4, batch_id=5 } +//! OtpParameters { secret=1 (bytes), name=2, issuer=3, +//! algorithm=4, digits=5, type=6, counter=7 } +//! Algorithm { UNSPECIFIED=0, SHA1=1, SHA256=2, SHA512=3, MD5=4 } +//! DigitCount { UNSPECIFIED=0, SIX=1, EIGHT=2 } +//! OtpType { UNSPECIFIED=0, HOTP=1, TOTP=2 } +//! ``` +//! +//! Two traps worth naming, because both silently produce wrong codes: +//! +//! * **`secret` is raw bytes, not base32.** It has to be base32-encoded to +//! become the secret an `otpauth://` URI would carry. +//! * **There is no period field.** Google only ever exports 30-second TOTP, +//! so 30 is assumed rather than guessed at. + +use crate::totp::{Algorithm, OtpAuth, Totp}; +use crate::{Error, Result}; + +/// Google never exports a period; every entry is the 30-second default. +const ASSUMED_PERIOD: u64 = 30; + +/// An account present in the export that we deliberately did not import. +#[derive(Debug, Clone)] +pub struct Skipped { + pub name: String, + pub reason: String, +} + +/// One decoded export QR code. +#[derive(Debug, Clone, Default)] +pub struct ImportBatch { + pub accounts: Vec, + pub skipped: Vec, + /// 0-based index of this QR within the export, when present. + pub batch_index: Option, + /// Total number of QR codes in the export, when present. + pub batch_size: Option, + /// Identifies which export a QR belongs to, so codes from two different + /// export sessions aren't mistaken for parts of one. + pub batch_id: Option, +} + +impl ImportBatch { + /// Human-readable "Part 2 of 3", when the export was split. + pub fn part_label(&self) -> Option { + match (self.batch_index, self.batch_size) { + (Some(i), Some(n)) if n > 1 => Some(format!("Part {} of {n}", i + 1)), + _ => None, + } + } +} + +/// Is this a Google Authenticator export link? +pub fn is_migration_uri(uri: &str) -> bool { + uri.trim() + .to_ascii_lowercase() + .starts_with("otpauth-migration://") +} + +/// Parse an `otpauth-migration://offline?data=…` link into its accounts. +pub fn parse_migration(uri: &str) -> Result { + let uri = uri.trim(); + let parsed = url::Url::parse(uri) + .map_err(|e| Error::invalid(format!("not a valid export link: {e}")))?; + + if !parsed.scheme().eq_ignore_ascii_case("otpauth-migration") { + return Err(Error::invalid( + "that is not a Google Authenticator export link", + )); + } + + // `url` percent-decodes query values for us. Google's base64 contains + // `+` and `/`, which arrive percent-encoded in a well-formed URI. + let data = parsed + .query_pairs() + .find(|(k, _)| k.eq_ignore_ascii_case("data")) + .map(|(_, v)| v.into_owned()) + .ok_or_else(|| Error::invalid("the export link has no ?data= parameter"))?; + + if data.trim().is_empty() { + return Err(Error::invalid("the export link's data parameter is empty")); + } + + let raw = decode_base64_lenient(&data)?; + decode_payload(&raw) +} + +/// Decode base64 across the variants that turn up in the wild. +/// +/// Google emits standard-alphabet padded base64, but the link is often +/// copied through tools that swap in the URL-safe alphabet or strip padding. +/// Trying all four costs nothing and avoids an unhelpful failure on a link +/// that is perfectly recoverable. +fn decode_base64_lenient(s: &str) -> Result> { + use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD}; + use base64::Engine; + + // Whitespace can be introduced by line-wrapping when a link is pasted. + let cleaned: String = s.chars().filter(|c| !c.is_whitespace()).collect(); + + // A macro rather than a slice of trait objects: base64's `Engine` is not + // dyn-compatible, and the alternatives are all zero-sized consts anyway. + macro_rules! try_decode { + ($engine:expr) => { + if let Ok(bytes) = $engine.decode(&cleaned) { + if !bytes.is_empty() { + return Ok(bytes); + } + } + }; + } + try_decode!(STANDARD); + try_decode!(STANDARD_NO_PAD); + try_decode!(URL_SAFE); + try_decode!(URL_SAFE_NO_PAD); + Err(Error::invalid( + "the export link's data is not valid base64 — copy the whole link, \ + including everything after data=", + )) +} + +// ── Minimal protobuf reader ─────────────────────────────────────────────── + +struct Reader<'a> { + buf: &'a [u8], + pos: usize, +} + +impl<'a> Reader<'a> { + fn new(buf: &'a [u8]) -> Self { + Self { buf, pos: 0 } + } + + fn done(&self) -> bool { + self.pos >= self.buf.len() + } + + fn varint(&mut self) -> Option { + let mut value: u64 = 0; + let mut shift = 0u32; + loop { + let byte = *self.buf.get(self.pos)?; + self.pos += 1; + // 10 groups of 7 bits is the most a u64 can hold; more than that + // is a malformed (or hostile) message rather than a big number. + if shift > 63 { + return None; + } + value |= u64::from(byte & 0x7f) << shift; + if byte & 0x80 == 0 { + return Some(value); + } + shift += 7; + } + } + + /// Returns `(field_number, wire_type)`. + fn tag(&mut self) -> Option<(u32, u8)> { + let key = self.varint()?; + let field = u32::try_from(key >> 3).ok()?; + let wire = (key & 0x07) as u8; + if field == 0 { + return None; // field 0 is illegal; treat as corruption + } + Some((field, wire)) + } + + fn length_delimited(&mut self) -> Option<&'a [u8]> { + let len = usize::try_from(self.varint()?).ok()?; + let end = self.pos.checked_add(len)?; + let slice = self.buf.get(self.pos..end)?; + self.pos = end; + Some(slice) + } + + /// Advance past a field we don't care about, so unknown additions to the + /// schema don't desynchronise the parse. + fn skip(&mut self, wire: u8) -> Option<()> { + match wire { + 0 => { + self.varint()?; + } + 1 => self.pos = self.pos.checked_add(8)?, + 2 => { + self.length_delimited()?; + } + 5 => self.pos = self.pos.checked_add(4)?, + // 3/4 are the deprecated start/end-group types, which this schema + // never uses. Anything else is corruption. + _ => return None, + } + if self.pos > self.buf.len() { + return None; + } + Some(()) + } +} + +fn corrupt() -> Error { + Error::invalid( + "the export data could not be decoded — re-export from Google \ + Authenticator and scan the fresh QR code", + ) +} + +fn decode_payload(raw: &[u8]) -> Result { + let mut out = ImportBatch::default(); + let mut r = Reader::new(raw); + + while !r.done() { + let (field, wire) = r.tag().ok_or_else(corrupt)?; + match (field, wire) { + (1, 2) => { + let entry = r.length_delimited().ok_or_else(corrupt)?; + match decode_entry(entry) { + Ok(Some(account)) => out.accounts.push(account), + Ok(None) => {} + Err(skipped) => out.skipped.push(skipped), + } + } + (3, 0) => out.batch_size = r.varint().ok_or_else(corrupt)?.try_into().ok(), + (4, 0) => out.batch_index = r.varint().ok_or_else(corrupt)?.try_into().ok(), + (5, 0) => out.batch_id = r.varint().ok_or_else(corrupt)?.try_into().ok(), + // version (2) and anything Google adds later. + _ => r.skip(wire).ok_or_else(corrupt)?, + } + } + + if out.accounts.is_empty() && out.skipped.is_empty() { + return Err(Error::invalid("that export link contains no accounts")); + } + Ok(out) +} + +/// Decode one `OtpParameters`. `Err(Skipped)` means "present but not +/// importable", which the UI reports rather than silently dropping. +fn decode_entry(buf: &[u8]) -> std::result::Result, Skipped> { + let mut secret: Vec = Vec::new(); + let mut name = String::new(); + let mut issuer = String::new(); + let mut algorithm = Algorithm::Sha1; + let mut digits: u8 = 6; + let mut otp_type: u64 = 2; // default to TOTP when unspecified + let mut bad_algorithm: Option = None; + + let mut r = Reader::new(buf); + while !r.done() { + let Some((field, wire)) = r.tag() else { break }; + match (field, wire) { + (1, 2) => secret = r.length_delimited().unwrap_or_default().to_vec(), + (2, 2) => { + name = + String::from_utf8_lossy(r.length_delimited().unwrap_or_default()).into_owned(); + } + (3, 2) => { + issuer = + String::from_utf8_lossy(r.length_delimited().unwrap_or_default()).into_owned(); + } + (4, 0) => match r.varint().unwrap_or(1) { + 0 | 1 => algorithm = Algorithm::Sha1, // unspecified means SHA1 + 2 => algorithm = Algorithm::Sha256, + 3 => algorithm = Algorithm::Sha512, + other => bad_algorithm = Some(other), // MD5 and anything new + }, + (5, 0) => { + digits = match r.varint().unwrap_or(1) { + 2 => 8, + _ => 6, // unspecified and SIX both mean 6 + }; + } + (6, 0) => otp_type = r.varint().unwrap_or(2), + _ => { + if r.skip(wire).is_none() { + break; + } + } + } + } + + // Split "Issuer:account", the same shape an otpauth:// label uses. The + // dedicated issuer field wins when both are present. + let (label_issuer, account) = match name.split_once(':') { + Some((i, a)) => (Some(i.trim().to_string()), a.trim().to_string()), + None => (None, name.trim().to_string()), + }; + let issuer_final = if issuer.trim().is_empty() { + label_issuer.unwrap_or_default() + } else { + issuer.trim().to_string() + }; + let display = if account.is_empty() && issuer_final.is_empty() { + "(unnamed)".to_string() + } else if account.is_empty() { + issuer_final.clone() + } else { + account.clone() + }; + + if otp_type == 1 { + return Err(Skipped { + name: display, + reason: "counter-based (HOTP) codes aren't supported".into(), + }); + } + if let Some(alg) = bad_algorithm { + let which = if alg == 4 { + "MD5" + } else { + "an unknown algorithm" + }; + return Err(Skipped { + name: display, + reason: format!("uses {which}, which isn't supported"), + }); + } + if secret.is_empty() { + return Err(Skipped { + name: display, + reason: "has no secret".into(), + }); + } + + // The protobuf carries raw secret bytes; Totp expects the same, so no + // base32 round-trip is needed here (only for display/export). + match Totp::new(secret, algorithm, digits, ASSUMED_PERIOD) { + Ok(totp) => Ok(Some(OtpAuth { + totp, + issuer: if issuer_final.is_empty() { + account.clone() + } else { + issuer_final + }, + label: account, + })), + Err(e) => Err(Skipped { + name: display, + reason: e.to_string(), + }), + } +} + +// ── Tests ───────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::totp::encode_secret; + + // Minimal protobuf *encoder*, so these tests drive the parser with real + // wire bytes rather than a mock. + + fn put_varint(v: u64, out: &mut Vec) { + let mut v = v; + loop { + let mut byte = (v & 0x7f) as u8; + v >>= 7; + if v != 0 { + byte |= 0x80; + } + out.push(byte); + if v == 0 { + break; + } + } + } + + fn put_tag(field: u32, wire: u8, out: &mut Vec) { + put_varint((u64::from(field) << 3) | u64::from(wire), out); + } + + fn put_bytes(field: u32, bytes: &[u8], out: &mut Vec) { + put_tag(field, 2, out); + put_varint(bytes.len() as u64, out); + out.extend_from_slice(bytes); + } + + fn put_uint(field: u32, v: u64, out: &mut Vec) { + put_tag(field, 0, out); + put_varint(v, out); + } + + #[derive(Clone)] + struct Entry { + secret: Vec, + name: &'static str, + issuer: &'static str, + algorithm: u64, + digits: u64, + otp_type: u64, + } + + impl Default for Entry { + fn default() -> Self { + Self { + secret: b"12345678901234567890".to_vec(), + name: "me@example.com", + issuer: "Cloudflare", + algorithm: 1, // SHA1 + digits: 1, // SIX + otp_type: 2, // TOTP + } + } + } + + fn encode_entry(e: &Entry) -> Vec { + let mut b = Vec::new(); + put_bytes(1, &e.secret, &mut b); + put_bytes(2, e.name.as_bytes(), &mut b); + put_bytes(3, e.issuer.as_bytes(), &mut b); + put_uint(4, e.algorithm, &mut b); + put_uint(5, e.digits, &mut b); + put_uint(6, e.otp_type, &mut b); + b + } + + fn encode_payload(entries: &[Entry], batch: Option<(i32, i32, i32)>) -> Vec { + let mut b = Vec::new(); + for e in entries { + put_bytes(1, &encode_entry(e), &mut b); + } + put_uint(2, 1, &mut b); // version + if let Some((size, index, id)) = batch { + put_uint(3, size as u64, &mut b); + put_uint(4, index as u64, &mut b); + put_uint(5, id as u64, &mut b); + } + b + } + + /// Build a link the way Google does: standard base64, percent-encoded + /// into the query string. + fn make_uri(payload: &[u8]) -> String { + use base64::Engine; + let b64 = base64::engine::general_purpose::STANDARD.encode(payload); + let escaped = b64 + .replace('+', "%2B") + .replace('/', "%2F") + .replace('=', "%3D"); + format!("otpauth-migration://offline?data={escaped}") + } + + fn uri_for(entries: &[Entry]) -> String { + make_uri(&encode_payload(entries, None)) + } + + #[test] + fn detects_migration_links() { + assert!(is_migration_uri("otpauth-migration://offline?data=abc")); + assert!(is_migration_uri("OTPAUTH-MIGRATION://offline?data=abc")); + assert!(!is_migration_uri("otpauth://totp/X?secret=A")); + } + + #[test] + fn imports_a_single_account() { + let b = parse_migration(&uri_for(&[Entry::default()])).unwrap(); + assert_eq!(b.accounts.len(), 1); + assert!(b.skipped.is_empty()); + let a = &b.accounts[0]; + assert_eq!(a.issuer, "Cloudflare"); + assert_eq!(a.label, "me@example.com"); + assert_eq!(a.totp.digits, 6); + assert_eq!(a.totp.period, 30, "Google exports are always 30s"); + assert_eq!(a.totp.algorithm, Algorithm::Sha1); + } + + #[test] + fn secret_is_raw_bytes_not_base32() { + // The single most dangerous trap: treating the protobuf secret as if + // it were already base32 yields plausible-looking but wrong codes. + // The RFC 6238 vector secret is ASCII "12345678901234567890". + let b = parse_migration(&uri_for(&[Entry::default()])).unwrap(); + let totp = &b.accounts[0].totp; + assert_eq!(totp.secret, b"12345678901234567890"); + // And it base32-encodes to the well-known test value. + assert_eq!( + encode_secret(&totp.secret), + "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" + ); + // Which means it produces the official RFC 6238 code at t=59. + assert_eq!(totp.code_at(59), "287082"); + } + + #[test] + fn imports_several_accounts_from_one_qr() { + let entries = vec![ + Entry { + name: "a@example.com", + issuer: "Cloudflare", + ..Entry::default() + }, + Entry { + name: "b@example.com", + issuer: "Plex", + ..Entry::default() + }, + Entry { + name: "c@example.com", + issuer: "Ubisoft", + ..Entry::default() + }, + ]; + let b = parse_migration(&uri_for(&entries)).unwrap(); + assert_eq!(b.accounts.len(), 3); + let issuers: Vec<_> = b.accounts.iter().map(|a| a.issuer.as_str()).collect(); + assert_eq!(issuers, ["Cloudflare", "Plex", "Ubisoft"]); + } + + #[test] + fn reports_batch_position() { + let uri = make_uri(&encode_payload(&[Entry::default()], Some((3, 1, 42)))); + let b = parse_migration(&uri).unwrap(); + assert_eq!(b.batch_size, Some(3)); + assert_eq!(b.batch_index, Some(1)); + assert_eq!(b.batch_id, Some(42)); + // batch_index is 0-based; humans count from one. + assert_eq!(b.part_label().as_deref(), Some("Part 2 of 3")); + + // A single-QR export shouldn't claim to be part of a series. + let solo = parse_migration(&uri_for(&[Entry::default()])).unwrap(); + assert!(solo.part_label().is_none()); + } + + #[test] + fn honours_algorithm_and_digit_variants() { + for (code, want) in [(2, Algorithm::Sha256), (3, Algorithm::Sha512)] { + let e = Entry { + algorithm: code, + ..Entry::default() + }; + let b = parse_migration(&uri_for(&[e])).unwrap(); + assert_eq!(b.accounts[0].totp.algorithm, want); + } + let e = Entry { + digits: 2, + ..Entry::default() + }; // EIGHT + let b = parse_migration(&uri_for(&[e])).unwrap(); + assert_eq!(b.accounts[0].totp.digits, 8); + } + + #[test] + fn unspecified_enums_fall_back_to_the_common_defaults() { + let e = Entry { + algorithm: 0, + digits: 0, + otp_type: 0, + ..Entry::default() + }; + let b = parse_migration(&uri_for(&[e])).unwrap(); + assert_eq!(b.accounts[0].totp.algorithm, Algorithm::Sha1); + assert_eq!(b.accounts[0].totp.digits, 6); + } + + #[test] + fn skips_unsupported_entries_but_keeps_the_rest() { + let entries = vec![ + Entry { + name: "good@example.com", + ..Entry::default() + }, + Entry { + name: "counter@example.com", + otp_type: 1, + ..Entry::default() + }, // HOTP + Entry { + name: "md5@example.com", + algorithm: 4, + ..Entry::default() + }, // MD5 + Entry { + name: "empty@example.com", + secret: vec![], + ..Entry::default() + }, + ]; + let b = parse_migration(&uri_for(&entries)).unwrap(); + + // One good account survives; the other three are reported, not dropped. + assert_eq!(b.accounts.len(), 1); + assert_eq!(b.accounts[0].label, "good@example.com"); + assert_eq!(b.skipped.len(), 3); + + let reason = |n: &str| { + b.skipped + .iter() + .find(|s| s.name == n) + .map(|s| s.reason.clone()) + .unwrap() + }; + assert!(reason("counter@example.com").contains("HOTP")); + assert!(reason("md5@example.com").contains("MD5")); + assert!(reason("empty@example.com").contains("no secret")); + } + + #[test] + fn name_may_carry_the_issuer_prefix() { + let e = Entry { + name: "Cloudflare:me@example.com", + issuer: "", + ..Entry::default() + }; + let b = parse_migration(&uri_for(&[e])).unwrap(); + assert_eq!(b.accounts[0].issuer, "Cloudflare"); + assert_eq!(b.accounts[0].label, "me@example.com"); + } + + #[test] + fn issuer_field_wins_over_the_name_prefix() { + let e = Entry { + name: "Stale:me@example.com", + issuer: "Microsoft", + ..Entry::default() + }; + let b = parse_migration(&uri_for(&[e])).unwrap(); + assert_eq!(b.accounts[0].issuer, "Microsoft"); + assert_eq!(b.accounts[0].label, "me@example.com"); + } + + #[test] + fn accepts_every_base64_variant() { + use base64::engine::general_purpose::{ + STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD, + }; + use base64::Engine; + // A payload whose base64 is guaranteed to contain +/ (or -_). + let entries: Vec = (0..4) + .map(|i| Entry { + secret: vec![0xfb, 0xff, 0xbf, i], + ..Entry::default() + }) + .collect(); + let raw = encode_payload(&entries, None); + + for (name, encoded) in [ + ("standard", STANDARD.encode(&raw)), + ("standard-nopad", STANDARD_NO_PAD.encode(&raw)), + ("urlsafe", URL_SAFE.encode(&raw)), + ("urlsafe-nopad", URL_SAFE_NO_PAD.encode(&raw)), + ] { + let escaped = encoded + .replace('+', "%2B") + .replace('/', "%2F") + .replace('=', "%3D"); + let uri = format!("otpauth-migration://offline?data={escaped}"); + let b = parse_migration(&uri).unwrap_or_else(|e| panic!("{name} should decode: {e}")); + assert_eq!(b.accounts.len(), 4, "{name}"); + } + } + + #[test] + fn unknown_fields_do_not_break_parsing() { + // Forward compatibility: if Google adds a field, we must skip it + // rather than desynchronise and mangle everything after it. + let mut entry = encode_entry(&Entry::default()); + put_bytes(99, b"some future string", &mut entry); + put_uint(98, 1234, &mut entry); + + let mut payload = Vec::new(); + put_bytes(1, &entry, &mut payload); + put_bytes(77, b"future payload field", &mut payload); + put_uint(76, 5, &mut payload); + + let b = parse_migration(&make_uri(&payload)).unwrap(); + assert_eq!(b.accounts.len(), 1); + assert_eq!(b.accounts[0].issuer, "Cloudflare"); + } + + #[test] + fn rejects_malformed_input_clearly() { + assert!(parse_migration("otpauth-migration://offline").is_err()); + assert!(parse_migration("otpauth-migration://offline?data=").is_err()); + assert!(parse_migration("otpauth-migration://offline?data=!!!not base64!!!").is_err()); + assert!(parse_migration("otpauth://totp/X?secret=JBSWY3DPEHPK3PXP").is_err()); + assert!(parse_migration("nonsense").is_err()); + + // Truncated protobuf: a length prefix that runs past the buffer. + let uri = make_uri(&[0x0a, 0x7f, 0x01, 0x02]); + assert!(parse_migration(&uri).is_err()); + } + + #[test] + fn an_export_with_no_accounts_is_an_error() { + // Version field only, no entries. + let mut payload = Vec::new(); + put_uint(2, 1, &mut payload); + let e = parse_migration(&make_uri(&payload)) + .unwrap_err() + .to_string(); + assert!(e.contains("no accounts"), "got: {e}"); + } + + #[test] + fn a_large_export_round_trips() { + // Google splits at roughly ten per QR; make sure nothing chokes on a + // full batch, and that every secret survives intact. + let entries: Vec = (0..10) + .map(|i| Entry { + secret: vec![i; 20], + name: "user@example.com", + ..Entry::default() + }) + .collect(); + let b = parse_migration(&uri_for(&entries)).unwrap(); + assert_eq!(b.accounts.len(), 10); + for (i, a) in b.accounts.iter().enumerate() { + assert_eq!( + a.totp.secret, + vec![i as u8; 20], + "entry {i} secret corrupted" + ); + } + } +} diff --git a/crates/core/src/settings.rs b/crates/core/src/settings.rs new file mode 100644 index 0000000..c6e0421 --- /dev/null +++ b/crates/core/src/settings.rs @@ -0,0 +1,727 @@ +//! Runtime-mutable settings: where codes are delivered, and how. +//! +//! OpenMFA delivers to exactly one chat platform at a time — Discord, Slack or +//! Telegram — but keeps the configuration for all three, so switching back and +//! forth doesn't mean re-pasting tokens. +//! +//! The three platforms have different authentication shapes, and it's worth +//! being clear about why: +//! +//! * **Discord** needs only an incoming-webhook URL. Webhooks can create, +//! edit and delete their own messages, so no bot is involved. +//! * **Slack** incoming webhooks can *post* but cannot edit — and editing in +//! place is the whole live-tile mechanism. So Slack needs a bot token +//! (`xoxb-…`, scope `chat:write`) plus the channel ID, and uses +//! `chat.postMessage` / `chat.update`. +//! * **Telegram** has no webhook concept at all; everything is a bot. A bot +//! token from @BotFather plus the chat ID, using `sendMessage` / +//! `editMessageText`. + +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; + +/// Which chat platform receives the live codes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Provider { + Discord, + Slack, + Telegram, +} + +impl Provider { + pub fn as_str(self) -> &'static str { + match self { + Self::Discord => "discord", + Self::Slack => "slack", + Self::Telegram => "telegram", + } + } + + /// Human-facing name for toasts and logs. + pub fn display_name(self) -> &'static str { + match self { + Self::Discord => "Discord", + Self::Slack => "Slack", + Self::Telegram => "Telegram", + } + } + + /// Whether this platform can carry the rendered image tile. + /// + /// Only Discord for now: a webhook can attach a file and replace it on + /// edit. Slack's file upload can't be swapped inside an existing message + /// without deleting and re-posting, and Telegram's `editMessageMedia` is + /// deferred to a later release. Both get the code as copyable text. + pub fn supports_image(self) -> bool { + matches!(self, Self::Discord) + } +} + +impl std::fmt::Display for Provider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.display_name()) + } +} + +/// How each account's live message is composed. Image modes only apply to +/// providers that [`Provider::supports_image`]; others always send text. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum PushMode { + /// Text only — one JSON edit per period, nothing uploaded, digits + /// selectable so they can actually be copied. + Embed, + /// A rendered WebP tile, uploaded fresh on every edit. + Image, + /// The tile *and* the code as text. + #[default] + Both, +} + +impl PushMode { + pub fn has_image(self) -> bool { + matches!(self, Self::Image | Self::Both) + } + pub fn has_text(self) -> bool { + matches!(self, Self::Embed | Self::Both) + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct DiscordConfig { + pub webhook_url: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct SlackConfig { + /// Bot user OAuth token, `xoxb-…`. Needs the `chat:write` scope, and the + /// bot must be a member of the channel (`/invite @OpenMFA`). + pub bot_token: String, + /// Channel ID such as `C0123ABCDEF` — not the `#name`. `chat.update` + /// requires the ID, so we insist on it up front rather than have the + /// first edit fail. + pub channel: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct TelegramConfig { + /// Bot token from @BotFather, `123456789:AAF…`. + pub bot_token: String, + /// Numeric chat ID (negative for groups, `-100…` for supergroups) or a + /// public `@channelusername`. + pub chat_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct Settings { + /// Master switch for delivery. Off, nothing is pushed and no tiles are + /// maintained. + pub push_enabled: bool, + /// Active platform. `None` until the operator picks one — first-run setup + /// makes this optional, so an install can exist before any chat is wired. + pub provider: Option, + pub discord: DiscordConfig, + pub slack: SlackConfig, + pub telegram: TelegramConfig, + pub push_mode: PushMode, +} + +impl Default for Settings { + fn default() -> Self { + Self { + push_enabled: true, + provider: None, + discord: DiscordConfig::default(), + slack: SlackConfig::default(), + telegram: TelegramConfig::default(), + push_mode: PushMode::default(), + } + } +} + +impl Settings { + /// True when delivery is on, a platform is chosen, and its configuration + /// is complete enough to send. + pub fn is_usable(&self) -> bool { + self.push_enabled && self.provider.is_some_and(|p| self.is_configured(p)) + } + + /// Does a given platform have everything it needs? + pub fn is_configured(&self, p: Provider) -> bool { + match p { + Provider::Discord => !self.discord.webhook_url.trim().is_empty(), + Provider::Slack => { + !self.slack.bot_token.trim().is_empty() && !self.slack.channel.trim().is_empty() + } + Provider::Telegram => { + !self.telegram.bot_token.trim().is_empty() + && !self.telegram.chat_id.trim().is_empty() + } + } + } + + /// Identifies *where* messages land. When this changes, every stored + /// message id refers to a message in some other place and is discarded. + /// + /// Deliberately excludes tokens: rotating a bot token for the same + /// channel leaves the existing tiles editable, and if the new token turns + /// out to be a different bot the engine recovers from the resulting + /// "can't edit that message" error by reposting. + pub fn destination_key(&self) -> Option { + let p = self.provider?; + Some(match p { + Provider::Discord => format!("discord:{}", self.discord.webhook_url.trim()), + Provider::Slack => format!("slack:{}", self.slack.channel.trim()), + Provider::Telegram => format!("telegram:{}", self.telegram.chat_id.trim()), + }) + } + + /// Effective mode: image modes collapse to text on platforms that can't + /// carry the tile. + pub fn effective_mode(&self) -> PushMode { + match self.provider { + Some(p) if p.supports_image() => self.push_mode, + _ => PushMode::Embed, + } + } + + /// Validate without persisting — first-run setup uses this so a mistyped + /// token is rejected before the admin account exists, rather than leaving + /// a half-configured install that needs the password to fix. + pub fn check(&self) -> Result { + let mut c = self.clone(); + c.normalise(); + c.validate()?; + Ok(c) + } + + fn normalise(&mut self) { + self.discord.webhook_url = self.discord.webhook_url.trim().to_string(); + self.slack.bot_token = self.slack.bot_token.trim().to_string(); + self.slack.channel = self.slack.channel.trim().to_string(); + self.telegram.bot_token = self.telegram.bot_token.trim().to_string(); + self.telegram.chat_id = self.telegram.chat_id.trim().to_string(); + } + + /// Validate whatever is filled in. Empty fields are fine (the platform is + /// simply not configured); non-empty fields must be well-formed, and the + /// *active* platform must be complete when delivery is on. + fn validate(&self) -> Result<()> { + if !self.discord.webhook_url.is_empty() { + validate_webhook_url(&self.discord.webhook_url)?; + } + if !self.slack.bot_token.is_empty() || !self.slack.channel.is_empty() { + validate_slack(&self.slack)?; + } + if !self.telegram.bot_token.is_empty() || !self.telegram.chat_id.is_empty() { + validate_telegram(&self.telegram)?; + } + if self.push_enabled { + if let Some(p) = self.provider { + if !self.is_configured(p) { + return Err(Error::invalid(format!( + "{p} is selected but not fully configured" + ))); + } + } + } + Ok(()) + } +} + +// ── Validators ──────────────────────────────────────────────────────────── + +/// Validate a Discord incoming-webhook URL. +/// +/// Checked strictly on purpose: a URL that is merely *syntactically* valid +/// fails later inside a background loop where the only evidence is a log +/// line. Catching the shape here means the person who pasted it finds out +/// while they still have the 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)", + )); + } + let segments: Vec<_> = parsed + .path_segments() + .map(|s| s.filter(|p| !p.is_empty()).collect()) + .unwrap_or_default(); + match segments.iter().position(|s| *s == "webhooks") { + 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", + )), + } +} + +/// Validate Slack bot token + channel ID shape. +pub fn validate_slack(c: &SlackConfig) -> Result<()> { + let token = c.bot_token.trim(); + if token.is_empty() { + return Err(Error::invalid("the Slack bot token is empty")); + } + if token.starts_with("xoxp-") { + return Err(Error::invalid( + "that is a Slack *user* token (xoxp-). Use the Bot User OAuth Token (xoxb-) \ + from your app's OAuth & Permissions page", + )); + } + if !token.starts_with("xoxb-") { + return Err(Error::invalid( + "a Slack bot token starts with xoxb- — copy the Bot User OAuth Token from \ + OAuth & Permissions", + )); + } + if token.len() < 20 + || !token + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-') + { + return Err(Error::invalid("that Slack token doesn't look complete")); + } + + let channel = c.channel.trim(); + if channel.is_empty() { + return Err(Error::invalid("the Slack channel ID is empty")); + } + if channel.starts_with('#') { + return Err(Error::invalid( + "use the channel *ID* (like C0123ABCDEF), not the #name — open the channel, \ + click its name, and copy the ID at the bottom of the details pane", + )); + } + let shape_ok = matches!(channel.chars().next(), Some('C' | 'G' | 'D')) + && channel.len() >= 9 + && channel.chars().all(|ch| ch.is_ascii_alphanumeric()); + if !shape_ok { + return Err(Error::invalid( + "a Slack channel ID looks like C0123ABCDEF (or G… for a private channel)", + )); + } + Ok(()) +} + +/// Validate Telegram bot token + chat id shape. +pub fn validate_telegram(c: &TelegramConfig) -> Result<()> { + let token = c.bot_token.trim(); + if token.is_empty() { + return Err(Error::invalid("the Telegram bot token is empty")); + } + let Some((id, secret)) = token.split_once(':') else { + return Err(Error::invalid( + "a Telegram bot token looks like 123456789:AAF… — copy it exactly as \ + @BotFather gave it to you", + )); + }; + if id.is_empty() || !id.chars().all(|ch| ch.is_ascii_digit()) || secret.len() < 30 { + return Err(Error::invalid( + "that Telegram bot token doesn't look complete", + )); + } + + let chat = c.chat_id.trim(); + if chat.is_empty() { + return Err(Error::invalid("the Telegram chat ID is empty")); + } + let numeric = chat + .strip_prefix('-') + .unwrap_or(chat) + .chars() + .all(|ch| ch.is_ascii_digit()) + && chat != "-"; + let username = chat.starts_with('@') + && chat.len() > 5 + && chat[1..] + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '_'); + if !numeric && !username { + return Err(Error::invalid( + "a Telegram chat ID is a number (negative for groups) or a public @channel — \ + use Detect below to find it", + )); + } + Ok(()) +} + +// ── Store ───────────────────────────────────────────────────────────────── + +#[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: "openmfa::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 destination changed — a different platform, or + /// a different channel on the same one — which the caller must treat as + /// "every stored message id is now meaningless". + pub fn replace(&self, mut incoming: Settings) -> Result<(Settings, bool)> { + incoming.normalise(); + incoming.validate()?; + + let changed = { + let mut g = self.inner.write(); + let changed = g.destination_key() != incoming.destination_key(); + *g = incoming.clone(); + changed + }; + self.persist(); + tracing::info!( + target: "openmfa::settings", + enabled = incoming.push_enabled, + provider = incoming.provider.map_or("none", Provider::as_str), + mode = ?incoming.push_mode, + destination_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: "openmfa::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: "openmfa::settings", "could not commit settings: {e}"); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + const HOOK: &str = "https://discord.com/api/webhooks/123456789012345678/abcdefTOKEN-_123"; + const XOXB: &str = "xoxb-1234567890-1234567890123-abcdefghijklmnopqrstuvwx"; + const TG: &str = "123456789:AAFabcdefghijklmnopqrstuvwxyz0123456789"; + + fn discord() -> Settings { + Settings { + provider: Some(Provider::Discord), + discord: DiscordConfig { + webhook_url: HOOK.into(), + }, + ..Settings::default() + } + } + fn slack() -> Settings { + Settings { + provider: Some(Provider::Slack), + slack: SlackConfig { + bot_token: XOXB.into(), + channel: "C0123ABCDEF".into(), + }, + ..Settings::default() + } + } + fn telegram() -> Settings { + Settings { + provider: Some(Provider::Telegram), + telegram: TelegramConfig { + bot_token: TG.into(), + chat_id: "-1001234567890".into(), + }, + ..Settings::default() + } + } + + #[test] + fn defaults_have_no_provider_and_are_not_usable() { + let dir = tempdir().unwrap(); + let s = SettingsStore::load_or_default(dir.path()).snapshot(); + assert!(s.provider.is_none()); + assert!(!s.is_usable(), "nothing configured, nothing to push to"); + assert_eq!(s.push_mode, PushMode::Both); + } + + #[test] + fn each_provider_is_usable_when_complete() { + for s in [discord(), slack(), telegram()] { + assert!(s.is_usable(), "{:?}", s.provider); + } + // Selected but half-filled is not usable. + let mut half = slack(); + half.slack.channel.clear(); + assert!(!half.is_usable()); + } + + #[test] + fn image_modes_collapse_to_text_off_discord() { + let mut s = slack(); + s.push_mode = PushMode::Both; + assert_eq!(s.effective_mode(), PushMode::Embed); + let mut t = telegram(); + t.push_mode = PushMode::Image; + assert_eq!(t.effective_mode(), PushMode::Embed); + assert_eq!(discord().effective_mode(), PushMode::Both); + } + + #[test] + fn discord_webhook_validation() { + for url in [ + HOOK, + "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}"); + } + for url in [ + "", + "not a url", + "http://discord.com/api/webhooks/1/t", + "https://evil.example.com/api/webhooks/1/t", + "https://notdiscord.com/api/webhooks/1/t", + "https://discord.com/api/webhooks/123", + "https://discord.com/channels/1/2", + ] { + assert!(validate_webhook_url(url).is_err(), "should reject {url}"); + } + } + + #[test] + fn slack_validation_steers_the_common_mistakes() { + let ok = SlackConfig { + bot_token: XOXB.into(), + channel: "C0123ABCDEF".into(), + }; + assert!(validate_slack(&ok).is_ok()); + // Private channels have G-prefixed ids. + let g = SlackConfig { + bot_token: XOXB.into(), + channel: "G0123ABCDEF".into(), + }; + assert!(validate_slack(&g).is_ok()); + + // A user token is the classic wrong paste; name it specifically. + let user = SlackConfig { + bot_token: "xoxp-1234567890-abc".into(), + channel: "C0123ABCDEF".into(), + }; + let e = validate_slack(&user).unwrap_err().to_string(); + assert!(e.contains("user"), "{e}"); + + // #name instead of an ID. + let name = SlackConfig { + bot_token: XOXB.into(), + channel: "#general".into(), + }; + let e = validate_slack(&name).unwrap_err().to_string(); + assert!(e.contains("ID"), "{e}"); + + for bad in ["", "general", "12345", "c0123abcdef"] { + let c = SlackConfig { + bot_token: XOXB.into(), + channel: bad.into(), + }; + assert!(validate_slack(&c).is_err(), "should reject channel {bad:?}"); + } + let short = SlackConfig { + bot_token: "xoxb-short".into(), + channel: "C0123ABCDEF".into(), + }; + assert!(validate_slack(&short).is_err()); + } + + #[test] + fn telegram_validation() { + for chat in ["123456", "-987654", "-1001234567890", "@my_channel"] { + let c = TelegramConfig { + bot_token: TG.into(), + chat_id: chat.into(), + }; + assert!(validate_telegram(&c).is_ok(), "should accept chat {chat}"); + } + for chat in ["", "-", "abc", "@ab", "12a3", "@bad-name"] { + let c = TelegramConfig { + bot_token: TG.into(), + chat_id: chat.into(), + }; + assert!( + validate_telegram(&c).is_err(), + "should reject chat {chat:?}" + ); + } + for tok in [ + "", + "no-colon", + ":onlysecret", + "abc:AAFabcdefghijklmnopqrstuvwxyz0123456789", + "123:short", + ] { + let c = TelegramConfig { + bot_token: tok.into(), + chat_id: "123".into(), + }; + assert!( + validate_telegram(&c).is_err(), + "should reject token {tok:?}" + ); + } + } + + #[test] + fn selecting_an_unconfigured_provider_with_push_on_is_refused() { + let dir = tempdir().unwrap(); + let s = SettingsStore::load_or_default(dir.path()); + let r = s.replace(Settings { + push_enabled: true, + provider: Some(Provider::Slack), + ..Settings::default() + }); + assert!(r.is_err()); + // Off, it's a legitimate half-configured state. + assert!(s + .replace(Settings { + push_enabled: false, + provider: Some(Provider::Slack), + ..Settings::default() + }) + .is_ok()); + } + + #[test] + fn destination_change_detection() { + let dir = tempdir().unwrap(); + let s = SettingsStore::load_or_default(dir.path()); + + let (_, changed) = s.replace(discord()).unwrap(); + assert!(changed, "first destination counts as a change"); + + // Same destination, different mode: message ids stay valid. + let mut same = discord(); + same.push_mode = PushMode::Embed; + assert!(!s.replace(same).unwrap().1); + + // Switching platform invalidates everything. + assert!(s.replace(slack()).unwrap().1); + + // Same Slack channel, rotated token: NOT a destination change — the + // tiles are still there and the engine self-heals if it can't edit. + let mut rotated = slack(); + rotated.slack.bot_token = "xoxb-9999999999-9999999999999-zyxwvutsrqponmlkjihgfedc".into(); + assert!(!s.replace(rotated).unwrap().1); + + // Different channel on the same platform is a change. + let mut other = slack(); + other.slack.channel = "C9999ZZZZZZ".into(); + assert!(s.replace(other).unwrap().1); + } + + #[test] + fn switching_provider_keeps_the_other_configs() { + let dir = tempdir().unwrap(); + let s = SettingsStore::load_or_default(dir.path()); + let mut both = slack(); + both.discord.webhook_url = HOOK.into(); + s.replace(both).unwrap(); + + let mut back = s.snapshot(); + back.provider = Some(Provider::Discord); + let (saved, _) = s.replace(back).unwrap(); + assert_eq!( + saved.slack.channel, "C0123ABCDEF", + "Slack config must survive" + ); + assert_eq!(saved.provider, Some(Provider::Discord)); + } + + #[test] + fn persists_across_a_reload() { + let dir = tempdir().unwrap(); + SettingsStore::load_or_default(dir.path()) + .replace(telegram()) + .unwrap(); + let v = SettingsStore::load_or_default(dir.path()).snapshot(); + assert_eq!(v.provider, Some(Provider::Telegram)); + 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!(SettingsStore::load_or_default(dir.path()) + .snapshot() + .provider + .is_none()); + } + + #[test] + fn provider_serialises_lowercase() { + assert_eq!( + serde_json::to_string(&Provider::Telegram).unwrap(), + "\"telegram\"" + ); + let s: Settings = serde_json::from_str(r#"{"provider":"slack"}"#).unwrap(); + assert_eq!(s.provider, Some(Provider::Slack)); + } +} diff --git a/crates/core/src/totp.rs b/crates/core/src/totp.rs new file mode 100644 index 0000000..d87de29 --- /dev/null +++ b/crates/core/src/totp.rs @@ -0,0 +1,514 @@ +//! 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") { + // Callers should route these to `crate::parse_import`, which + // understands them. Reaching here means a single-URI code path + // was handed a multi-account link. + return Err(Error::invalid( + "this is a Google Authenticator export link — use the import flow, \ + which reads all of its accounts at once.", + )); + } + 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. OpenMFA 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..c1ceb5d --- /dev/null +++ b/crates/core/src/vault.rs @@ -0,0 +1,910 @@ +//! 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 DeliveryState { + /// 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 delivery: DeliveryState, +} + +fn default_true() -> bool { + true +} + +/// An override wins when it has content; otherwise keep the parsed value. +fn pick(override_: Option<&str>, parsed: &str) -> String { + override_ + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(parsed) + .to_string() +} + +fn display_name(issuer: &str, label: &str) -> String { + match (issuer.is_empty(), label.is_empty()) { + (false, false) => format!("{issuer} ({label})"), + (false, true) => issuer.to_string(), + (true, false) => label.to_string(), + (true, true) => "(unnamed)".to_string(), + } +} + +/// 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.delivery.message_id.is_some() && a.delivery.last_error.is_none(), + push_error: a.delivery.last_error.clone(), + created_at: a.created_at, + } + } +} + +/// One account queued for import, with optional renames applied on the way in. +#[derive(Debug, Clone)] +pub struct NewAccount { + pub parsed: OtpAuth, + pub issuer: Option, + pub label: Option, + pub push_enabled: bool, +} + +/// Result of a bulk import. +#[derive(Debug, Clone, Default, Serialize)] +pub struct ImportOutcome { + pub added: Vec, + /// Display names of entries already in the vault, skipped rather than + /// duplicated. Re-importing the same Google export is a normal thing to + /// do — it should be a no-op, not ten more rows. + pub duplicates: Vec, +} + +/// 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: "openmfa::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: "openmfa::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.delivery.message_id.clone(), + last_code: a.delivery.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.delivery.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(), + delivery: DeliveryState::default(), + }; + + let public = AccountPublic::from(&account); + self.inner.write().push(account); + self.persist(); + tracing::info!( + target: "openmfa::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"))?; + + let mut renamed = false; + 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")); + } + renamed |= a.issuer != v; + 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")); + } + renamed |= a.label != v; + 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.delivery.last_error = None; + } + a.push_enabled = v; + } + + // A rename changes what the Discord tile should say, but not the + // code — and the push loop skips any account whose code it has + // already sent. Without this the new name would sit invisible + // until the code next rolled, up to a full period later. Clearing + // the last-sent code makes the very next tick treat the tile as + // stale, so the rename shows up in about a second. + if renamed { + a.delivery.last_code = None; + } + + 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: "openmfa::vault", + issuer = %removed.issuer, id = %removed.id, + "account removed" + ); + Ok(removed.delivery.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.delivery.message_id = Some(message_id.to_string()); + a.delivery.last_code = Some(code.to_string()); + a.delivery.last_error = None; + a.delivery.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.delivery.last_error.as_deref() == Some(error) { + return; + } + a.delivery.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.delivery.message_id = None; + a.delivery.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.delivery = DeliveryState::default(); + } + } + self.persist(); + } + + /// Is an identical account already stored? + /// + /// Matched on the decrypted secret *and* the naming. Secret-only matching + /// would block the legitimate case of deliberately holding one secret + /// under two labels; naming-only matching would miss a re-export where + /// the issuer string changed. + pub fn contains(&self, totp: &Totp, issuer: &str, label: &str) -> bool { + let issuer = issuer.trim(); + let label = label.trim(); + self.inner.read().iter().any(|a| { + a.issuer == issuer + && a.label == label + && a.digits == totp.digits + && a.period == totp.period + && a.algorithm == totp.algorithm + && self + .key + .open(&a.secret) + .is_ok_and(|plain| plain == totp.secret) + }) + } + + /// Add several accounts at once, skipping any already present. + /// + /// Persists once at the end rather than per account: a ten-account Google + /// export would otherwise mean ten separate rewrites of `vault.json`. + pub fn add_many(&self, items: &[NewAccount]) -> Result { + let mut outcome = ImportOutcome::default(); + let mut staged: Vec = Vec::new(); + + for item in items { + let issuer = pick(item.issuer.as_deref(), &item.parsed.issuer); + let label = pick(item.label.as_deref(), &item.parsed.label); + + if issuer.len() > 128 || label.len() > 128 { + return Err(Error::invalid( + "issuer and account must be 128 characters or fewer", + )); + } + + // Check against what's stored *and* what this batch has already + // staged, so an export containing the same account twice doesn't + // slip a duplicate through. + let dup_in_vault = self.contains(&item.parsed.totp, &issuer, &label); + let dup_in_batch = staged + .iter() + .any(|a| a.issuer == issuer && a.label == label); + if dup_in_vault || dup_in_batch { + outcome.duplicates.push(display_name(&issuer, &label)); + continue; + } + + staged.push(Account { + id: uuid::Uuid::new_v4().simple().to_string(), + issuer, + label, + algorithm: item.parsed.totp.algorithm, + digits: item.parsed.totp.digits, + period: item.parsed.totp.period, + secret: self.key.seal(&item.parsed.totp.secret)?, + push_enabled: item.push_enabled, + created_at: OffsetDateTime::now_utc(), + delivery: DeliveryState::default(), + }); + } + + if staged.is_empty() { + return Ok(outcome); + } + + outcome.added = staged.iter().map(AccountPublic::from).collect(); + { + let mut g = self.inner.write(); + g.extend(staged); + } + self.persist(); + tracing::info!( + target: "openmfa::vault", + added = outcome.added.len(), + duplicates = outcome.duplicates.len(), + "bulk import complete" + ); + Ok(outcome) + } + + 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: "openmfa::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: "openmfa::vault", "could not write vault temp file: {e}"); + return; + } + if let Err(e) = std::fs::rename(&tmp, self.path.as_path()) { + tracing::error!(target: "openmfa::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()); + } + + fn newacct(uri: &str, push: bool) -> NewAccount { + NewAccount { + parsed: parse_otpauth(uri).unwrap(), + issuer: None, + label: None, + push_enabled: push, + } + } + + #[test] + fn bulk_import_adds_everything_once() { + let dir = tempdir().unwrap(); + let v = vault(dir.path()); + let items: Vec<_> = ["Cloudflare", "Plex", "Ubisoft"] + .iter() + .map(|i| { + newacct( + &format!( + "otpauth://totp/{i}:me@example.com?secret=JBSWY3DPEHPK3PXP&issuer={i}" + ), + true, + ) + }) + .collect(); + + let out = v.add_many(&items).unwrap(); + assert_eq!(out.added.len(), 3); + assert!(out.duplicates.is_empty()); + assert_eq!(v.len(), 3); + + // Re-importing the same export must be a no-op, not three more rows. + let again = v.add_many(&items).unwrap(); + assert!(again.added.is_empty()); + assert_eq!(again.duplicates.len(), 3); + assert_eq!(v.len(), 3, "vault must not grow on a repeat import"); + assert!(again.duplicates[0].contains("Cloudflare")); + } + + #[test] + fn duplicates_within_one_batch_are_collapsed() { + let dir = tempdir().unwrap(); + let v = vault(dir.path()); + let one = newacct( + "otpauth://totp/Plex:me@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Plex", + true, + ); + let out = v.add_many(&[one.clone(), one]).unwrap(); + assert_eq!(out.added.len(), 1); + assert_eq!(out.duplicates.len(), 1); + assert_eq!(v.len(), 1); + } + + #[test] + fn the_same_secret_under_a_different_name_is_not_a_duplicate() { + let dir = tempdir().unwrap(); + let v = vault(dir.path()); + let base = + parse_otpauth("otpauth://totp/Plex:me@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Plex") + .unwrap(); + v.add_many(&[NewAccount { + parsed: base.clone(), + issuer: None, + label: None, + push_enabled: true, + }]) + .unwrap(); + + // Deliberately holding one secret under a second label stays allowed. + let out = v + .add_many(&[NewAccount { + parsed: base, + issuer: Some("Plex (work)".into()), + label: None, + push_enabled: true, + }]) + .unwrap(); + assert_eq!(out.added.len(), 1); + assert_eq!(v.len(), 2); + } + + #[test] + fn contains_matches_on_secret_and_naming() { + let dir = tempdir().unwrap(); + let v = vault(dir.path()); + let a = + parse_otpauth("otpauth://totp/Plex:me@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Plex") + .unwrap(); + assert!(!v.contains(&a.totp, "Plex", "me@example.com")); + v.add(&a, None, None, true).unwrap(); + assert!(v.contains(&a.totp, "Plex", "me@example.com")); + assert!(!v.contains(&a.totp, "Plex", "other@example.com")); + + // A different secret under the same name is not a match. + let b = + parse_otpauth("otpauth://totp/Plex:me@example.com?secret=GEZDGNBVGY3TQOJQ&issuer=Plex") + .unwrap(); + assert!(!v.contains(&b.totp, "Plex", "me@example.com")); + } + + #[test] + fn bulk_import_persists_once_and_survives_reload() { + let dir = tempdir().unwrap(); + { + let v = vault(dir.path()); + let items: Vec<_> = (0..5) + .map(|i| { + newacct( + &format!("otpauth://totp/Svc{i}:me@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Svc{i}"), + i % 2 == 0, + ) + }) + .collect(); + assert_eq!(v.add_many(&items).unwrap().added.len(), 5); + } + let reloaded = vault(dir.path()); + assert_eq!(reloaded.len(), 5); + // The per-account push flag must survive the round trip. + assert_eq!(reloaded.push_targets().len(), 3); + } + + #[test] + fn an_empty_import_is_harmless() { + let dir = tempdir().unwrap(); + let v = vault(dir.path()); + let out = v.add_many(&[]).unwrap(); + assert!(out.added.is_empty() && out.duplicates.is_empty()); + assert!(v.is_empty()); + } + + #[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/delivery/Cargo.toml b/crates/delivery/Cargo.toml new file mode 100644 index 0000000..14e7c8e --- /dev/null +++ b/crates/delivery/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "openmfa-delivery" +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] +openmfa-core.workspace = true +openmfa-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 +time.workspace = true + +[lints] +workspace = true + +[dev-dependencies] +axum.workspace = true +tempfile = "3" diff --git a/crates/delivery/src/discord.rs b/crates/delivery/src/discord.rs new file mode 100644 index 0000000..24da96f --- /dev/null +++ b/crates/delivery/src/discord.rs @@ -0,0 +1,312 @@ +//! Discord webhook transport: create, edit and delete one message. +//! +//! Discord is the one platform where a plain incoming webhook can edit its +//! own messages, so no bot is involved. The webhook URL is the whole +//! credential. +//! +//! Observed bucket is roughly 5 requests/second per webhook; the governor runs +//! at 2/second, which absorbs both the undocumented per-channel write limit +//! and any other client sharing the channel. + +use crate::error::{DeliveryError, Result}; +use crate::pacing::Governor; +use std::time::Duration; +use tokio::time::Instant; + +const MIN_INTERVAL: Duration = Duration::from_millis(500); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(20); +const MAX_BACKOFF: Duration = Duration::from_secs(60); + +/// One configured webhook endpoint. +#[derive(Debug, Clone)] +pub struct Webhook { + /// `https://discord.com/api/webhooks/{id}/{token}`, trailing slash stripped. + 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 design work: without it Discord returns + /// 204 with an empty body and we never learn the message id. + fn create_url(&self) -> String { + format!("{}?wait=true", self.base) + } + + fn message_url(&self, message_id: &str) -> String { + format!("{}/messages/{message_id}", self.base) + } +} + +/// A Discord message body, with an optional file to upload as `files[0]`. +#[derive(Debug, Clone)] +pub struct Payload { + /// Complete JSON. A PATCH replaces each provided top-level field wholesale, + /// so the full `embeds` array is resent every time. + pub json: serde_json::Value, + /// `(filename, bytes)` — the filename must match the embed's + /// `attachment://` reference 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: crate::http_client(REQUEST_TIMEOUT), + governor: Governor::new(MIN_INTERVAL), + } + } + + pub async fn create(&self, hook: &Webhook, payload: &Payload) -> Result { + let body = self + .send(reqwest::Method::POST, &hook.create_url(), payload) + .await?; + body.get("id") + .and_then(serde_json::Value::as_str) + // Snowflakes exceed 2^53; insist on the string Discord sends. + .map(str::to_string) + .ok_or_else(|| { + DeliveryError::Transient("Discord accepted the message but returned no id".into()) + }) + } + + 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(|_| ()) + } + + pub async fn delete(&self, hook: &Webhook, message_id: &str) -> Result<()> { + let mut guard = self.governor.acquire().await; + let resp = self + .http + .delete(hook.message_url(message_id)) + .send() + .await + .map_err(|e| DeliveryError::Transient(format!("could not reach Discord: {e}")))?; + *guard = Instant::now() + MIN_INTERVAL; + drop(guard); + if resp.status() == reqwest::StatusCode::NOT_FOUND || resp.status().is_success() { + return Ok(()); + } + Err(classify(resp.status(), &read_error(resp).await)) + } + + 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 resp = self + .build(method.clone(), url, payload)? + .send() + .await + .map_err(|e| DeliveryError::Transient(format!("could not reach Discord: {e}")))?; + + let status = resp.status(); + 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; + 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: "openmfa::discord", + wait_ms = wait.as_millis() as u64, + "rate limited by Discord; backing off" + ); + tokio::time::sleep(wait).await; + continue; + } + return Err(DeliveryError::Transient(format!( + "rate limited by Discord (retry in {:.1}s)", + wait.as_secs_f64() + ))); + } + + 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)); + } + 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(DeliveryError::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 { + Some((filename, bytes)) => { + let part = reqwest::multipart::Part::bytes(bytes.clone()) + .file_name(filename.clone()) + .mime_str("image/webp") + .map_err(|e| DeliveryError::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)), + } + } +} + +#[derive(Debug, Default)] +struct ApiError { + message: Option, + code: Option, + retry_after: Option, +} + +async fn read_error(resp: reqwest::Response) -> ApiError { + let text = resp.text().await.unwrap_or_default(); + let mut out = ApiError::default(); + if let Ok(v) = serde_json::from_str::(&text) { + out.message = v + .get("message") + .and_then(|m| m.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 +} + +/// Discord's JSON error codes are what distinguish "message deleted" +/// (recoverable) from "webhook deleted" (not); both arrive as 404. +fn classify(status: reqwest::StatusCode, err: &ApiError) -> DeliveryError { + let detail = err + .message + .clone() + .unwrap_or_else(|| format!("HTTP {status}")); + match err.code { + Some(10008) => DeliveryError::MessageGone, + Some(10015) => DeliveryError::Fatal( + "the Discord webhook no longer exists — recreate it in Discord and paste the new URL" + .into(), + ), + Some(10003) => { + DeliveryError::Fatal("the Discord channel for this webhook no longer exists".into()) + } + Some(50027) => DeliveryError::Fatal( + "the Discord webhook token is invalid — copy the webhook URL again".into(), + ), + Some(50013) => { + DeliveryError::Fatal("the webhook lacks permission to post in that channel".into()) + } + Some(40005) => { + DeliveryError::Fatal("the rendered tile is too large for Discord to accept".into()) + } + _ => match status.as_u16() { + 401 | 403 => DeliveryError::Fatal(format!("Discord rejected the webhook: {detail}")), + 404 => DeliveryError::Fatal(format!("Discord webhook not found: {detail}")), + 400 => DeliveryError::Fatal(format!("Discord rejected the message: {detail}")), + _ => DeliveryError::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() { + let w = Webhook::new("https://discord.com/api/webhooks/123/tok/"); + assert_eq!( + w.create_url(), + "https://discord.com/api/webhooks/123/tok?wait=true" + ); + 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, + retry_after: None, + } + } + + #[test] + fn deleted_message_is_recoverable_and_dead_webhook_is_fatal() { + assert!(matches!( + classify( + reqwest::StatusCode::NOT_FOUND, + &err(Some(10008), "Unknown Message") + ), + DeliveryError::MessageGone + )); + for code in [10015, 10003, 50027, 50013] { + assert!(classify(reqwest::StatusCode::NOT_FOUND, &err(Some(code), "x")).is_fatal()); + } + assert!(matches!( + classify(reqwest::StatusCode::BAD_GATEWAY, &err(None, "oops")), + DeliveryError::Transient(_) + )); + } +} diff --git a/crates/delivery/src/error.rs b/crates/delivery/src/error.rs new file mode 100644 index 0000000..73b8dcc --- /dev/null +++ b/crates/delivery/src/error.rs @@ -0,0 +1,35 @@ +//! One failure vocabulary for every platform. +//! +//! The three-way split is the important part, and it is the same on Discord, +//! Slack and Telegram: a message that has *gone* is recovered from by +//! reposting; a *fatal* problem (revoked token, deleted channel) must stop the +//! loop rather than spin; anything else is worth another try next tick. +//! Conflating the first two is expensive — every platform will eventually ban +//! a client that hammers 4xx responses. + +#[derive(Debug, thiserror::Error)] +pub enum DeliveryError { + /// The message we were editing no longer exists, or belongs to a bot we + /// no longer are. Self-healing: drop the stored id and post a fresh one. + #[error("the message no longer exists")] + MessageGone, + + /// The destination itself is unusable. Needs a human — a new token or + /// URL, the bot re-invited, the channel restored. Retrying only burns + /// toward a 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 DeliveryError { + pub fn is_fatal(&self) -> bool { + matches!(self, Self::Fatal(_)) + } +} + +pub type Result = std::result::Result; diff --git a/crates/delivery/src/lib.rs b/crates/delivery/src/lib.rs new file mode 100644 index 0000000..19424bf --- /dev/null +++ b/crates/delivery/src/lib.rs @@ -0,0 +1,276 @@ +//! Keeps one live message per account in a chat platform, edited in place as +//! each code rolls. Discord, Slack and Telegram are supported. +//! +//! ## How "live" actually works +//! +//! None of these platforms has a streaming primitive, and the obvious ideas +//! fail: an externally-hosted image URL is fetched once and cached (and must +//! be reachable anonymously, which would publish the codes); an animated +//! countdown drifts out of phase because playback starts at decode time. What +//! works everywhere is **editing the message**: post once, remember the id, +//! rewrite it every period. Every client sees the change at once. +//! +//! Each platform needs a different credential to be allowed to edit: +//! +//! | Platform | Credential | Why | +//! |----------|----------------------------------|--------------------------------------------------| +//! | Discord | incoming-webhook URL | webhooks can edit their own messages | +//! | Slack | bot token `xoxb-` + channel ID | incoming webhooks can post but never edit | +//! | Telegram | bot token + chat id | there is no webhook concept; everything is a bot | +//! +//! The engine below is platform-blind: it resolves a [`Sink`] from settings +//! each pass and only ever calls `create`, `edit` and `delete`. + +pub mod discord; +pub mod error; +pub mod message; +pub mod pacing; +pub mod sink; +pub mod slack; +pub mod telegram; + +pub use discord::{DiscordClient, Payload, Webhook}; +pub use error::{DeliveryError, Result}; +pub use sink::{Clients, Sink}; +pub use slack::{SlackClient, SlackIdentity}; +pub use telegram::{BotIdentity, ChatInfo, TelegramClient}; + +use message::Content; +use openmfa_core::{unix_now, Provider, 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 an edit within a second of the +/// code changing; remaining latency is the deliberate pacing in each client. +const TICK: Duration = Duration::from_secs(1); + +/// Shared HTTP client factory — rustls, timeout, identifying user agent. +pub(crate) fn http_client(timeout: Duration) -> reqwest::Client { + reqwest::Client::builder() + .timeout(timeout) + .user_agent(concat!( + "OpenMFA/", + env!("CARGO_PKG_VERSION"), + " (+https://gitea.milesward.dev/mward4/OpenMFA)" + )) + .build() + .expect("reqwest client builds with rustls") +} + +/// API base URLs. Overridable for tests, for corporate proxies, and for +/// Telegram's officially supported self-hosted Bot API servers. +#[derive(Debug, Clone)] +pub struct Endpoints { + pub slack_base: String, + pub telegram_base: String, +} + +impl Default for Endpoints { + fn default() -> Self { + Self { + slack_base: slack::DEFAULT_BASE.to_string(), + telegram_base: telegram::DEFAULT_BASE.to_string(), + } + } +} + +impl Endpoints { + /// Defaults, with `OPENMFA_SLACK_API_BASE` / `OPENMFA_TELEGRAM_API_BASE` + /// honoured when set. + pub fn from_env() -> Self { + let mut e = Self::default(); + if let Ok(v) = std::env::var("OPENMFA_SLACK_API_BASE") { + if !v.trim().is_empty() { + e.slack_base = v; + } + } + if let Ok(v) = std::env::var("OPENMFA_TELEGRAM_API_BASE") { + if !v.trim().is_empty() { + e.telegram_base = v; + } + } + e + } +} + +/// Owns the push loop. +#[derive(Clone)] +pub struct PushEngine { + vault: Vault, + settings: Arc, + clients: Arc, + /// Guarantees only one [`PushEngine::sync_once`] pass runs at a time. + /// Two overlapping passes could both see a new account with no message + /// id and both post — two tiles, only one of which ever updates 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, endpoints: &Endpoints) -> Self { + Self { + vault, + settings, + clients: Arc::new(Clients { + discord: DiscordClient::new(), + slack: SlackClient::new(&endpoints.slack_base), + telegram: TelegramClient::new(&endpoints.telegram_base), + }), + 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: "openmfa::push", "delivery 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 { + let _guard = self.sync_lock.lock().await; + let settings = self.settings.snapshot(); + let Some(sink) = Sink::from_settings(&self.clients, &settings) else { + return 0; + }; + let provider = sink.provider(); + let mode = settings.effective_mode(); + let mut written = 0; + + for (id, message_id) in self.vault.orphaned_messages() { + match sink.delete(&message_id).await { + Ok(()) => { + self.vault.clear_message_id(&id); + tracing::info!(target: "openmfa::push", account = %id, %provider, "removed paused account's tile"); + } + Err(e) => { + tracing::warn!(target: "openmfa::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); + // For most of every 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); + + let image = if mode.has_image() { + match openmfa_render::tile_webp(&openmfa_render::Tile { + issuer: &target.issuer, + label: &target.label, + code: &code, + period: target.totp.period, + }) { + Ok(bytes) => Some(bytes), + Err(e) => { + tracing::error!(target: "openmfa::push", account = %target.id, "could not render tile: {e}"); + None + } + } + } else { + None + }; + + let content = Content { + issuer: target.issuer.clone(), + label: target.label.clone(), + code: code.clone(), + expires_at, + period: target.totp.period, + mode, + image, + }; + + let result = match &target.message_id { + Some(id) => sink.edit(id, &content).await.map(|()| id.clone()), + None => sink.create(&content).await, + }; + + match result { + Ok(message_id) => { + self.vault.mark_pushed(&target.id, &message_id, &code); + written += 1; + } + Err(DeliveryError::MessageGone) => { + tracing::info!(target: "openmfa::push", account = %target.id, %provider, "tile is gone; will repost"); + self.vault.clear_message_id(&target.id); + } + Err(e) => { + if e.is_fatal() { + tracing::error!(target: "openmfa::push", account = %target.id, issuer = %target.issuer, %provider, "push failed permanently: {e}"); + } else { + tracing::warn!(target: "openmfa::push", account = %target.id, issuer = %target.issuer, %provider, "push failed, will retry: {e}"); + } + self.vault.mark_push_error(&target.id, &e.to_string()); + } + } + } + written + } + + /// Post a one-off message to prove the active destination works. + pub async fn send_test(&self) -> std::result::Result<(), String> { + let settings = self.settings.snapshot(); + let Some(p) = settings.provider else { + return Err("no chat platform is selected".into()); + }; + if !settings.is_configured(p) { + return Err(format!("{p} isn't fully configured yet")); + } + // Test regardless of the master switch — the point is to check the + // credentials before turning delivery on. + let mut probe = settings.clone(); + probe.push_enabled = true; + let sink = Sink::from_settings(&self.clients, &probe) + .ok_or_else(|| "destination is not configured".to_string())?; + sink.send_test().await.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(); + let mut probe = settings.clone(); + probe.push_enabled = true; + if let Some(sink) = Sink::from_settings(&self.clients, &probe) { + if let Err(e) = sink.delete(message_id).await { + tracing::warn!(target: "openmfa::push", "could not remove tile: {e}"); + } + } + } + + /// Validate a Slack token and report who it belongs to. + pub async fn slack_whoami(&self, token: &str) -> Result { + self.clients.slack.whoami(token).await + } + + /// Validate a Telegram token and report the bot's handle. + pub async fn telegram_whoami(&self, token: &str) -> Result { + self.clients.telegram.whoami(token).await + } + + /// Chats that have messaged the bot — backs the "Detect chat" button. + pub async fn telegram_chats(&self, token: &str) -> Result> { + self.clients.telegram.recent_chats(token).await + } + + /// Which platform is active, for the UI. + pub fn active_provider(&self) -> Option { + self.settings.snapshot().provider + } +} diff --git a/crates/delivery/src/message.rs b/crates/delivery/src/message.rs new file mode 100644 index 0000000..f39c876 --- /dev/null +++ b/crates/delivery/src/message.rs @@ -0,0 +1,381 @@ +//! Builds the message body for one account's live tile, per platform. +//! +//! Every platform gets the same two load-bearing details: +//! +//! 1. **The code is in a code span, ungrouped.** Discord and Slack render a +//! copy button on fenced code; Telegram makes `` tap-to-copy on +//! mobile. What lands on the clipboard is exactly the contents, so it's +//! `482913`, never `482 913`. The grouped, readable form lives on the +//! rendered tile, where Discord can carry one. +//! +//! 2. **A rollover time.** Discord's `` ticks live in the client for +//! free. Slack's `` renders once, in the viewer's local time, and +//! doesn't tick; Telegram has neither, so it gets a plain UTC clock. In all +//! three the digits are refreshed by an edit at the boundary regardless — +//! the countdown is a courtesy, not the mechanism. + +use crate::discord::Payload; +use openmfa_core::PushMode; +use std::fmt::Write as _; +use time::OffsetDateTime; + +/// What every builder receives. Provider-agnostic on purpose so the engine +/// never has to know which platform it's talking to. +#[derive(Debug, Clone)] +pub struct Content { + pub issuer: String, + pub label: String, + /// Digits only. + pub code: String, + /// Unix second at which this code stops being valid. + pub expires_at: u64, + pub period: u64, + pub mode: PushMode, + /// Rendered WebP tile, when the mode and platform call for one. + pub image: Option>, +} + +// ── Discord ─────────────────────────────────────────────────────────────── + +pub fn discord(c: &Content) -> Payload { + let mut description = String::new(); + if c.mode.has_text() { + description.push_str("```\n"); + description.push_str(&c.code); + description.push_str("\n```\n"); + } + if !c.label.trim().is_empty() { + description.push_str(&escape_discord(&c.label)); + description.push_str(" · "); + } + let _ = write!(description, "rolls ", c.expires_at); + + let mut embed = serde_json::json!({ + "title": clip(&c.issuer, 240), + "description": description, + "color": color_for(&c.issuer), + }); + + // `attachments` must be the complete post-edit set: an empty array clears + // a tile left over from a previous mode, whereas omitting the field would + // append and the message would accumulate images forever. + let (attachments, file) = match &c.image { + Some(bytes) if c.mode.has_image() => { + let name = format!("code-{}.webp", c.expires_at); + embed["image"] = serde_json::json!({ "url": format!("attachment://{name}") }); + ( + serde_json::json!([{ "id": "0", "filename": name }]), + Some((name, bytes.clone())), + ) + } + _ => (serde_json::json!([]), None), + }; + + Payload { + json: serde_json::json!({ "embeds": [embed], "attachments": attachments }), + file, + } +} + +pub fn test_discord() -> Payload { + Payload { + json: serde_json::json!({ + "embeds": [{ + "title": "OpenMFA", + "description": "Connected. Live codes will appear here, one message per account, \ + updated in place as each code rolls.", + "color": 0x4f_8a_f5, + }], + "attachments": [], + }), + file: None, + } +} + +// ── Slack ───────────────────────────────────────────────────────────────── + +/// Returns `(fallback text, blocks)`. The fallback is what notifications and +/// old clients show; the blocks are what renders in-channel. +pub fn slack(c: &Content) -> (String, serde_json::Value) { + let issuer = escape_mrkdwn(&c.issuer); + let label = escape_mrkdwn(&c.label); + let head = if label.is_empty() { + format!("*{issuer}*") + } else { + format!("*{issuer}* · {label}") + }; + // `` renders in the viewer's local time zone, with the literal + // after `|` as the fallback for clients that can't. + let when = format!( + "rolls ", + c.expires_at, + clock(c.expires_at) + ); + let blocks = serde_json::json!([ + { + "type": "section", + "text": { "type": "mrkdwn", "text": format!("{head}\n```{}```", c.code) } + }, + { + "type": "context", + "elements": [{ "type": "mrkdwn", "text": format!("{when} · OpenMFA") }] + } + ]); + let fallback = format!("{}: {}", c.issuer, c.code); + (fallback, blocks) +} + +pub fn test_slack() -> (String, serde_json::Value) { + let text = "OpenMFA connected. Live codes will appear here, one message per account, \ + updated in place as each code rolls."; + ( + text.to_string(), + serde_json::json!([{ "type": "section", "text": { "type": "mrkdwn", "text": format!("*OpenMFA*\n{text}") } }]), + ) +} + +// ── Telegram ────────────────────────────────────────────────────────────── + +/// HTML for `parse_mode: HTML`. `` is tap-to-copy in the mobile apps. +pub fn telegram(c: &Content) -> String { + let issuer = escape_html(&c.issuer); + let label = escape_html(&c.label); + let mut s = format!("{issuer}"); + if !label.is_empty() { + let _ = write!(s, " · {label}"); + } + let _ = write!( + s, + "\n{}\nrolls at {} UTC", + c.code, + clock(c.expires_at) + ); + s +} + +pub fn test_telegram() -> String { + "OpenMFA\nConnected. Live codes will appear here, one message per account, \ + updated in place as each code rolls." + .to_string() +} + +// ── Helpers ─────────────────────────────────────────────────────────────── + +/// `HH:MM:SS` in UTC for a unix second. +fn clock(unix: u64) -> String { + OffsetDateTime::from_unix_timestamp(i64::try_from(unix).unwrap_or_default()).map_or_else( + |_| "??:??:??".into(), + |t| format!("{:02}:{:02}:{:02}", t.hour(), t.minute(), t.second()), + ) +} + +/// Stable per-issuer colour, matching the tile's accent and the web UI's +/// avatar so one account reads the same everywhere. +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 text so `**ops**` renders literally. +fn escape_discord(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 +} + +/// Slack mrkdwn has no escape character for its formatting marks, so the +/// safe move is to drop them from user text; `&`, `<`, `>` become entities as +/// Slack requires. +fn escape_mrkdwn(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for ch in s.trim().chars() { + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '*' | '_' | '~' | '`' => {} + c => out.push(c), + } + } + out +} + +fn escape_html(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for ch in s.trim().chars() { + match ch { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + c => out.push(c), + } + } + out +} + +fn clip(s: &str, max: usize) -> String { + s.chars().take(max).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn content(issuer: &str, label: &str, mode: PushMode, image: Option>) -> Content { + Content { + issuer: issuer.into(), + label: label.into(), + code: "482913".into(), + expires_at: 1_756_402_860, + period: 30, + mode, + image, + } + } + + // ── discord ── + + #[test] + fn discord_embed_mode_has_copyable_digits_and_no_file() { + let p = discord(&content( + "Cloudflare", + "me@example.com", + PushMode::Embed, + None, + )); + let d = p.json["embeds"][0]["description"].as_str().unwrap(); + assert!(d.contains("```\n482913\n```"), "{d}"); + assert!( + d.contains(""), + "live countdown missing: {d}" + ); + assert!(p.file.is_none()); + assert_eq!(p.json["attachments"], serde_json::json!([])); + } + + #[test] + fn discord_image_mode_wires_the_attachment() { + let p = discord(&content("Ubisoft", "u", PushMode::Image, Some(vec![0; 16]))); + let (name, _) = p.file.as_ref().unwrap(); + assert_eq!( + p.json["embeds"][0]["image"]["url"].as_str().unwrap(), + format!("attachment://{name}") + ); + assert_eq!(p.json["attachments"][0]["id"], "0"); + assert!(!p.json["embeds"][0]["description"] + .as_str() + .unwrap() + .contains("482913")); + } + + #[test] + fn discord_markdown_in_names_is_neutralised() { + let p = discord(&content("Corp", "**ops**", PushMode::Embed, None)); + assert!(p.json["embeds"][0]["description"] + .as_str() + .unwrap() + .contains("\\*\\*ops\\*\\*")); + } + + // ── slack ── + + #[test] + fn slack_puts_the_code_in_a_fenced_block_with_a_local_time() { + let (fallback, blocks) = slack(&content( + "Cloudflare", + "me@example.com", + PushMode::Embed, + None, + )); + let section = blocks[0]["text"]["text"].as_str().unwrap(); + assert!(section.contains("*Cloudflare*"), "{section}"); + assert!(section.contains("```482913```"), "{section}"); + let ctx = blocks[1]["elements"][0]["text"].as_str().unwrap(); + // Slack date token renders in the viewer's zone; fallback carries UTC. + assert!(ctx.contains(""), "{ctx}"); + assert!(fallback.contains("482913")); + } + + #[test] + fn slack_strips_formatting_marks_and_escapes_entities() { + let (_, blocks) = slack(&content("A*B_C", "x&z", PushMode::Embed, None)); + let t = blocks[0]["text"]["text"].as_str().unwrap(); + assert!(t.contains("*ABC*"), "{t}"); + assert!(t.contains("x<y>&z"), "{t}"); + } + + #[test] + fn slack_omits_the_separator_for_an_empty_label() { + let (_, blocks) = slack(&content("Plex", " ", PushMode::Embed, None)); + assert!(!blocks[0]["text"]["text"].as_str().unwrap().contains("·")); + } + + // ── telegram ── + + #[test] + fn telegram_uses_code_tags_and_escapes_html() { + let html = telegram(&content("Cloud", "a&b", PushMode::Embed, None)); + assert!(html.contains("Cloud<flare>"), "{html}"); + assert!(html.contains(" · a&b"), "{html}"); + assert!(html.contains("482913"), "{html}"); + assert!(html.contains("rolls at "), "{html}"); + assert!(html.contains(" UTC"), "{html}"); + } + + #[test] + fn telegram_never_leaks_grouped_digits() { + // Tap-to-copy must yield exactly the digits. + assert!(!telegram(&content("X", "y", PushMode::Embed, None)).contains("482 913")); + } + + // ── shared ── + + #[test] + fn clock_formats_utc() { + assert_eq!(clock(0), "00:00:00"); + assert_eq!(clock(1_756_402_860), "17:41:00"); + } + + #[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 test_messages_build() { + assert!(test_discord().file.is_none()); + assert!(test_slack().0.contains("OpenMFA")); + assert!(test_telegram().contains("OpenMFA")); + } +} diff --git a/crates/delivery/src/pacing.rs b/crates/delivery/src/pacing.rs new file mode 100644 index 0000000..b07827e --- /dev/null +++ b/crates/delivery/src/pacing.rs @@ -0,0 +1,60 @@ +//! Serialises and spaces requests to one destination. +//! +//! 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. +//! The governor turns that burst into a trickle. It only smooths *bursts*; +//! sustained per-minute limits are a function of how many accounts you have, +//! and each platform's cap is documented alongside its client. + +use std::time::Duration; +use tokio::sync::{Mutex, MutexGuard}; +use tokio::time::Instant; + +#[derive(Debug)] +pub struct Governor { + gate: Mutex, + min_interval: Duration, +} + +impl Governor { + pub fn new(min_interval: Duration) -> Self { + Self { + gate: Mutex::new(Instant::now()), + min_interval, + } + } + + pub fn min_interval(&self) -> Duration { + self.min_interval + } + + /// Wait until it's this caller's turn, then hold the lock for the + /// duration of the request so nothing else overtakes. The caller sets + /// `*guard = Instant::now() + gap` before releasing to schedule the next. + pub async fn acquire(&self) -> MutexGuard<'_, Instant> { + let guard = self.gate.lock().await; + let now = Instant::now(); + if *guard > now { + tokio::time::sleep(*guard - now).await; + } + guard + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn spaces_successive_calls() { + let g = Governor::new(Duration::from_millis(300)); + let start = Instant::now(); + { + let mut guard = g.acquire().await; + *guard = Instant::now() + g.min_interval(); + } + let _second = g.acquire().await; + assert!(start.elapsed() >= Duration::from_millis(300)); + } +} diff --git a/crates/delivery/src/sink.rs b/crates/delivery/src/sink.rs new file mode 100644 index 0000000..8769030 --- /dev/null +++ b/crates/delivery/src/sink.rs @@ -0,0 +1,156 @@ +//! One destination, whatever the platform. +//! +//! An enum rather than a trait object: there are exactly three variants, the +//! set is closed, and `async fn` in traits is not object-safe. The engine +//! calls `create` / `edit` / `delete` and never learns which arm ran. + +use crate::discord::{DiscordClient, Webhook}; +use crate::error::Result; +use crate::message::{self, Content}; +use crate::slack::SlackClient; +use crate::telegram::TelegramClient; +use openmfa_core::{Provider, Settings}; + +pub enum Sink<'a> { + Discord { + client: &'a DiscordClient, + hook: Webhook, + }, + Slack { + client: &'a SlackClient, + token: String, + channel: String, + }, + Telegram { + client: &'a TelegramClient, + token: String, + chat_id: String, + }, +} + +/// The per-platform clients the engine keeps alive across passes, so each +/// one's rate-limit governor persists. +pub struct Clients { + pub discord: DiscordClient, + pub slack: SlackClient, + pub telegram: TelegramClient, +} + +impl Sink<'_> { + /// Resolve the active destination from settings, or `None` when delivery + /// is off or the chosen platform is incomplete. + pub fn from_settings<'a>(clients: &'a Clients, s: &Settings) -> Option> { + if !s.is_usable() { + return None; + } + Some(match s.provider? { + Provider::Discord => Sink::Discord { + client: &clients.discord, + hook: Webhook::new(&s.discord.webhook_url), + }, + Provider::Slack => Sink::Slack { + client: &clients.slack, + token: s.slack.bot_token.clone(), + channel: s.slack.channel.clone(), + }, + Provider::Telegram => Sink::Telegram { + client: &clients.telegram, + token: s.telegram.bot_token.clone(), + chat_id: s.telegram.chat_id.clone(), + }, + }) + } + + pub fn provider(&self) -> Provider { + match self { + Sink::Discord { .. } => Provider::Discord, + Sink::Slack { .. } => Provider::Slack, + Sink::Telegram { .. } => Provider::Telegram, + } + } + + /// Post a new tile; returns the platform's message id as a string. + pub async fn create(&self, c: &Content) -> Result { + match self { + Sink::Discord { client, hook } => client.create(hook, &message::discord(c)).await, + Sink::Slack { + client, + token, + channel, + } => { + let (text, blocks) = message::slack(c); + client.post(token, channel, &text, &blocks).await + } + Sink::Telegram { + client, + token, + chat_id, + } => client.send(token, chat_id, &message::telegram(c)).await, + } + } + + /// Rewrite an existing tile in place. + pub async fn edit(&self, id: &str, c: &Content) -> Result<()> { + match self { + Sink::Discord { client, hook } => client.edit(hook, id, &message::discord(c)).await, + Sink::Slack { + client, + token, + channel, + } => { + let (text, blocks) = message::slack(c); + client.update(token, channel, id, &text, &blocks).await + } + Sink::Telegram { + client, + token, + chat_id, + } => client.edit(token, chat_id, id, &message::telegram(c)).await, + } + } + + pub async fn delete(&self, id: &str) -> Result<()> { + match self { + Sink::Discord { client, hook } => client.delete(hook, id).await, + Sink::Slack { + client, + token, + channel, + } => client.delete(token, channel, id).await, + Sink::Telegram { + client, + token, + chat_id, + } => client.delete(token, chat_id, id).await, + } + } + + /// Post a one-off "connected" message to prove the destination works. + pub async fn send_test(&self) -> Result<()> { + match self { + Sink::Discord { client, hook } => client + .create(hook, &message::test_discord()) + .await + .map(|_| ()), + Sink::Slack { + client, + token, + channel, + } => { + let (text, blocks) = message::test_slack(); + client + .post(token, channel, &text, &blocks) + .await + .map(|_| ()) + } + Sink::Telegram { + client, + token, + chat_id, + } => client + .send(token, chat_id, &message::test_telegram()) + .await + .map(|_| ()), + } + } +} diff --git a/crates/delivery/src/slack.rs b/crates/delivery/src/slack.rs new file mode 100644 index 0000000..de80a32 --- /dev/null +++ b/crates/delivery/src/slack.rs @@ -0,0 +1,283 @@ +//! Slack transport via the Web API with a bot token. +//! +//! ## Why a bot token and not an incoming webhook +//! +//! Slack incoming webhooks can post but **cannot edit or delete**, and editing +//! in place is the entire live-tile mechanism. So OpenMFA needs a Slack app +//! with a Bot User OAuth Token (`xoxb-…`) carrying the `chat:write` scope, +//! invited to the target channel. `chat.postMessage` returns the message `ts`, +//! and `chat.update` rewrites it. +//! +//! ## Slack's error convention +//! +//! Almost every failure comes back as **HTTP 200** with `{"ok": false, +//! "error": "…"}`. Only rate limiting (429) and outages use HTTP status. So +//! the `ok` field, not the status code, is what gets classified. +//! +//! ## Limits +//! +//! `chat.update` is Tier 3 — about 50 calls per minute per workspace. Each +//! account costs two edits a minute, so the practical ceiling is roughly +//! **20 accounts** on one workspace. The governor spaces calls a second apart +//! to smooth the rollover burst; it can't raise the sustained limit. + +use crate::error::{DeliveryError, Result}; +use crate::pacing::Governor; +use std::time::Duration; +use tokio::time::Instant; + +pub const DEFAULT_BASE: &str = "https://slack.com/api"; +const MIN_INTERVAL: Duration = Duration::from_secs(1); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(20); +const MAX_BACKOFF: Duration = Duration::from_secs(60); + +/// What `auth.test` tells us about a token — surfaced in the UI so the +/// operator can see "connected as @openmfa in Acme" before saving. +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlackIdentity { + pub team: String, + pub user: String, + pub bot_id: Option, +} + +pub struct SlackClient { + http: reqwest::Client, + base: String, + governor: Governor, +} + +#[allow(clippy::missing_fields_in_debug)] // http client + governor add nothing useful +impl std::fmt::Debug for SlackClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SlackClient") + .field("base", &self.base) + .finish() + } +} + +impl SlackClient { + /// `base` is normally [`DEFAULT_BASE`]; overridable for tests and proxies. + pub fn new(base: &str) -> Self { + Self { + http: crate::http_client(REQUEST_TIMEOUT), + base: base.trim_end_matches('/').to_string(), + governor: Governor::new(MIN_INTERVAL), + } + } + + /// Post a message; returns its `ts`, which is the id `chat.update` needs. + pub async fn post( + &self, + token: &str, + channel: &str, + text: &str, + blocks: &serde_json::Value, + ) -> Result { + let body = serde_json::json!({ + "channel": channel, + "text": text, + "blocks": blocks, + // Live tiles shouldn't render link previews for anything in them. + "unfurl_links": false, + "unfurl_media": false, + }); + let v = self.call(token, "chat.postMessage", &body).await?; + v.get("ts") + .and_then(|t| t.as_str()) + .map(str::to_string) + .ok_or_else(|| { + DeliveryError::Transient("Slack accepted the message but returned no ts".into()) + }) + } + + pub async fn update( + &self, + token: &str, + channel: &str, + ts: &str, + text: &str, + blocks: &serde_json::Value, + ) -> Result<()> { + let body = serde_json::json!({ + "channel": channel, + "ts": ts, + "text": text, + "blocks": blocks, + }); + self.call(token, "chat.update", &body).await.map(|_| ()) + } + + pub async fn delete(&self, token: &str, channel: &str, ts: &str) -> Result<()> { + let body = serde_json::json!({ "channel": channel, "ts": ts }); + match self.call(token, "chat.delete", &body).await { + Ok(_) | Err(DeliveryError::MessageGone) => Ok(()), + Err(e) => Err(e), + } + } + + /// Validate a token and learn who it is. + pub async fn whoami(&self, token: &str) -> Result { + let v = self + .call(token, "auth.test", &serde_json::json!({})) + .await?; + let s = |k: &str| { + v.get(k) + .and_then(|x| x.as_str()) + .unwrap_or_default() + .to_string() + }; + Ok(SlackIdentity { + team: s("team"), + user: s("user"), + bot_id: v.get("bot_id").and_then(|x| x.as_str()).map(str::to_string), + }) + } + + /// One Web API call, paced, with a single retry on 429 / 5xx. + async fn call( + &self, + token: &str, + method: &str, + body: &serde_json::Value, + ) -> Result { + let url = format!("{}/{method}", self.base); + let mut guard = self.governor.acquire().await; + + for attempt in 0..2 { + let resp = self + .http + .post(&url) + .bearer_auth(token) + .json(body) + .send() + .await + .map_err(|e| DeliveryError::Transient(format!("could not reach Slack: {e}")))?; + + let status = resp.status(); + + if status == reqwest::StatusCode::TOO_MANY_REQUESTS { + let wait = resp + .headers() + .get(reqwest::header::RETRY_AFTER) + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.parse::().ok()) + .map_or(Duration::from_secs(1), Duration::from_secs) + .min(MAX_BACKOFF); + *guard = Instant::now() + wait + MIN_INTERVAL; + if attempt == 0 { + tracing::warn!(target: "openmfa::slack", wait_s = wait.as_secs(), "rate limited by Slack; backing off"); + tokio::time::sleep(wait).await; + continue; + } + return Err(DeliveryError::Transient(format!( + "rate limited by Slack (retry in {}s)", + wait.as_secs() + ))); + } + + *guard = Instant::now() + MIN_INTERVAL; + + if status.is_server_error() { + if attempt == 0 { + tokio::time::sleep(Duration::from_secs(2)).await; + continue; + } + return Err(DeliveryError::Transient(format!("Slack returned {status}"))); + } + + let text = resp.text().await.unwrap_or_default(); + let v: serde_json::Value = serde_json::from_str(&text).map_err(|_| { + DeliveryError::Transient(format!("Slack returned a non-JSON response ({status})")) + })?; + + if v.get("ok").and_then(serde_json::Value::as_bool) == Some(true) { + return Ok(v); + } + let code = v + .get("error") + .and_then(|e| e.as_str()) + .unwrap_or("unknown_error"); + return Err(classify(code, method)); + } + Err(DeliveryError::Transient( + "Slack did not accept the request after a retry".into(), + )) + } +} + +/// Map Slack's string error codes onto the three-way outcome. +fn classify(code: &str, method: &str) -> DeliveryError { + match code { + // The tile is gone, or was posted by a different bot: repost. + "message_not_found" | "cant_update_message" | "cant_delete_message" | "edit_window_closed" => { + DeliveryError::MessageGone + } + "invalid_auth" | "not_authed" | "token_revoked" | "token_expired" | "account_inactive" => { + DeliveryError::Fatal( + "Slack rejected the bot token — copy the Bot User OAuth Token again from OAuth & Permissions" + .into(), + ) + } + "missing_scope" => DeliveryError::Fatal( + "the Slack app is missing the chat:write scope — add it under OAuth & Permissions and reinstall" + .into(), + ), + "channel_not_found" => DeliveryError::Fatal( + "Slack can't find that channel — check the channel ID (not the #name)".into(), + ), + "not_in_channel" => DeliveryError::Fatal( + "the bot isn't in that channel — run /invite @YourBot in the channel".into(), + ), + "is_archived" | "channel_is_archived" => { + DeliveryError::Fatal("that Slack channel is archived".into()) + } + "restricted_action" | "no_permission" | "ekm_access_denied" => DeliveryError::Fatal( + "the Slack workspace's policy blocks the bot from posting there".into(), + ), + "msg_too_long" | "invalid_blocks" | "invalid_blocks_format" => { + DeliveryError::Fatal(format!("Slack rejected the message body ({code})")) + } + "ratelimited" | "internal_error" | "service_unavailable" | "fatal_error" | "request_timeout" => { + DeliveryError::Transient(format!("Slack {method}: {code}")) + } + other => DeliveryError::Fatal(format!("Slack {method} failed: {other}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classification() { + assert!(matches!( + classify("message_not_found", "chat.update"), + DeliveryError::MessageGone + )); + assert!(matches!( + classify("cant_update_message", "chat.update"), + DeliveryError::MessageGone + )); + assert!(classify("invalid_auth", "x").is_fatal()); + assert!(classify("channel_not_found", "x").is_fatal()); + assert!(classify("not_in_channel", "x").is_fatal()); + assert!(matches!( + classify("ratelimited", "x"), + DeliveryError::Transient(_) + )); + } + + #[test] + fn fatal_messages_say_what_to_do() { + let e = classify("not_in_channel", "chat.postMessage").to_string(); + assert!(e.contains("/invite"), "{e}"); + let e = classify("missing_scope", "chat.postMessage").to_string(); + assert!(e.contains("chat:write"), "{e}"); + } + + #[test] + fn base_normalised() { + let c = SlackClient::new("https://slack.com/api/"); + assert_eq!(c.base, "https://slack.com/api"); + } +} diff --git a/crates/delivery/src/telegram.rs b/crates/delivery/src/telegram.rs new file mode 100644 index 0000000..029cdf0 --- /dev/null +++ b/crates/delivery/src/telegram.rs @@ -0,0 +1,403 @@ +//! Telegram Bot API transport. +//! +//! Telegram has no webhook concept — everything is a bot. A token from +//! @BotFather plus a chat id is the whole credential. `sendMessage` returns a +//! `message_id`; `editMessageText` rewrites it. Messages a bot sent itself can +//! be edited indefinitely (the 48-hour rule applies only to messages it did +//! not author). +//! +//! ## Finding the chat id +//! +//! Users rarely know their chat id. [`TelegramClient::recent_chats`] reads the +//! bot's pending updates and lists every chat that has messaged it, so the UI +//! can offer "send /start to your bot, then click Detect". +//! +//! ## Limits +//! +//! Roughly 30 messages/second overall, **one per second per private chat**, +//! and **20 per minute per group**. Each account costs two edits a minute, so +//! a group tops out around **10 accounts**; a private chat around 30. The +//! governor spaces calls a second apart; it can't raise the per-minute cap. +//! +//! One quirk worth knowing: editing a message to identical text is a `400 +//! message is not modified` error. That is not a failure — it means the tile +//! already says the right thing — so it is treated as success. + +use crate::error::{DeliveryError, Result}; +use crate::pacing::Governor; +use std::time::Duration; +use tokio::time::Instant; + +pub const DEFAULT_BASE: &str = "https://api.telegram.org"; +const MIN_INTERVAL: Duration = Duration::from_secs(1); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(20); +const MAX_BACKOFF: Duration = Duration::from_secs(60); + +/// A chat the bot has heard from — what "Detect" shows. +#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)] +pub struct ChatInfo { + pub id: String, + /// `private`, `group`, `supergroup` or `channel`. + pub kind: String, + /// Group/channel title, or the person's name for a private chat. + pub title: String, +} + +/// What `getMe` reports. +#[derive(Debug, Clone, serde::Serialize)] +pub struct BotIdentity { + pub username: String, + pub name: String, +} + +pub struct TelegramClient { + http: reqwest::Client, + base: String, + governor: Governor, +} + +#[allow(clippy::missing_fields_in_debug)] // http client + governor add nothing useful +impl std::fmt::Debug for TelegramClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TelegramClient") + .field("base", &self.base) + .finish() + } +} + +impl TelegramClient { + /// `base` is normally [`DEFAULT_BASE`]. Telegram officially supports + /// self-hosted Bot API servers, and tests point this at a mock. + pub fn new(base: &str) -> Self { + Self { + http: crate::http_client(REQUEST_TIMEOUT), + base: base.trim_end_matches('/').to_string(), + governor: Governor::new(MIN_INTERVAL), + } + } + + fn url(&self, token: &str, method: &str) -> String { + format!("{}/bot{token}/{method}", self.base) + } + + /// Send an HTML-formatted message; returns its `message_id`. + pub async fn send(&self, token: &str, chat_id: &str, html: &str) -> Result { + let body = serde_json::json!({ + "chat_id": chat_id, + "text": html, + "parse_mode": "HTML", + "disable_web_page_preview": true, + // A tile appearing is not worth a phone buzz; edits never notify. + "disable_notification": true, + }); + let v = self.call(token, "sendMessage", &body).await?; + v.pointer("/result/message_id") + .and_then(serde_json::Value::as_i64) + .map(|n| n.to_string()) + .ok_or_else(|| { + DeliveryError::Transient("Telegram accepted the message but returned no id".into()) + }) + } + + pub async fn edit( + &self, + token: &str, + chat_id: &str, + message_id: &str, + html: &str, + ) -> Result<()> { + let body = serde_json::json!({ + "chat_id": chat_id, + "message_id": message_id.parse::().unwrap_or_default(), + "text": html, + "parse_mode": "HTML", + "disable_web_page_preview": true, + }); + self.call(token, "editMessageText", &body).await.map(|_| ()) + } + + pub async fn delete(&self, token: &str, chat_id: &str, message_id: &str) -> Result<()> { + let body = serde_json::json!({ + "chat_id": chat_id, + "message_id": message_id.parse::().unwrap_or_default(), + }); + match self.call(token, "deleteMessage", &body).await { + Ok(_) | Err(DeliveryError::MessageGone) => Ok(()), + Err(e) => Err(e), + } + } + + /// Validate a token and learn the bot's handle. + pub async fn whoami(&self, token: &str) -> Result { + let v = self.call(token, "getMe", &serde_json::json!({})).await?; + let s = |p: &str| { + v.pointer(p) + .and_then(|x| x.as_str()) + .unwrap_or_default() + .to_string() + }; + Ok(BotIdentity { + username: s("/result/username"), + name: s("/result/first_name"), + }) + } + + /// Every distinct chat present in the bot's pending updates. + /// + /// This is how "Detect chat" works: the operator messages the bot (or + /// adds it to a group and says something), then we read the backlog. + /// Note `getUpdates` and a configured webhook are mutually exclusive on + /// Telegram's side; OpenMFA never sets a webhook, so this is safe. + pub async fn recent_chats(&self, token: &str) -> Result> { + let body = serde_json::json!({ "limit": 100, "timeout": 0 }); + let v = self.call(token, "getUpdates", &body).await?; + let mut out: Vec = Vec::new(); + for upd in v + .get("result") + .and_then(|r| r.as_array()) + .into_iter() + .flatten() + { + // A message can arrive under several keys depending on where it came from. + let chat = [ + "message", + "edited_message", + "channel_post", + "my_chat_member", + "chat_member", + ] + .iter() + .find_map(|k| upd.get(k).and_then(|m| m.get("chat"))); + let Some(chat) = chat else { continue }; + let id = match chat.get("id") { + Some(serde_json::Value::Number(n)) => n.to_string(), + _ => continue, + }; + if out.iter().any(|c| c.id == id) { + continue; + } + let kind = chat + .get("type") + .and_then(|t| t.as_str()) + .unwrap_or("unknown") + .to_string(); + let title = chat + .get("title") + .and_then(|t| t.as_str()) + .map(str::to_string) + .or_else(|| { + let first = chat + .get("first_name") + .and_then(|t| t.as_str()) + .unwrap_or(""); + let last = chat.get("last_name").and_then(|t| t.as_str()).unwrap_or(""); + let name = format!("{first} {last}").trim().to_string(); + (!name.is_empty()).then_some(name) + }) + .or_else(|| { + chat.get("username") + .and_then(|t| t.as_str()) + .map(|u| format!("@{u}")) + }) + .unwrap_or_else(|| id.clone()); + out.push(ChatInfo { id, kind, title }); + } + Ok(out) + } + + async fn call( + &self, + token: &str, + method: &str, + body: &serde_json::Value, + ) -> Result { + let url = self.url(token, method); + let mut guard = self.governor.acquire().await; + + for attempt in 0..2 { + let resp = + self.http.post(&url).json(body).send().await.map_err(|e| { + DeliveryError::Transient(format!("could not reach Telegram: {e}")) + })?; + + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + let v: serde_json::Value = + serde_json::from_str(&text).unwrap_or(serde_json::Value::Null); + + if status == reqwest::StatusCode::TOO_MANY_REQUESTS { + let wait = v + .pointer("/parameters/retry_after") + .and_then(serde_json::Value::as_u64) + .map_or(Duration::from_secs(1), Duration::from_secs) + .min(MAX_BACKOFF); + *guard = Instant::now() + wait + MIN_INTERVAL; + if attempt == 0 { + tracing::warn!(target: "openmfa::telegram", wait_s = wait.as_secs(), "rate limited by Telegram; backing off"); + tokio::time::sleep(wait).await; + continue; + } + return Err(DeliveryError::Transient(format!( + "rate limited by Telegram (retry in {}s)", + wait.as_secs() + ))); + } + + *guard = Instant::now() + MIN_INTERVAL; + + if status.is_server_error() { + if attempt == 0 { + tokio::time::sleep(Duration::from_secs(2)).await; + continue; + } + return Err(DeliveryError::Transient(format!( + "Telegram returned {status}" + ))); + } + + if v.get("ok").and_then(serde_json::Value::as_bool) == Some(true) { + return Ok(v); + } + let description = v + .get("description") + .and_then(|d| d.as_str()) + .unwrap_or("") + .to_string(); + return classify(status, &description, method); + } + Err(DeliveryError::Transient( + "Telegram did not accept the request after a retry".into(), + )) + } +} + +/// Telegram signals everything through a free-text `description`, so this +/// matches on the stable phrases the Bot API has used for years. +fn classify( + status: reqwest::StatusCode, + description: &str, + method: &str, +) -> Result { + let d = description.to_ascii_lowercase(); + + // Not an error: the tile already shows exactly this text. + if d.contains("message is not modified") { + return Ok(serde_json::Value::Null); + } + if d.contains("message to edit not found") + || d.contains("message to delete not found") + || d.contains("message can't be edited") + || d.contains("message identifier is not specified") + { + return Err(DeliveryError::MessageGone); + } + if status == reqwest::StatusCode::UNAUTHORIZED || d.contains("unauthorized") { + return Err(DeliveryError::Fatal( + "Telegram rejected the bot token — copy it again from @BotFather".into(), + )); + } + if status == reqwest::StatusCode::NOT_FOUND && d.contains("not found") && method != "getUpdates" + { + // A malformed token makes the whole /bot/ path 404. + return Err(DeliveryError::Fatal( + "Telegram doesn't recognise that bot token — check it against @BotFather".into(), + )); + } + if d.contains("chat not found") { + return Err(DeliveryError::Fatal( + "Telegram can't find that chat — use Detect, or make sure the bot has been messaged or added" + .into(), + )); + } + if d.contains("bot was blocked") || d.contains("user is deactivated") { + return Err(DeliveryError::Fatal("the user has blocked the bot".into())); + } + if d.contains("bot is not a member") + || d.contains("bot was kicked") + || d.contains("not enough rights") + { + return Err(DeliveryError::Fatal( + "the bot isn't allowed to post in that chat — add it (as admin, for channels)".into(), + )); + } + if d.contains("can't parse entities") { + return Err(DeliveryError::Fatal(format!( + "Telegram rejected the message formatting: {description}" + ))); + } + if status.is_client_error() { + return Err(DeliveryError::Fatal(format!( + "Telegram {method} failed: {description}" + ))); + } + Err(DeliveryError::Transient(format!( + "Telegram {method}: {status} {description}" + ))) +} + +#[cfg(test)] +mod tests { + use super::*; + use reqwest::StatusCode as S; + + #[test] + fn urls_embed_the_token() { + let c = TelegramClient::new("https://api.telegram.org/"); + assert_eq!( + c.url("123:abc", "sendMessage"), + "https://api.telegram.org/bot123:abc/sendMessage" + ); + } + + #[test] + fn not_modified_is_success() { + assert!(classify( + S::BAD_REQUEST, + "Bad Request: message is not modified: ...", + "editMessageText" + ) + .is_ok()); + } + + #[test] + fn classification() { + assert!(matches!( + classify( + S::BAD_REQUEST, + "Bad Request: message to edit not found", + "editMessageText" + ), + Err(DeliveryError::MessageGone) + )); + assert!(classify(S::UNAUTHORIZED, "Unauthorized", "sendMessage") + .unwrap_err() + .is_fatal()); + assert!(classify(S::NOT_FOUND, "Not Found", "sendMessage") + .unwrap_err() + .is_fatal()); + assert!( + classify(S::BAD_REQUEST, "Bad Request: chat not found", "sendMessage") + .unwrap_err() + .is_fatal() + ); + assert!(classify( + S::FORBIDDEN, + "Forbidden: bot was blocked by the user", + "sendMessage" + ) + .unwrap_err() + .is_fatal()); + assert!(matches!( + classify(S::BAD_GATEWAY, "", "sendMessage"), + Err(DeliveryError::Transient(_)) + )); + } + + #[test] + fn fatal_messages_point_at_the_fix() { + let e = classify(S::BAD_REQUEST, "Bad Request: chat not found", "sendMessage") + .unwrap_err() + .to_string(); + assert!(e.contains("Detect"), "{e}"); + } +} diff --git a/crates/delivery/tests/live_push.rs b/crates/delivery/tests/live_push.rs new file mode 100644 index 0000000..7952d7d --- /dev/null +++ b/crates/delivery/tests/live_push.rs @@ -0,0 +1,805 @@ +//! End-to-end coverage of the live-tile mechanism against mock Discord, Slack +//! and Telegram servers. +//! +//! The mocks speak each platform's real protocol shape — Slack's `ok:false` +//! errors on HTTP 200, Telegram's `/bot/` paths and +//! `description` strings, Discord's `?wait=true` and JSON error codes — so +//! these assert on the things that actually break in production, not on a +//! stubbed trait. + +use axum::{ + extract::{Path, Query}, + http::HeaderMap, + routing::post, + Json, Router, +}; +use openmfa_core::{parse_otpauth, SettingsStore, Vault, VaultKey}; +use openmfa_delivery::{Endpoints, PushEngine}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +// ── recording ───────────────────────────────────────────────────────────── + +#[derive(Debug, Clone)] +struct Recorded { + method: String, + path: String, + query: HashMap, + bearer: Option, + 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") + } + fn multipart_payload(&self) -> serde_json::Value { + let text = String::from_utf8_lossy(&self.body); + let Some(start) = text.find('{') else { + return serde_json::Value::Null; + }; + let mut depth = 0usize; + for (i, ch) in text.as_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, Default)] +struct Log(Arc>>); + +impl Log { + fn push( + &self, + method: &str, + path: String, + q: HashMap, + h: &HeaderMap, + body: &[u8], + ) { + let ct = h + .get(axum::http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + let bearer = h + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .map(str::to_string); + self.0.lock().unwrap().push(Recorded { + method: method.into(), + path, + query: q, + bearer, + content_type: ct, + body: body.to_vec(), + }); + } + fn all(&self) -> Vec { + self.0.lock().unwrap().clone() + } + fn len(&self) -> usize { + self.0.lock().unwrap().len() + } + fn count(&self, pred: impl Fn(&Recorded) -> bool) -> usize { + self.0.lock().unwrap().iter().filter(|r| pred(r)).count() + } +} + +async fn serve(app: Router) -> String { + 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}") +} + +// ── mock Discord ────────────────────────────────────────────────────────── + +#[derive(Clone)] +struct DiscordMock { + log: Log, + /// When set, PATCH answers 404 with this Discord error code. + patch_error: Arc>>, + next_id: Arc>, +} + +async fn discord_mock() -> (String, DiscordMock) { + let m = DiscordMock { + log: Log::default(), + patch_error: Arc::new(Mutex::new(None)), + next_id: Arc::new(Mutex::new(1)), + }; + let create = { + let m = m.clone(); + move |Path((id, tok)): Path<(String, String)>, + Query(q): Query>, + h: HeaderMap, + b: axum::body::Bytes| { + let m = m.clone(); + async move { + m.log.push("POST", format!("/{id}/{tok}"), q, &h, &b); + let mut n = m.next_id.lock().unwrap(); + let msg = format!("90000000000000000{n}"); + *n += 1; + Json(serde_json::json!({ "id": msg })) + } + } + }; + let edit = { + let m = m.clone(); + move |Path((id, tok, mid)): Path<(String, String, String)>, + Query(q): Query>, + h: HeaderMap, + b: axum::body::Bytes| { + let m = m.clone(); + async move { + m.log + .push("PATCH", format!("/{id}/{tok}/messages/{mid}"), q, &h, &b); + if let Some(code) = *m.patch_error.lock().unwrap() { + return ( + axum::http::StatusCode::NOT_FOUND, + Json(serde_json::json!({ "message": "err", "code": code })), + ); + } + ( + axum::http::StatusCode::OK, + Json(serde_json::json!({ "id": mid })), + ) + } + } + }; + let delete = { + let m = m.clone(); + move |Path((id, tok, mid)): Path<(String, String, String)>, + h: HeaderMap, + b: axum::body::Bytes| { + let m = m.clone(); + async move { + m.log.push( + "DELETE", + format!("/{id}/{tok}/messages/{mid}"), + HashMap::new(), + &h, + &b, + ); + axum::http::StatusCode::NO_CONTENT + } + } + }; + let app = Router::new() + .route("/api/webhooks/{id}/{token}", post(create)) + .route( + "/api/webhooks/{id}/{token}/messages/{mid}", + axum::routing::patch(edit).delete(delete), + ); + let base = serve(app).await; + (format!("{base}/api/webhooks/123/tok"), m) +} + +// ── mock Slack ──────────────────────────────────────────────────────────── + +#[derive(Clone)] +struct SlackMock { + log: Log, + /// When set, chat.update answers `{ok:false, error:}` (HTTP 200, + /// exactly as Slack does). + update_error: Arc>>, +} + +async fn slack_mock() -> (String, SlackMock) { + let m = SlackMock { + log: Log::default(), + update_error: Arc::new(Mutex::new(None)), + }; + let handler = { + let m = m.clone(); + move |Path(method): Path, h: HeaderMap, b: axum::body::Bytes| { + let m = m.clone(); + async move { + m.log + .push("POST", format!("/{method}"), HashMap::new(), &h, &b); + // Slack rejects missing auth as ok:false, still HTTP 200. + let authed = h + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.starts_with("Bearer xoxb-")); + if !authed { + return Json(serde_json::json!({ "ok": false, "error": "not_authed" })); + } + match method.as_str() { + "chat.postMessage" => Json( + serde_json::json!({ "ok": true, "ts": "1700000000.000100", "channel": "C0TEST" }), + ), + "chat.update" => { + if let Some(e) = m.update_error.lock().unwrap().clone() { + return Json(serde_json::json!({ "ok": false, "error": e })); + } + Json(serde_json::json!({ "ok": true, "ts": "1700000000.000100" })) + } + "chat.delete" => Json(serde_json::json!({ "ok": true })), + "auth.test" => Json( + serde_json::json!({ "ok": true, "team": "Acme", "user": "openmfa", "bot_id": "B0BOT" }), + ), + _ => Json(serde_json::json!({ "ok": false, "error": "unknown_method" })), + } + } + } + }; + let base = serve(Router::new().route("/{method}", post(handler))).await; + (base, m) +} + +// ── mock Telegram ───────────────────────────────────────────────────────── + +#[derive(Clone)] +struct TelegramMock { + log: Log, + /// When set, editMessageText answers HTTP 400 with this description. + edit_error: Arc>>, + next_id: Arc>, +} + +async fn telegram_mock() -> (String, TelegramMock) { + let m = TelegramMock { + log: Log::default(), + edit_error: Arc::new(Mutex::new(None)), + next_id: Arc::new(Mutex::new(500)), + }; + let handler = { + let m = m.clone(); + move |Path((token, method)): Path<(String, String)>, h: HeaderMap, b: axum::body::Bytes| { + let m = m.clone(); + async move { + m.log + .push("POST", format!("/{token}/{method}"), HashMap::new(), &h, &b); + if token != "bot123:TESTTOKEN" { + return ( + axum::http::StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ "ok": false, "description": "Unauthorized" })), + ); + } + let ok = |v: serde_json::Value| { + ( + axum::http::StatusCode::OK, + Json(serde_json::json!({ "ok": true, "result": v })), + ) + }; + match method.as_str() { + "sendMessage" => { + let mut n = m.next_id.lock().unwrap(); + *n += 1; + ok(serde_json::json!({ "message_id": *n, "chat": { "id": -100 } })) + } + "editMessageText" => { + if let Some(d) = m.edit_error.lock().unwrap().clone() { + return ( + axum::http::StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "ok": false, "description": d })), + ); + } + ok(serde_json::json!({ "message_id": 1 })) + } + "deleteMessage" => ok(serde_json::json!(true)), + "getMe" => ok( + serde_json::json!({ "username": "openmfa_bot", "first_name": "OpenMFA" }), + ), + "getUpdates" => ok(serde_json::json!([ + { "update_id": 1, "message": { "chat": { "id": 42, "type": "private", "first_name": "Miles", "last_name": "Ward" } } }, + { "update_id": 2, "message": { "chat": { "id": -1_001_234, "type": "supergroup", "title": "Homelab" } } }, + { "update_id": 3, "message": { "chat": { "id": 42, "type": "private", "first_name": "Miles" } } }, + { "update_id": 4, "channel_post": { "chat": { "id": -1_009_999, "type": "channel", "title": "Codes" } } } + ])), + _ => ( + axum::http::StatusCode::NOT_FOUND, + Json(serde_json::json!({ "ok": false, "description": "Not Found" })), + ), + } + } + } + }; + let base = serve(Router::new().route("/{token}/{method}", post(handler))).await; + (base, m) +} + +// ── fixture ─────────────────────────────────────────────────────────────── + +/// `settings.json` is written directly rather than through `replace()`, which +/// validates the URL is a real discord.com address and the tokens are +/// well-formed — right in production, wrong for pointing at a mock. +#[allow(clippy::needless_pass_by_value)] // test helper; by-value reads cleaner at call sites +fn fixture( + dir: &std::path::Path, + settings: serde_json::Value, + period: u64, +) -> (Vault, Arc) { + std::fs::write(dir.join("settings.json"), settings.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)) +} + +fn discord_settings(url: &str, mode: &str) -> serde_json::Value { + serde_json::json!({ "push_enabled": true, "provider": "discord", "discord": { "webhook_url": url }, "push_mode": mode }) +} +fn slack_settings() -> serde_json::Value { + serde_json::json!({ "push_enabled": true, "provider": "slack", "slack": { "bot_token": "xoxb-test-token", "channel": "C0TEST" }, "push_mode": "both" }) +} +fn telegram_settings() -> serde_json::Value { + serde_json::json!({ "push_enabled": true, "provider": "telegram", "telegram": { "bot_token": "123:TESTTOKEN", "chat_id": "-100" }, "push_mode": "both" }) +} + +fn engine(vault: Vault, settings: Arc, slack: &str, telegram: &str) -> PushEngine { + PushEngine::new( + vault, + settings, + &Endpoints { + slack_base: slack.into(), + telegram_base: telegram.into(), + }, + ) +} + +// ── Discord ─────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn discord_first_pass_creates_then_later_passes_edit_in_place() { + let dir = tempfile::tempdir().unwrap(); + let (url, mock) = discord_mock().await; + let (vault, settings) = fixture(dir.path(), discord_settings(&url, "embed"), 1); + let eng = engine( + vault.clone(), + settings, + "http://127.0.0.1:1", + "http://127.0.0.1:1", + ); + + assert_eq!(eng.sync_once().await, 1); + let first = mock.log.all(); + assert_eq!(first[0].method, "POST"); + // Without wait=true Discord replies 204 with no body and nothing could ever be edited. + assert_eq!(first[0].query.get("wait").map(String::as_str), Some("true")); + let message_id = vault.push_targets()[0].message_id.clone().unwrap(); + + eng.sync_once().await; + assert_eq!( + mock.log.len(), + 1, + "an unchanged code must not cost a request" + ); + + tokio::time::sleep(Duration::from_millis(1100)).await; + assert_eq!(eng.sync_once().await, 1); + let log = mock.log.all(); + assert_eq!(log[1].method, "PATCH"); + assert!(log[1].path.ends_with(&format!("/messages/{message_id}"))); +} + +#[tokio::test] +async fn discord_image_mode_uploads_a_webp_wired_to_the_embed() { + let dir = tempfile::tempdir().unwrap(); + let (url, mock) = discord_mock().await; + let (vault, settings) = fixture(dir.path(), discord_settings(&url, "both"), 30); + engine(vault, settings, "http://127.0.0.1:1", "http://127.0.0.1:1") + .sync_once() + .await; + + let r = &mock.log.all()[0]; + assert!(r.is_multipart()); + let p = r.multipart_payload(); + let name = p["attachments"][0]["filename"].as_str().unwrap(); + assert_eq!( + p["embeds"][0]["image"]["url"].as_str().unwrap(), + format!("attachment://{name}") + ); + let riff = r + .body + .windows(4) + .position(|w| w == b"RIFF") + .expect("no WebP in body"); + assert_eq!(&r.body[riff + 8..riff + 12], b"WEBP"); +} + +#[tokio::test] +async fn discord_deleted_message_is_reposted_and_dead_webhook_is_surfaced() { + let dir = tempfile::tempdir().unwrap(); + let (url, mock) = discord_mock().await; + let (vault, settings) = fixture(dir.path(), discord_settings(&url, "embed"), 1); + let eng = engine( + vault.clone(), + settings, + "http://127.0.0.1:1", + "http://127.0.0.1:1", + ); + eng.sync_once().await; + let first_id = vault.push_targets()[0].message_id.clone().unwrap(); + + *mock.patch_error.lock().unwrap() = Some(10008); // Unknown Message + tokio::time::sleep(Duration::from_millis(1100)).await; + eng.sync_once().await; + assert!( + vault.push_targets()[0].message_id.is_none(), + "deleted tile must clear the id" + ); + assert!( + vault.list()[0].push_error.is_none(), + "a deleted tile is not the user's problem" + ); + + *mock.patch_error.lock().unwrap() = None; + eng.sync_once().await; + assert_ne!( + vault.push_targets()[0].message_id.as_ref().unwrap(), + &first_id + ); + + *mock.patch_error.lock().unwrap() = Some(10015); // Unknown Webhook + tokio::time::sleep(Duration::from_millis(1100)).await; + eng.sync_once().await; + let a = &vault.list()[0]; + assert!(!a.push_live); + assert!(a + .push_error + .as_deref() + .unwrap_or_default() + .contains("recreate it")); +} + +// ── Slack ───────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn slack_posts_with_bearer_token_then_updates_the_same_ts() { + let dir = tempfile::tempdir().unwrap(); + let (base, mock) = slack_mock().await; + let (vault, settings) = fixture(dir.path(), slack_settings(), 1); + let eng = engine(vault.clone(), settings, &base, "http://127.0.0.1:1"); + + assert_eq!(eng.sync_once().await, 1); + let first = &mock.log.all()[0]; + assert_eq!(first.path, "/chat.postMessage"); + assert_eq!( + first.bearer.as_deref(), + Some("xoxb-test-token"), + "must authenticate as the bot" + ); + let body = first.json(); + assert_eq!(body["channel"], "C0TEST"); + // Code in a fenced block, local-time rollover in the context line. + let section = body["blocks"][0]["text"]["text"].as_str().unwrap(); + assert!(section.contains("*Cloudflare*"), "{section}"); + assert!(section.contains("```"), "{section}"); + assert!(body["blocks"][1]["elements"][0]["text"] + .as_str() + .unwrap() + .contains("Cloudflare"), "{text}"); + assert!(text.contains(""), "tap-to-copy needs : {text}"); + assert_eq!( + body["disable_notification"], true, + "a tile appearing must not buzz the phone" + ); + let mid = vault.push_targets()[0].message_id.clone().unwrap(); + assert_eq!(mid, "501"); + + tokio::time::sleep(Duration::from_millis(1100)).await; + assert_eq!(eng.sync_once().await, 1); + let second = &mock.log.all()[1]; + assert_eq!(second.path, "/bot123:TESTTOKEN/editMessageText"); + assert_eq!(second.json()["message_id"], 501); +} + +#[tokio::test] +async fn telegram_not_modified_is_success_and_not_found_reposts() { + let dir = tempfile::tempdir().unwrap(); + let (base, mock) = telegram_mock().await; + let (vault, settings) = fixture(dir.path(), telegram_settings(), 1); + let eng = engine(vault.clone(), settings, "http://127.0.0.1:1", &base); + eng.sync_once().await; + + // "message is not modified" means the tile already says this — not an error. + *mock.edit_error.lock().unwrap() = Some("Bad Request: message is not modified: specified new message content and reply markup are exactly the same".into()); + tokio::time::sleep(Duration::from_millis(1100)).await; + assert_eq!(eng.sync_once().await, 1, "counted as written"); + assert!( + vault.list()[0].push_error.is_none(), + "must not surface as a failure" + ); + assert!(vault.list()[0].push_live); + + // Deleted by the user → repost. + *mock.edit_error.lock().unwrap() = Some("Bad Request: message to edit not found".into()); + tokio::time::sleep(Duration::from_millis(1100)).await; + eng.sync_once().await; + assert!(vault.push_targets()[0].message_id.is_none()); + *mock.edit_error.lock().unwrap() = None; + eng.sync_once().await; + assert_eq!(mock.log.count(|r| r.path.ends_with("/sendMessage")), 2); +} + +#[tokio::test] +async fn telegram_bad_token_is_fatal_with_a_next_step() { + let dir = tempfile::tempdir().unwrap(); + let (base, _mock) = telegram_mock().await; + let (vault, settings) = fixture( + dir.path(), + serde_json::json!({ "push_enabled": true, "provider": "telegram", "telegram": { "bot_token": "999:WRONG", "chat_id": "-100" } }), + 30, + ); + let eng = engine(vault.clone(), settings, "http://127.0.0.1:1", &base); + eng.sync_once().await; + let a = &vault.list()[0]; + assert!(!a.push_live); + assert!(a + .push_error + .as_deref() + .unwrap_or_default() + .contains("BotFather")); +} + +#[tokio::test] +async fn telegram_detect_lists_each_chat_once() { + let dir = tempfile::tempdir().unwrap(); + let (base, _mock) = telegram_mock().await; + let (vault, settings) = fixture(dir.path(), telegram_settings(), 30); + let eng = engine(vault, settings, "http://127.0.0.1:1", &base); + + let chats = eng.telegram_chats("123:TESTTOKEN").await.unwrap(); + let ids: Vec<_> = chats.iter().map(|c| c.id.as_str()).collect(); + // Chat 42 appears twice in the updates and once here; channel posts count too. + assert_eq!(ids, ["42", "-1001234", "-1009999"]); + assert_eq!(chats[0].title, "Miles Ward"); + assert_eq!(chats[0].kind, "private"); + assert_eq!(chats[1].title, "Homelab"); + assert_eq!(chats[2].kind, "channel"); + + let me = eng.telegram_whoami("123:TESTTOKEN").await.unwrap(); + assert_eq!(me.username, "openmfa_bot"); +} + +// ── cross-platform behaviour ────────────────────────────────────────────── + +#[tokio::test] +async fn renaming_refreshes_the_tile_on_every_platform_without_a_rollover() { + for (which, settings_json) in [ + ("slack", slack_settings()), + ("telegram", telegram_settings()), + ] { + let dir = tempfile::tempdir().unwrap(); + let (sbase, smock) = slack_mock().await; + let (tbase, tmock) = telegram_mock().await; + let (vault, settings) = fixture(dir.path(), settings_json, 30); + let eng = engine(vault.clone(), settings, &sbase, &tbase); + eng.sync_once().await; + eng.sync_once().await; + let before = smock.log.len() + tmock.log.len(); + assert_eq!(before, 1, "{which}: idle pass must be free"); + + let id = vault.list()[0].id.clone(); + vault.update(&id, Some("Renamed Co"), None, None).unwrap(); + assert_eq!(eng.sync_once().await, 1, "{which}: rename must push"); + let last = if which == "slack" { + smock.log.all().pop().unwrap() + } else { + tmock.log.all().pop().unwrap() + }; + let text = last.json().to_string(); + assert!( + text.contains("Renamed Co"), + "{which}: new name missing in {text}" + ); + assert!( + last.path.contains("update") || last.path.contains("edit"), + "{which}: must be an edit, was {}", + last.path + ); + } +} + +#[tokio::test] +async fn several_accounts_are_paced_rather_than_bursted_on_slack() { + let dir = tempfile::tempdir().unwrap(); + let (base, mock) = slack_mock().await; + let (vault, settings) = fixture(dir.path(), slack_settings(), 30); + for n in 0..3 { + vault + .add( + &parse_otpauth(&format!( + "otpauth://totp/Svc{n}:x?secret=JBSWY3DPEHPK3PXP&issuer=Svc{n}" + )) + .unwrap(), + None, + None, + true, + ) + .unwrap(); + } + let eng = engine(vault, settings, &base, "http://127.0.0.1:1"); + let start = std::time::Instant::now(); + assert_eq!(eng.sync_once().await, 4); + assert_eq!(mock.log.len(), 4); + // Slack pacing is 1 s; four calls need at least ~3 s of spacing. + assert!( + start.elapsed() >= Duration::from_millis(2800), + "took {:?}", + start.elapsed() + ); +} + +#[tokio::test] +async fn switching_platform_after_clearing_ids_reposts_to_the_new_one() { + let dir = tempfile::tempdir().unwrap(); + let (dbase, dmock) = discord_mock().await; + let (sbase, smock) = slack_mock().await; + let (vault, settings) = fixture(dir.path(), discord_settings(&dbase, "embed"), 30); + let eng = engine( + vault.clone(), + settings.clone(), + &sbase, + "http://127.0.0.1:1", + ); + eng.sync_once().await; + assert_eq!(dmock.log.len(), 1); + + // Operator switches to Slack. The HTTP layer does exactly this: replace + // settings, and clear every stored id because the destination changed. + let mut s = settings.snapshot(); + // The mock's http:// webhook would fail real validation; the operator is + // leaving Discord anyway, so clear it as the UI's Save would. + s.discord.webhook_url.clear(); + s.provider = Some(openmfa_core::Provider::Slack); + // A well-formed token: this path goes through real validation, unlike the + // fixtures that write settings.json directly. The mock accepts any xoxb-. + s.slack.bot_token = "xoxb-1234567890-1234567890123-abcdefghijklmnopqrstuvwx".into(); + s.slack.channel = "C0TESTCHAN".into(); + let (_, changed) = settings.replace(s).unwrap(); + assert!(changed); + vault.clear_all_messages(); + + assert_eq!(eng.sync_once().await, 1); + assert_eq!( + smock.log.count(|r| r.path == "/chat.postMessage"), + 1, + "posted fresh on Slack" + ); + assert_eq!(dmock.log.len(), 1, "Discord untouched after the switch"); +} + +#[tokio::test] +async fn nothing_is_sent_while_push_is_disabled_or_unconfigured() { + let dir = tempfile::tempdir().unwrap(); + let (base, mock) = slack_mock().await; + let (vault, settings) = fixture( + dir.path(), + serde_json::json!({ "push_enabled": false, "provider": "slack", "slack": { "bot_token": "xoxb-test-token", "channel": "C0TEST" } }), + 30, + ); + let eng = engine(vault, settings, &base, "http://127.0.0.1:1"); + assert_eq!(eng.sync_once().await, 0); + assert_eq!(mock.log.len(), 0); + + // No provider at all — the fresh-install state. + let dir2 = tempfile::tempdir().unwrap(); + let (vault2, settings2) = fixture(dir2.path(), serde_json::json!({ "push_enabled": true }), 30); + let eng2 = engine(vault2, settings2, &base, "http://127.0.0.1:1"); + assert_eq!(eng2.sync_once().await, 0); + assert!(eng2 + .send_test() + .await + .unwrap_err() + .contains("no chat platform")); +} diff --git a/crates/http-api/Cargo.toml b/crates/http-api/Cargo.toml new file mode 100644 index 0000000..2978b53 --- /dev/null +++ b/crates/http-api/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "openmfa-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 OpenMFA." + +[dependencies] +openmfa-core.workspace = true +openmfa-delivery.workspace = true +openmfa-render.workspace = true +openmfa-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"] } +base64.workspace = true diff --git a/crates/http-api/examples/make-google-export.rs b/crates/http-api/examples/make-google-export.rs new file mode 100644 index 0000000..516a930 --- /dev/null +++ b/crates/http-api/examples/make-google-export.rs @@ -0,0 +1,83 @@ +//! Emit a Google Authenticator export QR for manual testing: +//! `cargo run -p openmfa-http-api --example make-google-export -- out.png 8` +fn put_varint(v: u64, out: &mut Vec) { + let mut v = v; + loop { + let mut b = (v & 0x7f) as u8; + v >>= 7; + if v != 0 { + b |= 0x80; + } + out.push(b); + if v == 0 { + break; + } + } +} +fn put_bytes(field: u32, bytes: &[u8], out: &mut Vec) { + put_varint((u64::from(field) << 3) | 2, out); + put_varint(bytes.len() as u64, out); + out.extend_from_slice(bytes); +} +fn put_uint(field: u32, v: u64, out: &mut Vec) { + put_varint(u64::from(field) << 3, out); + put_varint(v, out); +} + +fn main() { + let mut a = std::env::args().skip(1); + let out = a + .next() + .expect("usage: make-google-export [count]"); + let n: usize = a.next().unwrap_or_else(|| "6".into()).parse().unwrap(); + + let names = [ + "Cloudflare", + "Plex", + "Ubisoft", + "GitHub", + "Steam", + "Proton", + "Fastmail", + "Namecheap", + "Vercel", + "Backblaze", + ]; + let mut payload = Vec::new(); + for i in 0..n { + let mut e = Vec::new(); + put_bytes(1, &[0x41 + i as u8; 20], &mut e); + put_bytes(2, b"miles@milesward.dev", &mut e); + put_bytes(3, names[i % names.len()].as_bytes(), &mut e); + put_uint(4, 1, &mut e); + // Give one entry 8 digits so the UI's variant chips get exercised. + put_uint(5, if i == 2 { 2 } else { 1 }, &mut e); + // And make one HOTP so the "skipped" path shows up. + put_uint(6, if i == 4 { 1 } else { 2 }, &mut e); + put_bytes(1, &e, &mut payload); + } + put_uint(2, 1, &mut payload); + put_uint(3, 2, &mut payload); // batch_size + put_uint(4, 0, &mut payload); // batch_index + put_uint(5, 77, &mut payload); + + use base64::Engine; + let b64 = base64::engine::general_purpose::STANDARD.encode(&payload); + let escaped = b64 + .replace('+', "%2B") + .replace('/', "%2F") + .replace('=', "%3D"); + let uri = format!("otpauth-migration://offline?data={escaped}"); + + let code = qrcode::QrCode::new(uri.as_bytes()).unwrap(); + let img = code + .render::>() + .module_dimensions(6, 6) + .quiet_zone(true) + .build(); + image::DynamicImage::ImageLuma8(img).save(&out).unwrap(); + println!( + "wrote {out} ({n} entries, {} bytes of payload)", + payload.len() + ); +} diff --git a/crates/http-api/examples/make-qr.rs b/crates/http-api/examples/make-qr.rs new file mode 100644 index 0000000..c7e904b --- /dev/null +++ b/crates/http-api/examples/make-qr.rs @@ -0,0 +1,14 @@ +//! Write a QR PNG for manual testing: `cargo run -p openmfa-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..8accd32 --- /dev/null +++ b/crates/http-api/src/app.rs @@ -0,0 +1,462 @@ +//! 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 openmfa_core as da; +use openmfa_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)) + .route("/api/accounts/preview", post(preview_account)) + .route("/api/accounts/import", post(import_accounts)) + .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_delivery)) + .route("/api/slack/whoami", post(slack_whoami)) + .route("/api/telegram/whoami", post(telegram_whoami)) + .route("/api/telegram/chats", post(telegram_chats)); + + 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), + ], + openmfa_webui::index_html(state.version), + ) + .into_response() +} + +async fn asset_js() -> Response { + ( + [ + (header::CONTENT_TYPE, "text/javascript; charset=utf-8"), + (header::CACHE_CONTROL, CACHE), + ], + openmfa_webui::app_js(), + ) + .into_response() +} + +async fn asset_css() -> Response { + ( + [ + (header::CONTENT_TYPE, "text/css; charset=utf-8"), + (header::CACHE_CONTROL, CACHE), + ], + openmfa_webui::app_css(), + ) + .into_response() +} + +async fn asset_logo() -> Response { + ( + [ + (header::CONTENT_TYPE, "image/svg+xml"), + (header::CACHE_CONTROL, CACHE), + ], + openmfa_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, +} + +/// Describe what a link contains, without storing anything. +/// +/// Handles both a plain `otpauth://` URI (one account) and a Google +/// Authenticator `otpauth-migration://` export (many), so the UI has a single +/// code path. Each entry is flagged if it is already in the vault, letting the +/// import screen pre-uncheck it rather than failing later. +async fn preview_account(State(state): State, Json(body): Json) -> Response { + let batch = match da::parse_import(&body.uri) { + Ok(b) => b, + Err(e) => return crate::error_response(&e), + }; + + let accounts: Vec<_> = batch + .accounts + .iter() + .enumerate() + .map(|(index, a)| { + json!({ + "index": index, + "issuer": a.issuer, + "label": a.label, + "algorithm": a.totp.algorithm.as_str(), + "digits": a.totp.digits, + "period": a.totp.period, + "duplicate": state.vault.contains(&a.totp, &a.issuer, &a.label), + }) + }) + .collect(); + + let skipped: Vec<_> = batch + .skipped + .iter() + .map(|s| json!({ "name": s.name, "reason": s.reason })) + .collect(); + + ( + // Names and parameters of the user's accounts — never cache. + [(header::CACHE_CONTROL, "no-store")], + Json(json!({ + "accounts": accounts, + "skipped": skipped, + "part": batch.part_label(), + "batch_id": batch.batch_id, + "is_export": da::is_migration_uri(&body.uri), + })), + ) + .into_response() +} + +#[derive(Debug, Deserialize)] +struct ImportItem { + /// Index into the preview's `accounts` array. + index: usize, + #[serde(default)] + issuer: Option, + #[serde(default)] + label: Option, + #[serde(default = "default_true")] + push_enabled: bool, +} + +#[derive(Debug, Deserialize)] +struct ImportBody { + uri: String, + items: Vec, +} + +/// Import the selected accounts from a link. +/// +/// The client sends back the original link plus the indices it wants, and the +/// server re-parses it. That keeps secrets from ever making the round trip +/// out to the browser and back, which they would if the preview had returned +/// them. +async fn import_accounts(State(state): State, Json(body): Json) -> Response { + let batch = match da::parse_import(&body.uri) { + Ok(b) => b, + Err(e) => return crate::error_response(&e), + }; + + let mut queued = Vec::with_capacity(body.items.len()); + for item in &body.items { + let Some(parsed) = batch.accounts.get(item.index) else { + return bad_request(&format!( + "selection {} is out of range for this link — reload and try again", + item.index + )); + }; + queued.push(da::NewAccount { + parsed: parsed.clone(), + issuer: item.issuer.clone(), + label: item.label.clone(), + push_enabled: item.push_enabled, + }); + } + + match state.vault.add_many(&queued) { + // The push loop picks new accounts up on its next tick; we don't kick + // a sync from here, so only one pass is ever in flight. + Ok(outcome) => (StatusCode::CREATED, Json(outcome)).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), + } +} + +fn default_true() -> bool { + true +} + +// ── 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 — a Google export screenshot + // legitimately does. Return them all, OTP links first, so the + // caller can import every account in one pass instead of only + // whichever happened to be detected first. + let mut uris: Vec = found + .iter() + .filter(|s| { + let l = s.to_ascii_lowercase(); + l.starts_with("otpauth://") || l.starts_with("otpauth-migration://") + }) + .cloned() + .collect(); + if uris.is_empty() { + // Nothing OTP-shaped; hand back whatever was there so the UI + // can say what it actually found. + uris = found; + } + match uris.first() { + Some(first) => ( + [(header::CACHE_CONTROL, "no-store")], + Json(json!({ "uri": first, "uris": uris })), + ) + .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 { + ( + // Tokens and the webhook URL are secrets; keep them out of any cache. + [(header::CACHE_CONTROL, "no-store")], + Json(state.settings.snapshot()), + ) + .into_response() +} + +async fn put_settings(State(state): State, Json(incoming): Json) -> Response { + match state.settings.replace(incoming) { + Ok((s, destination_changed)) => { + if destination_changed { + // Stored message ids belong to the old destination — a + // different platform or channel — and mean nothing now. + // Dropping them makes the next pass post a fresh tile per + // account. Tiles in the old place are left behind: by the + // time we hear about the change the old credentials may + // already be gone, so there's nothing reliable to delete with. + state.vault.clear_all_messages(); + tracing::info!( + target: "openmfa::http", + provider = s.provider.map_or("none", da::Provider::as_str), + "destination changed; every tile will be reposted" + ); + } + Json(s).into_response() + } + Err(e) => crate::error_response(&e), + } +} + +async fn test_delivery(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(), + } +} + +#[derive(Debug, Deserialize)] +struct TokenBody { + token: String, +} + +/// Validate a Slack bot token and say who it is, so the settings page can +/// show "connected as @openmfa in Acme" before anything is saved. +async fn slack_whoami(State(state): State, Json(body): Json) -> Response { + let token = body.token.trim(); + if let Err(e) = da::validate_slack(&da::SlackConfig { + bot_token: token.to_string(), + channel: "C000000000".into(), // shape only; the channel isn't checked here + }) { + return crate::error_response(&e); + } + match state.engine.slack_whoami(token).await { + Ok(id) => Json(id).into_response(), + Err(e) => ( + StatusCode::BAD_GATEWAY, + Json(json!({ "error": e.to_string() })), + ) + .into_response(), + } +} + +/// Validate a Telegram bot token and report the bot's @handle. +async fn telegram_whoami(State(state): State, Json(body): Json) -> Response { + match state.engine.telegram_whoami(body.token.trim()).await { + Ok(id) => Json(id).into_response(), + Err(e) => ( + StatusCode::BAD_GATEWAY, + Json(json!({ "error": e.to_string() })), + ) + .into_response(), + } +} + +/// Chats that have messaged the bot — the "Detect chat" button. The operator +/// sends /start to the bot (or adds it to a group and says anything), then +/// clicks Detect, and picks from the list instead of hunting for a chat id. +async fn telegram_chats(State(state): State, Json(body): Json) -> Response { + match state.engine.telegram_chats(body.token.trim()).await { + Ok(chats) => Json(json!({ "chats": chats })).into_response(), + Err(e) => ( + StatusCode::BAD_GATEWAY, + Json(json!({ "error": e.to_string() })), + ) + .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..3a54707 --- /dev/null +++ b/crates/http-api/src/auth.rs @@ -0,0 +1,420 @@ +//! 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 openmfa_core as da; +use openmfa_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 = "openmfa_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, + /// Optional at setup: an install can exist before any chat is wired, and + /// the same fields are editable later in Settings. + #[serde(default)] + pub provider: Option, + #[serde(default)] + pub discord: da::DiscordConfig, + #[serde(default)] + pub slack: da::SlackConfig, + #[serde(default)] + pub telegram: da::TelegramConfig, +} + +/// First-run setup: create the admin and, optionally, wire a chat platform 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 delivery config BEFORE creating anything, so a mistyped + // token doesn't leave a half-configured install that needs the password + // to fix. Delivery is on only when a platform was actually chosen. + let proposed = da::Settings { + push_enabled: body.provider.is_some(), + provider: body.provider, + discord: body.discord.clone(), + slack: body.slack.clone(), + telegram: body.telegram.clone(), + push_mode: da::PushMode::default(), + }; + let checked = match proposed.check() { + Ok(c) => c, + Err(e) => return crate::error_response(&e), + }; + + // 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::Error::crypto(e))); + + match result { + Ok(public) => { + if let Err(e) = state.settings.replace(checked) { + tracing::warn!(target: "openmfa::http", "could not store delivery settings 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::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::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("openmfa_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..8dbea79 --- /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 `openmfa-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: &openmfa_core::Error) -> Response { + use openmfa_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: "openmfa::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: "openmfa::http", "i/o error: {detail}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "could not write to disk — check the server log".to_string(), + ) + } + other => { + tracing::error!(target: "openmfa::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..fa41d54 --- /dev/null +++ b/crates/http-api/src/qr.rs @@ -0,0 +1,443 @@ +//! 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); + } + + /// Build a Google Authenticator export QR the way the real app emits + /// one, so the upload path is exercised end to end: image bytes -> QR + /// decode -> protobuf parse -> accounts. + fn google_export_png(n: usize, module_px: u32) -> Vec { + fn put_varint(v: u64, out: &mut Vec) { + let mut v = v; + loop { + let mut b = (v & 0x7f) as u8; + v >>= 7; + if v != 0 { + b |= 0x80; + } + out.push(b); + if v == 0 { + break; + } + } + } + fn put_bytes(field: u32, bytes: &[u8], out: &mut Vec) { + put_varint((u64::from(field) << 3) | 2, out); + put_varint(bytes.len() as u64, out); + out.extend_from_slice(bytes); + } + fn put_uint(field: u32, v: u64, out: &mut Vec) { + put_varint(u64::from(field) << 3, out); + put_varint(v, out); + } + + let mut payload = Vec::new(); + for i in 0..n { + let mut e = Vec::new(); + put_bytes(1, &[0x41 + i as u8; 20], &mut e); // secret + put_bytes(2, format!("user{i}@example.com").as_bytes(), &mut e); + put_bytes(3, format!("Service{i}").as_bytes(), &mut e); + put_uint(4, 1, &mut e); // SHA1 + put_uint(5, 1, &mut e); // 6 digits + put_uint(6, 2, &mut e); // TOTP + put_bytes(1, &e, &mut payload); + } + put_uint(2, 1, &mut payload); + + use base64::Engine; + let b64 = base64::engine::general_purpose::STANDARD.encode(&payload); + let escaped = b64 + .replace('+', "%2B") + .replace('/', "%2F") + .replace('=', "%3D"); + qr_png( + &format!("otpauth-migration://offline?data={escaped}"), + module_px, + ) + } + + #[test] + fn decodes_a_google_export_qr_end_to_end() { + let png = google_export_png(5, 4); + let found = decode(&png).unwrap(); + assert_eq!(found.len(), 1, "one QR, one payload"); + assert!(found[0].starts_with("otpauth-migration://")); + + // And that payload really does carry five importable accounts. + let batch = openmfa_core::parse_import(&found[0]).unwrap(); + assert_eq!(batch.accounts.len(), 5); + assert_eq!(batch.accounts[0].issuer, "Service0"); + assert_eq!(batch.accounts[4].label, "user4@example.com"); + } + + #[test] + fn decodes_several_qr_codes_from_one_screenshot() { + // A screenshot of a multi-part export can contain more than one code. + // Stacking two here proves `decode` returns both rather than the + // first, which is what lets the UI import every account in one pass. + let a = image::load_from_memory(&qr_png(URI, 5)).unwrap().to_luma8(); + let b = image::load_from_memory(&google_export_png(2, 5)) + .unwrap() + .to_luma8(); + + let w = a.width().max(b.width()); + let mut canvas = + GrayImage::from_pixel(w + 40, a.height() + b.height() + 60, image::Luma([255])); + image::imageops::replace(&mut canvas, &a, 20, 20); + image::imageops::replace(&mut canvas, &b, 20, i64::from(a.height()) + 40); + + let mut buf = std::io::Cursor::new(Vec::new()); + DynamicImage::ImageLuma8(canvas) + .write_to(&mut buf, image::ImageFormat::Png) + .unwrap(); + + let found = decode(&buf.into_inner()).unwrap(); + assert_eq!( + found.len(), + 2, + "both codes should be returned, got {found:?}" + ); + assert!(found.iter().any(|u| u.starts_with("otpauth://"))); + assert!(found.iter().any(|u| u.starts_with("otpauth-migration://"))); + } + + #[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..12ac7bd --- /dev/null +++ b/crates/http-api/src/state.rs @@ -0,0 +1,27 @@ +//! Shared handler state. + +use openmfa_core::{AdminStore, SettingsStore, Vault}; +use openmfa_delivery::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/openmfa/Cargo.toml b/crates/openmfa/Cargo.toml new file mode 100644 index 0000000..7e39a49 --- /dev/null +++ b/crates/openmfa/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "openmfa" +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 = "openmfa" +path = "src/main.rs" + +[dependencies] +openmfa-core.workspace = true +openmfa-delivery.workspace = true +openmfa-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/openmfa/src/main.rs b/crates/openmfa/src/main.rs new file mode 100644 index 0000000..f1d7007 --- /dev/null +++ b/crates/openmfa/src/main.rs @@ -0,0 +1,126 @@ +//! OpenMFA — 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 openmfa_core::{AdminStore, SettingsStore, Vault, VaultKey}; +use openmfa_delivery::{Endpoints, PushEngine}; +use openmfa_http_api::AppState; +use std::net::SocketAddr; +use std::path::PathBuf; + +const VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[derive(Debug, Parser)] +#[command( + name = "openmfa", + version = VERSION, + about = "Self-hosted TOTP authenticator with live delivery to Discord, Slack or Telegram" +)] +struct Args { + /// Address to serve the web UI and API on. + #[arg(long, env = "OPENMFA_HTTP_ADDR", default_value = "0.0.0.0:8720")] + http_addr: SocketAddr, + + /// Directory holding the vault, key, settings and admin account. + #[arg(long, env = "OPENMFA_WORK_DIR", default_value = "/var/lib/openmfa")] + work_dir: PathBuf, + + /// `tracing-subscriber` filter expression. + #[arg(long, env = "OPENMFA_LOG", default_value = "info,openmfa=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(), &Endpoints::from_env()); + tokio::spawn(engine.clone().run()); + + let state = AppState { + admin: admin.clone(), + vault, + settings, + sessions: openmfa_http_api::auth::SessionStore::default(), + engine, + version: VERSION, + }; + + let app = openmfa_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: "openmfa", + version = VERSION, + addr = %args.http_addr, + work_dir = %args.work_dir.display(), + "OpenMFA started" + ); + if !admin.is_configured() { + tracing::info!( + target: "openmfa", + "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: "openmfa", "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: "openmfa", "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/render/Cargo.toml b/crates/render/Cargo.toml new file mode 100644 index 0000000..c54f752 --- /dev/null +++ b/crates/render/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "openmfa-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/brand.rs b/crates/render/examples/brand.rs new file mode 100644 index 0000000..cd2d05b --- /dev/null +++ b/crates/render/examples/brand.rs @@ -0,0 +1,15 @@ +//! Rasterise the brand icon to PNG at the sizes the README, the Gitea repo +//! avatar and the Unraid template need: +//! `cargo run -p openmfa-render --example brand -- docs/brand` +fn main() { + let dir = std::env::args() + .nth(1) + .unwrap_or_else(|| "docs/brand".into()); + let svg = std::fs::read_to_string(format!("{dir}/icon.svg")).expect("read icon.svg"); + for size in [512u32, 256, 128] { + let png = openmfa_render::rasterise_svg(&svg, size, size).expect("render"); + let path = format!("{dir}/icon-{size}.png"); + std::fs::write(&path, &png).expect("write"); + println!("wrote {path} ({} bytes)", png.len()); + } +} diff --git a/crates/render/examples/preview.rs b/crates/render/examples/preview.rs new file mode 100644 index 0000000..9cafd78 --- /dev/null +++ b/crates/render/examples/preview.rs @@ -0,0 +1,25 @@ +//! Dump sample tiles for eyeballing: `cargo run -p openmfa-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 = openmfa_render::Tile { + issuer, + label, + code, + period, + }; + let png = openmfa_render::tile_png(&t).unwrap(); + let webp = openmfa_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..89cee7e --- /dev/null +++ b/crates/render/src/lib.rs @@ -0,0 +1,406 @@ +//! 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()) +} + +/// Rasterise arbitrary SVG source to a PNG at a given size. +/// +/// Exposed so the brand assets (README image, Gitea avatar, Unraid template +/// icon) are generated from the same `icon.svg` the app ships, rather than +/// being hand-exported and drifting out of sync with it. +pub fn rasterise_svg(svg: &str, width: u32, height: u32) -> Result> { + let tree = usvg::Tree::from_str(svg, options())?; + let mut pixmap = + tiny_skia::Pixmap::new(width, height).ok_or(RenderError::Pixmap(width, height))?; + + // Scale the document to fill the requested pixmap. + let size = tree.size(); + let scale = (width as f32 / size.width()).min(height as f32 / size.height()); + resvg::render( + &tree, + tiny_skia::Transform::from_scale(scale, scale), + &mut pixmap.as_mut(), + ); + + 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()]); + } + + 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 & of an SVG can't inherit + * currentColor, which is why the plated /assets/logo.svg is only used where + * no CSS context exists: the favicon.) */ +function markSvg(size) { + const ns = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(ns, 'svg'); + svg.setAttribute('viewBox', '0 0 32 32'); + svg.setAttribute('width', size); svg.setAttribute('height', size); + svg.setAttribute('fill', 'none'); svg.setAttribute('stroke', 'currentColor'); + svg.setAttribute('aria-hidden', 'true'); + const shield = 'M16 2.6 27 6.3v9.1c0 6.6-4.4 12.2-11 14.1-6.6-1.9-11-7.5-11-14.1V6.3L16 2.6Z'; + const add = (tag, attrs) => { const e = document.createElementNS(ns, tag); for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v); svg.append(e); }; + add('path', { d: shield, fill: 'currentColor', 'fill-opacity': '.12', stroke: 'none' }); + add('path', { d: shield, 'stroke-width': '1.9', 'stroke-linejoin': 'round' }); + add('path', { d: 'M20.9 12.6a5.9 5.9 0 1 0 1 3.3', 'stroke-width': '2.1', 'stroke-linecap': 'round' }); + add('circle', { cx: '16', cy: '15.9', r: '1.85', fill: 'currentColor', stroke: 'none' }); + return svg; +} + +function brandMark(size) { + return h('div.brand', {}, [markSvg(size || 26)]); +} + +// ── Delivery platforms ─────────────────────────────────────────────────────── + +const PROVIDERS = [ + { id: 'discord', name: 'Discord', color: '#5865F2' }, + { id: 'slack', name: 'Slack', color: '#2EB67D' }, + { id: 'telegram', name: 'Telegram', color: '#229ED9' }, +]; + +function providerName(id) { + const p = PROVIDERS.find((x) => x.id === id); + return p ? p.name : 'chat'; +} + +/** Three-way, optional: click a segment to select, click it again to clear. */ +function providerPicker(initial, onChange) { + let current = initial || null; + const buttons = PROVIDERS.map((p) => h('button', { + type: 'button', dataset: { provider: p.id }, + onclick: () => { current = current === p.id ? null : p.id; paint(); onChange(current); }, + }, [h('span.dot', { style: 'background:' + p.color }), p.name])); + const el = h('div.segmented', {}, buttons); + function paint() { for (const b of buttons) b.classList.toggle('active', b.dataset.provider === current); } + paint(); + return { el, get: () => current, set: (v) => { current = v; paint(); } }; +} + +/** Provider-specific credential fields. `values` is the settings object; + * `read()` returns {discord, slack, telegram} with the current inputs. */ +function providerFields(values) { + const v = values || {}; + const d = v.discord || {}, sl = v.slack || {}, tg = v.telegram || {}; + + const hook = h('input.input', { type: 'url', value: d.webhook_url || '', placeholder: 'https://discord.com/api/webhooks/…', autocomplete: 'off' }); + const slackTok = h('input.input.mono', { type: 'password', value: sl.bot_token || '', placeholder: 'xoxb-…', autocomplete: 'off' }); + const slackCh = h('input.input.mono', { type: 'text', value: sl.channel || '', placeholder: 'C0123ABCDEF', autocomplete: 'off' }); + const tgTok = h('input.input.mono', { type: 'password', value: tg.bot_token || '', placeholder: '123456789:AAF…', autocomplete: 'off' }); + const tgChat = h('input.input.mono', { type: 'text', value: tg.chat_id || '', placeholder: '-1001234567890 or @channel', autocomplete: 'off' }); + + const slackStatus = h('div.hint'); + const slackCheck = h('button.btn.btn-sm', { type: 'button', text: 'Check token', onclick: async () => { + slackStatus.textContent = 'Checking…'; + try { + const r = await api('/api/slack/whoami', { method: 'POST', body: { token: slackTok.value.trim() } }); + slackStatus.textContent = 'Connected as @' + r.user + ' in ' + r.team + '. Now invite the bot to the channel with /invite.'; + } catch (e) { slackStatus.textContent = e.message; } + } }); + + const tgStatus = h('div.hint'); + const tgChats = h('div.stack-s'); + const tgDetect = h('button.btn.btn-sm', { type: 'button', text: 'Detect chat', onclick: async () => { + tgStatus.textContent = 'Looking for chats that have messaged the bot…'; + tgChats.replaceChildren(); + try { + const me = await api('/api/telegram/whoami', { method: 'POST', body: { token: tgTok.value.trim() } }); + const r = await api('/api/telegram/chats', { method: 'POST', body: { token: tgTok.value.trim() } }); + if (!r.chats.length) { + tgStatus.textContent = 'Token is valid (@' + me.username + '), but nobody has messaged it yet. Open Telegram, send /start to @' + me.username + ' (or add it to a group and say hi), then Detect again.'; + return; + } + tgStatus.textContent = '@' + me.username + ' has heard from ' + r.chats.length + ' chat' + (r.chats.length === 1 ? '' : 's') + ' — pick one:'; + for (const c of r.chats) { + tgChats.append(h('button.btn.btn-block', { type: 'button', style: 'justify-content:space-between', onclick: () => { + tgChat.value = c.id; tgStatus.textContent = 'Using ' + c.title + ' (' + c.id + ').'; tgChats.replaceChildren(); + } }, [h('span', { text: c.title }), h('span.chip', { text: c.kind })])); + } + } catch (e) { tgStatus.textContent = e.message; } + } }); + + const panes = { + discord: h('div', {}, [ + h('div.field', {}, [h('label', { text: 'Webhook URL' }), hook, + h('div.hint', { text: 'Discord → Server Settings → Integrations → Webhooks → New Webhook → Copy Webhook URL. Webhooks can edit their own messages, so no bot is needed.' })]), + ]), + slack: h('div', {}, [ + h('div.alert.info', {}, [icon(ICON.info), h('span', { html: + 'Slack incoming webhooks can post but can\'t edit, and editing is how the tiles stay live. ' + + 'So OpenMFA needs a Slack app: api.slack.com/apps → Create New App → OAuth & Permissions, add the ' + + 'chat:write bot scope, install to your workspace, and copy the Bot User OAuth Token.' })]), + h('div.field', {}, [h('label', { text: 'Bot User OAuth Token' }), slackTok, + h('div', { style: 'display:flex;gap:8px;align-items:center;margin-top:6px' }, [slackCheck, slackStatus])]), + h('div.field', {}, [h('label', { text: 'Channel ID' }), slackCh, + h('div.hint', { text: 'Open the channel, click its name, and copy the ID from the bottom of the details pane (C… for public, G… for private). Then run /invite @YourBot in that channel.' })]), + ]), + telegram: h('div', {}, [ + h('div.alert.info', {}, [icon(ICON.info), h('span', { html: + 'In Telegram, message @BotFather, send /newbot, and paste the token it gives you.' })]), + h('div.field', {}, [h('label', { text: 'Bot token' }), tgTok]), + h('div.field', {}, [h('label', { text: 'Chat ID' }), tgChat, + h('div', { style: 'display:flex;gap:8px;align-items:center;margin-top:6px' }, [tgDetect, tgStatus]), + tgChats, + h('div.hint', { text: 'Send /start to your bot from your own account, or add it to a group and say anything, then Detect. For a channel, add the bot as an admin first.' })]), + ]), + }; + + const wrap = h('div.provider-fields'); + function show(id) { + wrap.replaceChildren(); + if (id && panes[id]) wrap.append(panes[id]); + } + function read() { + return { + discord: { webhook_url: hook.value.trim() }, + slack: { bot_token: slackTok.value.trim(), channel: slackCh.value.trim() }, + telegram: { bot_token: tgTok.value.trim(), chat_id: tgChat.value.trim() }, + }; + } + return { el: wrap, show, read }; +} + +function renderSetup() { + const err = h('div.alert.err.hidden'); + const user = h('input.input', { type: 'text', autocomplete: 'username', placeholder: 'admin', required: true }); + const pw = h('input.input', { type: 'password', autocomplete: 'new-password', placeholder: 'At least 8 characters', required: true }); + const pw2 = h('input.input', { type: 'password', autocomplete: 'new-password', placeholder: 'Repeat password', required: true }); + const submit = h('button.btn.btn-primary.btn-lg.btn-block', { type: 'submit', text: 'Create account' }); + + const fields = providerFields({}); + const skipNote = h('div.hint', { text: 'Optional — pick one now, or set it up later in Settings. Codes are only pushed once a platform is connected.' }); + const picker = providerPicker(null, (id) => { + fields.show(id); + skipNote.textContent = id + ? 'You can switch platforms or change these details later in Settings.' + : 'Optional — pick one now, or set it up later in Settings. Codes are only pushed once a platform is connected.'; + }); + + const fail = (msg) => { + err.replaceChildren(icon(ICON.alert), h('span', { text: msg })); + err.classList.remove('hidden'); + submit.disabled = false; submit.textContent = 'Create account'; + }; + + const form = h('form', { + onsubmit: async (e) => { + e.preventDefault(); + err.classList.add('hidden'); + if (pw.value !== pw2.value) return fail('The two passwords do not match.'); + submit.disabled = true; submit.textContent = 'Creating…'; + try { + await api('/api/setup', { + method: 'POST', + body: Object.assign({ username: user.value, password: pw.value, provider: picker.get() }, fields.read()), + }); + state.me = await api('/api/me'); + toast('Welcome to OpenMFA'); + enterApp(); + } catch (e2) { fail(e2.message); } + }, + }, [ + err, + h('div.field', {}, [h('label', { text: 'Username' }), user]), + h('div.field', {}, [h('label', { text: 'Password' }), pw]), + h('div.field', {}, [h('label', { text: 'Confirm password' }), pw2]), + h('div.field', {}, [ + h('label', { text: 'Deliver live codes to' }), + picker.el, + skipNote, + ]), + fields.el, + submit, + ]); + + root().replaceChildren(h('div.auth-wrap.striped', {}, [ + h('div.auth-card', {}, [ + brandMark(34), + h('h1', { text: 'Set up OpenMFA' }), + h('p.sub', { text: 'Create the administrator account for this install.' }), + form, + ]), + ])); + user.focus(); +} + +function renderLogin() { + const err = h('div.alert.err.hidden'); + const user = h('input.input', { type: 'text', autocomplete: 'username', required: true }); + const pw = h('input.input', { type: 'password', autocomplete: 'current-password', required: true }); + const submit = h('button.btn.btn-primary.btn-lg.btn-block', { type: 'submit', text: 'Sign in' }); + + const form = h('form', { + onsubmit: async (e) => { + e.preventDefault(); + err.classList.add('hidden'); + submit.disabled = true; + submit.textContent = 'Signing in…'; + try { + await api('/api/login', { method: 'POST', body: { username: user.value, password: pw.value } }); + state.me = await api('/api/me'); + enterApp(); + } catch (e2) { + err.replaceChildren(icon(ICON.alert), h('span', { text: e2.message })); + err.classList.remove('hidden'); + submit.disabled = false; + submit.textContent = 'Sign in'; + pw.value = ''; + pw.focus(); + } + }, + }, [ + err, + h('div.field', {}, [h('label', { text: 'Username' }), user]), + h('div.field', {}, [h('label', { text: 'Password' }), pw]), + submit, + ]); + + root().replaceChildren(h('div.auth-wrap.striped', {}, [ + h('div.auth-card', {}, [ + brandMark(34), + h('h1', { text: 'OpenMFA' }), + h('p.sub', { text: 'Sign in to view your codes.' }), + form, + ]), + ])); + user.focus(); +} + +// ── App shell ────────────────────────────────────────────────────────────── + +function renderShell() { + const searchInput = h('input', { + type: 'search', placeholder: 'Search accounts…', value: state.query, + 'aria-label': 'Search accounts', + oninput: (e) => { + state.query = e.target.value; + searchWrap.classList.toggle('has-value', !!state.query); + if (state.view === 'vault') renderView(); + }, + }); + const searchWrap = h('div.search' + (state.query ? '.has-value' : ''), {}, [ + icon(ICON.search, { width: 2 }), + searchInput, + h('button.clear', { type: 'button', 'aria-label': 'Clear search', onclick: () => { + state.query = ''; searchInput.value = ''; searchWrap.classList.remove('has-value'); + if (state.view === 'vault') renderView(); + searchInput.focus(); + } }, [icon(ICON.x, { width: 2.4 })]), + ]); + + // Three columns so the search sits on the page's centre line, matching the + // centred account tiles below it, regardless of how wide the brand or the + // action cluster happen to be. + const topbar = h('header.topbar', {}, [ + h('div.brand', { style: 'cursor:pointer', onclick: () => go('vault') }, [ + markSvg(26), h('strong', { text: 'OpenMFA' }), + ]), + searchWrap, + h('div.actions', {}, [ + h('button.icon-btn.theme-toggle', { + type: 'button', 'aria-label': 'Toggle theme', title: 'Toggle theme', + onclick: toggleTheme, + }, [ + icon(ICON.sun, { className: 't-sun', width: 2 }), + icon(ICON.moon, { className: 't-moon', width: 2 }), + ]), + h('button.icon-btn', { + type: 'button', 'aria-label': 'Settings', title: 'Settings', + onclick: () => go('settings'), + }, [icon(ICON.settings, { width: 1.7 })]), + h('button.icon-btn', { + type: 'button', 'aria-label': 'Sign out', title: 'Sign out', + onclick: async () => { await api('/api/logout', { method: 'POST' }); stopTicker(); boot(); }, + }, [icon(ICON.logout, { width: 2 })]), + ]), + ]); + + root().replaceChildren(h('div.shell', {}, [topbar, h('main.main', { id: 'view' })])); +} + +function go(view) { + state.view = view; + renderView(); + window.scrollTo({ top: 0, behavior: 'smooth' }); +} + +function toggleTheme() { + const cur = document.documentElement.getAttribute('data-theme'); + const next = cur === 'dark' ? 'light' : 'dark'; + document.documentElement.setAttribute('data-theme', next); + try { localStorage.setItem('openmfa-theme', next); } catch (_) {} +} + +function renderView() { + const el = document.getElementById('view'); + if (!el) return; + const fab = document.querySelector('.fab'); + if (fab) fab.remove(); + if (state.view === 'settings') el.replaceChildren(...settingsView()); + else el.replaceChildren(...vaultView()); +} + +// ── Vault ────────────────────────────────────────────────────────────────── + +function matches(a, q) { + if (!q) return true; + const s = q.toLowerCase(); + return (a.issuer || '').toLowerCase().includes(s) || + (a.label || '').toLowerCase().includes(s); +} + +function vaultView() { + if (!state.accounts.length) { + return [h('div.empty.card', {}, [ + h('div.glyph', {}, [icon(ICON.shield, { width: 1.8 })]), + h('h2', { text: 'No accounts yet' }), + h('p', { text: 'Add your first account and its rotating code will start streaming to your chat.' }), + h('button.btn.btn-primary.btn-lg', { onclick: openAddSheet }, [ + icon(ICON.plus), 'Click here to add an account', + ]), + ])]; + } + + const visible = state.accounts.filter((a) => matches(a, state.query)); + const out = []; + + if (!visible.length) { + out.push(h('div.empty.card', {}, [ + h('div.glyph', {}, [icon(ICON.search, { width: 1.8 })]), + h('h2', { text: 'No matches' }), + h('p', { text: `Nothing matches “${state.query}”.` }), + ])); + } else { + out.push(h('div.accounts', {}, visible.map(acctRow))); + } + + // Floating "Add an account" — lives on 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': `Edit ${a.issuer}`, title: 'Edit', + onclick: () => openAccountSheet(a), + }, [icon(ICON.edit, { width: 1.9 })]), + ]), + ]); + 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) { + const where = providerName(state.provider); + if (!a.push_enabled) return where + ' push paused'; + if (a.push_error) return where + ' push error: ' + a.push_error; + return a.push_live ? 'Live in ' + where : '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 }); + previewUris(res.uris && res.uris.length ? res.uris : [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(); previewUris([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 previewUris([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 previewUris([`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]); + } + + /** Fetch a preview for every link found, then show the import list. */ + async function previewUris(uris) { + panel.replaceChildren(h('div.dropzone', {}, [ + icon(ICON.search, { width: 1.7, className: 'spin' }), + h('strong', { text: 'Reading…' }), + ])); + + const sources = []; + const errors = []; + for (const uri of uris) { + try { + const res = await api('/api/accounts/preview', { method: 'POST', body: { uri } }); + sources.push(Object.assign({ uri: uri }, res)); + } catch (e) { errors.push(e.message); } + } + + if (!sources.length) { + panel.replaceChildren( + h('div.alert.err', {}, [ + icon(ICON.alert), + h('span', { text: errors[0] || 'Nothing importable was found in that.' }), + ]), + pastePane(), + ); + return; + } + renderImport(sources, errors); + } + + /** One account gets the detail form; several get a checklist. */ + function renderImport(sources, errors) { + const rows = []; + for (const src of sources) { + for (const acct of src.accounts) rows.push({ src: src, acct: acct }); + } + const skipped = sources.reduce((all, s) => all.concat(s.skipped || []), []); + + if (!rows.length) { + panel.replaceChildren( + h('div.alert.warn', {}, [ + icon(ICON.alert), + h('span', { text: 'No importable accounts in that link.' }), + ]), + skipped.length ? skippedList(skipped) : null, + pastePane(), + ); + return; + } + if (rows.length === 1) return singleForm(rows[0], skipped); + return multiForm(rows, sources, skipped, errors); + } + + function skippedList(skipped) { + return h('div', { style: 'margin-top:14px' }, [ + h('div.small.muted', { + style: 'margin-bottom:6px', + text: skipped.length + ' entr' + (skipped.length === 1 ? 'y was' : 'ies were') + ' skipped:', + }), + h('div.stack-s', {}, skipped.map((s) => + h('div.small.muted', { text: '• ' + s.name + ' — ' + s.reason }))), + ]); + } + + /** Google splits large exports across several QR codes; say so. */ + function partNotice(sources) { + const part = sources.map((s) => s.part).find(Boolean); + if (!part) return null; + return h('div.alert.info', {}, [ + icon(ICON.info), + h('span', { text: part + ' of a Google Authenticator export. Import this one, then upload the remaining QR codes to add the rest.' }), + ]); + } + + /** The familiar single-account confirm screen. */ + function singleForm(row, skipped) { + const src = row.src, acct = row.acct; + const issuer = h('input.input', { value: acct.issuer || '' }); + const label = h('input.input', { value: acct.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 { + const out = await api('/api/accounts/import', { + method: 'POST', + body: { + uri: src.uri, + items: [{ + index: acct.index, + issuer: issuer.value.trim(), + label: label.value.trim(), + push_enabled: push.checked, + }], + }, + }); + dlg.close(); + toast(out.added.length ? 'Account added' : 'That account is already in your vault', + out.added.length ? 'ok' : 'err'); + await reloadAll(); + } catch (e) { + save.disabled = false; save.textContent = 'Add account'; + toast(e.message, 'err'); + } + }); + + panel.replaceChildren(h('div', {}, [ + acct.duplicate + ? h('div.alert.warn', {}, [icon(ICON.alert), + h('span', { text: 'This account is already in your vault. Adding it again creates a second copy.' })]) + : 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: acct.algorithm })]), + h('div.kv', {}, [h('dt', { text: 'Digits' }), h('dd', { text: String(acct.digits) })]), + h('div.kv', {}, [h('dt', { text: 'Period' }), h('dd', { text: acct.period + 's' })]), + ]), + h('label.switch', { style: 'margin:16px 0' }, [ + push, h('span.track'), + h('span.switch-label', { text: 'Stream this code to ' + providerName(state.provider) }), + ]), + save, + skipped.length ? skippedList(skipped) : null, + ])); + } + + /** Multi-account import — where a Google export lands. */ + function multiForm(rows, sources, skipped, errors) { + // Anything already in the vault starts unticked, so re-scanning an export + // you partly imported doesn't quietly create duplicates. + const boxes = rows.map((r) => h('input', { type: 'checkbox', checked: !r.acct.duplicate })); + const push = h('input', { type: 'checkbox', checked: true }); + const go = h('button.btn.btn-primary.btn-block'); + + const selected = () => boxes.reduce((n, b) => n + (b.checked ? 1 : 0), 0); + const sync = () => { + const n = selected(); + go.textContent = n ? 'Add ' + n + ' account' + (n === 1 ? '' : 's') : 'Select accounts to add'; + go.disabled = n === 0; + }; + boxes.forEach((b) => b.addEventListener('change', sync)); + + const list = h('div.import-list', {}, rows.map((r, i) => { + const av = h('div.avatar', { text: initials(r.acct.issuer || r.acct.label) }); + av.setAttribute('style', avatarStyle(r.acct.issuer || r.acct.label || String(i))); + return h('label.import-row', {}, [ + boxes[i], + av, + h('div.meta', {}, [ + h('div.issuer', { text: r.acct.issuer || 'Unnamed' }), + h('div.label', { text: r.acct.label || '—' }), + ]), + h('div.tags', {}, [ + r.acct.duplicate ? h('span.chip.warn', { text: 'already added' }) : null, + r.acct.digits !== 6 ? h('span.chip', { text: r.acct.digits + ' digits' }) : null, + r.acct.algorithm !== 'SHA1' ? h('span.chip', { text: r.acct.algorithm }) : null, + r.acct.period !== 30 ? h('span.chip', { text: r.acct.period + 's' }) : null, + ]), + ]); + })); + + const setAll = (v) => { boxes.forEach((b) => { b.checked = v; }); sync(); }; + + go.addEventListener('click', async () => { + go.disabled = true; + const original = go.textContent; + go.textContent = 'Adding…'; + // Group by source link: each import call re-parses exactly one link + // server-side, so secrets never travel back out to the browser. + const bySource = new Map(); + rows.forEach((r, i) => { + if (!boxes[i].checked) return; + if (!bySource.has(r.src.uri)) bySource.set(r.src.uri, []); + bySource.get(r.src.uri).push({ index: r.acct.index, push_enabled: push.checked }); + }); + let added = 0, dupes = 0; + try { + for (const entry of bySource) { + const out = await api('/api/accounts/import', { + method: 'POST', body: { uri: entry[0], items: entry[1] }, + }); + added += out.added.length; + dupes += out.duplicates.length; + } + dlg.close(); + toast('Added ' + added + ' account' + (added === 1 ? '' : 's') + + (dupes ? ' · ' + dupes + ' already present' : '')); + await reloadAll(); + } catch (e) { + go.disabled = false; go.textContent = original; + toast(e.message, 'err'); + } + }); + + const dupeCount = rows.filter((r) => r.acct.duplicate).length; + + panel.replaceChildren(h('div', {}, [ + partNotice(sources), + h('div.alert.info', {}, [icon(ICON.check), + h('span', { text: 'Found ' + rows.length + ' accounts.' + + (dupeCount ? ' ' + dupeCount + ' already in your vault ' + (dupeCount === 1 ? 'is' : 'are') + ' unticked.' : '') })]), + errors && errors.length + ? h('div.alert.warn', {}, [icon(ICON.alert), + h('span', { text: errors.length + ' code in that image could not be read.' })]) + : null, + h('div.import-head', {}, [ + h('span.small.muted', { text: 'Select accounts to import' }), + h('div', {}, [ + h('button.btn.btn-ghost.btn-sm', { type: 'button', onclick: () => setAll(true), text: 'All' }), + h('button.btn.btn-ghost.btn-sm', { type: 'button', onclick: () => setAll(false), text: 'None' }), + ]), + ]), + list, + h('label.switch', { style: 'margin:16px 0' }, [ + push, h('span.track'), + h('span.switch-label', { text: 'Stream these codes to ' + providerName(state.provider) }), + ]), + go, + skipped.length ? skippedList(skipped) : null, + ])); + sync(); + } +} + +// ── 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('Edit account', [ + a.push_error ? h('div.alert.err', {}, [icon(ICON.alert), h('span', { text: a.push_error })]) : null, + h('div.field', {}, [ + h('label', { text: 'Name' }), issuer, + h('div.hint', { text: 'What this account is called — shown in the list and on the chat tile.' }), + ]), + h('div.field', {}, [ + h('label', { text: 'Account' }), label, + h('div.hint', { text: 'Usually the username or email the code belongs to. Left as-is unless you change it.' }), + ]), + h('label.switch', { style: 'margin:16px 0 4px' }, [ + push, h('span.track'), + h('span.switch-label', { text: 'Stream this code to ' + providerName(state.provider) }), + ]), + h('p.hint', { text: 'When on, this account keeps one live message in your ' + providerName(state.provider) + ' channel, edited in place every time the code rolls. Renaming updates that message within a second or two.' }), + ], [ + h('button.btn.btn-danger', { onclick: async () => { + if (!confirm(`Remove ${a.issuer}? Its secret is deleted permanently and its ' + providerName(state.provider) + ' 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' }), + // Deferred: `dlg` is still in its temporal dead zone while these + // arguments are being built, so `onclick: dlg.close` throws and the whole + // sheet fails to open. Every other handler here is already a closure. + 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 — the ' + providerName(state.provider) + ' tile updates shortly'); await reloadAll(); + } catch (e) { toast(e.message, 'err'); } + }, text: 'Save' }), + ]); +} + +// ── Settings ─────────────────────────────────────────────────────────────── + +function settingsView() { + const nav = h('div.settings-nav', {}, [ + ['delivery', 'Delivery'], ['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 = deliverySettings(); + + return [head, nav, body]; +} + +function deliverySettings() { + 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: 'Delivery' })]), body); + + api('/api/settings').then((s) => { + const fields = providerFields(s); + const modeField = h('div.field'); + const mode = h('select.select'); + for (const [v, lbl] of [ + ['embed', 'Text — code as copyable text (lightest)'], + ['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 })); + modeField.append(h('label', { text: 'Message style' }), mode, + h('div.hint', { text: 'Only Discord can carry the image tile; Slack and Telegram always receive the code as copyable text.' })); + + const picker = providerPicker(s.provider, (id) => { + fields.show(id); + modeField.classList.toggle('hidden', id !== 'discord'); + }); + fields.show(s.provider); + modeField.classList.toggle('hidden', s.provider !== 'discord'); + + 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']); + + const payload = () => Object.assign({ + push_enabled: enabled.checked, provider: picker.get(), push_mode: mode.value, + }, fields.read()); + + save.addEventListener('click', async () => { + save.disabled = true; + try { + const saved = await api('/api/settings', { method: 'PUT', body: payload() }); + state.provider = saved.provider; + toast(saved.provider ? providerName(saved.provider) + ' settings saved' : 'Delivery settings saved'); + await reloadAll(); + } catch (e) { toast(e.message, 'err'); } + save.disabled = false; + }); + test.addEventListener('click', async () => { + test.disabled = true; + try { + // Save first so the test exercises exactly what's on screen. + await api('/api/settings', { method: 'PUT', body: payload() }); + await api('/api/settings/test', { method: 'POST' }); + toast('Test message sent to ' + providerName(picker.get())); + } catch (e) { toast(e.message, 'err'); } + test.disabled = false; + }); + + body.replaceChildren( + h('div.field', {}, [h('label', { text: 'Platform' }), picker.el, + h('div.hint', { text: 'Switching platform (or channel) reposts every tile in the new place; tiles in the old one are left as they were.' })]), + fields.el, + modeField, + h('label.switch', { style: 'margin:4px 0 16px' }, [ + enabled, h('span.track'), + h('span.switch-label', { text: 'Push codes' }), + ]), + 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 channel can read these codes, ' + + 'which is most of the point of a second factor. Use a private channel only you are in, ' + + 'and treat the webhook URL or bot token 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: + 'OpenMFA keeps one live message per account in your Discord, Slack or Telegram 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..cddfa8e --- /dev/null +++ b/crates/webui/src/index.html @@ -0,0 +1,32 @@ + + + + + + + OpenMFA + + + + + + + +
+
+ + + 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..5e2d320 --- /dev/null +++ b/crates/webui/src/logo.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile new file mode 100644 index 0000000..ae678e9 --- /dev/null +++ b/deploy/docker/Dockerfile @@ -0,0 +1,81 @@ +# syntax=docker/dockerfile:1.7 +# +# OpenMFA — 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 openmfa \ + && cp target/x86_64-unknown-linux-musl/release/openmfa /openmfa \ + && ls -l /openmfa + +########## 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 openmfa \ + && adduser -u 10001 -S -G openmfa -h /var/lib/openmfa openmfa \ + && mkdir -p /var/lib/openmfa \ + && chown -R openmfa:openmfa /var/lib/openmfa + +COPY --from=build /openmfa /usr/local/bin/openmfa +COPY deploy/docker/entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/openmfa + +# NOT `USER openmfa` — 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/openmfa + +ENV OPENMFA_WORK_DIR=/var/lib/openmfa \ + OPENMFA_HTTP_ADDR=0.0.0.0:8720 \ + OPENMFA_LOG=info,openmfa=info + +EXPOSE 8720/tcp +VOLUME ["/var/lib/openmfa"] + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -qO- http://127.0.0.1:8720/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..bd6f455 --- /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 + +OPENMFA_UID=${OPENMFA_UID:-10001} +OPENMFA_GID=${OPENMFA_GID:-10001} +DATA_DIR=${OPENMFA_WORK_DIR:-/var/lib/openmfa} + +if [ "$(id -u)" = "0" ]; then + mkdir -p "$DATA_DIR" + chown -R "${OPENMFA_UID}:${OPENMFA_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 "${OPENMFA_UID}:${OPENMFA_GID}" /usr/local/bin/openmfa "$@" +fi + +exec /usr/local/bin/openmfa "$@" diff --git a/deploy/unraid/openmfa.xml b/deploy/unraid/openmfa.xml new file mode 100644 index 0000000..4baebb0 --- /dev/null +++ b/deploy/unraid/openmfa.xml @@ -0,0 +1,79 @@ + + + + OpenMFA + gitea.milesward.dev/mward4/openmfa:latest + https://gitea.milesward.dev/mward4/-/packages/container/openmfa + bridge + + sh + false + https://gitea.milesward.dev/mward4/OpenMFA/issues + https://gitea.milesward.dev/mward4/OpenMFA + + Self-hosted TOTP authenticator with live delivery to Discord, Slack or + Telegram. + + Add accounts by uploading a QR code, scanning with the camera, pasting an + otpauth:// link, or importing a Google Authenticator export (one export QR + carries many accounts; you get a checklist, and duplicates are detected). + 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 chosen + channel, rewritten in place every time the code rolls. Three accounts means + three live tiles that keep themselves current. Codes are posted as copyable + text; on Discord a rendered WebP tile is attached as well. + + Pick the platform during first-run setup or later in Settings. Discord + needs only a webhook URL. Slack needs a bot token (its webhooks can't edit + messages). Telegram needs a bot token, and the app detects your chat ID for + you. + + SECURITY NOTE: anyone who can read that channel can read these codes, which + is most of the point of a second factor. Use a private channel only you are + in, and treat the webhook URL or bot token as a secret. TOTP secrets are + encrypted at rest with XChaCha20-Poly1305. + + Tools:Utilities Security: + http://[IP]:[PORT:8720]/ + + https://gitea.milesward.dev/mward4/OpenMFA/raw/branch/main/docs/brand/icon-256.png + + + + + + + + One of: a Discord incoming-webhook URL; a Slack app with the chat:write + bot scope plus a channel ID; or a Telegram bot token from @BotFather. All + can be added after install from Settings → Delivery. + + 8720 + /mnt/user/appdata/openmfa + + info,openmfa=info + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7c97b9f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,34 @@ +# OpenMFA — single-container deployment. +# +# docker compose up -d +# +# Then open http://:8720/ and complete first-run setup. +# +# No special networking: outbound HTTPS to Discord, Slack or Telegram, and +# one served HTTP port. Bridge mode is fine. + +services: + openmfa: + image: gitea.milesward.dev/mward4/openmfa:latest + container_name: openmfa + restart: unless-stopped + ports: + - "8720:8720" + environment: + OPENMFA_HTTP_ADDR: "0.0.0.0:8720" + OPENMFA_LOG: "info,openmfa=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). + # OPENMFA_MASTER_KEY: "…" + volumes: + # Vault, key, admin account and settings. Back this up — losing it + # loses every stored TOTP secret. + - ./data/work:/var/lib/openmfa + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8720/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..5ae8a34 --- /dev/null +++ b/docs/PUBLISHING.md @@ -0,0 +1,74 @@ +# 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":"OpenMFA","description":"Self-hosted TOTP authenticator with live delivery to Discord, Slack or Telegram. Container-native Rust.","private":false,"auto_init":false}' +``` + +The repo is public so Unraid can pull the image anonymously without registry +credentials; the Gitea instance itself is not exposed beyond the LAN. + +## 2. Push the code and the tag + +```bash +cd "/Users/casperadmin/OpenMFA" && 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/openmfa:0.1` and `:latest`. + +```bash +printf '%s' "$GITEA_TOKEN" | docker login gitea.milesward.dev --username mward4 --password-stdin && docker push gitea.milesward.dev/mward4/openmfa:0.1 && docker push gitea.milesward.dev/mward4/openmfa: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/openmfa: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/openmfa` to `/mnt/user/appdata/openmfa`, +publish port `8720`, and leave networking on bridge. + +Or import `deploy/unraid/openmfa.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/docs/TEAMS.md b/docs/TEAMS.md new file mode 100644 index 0000000..a4ee25b --- /dev/null +++ b/docs/TEAMS.md @@ -0,0 +1,176 @@ +# Microsoft Teams — exploration for v0.2 + +**Short version:** Teams is meaningfully harder than Discord, Slack and Telegram, +and the reason is structural rather than incidental. Every path that lets an +outsider *post* into Teams without a bot has been retired or cannot edit. +Live tiles in Teams therefore require a **Bot Framework bot**, which means an +Azure Bot registration, an app manifest, and — for a self-hosted homelab — an +inbound HTTPS endpoint or a workaround for not having one. This document lays +out the options and recommends one. + +--- + +## 1. What no longer works + +### Office 365 Connectors / "Incoming Webhook" + +The classic Teams incoming webhook was an Office 365 Connector. Microsoft +retired the connector platform: URLs had to be migrated by January 2025, the +service was disabled for Teams in **May 2026**, and it is now gone. Even when it +worked, a connector could only *post* a message; there was no edit call. So it +was never a live-tile candidate, and today it isn't a candidate for anything. + +### Power Automate "Workflows" webhooks + +Microsoft's designated replacement. A Workflows-app webhook accepts a POST and +runs a flow that posts to a channel (Adaptive Cards included, private channels +supported since April 2026). It is fine for notifications. It cannot edit a +message it posted earlier — the flow's "Post card in a chat or channel" action +creates; there is a separate "Update an adaptive card" action, but it requires +the flow to have posted the card *and* to still hold its state, and it is +driven by the flow's own triggers, not by an external caller saying "edit +message X". Chaining that into a 30-second edit loop from outside is not a +supported shape and would be fragile even where it half-works. + +**Verdict:** Workflows can be a v0.2 *notification* target (post-only, one +message per roll — noisy), not a live-tile target. + +### Microsoft Graph + +`PATCH /teams/{team}/channels/{channel}/messages/{id}` exists. But `chatMessage` +update is **delegated-permission only** — an application (daemon) token cannot +call it. A self-hosted service has no signed-in user to delegate from without a +full interactive OAuth flow and refresh-token management, and Graph would also +require the message to have been *posted* by that same user. Not a fit for an +unattended service. + +--- + +## 2. What does work: a Bot Framework bot + +A bot registered in Azure Bot Service can post an activity to a channel and +later call `updateActivity` on it — the Teams equivalent of `chat.update`. This +is exactly the primitive OpenMFA needs, and it is the only supported one. + +What it involves: + +1. **Azure Bot registration** (free tier). Yields a Microsoft App ID and a + client secret (or certificate). No Azure spend is required for the bot + resource itself. +2. **Teams app manifest** declaring the bot, sideloaded into the tenant or + installed to a team. The tenant admin must permit custom app upload. +3. **A messaging endpoint.** Teams sends the bot an HTTPS POST when the bot is + installed, added to a team, or messaged. The bot needs this at least *once* + to learn the `conversation.id` and `serviceUrl` for the target channel — + after that, OpenMFA can post and edit proactively using only stored values + plus a token from the Bot Framework OAuth endpoint. +4. **Proactive messaging.** With a stored conversation reference, the bot + authenticates to `https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token` + with its App ID and secret, then calls: + - `POST {serviceUrl}/v3/conversations/{conversationId}/activities` → create, + returns `{ id }` + - `PUT {serviceUrl}/v3/conversations/{conversationId}/activities/{activityId}` → **update** + - `DELETE …/activities/{activityId}` → delete + + That maps one-to-one onto the `Sink` trait already in `crates/delivery`: + `create` / `edit` / `delete`. The engine would not change. + +5. **Message format.** An **Adaptive Card** with the code in a monospace + `TextBlock` (`fontType: Monospace`) and a `Copy` action is the natural tile. + Adaptive Cards don't have a live-ticking timestamp like Discord's ``, + but they do support `{{DATE()}}` / `{{TIME()}}` formatting functions that + render in the viewer's locale — the Slack-equivalent "renders once, in local + time" behaviour. + +### Rate limits + +Bot Framework in Teams allows roughly **1 message per second per bot per +conversation**, with a burst allowance, and Teams throttles harder on channel +messages than on 1:1 chats. Editing counts. With one edit per account per +30 seconds, a channel would comfortably carry **~15–20 accounts**; the same +one-second governor used for Slack and Telegram fits. + +### The self-hosting problem, and three answers + +The messaging endpoint is the sticking point for a homelab. Teams must be able +to reach the bot over public HTTPS, at least during installation. Options, in +order of preference: + +**A. Cloudflare Tunnel (recommended).** Miles already fronts Gitea with +Cloudflare. A named tunnel exposing `/api/teams/messages` on the OpenMFA +container gives Teams a stable public HTTPS URL with no port forwarding and no +certificate management. The endpoint only needs to validate the Bot Framework +JWT on inbound requests and store the conversation reference — a few dozen +lines with `jsonwebtoken` plus the Microsoft OpenID metadata for key discovery. +This keeps OpenMFA fully self-hosted. + +**B. One-time capture, then close the door.** Because the conversation +reference is only *learned* via the inbound endpoint, the tunnel could be +opened once during setup, the reference stored in `settings.json`, and the +tunnel torn down. Proactive posting and editing need only outbound HTTPS. +Downside: if the bot is removed and re-added, or the team is recreated, the +reference goes stale and the door has to be reopened. Workable for a +single-user homelab; clunky as a product default. + +**C. Manual reference entry.** The `serviceUrl` for a tenant is stable +(`https://smba.trafficmanager.net//`) and the channel conversation id +is visible in a Teams channel link (`19:…@thread.tacv2`). In principle an +operator could paste both. In practice the id format has changed across Teams +generations and proactive posting to a conversation the bot has never been +installed into is rejected, so this only works after installation anyway. Not +recommended as the primary path, but useful as an override field. + +--- + +## 3. Recommended v0.2 scope + +1. **`crates/delivery/src/teams.rs`** — `TeamsClient` with token acquisition + (client-credentials against `botframework.com`), `create` / `update` / + `delete` activities, Adaptive Card builder, and error classification + (`ConversationNotFound` → repost; 401/403 on token → fatal; 429 → back off + using `Retry-After`). +2. **`Provider::Teams`** in `core::settings` with `TeamsConfig { app_id, + app_secret, tenant_id, conversation_id, service_url }`. Validation on the + GUID shapes; `destination_key` on `conversation_id`. +3. **Inbound endpoint** `POST /api/teams/messages`, unauthenticated by session + but **gated by Bot Framework JWT validation** (issuer, audience = App ID, + signature via Microsoft's JWKS). Handles `conversationUpdate` (bot added → + store reference) and ignores everything else. This is the only public + surface OpenMFA would ever expose and it must accept nothing but a valid + Microsoft-signed token. +4. **Settings UI** — a fourth segment in the picker with a guided flow: paste + App ID + secret → OpenMFA shows the public endpoint URL to put in the Azure + Bot's *Messaging endpoint* → generate a downloadable Teams app manifest + (`manifest.json` + icons, zipped) → "install it to your team, then click + Detect" → the stored conversation appears, exactly like Telegram's chat + detection. +5. **Tunnel documentation** — a `cloudflared` sidecar example in + `docker-compose.yml` and an Unraid note. + +Effort is roughly two to three times the Slack integration: the API surface is +similar, but JWT validation, manifest generation and the inbound endpoint are +new machinery. Nothing in it changes the engine, the vault, or the other three +platforms. + +--- + +## 4. What I would *not* do + +- Don't ship Teams via Workflows webhooks as if it were live. Posting a new + message every 30 seconds fills a channel with 2,880 messages a day per + account. If a post-only Teams mode is wanted for notifications, it should be + a separate, clearly labelled "announce" mode — not the default and not called + live. +- Don't attempt Graph delegated auth. It would make OpenMFA hold a *user's* + refresh token for their Microsoft account to edit chat messages, which is a + larger credential than a bot secret and a worse thing to leak. + +--- + +## Sources + +- Microsoft 365 Developer Blog — *Retirement of Office 365 connectors within Microsoft Teams* +- Microsoft Learn — *Create an Incoming Webhook* (Teams), with the Workflows migration notice +- Microsoft Learn — Bot Framework REST API: *Update Activity*, *Send to Conversation*, *Proactive messages in Teams* +- Microsoft Learn — Teams bot rate limiting +- Microsoft Graph — `chatMessage: update` (delegated permissions only) diff --git a/docs/brand/icon-128.png b/docs/brand/icon-128.png new file mode 100644 index 0000000..2c70e7a Binary files /dev/null and b/docs/brand/icon-128.png differ diff --git a/docs/brand/icon-256.png b/docs/brand/icon-256.png new file mode 100644 index 0000000..c5fe880 Binary files /dev/null and b/docs/brand/icon-256.png differ diff --git a/docs/brand/icon-512.png b/docs/brand/icon-512.png new file mode 100644 index 0000000..da44400 Binary files /dev/null and b/docs/brand/icon-512.png differ diff --git a/docs/brand/icon.svg b/docs/brand/icon.svg new file mode 100644 index 0000000..5e2d320 --- /dev/null +++ b/docs/brand/icon.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + 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..4e9f28c --- /dev/null +++ b/scripts/build-and-publish-unraid.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# build-and-publish-unraid.sh — build the OpenMFA 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: OpenMFA +# IMAGE_NAME default: openmfa +# 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/openmfa-build (removed on exit) +# +# After this, set the Unraid template's Repository to: +# //openmfa: (or :latest) + +set -euo pipefail + +GITEA_HOST=${GITEA_HOST:-gitea.milesward.dev} +GITEA_OWNER=${GITEA_OWNER:-mward4} +GITEA_REPO=${GITEA_REPO:-OpenMFA} +IMAGE_NAME=${IMAGE_NAME:-openmfa} +IMAGE_TAG=${IMAGE_TAG:-0.1.0} +PLATFORM=${PLATFORM:-linux/amd64} +GIT_REF=${GIT_REF:-v0.1} +WORKDIR=${WORKDIR:-/tmp/openmfa-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