diff --git a/crates/idfoto-core/src/attachment.rs b/crates/idfoto-core/src/attachment.rs index 8af857c..d0da491 100644 --- a/crates/idfoto-core/src/attachment.rs +++ b/crates/idfoto-core/src/attachment.rs @@ -40,6 +40,96 @@ impl From<&AttachmentRef> for AttachmentSummary { } } +use zeroize::Zeroizing; + +use crate::crypto::{decrypt, encrypt}; +use crate::error::{IdfotoError, Result}; + +/// Encrypted attachment with the AID derived from plaintext content. +pub struct EncryptedAttachment { + pub id: AttachmentId, + pub bytes: Vec, +} + +/// Encrypt raw attachment bytes, deriving the [`AttachmentId`] from `sha256(plaintext)`. +/// +/// Returns [`IdfotoError::AttachmentTooLarge`] immediately if `plaintext.len() > max_bytes`, +/// before any crypto work is done. +/// +/// ## Call-site adaptation +/// +/// `crypto::encrypt` accepts `&[u8; 32]`; we coerce `&Zeroizing<[u8; 32]>` via +/// `&**master_key` (double-deref: `Zeroizing<[u8;32]>` → `[u8;32]` → `&[u8;32]`). +pub fn encrypt_attachment( + plaintext: &[u8], + master_key: &Zeroizing<[u8; 32]>, + max_bytes: u64, +) -> Result { + if plaintext.len() as u64 > max_bytes { + return Err(IdfotoError::AttachmentTooLarge { + size: plaintext.len() as u64, + max: max_bytes, + }); + } + let id = AttachmentId::from_plaintext(plaintext); + let bytes = encrypt(&**master_key, plaintext)?; + Ok(EncryptedAttachment { id, bytes }) +} + +/// Decrypt a blob produced by [`encrypt_attachment`], returning the plaintext +/// wrapped in [`Zeroizing`] so it is wiped on drop. +/// +/// ## Call-site adaptation +/// +/// `crypto::decrypt` accepts `&[u8; 32]`; we coerce via `&**master_key`. +pub fn decrypt_attachment( + encrypted: &[u8], + master_key: &Zeroizing<[u8; 32]>, +) -> Result>> { + let plaintext = decrypt(&**master_key, encrypted)?; + Ok(Zeroizing::new(plaintext)) +} + +#[cfg(test)] +mod crypto_tests { + use super::*; + + fn key() -> Zeroizing<[u8; 32]> { + Zeroizing::new([0x42u8; 32]) + } + + #[test] + fn attachment_round_trip() { + let plaintext = b"the quick brown fox jumps over the lazy dog"; + let enc = encrypt_attachment(plaintext, &key(), 1024).unwrap(); + let dec = decrypt_attachment(&enc.bytes, &key()).unwrap(); + assert_eq!(dec.as_slice(), plaintext); + } + + #[test] + fn attachment_id_matches_sha256() { + let plaintext = b"hello world"; + let enc = encrypt_attachment(plaintext, &key(), 1024).unwrap(); + assert_eq!(enc.id, AttachmentId::from_plaintext(plaintext)); + } + + #[test] + fn oversize_attachment_rejected() { + let plaintext = vec![0u8; 11_000_000]; + let err = encrypt_attachment(&plaintext, &key(), 10 * 1024 * 1024); + assert!(matches!(err, Err(IdfotoError::AttachmentTooLarge { .. }))); + } + + #[test] + fn wrong_key_fails_with_opaque_decrypt() { + let plaintext = b"x"; + let enc = encrypt_attachment(plaintext, &key(), 1024).unwrap(); + let wrong = Zeroizing::new([0u8; 32]); + let err = decrypt_attachment(&enc.bytes, &wrong); + assert!(matches!(err, Err(IdfotoError::Decrypt))); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/idfoto-core/src/lib.rs b/crates/idfoto-core/src/lib.rs index 569055f..bf5bde3 100644 --- a/crates/idfoto-core/src/lib.rs +++ b/crates/idfoto-core/src/lib.rs @@ -48,7 +48,7 @@ pub mod item; pub use item::{Field, FieldHistoryEntry, FieldKind, FieldValue, Item, Section}; pub mod attachment; -pub use attachment::{AttachmentRef, AttachmentSummary}; +pub use attachment::{decrypt_attachment, encrypt_attachment, AttachmentRef, AttachmentSummary, EncryptedAttachment}; pub mod manifest; pub use manifest::{Manifest, ManifestEntry, MANIFEST_SCHEMA_VERSION};