test(core): rewrite integration test for typed items
- full_workflow_login_and_note: round-trips Login + SecureNote + Manifest + Settings - two_factor_independence: confirms image_secret + passphrase combine into the master key - field_history_persists_through_round_trip: history survives encrypt/decrypt - wrong_key_fails_with_opaque_decrypt: opaque error per audit M4 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<u8> {
|
||||
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;
|
||||
// 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);
|
||||
}
|
||||
|
||||
// 1. (passphrase_A, image_A)
|
||||
let key_aa = derive_master_key(passphrase_a, &image_secret_a, &salt, ¶ms).unwrap();
|
||||
#[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();
|
||||
|
||||
// 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");
|
||||
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();
|
||||
|
||||
// 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");
|
||||
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)));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user