Skip to main content

vault_core/
totp_import.rs

1//! Reading and writing the export files other authenticator apps produce.
2//!
3//! Phase 22 gave the vault somewhere to keep a third-party TOTP seed. This
4//! module is how a seed gets in and out without being retyped from a phone
5//! screen — Ente Auth, Aegis, 2FAS, andOTP, Bitwarden and Google Authenticator
6//! on the way in; a portable `otpauth://` list, Aegis or 2FAS on the way out.
7//!
8//! # It lives here, and only here
9//!
10//! Six formats parsed twice is six chances to disagree, and a disagreement here
11//! is a seed that imports into the app and not the CLI — or worse, imports with
12//! the wrong `period` and produces six digits the issuer rejects. So this is
13//! Rust only: the CLI calls it directly, the desktop app calls it over IPC
14//! (`totp_import_parse` / `totp_export_build`), and there is no TypeScript twin
15//! to pin. That is the shape `pools.ts` already uses and the one CLAUDE.md's
16//! twin-pair table says to prefer over a golden fixture.
17//!
18//! # Encrypted exports are refused, never decrypted
19//!
20//! Every app here can export encrypted, and each uses its own KDF and envelope.
21//! Implementing six of those would mean this crate holding six password-guessing
22//! paths whose failures are indistinguishable from a corrupt file. [`detect`]
23//! recognises each encrypted shape and returns a message naming the app and
24//! saying to export again without encryption. Refusing with a reason beats
25//! failing to parse with none.
26//!
27//! # What is deliberately not read
28//!
29//! `otpauth://hotp/` and every counter-based entry in every format. HOTP has no
30//! clock, so "the current code" does not exist for it: importing one produces an
31//! entry whose code never changes and never works. They are counted and named in
32//! the report rather than silently dropped.
33
34use crate::totp::{self, Params, Stored};
35use base64::Engine;
36use serde::{Deserialize, Serialize};
37use serde_json::Value;
38
39/// An export format this module can read, write, or both.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42pub enum Format {
43    /// One `otpauth://` URI per line. What Ente Auth's plain-text export is,
44    /// and what KeePassXC, Raivo, Proton Pass and most others accept back.
45    OtpauthList,
46    /// Aegis Authenticator's unencrypted JSON vault.
47    Aegis,
48    /// 2FAS Auth's unencrypted JSON backup.
49    TwoFas,
50    /// andOTP's unencrypted JSON export (also read by FreeOTP+).
51    AndOtp,
52    /// A Bitwarden JSON export — reads `login.totp` off each item.
53    Bitwarden,
54    /// Google Authenticator's `otpauth-migration://offline?data=…` QR payload.
55    GoogleMigration,
56}
57
58impl Format {
59    /// The name a human typed, or that a report prints.
60    pub fn as_str(self) -> &'static str {
61        match self {
62            Format::OtpauthList => "otpauth",
63            Format::Aegis => "aegis",
64            Format::TwoFas => "2fas",
65            Format::AndOtp => "andotp",
66            Format::Bitwarden => "bitwarden",
67            Format::GoogleMigration => "google",
68        }
69    }
70
71    /// Parses a `--format` value.
72    ///
73    /// `ente` is an alias for `otpauth`, not a format of its own: Ente Auth's
74    /// plain export *is* a list of `otpauth://` URIs, and its import accepts the
75    /// same. The alias exists because somebody with an Ente export will type
76    /// `--format ente`, and being told "unknown format" when the thing works is
77    /// a bad answer.
78    pub fn parse(raw: &str) -> Option<Self> {
79        match raw
80            .trim()
81            .to_ascii_lowercase()
82            .replace(['-', '_'], "")
83            .as_str()
84        {
85            "otpauth" | "uri" | "uris" | "txt" | "text" | "ente" | "keepassxc" | "raivo" => {
86                Some(Format::OtpauthList)
87            }
88            "aegis" => Some(Format::Aegis),
89            "2fas" | "twofas" => Some(Format::TwoFas),
90            "andotp" | "freeotp" | "freeotpplus" => Some(Format::AndOtp),
91            "bitwarden" => Some(Format::Bitwarden),
92            "google" | "googleauthenticator" | "gauth" | "migration" => {
93                Some(Format::GoogleMigration)
94            }
95            _ => None,
96        }
97    }
98
99    /// The formats `--format` accepts on export.
100    ///
101    /// Read-only formats are absent on purpose. Bitwarden's export is a whole
102    /// password vault and writing one containing nothing but TOTP seeds would
103    /// produce a file that imports as a set of empty logins; Google's migration
104    /// payload is a QR code this app cannot draw (see Phase 19's decision about
105    /// the CSP), so writing the URI behind it would be a format nothing reads.
106    pub const EXPORTABLE: [Format; 3] = [Format::OtpauthList, Format::Aegis, Format::TwoFas];
107
108    /// Whether [`build`] can write this format.
109    pub fn is_exportable(self) -> bool {
110        Format::EXPORTABLE.contains(&self)
111    }
112}
113
114/// One seed read out of somebody else's export.
115///
116/// `issuer` and `account` are what the file said. They are *offered* to the
117/// caller — the CLI turns them into a provider name and an account for a new
118/// entry — and never overwrite an existing entry's own names, for the reason in
119/// [`crate::totp`]: renaming an entry is what every `${ref}` addresses it by.
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct Imported {
122    pub issuer: Option<String>,
123    pub account: Option<String>,
124    #[serde(flatten)]
125    pub stored: Stored,
126    /// A note or tag the source app carried, when it had one.
127    pub note: Option<String>,
128}
129
130impl Imported {
131    /// The name an entry made from this should carry.
132    ///
133    /// Issuer first because that is what a user calls the service; the account
134    /// alone is usually an email address, and a vault full of entries named
135    /// `me@example.com` is a vault you cannot search.
136    pub fn suggested_provider(&self) -> String {
137        self.issuer
138            .as_deref()
139            .map(str::trim)
140            .filter(|s| !s.is_empty())
141            .or_else(|| {
142                self.account
143                    .as_deref()
144                    .map(str::trim)
145                    .filter(|s| !s.is_empty())
146            })
147            .unwrap_or("Unnamed")
148            .to_string()
149    }
150}
151
152/// What a parse produced, including what it refused.
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct ParseReport {
155    pub format: Format,
156    pub items: Vec<Imported>,
157    /// Entries that were recognised and could not be imported, each with a
158    /// reason. Reported rather than dropped: a count that does not add up is
159    /// how somebody discovers six months later that one account never came
160    /// across.
161    pub skipped: Vec<Skipped>,
162}
163
164/// One entry the parser declined, and why.
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct Skipped {
167    /// Whatever the file called it. Never the secret.
168    pub name: String,
169    pub reason: String,
170}
171
172// ── Detection ─────────────────────────────────────────────────────────────────
173
174/// Work out which app wrote this file.
175///
176/// Errors name the app and what to do, rather than saying "unrecognised" — the
177/// encrypted variants are the common case for a user who has just pressed
178/// "export" in an app that defaults to encrypting, and they are all detectable.
179pub fn detect(text: &str) -> Result<Format, String> {
180    let trimmed = text.trim_start_matches('\u{feff}').trim();
181    if trimmed.is_empty() {
182        return Err("the file is empty".into());
183    }
184
185    if trimmed.len() >= 18 && trimmed[..18].eq_ignore_ascii_case("otpauth-migration:") {
186        return Ok(Format::GoogleMigration);
187    }
188    if trimmed.len() >= 8 && trimmed[..8].eq_ignore_ascii_case("otpauth:") {
189        return Ok(Format::OtpauthList);
190    }
191
192    if let Ok(v) = serde_json::from_str::<Value>(trimmed) {
193        // Aegis. Encrypted vaults keep `header.slots` populated and store `db`
194        // as a base64 string instead of an object.
195        if v.get("db").is_some() && v.get("header").is_some() {
196            let encrypted = v
197                .pointer("/header/slots")
198                .map(|s| !s.is_null())
199                .unwrap_or(false)
200                || v.get("db").map(|d| d.is_string()).unwrap_or(false);
201            if encrypted {
202                return Err(encrypted_msg(
203                    "Aegis",
204                    "Export again with encryption turned off",
205                ));
206            }
207            return Ok(Format::Aegis);
208        }
209        // 2FAS.
210        if v.get("services").is_some() || v.get("servicesEncrypted").is_some() {
211            let encrypted = v
212                .get("servicesEncrypted")
213                .and_then(|s| s.as_str())
214                .map(|s| !s.is_empty())
215                .unwrap_or(false);
216            if encrypted {
217                return Err(encrypted_msg(
218                    "2FAS",
219                    "Export again without a password set on the backup",
220                ));
221            }
222            return Ok(Format::TwoFas);
223        }
224        // Bitwarden.
225        if v.get("items").is_some() {
226            if v.get("encrypted").and_then(|e| e.as_bool()) == Some(true) {
227                return Err(encrypted_msg(
228                    "Bitwarden",
229                    "Export again as an unencrypted .json",
230                ));
231            }
232            return Ok(Format::Bitwarden);
233        }
234        // andOTP: a bare array of objects carrying `secret`.
235        if v.as_array()
236            .map(|a| a.iter().any(|e| e.get("secret").is_some()))
237            .unwrap_or(false)
238        {
239            return Ok(Format::AndOtp);
240        }
241        return Err(
242            "recognised the file as JSON but not as an authenticator export \
243             (expected Aegis, 2FAS, andOTP or Bitwarden)"
244                .into(),
245        );
246    }
247
248    // A plain-text list is the last guess, because it is the loosest: any file
249    // with an otpauth:// URI somewhere in it qualifies, so it must not shadow a
250    // format that identifies itself.
251    if text.lines().any(|l| {
252        let l = l.trim();
253        l.len() >= 8 && l[..8].eq_ignore_ascii_case("otpauth:")
254    }) {
255        return Ok(Format::OtpauthList);
256    }
257
258    Err(
259        "could not tell which authenticator app wrote this file. Supported: \
260         Ente Auth and any otpauth:// list, Aegis, 2FAS, andOTP, Bitwarden, \
261         Google Authenticator"
262            .into(),
263    )
264}
265
266fn encrypted_msg(app: &str, advice: &str) -> String {
267    format!(
268        "this is an *encrypted* {app} export. UnENVerse will not try to decrypt \
269         another app's vault — {advice}, import it here, then delete the \
270         plaintext file."
271    )
272}
273
274// ── Parsing ───────────────────────────────────────────────────────────────────
275
276/// Read an export, detecting the format.
277pub fn parse(text: &str) -> Result<ParseReport, String> {
278    let format = detect(text)?;
279    parse_as(text, format)
280}
281
282/// Read an export in a format the caller has already decided.
283pub fn parse_as(text: &str, format: Format) -> Result<ParseReport, String> {
284    let text = text.trim_start_matches('\u{feff}');
285    let (items, skipped) = match format {
286        Format::OtpauthList => parse_uri_list(text),
287        Format::Aegis => parse_aegis(text)?,
288        Format::TwoFas => parse_2fas(text)?,
289        Format::AndOtp => parse_andotp(text)?,
290        Format::Bitwarden => parse_bitwarden(text)?,
291        Format::GoogleMigration => parse_google(text)?,
292    };
293    Ok(ParseReport {
294        format,
295        items,
296        skipped,
297    })
298}
299
300/// One `otpauth://` URI per line — Ente Auth's plain export, and the format
301/// every other app will take back.
302///
303/// Blank lines and `#` comments are skipped in silence; anything else that is
304/// not a URI is reported. Ente writes an extra `codeDisplay={…}` parameter
305/// holding its own UI state, which the URI parser ignores along with every other
306/// unknown parameter — that is why it is ignored rather than special-cased.
307fn parse_uri_list(text: &str) -> (Vec<Imported>, Vec<Skipped>) {
308    let mut items = Vec::new();
309    let mut skipped = Vec::new();
310    for (n, raw) in text.lines().enumerate() {
311        let line = raw.trim();
312        if line.is_empty() || line.starts_with('#') || line.starts_with("//") {
313            continue;
314        }
315        match totp::parse_otpauth(line) {
316            Ok(stored) => items.push(Imported {
317                issuer: stored.issuer.clone(),
318                account: stored.account.clone(),
319                stored,
320                note: None,
321            }),
322            Err(e) => skipped.push(Skipped {
323                // The line number, never the line: a malformed URI still holds
324                // the secret, and this string reaches stdout.
325                name: format!("line {}", n + 1),
326                reason: e,
327            }),
328        }
329    }
330    (items, skipped)
331}
332
333/// Build a `Stored` from the pieces every JSON format spells slightly
334/// differently, defaulting whatever the file left out.
335fn stored_from(
336    secret: &str,
337    kind: Option<&str>,
338    algo: Option<&str>,
339    digits: Option<u64>,
340    period: Option<u64>,
341    counter: Option<u64>,
342) -> Result<Stored, String> {
343    // One reader for the four parameter fields, shared with the CLI and the
344    // desktop command — see `Params::from_fields`, which clamps every one of
345    // them and forces the shape Steam fixes.
346    let params = Params::from_fields(kind, algo, digits, period, counter);
347    // Goes through the same validator every other entry point uses, so a seed
348    // an export mangled is refused here rather than stored unusable.
349    let mut stored = totp::parse_seed(secret)?;
350    stored.params = params;
351    Ok(stored)
352}
353
354fn nonempty(v: Option<&str>) -> Option<String> {
355    v.map(str::trim)
356        .filter(|s| !s.is_empty())
357        .map(str::to_string)
358}
359
360fn parse_aegis(text: &str) -> Result<(Vec<Imported>, Vec<Skipped>), String> {
361    let v: Value = serde_json::from_str(text).map_err(|e| format!("not valid JSON: {e}"))?;
362    let entries = v
363        .pointer("/db/entries")
364        .and_then(|e| e.as_array())
365        .ok_or("this Aegis file has no db.entries array")?;
366    let mut items = Vec::new();
367    let mut skipped = Vec::new();
368    for e in entries {
369        let name = nonempty(e.get("name").and_then(|x| x.as_str()));
370        let issuer = nonempty(e.get("issuer").and_then(|x| x.as_str()));
371        let label = issuer
372            .clone()
373            .or_else(|| name.clone())
374            .unwrap_or_else(|| "unnamed".into());
375        // Aegis names all three kinds in `type`, and writes a `steam` entry with
376        // `type: "steam"` rather than through the `encoder` parameter its URI
377        // export uses.
378        let kind = e.get("type").and_then(|x| x.as_str()).unwrap_or("totp");
379        let secret = e
380            .pointer("/info/secret")
381            .and_then(|x| x.as_str())
382            .unwrap_or("");
383        match stored_from(
384            secret,
385            Some(kind),
386            e.pointer("/info/algo").and_then(|x| x.as_str()),
387            e.pointer("/info/digits").and_then(|x| x.as_u64()),
388            e.pointer("/info/period").and_then(|x| x.as_u64()),
389            e.pointer("/info/counter").and_then(|x| x.as_u64()),
390        ) {
391            Ok(stored) => items.push(Imported {
392                issuer,
393                account: name,
394                stored,
395                note: nonempty(e.get("note").and_then(|x| x.as_str())),
396            }),
397            Err(reason) => skipped.push(Skipped {
398                name: label,
399                reason,
400            }),
401        }
402    }
403    Ok((items, skipped))
404}
405
406fn parse_2fas(text: &str) -> Result<(Vec<Imported>, Vec<Skipped>), String> {
407    let v: Value = serde_json::from_str(text).map_err(|e| format!("not valid JSON: {e}"))?;
408    let services = v
409        .get("services")
410        .and_then(|s| s.as_array())
411        .ok_or("this 2FAS file has no services array")?;
412    let mut items = Vec::new();
413    let mut skipped = Vec::new();
414    for s in services {
415        // 2FAS keeps the display name at the top level and the issuer inside
416        // `otp`, and real backups have one or the other empty depending on how
417        // the entry was added.
418        let issuer = nonempty(s.pointer("/otp/issuer").and_then(|x| x.as_str()))
419            .or_else(|| nonempty(s.get("name").and_then(|x| x.as_str())));
420        let account = nonempty(s.pointer("/otp/account").and_then(|x| x.as_str()))
421            .or_else(|| nonempty(s.pointer("/otp/label").and_then(|x| x.as_str())));
422        let label = issuer
423            .clone()
424            .or_else(|| account.clone())
425            .unwrap_or_else(|| "unnamed".into());
426        let kind = s
427            .pointer("/otp/tokenType")
428            .and_then(|x| x.as_str())
429            .unwrap_or("TOTP");
430        match stored_from(
431            s.get("secret").and_then(|x| x.as_str()).unwrap_or(""),
432            Some(kind),
433            s.pointer("/otp/algorithm").and_then(|x| x.as_str()),
434            s.pointer("/otp/digits").and_then(|x| x.as_u64()),
435            s.pointer("/otp/period").and_then(|x| x.as_u64()),
436            s.pointer("/otp/counter").and_then(|x| x.as_u64()),
437        ) {
438            Ok(stored) => items.push(Imported {
439                issuer,
440                account,
441                stored,
442                note: None,
443            }),
444            Err(reason) => skipped.push(Skipped {
445                name: label,
446                reason,
447            }),
448        }
449    }
450    Ok((items, skipped))
451}
452
453fn parse_andotp(text: &str) -> Result<(Vec<Imported>, Vec<Skipped>), String> {
454    let v: Value = serde_json::from_str(text).map_err(|e| format!("not valid JSON: {e}"))?;
455    let arr = v
456        .as_array()
457        .ok_or("an andOTP export is a JSON array of entries")?;
458    let mut items = Vec::new();
459    let mut skipped = Vec::new();
460    for e in arr {
461        let issuer = nonempty(e.get("issuer").and_then(|x| x.as_str()));
462        let account = nonempty(e.get("label").and_then(|x| x.as_str()));
463        let label = issuer
464            .clone()
465            .or_else(|| account.clone())
466            .unwrap_or_else(|| "unnamed".into());
467        let kind = e.get("type").and_then(|x| x.as_str()).unwrap_or("TOTP");
468        match stored_from(
469            e.get("secret").and_then(|x| x.as_str()).unwrap_or(""),
470            Some(kind),
471            e.get("algorithm").and_then(|x| x.as_str()),
472            e.get("digits").and_then(|x| x.as_u64()),
473            e.get("period").and_then(|x| x.as_u64()),
474            e.get("counter").and_then(|x| x.as_u64()),
475        ) {
476            Ok(stored) => items.push(Imported {
477                issuer,
478                account,
479                stored,
480                note: e
481                    .get("tags")
482                    .and_then(|t| t.as_array())
483                    .map(|t| {
484                        t.iter()
485                            .filter_map(|x| x.as_str())
486                            .collect::<Vec<_>>()
487                            .join(", ")
488                    })
489                    .filter(|s| !s.is_empty()),
490            }),
491            Err(reason) => skipped.push(Skipped {
492                name: label,
493                reason,
494            }),
495        }
496    }
497    Ok((items, skipped))
498}
499
500/// Read `login.totp` out of a Bitwarden export.
501///
502/// Bitwarden stores either a bare base32 seed or a whole `otpauth://` URI in
503/// that one field, depending on how the entry was created — which is exactly the
504/// pair `parse_seed` already accepts, so both work with no branch here.
505fn parse_bitwarden(text: &str) -> Result<(Vec<Imported>, Vec<Skipped>), String> {
506    let v: Value = serde_json::from_str(text).map_err(|e| format!("not valid JSON: {e}"))?;
507    let items_json = v
508        .get("items")
509        .and_then(|i| i.as_array())
510        .ok_or("this Bitwarden file has no items array")?;
511    let mut items = Vec::new();
512    let mut skipped = Vec::new();
513    for it in items_json {
514        let raw = it
515            .pointer("/login/totp")
516            .and_then(|x| x.as_str())
517            .unwrap_or("");
518        if raw.trim().is_empty() {
519            // Most items in a password export have no TOTP at all. That is not
520            // a skip worth reporting — it would drown the ones that are.
521            continue;
522        }
523        let name = nonempty(it.get("name").and_then(|x| x.as_str()));
524        let account = nonempty(it.pointer("/login/username").and_then(|x| x.as_str()));
525        let label = name.clone().unwrap_or_else(|| "unnamed".into());
526        match totp::parse_seed(raw) {
527            Ok(stored) => items.push(Imported {
528                // A URI in this field carries its own issuer; the item's name is
529                // the fallback, and is usually the better of the two.
530                issuer: name.or_else(|| stored.issuer.clone()),
531                account: account.or_else(|| stored.account.clone()),
532                stored,
533                note: None,
534            }),
535            Err(reason) => skipped.push(Skipped {
536                name: label,
537                reason,
538            }),
539        }
540    }
541    Ok((items, skipped))
542}
543
544// ── Google Authenticator migration payloads ──────────────────────────────────
545
546/// Read `otpauth-migration://offline?data=…`.
547///
548/// The payload is a protobuf, and it is decoded by hand for the same reason
549/// base32 is: pulling in `prost` and a build-time code generator to read one
550/// message with seven scalar fields costs more than the forty lines below, and
551/// those forty lines are what a reader has to check.
552///
553/// A multi-QR export produces several URIs, one per batch. Pass them one per
554/// line and they accumulate; `batch_index` is not checked, because a user who
555/// scanned three of four codes should get three accounts and be told, not get an
556/// error and none.
557fn parse_google(text: &str) -> Result<(Vec<Imported>, Vec<Skipped>), String> {
558    let mut items = Vec::new();
559    let mut skipped = Vec::new();
560    let mut any = false;
561    for (n, raw) in text.lines().enumerate() {
562        let line = raw.trim();
563        if line.is_empty() || line.starts_with('#') {
564            continue;
565        }
566        if line.len() < 18 || !line[..18].eq_ignore_ascii_case("otpauth-migration:") {
567            skipped.push(Skipped {
568                name: format!("line {}", n + 1),
569                reason: "not an otpauth-migration:// URI".into(),
570            });
571            continue;
572        }
573        any = true;
574        let data = line
575            .split_once("data=")
576            .map(|(_, d)| d.split('&').next().unwrap_or(d))
577            .ok_or("the migration URI carries no data= parameter")?;
578        let decoded = decode_migration_b64(data)?;
579        let (mut got, mut bad) = decode_migration_payload(&decoded)?;
580        items.append(&mut got);
581        skipped.append(&mut bad);
582    }
583    if !any {
584        return Err("no otpauth-migration:// URI in this file".into());
585    }
586    Ok((items, skipped))
587}
588
589/// Percent-decode then base64-decode a migration payload.
590///
591/// Both alphabets are tried: the URI is standard base64 percent-encoded, but
592/// every tool that has ever copied one out of a QR reader writes it URL-safe,
593/// and telling a user their QR code is corrupt because of an alphabet is a poor
594/// answer.
595fn decode_migration_b64(data: &str) -> Result<Vec<u8>, String> {
596    let pct = percent_decode(data);
597    let cleaned: String = pct.chars().filter(|c| !c.is_whitespace()).collect();
598    use base64::engine::general_purpose as b64;
599    [
600        b64::STANDARD.decode(&cleaned),
601        b64::STANDARD_NO_PAD.decode(&cleaned),
602        b64::URL_SAFE.decode(&cleaned),
603        b64::URL_SAFE_NO_PAD.decode(&cleaned),
604    ]
605    .into_iter()
606    .flatten()
607    .next()
608    .ok_or_else(|| "the migration payload is not valid base64".to_string())
609}
610
611fn percent_decode(s: &str) -> String {
612    let bytes = s.as_bytes();
613    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
614    let mut i = 0;
615    while i < bytes.len() {
616        if bytes[i] == b'%' && i + 2 < bytes.len() {
617            if let Some(b) = std::str::from_utf8(&bytes[i + 1..i + 3])
618                .ok()
619                .and_then(|h| u8::from_str_radix(h, 16).ok())
620            {
621                out.push(b);
622                i += 3;
623                continue;
624            }
625        }
626        // `+` is base64's 62nd character *and* a form-encoded space. In a
627        // migration URI it is always the former — the payload is base64 and a
628        // space cannot appear in it — so it is left alone here, unlike in the
629        // otpauth label parser where the opposite is true.
630        out.push(bytes[i]);
631        i += 1;
632    }
633    String::from_utf8_lossy(&out).into_owned()
634}
635
636/// A minimal protobuf reader: just enough for `MigrationPayload`.
637struct Pb<'a> {
638    b: &'a [u8],
639    i: usize,
640}
641
642impl<'a> Pb<'a> {
643    fn new(b: &'a [u8]) -> Self {
644        Pb { b, i: 0 }
645    }
646    fn done(&self) -> bool {
647        self.i >= self.b.len()
648    }
649    /// Base-128 varint. Caps the shift so a malformed payload cannot loop or
650    /// overflow — this parses a file somebody else wrote.
651    fn varint(&mut self) -> Result<u64, String> {
652        let mut out: u64 = 0;
653        let mut shift = 0;
654        loop {
655            if self.i >= self.b.len() {
656                return Err("truncated migration payload".into());
657            }
658            let byte = self.b[self.i];
659            self.i += 1;
660            out |= u64::from(byte & 0x7f) << shift;
661            if byte & 0x80 == 0 {
662                return Ok(out);
663            }
664            shift += 7;
665            if shift > 63 {
666                return Err("malformed varint in migration payload".into());
667            }
668        }
669    }
670    /// Field key, returning `(field number, wire type)`.
671    fn key(&mut self) -> Result<(u64, u8), String> {
672        let k = self.varint()?;
673        Ok((k >> 3, (k & 0x07) as u8))
674    }
675    fn bytes(&mut self) -> Result<&'a [u8], String> {
676        let len = self.varint()? as usize;
677        let end = self
678            .i
679            .checked_add(len)
680            .filter(|e| *e <= self.b.len())
681            .ok_or("truncated length-delimited field")?;
682        let out = &self.b[self.i..end];
683        self.i = end;
684        Ok(out)
685    }
686    /// Step over a field this reader does not care about.
687    fn skip(&mut self, wire: u8) -> Result<(), String> {
688        match wire {
689            0 => {
690                self.varint()?;
691            }
692            1 => self.i = self.i.saturating_add(8).min(self.b.len()),
693            2 => {
694                self.bytes()?;
695            }
696            5 => self.i = self.i.saturating_add(4).min(self.b.len()),
697            _ => return Err(format!("unsupported protobuf wire type {wire}")),
698        }
699        Ok(())
700    }
701}
702
703fn decode_migration_payload(bytes: &[u8]) -> Result<(Vec<Imported>, Vec<Skipped>), String> {
704    let mut items = Vec::new();
705    let mut skipped = Vec::new();
706    let mut p = Pb::new(bytes);
707    while !p.done() {
708        let (field, wire) = p.key()?;
709        // Field 1 is the repeated OtpParameters; 2..=5 are version and batch
710        // bookkeeping we do not need.
711        if field == 1 && wire == 2 {
712            let inner = p.bytes()?;
713            match decode_otp_parameters(inner) {
714                Ok(Some(item)) => items.push(item),
715                Ok(None) => {}
716                Err(s) => skipped.push(s),
717            }
718        } else {
719            p.skip(wire)?;
720        }
721    }
722    Ok((items, skipped))
723}
724
725/// One `OtpParameters` message. `Ok(None)` means "nothing wrong, nothing to
726/// import" — which never happens today but keeps the caller honest if it does.
727fn decode_otp_parameters(bytes: &[u8]) -> Result<Option<Imported>, Skipped> {
728    let mut secret_raw: Vec<u8> = Vec::new();
729    let mut name = String::new();
730    let mut issuer = String::new();
731    let mut algo = 1u64; // 1 = SHA1, and 0 (unspecified) means the same thing.
732    let mut digits_enum = 1u64; // 1 = SIX
733    let mut kind = 2u64; // 2 = TOTP
734    let mut counter = 0u64;
735
736    let mut p = Pb::new(bytes);
737    let named = |n: &str, i: &str| {
738        if !i.is_empty() {
739            i.to_string()
740        } else if !n.is_empty() {
741            n.to_string()
742        } else {
743            "unnamed".to_string()
744        }
745    };
746    while !p.done() {
747        let (field, wire) = p.key().map_err(|e| Skipped {
748            name: named(&name, &issuer),
749            reason: e,
750        })?;
751        let fail = |e: String| Skipped {
752            name: named(&name, &issuer),
753            reason: e,
754        };
755        match (field, wire) {
756            (1, 2) => secret_raw = p.bytes().map_err(fail)?.to_vec(),
757            (2, 2) => name = String::from_utf8_lossy(p.bytes().map_err(fail)?).into_owned(),
758            (3, 2) => issuer = String::from_utf8_lossy(p.bytes().map_err(fail)?).into_owned(),
759            (4, 0) => algo = p.varint().map_err(fail)?,
760            (5, 0) => digits_enum = p.varint().map_err(fail)?,
761            (6, 0) => kind = p.varint().map_err(fail)?,
762            // Field 7 is the counter, present only on a counter-based entry.
763            // Dropping it hands the next device a seed starting from zero — a
764            // second factor that fails until the account is resynced.
765            (7, 0) => counter = p.varint().map_err(fail)?,
766            _ => p.skip(wire).map_err(fail)?,
767        }
768    }
769
770    let label = named(&name, &issuer);
771    if secret_raw.is_empty() {
772        return Err(Skipped {
773            name: label,
774            reason: "the migration entry carries no secret".into(),
775        });
776    }
777
778    // Google stores raw bytes; everything else in this module speaks base32.
779    let secret = totp::base32_encode(&secret_raw);
780    let params = Params {
781        algorithm: match algo {
782            2 => totp::Algorithm::Sha256,
783            3 => totp::Algorithm::Sha512,
784            // 0 (unspecified) and 1 (SHA1) are the same answer. 4 is MD5, which
785            // RFC 6238 does not define and nothing generates; it falls here
786            // rather than being refused, because a wrong algorithm is visible
787            // immediately (the code is rejected) while a lost account is not.
788            _ => totp::Algorithm::Sha1,
789        },
790        digits: if digits_enum == 2 { 8 } else { 6 },
791        // The migration format has no period field at all: Google only ever
792        // exports 30-second entries.
793        period: totp::STEP_SECS,
794        // 1 = HOTP, 2 = TOTP, 0 = unspecified. Google Authenticator has no Steam
795        // support, so there is no third case to read.
796        kind: if kind == 1 {
797            totp::Kind::Hotp
798        } else {
799            totp::Kind::Totp
800        },
801        counter: if kind == 1 { counter } else { 0 },
802    };
803    let stored = Stored {
804        secret,
805        params,
806        issuer: (!issuer.is_empty()).then(|| issuer.clone()),
807        account: (!name.is_empty()).then(|| name.clone()),
808    };
809    Ok(Some(Imported {
810        issuer: stored.issuer.clone(),
811        account: stored.account.clone(),
812        stored,
813        note: None,
814    }))
815}
816
817// ── Building ─────────────────────────────────────────────────────────────────
818
819/// Write the seeds out in a format another app reads.
820///
821/// **Every byte of this is secret material** — the whole point of the file is to
822/// carry seeds to another device. Callers treat it exactly as they treat a
823/// `.vaultbak`: `--out` to a 0600 file, refused to stdout without `--reveal`.
824pub fn build(items: &[Imported], format: Format) -> Result<String, String> {
825    if !format.is_exportable() {
826        return Err(format!(
827            "'{}' can be imported but not written. Export with {} instead.",
828            format.as_str(),
829            Format::EXPORTABLE
830                .iter()
831                .map(|f| f.as_str())
832                .collect::<Vec<_>>()
833                .join(", ")
834        ));
835    }
836    Ok(match format {
837        Format::OtpauthList => build_uri_list(items),
838        Format::Aegis => build_aegis(items),
839        Format::TwoFas => build_2fas(items),
840        _ => unreachable!("is_exportable covers every arm above"),
841    })
842}
843
844fn build_uri_list(items: &[Imported]) -> String {
845    let mut out = String::new();
846    for it in items {
847        out.push_str(&it.stored.to_uri(
848            it.issuer.as_deref().unwrap_or(""),
849            it.account.as_deref().unwrap_or(""),
850        ));
851        out.push('\n');
852    }
853    out
854}
855
856fn build_aegis(items: &[Imported]) -> String {
857    let entries: Vec<Value> = items
858        .iter()
859        .map(|it| {
860            // Aegis spells all three kinds in `type`, and reads `counter` only
861            // for the counter-based one. Writing a period onto an `hotp` entry
862            // is harmless there but says something untrue about the seed, so
863            // each kind writes only the field that governs it.
864            let mut info = serde_json::json!({
865                "secret": it.stored.secret,
866                "algo": it.stored.params.algorithm.as_str(),
867                "digits": it.stored.params.digits,
868            });
869            match it.stored.params.kind {
870                totp::Kind::Hotp => info["counter"] = Value::from(it.stored.params.counter),
871                totp::Kind::Totp | totp::Kind::Steam => {
872                    info["period"] = Value::from(it.stored.params.period)
873                }
874            }
875            serde_json::json!({
876                "type": it.stored.params.kind.as_str(),
877                // Aegis keys its own entries by uuid; a stable one derived from
878                // the seed means re-importing the same file twice updates rather
879                // than duplicating.
880                "uuid": stable_uuid(&it.stored.secret),
881                "name": it.account.clone().unwrap_or_default(),
882                "issuer": it.issuer.clone().unwrap_or_default(),
883                "note": it.note.clone().unwrap_or_default(),
884                "favorite": false,
885                "icon": Value::Null,
886                "info": info
887            })
888        })
889        .collect();
890    // `header.slots: null` is how Aegis marks a vault as unencrypted, and it is
891    // required — an absent header is not the same thing and Aegis refuses it.
892    let doc = serde_json::json!({
893        "version": 1,
894        "header": { "slots": Value::Null, "params": Value::Null },
895        "db": { "version": 3, "entries": entries }
896    });
897    serde_json::to_string_pretty(&doc).unwrap_or_default()
898}
899
900fn build_2fas(items: &[Imported]) -> String {
901    let services: Vec<Value> = items
902        .iter()
903        .map(|it| {
904            let issuer = it.issuer.clone().unwrap_or_default();
905            let account = it.account.clone().unwrap_or_default();
906            serde_json::json!({
907                "name": if issuer.is_empty() { account.clone() } else { issuer.clone() },
908                "secret": it.stored.secret,
909                "updatedAt": 0,
910                "otp": {
911                    "label": if account.is_empty() { issuer.clone() } else { account.clone() },
912                    "account": account,
913                    "issuer": issuer,
914                    "digits": it.stored.params.digits,
915                    "period": it.stored.params.period,
916                    "algorithm": it.stored.params.algorithm.as_str(),
917                    // 2FAS spells them in upper case, and carries a counter only
918                    // for the counter-based one.
919                    "tokenType": it.stored.params.kind.as_str().to_uppercase(),
920                    "counter": match it.stored.params.kind {
921                        totp::Kind::Hotp => Value::from(it.stored.params.counter),
922                        _ => Value::Null,
923                    },
924                    "source": "Manual",
925                },
926                "order": { "position": 0 },
927                "icon": { "selected": "Label", "label": { "text": "", "backgroundColor": "Blue" } },
928            })
929        })
930        .collect();
931    let doc = serde_json::json!({
932        "services": services,
933        // 2FAS refuses a backup whose schemaVersion it does not know. 4 is the
934        // oldest version every current build still reads.
935        "schemaVersion": 4,
936        "appVersionCode": 0,
937        "appVersionName": "UnENVerse",
938        "appOrigin": "android",
939        "servicesEncrypted": Value::Null,
940        "reference": Value::Null,
941    });
942    serde_json::to_string_pretty(&doc).unwrap_or_default()
943}
944
945/// A UUID-shaped string derived from the seed.
946///
947/// Not a real UUID and not claimed to be one — Aegis only needs the field to be
948/// stable and unique per entry. Deriving it from the seed is what makes a second
949/// import of the same file an update rather than a duplicate; a random one would
950/// give the user two of everything on the second try.
951///
952/// It is a hash, so the file does not leak the seed through this field. The file
953/// contains the seed anyway, which is why the whole thing is a materialising
954/// path — but a field that *looked* opaque and was not would be worse.
955fn stable_uuid(secret: &str) -> String {
956    use sha2::{Digest, Sha256};
957    let h = Sha256::digest(format!("unenverse-aegis-uuid:{secret}").as_bytes());
958    let hex: String = h.iter().take(16).map(|b| format!("{b:02x}")).collect();
959    format!(
960        "{}-{}-{}-{}-{}",
961        &hex[0..8],
962        &hex[8..12],
963        &hex[12..16],
964        &hex[16..20],
965        &hex[20..32]
966    )
967}
968
969// ── Merging an import into a vault ───────────────────────────────────────────
970//
971// The rules live here rather than in the CLI because they decide whether a
972// working second factor survives an import, and a rule like that must not be
973// able to differ between the app and the terminal. `unv totp import` and the
974// desktop app's Import button both call [`plan`] and [`apply`].
975
976/// Normalised key for "is this the same account?".
977///
978/// Provider and account together, case-folded and trimmed. Neither alone is
979/// enough: two GitHub accounts are two entries, and two services can share an
980/// email address. Case-folded because exports capitalise inconsistently —
981/// "GitHub" in one app, "github" in the next — and a case-sensitive match
982/// silently duplicates every account.
983fn match_key(provider: &str, account: &str) -> (String, String) {
984    (
985        provider.trim().to_lowercase(),
986        account.trim().to_lowercase(),
987    )
988}
989
990/// What an import would do to one incoming seed.
991#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
992#[serde(tag = "action", rename_all = "snake_case")]
993pub enum Plan {
994    /// No entry matches provider+account; make one.
995    Create,
996    /// An entry matches and carries no seed, or `force` was set.
997    Update { index: usize },
998    /// An entry matches and already holds this exact seed. Doing nothing is
999    /// what makes re-running an import idempotent.
1000    Unchanged { index: usize },
1001    /// An entry matches and holds a **different** seed. Reported, never
1002    /// overwritten without `force`.
1003    Conflict { index: usize },
1004}
1005
1006/// Decide, for each incoming seed, what should happen to it.
1007///
1008/// The rule that matters is [`Plan::Conflict`]. A stored second factor is not
1009/// recoverable from UnENVerse's side once it is gone, and an import is exactly
1010/// the moment a stale export gets pointed at a vault that has since been
1011/// re-enrolled. Silently taking the incoming value there would lock the user out
1012/// of an account at the moment they believe they are backing one up.
1013pub fn plan(entries: &[Value], items: &[Imported], force: bool) -> Vec<Plan> {
1014    let existing: std::collections::HashMap<(String, String), usize> = entries
1015        .iter()
1016        .enumerate()
1017        .map(|(i, e)| {
1018            (
1019                match_key(
1020                    e.get("provider").and_then(|v| v.as_str()).unwrap_or(""),
1021                    e.get("account_name").and_then(|v| v.as_str()).unwrap_or(""),
1022                ),
1023                i,
1024            )
1025        })
1026        .collect();
1027
1028    items
1029        .iter()
1030        .map(|item| {
1031            let key = match_key(
1032                &item.suggested_provider(),
1033                item.account.as_deref().unwrap_or(""),
1034            );
1035            let Some(&index) = existing.get(&key) else {
1036                return Plan::Create;
1037            };
1038            let current = entries[index]
1039                .get("totp_secret")
1040                .and_then(|v| v.as_str())
1041                .map(totp::normalize_b32)
1042                .unwrap_or_default();
1043            if current == item.stored.secret {
1044                Plan::Unchanged { index }
1045            } else if current.is_empty() || force {
1046                Plan::Update { index }
1047            } else {
1048                Plan::Conflict { index }
1049            }
1050        })
1051        .collect()
1052}
1053
1054/// Write a seed and its parameters onto an entry.
1055///
1056/// Parameters equal to the default are **removed**, not written: a field that
1057/// reads "SHA1" on every entry cannot be told apart from a defaulted one, and
1058/// removing rather than skipping is what makes re-importing a 60-second seed as
1059/// a 30-second one actually clear the old period.
1060pub fn write_fields(entry: &mut Value, stored: &Stored) {
1061    let defaults = Params::default();
1062    entry["totp_secret"] = Value::String(stored.secret.clone());
1063    let Some(o) = entry.as_object_mut() else {
1064        return;
1065    };
1066    if stored.params.algorithm == defaults.algorithm {
1067        o.remove("totp_algorithm");
1068    } else {
1069        o.insert(
1070            "totp_algorithm".into(),
1071            Value::String(stored.params.algorithm.as_str().into()),
1072        );
1073    }
1074    if stored.params.digits == defaults.digits {
1075        o.remove("totp_digits");
1076    } else {
1077        o.insert("totp_digits".into(), Value::from(stored.params.digits));
1078    }
1079    if stored.params.period == defaults.period {
1080        o.remove("totp_period");
1081    } else {
1082        o.insert("totp_period".into(), Value::from(stored.params.period));
1083    }
1084    if stored.params.kind == defaults.kind {
1085        o.remove("totp_kind");
1086    } else {
1087        o.insert(
1088            "totp_kind".into(),
1089            Value::String(stored.params.kind.as_str().into()),
1090        );
1091    }
1092    // The counter is state rather than configuration, so it is written whenever
1093    // the seed is counter-based — including at zero, which is a real position and
1094    // not an absent one. Removing it for the other kinds is what stops a seed
1095    // converted from HOTP to TOTP keeping a number nothing reads.
1096    if stored.params.kind == totp::Kind::Hotp {
1097        o.insert("totp_counter".into(), Value::from(stored.params.counter));
1098    } else {
1099        o.remove("totp_counter");
1100    }
1101}
1102
1103/// Build a fresh entry for a seed that matched nothing in the vault.
1104///
1105/// `secretType` is `password`, not `api_key`: a TOTP seed is a login's second
1106/// factor, and the password form is the one with a username field for the
1107/// account to live in. `api_key` is empty because there is no password beside it
1108/// yet — which is why the add/edit form lets an entry carrying a seed skip its
1109/// "value is required" check.
1110pub fn new_entry(
1111    item: &Imported,
1112    id: &str,
1113    now: &str,
1114    project: Option<&str>,
1115    category: Option<&str>,
1116) -> Value {
1117    let account = item.account.clone().unwrap_or_default();
1118    let mut projects = vec![Value::String("Universal".into())];
1119    if let Some(p) = project.filter(|p| *p != "Universal") {
1120        projects.push(Value::String(p.into()));
1121    }
1122    let mut e = serde_json::json!({
1123        "id": id,
1124        "provider": item.suggested_provider(),
1125        "api_key": "",
1126        "secretType": "password",
1127        "price_type": "free",
1128        "categories": category.map(|c| vec![Value::String(c.into())]).unwrap_or_default(),
1129        "projectIds": projects,
1130        "scopes": [],
1131        "created_at": now,
1132    });
1133    if !account.is_empty() {
1134        e["account_name"] = Value::String(account.clone());
1135        // Nearly always the login name, and the password form shows a username.
1136        e["username"] = Value::String(account);
1137    }
1138    if let Some(note) = &item.note {
1139        e["description"] = Value::String(note.clone());
1140    }
1141    write_fields(&mut e, &item.stored);
1142    e
1143}
1144
1145#[cfg(test)]
1146mod tests {
1147    use super::*;
1148
1149    /// "Hello!\u{0}\u{0}" — the RFC 4648 example, and a seed short enough to read.
1150    const SEED: &str = "JBSWY3DPEHPK3PXP";
1151
1152    fn one(items: &[Imported]) -> &Imported {
1153        assert_eq!(items.len(), 1, "expected exactly one item: {items:?}");
1154        &items[0]
1155    }
1156
1157    /// The first item, where a fixture deliberately carries several kinds.
1158    fn first(items: &[Imported]) -> &Imported {
1159        assert!(!items.is_empty(), "expected at least one item");
1160        &items[0]
1161    }
1162
1163    // ── Detection ───────────────────────────────────────────────────────────
1164
1165    #[test]
1166    fn every_supported_format_is_recognised_from_its_own_bytes() {
1167        // Detection has to be right before anything else can be: guessing wrong
1168        // means parsing an Aegis file as andOTP and reporting "no entries",
1169        // which tells the user nothing about what actually happened.
1170        let cases: [(&str, Format); 6] = [
1171            (
1172                "otpauth://totp/Acme:me?secret=JBSWY3DPEHPK3PXP",
1173                Format::OtpauthList,
1174            ),
1175            (
1176                "otpauth-migration://offline?data=CjEKCkhlbGxvIePop-8SEmpvaG5AZXhhbXBsZS5jb20aBUFjbWUgIAEoATACEAEYASAA",
1177                Format::GoogleMigration,
1178            ),
1179            (
1180                r#"{"version":1,"header":{"slots":null,"params":null},"db":{"version":3,"entries":[]}}"#,
1181                Format::Aegis,
1182            ),
1183            (r#"{"services":[],"schemaVersion":4}"#, Format::TwoFas),
1184            (r#"[{"secret":"JBSWY3DPEHPK3PXP","label":"a"}]"#, Format::AndOtp),
1185            (r#"{"encrypted":false,"items":[]}"#, Format::Bitwarden),
1186        ];
1187        for (text, want) in cases {
1188            assert_eq!(detect(text).unwrap(), want, "detecting {want:?}");
1189        }
1190    }
1191
1192    #[test]
1193    fn an_encrypted_export_is_refused_by_name_rather_than_failing_to_parse() {
1194        // This is the common case for somebody who just pressed "export" in an
1195        // app that encrypts by default. "Unrecognised file" would send them
1196        // looking for a bug; naming the app and the fix does not.
1197        let aegis = r#"{"version":1,"header":{"slots":[{"type":1}],"params":{}},"db":"AAAA"}"#;
1198        let err = detect(aegis).expect_err("encrypted Aegis must be refused");
1199        assert!(err.contains("Aegis"), "{err}");
1200        assert!(err.contains("encrypt"), "{err}");
1201
1202        let twofas = r#"{"services":[],"servicesEncrypted":"deadbeef","schemaVersion":4}"#;
1203        let err = detect(twofas).expect_err("encrypted 2FAS must be refused");
1204        assert!(err.contains("2FAS"), "{err}");
1205
1206        let bw = r#"{"encrypted":true,"items":[]}"#;
1207        let err = detect(bw).expect_err("encrypted Bitwarden must be refused");
1208        assert!(err.contains("Bitwarden"), "{err}");
1209    }
1210
1211    #[test]
1212    fn detection_never_panics_on_junk() {
1213        // A file picker hands this whatever the user chose.
1214        for junk in ["", "   ", "\u{feff}", "not json {", "\0\0\0", "[]", "{}"] {
1215            let _ = detect(junk);
1216        }
1217    }
1218
1219    // ── Ente / otpauth lists ────────────────────────────────────────────────
1220
1221    #[test]
1222    fn an_ente_plain_export_imports_including_its_codedisplay_parameter() {
1223        // Ente writes its own UI state into an extra `codeDisplay` parameter.
1224        // The URI parser ignores unknown parameters, which is why this needs no
1225        // special case — but it needs a test, because "ignored" and "chokes on"
1226        // look identical until somebody tries it.
1227        let text = concat!(
1228            "otpauth://totp/Acme:me%40example.com?secret=JBSWY3DPEHPK3PXP&issuer=Acme",
1229            "&algorithm=SHA1&digits=6&period=30",
1230            "&codeDisplay=%7B%22pinned%22%3Afalse%2C%22trashed%22%3Afalse%7D\n",
1231            "\n",
1232            "# a comment Ente does not write, but a hand-edited file might\n",
1233            "otpauth://totp/Other?secret=MZXW6YTBOI&digits=8&period=60&algorithm=SHA512\n",
1234        );
1235        let r = parse(text).unwrap();
1236        assert_eq!(r.format, Format::OtpauthList);
1237        assert_eq!(r.items.len(), 2);
1238        assert!(r.skipped.is_empty(), "{:?}", r.skipped);
1239        assert_eq!(r.items[0].stored.secret, SEED);
1240        assert_eq!(r.items[0].issuer.as_deref(), Some("Acme"));
1241        assert_eq!(r.items[0].account.as_deref(), Some("me@example.com"));
1242        assert_eq!(r.items[1].stored.params.digits, 8);
1243        assert_eq!(r.items[1].stored.params.period, 60);
1244        assert_eq!(r.items[1].stored.params.algorithm, totp::Algorithm::Sha512);
1245    }
1246
1247    #[test]
1248    fn a_bad_line_is_reported_by_line_number_and_never_by_content() {
1249        // A malformed URI still holds a secret, and this string reaches stdout.
1250        let text = "otpauth://totp/Ok?secret=JBSWY3DPEHPK3PXP\n\
1251                    otpauth://totp/Bad?secret=NOTBASE32!!!\n\
1252                    otpauth://hotp/Counter?secret=JBSWY3DPEHPK3PXP&counter=1\n";
1253        let r = parse(text).unwrap();
1254        // The counter-based line is imported now (Phase 22.2) rather than named
1255        // and skipped; only the mangled secret is still a skip.
1256        assert_eq!(r.items.len(), 2);
1257        assert_eq!(r.items[1].stored.params.kind, totp::Kind::Hotp);
1258        assert_eq!(r.items[1].stored.params.counter, 1);
1259        assert_eq!(r.skipped.len(), 1);
1260        for s in &r.skipped {
1261            assert!(s.name.starts_with("line "), "{s:?}");
1262            assert!(!s.reason.contains("NOTBASE32"), "{s:?}");
1263        }
1264    }
1265
1266    // ── The JSON formats ────────────────────────────────────────────────────
1267
1268    #[test]
1269    fn an_aegis_vault_imports_with_its_parameters_and_note() {
1270        let text = r#"{
1271          "version": 1,
1272          "header": { "slots": null, "params": null },
1273          "db": { "version": 3, "entries": [
1274            { "type": "totp", "uuid": "x", "name": "me@example.com", "issuer": "Acme",
1275              "note": "work laptop", "favorite": false, "icon": null,
1276              "info": { "secret": "JBSWY3DPEHPK3PXP", "algo": "SHA256", "digits": 8, "period": 60 } },
1277            { "type": "hotp", "uuid": "y", "name": "counter", "issuer": "Old",
1278              "info": { "secret": "JBSWY3DPEHPK3PXP", "algo": "SHA1", "digits": 6, "counter": 3 } }
1279          ] }
1280        }"#;
1281        let r = parse(text).unwrap();
1282        assert_eq!(r.format, Format::Aegis);
1283        assert_eq!(r.items.len(), 2);
1284        let it = first(&r.items);
1285        assert_eq!(it.stored.secret, SEED);
1286        assert_eq!(it.stored.params.algorithm, totp::Algorithm::Sha256);
1287        assert_eq!(it.stored.params.digits, 8);
1288        assert_eq!(it.stored.params.period, 60);
1289        assert_eq!(it.issuer.as_deref(), Some("Acme"));
1290        assert_eq!(it.account.as_deref(), Some("me@example.com"));
1291        assert_eq!(it.note.as_deref(), Some("work laptop"));
1292        // Aegis names all three kinds in `type`, and keeps the counter under
1293        // `info` where the period would be for a time-based entry.
1294        assert_eq!(r.items[1].stored.params.kind, totp::Kind::Hotp);
1295        assert_eq!(r.items[1].stored.params.counter, 3);
1296        assert!(r.skipped.is_empty(), "{:?}", r.skipped);
1297    }
1298
1299    #[test]
1300    fn an_aegis_steam_entry_keeps_steams_shape() {
1301        // Aegis writes `type: "steam"` in its JSON and `encoder=steam` in its
1302        // URI export. Reading only one of the two spellings imports the seed as
1303        // an ordinary six-digit TOTP, which produces codes Steam rejects with no
1304        // explanation.
1305        let text = r#"{
1306          "version": 1,
1307          "header": { "slots": null, "params": null },
1308          "db": { "version": 3, "entries": [
1309            { "type": "steam", "uuid": "s", "name": "me", "issuer": "Steam",
1310              "info": { "secret": "JBSWY3DPEHPK3PXP", "algo": "SHA1", "digits": 5, "period": 30 } }
1311          ] }
1312        }"#;
1313        let r = parse(text).unwrap();
1314        let it = one(&r.items);
1315        assert_eq!(it.stored.params.kind, totp::Kind::Steam);
1316        assert_eq!(it.stored.params.digits, totp::STEAM_DIGITS);
1317
1318        // And it survives a round trip back out through both writable formats.
1319        for fmt in [Format::Aegis, Format::OtpauthList] {
1320            let written = build(&r.items, fmt).expect("steam is exportable");
1321            let back = parse(&written).expect("what we wrote, we read");
1322            assert_eq!(
1323                back.items[0].stored.params.kind,
1324                totp::Kind::Steam,
1325                "{fmt:?} lost the Steam encoder"
1326            );
1327        }
1328    }
1329
1330    #[test]
1331    fn a_counter_survives_a_round_trip_through_every_writable_format() {
1332        // The counter is the one number in a seed that is *state*. An export
1333        // that drops it hands the next device a second factor that fails until
1334        // the account is resynced — and the failure looks exactly like a wrong
1335        // seed, which is the wrong thing to be debugging.
1336        let items = vec![Imported {
1337            issuer: Some("Acme".into()),
1338            account: Some("me".into()),
1339            stored: Stored {
1340                secret: SEED.into(),
1341                params: Params {
1342                    kind: totp::Kind::Hotp,
1343                    counter: 41,
1344                    ..Params::default()
1345                },
1346                issuer: Some("Acme".into()),
1347                account: Some("me".into()),
1348            },
1349            note: None,
1350        }];
1351        for fmt in Format::EXPORTABLE {
1352            let written = build(&items, fmt).expect("writable");
1353            let back = parse(&written).unwrap_or_else(|e| panic!("{fmt:?}: {e}"));
1354            assert_eq!(back.items.len(), 1, "{fmt:?}");
1355            assert_eq!(
1356                back.items[0].stored.params.kind,
1357                totp::Kind::Hotp,
1358                "{fmt:?}"
1359            );
1360            assert_eq!(back.items[0].stored.params.counter, 41, "{fmt:?}");
1361        }
1362    }
1363
1364    #[test]
1365    fn a_2fas_backup_imports_from_either_place_it_keeps_the_name() {
1366        // Real backups have `otp.issuer` empty for entries added by hand and
1367        // `name` empty for some scanned ones, so both have to be read.
1368        let text = r#"{
1369          "services": [
1370            { "name": "Acme", "secret": "JBSWY3DPEHPK3PXP",
1371              "otp": { "label": "me", "account": "me", "issuer": "Acme",
1372                       "digits": 6, "period": 30, "algorithm": "SHA1", "tokenType": "TOTP" } },
1373            { "name": "Fallback", "secret": "MZXW6YTBOI",
1374              "otp": { "label": "lbl", "tokenType": "TOTP" } },
1375            { "name": "Counter", "secret": "JBSWY3DPEHPK3PXP",
1376              "otp": { "label": "c", "tokenType": "HOTP" } }
1377          ],
1378          "schemaVersion": 4
1379        }"#;
1380        let r = parse(text).unwrap();
1381        assert_eq!(r.format, Format::TwoFas);
1382        // Three now: the `HOTP` service is read rather than skipped.
1383        assert_eq!(r.items.len(), 3);
1384        assert_eq!(r.items[0].issuer.as_deref(), Some("Acme"));
1385        // No `otp.issuer`: the top-level name stands in.
1386        assert_eq!(r.items[1].issuer.as_deref(), Some("Fallback"));
1387        assert_eq!(r.items[1].account.as_deref(), Some("lbl"));
1388        // Omitted parameters mean the defaults.
1389        assert!(r.items[1].stored.params.is_default());
1390        assert_eq!(r.items[2].stored.params.kind, totp::Kind::Hotp);
1391        assert!(r.skipped.is_empty(), "{:?}", r.skipped);
1392    }
1393
1394    #[test]
1395    fn an_andotp_export_imports_with_its_tags_as_the_note() {
1396        let text = r#"[
1397          { "secret": "JBSWY3DPEHPK3PXP", "issuer": "Acme", "label": "me",
1398            "digits": 6, "type": "TOTP", "algorithm": "SHA1", "period": 30,
1399            "tags": ["work", "critical"] }
1400        ]"#;
1401        let r = parse(text).unwrap();
1402        assert_eq!(r.format, Format::AndOtp);
1403        let it = one(&r.items);
1404        assert_eq!(it.stored.secret, SEED);
1405        assert_eq!(it.note.as_deref(), Some("work, critical"));
1406    }
1407
1408    #[test]
1409    fn a_bitwarden_export_reads_login_totp_in_both_spellings() {
1410        // Bitwarden stores a bare seed or a whole URI in the same field,
1411        // depending on how the item was created.
1412        let text = r#"{ "encrypted": false, "items": [
1413          { "type": 1, "name": "Plain", "login": { "username": "u1", "totp": "JBSWY3DPEHPK3PXP" } },
1414          { "type": 1, "name": "Uri", "login": { "username": "u2",
1415            "totp": "otpauth://totp/Ignored:x?secret=MZXW6YTBOI&digits=8" } },
1416          { "type": 1, "name": "NoTotp", "login": { "username": "u3", "password": "p" } },
1417          { "type": 2, "name": "SecureNote" }
1418        ] }"#;
1419        let r = parse(text).unwrap();
1420        assert_eq!(r.format, Format::Bitwarden);
1421        assert_eq!(r.items.len(), 2);
1422        assert_eq!(r.items[0].stored.secret, SEED);
1423        assert_eq!(r.items[0].account.as_deref(), Some("u1"));
1424        assert_eq!(r.items[1].stored.params.digits, 8);
1425        // The item's own name beats the URI's issuer: it is the one the user
1426        // chose, and the URI's half is usually whatever the site sent.
1427        assert_eq!(r.items[1].issuer.as_deref(), Some("Uri"));
1428        // Items with no TOTP are not "skipped" — in a password export they are
1429        // the overwhelming majority and would drown the real report.
1430        assert!(r.skipped.is_empty(), "{:?}", r.skipped);
1431    }
1432
1433    // ── Google Authenticator ────────────────────────────────────────────────
1434
1435    /// Hand-built `MigrationPayload`, so the test does not depend on owning a
1436    /// phone. Field numbers are from Google's `otpauth-migration` schema.
1437    fn google_payload(
1438        secret: &[u8],
1439        name: &str,
1440        issuer: &str,
1441        algo: u8,
1442        digits: u8,
1443        kind: u8,
1444    ) -> String {
1445        fn varint(out: &mut Vec<u8>, mut v: u64) {
1446            loop {
1447                let mut b = (v & 0x7f) as u8;
1448                v >>= 7;
1449                if v != 0 {
1450                    b |= 0x80;
1451                }
1452                out.push(b);
1453                if v == 0 {
1454                    break;
1455                }
1456            }
1457        }
1458        fn field_bytes(out: &mut Vec<u8>, num: u64, data: &[u8]) {
1459            varint(out, (num << 3) | 2);
1460            varint(out, data.len() as u64);
1461            out.extend_from_slice(data);
1462        }
1463        fn field_varint(out: &mut Vec<u8>, num: u64, v: u64) {
1464            varint(out, num << 3);
1465            varint(out, v);
1466        }
1467
1468        let mut params = Vec::new();
1469        field_bytes(&mut params, 1, secret);
1470        field_bytes(&mut params, 2, name.as_bytes());
1471        field_bytes(&mut params, 3, issuer.as_bytes());
1472        field_varint(&mut params, 4, u64::from(algo));
1473        field_varint(&mut params, 5, u64::from(digits));
1474        field_varint(&mut params, 6, u64::from(kind));
1475
1476        let mut payload = Vec::new();
1477        field_bytes(&mut payload, 1, &params);
1478        field_varint(&mut payload, 2, 1); // version
1479        field_varint(&mut payload, 3, 1); // batch_size
1480        field_varint(&mut payload, 4, 0); // batch_index
1481
1482        let b64 = base64::engine::general_purpose::STANDARD.encode(&payload);
1483        let pct: String = b64
1484            .chars()
1485            .map(|c| match c {
1486                '+' => "%2B".to_string(),
1487                '/' => "%2F".to_string(),
1488                '=' => "%3D".to_string(),
1489                c => c.to_string(),
1490            })
1491            .collect();
1492        format!("otpauth-migration://offline?data={pct}")
1493    }
1494
1495    #[test]
1496    fn a_google_migration_payload_decodes_to_the_same_seed_google_encoded() {
1497        // Google stores the secret as raw bytes; everything else in this module
1498        // speaks base32. Getting that conversion wrong produces a seed that is
1499        // the right length and the wrong value — six plausible digits that are
1500        // rejected, with nothing on screen saying why.
1501        let raw = totp::base32_decode(SEED).unwrap();
1502        let uri = google_payload(&raw, "me@example.com", "Acme", 1, 1, 2);
1503        let r = parse(&uri).unwrap();
1504        assert_eq!(r.format, Format::GoogleMigration);
1505        let it = one(&r.items);
1506        assert_eq!(it.stored.secret, SEED);
1507        assert_eq!(it.issuer.as_deref(), Some("Acme"));
1508        assert_eq!(it.account.as_deref(), Some("me@example.com"));
1509        // The migration format has no period field: Google only exports 30s.
1510        assert!(it.stored.params.is_default());
1511    }
1512
1513    #[test]
1514    fn the_migration_enums_map_to_the_right_algorithm_and_digit_count() {
1515        let raw = totp::base32_decode(SEED).unwrap();
1516        for (algo_enum, want) in [
1517            (0u8, totp::Algorithm::Sha1), // unspecified means SHA-1
1518            (1, totp::Algorithm::Sha1),
1519            (2, totp::Algorithm::Sha256),
1520            (3, totp::Algorithm::Sha512),
1521        ] {
1522            let uri = google_payload(&raw, "n", "i", algo_enum, 1, 2);
1523            assert_eq!(
1524                parse(&uri).unwrap().items[0].stored.params.algorithm,
1525                want,
1526                "algorithm enum {algo_enum}"
1527            );
1528        }
1529        // The digit count is an enum, not a number: 1 is SIX and 2 is EIGHT.
1530        let six = google_payload(&raw, "n", "i", 1, 1, 2);
1531        let eight = google_payload(&raw, "n", "i", 1, 2, 2);
1532        assert_eq!(parse(&six).unwrap().items[0].stored.params.digits, 6);
1533        assert_eq!(parse(&eight).unwrap().items[0].stored.params.digits, 8);
1534    }
1535
1536    #[test]
1537    fn a_counter_based_migration_entry_is_imported_with_its_counter() {
1538        // Phase 22 named and skipped these. Phase 22.2 reads them: the entry can
1539        // hold a counter, and the counter is the half that has to survive —
1540        // importing an HOTP seed at zero is a second factor that fails until the
1541        // account is resynced, which looks exactly like a wrong seed.
1542        let raw = totp::base32_decode(SEED).unwrap();
1543        let uri = google_payload(&raw, "counter", "Old", 1, 1, 1); // kind 1 = HOTP
1544        let r = parse(&uri).unwrap();
1545        assert_eq!(r.items.len(), 1);
1546        assert!(r.skipped.is_empty(), "{:?}", r.skipped);
1547        assert_eq!(r.items[0].stored.params.kind, totp::Kind::Hotp);
1548    }
1549
1550    #[test]
1551    fn several_migration_uris_accumulate_rather_than_conflicting() {
1552        // A large export is several QR codes. Somebody who scanned three of four
1553        // should get three accounts and be told, not get an error and none.
1554        let raw = totp::base32_decode(SEED).unwrap();
1555        let text = format!(
1556            "{}\n{}\n",
1557            google_payload(&raw, "a", "One", 1, 1, 2),
1558            google_payload(&raw, "b", "Two", 1, 1, 2)
1559        );
1560        let r = parse(&text).unwrap();
1561        assert_eq!(r.items.len(), 2);
1562        assert_eq!(r.items[1].issuer.as_deref(), Some("Two"));
1563    }
1564
1565    #[test]
1566    fn a_truncated_or_junk_migration_payload_errors_rather_than_panicking() {
1567        // This parses bytes from a QR code somebody else generated. Every length
1568        // in it is attacker-controlled as far as this function is concerned.
1569        for bad in [
1570            "otpauth-migration://offline?data=",
1571            "otpauth-migration://offline?data=!!!!",
1572            "otpauth-migration://offline?data=CjEKCg", // truncated mid-field
1573            "otpauth-migration://offline?data=%FF%FF%FF%FF",
1574        ] {
1575            let _ = parse(bad);
1576        }
1577        // And a length header claiming more bytes than exist.
1578        let payload = base64::engine::general_purpose::STANDARD.encode([0x0a, 0x7f, 0x01]);
1579        let r = parse(&format!("otpauth-migration://offline?data={payload}"));
1580        assert!(r.is_err(), "a lying length prefix must not be accepted");
1581    }
1582
1583    // ── Building ────────────────────────────────────────────────────────────
1584
1585    fn sample() -> Vec<Imported> {
1586        vec![
1587            Imported {
1588                issuer: Some("Acme".into()),
1589                account: Some("me@example.com".into()),
1590                stored: Stored {
1591                    secret: SEED.into(),
1592                    params: Params::default(),
1593                    issuer: None,
1594                    account: None,
1595                },
1596                note: Some("work".into()),
1597            },
1598            Imported {
1599                issuer: Some("Other".into()),
1600                account: None,
1601                stored: Stored {
1602                    secret: "MZXW6YTBOI".into(),
1603                    params: Params {
1604                        kind: totp::Kind::Totp,
1605                        counter: 0,
1606                        algorithm: totp::Algorithm::Sha512,
1607                        digits: 8,
1608                        period: 45,
1609                    },
1610                    issuer: None,
1611                    account: None,
1612                },
1613                note: None,
1614            },
1615        ]
1616    }
1617
1618    #[test]
1619    fn every_exportable_format_reads_back_through_this_modules_own_parser() {
1620        // The round trip is the property that matters, not the bytes: a file
1621        // that cannot be read back is one the destination app will not read
1622        // either, and the user finds that out on a phone with no other copy.
1623        for format in Format::EXPORTABLE {
1624            let text = build(&sample(), format).unwrap();
1625            let back = parse(&text)
1626                .unwrap_or_else(|e| panic!("{} did not read back: {e}", format.as_str()));
1627            assert_eq!(
1628                back.format, format,
1629                "detected the wrong format for {format:?}"
1630            );
1631            assert_eq!(back.items.len(), 2, "{format:?}");
1632            for (a, b) in sample().iter().zip(back.items.iter()) {
1633                assert_eq!(a.stored.secret, b.stored.secret, "{format:?}");
1634                assert_eq!(a.stored.params, b.stored.params, "{format:?}");
1635                assert_eq!(a.issuer, b.issuer, "{format:?}");
1636            }
1637        }
1638    }
1639
1640    #[test]
1641    fn a_read_only_format_is_refused_by_name_with_the_alternatives() {
1642        for format in [Format::Bitwarden, Format::GoogleMigration, Format::AndOtp] {
1643            let err = build(&sample(), format).expect_err("must refuse");
1644            assert!(err.contains(format.as_str()), "{err}");
1645            assert!(err.contains("otpauth"), "{err}");
1646        }
1647    }
1648
1649    #[test]
1650    fn the_aegis_uuid_is_stable_across_builds_so_a_re_import_updates() {
1651        // A random uuid would give the user two of everything the second time
1652        // they imported the same file.
1653        let a = build(&sample(), Format::Aegis).unwrap();
1654        let b = build(&sample(), Format::Aegis).unwrap();
1655        assert_eq!(a, b);
1656        // And it does not carry the seed it is derived from.
1657        assert!(!a.contains(&format!("\"uuid\": \"{SEED}\"")));
1658    }
1659
1660    #[test]
1661    fn aegis_marks_the_vault_unencrypted_the_way_aegis_requires() {
1662        // `header.slots: null` is the marker. An absent header is not the same
1663        // thing, and Aegis refuses the file rather than importing it.
1664        let v: Value = serde_json::from_str(&build(&sample(), Format::Aegis).unwrap()).unwrap();
1665        assert!(v.pointer("/header/slots").unwrap().is_null());
1666        assert_eq!(
1667            v.pointer("/db/entries").unwrap().as_array().unwrap().len(),
1668            2
1669        );
1670    }
1671
1672    #[test]
1673    fn format_names_round_trip_and_ente_is_an_alias_for_the_uri_list() {
1674        for f in [
1675            Format::OtpauthList,
1676            Format::Aegis,
1677            Format::TwoFas,
1678            Format::AndOtp,
1679            Format::Bitwarden,
1680            Format::GoogleMigration,
1681        ] {
1682            assert_eq!(Format::parse(f.as_str()), Some(f), "{f:?}");
1683        }
1684        // Ente's plain export *is* an otpauth list, and somebody holding one
1685        // will type --format ente. Refusing it would be pedantry.
1686        assert_eq!(Format::parse("ente"), Some(Format::OtpauthList));
1687        assert_eq!(Format::parse("Ente"), Some(Format::OtpauthList));
1688        assert_eq!(Format::parse("2FAS"), Some(Format::TwoFas));
1689        assert_eq!(
1690            Format::parse("google-authenticator"),
1691            Some(Format::GoogleMigration)
1692        );
1693        assert_eq!(Format::parse("nonsense"), None);
1694    }
1695
1696    #[test]
1697    fn suggested_provider_prefers_the_issuer_over_an_email_address() {
1698        // A vault of forty entries all called me@example.com is a vault you
1699        // cannot search.
1700        let mut it = sample().remove(0);
1701        assert_eq!(it.suggested_provider(), "Acme");
1702        it.issuer = None;
1703        assert_eq!(it.suggested_provider(), "me@example.com");
1704        it.account = Some("   ".into());
1705        assert_eq!(it.suggested_provider(), "Unnamed");
1706    }
1707
1708    // ── Merge rules ─────────────────────────────────────────────────────────
1709
1710    fn entry(provider: &str, account: &str, seed: Option<&str>) -> Value {
1711        let mut e = serde_json::json!({
1712            "id": provider, "provider": provider, "api_key": "",
1713            "account_name": account, "categories": [], "projectIds": ["Universal"], "scopes": []
1714        });
1715        if let Some(s) = seed {
1716            e["totp_secret"] = Value::String(s.into());
1717        }
1718        e
1719    }
1720
1721    fn incoming(issuer: &str, account: &str, seed: &str) -> Imported {
1722        Imported {
1723            issuer: Some(issuer.into()),
1724            account: Some(account.into()),
1725            stored: totp::parse_seed(seed).expect("fixture seed parses"),
1726            note: None,
1727        }
1728    }
1729
1730    const OTHER: &str = "MZXW6YTBOI";
1731
1732    #[test]
1733    fn an_unmatched_account_is_created() {
1734        assert_eq!(
1735            plan(&[], &[incoming("Acme", "me", SEED)], false),
1736            [Plan::Create]
1737        );
1738    }
1739
1740    #[test]
1741    fn an_entry_with_no_seed_yet_is_updated_rather_than_duplicated() {
1742        // The common case: the vault already holds the password for this login
1743        // and the import is adding its second factor beside it. Creating a
1744        // second entry would split one credential across two cards.
1745        let vault = [entry("Acme", "me", None)];
1746        assert_eq!(
1747            plan(&vault, &[incoming("Acme", "me", SEED)], false),
1748            [Plan::Update { index: 0 }]
1749        );
1750    }
1751
1752    #[test]
1753    fn re_running_the_same_import_changes_nothing() {
1754        // Idempotence is what makes an import safe to retry after it half-failed.
1755        let vault = [entry("Acme", "me", Some(SEED))];
1756        assert_eq!(
1757            plan(&vault, &[incoming("Acme", "me", SEED)], false),
1758            [Plan::Unchanged { index: 0 }]
1759        );
1760    }
1761
1762    #[test]
1763    fn a_different_stored_seed_is_a_conflict_and_not_an_overwrite() {
1764        // The failure this exists to prevent: pointing a stale export at a vault
1765        // that has since been re-enrolled silently replaces a working second
1766        // factor with a dead one, at the moment the user believes they are
1767        // backing it up.
1768        let vault = [entry("Acme", "me", Some(SEED))];
1769        assert_eq!(
1770            plan(&vault, &[incoming("Acme", "me", OTHER)], false),
1771            [Plan::Conflict { index: 0 }]
1772        );
1773        assert_eq!(
1774            plan(&vault, &[incoming("Acme", "me", OTHER)], true),
1775            [Plan::Update { index: 0 }],
1776            "--force is how you say you meant it"
1777        );
1778    }
1779
1780    #[test]
1781    fn matching_needs_both_the_provider_and_the_account() {
1782        // Two accounts at one service are two entries; two services can share an
1783        // email address. Matching on either alone merges credentials that are
1784        // not the same credential.
1785        let vault = [entry("Acme", "me", Some(SEED)), entry("Other", "me", None)];
1786        assert_eq!(
1787            plan(
1788                &vault,
1789                &[
1790                    incoming("Acme", "someone-else", OTHER),
1791                    incoming("Other", "me", OTHER),
1792                ],
1793                false
1794            ),
1795            [Plan::Create, Plan::Update { index: 1 }]
1796        );
1797    }
1798
1799    #[test]
1800    fn matching_ignores_case_and_surrounding_space() {
1801        // "GitHub" in one app, "github" in the next. A case-sensitive match
1802        // silently duplicates every account.
1803        let vault = [entry("GitHub", "Me@Example.com", None)];
1804        assert_eq!(
1805            plan(
1806                &vault,
1807                &[incoming(" github ", "me@example.com", SEED)],
1808                false
1809            ),
1810            [Plan::Update { index: 0 }]
1811        );
1812    }
1813
1814    #[test]
1815    fn a_seed_spelled_differently_is_still_the_same_seed() {
1816        // A stored seed written with the grouping spaces must not read as a
1817        // conflict against the same seed written without them.
1818        let vault = [entry("Acme", "me", Some("jbsw y3dp ehpk 3pxp"))];
1819        assert_eq!(
1820            plan(&vault, &[incoming("Acme", "me", SEED)], false),
1821            [Plan::Unchanged { index: 0 }]
1822        );
1823    }
1824
1825    #[test]
1826    fn write_fields_clears_a_parameter_that_returned_to_its_default() {
1827        // Removing rather than skipping is the point: re-importing a 60-second
1828        // seed as a 30-second one has to actually clear the old period, or the
1829        // entry keeps generating against a period nothing sent it.
1830        let mut e = serde_json::json!({ "provider": "A", "totp_period": 60, "totp_digits": 8 });
1831        write_fields(
1832            &mut e,
1833            &Stored {
1834                secret: SEED.into(),
1835                params: Params::default(),
1836                issuer: None,
1837                account: None,
1838            },
1839        );
1840        assert_eq!(e["totp_secret"], SEED);
1841        assert!(e.get("totp_period").is_none(), "{e}");
1842        assert!(e.get("totp_digits").is_none(), "{e}");
1843    }
1844
1845    #[test]
1846    fn a_new_entry_carries_the_account_into_username_and_stays_in_universal() {
1847        let it = incoming("Acme", "me@example.com", SEED);
1848        let e = new_entry(
1849            &it,
1850            "id-1",
1851            "2026-09-10T00:00:00Z",
1852            Some("web"),
1853            Some("2fa"),
1854        );
1855        assert_eq!(e["provider"], "Acme");
1856        assert_eq!(e["secretType"], "password");
1857        assert_eq!(e["api_key"], "");
1858        assert_eq!(e["account_name"], "me@example.com");
1859        assert_eq!(e["username"], "me@example.com");
1860        assert_eq!(e["totp_secret"], SEED);
1861        assert_eq!(e["categories"], serde_json::json!(["2fa"]));
1862        // A specific project never replaces Universal — every entry carries it.
1863        assert_eq!(e["projectIds"], serde_json::json!(["Universal", "web"]));
1864    }
1865}