Files
Miles WardandClaude Opus 4.8 eecdacd890 v0.1.1: import Google Authenticator exports
Google's "Transfer accounts -> Export accounts" produces an
otpauth-migration:// link wrapping a base64 protobuf that holds many accounts
at once, and splits large exports across several QR codes. v0.1 rejected these
outright; now they import.

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

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

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

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

124 tests (was 101).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-08-31 00:44:59 -04:00

84 lines
2.5 KiB
Rust

//! 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()
);
}