Files
relicario/crates/idfoto-core/src/vault.rs
adlee-was-taken 7baec1cd67 feat: add group field to Entry and ManifestEntry
Add optional group: Option<String> to both Entry and ManifestEntry for
logical organization (e.g. "work", "personal"). Backwards-compatible via
skip_serializing_if so existing vaults deserialize with group: None.
Includes three new tests verifying round-trip and legacy deserialization.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 09:25:18 -04:00

151 lines
5.0 KiB
Rust

//! Typed encryption/decryption wrappers for vault entries and manifests.
//!
//! 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).
use crate::crypto;
use crate::entry::{Entry, Manifest};
use crate::error::Result;
/// Serialize an [`Entry`] to JSON and encrypt it under the master key.
///
/// The resulting bytes are written to `entries/<id>.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<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`].
///
/// # 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<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.
///
/// 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)?;
crypto::encrypt(master_key, &json)
}
/// 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<Manifest> {
let json = crypto::decrypt(master_key, data)?;
let manifest: Manifest = serde_json::from_slice(&json)?;
Ok(manifest)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::entry::ManifestEntry;
fn test_key_a() -> [u8; 32] {
[0x42u8; 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 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()));
}
#[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());
}
}