Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
6cc7181
feat(core): add authenticated pbox v2 recovery and atomic rekey
mashfromband Oct 10, 2026
ae07962
test: update save-order mutation for recovered payload metadata
mashfromband Oct 10, 2026
1b84a19
fix(core): preserve reference and duplicate URL extensions during rec…
mashfromband Oct 10, 2026
7bdbae5
Merge remote-tracking branch 'origin/main' into feat/todo1163-pbox-re…
mashfromband Oct 10, 2026
91f7f45
fix(core): keep URL extensions attached across reorder and deletion
mashfromband Oct 10, 2026
0217e3d
build(wasm): promote verified pbox recovery dependencies and complete…
mashfromband Oct 10, 2026
af9b719
fix(core): construct keys after fill and separate golden recovery fix…
mashfromband Oct 10, 2026
6730629
build(wasm): synchronize validated guarded key construction assets
mashfromband Oct 10, 2026
865c065
test: never print recovery secrets from failing comparisons
mashfromband Oct 10, 2026
5770ad0
build(web): promote recovery assets verified against final core sources
mashfromband Oct 10, 2026
dbeb5fc
Merge remote-tracking branch 'origin/main' into feat/todo1163-pbox-re…
mashfromband Oct 10, 2026
d9214fe
test(core): keep decrypted payloads and keys out of assertion diagnos…
mashfromband Oct 10, 2026
53e6c59
build(web): attest final redacted recovery source inputs
mashfromband Oct 10, 2026
2cef3be
refactor(core): contain recovery validation within a boolean integrit…
mashfromband Oct 10, 2026
967b7b5
fix(acceptance): stream fixture SQL with single-request semantics
mashfromband Oct 10, 2026
d64f7ca
build(web): attest the verified recovery integrity boundary
mashfromband Oct 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,7 @@ fn update_primary_url(urls: &mut Vec<ItemUrl>, url: Option<String>) {
urls[index].href = href;
}
None if !href.is_empty() => urls.push(ItemUrl {
extensions: Default::default(),
label: String::new(),
primary: true,
href,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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(),
Expand Down
108 changes: 93 additions & 15 deletions crates/passshare-core/src/crypto.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -28,8 +29,16 @@ pub struct MasterKey([u8; 32]);
impl MasterKey {
/// Generate an account root key locally, independently of authentication.
pub fn generate() -> Result<Self, PassShareError> {
let mut key = [0u8; 32];
fill(&mut key).map_err(|_| PassShareError::Internal("Key generation failed".into()))?;
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<Self, PassShareError> {
if bytes.len() != 32 {
return Err(PassShareError::CorruptFile);
}
let key: [u8; 32] = bytes.try_into().map_err(|_| PassShareError::CorruptFile)?;
Ok(Self(key))
}

Expand All @@ -47,17 +56,28 @@ pub fn derive_master_key_with_params(
time_cost: u32,
parallelism: u32,
) -> Result<MasterKey, PassShareError> {
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 = 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.
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(&parallelism)
{
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<MasterKey, PassShareError> {
Expand All @@ -77,9 +97,67 @@ pub fn generate_salt() -> Result<[u8; 16], PassShareError> {
}

pub fn generate_recovery_secret() -> Result<String, PassShareError> {
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<MasterKey, PassShareError> {
if code.len() != 44 {
return Err(PassShareError::CorruptFile);
}
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(
master: &MasterKey,
recovery: &MasterKey,
aad: &[u8],
) -> Result<(Vec<u8>, [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<MasterKey, PassShareError> {
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>, [u8; 24]), PassShareError> {
Expand Down
45 changes: 36 additions & 9 deletions crates/passshare-core/src/domain/item.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, serde_json::Value>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
Expand Down Expand Up @@ -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, "")?,
];

Expand Down Expand Up @@ -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),
];

Expand Down Expand Up @@ -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()?,
Expand All @@ -170,6 +177,7 @@ impl Item {
) -> Result<Self, PassShareError> {
if let Some(url) = url.filter(|u| !u.is_empty()) {
self.urls.push(ItemUrl {
extensions: Default::default(),
label: String::new(),
primary: true,
href: url,
Expand Down Expand Up @@ -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) {
Expand All @@ -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))
}
}

Expand Down Expand Up @@ -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())
);
}
}
43 changes: 37 additions & 6 deletions crates/passshare-core/src/domain/payload.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use zeroize::Zeroize;

use super::item::Item;
use super::login_item::LoginItem;
Expand Down Expand Up @@ -52,7 +53,10 @@ enum RawPasswordBoxPayload {
}

impl RawPasswordBoxPayload {
fn into_payload(self, vault_name: &str) -> Result<PasswordBoxPayload, crate::error::PassShareError> {
fn into_payload(
self,
vault_name: &str,
) -> Result<PasswordBoxPayload, crate::error::PassShareError> {
match self {
RawPasswordBoxPayload::V2(payload) => Ok(payload),
RawPasswordBoxPayload::Legacy(legacy) => {
Expand All @@ -62,14 +66,18 @@ impl RawPasswordBoxPayload {
}
}

pub fn deserialize_payload(bytes: &[u8], vault_name: &str) -> Result<PasswordBoxPayload, crate::error::PassShareError> {
pub fn deserialize_payload(
bytes: &[u8],
vault_name: &str,
) -> Result<PasswordBoxPayload, crate::error::PassShareError> {
// 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
Expand All @@ -78,6 +86,7 @@ pub fn deserialize_payload(bytes: &[u8], vault_name: &str) -> Result<PasswordBox
// so the UI can prompt an app upgrade. Legacy payloads carry no `payloadVersion`
// and fall through to the untagged parse + migration below.
if let Some(version) = value
.0
.get("payloadVersion")
.and_then(serde_json::Value::as_u64)
{
Expand All @@ -86,11 +95,33 @@ pub fn deserialize_payload(bytes: &[u8], vault_name: &str) -> Result<PasswordBox
}
}

let raw: RawPasswordBoxPayload =
serde_json::from_value(value).map_err(|_| crate::error::PassShareError::CorruptFile)?;
// Borrow from the guard so ignored extensions and error paths do not
// consume an unguarded plaintext JSON allocation.
let raw = RawPasswordBoxPayload::deserialize(&value.0)
.map_err(|_| crate::error::PassShareError::CorruptFile)?;
raw.into_payload(vault_name)
}

struct ParsedPlaintext(serde_json::Value);
impl Drop for ParsedPlaintext {
fn drop(&mut self) {
fn clear(value: &mut serde_json::Value) {
match value {
serde_json::Value::String(text) => 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::*;
Expand Down
Loading
Loading