From 8e60bb70fb2fc032028a90043254f757fad3166a Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 11 Apr 2026 22:55:33 -0400 Subject: [PATCH] feat: add XChaCha20-Poly1305 encrypt/decrypt with binary format --- crates/idfoto-core/src/crypto.rs | 109 +++++++++++++++++++++++++++++++ crates/idfoto-core/src/lib.rs | 2 +- 2 files changed, 110 insertions(+), 1 deletion(-) diff --git a/crates/idfoto-core/src/crypto.rs b/crates/idfoto-core/src/crypto.rs index 94c7299..7d35f52 100644 --- a/crates/idfoto-core/src/crypto.rs +++ b/crates/idfoto-core/src/crypto.rs @@ -1,8 +1,64 @@ use argon2::{Algorithm, Argon2, Params, Version}; +use chacha20poly1305::{ + aead::{Aead, KeyInit}, + XChaCha20Poly1305, XNonce, +}; +use rand::{rngs::OsRng, RngCore}; use serde::{Deserialize, Serialize}; use crate::error::{IdfotoError, Result}; +const VERSION_BYTE: u8 = 0x01; +const NONCE_LEN: usize = 24; +const TAG_LEN: usize = 16; +const HEADER_LEN: usize = 1 + NONCE_LEN; // version + nonce + +pub fn encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result> { + let cipher = XChaCha20Poly1305::new(key.into()); + + let mut nonce_bytes = [0u8; NONCE_LEN]; + OsRng.fill_bytes(&mut nonce_bytes); + let nonce = XNonce::from(nonce_bytes); + + let ciphertext = cipher + .encrypt(&nonce, plaintext) + .map_err(|e| IdfotoError::Encrypt(e.to_string()))?; + + // Output: version(1) || nonce(24) || ciphertext+tag + let mut output = Vec::with_capacity(HEADER_LEN + ciphertext.len()); + output.push(VERSION_BYTE); + output.extend_from_slice(&nonce_bytes); + output.extend_from_slice(&ciphertext); + + Ok(output) +} + +pub fn decrypt(key: &[u8; 32], data: &[u8]) -> Result> { + if data.len() < HEADER_LEN + TAG_LEN { + return Err(IdfotoError::Format( + "data too short to be valid ciphertext".into(), + )); + } + + let version = data[0]; + if version != VERSION_BYTE { + return Err(IdfotoError::Format(format!( + "unknown version byte: 0x{:02x}", + version + ))); + } + + let nonce = XNonce::from_slice(&data[1..1 + NONCE_LEN]); + let ciphertext = &data[HEADER_LEN..]; + + let cipher = XChaCha20Poly1305::new(key.into()); + let plaintext = cipher + .decrypt(nonce, ciphertext) + .map_err(|_| IdfotoError::Decrypt)?; + + Ok(plaintext) +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct KdfParams { pub argon2_m: u32, @@ -100,4 +156,57 @@ mod tests { assert_ne!(key1, key2); } + + #[test] + fn encrypt_decrypt_round_trip() { + let key = [0xABu8; 32]; + let plaintext = b"hello, idfoto!"; + + let ciphertext = encrypt(&key, plaintext).unwrap(); + let decrypted = decrypt(&key, &ciphertext).unwrap(); + + assert_eq!(decrypted, plaintext); + } + + #[test] + fn decrypt_wrong_key_fails() { + let key = [0xABu8; 32]; + let wrong_key = [0xCDu8; 32]; + let plaintext = b"sensitive data"; + + let ciphertext = encrypt(&key, plaintext).unwrap(); + let result = decrypt(&wrong_key, &ciphertext); + + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), IdfotoError::Decrypt)); + } + + #[test] + fn decrypt_tampered_data_fails() { + let key = [0xABu8; 32]; + let plaintext = b"sensitive data"; + + let mut ciphertext = encrypt(&key, plaintext).unwrap(); + // Flip a byte in the ciphertext portion (after header) + let flip_pos = HEADER_LEN + 2; + ciphertext[flip_pos] ^= 0xFF; + + let result = decrypt(&key, &ciphertext); + assert!(result.is_err()); + } + + #[test] + fn ciphertext_format_has_correct_structure() { + let key = [0x11u8; 32]; + let plaintext = b"test plaintext for structure check"; + + let ciphertext = encrypt(&key, plaintext).unwrap(); + + // Expected length: 1 (version) + 24 (nonce) + plaintext_len + 16 (tag) + let expected_len = 1 + 24 + plaintext.len() + 16; + assert_eq!(ciphertext.len(), expected_len); + + // Version byte must be 0x01 + assert_eq!(ciphertext[0], 0x01); + } } diff --git a/crates/idfoto-core/src/lib.rs b/crates/idfoto-core/src/lib.rs index 45c83c0..221c1d3 100644 --- a/crates/idfoto-core/src/lib.rs +++ b/crates/idfoto-core/src/lib.rs @@ -2,4 +2,4 @@ pub mod error; pub use error::{IdfotoError, Result}; pub mod crypto; -pub use crypto::{derive_master_key, KdfParams}; +pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams};