feat: add XChaCha20-Poly1305 encrypt/decrypt with binary format

This commit is contained in:
adlee-was-taken
2026-04-11 22:55:33 -04:00
parent 1ae6abe049
commit 8e60bb70fb
2 changed files with 110 additions and 1 deletions

View File

@@ -1,8 +1,64 @@
use argon2::{Algorithm, Argon2, Params, Version}; use argon2::{Algorithm, Argon2, Params, Version};
use chacha20poly1305::{
aead::{Aead, KeyInit},
XChaCha20Poly1305, XNonce,
};
use rand::{rngs::OsRng, RngCore};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::error::{IdfotoError, Result}; 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<Vec<u8>> {
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<Vec<u8>> {
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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KdfParams { pub struct KdfParams {
pub argon2_m: u32, pub argon2_m: u32,
@@ -100,4 +156,57 @@ mod tests {
assert_ne!(key1, key2); 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);
}
} }

View File

@@ -2,4 +2,4 @@ pub mod error;
pub use error::{IdfotoError, Result}; pub use error::{IdfotoError, Result};
pub mod crypto; pub mod crypto;
pub use crypto::{derive_master_key, KdfParams}; pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams};