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]>
This commit is contained in:
2026-10-17 00:36:21 +09:00
co-authored by Claude Opus 5.5
parent 5eb2b622dd
commit 26664e141b
15 changed files with 2052 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
//! CSV import: 1Password's CSV export (`Title,Url,Username,Password,OTPAuth,Favorite,Archived,Tags,Notes`)
//! and any CSV whose header names columns like `title,url,username,password,otp,notes`
//! (case-insensitive; see [`column_for`]). Columns with no field of their own go into the notes.
//!
//! RFC 4180 parsing: quoted fields, `""` escapes, embedded newlines, CRLF or LF, a UTF-8 BOM.
use kv_model::ItemKind;
use zeroize::Zeroize;
use crate::{malformed, new_item, set_opt, Extras, ImportError, Limits, Parsed, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Column {
Title,
Url,
Username,
Password,
Otp,
Notes,
Favorite,
Tags,
Archived,
Other,
}
/// Header aliases (lowercased, non-alphanumerics removed). Covers 1Password 7/8, Bitwarden, Chrome.
fn column_for(header: &str) -> Column {
let h: String = header.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect();
match h.as_str() {
"title" | "name" => Column::Title,
"url" | "urls" | "website" | "loginuri" | "loginurl" | "uri" => Column::Url,
"username" | "loginusername" | "user" | "login" => Column::Username,
"password" | "loginpassword" => Column::Password,
"otp" | "otpauth" | "totp" | "logintotp" | "onetimepassword" => Column::Otp,
"notes" | "note" | "notesplain" | "comments" => Column::Notes,
"favorite" | "favourite" | "fav" => Column::Favorite,
"tags" | "folder" => Column::Tags,
"archived" => Column::Archived,
_ => Column::Other,
}
}
/// A parsed CSV whose cells are wiped on drop.
pub struct Records(pub Vec<Vec<String>>);
impl Drop for Records {
fn drop(&mut self) {
for row in &mut self.0 {
row.iter_mut().for_each(Zeroize::zeroize);
}
}
}
/// RFC 4180 reader. Blank lines are skipped. An unterminated quote is an error.
pub fn read_records(text: &str) -> Result<Records> {
let mut rows = Records(vec![]);
let mut row: Vec<String> = vec![];
let mut field = String::new();
let mut in_quotes = false;
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
if in_quotes {
match c {
'"' if chars.peek() == Some(&'"') => {
chars.next();
field.push('"');
}
'"' => in_quotes = false,
_ => field.push(c),
}
continue;
}
match c {
'"' if field.is_empty() => in_quotes = true,
',' => row.push(std::mem::take(&mut field)),
'\r' if chars.peek() == Some(&'\n') => {}
'\n' | '\r' => {
row.push(std::mem::take(&mut field));
push_row(&mut rows.0, std::mem::take(&mut row));
}
_ => field.push(c),
}
}
if in_quotes {
field.zeroize();
row.iter_mut().for_each(Zeroize::zeroize);
return Err(malformed("CSV has an unterminated quoted field"));
}
if !field.is_empty() || !row.is_empty() {
row.push(field);
push_row(&mut rows.0, row);
}
Ok(rows)
}
fn push_row(rows: &mut Vec<Vec<String>>, mut row: Vec<String>) {
if row.iter().all(|f| f.trim().is_empty()) {
row.iter_mut().for_each(Zeroize::zeroize);
} else {
rows.push(row);
}
}
fn truthy(s: &str) -> bool {
matches!(s.trim().to_lowercase().as_str(), "1" | "true" | "yes" | "y" | "x")
}
pub fn parse(bytes: &[u8], now: i64, limits: &Limits) -> Result<Parsed> {
let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
let text = std::str::from_utf8(bytes).map_err(|_| malformed("CSV is not UTF-8"))?;
let records = read_records(text)?;
let Some((header, rows)) = records.0.split_first() else { return Err(malformed("CSV is empty")) };
let columns: Vec<Column> = header.iter().map(|h| column_for(h)).collect();
let has = |c: Column| columns.contains(&c);
if !(has(Column::Title) || has(Column::Url))
|| !(has(Column::Password) || has(Column::Username) || has(Column::Notes))
{
return Err(ImportError::Unsupported(
"CSV header must name a title or url column and a username, password or notes column".into(),
));
}
limits.check_items(rows.len())?;
let mut out = Parsed::default();
let mut archived = 0u32;
for row in rows {
let cell = |c: Column| columns.iter().position(|x| *x == c).and_then(|i| row.get(i)).map_or("", |s| s.as_str());
let mut item = new_item(ItemKind::Login, cell(Column::Title), now);
if cell(Column::Title).trim().is_empty() {
if let Some(host) =
url::Url::parse(cell(Column::Url).trim()).ok().and_then(|u| u.host_str().map(String::from))
{
item.title = host;
}
}
set_opt(&mut item.username, cell(Column::Username));
set_opt(&mut item.password, cell(Column::Password));
set_opt(&mut item.totp, cell(Column::Otp).trim());
set_opt(&mut item.notes, cell(Column::Notes));
item.urls = cell(Column::Url).lines().map(str::trim).filter(|u| !u.is_empty()).map(String::from).collect();
item.favorite = truthy(cell(Column::Favorite));
item.tags =
cell(Column::Tags).split([',', ';']).map(str::trim).filter(|t| !t.is_empty()).map(String::from).collect();
if truthy(cell(Column::Archived)) {
item.tags.push("Archived".into());
archived += 1;
}
let mut extras = Extras::default();
for (i, value) in row.iter().enumerate() {
match columns.get(i) {
Some(Column::Other) => extras.push(&header[i], value),
Some(_) => {}
None => extras.push(&format!("Column {}", i + 1), value),
}
}
extras.append_to(&mut item, "— Imported fields —");
out.items.push(item);
}
if archived > 0 {
out.warnings.push(format!("{archived} archived item(s) were imported with the tag \"Archived\""));
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rfc4180() {
let r = read_records("a,\"b,c\",\"d \"\"q\"\"\"\r\n\r\n\"multi\nline\",,x\n").unwrap();
assert_eq!(r.0, vec![vec!["a", "b,c", "d \"q\""], vec!["multi\nline", "", "x"]]);
let r = read_records("a,b").unwrap();
assert_eq!(r.0, vec![vec!["a", "b"]]);
assert!(read_records("a,\"open").is_err());
}
#[test]
fn header_aliases() {
assert_eq!(column_for("Login_URI"), Column::Url);
assert_eq!(column_for("OTPAuth"), Column::Otp);
assert_eq!(column_for(" Title "), Column::Title);
assert_eq!(column_for("Security question"), Column::Other);
}
}
+110
View File
@@ -0,0 +1,110 @@
//! Duplicate detection: an imported item duplicates an existing one (or an earlier one in the
//! same import) when (title, username, primary URL host) match, case-insensitively.
use std::collections::HashSet;
use kv_model::Item;
pub const IMPORTED_SUFFIX: &str = " (imported)";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DuplicatePolicy {
/// Leave the existing item alone and drop the imported one.
Skip,
/// Import it anyway, with `" (imported)"` appended to the title.
AddSuffix,
}
pub type Key = (String, String, String);
/// Host of the first URL, lowercased, without a leading `www.`. Empty when there is no usable URL.
pub fn primary_host(item: &Item) -> String {
let Some(raw) = item.urls.first().map(|u| u.trim()) else { return String::new() };
let parsed = url::Url::parse(raw).ok().filter(|u| u.host_str().is_some()).or_else(|| {
// Bare "example.com/login" from CSVs.
url::Url::parse(&format!("https://{raw}")).ok()
});
let host = parsed.and_then(|u| u.host_str().map(str::to_lowercase)).unwrap_or_else(|| raw.to_lowercase());
host.strip_prefix("www.").map(str::to_string).unwrap_or(host)
}
pub fn key(item: &Item) -> Key {
(item.title.trim().to_lowercase(), item.username.as_deref().unwrap_or("").trim().to_lowercase(), primary_host(item))
}
pub struct Plan {
/// Items to store, in input order.
pub to_add: Vec<Item>,
/// Indices into the input of the duplicates (skipped or renamed).
pub duplicates: Vec<usize>,
pub skipped: u32,
pub renamed: u32,
}
/// Decides what to store. `existing` are the keys of the items already in the vault.
pub fn plan(items: Vec<Item>, existing: impl IntoIterator<Item = Key>, policy: DuplicatePolicy) -> Plan {
let mut seen: HashSet<Key> = existing.into_iter().collect();
let mut out = Plan { to_add: Vec::with_capacity(items.len()), duplicates: vec![], skipped: 0, renamed: 0 };
for (idx, mut item) in items.into_iter().enumerate() {
let k = key(&item);
if !seen.contains(&k) {
seen.insert(k);
out.to_add.push(item);
continue;
}
out.duplicates.push(idx);
match policy {
DuplicatePolicy::Skip => out.skipped += 1,
DuplicatePolicy::AddSuffix => {
item.title.push_str(IMPORTED_SUFFIX);
// Importing the same file twice still yields only one "(imported)" copy per key.
while !seen.insert(key(&item)) {
item.title.push_str(IMPORTED_SUFFIX);
}
out.renamed += 1;
out.to_add.push(item);
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn login(title: &str, user: &str, url: &str) -> Item {
let mut i = Item::new_login(title, 0);
i.username = Some(user.into());
if !url.is_empty() {
i.urls = vec![url.into()];
}
i
}
#[test]
fn host_normalization() {
assert_eq!(primary_host(&login("a", "b", "https://WWW.Example.com/login?x=1")), "example.com");
assert_eq!(primary_host(&login("a", "b", "example.com/login")), "example.com");
assert_eq!(primary_host(&login("a", "b", "")), "");
}
#[test]
fn skip_and_suffix() {
let existing = [key(&login("GitHub", "me", "https://github.com"))];
let items = vec![
login("github", "ME", "https://github.com/login"),
login("GitHub", "other", "https://github.com"),
login("Mail", "me", "https://mail.example"),
login("Mail", "me", "https://mail.example/inbox"),
];
let p = plan(items.clone(), existing.clone(), DuplicatePolicy::Skip);
assert_eq!((p.to_add.len(), p.skipped, p.renamed), (2, 2, 0));
assert_eq!(p.duplicates, vec![0, 3]);
let p = plan(items, existing, DuplicatePolicy::AddSuffix);
assert_eq!((p.to_add.len(), p.skipped, p.renamed), (4, 0, 2));
assert_eq!(p.to_add[0].title, "github (imported)");
assert_eq!(p.to_add[3].title, "Mail (imported)");
}
}
+182
View File
@@ -0,0 +1,182 @@
//! Passphrase-encrypted export (`.kvex`, docs/09 "Encrypted export").
//!
//! ```text
//! magic "KVEX" | version u8 = 1 | alg u8 = 1 | m_kib u32le | t u32le | p u32le | salt[16] | nonce[24] | ciphertext
//! key = Argon2id(NFKD(trim(passphrase)), salt, m_kib, t, p, L = 32)
//! ciphertext = XChaCha20-Poly1305(key, nonce, aad = all header bytes, pad(JSON))
//! JSON = {"format": "kestrove-export", "version": 1, "exported_at": <unix s>, "items": [Item…]}
//! ```
//!
//! `alg = 1` names the Argon2id + XChaCha20-Poly1305 suite. Readers refuse KDF parameters below
//! the account KDF minimum (m ≥ 64 MiB, t ≥ 3) and above a sanity cap (so a crafted file cannot
//! make the reader allocate gigabytes). Plaintext export is deliberately not offered.
use argon2::{Algorithm, Argon2, Params, Version};
use chacha20poly1305::{
aead::{Aead, KeyInit, Payload},
Key, XChaCha20Poly1305, XNonce,
};
use kv_crypto::{aead, random_bytes};
use kv_model::Item;
use kv_proto::{MIN_KDF_M_KIB, MIN_KDF_T};
use serde::{Deserialize, Serialize};
use unicode_normalization::UnicodeNormalization;
use zeroize::Zeroizing;
use crate::{malformed, ImportError, Result};
pub const MAGIC: &[u8; 4] = b"KVEX";
pub const VERSION: u8 = 1;
pub const ALG_ARGON2ID_XCHACHA20POLY1305: u8 = 1;
pub const HEADER_LEN: usize = 4 + 1 + 1 + 12 + 16 + 24;
pub const MIN_PASSPHRASE_CHARS: usize = 10;
const FORMAT_NAME: &str = "kestrove-export";
/// Upper bounds a reader accepts (1 GiB, 10 passes, 16 lanes).
const MAX_M_KIB: u32 = 1024 * 1024;
const MAX_T: u32 = 10;
const MAX_P: u32 = 16;
/// Argon2id cost for new exports: the account KDF default (RFC 9106 second option).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct KdfCost {
pub m_kib: u32,
pub t: u32,
pub p: u32,
}
impl Default for KdfCost {
fn default() -> Self {
Self { m_kib: 64 * 1024, t: 3, p: 4 }
}
}
impl KdfCost {
fn validate(&self) -> Result<()> {
if self.m_kib < MIN_KDF_M_KIB || self.t < MIN_KDF_T || self.p == 0 {
return Err(ImportError::Unsupported("KDF parameters below the minimum".into()));
}
if self.m_kib > MAX_M_KIB || self.t > MAX_T || self.p > MAX_P {
return Err(ImportError::Unsupported("KDF parameters above the allowed maximum".into()));
}
Ok(())
}
}
#[derive(Serialize)]
struct DocOut<'a> {
format: &'a str,
version: u32,
exported_at: i64,
items: &'a [Item],
}
#[derive(Deserialize)]
struct DocIn {
format: String,
version: u32,
items: Vec<Item>,
}
fn derive_key(passphrase: &str, salt: &[u8; 16], cost: KdfCost) -> Result<Zeroizing<[u8; 32]>> {
let normalized = Zeroizing::new(passphrase.trim().nfkd().collect::<String>());
let params = Params::new(cost.m_kib, cost.t, cost.p, Some(32)).map_err(|_| ImportError::Crypto)?;
let mut key = Zeroizing::new([0u8; 32]);
Argon2::new(Algorithm::Argon2id, Version::V0x13, params)
.hash_password_into(normalized.as_bytes(), salt, key.as_mut())
.map_err(|_| ImportError::Crypto)?;
Ok(key)
}
fn header(cost: KdfCost, salt: &[u8; 16], nonce: &[u8; 24]) -> Vec<u8> {
let mut h = Vec::with_capacity(HEADER_LEN);
h.extend_from_slice(MAGIC);
h.push(VERSION);
h.push(ALG_ARGON2ID_XCHACHA20POLY1305);
h.extend_from_slice(&cost.m_kib.to_le_bytes());
h.extend_from_slice(&cost.t.to_le_bytes());
h.extend_from_slice(&cost.p.to_le_bytes());
h.extend_from_slice(salt);
h.extend_from_slice(nonce);
h
}
/// Encrypts `items` under `passphrase` with the default cost.
pub fn encrypt(items: &[Item], passphrase: &str, now: i64) -> Result<Vec<u8>> {
encrypt_with_cost(items, passphrase, now, KdfCost::default())
}
pub fn encrypt_with_cost(items: &[Item], passphrase: &str, now: i64, cost: KdfCost) -> Result<Vec<u8>> {
if passphrase.trim().chars().count() < MIN_PASSPHRASE_CHARS {
return Err(ImportError::WeakPassphrase { min: MIN_PASSPHRASE_CHARS });
}
cost.validate()?;
let salt = random_bytes::<16>();
let nonce = random_bytes::<24>();
let key = derive_key(passphrase, &salt, cost)?;
let json = Zeroizing::new(
serde_json::to_vec(&DocOut { format: FORMAT_NAME, version: 1, exported_at: now, items }).map_err(malformed)?,
);
let padded = aead::pad(&json);
let mut out = header(cost, &salt, &nonce);
let ct = XChaCha20Poly1305::new(Key::from_slice(key.as_ref()))
.encrypt(XNonce::from_slice(&nonce), Payload { msg: &padded, aad: &out })
.map_err(|_| ImportError::Crypto)?;
out.extend_from_slice(&ct);
Ok(out)
}
/// True if `bytes` starts like an encrypted export.
pub fn looks_like_export(bytes: &[u8]) -> bool {
bytes.starts_with(MAGIC)
}
pub fn decrypt(bytes: &[u8], passphrase: &str) -> Result<Vec<Item>> {
if bytes.len() < HEADER_LEN + 16 || !looks_like_export(bytes) {
return Err(malformed("not a Kestrove encrypted export"));
}
let (hdr, ct) = bytes.split_at(HEADER_LEN);
if hdr[4] != VERSION || hdr[5] != ALG_ARGON2ID_XCHACHA20POLY1305 {
return Err(ImportError::Unsupported(format!("export version {} / alg {}", hdr[4], hdr[5])));
}
let u32_at = |o: usize| u32::from_le_bytes([hdr[o], hdr[o + 1], hdr[o + 2], hdr[o + 3]]);
let cost = KdfCost { m_kib: u32_at(6), t: u32_at(10), p: u32_at(14) };
cost.validate()?;
let mut salt = [0u8; 16];
salt.copy_from_slice(&hdr[18..34]);
let key = derive_key(passphrase, &salt, cost)?;
let padded = Zeroizing::new(
XChaCha20Poly1305::new(Key::from_slice(key.as_ref()))
.decrypt(XNonce::from_slice(&hdr[34..58]), Payload { msg: ct, aad: hdr })
.map_err(|_| ImportError::BadPassphrase)?,
);
let json = aead::unpad(&padded).map_err(|_| malformed("export padding"))?;
let doc: DocIn = serde_json::from_slice(&json).map_err(|e| malformed(format!("export JSON: {e}")))?;
if doc.format != FORMAT_NAME || doc.version != 1 {
return Err(ImportError::Unsupported(format!("{} version {}", doc.format, doc.version)));
}
Ok(doc.items)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn header_layout() {
assert_eq!(header(KdfCost::default(), &[0; 16], &[0; 24]).len(), HEADER_LEN);
}
#[test]
fn rejects_weak_and_huge_params() {
assert!(KdfCost { m_kib: 1024, t: 3, p: 1 }.validate().is_err());
assert!(KdfCost { m_kib: 64 * 1024, t: 1, p: 1 }.validate().is_err());
assert!(KdfCost { m_kib: 4 * 1024 * 1024, t: 3, p: 1 }.validate().is_err());
assert!(KdfCost::default().validate().is_ok());
}
#[test]
fn short_passphrase() {
assert_eq!(encrypt(&[], "short", 0).unwrap_err(), ImportError::WeakPassphrase { min: 10 });
}
}
+223
View File
@@ -0,0 +1,223 @@
//! Import from other password managers and passphrase-encrypted export
//! (kestrove-spec/docs/09-import-export.md).
//!
//! This crate is pure: it turns bytes into [`Item`]s (and back, for the encrypted export).
//! Storing, sealing and seq-stamping happen in `kv-ffi`, through the normal save path.
//!
//! Rules:
//! * Untrusted input never panics: every parser returns [`ImportError`].
//! * Every input is size-capped before parsing ([`Limits`]); ZIP entries are capped on
//! their *decompressed* size and the archive on its entry count.
//! * Plaintext buffers are zeroized ([`zeroize::Zeroizing`], wiped JSON values, wiped CSV cells).
//! * Nothing is silently dropped: fields without a home in [`Item`] go into the notes, and what
//! cannot be imported at all (attachments, password history) is reported in the warnings.
pub mod csv;
pub mod dedup;
pub mod export;
pub mod onepux;
use kv_model::{Item, ItemKind};
use thiserror::Error;
use uuid::Uuid;
use zeroize::{Zeroize, ZeroizeOnDrop};
pub use dedup::{plan, DuplicatePolicy, Plan};
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ImportError {
#[error("input is larger than the {limit}-byte limit")]
TooLarge { limit: u64 },
#[error("archive has more than {limit} entries")]
TooManyEntries { limit: usize },
#[error("malformed input: {0}")]
Malformed(String),
#[error("wrong passphrase or corrupted export")]
BadPassphrase,
#[error("export passphrase must be at least {min} characters")]
WeakPassphrase { min: usize },
#[error("unsupported export: {0}")]
Unsupported(String),
#[error("crypto failure")]
Crypto,
}
pub type Result<T> = std::result::Result<T, ImportError>;
fn malformed(e: impl std::fmt::Display) -> ImportError {
ImportError::Malformed(e.to_string())
}
/// Input caps. [`Limits::default`] is what the apps get; tests use smaller ones.
#[derive(Debug, Clone, Copy)]
pub struct Limits {
/// Raw input (zip, CSV or encrypted export).
pub max_input: u64,
/// Decompressed size of `export.data` inside a `.1pux`.
pub max_decompressed: u64,
/// Entries in a `.1pux` archive (including `files/…`).
pub max_entries: usize,
/// Items produced by one import.
pub max_items: usize,
}
impl Default for Limits {
fn default() -> Self {
Self {
max_input: 100 * 1024 * 1024,
max_decompressed: 64 * 1024 * 1024,
max_entries: 10_000,
max_items: 100_000,
}
}
}
impl Limits {
fn check_input(&self, len: usize) -> Result<()> {
if len as u64 > self.max_input {
return Err(ImportError::TooLarge { limit: self.max_input });
}
Ok(())
}
fn check_items(&self, n: usize) -> Result<()> {
if n > self.max_items {
return Err(ImportError::Malformed(format!("more than {} items", self.max_items)));
}
Ok(())
}
}
/// What to parse.
#[derive(Clone, Zeroize, ZeroizeOnDrop)]
pub enum Format {
/// 1Password Unencrypted Export (`.1pux`, a ZIP).
OnePux,
/// 1Password CSV or a generic `title,url,username,password,otp,notes` CSV.
Csv,
/// A Kestrove encrypted export ([`export`]).
Encrypted { passphrase: String },
}
/// Result of parsing. Items have fresh ids and `seq = 1`; nothing is stored yet.
#[derive(Default)]
pub struct Parsed {
pub items: Vec<Item>,
/// Human-readable, may name items or files, never contains a secret value.
pub warnings: Vec<String>,
pub attachments_skipped: u32,
}
/// Counts only: never prints item contents.
impl std::fmt::Debug for Parsed {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Parsed")
.field("items", &self.items.len())
.field("warnings", &self.warnings)
.field("attachments_skipped", &self.attachments_skipped)
.finish()
}
}
impl Parsed {
pub fn count(&self, kind: ItemKind) -> u32 {
self.items.iter().filter(|i| i.kind == kind).count() as u32
}
}
pub fn parse(bytes: &[u8], format: &Format, now: i64) -> Result<Parsed> {
parse_with_limits(bytes, format, now, &Limits::default())
}
pub fn parse_with_limits(bytes: &[u8], format: &Format, now: i64, limits: &Limits) -> Result<Parsed> {
limits.check_input(bytes.len())?;
let parsed = match format {
Format::OnePux => onepux::parse(bytes, now, limits)?,
Format::Csv => csv::parse(bytes, now, limits)?,
Format::Encrypted { passphrase } => {
let mut items = export::decrypt(bytes, passphrase)?;
limits.check_items(items.len())?;
for i in &mut items {
// A restored item is a new item in this vault: new id, fresh edit counter.
i.id = Uuid::new_v4();
i.seq = 1;
}
Parsed { items, ..Default::default() }
}
};
limits.check_items(parsed.items.len())?;
Ok(parsed)
}
/// A blank item of `kind`.
pub(crate) fn new_item(kind: ItemKind, title: &str, now: i64) -> Item {
let title = title.trim();
let mut item = Item::new_login(if title.is_empty() { "Untitled" } else { title }, now);
item.kind = kind;
item
}
/// Collects `label: value` lines that have no field of their own and appends them to the notes.
#[derive(Default)]
pub(crate) struct Extras {
lines: String,
}
impl Extras {
pub fn heading(&mut self, title: &str) {
let title = title.trim();
if !title.is_empty() {
if !self.lines.is_empty() {
self.lines.push('\n');
}
self.lines.push('[');
self.lines.push_str(title);
self.lines.push_str("]\n");
}
}
pub fn push(&mut self, label: &str, value: &str) {
if value.trim().is_empty() {
return;
}
let label = label.trim();
self.lines.push_str(if label.is_empty() { "Field" } else { label });
self.lines.push_str(": ");
self.lines.push_str(value);
self.lines.push('\n');
}
pub fn has_values(&self) -> bool {
self.lines.contains(": ")
}
/// Appends the collected lines under `banner` (only if any value was pushed).
pub fn append_to(mut self, item: &mut Item, banner: &str) {
if self.has_values() {
let mut notes = item.notes.take().unwrap_or_default();
let trimmed_len = notes.trim_end().len();
notes.truncate(trimmed_len);
if !notes.is_empty() {
notes.push_str("\n\n");
}
notes.push_str(banner);
notes.push('\n');
notes.push_str(self.lines.trim_end());
item.notes = Some(notes);
}
self.lines.zeroize();
}
}
impl Drop for Extras {
fn drop(&mut self) {
self.lines.zeroize();
}
}
/// Puts `s` into `slot` unless it is blank. Kept verbatim: a password may start or end with a space.
pub(crate) fn set_opt(slot: &mut Option<String>, s: &str) {
if !s.trim().is_empty() {
*slot = Some(s.to_string());
}
}
+297
View File
@@ -0,0 +1,297 @@
//! 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");
}
}