//! 1Password Unencrypted Export (`.1pux`): a ZIP holding `export.attributes`, `export.data` //! (JSON: `accounts[].vaults[].items[]`) and `files/` (attachments, not imported). //! //! Mapping (docs/09): Login 001 and Password 005 → login; Credit Card 002 → card; Secure Note 003 //! → secure note; Identity 004 → identity; every other category → secure note. Fields without a //! home in `Item` (card and identity fields, custom sections, extra form fields) are appended to //! the notes as `Label: value` lines under a "— Imported from 1Password —" banner. use std::io::{Cursor, Read}; use kv_model::{Item, ItemKind}; use serde_json::Value; use zeroize::{Zeroize, Zeroizing}; use crate::{malformed, new_item, set_opt, Extras, ImportError, Limits, Parsed, Result}; const DATA_ENTRY: &str = "export.data"; const BANNER: &str = "— Imported from 1Password —"; /// JSON whose strings are wiped on drop. struct Wiped(Value); impl Drop for Wiped { fn drop(&mut self) { wipe(&mut self.0); } } fn wipe(v: &mut Value) { match v { Value::String(s) => s.zeroize(), Value::Array(a) => a.iter_mut().for_each(wipe), Value::Object(m) => m.values_mut().for_each(wipe), _ => {} } } /// Reads `export.data` with the entry-count and decompressed-size caps. pub fn read_export_data(bytes: &[u8], limits: &Limits) -> Result<(Zeroizing>, usize)> { let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| malformed(format!("not a .1pux archive: {e}")))?; if zip.len() > limits.max_entries { return Err(ImportError::TooManyEntries { limit: limits.max_entries }); } let files = zip.file_names().filter_map(|n| n.ok()).filter(|n| n.starts_with("files/") && !n.ends_with('/')).count(); let entry = zip.by_name(DATA_ENTRY).map_err(|_| malformed("export.data is missing"))?; if entry.size() > limits.max_decompressed { return Err(ImportError::TooLarge { limit: limits.max_decompressed }); } // Never trust the declared size: stop reading one byte past the cap. let mut out = Zeroizing::new(Vec::new()); entry .take(limits.max_decompressed + 1) .read_to_end(&mut out) .map_err(|e| malformed(format!("export.data: {e}")))?; if out.len() as u64 > limits.max_decompressed { return Err(ImportError::TooLarge { limit: limits.max_decompressed }); } Ok((out, files)) } pub fn parse(bytes: &[u8], now: i64, limits: &Limits) -> Result { let (data, files_in_archive) = read_export_data(bytes, limits)?; let root = Wiped(serde_json::from_slice(&data).map_err(|e| malformed(format!("export.data: {e}")))?); let accounts = root.0.get("accounts").and_then(Value::as_array).ok_or_else(|| malformed("export.data has no accounts"))?; let vault_count: usize = accounts.iter().map(|a| a.get("vaults").and_then(Value::as_array).map_or(0, Vec::len)).sum(); let mut ctx = Ctx { out: Parsed::default(), now, password_history: 0, other_category: 0, trashed: 0, archived: 0 }; for account in accounts { for vault in account.get("vaults").and_then(Value::as_array).into_iter().flatten() { let vault_name = str_at(vault, &["attrs", "name"]); for raw in vault.get("items").and_then(Value::as_array).into_iter().flatten() { limits.check_items(ctx.out.items.len() + 1)?; if let Some(mut item) = ctx.item(raw)? { if vault_count > 1 && !vault_name.is_empty() { item.tags.push(vault_name.to_string()); } ctx.out.items.push(item); } } } } let Ctx { mut out, password_history, other_category, trashed, archived, .. } = ctx; if out.attachments_skipped == 0 && files_in_archive > 0 { out.attachments_skipped = files_in_archive as u32; out.warnings.push(format!( "{files_in_archive} file(s) in the archive were not imported (attachments are not supported yet)" )); } if password_history > 0 { out.warnings.push(format!("{password_history} previous password(s) from password history were not imported")); } if other_category > 0 { out.warnings.push(format!("{other_category} item(s) of other categories were imported as secure notes")); } if archived > 0 { out.warnings.push(format!("{archived} archived item(s) were imported with the tag \"Archived\"")); } if trashed > 0 { out.warnings.push(format!("{trashed} deleted item(s) were skipped")); } Ok(out) } struct Ctx { out: Parsed, now: i64, password_history: u32, other_category: u32, trashed: u32, archived: u32, } impl Ctx { fn item(&mut self, raw: &Value) -> Result> { if !raw.is_object() { return Err(malformed("item is not an object")); } let state = str_at(raw, &["state"]); if state == "deleted" || state == "trashed" { self.trashed += 1; return Ok(None); } let category = str_at(raw, &["categoryUuid"]); let kind = match category { "001" | "005" => ItemKind::Login, "002" => ItemKind::Card, "003" => ItemKind::SecureNote, "004" => ItemKind::Identity, _ => { self.other_category += 1; ItemKind::SecureNote } }; let overview = raw.get("overview").unwrap_or(&Value::Null); let details = raw.get("details").unwrap_or(&Value::Null); let mut item = new_item(kind, str_at(overview, &["title"]), self.now); let title = item.title.clone(); if let Some(t) = raw.get("createdAt").and_then(Value::as_i64) { item.created_at = t; } if let Some(t) = raw.get("updatedAt").and_then(Value::as_i64) { item.modified_at = t.max(item.created_at); } item.favorite = raw.get("favIndex").and_then(Value::as_i64).is_some_and(|f| f > 0); item.tags = overview .get("tags") .and_then(Value::as_array) .into_iter() .flatten() .filter_map(Value::as_str) .map(String::from) .collect(); if state == "archived" { item.tags.push("Archived".into()); self.archived += 1; } let mut urls: Vec = overview .get("urls") .and_then(Value::as_array) .into_iter() .flatten() .filter_map(|u| u.get("url").and_then(Value::as_str)) .filter(|u| !u.trim().is_empty()) .map(String::from) .collect(); let main_url = str_at(overview, &["url"]); if !main_url.is_empty() && !urls.iter().any(|u| u == main_url) { urls.insert(0, main_url.to_string()); } item.urls = urls; set_opt(&mut item.notes, str_at(details, &["notesPlain"])); let mut extras = Extras::default(); if category == "005" { set_opt(&mut item.password, str_at(details, &["password"])); } else if let Some(pw) = details.get("password").and_then(Value::as_str) { extras.push("Password", pw); } for f in details.get("loginFields").and_then(Value::as_array).into_iter().flatten() { let value = str_at(f, &["value"]); match str_at(f, &["designation"]) { "username" if item.username.is_none() && kind == ItemKind::Login => set_opt(&mut item.username, value), "password" if item.password.is_none() && kind == ItemKind::Login => set_opt(&mut item.password, value), _ => { let name = str_at(f, &["name"]); extras.push(if name.is_empty() { "Form field" } else { name }, value); } } } for section in details.get("sections").and_then(Value::as_array).into_iter().flatten() { let section_title = str_at(section, &["title"]); extras.heading(section_title); for field in section.get("fields").and_then(Value::as_array).into_iter().flatten() { self.field(&mut item, &mut extras, &title, field); } } if let Some(doc) = details.get("documentAttributes").filter(|d| d.is_object()) { self.attachment(&title, doc); } self.password_history += details.get("passwordHistory").and_then(Value::as_array).map_or(0, |h| h.len() as u32); extras.append_to(&mut item, BANNER); Ok(Some(item)) } fn field(&mut self, item: &mut Item, extras: &mut Extras, title: &str, field: &Value) { let label = { let t = str_at(field, &["title"]); if t.is_empty() { str_at(field, &["id"]) } else { t } }; let Some(value) = field.get("value").and_then(Value::as_object) else { return }; for (vtype, v) in value { match vtype.as_str() { "totp" if item.totp.is_none() && v.as_str().is_some_and(|s| !s.trim().is_empty()) => { item.totp = v.as_str().map(|s| s.trim().to_string()); } "file" => self.attachment(title, v), _ => { let text = Zeroizing::new(render(vtype, v)); extras.push(label, &text); } } } } fn attachment(&mut self, title: &str, attrs: &Value) { self.out.attachments_skipped += 1; let name = str_at(attrs, &["fileName"]); self.out.warnings.push(format!( "\"{title}\": attachment \"{}\" was not imported (attachments are not supported yet)", if name.is_empty() { "unnamed" } else { name } )); } } /// Text for a 1Password field value (`{"": }`). fn render(vtype: &str, v: &Value) -> String { match (vtype, v) { (_, Value::String(s)) => s.clone(), ("date", Value::Number(n)) => n.as_i64().map(format_date).unwrap_or_else(|| n.to_string()), ("monthYear", Value::Number(n)) => match n.as_i64() { Some(ym) if ym > 0 => format!("{:02}/{}", ym % 100, ym / 100), _ => n.to_string(), }, ("email", Value::Object(o)) => o.get("email_address").and_then(Value::as_str).unwrap_or("").to_string(), ("address", Value::Object(o)) => ["street", "city", "state", "zip", "country"] .iter() .filter_map(|k| o.get(*k).and_then(Value::as_str)) .filter(|s| !s.trim().is_empty()) .collect::>() .join(", "), ("sshKey", Value::Object(o)) => o.get("privateKey").and_then(Value::as_str).unwrap_or("").to_string(), (_, Value::Null) => String::new(), (_, Value::Bool(b)) => b.to_string(), (_, Value::Number(n)) => n.to_string(), // Unknown structured value: keep it verbatim rather than drop it. (_, other) => other.to_string(), } } /// `YYYY-MM-DD` (UTC) for a Unix timestamp in seconds. fn format_date(secs: i64) -> String { let z = secs.div_euclid(86_400) + 719_468; let era = z.div_euclid(146_097); let doe = z.rem_euclid(146_097); let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; let y = yoe + era * 400 + i64::from(m <= 2); format!("{y:04}-{m:02}-{d:02}") } fn str_at<'a>(v: &'a Value, path: &[&str]) -> &'a str { path.iter().try_fold(v, |v, k| v.get(k)).and_then(Value::as_str).unwrap_or("") } #[cfg(test)] mod tests { use super::*; #[test] fn dates() { assert_eq!(format_date(0), "1970-01-01"); assert_eq!(format_date(951_782_400), "2000-02-29"); assert_eq!(format_date(-86_400), "1969-12-31"); assert_eq!(render("monthYear", &serde_json::json!(202_712)), "12/2027"); } }