use chacha20poly1305::{
ChaCha20Poly1305, Nonce,
aead::{Aead, KeyInit},
};
use hkdf::Hkdf;
use mdk_storage_traits::Secret;
use nostr::secp256k1::rand::{RngCore, rngs::OsRng};
use sha2::{Digest, Sha256};
use crate::media_processing::validation::validate_file_size;
use crate::media_processing::{
MediaProcessingOptions, metadata::extract_metadata_from_encoded_image,
};
const UPLOAD_KEYPAIR_CONTEXT_V1: &[u8] = b"mip01-blossom-upload-v1";
const IMAGE_ENCRYPTION_CONTEXT_V2: &[u8] = b"mip01-image-encryption-v2";
const UPLOAD_KEYPAIR_CONTEXT_V2: &[u8] = b"mip01-blossom-upload-v2";
#[derive(Debug, Clone)]
pub struct GroupImageUpload {
pub encrypted_data: Secret<Vec<u8>>,
pub encrypted_hash: [u8; 32],
pub image_key: Secret<[u8; 32]>,
pub image_nonce: Secret<[u8; 12]>,
pub image_upload_key: Secret<[u8; 32]>,
pub upload_keypair: nostr::Keys,
pub original_size: usize,
pub encrypted_size: usize,
pub mime_type: String,
pub dimensions: Option<(u32, u32)>,
pub blurhash: Option<String>,
pub thumbhash: Option<String>,
}
#[derive(Debug, Clone)]
struct GroupImageEncrypted {
encrypted_data: Secret<Vec<u8>>,
encrypted_hash: [u8; 32],
image_key: Secret<[u8; 32]>,
image_nonce: Secret<[u8; 12]>,
image_upload_key: Secret<[u8; 32]>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GroupImageEncryptionInfo {
pub version: u16,
pub image_hash: [u8; 32],
pub image_key: Secret<[u8; 32]>,
pub image_nonce: Secret<[u8; 12]>,
pub image_upload_key: Option<Secret<[u8; 32]>>,
}
#[derive(Debug, thiserror::Error)]
pub enum GroupImageError {
#[error(transparent)]
MediaProcessing(#[from] crate::media_processing::types::MediaProcessingError),
#[error("Encryption failed: {reason}")]
EncryptionFailed {
reason: String,
},
#[error("Decryption failed: {reason}")]
DecryptionFailed {
reason: String,
},
#[error("Hash verification failed: expected {expected}, got {actual}")]
HashVerificationFailed {
expected: String,
actual: String,
},
#[error("Failed to derive upload keypair: {reason}")]
KeypairDerivationFailed {
reason: String,
},
}
fn encrypt_group_image(image_data: &[u8]) -> Result<GroupImageEncrypted, GroupImageError> {
let mut rng = OsRng;
let mut image_seed = [0u8; 32];
let mut image_upload_seed = [0u8; 32];
let mut image_nonce = [0u8; 12];
rng.fill_bytes(&mut image_seed);
rng.fill_bytes(&mut image_upload_seed);
rng.fill_bytes(&mut image_nonce);
let hk = Hkdf::<Sha256>::new(None, &image_seed);
let mut image_key = [0u8; 32];
hk.expand(IMAGE_ENCRYPTION_CONTEXT_V2, &mut image_key)
.map_err(|e| GroupImageError::EncryptionFailed {
reason: format!("HKDF expansion failed: {}", e),
})?;
let cipher = ChaCha20Poly1305::new_from_slice(&image_key).map_err(|e| {
GroupImageError::EncryptionFailed {
reason: format!("Failed to create cipher: {}", e),
}
})?;
let nonce = Nonce::from_slice(&image_nonce);
let encrypted_data =
cipher
.encrypt(nonce, image_data)
.map_err(|e| GroupImageError::EncryptionFailed {
reason: format!("Encryption failed: {}", e),
})?;
let encrypted_hash: [u8; 32] = Sha256::digest(&encrypted_data).into();
Ok(GroupImageEncrypted {
encrypted_data: Secret::new(encrypted_data),
encrypted_hash,
image_key: Secret::new(image_seed), image_nonce: Secret::new(image_nonce),
image_upload_key: Secret::new(image_upload_seed), })
}
pub fn decrypt_group_image(
encrypted_data: &[u8],
expected_hash: Option<&[u8; 32]>,
image_key: &Secret<[u8; 32]>,
image_nonce: &Secret<[u8; 12]>,
) -> Result<Vec<u8>, GroupImageError> {
match expected_hash {
Some(expected_hash) => {
let calculated_hash: [u8; 32] = Sha256::digest(encrypted_data).into();
if calculated_hash != *expected_hash {
return Err(GroupImageError::HashVerificationFailed {
expected: hex::encode(expected_hash),
actual: hex::encode(calculated_hash),
});
}
}
None => {
tracing::warn!(
target: "mdk_core::extension::group_image",
"Decrypting group image without hash verification (legacy mode). This is deprecated and insecure. Please update the extension to include image_hash."
);
}
}
let hk = Hkdf::<Sha256>::new(None, image_key.as_ref());
let mut derived_key = [0u8; 32];
if hk
.expand(IMAGE_ENCRYPTION_CONTEXT_V2, &mut derived_key)
.is_ok()
{
let cipher = ChaCha20Poly1305::new_from_slice(&derived_key).map_err(|e| {
GroupImageError::DecryptionFailed {
reason: format!("Failed to create cipher: {}", e),
}
})?;
let nonce = Nonce::from_slice(image_nonce.as_ref());
if let Ok(decrypted_data) = cipher.decrypt(nonce, encrypted_data) {
return Ok(decrypted_data);
}
}
let cipher = ChaCha20Poly1305::new_from_slice(image_key.as_ref()).map_err(|e| {
GroupImageError::DecryptionFailed {
reason: format!("Failed to create cipher: {}", e),
}
})?;
let nonce = Nonce::from_slice(image_nonce.as_ref());
let decrypted_data =
cipher
.decrypt(nonce, encrypted_data)
.map_err(|e| GroupImageError::DecryptionFailed {
reason: format!("Decryption failed (possible tampering): {}", e),
})?;
Ok(decrypted_data)
}
#[allow(clippy::doc_overindented_list_items)]
pub fn derive_upload_keypair(
seed_or_key: &Secret<[u8; 32]>,
version: u16,
) -> Result<nostr::Keys, GroupImageError> {
let hk = Hkdf::<Sha256>::new(None, seed_or_key.as_ref());
let mut upload_secret = [0u8; 32];
let context = match version {
1 => UPLOAD_KEYPAIR_CONTEXT_V1,
2 => UPLOAD_KEYPAIR_CONTEXT_V2,
_ => {
return Err(GroupImageError::KeypairDerivationFailed {
reason: format!("Unsupported extension version: {}", version),
});
}
};
hk.expand(context, &mut upload_secret).map_err(|e| {
GroupImageError::KeypairDerivationFailed {
reason: format!("HKDF expansion failed: {}", e),
}
})?;
let secret_key = nostr::SecretKey::from_slice(&upload_secret).map_err(|e| {
GroupImageError::KeypairDerivationFailed {
reason: format!("Invalid secret key: {}", e),
}
})?;
Ok(nostr::Keys::new(secret_key))
}
pub fn prepare_group_image_for_upload(
image_data: &[u8],
mime_type: &str,
) -> Result<GroupImageUpload, GroupImageError> {
prepare_group_image_for_upload_with_options(
image_data,
mime_type,
&MediaProcessingOptions::default(),
)
}
pub fn prepare_group_image_for_upload_with_options(
image_data: &[u8],
mime_type: &str,
options: &MediaProcessingOptions,
) -> Result<GroupImageUpload, GroupImageError> {
use crate::media_processing::{metadata, validation};
validate_file_size(image_data, options)?;
let canonical_mime_type =
validation::validate_group_image_mime_type_matches_data(image_data, mime_type)?;
let original_size = image_data.len();
let sanitized_data: Vec<u8>;
let dimensions: Option<(u32, u32)>;
let blurhash: Option<String>;
let thumbhash: Option<String>;
if options.sanitize_exif && metadata::is_safe_raster_format(&canonical_mime_type) {
metadata::preflight_dimension_check(image_data, options)?;
let (cleaned_data, decoded_img) =
metadata::strip_exif_and_return_image(image_data, &canonical_mime_type)?;
let metadata = metadata::extract_metadata_from_decoded_image(
&decoded_img,
options,
options.generate_blurhash,
options.generate_thumbhash,
)?;
sanitized_data = cleaned_data;
dimensions = metadata.dimensions;
blurhash = metadata.blurhash;
thumbhash = metadata.thumbhash;
} else {
let metadata = extract_metadata_from_encoded_image(
image_data,
options,
options.generate_blurhash,
options.generate_thumbhash,
)?;
sanitized_data = image_data.to_vec();
dimensions = metadata.dimensions;
blurhash = metadata.blurhash;
thumbhash = metadata.thumbhash;
}
let encrypted = encrypt_group_image(&sanitized_data)?;
let encrypted_size = encrypted.encrypted_data.len();
let upload_keypair = derive_upload_keypair(&encrypted.image_upload_key, 2)?;
Ok(GroupImageUpload {
encrypted_data: encrypted.encrypted_data,
encrypted_hash: encrypted.encrypted_hash,
image_key: encrypted.image_key,
image_nonce: encrypted.image_nonce,
image_upload_key: encrypted.image_upload_key,
upload_keypair,
original_size,
encrypted_size,
mime_type: canonical_mime_type,
dimensions,
blurhash,
thumbhash,
})
}
pub fn migrate_group_image_v1_to_v2(
encrypted_v1_data: &[u8],
v1_image_hash: Option<&[u8; 32]>,
v1_image_key: &Secret<[u8; 32]>,
v1_image_nonce: &Secret<[u8; 12]>,
mime_type: &str,
) -> Result<GroupImageUpload, GroupImageError> {
let decrypted_data = decrypt_group_image(
encrypted_v1_data,
v1_image_hash,
v1_image_key,
v1_image_nonce,
)?;
prepare_group_image_for_upload(&decrypted_data, mime_type)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_encrypt_decrypt_roundtrip() {
let original_data = b"This is a test group avatar image";
let encrypted = encrypt_group_image(original_data).unwrap();
assert_ne!(encrypted.encrypted_data.as_slice(), original_data);
assert!(encrypted.encrypted_data.len() > original_data.len());
let decrypted = decrypt_group_image(
&encrypted.encrypted_data,
Some(&encrypted.encrypted_hash),
&encrypted.image_key,
&encrypted.image_nonce,
)
.unwrap();
assert_eq!(decrypted.as_slice(), original_data);
}
#[test]
fn test_decrypt_with_wrong_key() {
let original_data = b"Test image data";
let encrypted = encrypt_group_image(original_data).unwrap();
let wrong_key = Secret::new([0x42u8; 32]);
let result = decrypt_group_image(
&encrypted.encrypted_data,
Some(&encrypted.encrypted_hash),
&wrong_key,
&encrypted.image_nonce,
);
assert!(result.is_err());
assert!(matches!(
result,
Err(GroupImageError::DecryptionFailed { .. })
));
}
#[test]
fn test_decrypt_with_wrong_nonce() {
let original_data = b"Test image data";
let encrypted = encrypt_group_image(original_data).unwrap();
let wrong_nonce = Secret::new([0x24u8; 12]);
let result = decrypt_group_image(
&encrypted.encrypted_data,
Some(&encrypted.encrypted_hash),
&encrypted.image_key,
&wrong_nonce,
);
assert!(result.is_err());
assert!(matches!(
result,
Err(GroupImageError::DecryptionFailed { .. })
));
}
#[test]
fn test_derive_upload_keypair_deterministic() {
let image_key = Secret::new([0x42u8; 32]);
let keypair1 = derive_upload_keypair(&image_key, 2).unwrap();
let keypair2 = derive_upload_keypair(&image_key, 2).unwrap();
assert_eq!(keypair1.public_key(), keypair2.public_key());
assert_eq!(
keypair1.secret_key().as_secret_bytes(),
keypair2.secret_key().as_secret_bytes()
);
}
#[test]
fn test_derive_upload_keypair_different_keys() {
let key1 = Secret::new([0x42u8; 32]);
let key2 = Secret::new([0x43u8; 32]);
let keypair1 = derive_upload_keypair(&key1, 2).unwrap();
let keypair2 = derive_upload_keypair(&key2, 2).unwrap();
assert_ne!(keypair1.public_key(), keypair2.public_key());
}
#[test]
fn test_prepare_group_image_for_upload() {
use image::{ImageBuffer, Rgb};
let img = ImageBuffer::from_fn(64, 64, |x, y| {
Rgb([(x * 4) as u8, (y * 4) as u8, ((x + y) * 2) as u8])
});
let mut image_data = Vec::new();
img.write_to(
&mut std::io::Cursor::new(&mut image_data),
image::ImageFormat::Png,
)
.unwrap();
let options = MediaProcessingOptions {
sanitize_exif: true,
generate_blurhash: false,
generate_thumbhash: false,
..Default::default()
};
let prepared =
prepare_group_image_for_upload_with_options(&image_data, "image/png", &options)
.unwrap();
assert!(!prepared.encrypted_data.is_empty());
assert_eq!(prepared.original_size, image_data.len());
assert_eq!(prepared.mime_type, "image/png");
assert_eq!(prepared.dimensions, Some((64, 64)));
assert_eq!(prepared.blurhash, None); assert_eq!(prepared.thumbhash, None);
assert_eq!(prepared.original_size, image_data.len());
assert_eq!(prepared.encrypted_size, prepared.encrypted_data.len());
let calculated_hash: [u8; 32] = Sha256::digest(prepared.encrypted_data.as_ref()).into();
assert_eq!(prepared.encrypted_hash, calculated_hash);
let decrypted = decrypt_group_image(
&prepared.encrypted_data,
Some(&prepared.encrypted_hash),
&prepared.image_key,
&prepared.image_nonce,
)
.unwrap();
assert!(!decrypted.is_empty());
let derived_keypair = derive_upload_keypair(&prepared.image_upload_key, 2).unwrap();
assert_eq!(
derived_keypair.public_key(),
prepared.upload_keypair.public_key()
);
}
#[test]
fn test_encrypted_hash_calculation() {
let image_data = b"Test data for hash";
let encrypted = encrypt_group_image(image_data).unwrap();
let calculated_hash: [u8; 32] = Sha256::digest(encrypted.encrypted_data.as_ref()).into();
assert_eq!(calculated_hash, encrypted.encrypted_hash);
}
#[test]
fn test_tampering_detection() {
let original_data = b"Original group image";
let encrypted = encrypt_group_image(original_data).unwrap();
let mut tampered = encrypted.encrypted_data.as_ref().to_vec();
tampered[0] ^= 0xFF;
let result = decrypt_group_image(
&tampered,
Some(&encrypted.encrypted_hash),
&encrypted.image_key,
&encrypted.image_nonce,
);
assert!(result.is_err());
assert!(matches!(
result,
Err(GroupImageError::HashVerificationFailed { .. })
));
}
#[test]
fn test_hash_verification_success() {
let original_data = b"Test hash verification";
let encrypted = encrypt_group_image(original_data).unwrap();
let result = decrypt_group_image(
&encrypted.encrypted_data,
Some(&encrypted.encrypted_hash),
&encrypted.image_key,
&encrypted.image_nonce,
);
assert!(result.is_ok());
assert_eq!(result.unwrap().as_slice(), original_data);
}
#[test]
fn test_hash_verification_failure_wrong_hash() {
let original_data = b"Test hash verification failure";
let encrypted = encrypt_group_image(original_data).unwrap();
let wrong_hash = [0xFFu8; 32];
let result = decrypt_group_image(
&encrypted.encrypted_data,
Some(&wrong_hash),
&encrypted.image_key,
&encrypted.image_nonce,
);
assert!(result.is_err());
assert!(matches!(
result,
Err(GroupImageError::HashVerificationFailed { .. })
));
}
#[test]
fn test_hash_verification_failure_wrong_blob() {
let original_data = b"Test hash verification with wrong blob";
let encrypted = encrypt_group_image(original_data).unwrap();
let mut rng = OsRng;
let mut different_key = [0u8; 32];
rng.fill_bytes(&mut different_key);
let different_nonce = [0x42u8; 12];
let cipher = ChaCha20Poly1305::new_from_slice(&different_key).unwrap();
let nonce = Nonce::from_slice(&different_nonce);
let different_blob = cipher.encrypt(nonce, b"Different data".as_ref()).unwrap();
let result = decrypt_group_image(
&different_blob,
Some(&encrypted.encrypted_hash),
&encrypted.image_key,
&encrypted.image_nonce,
);
assert!(result.is_err());
assert!(matches!(
result,
Err(GroupImageError::HashVerificationFailed { .. })
));
}
#[test]
fn test_hash_verification_backward_compatibility_none() {
let original_data = b"Test backward compatibility without hash";
let encrypted = encrypt_group_image(original_data).unwrap();
let result = decrypt_group_image(
&encrypted.encrypted_data,
None,
&encrypted.image_key,
&encrypted.image_nonce,
);
assert!(result.is_ok());
assert_eq!(result.unwrap().as_slice(), original_data);
}
#[test]
fn test_mime_type_validation() {
use image::{ImageBuffer, Rgb};
let img = ImageBuffer::from_fn(64, 64, |x, y| {
Rgb([(x * 4) as u8, (y * 4) as u8, ((x + y) * 2) as u8])
});
let mut png_data = Vec::new();
img.write_to(
&mut std::io::Cursor::new(&mut png_data),
image::ImageFormat::Png,
)
.unwrap();
let options = MediaProcessingOptions {
sanitize_exif: true,
generate_blurhash: false,
generate_thumbhash: false,
..Default::default()
};
let result = prepare_group_image_for_upload_with_options(&png_data, "image/png", &options);
assert!(result.is_ok());
assert_eq!(result.unwrap().mime_type, "image/png");
let result = prepare_group_image_for_upload_with_options(&png_data, "Image/PNG", &options);
assert!(result.is_ok());
assert_eq!(result.unwrap().mime_type, "image/png");
let result =
prepare_group_image_for_upload_with_options(&png_data, " image/png ", &options);
assert!(result.is_ok());
assert_eq!(result.unwrap().mime_type, "image/png");
let result = prepare_group_image_for_upload_with_options(&png_data, "image/jpeg", &options);
assert!(result.is_err());
assert!(matches!(result, Err(GroupImageError::MediaProcessing(_))));
let result = prepare_group_image_for_upload_with_options(&png_data, "image/webp", &options);
assert!(result.is_err());
assert!(matches!(result, Err(GroupImageError::MediaProcessing(_))));
let result = prepare_group_image_for_upload_with_options(&png_data, "invalid", &options);
assert!(result.is_err());
assert!(matches!(result, Err(GroupImageError::MediaProcessing(_))));
let long_mime = "a".repeat(101);
let result = prepare_group_image_for_upload_with_options(&png_data, &long_mime, &options);
assert!(result.is_err());
assert!(matches!(result, Err(GroupImageError::MediaProcessing(_))));
}
#[test]
fn test_prepare_with_default_options() {
use image::{ImageBuffer, Rgb};
let img = ImageBuffer::from_fn(64, 64, |x, y| {
Rgb([(x * 4) as u8, (y * 4) as u8, ((x + y) * 2) as u8])
});
let mut image_data = Vec::new();
img.write_to(
&mut std::io::Cursor::new(&mut image_data),
image::ImageFormat::Png,
)
.unwrap();
let options = MediaProcessingOptions {
sanitize_exif: true,
generate_blurhash: false,
generate_thumbhash: false,
..Default::default()
};
let result =
prepare_group_image_for_upload_with_options(&image_data, "image/png", &options);
assert!(result.is_ok());
let prepared = result.unwrap();
assert_eq!(prepared.mime_type, "image/png");
assert_eq!(prepared.dimensions, Some((64, 64)));
assert_eq!(prepared.blurhash, None); assert_eq!(prepared.thumbhash, None);
assert!(!prepared.encrypted_data.is_empty());
}
#[test]
fn test_custom_size_limits() {
use image::{ImageBuffer, Rgb};
let img = ImageBuffer::from_fn(32, 32, |x, y| Rgb([(x * 8) as u8, (y * 8) as u8, 128]));
let mut image_data = Vec::new();
img.write_to(
&mut std::io::Cursor::new(&mut image_data),
image::ImageFormat::Png,
)
.unwrap();
let restrictive_options = MediaProcessingOptions {
sanitize_exif: true,
generate_blurhash: false,
generate_thumbhash: false,
max_dimension: Some(16), max_file_size: Some(100), max_filename_length: None,
};
let result = prepare_group_image_for_upload_with_options(
&image_data,
"image/png",
&restrictive_options,
);
assert!(result.is_err());
assert!(matches!(result, Err(GroupImageError::MediaProcessing(_))));
let permissive_options = MediaProcessingOptions {
sanitize_exif: false, generate_blurhash: false, generate_thumbhash: false, max_dimension: Some(1024),
max_file_size: Some(10 * 1024 * 1024), max_filename_length: None,
};
let result = prepare_group_image_for_upload_with_options(
&image_data,
"image/png",
&permissive_options,
);
assert!(result.is_ok());
let prepared = result.unwrap();
assert_eq!(prepared.mime_type, "image/png");
assert_eq!(prepared.dimensions, Some((32, 32)));
assert_eq!(prepared.blurhash, None); assert_eq!(prepared.thumbhash, None); }
#[test]
fn test_v2_encryption_uses_seed_derivation() {
let original_data = b"Test v2 encryption";
let encrypted = encrypt_group_image(original_data).unwrap();
let decrypted = decrypt_group_image(
&encrypted.encrypted_data,
Some(&encrypted.encrypted_hash),
&encrypted.image_key,
&encrypted.image_nonce,
)
.unwrap();
assert_eq!(decrypted.as_slice(), original_data);
let hk = Hkdf::<Sha256>::new(None, encrypted.image_key.as_ref());
let mut derived_key = [0u8; 32];
hk.expand(IMAGE_ENCRYPTION_CONTEXT_V2, &mut derived_key)
.unwrap();
let cipher = ChaCha20Poly1305::new_from_slice(&derived_key).unwrap();
let nonce = Nonce::from_slice(encrypted.image_nonce.as_ref());
let decrypted_v2 = cipher
.decrypt(nonce, encrypted.encrypted_data.as_ref().as_slice())
.unwrap();
assert_eq!(decrypted_v2.as_slice(), original_data);
}
#[test]
fn test_v1_backward_compatibility_decryption() {
let original_data = b"Test v1 encrypted data";
let mut rng = OsRng;
let mut image_key_v1 = [0u8; 32];
let mut image_nonce = [0u8; 12];
rng.fill_bytes(&mut image_key_v1);
rng.fill_bytes(&mut image_nonce);
let cipher = ChaCha20Poly1305::new_from_slice(&image_key_v1).unwrap();
let nonce = Nonce::from_slice(&image_nonce);
let encrypted_data = cipher.encrypt(nonce, original_data.as_ref()).unwrap();
let encrypted_hash: [u8; 32] = Sha256::digest(&encrypted_data).into();
let decrypted = decrypt_group_image(
&encrypted_data,
Some(&encrypted_hash),
&Secret::new(image_key_v1),
&Secret::new(image_nonce),
)
.unwrap();
assert_eq!(decrypted.as_slice(), original_data);
}
#[test]
fn test_v2_upload_keypair_derivation() {
let mut rng = OsRng;
let mut image_seed = [0u8; 32];
rng.fill_bytes(&mut image_seed);
let hk = Hkdf::<Sha256>::new(None, &image_seed);
let mut upload_secret = [0u8; 32];
hk.expand(UPLOAD_KEYPAIR_CONTEXT_V2, &mut upload_secret)
.unwrap();
let secret_key = nostr::SecretKey::from_slice(&upload_secret).unwrap();
let expected_keypair = nostr::Keys::new(secret_key);
let derived_keypair = derive_upload_keypair(&Secret::new(image_seed), 2).unwrap();
assert_eq!(derived_keypair.public_key(), expected_keypair.public_key());
}
#[test]
fn test_v1_upload_keypair_derivation() {
let mut rng = OsRng;
let mut image_key_v1 = [0u8; 32];
rng.fill_bytes(&mut image_key_v1);
let hk = Hkdf::<Sha256>::new(None, &image_key_v1);
let mut upload_secret = [0u8; 32];
hk.expand(UPLOAD_KEYPAIR_CONTEXT_V1, &mut upload_secret)
.unwrap();
let secret_key = nostr::SecretKey::from_slice(&upload_secret).unwrap();
let expected_v1_keypair = nostr::Keys::new(secret_key);
let derived_keypair = derive_upload_keypair(&Secret::new(image_key_v1), 1).unwrap();
let derived_keypair2 = derive_upload_keypair(&Secret::new(image_key_v1), 1).unwrap();
assert_eq!(derived_keypair.public_key(), derived_keypair2.public_key());
assert_eq!(
derived_keypair.public_key(),
expected_v1_keypair.public_key(),
"v1 derivation should produce the expected keypair"
);
let v2_keypair = derive_upload_keypair(&Secret::new(image_key_v1), 2).unwrap();
assert_ne!(
derived_keypair.public_key(),
v2_keypair.public_key(),
"v2 and v1 should produce different keypairs for the same input"
);
}
#[test]
fn test_v1_v2_keypair_difference() {
let test_bytes = [0x42u8; 32];
let hk_v1 = Hkdf::<Sha256>::new(None, &test_bytes);
let mut upload_secret_v1 = [0u8; 32];
hk_v1
.expand(UPLOAD_KEYPAIR_CONTEXT_V1, &mut upload_secret_v1)
.unwrap();
let secret_key_v1 = nostr::SecretKey::from_slice(&upload_secret_v1).unwrap();
let keypair_v1 = nostr::Keys::new(secret_key_v1);
let hk_v2 = Hkdf::<Sha256>::new(None, &test_bytes);
let mut upload_secret_v2 = [0u8; 32];
hk_v2
.expand(UPLOAD_KEYPAIR_CONTEXT_V2, &mut upload_secret_v2)
.unwrap();
let secret_key_v2 = nostr::SecretKey::from_slice(&upload_secret_v2).unwrap();
let keypair_v2 = nostr::Keys::new(secret_key_v2);
assert_ne!(keypair_v1.public_key(), keypair_v2.public_key());
}
#[test]
fn test_v2_encryption_and_upload_derivation() {
let original_data = b"Test v2 derivation consistency";
let encrypted = encrypt_group_image(original_data).unwrap();
let image_seed = encrypted.image_key; let upload_seed = encrypted.image_upload_key;
assert_ne!(image_seed, upload_seed);
let hk_enc = Hkdf::<Sha256>::new(None, image_seed.as_ref());
let mut encryption_key = [0u8; 32];
hk_enc
.expand(IMAGE_ENCRYPTION_CONTEXT_V2, &mut encryption_key)
.unwrap();
let upload_keypair = derive_upload_keypair(&upload_seed, 2).unwrap();
let cipher = ChaCha20Poly1305::new_from_slice(&encryption_key).unwrap();
let nonce = Nonce::from_slice(encrypted.image_nonce.as_ref());
let decrypted = cipher
.decrypt(nonce, encrypted.encrypted_data.as_ref().as_slice())
.unwrap();
assert_eq!(decrypted.as_slice(), original_data);
let upload_keypair2 = derive_upload_keypair(&upload_seed, 2).unwrap();
assert_eq!(upload_keypair.public_key(), upload_keypair2.public_key());
let different_keypair = derive_upload_keypair(&image_seed, 2).unwrap();
assert_ne!(upload_keypair.public_key(), different_keypair.public_key());
}
#[test]
fn test_migrate_v1_to_v2() {
use image::{ImageBuffer, Rgb};
let img = ImageBuffer::from_fn(64, 64, |x, y| {
Rgb([(x * 4) as u8, (y * 4) as u8, ((x + y) * 2) as u8])
});
let mut original_data = Vec::new();
img.write_to(
&mut std::io::Cursor::new(&mut original_data),
image::ImageFormat::Png,
)
.unwrap();
let mut rng = OsRng;
let mut v1_image_key = [0u8; 32];
let mut v1_image_nonce = [0u8; 12];
rng.fill_bytes(&mut v1_image_key);
rng.fill_bytes(&mut v1_image_nonce);
let cipher = ChaCha20Poly1305::new_from_slice(&v1_image_key).unwrap();
let nonce = Nonce::from_slice(&v1_image_nonce);
let encrypted_v1_data = cipher.encrypt(nonce, original_data.as_ref()).unwrap();
let v1_image_hash: [u8; 32] = Sha256::digest(&encrypted_v1_data).into();
let v2_prepared = migrate_group_image_v1_to_v2(
&encrypted_v1_data,
Some(&v1_image_hash),
&Secret::new(v1_image_key),
&Secret::new(v1_image_nonce),
"image/png",
)
.unwrap();
assert_ne!(encrypted_v1_data, *v2_prepared.encrypted_data);
let decrypted_v2 = decrypt_group_image(
&v2_prepared.encrypted_data,
Some(&v2_prepared.encrypted_hash),
&v2_prepared.image_key, &v2_prepared.image_nonce,
)
.unwrap();
assert!(!decrypted_v2.is_empty());
let hk = Hkdf::<Sha256>::new(None, v2_prepared.image_key.as_ref());
let mut derived_key = [0u8; 32];
hk.expand(IMAGE_ENCRYPTION_CONTEXT_V2, &mut derived_key)
.unwrap();
let cipher_v2 = ChaCha20Poly1305::new_from_slice(&derived_key).unwrap();
let nonce_v2 = Nonce::from_slice(v2_prepared.image_nonce.as_ref());
let decrypted_with_derived = cipher_v2
.decrypt(nonce_v2, v2_prepared.encrypted_data.as_ref().as_slice())
.unwrap();
assert_eq!(decrypted_v2, decrypted_with_derived);
let upload_keypair = derive_upload_keypair(&v2_prepared.image_upload_key, 2).unwrap();
assert_eq!(
upload_keypair.public_key(),
v2_prepared.upload_keypair.public_key()
);
}
#[test]
fn test_migrate_v1_to_v2_wrong_key() {
let mut rng = OsRng;
let mut v1_key = [0u8; 32];
let mut v1_nonce = [0u8; 12];
rng.fill_bytes(&mut v1_key);
rng.fill_bytes(&mut v1_nonce);
let original_data = b"test data";
let cipher = ChaCha20Poly1305::new_from_slice(&v1_key).unwrap();
let nonce = Nonce::from_slice(&v1_nonce);
let encrypted = cipher.encrypt(nonce, original_data.as_ref()).unwrap();
let encrypted_hash: [u8; 32] = Sha256::digest(&encrypted).into();
let wrong_key = Secret::new([0xFFu8; 32]);
let result = migrate_group_image_v1_to_v2(
&encrypted,
Some(&encrypted_hash),
&wrong_key,
&Secret::new(v1_nonce),
"image/png",
);
assert!(result.is_err());
assert!(matches!(
result,
Err(GroupImageError::DecryptionFailed { .. })
));
}
#[test]
fn test_migrate_v1_to_v2_corrupted_data() {
let mut rng = OsRng;
let mut v1_key = [0u8; 32];
let mut v1_nonce = [0u8; 12];
rng.fill_bytes(&mut v1_key);
rng.fill_bytes(&mut v1_nonce);
let corrupted_data = vec![0xFFu8; 100];
let corrupted_hash: [u8; 32] = Sha256::digest(&corrupted_data).into();
let result = migrate_group_image_v1_to_v2(
&corrupted_data,
Some(&corrupted_hash),
&Secret::new(v1_key),
&Secret::new(v1_nonce),
"image/png",
);
assert!(result.is_err());
assert!(matches!(
result,
Err(GroupImageError::DecryptionFailed { .. })
));
}
#[test]
fn test_v1_v2_produce_different_encryption() {
use image::{ImageBuffer, Rgb};
let img = ImageBuffer::from_fn(64, 64, |x, y| {
Rgb([(x * 4) as u8, (y * 4) as u8, ((x + y) * 2) as u8])
});
let mut image_data = Vec::new();
img.write_to(
&mut std::io::Cursor::new(&mut image_data),
image::ImageFormat::Png,
)
.unwrap();
let mut rng = OsRng;
let mut v1_key = [0u8; 32];
let mut v1_nonce = [0u8; 12];
rng.fill_bytes(&mut v1_key);
rng.fill_bytes(&mut v1_nonce);
let cipher_v1 = ChaCha20Poly1305::new_from_slice(&v1_key).unwrap();
let nonce_v1 = Nonce::from_slice(&v1_nonce);
let encrypted_v1 = cipher_v1.encrypt(nonce_v1, image_data.as_ref()).unwrap();
let v1_hash: [u8; 32] = Sha256::digest(&encrypted_v1).into();
let v2_prepared = migrate_group_image_v1_to_v2(
&encrypted_v1,
Some(&v1_hash),
&Secret::new(v1_key),
&Secret::new(v1_nonce),
"image/png",
)
.unwrap();
assert_ne!(encrypted_v1, *v2_prepared.encrypted_data);
let hash_v1: [u8; 32] = Sha256::digest(&encrypted_v1).into();
assert_ne!(hash_v1, v2_prepared.encrypted_hash);
}
#[test]
fn test_migration_preserves_metadata() {
use image::{ImageBuffer, Rgb};
let img = ImageBuffer::from_fn(128, 64, |x, y| {
Rgb([(x * 2) as u8, (y * 4) as u8, ((x + y) * 2) as u8])
});
let mut image_data = Vec::new();
img.write_to(
&mut std::io::Cursor::new(&mut image_data),
image::ImageFormat::Png,
)
.unwrap();
let mut rng = OsRng;
let mut v1_key = [0u8; 32];
let mut v1_nonce = [0u8; 12];
rng.fill_bytes(&mut v1_key);
rng.fill_bytes(&mut v1_nonce);
let cipher = ChaCha20Poly1305::new_from_slice(&v1_key).unwrap();
let nonce = Nonce::from_slice(&v1_nonce);
let encrypted_v1 = cipher.encrypt(nonce, image_data.as_ref()).unwrap();
let v1_hash: [u8; 32] = Sha256::digest(&encrypted_v1).into();
let v2_prepared = migrate_group_image_v1_to_v2(
&encrypted_v1,
Some(&v1_hash),
&Secret::new(v1_key),
&Secret::new(v1_nonce),
"image/png",
)
.unwrap();
assert_eq!(v2_prepared.mime_type, "image/png");
assert_eq!(v2_prepared.dimensions, Some((128, 64)));
assert_eq!(v2_prepared.original_size, image_data.len());
}
#[test]
fn test_upload_keypair_depends_only_on_upload_seed() {
use image::{ImageBuffer, Rgb};
let img = ImageBuffer::from_fn(32, 32, |x, y| Rgb([x as u8, y as u8, 128]));
let mut image_data = Vec::new();
img.write_to(
&mut std::io::Cursor::new(&mut image_data),
image::ImageFormat::Png,
)
.unwrap();
let prepared1 = prepare_group_image_for_upload(&image_data, "image/png").unwrap();
let mut prepared2 = prepare_group_image_for_upload(&image_data, "image/png").unwrap();
prepared2.image_key = Secret::new([0xAAu8; 32]);
assert_ne!(
prepared1.upload_keypair.public_key(),
prepared2.upload_keypair.public_key()
);
prepared2.image_upload_key = prepared1.image_upload_key;
let keypair2 = derive_upload_keypair(&prepared2.image_upload_key, 2).unwrap();
assert_eq!(keypair2.public_key(), prepared1.upload_keypair.public_key());
}
#[test]
fn test_v1_decryption_still_works_after_migration() {
use image::{ImageBuffer, Rgb};
let img = ImageBuffer::from_fn(64, 64, |x, y| {
Rgb([(x * 4) as u8, (y * 4) as u8, ((x + y) * 2) as u8])
});
let mut image_data = Vec::new();
img.write_to(
&mut std::io::Cursor::new(&mut image_data),
image::ImageFormat::Png,
)
.unwrap();
let mut rng = OsRng;
let mut v1_key = [0u8; 32];
let mut v1_nonce = [0u8; 12];
rng.fill_bytes(&mut v1_key);
rng.fill_bytes(&mut v1_nonce);
let cipher = ChaCha20Poly1305::new_from_slice(&v1_key).unwrap();
let nonce = Nonce::from_slice(&v1_nonce);
let encrypted_v1 = cipher.encrypt(nonce, image_data.as_ref()).unwrap();
let v1_hash: [u8; 32] = Sha256::digest(&encrypted_v1).into();
let v2_prepared = migrate_group_image_v1_to_v2(
&encrypted_v1,
Some(&v1_hash),
&Secret::new(v1_key),
&Secret::new(v1_nonce),
"image/png",
)
.unwrap();
let decrypted_v1 = decrypt_group_image(
&encrypted_v1,
Some(&v1_hash),
&Secret::new(v1_key),
&Secret::new(v1_nonce),
)
.unwrap();
assert_eq!(decrypted_v1, image_data);
let decrypted_v2 = decrypt_group_image(
&v2_prepared.encrypted_data,
Some(&v2_prepared.encrypted_hash),
&v2_prepared.image_key,
&v2_prepared.image_nonce,
)
.unwrap();
assert!(!decrypted_v1.is_empty());
assert!(!decrypted_v2.is_empty());
}
}