diff --git a/crates/idfoto-core/tests/integration.rs b/crates/idfoto-core/tests/integration.rs index 992f558..5cd2d35 100644 --- a/crates/idfoto-core/tests/integration.rs +++ b/crates/idfoto-core/tests/integration.rs @@ -1,153 +1,111 @@ -use idfoto_core::{ - decrypt_entry, decrypt_manifest, derive_master_key, encrypt_entry, encrypt_manifest, - generate_entry_id, Entry, KdfParams, Manifest, ManifestEntry, -}; -use rand::RngCore; +//! End-to-end integration tests for the typed-item core. -fn make_test_jpeg(width: u32, height: u32) -> Vec { - use image::codecs::jpeg::JpegEncoder; - use image::{ImageBuffer, ImageEncoder, Rgb}; - let img = ImageBuffer::from_fn(width, height, |x, y| { - Rgb([ - ((x * 7 + y * 13) % 256) as u8, - ((x * 11 + y * 3) % 256) as u8, - ((x * 5 + y * 17) % 256) as u8, - ]) - }); - let mut buf = Vec::new(); - let encoder = JpegEncoder::new_with_quality(&mut buf, 92); - encoder - .write_image(img.as_raw(), width, height, image::ExtendedColorType::Rgb8) - .unwrap(); - buf -} +use idfoto_core::{ + crypto::KdfParams, + derive_master_key, encrypt_item, decrypt_item, + encrypt_manifest, decrypt_manifest, + encrypt_settings, decrypt_settings, + Field, FieldValue, Item, ItemCore, Manifest, Section, VaultSettings, +}; +use idfoto_core::item_types::{LoginCore, SecureNoteCore}; +use url::Url; +use zeroize::Zeroizing; fn fast_params() -> KdfParams { - KdfParams { - argon2_m: 256, - argon2_t: 1, - argon2_p: 1, - } + KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 } } #[test] -fn full_vault_workflow() { - // 1. Generate carrier JPEG - let carrier = make_test_jpeg(400, 300); +fn full_workflow_login_and_note() { + let salt = [0xAAu8; 32]; + let img = [0xBBu8; 32]; + let key = derive_master_key(b"correct horse battery staple", &img, &salt, &fast_params()).unwrap(); - // 2. Generate random image_secret and embed - let mut image_secret = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut image_secret); - let stego = idfoto_core::imgsecret::embed(&carrier, &image_secret).unwrap(); - - // 3. Extract and verify - let extracted = idfoto_core::imgsecret::extract(&stego).unwrap(); - assert_eq!(extracted, image_secret, "extracted image_secret must match embedded"); - - // 4. Derive master_key with fast params - let passphrase = b"test-passphrase-long-enough"; - let mut salt = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut salt); - let params = fast_params(); - let master_key = derive_master_key(passphrase, &image_secret, &salt, ¶ms).unwrap(); - - // 5. Create and encrypt an Entry - let entry = Entry { - name: "GitHub".to_string(), - url: Some("https://github.com".to_string()), - username: Some("alice".to_string()), - password: "supersecret123!".to_string(), - notes: Some("my main account".to_string()), - totp_secret: None, - group: None, - created_at: "2024-01-01T00:00:00Z".to_string(), - updated_at: "2024-01-01T00:00:00Z".to_string(), - }; - - let encrypted = encrypt_entry(&master_key, &entry).unwrap(); - - // 6. Decrypt and verify fields match - let decrypted = decrypt_entry(&master_key, &encrypted).unwrap(); - assert_eq!(decrypted.name, "GitHub"); - assert_eq!(decrypted.password, "supersecret123!"); - assert_eq!(decrypted.username, Some("alice".to_string())); - assert_eq!(decrypted.url, Some("https://github.com".to_string())); - assert_eq!(decrypted.notes, Some("my main account".to_string())); - - // 7. Wrong passphrase -> different key -> decrypt fails - let wrong_key = derive_master_key(b"wrong-passphrase-entirely", &image_secret, &salt, ¶ms).unwrap(); - assert!( - decrypt_entry(&wrong_key, &encrypted).is_err(), - "decryption with wrong passphrase must fail" - ); - - // 8. Wrong image_secret -> different key -> decrypt fails - let mut wrong_secret = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut wrong_secret); - // Make sure it's actually different - if wrong_secret == image_secret { - wrong_secret[0] ^= 0xFF; - } - let wrong_key2 = derive_master_key(passphrase, &wrong_secret, &salt, ¶ms).unwrap(); - assert!( - decrypt_entry(&wrong_key2, &encrypted).is_err(), - "decryption with wrong image_secret must fail" - ); - - // 9. Manifest round-trip - let entry_id = generate_entry_id(); let mut manifest = Manifest::new(); - manifest.add_entry( - entry_id.clone(), - ManifestEntry { - name: "GitHub".to_string(), - url: Some("https://github.com".to_string()), - username: Some("alice".to_string()), - group: None, - updated_at: "2024-01-01T00:00:00Z".to_string(), - }, - ); + let settings = VaultSettings::default(); - let manifest_enc = encrypt_manifest(&master_key, &manifest).unwrap(); - let manifest_dec = decrypt_manifest(&master_key, &manifest_enc).unwrap(); + // Add a Login + let login = Item::new("GitHub".into(), ItemCore::Login(LoginCore { + username: Some("alice".into()), + password: Some(Zeroizing::new("hunter2".into())), + url: Some(Url::parse("https://github.com").unwrap()), + totp: None, + })); + manifest.upsert(&login); + let login_blob = encrypt_item(&login, &key).unwrap(); - assert_eq!(manifest_dec.version, 1); - assert!(manifest_dec.entries.contains_key(&entry_id)); - assert_eq!(manifest_dec.entries[&entry_id].name, "GitHub"); + // Add a SecureNote + let note = Item::new("recovery".into(), ItemCore::SecureNote(SecureNoteCore { + body: Zeroizing::new("recovery codes go here".into()), + })); + manifest.upsert(¬e); + let note_blob = encrypt_item(¬e, &key).unwrap(); + + // Encrypt manifest + settings + let manifest_blob = encrypt_manifest(&manifest, &key).unwrap(); + let settings_blob = encrypt_settings(&settings, &key).unwrap(); + + // Decrypt + verify + let m = decrypt_manifest(&manifest_blob, &key).unwrap(); + assert_eq!(m.items.len(), 2); + + let l: Item = decrypt_item(&login_blob, &key).unwrap(); + let n: Item = decrypt_item(¬e_blob, &key).unwrap(); + let s: VaultSettings = decrypt_settings(&settings_blob, &key).unwrap(); + + assert_eq!(l.title, "GitHub"); + assert_eq!(n.title, "recovery"); + assert_eq!(s.attachment_caps.per_attachment_max_bytes, 10 * 1024 * 1024); } #[test] fn two_factor_independence() { - let mut salt = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut salt); - let params = fast_params(); + // Same passphrase, different image_secret → different keys. + let salt = [0u8; 32]; + let img_a = [0x01u8; 32]; + let img_b = [0x02u8; 32]; - let passphrase_a = b"passphrase-alpha"; - let passphrase_b = b"passphrase-bravo"; + let key_a = derive_master_key(b"same-passphrase", &img_a, &salt, &fast_params()).unwrap(); + let key_b = derive_master_key(b"same-passphrase", &img_b, &salt, &fast_params()).unwrap(); + assert_ne!(*key_a, *key_b); - let mut image_secret_a = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut image_secret_a); - let mut image_secret_b = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut image_secret_b); - // Ensure they differ - if image_secret_a == image_secret_b { - image_secret_b[0] ^= 0xFF; - } - - // 1. (passphrase_A, image_A) - let key_aa = derive_master_key(passphrase_a, &image_secret_a, &salt, ¶ms).unwrap(); - - // 2. (passphrase_B, image_A) -> different from #1 - let key_ba = derive_master_key(passphrase_b, &image_secret_a, &salt, ¶ms).unwrap(); - assert_ne!(key_aa, key_ba, "different passphrase must produce different key"); - - // 3. (passphrase_A, image_B) -> different from #1 - let key_ab = derive_master_key(passphrase_a, &image_secret_b, &salt, ¶ms).unwrap(); - assert_ne!(key_aa, key_ab, "different image_secret must produce different key"); - - // 4. (passphrase_B, image_B) -> different from all above - let key_bb = derive_master_key(passphrase_b, &image_secret_b, &salt, ¶ms).unwrap(); - assert_ne!(key_bb, key_aa, "key_bb must differ from key_aa"); - assert_ne!(key_bb, key_ba, "key_bb must differ from key_ba"); - assert_ne!(key_bb, key_ab, "key_bb must differ from key_ab"); + // Different passphrase, same image_secret → different keys. + let key_c = derive_master_key(b"other-passphrase", &img_a, &salt, &fast_params()).unwrap(); + assert_ne!(*key_a, *key_c); +} + +#[test] +fn field_history_persists_through_round_trip() { + let salt = [0u8; 32]; + let img = [0u8; 32]; + let key = derive_master_key(b"x", &img, &salt, &fast_params()).unwrap(); + + let mut item = Item::new("x".into(), ItemCore::Login(LoginCore::default())); + let f = Field::new("p".into(), FieldValue::Password(Zeroizing::new("v0".into()))); + let fid = f.id.clone(); + item.sections.push(Section { name: None, fields: vec![f] }); + item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v1".into()))).unwrap(); + item.set_field_value(&fid, FieldValue::Password(Zeroizing::new("v2".into()))).unwrap(); + + let blob = encrypt_item(&item, &key).unwrap(); + let decoded = decrypt_item(&blob, &key).unwrap(); + let hist = decoded.field_history.get(&fid).unwrap(); + assert_eq!(hist.len(), 2); + assert_eq!(hist[0].value.as_str(), "v0"); + assert_eq!(hist[1].value.as_str(), "v1"); +} + +#[test] +fn wrong_key_fails_with_opaque_decrypt() { + use idfoto_core::IdfotoError; + + let salt = [0u8; 32]; + let img = [0u8; 32]; + let right = derive_master_key(b"correct", &img, &salt, &fast_params()).unwrap(); + let wrong = derive_master_key(b"wrong", &img, &salt, &fast_params()).unwrap(); + + let item = Item::new("x".into(), ItemCore::SecureNote(SecureNoteCore::default())); + let blob = encrypt_item(&item, &right).unwrap(); + let err = decrypt_item(&blob, &wrong); + assert!(matches!(err, Err(IdfotoError::Decrypt))); }