Files
Discord-Authenticator/Cargo.toml
T
DSH Agent b21ade05eb v0.1.3: make the vault survive a power cut, and recover if it doesn't
The vault was being written with `fs::write(tmp)` → `rename(tmp, path)`. The
rename is atomic, so readers never saw a half-written file — but atomicity is
not durability. Nothing fsynced the new bytes before the rename, so on ext4
(`data=ordered`, the default) a crash or hard power-off could commit the
rename while the data it published never landed. The result is precisely the
failure that stopped the container booting: `vault.json` present, zero bytes,
"EOF while parsing a value at line 1 column 0".

Two things made it worse. The write happened on *every* push tick, so a
ten-account vault was rewriting all of its secrets thousands of times a day,
and there was no backup to fall back on — a damaged file meant a crash loop
with the web UI locked behind the service that would not start.

Storage

  * `store_io::write_atomic` fsyncs the new bytes, renames, then fsyncs the
    directory. That ordering is the whole fix; the module docs explain why.
  * Every vault write refreshes `vault.json.bak`, taken *after* the new file is
    safely in place so the two always agree. Backing up first would leave the
    backup a full change behind and quietly lose the most recent edit.
  * `Vault::load` falls back to the backup when `vault.json` is unparseable,
    moves the damaged file to `vault.json.corrupt-<ts>` rather than deleting
    it, and writes the recovered accounts back so the next boot is ordinary.
    With no usable backup it still refuses to start, as before.

Write volume

  * Discord bookkeeping — message ids, last codes, last errors — moves to its
    own `push-state.json`, batched and flushed at most every five seconds, with
    a final flush on shutdown. Codes roll constantly; accounts change only when
    the operator edits them, and keeping the two together was what put the
    secrets in the path of every rotation.
  * `push-state.json` is a cache by construction: losing or corrupting it costs
    a reposted tile, never a secret, so it is never a reason to refuse to boot.

Tests: recovery from a zero-length vault, backup retention, write-back on
recovery, refusal when both files are bad, reload of tile ids, batching, and
that a rotation leaves `vault.json` byte-identical. 143 passing.
2026-09-20 02:30:22 -04:00

116 lines
4.0 KiB
TOML

[workspace]
resolver = "2"
members = [
"crates/core",
"crates/render",
"crates/discord",
"crates/webui",
"crates/http-api",
"crates/discord-authenticator",
]
[workspace.package]
version = "0.1.3"
edition = "2021"
rust-version = "1.90"
license = "MIT OR Apache-2.0"
repository = "https://gitea.milesward.dev/mward4/Discord-Authenticator"
authors = ["Discord Authenticator contributors"]
[workspace.dependencies]
tokio = { version = "1", features = ["full"] }
axum = { version = "0.8", features = ["macros", "multipart"] }
tower-http = { version = "0.6", features = ["trace", "limit"] }
# rustls, never native-tls: an OpenSSL dependency would break the static
# musl build. `multipart` carries the rendered tile up to Discord.
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "multipart"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
anyhow = "1"
thiserror = "2"
clap = { version = "4", features = ["derive", "env"] }
uuid = { version = "1", features = ["v4", "serde"] }
time = { version = "0.3", features = ["serde", "serde-human-readable", "formatting"] }
parking_lot = "0.12"
url = "2"
percent-encoding = "2"
base64 = "0.22"
# ── TOTP primitives ──
# The RFC 6238 core is ~30 lines over HMAC, and we implement it directly
# rather than pulling `totp-rs`. The deciding factor was otpauth:// parsing:
# real-world URIs routinely carry a path issuer that disagrees with the
# `?issuer=` query param (Microsoft does this), and secrets that are
# lowercase or `=`-padded (Google, Dropbox). totp-rs rejects all of those,
# so we'd be writing the normalisation and reconciliation layer anyway.
# Pinned to the mature 0.10/0.12 RustCrypto generation.
hmac = "0.12"
sha1 = "0.10"
sha2 = "0.10"
base32 = "0.5"
# ── Secrets at rest ──
# XChaCha20-Poly1305 over AES-GCM for the 192-bit nonce: large enough to
# generate randomly per record with no birthday-collision worry, and
# constant-time in software on every target without AES-NI.
chacha20poly1305 = "0.10"
argon2 = "0.5"
rand = "0.8"
zeroize = { version = "1", features = ["derive"] }
# ── Imaging ──
# default-features = false is REQUIRED: `image`'s default feature set
# enables `avif`, dragging in the whole rav1e encoder for a format we never
# touch. (Never enable `avif-native` — that one is C bindings to libdav1d
# and would break the static musl build outright.)
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "bmp", "webp"] }
rqrr = "0.10"
# Tile rendering. The tile is drawn as SVG and rasterised, rather than
# blitting glyphs by hand: text centring, rounded corners and gradients are
# all one attribute each in SVG and a pile of arithmetic otherwise.
#
# `default-features = false` matters here too — resvg defaults to
# `system-fonts` + `memmap-fonts`, and a static binary in a slim container has
# no system fonts to find. Text would silently render blank. We disable both
# and register the embedded faces explicitly.
resvg = { version = "0.48", default-features = false, features = ["text"] }
usvg = { version = "0.48", default-features = false, features = ["text"] }
tiny-skia = "0.12"
da-core = { path = "crates/core" }
da-render = { path = "crates/render" }
da-discord = { path = "crates/discord" }
da-webui = { path = "crates/webui" }
da-http-api = { path = "crates/http-api" }
[workspace.lints.rust]
unsafe_code = "deny"
rust_2018_idioms = { level = "warn", priority = -1 }
[workspace.lints.clippy]
pedantic = { level = "warn", priority = -1 }
module_name_repetitions = "allow"
missing_errors_doc = "allow"
missing_panics_doc = "allow"
must_use_candidate = "allow"
doc_markdown = "allow"
items_after_statements = "allow"
cast_possible_truncation = "allow"
cast_lossless = "allow"
cast_sign_loss = "allow"
cast_precision_loss = "allow"
similar_names = "allow"
too_many_lines = "allow"
[profile.release]
lto = "thin"
codegen-units = 1
strip = "symbols"
opt-level = 3