2 Commits
Author SHA1 Message Date
Miles WardandClaude Opus 4.8 7751ebe9b3 v0.1.2: working Edit sheet, centred search, brand assets
Fixes a bug that shipped in v0.1: the per-account Edit sheet never opened at
all. Its Cancel button used `onclick: dlg.close`, evaluated while building the
arguments to openModal() and therefore before `const dlg` was initialised — a
temporal-dead-zone ReferenceError that aborted the whole handler. Every other
button in that sheet was already a closure, which is why it went unnoticed:
browser testing had covered add, import and settings, but never opened this
sheet.

Renames now reach Discord almost immediately. The push loop skips any account
whose current code it has already sent, so a rename previously stayed invisible
until the code next rolled — up to a full period. update() now clears the
last-sent code when the naming actually changes, so the next tick treats the
tile as stale and rewrites it in about a second. Toggling push alone is not a
rename and still costs nothing.

- webui: pencil icon and "Edit account" title so the row action reads as edit
  rather than settings; both fields labelled, with the second one explained as
  the username/email and left untouched unless changed
- webui: topbar is a three-column grid so the search sits on the same centre
  line as the account tiles. Below 820px there isn't room for brand + 420px
  search + actions, so it falls back to a row rather than clipping the
  wordmark; the brand now reads "Discord Authenticator" in full
- webui: the add-account FAB pins to the viewport corner instead of tracking
  the content column edge
- brand: docs/brand/icon.svg plus PNGs rendered from it by da-render, so the
  README image, Gitea avatar and Unraid template icon all derive from one
  source rather than drifting. Unraid's <Icon> now points at a PNG, which it
  renders more reliably than SVG

126 tests (was 124).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-31 01:42:18 -04:00
Miles WardandClaude Opus 4.8 eecdacd890 v0.1.1: import Google Authenticator exports
Google's "Transfer accounts -> Export accounts" produces an
otpauth-migration:// link wrapping a base64 protobuf that holds many accounts
at once, and splits large exports across several QR codes. v0.1 rejected these
outright; now they import.

The protobuf is decoded by hand rather than pulling prost: the schema is four
messages that have not changed since 2020, and the wire format needed is two
types (varint, length-delimited). Unknown fields are skipped properly, so a
future Google addition degrades rather than corrupting the parse.

Two details in this format silently produce plausible-but-wrong codes if taken
backwards, so both are pinned by tests: the `secret` field is RAW BYTES, not
base32, and the payload carries NO period field -- every Google export is
30-second TOTP.

- core/migration.rs: protobuf decode, lenient base64 (all four alphabet and
  padding spellings), issuer reconciliation between the `name` prefix and the
  dedicated `issuer` field, and batch metadata surfaced as "Part 1 of 2"
- vault: add_many() with duplicate detection, persisting once per batch rather
  than once per account; duplicates match on secret AND naming, so keeping one
  secret under two labels stays possible
- http-api: preview returns a list flagged with what is already stored; import
  takes the link plus selected indices and re-parses server-side, so secrets
  never travel out to the browser and back. /api/qr/decode now returns every
  payload in an image, not just the first
- webui: multi-account import checklist with select all/none, per-entry
  algorithm and digit chips, already-present entries pre-unticked, and skipped
  entries named with their reason (HOTP, MD5) instead of silently dropped

Verified end to end against real QR images: upload -> decode -> preview ->
import, with the resulting codes cross-checked against an independent RFC 6238
implementation, including an 8-digit account.

124 tests (was 101).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-31 00:44:59 -04:00
21 changed files with 1865 additions and 111 deletions

No files matched your search

