From eecdacd890f7d0b8cdd758e714d843bfe6d4a896 Mon Sep 17 00:00:00 2001 From: Miles Ward Date: Mon, 31 Aug 2026 00:44:59 -0400 Subject: [PATCH] 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 --- Cargo.lock | 13 +- Cargo.toml | 2 +- README.md | 40 +- crates/core/src/lib.rs | 22 +- crates/core/src/migration.rs | 733 ++++++++++++++++++ crates/core/src/totp.rs | 8 +- crates/core/src/vault.rs | 251 ++++++ crates/http-api/Cargo.toml | 1 + .../http-api/examples/make-google-export.rs | 83 ++ crates/http-api/src/app.rs | 151 +++- crates/http-api/src/qr.rs | 98 +++ crates/webui/src/app.css | 53 ++ crates/webui/src/app.js | 213 ++++- deploy/unraid/discord-authenticator.xml | 7 +- 14 files changed, 1599 insertions(+), 76 deletions(-) create mode 100644 crates/core/src/migration.rs create mode 100644 crates/http-api/examples/make-google-export.rs diff --git a/Cargo.lock b/Cargo.lock index 7b50d89..2269916 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -436,7 +436,7 @@ dependencies = [ [[package]] name = "da-core" -version = "0.1.0" +version = "0.1.1" dependencies = [ "argon2", "base32", @@ -461,7 +461,7 @@ dependencies = [ [[package]] name = "da-discord" -version = "0.1.0" +version = "0.1.1" dependencies = [ "axum", "da-core", @@ -478,9 +478,10 @@ dependencies = [ [[package]] name = "da-http-api" -version = "0.1.0" +version = "0.1.1" 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.1" dependencies = [ "image", "resvg", @@ -511,7 +512,7 @@ dependencies = [ [[package]] name = "da-webui" -version = "0.1.0" +version = "0.1.1" [[package]] name = "data-url" @@ -541,7 +542,7 @@ dependencies = [ [[package]] name = "discord-authenticator" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml index 790bc60..e0d37ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ members = [ ] [workspace.package] -version = "0.1.0" +version = "0.1.1" edition = "2021" rust-version = "1.90" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index 9d85e25..880626f 100644 --- a/README.md +++ b/README.md @@ -169,12 +169,41 @@ 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 @@ -218,6 +247,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,10 +271,16 @@ JetBrains Mono. Light and dark both ship. ### Testing -101 tests, including: +124 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 diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 291d983..0cd638e 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -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 { + if is_migration_uri(uri) { + return parse_migration(uri); + } + Ok(ImportBatch { + accounts: vec![parse_otpauth(uri)?], + ..ImportBatch::default() + }) +} /// Seconds since the Unix epoch. /// diff --git a/crates/core/src/migration.rs b/crates/core/src/migration.rs new file mode 100644 index 0000000..b260906 --- /dev/null +++ b/crates/core/src/migration.rs @@ -0,0 +1,733 @@ +//! Google Authenticator export parsing (`otpauth-migration://offline?data=…`). +//! +//! The payload is a base64 protobuf holding many accounts at once. Google +//! splits large exports across several QR codes, each carrying `batch_index` +//! and `batch_size` so the user knows how many screens to scan. +//! +//! ## Why the protobuf is hand-decoded +//! +//! Pulling `prost` would mean either a `protoc` build dependency or a +//! generated file checked in, for a schema that is four messages long and has +//! not changed since 2020. The wire format we need is two wire types (varint +//! and length-delimited), so the reader below is short enough to audit. +//! Unknown fields are skipped properly, so a future Google addition won't +//! break parsing. +//! +//! Schema (Google Authenticator 5.10, reverse-engineered but stable): +//! +//! ```text +//! MigrationPayload { otp_parameters=1 (repeated), version=2, +//! batch_size=3, batch_index=4, batch_id=5 } +//! OtpParameters { secret=1 (bytes), name=2, issuer=3, +//! algorithm=4, digits=5, type=6, counter=7 } +//! Algorithm { UNSPECIFIED=0, SHA1=1, SHA256=2, SHA512=3, MD5=4 } +//! DigitCount { UNSPECIFIED=0, SIX=1, EIGHT=2 } +//! OtpType { UNSPECIFIED=0, HOTP=1, TOTP=2 } +//! ``` +//! +//! Two traps worth naming, because both silently produce wrong codes: +//! +//! * **`secret` is raw bytes, not base32.** It has to be base32-encoded to +//! become the secret an `otpauth://` URI would carry. +//! * **There is no period field.** Google only ever exports 30-second TOTP, +//! so 30 is assumed rather than guessed at. + +use crate::totp::{Algorithm, OtpAuth, Totp}; +use crate::{Error, Result}; + +/// Google never exports a period; every entry is the 30-second default. +const ASSUMED_PERIOD: u64 = 30; + +/// An account present in the export that we deliberately did not import. +#[derive(Debug, Clone)] +pub struct Skipped { + pub name: String, + pub reason: String, +} + +/// One decoded export QR code. +#[derive(Debug, Clone, Default)] +pub struct ImportBatch { + pub accounts: Vec, + pub skipped: Vec, + /// 0-based index of this QR within the export, when present. + pub batch_index: Option, + /// Total number of QR codes in the export, when present. + pub batch_size: Option, + /// Identifies which export a QR belongs to, so codes from two different + /// export sessions aren't mistaken for parts of one. + pub batch_id: Option, +} + +impl ImportBatch { + /// Human-readable "Part 2 of 3", when the export was split. + pub fn part_label(&self) -> Option { + match (self.batch_index, self.batch_size) { + (Some(i), Some(n)) if n > 1 => Some(format!("Part {} of {n}", i + 1)), + _ => None, + } + } +} + +/// Is this a Google Authenticator export link? +pub fn is_migration_uri(uri: &str) -> bool { + uri.trim() + .to_ascii_lowercase() + .starts_with("otpauth-migration://") +} + +/// Parse an `otpauth-migration://offline?data=…` link into its accounts. +pub fn parse_migration(uri: &str) -> Result { + let uri = uri.trim(); + let parsed = url::Url::parse(uri) + .map_err(|e| Error::invalid(format!("not a valid export link: {e}")))?; + + if !parsed.scheme().eq_ignore_ascii_case("otpauth-migration") { + return Err(Error::invalid( + "that is not a Google Authenticator export link", + )); + } + + // `url` percent-decodes query values for us. Google's base64 contains + // `+` and `/`, which arrive percent-encoded in a well-formed URI. + let data = parsed + .query_pairs() + .find(|(k, _)| k.eq_ignore_ascii_case("data")) + .map(|(_, v)| v.into_owned()) + .ok_or_else(|| Error::invalid("the export link has no ?data= parameter"))?; + + if data.trim().is_empty() { + return Err(Error::invalid("the export link's data parameter is empty")); + } + + let raw = decode_base64_lenient(&data)?; + decode_payload(&raw) +} + +/// Decode base64 across the variants that turn up in the wild. +/// +/// Google emits standard-alphabet padded base64, but the link is often +/// copied through tools that swap in the URL-safe alphabet or strip padding. +/// Trying all four costs nothing and avoids an unhelpful failure on a link +/// that is perfectly recoverable. +fn decode_base64_lenient(s: &str) -> Result> { + use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD}; + use base64::Engine; + + // Whitespace can be introduced by line-wrapping when a link is pasted. + let cleaned: String = s.chars().filter(|c| !c.is_whitespace()).collect(); + + // A macro rather than a slice of trait objects: base64's `Engine` is not + // dyn-compatible, and the alternatives are all zero-sized consts anyway. + macro_rules! try_decode { + ($engine:expr) => { + if let Ok(bytes) = $engine.decode(&cleaned) { + if !bytes.is_empty() { + return Ok(bytes); + } + } + }; + } + try_decode!(STANDARD); + try_decode!(STANDARD_NO_PAD); + try_decode!(URL_SAFE); + try_decode!(URL_SAFE_NO_PAD); + Err(Error::invalid( + "the export link's data is not valid base64 — copy the whole link, \ + including everything after data=", + )) +} + +// ── Minimal protobuf reader ─────────────────────────────────────────────── + +struct Reader<'a> { + buf: &'a [u8], + pos: usize, +} + +impl<'a> Reader<'a> { + fn new(buf: &'a [u8]) -> Self { + Self { buf, pos: 0 } + } + + fn done(&self) -> bool { + self.pos >= self.buf.len() + } + + fn varint(&mut self) -> Option { + let mut value: u64 = 0; + let mut shift = 0u32; + loop { + let byte = *self.buf.get(self.pos)?; + self.pos += 1; + // 10 groups of 7 bits is the most a u64 can hold; more than that + // is a malformed (or hostile) message rather than a big number. + if shift > 63 { + return None; + } + value |= u64::from(byte & 0x7f) << shift; + if byte & 0x80 == 0 { + return Some(value); + } + shift += 7; + } + } + + /// Returns `(field_number, wire_type)`. + fn tag(&mut self) -> Option<(u32, u8)> { + let key = self.varint()?; + let field = u32::try_from(key >> 3).ok()?; + let wire = (key & 0x07) as u8; + if field == 0 { + return None; // field 0 is illegal; treat as corruption + } + Some((field, wire)) + } + + fn length_delimited(&mut self) -> Option<&'a [u8]> { + let len = usize::try_from(self.varint()?).ok()?; + let end = self.pos.checked_add(len)?; + let slice = self.buf.get(self.pos..end)?; + self.pos = end; + Some(slice) + } + + /// Advance past a field we don't care about, so unknown additions to the + /// schema don't desynchronise the parse. + fn skip(&mut self, wire: u8) -> Option<()> { + match wire { + 0 => { + self.varint()?; + } + 1 => self.pos = self.pos.checked_add(8)?, + 2 => { + self.length_delimited()?; + } + 5 => self.pos = self.pos.checked_add(4)?, + // 3/4 are the deprecated start/end-group types, which this schema + // never uses. Anything else is corruption. + _ => return None, + } + if self.pos > self.buf.len() { + return None; + } + Some(()) + } +} + +fn corrupt() -> Error { + Error::invalid( + "the export data could not be decoded — re-export from Google \ + Authenticator and scan the fresh QR code", + ) +} + +fn decode_payload(raw: &[u8]) -> Result { + let mut out = ImportBatch::default(); + let mut r = Reader::new(raw); + + while !r.done() { + let (field, wire) = r.tag().ok_or_else(corrupt)?; + match (field, wire) { + (1, 2) => { + let entry = r.length_delimited().ok_or_else(corrupt)?; + match decode_entry(entry) { + Ok(Some(account)) => out.accounts.push(account), + Ok(None) => {} + Err(skipped) => out.skipped.push(skipped), + } + } + (3, 0) => out.batch_size = r.varint().ok_or_else(corrupt)?.try_into().ok(), + (4, 0) => out.batch_index = r.varint().ok_or_else(corrupt)?.try_into().ok(), + (5, 0) => out.batch_id = r.varint().ok_or_else(corrupt)?.try_into().ok(), + // version (2) and anything Google adds later. + _ => r.skip(wire).ok_or_else(corrupt)?, + } + } + + if out.accounts.is_empty() && out.skipped.is_empty() { + return Err(Error::invalid("that export link contains no accounts")); + } + Ok(out) +} + +/// Decode one `OtpParameters`. `Err(Skipped)` means "present but not +/// importable", which the UI reports rather than silently dropping. +fn decode_entry(buf: &[u8]) -> std::result::Result, Skipped> { + let mut secret: Vec = Vec::new(); + let mut name = String::new(); + let mut issuer = String::new(); + let mut algorithm = Algorithm::Sha1; + let mut digits: u8 = 6; + let mut otp_type: u64 = 2; // default to TOTP when unspecified + let mut bad_algorithm: Option = None; + + let mut r = Reader::new(buf); + while !r.done() { + let Some((field, wire)) = r.tag() else { break }; + match (field, wire) { + (1, 2) => secret = r.length_delimited().unwrap_or_default().to_vec(), + (2, 2) => { + name = + String::from_utf8_lossy(r.length_delimited().unwrap_or_default()).into_owned(); + } + (3, 2) => { + issuer = + String::from_utf8_lossy(r.length_delimited().unwrap_or_default()).into_owned(); + } + (4, 0) => match r.varint().unwrap_or(1) { + 0 | 1 => algorithm = Algorithm::Sha1, // unspecified means SHA1 + 2 => algorithm = Algorithm::Sha256, + 3 => algorithm = Algorithm::Sha512, + other => bad_algorithm = Some(other), // MD5 and anything new + }, + (5, 0) => { + digits = match r.varint().unwrap_or(1) { + 2 => 8, + _ => 6, // unspecified and SIX both mean 6 + }; + } + (6, 0) => otp_type = r.varint().unwrap_or(2), + _ => { + if r.skip(wire).is_none() { + break; + } + } + } + } + + // Split "Issuer:account", the same shape an otpauth:// label uses. The + // dedicated issuer field wins when both are present. + let (label_issuer, account) = match name.split_once(':') { + Some((i, a)) => (Some(i.trim().to_string()), a.trim().to_string()), + None => (None, name.trim().to_string()), + }; + let issuer_final = if issuer.trim().is_empty() { + label_issuer.unwrap_or_default() + } else { + issuer.trim().to_string() + }; + let display = if account.is_empty() && issuer_final.is_empty() { + "(unnamed)".to_string() + } else if account.is_empty() { + issuer_final.clone() + } else { + account.clone() + }; + + if otp_type == 1 { + return Err(Skipped { + name: display, + reason: "counter-based (HOTP) codes aren't supported".into(), + }); + } + if let Some(alg) = bad_algorithm { + let which = if alg == 4 { + "MD5" + } else { + "an unknown algorithm" + }; + return Err(Skipped { + name: display, + reason: format!("uses {which}, which isn't supported"), + }); + } + if secret.is_empty() { + return Err(Skipped { + name: display, + reason: "has no secret".into(), + }); + } + + // The protobuf carries raw secret bytes; Totp expects the same, so no + // base32 round-trip is needed here (only for display/export). + match Totp::new(secret, algorithm, digits, ASSUMED_PERIOD) { + Ok(totp) => Ok(Some(OtpAuth { + totp, + issuer: if issuer_final.is_empty() { + account.clone() + } else { + issuer_final + }, + label: account, + })), + Err(e) => Err(Skipped { + name: display, + reason: e.to_string(), + }), + } +} + +// ── Tests ───────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::totp::encode_secret; + + // Minimal protobuf *encoder*, so these tests drive the parser with real + // wire bytes rather than a mock. + + fn put_varint(v: u64, out: &mut Vec) { + let mut v = v; + loop { + let mut byte = (v & 0x7f) as u8; + v >>= 7; + if v != 0 { + byte |= 0x80; + } + out.push(byte); + if v == 0 { + break; + } + } + } + + fn put_tag(field: u32, wire: u8, out: &mut Vec) { + put_varint((u64::from(field) << 3) | u64::from(wire), out); + } + + fn put_bytes(field: u32, bytes: &[u8], out: &mut Vec) { + put_tag(field, 2, out); + put_varint(bytes.len() as u64, out); + out.extend_from_slice(bytes); + } + + fn put_uint(field: u32, v: u64, out: &mut Vec) { + put_tag(field, 0, out); + put_varint(v, out); + } + + #[derive(Clone)] + struct Entry { + secret: Vec, + name: &'static str, + issuer: &'static str, + algorithm: u64, + digits: u64, + otp_type: u64, + } + + impl Default for Entry { + fn default() -> Self { + Self { + secret: b"12345678901234567890".to_vec(), + name: "me@example.com", + issuer: "Cloudflare", + algorithm: 1, // SHA1 + digits: 1, // SIX + otp_type: 2, // TOTP + } + } + } + + fn encode_entry(e: &Entry) -> Vec { + let mut b = Vec::new(); + put_bytes(1, &e.secret, &mut b); + put_bytes(2, e.name.as_bytes(), &mut b); + put_bytes(3, e.issuer.as_bytes(), &mut b); + put_uint(4, e.algorithm, &mut b); + put_uint(5, e.digits, &mut b); + put_uint(6, e.otp_type, &mut b); + b + } + + fn encode_payload(entries: &[Entry], batch: Option<(i32, i32, i32)>) -> Vec { + let mut b = Vec::new(); + for e in entries { + put_bytes(1, &encode_entry(e), &mut b); + } + put_uint(2, 1, &mut b); // version + if let Some((size, index, id)) = batch { + put_uint(3, size as u64, &mut b); + put_uint(4, index as u64, &mut b); + put_uint(5, id as u64, &mut b); + } + b + } + + /// Build a link the way Google does: standard base64, percent-encoded + /// into the query string. + fn make_uri(payload: &[u8]) -> String { + use base64::Engine; + let b64 = base64::engine::general_purpose::STANDARD.encode(payload); + let escaped = b64 + .replace('+', "%2B") + .replace('/', "%2F") + .replace('=', "%3D"); + format!("otpauth-migration://offline?data={escaped}") + } + + fn uri_for(entries: &[Entry]) -> String { + make_uri(&encode_payload(entries, None)) + } + + #[test] + fn detects_migration_links() { + assert!(is_migration_uri("otpauth-migration://offline?data=abc")); + assert!(is_migration_uri("OTPAUTH-MIGRATION://offline?data=abc")); + assert!(!is_migration_uri("otpauth://totp/X?secret=A")); + } + + #[test] + fn imports_a_single_account() { + let b = parse_migration(&uri_for(&[Entry::default()])).unwrap(); + assert_eq!(b.accounts.len(), 1); + assert!(b.skipped.is_empty()); + let a = &b.accounts[0]; + assert_eq!(a.issuer, "Cloudflare"); + assert_eq!(a.label, "me@example.com"); + assert_eq!(a.totp.digits, 6); + assert_eq!(a.totp.period, 30, "Google exports are always 30s"); + assert_eq!(a.totp.algorithm, Algorithm::Sha1); + } + + #[test] + fn secret_is_raw_bytes_not_base32() { + // The single most dangerous trap: treating the protobuf secret as if + // it were already base32 yields plausible-looking but wrong codes. + // The RFC 6238 vector secret is ASCII "12345678901234567890". + let b = parse_migration(&uri_for(&[Entry::default()])).unwrap(); + let totp = &b.accounts[0].totp; + assert_eq!(totp.secret, b"12345678901234567890"); + // And it base32-encodes to the well-known test value. + assert_eq!( + encode_secret(&totp.secret), + "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" + ); + // Which means it produces the official RFC 6238 code at t=59. + assert_eq!(totp.code_at(59), "287082"); + } + + #[test] + fn imports_several_accounts_from_one_qr() { + let entries = vec![ + Entry { + name: "a@example.com", + issuer: "Cloudflare", + ..Entry::default() + }, + Entry { + name: "b@example.com", + issuer: "Plex", + ..Entry::default() + }, + Entry { + name: "c@example.com", + issuer: "Ubisoft", + ..Entry::default() + }, + ]; + let b = parse_migration(&uri_for(&entries)).unwrap(); + assert_eq!(b.accounts.len(), 3); + let issuers: Vec<_> = b.accounts.iter().map(|a| a.issuer.as_str()).collect(); + assert_eq!(issuers, ["Cloudflare", "Plex", "Ubisoft"]); + } + + #[test] + fn reports_batch_position() { + let uri = make_uri(&encode_payload(&[Entry::default()], Some((3, 1, 42)))); + let b = parse_migration(&uri).unwrap(); + assert_eq!(b.batch_size, Some(3)); + assert_eq!(b.batch_index, Some(1)); + assert_eq!(b.batch_id, Some(42)); + // batch_index is 0-based; humans count from one. + assert_eq!(b.part_label().as_deref(), Some("Part 2 of 3")); + + // A single-QR export shouldn't claim to be part of a series. + let solo = parse_migration(&uri_for(&[Entry::default()])).unwrap(); + assert!(solo.part_label().is_none()); + } + + #[test] + fn honours_algorithm_and_digit_variants() { + for (code, want) in [(2, Algorithm::Sha256), (3, Algorithm::Sha512)] { + let e = Entry { + algorithm: code, + ..Entry::default() + }; + let b = parse_migration(&uri_for(&[e])).unwrap(); + assert_eq!(b.accounts[0].totp.algorithm, want); + } + let e = Entry { + digits: 2, + ..Entry::default() + }; // EIGHT + let b = parse_migration(&uri_for(&[e])).unwrap(); + assert_eq!(b.accounts[0].totp.digits, 8); + } + + #[test] + fn unspecified_enums_fall_back_to_the_common_defaults() { + let e = Entry { + algorithm: 0, + digits: 0, + otp_type: 0, + ..Entry::default() + }; + let b = parse_migration(&uri_for(&[e])).unwrap(); + assert_eq!(b.accounts[0].totp.algorithm, Algorithm::Sha1); + assert_eq!(b.accounts[0].totp.digits, 6); + } + + #[test] + fn skips_unsupported_entries_but_keeps_the_rest() { + let entries = vec![ + Entry { + name: "good@example.com", + ..Entry::default() + }, + Entry { + name: "counter@example.com", + otp_type: 1, + ..Entry::default() + }, // HOTP + Entry { + name: "md5@example.com", + algorithm: 4, + ..Entry::default() + }, // MD5 + Entry { + name: "empty@example.com", + secret: vec![], + ..Entry::default() + }, + ]; + let b = parse_migration(&uri_for(&entries)).unwrap(); + + // One good account survives; the other three are reported, not dropped. + assert_eq!(b.accounts.len(), 1); + assert_eq!(b.accounts[0].label, "good@example.com"); + assert_eq!(b.skipped.len(), 3); + + let reason = |n: &str| { + b.skipped + .iter() + .find(|s| s.name == n) + .map(|s| s.reason.clone()) + .unwrap() + }; + assert!(reason("counter@example.com").contains("HOTP")); + assert!(reason("md5@example.com").contains("MD5")); + assert!(reason("empty@example.com").contains("no secret")); + } + + #[test] + fn name_may_carry_the_issuer_prefix() { + let e = Entry { + name: "Cloudflare:me@example.com", + issuer: "", + ..Entry::default() + }; + let b = parse_migration(&uri_for(&[e])).unwrap(); + assert_eq!(b.accounts[0].issuer, "Cloudflare"); + assert_eq!(b.accounts[0].label, "me@example.com"); + } + + #[test] + fn issuer_field_wins_over_the_name_prefix() { + let e = Entry { + name: "Stale:me@example.com", + issuer: "Microsoft", + ..Entry::default() + }; + let b = parse_migration(&uri_for(&[e])).unwrap(); + assert_eq!(b.accounts[0].issuer, "Microsoft"); + assert_eq!(b.accounts[0].label, "me@example.com"); + } + + #[test] + fn accepts_every_base64_variant() { + use base64::engine::general_purpose::{ + STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD, + }; + use base64::Engine; + // A payload whose base64 is guaranteed to contain +/ (or -_). + let entries: Vec = (0..4) + .map(|i| Entry { + secret: vec![0xfb, 0xff, 0xbf, i], + ..Entry::default() + }) + .collect(); + let raw = encode_payload(&entries, None); + + for (name, encoded) in [ + ("standard", STANDARD.encode(&raw)), + ("standard-nopad", STANDARD_NO_PAD.encode(&raw)), + ("urlsafe", URL_SAFE.encode(&raw)), + ("urlsafe-nopad", URL_SAFE_NO_PAD.encode(&raw)), + ] { + let escaped = encoded + .replace('+', "%2B") + .replace('/', "%2F") + .replace('=', "%3D"); + let uri = format!("otpauth-migration://offline?data={escaped}"); + let b = parse_migration(&uri).unwrap_or_else(|e| panic!("{name} should decode: {e}")); + assert_eq!(b.accounts.len(), 4, "{name}"); + } + } + + #[test] + fn unknown_fields_do_not_break_parsing() { + // Forward compatibility: if Google adds a field, we must skip it + // rather than desynchronise and mangle everything after it. + let mut entry = encode_entry(&Entry::default()); + put_bytes(99, b"some future string", &mut entry); + put_uint(98, 1234, &mut entry); + + let mut payload = Vec::new(); + put_bytes(1, &entry, &mut payload); + put_bytes(77, b"future payload field", &mut payload); + put_uint(76, 5, &mut payload); + + let b = parse_migration(&make_uri(&payload)).unwrap(); + assert_eq!(b.accounts.len(), 1); + assert_eq!(b.accounts[0].issuer, "Cloudflare"); + } + + #[test] + fn rejects_malformed_input_clearly() { + assert!(parse_migration("otpauth-migration://offline").is_err()); + assert!(parse_migration("otpauth-migration://offline?data=").is_err()); + assert!(parse_migration("otpauth-migration://offline?data=!!!not base64!!!").is_err()); + assert!(parse_migration("otpauth://totp/X?secret=JBSWY3DPEHPK3PXP").is_err()); + assert!(parse_migration("nonsense").is_err()); + + // Truncated protobuf: a length prefix that runs past the buffer. + let uri = make_uri(&[0x0a, 0x7f, 0x01, 0x02]); + assert!(parse_migration(&uri).is_err()); + } + + #[test] + fn an_export_with_no_accounts_is_an_error() { + // Version field only, no entries. + let mut payload = Vec::new(); + put_uint(2, 1, &mut payload); + let e = parse_migration(&make_uri(&payload)) + .unwrap_err() + .to_string(); + assert!(e.contains("no accounts"), "got: {e}"); + } + + #[test] + fn a_large_export_round_trips() { + // Google splits at roughly ten per QR; make sure nothing chokes on a + // full batch, and that every secret survives intact. + let entries: Vec = (0..10) + .map(|i| Entry { + secret: vec![i; 20], + name: "user@example.com", + ..Entry::default() + }) + .collect(); + let b = parse_migration(&uri_for(&entries)).unwrap(); + assert_eq!(b.accounts.len(), 10); + for (i, a) in b.accounts.iter().enumerate() { + assert_eq!( + a.totp.secret, + vec![i as u8; 20], + "entry {i} secret corrupted" + ); + } + } +} diff --git a/crates/core/src/totp.rs b/crates/core/src/totp.rs index a85e582..061610d 100644 --- a/crates/core/src/totp.rs +++ b/crates/core/src/totp.rs @@ -178,10 +178,12 @@ pub fn parse_otpauth(uri: &str) -> Result { 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://")); diff --git a/crates/core/src/vault.rs b/crates/core/src/vault.rs index 1c63bf4..4c80d32 100644 --- a/crates/core/src/vault.rs +++ b/crates/core/src/vault.rs @@ -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, + pub label: Option, + pub push_enabled: bool, +} + +/// Result of a bulk import. +#[derive(Debug, Clone, Default, Serialize)] +pub struct ImportOutcome { + pub added: Vec, + /// Display names of entries already in the vault, skipped rather than + /// duplicated. Re-importing the same Google export is a normal thing to + /// do — it should be a no-op, not ten more rows. + pub duplicates: Vec, +} + /// An account plus its decrypted TOTP parameters, for the push loop. #[derive(Debug, Clone)] pub struct PushTarget { @@ -421,6 +458,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 { + let mut outcome = ImportOutcome::default(); + let mut staged: Vec = Vec::new(); + + for item in items { + let issuer = pick(item.issuer.as_deref(), &item.parsed.issuer); + let label = pick(item.label.as_deref(), &item.parsed.label); + + if issuer.len() > 128 || label.len() > 128 { + return Err(Error::invalid( + "issuer and account must be 128 characters or fewer", + )); + } + + // Check against what's stored *and* what this batch has already + // staged, so an export containing the same account twice doesn't + // slip a duplicate through. + let dup_in_vault = self.contains(&item.parsed.totp, &issuer, &label); + let dup_in_batch = staged + .iter() + .any(|a| a.issuer == issuer && a.label == label); + if dup_in_vault || dup_in_batch { + outcome.duplicates.push(display_name(&issuer, &label)); + continue; + } + + staged.push(Account { + id: uuid::Uuid::new_v4().simple().to_string(), + issuer, + label, + algorithm: item.parsed.totp.algorithm, + digits: item.parsed.totp.digits, + period: item.parsed.totp.period, + secret: self.key.seal(&item.parsed.totp.secret)?, + push_enabled: item.push_enabled, + created_at: OffsetDateTime::now_utc(), + 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 +710,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}:me@example.com?secret=JBSWY3DPEHPK3PXP&issuer={i}" + ), + true, + ) + }) + .collect(); + + let out = v.add_many(&items).unwrap(); + assert_eq!(out.added.len(), 3); + assert!(out.duplicates.is_empty()); + assert_eq!(v.len(), 3); + + // Re-importing the same export must be a no-op, not three more rows. + let again = v.add_many(&items).unwrap(); + assert!(again.added.is_empty()); + assert_eq!(again.duplicates.len(), 3); + assert_eq!(v.len(), 3, "vault must not grow on a repeat import"); + assert!(again.duplicates[0].contains("Cloudflare")); + } + + #[test] + fn duplicates_within_one_batch_are_collapsed() { + let dir = tempdir().unwrap(); + let v = vault(dir.path()); + let one = newacct( + "otpauth://totp/Plex:me@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Plex", + true, + ); + let out = v.add_many(&[one.clone(), one]).unwrap(); + assert_eq!(out.added.len(), 1); + assert_eq!(out.duplicates.len(), 1); + assert_eq!(v.len(), 1); + } + + #[test] + fn the_same_secret_under_a_different_name_is_not_a_duplicate() { + let dir = tempdir().unwrap(); + let v = vault(dir.path()); + let base = + parse_otpauth("otpauth://totp/Plex:me@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Plex") + .unwrap(); + v.add_many(&[NewAccount { + parsed: base.clone(), + issuer: None, + label: None, + push_enabled: true, + }]) + .unwrap(); + + // Deliberately holding one secret under a second label stays allowed. + let out = v + .add_many(&[NewAccount { + parsed: base, + issuer: Some("Plex (work)".into()), + label: None, + push_enabled: true, + }]) + .unwrap(); + assert_eq!(out.added.len(), 1); + assert_eq!(v.len(), 2); + } + + #[test] + fn contains_matches_on_secret_and_naming() { + let dir = tempdir().unwrap(); + let v = vault(dir.path()); + let a = + parse_otpauth("otpauth://totp/Plex:me@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Plex") + .unwrap(); + assert!(!v.contains(&a.totp, "Plex", "me@example.com")); + v.add(&a, None, None, true).unwrap(); + assert!(v.contains(&a.totp, "Plex", "me@example.com")); + assert!(!v.contains(&a.totp, "Plex", "other@example.com")); + + // A different secret under the same name is not a match. + let b = + parse_otpauth("otpauth://totp/Plex:me@example.com?secret=GEZDGNBVGY3TQOJQ&issuer=Plex") + .unwrap(); + assert!(!v.contains(&b.totp, "Plex", "me@example.com")); + } + + #[test] + fn bulk_import_persists_once_and_survives_reload() { + let dir = tempdir().unwrap(); + { + let v = vault(dir.path()); + let items: Vec<_> = (0..5) + .map(|i| { + newacct( + &format!("otpauth://totp/Svc{i}:me@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Svc{i}"), + i % 2 == 0, + ) + }) + .collect(); + assert_eq!(v.add_many(&items).unwrap().added.len(), 5); + } + let reloaded = vault(dir.path()); + assert_eq!(reloaded.len(), 5); + // The per-account push flag must survive the round trip. + assert_eq!(reloaded.push_targets().len(), 3); + } + + #[test] + fn an_empty_import_is_harmless() { + let dir = tempdir().unwrap(); + let v = vault(dir.path()); + let out = v.add_many(&[]).unwrap(); + assert!(out.added.is_empty() && out.duplicates.is_empty()); + assert!(v.is_empty()); + } + #[test] fn message_ids_are_persisted_as_strings() { // Snowflakes exceed 2^53; a JSON number would round-trip lossily. diff --git a/crates/http-api/Cargo.toml b/crates/http-api/Cargo.toml index 195707b..91aad92 100644 --- a/crates/http-api/Cargo.toml +++ b/crates/http-api/Cargo.toml @@ -29,3 +29,4 @@ workspace = true [dev-dependencies] qrcode = { version = "0.14", default-features = false, features = ["image"] } +base64.workspace = true diff --git a/crates/http-api/examples/make-google-export.rs b/crates/http-api/examples/make-google-export.rs new file mode 100644 index 0000000..c1d8dab --- /dev/null +++ b/crates/http-api/examples/make-google-export.rs @@ -0,0 +1,83 @@ +//! Emit a Google Authenticator export QR for manual testing: +//! `cargo run -p da-http-api --example make-google-export -- out.png 8` +fn put_varint(v: u64, out: &mut Vec) { + let mut v = v; + loop { + let mut b = (v & 0x7f) as u8; + v >>= 7; + if v != 0 { + b |= 0x80; + } + out.push(b); + if v == 0 { + break; + } + } +} +fn put_bytes(field: u32, bytes: &[u8], out: &mut Vec) { + put_varint((u64::from(field) << 3) | 2, out); + put_varint(bytes.len() as u64, out); + out.extend_from_slice(bytes); +} +fn put_uint(field: u32, v: u64, out: &mut Vec) { + put_varint(u64::from(field) << 3, out); + put_varint(v, out); +} + +fn main() { + let mut a = std::env::args().skip(1); + let out = a + .next() + .expect("usage: make-google-export [count]"); + let n: usize = a.next().unwrap_or_else(|| "6".into()).parse().unwrap(); + + let names = [ + "Cloudflare", + "Plex", + "Ubisoft", + "GitHub", + "Steam", + "Proton", + "Fastmail", + "Namecheap", + "Vercel", + "Backblaze", + ]; + let mut payload = Vec::new(); + for i in 0..n { + let mut e = Vec::new(); + put_bytes(1, &[0x41 + i as u8; 20], &mut e); + put_bytes(2, b"miles@milesward.dev", &mut e); + put_bytes(3, names[i % names.len()].as_bytes(), &mut e); + put_uint(4, 1, &mut e); + // Give one entry 8 digits so the UI's variant chips get exercised. + put_uint(5, if i == 2 { 2 } else { 1 }, &mut e); + // And make one HOTP so the "skipped" path shows up. + put_uint(6, if i == 4 { 1 } else { 2 }, &mut e); + put_bytes(1, &e, &mut payload); + } + put_uint(2, 1, &mut payload); + put_uint(3, 2, &mut payload); // batch_size + put_uint(4, 0, &mut payload); // batch_index + put_uint(5, 77, &mut payload); + + use base64::Engine; + let b64 = base64::engine::general_purpose::STANDARD.encode(&payload); + let escaped = b64 + .replace('+', "%2B") + .replace('/', "%2F") + .replace('=', "%3D"); + let uri = format!("otpauth-migration://offline?data={escaped}"); + + let code = qrcode::QrCode::new(uri.as_bytes()).unwrap(); + let img = code + .render::>() + .module_dimensions(6, 6) + .quiet_zone(true) + .build(); + image::DynamicImage::ImageLuma8(img).save(&out).unwrap(); + println!( + "wrote {out} ({n} entries, {} bytes of payload)", + payload.len() + ); +} diff --git a/crates/http-api/src/app.rs b/crates/http-api/src/app.rs index 81b7a6b..23deef5 100644 --- a/crates/http-api/src/app.rs +++ b/crates/http-api/src/app.rs @@ -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) -> 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, Json(body): Json) -> 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, #[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, } -async fn add_account(State(state): State, Json(body): Json) -> 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, Json(body): Json) -> 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, Path(id): Path) - } } +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 = 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"), } } diff --git a/crates/http-api/src/qr.rs b/crates/http-api/src/qr.rs index 260530f..4ed21c8 100644 --- a/crates/http-api/src/qr.rs +++ b/crates/http-api/src/qr.rs @@ -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 { + fn put_varint(v: u64, out: &mut Vec) { + let mut v = v; + loop { + let mut b = (v & 0x7f) as u8; + v >>= 7; + if v != 0 { + b |= 0x80; + } + out.push(b); + if v == 0 { + break; + } + } + } + fn put_bytes(field: u32, bytes: &[u8], out: &mut Vec) { + put_varint((u64::from(field) << 3) | 2, out); + put_varint(bytes.len() as u64, out); + out.extend_from_slice(bytes); + } + fn put_uint(field: u32, v: u64, out: &mut Vec) { + put_varint(u64::from(field) << 3, out); + put_varint(v, out); + } + + let mut payload = Vec::new(); + for i in 0..n { + let mut e = Vec::new(); + put_bytes(1, &[0x41 + i as u8; 20], &mut e); // secret + put_bytes(2, format!("user{i}@example.com").as_bytes(), &mut e); + put_bytes(3, format!("Service{i}").as_bytes(), &mut e); + put_uint(4, 1, &mut e); // SHA1 + put_uint(5, 1, &mut e); // 6 digits + put_uint(6, 2, &mut e); // TOTP + put_bytes(1, &e, &mut payload); + } + put_uint(2, 1, &mut payload); + + use base64::Engine; + let b64 = base64::engine::general_purpose::STANDARD.encode(&payload); + let escaped = b64 + .replace('+', "%2B") + .replace('/', "%2F") + .replace('=', "%3D"); + qr_png( + &format!("otpauth-migration://offline?data={escaped}"), + module_px, + ) + } + + #[test] + fn decodes_a_google_export_qr_end_to_end() { + let png = google_export_png(5, 4); + let found = decode(&png).unwrap(); + assert_eq!(found.len(), 1, "one QR, one payload"); + assert!(found[0].starts_with("otpauth-migration://")); + + // And that payload really does carry five importable accounts. + let batch = 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, "user4@example.com"); + } + + #[test] + fn decodes_several_qr_codes_from_one_screenshot() { + // A screenshot of a multi-part export can contain more than one code. + // Stacking two here proves `decode` returns both rather than the + // first, which is what lets the UI import every account in one pass. + let a = image::load_from_memory(&qr_png(URI, 5)).unwrap().to_luma8(); + let b = image::load_from_memory(&google_export_png(2, 5)) + .unwrap() + .to_luma8(); + + let w = a.width().max(b.width()); + let mut canvas = + GrayImage::from_pixel(w + 40, a.height() + b.height() + 60, image::Luma([255])); + image::imageops::replace(&mut canvas, &a, 20, 20); + image::imageops::replace(&mut canvas, &b, 20, i64::from(a.height()) + 40); + + let mut buf = std::io::Cursor::new(Vec::new()); + DynamicImage::ImageLuma8(canvas) + .write_to(&mut buf, image::ImageFormat::Png) + .unwrap(); + + let found = decode(&buf.into_inner()).unwrap(); + assert_eq!( + found.len(), + 2, + "both codes should be returned, got {found:?}" + ); + assert!(found.iter().any(|u| u.starts_with("otpauth://"))); + assert!(found.iter().any(|u| u.starts_with("otpauth-migration://"))); + } + #[test] fn empty_input_is_rejected() { assert!(decode(&[]).is_err()); diff --git a/crates/webui/src/app.css b/crates/webui/src/app.css index 53eb344..b518f50 100644 --- a/crates/webui/src/app.css +++ b/crates/webui/src/app.css @@ -607,6 +607,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 { diff --git a/crates/webui/src/app.js b/crates/webui/src/app.js index 5a046e7..f2fe995 100644 --- a/crates/webui/src/app.js +++ b/crates/webui/src/app.js @@ -697,7 +697,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 +777,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 +809,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 +824,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 +931,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 ────────────────────────────────────────────────────── diff --git a/deploy/unraid/discord-authenticator.xml b/deploy/unraid/discord-authenticator.xml index a0d7954..513a717 100644 --- a/deploy/unraid/discord-authenticator.xml +++ b/deploy/unraid/discord-authenticator.xml @@ -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