From 4a98be0daea20d33fa1be99d2518469ba509aca8 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 19 Apr 2026 15:38:52 -0400 Subject: [PATCH] feat(core): rewrite vault.rs for typed items encrypt_item / decrypt_item / encrypt_manifest / decrypt_manifest / encrypt_settings / decrypt_settings. All plaintext flows through Zeroizing so JSON buffers are wiped on drop. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/idfoto-core/src/lib.rs | 5 +- crates/idfoto-core/src/vault.rs | 186 +++++++++++--------------------- 2 files changed, 67 insertions(+), 124 deletions(-) diff --git a/crates/idfoto-core/src/lib.rs b/crates/idfoto-core/src/lib.rs index bf5bde3..d524ac7 100644 --- a/crates/idfoto-core/src/lib.rs +++ b/crates/idfoto-core/src/lib.rs @@ -69,6 +69,9 @@ pub mod entry; pub use entry::{generate_entry_id, Entry}; pub mod vault; -pub use vault::{decrypt_entry, decrypt_manifest, encrypt_entry, encrypt_manifest}; +pub use vault::{ + decrypt_item, decrypt_manifest, decrypt_settings, + encrypt_item, encrypt_manifest, encrypt_settings, +}; pub mod imgsecret; diff --git a/crates/idfoto-core/src/vault.rs b/crates/idfoto-core/src/vault.rs index 7d4debc..091c1f2 100644 --- a/crates/idfoto-core/src/vault.rs +++ b/crates/idfoto-core/src/vault.rs @@ -1,150 +1,90 @@ -//! Typed encryption/decryption wrappers for vault entries and manifests. +//! Typed wrappers around `crypto::{encrypt, decrypt}` for the new typed-item +//! data model. Each function does JSON-serialize → encrypt or decrypt → JSON-parse. //! -//! This module bridges the gap between the raw bytes-in/bytes-out layer in -//! [`crate::crypto`] and the typed data model in [`crate::entry`]. Each function -//! follows the same pattern: -//! -//! - **Encrypt**: serialize the struct to JSON via serde, then encrypt the JSON -//! bytes with [`crate::crypto::encrypt`]. -//! - **Decrypt**: decrypt the ciphertext with [`crate::crypto::decrypt`], then -//! deserialize the resulting JSON bytes back into the typed struct. -//! -//! ## Why a single master key -//! -//! All entries and the manifest are encrypted under the same `master_key`. This is -//! simpler than a per-entry subkey hierarchy and sufficient for family-scale vaults -//! (typically < 1000 entries). The security properties are equivalent: an attacker -//! who compromises the master key can decrypt everything regardless of whether -//! subkeys exist, and the vault's threat model already assumes the master key is -//! the single point of trust (protected by the two-factor KDF). +//! v1 helpers (encrypt_entry / decrypt_entry / encrypt_manifest with the old +//! Manifest type) are intentionally NOT carried forward. The CLI rewrite in +//! Plan 1B switches to the new helpers. -use crate::crypto; -use crate::entry::{Entry, Manifest}; +use zeroize::Zeroizing; + +use crate::crypto::{decrypt, encrypt}; use crate::error::Result; +use crate::item::Item; +use crate::manifest::Manifest; +use crate::settings::VaultSettings; -/// Serialize an [`Entry`] to JSON and encrypt it under the master key. -/// -/// The resulting bytes are written to `entries/.enc` by the CLI. -/// -/// # Errors -/// -/// - [`crate::IdfotoError::Json`] if JSON serialization fails (should not happen -/// with well-formed Entry structs). -/// - [`crate::IdfotoError::Encrypt`] if the underlying AEAD operation fails. -pub fn encrypt_entry(master_key: &[u8; 32], entry: &Entry) -> Result> { - let json = serde_json::to_vec(entry)?; - crypto::encrypt(master_key, &json) +pub fn encrypt_item(item: &Item, master_key: &Zeroizing<[u8; 32]>) -> Result> { + let json = serde_json::to_vec(item)?; + let plaintext = Zeroizing::new(json); + encrypt(&**master_key, plaintext.as_slice()) } -/// Decrypt an entry blob and deserialize it back into an [`Entry`]. -/// -/// # Errors -/// -/// - [`crate::IdfotoError::Decrypt`] if the master key is wrong or the data is -/// tampered. -/// - [`crate::IdfotoError::Format`] if the ciphertext blob has an invalid header. -/// - [`crate::IdfotoError::Json`] if the decrypted JSON is malformed. -pub fn decrypt_entry(master_key: &[u8; 32], data: &[u8]) -> Result { - let json = crypto::decrypt(master_key, data)?; - let entry: Entry = serde_json::from_slice(&json)?; - Ok(entry) +pub fn decrypt_item(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> Result { + let plaintext = decrypt(&**master_key, encrypted)?; + let plaintext = Zeroizing::new(plaintext); + let item: Item = serde_json::from_slice(&plaintext)?; + Ok(item) } -/// Serialize a [`Manifest`] to JSON and encrypt it under the master key. -/// -/// The resulting bytes are written to `manifest.enc` by the CLI. -/// -/// # Errors -/// -/// Same as [`encrypt_entry`]. -pub fn encrypt_manifest(master_key: &[u8; 32], manifest: &Manifest) -> Result> { +pub fn encrypt_manifest(manifest: &Manifest, master_key: &Zeroizing<[u8; 32]>) -> Result> { let json = serde_json::to_vec(manifest)?; - crypto::encrypt(master_key, &json) + let plaintext = Zeroizing::new(json); + encrypt(&**master_key, plaintext.as_slice()) } -/// Decrypt a manifest blob and deserialize it back into a [`Manifest`]. -/// -/// # Errors -/// -/// Same as [`decrypt_entry`]. -pub fn decrypt_manifest(master_key: &[u8; 32], data: &[u8]) -> Result { - let json = crypto::decrypt(master_key, data)?; - let manifest: Manifest = serde_json::from_slice(&json)?; +pub fn decrypt_manifest(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> Result { + let plaintext = decrypt(&**master_key, encrypted)?; + let plaintext = Zeroizing::new(plaintext); + let manifest: Manifest = serde_json::from_slice(&plaintext)?; Ok(manifest) } +pub fn encrypt_settings(settings: &VaultSettings, master_key: &Zeroizing<[u8; 32]>) -> Result> { + let json = serde_json::to_vec(settings)?; + let plaintext = Zeroizing::new(json); + encrypt(&**master_key, plaintext.as_slice()) +} + +pub fn decrypt_settings(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> Result { + let plaintext = decrypt(&**master_key, encrypted)?; + let plaintext = Zeroizing::new(plaintext); + let settings: VaultSettings = serde_json::from_slice(&plaintext)?; + Ok(settings) +} + #[cfg(test)] mod tests { use super::*; - use crate::entry::ManifestEntry; + use crate::item_types::{ItemCore, SecureNoteCore}; - fn test_key_a() -> [u8; 32] { - [0x42u8; 32] - } + fn key() -> Zeroizing<[u8; 32]> { Zeroizing::new([0x33u8; 32]) } - fn test_key_b() -> [u8; 32] { - [0x99u8; 32] - } - - fn sample_entry() -> Entry { - Entry { - name: "GitHub".to_string(), - url: Some("https://github.com".to_string()), - username: Some("alice".to_string()), - password: "secret123".to_string(), - notes: None, - totp_secret: None, - group: None, - created_at: "2024-01-01T00:00:00Z".to_string(), - updated_at: "2024-01-01T00:00:00Z".to_string(), - } + #[test] + fn item_round_trip() { + let item = Item::new("note".into(), ItemCore::SecureNote(SecureNoteCore { + body: Zeroizing::new("hello".into()), + })); + let bytes = encrypt_item(&item, &key()).unwrap(); + let decoded = decrypt_item(&bytes, &key()).unwrap(); + assert_eq!(decoded.title, "note"); } #[test] - fn entry_encrypt_decrypt_round_trip() { - let key = test_key_a(); - let entry = sample_entry(); - - let ciphertext = encrypt_entry(&key, &entry).unwrap(); - let decoded = decrypt_entry(&key, &ciphertext).unwrap(); - - assert_eq!(decoded.name, "GitHub"); - assert_eq!(decoded.password, "secret123"); - assert_eq!(decoded.username, Some("alice".to_string())); + fn manifest_round_trip() { + let mut m = Manifest::new(); + let item = Item::new("x".into(), ItemCore::SecureNote(SecureNoteCore::default())); + m.upsert(&item); + let bytes = encrypt_manifest(&m, &key()).unwrap(); + let decoded = decrypt_manifest(&bytes, &key()).unwrap(); + assert_eq!(decoded.items.len(), 1); } #[test] - fn manifest_encrypt_decrypt_round_trip() { - let key = test_key_a(); - let mut manifest = Manifest::new(); - manifest.add_entry( - "deadbeef".to_string(), - 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 ciphertext = encrypt_manifest(&key, &manifest).unwrap(); - let decoded = decrypt_manifest(&key, &ciphertext).unwrap(); - - assert_eq!(decoded.version, 1); - assert!(decoded.entries.contains_key("deadbeef")); - assert_eq!(decoded.entries["deadbeef"].name, "GitHub"); - } - - #[test] - fn entry_wrong_key_fails() { - let key_a = test_key_a(); - let key_b = test_key_b(); - let entry = sample_entry(); - - let ciphertext = encrypt_entry(&key_a, &entry).unwrap(); - let result = decrypt_entry(&key_b, &ciphertext); - - assert!(result.is_err()); + fn settings_round_trip() { + let s = VaultSettings::default(); + let bytes = encrypt_settings(&s, &key()).unwrap(); + let decoded = decrypt_settings(&bytes, &key()).unwrap(); + assert_eq!(decoded.attachment_caps.per_attachment_max_bytes, + s.attachment_caps.per_attachment_max_bytes); } }