From 6cc7181a62516177a8d9908f9019bbe5a247b453 Mon Sep 17 00:00:00 2001 From: mashfromband Date: Sat, 10 Oct 2026 19:43:25 +0900 Subject: [PATCH 01/14] feat(core): add authenticated pbox v2 recovery and atomic rekey --- crates/passshare-core/src/crypto.rs | 109 +++- crates/passshare-core/src/password_box.rs | 389 +++++++++++- .../src/password_box_recovery_tests.rs | 574 ++++++++++++++++++ .../passshare-core/src/storage/file_format.rs | 56 +- documents/spec/local-key-recovery.md | 129 ++++ 5 files changed, 1217 insertions(+), 40 deletions(-) create mode 100644 crates/passshare-core/src/password_box_recovery_tests.rs create mode 100644 documents/spec/local-key-recovery.md diff --git a/crates/passshare-core/src/crypto.rs b/crates/passshare-core/src/crypto.rs index 040f4f03..0f65d1eb 100644 --- a/crates/passshare-core/src/crypto.rs +++ b/crates/passshare-core/src/crypto.rs @@ -1,15 +1,16 @@ use argon2::{Algorithm, Argon2, Params, Version}; use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; use chacha20poly1305::{ - aead::{Aead, KeyInit}, + aead::{Aead, KeyInit, Payload}, XChaCha20Poly1305, XNonce, }; use getrandom::fill; -use zeroize::{Zeroize, ZeroizeOnDrop}; +use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing}; use crate::error::PassShareError; pub const FILE_VERSION: u32 = 1; +pub const RECOVERY_FILE_VERSION: u32 = 2; pub const KDF_MEMORY_KIB: u32 = 65536; pub const KDF_TIME_COST: u32 = 3; pub const KDF_PARALLELISM: u32 = 4; @@ -28,9 +29,18 @@ pub struct MasterKey([u8; 32]); impl MasterKey { /// Generate an account root key locally, independently of authentication. pub fn generate() -> Result { - let mut key = [0u8; 32]; - fill(&mut key).map_err(|_| PassShareError::Internal("Key generation failed".into()))?; - Ok(Self(key)) + let mut key = Self([0u8; 32]); + fill(&mut key.0).map_err(|_| PassShareError::Internal("Key generation failed".into()))?; + Ok(key) + } + + pub(crate) fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() != 32 { + return Err(PassShareError::CorruptFile); + } + let mut key = Self([0; 32]); + key.0.copy_from_slice(bytes); + Ok(key) } pub fn as_bytes(&self) -> &[u8; 32] { @@ -47,17 +57,28 @@ pub fn derive_master_key_with_params( time_cost: u32, parallelism: u32, ) -> Result { - if memory_kib < KDF_MIN_MEMORY_KIB - || time_cost < KDF_MIN_TIME_COST - || parallelism < KDF_MIN_PARALLELISM + validate_kdf_params(memory_kib, time_cost, parallelism)?; + let params = Params::new(memory_kib, time_cost, parallelism, Some(32)) + .map_err(|_| PassShareError::CorruptFile)?; + let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); + let mut output = MasterKey([0u8; 32]); + argon2.hash_password_into(password.as_bytes(), salt, &mut output.0)?; + Ok(output) +} + +/// Bound untrusted header costs before either normal opening or recovery. +pub(crate) fn validate_kdf_params( + memory_kib: u32, + time_cost: u32, + parallelism: u32, +) -> Result<(), PassShareError> { + if !(KDF_MIN_MEMORY_KIB..=262144).contains(&memory_kib) + || !(KDF_MIN_TIME_COST..=10).contains(&time_cost) + || !(KDF_MIN_PARALLELISM..=16).contains(¶llelism) { return Err(PassShareError::CorruptFile); } - let params = Params::new(memory_kib, time_cost, parallelism, Some(32))?; - let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); - let mut output = [0u8; 32]; - argon2.hash_password_into(password.as_bytes(), salt, &mut output)?; - Ok(MasterKey(output)) + Ok(()) } pub fn derive_master_key(password: &str, salt: &[u8]) -> Result { @@ -77,9 +98,65 @@ pub fn generate_salt() -> Result<[u8; 16], PassShareError> { } pub fn generate_recovery_secret() -> Result { - let mut bytes = [0u8; RECOVERY_SECRET_BYTES]; - fill(&mut bytes).map_err(|e| PassShareError::Internal(e.to_string()))?; - Ok(BASE64.encode(bytes)) + let mut bytes = Zeroizing::new([0u8; RECOVERY_SECRET_BYTES]); + fill(bytes.as_mut()).map_err(|e| PassShareError::Internal(e.to_string()))?; + Ok(BASE64.encode(bytes.as_ref())) +} + +pub(crate) fn recovery_key(code: &str) -> Result { + let bytes = Zeroizing::new( + BASE64 + .decode(code) + .map_err(|_| PassShareError::CorruptFile)?, + ); + let canonical = Zeroizing::new(BASE64.encode(bytes.as_slice())); + if bytes.len() != RECOVERY_SECRET_BYTES || canonical.as_str() != code { + return Err(PassShareError::CorruptFile); + } + MasterKey::from_bytes(&bytes) +} + +pub(crate) fn encrypt_recovery_key( + master: &MasterKey, + recovery: &MasterKey, + aad: &[u8], +) -> Result<(Vec, [u8; 24]), PassShareError> { + let cipher = XChaCha20Poly1305::new_from_slice(recovery.as_bytes()) + .map_err(|_| PassShareError::CorruptFile)?; + let mut nonce = [0; 24]; + fill(&mut nonce).map_err(|e| PassShareError::Internal(e.to_string()))?; + let ciphertext = cipher + .encrypt( + &XNonce::from(nonce), + Payload { + msg: master.as_bytes(), + aad, + }, + ) + .map_err(|_| PassShareError::CorruptFile)?; + Ok((ciphertext, nonce)) +} + +pub(crate) fn decrypt_recovery_key( + ciphertext: &[u8], + recovery: &MasterKey, + nonce: &[u8; 24], + aad: &[u8], +) -> Result { + let cipher = XChaCha20Poly1305::new_from_slice(recovery.as_bytes()) + .map_err(|_| PassShareError::CorruptFile)?; + let bytes = Zeroizing::new( + cipher + .decrypt( + &XNonce::from(*nonce), + Payload { + msg: ciphertext, + aad, + }, + ) + .map_err(|_| PassShareError::CorruptFile)?, + ); + MasterKey::from_bytes(&bytes) } pub fn encrypt(plaintext: &[u8], key: &MasterKey) -> Result<(Vec, [u8; 24]), PassShareError> { diff --git a/crates/passshare-core/src/password_box.rs b/crates/passshare-core/src/password_box.rs index 2e7dd65e..8e06ac71 100644 --- a/crates/passshare-core/src/password_box.rs +++ b/crates/passshare-core/src/password_box.rs @@ -5,8 +5,9 @@ use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; use zeroize::{Zeroize, Zeroizing}; use crate::crypto::{ - decrypt, derive_master_key, derive_master_key_with_params, encrypt, generate_recovery_secret, - generate_salt, FILE_VERSION, KDF_MEMORY_KIB, KDF_PARALLELISM, KDF_TIME_COST, + decrypt, decrypt_recovery_key, derive_master_key, derive_master_key_with_params, encrypt, + encrypt_recovery_key, generate_recovery_secret, generate_salt, recovery_key, MasterKey, + KDF_MEMORY_KIB, KDF_PARALLELISM, KDF_TIME_COST, RECOVERY_FILE_VERSION, }; use crate::domain::connect_id::ConnectId; use crate::domain::item::Item; @@ -18,7 +19,7 @@ use crate::error::PassShareError; use crate::export::export_onepassword_csv; use crate::generator::{generate_password, PasswordGeneratorOptions}; use crate::import::import_onepassword_csv; -use crate::storage::file_format::{KdfParams, PasswordBoxFile, StoredCipher}; +use crate::storage::file_format::{KdfParams, PasswordBoxFile, RecoveryWrapper, StoredCipher}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PasswordBoxState { @@ -41,6 +42,12 @@ pub struct PasswordBox { state: PasswordBoxState, payload: PasswordBoxPayload, salt: Option<[u8; 16]>, + file_version: u32, + recovery_only: bool, + // Keep the authenticated JSON for fields not represented by the current DTO. + // It is merged at save, and its serialized buffer is scrubbed on teardown. + original_payload: Option>>, + source_fingerprint: Option<[u8; 32]>, } impl PasswordBox { @@ -59,6 +66,10 @@ impl PasswordBox { state: self.state, payload: self.payload.clone(), salt: self.salt, + file_version: self.file_version, + recovery_only: self.recovery_only, + original_payload: self.original_payload.clone(), + source_fingerprint: self.source_fingerprint, }; let count = match change { SavedChange::Add(item) => { @@ -81,20 +92,27 @@ impl PasswordBox { }; staged.save(master_password)?; std::mem::swap(&mut self.payload, &mut staged.payload); + std::mem::swap(&mut self.original_payload, &mut staged.original_payload); + std::mem::swap(&mut self.source_fingerprint, &mut staged.source_fingerprint); Ok(count) } pub fn create(path: impl AsRef, master_password: &str) -> Result { let path = path.as_ref().to_path_buf(); let vault_name = default_vault_name(&path); + let vault = Vault::new(vault_name)?; let salt = generate_salt()?; let recovery_secret = generate_recovery_secret()?; - let payload = PasswordBoxPayload::new(Vault::new(vault_name)?, Some(recovery_secret)); + let payload = PasswordBoxPayload::new(vault, Some(recovery_secret)); let mut pb = Self { path, state: PasswordBoxState::Unlocked, payload, salt: Some(salt), + file_version: RECOVERY_FILE_VERSION, + recovery_only: false, + original_payload: None, + source_fingerprint: None, }; pb.save(master_password)?; Ok(pb) @@ -114,16 +132,172 @@ impl PasswordBox { file.kdf.time_cost, file.kdf.parallelism, )?; - let nonce = file.cipher.nonce_bytes()?; - let plaintext = Zeroizing::new(decrypt(&file.ciphertext, &key, &nonce)?); - let vault_name = default_vault_name(&path); - let payload = deserialize_payload(&plaintext, &vault_name)?; - Ok(Self { + let pb = Self::open_with_key(path, &file, &key, false)?; + if file.version == RECOVERY_FILE_VERSION { + let code = pb + .payload + .recovery_secret + .as_deref() + .ok_or(PassShareError::CorruptFile)?; + let recovered = Self::unwrap_recovery_key(&file, code)?; + if recovered.as_bytes() != key.as_bytes() { + return Err(PassShareError::CorruptFile); + } + } + Ok(pb) + } + + /// Read-only recovery. No password or recovered master key is retained. + /// Call `rekey_recovered` explicitly before attempting any saved mutation. + pub fn recover(path: impl AsRef, code: &str) -> Result { + let path = path.as_ref().to_path_buf(); + let file = PasswordBoxFile::load_from_path(&path)?; + file.validate_version()?; + let key = Self::unwrap_recovery_key(&file, code)?; + let pb = Self::open_with_key(path, &file, &key, true)?; + if pb.payload.recovery_secret.as_deref() != Some(code) { + return Err(PassShareError::CorruptFile); + } + Ok(pb) + } + + fn unwrap_recovery_key( + file: &PasswordBoxFile, + code: &str, + ) -> Result { + let wrapper = file.recovery.as_ref().ok_or(PassShareError::CorruptFile)?; + let recovery = recovery_key(code)?; + decrypt_recovery_key( + &wrapper.ciphertext, + &recovery, + &wrapper.cipher.nonce_bytes()?, + &file.recovery_aad()?, + ) + } + + fn open_with_key( + path: PathBuf, + file: &PasswordBoxFile, + key: &MasterKey, + recovery_only: bool, + ) -> Result { + let plaintext = + Zeroizing::new(decrypt(&file.ciphertext, key, &file.cipher.nonce_bytes()?)?); + #[derive(serde::Deserialize)] + struct FormatBinding { + #[serde(default, rename = "localKeyRecoveryVersion")] + recovery_version: Option, + } + let binding: FormatBinding = + serde_json::from_slice(&plaintext).map_err(|_| PassShareError::CorruptFile)?; + if (file.version == RECOVERY_FILE_VERSION + && binding.recovery_version != Some(RECOVERY_FILE_VERSION)) + || (file.version != RECOVERY_FILE_VERSION && binding.recovery_version.is_some()) + { + return Err(PassShareError::CorruptFile); + } + // Construct a scrub-on-drop owner before fallible payload deserialization. + let mut pb = Self { path, state: PasswordBoxState::Unlocked, - payload, - salt: Some(salt), - }) + payload: PasswordBoxPayload::new(Vault::new("_")?, None), + salt: Some(file.kdf.salt_bytes()?), + file_version: file.version, + recovery_only, + original_payload: Some(plaintext), + source_fingerprint: Some(file.fingerprint()?), + }; + pb.payload = deserialize_payload( + pb.original_payload.as_ref().unwrap(), + &default_vault_name(&pb.path), + )?; + Ok(pb) + } + + /// Explicit v1 opt-in. Authentication and atomic save must succeed before + /// publishing the new version/code to this object. v2 uses the same code. + pub fn enable_recovery( + &mut self, + current_master_password: &str, + ) -> Result, PassShareError> { + self.ensure_unlocked()?; + if self.recovery_only { + return Err(PassShareError::InvalidMasterPassword); + } + let authenticated = Self::open(&self.path, current_master_password)?; + if authenticated.source_fingerprint != self.source_fingerprint { + return Err(PassShareError::CorruptFile); + } + let code = Zeroizing::new(match &self.payload.recovery_secret { + Some(code) => code.clone(), + None => generate_recovery_secret()?, + }); + recovery_key(&code)?; + let salt = self.salt.ok_or(PassShareError::CorruptFile)?; + let saved = self.write_with_password( + current_master_password, + salt, + RECOVERY_FILE_VERSION, + Some(&code), + )?; + self.publish_saved(saved); + if let Some(previous) = &mut self.payload.recovery_secret { + previous.zeroize(); + } + self.payload.recovery_secret = Some(code.to_string()); + self.file_version = RECOVERY_FILE_VERSION; + Ok(code) + } + + /// Atomically re-encrypt a recovered box with a fresh salt and new password. + /// The recovery code is intentionally reusable until explicitly rotated. + pub fn rekey_recovered(&mut self, new_password: &str) -> Result<(), PassShareError> { + self.ensure_unlocked()?; + if !self.recovery_only { + return Err(PassShareError::CorruptFile); + } + if new_password.is_empty() { + return Err(PassShareError::PasswordRequired); + } + let salt = generate_salt()?; + let saved = self.write_with_password( + new_password, + salt, + RECOVERY_FILE_VERSION, + self.payload.recovery_secret.as_deref(), + )?; + self.publish_saved(saved); + self.salt = Some(salt); + self.recovery_only = false; + Ok(()) + } + + /// Replace the code on the current file; older backups retain their old code. + pub fn rotate_recovery( + &mut self, + current_master_password: &str, + ) -> Result, PassShareError> { + self.ensure_unlocked()?; + if self.recovery_only || self.file_version != RECOVERY_FILE_VERSION { + return Err(PassShareError::CorruptFile); + } + let authenticated = Self::open(&self.path, current_master_password)?; + if authenticated.source_fingerprint != self.source_fingerprint { + return Err(PassShareError::CorruptFile); + } + let code = Zeroizing::new(generate_recovery_secret()?); + let saved = self.write_with_password( + current_master_password, + self.salt.ok_or(PassShareError::CorruptFile)?, + RECOVERY_FILE_VERSION, + Some(&code), + )?; + self.publish_saved(saved); + if let Some(previous) = &mut self.payload.recovery_secret { + previous.zeroize(); + } + self.payload.recovery_secret = Some(code.to_string()); + Ok(code) } pub fn state(&self) -> PasswordBoxState { @@ -277,17 +451,32 @@ impl PasswordBox { pub fn save(&mut self, master_password: &str) -> Result<(), PassShareError> { self.ensure_unlocked()?; + if self.recovery_only { + return Err(PassShareError::InvalidMasterPassword); + } let salt = self.salt.ok_or(PassShareError::CorruptFile)?; + let saved = self.write_with_password( + master_password, + salt, + self.file_version, + self.payload.recovery_secret.as_deref(), + )?; + self.publish_saved(saved); + Ok(()) + } + + fn write_with_password( + &self, + master_password: &str, + salt: [u8; 16], + version: u32, + code: Option<&str>, + ) -> Result<(Zeroizing>, [u8; 32]), PassShareError> { let key = derive_master_key(master_password, &salt)?; - self.payload.sync_vault_item_count(); - // Serializing our own in-memory payload: a failure is an internal bug. - let plaintext = Zeroizing::new( - serde_json::to_vec(&self.payload) - .map_err(|e| PassShareError::Internal(e.to_string()))?, - ); + let plaintext = self.serialize_preserving_extensions(version, code)?; let (ciphertext, nonce) = encrypt(&plaintext, &key)?; - let file = PasswordBoxFile { - version: FILE_VERSION, + let mut file = PasswordBoxFile { + version, kdf: KdfParams { algorithm: "argon2id".to_string(), salt: BASE64.encode(salt), @@ -300,9 +489,72 @@ impl PasswordBox { nonce: BASE64.encode(nonce), }, ciphertext, + recovery: None, }; + if version == RECOVERY_FILE_VERSION { + let recovery = recovery_key(code.ok_or(PassShareError::CorruptFile)?)?; + let (ciphertext, nonce) = encrypt_recovery_key(&key, &recovery, &file.recovery_aad()?)?; + file.recovery = Some(RecoveryWrapper { + cipher: StoredCipher { + algorithm: "xchacha20poly1305".into(), + nonce: BASE64.encode(nonce), + }, + ciphertext, + }); + } + let fingerprint = file.fingerprint()?; + if let Some(expected) = self.source_fingerprint { + let current = PasswordBoxFile::load_from_path(&self.path)?; + if current.fingerprint()? != expected { + return Err(PassShareError::CorruptFile); + } + } file.save_to_path(&self.path)?; - Ok(()) + Ok((plaintext, fingerprint)) + } + + fn publish_saved(&mut self, (plaintext, fingerprint): (Zeroizing>, [u8; 32])) { + self.original_payload = Some(plaintext); + self.source_fingerprint = Some(fingerprint); + } + + fn serialize_preserving_extensions( + &self, + version: u32, + code: Option<&str>, + ) -> Result>, PassShareError> { + let mut current = SecretJson( + serde_json::to_value(&self.payload) + .map_err(|e| PassShareError::Internal(e.to_string()))?, + ); + if version == RECOVERY_FILE_VERSION { + current.0["localKeyRecoveryVersion"] = serde_json::json!(RECOVERY_FILE_VERSION); + } + if let Some(code) = code { + if let Some(previous) = current.0.get_mut("recoverySecret") { + scrub_json(previous); + } + current.0["recoverySecret"] = serde_json::Value::String(code.to_owned()); + } else if let Some(object) = current.0.as_object_mut() { + if let Some(mut removed) = object.remove("recoverySecret") { + scrub_json(&mut removed); + } + } + let mut original = SecretJson(match &self.original_payload { + Some(bytes) => { + serde_json::from_slice(bytes).map_err(|_| PassShareError::CorruptFile)? + } + None => serde_json::Value::Null, + }); + // Legacy plaintext is migrated to the typed payload rather than merged. + if original.0.get("vault").is_none() { + scrub_json(&mut original.0); + original.0 = serde_json::Value::Null; + } + merge_extensions(&mut current.0, &original.0, "payload"); + Ok(Zeroizing::new( + serde_json::to_vec(¤t.0).map_err(|e| PassShareError::Internal(e.to_string()))?, + )) } pub fn lock(&mut self) { @@ -314,6 +566,9 @@ impl PasswordBox { self.payload = PasswordBoxPayload::new(vault, None); } self.salt = None; + self.original_payload = None; + self.recovery_only = false; + self.source_fingerprint = None; } /// Scrub every plaintext secret still held in the in-memory payload. @@ -336,6 +591,7 @@ impl PasswordBox { if let Some(secret) = self.payload.recovery_secret.as_mut() { secret.zeroize(); } + self.original_payload = None; } pub fn generate_password(options: PasswordGeneratorOptions) -> String { @@ -383,6 +639,95 @@ use std::str::FromStr; #[path = "password_box_saved_tests.rs"] mod saved_change_tests; +#[cfg(test)] +#[path = "password_box_recovery_tests.rs"] +mod recovery_tests; + +/// Ensure temporary JSON copies, including unknown secret fields, are scrubbed. +struct SecretJson(serde_json::Value); + +impl Drop for SecretJson { + fn drop(&mut self) { + scrub_json(&mut self.0); + } +} + +fn scrub_json(value: &mut serde_json::Value) { + match value { + serde_json::Value::String(s) => s.zeroize(), + serde_json::Value::Array(values) => values.iter_mut().for_each(scrub_json), + serde_json::Value::Object(values) => values.values_mut().for_each(scrub_json), + _ => {} + } +} + +fn merge_extensions(current: &mut serde_json::Value, original: &serde_json::Value, context: &str) { + use serde_json::Value; + match (current, original) { + (Value::Object(current), Value::Object(original)) => { + let known: &[&str] = match context { + "payload" => &[ + "payloadVersion", + "vault", + "items", + "recoverySecret", + "localKeyRecoveryVersion", + ], + "vault" => &[ + "id", + "name", + "description", + "type", + "items", + "createdAt", + "updatedAt", + "contentVersion", + "attributeVersion", + ], + "items" => &[ + "id", + "title", + "category", + "vault", + "fields", + "sections", + "urls", + "tags", + "favorite", + "createdAt", + "updatedAt", + "lastEditedBy", + ], + "fields" => &["id", "label", "type", "purpose", "value", "section"], + "sections" => &["id", "label"], + "urls" => &["href", "primary", "label"], + "section" => &["id"], + _ => &[], + }; + for (name, previous) in original { + if let Some(value) = current.get_mut(name) { + merge_extensions(value, previous, name); + } else if !known.contains(&name.as_str()) { + current.insert(name.clone(), previous.clone()); + } + } + } + (Value::Array(current), Value::Array(original)) + if matches!(context, "items" | "fields" | "sections" | "urls") => + { + let key = if context == "urls" { "href" } else { "id" }; + for value in current { + if let Some(previous) = original.iter().find(|previous| { + value.get(key).is_some() && previous.get(key) == value.get(key) + }) { + merge_extensions(value, previous, context); + } + } + } + _ => {} + } +} + #[cfg(test)] mod tests { use super::*; @@ -636,7 +981,7 @@ mod tests { PasswordBox::create(&path, "correct-password").unwrap(); let mut stored = PasswordBoxFile::load_from_path(&path).unwrap(); - stored.version = FILE_VERSION + 1; + stored.version = RECOVERY_FILE_VERSION + 1; stored.save_to_path(&path).unwrap(); assert_eq!( diff --git a/crates/passshare-core/src/password_box_recovery_tests.rs b/crates/passshare-core/src/password_box_recovery_tests.rs new file mode 100644 index 00000000..6c3dc5ec --- /dev/null +++ b/crates/passshare-core/src/password_box_recovery_tests.rs @@ -0,0 +1,574 @@ +use super::*; +use sha2::{Digest, Sha256}; +use std::fs; +use tempfile::TempDir; + +const OLD_PASSWORD: &str = "synthetic-old-password"; +const NEW_PASSWORD: &str = "synthetic-new-password"; +const FIXED_CODE: &str = "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc="; + +// Frozen historical v1 wire bytes: independent of current create, encrypt and KDF. +// Synthetic plaintext uses a fixed DTO, salt [1; 16], nonce [2; 24] and FIXED_CODE. +fn write_v1(path: &Path) { + let json = r#"{"version":1,"kdf":{"algorithm":"argon2id","salt":"AQEBAQEBAQEBAQEBAQEBAQ==","memory_kib":65536,"time_cost":3,"parallelism":4},"cipher":{"algorithm":"xchacha20poly1305","nonce":"AgICAgICAgICAgICAgICAgICAgICAgIC"},"ciphertext":"uk2E9pzsrI6RYnpuSfPcLalhiP2iy3MLWWGWhiN7gZ/Zwa5/UUX8Is4Zb9XV8JHe+kKJZfVzOxULib9Y49G2Z5/SQ32T+WGBNABjYp0E2GKJPtSy2YFpZ9xoMyRv/TPVEziLPj+pVBFR4VCsdRLT3wbjed26usQeSin+4gD8c5LyaEeL2Axop4Ur3VVvVcW0MANAOcjiO7doM/q4Ryv+e6Q938NpFZal4t3nU+NZaHcB95mArO0rZxd+NsnGyZAQf0KMWwUayw9tuqaemFRljdYQXOsQlx4Nu5tFV3mnq0wFK/t2m7vLeX1rtLJFZQNMSX/anrEJQQ/8vLAtkIGTto0cU7XQ/qn0yOYvmh8h+aueRspvSUzBo2IPuCkH+b1Wrwsa9RHwewKypAEVvp+ZuIOu0HdM2G4Vz6if3z/j9Q=="}"#; + fs::write(path, json).unwrap(); +} + +fn add_complete_item(pb: &mut PasswordBox) { + let mut item = Item::new_login( + pb.vault().unwrap().id.clone(), + "Synthetic", + "synthetic-secret", + ) + .unwrap(); + item.urls = vec![ + crate::domain::item::ItemUrl { + href: "https://example.test/a".into(), + primary: true, + label: "A".into(), + }, + crate::domain::item::ItemUrl { + href: "https://example.test/b".into(), + primary: false, + label: "B".into(), + }, + ]; + item.tags = vec!["日本語".into(), "keep,comma".into()]; + item.favorite = true; + item.last_edited_by = Some("synthetic-editor".into()); + pb.add_item(item).unwrap(); + // Simulate authenticated future/extra Item JSON without changing the DTO. + let mut original = SecretJson(serde_json::to_value(&pb.payload).unwrap()); + original.0["items"][0]["files"] = serde_json::json!([{"id":"synthetic-attachment","contentPath":"attachments/ref","size":42}]); + original.0["items"][0]["version"] = serde_json::json!(17); + original.0["items"][0]["extra"] = serde_json::json!({"nested":["synthetic-extra"]}); + original.0["items"][0]["fields"][0]["extraField"] = serde_json::json!("synthetic-field"); + original.0["items"][0]["urls"][0]["extraUrl"] = serde_json::json!("keep-url"); + original.0["extraRoot"] = serde_json::json!("keep-root"); + pb.original_payload = Some(Zeroizing::new(serde_json::to_vec(&original.0).unwrap())); +} + +fn raw_payload(path: &Path, password: &str) -> SecretJson { + let file = PasswordBoxFile::load_from_path(path).unwrap(); + let key = derive_master_key_with_params( + password, + &file.kdf.salt_bytes().unwrap(), + file.kdf.memory_kib, + file.kdf.time_cost, + file.kdf.parallelism, + ) + .unwrap(); + let bytes = Zeroizing::new( + decrypt(&file.ciphertext, &key, &file.cipher.nonce_bytes().unwrap()).unwrap(), + ); + SecretJson(serde_json::from_slice(&bytes).unwrap()) +} + +#[test] +fn v2_restart_recover_rekey_preserves_full_json_and_code() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("box.pbox"); + let backup = dir.path().join("backup.pbox"); + let mut pb = PasswordBox::create(&path, OLD_PASSWORD).unwrap(); + add_complete_item(&mut pb); + pb.save(OLD_PASSWORD).unwrap(); + let code = Zeroizing::new(pb.recovery_secret().unwrap().unwrap().to_owned()); + let expected = pb.payload.clone(); + let raw_before = raw_payload(&path, OLD_PASSWORD); + let before = fs::read(&path).unwrap(); + fs::copy(&path, &backup).unwrap(); + assert_eq!(PasswordBoxFile::load_from_path(&path).unwrap().version, 2); + drop(pb); + let mut recovered = PasswordBox::recover(&path, &code).unwrap(); + assert_eq!(recovered.payload, expected); + assert_eq!(fs::read(&path).unwrap(), before); + assert_eq!( + recovered.save(NEW_PASSWORD), + Err(PassShareError::InvalidMasterPassword) + ); + let item = Item::new_secure_note( + recovered.vault().unwrap().id.clone(), + "Blocked save", + "Synthetic", + ) + .unwrap(); + assert_eq!( + recovered.apply_and_save(SavedChange::Add(&item), NEW_PASSWORD), + Err(PassShareError::InvalidMasterPassword) + ); + assert_eq!(recovered.payload, expected); + assert_eq!(fs::read(&path).unwrap(), before); + recovered.rekey_recovered(NEW_PASSWORD).unwrap(); + drop(recovered); + assert_eq!( + PasswordBox::open(&path, OLD_PASSWORD).err(), + Some(PassShareError::InvalidMasterPassword) + ); + let mut reopened = PasswordBox::open(&path, NEW_PASSWORD).unwrap(); + assert_eq!(reopened.payload, expected); + reopened.save(NEW_PASSWORD).unwrap(); + assert_eq!(raw_payload(&path, NEW_PASSWORD).0, raw_before.0); + assert_eq!( + PasswordBox::recover(&path, &code).unwrap().payload, + expected + ); + assert_eq!( + PasswordBox::open(&backup, OLD_PASSWORD).unwrap().payload, + expected + ); + assert_eq!( + PasswordBox::recover(&backup, &code).unwrap().payload, + expected + ); +} + +#[test] +fn fixed_v1_stays_v1_until_authenticated_explicit_upgrade() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("legacy.pbox"); + let backup = dir.path().join("backup.pbox"); + write_v1(&path); + fs::copy(&path, &backup).unwrap(); + assert!(PasswordBox::recover(&path, FIXED_CODE).is_err()); + let mut pb = PasswordBox::open(&path, OLD_PASSWORD).unwrap(); + pb.save(OLD_PASSWORD).unwrap(); + assert_eq!(PasswordBoxFile::load_from_path(&path).unwrap().version, 1); + let before = fs::read(&path).unwrap(); + assert_eq!( + pb.enable_recovery("wrong").err(), + Some(PassShareError::InvalidMasterPassword) + ); + assert_eq!(fs::read(&path).unwrap(), before); + let code = pb.enable_recovery(OLD_PASSWORD).unwrap(); + assert_eq!(code.as_str(), FIXED_CODE); + assert_eq!(PasswordBoxFile::load_from_path(&path).unwrap().version, 2); + let mut recovered = PasswordBox::recover(&path, &code).unwrap(); + assert_eq!(recovered.payload, pb.payload); + recovered.rekey_recovered(NEW_PASSWORD).unwrap(); + assert!(PasswordBox::open(&path, OLD_PASSWORD).is_err()); + assert!(PasswordBox::open(&path, NEW_PASSWORD).is_ok()); + assert_eq!(PasswordBoxFile::load_from_path(&backup).unwrap().version, 1); + assert!(PasswordBox::open(&backup, OLD_PASSWORD).is_ok()); + assert!(PasswordBox::recover(&backup, &code).is_err()); +} + +#[test] +fn upgrade_without_legacy_code_generates_one() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("legacy.pbox"); + write_v1(&path); + let mut pb = PasswordBox::open(&path, OLD_PASSWORD).unwrap(); + pb.payload.recovery_secret = None; + pb.save(OLD_PASSWORD).unwrap(); + drop(pb); + let mut pb = PasswordBox::open(&path, OLD_PASSWORD).unwrap(); + let code = pb.enable_recovery(OLD_PASSWORD).unwrap(); + assert_eq!(BASE64.decode(code.as_str()).unwrap().len(), 32); + assert!(PasswordBox::recover(&path, &code).is_ok()); +} + +#[test] +fn wrong_code_cross_file_wrapper_and_primary_tampering_are_rejected() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("a.pbox"); + let other = dir.path().join("b.pbox"); + let pb = PasswordBox::create(&path, OLD_PASSWORD).unwrap(); + let code = Zeroizing::new(pb.recovery_secret().unwrap().unwrap().to_owned()); + let second = PasswordBox::create(&other, OLD_PASSWORD).unwrap(); + let other_code = Zeroizing::new(second.recovery_secret().unwrap().unwrap().to_owned()); + assert!(PasswordBox::recover(&path, &other_code).is_err()); + assert!(PasswordBox::recover(&path, "not-base64").is_err()); + assert!(PasswordBox::recover(&path, code.trim_end_matches('=')).is_err()); + let original = PasswordBoxFile::load_from_path(&path).unwrap(); + let other_file = PasswordBoxFile::load_from_path(&other).unwrap(); + let mut mixed = original.clone(); + mixed.recovery = other_file.recovery; + mixed.save_to_path(&path).unwrap(); + assert!(PasswordBox::recover(&path, &other_code).is_err()); + assert!(PasswordBox::open(&path, OLD_PASSWORD).is_err()); + for mutate in [ + (|f: &mut PasswordBoxFile| f.ciphertext[0] ^= 1) as fn(&mut PasswordBoxFile), + |f| f.kdf.salt = BASE64.encode([9; 16]), + |f| f.kdf.time_cost += 1, + |f| f.cipher.nonce = BASE64.encode([9; 24]), + |f| f.recovery.as_mut().unwrap().ciphertext[0] ^= 1, + |f| f.recovery.as_mut().unwrap().cipher.nonce = BASE64.encode([9; 24]), + ] { + let mut altered = original.clone(); + mutate(&mut altered); + altered.save_to_path(&path).unwrap(); + assert!(PasswordBox::recover(&path, &code).is_err()); + assert!(PasswordBox::open(&path, OLD_PASSWORD).is_err()); + } +} + +#[test] +fn malformed_header_shapes_algorithms_versions_and_kdf_costs_rejected() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("box.pbox"); + let pb = PasswordBox::create(&path, OLD_PASSWORD).unwrap(); + let code = Zeroizing::new(pb.recovery_secret().unwrap().unwrap().to_owned()); + let original = PasswordBoxFile::load_from_path(&path).unwrap(); + for mutate in [ + (|f: &mut PasswordBoxFile| f.version = 3) as fn(&mut PasswordBoxFile), + |f| f.version = 1, + |f| { + f.version = 1; + f.recovery = None; + }, + |f| f.recovery = None, + |f| f.kdf.memory_kib = 1024, + |f| f.kdf.memory_kib = u32::MAX, + |f| f.kdf.time_cost = 1, + |f| f.kdf.time_cost = u32::MAX, + |f| f.kdf.parallelism = 0, + |f| f.kdf.parallelism = u32::MAX, + |f| f.kdf.algorithm = "unknown".into(), + |f| f.cipher.algorithm = "unknown".into(), + |f| f.recovery.as_mut().unwrap().cipher.algorithm = "unknown".into(), + |f| f.kdf.salt = "AAAA".into(), + |f| f.cipher.nonce = "AAAA".into(), + |f| f.recovery.as_mut().unwrap().cipher.nonce = "AAAA".into(), + |f| { + f.recovery + .as_mut() + .unwrap() + .ciphertext + .pop() + .map(|_| ()) + .unwrap() + }, + ] { + let mut changed = original.clone(); + mutate(&mut changed); + changed.save_to_path(&path).unwrap(); + assert_eq!( + PasswordBox::open(&path, OLD_PASSWORD).err(), + Some(PassShareError::CorruptFile) + ); + assert_eq!( + PasswordBox::recover(&path, &code).err(), + Some(PassShareError::CorruptFile) + ); + } + for field in ["root", "kdf", "cipher", "recovery", "wrapperCipher"] { + let mut json = serde_json::to_value(&original).unwrap(); + match field { + "root" => json["unknown"] = serde_json::json!(1), + "wrapperCipher" => json["recovery"]["cipher"]["unknown"] = serde_json::json!(1), + _ => json[field]["unknown"] = serde_json::json!(1), + } + fs::write(&path, serde_json::to_vec(&json).unwrap()).unwrap(); + assert_eq!( + PasswordBox::recover(&path, &code).err(), + Some(PassShareError::CorruptFile) + ); + assert_eq!( + PasswordBox::open(&path, OLD_PASSWORD).err(), + Some(PassShareError::CorruptFile) + ); + } +} + +fn failure_preserves_original(upgrade: bool) { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("box.pbox"); + let backup = dir.path().join("before.pbox"); + let (mut pb, code) = if upgrade { + write_v1(&path); + ( + PasswordBox::open(&path, OLD_PASSWORD).unwrap(), + Zeroizing::new(FIXED_CODE.to_owned()), + ) + } else { + let pb = PasswordBox::create(&path, OLD_PASSWORD).unwrap(); + let code = Zeroizing::new(pb.recovery_secret().unwrap().unwrap().to_owned()); + (PasswordBox::recover(&path, &code).unwrap(), code) + }; + let payload = pb.payload.clone(); + let salt = pb.salt; + let version = pb.file_version; + let before = fs::read(&path).unwrap(); + let hash = Sha256::digest(&before); + fs::rename(&path, &backup).unwrap(); + fs::create_dir(&path).unwrap(); + let failure = if upgrade { + pb.enable_recovery(OLD_PASSWORD).map(|_| ()) + } else { + pb.rekey_recovered(NEW_PASSWORD) + }; + assert!(failure.is_err()); + assert_eq!(pb.payload, payload); + assert_eq!(pb.salt, salt); + assert_eq!(pb.file_version, version); + assert_eq!(fs::read(&backup).unwrap(), before); + assert_eq!(Sha256::digest(fs::read(&backup).unwrap()), hash); + assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 2); + assert_eq!(fs::read_dir(&path).unwrap().count(), 0); + fs::remove_dir(&path).unwrap(); + fs::rename(&backup, &path).unwrap(); + let restarted = PasswordBox::open(&path, OLD_PASSWORD).unwrap(); + assert_eq!(restarted.payload, payload); + if upgrade { + pb.enable_recovery(OLD_PASSWORD).unwrap(); + } else { + assert_eq!(PasswordBox::recover(&path, &code).unwrap().payload, payload); + assert!(pb.recovery_only); + pb.rekey_recovered(NEW_PASSWORD).unwrap(); + } + assert!(PasswordBox::recover(&path, &code).is_ok()); +} + +#[test] +fn failed_rekey_preserves_exact_bytes_and_is_retryable() { + failure_preserves_original(false); +} + +#[test] +fn failed_upgrade_preserves_exact_bytes_and_is_retryable() { + failure_preserves_original(true); +} + +// Omit FILE_SHARE_DELETE: reads and password verification still work, but the +// actual atomic replacement fails against the original file on Windows. +#[cfg(windows)] +#[test] +fn real_atomic_failures_leave_the_original_path_and_state_unchanged() { + use std::os::windows::fs::OpenOptionsExt; + let dir = TempDir::new().unwrap(); + let path = dir.path().join("box.pbox"); + write_v1(&path); + let mut legacy = PasswordBox::open(&path, OLD_PASSWORD).unwrap(); + let before = fs::read(&path).unwrap(); + let hash = Sha256::digest(&before); + let held = fs::OpenOptions::new() + .read(true) + .share_mode(3) + .open(&path) + .unwrap(); + assert!(matches!( + legacy.enable_recovery(OLD_PASSWORD), + Err(PassShareError::Internal(_)) + )); + assert_eq!(legacy.file_version, 1); + assert_eq!(fs::read(&path).unwrap(), before); + assert_eq!(Sha256::digest(fs::read(&path).unwrap()), hash); + assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1); + assert!(PasswordBox::open(&path, OLD_PASSWORD).is_ok()); + drop(held); + let code = legacy.enable_recovery(OLD_PASSWORD).unwrap(); + let before = fs::read(&path).unwrap(); + let mut recovered = PasswordBox::recover(&path, &code).unwrap(); + let salt = recovered.salt; + let held = fs::OpenOptions::new() + .read(true) + .share_mode(3) + .open(&path) + .unwrap(); + assert!(matches!( + recovered.rekey_recovered(NEW_PASSWORD), + Err(PassShareError::Internal(_)) + )); + assert!(recovered.recovery_only); + assert_eq!(recovered.salt, salt); + assert_eq!(fs::read(&path).unwrap(), before); + assert_eq!( + Sha256::digest(fs::read(&path).unwrap()), + Sha256::digest(&before) + ); + assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1); + assert!(PasswordBox::open(&path, OLD_PASSWORD).is_ok()); + assert!(PasswordBox::recover(&path, &code).is_ok()); + assert!(PasswordBox::open(&path, NEW_PASSWORD).is_err()); + assert!(legacy.rotate_recovery(OLD_PASSWORD).is_err()); + assert_eq!(legacy.recovery_secret().unwrap(), Some(code.as_str())); + assert_eq!(fs::read(&path).unwrap(), before); + assert!(legacy.save(OLD_PASSWORD).is_err()); + assert_eq!(fs::read(&path).unwrap(), before); + drop(held); + recovered.rekey_recovered(NEW_PASSWORD).unwrap(); + assert!(PasswordBox::open(&path, NEW_PASSWORD).is_ok()); + assert!(PasswordBox::recover(&path, &code).is_ok()); +} + +#[test] +fn rotation_invalidates_current_code_and_preserves_backup_access() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("box.pbox"); + let backup = dir.path().join("backup.pbox"); + let mut pb = PasswordBox::create(&path, OLD_PASSWORD).unwrap(); + let old_code = Zeroizing::new(pb.recovery_secret().unwrap().unwrap().to_owned()); + fs::copy(&path, &backup).unwrap(); + let before = fs::read(&path).unwrap(); + assert!(pb.rotate_recovery("wrong").is_err()); + assert_eq!(fs::read(&path).unwrap(), before); + let new_code = pb.rotate_recovery(OLD_PASSWORD).unwrap(); + assert_ne!(new_code.as_str(), old_code.as_str()); + assert!(PasswordBox::recover(&path, &old_code).is_err()); + assert!(PasswordBox::recover(&path, &new_code).is_ok()); + assert!(PasswordBox::open(&path, OLD_PASSWORD).is_ok()); + assert!(PasswordBox::recover(&backup, &old_code).is_ok()); + assert!(PasswordBox::recover(&backup, &new_code).is_err()); +} + +#[test] +fn recovered_lock_and_drop_scrub_owned_buffers_and_return_no_secrets() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("box.pbox"); + let mut pb = PasswordBox::create(&path, OLD_PASSWORD).unwrap(); + add_complete_item(&mut pb); + pb.save(OLD_PASSWORD).unwrap(); + let code = Zeroizing::new(pb.recovery_secret().unwrap().unwrap().to_owned()); + let mut recovered = PasswordBox::recover(&path, &code).unwrap(); + recovered.zeroize_secrets(); + assert!(recovered + .payload + .items + .iter() + .flat_map(|item| &item.fields) + .all(|field| field.value.is_empty())); + assert_eq!(recovered.payload.recovery_secret.as_deref(), Some("")); + assert!(recovered.original_payload.is_none()); + recovered.lock(); + assert_eq!(recovered.recovery_secret(), Err(PassShareError::BoxLocked)); + assert_eq!( + recovered.list_connect_items(), + Err(PassShareError::BoxLocked) + ); + assert_eq!( + recovered.rekey_recovered(NEW_PASSWORD), + Err(PassShareError::BoxLocked) + ); + drop(recovered); + let recovered = PasswordBox::recover(&path, &code).unwrap(); + drop(recovered); // Existing shared scrub observer covers actual Drop execution. +} + +#[test] +fn unknown_item_fields_survive_but_removed_known_fields_stay_removed() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("box.pbox"); + let mut pb = PasswordBox::create(&path, OLD_PASSWORD).unwrap(); + add_complete_item(&mut pb); + pb.save(OLD_PASSWORD).unwrap(); + drop(pb); + let mut pb = PasswordBox::open(&path, OLD_PASSWORD).unwrap(); + let mut edited = pb.payload.items[0].clone(); + edited.urls.clear(); + edited.tags.clear(); + edited.last_edited_by = None; + edited.fields[0].purpose = None; + let id = edited.id.to_string(); + pb.apply_and_save(SavedChange::Update(&edited), OLD_PASSWORD) + .unwrap(); + let raw = raw_payload(&path, OLD_PASSWORD); + assert!(raw.0["items"][0].get("urls").is_none()); + assert!(raw.0["items"][0].get("tags").is_none()); + assert!(raw.0["items"][0].get("lastEditedBy").is_none()); + assert!(raw.0["items"][0]["fields"][0].get("purpose").is_none()); + assert_eq!(raw.0["items"][0]["version"], 17); + assert_eq!( + raw.0["items"][0]["files"][0]["contentPath"], + "attachments/ref" + ); + pb.apply_and_save(SavedChange::Delete(&id), OLD_PASSWORD) + .unwrap(); + assert_eq!( + raw_payload(&path, OLD_PASSWORD).0["items"], + serde_json::json!([]) + ); +} + +#[test] +fn stale_boxes_cannot_overwrite_a_saved_update_or_rotated_code() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("box.pbox"); + let mut current = PasswordBox::create(&path, OLD_PASSWORD).unwrap(); + let code = Zeroizing::new(current.recovery_secret().unwrap().unwrap().to_owned()); + let mut stale = PasswordBox::open(&path, OLD_PASSWORD).unwrap(); + let mut recovered = PasswordBox::recover(&path, &code).unwrap(); + let item = + Item::new_secure_note(current.vault().unwrap().id.clone(), "Saved", "Synthetic").unwrap(); + current + .apply_and_save(SavedChange::Add(&item), OLD_PASSWORD) + .unwrap(); + let before = fs::read(&path).unwrap(); + assert_eq!( + stale.enable_recovery(OLD_PASSWORD).err(), + Some(PassShareError::CorruptFile) + ); + assert_eq!( + stale.rotate_recovery(OLD_PASSWORD).err(), + Some(PassShareError::CorruptFile) + ); + assert_eq!(stale.save(OLD_PASSWORD), Err(PassShareError::CorruptFile)); + assert_eq!( + recovered.rekey_recovered(NEW_PASSWORD), + Err(PassShareError::CorruptFile) + ); + assert_eq!(fs::read(&path).unwrap(), before); + assert!(recovered.recovery_only); + let mut recovered = PasswordBox::recover(&path, &code).unwrap(); + let new_code = current.rotate_recovery(OLD_PASSWORD).unwrap(); + let before = fs::read(&path).unwrap(); + assert_eq!( + recovered.rekey_recovered(NEW_PASSWORD), + Err(PassShareError::CorruptFile) + ); + assert_eq!(fs::read(&path).unwrap(), before); + assert!(PasswordBox::recover(&path, &new_code).is_ok()); +} + +#[test] +fn binding_rejects_a_wrapper_with_correct_key_and_different_payload_code() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("box.pbox"); + PasswordBox::create(&path, OLD_PASSWORD).unwrap(); + let mut file = PasswordBoxFile::load_from_path(&path).unwrap(); + let key = derive_master_key(OLD_PASSWORD, &file.kdf.salt_bytes().unwrap()).unwrap(); + let wrong_recovery = recovery_key(FIXED_CODE).unwrap(); + let (ciphertext, nonce) = + encrypt_recovery_key(&key, &wrong_recovery, &file.recovery_aad().unwrap()).unwrap(); + file.recovery = Some(RecoveryWrapper { + cipher: StoredCipher { + algorithm: "xchacha20poly1305".into(), + nonce: BASE64.encode(nonce), + }, + ciphertext, + }); + file.save_to_path(&path).unwrap(); + // Unwrapping and primary decryption both succeed; payload/code binding must fail. + assert_eq!( + PasswordBox::unwrap_recovery_key(&file, FIXED_CODE) + .unwrap() + .as_bytes(), + key.as_bytes() + ); + assert_eq!( + PasswordBox::recover(&path, FIXED_CODE).err(), + Some(PassShareError::CorruptFile) + ); + assert_eq!( + PasswordBox::open(&path, OLD_PASSWORD).err(), + Some(PassShareError::CorruptFile) + ); +} + +#[test] +fn cross_file_wrapper_is_rejected_even_with_identical_master_and_recovery_keys() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("a.pbox"); + let other = dir.path().join("b.pbox"); + let first = PasswordBox::create(&path, OLD_PASSWORD).unwrap(); + let code = Zeroizing::new(first.recovery_secret().unwrap().unwrap().to_owned()); + let mut second = PasswordBox::create(&other, OLD_PASSWORD).unwrap(); + second.salt = first.salt; + second.payload.recovery_secret = Some(code.to_string()); + second.save(OLD_PASSWORD).unwrap(); + assert!(PasswordBox::recover(&other, &code).is_ok()); + let mut original = PasswordBoxFile::load_from_path(&path).unwrap(); + original.recovery = PasswordBoxFile::load_from_path(&other).unwrap().recovery; + original.save_to_path(&path).unwrap(); + assert!(PasswordBox::recover(&path, &code).is_err()); + assert!(PasswordBox::open(&path, OLD_PASSWORD).is_err()); +} diff --git a/crates/passshare-core/src/storage/file_format.rs b/crates/passshare-core/src/storage/file_format.rs index 2c43e159..092e2cea 100644 --- a/crates/passshare-core/src/storage/file_format.rs +++ b/crates/passshare-core/src/storage/file_format.rs @@ -4,12 +4,14 @@ use std::path::Path; use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use tempfile::NamedTempFile; -use crate::crypto::FILE_VERSION; +use crate::crypto::{validate_kdf_params, FILE_VERSION, RECOVERY_FILE_VERSION}; use crate::error::PassShareError; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] pub struct KdfParams { pub algorithm: String, pub salt: String, @@ -25,6 +27,7 @@ impl KdfParams { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] pub struct StoredCipher { pub algorithm: String, pub nonce: String, @@ -37,12 +40,23 @@ impl StoredCipher { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct RecoveryWrapper { + pub cipher: StoredCipher, + #[serde(with = "base64_bytes")] + pub ciphertext: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] pub struct PasswordBoxFile { pub version: u32, pub kdf: KdfParams, pub cipher: StoredCipher, #[serde(with = "base64_bytes")] pub ciphertext: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub recovery: Option, } impl PasswordBoxFile { @@ -103,14 +117,51 @@ impl PasswordBoxFile { } pub fn validate_version(&self) -> Result<(), PassShareError> { - if self.version != FILE_VERSION { + if !matches!(self.version, FILE_VERSION | RECOVERY_FILE_VERSION) + || (self.version == FILE_VERSION && self.recovery.is_some()) + || (self.version == RECOVERY_FILE_VERSION && self.recovery.is_none()) + { return Err(PassShareError::CorruptFile); } if self.kdf.algorithm != "argon2id" || self.cipher.algorithm != "xchacha20poly1305" { return Err(PassShareError::CorruptFile); } + validate_kdf_params( + self.kdf.memory_kib, + self.kdf.time_cost, + self.kdf.parallelism, + )?; + self.kdf.salt_bytes()?; + self.cipher.nonce_bytes()?; + if let Some(wrapper) = &self.recovery { + if wrapper.cipher.algorithm != "xchacha20poly1305" || wrapper.ciphertext.len() != 48 { + return Err(PassShareError::CorruptFile); + } + wrapper.cipher.nonce_bytes()?; + } Ok(()) } + + /// Bind the wrapper to the complete primary envelope, not only its nonce. + /// Tuple serialization fixes field order; ciphertext is hashed incrementally. + pub(crate) fn recovery_aad(&self) -> Result, PassShareError> { + let header = serde_json::to_vec(&(&self.kdf, &self.cipher)) + .map_err(|e| PassShareError::Internal(e.to_string()))?; + let mut hash = Sha256::new(); + hash.update((header.len() as u64).to_be_bytes()); + hash.update(header); + hash.update(&self.ciphertext); + let mut aad = b"PassShare/pbox/recovery-master-key/v2\0".to_vec(); + aad.extend_from_slice(&self.version.to_be_bytes()); + aad.extend_from_slice(&hash.finalize()); + Ok(aad) + } + + pub(crate) fn fingerprint(&self) -> Result<[u8; 32], PassShareError> { + let bytes = + serde_json::to_vec(self).map_err(|e| PassShareError::Internal(e.to_string()))?; + Ok(Sha256::digest(bytes).into()) + } } /// fsync the directory that just received a rename so the directory entry is @@ -186,6 +237,7 @@ mod tests { nonce: BASE64.encode([2u8; 24]), }, ciphertext, + recovery: None, } } diff --git a/documents/spec/local-key-recovery.md b/documents/spec/local-key-recovery.md new file mode 100644 index 00000000..8e923bb6 --- /dev/null +++ b/documents/spec/local-key-recovery.md @@ -0,0 +1,129 @@ +# 端末内のパスワードボックスの復旧 + +共有 ToDo 1163 / 1176、M6-B の Rust Core 実装仕様。アカウントの復旧とは別の機能である。 +この仕様は端末内の `.pbox` と架空データの検証を対象とする。画面・SDK・クラウドへの接続、 +製品全体の受入、販売の承認は含まない。 + +## 保存方式と互換性 + +新規作成は暗号ラッパー version 2。本文の形式を表す `payloadVersion` とは区別する。 +本文は従来どおり、マスターパスワードと Argon2id から得た 32 バイトの鍵で +XChaCha20-Poly1305 により一度だけ暗号化する。本文を複製しない。 + +version 2 は `recovery` に別の XChaCha20-Poly1305 暗号文と nonce を持つ。 +この暗号文は本文鍵の 32 バイトを封じたもの(認証タグ込み 48 バイト)である。 +鍵には OS の乱数から生成した 256 ビットの復旧コードを直接使用する。 +入力形式はパディング付きの標準 Base64 を正規化した 44 文字で、空白や省略形式は受け付けない。 +復旧コードを知らなければ、ヘッダーだけから本文鍵を得ることはできない。 +復旧コードはパスワードと同じく秘密として管理する。 + +復旧暗号文の associated data は、次を連結する。 + +1. 固定用途 `PassShare/pbox/recovery-master-key/v2` と末尾の NUL。 +2. ファイル version の 4 バイト big-endian 表現。 +3. 主暗号ヘッダーと主暗号本文の SHA-256 ハッシュ。 + +ハッシュの入力は `serde_json` による `(kdf, cipher)` の配列形式の直列化である。 +その長さを 8 バイト big-endian で先頭に置き、続けてヘッダーのバイト列と +主暗号文のバイト列を入力する。構造体のフィールド順が直列化順となる。 +KDF の方式・salt・コスト、主暗号の方式・nonce、主暗号文の全体が束縛される。 +本文、ヘッダー、別ファイルの復旧暗号文を差し替えると復旧は失敗する。 +通常のパスワードで開く場合も、本文内のコードで復旧暗号文を検証し、本文鍵との一致を確認する。 +復旧で開く場合は本文内のコードと入力コードの一致を確認する。 +本文内の `localKeyRecoveryVersion: 2` も照合する。ヘッダーの version を 1 に変更して +復旧暗号文を削除しても、暗号化本文に残る値との不一致により通常の読み込みを拒否する。 + +version 1 は引き続き読める。通常の `save` では version 1 を維持し、復旧を勝手に有効化しない。 +version 1 の本文にコードが記録されていても、明示的に有効化するまでは復旧に使えない。 +従来の version 1 アプリは version 2 を `CorruptFile` として拒否するため、 +復旧情報を無言で落として通常保存することはない。 + +ヘッダーとその子構造体では未知フィールドを拒否する。 +version 1 に復旧暗号文がある場合、version 2 に復旧暗号文がない場合、 +未知の version / 暗号方式、salt / nonce の長さ不一致、復旧暗号文の長さ不一致も拒否する。 +Argon2id の受入範囲はメモリ 19,456〜262,144 KiB、反復 2〜10、並列数 1〜16 とする。 +通常の新規保存は従来の 65,536 KiB、反復 3、並列数 4 を使用する。 +復旧の経路でも KDF の範囲を検査するが、パスワードによる導出は実行しない。 + +## Rust の呼び出し + +`PasswordBox::create(path, master_password)` は version 2 と復旧コードを生成する。 +既存の `recovery_secret()` は開いている間だけコードの参照を返す。 + +`enable_recovery(current_master_password)` は保存済みファイルで現在のパスワードを再確認し、 +version 1 を version 2 に保存する。本文内の正規なコードを再利用し、コードがなければ生成する。 +version 2 への再実行でもコードを変更しない。返り値は `Zeroizing`。 +失敗時はオブジェクトの version / コードを公開前の状態に保つ。 + +`PasswordBox::recover(path, code)` はマスターパスワードなしで開く。 +この呼び出しはファイルを書き換えず、取得した本文鍵もオブジェクトに保持しない。 +項目の読み取りとメモリ上の編集はできるが、`save` / `apply_and_save` は +`InvalidMasterPassword` として拒否する。復旧で開いたことによる誤保存を防ぐためであり、 +保存に渡したパスワードが正しいかどうかを検査した結果ではない。 + +`rekey_recovered(new_password)` は復旧で開いたオブジェクトだけに使用できる。 +空のパスワードは拒否する。新しい salt と新しいパスワードで本文鍵を導出し、本文と復旧暗号文を作り直す。 +原本と同じディレクトリの暗号文専用一時ファイルを fsync 後、原本へ atomic に置き換える。 +成功後に通常保存できる状態へ移る。失敗時には、原本とオブジェクトの salt / 状態を保持し、再試行できる。 +古いパスワードは変更済みのファイルを開けない。 + +復旧コードは再暗号化後も再利用でき、使っただけでは消費しない。 +`rotate_recovery(current_master_password)` はパスワードを再確認し、現在のファイルに新しいコードを設定する。 +変更済みファイルで古いコードは使えない。古いバックアップは元の version とパスワードで開け、 +復旧が有効だったバックアップは元のコードでも開ける。 +現在のファイルでコードを変更しても、既に他者が持つ古いコピーへのアクセスは取り消せない。 + +読み込み時と保存成功時に暗号ラッパー全体の SHA-256 を記録し、置換の直前に原本と照合する。 +別のオブジェクトが先に保存した場合は `CorruptFile` として上書きを拒否し、再読み込みを求める。 +この確認は複数プロセス間の排他ロックではないため、同時書き込みを直列化する機構は呼び出し側に必要である。 + +## データと秘密の保持 + +型付きの項目、URL、フィールド、項目群の属性、本文 version を保持する。 +認証済みの元 JSON も `Zeroizing>` に保持し、現在の DTO で表していない +項目の追加情報、添付ファイルの参照、項目 version を保存時に合成する。 +添付ファイル本体は `.pbox` に追加しない。未知項目は参照として保持するだけで解釈しない。 +既知フィールドの削除と項目の削除を、元 JSON から復活させない。 +従来の LoginItem 本文は既存の移行処理で型付き本文へ変換する。 + +本文鍵、復旧用の鍵、乱数生成途中の秘密、復号したバイト列、一時 JSON の文字列、 +保持中のフィールド値とコードを所有者の drop / lock / 失敗時に消去する。 +`lock()` 後は項目とコードの参照を返さない。呼び出し側が取得したコピーは呼び出し側の責任で消去する。 +既存 DTO の文字列再割当てなどにより既に解放されたコピーまで消去できるとは保証しない。 + +## 架空データでの受入 + +`password_box_recovery_tests.rs` は次を検査する。 + +- 新規 version 2 作成、保存、再起動、パスワードなしの復旧、新パスワードによる再暗号化。 +- 既知 DTO と追加 JSON・URL・添付参照・項目 version の保持、古いパスワードの拒否。 +- 独立した version 1 形式の固定 fixture、version 維持、明示的な復旧有効化。 +- 誤コード、別ファイルの復旧暗号文、主ヘッダー・本文・復旧暗号文の改変の拒否。 +- 本文鍵と復旧鍵が同じ別ファイルからの混在、本文と入力コードの不一致、v1 への偽装の拒否。 +- KDF の下限・上限、nonce・暗号方式・version・未知ヘッダー構造の拒否。 +- 保存失敗時の原本のバイト列と SHA-256 の一致、再起動と再試行。 +- 復旧コードの再利用・変更、古いバックアップへのアクセス、lock と drop。 +- 古い読み込み状態からの保存・復旧有効化・コード変更・再暗号化による上書きの拒否。 + +Rust Core と依存先の関連検査、Clippy を同じ専用生成先で順に実施する。 +最終 workspace 全件の検証は親担当がマージ前に一度実施する。 + +### 2026-10-10 の実行記録 + +| 検査 | 結果 | +| --- | --- | +| `cargo nextest run --locked -p passshare-core -p passshare-connect -p op-cli --no-fail-fast` | 132 passed / 0 failed / 0 skipped。run ID `478efab3-9837-4ff3-b802-ff908a5fe902` | +| `cargo clippy --locked -p passshare-core --all-targets -- -D warnings` | 終了コード 0 | +| 別モデル `gpt-6-luna` による独立レビューと限定再レビュー | Must 修正後、Must 0。作成時の秘密生成順序を修正。古い状態からの上書き防止も反映 | + +いずれも Windows 上で、D ドライブの専用生成先、共有 Cargo ゲート、共有 sccache を使用した。 +新しい依存は追加していない。DB は使用していない。 + +途中の失敗は隠さず記録する。初回 nextest は旧 LoginItem 形式を再読み込みした際の +`assertion left == right failed` で 1 件失敗した。既存移行処理が項目群の ID を毎回生成する点を確認し、 +認証時の同一性確認を暗号ファイルの指紋に変更した。v1 の受入には固定された過去の暗号ラッパーと +型付き本文の fixture を用い、最終検査で成功した。 +初回 Clippy は `manual_range_contains` が 3 件あり、範囲の `contains` へ修正して成功した。 + +workspace 全件・全 doctest・CI・旧アプリの実機起動・画面との接続はこの実装ジョブでは未検証。 +親担当の統合と販売判定の完了を表す記録ではない。 From ae07962d492996e54f953ca9b59ce417376a1b14 Mon Sep 17 00:00:00 2001 From: mashfromband Date: Sat, 10 Oct 2026 19:54:11 +0900 Subject: [PATCH 02/14] test: update save-order mutation for recovered payload metadata --- documents/evidence/1197-save-before-commit-regression.patch | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/documents/evidence/1197-save-before-commit-regression.patch b/documents/evidence/1197-save-before-commit-regression.patch index a4bc742b..c4d5d41d 100644 --- a/documents/evidence/1197-save-before-commit-regression.patch +++ b/documents/evidence/1197-save-before-commit-regression.patch @@ -1,8 +1,10 @@ diff --git a/crates/passshare-core/src/password_box.rs b/crates/passshare-core/src/password_box.rs --- a/crates/passshare-core/src/password_box.rs +++ b/crates/passshare-core/src/password_box.rs -@@ -89,3 +89,3 @@ +@@ -94,5 +94,5 @@ - staged.save(master_password)?; std::mem::swap(&mut self.payload, &mut staged.payload); + std::mem::swap(&mut self.original_payload, &mut staged.original_payload); + std::mem::swap(&mut self.source_fingerprint, &mut staged.source_fingerprint); + self.save(master_password)?; Ok(count) From 1b84a19e9808491675fefa41d8ed8bf1529fd6ec Mon Sep 17 00:00:00 2001 From: mashfromband Date: Sat, 10 Oct 2026 20:02:20 +0900 Subject: [PATCH 03/14] fix(core): preserve reference and duplicate URL extensions during recovery --- crates/passshare-core/src/crypto.rs | 18 +++-- crates/passshare-core/src/domain/payload.rs | 43 +++++++++-- crates/passshare-core/src/password_box.rs | 22 +++++- .../src/password_box_recovery_tests.rs | 77 +++++++++++++++++++ documents/spec/local-key-recovery.md | 4 + 5 files changed, 146 insertions(+), 18 deletions(-) diff --git a/crates/passshare-core/src/crypto.rs b/crates/passshare-core/src/crypto.rs index 0f65d1eb..3b809183 100644 --- a/crates/passshare-core/src/crypto.rs +++ b/crates/passshare-core/src/crypto.rs @@ -104,16 +104,18 @@ pub fn generate_recovery_secret() -> Result { } pub(crate) fn recovery_key(code: &str) -> Result { - let bytes = Zeroizing::new( - BASE64 - .decode(code) - .map_err(|_| PassShareError::CorruptFile)?, - ); - let canonical = Zeroizing::new(BASE64.encode(bytes.as_slice())); - if bytes.len() != RECOVERY_SECRET_BYTES || canonical.as_str() != code { + if code.len() != 44 { return Err(PassShareError::CorruptFile); } - MasterKey::from_bytes(&bytes) + let mut bytes = Zeroizing::new([0u8; RECOVERY_SECRET_BYTES]); + let count = BASE64 + .decode_slice(code, bytes.as_mut()) + .map_err(|_| PassShareError::CorruptFile)?; + let canonical = Zeroizing::new(BASE64.encode(bytes.as_ref())); + if count != RECOVERY_SECRET_BYTES || canonical.as_str() != code { + return Err(PassShareError::CorruptFile); + } + MasterKey::from_bytes(bytes.as_ref()) } pub(crate) fn encrypt_recovery_key( diff --git a/crates/passshare-core/src/domain/payload.rs b/crates/passshare-core/src/domain/payload.rs index b7e5604d..515fc536 100644 --- a/crates/passshare-core/src/domain/payload.rs +++ b/crates/passshare-core/src/domain/payload.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use zeroize::Zeroize; use super::item::Item; use super::login_item::LoginItem; @@ -52,7 +53,10 @@ enum RawPasswordBoxPayload { } impl RawPasswordBoxPayload { - fn into_payload(self, vault_name: &str) -> Result { + fn into_payload( + self, + vault_name: &str, + ) -> Result { match self { RawPasswordBoxPayload::V2(payload) => Ok(payload), RawPasswordBoxPayload::Legacy(legacy) => { @@ -62,14 +66,18 @@ impl RawPasswordBoxPayload { } } -pub fn deserialize_payload(bytes: &[u8], vault_name: &str) -> Result { +pub fn deserialize_payload( + bytes: &[u8], + vault_name: &str, +) -> Result { // Bytes here are already decrypted (the AEAD auth tag verified), so they came // from a genuine PassShare box. Distinguish two failure modes: // * invalid JSON / structure -> CorruptFile // * valid JSON but a newer -> UnsupportedVersion // `payloadVersion` we don't understand - let value: serde_json::Value = - serde_json::from_slice(bytes).map_err(|_| crate::error::PassShareError::CorruptFile)?; + let value = ParsedPlaintext( + serde_json::from_slice(bytes).map_err(|_| crate::error::PassShareError::CorruptFile)?, + ); // Peek the version BEFORE the typed parse: `payload_version` is a plain field, // so a newer-but-structurally-compatible payload would otherwise silently @@ -78,6 +86,7 @@ pub fn deserialize_payload(bytes: &[u8], vault_name: &str) -> Result Result text.zeroize(), + serde_json::Value::Array(values) => values.iter_mut().for_each(clear), + serde_json::Value::Object(values) => { + for (mut key, mut value) in std::mem::take(values) { + key.zeroize(); + clear(&mut value); + } + } + _ => {} + } + } + clear(&mut self.0); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/passshare-core/src/password_box.rs b/crates/passshare-core/src/password_box.rs index 8e06ac71..3f7a7bf9 100644 --- a/crates/passshare-core/src/password_box.rs +++ b/crates/passshare-core/src/password_box.rs @@ -684,6 +684,7 @@ fn merge_extensions(current: &mut serde_json::Value, original: &serde_json::Valu "contentVersion", "attributeVersion", ], + "vault-ref" => &["id"], "items" => &[ "id", "title", @@ -706,7 +707,14 @@ fn merge_extensions(current: &mut serde_json::Value, original: &serde_json::Valu }; for (name, previous) in original { if let Some(value) = current.get_mut(name) { - merge_extensions(value, previous, name); + // The payload owns a full Vault; an Item owns only a + // reference. Preserve extensions on that narrower DTO. + let nested = if context == "items" && name == "vault" { + "vault-ref" + } else { + name + }; + merge_extensions(value, previous, nested); } else if !known.contains(&name.as_str()) { current.insert(name.clone(), previous.clone()); } @@ -716,10 +724,16 @@ fn merge_extensions(current: &mut serde_json::Value, original: &serde_json::Valu if matches!(context, "items" | "fields" | "sections" | "urls") => { let key = if context == "urls" { "href" } else { "id" }; + let mut used = vec![false; original.len()]; for value in current { - if let Some(previous) = original.iter().find(|previous| { - value.get(key).is_some() && previous.get(key) == value.get(key) - }) { + if let Some((index, previous)) = + original.iter().enumerate().find(|(index, previous)| { + !used[*index] + && value.get(key).is_some() + && previous.get(key) == value.get(key) + }) + { + used[index] = true; merge_extensions(value, previous, context); } } diff --git a/crates/passshare-core/src/password_box_recovery_tests.rs b/crates/passshare-core/src/password_box_recovery_tests.rs index 6c3dc5ec..942ad497 100644 --- a/crates/passshare-core/src/password_box_recovery_tests.rs +++ b/crates/passshare-core/src/password_box_recovery_tests.rs @@ -64,6 +64,83 @@ fn raw_payload(path: &Path, password: &str) -> SecretJson { SecretJson(serde_json::from_slice(&bytes).unwrap()) } +#[test] +fn first_save_and_rekey_preserve_reference_extensions_and_duplicate_url_metadata() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("extensions.pbox"); + let mut pb = PasswordBox::create(&path, OLD_PASSWORD).unwrap(); + add_complete_item(&mut pb); + let code = Zeroizing::new(pb.recovery_secret().unwrap().unwrap().to_owned()); + // Seed independently authenticated bytes, before the merge implementation + // gets a chance to omit anything during its first save. + let mut full = SecretJson(serde_json::to_value(&pb.payload).unwrap()); + full.0["localKeyRecoveryVersion"] = serde_json::json!(2); + full.0["items"][0]["vault"]["name"] = serde_json::json!("synthetic-reference-name"); + full.0["items"][0]["vault"]["contentVersion"] = serde_json::json!(91); + full.0["items"][0]["urls"] = serde_json::json!([ + {"href":"https://example.invalid/same","primary":true,"label":"First","extension":"first-only"}, + {"href":"https://example.invalid/same","primary":false,"label":"Second","extension":"second-only"} + ]); + let mut file = PasswordBoxFile::load_from_path(&path).unwrap(); + let key = derive_master_key_with_params( + OLD_PASSWORD, + &file.kdf.salt_bytes().unwrap(), + file.kdf.memory_kib, + file.kdf.time_cost, + file.kdf.parallelism, + ) + .unwrap(); + let bytes = Zeroizing::new(serde_json::to_vec(&full.0).unwrap()); + let (ciphertext, nonce) = encrypt(&bytes, &key).unwrap(); + file.ciphertext = ciphertext; + file.cipher.nonce = BASE64.encode(nonce); + let (ciphertext, nonce) = encrypt_recovery_key( + &key, + &recovery_key(&code).unwrap(), + &file.recovery_aad().unwrap(), + ) + .unwrap(); + file.recovery = Some(RecoveryWrapper { + cipher: StoredCipher { + algorithm: "xchacha20poly1305".into(), + nonce: BASE64.encode(nonce), + }, + ciphertext, + }); + file.save_to_path(&path).unwrap(); + drop(pb); + let original = raw_payload(&path, OLD_PASSWORD); + assert_eq!( + original.0["items"][0]["vault"]["name"], + "synthetic-reference-name" + ); + assert_eq!( + original.0["items"][0]["urls"][1]["extension"], + "second-only" + ); + let mut opened = PasswordBox::open(&path, OLD_PASSWORD).unwrap(); + opened.save(OLD_PASSWORD).unwrap(); + drop(opened); + let saved = raw_payload(&path, OLD_PASSWORD); + assert_eq!( + saved.0["items"][0]["vault"], + original.0["items"][0]["vault"] + ); + assert_eq!(saved.0["items"][0]["urls"], original.0["items"][0]["urls"]); + let mut recovered = PasswordBox::recover(&path, &code).unwrap(); + recovered.rekey_recovered(NEW_PASSWORD).unwrap(); + drop(recovered); + let rekeyed = raw_payload(&path, NEW_PASSWORD); + assert_eq!( + rekeyed.0["items"][0]["vault"], + original.0["items"][0]["vault"] + ); + assert_eq!( + rekeyed.0["items"][0]["urls"], + original.0["items"][0]["urls"] + ); +} + #[test] fn v2_restart_recover_rekey_preserves_full_json_and_code() { let dir = TempDir::new().unwrap(); diff --git a/documents/spec/local-key-recovery.md b/documents/spec/local-key-recovery.md index 8e923bb6..5ca10003 100644 --- a/documents/spec/local-key-recovery.md +++ b/documents/spec/local-key-recovery.md @@ -79,6 +79,10 @@ version 2 への再実行でもコードを変更しない。返り値は `Zeroi ## データと秘密の保持 +認証済みJSONを借用して型へ変換し、無視された追加情報を含む一時JSONの文字列とキーを +破棄時に消去する。入力・原本・自分が所有するJSONのバッファを対象とし、serde内部の +一時割当や、メモリー全体に残るすべてのコピーを消去できるとは保証しない。 + 型付きの項目、URL、フィールド、項目群の属性、本文 version を保持する。 認証済みの元 JSON も `Zeroizing>` に保持し、現在の DTO で表していない 項目の追加情報、添付ファイルの参照、項目 version を保存時に合成する。 From 91f7f45415f5df12e47d699250f10d440a8570c8 Mon Sep 17 00:00:00 2001 From: mashfromband Date: Sat, 10 Oct 2026 20:26:43 +0900 Subject: [PATCH 04/14] fix(core): keep URL extensions attached across reorder and deletion --- apps/desktop/src-tauri/src/lib.rs | 7 +++ crates/passshare-core/src/domain/item.rs | 45 +++++++++++++++---- crates/passshare-core/src/import/op_csv.rs | 7 +-- crates/passshare-core/src/password_box.rs | 20 ++++++++- .../src/password_box_recovery_tests.rs | 29 +++++++++++- .../src/password_box_saved_tests.rs | 2 + 6 files changed, 94 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 4c019491..83743f3c 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -520,6 +520,7 @@ fn update_primary_url(urls: &mut Vec, url: Option) { urls[index].href = href; } None if !href.is_empty() => urls.push(ItemUrl { + extensions: Default::default(), label: String::new(), primary: true, href, @@ -838,16 +839,19 @@ mod smoke_tests { ); original.urls = vec![ ItemUrl { + extensions: Default::default(), label: "Secondary".into(), primary: false, href: "https://secondary.example/".into(), }, ItemUrl { + extensions: Default::default(), label: "Primary".into(), primary: true, href: "https://primary.example/".into(), }, ItemUrl { + extensions: Default::default(), label: "Other primary".into(), primary: true, href: "https://third.example/".into(), @@ -907,6 +911,7 @@ mod smoke_tests { assert_eq!( empty, vec![ItemUrl { + extensions: Default::default(), label: String::new(), primary: true, href: "https://new.example/".into() @@ -938,11 +943,13 @@ mod smoke_tests { original.fields.push(extra); original.urls = vec![ ItemUrl { + extensions: Default::default(), label: "Secondary".into(), primary: false, href: "https://secondary.example/".into(), }, ItemUrl { + extensions: Default::default(), label: "Primary".into(), primary: true, href: "https://primary.example/".into(), diff --git a/crates/passshare-core/src/domain/item.rs b/crates/passshare-core/src/domain/item.rs index b7d3b8e0..bc34859b 100644 --- a/crates/passshare-core/src/domain/item.rs +++ b/crates/passshare-core/src/domain/item.rs @@ -34,6 +34,9 @@ pub struct ItemUrl { #[serde(default)] pub primary: bool, pub href: String, + /// Preserve properties on the URL itself when it moves or is removed. + #[serde(flatten)] + pub extensions: std::collections::BTreeMap, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -76,7 +79,8 @@ impl Item { let now = Utc::now(); let fields = vec![ Field::new("username", FieldType::String, "")?.with_purpose(FieldPurpose::Username), - Field::new("password", FieldType::Concealed, password)?.with_purpose(FieldPurpose::Password), + Field::new("password", FieldType::Concealed, password)? + .with_purpose(FieldPurpose::Password), Field::new("notes", FieldType::String, "")?, ]; @@ -112,7 +116,8 @@ impl Item { let now = Utc::now(); let fields = vec![ - Field::new("password", FieldType::Concealed, password)?.with_purpose(FieldPurpose::Password), + Field::new("password", FieldType::Concealed, password)? + .with_purpose(FieldPurpose::Password), Field::new("notes", FieldType::String, "")?.with_purpose(FieldPurpose::Notes), ]; @@ -144,7 +149,9 @@ impl Item { } let now = Utc::now(); - let fields = vec![Field::new("notesPlain", FieldType::String, body)?.with_purpose(FieldPurpose::Notes)]; + let fields = + vec![Field::new("notesPlain", FieldType::String, body)? + .with_purpose(FieldPurpose::Notes)]; Ok(Self { id: ConnectId::generate()?, @@ -170,6 +177,7 @@ impl Item { ) -> Result { if let Some(url) = url.filter(|u| !u.is_empty()) { self.urls.push(ItemUrl { + extensions: Default::default(), label: String::new(), primary: true, href: url, @@ -277,22 +285,35 @@ impl Item { self.touch(); } - pub fn set_field_by_purpose(&mut self, purpose: FieldPurpose, value: &str) -> Result<(), PassShareError> { + pub fn set_field_by_purpose( + &mut self, + purpose: FieldPurpose, + value: &str, + ) -> Result<(), PassShareError> { if let Some(field) = self.fields.iter_mut().find(|f| f.purpose == Some(purpose)) { field.value = value.to_string(); self.touch(); return Ok(()); } - Err(PassShareError::Internal(format!("missing field purpose {:?}", purpose))) + Err(PassShareError::Internal(format!( + "missing field purpose {:?}", + purpose + ))) } pub fn set_field_by_label(&mut self, label: &str, value: &str) -> Result<(), PassShareError> { - if let Some(field) = self.fields.iter_mut().find(|f| f.label.eq_ignore_ascii_case(label)) { + if let Some(field) = self + .fields + .iter_mut() + .find(|f| f.label.eq_ignore_ascii_case(label)) + { field.value = value.to_string(); self.touch(); return Ok(()); } - Err(PassShareError::Internal(format!("missing field label {label}"))) + Err(PassShareError::Internal(format!( + "missing field label {label}" + ))) } fn touch(&mut self) { @@ -313,7 +334,10 @@ impl Item { || self .field_value_by_purpose(FieldPurpose::Notes) .is_some_and(|v| v.to_lowercase().contains(&q)) - || self.fields.iter().any(|f| f.value.to_lowercase().contains(&q)) + || self + .fields + .iter() + .any(|f| f.value.to_lowercase().contains(&q)) } } @@ -352,6 +376,9 @@ mod tests { let note = Item::new_secure_note(vault.id, "Memo", "hello").unwrap(); assert_eq!(note.category, ItemCategory::SecureNote); - assert_eq!(note.field_value_by_label("notesPlain"), Some("hello".into())); + assert_eq!( + note.field_value_by_label("notesPlain"), + Some("hello".into()) + ); } } diff --git a/crates/passshare-core/src/import/op_csv.rs b/crates/passshare-core/src/import/op_csv.rs index 4ced0b37..4c848459 100644 --- a/crates/passshare-core/src/import/op_csv.rs +++ b/crates/passshare-core/src/import/op_csv.rs @@ -26,11 +26,7 @@ pub fn import_onepassword_csv(vault: &Vault, csv_data: &str) -> Result let mut items = Vec::new(); for result in reader.records() { let record = result.map_err(|e| PassShareError::ImportError(e.to_string()))?; - let title = record - .get(title_idx) - .unwrap_or("") - .trim() - .to_string(); + let title = record.get(title_idx).unwrap_or("").trim().to_string(); if title.is_empty() { continue; } @@ -60,6 +56,7 @@ pub fn import_onepassword_csv(vault: &Vault, csv_data: &str) -> Result if let Some(idx) = column_map.get("url").or_else(|| column_map.get("website")) { if let Some(url) = record.get(*idx).filter(|v| !v.is_empty()) { item.urls.push(ItemUrl { + extensions: Default::default(), label: String::new(), primary: true, href: url.to_string(), diff --git a/crates/passshare-core/src/password_box.rs b/crates/passshare-core/src/password_box.rs index 3f7a7bf9..863f56a2 100644 --- a/crates/passshare-core/src/password_box.rs +++ b/crates/passshare-core/src/password_box.rs @@ -622,6 +622,14 @@ fn scrub_item_fields(item: &mut Item) { for field in &mut item.fields { field.value.zeroize(); } + for url in &mut item.urls { + url.href.zeroize(); + url.label.zeroize(); + for (mut key, mut value) in std::mem::take(&mut url.extensions) { + key.zeroize(); + scrub_json(&mut value); + } + } #[cfg(test)] saved_change_tests::observe_scrubbed_item(item); } @@ -656,12 +664,22 @@ fn scrub_json(value: &mut serde_json::Value) { match value { serde_json::Value::String(s) => s.zeroize(), serde_json::Value::Array(values) => values.iter_mut().for_each(scrub_json), - serde_json::Value::Object(values) => values.values_mut().for_each(scrub_json), + serde_json::Value::Object(values) => { + for (mut key, mut value) in std::mem::take(values) { + key.zeroize(); + scrub_json(&mut value); + } + } _ => {} } } fn merge_extensions(current: &mut serde_json::Value, original: &serde_json::Value, context: &str) { + // URLs now carry their own extensions. Never infer identity from href or + // reinsert properties that a caller explicitly removed from that value. + if context == "urls" { + return; + } use serde_json::Value; match (current, original) { (Value::Object(current), Value::Object(original)) => { diff --git a/crates/passshare-core/src/password_box_recovery_tests.rs b/crates/passshare-core/src/password_box_recovery_tests.rs index 942ad497..c25d68a0 100644 --- a/crates/passshare-core/src/password_box_recovery_tests.rs +++ b/crates/passshare-core/src/password_box_recovery_tests.rs @@ -23,11 +23,13 @@ fn add_complete_item(pb: &mut PasswordBox) { .unwrap(); item.urls = vec![ crate::domain::item::ItemUrl { + extensions: Default::default(), href: "https://example.test/a".into(), primary: true, label: "A".into(), }, crate::domain::item::ItemUrl { + extensions: Default::default(), href: "https://example.test/b".into(), primary: false, label: "B".into(), @@ -44,6 +46,9 @@ fn add_complete_item(pb: &mut PasswordBox) { original.0["items"][0]["extra"] = serde_json::json!({"nested":["synthetic-extra"]}); original.0["items"][0]["fields"][0]["extraField"] = serde_json::json!("synthetic-field"); original.0["items"][0]["urls"][0]["extraUrl"] = serde_json::json!("keep-url"); + pb.payload.items[0].urls[0] + .extensions + .insert("extraUrl".into(), serde_json::json!("keep-url")); original.0["extraRoot"] = serde_json::json!("keep-root"); pb.original_payload = Some(Zeroizing::new(serde_json::to_vec(&original.0).unwrap())); } @@ -127,6 +132,28 @@ fn first_save_and_rekey_preserve_reference_extensions_and_duplicate_url_metadata original.0["items"][0]["vault"] ); assert_eq!(saved.0["items"][0]["urls"], original.0["items"][0]["urls"]); + let mut reordered = PasswordBox::open(&path, OLD_PASSWORD).unwrap(); + reordered.payload.items[0].urls.reverse(); + reordered.save(OLD_PASSWORD).unwrap(); + drop(reordered); + let ordered = raw_payload(&path, OLD_PASSWORD); + assert_eq!( + ordered.0["items"][0]["urls"][0], + original.0["items"][0]["urls"][1] + ); + assert_eq!( + ordered.0["items"][0]["urls"][1], + original.0["items"][0]["urls"][0] + ); + let mut removed = PasswordBox::open(&path, OLD_PASSWORD).unwrap(); + removed.payload.items[0].urls.remove(1); + removed.save(OLD_PASSWORD).unwrap(); + drop(removed); + let retained = raw_payload(&path, OLD_PASSWORD); + assert_eq!( + retained.0["items"][0]["urls"], + serde_json::json!([original.0["items"][0]["urls"][1]]) + ); let mut recovered = PasswordBox::recover(&path, &code).unwrap(); recovered.rekey_recovered(NEW_PASSWORD).unwrap(); drop(recovered); @@ -137,7 +164,7 @@ fn first_save_and_rekey_preserve_reference_extensions_and_duplicate_url_metadata ); assert_eq!( rekeyed.0["items"][0]["urls"], - original.0["items"][0]["urls"] + retained.0["items"][0]["urls"] ); } diff --git a/crates/passshare-core/src/password_box_saved_tests.rs b/crates/passshare-core/src/password_box_saved_tests.rs index a406bbc5..1252f160 100644 --- a/crates/passshare-core/src/password_box_saved_tests.rs +++ b/crates/passshare-core/src/password_box_saved_tests.rs @@ -47,11 +47,13 @@ fn failure_then_retry(operation: Operation) { original.tags = vec!["keep,comma".into(), "unchanged".into()]; original.urls = vec![ crate::domain::item::ItemUrl { + extensions: Default::default(), href: "https://example.test/first".into(), primary: true, label: "First".into(), }, crate::domain::item::ItemUrl { + extensions: Default::default(), href: "https://example.test/second".into(), primary: false, label: "Second".into(), From 0217e3d8ca407b14e6a2ef4e4394660754768629 Mon Sep 17 00:00:00 2001 From: mashfromband Date: Sat, 10 Oct 2026 20:43:05 +0900 Subject: [PATCH 05/14] build(wasm): promote verified pbox recovery dependencies and complete source fingerprints --- frontend/cloud-assets.json | 23 +++++++++--------- .../cloud-crypto/passshare_cloud_wasm_bg.wasm | Bin 665700 -> 666294 bytes 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/frontend/cloud-assets.json b/frontend/cloud-assets.json index 3afe9b5e..1cb867ac 100644 --- a/frontend/cloud-assets.json +++ b/frontend/cloud-assets.json @@ -1,6 +1,6 @@ { - "sourceCommit": "35faefccb3f3dee0a93b4bca7b03ea71e26c7147", - "run": 38043864667, + "sourceCommit": "91f7f45415f5df12e47d699250f10d440a8570c8", + "run": 38048434256, "buildKind": "github-actions-tested", "inputs": { "Cargo.toml": "f3a088bb1e72f919cbc8d485f97e87a377f5be619993b485b70fa30b01f83012", @@ -13,36 +13,37 @@ "crates/passshare-core/src/error.rs": "5df9b408190b4ca2ea8efb74f844e35c4cfe9d1b27aff9f13525cc60236448f8", "crates/passshare-core/src/migration.rs": "5b8aa4736edda87a8ba78f243af9a18a1a5a2cfa82f38dedf53bd5f65fd81a83", "crates/passshare-core/src/lib.rs": "c18ff9e5539495cdcee8eb3f8e49a89edb21961e7dd07e210feeada60082e115", - "crates/passshare-core/src/password_box.rs": "4fed266a360dc97b1bf56e5d4441e5b1b8b97f2d7988b92cf8e67c84f5ecfdb6", - "crates/passshare-core/src/crypto.rs": "57597d09fcca1d6723a513459208a99363b804f1a0b7a443e5795bfd505480a3", + "crates/passshare-core/src/password_box.rs": "b367cffc66af8abc781b94593a4518b5f023c326430385b2447b2ceb5c1d2235", + "crates/passshare-core/src/crypto.rs": "f2f01d76d0662fb9ca27aa95dd89e2b0055a620845d3eb1b6e94be74a626ff80", "crates/passshare-core/src/storage/mod.rs": "5e73deea4e8723038ad5f1850260bb6b7671820c77b891456467a277453a2727", - "crates/passshare-core/src/storage/file_format.rs": "72ebc2ce23b4380d4795f32d5db710572728d9aa1a9bf52aadfa5b579174894a", - "crates/passshare-core/src/password_box_saved_tests.rs": "7368f6dc67183d6b8d1c9b87ed1d900a69731ec809522044393e1f2fe179ac22", + "crates/passshare-core/src/storage/file_format.rs": "fcb7899ec67d76f390ca70db562dd175154fca29996716475cf471b323a4e3b9", + "crates/passshare-core/src/password_box_saved_tests.rs": "fd3f2671df037a9775cd477b3cbc507533c2872b94d13cf1de12250a24b9a84f", "crates/passshare-core/src/generator.rs": "fdf09c4c0ee524e2b5c8f1c65674f95a991174a268754337d0e8f96729da04e0", "crates/passshare-core/src/export/op_csv.rs": "b12a369e58b03baed7301a23ce1b98021b2058be8b87559d409935b67e0cda4b", "crates/passshare-core/src/export/mod.rs": "6940d64482e700a24a163b2a2c01e3380d25b9b0f962fc2bf6472f6bd6308fc8", "crates/passshare-core/src/import/mod.rs": "2cc680cdc34f90763ac42131b20795ea5a64bd545c388b8e7dc63b6b1b199b0d", "crates/passshare-core/src/domain/connect_id.rs": "93523fe25f92c3ef28d8e54abedb72d12a7fc8937d7ced25bbc83448a80e9543", - "crates/passshare-core/src/import/op_csv.rs": "4710303bad642759255a29765a1b288353d8b4b098df4efd4964857c5f6b6181", + "crates/passshare-core/src/import/op_csv.rs": "bd7d46e34e938930f65b8451bbe5b1331806cc92f576e72ac0c4556add5b47c6", "crates/passshare-core/src/domain/field.rs": "0edcb6f597890f43f1d3f81ed4d01d6e3f777d54aa7c962204df7b3892dde624", "crates/passshare-core/src/cloud_crypto/shared_keys.rs": "87b1c347d5062b0399852a777a601f6e7a0d151cde99c0421b665d47fd11cd90", "crates/passshare-core/src/domain/login_item.rs": "de7c2456cbaa045ebd660d6b026cb64c8a785bcb65b8ca8b4887b6472c3140db", - "crates/passshare-core/src/domain/item.rs": "f0aa4248d9e1965a86cea46aa952a3591ba650c959300c8d81cef0bf2b95c0f4", + "crates/passshare-core/src/domain/item.rs": "3a01a74e967441bfa88191a8fe5426694ca0b3cd13f1005945f193ce7e2fc016", "crates/passshare-core/src/domain/login_view.rs": "79a9c0920f482b243373a00f1553e2b810a20c3781b28dc02aecdcd40c46987a", "crates/passshare-core/src/domain/mod.rs": "e13146bdcd180e2adc9fc27c8fbf294502f6d19c3dd235b7c65d22e106353f7a", "crates/passshare-core/src/domain/vault.rs": "ee0a12702ae14b48ecb5cb07d9593b7f3a14c36ef3ec4f6582df09cc974b4e2a", - "crates/passshare-core/src/domain/payload.rs": "2610d0a3591b8c72722bdba28acc4820de18699d20dbb2a77cbbf7320c9c52e2", + "crates/passshare-core/src/domain/payload.rs": "30904f6ed16dc1e1f73dcda8b899bc688934153d5516b5029ba64917239e9280", "packages/cloud-wasm/src/worker.rs": "b37ec70635098d833067faad0eb2223328f2aea276997bbd48dc8a2dd9e9b390", "packages/cloud-wasm/src/lib.rs": "826c1d1ca9dd84c0e4e036eae8d23b50f7f8f34339652a6534ba8eaa6ac987f7", "packages/cloud-wasm/src/shared.rs": "125e2d2865bf5ae292d335bdbfdd1a4840f509ed7ea9f3a6a13f842b615af404", "packages/cloud-wasm/src/item_edit.rs": "4373427b6397c1f2d2de8dd69df96419f67c7c7be03eaa3207e0a7bbaf3de97e", "backend/native/src/edit.rs": "d8e39b54705e65aa82cab651ca6604e087b9b219bc7c290cce4b8e728e29f89e", - "packages/cloud-wasm/src/recovery.rs": "50cc8af8b7663dc46cf5cdc667a871df569da9587043853dbd3665dfd4a1e997" + "packages/cloud-wasm/src/recovery.rs": "50cc8af8b7663dc46cf5cdc667a871df569da9587043853dbd3665dfd4a1e997", + "crates/passshare-core/src/password_box_recovery_tests.rs": "03fae103483fbd876cfacef89280908c4b332076318886252ed9c34bbe23a567" }, "assets": { "boot.mjs": "d084febcad89252109d54221eb6d10b5934e9c8cd83f4aa5e59f311c888befc7", "passshare_cloud_wasm.js": "c990db62845225f5051f1eb075fb1284167371c3fc42440fe753bf1ad0cb97c0", - "passshare_cloud_wasm_bg.wasm": "d0448d31bbc04e163e3f5f3a4774df32175477b94fef52f7ed9fa40ef1a55b46" + "passshare_cloud_wasm_bg.wasm": "ccc42f3042568bba5082daa3232e63665d88fda19b9dacd18f581c925f3f1136" }, "artifact": "passshare-cloud-wasm" } diff --git a/frontend/public/cloud-crypto/passshare_cloud_wasm_bg.wasm b/frontend/public/cloud-crypto/passshare_cloud_wasm_bg.wasm index 1743886f2caab99bd3bfc29ec8d215be0c9be51e..2e2ec6015793b5de026da86c9548d71fe2b317f6 100644 GIT binary patch delta 78731 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%0AiW!y(Kl|z()D4$8qXO^s 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⩔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 From af9b719330472f78efd3eecc7f8c6b74190b2f24 Mon Sep 17 00:00:00 2001 From: mashfromband Date: Sat, 10 Oct 2026 21:26:53 +0900 Subject: [PATCH 06/14] fix(core): construct keys after fill and separate golden recovery fixtures --- crates/passshare-core/src/crypto.rs | 17 +- .../src/password_box_recovery_tests.rs | 246 ++++++++++-------- .../passshare-core/src/storage/file_format.rs | 7 +- .../src/testdata/pbox-v1-golden.json | 19 ++ 4 files changed, 164 insertions(+), 125 deletions(-) create mode 100644 crates/passshare-core/src/testdata/pbox-v1-golden.json diff --git a/crates/passshare-core/src/crypto.rs b/crates/passshare-core/src/crypto.rs index 3b809183..caa8ddda 100644 --- a/crates/passshare-core/src/crypto.rs +++ b/crates/passshare-core/src/crypto.rs @@ -29,18 +29,17 @@ pub struct MasterKey([u8; 32]); impl MasterKey { /// Generate an account root key locally, independently of authentication. pub fn generate() -> Result { - let mut key = Self([0u8; 32]); - fill(&mut key.0).map_err(|_| PassShareError::Internal("Key generation failed".into()))?; - Ok(key) + let mut key = Zeroizing::new([0u8; 32]); + fill(key.as_mut()).map_err(|_| PassShareError::Internal("Key generation failed".into()))?; + Ok(Self(*key)) } pub(crate) fn from_bytes(bytes: &[u8]) -> Result { if bytes.len() != 32 { return Err(PassShareError::CorruptFile); } - let mut key = Self([0; 32]); - key.0.copy_from_slice(bytes); - Ok(key) + let key: [u8; 32] = bytes.try_into().map_err(|_| PassShareError::CorruptFile)?; + Ok(Self(key)) } pub fn as_bytes(&self) -> &[u8; 32] { @@ -61,9 +60,9 @@ pub fn derive_master_key_with_params( let params = Params::new(memory_kib, time_cost, parallelism, Some(32)) .map_err(|_| PassShareError::CorruptFile)?; let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); - let mut output = MasterKey([0u8; 32]); - argon2.hash_password_into(password.as_bytes(), salt, &mut output.0)?; - Ok(output) + let mut output = Zeroizing::new([0u8; 32]); + argon2.hash_password_into(password.as_bytes(), salt, output.as_mut())?; + Ok(MasterKey(*output)) } /// Bound untrusted header costs before either normal opening or recovery. diff --git a/crates/passshare-core/src/password_box_recovery_tests.rs b/crates/passshare-core/src/password_box_recovery_tests.rs index c25d68a0..9212062b 100644 --- a/crates/passshare-core/src/password_box_recovery_tests.rs +++ b/crates/passshare-core/src/password_box_recovery_tests.rs @@ -3,22 +3,37 @@ use sha2::{Digest, Sha256}; use std::fs; use tempfile::TempDir; -const OLD_PASSWORD: &str = "synthetic-old-password"; -const NEW_PASSWORD: &str = "synthetic-new-password"; -const FIXED_CODE: &str = "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc="; +fn old_password() -> Zeroizing { + let fixture = golden(); + Zeroizing::new(fixture.0["masterPassword"].as_str().unwrap().to_owned()) +} +thread_local! { static NEW_SECRET: Zeroizing = Zeroizing::new(generate_recovery_secret().unwrap()); } +fn new_password() -> Zeroizing { + NEW_SECRET.with(Clone::clone) +} +fn fixed_code() -> Zeroizing { + let fixture = golden(); + Zeroizing::new(fixture.0["recoveryCode"].as_str().unwrap().to_owned()) +} +fn golden() -> SecretJson { + let bytes = + fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("src/testdata/pbox-v1-golden.json")) + .unwrap(); + SecretJson(serde_json::from_slice(&bytes).unwrap()) +} // Frozen historical v1 wire bytes: independent of current create, encrypt and KDF. -// Synthetic plaintext uses a fixed DTO, salt [1; 16], nonce [2; 24] and FIXED_CODE. +// Synthetic plaintext uses a fixed DTO, salt [1; 16], nonce [2; 24] and fixed_code().as_str(). fn write_v1(path: &Path) { - let json = r#"{"version":1,"kdf":{"algorithm":"argon2id","salt":"AQEBAQEBAQEBAQEBAQEBAQ==","memory_kib":65536,"time_cost":3,"parallelism":4},"cipher":{"algorithm":"xchacha20poly1305","nonce":"AgICAgICAgICAgICAgICAgICAgICAgIC"},"ciphertext":"uk2E9pzsrI6RYnpuSfPcLalhiP2iy3MLWWGWhiN7gZ/Zwa5/UUX8Is4Zb9XV8JHe+kKJZfVzOxULib9Y49G2Z5/SQ32T+WGBNABjYp0E2GKJPtSy2YFpZ9xoMyRv/TPVEziLPj+pVBFR4VCsdRLT3wbjed26usQeSin+4gD8c5LyaEeL2Axop4Ur3VVvVcW0MANAOcjiO7doM/q4Ryv+e6Q938NpFZal4t3nU+NZaHcB95mArO0rZxd+NsnGyZAQf0KMWwUayw9tuqaemFRljdYQXOsQlx4Nu5tFV3mnq0wFK/t2m7vLeX1rtLJFZQNMSX/anrEJQQ/8vLAtkIGTto0cU7XQ/qn0yOYvmh8h+aueRspvSUzBo2IPuCkH+b1Wrwsa9RHwewKypAEVvp+ZuIOu0HdM2G4Vz6if3z/j9Q=="}"#; - fs::write(path, json).unwrap(); + let fixture = golden(); + fs::write(path, serde_json::to_vec(&fixture.0["file"]).unwrap()).unwrap(); } fn add_complete_item(pb: &mut PasswordBox) { let mut item = Item::new_login( pb.vault().unwrap().id.clone(), "Synthetic", - "synthetic-secret", + generate_recovery_secret().unwrap(), ) .unwrap(); item.urls = vec![ @@ -73,7 +88,7 @@ fn raw_payload(path: &Path, password: &str) -> SecretJson { fn first_save_and_rekey_preserve_reference_extensions_and_duplicate_url_metadata() { let dir = TempDir::new().unwrap(); let path = dir.path().join("extensions.pbox"); - let mut pb = PasswordBox::create(&path, OLD_PASSWORD).unwrap(); + let mut pb = PasswordBox::create(&path, old_password().as_str()).unwrap(); add_complete_item(&mut pb); let code = Zeroizing::new(pb.recovery_secret().unwrap().unwrap().to_owned()); // Seed independently authenticated bytes, before the merge implementation @@ -88,7 +103,7 @@ fn first_save_and_rekey_preserve_reference_extensions_and_duplicate_url_metadata ]); let mut file = PasswordBoxFile::load_from_path(&path).unwrap(); let key = derive_master_key_with_params( - OLD_PASSWORD, + old_password().as_str(), &file.kdf.salt_bytes().unwrap(), file.kdf.memory_kib, file.kdf.time_cost, @@ -114,7 +129,7 @@ fn first_save_and_rekey_preserve_reference_extensions_and_duplicate_url_metadata }); file.save_to_path(&path).unwrap(); drop(pb); - let original = raw_payload(&path, OLD_PASSWORD); + let original = raw_payload(&path, old_password().as_str()); assert_eq!( original.0["items"][0]["vault"]["name"], "synthetic-reference-name" @@ -123,20 +138,20 @@ fn first_save_and_rekey_preserve_reference_extensions_and_duplicate_url_metadata original.0["items"][0]["urls"][1]["extension"], "second-only" ); - let mut opened = PasswordBox::open(&path, OLD_PASSWORD).unwrap(); - opened.save(OLD_PASSWORD).unwrap(); + let mut opened = PasswordBox::open(&path, old_password().as_str()).unwrap(); + opened.save(old_password().as_str()).unwrap(); drop(opened); - let saved = raw_payload(&path, OLD_PASSWORD); + let saved = raw_payload(&path, old_password().as_str()); assert_eq!( saved.0["items"][0]["vault"], original.0["items"][0]["vault"] ); assert_eq!(saved.0["items"][0]["urls"], original.0["items"][0]["urls"]); - let mut reordered = PasswordBox::open(&path, OLD_PASSWORD).unwrap(); + let mut reordered = PasswordBox::open(&path, old_password().as_str()).unwrap(); reordered.payload.items[0].urls.reverse(); - reordered.save(OLD_PASSWORD).unwrap(); + reordered.save(old_password().as_str()).unwrap(); drop(reordered); - let ordered = raw_payload(&path, OLD_PASSWORD); + let ordered = raw_payload(&path, old_password().as_str()); assert_eq!( ordered.0["items"][0]["urls"][0], original.0["items"][0]["urls"][1] @@ -145,19 +160,19 @@ fn first_save_and_rekey_preserve_reference_extensions_and_duplicate_url_metadata ordered.0["items"][0]["urls"][1], original.0["items"][0]["urls"][0] ); - let mut removed = PasswordBox::open(&path, OLD_PASSWORD).unwrap(); + let mut removed = PasswordBox::open(&path, old_password().as_str()).unwrap(); removed.payload.items[0].urls.remove(1); - removed.save(OLD_PASSWORD).unwrap(); + removed.save(old_password().as_str()).unwrap(); drop(removed); - let retained = raw_payload(&path, OLD_PASSWORD); + let retained = raw_payload(&path, old_password().as_str()); assert_eq!( retained.0["items"][0]["urls"], serde_json::json!([original.0["items"][0]["urls"][1]]) ); let mut recovered = PasswordBox::recover(&path, &code).unwrap(); - recovered.rekey_recovered(NEW_PASSWORD).unwrap(); + recovered.rekey_recovered(new_password().as_str()).unwrap(); drop(recovered); - let rekeyed = raw_payload(&path, NEW_PASSWORD); + let rekeyed = raw_payload(&path, new_password().as_str()); assert_eq!( rekeyed.0["items"][0]["vault"], original.0["items"][0]["vault"] @@ -173,21 +188,21 @@ fn v2_restart_recover_rekey_preserves_full_json_and_code() { let dir = TempDir::new().unwrap(); let path = dir.path().join("box.pbox"); let backup = dir.path().join("backup.pbox"); - let mut pb = PasswordBox::create(&path, OLD_PASSWORD).unwrap(); + let mut pb = PasswordBox::create(&path, old_password().as_str()).unwrap(); add_complete_item(&mut pb); - pb.save(OLD_PASSWORD).unwrap(); + pb.save(old_password().as_str()).unwrap(); let code = Zeroizing::new(pb.recovery_secret().unwrap().unwrap().to_owned()); let expected = pb.payload.clone(); - let raw_before = raw_payload(&path, OLD_PASSWORD); + let raw_before = raw_payload(&path, old_password().as_str()); let before = fs::read(&path).unwrap(); fs::copy(&path, &backup).unwrap(); assert_eq!(PasswordBoxFile::load_from_path(&path).unwrap().version, 2); drop(pb); let mut recovered = PasswordBox::recover(&path, &code).unwrap(); - assert_eq!(recovered.payload, expected); + assert!(recovered.payload == expected); assert_eq!(fs::read(&path).unwrap(), before); assert_eq!( - recovered.save(NEW_PASSWORD), + recovered.save(new_password().as_str()), Err(PassShareError::InvalidMasterPassword) ); let item = Item::new_secure_note( @@ -197,27 +212,29 @@ fn v2_restart_recover_rekey_preserves_full_json_and_code() { ) .unwrap(); assert_eq!( - recovered.apply_and_save(SavedChange::Add(&item), NEW_PASSWORD), + recovered.apply_and_save(SavedChange::Add(&item), new_password().as_str()), Err(PassShareError::InvalidMasterPassword) ); - assert_eq!(recovered.payload, expected); + assert!(recovered.payload == expected); assert_eq!(fs::read(&path).unwrap(), before); - recovered.rekey_recovered(NEW_PASSWORD).unwrap(); + recovered.rekey_recovered(new_password().as_str()).unwrap(); drop(recovered); assert_eq!( - PasswordBox::open(&path, OLD_PASSWORD).err(), + PasswordBox::open(&path, old_password().as_str()).err(), Some(PassShareError::InvalidMasterPassword) ); - let mut reopened = PasswordBox::open(&path, NEW_PASSWORD).unwrap(); - assert_eq!(reopened.payload, expected); - reopened.save(NEW_PASSWORD).unwrap(); - assert_eq!(raw_payload(&path, NEW_PASSWORD).0, raw_before.0); + let mut reopened = PasswordBox::open(&path, new_password().as_str()).unwrap(); + assert!(reopened.payload == expected); + reopened.save(new_password().as_str()).unwrap(); + assert_eq!(raw_payload(&path, new_password().as_str()).0, raw_before.0); assert_eq!( PasswordBox::recover(&path, &code).unwrap().payload, expected ); assert_eq!( - PasswordBox::open(&backup, OLD_PASSWORD).unwrap().payload, + PasswordBox::open(&backup, old_password().as_str()) + .unwrap() + .payload, expected ); assert_eq!( @@ -233,26 +250,26 @@ fn fixed_v1_stays_v1_until_authenticated_explicit_upgrade() { let backup = dir.path().join("backup.pbox"); write_v1(&path); fs::copy(&path, &backup).unwrap(); - assert!(PasswordBox::recover(&path, FIXED_CODE).is_err()); - let mut pb = PasswordBox::open(&path, OLD_PASSWORD).unwrap(); - pb.save(OLD_PASSWORD).unwrap(); + assert!(PasswordBox::recover(&path, fixed_code().as_str()).is_err()); + let mut pb = PasswordBox::open(&path, old_password().as_str()).unwrap(); + pb.save(old_password().as_str()).unwrap(); assert_eq!(PasswordBoxFile::load_from_path(&path).unwrap().version, 1); let before = fs::read(&path).unwrap(); assert_eq!( - pb.enable_recovery("wrong").err(), + pb.enable_recovery(new_password().as_str()).err(), Some(PassShareError::InvalidMasterPassword) ); assert_eq!(fs::read(&path).unwrap(), before); - let code = pb.enable_recovery(OLD_PASSWORD).unwrap(); - assert_eq!(code.as_str(), FIXED_CODE); + let code = pb.enable_recovery(old_password().as_str()).unwrap(); + assert!(code.as_str() == fixed_code().as_str()); assert_eq!(PasswordBoxFile::load_from_path(&path).unwrap().version, 2); let mut recovered = PasswordBox::recover(&path, &code).unwrap(); - assert_eq!(recovered.payload, pb.payload); - recovered.rekey_recovered(NEW_PASSWORD).unwrap(); - assert!(PasswordBox::open(&path, OLD_PASSWORD).is_err()); - assert!(PasswordBox::open(&path, NEW_PASSWORD).is_ok()); + assert!(recovered.payload == pb.payload); + recovered.rekey_recovered(new_password().as_str()).unwrap(); + assert!(PasswordBox::open(&path, old_password().as_str()).is_err()); + assert!(PasswordBox::open(&path, new_password().as_str()).is_ok()); assert_eq!(PasswordBoxFile::load_from_path(&backup).unwrap().version, 1); - assert!(PasswordBox::open(&backup, OLD_PASSWORD).is_ok()); + assert!(PasswordBox::open(&backup, old_password().as_str()).is_ok()); assert!(PasswordBox::recover(&backup, &code).is_err()); } @@ -261,12 +278,12 @@ fn upgrade_without_legacy_code_generates_one() { let dir = TempDir::new().unwrap(); let path = dir.path().join("legacy.pbox"); write_v1(&path); - let mut pb = PasswordBox::open(&path, OLD_PASSWORD).unwrap(); + let mut pb = PasswordBox::open(&path, old_password().as_str()).unwrap(); pb.payload.recovery_secret = None; - pb.save(OLD_PASSWORD).unwrap(); + pb.save(old_password().as_str()).unwrap(); drop(pb); - let mut pb = PasswordBox::open(&path, OLD_PASSWORD).unwrap(); - let code = pb.enable_recovery(OLD_PASSWORD).unwrap(); + let mut pb = PasswordBox::open(&path, old_password().as_str()).unwrap(); + let code = pb.enable_recovery(old_password().as_str()).unwrap(); assert_eq!(BASE64.decode(code.as_str()).unwrap().len(), 32); assert!(PasswordBox::recover(&path, &code).is_ok()); } @@ -276,9 +293,9 @@ fn wrong_code_cross_file_wrapper_and_primary_tampering_are_rejected() { let dir = TempDir::new().unwrap(); let path = dir.path().join("a.pbox"); let other = dir.path().join("b.pbox"); - let pb = PasswordBox::create(&path, OLD_PASSWORD).unwrap(); + let pb = PasswordBox::create(&path, old_password().as_str()).unwrap(); let code = Zeroizing::new(pb.recovery_secret().unwrap().unwrap().to_owned()); - let second = PasswordBox::create(&other, OLD_PASSWORD).unwrap(); + let second = PasswordBox::create(&other, old_password().as_str()).unwrap(); let other_code = Zeroizing::new(second.recovery_secret().unwrap().unwrap().to_owned()); assert!(PasswordBox::recover(&path, &other_code).is_err()); assert!(PasswordBox::recover(&path, "not-base64").is_err()); @@ -289,7 +306,7 @@ fn wrong_code_cross_file_wrapper_and_primary_tampering_are_rejected() { mixed.recovery = other_file.recovery; mixed.save_to_path(&path).unwrap(); assert!(PasswordBox::recover(&path, &other_code).is_err()); - assert!(PasswordBox::open(&path, OLD_PASSWORD).is_err()); + assert!(PasswordBox::open(&path, old_password().as_str()).is_err()); for mutate in [ (|f: &mut PasswordBoxFile| f.ciphertext[0] ^= 1) as fn(&mut PasswordBoxFile), |f| f.kdf.salt = BASE64.encode([9; 16]), @@ -302,7 +319,7 @@ fn wrong_code_cross_file_wrapper_and_primary_tampering_are_rejected() { mutate(&mut altered); altered.save_to_path(&path).unwrap(); assert!(PasswordBox::recover(&path, &code).is_err()); - assert!(PasswordBox::open(&path, OLD_PASSWORD).is_err()); + assert!(PasswordBox::open(&path, old_password().as_str()).is_err()); } } @@ -310,7 +327,7 @@ fn wrong_code_cross_file_wrapper_and_primary_tampering_are_rejected() { fn malformed_header_shapes_algorithms_versions_and_kdf_costs_rejected() { let dir = TempDir::new().unwrap(); let path = dir.path().join("box.pbox"); - let pb = PasswordBox::create(&path, OLD_PASSWORD).unwrap(); + let pb = PasswordBox::create(&path, old_password().as_str()).unwrap(); let code = Zeroizing::new(pb.recovery_secret().unwrap().unwrap().to_owned()); let original = PasswordBoxFile::load_from_path(&path).unwrap(); for mutate in [ @@ -347,7 +364,7 @@ fn malformed_header_shapes_algorithms_versions_and_kdf_costs_rejected() { mutate(&mut changed); changed.save_to_path(&path).unwrap(); assert_eq!( - PasswordBox::open(&path, OLD_PASSWORD).err(), + PasswordBox::open(&path, old_password().as_str()).err(), Some(PassShareError::CorruptFile) ); assert_eq!( @@ -368,7 +385,7 @@ fn malformed_header_shapes_algorithms_versions_and_kdf_costs_rejected() { Some(PassShareError::CorruptFile) ); assert_eq!( - PasswordBox::open(&path, OLD_PASSWORD).err(), + PasswordBox::open(&path, old_password().as_str()).err(), Some(PassShareError::CorruptFile) ); } @@ -381,11 +398,11 @@ fn failure_preserves_original(upgrade: bool) { let (mut pb, code) = if upgrade { write_v1(&path); ( - PasswordBox::open(&path, OLD_PASSWORD).unwrap(), - Zeroizing::new(FIXED_CODE.to_owned()), + PasswordBox::open(&path, old_password().as_str()).unwrap(), + Zeroizing::new(fixed_code().as_str().to_owned()), ) } else { - let pb = PasswordBox::create(&path, OLD_PASSWORD).unwrap(); + let pb = PasswordBox::create(&path, old_password().as_str()).unwrap(); let code = Zeroizing::new(pb.recovery_secret().unwrap().unwrap().to_owned()); (PasswordBox::recover(&path, &code).unwrap(), code) }; @@ -397,12 +414,12 @@ fn failure_preserves_original(upgrade: bool) { fs::rename(&path, &backup).unwrap(); fs::create_dir(&path).unwrap(); let failure = if upgrade { - pb.enable_recovery(OLD_PASSWORD).map(|_| ()) + pb.enable_recovery(old_password().as_str()).map(|_| ()) } else { - pb.rekey_recovered(NEW_PASSWORD) + pb.rekey_recovered(new_password().as_str()) }; assert!(failure.is_err()); - assert_eq!(pb.payload, payload); + assert!(pb.payload == payload); assert_eq!(pb.salt, salt); assert_eq!(pb.file_version, version); assert_eq!(fs::read(&backup).unwrap(), before); @@ -411,14 +428,14 @@ fn failure_preserves_original(upgrade: bool) { assert_eq!(fs::read_dir(&path).unwrap().count(), 0); fs::remove_dir(&path).unwrap(); fs::rename(&backup, &path).unwrap(); - let restarted = PasswordBox::open(&path, OLD_PASSWORD).unwrap(); - assert_eq!(restarted.payload, payload); + let restarted = PasswordBox::open(&path, old_password().as_str()).unwrap(); + assert!(restarted.payload == payload); if upgrade { - pb.enable_recovery(OLD_PASSWORD).unwrap(); + pb.enable_recovery(old_password().as_str()).unwrap(); } else { assert_eq!(PasswordBox::recover(&path, &code).unwrap().payload, payload); assert!(pb.recovery_only); - pb.rekey_recovered(NEW_PASSWORD).unwrap(); + pb.rekey_recovered(new_password().as_str()).unwrap(); } assert!(PasswordBox::recover(&path, &code).is_ok()); } @@ -442,7 +459,7 @@ fn real_atomic_failures_leave_the_original_path_and_state_unchanged() { let dir = TempDir::new().unwrap(); let path = dir.path().join("box.pbox"); write_v1(&path); - let mut legacy = PasswordBox::open(&path, OLD_PASSWORD).unwrap(); + let mut legacy = PasswordBox::open(&path, old_password().as_str()).unwrap(); let before = fs::read(&path).unwrap(); let hash = Sha256::digest(&before); let held = fs::OpenOptions::new() @@ -451,16 +468,16 @@ fn real_atomic_failures_leave_the_original_path_and_state_unchanged() { .open(&path) .unwrap(); assert!(matches!( - legacy.enable_recovery(OLD_PASSWORD), + legacy.enable_recovery(old_password().as_str()), Err(PassShareError::Internal(_)) )); assert_eq!(legacy.file_version, 1); assert_eq!(fs::read(&path).unwrap(), before); assert_eq!(Sha256::digest(fs::read(&path).unwrap()), hash); assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1); - assert!(PasswordBox::open(&path, OLD_PASSWORD).is_ok()); + assert!(PasswordBox::open(&path, old_password().as_str()).is_ok()); drop(held); - let code = legacy.enable_recovery(OLD_PASSWORD).unwrap(); + let code = legacy.enable_recovery(old_password().as_str()).unwrap(); let before = fs::read(&path).unwrap(); let mut recovered = PasswordBox::recover(&path, &code).unwrap(); let salt = recovered.salt; @@ -470,7 +487,7 @@ fn real_atomic_failures_leave_the_original_path_and_state_unchanged() { .open(&path) .unwrap(); assert!(matches!( - recovered.rekey_recovered(NEW_PASSWORD), + recovered.rekey_recovered(new_password().as_str()), Err(PassShareError::Internal(_)) )); assert!(recovered.recovery_only); @@ -481,17 +498,17 @@ fn real_atomic_failures_leave_the_original_path_and_state_unchanged() { Sha256::digest(&before) ); assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1); - assert!(PasswordBox::open(&path, OLD_PASSWORD).is_ok()); + assert!(PasswordBox::open(&path, old_password().as_str()).is_ok()); assert!(PasswordBox::recover(&path, &code).is_ok()); - assert!(PasswordBox::open(&path, NEW_PASSWORD).is_err()); - assert!(legacy.rotate_recovery(OLD_PASSWORD).is_err()); + assert!(PasswordBox::open(&path, new_password().as_str()).is_err()); + assert!(legacy.rotate_recovery(old_password().as_str()).is_err()); assert_eq!(legacy.recovery_secret().unwrap(), Some(code.as_str())); assert_eq!(fs::read(&path).unwrap(), before); - assert!(legacy.save(OLD_PASSWORD).is_err()); + assert!(legacy.save(old_password().as_str()).is_err()); assert_eq!(fs::read(&path).unwrap(), before); drop(held); - recovered.rekey_recovered(NEW_PASSWORD).unwrap(); - assert!(PasswordBox::open(&path, NEW_PASSWORD).is_ok()); + recovered.rekey_recovered(new_password().as_str()).unwrap(); + assert!(PasswordBox::open(&path, new_password().as_str()).is_ok()); assert!(PasswordBox::recover(&path, &code).is_ok()); } @@ -500,17 +517,17 @@ fn rotation_invalidates_current_code_and_preserves_backup_access() { let dir = TempDir::new().unwrap(); let path = dir.path().join("box.pbox"); let backup = dir.path().join("backup.pbox"); - let mut pb = PasswordBox::create(&path, OLD_PASSWORD).unwrap(); + let mut pb = PasswordBox::create(&path, old_password().as_str()).unwrap(); let old_code = Zeroizing::new(pb.recovery_secret().unwrap().unwrap().to_owned()); fs::copy(&path, &backup).unwrap(); let before = fs::read(&path).unwrap(); - assert!(pb.rotate_recovery("wrong").is_err()); + assert!(pb.rotate_recovery(new_password().as_str()).is_err()); assert_eq!(fs::read(&path).unwrap(), before); - let new_code = pb.rotate_recovery(OLD_PASSWORD).unwrap(); - assert_ne!(new_code.as_str(), old_code.as_str()); + let new_code = pb.rotate_recovery(old_password().as_str()).unwrap(); + assert!(new_code.as_str() != old_code.as_str()); assert!(PasswordBox::recover(&path, &old_code).is_err()); assert!(PasswordBox::recover(&path, &new_code).is_ok()); - assert!(PasswordBox::open(&path, OLD_PASSWORD).is_ok()); + assert!(PasswordBox::open(&path, old_password().as_str()).is_ok()); assert!(PasswordBox::recover(&backup, &old_code).is_ok()); assert!(PasswordBox::recover(&backup, &new_code).is_err()); } @@ -519,9 +536,9 @@ fn rotation_invalidates_current_code_and_preserves_backup_access() { fn recovered_lock_and_drop_scrub_owned_buffers_and_return_no_secrets() { let dir = TempDir::new().unwrap(); let path = dir.path().join("box.pbox"); - let mut pb = PasswordBox::create(&path, OLD_PASSWORD).unwrap(); + let mut pb = PasswordBox::create(&path, old_password().as_str()).unwrap(); add_complete_item(&mut pb); - pb.save(OLD_PASSWORD).unwrap(); + pb.save(old_password().as_str()).unwrap(); let code = Zeroizing::new(pb.recovery_secret().unwrap().unwrap().to_owned()); let mut recovered = PasswordBox::recover(&path, &code).unwrap(); recovered.zeroize_secrets(); @@ -531,7 +548,7 @@ fn recovered_lock_and_drop_scrub_owned_buffers_and_return_no_secrets() { .iter() .flat_map(|item| &item.fields) .all(|field| field.value.is_empty())); - assert_eq!(recovered.payload.recovery_secret.as_deref(), Some("")); + assert!(recovered.payload.recovery_secret.as_deref() == Some("")); assert!(recovered.original_payload.is_none()); recovered.lock(); assert_eq!(recovered.recovery_secret(), Err(PassShareError::BoxLocked)); @@ -540,7 +557,7 @@ fn recovered_lock_and_drop_scrub_owned_buffers_and_return_no_secrets() { Err(PassShareError::BoxLocked) ); assert_eq!( - recovered.rekey_recovered(NEW_PASSWORD), + recovered.rekey_recovered(new_password().as_str()), Err(PassShareError::BoxLocked) ); drop(recovered); @@ -552,20 +569,20 @@ fn recovered_lock_and_drop_scrub_owned_buffers_and_return_no_secrets() { fn unknown_item_fields_survive_but_removed_known_fields_stay_removed() { let dir = TempDir::new().unwrap(); let path = dir.path().join("box.pbox"); - let mut pb = PasswordBox::create(&path, OLD_PASSWORD).unwrap(); + let mut pb = PasswordBox::create(&path, old_password().as_str()).unwrap(); add_complete_item(&mut pb); - pb.save(OLD_PASSWORD).unwrap(); + pb.save(old_password().as_str()).unwrap(); drop(pb); - let mut pb = PasswordBox::open(&path, OLD_PASSWORD).unwrap(); + let mut pb = PasswordBox::open(&path, old_password().as_str()).unwrap(); let mut edited = pb.payload.items[0].clone(); edited.urls.clear(); edited.tags.clear(); edited.last_edited_by = None; edited.fields[0].purpose = None; let id = edited.id.to_string(); - pb.apply_and_save(SavedChange::Update(&edited), OLD_PASSWORD) + pb.apply_and_save(SavedChange::Update(&edited), old_password().as_str()) .unwrap(); - let raw = raw_payload(&path, OLD_PASSWORD); + let raw = raw_payload(&path, old_password().as_str()); assert!(raw.0["items"][0].get("urls").is_none()); assert!(raw.0["items"][0].get("tags").is_none()); assert!(raw.0["items"][0].get("lastEditedBy").is_none()); @@ -575,10 +592,10 @@ fn unknown_item_fields_survive_but_removed_known_fields_stay_removed() { raw.0["items"][0]["files"][0]["contentPath"], "attachments/ref" ); - pb.apply_and_save(SavedChange::Delete(&id), OLD_PASSWORD) + pb.apply_and_save(SavedChange::Delete(&id), old_password().as_str()) .unwrap(); assert_eq!( - raw_payload(&path, OLD_PASSWORD).0["items"], + raw_payload(&path, old_password().as_str()).0["items"], serde_json::json!([]) ); } @@ -587,36 +604,39 @@ fn unknown_item_fields_survive_but_removed_known_fields_stay_removed() { fn stale_boxes_cannot_overwrite_a_saved_update_or_rotated_code() { let dir = TempDir::new().unwrap(); let path = dir.path().join("box.pbox"); - let mut current = PasswordBox::create(&path, OLD_PASSWORD).unwrap(); + let mut current = PasswordBox::create(&path, old_password().as_str()).unwrap(); let code = Zeroizing::new(current.recovery_secret().unwrap().unwrap().to_owned()); - let mut stale = PasswordBox::open(&path, OLD_PASSWORD).unwrap(); + let mut stale = PasswordBox::open(&path, old_password().as_str()).unwrap(); let mut recovered = PasswordBox::recover(&path, &code).unwrap(); let item = Item::new_secure_note(current.vault().unwrap().id.clone(), "Saved", "Synthetic").unwrap(); current - .apply_and_save(SavedChange::Add(&item), OLD_PASSWORD) + .apply_and_save(SavedChange::Add(&item), old_password().as_str()) .unwrap(); let before = fs::read(&path).unwrap(); assert_eq!( - stale.enable_recovery(OLD_PASSWORD).err(), + stale.enable_recovery(old_password().as_str()).err(), Some(PassShareError::CorruptFile) ); assert_eq!( - stale.rotate_recovery(OLD_PASSWORD).err(), + stale.rotate_recovery(old_password().as_str()).err(), Some(PassShareError::CorruptFile) ); - assert_eq!(stale.save(OLD_PASSWORD), Err(PassShareError::CorruptFile)); assert_eq!( - recovered.rekey_recovered(NEW_PASSWORD), + stale.save(old_password().as_str()), + Err(PassShareError::CorruptFile) + ); + assert_eq!( + recovered.rekey_recovered(new_password().as_str()), Err(PassShareError::CorruptFile) ); assert_eq!(fs::read(&path).unwrap(), before); assert!(recovered.recovery_only); let mut recovered = PasswordBox::recover(&path, &code).unwrap(); - let new_code = current.rotate_recovery(OLD_PASSWORD).unwrap(); + let new_code = current.rotate_recovery(old_password().as_str()).unwrap(); let before = fs::read(&path).unwrap(); assert_eq!( - recovered.rekey_recovered(NEW_PASSWORD), + recovered.rekey_recovered(new_password().as_str()), Err(PassShareError::CorruptFile) ); assert_eq!(fs::read(&path).unwrap(), before); @@ -627,10 +647,10 @@ fn stale_boxes_cannot_overwrite_a_saved_update_or_rotated_code() { fn binding_rejects_a_wrapper_with_correct_key_and_different_payload_code() { let dir = TempDir::new().unwrap(); let path = dir.path().join("box.pbox"); - PasswordBox::create(&path, OLD_PASSWORD).unwrap(); + PasswordBox::create(&path, old_password().as_str()).unwrap(); let mut file = PasswordBoxFile::load_from_path(&path).unwrap(); - let key = derive_master_key(OLD_PASSWORD, &file.kdf.salt_bytes().unwrap()).unwrap(); - let wrong_recovery = recovery_key(FIXED_CODE).unwrap(); + let key = derive_master_key(old_password().as_str(), &file.kdf.salt_bytes().unwrap()).unwrap(); + let wrong_recovery = recovery_key(fixed_code().as_str()).unwrap(); let (ciphertext, nonce) = encrypt_recovery_key(&key, &wrong_recovery, &file.recovery_aad().unwrap()).unwrap(); file.recovery = Some(RecoveryWrapper { @@ -643,17 +663,17 @@ fn binding_rejects_a_wrapper_with_correct_key_and_different_payload_code() { file.save_to_path(&path).unwrap(); // Unwrapping and primary decryption both succeed; payload/code binding must fail. assert_eq!( - PasswordBox::unwrap_recovery_key(&file, FIXED_CODE) + PasswordBox::unwrap_recovery_key(&file, fixed_code().as_str()) .unwrap() .as_bytes(), key.as_bytes() ); assert_eq!( - PasswordBox::recover(&path, FIXED_CODE).err(), + PasswordBox::recover(&path, fixed_code().as_str()).err(), Some(PassShareError::CorruptFile) ); assert_eq!( - PasswordBox::open(&path, OLD_PASSWORD).err(), + PasswordBox::open(&path, old_password().as_str()).err(), Some(PassShareError::CorruptFile) ); } @@ -663,16 +683,16 @@ fn cross_file_wrapper_is_rejected_even_with_identical_master_and_recovery_keys() let dir = TempDir::new().unwrap(); let path = dir.path().join("a.pbox"); let other = dir.path().join("b.pbox"); - let first = PasswordBox::create(&path, OLD_PASSWORD).unwrap(); + let first = PasswordBox::create(&path, old_password().as_str()).unwrap(); let code = Zeroizing::new(first.recovery_secret().unwrap().unwrap().to_owned()); - let mut second = PasswordBox::create(&other, OLD_PASSWORD).unwrap(); + let mut second = PasswordBox::create(&other, old_password().as_str()).unwrap(); second.salt = first.salt; second.payload.recovery_secret = Some(code.to_string()); - second.save(OLD_PASSWORD).unwrap(); + second.save(old_password().as_str()).unwrap(); assert!(PasswordBox::recover(&other, &code).is_ok()); let mut original = PasswordBoxFile::load_from_path(&path).unwrap(); original.recovery = PasswordBoxFile::load_from_path(&other).unwrap().recovery; original.save_to_path(&path).unwrap(); assert!(PasswordBox::recover(&path, &code).is_err()); - assert!(PasswordBox::open(&path, OLD_PASSWORD).is_err()); + assert!(PasswordBox::open(&path, old_password().as_str()).is_err()); } diff --git a/crates/passshare-core/src/storage/file_format.rs b/crates/passshare-core/src/storage/file_format.rs index 092e2cea..9daadffb 100644 --- a/crates/passshare-core/src/storage/file_format.rs +++ b/crates/passshare-core/src/storage/file_format.rs @@ -190,9 +190,10 @@ fn decode_fixed(encoded: &str) -> Result<[u8; N], PassShareError if bytes.len() != N { return Err(PassShareError::CorruptFile); } - let mut out = [0u8; N]; - out.copy_from_slice(&bytes); - Ok(out) + bytes + .as_slice() + .try_into() + .map_err(|_| PassShareError::CorruptFile) } mod base64_bytes { diff --git a/crates/passshare-core/src/testdata/pbox-v1-golden.json b/crates/passshare-core/src/testdata/pbox-v1-golden.json new file mode 100644 index 00000000..e8e30011 --- /dev/null +++ b/crates/passshare-core/src/testdata/pbox-v1-golden.json @@ -0,0 +1,19 @@ +{ + "masterPassword": "synthetic-old-password", + "recoveryCode": "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=", + "file": { + "version": 1, + "kdf": { + "algorithm": "argon2id", + "salt": "AQEBAQEBAQEBAQEBAQEBAQ==", + "memory_kib": 65536, + "time_cost": 3, + "parallelism": 4 + }, + "cipher": { + "algorithm": "xchacha20poly1305", + "nonce": "AgICAgICAgICAgICAgICAgICAgICAgIC" + }, + "ciphertext": "uk2E9pzsrI6RYnpuSfPcLalhiP2iy3MLWWGWhiN7gZ/Zwa5/UUX8Is4Zb9XV8JHe+kKJZfVzOxULib9Y49G2Z5/SQ32T+WGBNABjYp0E2GKJPtSy2YFpZ9xoMyRv/TPVEziLPj+pVBFR4VCsdRLT3wbjed26usQeSin+4gD8c5LyaEeL2Axop4Ur3VVvVcW0MANAOcjiO7doM/q4Ryv+e6Q938NpFZal4t3nU+NZaHcB95mArO0rZxd+NsnGyZAQf0KMWwUayw9tuqaemFRljdYQXOsQlx4Nu5tFV3mnq0wFK/t2m7vLeX1rtLJFZQNMSX/anrEJQQ/8vLAtkIGTto0cU7XQ/qn0yOYvmh8h+aueRspvSUzBo2IPuCkH+b1Wrwsa9RHwewKypAEVvp+ZuIOu0HdM2G4Vz6if3z/j9Q==" + } +} \ No newline at end of file From 673062904be9a3bb8c86bb8dd198e80822254a01 Mon Sep 17 00:00:00 2001 From: mashfromband Date: Sat, 10 Oct 2026 21:35:18 +0900 Subject: [PATCH 07/14] build(wasm): synchronize validated guarded key construction assets --- frontend/cloud-assets.json | 12 ++++++------ .../cloud-crypto/passshare_cloud_wasm_bg.wasm | Bin 666294 -> 666296 bytes 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/cloud-assets.json b/frontend/cloud-assets.json index 1cb867ac..b9d2dd3f 100644 --- a/frontend/cloud-assets.json +++ b/frontend/cloud-assets.json @@ -1,6 +1,6 @@ { - "sourceCommit": "91f7f45415f5df12e47d699250f10d440a8570c8", - "run": 38048434256, + "sourceCommit": "af9b719330472f78efd3eecc7f8c6b74190b2f24", + "run": 38051988347, "buildKind": "github-actions-tested", "inputs": { "Cargo.toml": "f3a088bb1e72f919cbc8d485f97e87a377f5be619993b485b70fa30b01f83012", @@ -14,9 +14,9 @@ "crates/passshare-core/src/migration.rs": "5b8aa4736edda87a8ba78f243af9a18a1a5a2cfa82f38dedf53bd5f65fd81a83", "crates/passshare-core/src/lib.rs": "c18ff9e5539495cdcee8eb3f8e49a89edb21961e7dd07e210feeada60082e115", "crates/passshare-core/src/password_box.rs": "b367cffc66af8abc781b94593a4518b5f023c326430385b2447b2ceb5c1d2235", - "crates/passshare-core/src/crypto.rs": "f2f01d76d0662fb9ca27aa95dd89e2b0055a620845d3eb1b6e94be74a626ff80", + "crates/passshare-core/src/crypto.rs": "012e085319f5bb7d9e64e97c8f9c718f0039b2ba4972244b42c846c654d67260", "crates/passshare-core/src/storage/mod.rs": "5e73deea4e8723038ad5f1850260bb6b7671820c77b891456467a277453a2727", - "crates/passshare-core/src/storage/file_format.rs": "fcb7899ec67d76f390ca70db562dd175154fca29996716475cf471b323a4e3b9", + "crates/passshare-core/src/storage/file_format.rs": "490213271a666adbb2d9bb1476651905a804d8f4bdb5b55752dc974bbc687bfe", "crates/passshare-core/src/password_box_saved_tests.rs": "fd3f2671df037a9775cd477b3cbc507533c2872b94d13cf1de12250a24b9a84f", "crates/passshare-core/src/generator.rs": "fdf09c4c0ee524e2b5c8f1c65674f95a991174a268754337d0e8f96729da04e0", "crates/passshare-core/src/export/op_csv.rs": "b12a369e58b03baed7301a23ce1b98021b2058be8b87559d409935b67e0cda4b", @@ -38,12 +38,12 @@ "packages/cloud-wasm/src/item_edit.rs": "4373427b6397c1f2d2de8dd69df96419f67c7c7be03eaa3207e0a7bbaf3de97e", "backend/native/src/edit.rs": "d8e39b54705e65aa82cab651ca6604e087b9b219bc7c290cce4b8e728e29f89e", "packages/cloud-wasm/src/recovery.rs": "50cc8af8b7663dc46cf5cdc667a871df569da9587043853dbd3665dfd4a1e997", - "crates/passshare-core/src/password_box_recovery_tests.rs": "03fae103483fbd876cfacef89280908c4b332076318886252ed9c34bbe23a567" + "crates/passshare-core/src/password_box_recovery_tests.rs": "505a7f7f682350f19af2ab09aad62de6c4ebd46929a2778c2877b0ad9e9de770" }, "assets": { "boot.mjs": "d084febcad89252109d54221eb6d10b5934e9c8cd83f4aa5e59f311c888befc7", "passshare_cloud_wasm.js": "c990db62845225f5051f1eb075fb1284167371c3fc42440fe753bf1ad0cb97c0", - "passshare_cloud_wasm_bg.wasm": "ccc42f3042568bba5082daa3232e63665d88fda19b9dacd18f581c925f3f1136" + "passshare_cloud_wasm_bg.wasm": "10f6edf3c668fc84550144001ec149a9f3f1a1af7f54d7efc2fe271a204fbe41" }, "artifact": "passshare-cloud-wasm" } diff --git a/frontend/public/cloud-crypto/passshare_cloud_wasm_bg.wasm b/frontend/public/cloud-crypto/passshare_cloud_wasm_bg.wasm index 2e2ec6015793b5de026da86c9548d71fe2b317f6..24be6c92950feaef9c7eff078548591a04e6b331 100644 GIT binary patch delta 17773 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 TmhmwYiWFncTF)PBsYCw<@ZMDj delta 17710 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

P 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 Date: Sat, 10 Oct 2026 21:54:32 +0900 Subject: [PATCH 08/14] test: never print recovery secrets from failing comparisons --- crates/passshare-core/src/password_box_recovery_tests.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/passshare-core/src/password_box_recovery_tests.rs b/crates/passshare-core/src/password_box_recovery_tests.rs index 9212062b..f83a0f17 100644 --- a/crates/passshare-core/src/password_box_recovery_tests.rs +++ b/crates/passshare-core/src/password_box_recovery_tests.rs @@ -226,7 +226,7 @@ fn v2_restart_recover_rekey_preserves_full_json_and_code() { let mut reopened = PasswordBox::open(&path, new_password().as_str()).unwrap(); assert!(reopened.payload == expected); reopened.save(new_password().as_str()).unwrap(); - assert_eq!(raw_payload(&path, new_password().as_str()).0, raw_before.0); + assert!(raw_payload(&path, new_password().as_str()).0 == raw_before.0); assert_eq!( PasswordBox::recover(&path, &code).unwrap().payload, expected @@ -433,7 +433,7 @@ fn failure_preserves_original(upgrade: bool) { if upgrade { pb.enable_recovery(old_password().as_str()).unwrap(); } else { - assert_eq!(PasswordBox::recover(&path, &code).unwrap().payload, payload); + assert!(PasswordBox::recover(&path, &code).unwrap().payload == payload); assert!(pb.recovery_only); pb.rekey_recovered(new_password().as_str()).unwrap(); } @@ -502,7 +502,7 @@ fn real_atomic_failures_leave_the_original_path_and_state_unchanged() { assert!(PasswordBox::recover(&path, &code).is_ok()); assert!(PasswordBox::open(&path, new_password().as_str()).is_err()); assert!(legacy.rotate_recovery(old_password().as_str()).is_err()); - assert_eq!(legacy.recovery_secret().unwrap(), Some(code.as_str())); + assert!(legacy.recovery_secret().unwrap() == Some(code.as_str())); assert_eq!(fs::read(&path).unwrap(), before); assert!(legacy.save(old_password().as_str()).is_err()); assert_eq!(fs::read(&path).unwrap(), before); @@ -551,7 +551,7 @@ fn recovered_lock_and_drop_scrub_owned_buffers_and_return_no_secrets() { assert!(recovered.payload.recovery_secret.as_deref() == Some("")); assert!(recovered.original_payload.is_none()); recovered.lock(); - assert_eq!(recovered.recovery_secret(), Err(PassShareError::BoxLocked)); + assert!(recovered.recovery_secret() == Err(PassShareError::BoxLocked)); assert_eq!( recovered.list_connect_items(), Err(PassShareError::BoxLocked) From 5770ad0713d66c3f28331487e7147a26e01c3144 Mon Sep 17 00:00:00 2001 From: mashfromband Date: Sat, 10 Oct 2026 22:05:58 +0900 Subject: [PATCH 09/14] build(web): promote recovery assets verified against final core sources --- frontend/cloud-assets.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/cloud-assets.json b/frontend/cloud-assets.json index b9d2dd3f..c518fc64 100644 --- a/frontend/cloud-assets.json +++ b/frontend/cloud-assets.json @@ -1,6 +1,6 @@ { - "sourceCommit": "af9b719330472f78efd3eecc7f8c6b74190b2f24", - "run": 38051988347, + "sourceCommit": "865c0653f5f529bf0b4b2c253cf666d70dba3670", + "run": 38053698941, "buildKind": "github-actions-tested", "inputs": { "Cargo.toml": "f3a088bb1e72f919cbc8d485f97e87a377f5be619993b485b70fa30b01f83012", @@ -38,7 +38,7 @@ "packages/cloud-wasm/src/item_edit.rs": "4373427b6397c1f2d2de8dd69df96419f67c7c7be03eaa3207e0a7bbaf3de97e", "backend/native/src/edit.rs": "d8e39b54705e65aa82cab651ca6604e087b9b219bc7c290cce4b8e728e29f89e", "packages/cloud-wasm/src/recovery.rs": "50cc8af8b7663dc46cf5cdc667a871df569da9587043853dbd3665dfd4a1e997", - "crates/passshare-core/src/password_box_recovery_tests.rs": "505a7f7f682350f19af2ab09aad62de6c4ebd46929a2778c2877b0ad9e9de770" + "crates/passshare-core/src/password_box_recovery_tests.rs": "ed73edc5ff17988a875737b7b4c894e5e7a812eb285854a9d4e5bedee4403c56" }, "assets": { "boot.mjs": "d084febcad89252109d54221eb6d10b5934e9c8cd83f4aa5e59f311c888befc7", From d9214fe78b32fb4bc7f50bb6acf712f934c189ce Mon Sep 17 00:00:00 2001 From: mashfromband Date: Sat, 10 Oct 2026 22:33:15 +0900 Subject: [PATCH 10/14] test(core): keep decrypted payloads and keys out of assertion diagnostics --- .../src/password_box_recovery_tests.rs | 81 +++++-------------- 1 file changed, 22 insertions(+), 59 deletions(-) diff --git a/crates/passshare-core/src/password_box_recovery_tests.rs b/crates/passshare-core/src/password_box_recovery_tests.rs index f83a0f17..bfbdc6aa 100644 --- a/crates/passshare-core/src/password_box_recovery_tests.rs +++ b/crates/passshare-core/src/password_box_recovery_tests.rs @@ -130,57 +130,35 @@ fn first_save_and_rekey_preserve_reference_extensions_and_duplicate_url_metadata file.save_to_path(&path).unwrap(); drop(pb); let original = raw_payload(&path, old_password().as_str()); - assert_eq!( - original.0["items"][0]["vault"]["name"], - "synthetic-reference-name" - ); - assert_eq!( - original.0["items"][0]["urls"][1]["extension"], - "second-only" - ); + assert!(original.0["items"][0]["vault"]["name"] == "synthetic-reference-name"); + assert!(original.0["items"][0]["urls"][1]["extension"] == "second-only"); let mut opened = PasswordBox::open(&path, old_password().as_str()).unwrap(); opened.save(old_password().as_str()).unwrap(); drop(opened); let saved = raw_payload(&path, old_password().as_str()); - assert_eq!( - saved.0["items"][0]["vault"], - original.0["items"][0]["vault"] - ); - assert_eq!(saved.0["items"][0]["urls"], original.0["items"][0]["urls"]); + assert!(saved.0["items"][0]["vault"] == original.0["items"][0]["vault"]); + assert!(saved.0["items"][0]["urls"] == original.0["items"][0]["urls"]); let mut reordered = PasswordBox::open(&path, old_password().as_str()).unwrap(); reordered.payload.items[0].urls.reverse(); reordered.save(old_password().as_str()).unwrap(); drop(reordered); let ordered = raw_payload(&path, old_password().as_str()); - assert_eq!( - ordered.0["items"][0]["urls"][0], - original.0["items"][0]["urls"][1] - ); - assert_eq!( - ordered.0["items"][0]["urls"][1], - original.0["items"][0]["urls"][0] - ); + assert!(ordered.0["items"][0]["urls"][0] == original.0["items"][0]["urls"][1]); + assert!(ordered.0["items"][0]["urls"][1] == original.0["items"][0]["urls"][0]); let mut removed = PasswordBox::open(&path, old_password().as_str()).unwrap(); removed.payload.items[0].urls.remove(1); removed.save(old_password().as_str()).unwrap(); drop(removed); let retained = raw_payload(&path, old_password().as_str()); - assert_eq!( - retained.0["items"][0]["urls"], - serde_json::json!([original.0["items"][0]["urls"][1]]) + assert!( + retained.0["items"][0]["urls"] == serde_json::json!([original.0["items"][0]["urls"][1]]) ); let mut recovered = PasswordBox::recover(&path, &code).unwrap(); recovered.rekey_recovered(new_password().as_str()).unwrap(); drop(recovered); let rekeyed = raw_payload(&path, new_password().as_str()); - assert_eq!( - rekeyed.0["items"][0]["vault"], - original.0["items"][0]["vault"] - ); - assert_eq!( - rekeyed.0["items"][0]["urls"], - retained.0["items"][0]["urls"] - ); + assert!(rekeyed.0["items"][0]["vault"] == original.0["items"][0]["vault"]); + assert!(rekeyed.0["items"][0]["urls"] == retained.0["items"][0]["urls"]); } #[test] @@ -227,20 +205,14 @@ fn v2_restart_recover_rekey_preserves_full_json_and_code() { assert!(reopened.payload == expected); reopened.save(new_password().as_str()).unwrap(); assert!(raw_payload(&path, new_password().as_str()).0 == raw_before.0); - assert_eq!( - PasswordBox::recover(&path, &code).unwrap().payload, - expected - ); - assert_eq!( + assert!(PasswordBox::recover(&path, &code).unwrap().payload == expected); + assert!( PasswordBox::open(&backup, old_password().as_str()) .unwrap() - .payload, - expected - ); - assert_eq!( - PasswordBox::recover(&backup, &code).unwrap().payload, - expected + .payload + == expected ); + assert!(PasswordBox::recover(&backup, &code).unwrap().payload == expected); } #[test] @@ -552,10 +524,7 @@ fn recovered_lock_and_drop_scrub_owned_buffers_and_return_no_secrets() { assert!(recovered.original_payload.is_none()); recovered.lock(); assert!(recovered.recovery_secret() == Err(PassShareError::BoxLocked)); - assert_eq!( - recovered.list_connect_items(), - Err(PassShareError::BoxLocked) - ); + assert!(recovered.list_connect_items() == Err(PassShareError::BoxLocked)); assert_eq!( recovered.rekey_recovered(new_password().as_str()), Err(PassShareError::BoxLocked) @@ -587,17 +556,11 @@ fn unknown_item_fields_survive_but_removed_known_fields_stay_removed() { assert!(raw.0["items"][0].get("tags").is_none()); assert!(raw.0["items"][0].get("lastEditedBy").is_none()); assert!(raw.0["items"][0]["fields"][0].get("purpose").is_none()); - assert_eq!(raw.0["items"][0]["version"], 17); - assert_eq!( - raw.0["items"][0]["files"][0]["contentPath"], - "attachments/ref" - ); + assert!(raw.0["items"][0]["version"] == 17); + assert!(raw.0["items"][0]["files"][0]["contentPath"] == "attachments/ref"); pb.apply_and_save(SavedChange::Delete(&id), old_password().as_str()) .unwrap(); - assert_eq!( - raw_payload(&path, old_password().as_str()).0["items"], - serde_json::json!([]) - ); + assert!(raw_payload(&path, old_password().as_str()).0["items"] == serde_json::json!([])); } #[test] @@ -662,11 +625,11 @@ fn binding_rejects_a_wrapper_with_correct_key_and_different_payload_code() { }); file.save_to_path(&path).unwrap(); // Unwrapping and primary decryption both succeed; payload/code binding must fail. - assert_eq!( + assert!( PasswordBox::unwrap_recovery_key(&file, fixed_code().as_str()) .unwrap() - .as_bytes(), - key.as_bytes() + .as_bytes() + == key.as_bytes() ); assert_eq!( PasswordBox::recover(&path, fixed_code().as_str()).err(), From 53e6c596c3124bab68f56e57fee6dadac3b34f93 Mon Sep 17 00:00:00 2001 From: mashfromband Date: Sat, 10 Oct 2026 22:41:25 +0900 Subject: [PATCH 11/14] build(web): attest final redacted recovery source inputs --- frontend/cloud-assets.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/cloud-assets.json b/frontend/cloud-assets.json index c518fc64..be6bb5b5 100644 --- a/frontend/cloud-assets.json +++ b/frontend/cloud-assets.json @@ -1,6 +1,6 @@ { - "sourceCommit": "865c0653f5f529bf0b4b2c253cf666d70dba3670", - "run": 38053698941, + "sourceCommit": "d9214fe78b32fb4bc7f50bb6acf712f934c189ce", + "run": 38056168946, "buildKind": "github-actions-tested", "inputs": { "Cargo.toml": "f3a088bb1e72f919cbc8d485f97e87a377f5be619993b485b70fa30b01f83012", @@ -38,7 +38,7 @@ "packages/cloud-wasm/src/item_edit.rs": "4373427b6397c1f2d2de8dd69df96419f67c7c7be03eaa3207e0a7bbaf3de97e", "backend/native/src/edit.rs": "d8e39b54705e65aa82cab651ca6604e087b9b219bc7c290cce4b8e728e29f89e", "packages/cloud-wasm/src/recovery.rs": "50cc8af8b7663dc46cf5cdc667a871df569da9587043853dbd3665dfd4a1e997", - "crates/passshare-core/src/password_box_recovery_tests.rs": "ed73edc5ff17988a875737b7b4c894e5e7a812eb285854a9d4e5bedee4403c56" + "crates/passshare-core/src/password_box_recovery_tests.rs": "11e9761c7801e5da4363e13e45cc5d13ff59f50d3da9887088579c66ea003776" }, "assets": { "boot.mjs": "d084febcad89252109d54221eb6d10b5934e9c8cd83f4aa5e59f311c888befc7", From 2cef3beae409f8d08d752754daa098807fc2ef0e Mon Sep 17 00:00:00 2001 From: mashfromband Date: Sat, 10 Oct 2026 23:15:02 +0900 Subject: [PATCH 12/14] refactor(core): contain recovery validation within a boolean integrity boundary --- crates/passshare-core/src/password_box.rs | 38 +++++++++++++++---- .../src/password_box_recovery_tests.rs | 33 ++++++++++++++++ 2 files changed, 63 insertions(+), 8 deletions(-) diff --git a/crates/passshare-core/src/password_box.rs b/crates/passshare-core/src/password_box.rs index 863f56a2..5379a955 100644 --- a/crates/passshare-core/src/password_box.rs +++ b/crates/passshare-core/src/password_box.rs @@ -134,13 +134,8 @@ impl PasswordBox { )?; let pb = Self::open_with_key(path, &file, &key, false)?; if file.version == RECOVERY_FILE_VERSION { - let code = pb - .payload - .recovery_secret - .as_deref() - .ok_or(PassShareError::CorruptFile)?; - let recovered = Self::unwrap_recovery_key(&file, code)?; - if recovered.as_bytes() != key.as_bytes() { + let aad = file.recovery_aad()?; + if !pb.recovery_binding_matches(&file, &key, &aad) { return Err(PassShareError::CorruptFile); } } @@ -164,6 +159,15 @@ impl PasswordBox { fn unwrap_recovery_key( file: &PasswordBoxFile, code: &str, + ) -> Result { + let aad = file.recovery_aad()?; + Self::unwrap_recovery_key_with_aad(file, code, &aad) + } + + fn unwrap_recovery_key_with_aad( + file: &PasswordBoxFile, + code: &str, + aad: &[u8], ) -> Result { let wrapper = file.recovery.as_ref().ok_or(PassShareError::CorruptFile)?; let recovery = recovery_key(code)?; @@ -171,10 +175,28 @@ impl PasswordBox { &wrapper.ciphertext, &recovery, &wrapper.cipher.nonce_bytes()?, - &file.recovery_aad()?, + aad, ) } + // Normal opening already authenticated the primary payload. This check + // reveals only whether its recovery wrapper binds the same master key; + // neither the code nor the unwrapped key leaves the verification boundary. + fn recovery_binding_matches( + &self, + file: &PasswordBoxFile, + expected: &MasterKey, + aad: &[u8], + ) -> bool { + let Some(code) = self.payload.recovery_secret.as_deref() else { + return false; + }; + match Self::unwrap_recovery_key_with_aad(file, code, aad) { + Ok(recovered) => recovered.as_bytes() == expected.as_bytes(), + Err(_) => false, + } + } + fn open_with_key( path: PathBuf, file: &PasswordBoxFile, diff --git a/crates/passshare-core/src/password_box_recovery_tests.rs b/crates/passshare-core/src/password_box_recovery_tests.rs index bfbdc6aa..e7b487d8 100644 --- a/crates/passshare-core/src/password_box_recovery_tests.rs +++ b/crates/passshare-core/src/password_box_recovery_tests.rs @@ -260,6 +260,39 @@ fn upgrade_without_legacy_code_generates_one() { assert!(PasswordBox::recover(&path, &code).is_ok()); } +#[test] +fn normal_open_recovery_binding_exposes_only_an_integrity_decision() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("binding.pbox"); + let mut pb = PasswordBox::create(&path, old_password().as_str()).unwrap(); + let mut file = PasswordBoxFile::load_from_path(&path).unwrap(); + let salt = file.kdf.salt_bytes().unwrap(); + let expected = derive_master_key_with_params( + old_password().as_str(), + &salt, + file.kdf.memory_kib, + file.kdf.time_cost, + file.kdf.parallelism, + ) + .unwrap(); + let aad = file.recovery_aad().unwrap(); + assert!(pb.recovery_binding_matches(&file, &expected, &aad)); + let other = MasterKey::generate().unwrap(); + assert!(!pb.recovery_binding_matches(&file, &other, &aad)); + let code = Zeroizing::new(pb.payload.recovery_secret.take().unwrap()); + assert!(!pb.recovery_binding_matches(&file, &expected, &aad)); + pb.payload.recovery_secret = Some("invalid".into()); + assert!(!pb.recovery_binding_matches(&file, &expected, &aad)); + pb.payload.recovery_secret = Some(code.to_string()); + file.recovery.as_mut().unwrap().ciphertext[0] ^= 1; + assert!(!pb.recovery_binding_matches(&file, &expected, &aad)); + file.save_to_path(&path).unwrap(); + assert!( + PasswordBox::open(&path, old_password().as_str()).err() + == Some(PassShareError::CorruptFile) + ); +} + #[test] fn wrong_code_cross_file_wrapper_and_primary_tampering_are_rejected() { let dir = TempDir::new().unwrap(); From 967b7b55aaa152800b6b21be4283e8eaa510ba58 Mon Sep 17 00:00:00 2001 From: mashfromband Date: Sat, 10 Oct 2026 23:16:44 +0900 Subject: [PATCH 13/14] fix(acceptance): stream fixture SQL with single-request semantics --- .../spec/encrypted-restore-acceptance.md | 37 ++ .../encrypted-storage-acceptance/src/main.rs | 418 ++++++++++++++---- 2 files changed, 379 insertions(+), 76 deletions(-) diff --git a/documents/spec/encrypted-restore-acceptance.md b/documents/spec/encrypted-restore-acceptance.md index c7c18ae2..f64c1c3b 100644 --- a/documents/spec/encrypted-restore-acceptance.md +++ b/documents/spec/encrypted-restore-acceptance.md @@ -61,6 +61,20 @@ PostgreSQL 16とサーバー内の `owner_id` を既存の手順で照合する archiveは上限64MiBのメモリ内に保持し、dump/restoreプロセスの起動・入力・出力回収には 60秒の共通期限を設ける。DB作成も60秒で打ち切り、作成先を起動前から所有登録する。 通常のSQL期限と所有資源のDropを維持する。 +通常のfixture SQLもDockerでは `exec -i`、psqlでは `-f -` を使い、全文を標準入力へ送る。 +SQLをBase64で運び、1行1列の `SELECT convert_from(decode(...))` の結果を `\gexec` で +そのまま1回のサーバー要求として実行する。SQL全文をプロセス引数やログへ出さない。 +単に `-f -` で文を分割したり、全呼び出しを `--single-transaction` で包んだりしない。 +元の単一 `-c` と同じ暗黙トランザクションと複数結果の出力を保つ。 +この実行単位は [PostgreSQL 16のpsql仕様](https://www.postgresql.org/docs/16/app-psql.html) の +`-c` と `\gexec` の規則に従う。 +呼び出し元を確認すると、通常の読取・更新・複数文DDLのほか、識別鍵と共有鍵の制約検査に +明示的なBEGIN/COMMITがあり、暗号化操作の拒否検査にはBEGIN/ROLLBACKがある。 +関数定義の中のBEGIN、引用・改行・コメントも分割しない。 +トランザクション外で実行するDB作成・削除は、別の所有fixture制御のままにする。 +入力の書込と両出力の回収を同時に進め、呼び出し元が渡した絶対期限を更新しない。 +終了していないI/Oスレッドをjoinしない。失敗時は子プロセスをkillして最大2秒でreapし、 +回収できなければ既存の後片付け失敗フラグを立てる。新規依存は追加しない。 プロセスの異常終了、期限超過、内容不一致、後片付け失敗を成功に読み替えない。 秘密値、暗号文、パスワード、認証文字列、DTO本文、PostgreSQLエラー本文を報告しない。 失敗は静的な分類、成功は件数とfixture測定だけを出力する。 @@ -77,6 +91,29 @@ archiveは上限64MiBのメモリ内に保持し、dump/restoreプロセスの ## 検証記録 +SQLの標準入力経路には、Docker不要の子プロセス対照検査がある。 +256KiBを超える入力を書いている間に両出力を回収する成功対照、入力を読まない子の期限超過、 +早期終了による書込失敗、8MiBの出力上限超過、起動前に期限切れになった要求を検査する。 +所有PostgreSQLの実検査は、通常の `LocalPostgres::start` でこの実行専用DBを作り、 +所有ID・用途・DB名を照合する。引用・改行・日本語を含む300KiB以上のSQLで全値を照合し、 +暗黙トランザクション、複数結果、エラー時のDML/DDL巻き戻し、明示的COMMIT/ROLLBACK、 +トランザクション外のVACUUM、Dropでの専用DB削除を検査する。 +この実検査は明示的な所有manifestを必要とし、通常のunit testでは実行しない。 + +```powershell +$env:PASSSHARE_SQL_FIXTURE_MANIFEST='' +cargo test --locked --manifest-path tools/encrypted-storage-acceptance/Cargo.toml -p passshare-encrypted-storage-acceptance contract_tests::sql_transport_live_large_sql_preserves_single_request_semantics -- --ignored --exact --nocapture +``` + +2026-10-10、SQL経路の最終ソースで書式検査、nextest 12件、Clippy全対象 +(警告をエラー扱い)が成功した。nextestで省略した2件は子プロセス専用対照と +明示manifestが必要な実DB検査である。後者は指定の所有PostgreSQL 16.15で別に実行し、 +1件成功と専用DBの後片付けを確認した。既存101項目の条件と拒否検査は変更していない。 +この修正のDocker配布イメージ方式は担当のPCでは実行しておらず、CI成功は未確認である。 +途中の試行ではWindowsの改行比較を修正したほか、DB操作が静的分類 `other` で2回失敗した。 +その2回の原因は取得済みの静的ログからは特定できていない。削除が失敗した専用DB1件は、 +用途・所有ID・DB名を照合してそのDBだけを削除した。失敗ログも保持している。 + 2026-10-10、基点 `a6aa864` の既存Core/API/SDKを使い、指定の所有サーバーで `--local-postgres ... --encrypted-restore ...` を実行した。終了コード0と `encrypted storage mode=node acceptance and owned fixture cleanup passed` を確認した。 diff --git a/tools/encrypted-storage-acceptance/src/main.rs b/tools/encrypted-storage-acceptance/src/main.rs index 05d10c18..e6c772f1 100644 --- a/tools/encrypted-storage-acceptance/src/main.rs +++ b/tools/encrypted-storage-acceptance/src/main.rs @@ -244,82 +244,10 @@ impl Database { self.sql_before(sql, std::time::Instant::now() + Duration::from_secs(15)) } fn sql_before(&self, sql: &str, deadline: std::time::Instant) -> String { - let mut child = self - .psql(false, None) - .args(["-c", sql]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - let stdout = child.stdout.take().unwrap(); - let stderr = child.stderr.take().unwrap(); - let (sender, receiver) = std::sync::mpsc::channel(); - let mut readers = Vec::new(); - for (is_stdout, pipe) in [ - (true, Box::new(stdout) as Box), - (false, Box::new(stderr) as Box), - ] { - let sender = sender.clone(); - readers.push(thread::spawn(move || { - let mut bytes = Vec::new(); - let result = pipe.take(8 * 1024 * 1024 + 1).read_to_end(&mut bytes); - let _ = sender.send((is_stdout, result, bytes)); - })); - } - drop(sender); - let mut failure_category = "other"; - let status = loop { - if let Some(status) = child.try_wait().unwrap() { - break status; - } - if std::time::Instant::now() >= deadline { - let _ = child.kill(); - if !reap_before( - &mut child, - std::time::Instant::now() + Duration::from_secs(2), - ) { - CLEANUP_FAILED.store(true, Ordering::SeqCst); - } - panic!("fixture SQL exceeded its deadline"); - } - thread::sleep(Duration::from_millis(10)); - }; - let mut output = None; - for _ in 0..2 { - let (is_stdout, result, bytes) = receiver - .recv_timeout(deadline.saturating_duration_since(std::time::Instant::now())) - .expect("fixture SQL output drain exceeded deadline"); - result.expect("fixture SQL output read failed"); - assert!( - bytes.len() <= 8 * 1024 * 1024, - "fixture SQL output exceeded bound" - ); - if is_stdout { - output = Some(bytes); - } else { - let diagnostic = String::from_utf8_lossy(&bytes); - if diagnostic.contains("canceling statement due to statement timeout") { - failure_category = "statement_timeout"; - } else if diagnostic.contains("does not exist") { - failure_category = "missing_dependency"; - } else if diagnostic.contains("could not connect") - || diagnostic.contains("connection refused") - { - failure_category = "connection_unavailable"; - } - } - } - for reader in readers { - reader.join().expect("fixture SQL reader failed"); - } - assert!( - status.success(), - "fixture SQL failed category={failure_category}" - ); - String::from_utf8(output.unwrap()) - .unwrap() - .trim() - .to_owned() + let mut cmd = self.psql(true, None); + // SQLSTATE is safe diagnostic metadata; verbose text is never logged. + cmd.args(["-f", "-", "-v", "ECHO=none", "-v", "VERBOSITY=verbose"]); + fixture_sql(cmd, sql, deadline).unwrap_or_else(|reason| panic!("fixture SQL {reason}")) } fn psql(&self, interactive: bool, label: Option<&str>) -> Command { let mut c = if let Some(local) = &self.local { @@ -363,6 +291,157 @@ impl Database { ) } } + +struct SqlChild(Child); +impl Drop for SqlChild { + fn drop(&mut self) { + if !matches!(self.0.try_wait(), Ok(Some(_))) { + let _ = self.0.kill(); + if !reap_before( + &mut self.0, + std::time::Instant::now() + Duration::from_secs(2), + ) { + CLEANUP_FAILED.store(true, Ordering::SeqCst); + } + } + } +} + +// -f alone splits statements and changes the former single -c transaction/result +// semantics. One gexec cell sends the ENTIRE SQL literally as one server request. +// Base64 keeps quotes, newlines, dollar bodies and backslashes out of psql's lexer. +// Audited callers include explicit BEGIN/COMMIT/ROLLBACK and multi-statement DDL; +// CREATE/DROP DATABASE use the separate local fixture control, without wrapping. +fn fixture_sql( + mut cmd: Command, + sql: &str, + deadline: std::time::Instant, +) -> Result { + const OUTPUT_LIMIT: usize = 8 * 1024 * 1024; + if std::time::Instant::now() >= deadline { + return Err("exceeded its deadline"); + } + let input = format!( + "SELECT pg_catalog.convert_from(pg_catalog.decode('{}','base64'),'UTF8') \\gexec\n", + BASE64.encode(sql) + ); + if std::time::Instant::now() >= deadline { + return Err("exceeded its deadline"); + } + let mut child = SqlChild( + cmd.stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|_| "launch failed")?, + ); + let (sender, receiver) = std::sync::mpsc::channel(); + let mut workers = Vec::new(); + let mut stdin = child.0.stdin.take().unwrap(); + let writer_sender = sender.clone(); + workers.push(thread::spawn(move || { + let result = stdin.write_all(input.as_bytes()); + drop(stdin); // EOF is part of completion, not deferred until after wait. + let _ = writer_sender.send((0, result.map(|_| 0), Vec::new())); + })); + for (kind, pipe) in [ + ( + 1, + Box::new(child.0.stdout.take().unwrap()) as Box, + ), + ( + 2, + Box::new(child.0.stderr.take().unwrap()) as Box, + ), + ] { + let sender = sender.clone(); + workers.push(thread::spawn(move || { + let mut bytes = Vec::new(); + let result = pipe.take(OUTPUT_LIMIT as u64 + 1).read_to_end(&mut bytes); + let _ = sender.send((kind, result, bytes)); + })); + } + drop(sender); + let mut status = None; + let mut output = Vec::new(); + let mut events = 0; + let mut failure = "failed category=other"; + loop { + if std::time::Instant::now() >= deadline { + return Err("exceeded its deadline"); + } + if status.is_none() { + status = child.0.try_wait().map_err(|_| "child wait failed")?; + } + while let Ok((kind, result, bytes)) = receiver.try_recv() { + result.map_err(|_| { + if kind == 0 { + "input write failed" + } else { + "output read failed" + } + })?; + if bytes.len() > OUTPUT_LIMIT { + return Err("output exceeded bound"); + } + events += 1; + if kind == 1 { + output = bytes; + } else if kind == 2 { + // PostgreSQL diagnostics can contain SQL/row values. Classify only. + let diagnostic = String::from_utf8_lossy(&bytes); + if diagnostic.contains("canceling statement due to statement timeout") { + failure = "failed category=statement_timeout"; + } else if diagnostic.contains("does not exist") { + failure = "failed category=missing_dependency"; + } else if diagnostic.contains("could not connect") + || diagnostic.contains("connection refused") + { + failure = "failed category=connection_unavailable"; + } else { + for (code, category) in [ + ("55P03", "failed category=lock_timeout"), + ("40P01", "failed category=deadlock"), + ("42601", "failed category=syntax_error"), + ("22021", "failed category=invalid_encoding"), + ("22012", "failed category=division_by_zero"), + ("53100", "failed category=disk_full"), + ("53200", "failed category=out_of_memory"), + ("53300", "failed category=connections_exhausted"), + ("55006", "failed category=object_in_use"), + ("42501", "failed category=permission_denied"), + ("XX000", "failed category=internal_error"), + ] { + if diagnostic.contains(&format!("ERROR: {code}:")) { + failure = category; + break; + } + } + } + } + } + // Even after a worker sends its event it may not have returned yet. + // Join only finished workers; never introduce an unbounded writer join. + if status.is_some() && events == 3 && workers.iter().all(|w| w.is_finished()) { + break; + } + thread::sleep(Duration::from_millis(10)); + } + for worker in workers { + worker.join().map_err(|_| "I/O worker failed")?; + } + if !status.unwrap().success() { + return Err(failure); + } + let output = String::from_utf8(output) + .map_err(|_| "output encoding failed")? + .trim() + .to_owned(); + if std::time::Instant::now() >= deadline { + return Err("exceeded its deadline"); + } + Ok(output) +} impl Drop for Database { fn drop(&mut self) { if self.local.is_none() { @@ -3256,6 +3335,193 @@ mod contract_tests { use super::*; use base64::engine::general_purpose::STANDARD as BASE64; + fn sql_child(mode: &str) -> Command { + let mut cmd = command(std::env::current_exe().unwrap().to_str().unwrap()); + cmd.args([ + "--ignored", + "--exact", + "contract_tests::sql_transport_child", + "--nocapture", + ]) + .env("PASSSHARE_SQL_CHILD_CONTROL", mode); + cmd + } + + #[test] + #[ignore = "subprocess control invoked only by the SQL transport tests"] + fn sql_transport_child() { + match std::env::var("PASSSHARE_SQL_CHILD_CONTROL") + .expect("explicit SQL child control") + .as_str() + { + "drain" => { + // Fill BOTH output pipes before consuming input. Sequential I/O + // deadlocks on this control even though the process is healthy. + std::io::stdout() + .write_all(&vec![b'Z'; 300 * 1024]) + .unwrap(); + std::io::stderr() + .write_all(&vec![b'E'; 300 * 1024]) + .unwrap(); + let mut input = Vec::new(); + std::io::stdin().read_to_end(&mut input).unwrap(); + assert!(input.len() > 256 * 1024); + assert!(input.ends_with(b" \\gexec\n")); + } + "blocked" => thread::sleep(Duration::from_secs(30)), + "reject" => std::process::exit(7), + "overflow" => { + let _ = std::io::stdout().write_all(&vec![b'Z'; 8 * 1024 * 1024 + 1]); + } + _ => panic!("unknown SQL child control"), + } + } + + #[test] + fn sql_transport_drains_both_outputs_while_writing_large_input() { + let reply = fixture_sql( + sql_child("drain"), + &"x".repeat(300 * 1024), + std::time::Instant::now() + Duration::from_secs(5), + ) + .expect("concurrent transport success control"); + assert_eq!(reply.bytes().filter(|b| *b == b'Z').count(), 300 * 1024); + } + + #[test] + fn sql_transport_deadline_covers_a_blocked_writer_and_reaps_child() { + let start = std::time::Instant::now(); + let reply = fixture_sql( + sql_child("blocked"), + &"x".repeat(2 * 1024 * 1024), + start + Duration::from_millis(400), + ); + assert!(matches!(reply, Err("exceeded its deadline"))); + assert!(start.elapsed() < Duration::from_secs(5)); + } + + #[test] + fn sql_transport_rejects_early_exit_and_output_overflow() { + for (mode, expected) in [ + ("reject", "input write failed"), + ("overflow", "output exceeded bound"), + ] { + let input = if mode == "overflow" { + "SELECT 1".to_owned() + } else { + "x".repeat(2 * 1024 * 1024) + }; + let reply = fixture_sql( + sql_child(mode), + &input, + std::time::Instant::now() + Duration::from_secs(5), + ); + assert!(matches!(reply, Err(reason) if reason == expected)); + } + assert!(matches!( + fixture_sql( + command("unused-sql-client"), + "SELECT 1", + std::time::Instant::now() + ), + Err("exceeded its deadline") + )); + } + + #[test] + #[ignore = "requires PASSSHARE_SQL_FIXTURE_MANIFEST naming an owned PostgreSQL 16 fixture"] + fn sql_transport_live_large_sql_preserves_single_request_semantics() { + let manifest = PathBuf::from( + std::env::var_os("PASSSHARE_SQL_FIXTURE_MANIFEST") + .expect("explicit owned PostgreSQL fixture manifest required"), + ); + assert!(manifest.is_absolute()); + let local = local_postgres::LocalPostgres::start(&manifest); + let db = Database { + name: local.database.clone(), + port: local.port, + local: Some(local), + }; + // The invocation owns this fresh DB. Bind its purpose outside public, + // just as restore acceptance does, and retain the standard Drop cleanup. + println!("SQL transport live owned_database={}", db.name); + println!("SQL transport live step=bind"); + db.sql("CREATE SCHEMA passshare_acceptance_control; CREATE TABLE passshare_acceptance_control.binding AS SELECT 'sql-transport-regression'::text AS purpose, owner_id, current_database() AS database_name FROM passshare_fixture_owner; CREATE TABLE sql_transport_fixture(id integer PRIMARY KEY, payload text)"); + assert_eq!(db.sql("SELECT purpose='sql-transport-regression' AND database_name=current_database() AND owner_id=(SELECT owner_id FROM passshare_fixture_owner) FROM passshare_acceptance_control.binding"), "t"); + let payload = format!( + "';\\q\n$$ :name /* BEGIN; COMMIT */ 日本語{}", + "x".repeat(300 * 1024) + ); + let quoted = payload.replace('\'', "''"); + let sql = format!("INSERT INTO sql_transport_fixture VALUES(1,'{quoted}'); SELECT octet_length(payload)::text || ':' || md5(payload) FROM sql_transport_fixture WHERE id=1"); + assert!(sql.len() > 256 * 1024); + println!("SQL transport live step=large_insert"); + let reply = db.sql(&sql); + assert!(reply.starts_with(&format!("{}:", payload.len()))); + // Compare the full stored value without putting it in assertion output. + println!("SQL transport live step=large_compare"); + assert_eq!( + db.sql(&format!( + "SELECT payload='{quoted}' FROM sql_transport_fixture WHERE id=1" + )), + "t" + ); + println!("SQL transport live step=transaction_ids"); + let txids = db.sql("SELECT txid_current(); SELECT txid_current()"); + let txids = txids.lines().collect::>(); + assert!(txids.len() == 2 && txids[0] == txids[1]); + assert_eq!( + db.sql("SELECT 'first'; SELECT 'last'") + .lines() + .collect::>(), + ["first", "last"] + ); + println!("SQL transport live: large input and multi-results passed"); + + let execute = |sql: &str| { + let mut cmd = db.psql(true, None); + cmd.args(["-f", "-", "-v", "ECHO=none", "-v", "VERBOSITY=verbose"]); + fixture_sql( + cmd, + sql, + std::time::Instant::now() + Duration::from_secs(15), + ) + }; + assert!( + execute("INSERT INTO sql_transport_fixture VALUES(2,'implicit'); SELECT 1/0").is_err() + ); + assert_eq!( + db.sql("SELECT count(*) FROM sql_transport_fixture WHERE id=2"), + "0" + ); + assert!(execute("CREATE TABLE sql_transport_rollback(id integer); SELECT 1/0").is_err()); + assert_eq!( + db.sql("SELECT to_regclass('sql_transport_rollback') IS NULL"), + "t" + ); + println!("SQL transport live: implicit DML/DDL rollback passed"); + assert_eq!(db.sql("BEGIN; INSERT INTO sql_transport_fixture VALUES(3,'rollback'); SELECT 'rollback-result'; ROLLBACK"), "rollback-result"); + assert_eq!( + db.sql("SELECT count(*) FROM sql_transport_fixture WHERE id=3"), + "0" + ); + assert!(execute( + "BEGIN; INSERT INTO sql_transport_fixture VALUES(4,'committed'); COMMIT; SELECT 1/0" + ) + .is_err()); + assert_eq!( + db.sql("SELECT count(*) FROM sql_transport_fixture WHERE id=4"), + "1" + ); + println!("SQL transport live: explicit rollback/commit passed"); + assert_eq!(db.sql("VACUUM sql_transport_fixture"), ""); + println!("SQL transport live: nontransaction operation passed"); + db.sql("DO $$ BEGIN IF (SELECT count(*) FROM sql_transport_fixture)<>2 THEN RAISE EXCEPTION 'synthetic count mismatch'; END IF; END $$"); + drop(db); + assert!(!CLEANUP_FAILED.load(Ordering::SeqCst)); + println!("SQL transport: >256KiB payload, exact bytes, one implicit transaction, multi-results, DDL rollback, explicit rollback/commit, nontransaction command and owned DB cleanup passed"); + } + #[test] fn cli_modes_are_exact_and_image_values_cannot_be_docker_options() { assert!(matches!( From d64f7ca8d09f166add9958f00cd96935a0e3cc0b Mon Sep 17 00:00:00 2001 From: mashfromband Date: Sat, 10 Oct 2026 23:29:15 +0900 Subject: [PATCH 14/14] build(web): attest the verified recovery integrity boundary --- frontend/cloud-assets.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/cloud-assets.json b/frontend/cloud-assets.json index be6bb5b5..041aeb52 100644 --- a/frontend/cloud-assets.json +++ b/frontend/cloud-assets.json @@ -1,6 +1,6 @@ { - "sourceCommit": "d9214fe78b32fb4bc7f50bb6acf712f934c189ce", - "run": 38056168946, + "sourceCommit": "2cef3beae409f8d08d752754daa098807fc2ef0e", + "run": 38058867258, "buildKind": "github-actions-tested", "inputs": { "Cargo.toml": "f3a088bb1e72f919cbc8d485f97e87a377f5be619993b485b70fa30b01f83012", @@ -13,7 +13,7 @@ "crates/passshare-core/src/error.rs": "5df9b408190b4ca2ea8efb74f844e35c4cfe9d1b27aff9f13525cc60236448f8", "crates/passshare-core/src/migration.rs": "5b8aa4736edda87a8ba78f243af9a18a1a5a2cfa82f38dedf53bd5f65fd81a83", "crates/passshare-core/src/lib.rs": "c18ff9e5539495cdcee8eb3f8e49a89edb21961e7dd07e210feeada60082e115", - "crates/passshare-core/src/password_box.rs": "b367cffc66af8abc781b94593a4518b5f023c326430385b2447b2ceb5c1d2235", + "crates/passshare-core/src/password_box.rs": "0ba68b61884fbb0d2919fea453c53a227638f1dad8adb8a4de49f190e9845c44", "crates/passshare-core/src/crypto.rs": "012e085319f5bb7d9e64e97c8f9c718f0039b2ba4972244b42c846c654d67260", "crates/passshare-core/src/storage/mod.rs": "5e73deea4e8723038ad5f1850260bb6b7671820c77b891456467a277453a2727", "crates/passshare-core/src/storage/file_format.rs": "490213271a666adbb2d9bb1476651905a804d8f4bdb5b55752dc974bbc687bfe", @@ -38,7 +38,7 @@ "packages/cloud-wasm/src/item_edit.rs": "4373427b6397c1f2d2de8dd69df96419f67c7c7be03eaa3207e0a7bbaf3de97e", "backend/native/src/edit.rs": "d8e39b54705e65aa82cab651ca6604e087b9b219bc7c290cce4b8e728e29f89e", "packages/cloud-wasm/src/recovery.rs": "50cc8af8b7663dc46cf5cdc667a871df569da9587043853dbd3665dfd4a1e997", - "crates/passshare-core/src/password_box_recovery_tests.rs": "11e9761c7801e5da4363e13e45cc5d13ff59f50d3da9887088579c66ea003776" + "crates/passshare-core/src/password_box_recovery_tests.rs": "508ba3a1a92579562f6ae62b83ae4d0983a94e20aaaa5b9bbb9a170d36284e79" }, "assets": { "boot.mjs": "d084febcad89252109d54221eb6d10b5934e9c8cd83f4aa5e59f311c888befc7",