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>
151 lines
5.0 KiB
Rust
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());
|
|
}
|
|
}
|