Generated
+7 -6
View File
@@ -436,7 +436,7 @@ dependencies = [
[[package]]
name = "da-core"
version = "0.1.0"
version = "0.1.2"
dependencies = [
"argon2",
"base32",
@@ -461,7 +461,7 @@ dependencies = [
[[package]]
name = "da-discord"
version = "0.1.0"
version = "0.1.2"
dependencies = [
"axum",
"da-core",
@@ -478,9 +478,10 @@ dependencies = [
[[package]]
name = "da-http-api"
version = "0.1.0"
version = "0.1.2"
dependencies = [
"axum",
"base64 0.22.1",
"da-core",
"da-discord",
"da-render",
@@ -499,7 +500,7 @@ dependencies = [
[[package]]
name = "da-render"
version = "0.1.0"
version = "0.1.2"
dependencies = [
"image",
"resvg",
@@ -511,7 +512,7 @@ dependencies = [
[[package]]
name = "da-webui"
version = "0.1.0"
version = "0.1.2"
[[package]]
name = "data-url"
@@ -541,7 +542,7 @@ dependencies = [
[[package]]
name = "discord-authenticator"
version = "0.1.0"
version = "0.1.2"
dependencies = [
"anyhow",
"axum",
+1 -1
View File
@@ -10,7 +10,7 @@ members = [
]
[workspace.package]
version = "0.1.0"
version = "0.1.2"
edition = "2021"
rust-version = "1.90"
license = "MIT OR Apache-2.0"
+60 -9
View File
@@ -1,7 +1,13 @@
# Discord Authenticator
<p align="center">
<img src="docs/brand/icon-256.png" width="128" height="128" alt="Discord Authenticator" />
</p>
A self-hosted TOTP authenticator whose codes appear in Discord as **live
messages that rewrite themselves** as each code rolls.
<h1 align="center">Discord Authenticator</h1>
<p align="center">
A self-hosted TOTP authenticator whose codes appear in Discord as
<strong>live messages that rewrite themselves</strong> 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.
@@ -169,20 +175,57 @@ Settings.
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, not one — it's 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 shown, and an **Add N accounts** button.
Details worth knowing:
- **Large exports are split across several QR codes.** Google shows them as
separate screens. Each one reports its position, so the UI tells you
"Part 1 of 2" and prompts you to upload the rest. Import them in any order.
- **Re-importing is safe.** Anything already in your vault is detected and
pre-unticked, so scanning an export twice adds nothing. Duplicates are
matched 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 if you get them backwards: the protobuf `secret` field is **raw
bytes**, not base32, and the payload carries **no period field** — every Google
export is 30-second TOTP.
The parser is deliberately forgiving of real-world URIs: it accepts lowercase
and `=`-padded secrets, and where the issuer in the path disagrees with the
`?issuer=` parameter — which Microsoft does — it takes the query parameter
rather than refusing the account.
**In the list**, click any code to copy it. `/` focuses the search box. The
gear on a row renames the account or pauses its Discord push; pausing removes
its tile from the channel.
**In the list**, click any code to copy it. `/` focuses the search box.
The pencil on a row opens **Edit account**. The *Name* is what shows in the
list and on the Discord tile; the *Account* field underneath (usually the
username or email the code belongs to) is left alone unless you change it —
it's editable, since providers sometimes put something unhelpful there. The
same sheet pauses Discord push for that account, or removes it entirely.
A rename reaches Discord within a second or two. It doesn't wait for the code
to roll: the push loop normally skips any account whose current code it has
already sent, so renaming clears that marker to force one refresh.
---
@@ -218,6 +261,7 @@ want the traffic minimal, Embed is the frugal choice.
```
crates/
core/ TOTP, vault, crypto, settings, admin auth — no network
(incl. Google Authenticator export parsing)
render/ SVG → WebP tile rendering (resvg + embedded fonts)
discord/ Webhook client, rate limiter, live-tile push engine
webui/ Embedded HTML/CSS/JS, compiled into the binary
@@ -241,14 +285,21 @@ JetBrains Mono. Light and dark both ship.
### Testing
101 tests, including:
126 tests, including:
- The **RFC 6238 test vectors** for SHA-1, SHA-256 and SHA-512.
- Real **QR round-trips** — clean, 1-pixel-per-module, oversized, rotated, JPEG.
- Real **QR round-trips** — clean, 1-pixel-per-module, oversized, rotated, JPEG,
and two codes stacked in one screenshot.
- **Google export decoding** driven from real protobuf wire bytes: multi-account
payloads, batch metadata, every algorithm and digit variant, all four base64
spellings, unknown-field forward compatibility, and truncated input. One test
asserts the raw-bytes secret handling against the RFC 6238 vector, because
that is the mistake that would otherwise ship working-looking wrong codes.
- The **live push cycle against a mock Discord**: that the first call carries
`?wait=true`, that later passes PATCH the same message rather than posting
again, that a deleted tile is reposted, that a dead webhook is surfaced
instead of hammered, and that concurrent rollovers are paced apart.
instead of hammered, that concurrent rollovers are paced apart, and that a
rename refreshes the tile immediately without re-posting it or looping.
```bash
cargo test --workspace
+21 -1
View File
@@ -10,6 +10,7 @@
pub mod auth;
pub mod crypto;
pub mod error;
pub mod migration;
pub mod settings;
pub mod totp;
pub mod vault;
@@ -17,9 +18,28 @@ 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_webhook_url, PushMode, Settings, SettingsStore};
pub use totp::{decode_secret, encode_secret, parse_otpauth, Algorithm, OtpAuth, Totp};
pub use vault::{Account, AccountPublic, DiscordState, PushTarget, Vault};
pub use vault::{
Account, AccountPublic, DiscordState, 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.
///
+733
View File
@@ -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"
);
}
}
}
+5 -3
View File
@@ -178,10 +178,12 @@ pub fn parse_otpauth(uri: &str) -> Result<OtpAuth> {
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, which contains several \
accounts at once. Export them individually, or add each account's \
own QR code.",
"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://"));
+266
View File
@@ -61,6 +61,24 @@ 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 {
@@ -95,6 +113,25 @@ impl From<&Account> for AccountPublic {
}
}
/// 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 {
@@ -317,16 +354,20 @@ impl Vault {
.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 {
@@ -337,6 +378,17 @@ impl Vault {
}
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.discord.last_code = None;
}
AccountPublic::from(&*a)
};
self.persist();
@@ -421,6 +473,91 @@ impl Vault {
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(),
discord: DiscordState::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: "da::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(),
@@ -588,6 +725,135 @@ mod tests {
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.
+81
View File
@@ -385,6 +385,87 @@ async fn a_dead_webhook_is_surfaced_and_not_hammered() {
assert!(vault.push_targets()[0].message_id.is_some());
}
#[tokio::test]
async fn renaming_an_account_refreshes_its_tile_without_waiting_for_the_code() {
let dir = tempfile::tempdir().unwrap();
let (url, mock) = spawn_mock().await;
// A 30-second period: the code will NOT roll during this test, so any
// update we see must have been triggered by the rename itself.
let (vault, settings) = fixture(dir.path(), &url, "embed", 30);
let engine = PushEngine::new(vault.clone(), settings);
engine.sync_once().await;
let message_id = vault.push_targets()[0].message_id.clone().unwrap();
assert_eq!(mock.log.lock().unwrap().len(), 1);
// Nothing changed yet, so a second pass must stay silent.
engine.sync_once().await;
assert_eq!(
mock.log.lock().unwrap().len(),
1,
"an idle pass must cost nothing"
);
// Rename, then run one pass — no sleeping, no code rollover.
let id = vault.list()[0].id.clone();
vault
.update(&id, Some("Cloudflare Work"), Some("[email protected]"), None)
.unwrap();
assert_eq!(
engine.sync_once().await,
1,
"the rename should trigger a push"
);
{
let log = mock.log.lock().unwrap();
assert_eq!(log.len(), 2);
assert_eq!(
log[1].method, "PATCH",
"the same tile is edited, not replaced"
);
assert!(log[1].path.ends_with(&format!("/messages/{message_id}")));
// The edited message must carry the new naming.
let body = log[1].json();
assert_eq!(body["embeds"][0]["title"], "Cloudflare Work");
let desc = body["embeds"][0]["description"].as_str().unwrap();
assert!(
desc.contains("[email protected]"),
"new label missing: {desc}"
);
assert!(
!desc.contains("[email protected]"),
"old label still present: {desc}"
);
}
// And it settles: a further pass sends nothing more.
engine.sync_once().await;
assert_eq!(
mock.log.lock().unwrap().len(),
2,
"rename must not re-push forever"
);
}
#[tokio::test]
async fn toggling_push_alone_does_not_rewrite_the_tile() {
let dir = tempfile::tempdir().unwrap();
let (url, mock) = spawn_mock().await;
let (vault, settings) = fixture(dir.path(), &url, "embed", 30);
let engine = PushEngine::new(vault.clone(), settings);
engine.sync_once().await;
let id = vault.list()[0].id.clone();
// Re-affirming the same push state is not a rename and changes nothing
// the tile displays, so it should not cost a request.
vault.update(&id, None, None, Some(true)).unwrap();
engine.sync_once().await;
assert_eq!(mock.log.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn pausing_an_account_removes_its_tile() {
let dir = tempfile::tempdir().unwrap();
+1
View File
@@ -29,3 +29,4 @@ 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 da-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()
);
}
+111 -40
View File
@@ -24,8 +24,9 @@ pub fn router(state: AppState) -> Router {
.route("/api/login", post(auth::api_login))
.route("/api/logout", post(auth::api_logout))
.route("/api/credentials", post(auth::api_update_credentials))
.route("/api/accounts", get(list_accounts).post(add_account))
.route("/api/accounts", 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),
@@ -114,27 +115,59 @@ struct PreviewBody {
uri: String,
}
/// Parse an `otpauth://` URI and report what it contains, without storing it.
/// Describe what a link contains, without storing anything.
///
/// Lets the "add account" flow show the issuer, algorithm and digits for
/// confirmation before anything touches the vault.
async fn preview_account(Json(body): Json<PreviewBody>) -> Response {
match da_core::parse_otpauth(&body.uri) {
Ok(a) => Json(json!({
"issuer": a.issuer,
"label": a.label,
"algorithm": a.totp.algorithm.as_str(),
"digits": a.totp.digits,
"period": a.totp.period,
}))
.into_response(),
Err(e) => crate::error_response(&e),
}
/// 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_core::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_core::is_migration_uri(&body.uri),
})),
)
.into_response()
}
#[derive(Debug, Deserialize)]
struct AddBody {
uri: String,
struct ImportItem {
/// Index into the preview's `accounts` array.
index: usize,
#[serde(default)]
issuer: Option<String>,
#[serde(default)]
@@ -143,25 +176,44 @@ struct AddBody {
push_enabled: bool,
}
fn default_true() -> bool {
true
#[derive(Debug, Deserialize)]
struct ImportBody {
uri: String,
items: Vec<ImportItem>,
}
async fn add_account(State(state): State<AppState>, Json(body): Json<AddBody>) -> Response {
let parsed = match da_core::parse_otpauth(&body.uri) {
Ok(p) => p,
/// 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_core::parse_import(&body.uri) {
Ok(b) => b,
Err(e) => return crate::error_response(&e),
};
match state.vault.add(
&parsed,
body.issuer.as_deref(),
body.label.as_deref(),
body.push_enabled,
) {
// The push loop picks the new account up on its next tick and posts
// the tile; we deliberately don't kick a sync from here, so there is
// only ever one pass in flight.
Ok(a) => (StatusCode::CREATED, Json(a)).into_response(),
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_core::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),
}
}
@@ -207,6 +259,10 @@ async fn delete_account(State(state): State<AppState>, Path(id): Path<String>) -
}
}
fn default_true() -> bool {
true
}
// ── Codes ─────────────────────────────────────────────────────────────────
/// Current and next code for every account, plus the server clock.
@@ -275,14 +331,29 @@ async fn decode_qr(mut multipart: Multipart) -> Response {
match crate::qr::decode(&bytes) {
Ok(found) => {
// An image can hold several codes; prefer an otpauth one over
// whatever else happens to be in frame (a Wi-Fi QR, a URL).
let best = found
// 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()
.find(|s| s.to_ascii_lowercase().starts_with("otpauth"))
.or_else(|| found.first());
match best {
Some(uri) => Json(json!({ "uri": uri })).into_response(),
.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"),
}
}
+98
View File
@@ -282,6 +282,104 @@ mod tests {
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 = da_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());
+15
View File
@@ -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 da-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 = da_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());
}
}
+35
View File
@@ -75,6 +75,41 @@ pub fn tile_png(tile: &Tile<'_>) -> Result<Vec<u8>> {
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())?;
+81 -3
View File
@@ -160,15 +160,39 @@ a:hover { text-decoration: underline; }
.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: flex; align-items: center; gap: 12px;
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 "Discord Authenticator",
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; }
@@ -192,8 +216,9 @@ a:hover { text-decoration: underline; }
.search {
position: relative;
flex: 1 1 auto;
width: 100%;
max-width: 420px;
justify-self: center;
}
.search svg {
position: absolute; left: 10px; top: 50%; transform: translateY(-50%);
@@ -483,7 +508,7 @@ textarea.input { height: auto; min-height: 76px; padding: 9px 11px;
.fab {
position: fixed;
right: max(20px, calc(50vw - 344px));
right: 24px;
bottom: 24px;
z-index: 25;
display: inline-flex; align-items: center; gap: 8px;
@@ -607,6 +632,59 @@ textarea.input { height: auto; min-height: 76px; padding: 9px 11px;
0 0 0 9999px oklch(0% 0 0 / .45);
}
/* ── 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 {
+230 -45
View File
@@ -89,6 +89,7 @@ const ICON = {
back: 'M19 12H5m7 7-7-7 7-7',
key: ['M12.7 11.3a5 5 0 1 0-4 4L10 14h2v-2h2l-1.3-1.3Z'],
send: ['M22 2 11 13', 'M22 2l-7 20-4-9-9-4 20-7z'],
edit: ['M12 20h9', 'M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z'],
};
// ── State ──────────────────────────────────────────────────────────────────
@@ -349,27 +350,31 @@ function renderShell() {
const logoImg = h('img', { src: '/assets/logo.svg', alt: '', width: 26, height: 26 });
// Three columns so the search sits on the page's centre line, matching the
// centred account tiles below it, regardless of how wide the brand or the
// action cluster happen to be.
const topbar = h('header.topbar', {}, [
h('div.brand', { style: 'cursor:pointer', onclick: () => go('vault') }, [
logoImg, h('strong', { text: 'Authenticator' }),
logoImg, h('strong', { text: 'Discord Authenticator' }),
]),
searchWrap,
h('div.spacer'),
h('button.icon-btn.theme-toggle', {
type: 'button', 'aria-label': 'Toggle theme', title: 'Toggle theme',
onclick: toggleTheme,
}, [
icon(ICON.sun, { className: 't-sun', width: 2 }),
icon(ICON.moon, { className: 't-moon', width: 2 }),
h('div.actions', {}, [
h('button.icon-btn.theme-toggle', {
type: 'button', 'aria-label': 'Toggle theme', title: 'Toggle theme',
onclick: toggleTheme,
}, [
icon(ICON.sun, { className: 't-sun', width: 2 }),
icon(ICON.moon, { className: 't-moon', width: 2 }),
]),
h('button.icon-btn', {
type: 'button', 'aria-label': 'Settings', title: 'Settings',
onclick: () => go('settings'),
}, [icon(ICON.settings, { width: 1.7 })]),
h('button.icon-btn', {
type: 'button', 'aria-label': 'Sign out', title: 'Sign out',
onclick: async () => { await api('/api/logout', { method: 'POST' }); stopTicker(); boot(); },
}, [icon(ICON.logout, { width: 2 })]),
]),
h('button.icon-btn', {
type: 'button', 'aria-label': 'Settings', title: 'Settings',
onclick: () => go('settings'),
}, [icon(ICON.settings, { width: 1.7 })]),
h('button.icon-btn', {
type: 'button', 'aria-label': 'Sign out', title: 'Sign out',
onclick: async () => { await api('/api/logout', { method: 'POST' }); stopTicker(); boot(); },
}, [icon(ICON.logout, { width: 2 })]),
]);
root().replaceChildren(h('div.shell', {}, [topbar, h('main.main', { id: 'view' })]));
@@ -480,9 +485,9 @@ function acctRow(a) {
codeBtn,
ring,
h('button.icon-btn', {
type: 'button', 'aria-label': `Options for ${a.issuer}`, title: 'Options',
type: 'button', 'aria-label': `Edit ${a.issuer}`, title: 'Edit',
onclick: () => openAccountSheet(a),
}, [icon(ICON.settings, { width: 1.7 })]),
}, [icon(ICON.edit, { width: 1.9 })]),
]),
]);
return row;
@@ -697,7 +702,7 @@ function openAddSheet() {
]));
try {
const res = await api('/api/qr/decode', { method: 'POST', body: fd });
confirmPane(res.uri);
previewUris(res.uris && res.uris.length ? res.uris : [res.uri]);
} catch (e) {
panel.replaceChildren(
h('div.alert.err', {}, [icon(ICON.alert), h('span', { text: e.message })]),
@@ -777,7 +782,7 @@ function openAddSheet() {
} catch (_) { /* transient decode error — keep going */ }
timer = setTimeout(scan, detector ? 180 : 650);
};
const done = (uri) => { stop(); confirmPane(uri); };
const done = (uri) => { stop(); previewUris([uri]); };
scan();
})();
@@ -809,13 +814,13 @@ function openAddSheet() {
onclick: () => {
err.classList.add('hidden');
const uri = ta.value.trim();
if (uri) return confirmPane(uri);
if (uri) return previewUris([uri]);
if (mSecret.value.trim()) {
const q = new URLSearchParams({ secret: mSecret.value.trim().replace(/\s+/g, '') });
if (mIssuer.value.trim()) q.set('issuer', mIssuer.value.trim());
const path = encodeURIComponent(
(mIssuer.value.trim() ? mIssuer.value.trim() + ':' : '') + (mLabel.value.trim() || 'account'));
return confirmPane(`otpauth://totp/${path}?${q}`);
return previewUris([`otpauth://totp/${path}?${q}`]);
}
err.replaceChildren(icon(ICON.alert), h('span', { text: 'Paste an otpauth:// link or fill in the manual fields.' }));
err.classList.remove('hidden');
@@ -824,36 +829,105 @@ function openAddSheet() {
return h('div', {}, [err, h('div.field', {}, [h('label', { text: 'otpauth:// link' }), ta]), manual, go]);
}
/** Preview what we parsed, let the user rename, then commit. */
async function confirmPane(uri) {
/** Fetch a preview for every link found, then show the import list. */
async function previewUris(uris) {
panel.replaceChildren(h('div.dropzone', {}, [
icon(ICON.search, { width: 1.7, className: 'spin' }),
h('strong', { text: 'Checking…' }),
h('strong', { text: 'Reading…' }),
]));
let parsed;
try {
parsed = await api('/api/accounts/preview', { method: 'POST', body: { uri } });
} catch (e) {
const sources = [];
const errors = [];
for (const uri of uris) {
try {
const res = await api('/api/accounts/preview', { method: 'POST', body: { uri } });
sources.push(Object.assign({ uri: uri }, res));
} catch (e) { errors.push(e.message); }
}
if (!sources.length) {
panel.replaceChildren(
h('div.alert.err', {}, [icon(ICON.alert), h('span', { text: e.message })]),
h('div.alert.err', {}, [
icon(ICON.alert),
h('span', { text: errors[0] || 'Nothing importable was found in that.' }),
]),
pastePane(),
);
return;
}
const issuer = h('input.input', { value: parsed.issuer || '' });
const label = h('input.input', { value: parsed.label || '' });
renderImport(sources, errors);
}
/** One account gets the detail form; several get a checklist. */
function renderImport(sources, errors) {
const rows = [];
for (const src of sources) {
for (const acct of src.accounts) rows.push({ src: src, acct: acct });
}
const skipped = sources.reduce((all, s) => all.concat(s.skipped || []), []);
if (!rows.length) {
panel.replaceChildren(
h('div.alert.warn', {}, [
icon(ICON.alert),
h('span', { text: 'No importable accounts in that link.' }),
]),
skipped.length ? skippedList(skipped) : null,
pastePane(),
);
return;
}
if (rows.length === 1) return singleForm(rows[0], skipped);
return multiForm(rows, sources, skipped, errors);
}
function skippedList(skipped) {
return h('div', { style: 'margin-top:14px' }, [
h('div.small.muted', {
style: 'margin-bottom:6px',
text: skipped.length + ' entr' + (skipped.length === 1 ? 'y was' : 'ies were') + ' skipped:',
}),
h('div.stack-s', {}, skipped.map((s) =>
h('div.small.muted', { text: '• ' + s.name + ' — ' + s.reason }))),
]);
}
/** Google splits large exports across several QR codes; say so. */
function partNotice(sources) {
const part = sources.map((s) => s.part).find(Boolean);
if (!part) return null;
return h('div.alert.info', {}, [
icon(ICON.info),
h('span', { text: part + ' of a Google Authenticator export. Import this one, then upload the remaining QR codes to add the rest.' }),
]);
}
/** The familiar single-account confirm screen. */
function singleForm(row, skipped) {
const src = row.src, acct = row.acct;
const issuer = h('input.input', { value: acct.issuer || '' });
const label = h('input.input', { value: acct.label || '' });
const push = h('input', { type: 'checkbox', checked: true });
const save = h('button.btn.btn-primary.btn-block', { style: 'margin-top:4px', text: 'Add account' });
save.addEventListener('click', async () => {
save.disabled = true; save.textContent = 'Adding…';
try {
await api('/api/accounts', {
const out = await api('/api/accounts/import', {
method: 'POST',
body: { uri, issuer: issuer.value.trim(), label: label.value.trim(), push_enabled: push.checked },
body: {
uri: src.uri,
items: [{
index: acct.index,
issuer: issuer.value.trim(),
label: label.value.trim(),
push_enabled: push.checked,
}],
},
});
dlg.close();
toast('Account added');
toast(out.added.length ? 'Account added' : 'That account is already in your vault',
out.added.length ? 'ok' : 'err');
await reloadAll();
} catch (e) {
save.disabled = false; save.textContent = 'Add account';
@@ -862,21 +936,123 @@ function openAddSheet() {
});
panel.replaceChildren(h('div', {}, [
h('div.alert.info', {}, [icon(ICON.check), h('span', { text: 'Found a valid one-time-password secret.' })]),
acct.duplicate
? h('div.alert.warn', {}, [icon(ICON.alert),
h('span', { text: 'This account is already in your vault. Adding it again creates a second copy.' })])
: h('div.alert.info', {}, [icon(ICON.check),
h('span', { text: 'Found a valid one-time-password secret.' })]),
h('div.field', {}, [h('label', { text: 'Issuer' }), issuer]),
h('div.field', {}, [h('label', { text: 'Account' }), label]),
h('dl', {}, [
h('div.kv', {}, [h('dt', { text: 'Algorithm' }), h('dd', { text: parsed.algorithm })]),
h('div.kv', {}, [h('dt', { text: 'Digits' }), h('dd', { text: String(parsed.digits) })]),
h('div.kv', {}, [h('dt', { text: 'Period' }), h('dd', { text: parsed.period + 's' })]),
h('div.kv', {}, [h('dt', { text: 'Algorithm' }), h('dd', { text: acct.algorithm })]),
h('div.kv', {}, [h('dt', { text: 'Digits' }), h('dd', { text: String(acct.digits) })]),
h('div.kv', {}, [h('dt', { text: 'Period' }), h('dd', { text: acct.period + 's' })]),
]),
h('label.switch', { style: 'margin:16px 0' }, [
push, h('span.track'),
h('span.switch-label', { text: 'Stream this code to Discord' }),
]),
save,
skipped.length ? skippedList(skipped) : null,
]));
}
/** Multi-account import — where a Google export lands. */
function multiForm(rows, sources, skipped, errors) {
// Anything already in the vault starts unticked, so re-scanning an export
// you partly imported doesn't quietly create duplicates.
const boxes = rows.map((r) => h('input', { type: 'checkbox', checked: !r.acct.duplicate }));
const push = h('input', { type: 'checkbox', checked: true });
const go = h('button.btn.btn-primary.btn-block');
const selected = () => boxes.reduce((n, b) => n + (b.checked ? 1 : 0), 0);
const sync = () => {
const n = selected();
go.textContent = n ? 'Add ' + n + ' account' + (n === 1 ? '' : 's') : 'Select accounts to add';
go.disabled = n === 0;
};
boxes.forEach((b) => b.addEventListener('change', sync));
const list = h('div.import-list', {}, rows.map((r, i) => {
const av = h('div.avatar', { text: initials(r.acct.issuer || r.acct.label) });
av.setAttribute('style', avatarStyle(r.acct.issuer || r.acct.label || String(i)));
return h('label.import-row', {}, [
boxes[i],
av,
h('div.meta', {}, [
h('div.issuer', { text: r.acct.issuer || 'Unnamed' }),
h('div.label', { text: r.acct.label || '—' }),
]),
h('div.tags', {}, [
r.acct.duplicate ? h('span.chip.warn', { text: 'already added' }) : null,
r.acct.digits !== 6 ? h('span.chip', { text: r.acct.digits + ' digits' }) : null,
r.acct.algorithm !== 'SHA1' ? h('span.chip', { text: r.acct.algorithm }) : null,
r.acct.period !== 30 ? h('span.chip', { text: r.acct.period + 's' }) : null,
]),
]);
}));
const setAll = (v) => { boxes.forEach((b) => { b.checked = v; }); sync(); };
go.addEventListener('click', async () => {
go.disabled = true;
const original = go.textContent;
go.textContent = 'Adding…';
// Group by source link: each import call re-parses exactly one link
// server-side, so secrets never travel back out to the browser.
const bySource = new Map();
rows.forEach((r, i) => {
if (!boxes[i].checked) return;
if (!bySource.has(r.src.uri)) bySource.set(r.src.uri, []);
bySource.get(r.src.uri).push({ index: r.acct.index, push_enabled: push.checked });
});
let added = 0, dupes = 0;
try {
for (const entry of bySource) {
const out = await api('/api/accounts/import', {
method: 'POST', body: { uri: entry[0], items: entry[1] },
});
added += out.added.length;
dupes += out.duplicates.length;
}
dlg.close();
toast('Added ' + added + ' account' + (added === 1 ? '' : 's') +
(dupes ? ' · ' + dupes + ' already present' : ''));
await reloadAll();
} catch (e) {
go.disabled = false; go.textContent = original;
toast(e.message, 'err');
}
});
const dupeCount = rows.filter((r) => r.acct.duplicate).length;
panel.replaceChildren(h('div', {}, [
partNotice(sources),
h('div.alert.info', {}, [icon(ICON.check),
h('span', { text: 'Found ' + rows.length + ' accounts.' +
(dupeCount ? ' ' + dupeCount + ' already in your vault ' + (dupeCount === 1 ? 'is' : 'are') + ' unticked.' : '') })]),
errors && errors.length
? h('div.alert.warn', {}, [icon(ICON.alert),
h('span', { text: errors.length + ' code in that image could not be read.' })])
: null,
h('div.import-head', {}, [
h('span.small.muted', { text: 'Select accounts to import' }),
h('div', {}, [
h('button.btn.btn-ghost.btn-sm', { type: 'button', onclick: () => setAll(true), text: 'All' }),
h('button.btn.btn-ghost.btn-sm', { type: 'button', onclick: () => setAll(false), text: 'None' }),
]),
]),
list,
h('label.switch', { style: 'margin:16px 0' }, [
push, h('span.track'),
h('span.switch-label', { text: 'Stream these codes to Discord' }),
]),
go,
skipped.length ? skippedList(skipped) : null,
]));
sync();
}
}
// ── Per-account sheet ──────────────────────────────────────────────────────
@@ -886,15 +1062,21 @@ function openAccountSheet(a) {
const label = h('input.input', { value: a.label || '' });
const push = h('input', { type: 'checkbox', checked: !!a.push_enabled });
const dlg = openModal(a.issuer || 'Account', [
const dlg = openModal('Edit account', [
a.push_error ? h('div.alert.err', {}, [icon(ICON.alert), h('span', { text: a.push_error })]) : null,
h('div.field', {}, [h('label', { text: 'Issuer' }), issuer]),
h('div.field', {}, [h('label', { text: 'Account' }), label]),
h('div.field', {}, [
h('label', { text: 'Name' }), issuer,
h('div.hint', { text: 'What this account is called — shown in the list and on the Discord tile.' }),
]),
h('div.field', {}, [
h('label', { text: 'Account' }), label,
h('div.hint', { text: 'Usually the username or email the code belongs to. Left as-is unless you change it.' }),
]),
h('label.switch', { style: 'margin:16px 0 4px' }, [
push, h('span.track'),
h('span.switch-label', { text: 'Stream this code to Discord' }),
]),
h('p.hint', { text: 'When on, this account keeps one live message in your Discord channel, edited in place every time the code rolls.' }),
h('p.hint', { text: 'When on, this account keeps one live message in your Discord channel, edited in place every time the code rolls. Renaming updates that message within a second or two.' }),
], [
h('button.btn.btn-danger', { onclick: async () => {
if (!confirm(`Remove ${a.issuer}? Its secret is deleted permanently and its Discord message is removed.`)) return;
@@ -904,14 +1086,17 @@ function openAccountSheet(a) {
} catch (e) { toast(e.message, 'err'); }
} }, [icon(ICON.trash, { width: 1.9 }), 'Remove']),
h('div', { style: 'flex:1' }),
h('button.btn', { onclick: dlg.close, text: 'Cancel' }),
// Deferred: `dlg` is still in its temporal dead zone while these
// arguments are being built, so `onclick: dlg.close` throws and the whole
// sheet fails to open. Every other handler here is already a closure.
h('button.btn', { onclick: () => dlg.close(), text: 'Cancel' }),
h('button.btn.btn-primary', { onclick: async () => {
try {
await api('/api/accounts/' + encodeURIComponent(a.id), {
method: 'PATCH',
body: { issuer: issuer.value.trim(), label: label.value.trim(), push_enabled: push.checked },
});
dlg.close(); toast('Saved'); await reloadAll();
dlg.close(); toast('Saved — the Discord tile updates shortly'); await reloadAll();
} catch (e) { toast(e.message, 'err'); }
}, text: 'Save' }),
]);
+6 -3
View File
@@ -24,8 +24,11 @@
Self-hosted TOTP authenticator with live Discord delivery.
Add accounts by uploading a QR code, scanning with the camera, or pasting
an otpauth:// link. The web UI lists every account with its rotating code,
the way Google or Microsoft Authenticator does.
an otpauth:// link. Google Authenticator exports are supported too: one
export QR carries many accounts, and you get a checklist to pick which to
import, with duplicates detected automatically. The web UI lists every
account with its rotating code, the way Google or Microsoft Authenticator
does.
The distinctive part: each account keeps ONE message in your Discord
channel, delivered through an incoming webhook and rewritten in place
@@ -42,7 +45,7 @@
<Category>Tools:Utilities Security:</Category>
<WebUI>http://[IP]:[PORT:8710]/</WebUI>
<TemplateURL/>
<Icon>https://gitea.milesward.dev/mward4/Discord-Authenticator/raw/branch/main/crates/webui/src/logo.svg</Icon>
<Icon>https://gitea.milesward.dev/mward4/Discord-Authenticator/raw/branch/main/docs/brand/icon-256.png</Icon>
<ExtraParams/>
<PostArgs/>
<CPUset/>
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

+31
View File
@@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512"
role="img" aria-label="Discord Authenticator">
<!-- App icon: the shield-and-arc mark on the product's own dark surface.
Colours are literal rather than currentColor so this renders correctly
as a favicon, a Gitea repo avatar and an Unraid template icon, none of
which supply 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="#161a22"/>
</linearGradient>
<linearGradient id="mark" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#6ea3ff"/>
<stop offset="1" stop-color="#3d78e8"/>
</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.09" stroke-width="2"/>
<g transform="translate(96 96) scale(10)">
<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="url(#mark)" fill-opacity=".18"/>
<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="none" stroke="url(#mark)" stroke-width="1.9" stroke-linejoin="round"/>
<path d="M20.9 12.6a5.9 5.9 0 1 0 1 3.3"
fill="none" stroke="url(#mark)" stroke-width="2.1" stroke-linecap="round"/>
<circle cx="16" cy="15.9" r="1.85" fill="url(#mark)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB