diff --git a/crates/relicario-core/src/backup.rs b/crates/relicario-core/src/backup.rs index 7e86368..8242341 100644 --- a/crates/relicario-core/src/backup.rs +++ b/crates/relicario-core/src/backup.rs @@ -301,12 +301,20 @@ pub fn unpack_backup(data: &[u8], passphrase: &str) -> Result { } fn derive_backup_key(passphrase: &[u8], salt: &[u8]) -> Result> { + use unicode_normalization::UnicodeNormalization; + + // NFC normalize passphrase (matches derive_master_key in crypto.rs) + let nfc_passphrase: Vec = match std::str::from_utf8(passphrase) { + Ok(s) => s.nfc().collect::().into_bytes(), + Err(_) => passphrase.to_vec(), + }; + let params = Params::new(ARGON2_M_KIB, ARGON2_T, ARGON2_P, Some(32)) .map_err(|e| RelicarioError::Kdf(format!("argon2 params: {e}")))?; let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); let mut key = Zeroizing::new([0u8; 32]); argon - .hash_password_into(passphrase, salt, key.as_mut_slice()) + .hash_password_into(&nfc_passphrase, salt, key.as_mut_slice()) .map_err(|e| RelicarioError::Kdf(format!("argon2 hash: {e}")))?; Ok(key) } diff --git a/crates/relicario-core/tests/backup.rs b/crates/relicario-core/tests/backup.rs index 1ca3ec6..7433e88 100644 --- a/crates/relicario-core/tests/backup.rs +++ b/crates/relicario-core/tests/backup.rs @@ -186,3 +186,30 @@ fn tampered_ciphertext_rejected_as_decrypt_error() { other => panic!("expected Decrypt for tampered tag, got {other:?}"), } } + +#[test] +fn backup_roundtrip_with_nfd_passphrase() { + // "café" in NFD (decomposed: e + combining acute accent) + let nfd_passphrase = "caf\u{0065}\u{0301}"; + // "café" in NFC (precomposed é) + let nfc_passphrase = "caf\u{00E9}"; + + let input = BackupInput { + salt: &[0u8; 32], + params_json: r#"{"format_version":2,"kdf":{"argon2_m":256,"argon2_t":1,"argon2_p":1},"aead":"xchacha20poly1305","salt_path":".relicario/salt"}"#, + devices_json: "[]", + manifest_enc: &[1, 2, 3], + settings_enc: &[4, 5, 6], + items: vec![], + attachments: vec![], + reference_jpg: None, + git_archive: None, + }; + + // Pack with NFD passphrase + let packed = pack_backup(input, nfd_passphrase).unwrap(); + + // Unpack with NFC passphrase — should work after fix + let unpacked = unpack_backup(&packed, nfc_passphrase).unwrap(); + assert_eq!(unpacked.manifest_enc, vec![1, 2, 3]); +}