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:
@@ -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;
|
||||||
|
|||||||
@@ -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());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user