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) <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-19 15:38:52 -04:00
parent f673b1ddee
commit 4a98be0dae
2 changed files with 67 additions and 124 deletions

View File

@@ -69,6 +69,9 @@ pub mod entry;
pub use entry::{generate_entry_id, Entry}; pub use entry::{generate_entry_id, Entry};
pub mod vault; 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; pub mod imgsecret;

View File

@@ -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 //! v1 helpers (encrypt_entry / decrypt_entry / encrypt_manifest with the old
//! [`crate::crypto`] and the typed data model in [`crate::entry`]. Each function //! Manifest type) are intentionally NOT carried forward. The CLI rewrite in
//! follows the same pattern: //! Plan 1B switches to the new helpers.
//!
//! - **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).
use crate::crypto; use zeroize::Zeroizing;
use crate::entry::{Entry, Manifest};
use crate::crypto::{decrypt, encrypt};
use crate::error::Result; 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. pub fn encrypt_item(item: &Item, master_key: &Zeroizing<[u8; 32]>) -> Result<Vec<u8>> {
/// let json = serde_json::to_vec(item)?;
/// The resulting bytes are written to `entries/<id>.enc` by the CLI. let plaintext = Zeroizing::new(json);
/// encrypt(&**master_key, plaintext.as_slice())
/// # 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<Vec<u8>> {
let json = serde_json::to_vec(entry)?;
crypto::encrypt(master_key, &json)
} }
/// Decrypt an entry blob and deserialize it back into an [`Entry`]. pub fn decrypt_item(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> Result<Item> {
/// let plaintext = decrypt(&**master_key, encrypted)?;
/// # Errors let plaintext = Zeroizing::new(plaintext);
/// let item: Item = serde_json::from_slice(&plaintext)?;
/// - [`crate::IdfotoError::Decrypt`] if the master key is wrong or the data is Ok(item)
/// 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<Entry> {
let json = crypto::decrypt(master_key, data)?;
let entry: Entry = serde_json::from_slice(&json)?;
Ok(entry)
} }
/// Serialize a [`Manifest`] to JSON and encrypt it under the master key. pub fn encrypt_manifest(manifest: &Manifest, master_key: &Zeroizing<[u8; 32]>) -> Result<Vec<u8>> {
///
/// 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<Vec<u8>> {
let json = serde_json::to_vec(manifest)?; 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`]. pub fn decrypt_manifest(encrypted: &[u8], master_key: &Zeroizing<[u8; 32]>) -> Result<Manifest> {
/// let plaintext = decrypt(&**master_key, encrypted)?;
/// # Errors let plaintext = Zeroizing::new(plaintext);
/// let manifest: Manifest = serde_json::from_slice(&plaintext)?;
/// Same as [`decrypt_entry`].
pub fn decrypt_manifest(master_key: &[u8; 32], data: &[u8]) -> Result<Manifest> {
let json = crypto::decrypt(master_key, data)?;
let manifest: Manifest = serde_json::from_slice(&json)?;
Ok(manifest) Ok(manifest)
} }
pub fn encrypt_settings(settings: &VaultSettings, master_key: &Zeroizing<[u8; 32]>) -> Result<Vec<u8>> {
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<VaultSettings> {
let plaintext = decrypt(&**master_key, encrypted)?;
let plaintext = Zeroizing::new(plaintext);
let settings: VaultSettings = serde_json::from_slice(&plaintext)?;
Ok(settings)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::entry::ManifestEntry; use crate::item_types::{ItemCore, SecureNoteCore};
fn test_key_a() -> [u8; 32] { fn key() -> Zeroizing<[u8; 32]> { Zeroizing::new([0x33u8; 32]) }
[0x42u8; 32]
}
fn test_key_b() -> [u8; 32] { #[test]
[0x99u8; 32] fn item_round_trip() {
} let item = Item::new("note".into(), ItemCore::SecureNote(SecureNoteCore {
body: Zeroizing::new("hello".into()),
fn sample_entry() -> Entry { }));
Entry { let bytes = encrypt_item(&item, &key()).unwrap();
name: "GitHub".to_string(), let decoded = decrypt_item(&bytes, &key()).unwrap();
url: Some("https://github.com".to_string()), assert_eq!(decoded.title, "note");
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] #[test]
fn entry_encrypt_decrypt_round_trip() { fn manifest_round_trip() {
let key = test_key_a(); let mut m = Manifest::new();
let entry = sample_entry(); let item = Item::new("x".into(), ItemCore::SecureNote(SecureNoteCore::default()));
m.upsert(&item);
let ciphertext = encrypt_entry(&key, &entry).unwrap(); let bytes = encrypt_manifest(&m, &key()).unwrap();
let decoded = decrypt_entry(&key, &ciphertext).unwrap(); let decoded = decrypt_manifest(&bytes, &key()).unwrap();
assert_eq!(decoded.items.len(), 1);
assert_eq!(decoded.name, "GitHub");
assert_eq!(decoded.password, "secret123");
assert_eq!(decoded.username, Some("alice".to_string()));
} }
#[test] #[test]
fn manifest_encrypt_decrypt_round_trip() { fn settings_round_trip() {
let key = test_key_a(); let s = VaultSettings::default();
let mut manifest = Manifest::new(); let bytes = encrypt_settings(&s, &key()).unwrap();
manifest.add_entry( let decoded = decrypt_settings(&bytes, &key()).unwrap();
"deadbeef".to_string(), assert_eq!(decoded.attachment_caps.per_attachment_max_bytes,
ManifestEntry { s.attachment_caps.per_attachment_max_bytes);
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());
} }
} }