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]>
This commit is contained in:
Miles WardandClaude Opus 4.8 committed 2026-08-31 00:44:59 -04:00
1 parent f33effe20a
commit eecdacd890
14 files changed
+1599 -76

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.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",
+1 -1
View File
@@ -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"
+38 -2
View File
@@ -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
+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://"));
+251
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 {
@@ -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<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 +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}:[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.
+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());
+53
View File
@@ -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 {
+192 -21
View File
@@ -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 ──────────────────────────────────────────────────────
+5 -2
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