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:
1 parent
f33effe20a
commit
eecdacd890
14 files changed
+1599
-76
No files matched your search
Generated
+7
-6
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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://"));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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 ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user