From ad3720edbf24f2dd05fd1b99ea7b19e74a52144b Mon Sep 17 00:00:00 2001 From: Miles Ward Date: Wed, 9 Sep 2026 00:21:18 -0400 Subject: [PATCH] =?UTF-8?q?v0.1:=20OpenMFA=20=E2=80=94=20live=20TOTP=20del?= =?UTF-8?q?ivery=20to=20Discord,=20Slack=20or=20Telegram?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .dockerignore | 27 + .gitignore | 15 + Cargo.lock | 2791 +++++++++++++++++ Cargo.toml | 115 + README.md | 347 ++ crates/core/Cargo.toml | 37 + crates/core/src/auth.rs | 347 ++ crates/core/src/crypto.rs | 256 ++ crates/core/src/error.rs | 64 + crates/core/src/lib.rs | 66 + crates/core/src/migration.rs | 733 +++++ crates/core/src/settings.rs | 727 +++++ crates/core/src/totp.rs | 514 +++ crates/core/src/vault.rs | 910 ++++++ crates/delivery/Cargo.toml | 28 + crates/delivery/src/discord.rs | 312 ++ crates/delivery/src/error.rs | 35 + crates/delivery/src/lib.rs | 276 ++ crates/delivery/src/message.rs | 381 +++ crates/delivery/src/pacing.rs | 60 + crates/delivery/src/sink.rs | 156 + crates/delivery/src/slack.rs | 283 ++ crates/delivery/src/telegram.rs | 403 +++ crates/delivery/tests/live_push.rs | 805 +++++ crates/http-api/Cargo.toml | 32 + .../http-api/examples/make-google-export.rs | 83 + crates/http-api/examples/make-qr.rs | 14 + crates/http-api/src/app.rs | 462 +++ crates/http-api/src/auth.rs | 420 +++ crates/http-api/src/lib.rs | 53 + crates/http-api/src/qr.rs | 443 +++ crates/http-api/src/state.rs | 27 + crates/openmfa/Cargo.toml | 27 + crates/openmfa/src/main.rs | 126 + crates/render/Cargo.toml | 20 + crates/render/assets/JetBrainsMono-Bold.ttf | Bin 0 -> 277828 bytes crates/render/assets/JetBrainsMono-OFL.txt | 93 + .../render/assets/JetBrainsMono-Regular.ttf | Bin 0 -> 273900 bytes crates/render/examples/brand.rs | 15 + crates/render/examples/preview.rs | 25 + crates/render/src/lib.rs | 406 +++ crates/webui/Cargo.toml | 14 + crates/webui/src/app.css | 818 +++++ crates/webui/src/app.js | 1426 +++++++++ crates/webui/src/index.html | 32 + crates/webui/src/lib.rs | 38 + crates/webui/src/logo.svg | 25 + deploy/docker/Dockerfile | 81 + deploy/docker/entrypoint.sh | 31 + deploy/unraid/openmfa.xml | 79 + docker-compose.yml | 34 + docs/PUBLISHING.md | 74 + docs/TEAMS.md | 176 ++ docs/brand/icon-128.png | Bin 0 -> 11282 bytes docs/brand/icon-256.png | Bin 0 -> 24785 bytes docs/brand/icon-512.png | Bin 0 -> 53501 bytes docs/brand/icon.svg | 25 + rust-toolchain.toml | 3 + scripts/build-and-publish-unraid.sh | 103 + 59 files changed, 14893 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 crates/core/Cargo.toml create mode 100644 crates/core/src/auth.rs create mode 100644 crates/core/src/crypto.rs create mode 100644 crates/core/src/error.rs create mode 100644 crates/core/src/lib.rs create mode 100644 crates/core/src/migration.rs create mode 100644 crates/core/src/settings.rs create mode 100644 crates/core/src/totp.rs create mode 100644 crates/core/src/vault.rs create mode 100644 crates/delivery/Cargo.toml create mode 100644 crates/delivery/src/discord.rs create mode 100644 crates/delivery/src/error.rs create mode 100644 crates/delivery/src/lib.rs create mode 100644 crates/delivery/src/message.rs create mode 100644 crates/delivery/src/pacing.rs create mode 100644 crates/delivery/src/sink.rs create mode 100644 crates/delivery/src/slack.rs create mode 100644 crates/delivery/src/telegram.rs create mode 100644 crates/delivery/tests/live_push.rs create mode 100644 crates/http-api/Cargo.toml create mode 100644 crates/http-api/examples/make-google-export.rs create mode 100644 crates/http-api/examples/make-qr.rs create mode 100644 crates/http-api/src/app.rs create mode 100644 crates/http-api/src/auth.rs create mode 100644 crates/http-api/src/lib.rs create mode 100644 crates/http-api/src/qr.rs create mode 100644 crates/http-api/src/state.rs create mode 100644 crates/openmfa/Cargo.toml create mode 100644 crates/openmfa/src/main.rs create mode 100644 crates/render/Cargo.toml create mode 100644 crates/render/assets/JetBrainsMono-Bold.ttf create mode 100644 crates/render/assets/JetBrainsMono-OFL.txt create mode 100644 crates/render/assets/JetBrainsMono-Regular.ttf create mode 100644 crates/render/examples/brand.rs create mode 100644 crates/render/examples/preview.rs create mode 100644 crates/render/src/lib.rs create mode 100644 crates/webui/Cargo.toml create mode 100644 crates/webui/src/app.css create mode 100644 crates/webui/src/app.js create mode 100644 crates/webui/src/index.html create mode 100644 crates/webui/src/lib.rs create mode 100644 crates/webui/src/logo.svg create mode 100644 deploy/docker/Dockerfile create mode 100755 deploy/docker/entrypoint.sh create mode 100644 deploy/unraid/openmfa.xml create mode 100644 docker-compose.yml create mode 100644 docs/PUBLISHING.md create mode 100644 docs/TEAMS.md create mode 100644 docs/brand/icon-128.png create mode 100644 docs/brand/icon-256.png create mode 100644 docs/brand/icon-512.png create mode 100644 docs/brand/icon.svg create mode 100644 rust-toolchain.toml create mode 100644 scripts/build-and-publish-unraid.sh 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 0000000000000000000000000000000000000000..8c93043de6454ad2d5575f0751150c6551d9c588 GIT binary patch literal 277828 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

^Q2@p_nJptvCyhdNtfUDv9XvP^J$Tq^Yf}I z@@opbHfveEGpz1a^zA|AGxJUMeQUVL-5D;~x<4Ik?KA7Gr9E|b}8{ES>(x&r`Jsd84{T%PSn4j6idC323^TgQ&Rm9C)v`@`3$U#6= zqI#*s4p$8cT8Ca4oXVMrY}>sK?Z3V3x%gkPqWHhDPWi^di^qPjK)Au*&0$c*VepVV z$6=G+{(fxGYW*Kwqy3Sj_6ImW6z~5_tsQ(XTOYUD^bu;u9wa{(OM33h+H-)#hNSkF zlG?MA+P{+0PJCC4|3FGRoe9zYpw>?F;`~^&^LecTr}xRblHfu7SoD8fdk^iILi|{? ze~{W~FTs!Vu$LvojrF}WVdduOv@Nw-H2#I&CB%v857C(9ZS|#c<)mG1RT=Vz?dpCf z_%Nn|xr8a^3_PJx8jTH^nbANz3%3r+hRc!}cTAZ-4v za)W^RV=Bx^5ibPHtx|2wuftqFKe*4z&`xEp(7{R|Hd#v&AhvI1UyDD>TH`OvH_m?L zZ2V>P1MD^dc13`lPwI(5UdLUzGO-+RR8tDHh@pbY35Mz^bC>EY)yI4m4OVNg)G%lr z2?DHTC0o7y&2+vR3C6!nEO*<9FM_oK9(<1cjJ0FV37I~d>1>Ji2f6&lxSRy-0~Ct54M@Mj3^Ou-(>7u<$|}uIqhFx)_|J#` zMwY(1wDeW^M*N4YX4%jB=yF@ZQ%3R5e91|Y4Jkz#CNvXQu|e&!S!N83I7_BgHk;ec z*k&hWEcDN(t>%BStRBHv0PP_CIA}bd-5w14M%Jyvxb*9*UzM}=+=4%QmVZT~vrq2_ zj7j#(;4qGH7<1TQZJLglK{y9}kO3EI8en&l5i;Pa)Eb=47lvGF4?6Ze@(6qKkw+HU zZx$EhmPHO%{!TvDx|ID)Z`bxSyXcsyPV0E*G>(eDtDAtNIM6_@P$lit;CV?%D8mdtIW1SLe z@Jx_GS|1K{9QST&^Ah?7lge(s&gYdQ)yHb!o0b(OUxt(fF-4Z8N!m&-Cue3@AaP1L zEXODgG-P2e5(9G!Of384olD)3aL>}6whOb7-kyl8EHBUAhAqU{7(>3w_Y_tF&i{Ui zzag&!uiAmvnFu+oGc(u`hSxkXnNp@b511@CKF&*iP|5cxKKt;}?)&a}@=x!3-!2@R zAF#UklkxYkb@4A^++mzuydP`c#>f4bdPXrV-0^usGD@CUDZas4fx8n}Il2>#G)hLh z-C!-ki6J(0+Y*bwpzzot-g$za9Y#oepFJhR9;VzXMzU|YDnmW(8;Ogc--ayZ1K&EY7H zSJYvKV06dv<4f;*AB!+w_nB_C{;ln2w!ev^M(>Z{{S}<9G{x$3G8M?icr~m$P?j%) zb&vtZfTUsp6i|~wY=K}L!F*CCiKItyRWI};d*l<_cRYE{Y%|TwOj~oX7hhu?AYsAY zd+rFuA7V#0eCb5|+hW{J7&jN=+NH{vH&X$maDgx%4hz(VvrBe+q20*E;b4A~93~Q} zK%cO0difXM)GX5bBUp@xkCt!XuU!bv*+7t9Y zm*#|h68$fzxUvax>onIAl1p<;Mj#0rCQRW-(+<)o877Ilc607pA{7_fi@XFRp(ODc zgV?DnAeG|vo`%4ynEPq5j`pMLuHV{HFSm%<19CRLB6+#9YhRFXMXZI+u9%xn$9Dc0t2=i7{9OF|$IdTAgRDo>Z)t8c2M*7PR5+)#YxFw3zecaAUD&R~doS@Bs_bckVkiNl zINdFBGRfn1Ru)xeWn%XVS)pp7AOl4(;EaU1@MKo`U{RlroU`)z`rLHJ)LHAro-u%y7Mqe#=FX$$iQc!~gPFB>iQFsZuA(@@^v zcI9LNm}RU?l}#bPN6oPJB~FCdY*OujO6u7-(XpZ0V-^P_*jQ6sSW?vYG4-7Mu%of4 zt%#kQWHWS5a-wx95||D<2CFD}~Ns*TuQr9q!qUj*D|!+p5-m z$<+~z9t%dFchy{N>7%9A1THqV$G<#%&etc0;=f^AE28nLXz$x5#>* z>ZBG6p3=TH;{&BT@rn{LA1Qw(D1TNQJ2nZ+Ff4=_7MT=4vkfd9!Vdrj?Pkj+78Dw_O$)@wrY z3Y|d`^hwpg4-C-Rax~>MeRkuPEgLJFi)AA#dg{RkpNjvRQ7&#adi{PceoV9b+2Q@O z?%IN_$6q?}{S!M2>Pn9_*RjKO%`LU@57#vVmt?M+kc^ZGye0`2A`C>>89FUwjDQ5A zNp+6=oGf(9^n*Kd$&Sps#E(e{!o0}FH}b1NvhfYxa!a_UCwz;1qwUE0^+(!ZPr5r6 z48|}X#6vEV(Rh#uaQ0wQfHn-T6--vJ>5`(S+4i&{E4`3g$*u>zi(9{aK8HT$=+fP@ zx7{{-_pOo6&d9Cujr&hcPM-QCD~zk z?~H$oRmA^^y{{t}>;jz`!6X_voms+s61IT!c1`9RfozWXg8qp9n#|V@UhM(A>$o4m zU?i*2W#AQ1=@OQqOyXSPaFUFr`w_}YONt8%tY)J@s$+G!A3;@;LjG!aD#2%xeF_DI zPNzUY*1Y*-_r-N>k=|PW)RqGmj&7J~yU@|n4FA)SZ3jC~*g})*;0I}`jM|C|7KRU= z4D9c&3k7Q{8u0o2j=l*v8L)mnZ@&IJQ`Vo}E^NRI{cHF-J5?Wp5=!wg7)>%v7MIEJ zBP%O>3?`Fd5;(|-RI-m@wWpGN468jRd<>mZM`=ZVeMP}4K8D{7jGDXQuerOSc-QpW zAU!sa?01OeEbM-J_^J*FfOQo(Ycgy*@CkSWd=l{2@M#cuS&eg1PN%oTVpbG77a$i< zcVK0QjO#>SjJytXg!WbMdx4$701L;K^m~;_{MmaigX)l z*h{1rx`$6!NVrB4_BjezeXCbzLE<2d_mBNnFG*^)f2R`R%#=LQx{mF?MoarG*_`|4HZnEUzS z;?L*eKL`HsIr4M*UW}*tWH2tyBh9^-?5%*ei4B`E_z+1baP*O(8kXznq*dW-Q@aT~ z3C|Q4cxzxFu~@S1O30ok4f!&Qwy|lTAep$8Wn73bckVowuDkBHNbV z2z7LXWb5+lN6rB@{QVp@4{+FMF&gRZEA>gyUptGUKk0L#JyoBhb&2+59Y*MxqJ5=4 zC)$NRCv4+ql}&&)Lk$C;?T8BRiS~oH5_g zNK-0+d_yD6J?8k}k#m|(be6;E18H#b$e0(_C)$(s)#U!k>pRPJG|^wPV-oz%a(kg@ z=d!uFKSD7Dzb0D+{hd(8l5`J>%MkO`;xYu@kWM1n zH9M&fbcJG-GKsF3Si)qLuo^*Of9FYSj=_*)J$}dYFz!_P zwiOj^t7Jb~_7kni#(Ov8y=CMtD9JH#8yrZmYJAg(R+&_0FDeqoH~w}9e|usWpCTfm zlzqdqO;8@qO6+)-VpR~O(Kxg9-Zt=X*`&bY7?(@7m6mQRVe!PjU^|oCJ6x_E?l|EV zpA*NeKS;r?^mgs6iFP_`qQ7?5())9q!-NFukwxG&pt19?x z{G;)EzL8&@k>M}+2F7+`Y>E}imVB{t46P)FPV6K^(xNdG4!|tgR*rm5?uoLYBJJDZ*ofCF1%u{dI&T;yC zv~w)l$<`v?qn%^XPPP-#&d;SjPr`H2uHiW>^s*K3v69UhcuwHKLR2maaX>-5pN5t2 zCFa@14uzQ)T#tDjTUb^6r|hZt4c2;=y|#EZp0P-9;qT>e`HK{|q_=Cbxo9Upjp(oW z*K#qxO_*P?1e2 zN4(&>?5Xek-M!!bhj_sfyPX{(*9Zkh(teQcBYY#D=W`dn5t0eX-c2&0pQMmH*#Dty zLZj8CRW>Q?1wf4nVsYKlC`ou;PqDwm?{s9^NV?MqIC;mD%xWUGE=}pYI=EwKH(8t~ zce;u${8+W7T_4%tv7g?nS)LD_%_=B%9brW&hBR5~0H4Z`TYwLh(v@eO%w%S&-3+z> z5@orBYBn*mF3a|fSRppS#%fXRJ77#}c7?vzuJ%_00>)x4R|%8K%StL;m4yx=)0&ME zn7d82e?X88hC`5P%|0GR=73meQiWOwx&?**xpQ)1;_k_YkaFH|ZhG$Q1@mrqGg$zL{{>(MV%oQzez1r+wq=RqVi_FYC+D}pZlivQ2 z-v7GZ?txENdr!6LJhej>UF*4L_2&R@rM^_JySO$CWGk zd!Epl>WZYC$(oNbQT31zmU?Nr?=1Q?HC`CBwBwCFCD&# z^&OMp|I^!h^!{h{_Yl6mrnjG=cIaXqkp26C$2C$=`g7I7>1P(JgGtreq_gl*)M_Tt3Q!u_QO*!`PJ1jskyN>SQo6S zEH87rbd;}QHJZt9CCUdi6S@eceTNeuSCqn(KGCsZd*xbZp{zDn4Y5r=6*{a(b(k(D z!&-6Z?WZ}c70C^n(FRyI!UNhT{YlK`fEZBE$TB?iOgZB$uN zPC+#J_MGfY8%PU6j&f5-OOn0}jz%^`*jV)tRRA18@%bP@ydROtz!d^9>pimhTVbM+5p?W$y;6RjlWp!J=$DT-NNyn!kfj@3;A6z}oxGycv7*WuLPy;g&XADER zZXZ-b$DBSt-zu?(Q5+o>9+>NZF=u8;xu|qDk)5@<1OHs2IOBT}`*i$3eRDK;U$CvY z{=jj780Ub`i^KjORM_K-RoYL=llY$2K{3XN?bTw8Yf|igzf+7c*7<)EW1RAsu>Unn zO?ikh&RZ?Ucug?*G@IYwkU1k%JhE>!n9VR7Y#y7sM3iyAeSXIZ2=tPi;Y6Pb!1Tk|Q%7&qnJ)wg=EgQ0|%v;s*k(rJ6 zSAAqAh6tmRL(z5XqHX>ChK|_{{fDD1PE(|1G&Wv$DA+q*yJz$LeF)P&w!Xb>{ra|c z_@Utu3SfU`Kzjzg!ZhXH)UtK)( zloUI_wuM0KD00A0rn<-qSV4wL$JKocijDJuT*C*;h*Imj_x+*0dxyg7&5GgHv$A>p ztJ%j3r}(Rm?gELv-5;iJ^9leyhp}Wp0LRncQ_{b!o1_QdX+T z35j)+rBsbW3}gMY{fV_N@0gm}(LHQY439s)G&*`gwhY@_LZOzHV9>TWIW~E^J91a! z&Rx59F8|$JwChBBa5UK778(t;5e{#}exp((bb<;Vxk{O92IVruBb-bu7}S+?7|9~ryb$ysPXo! z3xECNws5q~7MWi6o^{g^*sBlw+uB_v1H0H1ZenQf=_Pu?=gH~yPgHshUb#y9DK!ov zz5mHI+82`AALTN!c>nWyyGPE`$0gYxaTbvMlb;(%dhT=jbFa(YT$UE^`+QP+9+#y> z`xjE$iC&8K*)`hd^mfdP%h;knpVunzI3|xK!HHyS(f_Re9@>ABu|@kCYUk&H;ze4Z z*)~gqv5k@uIy9Np&5~j?Biq1ag)c)oELkid?6S;RIL8!jp=@fXt*P=+vWBA|Hycu! zpZQ7QGxAI^?psYklwg4PIEB}Mua#oMT40YV zm)6B%x@el_t?7d+BgwB*jLQbxrA6h%<+<6J88!=CHBRPCan<-LRHYWj0p4?dY)STH z^&5`1u`TmEvv1G7ZLe5fH2i}QIcsSC1H07qz*XbG{6ThFu_;BMPx%zRhUiaN6Tn0$ zmrRX%NVqpBlsVrEPrc^efN%>X8)>Y#L-ED1ml$x2jteK)y6%NFEu24~qDps#fKI3b8mg2Vx;ll4TIsfKa~);&ll1 zbHyqXT@nr4Ygo zeyE{Ewhu*iPEYTQ4B0Q7na<>VJQTV<+g#(_-4tA$JU($c*t~c7r+bdDSbK0H*dA@2 zZH)^2AbXC`^It=Q*>s%p1%Nl{?Wg2jtG1t9qkSQz{S@#|y#JPzb~^i_eU91zQu4)T zabC>#oQz9ohxiYxn;^JodbFU9uqV_L=KE>=w^p zMsgEs4f`2AZZ^%y$MCm^S8l?SRuS9JIF@#fDClMwUp>P@gl!}4WB7%qSk<2_vh|Dc zA&MgwW58~pj&WQ*%i*8ieu~CkrT@t_+80vV6Kj}Vqn)pf-Vb|oV$Id+n$6%1tB-$7 zo=BP(Xt27Dv&sF52CMC7s2yi<7&-^WK?X#nbjQ#$S+v@<`0*3}H@<~`l@U}874mzky`%tptCugS8=DBZPwW|JvkHUL8fzp|vNSOo+W6~FMEFuHdu;CuKs6Qq#bA|k?=(kX~;YtWgP{#pNG)s_2~Y_mYjC<@50$k|82uJmJrK6*>X@9Jw}YBDNlK{AP4%8$m3( z7K#Bvq{i0g>uL*PlvJ0S@0*WaYQ9qV$SvQ$a>9yu_6+O1Y~MWId#-nIykq%UT*B3v zm)BVue^HwchliNYyo?i1?SK>3ug~*>e1f-|0hjv$7t9|edypr9ZA#A*&|v4FwKynI zKqjl3UW@p)%JV?cu+JP<)I3cC%{D%&WaA702Cj55O92Y`}Dt@>G{s$|7<(6 z{W(B$YAZ5~33knxQ!V(ZSK9rY4Wcy$5?r;=#7#Hcec3r;F&mLmfB{{x5`nYRsW)S3*OTLd1Lrluyn8y1?G2oD+E=$~j_Cz?Jl8HJdv5bh1c(rf0RYhINnP{AXgn+|u~7b|`(? zx=V4+lu2^uL4HBr1>P$eCn;~M+lWmo$J+4)V+IQJg8Y--Wh_SY#4VLp+ViUr!ROK_ zlFFVb$4hLZ$ff>ucS9i5)D&7Gb*_8cMEen*`M`Pt5n|V}e=b6B`cq1KNUW3Sr&y1$ z-;#d!2ITu@(2A72hd$0MMBW49RoBXUP)R<7*Ho30H5|MB_G9!Dh(-hWvEF**^3hvv zIePiXt@F{)?}eiL7vRsw=X+P7?wzK$VO~k?3B4_~J)yUywkPzjH)O82G4cKtdK+(F zO>cWc=6aj@TtaVqL+EX4dqQuc@!lwUg{1#!u*Nq@OkG?Fs!XbsY))jM_;y z5bxponI!fvh{!jmR3$Awo5u@Dh?D3=WUF!&hz7hf8r6fBFfZ}gW#sVlKB^&V^=_~T zlP*(UR#a77ML}n!oJ zL(Z!~sxB#pCaxxlQ5Pxo!T7b3@U}=+ng&;f|Bx z4)$jyWecIrSZis=naJ>c)8{2?*D*>-MZrj0v`D|2%zi+67P@Ub$VyQF_qd>*UtFq5akR~CP> z%=eou^e@n*5%5Vn;Fk|^WcE3Gs*j4SBtk?WdL)y9sFz3jLn@~v91|*0CkWa6~oZUzcwj z33Z0Xd*45Pwkz_UCs<*Uj|z29Ztj+mg~|PGC!Vt-dV~AhT7d;LSMG01qOYlQrS+uH z)ugXQ5WhmFqW~hT@VQ}_ysAolJqceMt;e3aoS`K{&X)G=Q`6hpM{{P?)jX|S(VcA_ zq4@t{nMtdGNCY1X>=^pq}90t-ypo7MP9a-S7@lV4Rudt*d!b-INn zji^0fV7a5@IowKa#Gh)L^Tbk6cn;x5M7&U;aI%CR4uwFLK#=4rzLtF{a%c(VkO1p0 zC(ENiHWY4f#eksdmmJd)VyWoxHd3j>r`px+lNW4TdUqaL3bh16vj2Fb_rzqVDG=_8 z#{Zn?4WAdMH!(nm=*=yh-lVsmRCSXQ$eUOPc#-ITfl(|4lls5|osiG!r4I;yI#Q=7 zi2_mOP^nGLB;1VS$4wEEe?1Ds9Cb}l4Tcq`cipbwc!96Ky8L9n{uLQTS2dNp( z0xPAj*XyYnJbhh+MQf=UAV}N~OEyJ4H^avqQ>QOB*EKY@R`}lAKQKGhbl{YHJ8P(F zs;Tpo-8nomKW7~qF0ZISWP~x#x^vy=j-4AzJl@jcA|=l{HnepQharavhb{S}60|(M zoy&$ChBX|9-O{&t{U;@o81MuxwYPZzo3gmSL)=KO6UG_k8^8PJP>?qnb`3@UmMsf#2+=aWh z+q`Gv+ig#&URx@CFEn|)4|ooI2A+$((_-N%ucHRBa&=VcDH9=b6ax-RJ7gT-IF~zk znh={bn?Rm;K{Rx(tMpcS8!Lq+TPqzy7137ab_n1QLfQx6?p$}YBNpl&?mV_J)EJEQ zw6^-2Iy#z~J7jC$bhLF(d*_}EIrHfH?t`I)p4LG3R9jPHdv9R*4dBxwq*sC;WJpx= zfN4eK5;3nRspR#Em{;AhZhz$X*mKWR)XC+$j?C9rEdMk16YnD1NrC)jFtKnffb}X@ zkt6|Y75PxX1QA;YGr4NA$;-`xBLOA}iphZMizIN>aHF~ZgnMLqWd8?0c@t2bz&#lg+3JIg>K9#bTX6N@Kf~3}r6d3|0u2f%ub& z0w__QoS}}(^AZzwkKvPi_)mTk6Zq4S{jW})q$!-7>cSkxhPl6vuUqi~4`8!_Zay#9 zN&~`nJiu+oG*{R+2{u*IP;3**v6;&lLtGK+z!>D` zjX z54Jo0Ny_zLyW&stTo3+!P7}9byaY{5Z`btb^ma{;PH)%Ft!O7bR=i)+V@3P2+Fotq z?buI}um1!4sjz4ML-Pf!)tAQpUHg(gyn5fCte6AoXDj=9Xdm=-Xu4zix;4F9w3F^A z)-CjI5w~;#ds{4xifpe^C{&Viij<^r87CI|3zygwh*vKefL!<*+W4y9j>W9L{5<5& znvidVY9o4zkE&Dq$Ru6iA6javt7|^)JJQv0W_tScx7k?ykH{p%_CbdZ_r>2jeC7=2 zk2L2K92X9$xBv?=aB~QL3CtO55$)$ydue+AV{(7eduZ*V|JjuOq&JK9(@E_@Zx*o9 z^kxo2t~ZNzp*M?|DLND7>G>>x9b~d80FZl&J?;X8?`35|SuSVgS~xWFSqLgTB`O%; ze#{nnLZ0Uw-E1+kCnUN5S8zrX=nub98k5gEKL zC@PNmr{%Gb7fY+WvU(oNE1$dTm@P*!WLf8~AS^%r>9X#^!frR)vV4PhLN4A*Sw}1L zSh!793&>xQ$C3!h7wIy(QT^!d=~e^SrpeSb+hH=JAc8S4vG>jpMd;fM5zOGvJ)4S( zHhI`K{A;kNXb}HezQJ~s4mzEKrSVSzjetLVUL3E;|CFFH>FpZL5bZ=WM1PHDr1$4% z4eQq6xiXifbL3s+vQ+-%XYSu+$p(NeyB_$=mmV0mAZaMuGQmoj?VijstF zD0b`AmH4l(e@C{&|FyWY82__k%1k2I@%MAsol1dSdbuJI?)PH++ZwRjhjACVV~y3+UHJxqdUN^XLT zmf~NA9|~Lgj{fDgFMNvCy-20MM4c-BF7Efb#OL^sypO}2+L3#Oxrp|8c|o*82B5L4 z!Tk}DE}Q6kz?S-dirV3Ms$}^JZ-+ku*QMxnsQ|4 zAiT#g2SXr4f-YihPNxWikVB``pgb28My(qd{pGd#!EB6TNyzC)5?XN})BqSnQEM@4 zOfwn8R$LMEm?Ys9sQsAX(5 z`xO_IF-C`Ce|u?4~O78nR17jp?YgV`k8bee>0oq}-RYXvYu`cpCj?SIF|VCDft z8l?tQGS;MPQVUX~>(%$zSE1>ks+8^OS)a7$2aJYfm2-XGp-rhcpobUJnR4Q+0UkJm zfSE3DD^kPFe(=y1@RSy*hh@iX7G^M+nH51w!aY)#!BEf~+2GJG5wvNv&LA)s{z=t> zOGRGBDT)FqS$CvY&y*sVrdb&h1FJxg}Nz=~&KN>FWvkag7Pgw!Wv%_|;4 z2h7PLO|SAa%!n`(_=2teDkgPATYG{%4Ruv5{uWdXFXp9XvZPApQ?17ot)bRiMeQp@ zefhY?jOsF4DHk%%3A<@Qp*{K18{${} z9#2)3#{*`GJ>`4Hd4kB7*KGCa?V2u=-mdYA^mdK6igv_VG4cLeR5?$y6K@gi8gJPM zT(WT=-a{P6=3u8F9)orU%?Iv`y+hB?E|(=0eNBq{*Jt2!VqQB7|L2?1r~B+`z&Q0M|C=7o)7DO_6{L5ZlV(didMhhqKx z4UtF#`}pGWFJp&79F8>aiyV$(-V0*hH~$B8zYTmSUkZQ{!e`AWqE}}xn3FU`Q|84g zb92;45f~iwgnJ$)lSH+rrq4XYvYYE_n=hTUof>CPE-wGPJ`}3oxpSPv2*irQoFzU;PN5F_+fwcG&ZDO1Oxldwmu3ZzmgW3JdnckNd-`sj;>r>hmof@CO+x1u! zk90US7R8MO1qHAJrz9Vs%;49em=uv4%sm|9drUGtG)43gktrU@Q;}DZ6q({&5t-tv zK>8Ksdm=977{Vo5{FNcADc}$GFV;8CHZ&kCrM9)nU)?CeQaU=m@|6xmrVw;^jnDNs zk3|ylnVqZTGpDFeMN-kqG5}F6H(um3@7s8^%3xJ8%x%Xa=1f^O8>*)U?%q5Mjp;Mx zn@dUt%YOOu@`2LQfpRtieQ9g_8KPg{jPiPZ?w-KB5_(d4yGD;hJJB7{Uz7FI`wKlu zmJt0Zug4isAl;%8gau`8#BDNam>I=la}y~xOEOpuRrCO<0L`M4BkhD!K=H_570&(le^OL!Pi zpdUFcR4+^srCsRcS;k?xQ%!NGLSPr6$D%B`YC_a)q}+@Mk@ew+AD-I!==+OGzy9^F zFD}m?-L!4Kq-bIuLMtD49mcK4J{OSOiaQ*h#W)aR;n@tAbDB{zQOsd7F@t`zrV3MXt!AF`E;?uNNd``AKt?&dg zkbdFCOql89jFbSiqX3kmz*&GAf_OiVrK7m`)#h4_FpHG={@Jm+Ps{!la~{8T4bx7VcNVmt zPa)zB+5ajeD?wvqb`>gyB$EffZNx^k9x;Ej*#P-L_*#daDaBc=S+g&C(E|xXDHT7* zYf(7^84R*Cr@b_-BSc+yHmdPvd$YMx=E%>@p(G19CK1v~nPCh}S*>-3TLYY{Y#eJC zot&)q`RXSp-j}?EOWW3YYihjfw(VH~mlT+okcDtAW`hC@Cj^R(setj&dA(|Zd)5f> z(4|6viOHnE)~|+e`c~_K@2H!`+98A>C0a6Gq~~~f&9rVY-)cQpZmGAZC<}q+MfFAXHGW@(#|_QJp6SeTnpX`p zC+C+IbiNua*vDxfD{5-uzs9c$d|<(3u)IeLI@kU_uo6W43{brSKl%Hl*=KY3#TP<} zggj5YM>=9LA>s#%MqJbsu@#j0cEvpD-}0bOc@2ZP zP0rNTmcPnAvj#Pm{&Pty`bo97qPn`mTdkN^SxNGFM%7$t4h)kn@epePw?WN`9XYW?;QlH&(M|xAZfL9n680kW-Pv zaka1j_lBY1k+)JDQYg?qn7OD-soA{h`UBbSt#b|Gf~r4DfkIbaX8v$PmAANQHE0m^ zAbvvrr&8&`XNxIrLxn~eBp_y>xDW=>M|U&A=r5EY0SGsG!BDhTr6KhcB$)ZYDS2T$P-Jo>l8G9g zL<;;${gMh2@g5MT(ShT1If5YtF-GdLmwB}~=TLNo4R&gyu8CCi36`lVQTSe{;?9@z|voTE?S{37J*Y39h@7^Vcr2taR z2)NotvWCvV!A|;ddA%1>o$K~WvC5wKurvy?u8%XyH0Z^7nTt)?kWsT1f?gU$pFHuui`n*d|fBz>Fy zoa$Mv<|&A%{NBF~J&+ft9L(f0wve)4P|aq=#Vl~)vih!OB(BHswyc*zR&@# z57EwKpe%W^&9aInSv7(novsX_y@G(Q3jNcb>Jn#e*+^TH_K)Mq@V~R5INw|1ZSBV2 zo>Kh1etGu!mbzo8$wGJ2DwkoEQ{UNwLf@UV`XL2kAJ-M$Rpu{@~bVoJF6NRsxB>}@N{VL zQs?d#oQGta0^-0DmIRmEQl4&IIp6%{5e7gArpaQE<&9ZA-30WrTWQ1V*lW47IG( z$Kb=mQz*OUE@e_hd8yy+Ct9c0MshKiEkn=cRHMbUx@$1YX{9AZR42=9h;{`>-9?3N zdA+%>HQL{&WHe`$SC1ArOW8{;)z!_eyaIc+zp=IUE78*O7EehbYTT12O_n~z{*rxC z{wdEpLdGN=S)z23jqUV9BnLn2FJpn8U~g}* zClG5dFYYP!c<`$n(}0}=su=v~aNqgATRPeS)t=0*;^_RH%?Mj zdKaj+q*h(e!flggGgV!u+Y@yc3cv6YQMqFcjnWAdw$g+^wX~fbriT9Ds{g%Xgm3Bxs1A@#tY4LFQ1L$Irq^V+kF1S5G<|v6M^^37s zCcTkgB5f4whLlG$=sr(V82|1vnKA)A=gqUSs1fR*I`*(tLMPZk?kh58myDp4DhMa# zC%fpa%1vU}?HVg8fkH@dF;rMm>!8!og~||4$2?Wxmaev5Rb=jV@K(v8$!4tGTu3B7$Yt&vy0&yNCPE*l?|4 zOBda!c&xp#G1}dVYZY7fc69EwDVays$0j0TJ(~c3vJvyR$6SOSsc|(WBBDp&v3frh zM;1NRL_gm@Q=iBfKWFxV4H<7i^FWu=e5&vV}dg^tf4`+-p32 z7yI9+6+9Qe=hjP@E5Ie={xHmSjL(&50q#D;=NSLYYbndP>PnixAjm_-_cUj-gc{08 z=Mz+vx#*Jj)YD0Gu#d7AmR=%rzdX167{;VAaNh^sc|pJjxQ9ER@fkUy_`V6>Q{8eC zuUjr+m8ouXdW=*FkC75vDPp9EBqiIQ*{6oS|9cCz99hn?9Jv~Q>kG0c{$go&etvf; z>tFU`EKY~{9{w+yyVyerR~0w^RgR#ruoJeIYU$HM&*YHM20fq2r)XuG zdzHiM9}ta|rCBlvl)x9F5?fwy-x$yK})ND{mF1^?|L3z}I>?Xf6sHktmn_Jk)gVP5W@WT7$GyKOd z-if)Azc@!ijTBrpAe(p6ou9;c$l9JX+r)kERKzBF$K`}g?9*+Y8{&93LO)&{x% zS9BI{-o?*i2GI}lNEc(iDrRCJI-L^+P-j5J6)U3eI8vp3H5SWGgPAjotZ)9G&+&_o zF1@t$(WNCU@Co_ovVTdHRg&-Pf%glHVFqu(pb%eCQdpG~)XQ@+ymk|p7VuUr%p)PA zES=dr-`zdG`OIRZuP?F)V*9hUBM?y9C;=iyzIWghjh8MP0pC<&NuIy8Wh3yMRmMHP z^yZ)#cyNFQ9*|GZ#$Sm}_4iLl7rI&lz+}*Y1a9O&PXUhx+=(^MQA8h>3&ZP##b8$W zf-@nV(7m~tIhm;P2Yj-5AxwxXD9IW}X{H3&Djs)6+^~6}YHW6P?6(W?vsEYwS6hYE zzcMq{z6Z7LP};AhzO5eG0buYO@CvFiUkF}%OmK^GY=v2p%~bVGlWH(2Ws-y)Ax43m ztlEX#q%LGWQmp`lBp54<;39bvSa`7$LpbbJ-aIdfL1wUPE(NKxS6ND80hauB_z>&d z+m0UHcGq458{5o|y2%#oHXfdn^Wv`@+=~C?i}8`E`i_gB?nIha)ge$jgJQF{RL z8IBv~0kiNN^jm@}d^r&PeQv@k=8pgD;I^r)2id{+C&ue4C;hdYhN*W2frj0Qxgj`A@FVbsO|cDn z%HA3`O+yNzLevQ<$m4c#^Ca#`fg#7oe7Y1wf(6I_Bwbad#r%HhQ0nc;-}z2kFc`g* zetmK@IJSt3Q=&l>5G7cG=3>5?kllQ|mZ=&t0r*0vVPq(vyFkf1ZkohYF<)e%k^EL} z9<4^f4tst@E>c|TamO_m(s=M3a5GszBiRUa+TV348Vt7S6Wgdy_-|=KXBWrRd7-{( z{3XmUJV`uaA0|on@|vV|9Nzphmxb|}%fgVB(NDd{7X7yHb>SC}XDy`MEZ~OXfMI|~ zreKW%Q&hZ1ZkEPd3Z+638ED~HBJOdC65M&^5nqMbY@eNFdrs%9+cva+X;Xbmtvqw) z%-`f}JhnIfx2$?1P#6C`Mh4WH0oNYD)d9$!5oR73s3m4HAlSfc76zgcwX!(49?lO@ z+FHGjqHs($PJ&EuY1x%jh8%sN4?7E)dd*jEww_~!AS{~@oe{1I0Sc(DYMt1K)ZA+J z{-OQb7Z$ef9~wDuV5{5fb(eX)w!_;$9Dg1t|DC(GPfu@;FKs_OUhiiQP}pbuv>ze~ zWlRh;GQ0oXJ*)A=*6Hb(96|8H2_kTd(Aj&W^)!sP zW?Uwk3`9`U$`G=lVxFp%!yt9r{{5sQU%SRSjSNfntXW&mWi=1oJqdI zx zWp`>qX>dM8EXA5J)>f>y6k`dQ7atIggq%i$ffN)?Eh1Q|GH*UfBWfKa0_3-~w*!*P zNA}^S!n~Ts_~Uq^j67^{KX3_X(7ISmx6uhHM86NglT-r|gr(F4b=>3fmHNoJ%Uz-+ ztVCx-$=7uWjzk2W7f<13AZt(!m2GPa1lpER6m?*xqhn?u(DuDHs#|^INT97fz-han z@$>D0xw$|)_8ouZy}TC9%lIeRI0Rkiwguk)os@Q=KU3`*p+9T=#r-{0*MB0>A16Z9 zMRCvyaq`<^5x@s3GHz~!+B^xg^58U5pdbl0fCAmTi*m5gjmR;KA{L0yu&8u3aiN8x z+8ag12EKmW;B?#Zs-AEw>zw|Mm2FcKtxOt1BEo2zwf= zYQ(#^-ow}Uvy?gG1Gn!WjsW-pN^3L21&T8;%oPn>Lx!@Y5GX0LI5QKHgd~JVQ-%oz zZ(xE$K6>Ih5VG|sI1>5n-!9xbcm6%gPu+d{u?u%RpiC*+luaNwRd6BLy zY!RR5JeGe}o)HXE{E zvuwz(KzFM>4@KIG)eB3wwG8}6XK3CIx539s~FK+@+O66(ZdBG^Wa zDMJ_`#8;>+TBeypDkv)8W%7{kyGm$T*V%#!b4HSe|K0rY&c$L!X^NVUz%slSu;Am8 zt}k2Bpy~Rc4e{>*Z{Grj35gHi86}9-h+Z_96uOcDNM@MBU6^2#FlZyfX54~*vMolq zQYnO@Adkuu8GJ?`UZNmW##wK-d+n$N9SQq*5~X&7N-)H$#3LEJps-Nh8~-sb>EuF{ zD(eZ6D*ioV&6_`D6*zAsQHk5Slg^x2k9uzk_K=9XjQSd{q4vqEI31P5QL@OIKWs&= z-DUtC$|pB-3;VWqJjBJ+xWN#fT2Uwh@zex>;zm~C9Q>%1|btdU`3U!i>K!Q z?!~)pJ7N<@E*@)*MqBY?W9JUPb=y6b+}pBk_q0WjEPCkhtqUE&Lsa-4zc5FISje0YKott*C^*)cs7ym%D z&sPmFT?8PJg_dgjE=@8trP;nOvM1s{ll^DoBmn@&`12GyAVXjRjvbdT<8NV8&zB#B zHU`@_g^#4!zDxP-hO5}V^)J@8eOH{PDa^gX=Kwq)+jlS4kc|_z()L~YJKDaZ$+mA? z*XFFss|4k`y6#N;#inmRAv4dlH}b}|-HdN|E9d4Wl=7Z#am{lAugmC zyuBh4g(43tcyn>lc zZfL3wR5e~?3x@)S&h&?S2BTAb=DkCu-m3EQs_BZ-GOxRMV8>{GbGL#Z@Q%PRtO%kO zB*n+&NR=^fHhFYJ?ktWB_D}Uyk=*653uh78_%#!MR2f+`TEFtaMT7Mvgw?;^c%+j3 zC1U9_8V;3{vsC4syza6Li59ra^L{YyZs2YV<7Sdh0Et7$?c4?it_jM8m`v4Ahd!BH zTsOm*0s%UKt-F`@Ub_EdPd|uytN!nOkFtV)@)L~7-_7YqF(31|d<1_Zp%FMM)(Tsn zm(vf`R{%nZ`K;zEz&EPSl;$e{38i>=%~udnodr1LzCve(51PosOHUZ!Eij*scCk%> z%CrA8zsTny`~`GwAy?;SK_DP8LCqC`s}QS)NTvZF&MGICPXl4``S4RA$AY_8d9XrauLBd#jn8;NfY2xypE(^+DF)n z^iqK5dVHAhMbsX{I}>qaxSIvE@E`aXu;d|ESRI2fgo2fp z7r6r;W2o`?7y}7T+GifChR-^VD)8qGV`R#aApb>wtQV*tOcjiW)s~JVoE4I51Re)t znQ`=8`xBIOe?rGtATZW(N&L0Y5{*89AKYvqM-Fyf?K%*V>7NJi&x80U%V%?)!TSSU zU4i?_9zg2>jF1nH^_}D}>%nIeKjW~?gSYAa1mM}K{sfpHw9GY9SlRErcmGj@{@rrv zFD7@t_kl}D!e5@<70tn-Rvv$LuDE0otW!x4ue!*0tn5!_az+Mb8vPed@$UcR@VJZP|Ej(O zPDobwC8U1!4!(qs;TV1lz65`Klzn>n2Kf^9V1I*Hn+=v=+>eNvXubrzImMTdZ_k23 zU9#%F1c%@$KHZbB=hAzpPoJ5*_eyt-uIc%j%E}sm5n~nN*E)=q3F?&` zW1$~J$eu}lgoN`#jj;ezTQTMbE-&vtym)%*!0v|T=7!zuGiUgy3)PjC)g`46zf6#qd+hKTIQ$j(3tzF4g4KhOpyoaWYd;XSC?GdH*Ab$AbIYFk=rYh?fD z5AWPQMKtM9b9G${UpHkn%19l<_}(>r2f!r|q__1Qs9o0b9e6z@MHr$GzJq+-cR;L# zPn}#Jpqb>4PB;%(eu)PbwVI~Q`}b{r_<+Lp46^Aam&fC3GR{Ps*`d$v9%$RJgMIC$ zba3IYr=+daOZ!N4l-9GV?*K6U9efAG8ItFfIrtLK-D1qbHZ+i}U*?S@NATT`(8$tT-`%~9o&;dbqsv19vd zo7;l9hn>M_bM1briNd5F%r#q|t0odc?p<3HO!znu=0Wj;FaYqxm&9DF)j*Fhj*5ui zOu1-;Rqw`x?ln;Fasd3(+x7bq z)~L--M1bn2xyg0}j)#;YaA7FO3Iu&9o+KHYD2tb@x|%)GExD1|R0RVe{7z-{b}S5- zgB>tyWwG_EEFcjqJ@JGhTTvJr_gOSGg_q=3y5ML)6vh57E9X_RUzLHuz+>_+M6d^M z>t85+TmJ&bb|d*0xN`D;<6nSzngk`0^RZIbzi(*I@WR6Io}n!V4s0o{@VZOkU)Vn~ z^zX9&ryEB0?Hl>|$o_-16+fp$m!DU_p(aaF{32P3>~w4U7C=tOAr9X{4jj<9kFqeQ zP}tD@+_wrh4cU zVC4*JZbf*+NbXyS0ibPfHU=WRF8b)`(&LXSqBuJ4aX1@k{jIj?NvQA!zt;hCMYI57 z?+8|_`WH&q^e^xu#-sj#i}xNrOm>KmeB|ZDSI?eWd>QD4caw~>5$}F`{{kezxA8A< zg@x)ggniW{NAJFR{~sUOy%4wu%KHy-OT?etyoPHc7;H}zn?btR>VAckQC9dBfPbVM zVUV!TF@T5z_!Xd)egFFqtFFEB3eI{h_7v3{Aq(Eh*QS0(o-Ab|!&bS%r@)yjWLk7j z@hM!JHC46%+k)CkW#LDy7Ke8$|%Qr=fngbH-dFW zu+Dtae}L=heud->OZ6+H38u7KcIUlZIK6eLZC)2oX~`H0Fr~XaN;phO_QeOyuOLK9 z5pI?2R}dVHmx?9*AXhEDHd01BQDR@(T z3JK2&rO&{@Z)foLr7~)9tCsFp;I+hsUtz=1_V%M2`0r=`xg!$kI2Gym=MLi2_w`MN zBNKi1^-YD@P*J$OJ^W@zxU@9ffwgiSh_4AEr8al8uU;R7(h4Mb4>o!Yp93l=ynCMm zsZ#%b_tLT3-@E+uy-O$0zvsRKte9ny-GY^{%=oY3|4LFP<|nIl0%-j7JI|32uhj@3 zSI63So_~^;2#DL4$b1)ngY27lIo7-j1MnILG6l4&`QbFCO6%72Um%A5o&6V_(D6&x z`2_CRyAWSer|>k~7wQ}mI`|)_>EQT`ISD%vXoK!o5E3E2dl!BMA!OmQS+l`w^x`0- z_!XjviRYRw=i77}CoF9|w2! zwFti4N5Xx##7EO&!||ENcGLGfwp-TvV|tmAKjuTm5ZQwysfKJ%Y=TtZS_%tAh5)U= zU_mK}eE6^6#o{;b_@4jx=i>jk<0m_#X0rD`@)B8=cZW?-G31O^7Od{&2%1sf;ts~;pKTtmIePK zz8@g`QDuK!UB535YYN5`Haqgiix^)uHX5HX8^CM;bD(>jDeo|Y=i=a6J#YhMhSFcS zHwN2@O9$ZfG8$`*qiPOeh6NETN!f!|Y;Hb+E|~f=fPl-L>;e3ILq2-;Z2UX;`J1z6 z`JB{!!W<^}XP^khG6JuDr0=U9BMi7~!1sR4F@wC5z$z3QBe;;lLKW(?Nm4Oh@*mbx zk_0+u?iT(zV$7(d7hbnJAdj|@!y^zI5lAmJ?mPf4=OT`6< zILJq)fdxSvVFc=eyh5YHZwg=$q3DiZz=*aGYChT=T7XIIX*oLH!}i7hDAdiq84q`d z=-=Ik+LWiUQ1~=3b~Su|wbHn#*IrIpR1i;2L(t{?yJ-aSgQQIG@;QJH%IEMd@L^tF ztB)w3Q!CXvAQIQqs`KSDR+Uo0>&&DPg)w(EX^+%XN8llMPd&nbHl66K?{Po;U2knq zDAZHyJ!4dS8^ZsEFiYg2{8xBAi~?t_hwEMobaw|{s|zC`0Tzq@#Cj+{hWy1uQ~%;- z32!I=8s$~^A>H%#_#f%*pabx0{{^)GuVPv|owua+b-evUYFq{N{~GT> zkGDT1`s3e#FK}>H9T!glC-`|*K8OB1dI96|_Y2%fX~%o8mSM=T>+yc41e>fGS^Q)@ zH{%eSF5_FXf_y95rkW9`r6yA?8Hjl9VIyU!;3=5XamyAdqn5Jd_#ZN_a7D$3FreY3 z-S^$|Tn>$?agk&Aa?AO}kx^kU4`iD?FK#mxt(4R00tJSw3F&hpH_4sz{7D zfH~Duc)%ZaAKV*%ap^iu=yrA}{$!k{hf6Vd@lqk9YWGFVJeP7OcQMCUA^@$be5N3d z0M<4_YjB==0vt#CRx%^jCJAxqgA$QcXiMDVq29+$b$d1^J+J=MS%-TVFK z*2DbT3mG?Zvz!reC3hrZ<5zOGho+ZrOo!UVjofXx@}>=70$c|H*9M$V7pzx^D$T^< zEF$VPOwk1>4n~)hN|Wf?1VW*Pvov!~)hLg;L1`N5{FOd$X{);x?{GC$TEPaKdPFI? z*5FVPLU^@LTtAZRha=s!pm4C`$hy8GZC%ZEu}WM!(mP^R3}-{Vy`h%!rbwizF&eur zzt}ewZQ0!x*_t6+B0B=TQ|JMj<@jZ?!Ud;5inc{bNwoj3;oc*Pd9IXtV_jJo0I0|i^Mg2H zG7gVpr)yHTmSTR?pazt*Lj(;18)#pRYBofW?ky)yz|)Q>t|5dQYeZ56YuGT;0a9!9 z?rmp(82_*P9=+vMAYw1>X=!h>vB*^4!JhF*NvCh>$i`>xxcAP5O}kn~t2zTmL+zL@ z=G6`#onKDzE2DauK|zHGBgKqgmMo|gjS88_i?pEb4p^!@i6sCT|yRq~TZQ&ly3+5{rH32Zdi5@!<8f{)%#sySNanG23F6 zn&c)M?Hh#LtB?PW5M*;pL~=BT%hM5CyWh=3W)$mt}!GP7648*QcMCk{=|&LZCRi_eb? z9JwPLsd^#0E<4LkBj|B9Hm6{4)Jo8}YG7P_e_%KrV|(q&#Vf$-mkNtO+9S@xUL=h&Z4&>dd!$!O^K zak|Khae1i$oH*saPf%Xi4)}eawUiZ@bl)eYL6vhTFqfnq$j=|%HvM@P^||lCeV@u= z{F=Lx?)%)0!w>(uey^wxpY{ANio()Vj3m*Rf^RvQ38Ll;(<_ks{(kcnW=UM>>G=Yr zWBo2qtbzDlHr7xi?S0OX&0j4dy^DwqV=*AolU$;NgT!cTq?-Dr>Q}^9@IB|x&Dz(( z1%@JjxKPn&H(QWODug>g8vpRAiW|}Bk)=>G?p3%QbtQsL&8-l)!TaJ*1>4($Z13_7 z7>&ClcRc|#08A2o(K$j!(8l$URViPyBNi>=HqL^4SssKH8!j|<8NVrVk8;SBN$f}y z?L~cEbrtkUBkCPAny?!zn$19LKkynnP%YES14PO9z`B~++nPW8XxqjEAKY5r z+H|NX)8@SAbE7@szP2Ol)*bCsmJIFkZQ+`lmieycjm5Q&TSo?h1Nk0PRp5zr<5_a% zw)XJ$=B91oa+ry+rgR^CF8owc>_xux`)WQD7oB;QE_Q+#q)hc>1hJwZs^Zl<0P#Qx zDWh_Y4)lhYJLO?n#%9tGW#~y|i@D56 z{GKNg;Z`n{&nJZL(dg0jM=wt8=pMExhQlqvU`uN-bY0FvWmYbp5I(U&E|%a|P~}@g zz$F*vRxY_?CFP6_!a~mO24fPFlSeD^8Wbo|Mu8e!KuU2)iJO*?TRq&AmEko=0^4fn}C z67Fi7+0Yt^@R&v9HeT+Vj2u_2n>!BTABbkY+#bNM00D<_eY^nk$|0SOtQlPB05(#X zBAqTy$ee~9Y<^__AF%&^*;Vv7zZgfo76)g5YW~o@ASSjA5= zMm>B~e#+*tAtM4YoJ`&f+*F6AsxlISI50MseYk2+Rp}|iLvE+H%xTnaRv#GTA4S*)mJE znIw}v)1*n;wCR?%Y1*_&x}`g`sgzP$N~Hw~(uyJ=Ad63hhfi84f(whwQy+@>S3txk zqN2~I&-M9KXma!a{?5HKnWQNc-~aP3+|0~f&OPUM&iU=jP+fem$x@AMzS8;g3{Dz1 zt{Kldq^m8zwYG)*YyF1y8{xmf&NF9A?X5L!GjNjPtD*Ru1fWxfErV~ah~iGMiEPUu zE&*50Ap!HgDo7H46+FrNIa2~gr|Qb07_h9uI*yWV zzRV598NdcCc!K5)VY$d&NfH8r-2*5l=E$)KQ5L1?Qki}ZE5))po%@*h!+K>&ffWhr zbm!W7*Dfw7g0Z$>TS1n?kd2nF7QmL&sX}~bM*Hae_VGc z>%h8mONSREnxM!a+(){GDEIR^f)hI3*@DdTr0=Hlq^IuBxc?*Sd7bWYT%~sS`r-aR zw0_VI0#>HalfIkIPwUN<`_T?RKi!U^AE&&VvTPBdp+2tr2>gY6@wG3t#FcjRqTv0;)i$zzjpj zq~1-w*0^~viQGugQy@N(YQa)c@hp?ZrA53vbc$wDJ0W3}^q!*I4|kl}zj1EyjWWPZ z?t6C`j`Q~zbCnQdM-|V}HihfGh{HE#QDQ<9`Hs)U>QmO9X;%M#(a1E<|G#Ktn)z>^ zV>q0Lzs&K#&FoTAz~!~T#Q{%sHH_o2>R4^fTw*~sVf^DYH*Qi)y=KiLR+QAoC=0>vj4XdQV1wa0*jz)>RZi558d8qs>M zlt@Z06bhl&fupLypv1N+j;dT~qUv=Z>!*BHD>n?rtI8`XGhJ8Zn5x=Q9aw=IR3AX_ z(WEOAg)Hn@@|(#Q!os3tPagMxKsXyjQ|&PW8RJz7qc@?8K$u1aVHU#gH;A-RCB+5d z!f>u#DF!FxvwU*~^5p)CnTRr|WRQyE1L+8nUpx2l)`dauZZWyr8(i2Ho2S#yD~J!p zW3jKsOhbLAYZu!0?6EJbJ>54%io((4=RcoJ{+#~DSh(GX%LHWiq1+iuV22IEcLKQp zaxK6xicr8tNhyT7Jvf8R#JCqesD&P{mddo&OAs8PCCejLSE*VgIsf*dY#2}42XnfO zu>WQm$Bum!l;#fKzSjDmPTPOaDohqPi6%xwGDMo6!JXG@~=cg zGO&N2T<+fo*ulLU3kN!JZW+iWS;Iz|X(&EZM1GWXpeP?W8%7*F`bqX4rT7XS(?eHD z@j={V5%lW)aiphFjX(OTe}4F%?~1(~V?*tCwVnD(SC&N-bKtr$3DbXtcuHQ(LW!Pi z^CDO<@J7r$YB*9J06X$dc!daXXkW6|Taxd>W`cfQ6N^>^HKHCS0S#2|8mcTv!YH0@ z1RqH(B}k6aLFgNN#f45@paLysLt)9Ylq-JfB*p9CS%{E5XciJStPrmQ!8~9F5N3kk z>7R@LJhZ#DzPSLK?0ha5jiN-*mNi{#E!jg0 z*6*f~{RogM5heK#_IZ&=(mr{shQ&4VtB!@S*a8RkD*2LGAm0@T9-%k6pa|7+r9vC* zSkM@X5|}G3G%}<(Jc@;hwj^W$fIfDRuRpL`8es(jFA*O)(X#duO z%f#jR3##N7sutjFG&j)8!{VC_j=)Wxj_!Pi9WpJYNlZ_I!q<~oiLVlu6H)R&CwR=@ z(WfT>#&IK8!!4OQa|~s(6W*crtK7|DGzN31f(A;c6nM5mF120f`*HkV}@l z_bJzva`>?@R2*>J4MA8~mRwRwE5%>pv|%)S zt-#AlcZ~8+QjLnV6%;x32vbqo0$y$-9HQ)#>E(i)s;OkaYNX`!a!HU26t+id#b~iF z>bbVoa4bIa(3vw2ZEJ01 z@H>aT>}I-xQ7ZoYT`RK8v)rkC>A(0YM~5l3vCIs%a(rH!E2LNBpWc+>%@2I5Y7 z>+(D9U{8PKBX761;uRGYlUJ#0qzHR_5#C`3MaD=SV2ybwiQXf-3eVjT!l8G3IXcCyQHux{^tUUS#@Q1h`-!slX>q^)TtbU&rT%MbEt zV0R&e0^Jd|;1Ah-$(IC_o))cw&J|gllqJY{joQbMzj2e+hH4W^!WEB`cIlQgEGBQK ziJn}_!H#i~t(4l#1>B1TjMJJWYK1(2ooAPT`Wn_a@&FZviDd)$N%l67uRVWPF$x>y zyU43755X;D=)x#nlp>DoF$vH&m||>>M3Aqy*uN}a_V5`@?M<;;(8B#?14-6O9s21{ zvF@IZZ(UO)-@vXdTC+7SFJ#*X|1^0OUxYmM9eW%7q@4hqWmbyWQ`vN%s0!H7qQPF zKUcG7S+Tq_D1yfYPEXMw1)~Rv`UlGpgz2>9z-N}Mf)X2r0<;~W7_>ksOc)E4{3&wn z7MInfcv7UR_b4u=+>?SRXgP>aCJrI|&Z66|xn?^ZM0{{8RZ)n$B#lGl7l7!k;xQ`` zqM*3kwUCI=LKi++13r1*X)V(nn?T$1U@@DQY%g+w9#P(L@DgMjN5BK@?GQUN|` z0K*X}-c%w$BBeIpWN5(T^B8orraYWCfE&BY7pQCi>;Q|Iab*Aa@F6VdZHG$p@=6c= zuoHmYJ2bRc{vsB%{AE3B(95qzhc-g&RbWec=eIZ~$TxC159phX8lr#wa`Ff$s4 z83_2wJ)FA*RzY*wwmgrSFKJ~Orf~{5G(HnrtRGuL1DH#l*SnZ$M)ohA|LA8vQAm?= zptP{C^Z+aNd&&YmU8l|FN}8P|+ida^tVf+91FJJURN3v6|Lp9M=QB4}v&i!=xG+ya zVIHmj4(M%T*nt=ZoW>FUcnrb_RPIM<8yODBxq&$aL*Hx42po`$n zRcC884bXI*`tpDy{X?&Co66k@oo-%ubY=7W+Q^>vMc3~c+7%w?O(bKHz4Mlw9N%`Z zz9qh=p~LI0ZR)7s-8e5;R-JghcVJ1xs=*$q#LjUp~S9at_cJ9|zG}ifw>)6`Vr;1$k5j%mxXWb|lR_VRV($k&MGhox_gu zyLzCz90oY5{A*lcT7lJw2_XeMF&}3*vwF>2t;DN}hRJ>~srdLi@fmyMGH|Iht}6T? zd4^~l=sI?j^7=HYCnS)jDk*}NuTYgt19lK{%ugP>>$+?U6Rp|TMISrd_VIS$(zg)M(78$cwHQ`OC}wMdtCRNL)#zE}{$?NHK|N<^B-(x2vqc{{KdSm$sEmt(@eMg zvx>j5kH{l@{5|Lc$rQxnBa;CMn52ZjGvum0rpKniY8s$Vs0XmrL}}T~+!=P8-9R|4 zo{CzZVrxRzxSSA^v6ZD!dxBxdFT**^|g?xe~h- z87e7tyR8f;wYn;juhAKzl-&o(>SvzQ;~Xkg(aoWhTMtN#LB|Z~Y%=ru;P*qeFlcM} zMl4F!Uix0FVeK&Hya`HMaEGJh$AHa?LOFgP6T%g~N`ECx($ndlp{k%lIiz(5OE5D~ z2oV-YcMo0)m=YWdw^V>raOMT;VP5}_mBSlXetdpQ(;_J>Z66yybS7u{sn*{4f#~zy zD?V-rWCG|unsZtfi!UMJt&ABn}-vi7B>oE7!*XL>uXl>S??y;jx>)J1S4 zeK_e9Y&;4lEPO%%jV0_WG+^uKq59HL#{Y~H;Qvz)^kUemRFKkN&+Fr9>!1XpgkXR} z5#j{qxQnAa(2Ayvlwcfc87aXi-MMN%hnu-c!GA%LD?sNc`VmqXF}bkfg94HVFwW$` zGlBFJY;R&o&Y3egOA<@hub02j+WH5h-C(dAJ9>L>Z3R136J8F!dm>!3d+5>iyJJ{d zyTKCvTol*zKDua4UTEae>IK+k*lUW7s)p+#z^A;ME<)u9=PA-9nSD4K&yXxh5uqaT zOfoo25rJW_YVt_fc6E5PzuNDw?jH?bP4c8^-^iV#`}U149kR$V%lf4w`})}Zd0mk^ zBb_v#lrM?o$Jz7g_mx>qm`_h?KBrGyay~h8o1RbU67#9vh53|71^8a`Ns=4*Vs$<< z+|7tdSC+mvWuGh80W1oS?9BZQrg5n{r6*fkpTInJ@EM(7TxQz6{?Vb`HDLrd2bPCx z_++kG^l0xIQItzXF^@f)yP*2f5x_^y&w2h`)mcJf|^>4w3KK$%w{nfF+t5}^#EIU2b>waimvuOQp*6>}6^~G4ED^K3U z?#t^`rX1^Yo{EQafeqoa=;FETQu9HIKT>QHO>gOgZU|;46xdZu1EO-75*!3J!e%oY z;ZwK4?FmUtDnraWn zDf6J4h7S++iysh<3HK{F#Tt$Hybd5sU4X&D4y0oDGQAFG9bg9ZbTW;ay_B6ygppSi zu>ips@G0ovK9|s8a-T~=EOZmNPRxy7&zB#DIQQM~c`SkpZ8%h3R_HJCqjUKVzpvH= z>6IefVyP-4%H-h`SHh8d5P3dg9lA)Hkea(alUkD|r#C@Cfr;LnGiw(gNG@2AbmruI zqqUV!-uVjlTBwSz%c@Y{H4a>};}VF*GHrG8Weaur2lVmV?=>3<-sov|&f>sx=u z>S*f$cgm2;3y4q-SM45>??N8QHNEm(oYJ!72pm;_Q?R}QQUUlJilFujw2WzwMMh0YHFvSqqGZi-aVM~D<#Q@3C#E8d&&@= zKzfJFvSb$EQKk@4E}1IcjEZNRr0AfOwd-Xqq1xII9j>Ax7mi;%@o^S@Nd7y9ekAPk zh3W9-JK(>T>&TxRp_MI*C|mZbLL-;3XVmfg;P7zE=l#V%4XI)&k?Arq({P|j0d-{9 ziTYvGGSau{K@8_88gq$zQMN>ZL8v0%m+vJA6wMfa*1L?KVNG*Y7%gK^L@eFSvtS@k z-Cvyd4C~uAG&Cm|CO%uC6K|-Pd~0xPGO(8Y1&dHwr-)17@{|3$*7nrTS{Ee6K;>kA zWsP`b^7Vxn5cYxKpyqa?zX zsGL)o&JtSlaO5JH2YZBS#!Dk+WD??14@_dzT##;3OnKl%=>byu&2f9u58f(uqX6c5 z?)zXXgMK0!35R^%(h_vO*yeQjytXVOx3(&Y&R`HEo(eD^ONjCph#wRS`M#CEUhA#; zx=^6eq-&~dNZ#fPANPe>PitB6_hLn^*RhHMo!$`brJfdf1*-POD&zsEk?TV8Kn1(r zTdS9S?0Qd?Ne-X2z1Om%RZ&*yWp%lY-=g7VaEBlVsD%V*9>=O#h zn8(?Ot~|hrWPv4+1t=DjB=xXxmqNGAVPBpzN750-gCLN{^xCiwC%{dnCk6DHR<$xI zM$l(YmO-z{MnMk!uth6^0H+h2UZ>XT+;zf3GlYuS0G;lk0-E{IB# zf$tB1p42dyMWIm@(UrhPsQM~?;cD-E+cLLOk4pkyGyJqMUs&SkzN`+4jdvOA$DA- z93K9IHCerMVYt4drRC~3V2uKmUl@$+9`bs7*MRGJo)i3Od5?6S@ImZAMrgJKT2Hq4 z5=u`j=ed=hsj@HAO3zdhleC@_b9HH@=i;z-{ldD|@{-f>mV;Z@Tww|umo?S3mKL0B znSbrBk*gvR|CvZ_i7Q%HUwI-}I#~obZ*@Wt-;HJW4`>%MxFKc95iro@AF34w&FVB?KHw zhB0Lhhf@`k3B{U#h&f?}kP9QEVwzJ)EVP!S=}<|mnmGjX!H_B2LBuwXwRE(cI(6nN zU-`0(8EI{GC)?Qd75CgjB83iJ z&-g+shr?I>JJ+4ERkF@9+K-RVZauh^eNle7YEgQPNAUi+b*Fg!`_Y~L`(t-sWy=*s zXU+$rpS!;OqexgO-&ViSY+h8y_D{afSx-QnUW^QRobRq7pQ@Tj4zSG()u}=TGgYU- z0P)Ru$YU!k$W)!SiwT6za@A>|0beCjo|3anhVGQBQa5e7eG#NzBq!A@sT&6Qg($|P z`BkHL`7~yc8MS2}Xj|RJmbWd}nW5gx(jwuByf4q=wB#VA`x*JlyfSBQj?rRcrzc;3 z$Ud($uPgr{3<^YYxTq^HudAq;!xZkCVl&2fNgXP{`tCYZ)VqQ05v*enzBSC-{P=et zKYC03Bd57Qs@oi;zq$YJyQ<_r<*#Zu zugx)iqVqOGuH$?~ZDc-*L41m>pS-7`yUOI1vFaX@vaVxb=Vxmx#zDzc-=_`g-V8 z%!dg(pRUZzhr)zr2eY2De=y}hZv?P(U@$FjS+Yuh^N z=5K3)?|tKytxusBOlZNpko;e);9QM1FGo>FL8lM%=P@WuunNe`#&ylAohYqf` zXQP7aN=m^Phu^zO{Jr%`kQQ{xP?|FT?xRPWDecLJ?n&WF}xDJa&+LmS>-an-GSrjyUZE z!if!Pt?@CVfMTj#Z04BBTgj#BiCM+2>yEU<<4uk6AFv1HSJ|fBR~=D?Tw7C{U+62%@nIrkN$YkA14qfWi03PALo%(RkM>1SP?-~N<$?qP@u)*@}TmYL4YDqg+C7> z0*+M#EApu0`$O3tAbrurSG4)dK5^TZHP+_xw)L^LD;77-pWleX)VgO;@lBU=KnmxekcD;+Jp8F-K9*Ztq#{m>iyo*kUNC%gA!JfMk6!htpKZ3 zNjy>pr$7cpfBHL%F1oV455ZSW+q$~OnvgZOul>qJ)y>V-bR>Fu5;&+@Ue}gv(Y&Um zd2K9)I(ci%V)m9U6wPB_XsWDgYO1PilFudU>gUa?uS)`KH2?VH1wP0X%E*t80U1yR zEf8Z-g02g;+EJ_m#p?qOG0>qRl-gOAS4O(jfLBirIM8!~v!fM2Y&gxmUdkNFV~=K) zm9=he#+)mwAXlAw7BprzkI{rHYe4=P`vA>4%|75ijlan=lDrH{a~)Wiv?IlIzerIH zuA7VxMTMw*>nZZ&=P7twwIa{L+maKdOn|is!1ZvInSrgp@TRdjQs1_}t{q=4Le*mK zZR9_nGj$T#j_MkvwUDvke#?AA( zcbB!>+I)HYFt4QhFG8P+A=|?L8=4woqkv|yua80gRnp(7s!UqHB+{w~r?i9#es4** zH0*M!E~OXw2&Q;@rLCN@VBIFw6%4xmVL=& z%xU(9>gz+^Ci(uBN?(V~V$JFBR*_o(*1BS2dL8={-q|iDAufnqm_W&ofsBK=9fiXP zw1s@0GRb+`wp0u^s0@Aa1!@4cknzj!3!-?7jwGGAj?2dh}`9o~a=mJnYikCV*brpM+B_%j|3c}>1 zU_2*?Pft#st&d&I8&cP2^#2yL{|bNJn7Tfr|JP0JWG6M%KTq&6r=FkD{_&{`fZx9=@RN@#@RN_HpT8Xo<1O~Q0zc(CdphI#XI=;5WzhW9jRFpK@Ky?B6YD{}ui`;5Wzf z*Wvje@%{sT%5^F8`495v2|j?|9PJ-xPWGz!Q}{BA!o3RgrtT%^(b_|XwYS9AaZOUL zv8U75PUG5l`E#0!uHAq(f5@NHUUcmgo8tXe@>lUc3O>7?9c6Dx?H9>h%QIxI z6+G5RmAT9X$b*%Zxo-c^seM=OWk;8?;K1M_`8If-FyS=X{4U0oPo7gGa{))r61rAS z3tcOC_8%k_$Sb!8b>>1eKaJs$Xpwk)a9H}d^Ufz7u8J~uXn?Jo-!oVlIQo5dblvG3 zztbHEy63H2KF{A(vWdidyzK_S_JKB@?mv2UepF!v$Q<$+vD|u2z*yDd;ejeYIRIDe ze3e!1NOW#whgNpFDU&9zKA)BEmDNaj)VM;aw3|T#1 z8kZE=Y|k$q9(m%nHSFl--G7oNv&Q6KD=<0%7`=w}3*JpOQ^!ke2H?U3C8|grE*xGA za}1Xv)s<;u)k`)GR0e#NQSTir^7{O~bsN~BWl6U;TvlH3){_3d&Q3(HU@sg~`;r3= z4ssdkEr@?h%Vo)u;l5yPZ4d`Lw7RRd@$1ifmUVQkUL5v%!*nR{xdrfl1@JKlE1~sBY16t!BNC;9dI{-@R78i{32>*8v_s!X6?&i&~P! z90;wPOBP$1A&UVp!2k&bm61SguohX%;SFKQq5vtQI!$R&Ox;$LQ9xk>6_y;j>lt!b z-$czHHHuo~C zr?AN5DJt~*o8RsB(?R1Bg%5I=cEO*a^j+jH1qH59GDQ^TJ4$m)@r2#y)0^`V53S~h zRDA9UH)PK9j}L3Id~TT{Eu%_uPSbdKv4l zlvfi-VQ=t{&;>vb0EaM&Dd1`|Ndu2|+*n#rZV!`~=cuo1*Q7)|b&@O^An`BJFT#yq?idvEfy>Zre!+_8N_jucZq69LjoAmYNlj6MYK!p z2NqwlVW@AxdQHE!(i5y^=EiWce|Sl-x;lu19a`0Och9Q%b&lewzuy~Jmh4*93rrrN z1O2}S{U=&T{a=z?G*hNa$)(e>9RMs2rsO&?v-^3us#rJO{j|U^V?U^pX}bIV>8_ia z!t+)RFIl{Cs4rMu6X4ySA9oZ+`~%+LvX0LF{?2=PR{b_q?g@uIo-pxe*G=tGd1u6e z&%rxiH}#In8{)dMHfQjL*D*VIqo08PDH1-eaNbiN=bSU)xT`siBU!P5A`oiQvLd{U zAxlw-R3O7a9%M;M4+MsAd9fkVkSQrJHm{nDpm_I*JQe)Mcvtu5h zxqPcdUuZ8ZD=V}Y{Loiik>haaOB^M>wA}SFmrL9!yf8y9A-?!!&QrQC+PBA94z9fd zxVeQp32xwv@8ob}oNs&?bg5KWp6tt;QwBw92BZW=iV9U0onBXvL$R))H_GQyC_EfF z<5J2>)lHFFReS;IdBB}&kcVzLGJm6fKwDYntE}|70;ZKL`eRFN*}N5CVdf{CMNz;l zH)m?epVerY5kUhoEnjgn}e_B9Ja$4Uok-$tEE%7JmKkEC1_G*#t-Z0H+h_ z=WOu9dH9@e%lss~1K=|!1{|VMA7pU9Q4@0TdXC}=J^IwY)EnNvMEfcva)(Tfiv#Zu)IGW(B+!j7Hu1P0%QOF$yl+g zpmZtgZEjw$VWBx-+s^Ko_dTP_7+cfCYexl^EiUo8ii-2&9W6EUVim|_Fc09(fK`dG zjl*ia0;_zdXfTR!mr@1`Ba~hpEZDj2h$|$>08Uzi4!<%W#7A0U=U#ba`n`;idN{|4 zWT`OHXAEuqc?^y1edOqx8;APXN9TRdkZ+6)G-5p3$v|@Gk*9K2yo4>D9z!8kb(r~- z8FyNt!1Ke|$}BD+H#Aid-Z{;|izpRk1cPieA49GOaEn?*zKPk0t3`5J#P8_M0}yQt zdXr(hU^eSVtXURGZ`Ln|FIFiyxvmT*{~vBDIX|NnZpisZdIUGF5Q@W~xUDj& z0iV}XR#aL9-$;u8ofa^1K)h(RIe_(M2^n!!$dIt3GAHDowncp?VN+U?U)ue#pD&Nr zEqa~rxAerEX|8tU7dV{-?TMC_4sAoIwne^@c4K;qKnvbWkEF`8NIwX4!|#6!dXvwo zdXrtM-ei}eHvty9h26qllooRNmueQN)SN;CK?TXFnQOX%t|;tsh762w|7rS7RxjcJ z)s3bo0tW_qdvSO}AukSg%krMvyO%HTzP)Gpl8W-%%Y#Auso=OqNJ8(Lk9a1s3Bg9| zwwO^&141h>mBdZ}O@Rb~+2Nf51tQ#uIyOeK25FVN11kC9#%hJ^=z@xU4tW&yIu=DG zRDb!Qa&EABg4bQ&RAjI}WcFE;#QRJBSuDX?XONoai1c-jSf*R%Dfmpp*5 z8eBO)PG63Sf6s`^)DMrkp7(yS@{iqH7P{*$;3 z$X4|j&XWy6ZR^0MZgWyPVSW4omQE+PNcC? zi@DBq8K$wKdZ1~Y}y>Yd~_i5aORNo%4Dn1{j23BOSd}^aF>?(gYI9j zzsbg5`%6mvbnw0|L0=<$jx*O6cLqvN?`2J*gO$*GO5AXE=(p^)u+OC4Gx;fcEr-oG zVDlHihVt}%SAk6c-frN*kZBy-0F{)ef|6={g&jtVh!L%}6L~WgP+iX5NUMOmtsp*^ zb2qh}462>b9eej)@U)I(u`DS>7C{eLEb}}*o~=5OE+hvJ7sZgGcg~!QFN@1ytcu@p zx_5VLd|hh~pBJ}3;Qqz44bjT_57duzwr!|q%cp0E*3%Z$qxvPEqjKSZIxdJc5DoWZ z;!%}CyPZ@=94}zrc(MWDg*IA0LAnt%``CNjIhZUU0>G;zBO1a|ZY@{#Yf6lRjG5Z6 zs$DiwA9s;Rr=((CO~X(YfHJr%)UjqRgZj~Ve0<7iiGIB#OtG8U)1Y4=i~!}pi3?{& zm3}edQQ^PXzp&o}|GAUJz^?)uOkrn6WMUdQ`)A;|riBZeaQqkli*I;PC=)gzx7Kd> zouf8_-&0mxkne;uyOw3~482#`av1Pt0vSwU77GRwt~?j4W8k$Q=|J*v=~3Ytsh{+! z%hyx=Jr-|neQ2L|UqwV`*k;g2EATy3pX;^s^e?cukP*nTsbzDn-=?*OheOt4S9Rf` zLxt6@VrytPY}MNQxtnphGT&7NhfD^1dpr9-To(xniDd{Hjobpr{DP1wWNSda7s}S4 zMIBOc=+#X#YmEe?M9TnkdwQR=@aT#fUT&jR_pS@x48x01Gy5@=PVQOGpv05hSuug`%*F7JK?jZl&1 z%e!AeQpLDGG>uK(t3&Sx^B|)v@`ecRVi)3B0ERrLCupZ|r36(IQ6m181ZAXfw2Ctb z!M9C&#b9Mi{2oF+SB_{}(#YpW|I^bG(brW}C9VJh8oE-m?u``Y=5Y4{_>@1HDxx65 zpq2n)F^Vk9B!V*%kd%nri>y>pLmEc3vLvS`qB1a=e z21XpO;rUU>g3A{n|6_DV;?VFb2b9mGfOdNMft1Lo@0RfPl`mQ6UbD4)ZTW3DmaKOV zZ1b!wKkccx&3)UmtIw}~7XOqF<~YxBkHYhTmj>>E9Q{}E+u|m^jssjag0_VgGF>AE z4yzbnQvx+f2NEm}CULj;?Q!fdCbUl7E*A6WT}i&7PY~O8RmMXu$PBG)<8k>RzTnko zir)1ZEuLX_%bz^XHYg8Ke*_JDFjwK5Rp?YGf&<(%V04PNbi@y;hc_8s4~&ePkT*mfE;AJ32D`E%;?Mta^=&jTgW zNnXe4Q8aP&5%sZaec$=m6L-FdXS=69%%(8cJ^|TeD9W^mri^^Uf#6ZW0q7p)2g)h~ z1uOjcx$_tFtE63pNeHgs2>kUipXwe5Cm*tVVbb6pPudO;Vvk4JqPt6|&P?zq1~v}!xr+dH&YDddk|F}mWePl z{{YQ569%U~EIN3fVKG8Dh2px1t;{gx#9KfYKwTZg-C8)SA%)TkeD$HreoquR*+7|6 zU4*Ymy3f3iywB8Sop&(q4{LIDtql#Wx?D}zAMb7+i0E=n11C=om~wTIH7!>h@O$o! zFEAQAP$0JMY?^`+Ee^lo6u&VJQbssNopa!rZvI<^ zCw}+vckRUs{{Y;o6-tvuq(tIrD4CE(9RR3Z(jm1VuY&U5Cr(V9IKg(YE98&JA3@JP zhUfqFa-R3_=UK^(H$Hshjp+t>A9kRP-|#k2uK>v=fc40j474E#SJ`t!qy_SLDb)ts zikDBEV0+je`NOz~n?8a+U%jknTkzh8Z_Io)#qVeZU(y4$BNI=ewG_ss9?*pGq+A;G zLE&&{wrH{@zRBM|qYdiN2~54n85t27VbJERHpq+l3rDDliEpZW(Qe>v341yq3@tuu z0d*^bqcZs$Wk@9HHnA2&Xg59TE$kS&p-L???-@*H<5EBfL_AR#54g!DZD_Zk)(Y)V z_&9Q{dfHvjdCRte)!Rxdc2rb58>&BhcGv!B_3=Qyr?kW;9xCb|9bM_$n7F5LS#y3g zF+RQ_c2)ON4PDM+xP*dQVfZN?F9lNpFrCHO;l4CXwZzofSUE9tQWIaav)Z6(z(T;P zpvBK&D$If@)_xo-|H$5Psfm_7^w}C*sY~A~7G7z$xPkVa(lXQ|g;?CffIs{5+ghVk)4G{1RS<2{LuzLle+{lLbz0@wc* zV=zMhVR#aWiUbk#LM;T7kWy&~e!KU+RjcmXyQQPM`|7-14{m;P%LBXfCSDUi>^;2V z$>oO$9-^=ZZG9)*mJLBaXv>9SAf$cJ1yvfdhc{(HOC48B&*$xaV9S%6AKZ1k{P6N8 zR~!Z##)Mm8v-l3X0k{|V0w+?t6ubFW|Ygm-Czr@5i0=`nlfUI(3y;JoO(K$7S+bF0|Lm-aNGrF7@cgOVCZ-A$%Bg z{Qc?KklW5h_7K&>kqj{;-sxXzFqlhx&31cBNp7QS3-5pb)QIT7 z`vTI28%QSEpvokx*&|4@MmhG}gvBz19@Z!<&B+mj9CwZzTsys(q#k5!Ad#7TUHQ)U z&iak=#|Cq0;q?WDR#RjPdR1C@JipLlj)jb!%JxM4%pl1 z-H7y7_%m@)TArVNry~jAfV0LL#w%SpBHX?i&qW1p3^_kIGj{X!QIoYO|9DY}*}xvz z;%dw-X|dazORZtpiD+LD{3R9s;JOv~gX!gMx?DLLNJq0<)z5m1C|w^hSqt-z7nYh0 zPh1}{Th#Bf-gxaC?{^9nVp22!qnauDh3Bv$8cu}#A|V*zeuB=d10P2i7wIuK&$@pq+B*J^U}rKKJ-dkDZlI zo_qYp$IjwC%0HS7;Z~u7eTTotpDdr|M=nA$oGnTBpY?q4i^re-GMf6spJ-m*e>*TN zqkm_<@XX0CezD>YfBNGev6dwENpZFK3f6OYs$1-$Pa#X}!nzI%Kfv!IzOK7c>zaQN zvB2pI?T2ypIq1zukFe_$?b%%>U_r5PZ2Vr7L&sE9ppV-dsCzMD?QM&N&8SRTx!ztsq>z6LeTHJT)kJI6PiLRz04vmQXcF1Uh#s~%M!T``y}3k1OT z^ad8q@0{1s+?c2j1@UShB&K$F|X?fGEE^>mlg@2c2Y!}cat0BDlRx^?C<=O zcF3-oTXbMAzNd8YA(AdC+y_c4W(gPXB6VPnE<7Z75qGu^iQ%~B3mz4S+>kgIiV`?! z9%qDjoaHvJog*ZKkV-u6k;Weg-uscC{rvVvB(wNVpZ0N$Q6uh?eP?oOnStmplmR zOpjnYesZl6oR=ZMS*;*Vg}~J=O2wCJ^HOSfuCP@=83bhGTz?BT5Z~`b7o-a}Y7XM# zk&nLlvrj!*;MQo}MGphy-c)aXLg0SGIgZQCv&?_W-qfk$D^ z9607RbKqvUeVgrOI}#)-@1lVxrr$;4A-&V{UUQEXabq3s_GE z;YId$_Dygt&_xJ{#A8~4$M8rR_;X-gL79W;;Q9zM%fi6G&9X%W$Z%qX_6V_~f}6Q1 zY!G3p)De9{SIm!ew-$To>(rF2gP!F3&^ZL9?#ZuU^+R_40haUIho;8Czm`F{&#3Nd;p@+AfAKj zMST?b48h<`ShS#XUXo+8nySi(zco+c`lY!mw5;q~@)sxcPNcH8l z@BIEfwKwd0bKjzIXj|X;x>HN%Zf7b<(IoDLnJb~?_8thdo+ofh-<3Kjk3+mJ|C#<`tG~aq>N7}+W zd#H@k{e=g05-7<^HLbfGAcf$tQ4h4H6_)w2d;ArCINQwJF_exH)=}CG@2eq~`kiX2~Rmyoc^Paz%7m;;w#DWvXszmR2Fc*^kp8 zg8|-eymq;&Oe9pSrARUyqzaKAWVa)Sfb%gjYmruwUXOQQquSqs(gMP3$a+)!5vyQ; zPb7IYB8j`8J=o&{>?HxEkkcmGc)K!Fjs=uyXxjy1o_Hmx}9!pr&}LQ@MTtI#X22 z4owojPi-H>Js*QSF#T-JLv+4h_!#Qy$v7XMe0U1}wVDDvp!kXYM3FJ%Z&JVDBNLP& zlYhrmofP5ydT#2k!hb5~FZ>bb={ogGFGZ$C@JW4ZMIFr>V0p#03wwkFzo$k>pbkyJ z6sZ?-5JPx2LE??5e4w<1-4ZwUFjB2u;tpf#4&)6ZSBj(=4y;;_Xt9FXIpRkqC{PFYfVci;HfIFOu2Mep*ir+<H>U?Cw1BRtpFsSS8>bb>D!V3q z&oO1M(N&D4c#5QBLu!v$Jy*L2o|A=}pyz8~L+UlhBOede7}7YXN5Yo`1*h?}rKjOqw@Z%E+d6<2>M%Ie{rv4^& zm)u^%{yh1M#K*>ukKc0-{tyBr*%h=#hrSh2-5+=;>rPl03MHIGE;_`9f#7WCd@}WA z%nU6F{6p{oZ3*TX9wKOCE>pb~z0#(8r3gc*p3$dVP&c_+{6TEuf)tHUJSd&)?e7(D znOqtde>w4&xYRN6@w&aY?YepQ&AVQBfq&da@IA@#EWx)%cHqsh+0)$c^8+02sq>NV z()oVO$BWAO$v64^H9x}n3xC7;=auspZoql8r_zg>?||Qx7vTDT;X2iJDMfwHq9+T< z7Nq)_!~`u8D#GPz438?fOP_7PR)pRU9yBR@2)w7_9b<~ueZNm)wt+7x%li9$vJ+1x zo2C)u96e?RlOEaH=j<;ry8beE0UVV99iKZ|7AYt#fM13)AI>Lv?};7#*vQ)Yh6Mi3 za6gehtl+6f1CVT!ptsAyqNYxnT6;6Ykd5#)wwm}neHfJvUOhYv8FRQE&L(cq^Z;QZ9o)a1{EM))1?Cftxk^sZ`$@ojMEkKpR?8 zX!}|Iur&E?)mOleOi$IqO-Xwx(;NKQ0OFDoX6Oa{7AgE$5ImWdEAOLD#e>se18K>V1RB!s;r>7T}qwQV=9=^&e7gVRJ!c$_~zOLS*1@FavdHeyV{AunB1HYII9~tI$ec~ATD@-0`^W`7Q7r=vE zUHag`Y4+>c@q0>B{twLZm?VN5qoqIj0{jAuz#Vb;namS@oHPXhn@Ut07O_>xLui3u zb=VzN`!TqdS`68i9Y9@nox?unv|;q{AIQ;xyUuYjq@&wv27YVo%gY6@3nSTuI~ytWr0E@5p*;O}PG4aABCWSl}#bUQxr>k_Ax=XdX z$};Y_|BG4E;y5qYrFng#R~L95zBtx(H~X5R5}l8@EoL(AhXi0ku-2HvA~Ks*6p(an zOvuX83}Yr}abry?`eyFFDdgql4Cgytc1=!BQ%;H%>87^KM)HEhUblF9Wvk#+oj^;C`v(t%%v^dosZEpe(3g{ui?P4&_?(7_` zfvXx!)-*K=Lb9!~yJdt2v%WO9N0mrQ3_ac@}=h5G1EYQYWd zZrI$_G14He{RVuP5{cM6?oSIxTDi_!TI%!gi1@&=Lk*BDa8439C6wtR&*5UH!=|xXo2;0Em~vLRiA17HH8cGX zHnAWOcvG-NR|v7D)LApOa|+hBlDU9AhdxW?y12iw3!9qotn8RU(%(=?J16rh!Fq## z)_{R1f)nAvt$9PDA(247xa5Q?i|!clZKIO4UmQAvZb$rDy}$n4o8y%ux(FWN!?8F zefMTF8yHMN99_1wZ^`1`MGF`7bkFZ10|~e*p+#tEZfeY&g7=1h=-uG|F7q(ge5B^( z!k1>u%Y-^Fsn5*$nNa6R`Q-C7>7HcuG-0VPnG-g1(&(2uaq8q5-kz_{9nD!kT<6xq z$6&KC#!SlmEs51sMZ*;Vr-9K#(kvPT@WSBE_$=ya7UQ+mm61>|7o3GO?64{GWuTd? zqxr>b8eFa>7v{2e`s(yUG@qCAnwgCaCbtQ~*tV@(Hg6go8Q!>IX#KjuwF7Hbuj*g9 zVmZw>mf`FvfB*AcKgWEh^vAP5GkF}SJ5*kA<|h$Ymq%f@XMeuEmj?r+0t1-7e69C1 z4!BjTOnB-`=8~8aR6RAm43wY7A-~Pf@YTWeZuxDN1fBjSf{CXD)<{c*A>rs0YnrvY zX?f5;H3UKV!T)9~8fD>d{uKHkCDwv00PiS4)Exr&QTqTfr+JPnI0a0{U8r2CMXyB( zjFvtpvs$I$)UOn$30(q_$kr5hOXxzd=P`%b?oi`5W^%XGjAdA`SYS;^GrG+wM%Rj^ z^)5}#@i53A%7Yez*YyQ`z{SK|DZ0XcjMpVC12I+joJHO;cw31*J@Em|5j)SGPJv&I z06BZ$zSPqz%2p-`k1yEI1@ULKB;j;=rW!sn~+oGCUa%OO|Py%ql zK1y6CT_UdY(hn=Q!$(FCJSa$mSMvD(S(X($c8At+v3#G}39uCs-T7+sY;lq=;GpgkwC}df>&C{`zSBJiWLzt^Cte=8?~201dAE(d zyyZiA`xQ_@PxKb_M2%d3+eUgq_%+r6hQ}2x6Qw7FKI+!#doC=;RYf0lJbiBtX{6}h zbE5JI{|gZ^`ft##new+mCKXOV39r$Tvo?H)z-LaU{~@g~DO)q6{SXvHrZrOcO0tNE zqJ!dflWS_lA5UBuh0#%de*A;`Wr5h8!V>XCp!P^$6@&3@L%2Uqb$h4Ii8*Hpod9;)%Ys%=u~VesONGZX;p;&F*5so!cJXVIj#{a7 z;;E6zFX6ZR$!QR%v5ONL^x>bB^>X1`d@b;G1ATSdO#p?w9@ohpV4!$bRE9`F2%cg} zs2EQpt${Br#eI1h;*#a{Z0L42bX@*>QIY%(UOBN!d~xmC$@oR>LDhx!(5NV!z#=`blZ8$)~w4~W5sAdT=Fme2Mu({leZk8`4296u_KpV}wsC*CIaZP-2D zO8bAmhaWo{aZbLaY2=RI8m`wr16)n9re2{ReJXbuPQL9#&WP| zvn4p;Nq~kf$7n@f7MSp|G9Y6%o8XmWGIg3#ka=H^HQ}-3`+5Q|0+eDXZUK9t0;%GH zT#vIX$B}~^EXrQI2x|5WsF9~DL5_dUurW+{IrM&cHT&g-XBmCG^Tmr`$e}n3j!ZZ* z^&!LsRwHXZ_9eXDr1Z{A#c=8bor40gv{F5i4l#CUVq%;8YgRhW?zjdeeJf?4af;Qy zT?}J?>!9{VJ_QOd7h!-`J;H?(JRX`|1;1HApRLa}n{cNN$x*+{+FZ*&Fg?+;)0+RYd?<5@m2fUMZ1l|e95_n}{|C4JdcIN1Tz1@UGG zbkrbP%H<_kIDrD`;};1u`SS#`$@@~@_9E}it8!NyrPh@%{2e>+j;B>vpguQBkP9T0uMO2u%2t`b%!SHjf6Y|LzcuxFCgv{CR_1T8uV zTx~WP%wtFsm9Az^CFzJ_p+b-#R7ESmIlD{HTCuH^D=)I)PVK5khYW(K{uC%21#cv#;O1dws|H zJNPwOguYqlFc$kv6Cy$WGxvdrnQX2Y~wl$S*{(~D7jP=mfM$%TTn7a)gik31zDL{#zc zR;(X3ip4sQ+^h>RK%N#+xjKjsQ5dEB;4qqvV7>B^iqeW`xfX=iAJlovgZf|s0Y6AC z;erCqg=7*u?#M;O#Zr-M?DuP{T3V~tbggo`*L1C|X>O}o)w!x9$1)&asO{{mjdgWh zF;G;5>$Pod)dTa_7M0+7ZF6bDHPAOd7Mo88)?5?j#w~3VsKR13)k6suIC3;1s}2;x z^;!&h$ht2`+~VBSBn1&9?y6V9>yZ5ei_PV& z3p$kmJY}xxbq*fO7-ZR-_OT;XA^VyQJJ+{w*u{=4@eB-&kDl~<7oH4NRfTT7!LWO+ zzt-@wHgErm)q4z>zFn((tuGk!_Vo|!Gkj2d)LWPJv}JNiR~zs*7%{GeQ~!ioPE_NC zDo1|&^98U28p$+IX7Z}E-vp(yFdCR~2P&t6{}~ia7G^e?H|yazCn&X0$O*W5&IjPy zfI1I5QqQ0)uJQ!DhLXWhh{_m*q9Ig*L@@)*up+o9RsjTOqa1)0CPS**LykbG9~TcI zXB*#~z_Zwoi1ova^C?HC$UNLtjo!U0u!QqwHI;D@V#l zu54@gVCAaSt5?b99Sv=}6S4eQVn_atcZP;APz$ma6pL?Ro=1`!kd05H*J)sKWmzmc z>lo%4j0AQJk3d9G z9g$at#rGni&}R2|Z4QqSSkX!0BlUoJsupuOl8<5%pgZ?|AR4>xzIA<%e4_r|L`b{>i=af3>ayZ!HQ@z%~ll=EJVFE{QmDb-O@z&>IcMfbZb125%5J zM`fEuJyIi*PlYs!z5%)hgJ~3wARVTq1#lZIs4b|ChLLcNI|`%8d}p~mV6#BhKm<=Z z8Ml5q8F!pBIUXJehmj=W7E!5)l87>IPHsVMDAK^L)*Kw!xWBYuL`Hw*e{@NfZ!;B zxqMxu$#x6J^J}kdM)BnO%{OoD+0NSckB;u|*?Kd(w<#7waWkbxj|=dR z2%X6ebc19iHDS0Go(AwWr(UVGG68XN9x$f5Mr!{&8|6ExUVax%-OVK!PvE$>qL}FX)N^SeEQ~adkNcrqa_?Guw-v{ zhU4DGwXwZJ74`8@X(Uv<-C&B&3mf5;hJs&!XD#6AqPQ-Afzk_F4O}dS>jP!3A<0RJ zeoWOE!bG?Pq`KrpFzmVwa#?i36>dI%dSfZX+bLVdS><#;}?UKfpeZ}$5-gxfZxud%`bZlTBm@IA1$!RW~ zlyAY$-MgtzR3Eh%ee%$JQs9M9#zeS+fLlP^D$^W;rUS_8C{R*7Y4(IwT#ITB_pB&GG7Hd__i+bGAMGr~3EU z&OK(?ilTFg_$~3q>d3OKmd6y>HKCtw=?26*AYX1J%%Va;o+xQE`>93!YI4!VB#JwL zO*ukB9=!70lJZ=hHOawwF%>(4Y^I2$mzbS&N=Y?VJ@Ma17xrJ%TvMG0dbh6Fuwli~ z)msmDH8ymF0-LBrx^%VCS+<_1hTx4coV@ZEfPF)9Cym!ifXIw1QW zA`K`B4UAtrbBYE@kw5h*1pwI`?1*!G@`@0qz0w*;cm z01mcO{qp>J_+EZJ)04v6}*dAvXT;?z}3 z`C?q;twj|JC;~mt+z0hzvGq3Wz)XIdC(~)?mgX!XHDQ-?!;?UUzWpc zsalwjT@IO+jyifFRze}BO6V_OW#~NJiN3dnpaemC)1y{s-;{rP$!fDZ#>KK zhL_?_2y-;4m;)NMOJEK-g&T_y!vdMxc=ntt5)c>5t?MMFkNc;H%QsKm}=r};_{!V_G z?UE+2`{v9I2K|o%4gBt?{Ia&RIJumC}dbp(DEhk4yKPhW)#vJgYeJZ%yDu$VJZM zG-(Vg@mrUIC(n)mpejZI$_H}-+9Xk;{A8r721M<4yB4aL1cJVtu>1dC^4 zfA`haU0uCYS5;T<-PP4=x;slR=`5Y3vXGDtp}P|#2}?EuBmqKJHc1FONkJ+^823q-m8Aq+0mKLe+hI~b=7I}}YB9^Wi=aGS?}MdujstQsR@PB-sFPHrYkx~whP7B9u*r+Zw6fuTuj zR3ePx$C2g;NG~a!Qz9o5QlIk~mAq=t+&*j8_A^zD^~S*R%oR-Uv$-z+;N5(JG zRoaq@YM~kq&7+Z+Whq5)W{FwO%E_~4P5#Vh>Kgy;-x}*a^Te#lb0*K~Y^;lpi`F%E z${GDLqf%Y<7hJC#a`&v4Q=e9E1J&UyRC>(oaTiP(#Neyf$&))Ck4QzYRO^*x!E=ne z)HjfgzRvX_Lu|B&qnM#k?8olgu|-1*>~a^v;O~;M(5lPaJT&x$A#u7g+7iyNpt)U- zTVP}|MD(M*+9QWt2Trz%Dxxh_RF73zLl`oTkw?kT67`sMhW%saUwrYq&UsWq1r*)TWCElMmv5PPl(3xLa_qtZ`OYPSYg89{$=u%v{jcgg}`Xh|kYM{6+( zPY|)xTZ>V#vqX^=O+tw1Pn)j3qQ7@-Z~qlnZ?c}aded6>Z*}p;mF*KIw6EM~{eI;} z`@M}9!&qysG`lpPtjg|vn^S@pq8s63VEBMLu>lroK$B+ zD%Oq?mg-E`$J!s?c5{BjXGZcQ#n8GkI%hCZm1s}s*LZte=B!8s`m8yZ+RRPHF1zhp z4I3GaQUqk_XH0_uUQA;Rn7}ZOh%B{w8}NmzAQp2+Ha^;wnd6UyNL&zaY>e_#k{7Ji z_EvoWp=FEw%U0>NZBGqS4NkTVZ1?XLo^{s3Y0IJ&(e_HZKrCFkbYMDvm5i&Zt*iR% zvNIMhpV7N`0ulJl=h=S@j&I`+J=eqgjV*~t+AzG@fueEJ*Tj~t`H=oHmzD17Wo5-dGMsp^u=cZ?6_eerS(7kNJ zd8EV;uakQ>WWQKcSeh|Q>>TFkgi#3=8yc=&hj65OXk5d z-^BM9HX1W2WClg;LmsSalL{6MHOzx3pOQ-JJsuU-%E_FFzP0v!^STGDmweBVShL`e zK2KO!M^6?lnfibESf`MeLrv(&P00W{vbns$de{0Ps&YZf`ft&ccQpUrdP}tBcN*W; zYO==fdyxI#!jBweO>`Yc8RTT@TE4aGlbie-4~YfRc`jp6-DM0ugz1UpaZG*9nDzy~ zr7AvYz2x7x?NGiD^z6eUX42tne+6HoYX=91=4;aEvC3vnd?aM+a5B8QnwQ!89hLYb zS>N#n6D{~?+^(sqw}}oRLL^B%=)whRkKlG@gt(%wTR*i~o@%gq;d7H(8sqI7+&7Q^ z)6Rc_=Mm)}$-mipxp>ZCydvHn<266HCKhIqPVrEQy?3s9@u5c_z2?zJA2FL(KECa-$F@B#EWzl*rgN^If8OJi&e!@Q z-Ip+GwX|^B1U&Cm{Q0`FaCPzXYTn30*|&Y`KU5YbJLkqt|GfTZp7)GBcKqT9u4DF_ zEHD`}7|4z5J^$;c`u@HI~7hiw`{k9--PiR^u-a@DSsvz&Ka3clOg*OqD+WxdRKJ`*oR{)r(!&dp??ryyHhaES@jnorsLhy~GEwsKf`b z!1KF?2f7A^yVk#d^844nc;%I?S6+!g^X~)0!vkjN$iJ_-=%O{-u4=xD|3R158@tdO z@6+7ZX;;^4o`pZ(qJ*&+zb`6$6*gn|Jwu{ky!rzMP+~?3Y~F zmHn1$zN^3I8sKI3Vl#dPu0_;|meAQ4S>X?WP3d-^qfTO}C_TZc(`NSUwEkhImh~;znOIu?#kZocYA$+!wy?O2s0Wnrj3xa$r(&0P2#H zxm%x@NLC=pQAeVs*IhmH>g#sSnK^Cd9QE7nS6{u|imTtwnlopXB_--y+{Xm>9-K)R z51&oNLb?+1b2QQ|wFlm*A7Z{@#bsdiTPrRP0!X9h@V&&>M2H8NsEkZxyz+%6Btt%C ze3~Jkx1bdw!8?|8z6{0GnBU>UzuSLjKV=wJx+0|91s+`s<_CE7?DB5 zRC zcFCFlf8(igjOGc~|F}g;Qp2+O&N*x?@?#ybGt^e*1Ro_uCh3 z-(;3fm=;ud{k<9wRp4Pdct{zof`=$4OC#HQ6b~34ZX!e_FMo@x@fNtz4X{(p=*BW$ z3_BIx$kTXm?#aLTF^`k#slOINU9ba2LdC9Dv>0qU`TD4ez+qR@tO|E(01fN54>g!Cb)! z*x01L+gTfMSW+v2o+5epdbQ|L zmXuZ>wivnkIa`dpPkmj4Mr+ru4-Br#%WGZ_^9il2>=ftl zKt0V_ttTVeQ5*^`jki|ok2|Y=qRPzng|hxNtj7$_h$_7!Kbi>_vm@)A9Vz&qg!zjd zDNCK*kvWF~g_~E%p%7dZ$s=MlIJzV8muGk6@o!m?Atr-{lMShQAgPiqDcq@0>=zE- zK5|3Cr*Q>qwk?K@nuj`n_{Z_qPt>HM!uG+5vlh*`c)_anL7I8il(((CZv3>K*_lgk zS%1r=YUds0mkfwW`UIQ?E^IC>xR?mzHPq&a$_wS) z%q9tIxp*V)4dK_s)qBDPz?eh}OyCv;V?WtDG$vYGB;Q#uq0s<~5=;pFh{Zh1pxqOT zvx~y6!k`fnVdXm}%^JXre?F7Hao_kK|7pBxvwl!q)ZTgR_$fWJtIJ!??Z2bqVg~~b zO4N_R!Jhh_;vLX2Fvs8vrDf~BX;x#FsgVJ>&3K z;A>L9z1&D=>dQ(g%o^5}Srrk05-r6fu^Xt%6G>^}(br3r?Or&(FPRANWW0U^o`LZ* z?&$mGFNcTD|CnVB3{8K&qGqyYaZ|Rf^bux;h0%XaL3^rfzy=RGB7)wfh$uraRyFqRzt9R7pGgSvd=M=!(#C#H48@*=6GaLl zH=peDkP3w_AGH*h=F7HBNEDeZ^4QD-u_xWPWJmk`&vy{n(K^(+^zx3s`9{av)?3X( zEh6rH@Zz02^~+Ugg1NNTmT898iVAUDn2F(wRFq{o>10wk^Kx{X2HNqptMLD%3U^ zCg`QPdZD@6$_m_Vxc%ff{~w~cibR&?T6&v2Gm+%GbYFHpXIwUWze`>j=-A&8&#J2za(w!gNHC(f;>*>KAt3NGdw?Db5)twr1b7kiw3wgUOFL!I%*9hn5 zYjK&f#3C+Je+-)r<_ZL6;X(+^aG_O21txrm{RER2hk7_)3(O~VlO`a^!jyd`v5Imo z>3?#tYk4v15KmTKX71>mHFs6-R~I2CU-*$V?^thC_-2t z&4K4Vw*4{wCl6+9KbaMDTR3c1a)0nqQhK_ijV6&L#Fo%6bKmgp-NV=2Fnq)HORn3t zWcT&FUvk6lCD$X5tTWQ|gWb-3QlHX|OMn81;y7jBBthz4-=u_fg(pn4Z*!qn8 zn=z|lpyiCg3dyEcD1p369En|RF{kz;JJZ%^MVHetxtg)>IQ`;}c6{yM&Ko@M+Rt|W z)4|jGU!>lnDze_;WKJ}c|DINr0y zI1^lr&*M0d$9d;vz3k|GGkIRUNf|NYY&##N8U`#1o%TS5f%-&IfPW_TOCIgRQ+$z0AH|Fa0#_=TwRO=foeo*6g*e$0$Rp zE%ps<8DKZ$Mh&9*YOA5`DYw}bQjd-^mWXia$k}ImfR2bYK3Y8B;cRGe_6-vP?s@7g zGmczMg-P89v|3D4jx_Ca^RzJy+7p+@#^f+`+5(5>eeufA?>H|{3P|LwQfH}~t*ien z(Gm`~B>qjt_u=uq-<(O@S`9f0Q;o%$fxd={F!2|X6M&r{;Kv~m4(4+L5@V|0_>DEb zJi*LxX9wk)jh?QKshv}s8*AI)0*R6$qC#s_O@Vm-Q8sM5U$T29jxZfuj0Vz@E;7Z` z-e^p z2|h>wxTlk1T?htawu_;~R~A3BYZ@+t(`Qayv81OTpTeR{Pj&fA>E_AHx}N2yw(^Hf zSB?9?01k)h<=2kewFtLD-LP6e?(`~kdQtDWmeqUi)Q*8F>xmNRqz(V@1pI9ggPh@G z(}-wZUITrb*ErvG6uW%RxSR;ko-mTpoPKDpJSr}1+P#k s3n!(`lCJDArN|NmBd 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

Zh7cd=34FaGWy`SDvi)66*nbC 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 RXt+-ctVzYaTEQjd{{RH8A$|Y= literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..dff66cc50702c75abd025dcf49f62a4dcc2d72de GIT binary patch literal 273900 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 z

EEToLXFWwNs|a3YS9g5P8vgq_@4oHbUi==#Tjs{NaLc#%Wvpj^p78*S8IS)oMb(kC$$8?xr6Oe<}VB&6G zhlolLQ;46wa0p{p#E@7$@n!y4TF7P#rKyX6jMI;4FZH9N>P#GW{sY9-rz$i$I?> zHeVD%&K}S^*ztqQKAYw~F{QzXM#ju+MD9Or31yeNCx`{ae zvvdG`yXa?$)|99>A1WrbF)&S`a~djNq;&W>@i+^IQ+zOi4Qd$l&PEJb*sv%h`uu=t z4(v(xAuHEn{uA+6H0y>qSUk#f?il5e5?5GnF&=401S>M)QV=u<<&!X>u}L$^9qfc> z5HZY#9fTPLB&V}WnH*+kcCtP?tx=Y#@k_tE&QDB}7^ok7(=fh=nC6~wqp{iL$}}3i z?&MS71^2w}I%1vIUH4MD$7?X=W*`U}aDj*3$9H1>jEH&ch}aTsuz<(So*mH_m^+** zR|NuTY3A@J$9!tjhUipWjV=Y+bj44V`t{lKG;59&*yIkwCbu0iWVPtgS0AZqsjfmd zV^5kZ*_yz3U1>%-9EXT_)gqk%HoFG496^cYWvtAbYCx4N>c|&@tho zmdesjhk-Dt`q(i4Z&8Pss3A>-%tV|E-00|Eg4kXgg8R&}{-=;?qmL?qno;#U?i?Hv z2*kyi!VJQsav&}i3&(XPpwLQCkH$sLyPHx(s3?*8nM4QPN3z5Exf48EToSeNP4l>`wcpb#f0 zG~Hpxo-eCz8wFL`#$E;jCp&{|JuZwSki;+ND8>j|uy<;UD_CcAtIz!T&l~>yXWqf< zWd{f+>N0($>~8~q!~dz$Xekmcw-3EE$_#2T8|7a??gH`dEM(j4*SJp}I2%)tVvdAf z^f#qWN=CyrSVlk(BUoDxS?%P@0B#lY0ZvIjUQ*DfLI$FRVxUv5Op!7y|kFhJ}|M~nUIX!ScEs&&>T03(kr(m8Xbxq z`r6buOK~zCr8?yZps{EdS&(a?UqW)Yb~=r>CO_ZjLz+sm9mh3GkT2j1VKd9g7BjcCWDTH|4l0BBd|^!E-1R_F~xwaQqE+9RCjSS2yGPemTx{>dDKKH6ifQi&8z?s@eSQaM zk7vg7X$4|PJ0UDgt0R&X3E(_~Uc`RBvosMwa^iGN$st$m)IMG3r_K8tdrn<${B~@U zZZ}>@TX*2Pf#?oBAh&EjKc<9O2uSH%Wh#shox6KWRddR4;*V2{-al&IkAQ5ZrDj8uQJDn`{wsH<=E1ax&Ugdy+i}BTr&U z(rZj`heIF&4fq1dC!T*d=JEhKa=5XeIa7Op(AZ@kaz%Lop5V zrLJz9NLCV?PCue-k1UCZM<)uKl$6syYP%QG{{8pk?Ovio)ebsTfR{v89w{M=Ai^Rl z!%E!{XGJXt_=;8*PG`Ci%4#q+K^}UhYL89FQ{p=l&EF41r(kkm5@UQwr}qqocnK$( z6P(?sfZFh6R7jUbg}A3pvn`m%ACeEwPJMLuQ-;r=k73NnFn+EML{8>g(`^mVqeq}e zQys#PAqzObj;1VmPIl1ZCz52XLY#9wE8|yS*QRrj`;RDAiXye3FTshhR?kqVKb(9R z`qDR`i#nO*tI3H8OjJWgBPoLfSQ0Zhe5}Y@dDEF|Hs?CkG_hskPO;_Ip)a5a7X(hz zeH&gGRojPNp>UTNo*eMRcShPXTySJBT(gj+8XU|)nnjA#EL=*BM{=h?*l!L`u`JK{ zLvOvcd$-}Zu3il9{-WWo*b*CV5AXtBI7`RCz6iV&vj~4ltY+vt`~sy_X2J3|N&Oo5 zBa@R7G1@qYt}GxK^m0KHZiu-Yt$(Hw7g;2xj3Ve1z^sW@X!E$tf)QAV&g2M)s`168 zoc;~KeoFbv>BR4=d+-%&pCAR1(FdtSpi&)=^n3?a3)Xu|5(NZEJ~gL4I9;1gghrU? zN<>%yuGf%B>pcpJM_Xl6@a#4WjKAI9Zb%uwe|J1o+G?KyY;yASd8`BLZ8g!<>2yG< z(I53LrykCH;(u8{@~K2t87ZTH9=RSFaz?NrBWDSsIZOduYzChdB&N`c45Y{vN$dx{ zd@M(}^F8V>bYq+Hfz#K*!uDuDvK)LHFeDg>?;MM*{eSgYr?1qXWqkEirqO1adRDK| zVb-68YGUUjP6swcxQyV#)&zGXAEJmMDjw}5Vvq%tVLiaWpdgceENzNhCRF=9)Un3Q zFC7{>^|_}p^}C+o2PY>UM5-Z}=_KJ#j)`oS4&tRU2Ot^_MAk|Jp%7j?LZM+Zuz83eqGZ;%}KQ4@&BM|cI zk28Gc`@4SkyWLNXZ;n0g7wXUGanpE`cW$0EIXV6V@U1UOzU3Xi2;&PWB@{cH$Z$Di~7UQqwG!N5u)l6Q_1h7jaKYlD1d|Y__*1A;kZDO3?-2OdmH?f(FW<0n}M=1d+RAI5R;u|LC>s?-8Y;2r91u%RrTvY{aG1oR2U zrP~)5ZbNaA4aEbiVU0871gEDko3|#uELIN0lNqm%^9$fQE*Q945uy!eAi94!@5vv z)EWojzhZ~AC&b%^35&X2*gqZUGBU%?3HQ9PE8}lq@Bg+!ZA+<-11h&?XT(zQ(QXcD zcskPs_vK`iBCP4=FlRVs1FnN<4A8jsiG^))Xdizpl}y2@>LRiF^o4{@b&;zeA)&y< zAKRx~a{AizJv`YClW>}yr|lry*~j^Q5WpVwEqj{L=uS}I#`Bnhc(22d|DrZDb@`4w2<`0J0Y=6$zOL$GZlrml?q25Yd7gb+UfE!>E>;kqZNC zCYjZyheJ&_u%X5#>}T_t)wto}T@$az#eRLK^qg?kvQ@CLtsdn=Rb|u6YLz80s~IZZ zjs}3)E&t_$t=Mew>{&(@wyP{VS5-8dVN^S>cOIjfGX6MhiEjcAVb;dFDTBAWrMUtH ziT*}x9VHP;w2T2Q#EO@~R zSt+|La!EE%P69C}LdQ*1#ys2!1C}#k4D|*SmqmyqXc(zHDcvoSc~bJu6lBVwWeFmG zHO;M|FU=tm5*icwi;BEn6zeH1qRLAM+41Ik^(qT<+5j_P1N2j+J^89JQ_~Wd-{cD| zOU*&K%ewuZAT={3{rtYTjt$fopr!EsZ$%lf4yl_oNmUJT@3jS%DjwC5vP9(+LEQPl{ zhVh}HapRtQjZLS%(<*7@y6Yw$gx};igv==!*a7~|j7e=hEWL+oJ3|%33mk3gFF9A3LRd+t2_9iUTCIZz&pa$toa>eR` z@Z9G~w*&QRjmgDQk(?$Mhy1X8m`@dBZSFCtnhN=z*mJM>vX0JouZp5scTlZZN#eXV z-!iV?|F?1Igz`CE6&?by1UouUz1*gg%N*m-Wqudw=&|`A^q$@OHxFu~IzWglN>SQ9 zzI)g2f4_V8_{g4n4aZ?n8jhhB8Ubi{U|jQ-h5Nz?Y-)0 zlJixJIZ8+9>3KHY9;;AusV_{o*Fn)1WE?zu4CUjWXpb1q8-IR}gz67}h+Caga6zy5 z8}Rj*hA;S3NVoS+d|AWWqfxx20Pf|eO7gjgb!C4qGS97~R#1WYT4sjV&3rHkRFuf1 zTadCU;fmW$zBg(mmMAQ{@iR*tPDyqu(EPEe>*zAnY#V!D*P+#RAF`ABR`sEl4FFoc zY)MyVTT4TIb!8wWpZ@t%T;#|jv>-1-si|(mEB;d}kAX&jN+2&0uei^EIetcO%PFYJ z^X9nSIo`bLV6ZknJ2%yxo1I@13|8l%WnjhHp5|b+KMQB%=H%BD1Z!}HJiDquyK*S) z&3M#5kr@hQ&U}Rn(M@RPLHgUP{wy|2(!K(};`zv!Ivz=ALAw$vY61aTn*++?LE?}J zZ8uE^Q6NTvls#tW=!$kNY@={#am)aWvk3nk#D`+u0@N#JB*Qest`w+w|2{57zQ8}HinE4o;j}ykud;wgjS(!_F7In0>HaAkn zbx{b#C37?Tvihi9SWHr(WS40o5|WTWz_BExIHXS}@`*FbP^Ui_rROD0!75O;+nt@| z2gT2%avZJdm{-!OA&i5Xn|tC>KaIR~xrOL>J9{XSkb<7)NpMHnsZcBNk}wbkeb8pG zE2!2AhH@>U5DZjGO)=PaS;@1HNgR}46t)Gm)tpczDXFU=cBR2)2OTf?(!od(XInOJ z8d|qzpr5!2GAEkqYb(o(p{$`sM4A&dKG}A@oxHd~AUvemLo}OSJY#tuj0TM7^o#kG zd0AO5t6dl}+|u}eS7so_ZvIu)L(8e^_4ND#uae+U47r)oar3M_6v~R9p7`(6rYimi z`VMJPEr$K!P52&?Ss>zbz~TU@6A>aaj1I{xIY-)$!sI}RCTZ~+URW~m5-A-{ui5N% z@*i;Tu=?qJ=W{nDl>rUHZhb?%gYHRq*7)D>tcUO{Y3J~!%TjpyBWK$=vRSr#q;bY0 z&2idr5Il4?0Ii~VO4n6=>OW?|^I7$kDW&M0iB90b$$rWI${#^xtbJOCYl^0j?10+- zAcGJA!boGKcwjbykQgup6p$~1v7BlPjLkk7eR8yuflZt{7(ugC^!t(RHDH27F9@DP zP4Gf;y`yQHo^^-?k7`el#7(BrqK2aUP*!ee?b`0*#-czd(^nu;)CyPkFSAQZvkQDJ zEgb{&tuV(p;78mk{OND&{UE!ruRo(@P9~IRV6Alm46!^b68#H{ayi1*D76w5^cmVG zqx5QQ$dHX$0vd-P3L1gY2`IsWGGHT!c13D4sxG5|g00ntaFX^YH=KP72P*5G^$vrP zVE1Cq`g#|y>R#2>(%e{EU0RGb*oZrE!paCVb@46=f}VyZ%FZM_h4oMis?4SZNyBD4 zElUNuG8OhfIF^yns->?WjdDxX;_9N}%$<h215kE#<{E`HjnMb9S!WhuoHorTja3 z1Itsia)RELmcsTReQm3;UpHSjOQgO0&=(k+*3uGwmA7>rO78pE@Zo=Gf!q|^_ff$Z z1tWMoz&?fX38T8dPI1J=ZLBfJOKp&KvEuv-+mh2)pfE79|1?0sAs+xMH% zE*0`to(pe5+V{iZI8jQ9RzN0lVrPk9HRKTc#=|*9zB~^JRMiC#u5w6p!Uj{&v}A2X zYEBl3TvDJ)#Tm6hjUOhz4f#6ox+N#i6VITQG!+(x;}4KTK!M2QRpaV<_6Gk7e~8tv ztF&$A|N3^$S#%T6%0OX7gy(`fYj_Q9=Nfg!ui@y%F@4h>j_$t;*p=U)!Jl>Sy;Y~B z5%!Nr!a3*(v>7#~WaBd0dmC7?Eg5O}Xwzajn8J}oe{G^2?PC%Cf;ah;W zFIK)q#@SgxOuZ*OxV|xVJsNk7y{{WYBGGFF;-{52^|< zTeNwfft(9q3eR=Nld~rCYNVZ(lb!GVJdrmqNagAIf%JHSj{iQ3+T(xB3x`G6KQ;Us4^M>l6| zGl*y<>Bmf#Pqp!>HOKh-=?m76@1T$3{h0Q77&PvKzs+N55q(GEPR^G!w3YlCK*w5 z6A?fZ?x%s2>Pbad=UIc{r~n+yc@nfJhy-Q}e-@n@o;?akeH@imghUE9~C*8YU;7u$u6&G!ibP!eGfZ2 z#7*@@P2K3VSXWdY^!tN0@tXS0P=(fDu_-kp`x!qmtIn}B?|&F(N>D2R%e3&%Qk z?b_9Hv<2_<(Qyl*Z27M3@Le-B9PLh)lr9~qG}__-kG z<9MNT7fE-Q1*P|3l(}c%$}4edSp7bJh7bDr$<6!-`-Ctl#rl1I%r8_dxKFO^Qi?Ei z;C80tj4D0E?)GEtZm5~TZ%4k^k8rJ-70au0LC?5cOEI{Be93WcKng#k8(|SCe!TcH zz@~nh0KA=UsofSZ9u;?D&i?@O=Al@9i>dK07*2iELJf-RC$CaaAP}@(BjGH>_K$%M z;sbs*Bh+1hjb05L!P<6@!-HK4vV#e`6alQn#ix?YBoliPtNK`WAdrpMxc(`w&&$pU z1ah+TqMtx9_T7)*`C0P$A^CJldBDS^k53{D?H;u!D*6-SBVyGTkd5WXYwGs6=Q454 zq*rk+VBR(ZIeJvW^D$hzabefO@1uTf_sD+zTA?=M+DGKIW>zgnquZ}% zq>s!u_Y~qrH+_5GrbFuA@kV#n;F{bl3MQYFu?!kb#}XX?b<~Bc;Ns&+Ytlu*$&-ZJ zT)=ZZ;4v{Bw~+Co+#Rf4^Z!EMKx6}w5+wPBsd0!|&HeD|qgz)E(DVDr$vVfq;C8~j zaKpF+2iz-V>R!MtjpjbO)3y)eUj6+Vj%&r(u?CF%cd?HOgB+B0wD6lA75 z(H*i-|57Dxh}N8merj;v2`~L0KwAZJju63xxsK1K!SJGlCrJm}7Hselac6HY#(fEB zuR&?V-qcUm_nx5EY91mQ2Fnhuaf(x-Y#nr_&WfbbFM^y*(|#C?P=y#6gZ=qaB#JZ` zE!z>*H_ht3)rr1=T?G}+LUB#I%aPg<3i#3x(Foo39l&s&z8-^e6qy=tqtpsKTk;oF z=XNF=5IrCVfhy)kywpe6AKJwIc#}&|A5V{RV2Y>6edQWFCq9Tmazt|={GS7tU8x*+ z>n;4u_+&bNee$@t8MWHuaUsWg0B{O6NZ>;40t%pZrQsK>hM7NTxPauH;*a7&LC&YH z5d1zf(A!kOU+)O?4wTUF0rN*DOD3}r%aRaX|MHP%;;nihzO4BglXa7wke5OvX6i|5Ezi^y zW;gk*-ZC^bMMYFTaRbkT6^ML8?_FCx0dS7lK!q>L#AyU)!iI z$q{=eZq{&{Hu;IkPk_$PE-x8zy_&{b+tnxcDdMz>>ml1*Ca&i%Ll!Wbc51Q>j=hd! zhvczcv150MYdGy6X4CH2vESp^L-N?3*s+_$H6mFa+Z#J}A3s%gjM_`zfy{}kxXZ2Ca#y#K_p4}vbt zrc0y8qO=Xw;u0VQ@|jWv`RoC?-bx}1m(3O`mnl`z)gArR=JF#-CFHUbcy4aw%re=W z@|aR7r9Vi3p#Nn`G32p_K^LA#>YQseCWk4-Q8^6PY&`Qdw6;n9(yxL1g=-#>*Fffu zELis9xY2aEi|(kyk&Am?k9(R34`-C0aGQA9iS7}Vo#O9tM!AXbK*Q2yCfp+?GbusH z5f4LVLRXyy%1bmebIM9eaE7d;LRNZJ(pc3Q>%z~a1;o$)oV+|HBdPPrNJ<69_5ft0 zZU0Yd^DSI9QYxY{QS_7ed9AVp@ZJP^*7`rsD;IHHCQ_DYGLhn&tWjQoJk$N}k%u@H zK#iA$$`XCO0~JkZ8SZOOwWoFz<#{u7SqLP!wo}3?!F>9Cwrk3nBs1 z@6m94N=zBS9wt_Tdmt7lFD1_idjr?BLA}nNTDxHh#PzD8!b&CBF`$tk;qwx})}WlG zeIw!}xe78MY1z7Th+9SlO$D;mQyrHbQcgeryyR1pS>pD|&nZhlyDb_{Q>2h{0Lx6k z)Q4Pf0k?t^2f56{jvmvbDKwmRNok5CDZMF1;PvceS8rpbxV^)btP4}|veeo5VZ3ZJ zw+t18Ty~X|%RFqsJRSAN^#(dpIy;lD9TV7MEInRknvVycNmlCrswO7&o^V5qPo0vM zG&xCW)ylKXCq!xGS*GNue@TYw)PTU0N+C)x5}y!cfN6&0JeOcK7=iC7UbfPv2#j_? za+O*OdGj#j%?0yby;;Say!V|r)=O(@vK9CK&{=p13W!L1D)g%-!AeM0QmP;;JpkF^ zqfyy`y{=S3Ryu)WH^z?Lr4&O}dKkw(7CZKP9D7I}`*`fwO-eCjB|7gDv11qFyjSG0 zo8~xnbL`krB?uXauDvC8?4(d3E4_+ix5kbgRVr{E!Tia%V@q)CCLFs>JBF3|JY=-v zkiE_zN5XMnu!`{0Lve`x2PE!PL1UR8VjA5k6^EyB~4=h(`GlKS&@bDXz5yV&-6*%^bXPj%K9zt`U zJQ(r7uY%$eaP7O2vz^&^W|BYO3KQM5{oa%$rsMCPGHX`2qol@a3HaiM(rS04w}gC- zjD+eL_^gF}T%?`jb5Z)al@JqZyQ)C-aUHskfs30 zXH0%ye+D`hqRuZpL)$EYXF{}cIi49Uq$S;{dWC{n&jao1&%5=?(aY5J^E~f4p;RAqRzwtXNfm+*#LSO(aIXOiahYcWEdK5@QwDKgP<HHjvB2G5N0SCp7pIDegevd&+Ce@tTD$P3WzSDx?aT@FUl(=HO6=)*1>=2&-gd;%3~Le9#S{y%aEu){&~;gdDD3%k>443rF314 zEPz+Wo%lX^<;hA*Qs-J}$*O=ZpiTwdno;%=gf2Vhrcy+P$?Y7v=%Ui#$1}3`-Fxr8Lf^3rFW)Q9 zofw}e4#jk+3H6D!>-9r{wK(@h@Yfd3i81W!E*%04%%O+ zWjZ2YuokJuhH03>J`AQ6vVm%jDJN5=*E;OTKc>QR&V;xYsx>Y|W7kw8UJwum6a>Zu z&~?DMYv5OZKIemWw-GV-mflTcc8|$sOyD0w55SS$i3joVvbV=m*^$Lvu#pGxJ!_>; z{UhN_Vbzl-M1isg7z?m!))ncfE|8gJ7z&!c8F!Ic2FesSQ|m4!bEMcX*ouj*;H(Ta zp!~VstW;E$L|?^Bo zFn5tGs_znYZjg;Z6HNohSi(#6Krmb_IOV!z-P2ipx z{vah3q9|i_30hvG=P*iUx&u)Yh^B6a*FJeaxBll3{p4Wk4=?hCR`DxWsimv3q?f6# zFta{K`fAormJqC{#)g}N;4VU#82w8_evdqaH`x01?}#12U)phjNRyEMU4h+>>Zx*r z73Y3~E!Dq+8)0+QjwASr`}OY%YvC38cLOWsd-U%{wuXOF|88Q%{4M>v87*Dh`gaRU z5f|#;t!$aNUjLrJs>M(A?{-#A1^6U94xXok_3w$S9x|dljzJU}Zq&bX)K~bL{#~$g z!>{!33QIF4EZRGA=+LgMzODQB9XfpQ$kxNV_U~J?cmJVnqtgy`9^AiY+dkj6^N)<| zJ>=WFYsbjpBL}w~S~Wr^ZW%e~+s+oT{pj_-#Kw~cL;*LScZ_}qzO2j!WC^1g@hoI|V$?Zhka zq+RliBWw#R$5s2;c{n=zDRUi*KDTI&^QWL%1!#5vj=g~RAVyP;@Y_mOi*_K(Fb;a3 zIil!&%Mgwpf9>our=H@Qaqc=?Nn_rH5$%(hoO-SgBR-78lM#uX{lE!flJH#!M}HL{ zY{07)uP`bP)Ui>#wy?QI`OiVROU}gzo_829j^O^=fOW#o9vs^b$mX8sLxAWYUimF~dmTY)<)|0l0n zB;h(F@3|GvrWLKtIMD)e1#LypdHZpehR^xVB0jSpRH5N>8#^EO7{Qgqb!b+0$T1R~ zZNsru_$zwHE%;8evz=r>`EPO?7Qen%Xh*bn(+}~P&%TeX9bv#oN`PF$E$n-c&umCw zuyY67!V}q-c@o-oI5C8;u#fQ+7Gwo%f=!~TDH=XtulWP&n51C=Z{r@G&YnUNUnbAu zUY^aK<~cl_@zX{g}7%Hr~!7yo2rFo$M!k z5%1!Qc{lIjOZZa0jQ8^8?Bl$T-Oo;d3H}i*rjGaX6?}lz^Ofv1HqKY^)qD+Ki^$S- zd_CU)gG~c_fe*2tvYmV*dy#MA=kRm+FyG8a_!hpEkMeDNI|{TnvL?Qh@8Y|$TQ;L+ z@mqW^`#L|5@8kRV0gh}@eh|42hxlQBgkQifB z+kTuK|9VtxF}{41=V-_K7#wtav<$RFY-`B&L*vDK~Q z5A(0_NBDp8N7+RnzKi+S`8U`H_+$KW{sjLf{}#KHf16#xpXC3=zr(-FzsH~A-{(*B zXZW-1GyDhaGWGzN#@pCh*YfB15BZPSI{st!Iy=pu=RaZB^B359{!{)Ue~JH$ZD5z9 z+e<-g^x@z?nq{CE8K{15z1NG5}9i2o1&BY&I! ziETuGr~l#aut)iy`Cs_|B4g}t>>U1g_B;L${w{xypW@^EG@sxqpM-N2E=^bu48n*u zb!cCW1PGf*K&M6rI$|Y>WZ^{iuS=u~w@5>LcDl$AnIa2O$k`%Cktl`?S1QWTMY=*%iYiepYD6u1n%9f4Xb_F)7~Cvc&`YijT^=Ls)9g;sAv(n( z(Ipm(ZqXx_pg-X<(JPh#Q|}?I4BN@!{UgzKwKy;5*Le0#0SKs;xci$ zxI&DH4~i?rRpM$y_FOBj6CV=SiyOp;#ZmDQ@lkQ3_?Y-OMEjc{o8KaC6`vHhiQC1e z#4&M)__VlF{D(L$J|pfDpA~nDd&K9&=fxMq7sb8eKJg{-W$_hpzc?Wt5D$un#7Xg0 z@v!)sctrfCcvO5{d_z1Y9v4rDZ;Ee;Z;L0ze~Isi?~3nnlFMc9k5I+?!ikHOC#LvYq#LMEB;#cC=;y2>I#Vg`f@muklcwM|9ekXn}{vh5I zZ;AgAe-v+vKZ$q5pT%Fq|BAngzlpz#e~5R*d*YNB7pK`VF(Fhj39~c#qEMp)egzY{ zO;{AGVp9?nyW&t1l_Ul6PD+a6Qc@MSlBRf+bR|Q{RI(JWlC9(@xr$HmD|t#l$yb6( zff7;*l_I5BDN#z5GNoLpP%4!wr5YuAYn3{sUI{CR;Z&NGW~Bu_-ZrIOi6|XPr?N=t zQWh)SN{_NcS*k2kdZm2tM$ZBdONXwJ344TBS+4Xc{mKeuKv}7*QdTQ#l(ot_WxcXN z8B~UpjmjqF9OYbPSlO(MC|i`R%Ku^QUEr%as{Qf(oSeMQD7@9%H&*)wacS+i!%n&+OGGkbyAVJ^7H} zJ!Y@jXMW7=Hn_n;= zG{0zWGQWho)Ha(B;e_Ca%q`}Z&8_BF@RpLN-LB2WyGo<^u=!PUoB1_!yZLo$8xv?sJDweOj`&F^a8(spTwv>#%B*4A>& z$IQLvFU{x8e>G2nf|-8f(>9N?1v&)~d7Wtp;m?b+vU3K6!Yp^&zX#YOP|8-1&HuH61H>|EZnV_G4o4IqeFpB7aePQ2T=RO>GCJ8%^2+*3H%o>lW))EaPmo zW@=lshqP~Ko2`#nv#fuyW?LV%Znx&(+C-1`tkrJKv*ue1tPX3TwaDtUx~#?49agus z#Okqntv>5xR=>5>8n6bfW!7?Qg>|R3()zfy%KC)0+PceHW8H17weGR*wLWRxXMM_A zXMNgQZ+*tP-}-R6taH{Mtyir-S+7}t*1m7O zZvCfq-uf@=4eKw~1?#WYo7P*_MeA?Y+tzE?e(l)kU`r+r&?_Y+Kk_w#UxK z=WufEJUid^+CJNF2kf9-U>D*Jmm+((U2K=wAv zWskA1va9W}c8xvG9&dlZj@VHowRH1I=dd9MV??^ZC`^AfnIBW$ZoWo>}I>g zo@lq)ZT5BcB>Q@Mvi)IuihYAU)xOc5X5VB_w{Ny**tgiX+PB#=?T^^_h>tzn{-}Ms zJ;$DFx7+jV`St?4!(M1FvODcAd$E0o-EA+id+c7j&;FR*Z!fh6>_K~(z1&`5-)XP3 zKW?wGKVh%7@3Pm}ciU_2d+dAdPulm{pR(84pSIWApRw<^KWlHWKWA^WKW{%^f5Cpx z{-V9f{*t}fe#qWpf7#w@f5m>-{;Ivr{+hkr{<^)x{)YXC{Y`tP{VjW!{cZbE`#bh- z`@8lY`!Rd3{kXl)e!_mz{+_+x{=R*{{(*hae#$;%|Ij{c|HwXKKW#r_|JZ)k{)v6m z{;7S;{+a!p{d4=c{R{hq{Y(3K`(N#o_OI+0?0>UQ*}ukWVz#zUyHEQJzNdD-c8~p{ z{Tm#=I;X9*U($})zqL=>zq8NSzqenu|6spj|GRzG{tx?{{YU#%`%m_3_Mh$7?f!@2QZYi5iE>dPt`H-|m12|_EhV!F6l%n-MTTg7c+ruc}MCH_Us79SP2i#cMhXczOue6c`uh=pR2 z=oDRIvA9EYizT8*^ol<5G0`uUiUBbwmWkzJg}76!6dxC>#3#gRahF&l?iOpsJ>p*R zNpYX}lvpP|E!Kj(;xVyTJTCT$C&ZKDdt$%%zBnL$AP$PB#3AuRaajCF91%~8XT*=iv*IV>sQ9Tk zCVnQK6F(Qn#V^DO@k{Z%_*ZdK{7Sqa{!N?`zZNfw--xDp{T<6XvIcv)qODD>%5Sb^ zKf->D{e<)*(Uyq{Zz3G=S{UbeP4$d7vCndvVp&Z~+UNK8_GC5nF6!;+xFf5nwSE5J zKu2!N{I34_gG&~6cdW>1SuA>xluS+W_MMa{qSk84R>DDAH z_2o`V^N8zRwrycswA5!^LoQWe(^bagN2$eo;ysieni#T2rRn3BqIBpPqFrXWo* zRRv9T1tpSZYtJHaqbu2ooSBK7>4|KYiClCO9lLQ6wzS$ecJ}u7C{s;jrCXg?%r%j% z-Wtijv2(CzQG5U3lJ54wfvg)jYh<0)rraBsVg?>xtqSumIkyx}z+~Ue}E9b4X*1jd3msS?o#ujOH^2T{;ZOy+WBQLjb zUT#svWZ%-?1>@dAg(YrNYTuStJNvc;T^(5U?OG~sTh!matRwd$=|E*p)TED45Tj)K zNG#6QZL1fvNNmomRHYKPji0|PQkSdJBiQdslnCBb%FIh?5G+0{z4|;Vk9aL1;wE^} zTG8$@SyP)*skx5*DEo2tlhQ}a=Ynq{94+7J2{>L;gW_*$W}oFW#k1PkobBA!+B=j9 z=clcdGe4d01ys(WgOn53xH>Qe?)pR!zKYI!T*>966|-CA`!f) zqGVoICw+$`Ih`~qTOV*Yb_x~?rPm;&fuoUJ)h-xzl#WJ}(n%>K8WDF&FPeKN)c}J1 zt_nm1@10H+BUHwZwo+Nhh>nucCgNkrAWB@tD9QX;~>nt38zbxMh- z8g&y9C!U%xB%-R3C)6AX{A$=rL{;aNh^Wph5vA@3;SR2bxkOaWsS;7@kRu64&lsl@ zWBp=|e#{?Zy<)6ajP;1IUNM?tK_2T9apZIQG1f1}dPN*QM{g&;PCBe_gyly#y$Gih zVL35JKTa>g`ZqW}%g6hN9AXmcJtvZA=5kT*JHVNb2ZIC;0tq$UjU?2>5VJD1nZaJbP{ap z1nZS#{sillV7(HoM}qZAus#XaC+W!N^b@RKg7xAVTO#S`?c~=QmfZYmyoDiAGa3ek2m#KCGC`< zctawqwQp$`W>3~E1evV}p3w=&x*l)i_J`a1`r7e2x@6vhcH@RYW9pzWvkR|#UGi<- znAT-Z@9ed2?pm~@-JH=rn01R1WlrntGF$LWTiT_PX==!!^apx-dY9&LiiCKOA_3Vl zK^4yO6~ZzJ$+>Y!2Ts|L5XtTgB0gqvaFu9FTMp@;ZiDd_+51Q8?RGN8Jmk|}B#mJ& z=;$73&+1@PtVDn*0|&?yUD6bHkSV&!6iR+`i_tS^tmw+>RrWCZJ9|0hn7x#YG616t zvQheAkokCk+PyL#*;1~`jD#HJ18zhGvj{?bj5zFtLvR&?RU#yZQd1yhT(NLw3GpDa z1StL0L_ZQ&6My)%Ih1`eUJh9dAZ7~690ZHOXl(-~AA`|}$#}i;s5Qr;k(fd;2bX~@ z%A2S>YJuQUXhI1{L@6+#A|~PrqsASJMw1Gqwhfe1QNti`mei!URH6x$XoAC9RahH& zN$SL6(O5#ED$%4$G|5DjCRQ$F7Vs)Up2}vjL18Mvq)ITU5=^QDlPbZaO0ZUCvQ}lX zR;5>~(yLYJ)vEMrReH55y;_xCtxB&}rB~aWy|Safcf52w1Iv45Vbpt;cC8>FyQizC zgP@#_u0@@4!X9nNUf8uv64eAC7O7VTh}2WB2Aq09_|)scSB8(&Q!fTwSw2!vy%})o zRpB=%RTlL0F7aTUZ#)4~Ckf;XbmB#p!0d&+gZ&IiRftcjOnghRGTEa*rL&}}j1WZG z?HOFsPiYV!(~tnCAwiXff@B)RC(|H4l?H(-4Pqo?Nk}$>SfoykZjoj+Dn**r=n!dU zyVS8=a1WZ|XS>v~UFz5_bq%sFhkasH5{$@MLzKF2X*ojj9a|~Pu?S&aQcfBm%_>z* zv!*b`nnGzHf%3$u60dV7UZ)agwd6wb=OP9O7SG5P>Lr}R$H-BC)K1@@?se7l=anIA{L3(W_PTZkDdu{x+)|- z(Tm!MH&`sAbhIz%;Nn%2nOG#=mNUN>BOu<2mv)5vI{LeM7szF3EbA?xg`AeA85CCZ z-eEG4wl+1TfKNkztWH&)SY55^;OgpB-qgSd8)Fk=J?24BG`E+mVkQZC~v$xrdpCga)*q` z44H@5O_&4y#+)=rB0`yrSfXl&3KZnyAh{?YQ!8NH%@fX~&2fkMIjp;5=~5Cf%te@! zq82f=9jifV6^)pSNzv|(g{-oKD4_B^0yyh#3tX9IQ?=+lcuL>JqgW}OU^>vTK!`~1 zX~#ktbVtX4QkX3^%uP&Xm{J9+l7p5p59Qsps7Ix$s^%Q!5ve4D{u$#Jd>CJ6X}>UQT)_e=BPF!OSs5Ict3JFdV%83NF0W* zikEg^P9_`moD@X5k4&NIfHXubK`wV!nU2UH4Q&enCsZB;ClSNllsRe53!O~6OQRq~ zB!x*pQHtgOL@sx*Iqw1}JS{4nW_kNkw9Kxhcg%SgKyikp$Zmrcq^N4yi@J?;otx9w z-Y+{-Zd)a;kW}?2>ER(ET-8;|t}s{DT~t>Q6uZby=1EG(6_5rgXmlE%^PV7aMcy;$ z??f2pijcOmTaUpW3^GgS_x5*C3Zq;R(m_LPSU`qO6%s`(&7@@p`c+_BgCZBfRq29? z>@Ho7N&%D*M|BB!H#yB+sQBig=W-EK=$tgM%#~gAhI7&=$q%LRp+QCNCPd6wS&KH5 zy2-9gQXma|b!vGgR>$*$I<;&AT)ijMsnvSmb=mDb7&yB-y7*hMKMZj{^_9E=Y`tZ9>te%U83p&OHA5$w>u~?)fM^&!0 zO>5asJOhmJj4-D5v!OEEnrDu&)>t+~EFGM;w1Z2#KI!RL(l>A?2sz!oi@LClgAJW* z27CMSy5y{NX$Spc$%fr<Y{!JNX$R6|rvaN|6ojjLlNC}}Q1b%X*q z>)hncuxLlczsWUW4l$DDlva!|# z^I*(SY9^Yqp{|_Mx2xBfm^#9MI9!No(GiNVp|}uZT&S@IuJR4FUb+2(<~`Uw(AC#{ zC)=eVN0Ixw2bX5G_xJZMAMB%`XhU5N;WX7`Qhhd&7W6Jxbt>A>peS+{z&y2zGOu@_ zQ$d4$3mk4$!=hZuQMP)Ntsdo4jIu$ZoXIGcc9i>rs9H(I*$>tJqTKOC)rp{3w536* zj+ei|zI>(fyl%=!o`cCeq!6io3Xz(tyr|vmSX8ap!smV|qH0l`JJLF?RdsCiIxd7d z&R<=dDy+ITRcq?nR6mmBwU#8WwIs3DvY=-kjt$&F=_h%uCCO_o$@)5*L;GA@oO+V$ zY?6DoWc|df?u9PCdR0Y|^{U+_(R=j|xWXHhmdOUCWwL>_58p1syiISqtt^zHEWXmNubkp2{kB-AQ#gDV9{Lj_5;JnHJT#BzY)E zwzTG;+4amD?C$1{G0AN*sSbxAg?g-UA$Y;O-W4hlZuLp7FG=oQl3cz?F5e{25|TW7 zNMg2tH?H<$q%;_wV4b3Ib3aaBHyeJuTmvroL`>uCF2gx z`HOQ4p84k|zsEo?j(-K9uCiMKZySN9}YWKdf(z>1vZ2c4D08 zMM+-UOx8Jc)&C`VVwkMs`o{fXlIKtIn-J8->zsUXIjT)-*pG3wSsTM{fa2%=G^sYN zVMms)HhICvIL{xGYDW}wE+3wECDl$N>M!f7b~I6M9X_ttJONIsO>F4Jc2}F^z&Sm& zGl_H@yE%L;Pi>lmpYvVId}=um>9QU?A5HQMD#Jz+jxlTWH0R?t~)lx(Yqi6QLm5inIM?Gimrq>n(8l6ypE$SYINLGKdc?UN#@SwRPAAU##?{U!Dq3B!>RFZ*j|d6X$js=k_0GJI2|rac)O( zu4i#>pK%_+;%v`2*Q+@9W4I23^-*?=b32Q3{fTqG8RzyA=lT)n`V{B(ALsrl&h0Yp zEa!4NjB|a7bH5ko{ulQ)sPvuk;(8J1b{6OQ9_Mx*=kkwpy^C|d9Ow2Q=lUJz_8#Yc zJcWF&i#9w`^z}@^R>L3T+8#XS{@(kxc{u<`dp_Doy6+mwL!e@_w+8upt`67 zZ=pC^*zKox{yr=cGeIor>cP2gfme{;o&}WSC`*jWb$Ki+F2O?ag4}r>-Q66AxDwn? z#_OB&=eOh4(g_`gLr!x1?U>$y+ZB4;apK(P#<|VLdCZA(TZu zGMeAv&Tt_nLmfAFV9^e{yiWFfc-L6c*3q$mqYPWr-#gehlq-y5k6kNaoA&M*?U-`4 z%Y|W%p=xw}gWcIXxKzEi;B2s>^7w}vtQc4Q8$NqLcvPK?h(*;Qn^>I3hj>e?uNzyI zF1m_dmo`U4mt&wx75`ThAkav?;p5v;Uw%g;ruu&6=bTb zxC8R-aiD7et%jM3mfqPjzZ2&xVASgubUS2aHlD~w>zfMET@DOYw*>Et19Dm5U1jBM zUn1W>v0Nb6qNoreu{P?I;MWzawW*AScUrU>CfC&G@XD231t#hU7xmq7Mx@f3lY-O| zo=mzbMN5aKp)_AfnlHV^Ny-P?2Zk;e7N?n05Q!R@LepuaAz~>^v!oO9$>mD5#Y9ZQ z(|AUP3nV?gzeBvk9GbIRL)1K?I-6pQxRXF{3Q_8z7{x9^MxsF{AgynqLvpY4dL=Q< zQTS-NVsFu75Rt8|1?g9*F(lTV1IAOfEM=KH} zDd>cx_drslbb29&OR2*c2`F@e+{BbFPN1tUr(`){>10YB(hy%22U*4?Xt;~uHbkMD zj!H+di`_&PPXVfn;q6?>L{uNKr){ES5}w`!E+ZzcH!Q)WUyy>Gtt=87${=MFQ9cDZ z>INK4Hc6MBvJLSwgzCo4cqPa#bO#PzWo-N_5krWj#GWQ*WUPE>V49E8R|ZXQW+_9cj=-J6qI5iWBM7?4?dlA|a2Lrf zA>^W`K~iF58h7P5BOfY#b;91&DGqZLD2bvyvapNlVovX$)$$ugt$MSG@EZzzo;0W( zl1Pk?P4f;7aC#$-@WB@Nb=h=~e}3(cik-;s?CrgyeO~V}%#KnrX+~TKB&acrCe(i!^uM~~dN=4-uM`zl0rr2|N4}j{8-WBKf3>ix3$~z(GA|@8ZCz?=*qZ97@QLL8%YrP%>m2Y{eco z`?R_QoZ4h-MEzs}KGW4nNZ@fV|B?~qDZ5jDms5?HU`@Sc$`teFX)PLVj@0q}3m<&k z45{u3)6W3D0hEv8nS&djbZsU6b!`=HG|;tA<5%8s2AX#!d*`~?s+-`_htPLaQ~>k z3ir=CP!_hRBM zrtxs2MlIZWqXF*K1~kMiOdo{XXtcqdgzx<7xOeGBxHlO$!@b48UBmLWCAhe43GN)+ z9I6}bMmya3#sattjfHT#j2^gs#vt4~jXU9f-1s4+@F{~f%|jw7n&|_!hpNW-Uau^HY|x- zCr-fqll>>Sf42XO&r9PL2+hDP3U_HHZa~-p_i^zwT--Z=GLn}8YxriKrpd4IHUiI0 z-Kg-{)U5}&^8jUULJBT?#Vrb3H6KZVRY6^XTNu8A*tnDocN2}m9sf4&a!cSAv^Lxl zW=w3GGD9oHVnh$__A!>U_urv4F1cgL9k{b>1!CaK6^OZ-{k6D57v+W;g@g!N2TVch zp@X{3PNqb6>?2k}^EAzBtjp&v>C=RxVKu*;%e~2q7(22{Oh=1XqgPvP!{?*?JVG+Rtk3%?kX}y;7j3v9SfkX zkR@Qz5kWk~c*@vrcOg5{`o(0o$op6`xAc7JtEF$2Ucz%8|A0mqBRojs{Y%9{so<(! z+>NnJv#D&nxPjtn+`fdbdX_#r1-hx~4*5O9DJ?J-P>o1~z=*3NKhSo=|`(wB%txfBv_P7-HoGsA?sC^Am`&xz@ z%O2F0Q%m_cwUkxVQa(W~Wi_>wyKwQNR*LTd(lZ#g@(cr)s z4Xz9NUvZBk#%TSd9HaH$$T3>~ogAa}Gjfd9|6PvJ`dK+f>*r)E*3ZlP6ZQX+_b2Lq zkt4PKS2xT6tcG;V3c7!8RSqj5tc#%SEoh%p*> zG-8a#EsYqXaZ@A4Xx!F_F&g(ZVvI(r7^87pBgSal(}*z|cQs;+#(j+#qmeeoXx!R} zF&Z~FVvL3!7^886BgSal+=ww6GX{*&xVsT!wDBQ1MjMTCj5eC&7;QAmG1_RAJ&$pn z9CI@}+6-1xY@Zmx7A%>Ap0LrJQnT#ivtH zI<-`&)KA4IsdV^C3R6(XfztUX@i??_hLqBYsE3?O38@$aNqLZjyh$43%=qDwYPIKgyPE$4*LOYDKpAA2UG+-Bo`u{G3ku=R$ypiIrNYU{9AP&9Pfx?(K zF>SpAg^@1D-Q>`U(IYV*`XvQPJ_R}X0G^VUrb(z6y$wQPD~6Dx_lGD3`aN7pU*YgM z^8TLH!l*S|Cncn6Qzmpu+FtTW$qJH^QC}oY)fWlL5>$LB+ccko!YL`?hzymQ7VmQ) zM^oTZ@*xU+791$a(rLv9Qc#*Ep~DoKNuwB<@`lz)r{=g*Q~JqtOY2LoEuC0888vK1 z3R?JXsQ7s|Ep$%CC_d%TichDYR~#su3YBT77$ucXXi1?1g{L}D@v9CbX`IG+hgSS% z3cBP#B}NJgIgm_E=1k@-yw9;l3UX-$4l0@dyZzEJRBe)}W!jn3UFc3jm9!K0@6}Vp zqP8z%NT|ds%ShJU6twU?pi>S_#*p@tHP)e(6gtp*$@}{;!YRomh9jjUljTMbknA=605Ag!pKr`ua8-q1=zVf4r)mmMgS<3J@f4kT$Mw-R59RuXeU zOHjs2N;+;DDv=haG+a{9IyhaKYo$R-O35^bCiUZVXFIf#c`2yVfn52JZw4wgym2e2 z6q>5PF!k3GcG;x@7)l!{4Wyuz4pf>BP0>o$q(Wuhkc03gnFhBHq`?rzllO;0Meb15 zQbMH;Ei^I(jd7sxtM7(nx*Yd{6B>Hkfx>wXT}3vEt? zKAeJfNXdAnmF#k$@V%6pEGb!nx0V7b*^`2v{Cg0^r5Hn?c_igvD(;aKbku>;F-nf7 zXzNi@+bNePQ_zbJYKr%HM6FfnO7A|(6ba|wq_p-C<&7>5mfm6rd4gLMN zl#iD)rKtW-(p3LP5OS26hHOWQ28c@<5cRQ$c`39C?z1F$Sqe(i0EPBaXa-HiAUbME zXv6;kLM;hxpnOmdEVWX7vDE7Qq0ob=Q0UBjTcPQ8N^@HZdL#wy2lPCJKAM8kG(eoA z44R4|^~xQhw;WC-G6hv*6XlaOAsHCZuC!x>{@4mGM30t!hvZax-F?=wTwNNezZ&``;)u|in` zZ6JI~Y7mxCB&AD6epR|ITh)U18c7W|BZe3LBuD48_MlExF&(KC1F5M$mF`fQV;32! z{?d}uHqFe3;&YXPffQ-x8}4_cgjYC_YBN+5sf9{N($t>{m2jFS+qC*i&w|3M9U5ve z&qiEvD-gd@a-?bLo*_+3r;!Pz`xli;Iy60-b3yB2YwGLLd}%1WQOSTVH7ST{oWx{G z$iXH^fHl-#y61A!oLstSsXo?~8hS$Ym!?Tb<;Z0N)G*Y#_iK%Zp%1k|r}W+L=ZY3a zjdeob4{~bLyZO@c-W^wJ_3qU&Cx%M*-IBwovD}Ubl2&+kx*4>TUD6gx&x_Ngm9BLz zK3A=KkNHNbq{FYM(m=W?n(TLZ9+sM4rD^Y3pVOMkwx#+j(A1yQiXoILLmu#7)XR3j*cOH+xKDnFy&H-~0+?$L_xE8rt2KM$Z zpTwsMq&pVwv3IcNd-*r-B#9#7lkZ?3{_-&-16a9cHsHt20NlTl`!{k=vO7_7n%Mcr z_dfp!_tz$N^)K(H&@*Nqh3i0(9eF9mx90WB7s!1J-+`B*+dv;qoNti(7RQotPs_YtK1S}7WP#J<9#hsJ`bi1v z^RauW9V+uGbPg zgW!)aMbRZ^ttDqTm+ZhzvOEM>udn36u zHJ=bW*9^PUTxb^{1ZlUUONVulXM3<$ILYXo^+E@@4%TNwMyr&_?Ov+fTBF zG?IpADfBriiyCsrlB*Mclr$ep{5KJ$hHO|vWmH3U_&J5%p=?OCor$48dch zRrQes-=_2=xQcjci1JHnb3cMxR?xiw_%D8XZtOL3zlGfHrR=m~$j6zLw2~D;H!!AYO*=zQIduyM=RD#)ma?tQF#`}+M|ta$1zEJpS)k(enp;5 zvtF1#e@UPH47sOp`DWC532w}K8E)J<2RC884mW9CfLm+54Y$tL;MU{Y$hi92&cm-p zB6bjN)Gme_v&-Nn>``!&b~W5uI|8@Pu7z7~Ujw(nZkCtP*^}g5y7ttCxMj;eE-&S` zXOKIK+_~f~B)6N~esWikyBfa->ahz4%=L77`aGLGD?Dr9ZuYG6Y=pbbvomk5XOCyU zx6yOhbJTOfa|-ww&pFR|&&6yl+v9D__GcHvt;inZZG<1mu7}&0Jt=!y_RQ?L*`0Y~ z^2TKMWv_s{CVO4>#_Y}6+p>4&pUU2oy+3bm_TlWK*(b730Y8&{E?@fRvoGdoIi4JU zPH|2}&X}A?4kYF@=1j`(%bAulGiPp2XMS-`U(Sl0H96~YHs);3*@lpvIeXyl&pDiP zH0K1MQ#r^x{Bt?y;a<$uay_~J+~VAd+%dV4+-KZf+-N+j9GI zSLCkAU6;EtcQde^xqGDcx%+bu!#$dNBKK7889?W9&%?c#r{#I_{CUNB72t~G)#uI4 zYs{MjcUs;|xO4M5^ZN4E<*mqD19x5C#=OmW+wyki?aAApcR258-ub)}d8hKuok^FjZV}4`)B)HS^XM!>}zZ33?{57!By8MlBH|KAIyEA_e z-2M57^N;4A0CXn*T>km|i{AZS&Fk^{y~W-NuPmF07ZSabywkihy>q>t-ahXN?;7tq z??!NL_HOg;^zH$4*n8A_!h6bl#(U0t-h0uf`8+`N8{>=k>V1vANxo^mnZCKc zPG6sIg>Q{-oo}OWvu~Skr*DsMzwfZ`sPBaDl<$o1obSBvqF?iS{Cm`r|ET|H;JE*U z|CIlX|D6B4|6)K3cmn=FaiAhFCJ+hK2O0yD0@DIB19JnNfxf_sz?#6iz{bGlz_!3n z?-Wh*KA_I_d0&!e`+Q^M**@QZJlp3xEYJ4&^W@n+|4ezd&%Z;S?em|P{hhv-T-7sw zpJ2Q{fc^t=50d*7xrfO8A-RXi{SmoG$bFjJXUP3AxzCdO6LOD|`%`jhrmFvp;4hOa z-~MzR?_+SkLhjed-A3-i2`wlL7bv$!MV-r@u}*mIK4STyA9t@{U}cFEx^Zy7vmeW@*~LuI2Cy( z&J?ZD?$JJp?>c@4C;B#O58!Kno3$-C+xInm^YI(_D&V)YNAa!Q$FzO;KI8Xs&hLlX zk8q;$sP;2_<@XobFSV2S^6YQ$)ve#-d$MP>SMf#O|HS#>ziMx3f5Qplcko4Gfv*tf zr;Bojma2($QZoo;UYxO34?Y9lz`JJrapijehd}rVb zzBBcI!S{OS;!C{?@Ri<9e4V!&U*zr6`|%y#W%vf~N}O_9t*_D7;%wo4`Z|1P_kMi? zzN7m9Sy-=-r%Yk(5zpZSDtzn%$gx!S9$m3?kir9{ z?3XdUg7J~;k7Bx<0V4gt7{*y{phn?1A3@>8CpcW5&z1ZYj1Q6LbM*15a3AN}YcalF z#m|46{fmn3e_i2dZDbce=8Q5O{{_a`PJV}<r_-o8J?;r9hz5hWzyxUnG-m;{g`9(@D`Xz1ps4Vg66P zWB&mTU&is}*?po&CK8x|$jNh*C{E-UJ-O4!Er`!jazmfTy z8UH8iH|(I2KkP8e{|EWaTdVkUF0%h7$E#;OCop~uPH5xd_!}I~?Z(IU&mF14 zv$wLpMe*eZ8Q;k9POzNk6`u1phc9D)faz?n?8S_8{<7yXAKTIQAj{jt@;5Wi>HAn8 z-@`0tJICj7!ncw6AK>uy9G=6zhvlwfzSYdPSm7S-7d@{rUnldi-o9?edpNw0@qUE| zxqO2>KKK@J_(J7-oP2P;ecX?@_*8lRAJg>>a5`*vPyKuAm(BL@l(D{CUxPE5pK;%< zjB|hEo5lESPUm*zXL0`>Admp!ZU$sgn@SKtxIPnkDC3%5;%)g)c3t2Cxoes%IU@Pf2aYN~IGyP{-r8Ib0-PVTFEx%&Vn3*S>rM7A zC_iwP!z1k1D?k5bvX9|E=0C&GYXO6y9{(DK*7;TcV)@?=Ob+A)%INoABjpSN??NVi zohI-;=M8N!I^wNmu?q=oES%t^QwRh5w1NO8<$nM*I_HmHiWCmH!iERs0iWUGZMBsHwrx zv0|W`njUOM&DMjpS{!eLhqWl&1Lmitt6|?Qu+Kzqf}R~?bYM5$_4U9`trPBc6TMF` zPeUIO6dFeQpk{s^^o`nlxa-X=(ltDA?=`S;9$0OBU%J|@a93dUKo9hr-;k~mgWGM$ zw7Lyw5?E-Ue+VqZ+JYXKYi^US{QYiVmXQnhLG2@OXHe)=tsU?rxhE1>rOkr7%=|js zYqVS7*6J8P0<|)Cfe6~J9;h~#OBZVvdSH~+0k_P2Sh}Q7v4(yq5Y$VgOA_+Te!LC6 zY&-$?gz-4&n*2?$|B$g4?gaz=rN0k-hK@I1{3aUjok+1@nsohdyz>XZ6VXrT1&Mdi zlliBhU(qq{7aAror>lGKLPn$;2#B!84t;o^h1aTeS8POKN9{v z;C=A-f`0)1F7UU(-wC`2K1#T#89quP*a;tL7Egr_*+Iy=0_pfR1Mb$0;OyW6xbyIf z+T$oo10x3@aU7wyYKH&KA`fU&G{fux%|Oj|LMI{gbg&V$YY}=U#kfW@hMmD&&Irax z-sfOn#P_ug!>|2SUUhagRc>_Q}WHi%8-FoCrSGdXs?1cEq^~~XNYz!;?{!J zmM`s8BWWgTXaFhtk9l7LZ3WR#ivvBd_={L!G6D;T)(aZ!P;BK7fHsY29|P@L&B$Lc z>=0-P(8kMr_;-Oe!!M<718p49mVq|0_!`h)KT$*Z4S|*z9z$F|Xx9-s9YMc9h~tr&dA&5h8-i+t32^FXtR zhQ1mt*2sHq7$l$%khVf=^+UhmC-P8cq~CE-2|lEo_vkR#6mQOw?+VaPBi&JXTR}TX z@-Slf4glMmhqClRZ=|~(wB6tnd8XxvvGGkx$yMMAH0kBJT6Dbz@dRAn`Lk0eFgHoM0*w3%em{| zo&oK+c@N^k&bccJU|BEOSzHBO7K7G_vDfg=m3#stiuVQ3W){FM-sgxm1+-l#^X}Y9 zplu`CXwWvHmc5wg2km~MRe`n^aYx}T&%jJy%KHFlJ)ji^(2BhaK#P%nuyd{zK;7`p z1T9LmshW{t4G-;P>h!57x-;{k6?t_xg#E2+4viIjd0NO^-ZY91=z&2zHor<~tVHR& zojnt@KH}Q}zGdK>l-~ndH~1bVzJAc^^B05G3EEePCOH;nkAb^@;$prgIkJoM=YfX$ zY_0~Mk$<~p;P>Uv079Q&i9V6&=RuETC;TXL|0_iQFwysbUXeY@cMa(BMwH{Cj_BJ# z_xozH3qjumdXr2U@*mVpUnzFaEzs8y|8Y56#(TDz_l)PfAGY(~OZ3Uazg9CnFL^I` z&VarQ^cgH?0QBd)uXv>Xol1_Vg`7Un5Be8+4uigc>61X82l}I)J>G+$-%9il$)5rG zR?jvsYNme@(fvek1O0x_M((LYG^ zQqX66=6X@Wem~J~Bf3{Jy|X=2JkvnOn>0%08gHV zm%Ge|d7*p6X_-73dfp zblaw<~P5+u<?ZTZZMV_1Gpi{z#eJ=?u2;^H&i^1`zfB#pVYsHyD7e}AJBiGAJm`H59vSD59>dY zw@nqipp6P#bWRuq_5}6^4hN0~P6UnM>cFXBUT}J#J6IGP8SDx4<21sV;P}Amz`5X& z;Do@v!M5Pb1(Sn2gL4Dx1LuS2i?MoE>9W|0bM$%ffcS!VP<&Br5?{gzq=&>7u~mEpH!^J#UlZHKH^d|2n>dy9EwM{{TRbYh zBX*1LipRu3@sv0uekcx$ABiL4X|$V>+Er$w+4N7B`-1iXT+H;G_?hlS3&y~&fBv7I z#xd<`ssY6vJ0b?!#H%yfA1Y^wup*7OA@%|1ND>QH;M8Wz8;Mvl( z&|3kHq35G89eo~f%<$miZ1ymWe{#PA?}_rQQLZlO=&j}ZxgMO0XEAsN;G@S1uEcY% z!iTK^j5&XBBXCKBkJh9IF)tbRApGrk9>wz{p2K*K;W>#gncj=ce+K?$;OI5w%17{R zv~5epFL?$&WEB|jci`Eh!V1ub7Zl?`z6y~40*t!_jR@a{2k8ylk4MHmf#)SW=Mav( z4TJmwShfJ~k_8x(37-mhCZ2hCy74T-vj)$4JjerP=X$|TJbUpR#Pcj3$Sr{W1+Us?QxYv|mv@E%`WFg$Xk~MJGmtf{tvZDku-I7E2t+7#Zs^lEpH$n#1Ci3u$ zZoOg`9^}0OI)*}cD)FGyL&&dO4GN(=L&#^S2hU18_u)Z#g^_yW0eWbth}~zTID>VmqMNyURS1- zA3^-vhdm$OK=CJ0dzX41B7D46g7C>j+rz5}AIsrmsdq5Uu`;}9SrJCeAw;7)`o+O%kU>rzJB6QQ2f2a z=7qhC%kbTWyYTy6l`k1T2s`93F2lDJJR;Xzk=_K>Ay9u(2P-uQEhOikHVYMsJ>Ba;cgg(9uWMuA)lINkJvV1_o(fo_KrHD*`+lF z&xH1l*fZh~=!ZufA9Y~Vu~9E-_LZeqR*yP8>h)2VL4Rh%^P`Q?!O@t1hc*->LR&_h z9Pu*fuZ(zObZB(-=m~%?ggZxHJ9_Hq+X25Bo;rHo=>E}b!N07a6gA*tnb7PqPg&9E zm7~{>-U7I|ta9|jqj!%+x@BX^YDXU#{rqUiEUPbT8+~^4o0USdDsw7BW8bdH)2y;< z%cfLTR7P+^TP*lo=*6;WfD+-=1zXByAarKg!pf1AF+hvUR#etkPD1F&(D>kvvQ?n1 zDcevvu~PCqP`0gdR^?(qJIW51?X5)b6RIwIG88O(22f$BBDksSC_>1D)?H*D5zvqUWl7 zwA`vZSos|IjNqiuo^lT$E3`Lwwj4PMi6G*a`vDaOpAS7;UWh)dxV*A*SLFdf)s=@s zd6g#sjlbe(>v7 zX7-rH)elzh(Cn*v93%S}6Kz?JI@Z{! z**)Z6q3cOBE`8>X}X16QeJ)#*P^`cWnK*e#s|$ z*EAn`N+}O=5yPrFFt(!V=-3GJu@o7DnP)_Vr`r;w%=bm{9N_f>I+&{)q~@Ws_j*~eG97J7^`WbYFE{hzPTvVJ8p~;~p8ezIu!I@VLzgvvD8c zk3}g~DqAFGykxv)yon_YIa1liuZ=(A9p-;bte#-*^}6vE`hVyoS9GA+wV)apjZr6Q z^$M6AB~Wjf3NC?D*@ZZlJsc;qOSJ59G3eGaZvD6|<0g;0b=iksSD7(0ZXU9q zl@i>y)k<93u04We7wbJZO(DaVi#t(8J}&PoMr;pGfoJKtu-`@_VbscXBCHb9Jkc74 zcOA@!k&~0gX=p5UGf_9uCP;6?K7{%#rOJ`_AZo%<{W5Zjg$uYg-4%V4F#vd*+_RQ^ zq@9ktbXOqPD+TlyYs7lEdjw{2xL5ZF%(p>o0lrl{40oH@4)+`4yKo;9kHI~NH}AAO zc{lG0+|4WH`AJ>@$txpySes(~G}7-N>Gv1X?*i_$9MImD`sroh!wxj^@elZCrcjIp+sv}Z*+qB^ShwRvK&#ib`6cra?1;+MciUWTZZp4TZYQ|M{J8l!+Vi!| z9p)bMG1?Qht*gZdQAVr!!dz>9%6!oLBCVGTb5KkdH;Y@uzaV~=Sz$h8e%t&G_IBlp zeYV+X-fMo+`~ud;E3ii>cR+K@K69nHN)(7%ajp1}kiS1WuH~6a%>nZh=H2FJ@XNG% zF+ofbQ^idP@tNJ`5_7q^0{d5m*n^2;zcneY789`pD|a$V%zMoH%uman$J}Io-P~z@ z%X}1jPQ$Ucb%huydn0ib_AF!Ad%PZdjdEAR&^#DP&@G$IC^4N{fI&4ji)w6;YV0to zu@zKfaoWV}H9uzdo9oQ==4Nx3@MF)eScI@6S1QWIXi+Ju#8~kG{HiXFmGuu|9lb@g ziOJ%_*bAN^Zo^KD+@CRU`ifdVRx~bXw(LvLqZDDS!$a@F`P3@}QD0YR!(|=SDr7Cy zMxvfB*G3t4p|)bD3(!g9SE#f9W}HHw_M-6{^mD&OuQS%H!AKiH?~}loiuW#zR;_r? zyUv`1zG61s1cs^cQocjjPoS?bG%r?N-a(tm($Ut?@8n{4B45u(``M^(L`_N<3G_F$ z=*fj~J$j=ovX_VKl~496CVPd+UX^68D#$I6Ee8Fo%&CEvQwWJg=rcWfBi``ysHOU; zr4~?29Y(pn60Pf(T8(UB+BmYn2lR{jMJ4{W>iRzn+;4oo>WvmQC;FIaDhO ztt+i7wPBRkBFbx+@>)rGy^8Xhpu8sSr;)RfYW+!@h595%6BrcFhztz-U#_rm85s5@ zU18A-ES`ZSGcZ|y)9GQ{cVQTxUD)gl4EvF;FzhC}u+L>+*iCnZJ(PjTQ6QZ<-sWA{ zBN-UhhFoF0GBB*cxxx--U|4~0g&{2$b|M2ilYzaSf#Lnp6%TKhX-xNKV0Z&|g<)PN%C^4(3kfU(L37_R(7eZcr7VP3t*Tbbn9NBjytM6gU(@gHJ+T1k$q zSG;SWUDFKdJLRf_027l3&G@ zC8^`q4dyX_Ot7S+WfBkBisaCnfJ|4#O-SixVkke>StVWB++9WzcIem1WDyhVxoB}| zx?F`<^cGXX4nLIz%N*~bD_c=oDqmDfR9P!I6R6x3d=hN&G&s;2!1q`UD#r0lM(;Jsc_XRVpk|5eCm+!8;68{Bo!{(v8K;T;nXY2S)^MY@CFcLvVd;W;o`p8S>J&DnI?EA^bNv{2YGsknsPntZNJPwW#9j+y8w; zP!um=oRG>e3{#Pa0xKhg2t*%5MDWFg^x)+vazH$o7(_utNDn1Ogc^hrgcA959txEt zA{3=0L!!`7q!2_3rQdDMciH=}|7X^>)~uOXvu0gp=G*^k8ZWzt{CR`Fc7wlegU75b z$2T3>;E!zZM>qJR8~m{i{@4b8e1qS@4>mn47w5BK6tOx^#xE@{{XpTJ828<{Q-Y)a zsycOr`*?7)aaHY^UGG}sxdM0B3U_zv(W4e(W_ z8+)u}6hbdY&i2Zhx3_xMB^ed`|f9h8d4=J|!dPQFmIZm-?m0L@ zN7#bxadk6ODJTZDo*cKrV+)jI0AZVKv%< zbcX+j$c2`p>Q9lG?0CHv>G_TcP@qxIh9ul;S~bgBT3_!PimcxYPV<2MDN!0Xoiilp z+Y4%e=be!t?a=Y zJS?iAwxi7jQdgGDdIP7qs0q7Ao};vGJ%Y{;O6YvAvCQVoh3#eiHJ4$5VAY~a))iVX zS#{}3+xu>vH)!}n+ryJ$QKmhd7dH+TWQF4ia^rNGyr_rUK{Jl$%fiLhEqxWr$-lW$ zC&hR1pkp-DTJDWmlKkWkd&^UFx5`;u_)x66o_oz&e7tJ0ysR~}f^y-ywRNL(P*zUK zTV0)f?^qrXC2Kl+XO`6pCGJ*aac4hgjc*UcRd)J;4EeN$zw{b9-CXTyFxINPx3IA1 z-<@m4CP2g7y0n3{aD#)Mcv0){TnmyQ4emQ_Xl^+`bL0Tcod;;{nx8-t;EYT!{gFQM z$)^VmrB;0FR(-QytU4dhXwT^piT~t={!4?N(O%W%p{FM#{N)Y($Q83!z%v~3=Zunm zlRNUwV)rMx&-JF4HzhsZXjw_~#-@)~{d)M*i<17P4gGyhuk}{=-dy!Z7LWAzZ|HwI z=p|8n=c?(cYo!0#hW^h@FF#!M7t_lFlm3+r{a**Y{p72mr&lKZ-`4aKZXRU59P6JF zdU|TmPp59^VZlwGyzj8-<-JLdQAi6Jm)VDNeEWv%)6<@FymQhXJv{sO;pQKCI%D!J zOya-M_~`nrt}owC{B7(0Vh3IFl}9K3o~|#eUiCoN5Bx2|W4(>Nr+B}|2fcE*dScVw zlU?6FqMjP^6^^!_uyyvKQnz>NYjVkJ53)Lr<+Ut#tyyZ zK}!jFOjwVI%9!=FUlv}rkN8VB^=U;(OMgWCXV>GKJ{LTExA32Dyyt7u-a6X~eA$M7 zuYYDV{%& zFlFQq*~)UySwHpG;{zee9jQJ7N=5gr$I=+=5#fJRVvaVr&bUvIXXpK?$NS7(4`o4# zkN_?H`m9H`RfY3W_slx=H9`B<5b4Cs$I%Muum(nigDg7nn)q;(;Bk_<8k&Br|M?uh z(?j^&V@bbvKo;DGX*s!H&S>3s%91T(8q|}=bD3Oxa~+73eF~&g7iX37V4R(%`J(uU zK4VE+mYrtDG1)x+A!AAyI)~cQ1!rs=&l!Bi*iQDJPrP^5D~?a>HG0kQ33dh)AuI)hV%)WYM{Y87zp~g#ls;tF*E57)h&9SRmSA}o? zkX&Pz>PLp9XYBtkH!#j~+HOUoSN+4utoc#fSs&*%gWg)crvH8D+iyHS`+-GlteuQi z+}eBkZ@T`lTW>Ae^U7JZhm&3R&u#d=V|huC|>OKj{p|i!%!2 zDM9HG@%x4Z4|y!snb#coBhCZ8*BSZse3DDmZ`GT&G{4JS8uz|&){FWekfTT3P>J=L zGX&=gy=n|`ZIN5==UiwZJ#;W5hU3oY-_wOf>X^)Yu^!Q-UZNa0>mOd#Zf8o}1{vq&IKOwu$O}R()%;Gt< z&Moj(1IyeQOgZ*n&wUzin)nFQ)4DW0BYKY?zHQ8yJxk9QdsOvIq~K9hJZBaNtPX{G24on(GS%o>_}n2y~{N4 zv1{!$#%k#~!`l6} 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 & + + +

+
+ + + 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 0000000000000000000000000000000000000000..2c70e7a443c3f5f364e7f7de5af8828527137143 GIT binary patch literal 11282 zcmb7K30PBC+76;(MWxzmTuJN3Z>S5)je9MTyF^@_z68 zedp-M>mnwOpE;gFp-lYX{dd-b@2T+jxUt}CMOaJ?g|fK*gLhW{nd{YE?tOm##5{rOW2_;9r(}zW;~xkoX5;Gfnv=QW4Cvr}u~_ zu|gVpn?F0pisiVj)bn?L@XMwnvD7|xc2GK`(M~gBfuU`vV zJF($?Q$s^T-uIn%42$mE4=yR;>y!9>deLi(@a%&&zvvK6SFHwbv2H1~&fE7nCuqX` zgPY>FFI;lEHgn>=7s}tvI^K56ce^M(h&~1TNGHh^q}8I8j1$N-Y;GV$C&}6Q`1*(H zFW!G^N_u+wJEOlJJ^4`mPp*b3{Z04PTdiN9gfA0+`Gec2U#s@Um(qI$Xp4Pan{jf@ zgZ>2OJ9#hnR^}*-P^D(o%${865U-_SoOD&haqRUmrrD%ilRrz<5ngSesYzL8T5~tgu z_8$!9mPLGB_BSHrY)wb}go^TOIXi4O>~pa!=!BAsJxc* z-|r0fN=42ei4Mv)yfH1q&(Vq6oV2!g4{|5VZWhQj)^5WbsXcHK!v}j^hXd~;{?*d= zmCJJVJBTqWTvM=c-9}|bIVDHGjYYu@M)kRqtjxsYE#a=Cpri`>&T(rV)(_f<^xM5E_02_6C6ce0sFigO;IKW(NmGtU?ZPE z*Qrg56~)D<>t4^@h{pY$Y=im0tE?mBlKwLOPFHjDg3Wo{N#G<{EbA4FaU3}b4x*}c z`0gSW=+b~8sA*n-swyc(Hzr86-M?J=$M5<-k)9a%+Zmk9P4 zPhDF3N3KgO-xS|Pn=Ng(m~ge}z$aWUR?7yq!EUiww6db3`e;!VgQ**{R8^5fJ&G*E z8kK0dj=jzQ76_ur(<#^%rEfIrMG*8}O7!gRUCY+pUlaD|RX(zVL}w`!VbJHENG*?(Jfd2 zPUXciJ(W;%D;^G7Z`(szFq z`#fwj#uZmh9j)p;Cbo<3(hF zD9<0Hj`(^VTk(_H`|q0jUs6Vw=gI0rm^{fQbp9V+q|va)Wn9kcEYcWbC|-NCcrtU} zxrKqi#x!hHu0BSY0ih*9$BxG6L4MmsRnl=2Qjs@wV*=?%K*UmX?3xv>Rl1E6K8^nx z{g?FWaVXpp{P4hN~m9D$rdv3{TecW?_&+ru(V&(R8Kd6t(oE60q$&JLN&jqhA zNX4gjDYx{X2V( zlGVl1Y8zzTo6U6!#>`YjDDeVMpCn+GX!izCat14-=V2Imch|@p3z7w{)ZZ=%NF(ol zru0T9I*H@@l2ertYPri`x{Rd(##pB5zT;Q1a~fP;b(<`^BNqR8TVXgI-zU~Noo|ci zzf~W0O7I_MAqUlhIU1r{bBvnYbpcn~(iNd;H2cwfotocWQtHeK;SLt0O~tr6NVV;a zW%T1nLPpgnS$mD_oYm@w_z#dIPloaA(H#*UYX?if+LCy$CuseC-@hH(eSd|0ZBm%gUn znzY0mM6Ec$6+!;^4B-S2IPm`~@Ar|4EPS||Ip z)oR)&BDHGnhRbC=n~3E^%ie6gg73J9KVaL}CZKKWhy`x`kt?sgKFzoH6y5r{M#~)S z;eA$4Uc!IcS%60&_fF^ENerZ?dT}e~`B&%3ZqG8G6PrHYlGIe>h<8-9`Kz*>wn(f1 zwd6JO72hy|hp>TK&0y^ zW0-;2*J^u7voF7lJ~ev-iY4QPG}mXWKY_wV#w^%ZcdTk#7RwyRuC?3K8&z(!N0QPT z!5>F+o}}H(cp^&f=|~b7{O~F^v0UV6zi9s2qw0)pKAFv(&guVwQyDL>F4*h*LT&!8 z|Bjd#D>oOQBI>_k8IT6*@TIC7jj91B+es&vyKRZ%Ei~IY7XB4WMAbdJrDKK%1H-Ue z5sc%(Dffe0m$Ta*loHqDkr*<;ZlsfSf<~kn+P`8y z?m!Z(jfr&BZWr956ZgcX_5kKr)PK8~7SQFFvw4*j-21^P^H|t&R%WNT?5tk@gAent zUgdGaMo!^hKlXClhbYq6ondDJm>vI&9-$Jj@AkvMJuTPG!v9Zb~d_k>kw81J6>^AWGzZ^QWcuo@AR4h(BTOK!E1}X?5^U#?4^@0f_rE zyfz5Zhv5L>>&Bcn(}!{a$VoKagP=R&XAIUj)^v=QtB>y4$r|?>K-;JEnF#fW6~It( zNPe*tyP2b}VfZ96G5UEk6ePns0L>cMWNdVDd`3Fc@hyatSn%fvMk^qS5M#>O!~?DI z%tc0~)0sjP9UsPdxiT@(y395A?GD`S+}^o@IZy-x{TMIpFEH=m^pm(aQ>BWRRTr4T zQLIc*ZXh%?V+Pt=ApM`?c)iZ01#8Oqr|2wrZ90Xr@3>UXW{t&fShc(;cw?&Wgg6Tw!Y`s;lAF3z0>{1qc=T zPSmoP{JCeR5)~pbN-R!vP?C>RftsUCrTnJA*PCEX;r)7%&v2cBAw9c`w#OQ3y(-vK zK95EOUM?e6LF8eb=+w=mH5VtM`p)uqjKv#2r^zi%e{mIkb59@0~ zR!^EV2|vW{pUQ8K5F9Hr$bBe*et&cJsZE5zE|NaZlfFv7by4sn!1#{<=AnR4e33&R zsDj|e*Rg?wY*d9)Qavd1EudLd0GY)d8S>_9+|_)ml{it7lAfe5Ky`UAZ%S`6O2AI( zD*FL?$8K#)6F|QH4XhI!+pps2rC6j&t`ZkO67UK)w#?TD)W#00FHP@e}29T$c?UA4KwB%K=J$6VsJ}D)lKXLUUt} zT<=f33&g(X(ft1`x;=a}Y8vpXZ?wPZNV@4N9b3;RLHj$=mI!`tzzs$!u3kE}dEjt4 z*^ZBki;HbFX#XrZAU-FlcedAw>cXWOdSEpTD($wA8GZoRZ0-IbCO&5~%zm@bNN96v^r59h zeWd=YQRE6eBl#W?B5G)7piypb-yUktFeUj_5ALgkjA9a8p*?6q6 zxTFL)79S11a}yvI^k3wS0=!31OAuakr5!aSo1x*m3^d~$ZC?uG@$E>j#9TZ$5Ek9R zK&7Uw?Z6A&`c={&m5B|%{@RM;&>!UtCZ~TP=N7;lI{d=1EaX)BxgJFt4@g~hIo05j z8*V#UmN?cqm@lyc7X@={1q6%n^PHr{+n|p%e5H)?v9E9wMi`0@l9@{=eXq)js?hE6 z@flPdsNNfp^8hMf=)|zD%({$b`CJD)8?aBZH~>%sMP>8~)jJ(&ztJXgg_0!`2rZAq znOw)9+;Dn+ehaXjDzQNWt{X(JtC0i89B_27ofBQBpp|uFkoI~0|3QlMT~$LN zu~*`(U@}DZTzjtIBtijYaPAc(Y@Fwb9R&c*8&j;IjVUS_qeRWCjcPlDJLrz3{B{NZ zl-@FrX*tc)Q2C4KN|^5u5}JF;3@s~jsfPw}+`5cDDvz3a2Q7E&(o%0jo=TVjM}sK8 z5wf~Hkhg_%`l~s&l6ZRSRa8)pT`NIVQ7N^-#tLTtOOTRK;7Q<#q=6s^rS$-QRozK^ zs~>lrF3sO$OgA-xei~VT0lxDAYG7}@D1FhbxyN7&907vr#N=Ede-VXj9HRDQ!7bnz zP3t$BmfZ;dxJ28Oq^%%Jyim{=4EJ-a(LA1`$r{SEzF?SXbo*6$ZKB<-2=$@_y5y6@SrYwb0mobG59@7c4bJTY03#N7;&>XlgH5~y3NG_Dlw zY5kYr-wX~7ZOW*XUz6XVo2`U(jxEb)orakhO zc=SL-SZj+TKGfXerwLWbTFA#hxjeoP{KMmf~Y2b*&} z{e)rgGceLKqdH)TK7==Eo!)$tkpGol4`jxhTKiV*&7Hg_0s9~appz*ZA*f6@Ph?xC z?&5Gv4u>(Dm`?&N7WYOO0Ckd$i5j4-jiUgw8FU|m`NJqr_fad$vz?WZ*d&hiA>-!| zSzCko0qE#T1M{t8*moX2Tqwq8imNdUkjM8x=*uUgfGpl@vNl$n7CHV5tXryu0?4!p z>P~_43uz$v0gymyV1=ECK52gl@D*W;G3HM?W57;k?_dso8ki?6cRtwrB zH1AXiZGWMV9|!a^AbZ318_0EMb!!icZCB~dl9aj#-fUR3E1buXPsk7f2s&BK7A#S-ljcBuSqG{y-^a(tQ3VQ< z02c}dU=Whq#RDf2Cf5sfd2(Vgl0d`u<||?Dn_qX!s%%g?(=$Q0t8NW?)oPt&18#7i zUL@KJX@oyi+G#%5LDAxht~nBo($!+C$`>L%{>X&%_yM??F`&CB^e(lMRk6jv~>1Ox(XJ z0d-D#W0_mvLuI>~Zdotx0?8&~4k|+5f$7>OjyrT>es+rT0+YC+sNAlA1Pqr4ChZAfI9>f%IRhx2-mg@-%tIlUOG4DZ-bx`!aW%qr-Ye?exxo0Dw zGjoH+p6^_BXSuqmx4t5f;xUwk;|;w9;Ex?Nd)G=~13WSLl9~&9Wvd`-qqx~m<@i@* zySK(5L-?dQRQ+GmDbHN4QT%kXE6?mjZI9yj1()JK9h*=}tdc+OL{CK_Uqu0t1MxJhKbHA|d%Ah2A}6O~;ak<8T$c1ytNagpxu-Q4 zN)iiD&~fuR5`}a`wM)KP^~I?qzM=$nP6XV>wi$=h^xFrq)ON)W5y+bpnFl|;xX$e0 zRYj%D$?sdqrnut>=-Ryx8s}TPf6kEovKZmGQuWbKr^n=Y!&J?&?dNp}1(QQU8c1#c ztM6zn@r|}-Dip~rPOU-4)dS#$+=dUhA)@uOeN4blJ$p6A1c;0)LcbB$+;=h{g3Z^id zD@vV~kaCamluJDtq^j6bT=>`X3OTqTU}6Ax+@`#+7e76#yUQ3v!&;yQ3UHm$0!pF> z?Tjc}lh*DXND={YACP?UGF<^ihlSE#-dZ5fS|hZ0YzJm4a63j#^|myB`+9LZI5KEh z4^7XXHsYhCbs3?DRk$*;WdjSJiVK0Jqd+Z*wnOfK|D?i45d_U&75A*O{BfPT1P4?wdiv7&Sm zVww3Jh-ToIm*#KcW@9Qvls94<8Kl*PlxLL25K;j~M?S4V?zX!-s!a z$zT8h{9OMVRH6ZE#`wQK<+GPk*!av8q$5GDC4B;EH(G<5)_tS2_X8^gm0}!G&LJ)JiA+&`?v=+@@NtqwF zsIRkM?hPNdxQ!p(+g?MAegHCoqL%Ih-L>$lDh?H8*bDDA>7>e*C{&YY>_TucewYd3H##(>HRzLn~dWde69J z9gT^}GpZa@=zsq&D*<{0L4IhZn8KW`s|pjlV!7*H7S${hnf~D$tTkV`So~1)ZBcdN zwLfI1mC7BVn>TMZ;A#P`KoyDK8F`I~*4^1<`0DJ^9kUftw?69GIq-br_c8+n>TqUw zDHDGzx+1%VWk%ZUsADO3!$fTcubJ#sK22t0#Dq56sp7&vcEU4LW;2U1??_%FTgFt; z4pFxSG5hj1H)I6QQkXV1NFRAlv$9Eb;y?;j$Z&u)VdSy>fXhCgN zF74Nq#R&H1T6%8o$Pt<6f+O7e?Zca<*bpX|6gX;U8E<}KqjvRZu3a~XLipy@eu!;3 zc!0GjUK;H=0UMYi3Es~mEyL6AvB&K`-5jcvR4@S!d_ti|9m*KqHCY>O(@_cZuh zw`Hf8@&g&aSmuF8G^y^)_&;KXZX=+}-QBh9H&a?G8=QS?!elUg_qJn}thYwiAM#Ur zpHQqW*!-~5%Z>d0Zsj7cT?1C!$Sm8v!oVc<3anISHR=x@bQZIo#IZV@<7E04q9y*L zOF`>iqUX5Jd&qv^Q(3_9!`{l#?+{%&`=NTnw2VycH7q&bR*Lqo0}n(ewlY(@LFZNEpX=;1PRacsK2uq_V#F+BFn)&Joi_j5aUU)^GeucO7>?MQUJ40eV+w&I zB=Y2TBIQNbja`a(*~3|3yEu~l-Meu3i1yQ^6@l{WyUXjg#5^5=ksvGx9WF*EC@0?0 zCZYN3u3vU-J(SRNZVBI>?Xo42uV*WmqZVygFz(*hiTU8IFXe-G*S&N3t>^>)4-S%h A6#xJL literal 0 HcmV?d00001 diff --git a/docs/brand/icon-256.png b/docs/brand/icon-256.png new file mode 100644 index 0000000000000000000000000000000000000000..c5fe880393d1f08e7669c9dbbceadaf319c5317e GIT binary patch literal 24785 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�J)>#|4v1hZAWI{h@vy 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%0AUZmHh1YRSV3nVm{`?@kcHB< zyQ(BXLk+tr2v#~u{K!+4%03nB>vUDs$%aOc)XwAXpE^A9RD@Pq-#2LmB&!&#OEwex z@DYLygdy0R4Eboly(A;bz;1)tgf+N-y`@RU-4Sl+E0J8#2RAii;O;~IA-vOOej4RzX(?Lhz` znXfzBFU1nH&_x5m3D^BY-wnzQz;~PWp^8@4w4UB_8tOiQHub_ubOw2itC00ah?PfV z97KWzlZHbrNMjJ6P5Q3}bok*wQj1y1kV8n1w8=2QACZI~=kR)NqpfmLvvc4ys!MtncGpOAQEU&uX$_P4W>RPAgjt3)#C#FEZqm zfKrsD3yR&L(l~P~{iUhirJcQRE$7Q#!z~3H_B2v^b;!&NJh@T>>ij^jz=8x&!B>%K z?#H$&Ut#X<LD{4d z)paWDkg^*i#N=U zikxy`36LNDCLQZVEr%hhJCvIQY;DvQ1euUVMq^uP-^b+U?lyHf#+D@0`X+@K=Z!`P zuoz?IkeRrIE(xIAG*fu&r$?;+t3B|yul}H*oxubqCP2aHg0Qj)0f1cx_fhn$4Sn-TK*7$276v0WZ1)skHdQdvjG!A9{0aN%< z4Um0M6ty{F-O(|*!Sxa|JIcggFonkz53#Ys`{|J=H>TOm%F0n}*tVbluMmpx4y|93 zr0|7?1`=_^mhj1n?!o-jYKZO$d;=Q2`1pgAnHXhqqVad!gt6+@(T@$r>|w(G&G*E+ zpDcR)zba;M&l+=eRG>%<9o4OCIFvp6i-4AZ#6u3$wuY<*BR3^{G~GMcq`|2}{an>T zjEKV~;}WjKbk$JybGo?$92oL_7q`rf9K|n+e0-Jj7U)e*rm4Kjip11L^&6?}eUn34 z_6gfuzT>UJ1fFbS;oBMokNsuru+hl3D0YsPZz`|Dq3;em=ySNxkW#J!+$j6ZXr3DI zWRI(goo*qgsd}9Lg{`=$9Y+BBFQibap&=c3=D*nMKbotgZ$SV9s2TZe!h!9w?keNp zPb&zmvSmEV9hH9CSg<=9dC-tmMTDUrRZ1ZHAdA`~5G2+sqtJg&pag0AW9L1;+!x}^ z;8kUKcx5Q^;a&?y^JH;*NuNID9EO$IWYj-Q*Ra;kD^ujV7OA?AM&I$%6J4tmPP7X4 ztx+ml-RVq`Ty0a9Qklr9oIrAZVsolKG( z7-Jf^c%MUouhBl+x8kKC&{{Y+8fO6?JK#*U_gK4w28{t4)&|RHir20`Z#W>O86lv2 z$|)VXz;h@&%-|>utTO$9^^wx!%9P2Hg=G;9qReYK{KSL37nNLryl)id8F){8LGz83 zYl5)uSWKK~O$}{ps$}Esb!SA~ly1>E$Umh`^}p{m(M9M-+?!6Rrj>Qu^Tr%&60V~} z+&eeGRsG2#_)iF?K8b&RK#$DPicqE$_w*(0!_}z;#W{^$Zoh_~uUi>(b%O~&VSwsE zKK%u**kCXO-eO=A22ZCy?rCYKn3m59)+IwJFGw$p_4 zoHj^{`68RMIII|fozxy=OYK2i_M<22##GbMH9!dMMf_=^HhN=e&-pHr-7cFPr<{|Z_~1!LJQ znqIT2iT@dE+c>u+n82S++`Pi1QJ%l3ZH9bRUdtv2I#IxiXu9d!=B zPN%eXf5zO93uNhXiZOdd%4t#XlfM=;pW#-=lSm?>ZICnes5pY z+HFbE=!_G$YlwA`l001ZRyZRBap+`RllQUSo`A6}5nmqoxq(W=vi+}Vb-ivJzu8)< zcoNwum*@9-`}#(wwqMK6NZ&|1kLgYzmigCqK5JoC)jzvfpj*zJnf%t)u|%DmA%MugrndIN zv(VD|M;?+rxMUozNJ?K=c$WfbcKi}0!d31^N2nuxiJ4-+v&Z=M+Cl*kTy=S3VrQDu zMe(VxA`NJTmfr_PN0a{OZ|UP{M1qq+tVb)!%4&KaQh6@^Klk>0(z|u;;y{WN7;g*?sT#pSgP2M|E+vJh{8EHkjd*M#7 zJqHO3P5e-uUt2k2Z^xtz7u}~O;9t%ZPrD^5lUE8c%2bVWi~Oc6-_jS4zw^QQFj*fa zbm-ch#&g1w8txt0)tny|A@-Elu+TyjEd1Vl1y>{yU;=ZjcZ#^^OQFN=Jv(`BM!kKh z?uZ+Bo4lf~GM56wu8>nct*?KX7u3l(l#iYAZG$OGew=RE>Cb(t1vX6luW ziMifpP0z3EQJlQEoiQ`T9X>qtnu|f)+Y)M8_RBL~ivz<%!G*hT=bI7wqd191-j#JT zPcFI4e!UA5w9yMO{Q~SDO1%0MOOn^5?0T8Mbbd6ZDz_5ogoHOddk`Jt6e7IK?r*0Z zFvWc}#M-YGViDn)iD&X3^#WR4{@*XCsjj~KEcEx946Q&_yxS0uU^SZL^cFk!O^UWk zuK2BPW$eBmhIDO4Y=#YTYq`Qs_mVc>6TQdiTrTm(fjCD5hZEmZJLjk2efzc9Gma)E zejNn#7%SyhxOe`fwYR*r{VsoO6egxi77?b#V(lz$LQTS6$t1NL=f*i%sfkouoc9PsxA3RF{F%0RTFyDbNfXzt4H9&)1G@Oks6UuN4@BOD7LhkAGZ~{ek5+_b zQKX*YCZ-DkJBxW$Fh6S(O3c6rbFA+j#8NxqUb9jabLFY(p=^!Pdcd^`TIF}QzBUN% zno6|RiMZdrWIu8BukV{`Udw-MznoRD+14{#|NbgmFn>@3>;G55$$(9vM3R--)cj!T zKQ3M{=&NCi%Ke+zYGH(je_cSkL+#G;#Rkd;j@}D?v~cy!N+G96j$(pXWv;+^cI$N7 zf$7UN`HL}071ot17F^`fa^D}M!G65k=6Q_r`Ei$z5;lNC`xrJpu!ePb@F!XLg)9=I z__B}R3w1AwG&~x5;8hyY=TLu|IG~9i$?SjRDK21|*NX3-aNBGf|Ac&rXcR9w?{4LC z2G3(l;%WQManB1p+I<`Uy58uSYv|qKse7rGjMDHaSF02WUnyAWZ8g&g46 zU4EEDU_KqQAkyI_uj=pk5T)%tnWA4BKlOW@%y3 z%swOMrNOWTL}*HsMm2CN5r_dlE0^#NCziUkPIHs!F77m>+_kuIC+KKNGmRnNJB&gh zI)u6aB@Vxg32SKmYjOVzJT@uGs-BNrFwc}8=E?$ZclSI}^~>CGxnj_2??Q0!vo3|) zta1v9f(v6{pAkW8h4j|?pm)MzcdI0f*(*t2p5-x*acGmyGQ3GXrSb6ciU3JFq14Oa z>b+(6tV`fQx=efcE&%Jf~b{?err|e#sw001EX+(3CkfYj(YhLM zBZl1yo*Ht+v{xY5n5KgF$YaFVkb$MAXRp4Z95SUPR%=1MBnrUs-}2bHjl>DZeSmH& zI#Zr8zJqdggI8~_;K3@O`IaXIo^ABJy4u5*p|C04aYb-0N8DmS7!&4eg|y~Wt6wBX zR5)E{ee`^vpX5(qZZI>`+Vb1>-}b$f>qOPy@Q+5%e3do9?lOD3XL?QrgQzwjED8TF z@TkDID|7POOZ%$*mll*FwE>cIi%6gwmg58YJoVGFvZ{g5`>c0Bf2MwY8&r{MI1Pk; z0kMlJx{=m9o>`crO5PpV0PtoeavjJi<#bBQYE<#KC2)$jB|0%8mR?A>YT{9M1n+7H zUS5WEt8;wKWS;cyo;k(6SmM!TGfWF%)tzQ7p#S#4$TD zjsHzr*9$dzWRrhvxMNe?hGf&?ic>v-4dj|E=%JE?NZ_h$a8lbT*H+&WAKI>C3o9K-Ntju%o>j1tmA`m>ds~GQXMN+1E2fjxB7aP) z+A;$;TA*z`GWZ4Aet@bw#Hp>VGcIXSyrav7hsrJX7eXP>-=T>xFIZpJHHF$aL9N5Q zl}qLS1e@VjLB;_g!OzS_*eLRWtK<_h>!CeqC~S+IX179~0l9aXQSX+VpZxalI~)R^ za%_$#Q`8-8qCgJJ7g@Hv={T}o9R7LBy?`0IXOh3RFP_$9(M2zQPbn~zt(>Hh{J3uk zwWaAC2?<)6oR^&!n;^W6;VW7_633+GORL9NR6gMjTC>5K5K zFtX`w=3ZH5e2Hgw>&jC#OMbbuq2|()7MA&j({YLZu7ab`<+q9zFutzD_p`#2Kk4Wc zo!oT}(gfIDHzMc`;0n^hFSLK-TD71(C@TjBlBYdlJ9G!_#Vecechm#Nl5rb2(h;Dd zHIOuvHWks?COv+dfs^t|tqty4oSGB&qF|xv+b!Ap@0s-ee>>=DSs-+vzhP>v(1bUN zx;u90U_L8|%KCeCbaq>Uvk$<;jl+}ib=jnbwZ%JA89M@ZtfYoX{$#1o(M>VEyxnNQ zZ4SJ*`JmIv%i9~5dW-;-!TN%^Va0fTVKOR74to7n84=MEh4W=YD5RaVM{g z!S}}m3jzEdn)!xZyOLF4V!h|q_0DS*fx$a!-Tq{huGw-+TW$Lh0xdKXW19yNMh}7> z{d(JIfxze?C#vaT08}F-9lp^}X}A*RA-^5*`4xzHKF@qFu0P`5hW=9*o{PRrz`Kl(62c7ZMx0 z;?~N-I)H@DDboz&EL!B$8m%vY*l0bmju-XWH${7yYvar*@V9*FnkA{ + + + + + + + + + + + + + + + + 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