Files
kestrove-core/crates/kv-import/src/onepux.rs
T
samiandClaude Opus 5.5 26664e141b kv-import: 1Password .1pux and CSV import, encrypted export (M5)
New crate kv-import (pure parsing, no store) and kv-ffi/src/import.rs:
- .1pux: accounts -> vaults -> items; Login/Password/Card/Note/Identity mapped, other categories
  as secure notes; unmapped fields appended to notes; attachments and password history reported.
- CSV: 1Password 8 export and generic headers (aliases, BOM, quotes, embedded newlines,
  extra columns into notes).
- Duplicates on (title, username, URL host): skip or add " (imported)".
- Encrypted export KVEX v1: Argon2id (>= account KDF minimum) + XChaCha20-Poly1305,
  authenticated header, padded JSON; restorable through import.
- Caps: 100 MB input, 64 MB decompressed export.data, 10k zip entries, 100k items.
- FFI: VaultCore.import_preview / import_commit / export_encrypted (unlocked only); items are
  sealed, seq-stamped and queued for sync like save_item.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-17 00:36:21 +09:00

298 lines
12 KiB
Rust

//! 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<Vec<u8>>, 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<Parsed> {
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<Option<Item>> {
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<String> = 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 (`{"<type>": <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::<Vec<_>>()
.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");
}
}