v0.1: OpenMFA — live TOTP delivery to Discord, Slack or Telegram
Platform-neutral successor to Discord Authenticator, forked from its v0.1.2
state with fresh history. Same vault, same UI, same live-message mechanism —
now behind one `Sink` that speaks three platforms.
Why each platform needs what it needs (this drove the whole settings model):
- Discord: an incoming-webhook URL. Webhooks can edit their own messages.
- Slack: a bot token (xoxb-, scope chat:write) + channel ID. Slack incoming
webhooks can post but never edit, and editing IS the live mechanism, so
chat.postMessage / chat.update via a bot is the only path.
- Telegram: a bot token + chat id. No webhook concept exists. The app detects
the chat id itself from the bot's pending updates ("send /start, then
Detect"), since users rarely know it.
- core: Provider enum; settings hold all three configs so switching back and
forth never re-asks for tokens; per-platform shape validation that names the
common mistakes (xoxp- user token, #name instead of channel ID); a
destination key that clears message ids on platform/channel change but not
on token rotation
- delivery: shared DeliveryError + pacing Governor; Discord client refactored
onto them; new Slack client (classifies Slack's ok:false-on-HTTP-200
errors) and Telegram client ("message is not modified" is success; chat
detection via getUpdates); provider-agnostic message builders — code in a
copyable span everywhere, rollover time as live as each platform allows
- http-api: setup accepts an optional provider; settings PUT/GET carry the
full model; /api/slack/whoami, /api/telegram/whoami, /api/telegram/chats
- webui: three-way optional picker on setup and in Settings → Delivery with
guided per-platform fields; "Check token" (Slack) and "Detect chat"
(Telegram); inline white mark via currentColor
- brand: white mark on the dark plate; blue stays with the Discord sibling
- docs/TEAMS.md: exploration for v0.2 — Teams connectors were disabled May
2026 and Workflows webhooks can't edit, so live tiles need a Bot Framework
bot; options for the inbound endpoint on a homelab and a recommended scope
Verified against mock Discord, Slack and Telegram servers speaking each
platform's real protocol shape: edits target the same message rather than
re-posting, deleted tiles are recovered, dead credentials are surfaced with a
next step, renames refresh without a rollover, and switching platform reposts
on the new one and leaves the old one alone. 138 tests.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
commit
ad3720edbf
59 files changed
+14893
No files matched your search
@@ -0,0 +1,27 @@
|
||||
# Keep the Docker build context small. The multi-stage Dockerfile COPYs
|
||||
# only what it needs.
|
||||
|
||||
target/
|
||||
**/target/
|
||||
|
||||
# Runtime state (vault + keys live here — must never enter an image layer).
|
||||
data/
|
||||
/tmp/
|
||||
|
||||
# VCS + editor noise.
|
||||
.git/
|
||||
.github/
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
*.swp
|
||||
|
||||
# Claude / local tooling state.
|
||||
.claude/
|
||||
MEMORY.md
|
||||
|
||||
# Docs & meta.
|
||||
docs/
|
||||
*.md
|
||||
!README.md
|
||||
LICENSE*
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
/target
|
||||
.DS_Store
|
||||
*.swp
|
||||
|
||||
# Runtime state. These only ever live in the work directory, so the patterns
|
||||
# are anchored there rather than matching any file with those names anywhere
|
||||
# in the tree.
|
||||
/data/
|
||||
**/vault.key
|
||||
**/vault.json
|
||||
**/auth.json
|
||||
**/settings.json.tmp
|
||||
|
||||
# Per-user Claude Code state.
|
||||
.claude/
|
||||
Generated
+2791
File diff suppressed because it is too large.
Load diff
+115
@@ -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
|
||||
@@ -0,0 +1,347 @@
|
||||
<p align="center">
|
||||
<img src="docs/brand/icon-256.png" width="128" height="128" alt="OpenMFA" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">OpenMFA</h1>
|
||||
|
||||
<p align="center">
|
||||
A self-hosted TOTP authenticator whose codes appear in
|
||||
<strong>Discord, Slack or Telegram</strong> as live messages that rewrite
|
||||
themselves as each code rolls.
|
||||
</p>
|
||||
|
||||
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 `<code>` is tap-to-copy on mobile.
|
||||
- **A rollover time, as live as the platform allows.** Discord's `<t:…:R>`
|
||||
ticks in the client every second for free. Slack's `<!date>` 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://<host>: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=<token with write:package> ./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 <account>`) 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`).
|
||||
@@ -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
|
||||
@@ -0,0 +1,347 @@
|
||||
//! Single-admin authentication, in the Sonarr/Radarr mould.
|
||||
//!
|
||||
//! On a fresh install no admin exists and the UI shows a first-run form. Once
|
||||
//! created, those credentials gate `/api/*`, and the admin can rotate the
|
||||
//! username and password from Settings.
|
||||
//!
|
||||
//! Multi-user RBAC is not a goal — this is one person's vault of their own
|
||||
//! second factors, so one record is enough.
|
||||
//!
|
||||
//! Unlike the vault, a corrupt `auth.json` falls back to "no admin
|
||||
//! configured" rather than refusing to start: that puts the operator back
|
||||
//! into the setup flow instead of locking them out of their own box. The
|
||||
//! vault stays encrypted either way, so this is not a bypass — a fresh admin
|
||||
//! still can't read secrets without the vault key.
|
||||
|
||||
use crate::{Error, Result};
|
||||
use argon2::password_hash::{
|
||||
rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString,
|
||||
};
|
||||
use argon2::Argon2;
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AdminAccount {
|
||||
pub username: String,
|
||||
/// Argon2id PHC string. The plaintext never outlives the request that set it.
|
||||
pub password_hash: String,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub created_at: OffsetDateTime,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub updated_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
/// Public projection — no hash. Safe to send to the browser.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AdminPublic {
|
||||
pub username: String,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub created_at: OffsetDateTime,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub updated_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
impl From<&AdminAccount> for AdminPublic {
|
||||
fn from(a: &AdminAccount) -> Self {
|
||||
Self {
|
||||
username: a.username.clone(),
|
||||
created_at: a.created_at,
|
||||
updated_at: a.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
struct Inner {
|
||||
admin: Option<AdminAccount>,
|
||||
}
|
||||
|
||||
/// In-memory + on-disk admin registry. Cheap to clone.
|
||||
///
|
||||
/// Every method here that hashes or verifies runs Argon2id, which is
|
||||
/// deliberately expensive (tens of milliseconds). Callers on an async runtime
|
||||
/// should invoke these from `spawn_blocking`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AdminStore {
|
||||
path: Arc<PathBuf>,
|
||||
inner: Arc<RwLock<Inner>>,
|
||||
}
|
||||
|
||||
impl AdminStore {
|
||||
pub fn load_or_default(work_dir: &std::path::Path) -> Self {
|
||||
let path = work_dir.join("auth.json");
|
||||
let inner = match std::fs::read_to_string(&path) {
|
||||
Ok(text) => serde_json::from_str::<Inner>(&text).unwrap_or_else(|e| {
|
||||
tracing::warn!(
|
||||
target: "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<AdminPublic> {
|
||||
self.inner.read().admin.as_ref().map(AdminPublic::from)
|
||||
}
|
||||
|
||||
/// First-run setup. Refuses to run twice, so an exposed instance can't be
|
||||
/// re-bootstrapped by whoever finds the URL.
|
||||
pub fn bootstrap(&self, username: &str, password: &str) -> Result<AdminPublic> {
|
||||
validate_username(username)?;
|
||||
validate_password(password)?;
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let admin = AdminAccount {
|
||||
username: username.trim().to_string(),
|
||||
password_hash: hash_password(password)?,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
{
|
||||
let mut g = self.inner.write();
|
||||
if g.admin.is_some() {
|
||||
return Err(Error::conflict("an admin account already exists"));
|
||||
}
|
||||
g.admin = Some(admin.clone());
|
||||
}
|
||||
self.persist();
|
||||
tracing::info!(target: "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<Option<AdminPublic>> {
|
||||
let Some(admin) = self.inner.read().admin.clone() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if username.trim() != admin.username {
|
||||
return Ok(None);
|
||||
}
|
||||
let parsed = PasswordHash::new(&admin.password_hash)
|
||||
.map_err(|e| Error::crypto(format!("stored password hash is unreadable: {e}")))?;
|
||||
match Argon2::default().verify_password(password.as_bytes(), &parsed) {
|
||||
Ok(()) => Ok(Some((&admin).into())),
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotate username and/or password. `current_password` must match the
|
||||
/// existing hash — proof that the person at the keyboard is the owner,
|
||||
/// not just someone holding a stolen session cookie.
|
||||
pub fn update_credentials(
|
||||
&self,
|
||||
current_password: &str,
|
||||
new_username: Option<&str>,
|
||||
new_password: Option<&str>,
|
||||
) -> Result<AdminPublic> {
|
||||
let existing = self
|
||||
.inner
|
||||
.read()
|
||||
.admin
|
||||
.clone()
|
||||
.ok_or_else(|| Error::conflict("no admin account exists yet"))?;
|
||||
|
||||
let parsed = PasswordHash::new(&existing.password_hash)
|
||||
.map_err(|e| Error::crypto(format!("stored password hash is unreadable: {e}")))?;
|
||||
if Argon2::default()
|
||||
.verify_password(current_password.as_bytes(), &parsed)
|
||||
.is_err()
|
||||
{
|
||||
return Err(Error::invalid("current password is incorrect"));
|
||||
}
|
||||
|
||||
let mut updated = existing;
|
||||
if let Some(u) = new_username.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
validate_username(u)?;
|
||||
updated.username = u.to_string();
|
||||
}
|
||||
if let Some(p) = new_password.filter(|s| !s.is_empty()) {
|
||||
validate_password(p)?;
|
||||
updated.password_hash = hash_password(p)?;
|
||||
}
|
||||
updated.updated_at = OffsetDateTime::now_utc();
|
||||
|
||||
self.inner.write().admin = Some(updated.clone());
|
||||
self.persist();
|
||||
tracing::info!(target: "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<String> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
Argon2::default()
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map(|h| h.to_string())
|
||||
.map_err(|e| Error::crypto(format!("could not hash password: {e}")))
|
||||
}
|
||||
|
||||
fn validate_username(u: &str) -> Result<()> {
|
||||
let u = u.trim();
|
||||
if u.is_empty() {
|
||||
return Err(Error::invalid("username must not be empty"));
|
||||
}
|
||||
if u.len() > 64 {
|
||||
return Err(Error::invalid("username must be 64 characters or fewer"));
|
||||
}
|
||||
if !u.chars().all(|c| c.is_ascii_graphic()) {
|
||||
return Err(Error::invalid(
|
||||
"username must use printable ASCII with no spaces",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_password(p: &str) -> Result<()> {
|
||||
if p.chars().count() < 8 {
|
||||
return Err(Error::invalid("password must be at least 8 characters"));
|
||||
}
|
||||
if p.len() > 256 {
|
||||
return Err(Error::invalid("password must be 256 bytes or fewer"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn starts_unconfigured() {
|
||||
let dir = tempdir().unwrap();
|
||||
let s = AdminStore::load_or_default(dir.path());
|
||||
assert!(!s.is_configured());
|
||||
assert!(s.snapshot().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_then_verify() {
|
||||
let dir = tempdir().unwrap();
|
||||
let s = AdminStore::load_or_default(dir.path());
|
||||
let p = s.bootstrap("admin", "hunter2hunter2").unwrap();
|
||||
assert_eq!(p.username, "admin");
|
||||
assert!(s.verify("admin", "hunter2hunter2").unwrap().is_some());
|
||||
assert!(s.verify("admin", "wrong").unwrap().is_none());
|
||||
assert!(s.verify("nobody", "hunter2hunter2").unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_is_never_stored_in_the_clear() {
|
||||
let dir = tempdir().unwrap();
|
||||
let s = AdminStore::load_or_default(dir.path());
|
||||
s.bootstrap("admin", "hunter2hunter2").unwrap();
|
||||
let raw = std::fs::read_to_string(dir.path().join("auth.json")).unwrap();
|
||||
assert!(!raw.contains("hunter2hunter2"));
|
||||
assert!(
|
||||
raw.contains("$argon2id$"),
|
||||
"expected an Argon2id PHC string"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_refuses_a_second_run() {
|
||||
let dir = tempdir().unwrap();
|
||||
let s = AdminStore::load_or_default(dir.path());
|
||||
s.bootstrap("admin", "hunter2hunter2").unwrap();
|
||||
assert!(matches!(
|
||||
s.bootstrap("other", "anotherpass1"),
|
||||
Err(Error::Conflict(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn survives_a_reload() {
|
||||
let dir = tempdir().unwrap();
|
||||
AdminStore::load_or_default(dir.path())
|
||||
.bootstrap("admin", "hunter2hunter2")
|
||||
.unwrap();
|
||||
let s2 = AdminStore::load_or_default(dir.path());
|
||||
assert!(s2.is_configured());
|
||||
assert!(s2.verify("admin", "hunter2hunter2").unwrap().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_requires_the_current_password() {
|
||||
let dir = tempdir().unwrap();
|
||||
let s = AdminStore::load_or_default(dir.path());
|
||||
s.bootstrap("admin", "hunter2hunter2").unwrap();
|
||||
|
||||
assert!(s
|
||||
.update_credentials("nope", None, Some("newpassword1"))
|
||||
.is_err());
|
||||
assert!(s.verify("admin", "hunter2hunter2").unwrap().is_some());
|
||||
|
||||
s.update_credentials("hunter2hunter2", Some("alice"), Some("newpassword1"))
|
||||
.unwrap();
|
||||
assert!(s.verify("admin", "hunter2hunter2").unwrap().is_none());
|
||||
assert!(s.verify("alice", "newpassword1").unwrap().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotation_can_change_just_one_field() {
|
||||
let dir = tempdir().unwrap();
|
||||
let s = AdminStore::load_or_default(dir.path());
|
||||
s.bootstrap("admin", "hunter2hunter2").unwrap();
|
||||
// Password only — username sticks.
|
||||
s.update_credentials("hunter2hunter2", None, Some("newpassword1"))
|
||||
.unwrap();
|
||||
assert!(s.verify("admin", "newpassword1").unwrap().is_some());
|
||||
// Username only — password sticks. Empty strings mean "unchanged".
|
||||
s.update_credentials("newpassword1", Some("bob"), Some(""))
|
||||
.unwrap();
|
||||
assert!(s.verify("bob", "newpassword1").unwrap().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_validation() {
|
||||
let dir = tempdir().unwrap();
|
||||
let s = AdminStore::load_or_default(dir.path());
|
||||
assert!(s.bootstrap("", "hunter2hunter2").is_err());
|
||||
assert!(s.bootstrap("has space", "hunter2hunter2").is_err());
|
||||
assert!(s.bootstrap("admin", "short").is_err());
|
||||
assert!(s.bootstrap(&"a".repeat(65), "hunter2hunter2").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_auth_file_falls_back_to_setup_mode() {
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("auth.json"), b"{ not json").unwrap();
|
||||
assert!(!AdminStore::load_or_default(dir.path()).is_configured());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
//! Envelope encryption for the TOTP secrets on disk.
|
||||
//!
|
||||
//! **Threat model, stated plainly.** This app must mint codes unattended,
|
||||
//! around the clock, to keep the Discord tiles live. That rules out deriving
|
||||
//! the vault key from the admin password: nobody is there to type it after a
|
||||
//! container restart. So the key necessarily sits somewhere the process can
|
||||
//! read at boot.
|
||||
//!
|
||||
//! What that buys you, and what it doesn't:
|
||||
//!
|
||||
//! * **Protects against** a leaked or backed-up `vault.json` — the ciphertext
|
||||
//! alone is useless. That is the realistic homelab accident: an appdata
|
||||
//! share browsed over SMB, a snapshot copied somewhere careless, a bind
|
||||
//! mount that ends up in a backup tarball.
|
||||
//! * **Does not protect against** an attacker who can read the whole work
|
||||
//! directory, since `vault.key` lives there by default. For that case set
|
||||
//! `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(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
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. `<work_dir>/vault.key` — read if present.
|
||||
/// 3. Otherwise a fresh random key is generated and written there with
|
||||
/// mode 0600.
|
||||
pub fn load_or_create(work_dir: &Path) -> Result<Self> {
|
||||
if let Ok(encoded) = std::env::var(KEY_ENV) {
|
||||
let encoded = encoded.trim();
|
||||
if !encoded.is_empty() {
|
||||
let mut raw = b64_decode(encoded)
|
||||
.map_err(|_| Error::crypto(format!("{KEY_ENV} is not valid base64")))?;
|
||||
if raw.len() != 32 {
|
||||
raw.zeroize();
|
||||
return Err(Error::crypto(format!(
|
||||
"{KEY_ENV} must decode to exactly 32 bytes"
|
||||
)));
|
||||
}
|
||||
let key = *Key::from_slice(&raw);
|
||||
raw.zeroize();
|
||||
tracing::info!(target: "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<Self> {
|
||||
if raw.len() != 32 {
|
||||
return Err(Error::crypto("vault key must be exactly 32 bytes"));
|
||||
}
|
||||
Ok(Self {
|
||||
key: *Key::from_slice(raw),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn seal(&self, plaintext: &[u8]) -> Result<Sealed> {
|
||||
let cipher = XChaCha20Poly1305::new(&self.key);
|
||||
let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
|
||||
let ciphertext = cipher
|
||||
.encrypt(&nonce, plaintext)
|
||||
.map_err(|_| Error::crypto("could not encrypt secret"))?;
|
||||
Ok(Sealed {
|
||||
nonce: b64_encode(&nonce),
|
||||
ciphertext: b64_encode(&ciphertext),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn open(&self, sealed: &Sealed) -> Result<Vec<u8>> {
|
||||
let nonce = b64_decode(&sealed.nonce)
|
||||
.map_err(|_| Error::crypto("stored nonce is not valid base64"))?;
|
||||
if nonce.len() != 24 {
|
||||
return Err(Error::crypto("stored nonce has the wrong length"));
|
||||
}
|
||||
let ciphertext = b64_decode(&sealed.ciphertext)
|
||||
.map_err(|_| Error::crypto("stored ciphertext is not valid base64"))?;
|
||||
let cipher = XChaCha20Poly1305::new(&self.key);
|
||||
cipher
|
||||
.decrypt(XNonce::from_slice(&nonce), ciphertext.as_ref())
|
||||
.map_err(|_| {
|
||||
Error::crypto("could not decrypt secret — the vault key does not match this vault")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path` readable only by the owner.
|
||||
///
|
||||
/// The mode is set at creation time on Unix rather than chmod-ed afterwards,
|
||||
/// so the key is never briefly world-readable.
|
||||
fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
use std::io::Write;
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
opts.mode(0o600);
|
||||
}
|
||||
let mut f = opts.open(path)?;
|
||||
f.write_all(bytes)?;
|
||||
f.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn b64_encode(bytes: &[u8]) -> String {
|
||||
use base64::Engine;
|
||||
base64::engine::general_purpose::STANDARD.encode(bytes)
|
||||
}
|
||||
|
||||
fn b64_decode(s: &str) -> std::result::Result<Vec<u8>, base64::DecodeError> {
|
||||
use base64::Engine;
|
||||
base64::engine::general_purpose::STANDARD.decode(s.trim())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn seal_open_round_trip() {
|
||||
let k = VaultKey::from_bytes(&[7u8; 32]).unwrap();
|
||||
let sealed = k.seal(b"super secret bytes").unwrap();
|
||||
assert_eq!(k.open(&sealed).unwrap(), b"super secret bytes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_seal_uses_a_fresh_nonce() {
|
||||
let k = VaultKey::from_bytes(&[7u8; 32]).unwrap();
|
||||
let a = k.seal(b"same plaintext").unwrap();
|
||||
let b = k.seal(b"same plaintext").unwrap();
|
||||
// Identical input must not produce identical ciphertext, or the
|
||||
// vault would leak which accounts share a secret.
|
||||
assert_ne!(a.nonce, b.nonce);
|
||||
assert_ne!(a.ciphertext, b.ciphertext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_different_key_cannot_open_it() {
|
||||
let a = VaultKey::from_bytes(&[1u8; 32]).unwrap();
|
||||
let b = VaultKey::from_bytes(&[2u8; 32]).unwrap();
|
||||
let sealed = a.seal(b"secret").unwrap();
|
||||
assert!(b.open(&sealed).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_ciphertext_is_rejected() {
|
||||
let k = VaultKey::from_bytes(&[9u8; 32]).unwrap();
|
||||
let mut sealed = k.seal(b"secret").unwrap();
|
||||
// Flip a byte: Poly1305 must catch it rather than return garbage.
|
||||
let mut raw = b64_decode(&sealed.ciphertext).unwrap();
|
||||
raw[0] ^= 0xff;
|
||||
sealed.ciphertext = b64_encode(&raw);
|
||||
assert!(k.open(&sealed).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_file_is_created_once_and_reused() {
|
||||
let dir = tempdir().unwrap();
|
||||
let k1 = VaultKey::load_or_create(dir.path()).unwrap();
|
||||
let sealed = k1.seal(b"persisted").unwrap();
|
||||
// A second load must reuse the same key, or every restart would
|
||||
// orphan the vault.
|
||||
let k2 = VaultKey::load_or_create(dir.path()).unwrap();
|
||||
assert_eq!(k2.open(&sealed).unwrap(), b"persisted");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn key_file_is_owner_only() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempdir().unwrap();
|
||||
let _ = VaultKey::load_or_create(dir.path()).unwrap();
|
||||
let meta = std::fs::metadata(dir.path().join(KEY_FILE)).unwrap();
|
||||
assert_eq!(meta.permissions().mode() & 0o777, 0o600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_a_truncated_key_file() {
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(dir.path().join(KEY_FILE), b"too short").unwrap();
|
||||
// Silently generating a fresh key here would strand every stored
|
||||
// secret, so this must be a hard error.
|
||||
assert!(VaultKey::load_or_create(dir.path()).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! One error type for the whole domain core.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
/// Caller supplied something we can't accept. Surfaces as HTTP 400 and
|
||||
/// the message is written to be read by a human in a toast.
|
||||
#[error("{0}")]
|
||||
Invalid(String),
|
||||
|
||||
/// Asked for something that isn't there. Surfaces as HTTP 404.
|
||||
#[error("{0} not found")]
|
||||
NotFound(String),
|
||||
|
||||
/// Refused because it would clobber existing state. Surfaces as 409.
|
||||
#[error("{0}")]
|
||||
Conflict(String),
|
||||
|
||||
#[error("i/o error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("serialization error: {0}")]
|
||||
Serde(#[from] serde_json::Error),
|
||||
|
||||
/// Vault could not be sealed or unsealed. Deliberately vague — the
|
||||
/// detail goes to the log, not to the caller.
|
||||
#[error("cryptography error: {0}")]
|
||||
Crypto(String),
|
||||
|
||||
#[error(transparent)]
|
||||
Other(#[from] anyhow_lite::Any),
|
||||
}
|
||||
|
||||
impl Error {
|
||||
pub fn invalid(msg: impl fmt::Display) -> Self {
|
||||
Self::Invalid(msg.to_string())
|
||||
}
|
||||
pub fn not_found(what: impl fmt::Display) -> Self {
|
||||
Self::NotFound(what.to_string())
|
||||
}
|
||||
pub fn conflict(msg: impl fmt::Display) -> Self {
|
||||
Self::Conflict(msg.to_string())
|
||||
}
|
||||
pub fn crypto(msg: impl fmt::Display) -> Self {
|
||||
Self::Crypto(msg.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// A minimal boxed-error shim so `core` doesn't need the `anyhow` crate
|
||||
/// just to carry the occasional opaque third-party error.
|
||||
pub mod anyhow_lite {
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("{0}")]
|
||||
pub struct Any(pub String);
|
||||
|
||||
impl Any {
|
||||
pub fn new(e: impl std::fmt::Display) -> Self {
|
||||
Self(e.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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<ImportBatch> {
|
||||
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)
|
||||
}
|
||||
@@ -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<OtpAuth>,
|
||||
pub skipped: Vec<Skipped>,
|
||||
/// 0-based index of this QR within the export, when present.
|
||||
pub batch_index: Option<i32>,
|
||||
/// Total number of QR codes in the export, when present.
|
||||
pub batch_size: Option<i32>,
|
||||
/// 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<i32>,
|
||||
}
|
||||
|
||||
impl ImportBatch {
|
||||
/// Human-readable "Part 2 of 3", when the export was split.
|
||||
pub fn part_label(&self) -> Option<String> {
|
||||
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<ImportBatch> {
|
||||
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<Vec<u8>> {
|
||||
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<u64> {
|
||||
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<ImportBatch> {
|
||||
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<Option<OtpAuth>, Skipped> {
|
||||
let mut secret: Vec<u8> = 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<u64> = 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<u8>) {
|
||||
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<u8>) {
|
||||
put_varint((u64::from(field) << 3) | u64::from(wire), out);
|
||||
}
|
||||
|
||||
fn put_bytes(field: u32, bytes: &[u8], out: &mut Vec<u8>) {
|
||||
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<u8>) {
|
||||
put_tag(field, 0, out);
|
||||
put_varint(v, out);
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Entry {
|
||||
secret: Vec<u8>,
|
||||
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: "[email protected]",
|
||||
issuer: "Cloudflare",
|
||||
algorithm: 1, // SHA1
|
||||
digits: 1, // SIX
|
||||
otp_type: 2, // TOTP
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_entry(e: &Entry) -> Vec<u8> {
|
||||
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<u8> {
|
||||
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, "[email protected]");
|
||||
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: "[email protected]",
|
||||
issuer: "Cloudflare",
|
||||
..Entry::default()
|
||||
},
|
||||
Entry {
|
||||
name: "[email protected]",
|
||||
issuer: "Plex",
|
||||
..Entry::default()
|
||||
},
|
||||
Entry {
|
||||
name: "[email protected]",
|
||||
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: "[email protected]",
|
||||
..Entry::default()
|
||||
},
|
||||
Entry {
|
||||
name: "[email protected]",
|
||||
otp_type: 1,
|
||||
..Entry::default()
|
||||
}, // HOTP
|
||||
Entry {
|
||||
name: "[email protected]",
|
||||
algorithm: 4,
|
||||
..Entry::default()
|
||||
}, // MD5
|
||||
Entry {
|
||||
name: "[email protected]",
|
||||
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, "[email protected]");
|
||||
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("[email protected]").contains("HOTP"));
|
||||
assert!(reason("[email protected]").contains("MD5"));
|
||||
assert!(reason("[email protected]").contains("no secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_may_carry_the_issuer_prefix() {
|
||||
let e = Entry {
|
||||
name: "Cloudflare:[email protected]",
|
||||
issuer: "",
|
||||
..Entry::default()
|
||||
};
|
||||
let b = parse_migration(&uri_for(&[e])).unwrap();
|
||||
assert_eq!(b.accounts[0].issuer, "Cloudflare");
|
||||
assert_eq!(b.accounts[0].label, "[email protected]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issuer_field_wins_over_the_name_prefix() {
|
||||
let e = Entry {
|
||||
name: "Stale:[email protected]",
|
||||
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, "[email protected]");
|
||||
}
|
||||
|
||||
#[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<Entry> = (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<Entry> = (0..10)
|
||||
.map(|i| Entry {
|
||||
secret: vec![i; 20],
|
||||
name: "[email protected]",
|
||||
..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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Provider>,
|
||||
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<String> {
|
||||
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<Self> {
|
||||
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<Settings>,
|
||||
}
|
||||
|
||||
impl SettingsStore {
|
||||
/// Load from `<work_dir>/settings.json`, or start with defaults. Never
|
||||
/// fails: a bad settings file is not a reason to refuse to boot.
|
||||
pub fn load_or_default(work_dir: &Path) -> Arc<Self> {
|
||||
let path = work_dir.join("settings.json");
|
||||
let initial = match std::fs::read_to_string(&path) {
|
||||
Ok(text) => serde_json::from_str::<Settings>(&text).unwrap_or_else(|e| {
|
||||
tracing::warn!(
|
||||
target: "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));
|
||||
}
|
||||
}
|
||||
@@ -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 = <Hmac<$hash>>::new_from_slice($key).expect("HMAC accepts keys of any length");
|
||||
mac.update($msg);
|
||||
mac.finalize().into_bytes().to_vec()
|
||||
}};
|
||||
}
|
||||
|
||||
/// Hash backing the HMAC. SHA-1 is the near-universal default; the others
|
||||
/// appear rarely but are part of RFC 6238 and cost nothing to support.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "UPPERCASE")]
|
||||
pub enum Algorithm {
|
||||
#[default]
|
||||
Sha1,
|
||||
Sha256,
|
||||
Sha512,
|
||||
}
|
||||
|
||||
impl Algorithm {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Sha1 => "SHA1",
|
||||
Self::Sha256 => "SHA256",
|
||||
Self::Sha512 => "SHA512",
|
||||
}
|
||||
}
|
||||
|
||||
fn parse(s: &str) -> Result<Self> {
|
||||
match s.trim().to_ascii_uppercase().as_str() {
|
||||
"SHA1" | "SHA-1" => Ok(Self::Sha1),
|
||||
"SHA256" | "SHA-256" => Ok(Self::Sha256),
|
||||
"SHA512" | "SHA-512" => Ok(Self::Sha512),
|
||||
other => Err(Error::invalid(format!(
|
||||
"unsupported algorithm '{other}' (expected SHA1, SHA256 or SHA512)"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything needed to produce codes for one account. The secret lives
|
||||
/// here in plaintext; callers are responsible for keeping this struct out
|
||||
/// of anything that gets serialized to disk unencrypted.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Totp {
|
||||
pub secret: Vec<u8>,
|
||||
pub algorithm: Algorithm,
|
||||
pub digits: u8,
|
||||
pub period: u64,
|
||||
}
|
||||
|
||||
impl Totp {
|
||||
pub fn new(secret: Vec<u8>, algorithm: Algorithm, digits: u8, period: u64) -> Result<Self> {
|
||||
if secret.is_empty() {
|
||||
return Err(Error::invalid("the shared secret is empty"));
|
||||
}
|
||||
if !(6..=8).contains(&digits) {
|
||||
return Err(Error::invalid(format!(
|
||||
"digits must be between 6 and 8 (got {digits})"
|
||||
)));
|
||||
}
|
||||
if period == 0 {
|
||||
return Err(Error::invalid("period must be at least 1 second"));
|
||||
}
|
||||
Ok(Self {
|
||||
secret,
|
||||
algorithm,
|
||||
digits,
|
||||
period,
|
||||
})
|
||||
}
|
||||
|
||||
/// Which time step `unix_secs` falls in. T0 is 0 per RFC 6238 §4.1.
|
||||
pub fn counter_at(&self, unix_secs: u64) -> u64 {
|
||||
unix_secs / self.period
|
||||
}
|
||||
|
||||
/// Unix second at which the code covering `unix_secs` stops being valid.
|
||||
pub fn expires_at(&self, unix_secs: u64) -> u64 {
|
||||
(self.counter_at(unix_secs) + 1) * self.period
|
||||
}
|
||||
|
||||
/// Seconds remaining on the code covering `unix_secs`.
|
||||
pub fn remaining_at(&self, unix_secs: u64) -> u64 {
|
||||
self.expires_at(unix_secs) - unix_secs
|
||||
}
|
||||
|
||||
/// The code for the step containing `unix_secs`.
|
||||
pub fn code_at(&self, unix_secs: u64) -> String {
|
||||
self.hotp(self.counter_at(unix_secs))
|
||||
}
|
||||
|
||||
/// The code for the step *after* the one containing `unix_secs`. The UI
|
||||
/// and the Discord pusher both pre-fetch this so a rollover never has a
|
||||
/// request on the critical path.
|
||||
pub fn next_code_at(&self, unix_secs: u64) -> String {
|
||||
self.hotp(self.counter_at(unix_secs) + 1)
|
||||
}
|
||||
|
||||
/// RFC 4226 §5.3 — HMAC, dynamic truncation, modulo.
|
||||
fn hotp(&self, counter: u64) -> String {
|
||||
let msg = counter.to_be_bytes();
|
||||
let digest: Vec<u8> = match self.algorithm {
|
||||
Algorithm::Sha1 => mac_bytes!(Sha1, &self.secret, &msg),
|
||||
Algorithm::Sha256 => mac_bytes!(Sha256, &self.secret, &msg),
|
||||
Algorithm::Sha512 => mac_bytes!(Sha512, &self.secret, &msg),
|
||||
};
|
||||
|
||||
// Dynamic truncation: the low nibble of the last byte picks a
|
||||
// 4-byte window; the top bit is masked off to dodge sign issues in
|
||||
// the languages the RFC was written for.
|
||||
let offset = (digest[digest.len() - 1] & 0x0f) as usize;
|
||||
let bin = u32::from_be_bytes([
|
||||
digest[offset] & 0x7f,
|
||||
digest[offset + 1],
|
||||
digest[offset + 2],
|
||||
digest[offset + 3],
|
||||
]);
|
||||
|
||||
let modulo = 10_u32.pow(u32::from(self.digits));
|
||||
format!("{:0width$}", bin % modulo, width = usize::from(self.digits))
|
||||
}
|
||||
}
|
||||
|
||||
// ── otpauth:// parsing ────────────────────────────────────────────────────
|
||||
|
||||
/// A parsed `otpauth://totp/...` URI: the crypto parameters plus the
|
||||
/// human-facing naming.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OtpAuth {
|
||||
pub totp: Totp,
|
||||
pub issuer: String,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
/// Parse an `otpauth://totp/...` URI.
|
||||
///
|
||||
/// Deliberately lenient — see the module docs for why. `hotp://` counter-based
|
||||
/// URIs are rejected with a specific message rather than a generic parse
|
||||
/// failure, because a user scanning a counter-based QR deserves to know that's
|
||||
/// what happened.
|
||||
pub fn parse_otpauth(uri: &str) -> Result<OtpAuth> {
|
||||
let uri = uri.trim();
|
||||
let parsed =
|
||||
url::Url::parse(uri).map_err(|e| Error::invalid(format!("not a valid URI: {e}")))?;
|
||||
|
||||
if !parsed.scheme().eq_ignore_ascii_case("otpauth") {
|
||||
if parsed.scheme().eq_ignore_ascii_case("otpauth-migration") {
|
||||
// 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<Vec<u8>> {
|
||||
let cleaned: String = raw
|
||||
.chars()
|
||||
.filter(|c| !c.is_whitespace() && *c != '-' && *c != '=')
|
||||
.collect::<String>()
|
||||
.to_ascii_uppercase();
|
||||
|
||||
if cleaned.is_empty() {
|
||||
return Err(Error::invalid("the shared secret is empty"));
|
||||
}
|
||||
|
||||
base32::decode(base32::Alphabet::Rfc4648 { padding: false }, &cleaned)
|
||||
.filter(|b| !b.is_empty())
|
||||
.ok_or_else(|| {
|
||||
Error::invalid(
|
||||
"the shared secret is not valid base32 (expected letters A–Z and digits 2–7)",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Encode a secret back to base32 — used when re-exporting an account.
|
||||
pub fn encode_secret(secret: &[u8]) -> String {
|
||||
base32::encode(base32::Alphabet::Rfc4648 { padding: false }, secret)
|
||||
}
|
||||
|
||||
fn percent_decode(s: &str) -> String {
|
||||
percent_encoding::percent_decode_str(s)
|
||||
.decode_utf8_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// RFC 6238 Appendix B. The three secrets are the ASCII string
|
||||
/// "12345678901234567890" truncated/repeated to each hash's block size.
|
||||
fn rfc_secret(alg: Algorithm) -> Vec<u8> {
|
||||
let seed = b"1234567890";
|
||||
let len = match alg {
|
||||
Algorithm::Sha1 => 20,
|
||||
Algorithm::Sha256 => 32,
|
||||
Algorithm::Sha512 => 64,
|
||||
};
|
||||
seed.iter().copied().cycle().take(len).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc6238_test_vectors() {
|
||||
let times = [
|
||||
59_u64,
|
||||
1_111_111_109,
|
||||
1_111_111_111,
|
||||
1_234_567_890,
|
||||
2_000_000_000,
|
||||
20_000_000_000,
|
||||
];
|
||||
|
||||
let sha1 = [
|
||||
"94287082", "07081804", "14050471", "89005924", "69279037", "65353130",
|
||||
];
|
||||
let sha256 = [
|
||||
"46119246", "68084774", "67062674", "91819424", "90698825", "77737706",
|
||||
];
|
||||
let sha512 = [
|
||||
"90693936", "25091201", "99943326", "93441116", "38618901", "47863826",
|
||||
];
|
||||
|
||||
for (alg, expected) in [
|
||||
(Algorithm::Sha1, sha1),
|
||||
(Algorithm::Sha256, sha256),
|
||||
(Algorithm::Sha512, sha512),
|
||||
] {
|
||||
let totp = Totp::new(rfc_secret(alg), alg, 8, 30).unwrap();
|
||||
for (t, want) in times.iter().zip(expected.iter()) {
|
||||
assert_eq!(&totp.code_at(*t), want, "{} at t={t}", alg.as_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn six_digit_codes_are_the_low_six_of_the_eight() {
|
||||
let totp = Totp::new(rfc_secret(Algorithm::Sha1), Algorithm::Sha1, 6, 30).unwrap();
|
||||
// RFC vector at t=59 is 94287082 → the 6-digit form is its last six.
|
||||
assert_eq!(totp.code_at(59), "287082");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codes_are_zero_padded_to_width() {
|
||||
// Every generated code must be exactly `digits` long, including the
|
||||
// ~10% of values with a leading zero. 07081804 above already covers
|
||||
// the 8-digit case; assert the invariant broadly here.
|
||||
let totp = Totp::new(rfc_secret(Algorithm::Sha1), Algorithm::Sha1, 6, 30).unwrap();
|
||||
for t in (0..30_000).step_by(37) {
|
||||
assert_eq!(totp.code_at(t).len(), 6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn period_boundaries() {
|
||||
let totp = Totp::new(vec![1, 2, 3, 4], Algorithm::Sha1, 6, 30).unwrap();
|
||||
assert_eq!(totp.counter_at(0), 0);
|
||||
assert_eq!(totp.counter_at(29), 0);
|
||||
assert_eq!(totp.counter_at(30), 1);
|
||||
assert_eq!(totp.expires_at(0), 30);
|
||||
assert_eq!(totp.expires_at(29), 30);
|
||||
assert_eq!(totp.expires_at(30), 60);
|
||||
assert_eq!(totp.remaining_at(25), 5);
|
||||
// The next code at t is the current code one period later.
|
||||
assert_eq!(totp.next_code_at(10), totp.code_at(40));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_parameters() {
|
||||
assert!(Totp::new(vec![], Algorithm::Sha1, 6, 30).is_err());
|
||||
assert!(Totp::new(vec![1], Algorithm::Sha1, 5, 30).is_err());
|
||||
assert!(Totp::new(vec![1], Algorithm::Sha1, 9, 30).is_err());
|
||||
assert!(Totp::new(vec![1], Algorithm::Sha1, 6, 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_a_canonical_uri() {
|
||||
let a = parse_otpauth(
|
||||
"otpauth://totp/Cloudflare:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=Cloudflare",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(a.issuer, "Cloudflare");
|
||||
assert_eq!(a.label, "[email protected]");
|
||||
assert_eq!(a.totp.digits, 6);
|
||||
assert_eq!(a.totp.period, 30);
|
||||
assert_eq!(a.totp.algorithm, Algorithm::Sha1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_issuer_wins_over_a_disagreeing_label() {
|
||||
// The Microsoft case: the label says one thing, ?issuer= another.
|
||||
// A strict parser rejects this outright; we take the query param.
|
||||
let a = parse_otpauth(
|
||||
"otpauth://totp/Issuer1:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=Microsoft",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(a.issuer, "Microsoft");
|
||||
assert_eq!(a.label, "[email protected]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_the_label_issuer_then_the_account() {
|
||||
let a =
|
||||
parse_otpauth("otpauth://totp/Plex:[email protected]?secret=JBSWY3DPEHPK3PXP").unwrap();
|
||||
assert_eq!(a.issuer, "Plex");
|
||||
|
||||
let b = parse_otpauth("otpauth://totp/[email protected]?secret=JBSWY3DPEHPK3PXP").unwrap();
|
||||
assert_eq!(b.issuer, "[email protected]");
|
||||
assert_eq!(b.label, "[email protected]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percent_encoded_labels_survive() {
|
||||
let a = parse_otpauth(
|
||||
"otpauth://totp/Big%20Corp%3Aalice%40example.com?secret=JBSWY3DPEHPK3PXP",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(a.issuer, "Big Corp");
|
||||
assert_eq!(a.label, "[email protected]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn honours_explicit_parameters() {
|
||||
let a = parse_otpauth(
|
||||
"otpauth://totp/X:y?secret=JBSWY3DPEHPK3PXP&algorithm=SHA256&digits=8&period=60",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(a.totp.algorithm, Algorithm::Sha256);
|
||||
assert_eq!(a.totp.digits, 8);
|
||||
assert_eq!(a.totp.period, 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_query_parameters_are_ignored() {
|
||||
assert!(parse_otpauth(
|
||||
"otpauth://totp/X:y?secret=JBSWY3DPEHPK3PXP&image=https://x/y.png&lock=false"
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_normalisation() {
|
||||
// Lowercase (Google has shipped these).
|
||||
assert_eq!(
|
||||
decode_secret("jbswy3dpehpk3pxp").unwrap(),
|
||||
decode_secret("JBSWY3DPEHPK3PXP").unwrap()
|
||||
);
|
||||
// Padded.
|
||||
assert_eq!(
|
||||
decode_secret("JBSWY3DPEHPK3PXP===").unwrap(),
|
||||
decode_secret("JBSWY3DPEHPK3PXP").unwrap()
|
||||
);
|
||||
// Spaced and hyphenated, the way providers print them for typing.
|
||||
assert_eq!(
|
||||
decode_secret("jbsw y3dp ehpk 3pxp").unwrap(),
|
||||
decode_secret("JBSWY3DPEHPK3PXP").unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
decode_secret("JBSW-Y3DP-EHPK-3PXP").unwrap(),
|
||||
decode_secret("JBSWY3DPEHPK3PXP").unwrap()
|
||||
);
|
||||
// Base32 excludes 0/1/8/9 — these must fail rather than silently decode.
|
||||
assert!(decode_secret("01890189").is_err());
|
||||
assert!(decode_secret("").is_err());
|
||||
assert!(decode_secret(" ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_round_trips() {
|
||||
let bytes = decode_secret("JBSWY3DPEHPK3PXP").unwrap();
|
||||
assert_eq!(encode_secret(&bytes), "JBSWY3DPEHPK3PXP");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_totp_links_with_specific_messages() {
|
||||
// HOTP must name the actual problem, not just "parse error".
|
||||
let e = parse_otpauth("otpauth://hotp/X:y?secret=JBSWY3DPEHPK3PXP&counter=1")
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(e.contains("counter-based"), "got: {e}");
|
||||
|
||||
let e = parse_otpauth("otpauth-migration://offline?data=abc")
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(e.contains("export"), "got: {e}");
|
||||
|
||||
assert!(parse_otpauth("https://example.com").is_err());
|
||||
assert!(parse_otpauth("not a uri at all").is_err());
|
||||
// Missing secret.
|
||||
assert!(parse_otpauth("otpauth://totp/X:y?issuer=X").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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<String>,
|
||||
/// Last delivery failure, surfaced in the UI. `None` once a push succeeds.
|
||||
pub last_error: Option<String>,
|
||||
/// Code most recently written to Discord — lets the pusher skip an edit
|
||||
/// when nothing actually changed.
|
||||
pub last_code: Option<String>,
|
||||
#[serde(with = "time::serde::rfc3339::option")]
|
||||
pub last_push_at: Option<OffsetDateTime>,
|
||||
}
|
||||
|
||||
/// One stored account. The `secret` field is ciphertext.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Account {
|
||||
pub id: String,
|
||||
pub issuer: String,
|
||||
pub label: String,
|
||||
pub algorithm: Algorithm,
|
||||
pub digits: u8,
|
||||
pub period: u64,
|
||||
pub secret: Sealed,
|
||||
#[serde(default = "default_true")]
|
||||
pub push_enabled: bool,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub created_at: OffsetDateTime,
|
||||
#[serde(default)]
|
||||
pub 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<String>,
|
||||
#[serde(with = "time::serde::rfc3339")]
|
||||
pub created_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
impl From<&Account> for AccountPublic {
|
||||
fn from(a: &Account) -> Self {
|
||||
Self {
|
||||
id: a.id.clone(),
|
||||
issuer: a.issuer.clone(),
|
||||
label: a.label.clone(),
|
||||
algorithm: a.algorithm,
|
||||
digits: a.digits,
|
||||
period: a.period,
|
||||
push_enabled: a.push_enabled,
|
||||
push_live: a.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<String>,
|
||||
pub label: Option<String>,
|
||||
pub push_enabled: bool,
|
||||
}
|
||||
|
||||
/// Result of a bulk import.
|
||||
#[derive(Debug, Clone, Default, Serialize)]
|
||||
pub struct ImportOutcome {
|
||||
pub added: Vec<AccountPublic>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// An account plus its decrypted TOTP parameters, for the push loop.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PushTarget {
|
||||
pub id: String,
|
||||
pub issuer: String,
|
||||
pub label: String,
|
||||
pub totp: Totp,
|
||||
pub message_id: Option<String>,
|
||||
pub last_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
struct VaultFile {
|
||||
#[serde(default)]
|
||||
accounts: Vec<Account>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Vault {
|
||||
path: Arc<PathBuf>,
|
||||
key: Arc<VaultKey>,
|
||||
inner: Arc<RwLock<Vec<Account>>>,
|
||||
}
|
||||
|
||||
#[allow(clippy::missing_fields_in_debug)] // secrets must never reach a log line
|
||||
impl std::fmt::Debug for Vault {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Vault")
|
||||
.field("accounts", &self.inner.read().len())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Vault {
|
||||
/// Load `<work_dir>/vault.json`, or start empty if it doesn't exist yet.
|
||||
///
|
||||
/// A file that exists but won't parse is an error, not a fallback — see
|
||||
/// the module docs.
|
||||
pub fn load(work_dir: &Path, key: VaultKey) -> Result<Self> {
|
||||
let path = work_dir.join("vault.json");
|
||||
let accounts = match std::fs::read_to_string(&path) {
|
||||
Ok(text) => {
|
||||
serde_json::from_str::<VaultFile>(&text)
|
||||
.map_err(|e| {
|
||||
Error::invalid(format!(
|
||||
"{} exists but could not be parsed ({e}). Refusing to start \
|
||||
with an empty vault — move the file aside to start fresh.",
|
||||
path.display()
|
||||
))
|
||||
})?
|
||||
.accounts
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
tracing::info!(target: "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<AccountPublic> {
|
||||
let g = self.inner.read();
|
||||
let mut v: Vec<_> = g.iter().collect();
|
||||
v.sort_by_key(|a| a.created_at);
|
||||
v.into_iter().map(AccountPublic::from).collect()
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> Option<AccountPublic> {
|
||||
self.inner
|
||||
.read()
|
||||
.iter()
|
||||
.find(|a| a.id == id)
|
||||
.map(AccountPublic::from)
|
||||
}
|
||||
|
||||
/// Decrypt one account's TOTP parameters.
|
||||
pub fn totp_for(&self, id: &str) -> Result<Totp> {
|
||||
let g = self.inner.read();
|
||||
let a = g
|
||||
.iter()
|
||||
.find(|a| a.id == id)
|
||||
.ok_or_else(|| Error::not_found("account"))?;
|
||||
self.decrypt(a)
|
||||
}
|
||||
|
||||
/// Every account, with secrets decrypted, in stable order — what the
|
||||
/// code endpoint and the push loop both iterate.
|
||||
pub fn all_with_totp(&self) -> Vec<(AccountPublic, Totp)> {
|
||||
let g = self.inner.read();
|
||||
let mut v: Vec<_> = g.iter().collect();
|
||||
v.sort_by_key(|a| a.created_at);
|
||||
v.into_iter()
|
||||
.filter_map(|a| match self.decrypt(a) {
|
||||
Ok(t) => Some((AccountPublic::from(a), t)),
|
||||
Err(e) => {
|
||||
// One unreadable record must not blank the whole list.
|
||||
tracing::error!(
|
||||
target: "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<PushTarget> {
|
||||
let g = self.inner.read();
|
||||
let mut v: Vec<_> = g.iter().filter(|a| a.push_enabled).collect();
|
||||
v.sort_by_key(|a| a.created_at);
|
||||
v.into_iter()
|
||||
.filter_map(|a| {
|
||||
let totp = self.decrypt(a).ok()?;
|
||||
Some(PushTarget {
|
||||
id: a.id.clone(),
|
||||
issuer: a.issuer.clone(),
|
||||
label: a.label.clone(),
|
||||
totp,
|
||||
message_id: a.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<Totp> {
|
||||
let secret = self.key.open(&a.secret)?;
|
||||
Totp::new(secret, a.algorithm, a.digits, a.period)
|
||||
}
|
||||
|
||||
/// Add an account parsed from an `otpauth://` URI. `issuer`/`label`
|
||||
/// override the parsed values when non-empty, so the user can rename in
|
||||
/// the confirm step without editing the URI.
|
||||
pub fn add(
|
||||
&self,
|
||||
parsed: &OtpAuth,
|
||||
issuer: Option<&str>,
|
||||
label: Option<&str>,
|
||||
push_enabled: bool,
|
||||
) -> Result<AccountPublic> {
|
||||
let issuer = issuer
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(&parsed.issuer)
|
||||
.to_string();
|
||||
let label = label
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(&parsed.label)
|
||||
.to_string();
|
||||
|
||||
if issuer.len() > 128 || label.len() > 128 {
|
||||
return Err(Error::invalid(
|
||||
"issuer and account must be 128 characters or fewer",
|
||||
));
|
||||
}
|
||||
|
||||
let account = Account {
|
||||
id: uuid::Uuid::new_v4().simple().to_string(),
|
||||
issuer,
|
||||
label,
|
||||
algorithm: parsed.totp.algorithm,
|
||||
digits: parsed.totp.digits,
|
||||
period: parsed.totp.period,
|
||||
secret: self.key.seal(&parsed.totp.secret)?,
|
||||
push_enabled,
|
||||
created_at: OffsetDateTime::now_utc(),
|
||||
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<bool>,
|
||||
) -> Result<AccountPublic> {
|
||||
let public = {
|
||||
let mut g = self.inner.write();
|
||||
let a = g
|
||||
.iter_mut()
|
||||
.find(|a| a.id == id)
|
||||
.ok_or_else(|| Error::not_found("account"))?;
|
||||
|
||||
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<Option<String>> {
|
||||
let removed = {
|
||||
let mut g = self.inner.write();
|
||||
let idx = g
|
||||
.iter()
|
||||
.position(|a| a.id == id)
|
||||
.ok_or_else(|| Error::not_found("account"))?;
|
||||
g.remove(idx)
|
||||
};
|
||||
self.persist();
|
||||
tracing::info!(
|
||||
target: "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<ImportOutcome> {
|
||||
let mut outcome = ImportOutcome::default();
|
||||
let mut staged: Vec<Account> = 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:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=Cloudflare",
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn vault(dir: &Path) -> Vault {
|
||||
Vault::load(dir, VaultKey::from_bytes(&[3u8; 32]).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_list_and_decrypt() {
|
||||
let dir = tempdir().unwrap();
|
||||
let v = vault(dir.path());
|
||||
assert!(v.is_empty());
|
||||
|
||||
let a = v.add(&sample(), None, None, true).unwrap();
|
||||
assert_eq!(a.issuer, "Cloudflare");
|
||||
assert_eq!(a.label, "[email protected]");
|
||||
assert_eq!(v.len(), 1);
|
||||
|
||||
// The decrypted secret must produce the same codes as the original.
|
||||
let totp = v.totp_for(&a.id).unwrap();
|
||||
assert_eq!(totp.code_at(59), sample().totp.code_at(59));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secrets_are_never_stored_in_the_clear() {
|
||||
let dir = tempdir().unwrap();
|
||||
let v = vault(dir.path());
|
||||
v.add(&sample(), None, None, true).unwrap();
|
||||
|
||||
let raw = std::fs::read_to_string(dir.path().join("vault.json")).unwrap();
|
||||
// Neither the base32 form nor the raw bytes may appear on disk.
|
||||
assert!(!raw.contains("JBSWY3DPEHPK3PXP"));
|
||||
assert!(!raw.contains("Hello!"));
|
||||
// But the metadata should still be readable.
|
||||
assert!(raw.contains("Cloudflare"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overrides_win_over_parsed_names() {
|
||||
let dir = tempdir().unwrap();
|
||||
let v = vault(dir.path());
|
||||
let a = v
|
||||
.add(&sample(), Some("Work CF"), Some("ops@corp"), false)
|
||||
.unwrap();
|
||||
assert_eq!(a.issuer, "Work CF");
|
||||
assert_eq!(a.label, "ops@corp");
|
||||
assert!(!a.push_enabled);
|
||||
// Blank overrides fall back to the parsed values.
|
||||
let b = v.add(&sample(), Some(" "), None, true).unwrap();
|
||||
assert_eq!(b.issuer, "Cloudflare");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn survives_a_reload() {
|
||||
let dir = tempdir().unwrap();
|
||||
let id = {
|
||||
let v = vault(dir.path());
|
||||
v.add(&sample(), None, None, true).unwrap().id
|
||||
};
|
||||
let v2 = vault(dir.path());
|
||||
assert_eq!(v2.len(), 1);
|
||||
assert!(v2.totp_for(&id).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrong_key_leaves_metadata_but_no_codes() {
|
||||
let dir = tempdir().unwrap();
|
||||
let id = {
|
||||
let v = vault(dir.path());
|
||||
v.add(&sample(), None, None, true).unwrap().id
|
||||
};
|
||||
let other = Vault::load(dir.path(), VaultKey::from_bytes(&[9u8; 32]).unwrap()).unwrap();
|
||||
assert_eq!(other.len(), 1, "the record still loads");
|
||||
assert!(
|
||||
other.totp_for(&id).is_err(),
|
||||
"but its secret must not decrypt"
|
||||
);
|
||||
// The list endpoint must not panic or drop the account.
|
||||
assert_eq!(other.list().len(), 1);
|
||||
// all_with_totp skips what it can't decrypt rather than failing.
|
||||
assert!(other.all_with_totp().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_vault_is_fatal_rather_than_silently_empty() {
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("vault.json"), b"{ not json").unwrap();
|
||||
let r = Vault::load(dir.path(), VaultKey::from_bytes(&[1u8; 32]).unwrap());
|
||||
assert!(r.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_and_remove() {
|
||||
let dir = tempdir().unwrap();
|
||||
let v = vault(dir.path());
|
||||
let a = v.add(&sample(), None, None, true).unwrap();
|
||||
|
||||
let up = v.update(&a.id, Some("Renamed"), None, Some(false)).unwrap();
|
||||
assert_eq!(up.issuer, "Renamed");
|
||||
assert!(!up.push_enabled);
|
||||
// Label was not supplied, so it must be untouched.
|
||||
assert_eq!(up.label, "[email protected]");
|
||||
|
||||
assert!(v.remove(&a.id).is_ok());
|
||||
assert!(v.is_empty());
|
||||
assert!(v.remove(&a.id).is_err(), "second remove is a 404");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_bookkeeping() {
|
||||
let dir = tempdir().unwrap();
|
||||
let v = vault(dir.path());
|
||||
let a = v.add(&sample(), None, None, true).unwrap();
|
||||
assert!(!v.get(&a.id).unwrap().push_live);
|
||||
|
||||
v.mark_pushed(&a.id, "123456789012345678", "111222");
|
||||
let p = v.get(&a.id).unwrap();
|
||||
assert!(p.push_live);
|
||||
assert!(p.push_error.is_none());
|
||||
|
||||
v.mark_push_error(&a.id, "webhook returned 404");
|
||||
let p = v.get(&a.id).unwrap();
|
||||
assert!(!p.push_live);
|
||||
assert_eq!(p.push_error.as_deref(), Some("webhook returned 404"));
|
||||
|
||||
// Message id survives an error (we keep editing the same tile), but
|
||||
// clearing it forces a re-create.
|
||||
assert!(v.push_targets()[0].message_id.is_some());
|
||||
v.clear_message_id(&a.id);
|
||||
assert!(v.push_targets()[0].message_id.is_none());
|
||||
}
|
||||
|
||||
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}:[email protected]?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:[email protected]?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:[email protected]?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:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=Plex")
|
||||
.unwrap();
|
||||
assert!(!v.contains(&a.totp, "Plex", "[email protected]"));
|
||||
v.add(&a, None, None, true).unwrap();
|
||||
assert!(v.contains(&a.totp, "Plex", "[email protected]"));
|
||||
assert!(!v.contains(&a.totp, "Plex", "[email protected]"));
|
||||
|
||||
// A different secret under the same name is not a match.
|
||||
let b =
|
||||
parse_otpauth("otpauth://totp/Plex:[email protected]?secret=GEZDGNBVGY3TQOJQ&issuer=Plex")
|
||||
.unwrap();
|
||||
assert!(!v.contains(&b.totp, "Plex", "[email protected]"));
|
||||
}
|
||||
|
||||
#[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}:[email protected]?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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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<u8>)>,
|
||||
}
|
||||
|
||||
pub struct DiscordClient {
|
||||
http: reqwest::Client,
|
||||
governor: Governor,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for DiscordClient {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("DiscordClient")
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DiscordClient {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl DiscordClient {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
http: crate::http_client(REQUEST_TIMEOUT),
|
||||
governor: Governor::new(MIN_INTERVAL),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create(&self, hook: &Webhook, payload: &Payload) -> Result<String> {
|
||||
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<serde_json::Value> {
|
||||
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<reqwest::RequestBuilder> {
|
||||
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<String>,
|
||||
code: Option<i64>,
|
||||
retry_after: Option<f64>,
|
||||
}
|
||||
|
||||
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::<serde_json::Value>(&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<f64> {
|
||||
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<i64>, 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(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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<T> = std::result::Result<T, DeliveryError>;
|
||||
@@ -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<SettingsStore>,
|
||||
clients: Arc<Clients>,
|
||||
/// 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<tokio::sync::Mutex<()>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PushEngine {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("PushEngine")
|
||||
}
|
||||
}
|
||||
|
||||
impl PushEngine {
|
||||
pub fn new(vault: Vault, settings: Arc<SettingsStore>, 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<SlackIdentity> {
|
||||
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<BotIdentity> {
|
||||
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<Vec<ChatInfo>> {
|
||||
self.clients.telegram.recent_chats(token).await
|
||||
}
|
||||
|
||||
/// Which platform is active, for the UI.
|
||||
pub fn active_provider(&self) -> Option<Provider> {
|
||||
self.settings.snapshot().provider
|
||||
}
|
||||
}
|
||||
@@ -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 `<code>` 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 `<t:…:R>` ticks live in the client for
|
||||
//! free. Slack's `<!date^…>` 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<Vec<u8>>,
|
||||
}
|
||||
|
||||
// ── 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 <t:{}:R>", 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}")
|
||||
};
|
||||
// `<!date^…>` renders in the viewer's local time zone, with the literal
|
||||
// after `|` as the fallback for clients that can't.
|
||||
let when = format!(
|
||||
"rolls <!date^{}^{{time_secs}}|at {} UTC>",
|
||||
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`. `<code>` 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!("<b>{issuer}</b>");
|
||||
if !label.is_empty() {
|
||||
let _ = write!(s, " · {label}");
|
||||
}
|
||||
let _ = write!(
|
||||
s,
|
||||
"\n<code>{}</code>\n<i>rolls at {} UTC</i>",
|
||||
c.code,
|
||||
clock(c.expires_at)
|
||||
);
|
||||
s
|
||||
}
|
||||
|
||||
pub fn test_telegram() -> String {
|
||||
"<b>OpenMFA</b>\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<Vec<u8>>) -> 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",
|
||||
"[email protected]",
|
||||
PushMode::Embed,
|
||||
None,
|
||||
));
|
||||
let d = p.json["embeds"][0]["description"].as_str().unwrap();
|
||||
assert!(d.contains("```\n482913\n```"), "{d}");
|
||||
assert!(
|
||||
d.contains("<t:1756402860:R>"),
|
||||
"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",
|
||||
"[email protected]",
|
||||
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("<!date^1756402860^{time_secs}|at "), "{ctx}");
|
||||
assert!(ctx.contains("UTC>"), "{ctx}");
|
||||
assert!(fallback.contains("482913"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slack_strips_formatting_marks_and_escapes_entities() {
|
||||
let (_, blocks) = slack(&content("A*B_C", "x<y>&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<flare>", "a&b", PushMode::Embed, None));
|
||||
assert!(html.contains("<b>Cloud<flare></b>"), "{html}");
|
||||
assert!(html.contains(" · a&b"), "{html}");
|
||||
assert!(html.contains("<code>482913</code>"), "{html}");
|
||||
assert!(html.contains("rolls at "), "{html}");
|
||||
assert!(html.contains(" UTC</i>"), "{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("<b>OpenMFA</b>"));
|
||||
}
|
||||
}
|
||||
@@ -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<Instant>,
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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<Sink<'a>> {
|
||||
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<String> {
|
||||
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(|_| ()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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<SlackIdentity> {
|
||||
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<serde_json::Value> {
|
||||
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::<u64>().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");
|
||||
}
|
||||
}
|
||||
@@ -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<String> {
|
||||
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::<i64>().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::<i64>().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<BotIdentity> {
|
||||
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<Vec<ChatInfo>> {
|
||||
let body = serde_json::json!({ "limit": 100, "timeout": 0 });
|
||||
let v = self.call(token, "getUpdates", &body).await?;
|
||||
let mut out: Vec<ChatInfo> = 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<serde_json::Value> {
|
||||
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<serde_json::Value> {
|
||||
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<token>/ 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}");
|
||||
}
|
||||
}
|
||||
@@ -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<token>/<method>` 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<String, String>,
|
||||
bearer: Option<String>,
|
||||
content_type: String,
|
||||
body: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Recorded {
|
||||
fn json(&self) -> serde_json::Value {
|
||||
serde_json::from_slice(&self.body).unwrap_or(serde_json::Value::Null)
|
||||
}
|
||||
fn is_multipart(&self) -> bool {
|
||||
self.content_type.starts_with("multipart/form-data")
|
||||
}
|
||||
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<Mutex<Vec<Recorded>>>);
|
||||
|
||||
impl Log {
|
||||
fn push(
|
||||
&self,
|
||||
method: &str,
|
||||
path: String,
|
||||
q: HashMap<String, String>,
|
||||
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<Recorded> {
|
||||
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<Mutex<Option<i64>>>,
|
||||
next_id: Arc<Mutex<u64>>,
|
||||
}
|
||||
|
||||
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<HashMap<String, String>>,
|
||||
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<HashMap<String, String>>,
|
||||
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:<this>}` (HTTP 200,
|
||||
/// exactly as Slack does).
|
||||
update_error: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
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<String>, 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<Mutex<Option<String>>>,
|
||||
next_id: Arc<Mutex<i64>>,
|
||||
}
|
||||
|
||||
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<SettingsStore>) {
|
||||
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:[email protected]?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<SettingsStore>, 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("<!date^"));
|
||||
assert_eq!(
|
||||
vault.push_targets()[0].message_id.as_deref(),
|
||||
Some("1700000000.000100")
|
||||
);
|
||||
|
||||
eng.sync_once().await;
|
||||
assert_eq!(mock.log.len(), 1, "unchanged code must not cost a request");
|
||||
|
||||
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, "/chat.update",
|
||||
"the live mechanism is chat.update, never a second post"
|
||||
);
|
||||
assert_eq!(second.json()["ts"], "1700000000.000100");
|
||||
assert_eq!(second.json()["channel"], "C0TEST");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slack_ok_false_errors_are_classified_not_ignored() {
|
||||
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");
|
||||
eng.sync_once().await;
|
||||
|
||||
// Slack says the message is gone — HTTP 200, ok:false. Must repost.
|
||||
*mock.update_error.lock().unwrap() = Some("message_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.update_error.lock().unwrap() = None;
|
||||
eng.sync_once().await;
|
||||
assert_eq!(
|
||||
mock.log.count(|r| r.path == "/chat.postMessage"),
|
||||
2,
|
||||
"reposted after message_not_found"
|
||||
);
|
||||
|
||||
// A revoked token is fatal and must be explained.
|
||||
*mock.update_error.lock().unwrap() = Some("invalid_auth".into());
|
||||
tokio::time::sleep(Duration::from_millis(1100)).await;
|
||||
eng.sync_once().await;
|
||||
let err = vault.list()[0].push_error.clone().unwrap_or_default();
|
||||
assert!(err.contains("Bot User OAuth Token"), "{err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slack_whoami_and_test_message() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (base, mock) = slack_mock().await;
|
||||
let (vault, settings) = fixture(dir.path(), slack_settings(), 30);
|
||||
let eng = engine(vault, settings, &base, "http://127.0.0.1:1");
|
||||
|
||||
let id = eng.slack_whoami("xoxb-test-token").await.unwrap();
|
||||
assert_eq!(id.team, "Acme");
|
||||
assert_eq!(id.user, "openmfa");
|
||||
|
||||
eng.send_test().await.unwrap();
|
||||
assert!(mock
|
||||
.log
|
||||
.all()
|
||||
.iter()
|
||||
.any(|r| r.path == "/chat.postMessage"
|
||||
&& r.json()["text"].as_str().unwrap().contains("OpenMFA")));
|
||||
|
||||
// Wrong token shape is rejected by Slack as not_authed → fatal.
|
||||
assert!(eng
|
||||
.slack_whoami("xoxp-user-token")
|
||||
.await
|
||||
.unwrap_err()
|
||||
.is_fatal());
|
||||
}
|
||||
|
||||
// ── Telegram ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn telegram_sends_then_edits_the_same_message_id() {
|
||||
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);
|
||||
|
||||
assert_eq!(eng.sync_once().await, 1);
|
||||
let first = &mock.log.all()[0];
|
||||
assert_eq!(
|
||||
first.path, "/bot123:TESTTOKEN/sendMessage",
|
||||
"token rides in the path"
|
||||
);
|
||||
let body = first.json();
|
||||
assert_eq!(body["chat_id"], "-100");
|
||||
assert_eq!(body["parse_mode"], "HTML");
|
||||
let text = body["text"].as_str().unwrap();
|
||||
assert!(text.contains("<b>Cloudflare</b>"), "{text}");
|
||||
assert!(text.contains("<code>"), "tap-to-copy needs <code>: {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"));
|
||||
}
|
||||
@@ -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
|
||||
@@ -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<u8>) {
|
||||
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<u8>) {
|
||||
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<u8>) {
|
||||
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 <out.png> [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"[email protected]", &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::<image::Luma<u8>>()
|
||||
.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()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//! Write a QR PNG for manual testing: `cargo run -p openmfa-http-api --example make-qr -- <out.png> <text>`
|
||||
fn main() {
|
||||
let mut a = std::env::args().skip(1);
|
||||
let out = a.next().expect("usage: make-qr <out.png> <text>");
|
||||
let text = a.next().expect("usage: make-qr <out.png> <text>");
|
||||
let code = qrcode::QrCode::new(text.as_bytes()).unwrap();
|
||||
let img = code
|
||||
.render::<image::Luma<u8>>()
|
||||
.module_dimensions(8, 8)
|
||||
.quiet_zone(true)
|
||||
.build();
|
||||
image::DynamicImage::ImageLuma8(img).save(&out).unwrap();
|
||||
println!("wrote {out}");
|
||||
}
|
||||
@@ -0,0 +1,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<AppState>) -> 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<AppState>) -> 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<AppState>, Json(body): Json<PreviewBody>) -> 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<String>,
|
||||
#[serde(default)]
|
||||
label: Option<String>,
|
||||
#[serde(default = "default_true")]
|
||||
push_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ImportBody {
|
||||
uri: String,
|
||||
items: Vec<ImportItem>,
|
||||
}
|
||||
|
||||
/// 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<AppState>, Json(body): Json<ImportBody>) -> 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<String>,
|
||||
#[serde(default)]
|
||||
label: Option<String>,
|
||||
#[serde(default)]
|
||||
push_enabled: Option<bool>,
|
||||
}
|
||||
|
||||
async fn update_account(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<UpdateBody>,
|
||||
) -> Response {
|
||||
match state.vault.update(
|
||||
&id,
|
||||
body.issuer.as_deref(),
|
||||
body.label.as_deref(),
|
||||
body.push_enabled,
|
||||
) {
|
||||
Ok(a) => Json(a).into_response(),
|
||||
Err(e) => crate::error_response(&e),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_account(State(state): State<AppState>, Path(id): Path<String>) -> Response {
|
||||
match state.vault.remove(&id) {
|
||||
Ok(message_id) => {
|
||||
// Take the tile down too, best-effort and off the response path —
|
||||
// the account is already gone from the vault either way.
|
||||
if let Some(mid) = message_id {
|
||||
let engine = state.engine.clone();
|
||||
tokio::spawn(async move { engine.remove_message(&mid).await });
|
||||
}
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
Err(e) => crate::error_response(&e),
|
||||
}
|
||||
}
|
||||
|
||||
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<AppState>) -> Response {
|
||||
let now = unix_now();
|
||||
let codes: Vec<_> = state
|
||||
.vault
|
||||
.all_with_totp()
|
||||
.into_iter()
|
||||
.map(|(account, totp)| {
|
||||
json!({
|
||||
"id": account.id,
|
||||
"code": totp.code_at(now),
|
||||
"next": totp.next_code_at(now),
|
||||
"period": totp.period,
|
||||
// Milliseconds, to pair with the JS clock.
|
||||
"expires_at": totp.expires_at(now) * 1000,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
(
|
||||
[(header::CACHE_CONTROL, "no-store")],
|
||||
Json(json!({ "server_time": unix_now_millis(), "codes": codes })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
// ── QR ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Decode an uploaded image and return the first `otpauth://` payload.
|
||||
///
|
||||
/// This backs both the file-upload tab and the camera tab's fallback path —
|
||||
/// browsers without `BarcodeDetector` (Safari and Firefox, so every iPhone)
|
||||
/// post frames here instead, which keeps the front-end free of a vendored QR
|
||||
/// library.
|
||||
async fn decode_qr(mut multipart: Multipart) -> Response {
|
||||
let mut bytes: Option<Vec<u8>> = None;
|
||||
loop {
|
||||
match multipart.next_field().await {
|
||||
Ok(Some(field)) => {
|
||||
if field.name() == Some("image") {
|
||||
match field.bytes().await {
|
||||
Ok(b) => bytes = Some(b.to_vec()),
|
||||
Err(e) => {
|
||||
return bad_request(&format!("could not read the upload: {e}"));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(e) => return bad_request(&format!("could not read the upload: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
let Some(bytes) = bytes else {
|
||||
return bad_request("no image was uploaded");
|
||||
};
|
||||
|
||||
match crate::qr::decode(&bytes) {
|
||||
Ok(found) => {
|
||||
// An image can hold several codes — 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<String> = 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<AppState>) -> 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<AppState>, Json(incoming): Json<Settings>) -> 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<AppState>) -> Response {
|
||||
match state.engine.send_test().await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => (StatusCode::BAD_GATEWAY, Json(json!({ "error": e }))).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[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<AppState>, Json(body): Json<TokenBody>) -> 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<AppState>, Json(body): Json<TokenBody>) -> 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<AppState>, Json(body): Json<TokenBody>) -> 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()
|
||||
}
|
||||
@@ -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<RwLock<HashMap<String, Session>>>,
|
||||
}
|
||||
|
||||
impl SessionStore {
|
||||
/// Mint a session token. UUIDv4 gives 122 bits of randomness, comfortably
|
||||
/// above the 64–128 typical for session identifiers.
|
||||
pub fn create(&self, username: &str) -> String {
|
||||
let id = uuid::Uuid::new_v4().simple().to_string();
|
||||
self.inner.write().insert(
|
||||
id.clone(),
|
||||
Session {
|
||||
username: username.to_string(),
|
||||
expires_at: Instant::now() + SESSION_TTL,
|
||||
},
|
||||
);
|
||||
id
|
||||
}
|
||||
|
||||
/// Resolve a token and refresh its idle timer. Expired entries are
|
||||
/// evicted on contact so the map can't grow without bound.
|
||||
pub fn touch(&self, id: &str) -> Option<String> {
|
||||
let mut g = self.inner.write();
|
||||
let s = g.get_mut(id)?;
|
||||
if s.expires_at <= Instant::now() {
|
||||
g.remove(id);
|
||||
return None;
|
||||
}
|
||||
s.expires_at = Instant::now() + SESSION_TTL;
|
||||
Some(s.username.clone())
|
||||
}
|
||||
|
||||
pub fn revoke(&self, id: &str) {
|
||||
self.inner.write().remove(id);
|
||||
}
|
||||
|
||||
/// Invalidate everything — used after a credential change so a cookie
|
||||
/// minted under the old password stops working.
|
||||
pub fn revoke_all(&self) {
|
||||
self.inner.write().clear();
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.inner.read().len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cookies ───────────────────────────────────────────────────────────────
|
||||
|
||||
fn cookie(value: &str, max_age: i64) -> String {
|
||||
// HttpOnly keeps the token away from any injected script; SameSite=Lax
|
||||
// blocks cross-site POSTs while still allowing normal navigation.
|
||||
//
|
||||
// No `Secure` flag: Unraid templates default to a plain-http LAN address,
|
||||
// and setting Secure there would silently drop the cookie and make the
|
||||
// app unusable. Behind a TLS terminator the cookie still travels
|
||||
// encrypted; this only means the browser won't *enforce* it.
|
||||
format!("{SESSION_COOKIE}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}")
|
||||
}
|
||||
|
||||
fn parse_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
|
||||
let raw = headers.get(header::COOKIE)?.to_str().ok()?;
|
||||
for part in raw.split(';') {
|
||||
if let Some(v) = part
|
||||
.trim()
|
||||
.strip_prefix(SESSION_COOKIE)
|
||||
.and_then(|rest| rest.strip_prefix('='))
|
||||
{
|
||||
return Some(v.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ── Middleware ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Paths reachable without a session once an admin exists.
|
||||
fn is_public(path: &str) -> bool {
|
||||
if !path.starts_with("/api/") {
|
||||
// The UI shell, its assets, and health probes. The shell itself shows
|
||||
// nothing sensitive — every byte of vault data comes from /api/*.
|
||||
return true;
|
||||
}
|
||||
matches!(
|
||||
path,
|
||||
"/api/setup" | "/api/login" | "/api/logout" | "/api/me"
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn require_auth(
|
||||
State(state): State<AppState>,
|
||||
req: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
// Before setup there is nobody to authenticate against. `/api/setup`
|
||||
// refuses to run twice, so this window closes permanently on first use.
|
||||
if !state.admin.is_configured() {
|
||||
return next.run(req).await;
|
||||
}
|
||||
if is_public(req.uri().path()) {
|
||||
return next.run(req).await;
|
||||
}
|
||||
let ok = parse_cookie(req.headers()).is_some_and(|t| state.sessions.touch(&t).is_some());
|
||||
if ok {
|
||||
return next.run(req).await;
|
||||
}
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({ "error": "authentication required" })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SetupBody {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
/// 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<da::Provider>,
|
||||
#[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<AppState>, Json(body): Json<SetupBody>) -> Response {
|
||||
if state.admin.is_configured() {
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
Json(json!({ "error": "an admin account already exists" })),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Validate the 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<AppState>, Json(body): Json<LoginBody>) -> Response {
|
||||
let admin = state.admin.clone();
|
||||
let verdict = tokio::task::spawn_blocking(move || admin.verify(&body.username, &body.password))
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(da::Error::crypto(e)));
|
||||
|
||||
match verdict {
|
||||
Ok(Some(public)) => {
|
||||
let token = state.sessions.create(&public.username);
|
||||
session_response(StatusCode::OK, &public, &token)
|
||||
}
|
||||
// Deliberately vague: no distinction between "no such user" and
|
||||
// "wrong password", so the endpoint can't be used to enumerate.
|
||||
Ok(None) => (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({ "error": "invalid username or password" })),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => crate::error_response(&e),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn api_logout(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Response {
|
||||
if let Some(t) = parse_cookie(&headers) {
|
||||
state.sessions.revoke(&t);
|
||||
}
|
||||
let mut resp = StatusCode::NO_CONTENT.into_response();
|
||||
resp.headers_mut().insert(
|
||||
header::SET_COOKIE,
|
||||
HeaderValue::from_str(&cookie("", 0)).expect("cookie header is ASCII"),
|
||||
);
|
||||
resp
|
||||
}
|
||||
|
||||
/// Bootstrap status for the front-end shell.
|
||||
pub async fn api_me(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Response {
|
||||
let version = state.version;
|
||||
if !state.admin.is_configured() {
|
||||
return Json(json!({
|
||||
"setup_required": true,
|
||||
"authenticated": false,
|
||||
"version": version,
|
||||
}))
|
||||
.into_response();
|
||||
}
|
||||
let user = parse_cookie(&headers).and_then(|t| state.sessions.touch(&t));
|
||||
match user {
|
||||
Some(_) => Json(json!({
|
||||
"setup_required": false,
|
||||
"authenticated": true,
|
||||
"user": state.admin.snapshot(),
|
||||
"version": version,
|
||||
}))
|
||||
.into_response(),
|
||||
None => Json(json!({
|
||||
"setup_required": false,
|
||||
"authenticated": false,
|
||||
"version": version,
|
||||
}))
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateCredentialsBody {
|
||||
pub current_password: String,
|
||||
#[serde(default)]
|
||||
pub new_username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub new_password: Option<String>,
|
||||
}
|
||||
|
||||
/// Rotate the admin credentials. The middleware already proved a valid
|
||||
/// session; requiring the current password additionally proves the person at
|
||||
/// the keyboard is the owner and not someone using a borrowed browser.
|
||||
pub async fn api_update_credentials(
|
||||
State(state): State<AppState>,
|
||||
Json(body): Json<UpdateCredentialsBody>,
|
||||
) -> Response {
|
||||
let admin = state.admin.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
admin.update_credentials(
|
||||
&body.current_password,
|
||||
body.new_username.as_deref(),
|
||||
body.new_password.as_deref(),
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(da::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"));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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<T> = std::result::Result<T, String>;
|
||||
|
||||
/// Find the first QR payload in `bytes`.
|
||||
///
|
||||
/// Returns every distinct payload found, first one first — a Google
|
||||
/// Authenticator export screenshot can legitimately carry several codes.
|
||||
pub fn decode(bytes: &[u8]) -> Result<Vec<String>> {
|
||||
if bytes.is_empty() {
|
||||
return Err("the uploaded file is empty".into());
|
||||
}
|
||||
reject_unsupported(bytes)?;
|
||||
|
||||
let img = image::load_from_memory(bytes)
|
||||
.map_err(|e| format!("could not read that image ({e}). Try a PNG or JPEG screenshot."))?;
|
||||
|
||||
let found = walk_ladder(&img);
|
||||
if found.is_empty() {
|
||||
return Err(
|
||||
"no QR code found in that image. Crop tighter around the code, or use a \
|
||||
screenshot rather than a photo of a screen."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
/// Try progressively more aggressive preparations until something decodes.
|
||||
fn walk_ladder(img: &DynamicImage) -> Vec<String> {
|
||||
let gray = img.to_luma8();
|
||||
let (w, h) = (gray.width(), gray.height());
|
||||
let long_edge = w.max(h);
|
||||
|
||||
// 1. As-is. Most clean screenshots stop here.
|
||||
let hits = read(gray.clone());
|
||||
if !hits.is_empty() {
|
||||
return hits;
|
||||
}
|
||||
|
||||
// 2. Resize toward the range quirc handles best.
|
||||
let resized = if long_edge < SMALL_EDGE {
|
||||
// Integer-ish upscale so module edges stay sharp.
|
||||
let factor = (SMALL_EDGE as f32 / long_edge as f32).ceil().min(6.0) as u32;
|
||||
Some(image::imageops::resize(
|
||||
&gray,
|
||||
w * factor,
|
||||
h * factor,
|
||||
image::imageops::FilterType::Lanczos3,
|
||||
))
|
||||
} else if long_edge > LARGE_EDGE {
|
||||
let scale = LARGE_EDGE as f32 / long_edge as f32;
|
||||
Some(image::imageops::resize(
|
||||
&gray,
|
||||
((w as f32 * scale) as u32).max(1),
|
||||
((h as f32 * scale) as u32).max(1),
|
||||
image::imageops::FilterType::Lanczos3,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let base = resized.unwrap_or_else(|| gray.clone());
|
||||
let hits = read(base.clone());
|
||||
if !hits.is_empty() {
|
||||
return hits;
|
||||
}
|
||||
|
||||
// 3. Stretch contrast. Helps dim photos and low-contrast dark-mode
|
||||
// screenshots where the "black" modules are only mid-grey.
|
||||
let stretched = autocontrast(&base);
|
||||
let hits = read(stretched.clone());
|
||||
if !hits.is_empty() {
|
||||
return hits;
|
||||
}
|
||||
|
||||
// 4. Rotations. quirc tolerates moderate skew but not a sideways photo.
|
||||
for turns in 1..4 {
|
||||
let mut rotated = base.clone();
|
||||
for _ in 0..turns {
|
||||
rotated = image::imageops::rotate90(&rotated);
|
||||
}
|
||||
let hits = read(rotated);
|
||||
if !hits.is_empty() {
|
||||
return hits;
|
||||
}
|
||||
}
|
||||
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Run the detector and collect every distinct payload.
|
||||
fn read(gray: GrayImage) -> Vec<String> {
|
||||
let mut prepared = rqrr::PreparedImage::prepare(gray);
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for grid in prepared.detect_grids() {
|
||||
// A grid that fails to decode is normal (partially occluded, too
|
||||
// blurry); the others in the image may still be fine.
|
||||
if let Ok((_meta, content)) = grid.decode() {
|
||||
let content = content.trim().to_string();
|
||||
if !content.is_empty() && content.len() <= MAX_PAYLOAD && !out.contains(&content) {
|
||||
out.push(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Linear contrast stretch over the 2nd–98th percentile.
|
||||
///
|
||||
/// Percentiles rather than min/max so one blown-out highlight or a single
|
||||
/// dark speck can't flatten the whole image.
|
||||
fn autocontrast(img: &GrayImage) -> GrayImage {
|
||||
let mut hist = [0u32; 256];
|
||||
for p in img.pixels() {
|
||||
hist[p.0[0] as usize] += 1;
|
||||
}
|
||||
let total: u32 = hist.iter().sum();
|
||||
if total == 0 {
|
||||
return img.clone();
|
||||
}
|
||||
let cut = total / 50; // 2%
|
||||
|
||||
let mut acc = 0;
|
||||
let mut lo = 0u8;
|
||||
for (v, count) in hist.iter().enumerate() {
|
||||
acc += count;
|
||||
if acc > cut {
|
||||
lo = v as u8;
|
||||
break;
|
||||
}
|
||||
}
|
||||
acc = 0;
|
||||
let mut hi = 255u8;
|
||||
for (v, count) in hist.iter().enumerate().rev() {
|
||||
acc += count;
|
||||
if acc > cut {
|
||||
hi = v as u8;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if hi <= lo {
|
||||
return img.clone();
|
||||
}
|
||||
|
||||
let span = f32::from(hi) - f32::from(lo);
|
||||
let mut out = img.clone();
|
||||
for p in out.pixels_mut() {
|
||||
let v = (f32::from(p.0[0]) - f32::from(lo)) / span;
|
||||
p.0[0] = (v.clamp(0.0, 1.0) * 255.0) as u8;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Give a useful message for formats we knowingly can't decode, instead of a
|
||||
/// generic parse failure.
|
||||
///
|
||||
/// HEIC matters specifically because it is the iPhone camera default, and
|
||||
/// there is no pure-Rust HEIC decoder — the C libheif would break the static
|
||||
/// musl build. Telling someone to screenshot instead is a one-step fix; "could
|
||||
/// not read that image" would leave them stuck.
|
||||
fn reject_unsupported(bytes: &[u8]) -> Result<()> {
|
||||
if bytes.len() >= 12 && &bytes[4..8] == b"ftyp" {
|
||||
let brand = &bytes[8..12];
|
||||
if matches!(
|
||||
brand,
|
||||
b"heic" | b"heix" | b"hevc" | b"hevx" | b"mif1" | b"msf1" | b"avif"
|
||||
) {
|
||||
return Err(
|
||||
"that looks like a HEIC/AVIF image, which this app can't read. On iPhone, \
|
||||
take a screenshot instead (screenshots are PNG), or export the photo as JPEG."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if bytes.starts_with(b"%PDF") {
|
||||
return Err("that's a PDF. Export or screenshot the QR code as an image first.".into());
|
||||
}
|
||||
if bytes.starts_with(b"<svg") || bytes.starts_with(b"<?xml") {
|
||||
return Err(
|
||||
"that's an SVG, which this app can't rasterise. Save or screenshot the QR \
|
||||
code as a PNG first."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const URI: &str = "otpauth://totp/Test:[email protected]?secret=JBSWY3DPEHPK3PXP&issuer=Test";
|
||||
|
||||
/// Render `data` as a real QR PNG so these tests exercise the actual
|
||||
/// detector rather than a mock. `module_px` controls how many pixels each
|
||||
/// QR module occupies, which is exactly the variable that decides whether
|
||||
/// the ladder's upscale step is needed.
|
||||
fn qr_png(data: &str, module_px: u32) -> Vec<u8> {
|
||||
let code = qrcode::QrCode::new(data.as_bytes()).unwrap();
|
||||
let img = code
|
||||
.render::<image::Luma<u8>>()
|
||||
.module_dimensions(module_px, module_px)
|
||||
.quiet_zone(true)
|
||||
.build();
|
||||
let mut buf = std::io::Cursor::new(Vec::new());
|
||||
DynamicImage::ImageLuma8(img)
|
||||
.write_to(&mut buf, image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
buf.into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_a_clean_qr_code() {
|
||||
let found = decode(&qr_png(URI, 6)).unwrap();
|
||||
assert_eq!(found, vec![URI.to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_a_tiny_qr_via_the_upscale_rung() {
|
||||
// 1px modules is the small-screenshot case the ladder exists for.
|
||||
let found = decode(&qr_png(URI, 1)).unwrap();
|
||||
assert_eq!(found[0], URI);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_an_oversized_qr_via_the_downscale_rung() {
|
||||
let found = decode(&qr_png(URI, 26)).unwrap();
|
||||
assert_eq!(found[0], URI);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_a_rotated_qr() {
|
||||
let png = qr_png(URI, 6);
|
||||
let img = image::load_from_memory(&png).unwrap();
|
||||
for turns in 1..4 {
|
||||
let mut rotated = img.to_luma8();
|
||||
for _ in 0..turns {
|
||||
rotated = image::imageops::rotate90(&rotated);
|
||||
}
|
||||
let mut buf = std::io::Cursor::new(Vec::new());
|
||||
DynamicImage::ImageLuma8(rotated)
|
||||
.write_to(&mut buf, image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let found = decode(&buf.into_inner())
|
||||
.unwrap_or_else(|e| panic!("rotation {turns}×90° failed: {e}"));
|
||||
assert_eq!(found[0], URI);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_a_jpeg_screenshot() {
|
||||
// JPEG is what you get from a photo or a re-shared screenshot, and
|
||||
// its ringing artefacts are exactly what breaks naive decoders.
|
||||
let img = image::load_from_memory(&qr_png(URI, 8)).unwrap();
|
||||
let mut buf = std::io::Cursor::new(Vec::new());
|
||||
img.write_to(&mut buf, image::ImageFormat::Jpeg).unwrap();
|
||||
assert_eq!(decode(&buf.into_inner()).unwrap()[0], URI);
|
||||
}
|
||||
|
||||
/// 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<u8> {
|
||||
fn put_varint(v: u64, out: &mut Vec<u8>) {
|
||||
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<u8>) {
|
||||
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<u8>) {
|
||||
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, "[email protected]");
|
||||
}
|
||||
|
||||
#[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"<svg xmlns=...").unwrap_err().contains("SVG"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_image_is_reported_clearly() {
|
||||
let e = decode(b"this is definitely not an image at all").unwrap_err();
|
||||
assert!(e.contains("could not read"), "got: {e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_with_no_qr_says_so() {
|
||||
// Flat grey 200x200 PNG — decodes fine as an image, contains no code.
|
||||
let img = GrayImage::from_pixel(200, 200, image::Luma([128]));
|
||||
let mut buf = std::io::Cursor::new(Vec::new());
|
||||
DynamicImage::ImageLuma8(img)
|
||||
.write_to(&mut buf, image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let e = decode(&buf.into_inner()).unwrap_err();
|
||||
assert!(e.contains("no QR code found"), "got: {e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autocontrast_stretches_a_low_contrast_image() {
|
||||
// All values squeezed into 100..140 should come out spanning wider.
|
||||
let mut img = GrayImage::new(20, 20);
|
||||
for (i, p) in img.pixels_mut().enumerate() {
|
||||
p.0[0] = 100 + (i % 40) as u8;
|
||||
}
|
||||
let out = autocontrast(&img);
|
||||
let lo = out.pixels().map(|p| p.0[0]).min().unwrap();
|
||||
let hi = out.pixels().map(|p| p.0[0]).max().unwrap();
|
||||
assert!(hi - lo > 140, "expected a wider range, got {lo}..{hi}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autocontrast_leaves_a_flat_image_alone() {
|
||||
// A uniform image has no range to stretch; the guard must not divide
|
||||
// by zero or blow the pixels out.
|
||||
let img = GrayImage::from_pixel(10, 10, image::Luma([128]));
|
||||
let out = autocontrast(&img);
|
||||
assert!(out.pixels().all(|p| p.0[0] == 128));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//! Shared handler state.
|
||||
|
||||
use 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<SettingsStore>,
|
||||
pub sessions: SessionStore,
|
||||
pub engine: PushEngine,
|
||||
pub version: &'static str,
|
||||
}
|
||||
|
||||
#[allow(clippy::missing_fields_in_debug)] // vault + sessions must not be dumped
|
||||
impl std::fmt::Debug for AppState {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AppState")
|
||||
.field("accounts", &self.vault.len())
|
||||
.field("configured", &self.admin.is_configured())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
@@ -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 => {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
@@ -0,0 +1,15 @@
|
||||
//! Rasterise the brand icon to PNG at the sizes the README, the Gitea repo
|
||||
//! avatar and the Unraid template need:
|
||||
//! `cargo run -p openmfa-render --example brand -- docs/brand`
|
||||
fn main() {
|
||||
let dir = std::env::args()
|
||||
.nth(1)
|
||||
.unwrap_or_else(|| "docs/brand".into());
|
||||
let svg = std::fs::read_to_string(format!("{dir}/icon.svg")).expect("read icon.svg");
|
||||
for size in [512u32, 256, 128] {
|
||||
let png = openmfa_render::rasterise_svg(&svg, size, size).expect("render");
|
||||
let path = format!("{dir}/icon-{size}.png");
|
||||
std::fs::write(&path, &png).expect("write");
|
||||
println!("wrote {path} ({} bytes)", png.len());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//! Dump sample tiles for eyeballing: `cargo run -p openmfa-render --example preview`.
|
||||
fn main() {
|
||||
let out = std::env::args().nth(1).unwrap_or_else(|| ".".into());
|
||||
for (issuer, label, code, period) in [
|
||||
("Cloudflare", "[email protected]", "482913", 30),
|
||||
("Plex", "[email protected]", "071204", 30),
|
||||
("Ubisoft", "casperadmin", "93810245", 30),
|
||||
] {
|
||||
let t = openmfa_render::Tile {
|
||||
issuer,
|
||||
label,
|
||||
code,
|
||||
period,
|
||||
};
|
||||
let png = openmfa_render::tile_png(&t).unwrap();
|
||||
let webp = openmfa_render::tile_webp(&t).unwrap();
|
||||
std::fs::write(format!("{out}/tile-{issuer}.png"), &png).unwrap();
|
||||
std::fs::write(format!("{out}/tile-{issuer}.webp"), &webp).unwrap();
|
||||
println!(
|
||||
"{issuer:12} png {:>6} B webp {:>6} B",
|
||||
png.len(),
|
||||
webp.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 `<t:…:R>` relative timestamp
|
||||
//! ticks live in the client for free, so the countdown belongs in the message
|
||||
//! text and the tile stays a clean statement of the current code.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use image::ImageEncoder;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Rendered at 2× the nominal display size so the digits stay crisp on
|
||||
/// retina displays without asking Discord to upscale.
|
||||
const WIDTH: u32 = 760;
|
||||
const HEIGHT: u32 = 264;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RenderError {
|
||||
#[error("could not parse the tile SVG: {0}")]
|
||||
Svg(#[from] usvg::Error),
|
||||
#[error("could not allocate a {0}×{1} pixmap")]
|
||||
Pixmap(u32, u32),
|
||||
#[error("could not encode the tile: {0}")]
|
||||
Encode(#[from] image::ImageError),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, RenderError>;
|
||||
|
||||
/// What to draw.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Tile<'a> {
|
||||
pub issuer: &'a str,
|
||||
pub label: &'a str,
|
||||
/// The code, digits only — grouping is applied here, not by the caller.
|
||||
pub code: &'a str,
|
||||
pub period: u64,
|
||||
}
|
||||
|
||||
/// Encode the tile as lossless WebP.
|
||||
///
|
||||
/// WebP because it's what Discord renders natively and what was asked for.
|
||||
/// Note that pure-Rust WebP encoding is **lossless-only** — there is no
|
||||
/// pure-Rust lossy encoder, and the lossy one lives in libwebp (C), which
|
||||
/// would break the static musl build. That is fine here: the tile is flat
|
||||
/// colour and large text, exactly the content lossless compresses well, and
|
||||
/// the result lands in the low tens of kilobytes.
|
||||
pub fn tile_webp(tile: &Tile<'_>) -> Result<Vec<u8>> {
|
||||
let rgba = rasterise(tile)?;
|
||||
let mut out = Vec::new();
|
||||
image::codecs::webp::WebPEncoder::new_lossless(&mut out).write_image(
|
||||
&rgba,
|
||||
WIDTH,
|
||||
HEIGHT,
|
||||
image::ExtendedColorType::Rgba8,
|
||||
)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Encode the tile as PNG. Kept as a fallback and for tests.
|
||||
pub fn tile_png(tile: &Tile<'_>) -> Result<Vec<u8>> {
|
||||
let rgba = rasterise(tile)?;
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
image::codecs::png::PngEncoder::new(&mut out).write_image(
|
||||
&rgba,
|
||||
WIDTH,
|
||||
HEIGHT,
|
||||
image::ExtendedColorType::Rgba8,
|
||||
)?;
|
||||
Ok(out.into_inner())
|
||||
}
|
||||
|
||||
/// 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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
let svg = build_svg(tile);
|
||||
let tree = usvg::Tree::from_str(&svg, options())?;
|
||||
|
||||
let mut pixmap =
|
||||
tiny_skia::Pixmap::new(WIDTH, HEIGHT).ok_or(RenderError::Pixmap(WIDTH, HEIGHT))?;
|
||||
resvg::render(&tree, tiny_skia::Transform::default(), &mut pixmap.as_mut());
|
||||
|
||||
// tiny-skia stores premultiplied alpha; PNG and WebP both want
|
||||
// straight alpha. Skipping this makes every antialiased edge — and the
|
||||
// rounded corners in particular — render subtly too dark.
|
||||
let mut rgba = Vec::with_capacity((WIDTH * HEIGHT * 4) as usize);
|
||||
for px in pixmap.pixels() {
|
||||
let c = px.demultiply();
|
||||
rgba.extend_from_slice(&[c.red(), c.green(), c.blue(), c.alpha()]);
|
||||
}
|
||||
Ok(rgba)
|
||||
}
|
||||
|
||||
/// Font database and render options, built once.
|
||||
///
|
||||
/// `fontdb_mut()` clones the database when it's already shared, so building
|
||||
/// this per render would copy both faces on every push.
|
||||
fn options() -> &'static usvg::Options<'static> {
|
||||
static OPTS: OnceLock<usvg::Options<'static>> = OnceLock::new();
|
||||
OPTS.get_or_init(|| {
|
||||
let mut opt = usvg::Options::default();
|
||||
{
|
||||
let db = opt.fontdb_mut();
|
||||
db.load_font_data(FONT_REGULAR.to_vec());
|
||||
db.load_font_data(FONT_BOLD.to_vec());
|
||||
}
|
||||
// Must match the family name inside the TTFs, or resvg falls back to
|
||||
// its default ("Times New Roman") and finds nothing.
|
||||
opt.font_family = "JetBrains Mono".to_string();
|
||||
opt
|
||||
})
|
||||
}
|
||||
|
||||
const FONT_REGULAR: &[u8] = include_bytes!("../assets/JetBrainsMono-Regular.ttf");
|
||||
const FONT_BOLD: &[u8] = include_bytes!("../assets/JetBrainsMono-Bold.ttf");
|
||||
|
||||
/// Group a code the way every authenticator app does: `123 456`, `1234 5678`.
|
||||
fn group(code: &str) -> String {
|
||||
let n = code.chars().count();
|
||||
if n < 6 {
|
||||
return code.to_string();
|
||||
}
|
||||
let mid = if n == 8 { 4 } else { n.div_ceil(2) };
|
||||
let (a, b) = code.split_at(mid);
|
||||
format!("{a} {b}")
|
||||
}
|
||||
|
||||
/// Stable per-issuer accent hue, matching how the web UI colours avatars so
|
||||
/// a tile in Discord reads as the same account as the row in the browser.
|
||||
fn hue_for(seed: &str) -> u16 {
|
||||
let mut hash: i32 = 0;
|
||||
for ch in seed.chars() {
|
||||
hash = hash.wrapping_mul(31).wrapping_add(ch as i32);
|
||||
}
|
||||
(hash.unsigned_abs() % 360) as u16
|
||||
}
|
||||
|
||||
/// HSL → `#rrggbb`. Saturation and lightness are fixed so every generated
|
||||
/// accent sits at the same visual weight regardless of hue.
|
||||
#[allow(clippy::many_single_char_names)] // conventional names for colour maths
|
||||
fn hsl_hex(h: f32, s: f32, l: f32) -> String {
|
||||
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
|
||||
let hp = h / 60.0;
|
||||
let x = c * (1.0 - (hp % 2.0 - 1.0).abs());
|
||||
let (r1, g1, b1) = match hp as u32 {
|
||||
0 => (c, x, 0.0),
|
||||
1 => (x, c, 0.0),
|
||||
2 => (0.0, c, x),
|
||||
3 => (0.0, x, c),
|
||||
4 => (x, 0.0, c),
|
||||
_ => (c, 0.0, x),
|
||||
};
|
||||
let m = l - c / 2.0;
|
||||
let to = |v: f32| ((v + m).clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
format!("#{:02x}{:02x}{:02x}", to(r1), to(g1), to(b1))
|
||||
}
|
||||
|
||||
/// XML-escape text that lands inside the SVG. Issuer and label are
|
||||
/// user-controlled, so an unescaped `&` or `<` would produce an SVG that
|
||||
/// fails to parse — and the tile would silently stop updating.
|
||||
fn esc(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for ch in s.chars() {
|
||||
match ch {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\'' => out.push_str("'"),
|
||||
// Control characters are not legal XML content at all.
|
||||
c if (c as u32) < 0x20 && c != '\t' => out.push(' '),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Truncate to a character budget, with an ellipsis. The tile is fixed
|
||||
/// width, so an over-long issuer would otherwise run off the edge.
|
||||
fn clip(s: &str, max: usize) -> String {
|
||||
let n = s.chars().count();
|
||||
if n <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
let kept: String = s.chars().take(max.saturating_sub(1)).collect();
|
||||
format!("{kept}…")
|
||||
}
|
||||
|
||||
fn build_svg(tile: &Tile<'_>) -> String {
|
||||
let hue = hue_for(if tile.issuer.is_empty() {
|
||||
tile.label
|
||||
} else {
|
||||
tile.issuer
|
||||
});
|
||||
let accent = hsl_hex(f32::from(hue), 0.62, 0.58);
|
||||
let accent_soft = hsl_hex(f32::from(hue), 0.55, 0.42);
|
||||
|
||||
let issuer = esc(&clip(tile.issuer, 26));
|
||||
let label = esc(&clip(tile.label, 34));
|
||||
let code = esc(&group(tile.code));
|
||||
|
||||
// Long codes need to shrink to stay inside the tile. 8 digits plus a
|
||||
// separator is 9 glyphs against 6+1=7 for the common case.
|
||||
let code_size = if tile.code.chars().count() >= 8 {
|
||||
96
|
||||
} else {
|
||||
116
|
||||
};
|
||||
|
||||
format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="{WIDTH}" height="{HEIGHT}" viewBox="0 0 {WIDTH} {HEIGHT}">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#1c2027"/>
|
||||
<stop offset="1" stop-color="#141821"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="rail" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="{accent}"/>
|
||||
<stop offset="1" stop-color="{accent_soft}"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect x="0" y="0" width="{WIDTH}" height="{HEIGHT}" rx="28" fill="url(#bg)"/>
|
||||
<rect x="0.75" y="0.75" width="{ring_w}" height="{ring_h}" rx="27.25"
|
||||
fill="none" stroke="#ffffff" stroke-opacity="0.10" stroke-width="1.5"/>
|
||||
|
||||
<!-- Accent rail, tinted per issuer to match the web UI's avatar colour. -->
|
||||
<rect x="30" y="34" width="9" height="{rail_h}" rx="4.5" fill="url(#rail)"/>
|
||||
|
||||
<text x="62" y="74" font-family="JetBrains Mono" font-weight="700"
|
||||
font-size="30" fill="#f2f4f8">{issuer}</text>
|
||||
<text x="62" y="108" font-family="JetBrains Mono" font-weight="400"
|
||||
font-size="21" fill="#98a2b3">{label}</text>
|
||||
|
||||
<text x="62" y="{code_y}" font-family="JetBrains Mono" font-weight="700"
|
||||
font-size="{code_size}" fill="#ffffff" letter-spacing="4">{code}</text>
|
||||
|
||||
<text x="{meta_x}" y="{meta_y}" text-anchor="end" font-family="JetBrains Mono"
|
||||
font-weight="400" font-size="18" fill="#6b7484">{period}s</text>
|
||||
</svg>"##,
|
||||
WIDTH = WIDTH,
|
||||
HEIGHT = HEIGHT,
|
||||
ring_w = WIDTH - 1,
|
||||
ring_h = HEIGHT - 1,
|
||||
rail_h = HEIGHT - 68,
|
||||
code_y = HEIGHT - 52,
|
||||
meta_x = WIDTH - 32,
|
||||
meta_y = HEIGHT - 30,
|
||||
period = tile.period,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample<'a>(issuer: &'a str, code: &'a str) -> Tile<'a> {
|
||||
Tile {
|
||||
issuer,
|
||||
label: "[email protected]",
|
||||
code,
|
||||
period: 30,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_webp_with_a_valid_header() {
|
||||
let bytes = tile_webp(&sample("Cloudflare", "123456")).unwrap();
|
||||
assert!(bytes.len() > 200, "suspiciously small: {}", bytes.len());
|
||||
// RIFF container with a WEBP fourcc at offset 8.
|
||||
assert_eq!(&bytes[0..4], b"RIFF");
|
||||
assert_eq!(&bytes[8..12], b"WEBP");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webp_round_trips_back_to_the_expected_size() {
|
||||
let bytes = tile_webp(&sample("Plex", "987654")).unwrap();
|
||||
let decoded = image::load_from_memory(&bytes).expect("decodes as an image");
|
||||
assert_eq!(decoded.width(), WIDTH);
|
||||
assert_eq!(decoded.height(), HEIGHT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_png_too() {
|
||||
let bytes = tile_png(&sample("Ubisoft", "246810")).unwrap();
|
||||
assert_eq!(&bytes[1..4], b"PNG");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actually_draws_something() {
|
||||
// A blank canvas would still encode cleanly, so assert the render
|
||||
// produced non-background pixels — this is what catches the font
|
||||
// database silently failing to load.
|
||||
let rgba = rasterise(&sample("Cloudflare", "123456")).unwrap();
|
||||
let white = rgba
|
||||
.chunks_exact(4)
|
||||
.filter(|p| p[0] > 220 && p[1] > 220 && p[2] > 220 && p[3] > 200)
|
||||
.count();
|
||||
assert!(
|
||||
white > 500,
|
||||
"expected the digits to paint many near-white pixels, saw {white}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_grouping() {
|
||||
assert_eq!(group("123456"), "123 456");
|
||||
assert_eq!(group("12345678"), "1234 5678");
|
||||
assert_eq!(group("1234567"), "1234 567");
|
||||
assert_eq!(group("12345"), "12345");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issuer_hue_is_stable_and_varies() {
|
||||
assert_eq!(hue_for("Cloudflare"), hue_for("Cloudflare"));
|
||||
assert_ne!(hue_for("Cloudflare"), hue_for("Plex"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hsl_conversion_spans_the_wheel() {
|
||||
// Six anchor hues must land in six distinct colours, and stay in range.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for h in [0.0, 60.0, 120.0, 180.0, 240.0, 300.0] {
|
||||
let hex = hsl_hex(h, 0.62, 0.58);
|
||||
assert_eq!(hex.len(), 7, "{hex}");
|
||||
assert!(hex.starts_with('#'));
|
||||
seen.insert(hex);
|
||||
}
|
||||
assert_eq!(seen.len(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xml_special_characters_cannot_break_the_svg() {
|
||||
// An unescaped ampersand here would make the SVG unparseable and the
|
||||
// account's tile would stop updating with no obvious cause.
|
||||
let tile = Tile {
|
||||
issuer: "Ben & Jerry's <Corp>",
|
||||
label: "a\"b'c & <script>",
|
||||
code: "111222",
|
||||
period: 30,
|
||||
};
|
||||
assert!(tile_webp(&tile).is_ok());
|
||||
let svg = build_svg(&tile);
|
||||
assert!(svg.contains("&"));
|
||||
assert!(!svg.contains("<script>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_long_names_are_clipped() {
|
||||
let long = "X".repeat(200);
|
||||
let tile = Tile {
|
||||
issuer: &long,
|
||||
label: &long,
|
||||
code: "123456",
|
||||
period: 30,
|
||||
};
|
||||
assert!(tile_webp(&tile).is_ok());
|
||||
assert!(clip(&long, 26).chars().count() <= 26);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eight_digit_and_unicode_content_render() {
|
||||
assert!(tile_webp(&sample("Steam", "12345678")).is_ok());
|
||||
assert!(tile_webp(&sample("Ünïcøde ✓ Ω", "123456")).is_ok());
|
||||
// Empty issuer must not panic — hue falls back to the label.
|
||||
assert!(tile_webp(&sample("", "123456")).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "openmfa-webui"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
authors.workspace = true
|
||||
description = "Embedded, offline web UI for OpenMFA."
|
||||
|
||||
[dependencies]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,818 @@
|
||||
/* OpenMFA — web UI.
|
||||
*
|
||||
* Visual language follows Beautiful UI (https://www.beautifului.dev/):
|
||||
* OKLCH ramps, hairline-ring shadows, inset fields / raised buttons,
|
||||
* Inter + JetBrains Mono. Tokens below were taken from that system and
|
||||
* extended with the few this app needs (code display, countdown ring).
|
||||
*
|
||||
* Fully offline: no CDN, no webfonts fetched at runtime. The font stack
|
||||
* falls back to the platform UI face, which on every OS Miles runs is
|
||||
* already a well-tuned grotesque.
|
||||
*/
|
||||
|
||||
/* ── Tokens ───────────────────────────────────────────────────────────── */
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
|
||||
--page: oklch(98.5% .001 286.376);
|
||||
--canvas: oklch(96.1% .002 247.84);
|
||||
--surface: oklch(100% 0 0);
|
||||
--inset: oklch(97.9% .002 247.839);
|
||||
--hover: oklch(97% .002 247.839);
|
||||
--hover-2: oklch(93.3% .003 247.86);
|
||||
|
||||
--ink: oklch(24.7% .006 258.361);
|
||||
--ink-2: oklch(50.6% .01 264.477);
|
||||
--ink-3: oklch(69.5% .009 264.505);
|
||||
|
||||
--line: oklch(94.6% .003 264.542);
|
||||
--line-strong: oklch(91.2% .005 258.326);
|
||||
--line-soft: oklch(96.6% .002 264.542);
|
||||
|
||||
--field: oklch(96.1% .001 286.375);
|
||||
--stripe: oklch(40.5% 0 0/.075);
|
||||
--stripe-bg: oklch(97% 0 0);
|
||||
|
||||
--accent: oklch(62.6% .205 254.947);
|
||||
--accent-ink: oklch(55.6% .187 255.617);
|
||||
--accent-tint: oklch(96% .019 252.878);
|
||||
--on-accent: oklch(100% 0 0);
|
||||
|
||||
--green: oklch(60.3% .155 150.883);
|
||||
--green-tint: oklch(60.3% .155 150.883/.12);
|
||||
--orange: oklch(68.9% .179 49.902);
|
||||
--orange-tint: oklch(68.9% .179 49.902/.12);
|
||||
--red: oklch(62.1% .192 23.042);
|
||||
--red-tint: oklch(62.1% .192 23.042/.12);
|
||||
|
||||
/* Beautiful UI's signature: hairline ring + very soft graduated drop. */
|
||||
--shadow-card: 0 0 0 1px oklch(94.6% .003 264.542),
|
||||
0 18px 47px 0 #00000008, 0 7.5px 19px 0 #00000005,
|
||||
0 4px 10.5px 0 #00000005, 0 2.3px 5.8px 0 #00000003,
|
||||
0 1.2px 3.1px 0 #00000003, 0 .5px 1.3px 0 #00000003;
|
||||
--shadow-btn: 0 0 0 1px oklch(91.2% .005 258.326), 0 0 4px 0 #0000000a;
|
||||
--shadow-raised: 0 0 0 1px oklch(91.2% .005 258.326), 0 2px 10px #0000000f;
|
||||
--shadow-overlay: 0 0 0 1px oklch(91.2% .005 258.326), 0 16px 48px #00000021;
|
||||
--shadow-inset-field: inset 0 1px 2px oklch(0% 0 0/.12);
|
||||
--ring: 0 0 0 3px var(--accent-tint);
|
||||
|
||||
--radius: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
--topbar-h: 60px;
|
||||
|
||||
--sans: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
system-ui, sans-serif;
|
||||
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas,
|
||||
monospace;
|
||||
|
||||
--ease: cubic-bezier(.22, 1, .36, 1);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
|
||||
--page: oklch(20.9% .004 264.477);
|
||||
--canvas: oklch(23.1% .004 264.487);
|
||||
--surface: oklch(26% .006 271.191);
|
||||
--inset: oklch(24.3% .004 264.492);
|
||||
--hover: oklch(28.9% .006 271.22);
|
||||
--hover-2: oklch(31.8% .007 274.747);
|
||||
|
||||
--ink: oklch(96.4% .002 247.839);
|
||||
--ink-2: oklch(73.1% .008 260.731);
|
||||
--ink-3: oklch(54.1% .01 264.484);
|
||||
|
||||
--line: oklch(30.8% .006 258.354);
|
||||
--line-strong: oklch(35.6% .007 264.474);
|
||||
--line-soft: oklch(27.8% .006 258.354);
|
||||
|
||||
--field: oklch(29.3% .006 271.223);
|
||||
--stripe: oklch(100% 0 0/.055);
|
||||
--stripe-bg: oklch(22.6% .004 264.485);
|
||||
|
||||
--accent: oklch(68% .173 253.301);
|
||||
--accent-ink: oklch(78.8% .113 248.33);
|
||||
--accent-tint: oklch(68% .173 253.301/.16);
|
||||
--on-accent: oklch(20% .01 253);
|
||||
|
||||
--green: oklch(70.5% .154 153.814);
|
||||
--green-tint: oklch(70.5% .154 153.814/.14);
|
||||
--orange: oklch(74.6% .156 55.642);
|
||||
--orange-tint: oklch(74.6% .156 55.642/.14);
|
||||
--red: oklch(66.6% .18 21.433);
|
||||
--red-tint: oklch(66.6% .18 21.433/.14);
|
||||
|
||||
--shadow-card: 0 0 0 1px oklch(100% 0 0/.11), 0 1px 2px oklch(0% 0 0/.2),
|
||||
0 2px 6px oklch(0% 0 0/.2);
|
||||
--shadow-btn: 0 0 0 1px oklch(100% 0 0/.1), 0 1px 2px oklch(0% 0 0/.3);
|
||||
--shadow-raised: 0 0 0 1px oklch(100% 0 0/.13), 0 2px 10px oklch(0% 0 0/.22);
|
||||
--shadow-overlay: 0 0 0 1px oklch(100% 0 0/.15), 0 8px 28px oklch(0% 0 0/.34);
|
||||
--shadow-inset-field: inset 0 1px 2px oklch(0% 0 0/.4);
|
||||
}
|
||||
|
||||
/* ── Base ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
background: var(--page);
|
||||
color: var(--ink);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
h1, h2, h3 { margin: 0; font-weight: 600; letter-spacing: -0.02em; }
|
||||
h1 { font-size: 21px; }
|
||||
h2 { font-size: 17px; }
|
||||
h3 { font-size: 14px; }
|
||||
p { margin: 0; }
|
||||
|
||||
a { color: var(--accent-ink); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
::selection { background: var(--accent-tint); }
|
||||
|
||||
:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--ring), var(--shadow-btn);
|
||||
}
|
||||
|
||||
/* Diagonal-stripe backdrop — the Beautiful UI panel treatment. Used
|
||||
behind the pre-auth screens so setup/login feel like a product, not
|
||||
a bare form. */
|
||||
.striped {
|
||||
background-color: var(--stripe-bg);
|
||||
background-image: repeating-linear-gradient(
|
||||
-45deg,
|
||||
var(--stripe) 0 1px,
|
||||
transparent 1px 9px
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Shell ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.shell { display: flex; flex-direction: column; min-height: 100vh; }
|
||||
|
||||
/* Three columns: the search occupies the middle one, so it lands on the same
|
||||
centre line as the account tiles below no matter how wide the brand or the
|
||||
action cluster get. The outer columns are equal-width and allowed to
|
||||
shrink, which keeps the centring exact rather than approximate. */
|
||||
.topbar {
|
||||
position: sticky; top: 0; z-index: 30;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 420px) minmax(0, 1fr);
|
||||
align-items: center; gap: 12px;
|
||||
height: var(--topbar-h);
|
||||
padding: 0 16px;
|
||||
background: color-mix(in oklab, var(--page) 82%, transparent);
|
||||
backdrop-filter: saturate(1.6) blur(12px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.topbar .brand { justify-self: start; min-width: 0; }
|
||||
.topbar .brand strong { overflow: hidden; text-overflow: ellipsis; }
|
||||
.topbar .actions {
|
||||
justify-self: end;
|
||||
display: flex; align-items: center; gap: 2px;
|
||||
}
|
||||
|
||||
/* True centring needs room for all three: the brand wordmark (~210px), the
|
||||
420px search, the action cluster (~110px), plus gaps and padding — about
|
||||
800px in total. Below that the grid starts clipping "OpenMFA",
|
||||
so fall back to a plain row where the brand keeps its space and the search
|
||||
takes whatever is left. */
|
||||
@media (max-width: 820px) {
|
||||
.topbar { display: flex; }
|
||||
.topbar .brand { flex: 0 0 auto; }
|
||||
.topbar .search { flex: 1 1 auto; justify-self: auto; }
|
||||
.topbar .actions { margin-left: auto; flex: 0 0 auto; }
|
||||
}
|
||||
|
||||
.brand { display: flex; align-items: center; gap: 9px; flex: none; }
|
||||
.brand svg { width: 26px; height: 26px; display: block; }
|
||||
.brand strong {
|
||||
font-size: 14.5px; font-weight: 600; letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@media (max-width: 560px) { .brand strong { display: none; } }
|
||||
|
||||
.topbar .spacer { flex: 1; }
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 22px 16px 128px;
|
||||
}
|
||||
|
||||
/* ── Search ───────────────────────────────────────────────────────────── */
|
||||
|
||||
.search {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
justify-self: center;
|
||||
}
|
||||
.search svg {
|
||||
position: absolute; left: 10px; top: 50%; transform: translateY(-50%);
|
||||
width: 15px; height: 15px; color: var(--ink-3); pointer-events: none;
|
||||
}
|
||||
.search input {
|
||||
width: 100%; height: 34px;
|
||||
padding: 0 30px 0 31px;
|
||||
font: inherit; font-size: 13.5px;
|
||||
color: var(--ink);
|
||||
background: var(--field);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-inset-field);
|
||||
transition: box-shadow .15s var(--ease), border-color .15s var(--ease);
|
||||
}
|
||||
.search input::placeholder { color: var(--ink-3); }
|
||||
.search input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--shadow-inset-field), var(--ring);
|
||||
}
|
||||
.search .clear {
|
||||
position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
|
||||
display: none; place-items: center;
|
||||
width: 20px; height: 20px; padding: 0;
|
||||
border: 0; border-radius: 5px;
|
||||
background: transparent; color: var(--ink-3); cursor: pointer;
|
||||
}
|
||||
.search .clear:hover { background: var(--hover-2); color: var(--ink); }
|
||||
.search.has-value .clear { display: grid; }
|
||||
|
||||
/* ── Buttons ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
|
||||
height: 34px; padding: 0 13px;
|
||||
font: inherit; font-size: 13.5px; font-weight: 500;
|
||||
color: var(--ink);
|
||||
background: var(--surface);
|
||||
border: 0; border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-btn);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background .14s var(--ease), transform .14s var(--ease),
|
||||
box-shadow .14s var(--ease);
|
||||
}
|
||||
.btn:hover { background: var(--hover); }
|
||||
.btn:active { transform: translateY(0.5px); }
|
||||
.btn[disabled] { opacity: .5; pointer-events: none; }
|
||||
.btn svg { width: 15px; height: 15px; flex: none; }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent); color: var(--on-accent);
|
||||
box-shadow: 0 0 0 1px color-mix(in oklab, var(--accent) 70%, black),
|
||||
0 1px 2px #00000026;
|
||||
}
|
||||
.btn-primary:hover { background: var(--accent-ink); }
|
||||
|
||||
.btn-danger { color: var(--red); }
|
||||
.btn-danger:hover { background: var(--red-tint); }
|
||||
|
||||
.btn-ghost { background: transparent; box-shadow: none; color: var(--ink-2); }
|
||||
.btn-ghost:hover { background: var(--hover-2); color: var(--ink); }
|
||||
|
||||
.btn-lg { height: 40px; padding: 0 18px; font-size: 14px; }
|
||||
.btn-block { width: 100%; }
|
||||
|
||||
.icon-btn {
|
||||
display: grid; place-items: center;
|
||||
width: 32px; height: 32px; padding: 0;
|
||||
border: 0; border-radius: var(--radius);
|
||||
background: transparent; color: var(--ink-2);
|
||||
cursor: pointer;
|
||||
transition: background .14s var(--ease), color .14s var(--ease);
|
||||
}
|
||||
.icon-btn:hover { background: var(--hover-2); color: var(--ink); }
|
||||
.icon-btn svg { width: 17px; height: 17px; }
|
||||
|
||||
/* Theme toggle shows one glyph per theme. */
|
||||
.theme-toggle .t-moon { display: none; }
|
||||
:root[data-theme="dark"] .theme-toggle .t-sun { display: none; }
|
||||
:root[data-theme="dark"] .theme-toggle .t-moon { display: block; }
|
||||
|
||||
/* ── Cards & panels ───────────────────────────────────────────────────── */
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
.card + .card { margin-top: 14px; }
|
||||
|
||||
.card-head {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.card-head h2 { flex: 1; }
|
||||
.card-body { padding: 16px; }
|
||||
.card-sub { font-size: 12.5px; color: var(--ink-2); margin-top: 3px; }
|
||||
|
||||
/* ── Forms ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.field { margin-bottom: 14px; }
|
||||
.field:last-child { margin-bottom: 0; }
|
||||
.field label {
|
||||
display: block; margin-bottom: 6px;
|
||||
font-size: 12.5px; font-weight: 500; color: var(--ink-2);
|
||||
}
|
||||
.field .hint { margin-top: 6px; font-size: 12px; color: var(--ink-3); }
|
||||
|
||||
.input, .select, textarea.input {
|
||||
width: 100%; height: 38px;
|
||||
padding: 0 11px;
|
||||
font: inherit; font-size: 13.5px;
|
||||
color: var(--ink);
|
||||
background: var(--field);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-inset-field);
|
||||
transition: border-color .15s var(--ease), box-shadow .15s var(--ease);
|
||||
}
|
||||
textarea.input { height: auto; min-height: 76px; padding: 9px 11px;
|
||||
resize: vertical; font-family: var(--mono); font-size: 12.5px;
|
||||
line-height: 1.6; }
|
||||
.input::placeholder { color: var(--ink-3); }
|
||||
.input:focus, .select:focus, textarea.input:focus {
|
||||
outline: none; border-color: var(--accent);
|
||||
box-shadow: var(--shadow-inset-field), var(--ring);
|
||||
}
|
||||
.input[aria-invalid="true"] { border-color: var(--red); }
|
||||
|
||||
.select { appearance: none; cursor: pointer;
|
||||
background-image: linear-gradient(45deg, transparent 50%, var(--ink-3) 50%),
|
||||
linear-gradient(135deg, var(--ink-3) 50%, transparent 50%);
|
||||
background-position: calc(100% - 15px) 17px, calc(100% - 10px) 17px;
|
||||
background-size: 5px 5px, 5px 5px;
|
||||
background-repeat: no-repeat;
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
.row { display: flex; gap: 10px; }
|
||||
.row > * { flex: 1; min-width: 0; }
|
||||
|
||||
/* Switch */
|
||||
.switch { display: flex; align-items: center; gap: 10px; cursor: pointer; }
|
||||
.switch input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.switch .track {
|
||||
position: relative; flex: none;
|
||||
width: 34px; height: 20px;
|
||||
background: var(--hover-2);
|
||||
border-radius: 999px;
|
||||
box-shadow: inset 0 0 0 1px var(--line-strong);
|
||||
transition: background .18s var(--ease);
|
||||
}
|
||||
.switch .track::after {
|
||||
content: ""; position: absolute; top: 2px; left: 2px;
|
||||
width: 16px; height: 16px;
|
||||
background: var(--surface); border-radius: 50%;
|
||||
box-shadow: 0 1px 2px #00000033;
|
||||
transition: transform .18s var(--ease);
|
||||
}
|
||||
.switch input:checked + .track { background: var(--accent); }
|
||||
.switch input:checked + .track::after { transform: translateX(14px); }
|
||||
.switch input:focus-visible + .track { box-shadow: inset 0 0 0 1px var(--line-strong), var(--ring); }
|
||||
.switch .switch-label { font-size: 13.5px; }
|
||||
|
||||
/* ── Account list ─────────────────────────────────────────────────────── */
|
||||
|
||||
.accounts { display: flex; flex-direction: column; gap: 10px; }
|
||||
|
||||
.acct {
|
||||
display: grid;
|
||||
grid-template-columns: 38px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
padding: 13px 15px;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
transition: background .14s var(--ease);
|
||||
}
|
||||
.acct:hover { background: var(--hover); }
|
||||
|
||||
.acct .avatar {
|
||||
display: grid; place-items: center;
|
||||
width: 38px; height: 38px;
|
||||
border-radius: 10px;
|
||||
font-size: 15px; font-weight: 600;
|
||||
color: var(--on-accent);
|
||||
background: var(--accent);
|
||||
box-shadow: inset 0 0 0 1px #0000001a;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.acct .meta { min-width: 0; }
|
||||
.acct .issuer {
|
||||
font-size: 13.5px; font-weight: 600; letter-spacing: -0.01em;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.acct .label {
|
||||
font-size: 12px; color: var(--ink-2);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.acct .right { display: flex; align-items: center; gap: 11px; }
|
||||
|
||||
/* The code itself — the thing people came for. Tabular figures so the
|
||||
digits don't shimmy as they change, and a word-gap in the middle the
|
||||
way every authenticator app renders it. */
|
||||
.code {
|
||||
font-family: var(--mono);
|
||||
font-size: 25px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.055em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
border: 0; background: none; padding: 2px 4px;
|
||||
border-radius: 6px;
|
||||
transition: color .2s var(--ease), background .14s var(--ease);
|
||||
line-height: 1.1;
|
||||
}
|
||||
.code:hover { background: var(--hover-2); }
|
||||
.code .gap { display: inline-block; width: 0.42em; }
|
||||
/* Last 5 seconds: the code is about to roll. */
|
||||
.acct.expiring .code { color: var(--orange); }
|
||||
|
||||
/* Countdown ring */
|
||||
.ring { width: 26px; height: 26px; flex: none; transform: rotate(-90deg); }
|
||||
.ring circle { fill: none; stroke-width: 2.5; }
|
||||
.ring .bg { stroke: var(--line-strong); }
|
||||
.ring .fg {
|
||||
stroke: var(--accent); stroke-linecap: round;
|
||||
transition: stroke-dashoffset .95s linear, stroke .3s var(--ease);
|
||||
}
|
||||
.acct.expiring .ring .fg { stroke: var(--orange); }
|
||||
|
||||
/* Discord push status */
|
||||
.push-dot {
|
||||
width: 7px; height: 7px; border-radius: 50%; flex: none;
|
||||
background: var(--ink-3);
|
||||
}
|
||||
.push-dot.live { background: var(--green); box-shadow: 0 0 0 3px var(--green-tint); }
|
||||
.push-dot.error { background: var(--red); box-shadow: 0 0 0 3px var(--red-tint); }
|
||||
.push-dot.paused { background: var(--ink-3); }
|
||||
|
||||
/* ── Chips ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
height: 22px; padding: 0 8px;
|
||||
font-size: 11.5px; font-weight: 500;
|
||||
color: var(--ink-2);
|
||||
background: var(--inset);
|
||||
border-radius: 999px;
|
||||
box-shadow: inset 0 0 0 1px var(--line);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chip.ok { color: var(--green); background: var(--green-tint); box-shadow: none; }
|
||||
.chip.warn { color: var(--orange); background: var(--orange-tint); box-shadow: none; }
|
||||
.chip.err { color: var(--red); background: var(--red-tint); box-shadow: none; }
|
||||
|
||||
/* ── Empty state ──────────────────────────────────────────────────────── */
|
||||
|
||||
.empty {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
gap: 5px;
|
||||
padding: 56px 24px 60px;
|
||||
text-align: center;
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
.empty .glyph {
|
||||
display: grid; place-items: center;
|
||||
width: 60px; height: 60px; margin-bottom: 14px;
|
||||
border-radius: 18px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-tint);
|
||||
}
|
||||
.empty .glyph svg { width: 28px; height: 28px; }
|
||||
.empty h2 { font-size: 16px; }
|
||||
.empty p { font-size: 13px; color: var(--ink-2); max-width: 330px; }
|
||||
.empty .btn { margin-top: 16px; }
|
||||
|
||||
/* ── Floating action button ───────────────────────────────────────────── */
|
||||
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
z-index: 25;
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
height: 46px; padding: 0 19px;
|
||||
font: inherit; font-size: 14px; font-weight: 500;
|
||||
color: var(--on-accent);
|
||||
background: var(--accent);
|
||||
border: 0; border-radius: 999px;
|
||||
box-shadow: 0 0 0 1px color-mix(in oklab, var(--accent) 70%, black),
|
||||
0 6px 20px color-mix(in oklab, var(--accent) 40%, transparent),
|
||||
0 2px 6px #0000001f;
|
||||
cursor: pointer;
|
||||
transition: transform .16s var(--ease), background .16s var(--ease);
|
||||
}
|
||||
.fab:hover { background: var(--accent-ink); transform: translateY(-1px); }
|
||||
.fab:active { transform: translateY(0); }
|
||||
.fab svg { width: 17px; height: 17px; }
|
||||
|
||||
/* ── Modal ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.scrim {
|
||||
position: fixed; inset: 0; z-index: 40;
|
||||
display: grid; place-items: center;
|
||||
padding: 20px;
|
||||
background: oklch(0% 0 0 / .45);
|
||||
backdrop-filter: blur(3px);
|
||||
animation: fade .16s var(--ease);
|
||||
}
|
||||
.modal {
|
||||
width: 100%; max-width: 460px;
|
||||
max-height: calc(100vh - 40px);
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-overlay);
|
||||
animation: pop .2s var(--ease);
|
||||
overflow: hidden;
|
||||
}
|
||||
.modal-head {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 15px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.modal-head h2 { flex: 1; }
|
||||
.modal-body { padding: 16px; overflow-y: auto; }
|
||||
.modal-foot {
|
||||
display: flex; justify-content: flex-end; gap: 8px;
|
||||
padding: 13px 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--inset);
|
||||
}
|
||||
|
||||
@keyframes fade { from { opacity: 0 } }
|
||||
@keyframes pop { from { opacity: 0; transform: translateY(8px) scale(.98) } }
|
||||
|
||||
/* ── Tabs ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.tabs {
|
||||
display: flex; gap: 3px;
|
||||
padding: 3px;
|
||||
background: var(--inset);
|
||||
border-radius: 10px;
|
||||
box-shadow: inset 0 0 0 1px var(--line);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.tabs button {
|
||||
flex: 1;
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
height: 32px; padding: 0 8px;
|
||||
font: inherit; font-size: 12.5px; font-weight: 500;
|
||||
color: var(--ink-2);
|
||||
background: transparent; border: 0; border-radius: 7px;
|
||||
cursor: pointer;
|
||||
transition: background .14s var(--ease), color .14s var(--ease);
|
||||
}
|
||||
.tabs button svg { width: 14px; height: 14px; }
|
||||
.tabs button:hover { color: var(--ink); }
|
||||
.tabs button.active {
|
||||
color: var(--ink);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-btn);
|
||||
}
|
||||
|
||||
/* ── Dropzone (QR upload) ─────────────────────────────────────────────── */
|
||||
|
||||
.dropzone {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 4px;
|
||||
padding: 34px 20px;
|
||||
text-align: center;
|
||||
color: var(--ink-2);
|
||||
background: var(--inset);
|
||||
border: 1.5px dashed var(--line-strong);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: border-color .16s var(--ease), background .16s var(--ease);
|
||||
}
|
||||
.dropzone:hover, .dropzone.over {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-tint);
|
||||
}
|
||||
.dropzone svg { width: 26px; height: 26px; color: var(--ink-3); margin-bottom: 8px; }
|
||||
.dropzone.over svg { color: var(--accent); }
|
||||
.dropzone strong { font-size: 13.5px; color: var(--ink); font-weight: 500; }
|
||||
.dropzone span { font-size: 12px; }
|
||||
|
||||
/* Camera scanner */
|
||||
.scanner {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
width: 100%;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
background: oklch(15% 0 0);
|
||||
box-shadow: inset 0 0 0 1px var(--line);
|
||||
}
|
||||
.scanner video { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.scanner .reticle {
|
||||
position: absolute; inset: 16%;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 0 0 3px oklch(100% 0 0 / .85),
|
||||
0 0 0 9999px oklch(0% 0 0 / .45);
|
||||
}
|
||||
|
||||
/* ── Segmented provider picker ──────────────────────────────────────── */
|
||||
|
||||
.segmented {
|
||||
display: grid; grid-template-columns: repeat(3, 1fr); gap: 3px;
|
||||
padding: 3px;
|
||||
background: var(--inset);
|
||||
border-radius: 10px;
|
||||
box-shadow: inset 0 0 0 1px var(--line);
|
||||
}
|
||||
.segmented button {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
|
||||
height: 36px; padding: 0 10px;
|
||||
font: inherit; font-size: 13px; font-weight: 500;
|
||||
color: var(--ink-2);
|
||||
background: transparent; border: 0; border-radius: 7px;
|
||||
cursor: pointer;
|
||||
transition: background .14s var(--ease), color .14s var(--ease);
|
||||
}
|
||||
.segmented button:hover { color: var(--ink); }
|
||||
.segmented button.active {
|
||||
color: var(--ink);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-btn);
|
||||
}
|
||||
.segmented .dot {
|
||||
width: 9px; height: 9px; border-radius: 50%; flex: none;
|
||||
box-shadow: inset 0 0 0 1px #0000001f;
|
||||
opacity: .55;
|
||||
}
|
||||
.segmented button.active .dot { opacity: 1; }
|
||||
.provider-fields:not(:empty) { margin-top: 14px; }
|
||||
.provider-fields .alert { margin-bottom: 14px; }
|
||||
.brand svg { width: 26px; height: 26px; display: block; }
|
||||
.auth-card .brand svg { width: 34px; height: 34px; }
|
||||
|
||||
/* ── Import list (Google export / multi-account) ───────────────────── */
|
||||
|
||||
.import-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 10px; margin: 14px 0 8px;
|
||||
}
|
||||
|
||||
.btn-sm { height: 26px; padding: 0 9px; font-size: 12px; }
|
||||
|
||||
/* Capped so a 20-account export scrolls inside the modal rather than
|
||||
pushing the Import button off the bottom of the screen. */
|
||||
.import-list {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: inset 0 0 0 1px var(--line);
|
||||
background: var(--inset);
|
||||
}
|
||||
|
||||
.import-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 30px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 9px 12px;
|
||||
cursor: pointer;
|
||||
transition: background .12s var(--ease);
|
||||
}
|
||||
.import-row + .import-row { border-top: 1px solid var(--line-soft); }
|
||||
.import-row:hover { background: var(--hover); }
|
||||
|
||||
.import-row input[type="checkbox"] {
|
||||
width: 16px; height: 16px; margin: 0;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.import-row .avatar {
|
||||
width: 30px; height: 30px; border-radius: 8px; font-size: 13px;
|
||||
}
|
||||
.import-row .meta { min-width: 0; }
|
||||
.import-row .issuer { font-size: 13px; font-weight: 600; letter-spacing: -0.01em;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.import-row .label { font-size: 11.5px; color: var(--ink-2);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.import-row .tags { display: flex; gap: 4px; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.import-row .tags .chip { height: 19px; padding: 0 7px; font-size: 10.5px; }
|
||||
|
||||
/* An account already in the vault is de-emphasised but still selectable. */
|
||||
.import-row:has(input:not(:checked)) .meta { opacity: .55; }
|
||||
|
||||
/* ── Toast ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.toasts {
|
||||
position: fixed; left: 50%; bottom: 22px; z-index: 60;
|
||||
transform: translateX(-50%);
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 9px 14px;
|
||||
font-size: 13px; font-weight: 500;
|
||||
color: var(--ink);
|
||||
background: var(--surface);
|
||||
border-radius: 999px;
|
||||
box-shadow: var(--shadow-overlay);
|
||||
animation: toast-in .22s var(--ease);
|
||||
}
|
||||
.toast svg { width: 15px; height: 15px; flex: none; }
|
||||
.toast.ok svg { color: var(--green); }
|
||||
.toast.err svg { color: var(--red); }
|
||||
@keyframes toast-in { from { opacity: 0; transform: translateY(10px) } }
|
||||
|
||||
/* ── Auth screens (setup / login) ─────────────────────────────────────── */
|
||||
|
||||
.auth-wrap {
|
||||
min-height: 100vh;
|
||||
display: grid; place-items: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.auth-card {
|
||||
width: 100%; max-width: 400px;
|
||||
padding: 28px;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-overlay);
|
||||
}
|
||||
.auth-card .brand {
|
||||
justify-content: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.auth-card .brand svg { width: 34px; height: 34px; }
|
||||
.auth-card h1 { text-align: center; font-size: 19px; margin-top: 12px; }
|
||||
.auth-card .sub {
|
||||
text-align: center; font-size: 13px; color: var(--ink-2);
|
||||
margin: 5px 0 22px;
|
||||
}
|
||||
.auth-card .btn { margin-top: 6px; }
|
||||
|
||||
.alert {
|
||||
display: flex; align-items: flex-start; gap: 8px;
|
||||
padding: 10px 12px; margin-bottom: 14px;
|
||||
font-size: 12.5px; line-height: 1.45;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.alert svg { width: 15px; height: 15px; flex: none; margin-top: 1px; }
|
||||
.alert.err { color: var(--red); background: var(--red-tint); }
|
||||
.alert.warn { color: var(--orange); background: var(--orange-tint); }
|
||||
.alert.info { color: var(--ink-2); background: var(--inset);
|
||||
box-shadow: inset 0 0 0 1px var(--line); }
|
||||
|
||||
/* ── Settings ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.settings-nav { display: flex; gap: 4px; margin-bottom: 18px; flex-wrap: wrap; }
|
||||
|
||||
.kv { display: flex; justify-content: space-between; gap: 16px;
|
||||
padding: 9px 0; font-size: 13px; }
|
||||
.kv + .kv { border-top: 1px solid var(--line-soft); }
|
||||
.kv dt { color: var(--ink-2); }
|
||||
.kv dd { margin: 0; font-family: var(--mono); font-size: 12.5px;
|
||||
text-align: right; word-break: break-all; }
|
||||
|
||||
/* ── Utility ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.mono { font-family: var(--mono); }
|
||||
.muted { color: var(--ink-2); }
|
||||
.small { font-size: 12.5px; }
|
||||
.stack { display: flex; flex-direction: column; gap: 14px; }
|
||||
.stack-s { display: flex; flex-direction: column; gap: 8px; }
|
||||
.hidden { display: none !important; }
|
||||
.sr-only {
|
||||
position: absolute; width: 1px; height: 1px;
|
||||
padding: 0; margin: -1px; overflow: hidden;
|
||||
clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
||||
}
|
||||
.spin { animation: spin 1s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg) } }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: .01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: .01ms !important;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,32 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>OpenMFA</title>
|
||||
<!-- `?v=…` is replaced server-side with the running version so every
|
||||
upgrade ships brand-new asset URLs; combined with
|
||||
`Cache-Control: no-cache, must-revalidate` on the asset handlers the
|
||||
worst-case staleness window is one release. -->
|
||||
<link rel="stylesheet" href="/assets/app.css?v={{ASSET_VERSION}}" />
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg?v={{ASSET_VERSION}}" />
|
||||
<!-- Paint the stored theme before first paint to avoid a light→dark flash. -->
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var stored = localStorage.getItem('openmfa-theme');
|
||||
var theme = stored || (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
} catch (e) {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div class="toasts" id="toasts" aria-live="polite"></div>
|
||||
<script src="/assets/app.js?v={{ASSET_VERSION}}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Offline-only web UI. Every byte the browser needs (HTML, CSS, JS, the
|
||||
//! brand mark) is compiled into the binary via `include_str!`. No CDN, no
|
||||
//! remote fonts, no external images — the UI renders identically on an
|
||||
//! air-gapped LAN, which matters for a box holding TOTP secrets.
|
||||
//!
|
||||
//! Visual language follows Beautiful UI (<https://www.beautifului.dev/>):
|
||||
//! OKLCH ramps, hairline-ring shadows, inset fields and raised buttons.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
/// Render the single-page shell.
|
||||
///
|
||||
/// `asset_version` is interpolated into every asset URL as `?v=…` so each
|
||||
/// release ships fresh URLs and no browser or intermediary proxy can keep
|
||||
/// serving the previous release's `app.js` against this release's API.
|
||||
#[must_use]
|
||||
pub fn index_html(asset_version: &str) -> String {
|
||||
INDEX_HTML.replace("{{ASSET_VERSION}}", asset_version)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn app_js() -> &'static str {
|
||||
APP_JS
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn app_css() -> &'static str {
|
||||
APP_CSS
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn logo_svg() -> &'static str {
|
||||
LOGO_SVG
|
||||
}
|
||||
|
||||
const INDEX_HTML: &str = include_str!("index.html");
|
||||
const APP_CSS: &str = include_str!("app.css");
|
||||
const APP_JS: &str = include_str!("app.js");
|
||||
const LOGO_SVG: &str = include_str!("logo.svg");
|
||||
@@ -0,0 +1,25 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512"
|
||||
role="img" aria-label="OpenMFA">
|
||||
<!-- OpenMFA mark: white shield-and-arc on the product's dark surface.
|
||||
Blue is reserved for the Discord-only sibling project; OpenMFA is
|
||||
platform-neutral, so its mark is monochrome. Literal colours rather
|
||||
than currentColor because this renders as a favicon, a Gitea avatar and
|
||||
an Unraid icon, none of which provide a CSS context. -->
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#232833"/>
|
||||
<stop offset="1" stop-color="#14171e"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="114" fill="url(#bg)"/>
|
||||
<rect x="1" y="1" width="510" height="510" rx="113"
|
||||
fill="none" stroke="#ffffff" stroke-opacity="0.10" stroke-width="2"/>
|
||||
<g transform="translate(96 96) scale(10)" fill="none" stroke="#ffffff">
|
||||
<path d="M16 2.6 27 6.3v9.1c0 6.6-4.4 12.2-11 14.1-6.6-1.9-11-7.5-11-14.1V6.3L16 2.6Z"
|
||||
fill="#ffffff" fill-opacity=".12" stroke="none"/>
|
||||
<path d="M16 2.6 27 6.3v9.1c0 6.6-4.4 12.2-11 14.1-6.6-1.9-11-7.5-11-14.1V6.3L16 2.6Z"
|
||||
stroke-width="1.9" stroke-linejoin="round"/>
|
||||
<path d="M20.9 12.6a5.9 5.9 0 1 0 1 3.3" stroke-width="2.1" stroke-linecap="round"/>
|
||||
<circle cx="16" cy="15.9" r="1.85" fill="#ffffff" stroke="none"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -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"]
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
#!/bin/sh
|
||||
# Fix bind-mount ownership, then drop privileges.
|
||||
#
|
||||
# Bind-mounted volumes arrive owned by whatever the host says — on Unraid
|
||||
# that is typically nobody:users (99:100), and on a plain `docker run -v`
|
||||
# it is usually root:root. The service runs as uid 10001 and could not
|
||||
# otherwise write the vault, so when we start as root we chown the data
|
||||
# directory first and only then hand off.
|
||||
#
|
||||
# When the container is already running as non-root (some platforms assign
|
||||
# their own uid), we skip the chown and exec directly — there is nothing
|
||||
# useful we could do about permissions from there anyway.
|
||||
set -e
|
||||
|
||||
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 "$@"
|
||||
@@ -0,0 +1,79 @@
|
||||
<?xml version="1.0"?>
|
||||
<!--
|
||||
Unraid Docker template for OpenMFA.
|
||||
|
||||
Drop this into /boot/config/plugins/dockerMan/templates-user/ on the Unraid
|
||||
box, or add the repo as a template repository under Docker → Add Container.
|
||||
|
||||
Bridge networking is correct: OpenMFA makes outbound HTTPS calls to the chat
|
||||
platform you choose and serves one HTTP port.
|
||||
|
||||
Web UI: http://<unraid-ip>:8720/
|
||||
-->
|
||||
<Container version="2">
|
||||
<Name>OpenMFA</Name>
|
||||
<Repository>gitea.milesward.dev/mward4/openmfa:latest</Repository>
|
||||
<Registry>https://gitea.milesward.dev/mward4/-/packages/container/openmfa</Registry>
|
||||
<Network>bridge</Network>
|
||||
<MyIP/>
|
||||
<Shell>sh</Shell>
|
||||
<Privileged>false</Privileged>
|
||||
<Support>https://gitea.milesward.dev/mward4/OpenMFA/issues</Support>
|
||||
<Project>https://gitea.milesward.dev/mward4/OpenMFA</Project>
|
||||
<Overview>
|
||||
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.
|
||||
</Overview>
|
||||
<Category>Tools:Utilities Security:</Category>
|
||||
<WebUI>http://[IP]:[PORT:8720]/</WebUI>
|
||||
<TemplateURL/>
|
||||
<Icon>https://gitea.milesward.dev/mward4/OpenMFA/raw/branch/main/docs/brand/icon-256.png</Icon>
|
||||
<ExtraParams/>
|
||||
<PostArgs/>
|
||||
<CPUset/>
|
||||
<DateInstalled/>
|
||||
<DonateText/>
|
||||
<DonateLink/>
|
||||
<Requires>
|
||||
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.
|
||||
</Requires>
|
||||
<Config Name="WebUI Port" Target="8720" Default="8720" Mode="tcp"
|
||||
Description="Port for the web UI and API."
|
||||
Type="Port" Display="always" Required="true" Mask="false">8720</Config>
|
||||
<Config Name="Data" Target="/var/lib/openmfa"
|
||||
Default="/mnt/user/appdata/openmfa"
|
||||
Mode="rw"
|
||||
Description="Vault, encryption key, admin account and settings. BACK THIS UP — losing it loses every stored TOTP secret."
|
||||
Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/openmfa</Config>
|
||||
<Config Name="Master key (optional)" Target="OPENMFA_MASTER_KEY" Default=""
|
||||
Mode=""
|
||||
Description="Base64 of 32 random bytes. Set this to keep the vault key OFF the appdata share, so a leaked backup of the share is useless on its own. Generate with: openssl rand -base64 32. Leave blank to auto-generate a key file inside the data folder instead. WARNING: if you set this later, existing secrets become unreadable — export them first."
|
||||
Type="Variable" Display="advanced" Required="false" Mask="true"></Config>
|
||||
<Config Name="Log filter" Target="OPENMFA_LOG" Default="info,openmfa=info"
|
||||
Mode=""
|
||||
Description="tracing-subscriber EnvFilter expression. Use info,openmfa=debug for more detail."
|
||||
Type="Variable" Display="advanced" Required="false" Mask="false">info,openmfa=info</Config>
|
||||
</Container>
|
||||
@@ -0,0 +1,34 @@
|
||||
# OpenMFA — single-container deployment.
|
||||
#
|
||||
# docker compose up -d
|
||||
#
|
||||
# Then open http://<this-host>: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
|
||||
@@ -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='<paste-your-token>'
|
||||
```
|
||||
|
||||
## 1. Create the repository
|
||||
|
||||
```bash
|
||||
curl -s -X POST "https://gitea.milesward.dev/api/v1/user/repos" -H "Authorization: token $GITEA_TOKEN" -H "Content-Type: application/json" -d '{"name":"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
|
||||
```
|
||||
+176
@@ -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 `<t:R>`,
|
||||
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/<region>/`) 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)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
@@ -0,0 +1,25 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512"
|
||||
role="img" aria-label="OpenMFA">
|
||||
<!-- OpenMFA mark: white shield-and-arc on the product's dark surface.
|
||||
Blue is reserved for the Discord-only sibling project; OpenMFA is
|
||||
platform-neutral, so its mark is monochrome. Literal colours rather
|
||||
than currentColor because this renders as a favicon, a Gitea avatar and
|
||||
an Unraid icon, none of which provide a CSS context. -->
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#232833"/>
|
||||
<stop offset="1" stop-color="#14171e"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="114" fill="url(#bg)"/>
|
||||
<rect x="1" y="1" width="510" height="510" rx="113"
|
||||
fill="none" stroke="#ffffff" stroke-opacity="0.10" stroke-width="2"/>
|
||||
<g transform="translate(96 96) scale(10)" fill="none" stroke="#ffffff">
|
||||
<path d="M16 2.6 27 6.3v9.1c0 6.6-4.4 12.2-11 14.1-6.6-1.9-11-7.5-11-14.1V6.3L16 2.6Z"
|
||||
fill="#ffffff" fill-opacity=".12" stroke="none"/>
|
||||
<path d="M16 2.6 27 6.3v9.1c0 6.6-4.4 12.2-11 14.1-6.6-1.9-11-7.5-11-14.1V6.3L16 2.6Z"
|
||||
stroke-width="1.9" stroke-linejoin="round"/>
|
||||
<path d="M20.9 12.6a5.9 5.9 0 1 0 1 3.3" stroke-width="2.1" stroke-linecap="round"/>
|
||||
<circle cx="16" cy="15.9" r="1.85" fill="#ffffff" stroke="none"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "stable"
|
||||
components = ["rustfmt", "clippy"]
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env bash
|
||||
# build-and-publish-unraid.sh — build the 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:
|
||||
# <GITEA_HOST>/<owner>/openmfa:<tag> (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
|
||||
Reference in new issue
Block a user