feat: add XChaCha20-Poly1305 encrypt/decrypt with binary format
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
Reference in New Issue
Block a user