diff --git a/CHANGELOG.md b/CHANGELOG.md index d7e0fb84..df74d717 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ### Added -- Post-quantum hybrid public-key cryptography using X-Wing (ML-KEM-768 + X25519): `KeyVersion::V2` key pairs, `CiphertextVersion::V3` asymmetric ciphertexts, and a new `key_encapsulation` module (`encapsulate` / `decapsulate`, `DataType::EncapsulatedKey`). This is opt-in: `Latest` still selects the existing versions. +- Post-quantum hybrid public-key cryptography using X-Wing (ML-KEM-768 + X25519): `KeyVersion::V2` key pairs, `CiphertextVersion::V3` asymmetric ciphertexts, and a new `key_encapsulation` module (`encapsulate` / `decapsulate`, `DataType::EncapsulatedKey`). Available in the Rust, FFI/C#, uniffi (Python, Kotlin, Swift), WASM and CLI wrappers. This is opt-in: `Latest` still selects the existing versions. ## [0.10.3] - 2026-10-26 diff --git a/cli/src/main.rs b/cli/src/main.rs index b21470a1..6f1e467c 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -19,7 +19,11 @@ enum Commands { Generate, /// Generate a random keypair - GenerateKeypair, + GenerateKeypair { + /// The key version. Use 2 for an X-Wing post-quantum keypair. + #[arg(short, long)] + version: Option, + }, /// Generate secret key with parts shared accross multiple parties. #[command(arg_required_else_help = true)] @@ -120,6 +124,27 @@ enum Commands { key: String, }, + /// Encapsulate a fresh shared secret to a version 2 (X-Wing) public key + #[command(arg_required_else_help = true)] + Encapsulate { + /// The public key to encapsulate to + key: String, + + /// The encapsulation version to use + #[arg(short, long)] + version: Option, + }, + + /// Recover a shared secret from an encapsulated key using a version 2 (X-Wing) private key + #[command(arg_required_else_help = true)] + Decapsulate { + /// The encapsulated key + data: String, + + /// The private key to use + key: String, + }, + /// Hash a password #[command(arg_required_else_help = true)] HashPassword { @@ -196,7 +221,7 @@ fn main() { match cli.command { Commands::Generate => generate_key(), - Commands::GenerateKeypair => generate_keypair(), + Commands::GenerateKeypair { version } => generate_keypair(version), Commands::GenerateArgon2Parameters { memory, lanes, @@ -219,6 +244,8 @@ fn main() { } Commands::Decrypt { data, key } => decrypt(data, key), Commands::DecryptAsymmetric { data, key } => decrypt_asymmetric(data, key), + Commands::Encapsulate { key, version } => encapsulate(key, version), + Commands::Decapsulate { data, key } => decapsulate(data, key), Commands::HashPassword { password, params } => hash_password(password, params), Commands::VerifyPassword { password, hash } => verify_password(hash, password), Commands::MixKeyExchange { private, public } => mix_key_exchange(private, public), @@ -354,6 +381,7 @@ fn detect_dc_type(bytes: &[u8]) -> String { Ok(DataType::Signature) => "Signature".to_string(), Ok(DataType::KeyDerivation) => "DerivationParameters".to_string(), Ok(DataType::KdfEncryptedData) => "KdfEncryptedData".to_string(), + Ok(DataType::EncapsulatedKey) => "EncapsulatedKey".to_string(), _ => "unknown".to_string(), } } @@ -504,8 +532,13 @@ fn verify_password(hash: String, password: String) { println!("{}", hash.verify_password(password.as_bytes())); } -fn generate_keypair() { - let keypair = devolutions_crypto::key::generate_keypair(Default::default()); +fn generate_keypair(version: Option) { + let version = devolutions_crypto::key::KeyVersion::try_from(version.unwrap_or(0)) + .unwrap_or_else(|_| { + eprintln!("Error: 'version' - unknown key version."); + std::process::exit(1); + }); + let keypair = devolutions_crypto::key::generate_keypair(version); println!( "Private Key: {}\nPublic Key: {}", @@ -514,6 +547,66 @@ fn generate_keypair() { ); } +fn encapsulate(key: String, version: Option) { + use devolutions_crypto::key::PublicKey; + use devolutions_crypto::key_encapsulation::{KeyEncapsulationVersion, encapsulate}; + + let key_bytes = decode_base64_arg("key", &key); + let key = PublicKey::try_from(key_bytes.as_slice()).unwrap_or_else(|_| { + eprintln!( + "Error: 'key' - expected PublicKey, received {}.", + detect_dc_type(&key_bytes) + ); + std::process::exit(1); + }); + + let version = KeyEncapsulationVersion::try_from(version.unwrap_or(0)).unwrap_or_else(|_| { + eprintln!("Error: 'version' - unknown encapsulation version."); + std::process::exit(1); + }); + + let (encapsulated, secret) = encapsulate(&key, version).unwrap_or_else(|e| { + eprintln!("Error: encapsulation failed: {}.", e); + std::process::exit(1); + }); + + println!( + "Encapsulated Key: {}\nSecret Key: {}", + base64_encode(&Vec::::from(encapsulated)), + base64_encode(&Vec::::from(secret)) + ); +} + +fn decapsulate(data: String, key: String) { + use devolutions_crypto::key::PrivateKey; + use devolutions_crypto::key_encapsulation::{EncapsulatedKey, decapsulate}; + + let data_bytes = decode_base64_arg("data", &data); + let encapsulated = EncapsulatedKey::try_from(data_bytes.as_slice()).unwrap_or_else(|_| { + eprintln!( + "Error: 'data' - expected EncapsulatedKey, received {}.", + detect_dc_type(&data_bytes) + ); + std::process::exit(1); + }); + + let key_bytes = decode_base64_arg("key", &key); + let key = PrivateKey::try_from(key_bytes.as_slice()).unwrap_or_else(|_| { + eprintln!( + "Error: 'key' - expected PrivateKey, received {}.", + detect_dc_type(&key_bytes) + ); + std::process::exit(1); + }); + + let secret: devolutions_crypto::key::SecretKey = decapsulate(&key, &encapsulated) + .unwrap_or_else(|e| { + eprintln!("Error: decapsulation failed: {}.", e); + std::process::exit(1); + }); + println!("{}", base64_encode(&Vec::::from(secret))); +} + fn mix_key_exchange(private: String, public: String) { let private = devolutions_crypto::key::PrivateKey::try_from(base64_decode(&private).unwrap().as_slice()) diff --git a/ffi/src/lib.rs b/ffi/src/lib.rs index d74048b9..cb0ad999 100644 --- a/ffi/src/lib.rs +++ b/ffi/src/lib.rs @@ -28,6 +28,9 @@ use devolutions_crypto::key::{ KeyVersion, PrivateKey, PublicKey, generate_keypair, generate_secret_key, mix_key_exchange, }; use devolutions_crypto::key_derivation::{Argon2, DerivationParameters, Pbkdf2}; +use devolutions_crypto::key_encapsulation::{ + EncapsulatedKey, KeyEncapsulationVersion, decapsulate, encapsulate, +}; use devolutions_crypto::password_hash::{ PasswordHash, PasswordHashVersion, hash_password, hash_password_with_parameters, }; @@ -187,6 +190,10 @@ pub unsafe extern "C" fn EncryptAsymmetric( let res: Zeroizing> = Zeroizing::new(res.into()); let length = res.len(); + if length > result.len() { + return Error::InvalidOutputLength.error_code(); + } + result[0..length].copy_from_slice(&res); length as i64 } @@ -227,6 +234,9 @@ pub extern "C" fn EncryptAsymmetricSize(data_length: usize, version: u16) -> i64 0 | 2 => { (8 + 32 + 24 + data_length + 16) as i64 // Header + public_key + nonce + data + Poly1305 tag } + 3 => { + (8 + 1120 + 24 + data_length + 16) as i64 // Header + X-Wing ciphertext + nonce + data + Poly1305 tag + } _ => Error::UnknownVersion.error_code(), } } @@ -993,6 +1003,92 @@ pub unsafe extern "C" fn GenerateKeyPair( } } +/// Generate a key pair of a specific version. Version 1 is X25519 and is compatible with +/// `MixKeyExchange()`. Version 2 is the post-quantum X-Wing hybrid (ML-KEM-768 + X25519) and +/// must be used with `EncryptAsymmetric()` using ciphertext version 3, or with `Encapsulate()`. +/// # Arguments +/// * `private` - Pointer to the buffer to write the private key to. +/// * `private_length` - Length of the buffer to write the private key to. +/// You can get the value by calling `GenerateKeyPairPrivateSize()` beforehand. +/// * `public` - Pointer to the buffer to write the public key to. +/// * `public_length` - Length of the buffer to write the public key to. +/// You can get the value by calling `GenerateKeyPairPublicSize()` beforehand. +/// * `version` - Key version to use. Use 0 for the latest one. +/// # Returns +/// Returns 0 if the generation worked. If there is an error, +/// it will return the appropriate error code defined in DevoCryptoError. +/// # Safety +/// This method is made to be called by C, so it is therefore unsafe. The caller should make sure it passes the right pointers and sizes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn GenerateKeyPairWithVersion( + private: *mut u8, + private_length: usize, + public: *mut u8, + public_length: usize, + version: u16, +) -> i64 { + unsafe { + if private.is_null() || public.is_null() { + return Error::NullPointer.error_code(); + }; + + let key_version = match KeyVersion::try_from(version) { + Ok(v) => v, + Err(_) => return Error::UnknownVersion.error_code(), + }; + + if private_length != GenerateKeyPairPrivateSize(version) as usize + || public_length != GenerateKeyPairPublicSize(version) as usize + { + return Error::InvalidOutputLength.error_code(); + } + + let private = slice::from_raw_parts_mut(private, private_length); + let public = slice::from_raw_parts_mut(public, public_length); + + let keypair = generate_keypair(key_version); + + let priv_res: Zeroizing> = Zeroizing::new(keypair.private_key.into()); + let pub_res: Zeroizing> = Zeroizing::new(keypair.public_key.into()); + + if priv_res.len() > private.len() || pub_res.len() > public.len() { + return Error::InvalidOutputLength.error_code(); + } + + public[0..pub_res.len()].copy_from_slice(&pub_res); + private[0..priv_res.len()].copy_from_slice(&priv_res); + 0 + } +} + +/// Get the size of the private key for a given key version. +/// # Arguments +/// * `version` - Key version. Use 0 for the latest one. +/// # Returns +/// Returns the length to input as `private_length` in `GenerateKeyPairWithVersion()`. +#[unsafe(no_mangle)] +pub extern "C" fn GenerateKeyPairPrivateSize(version: u16) -> i64 { + match version { + 0 | 1 => 8 + 32, // header + X25519 secret + 2 => 8 + 32, // header + X-Wing seed + _ => Error::UnknownVersion.error_code(), + } +} + +/// Get the size of the public key for a given key version. +/// # Arguments +/// * `version` - Key version. Use 0 for the latest one. +/// # Returns +/// Returns the length to input as `public_length` in `GenerateKeyPairWithVersion()`. +#[unsafe(no_mangle)] +pub extern "C" fn GenerateKeyPairPublicSize(version: u16) -> i64 { + match version { + 0 | 1 => 8 + 32, // header + X25519 public key + 2 => 8 + 1184 + 32, // header + ML-KEM-768 public key + X25519 public key + _ => Error::UnknownVersion.error_code(), + } +} + /// Generate a key pair to sign and verify data with. /// # Arguments /// * `keypair` - Pointer to the buffer to write the keypair to. @@ -1202,6 +1298,157 @@ pub extern "C" fn MixKeyExchangeSize() -> i64 { 32 } +/// Encapsulate a fresh shared secret to a public key (key encapsulation mechanism). +/// The `encapsulated` output must be sent to the owner of the private key, who can recover +/// the same secret with `Decapsulate()`. +/// # Arguments +/// * `public_key` - Pointer to the recipient's public key. It must be a version 2 (X-Wing) key. +/// * `public_key_length` - Length of the recipient's public key. +/// * `encapsulated` - Pointer to the buffer to write the encapsulated key to. +/// * `encapsulated_length` - Length of the buffer to write the encapsulated key to. +/// You can get the value by calling `EncapsulateSize()` beforehand. +/// * `secret` - Pointer to the buffer to write the shared secret key to. +/// * `secret_length` - Length of the buffer to write the shared secret key to. +/// You can get the value by calling `GenerateSecretKeySize()` beforehand. +/// * `version` - Key encapsulation version to use. Use 0 for the latest one. +/// # Returns +/// Returns 0 if the operation worked. If there is an error, +/// it will return the appropriate error code defined in DevoCryptoError. +/// # Safety +/// This method is made to be called by C, so it is therefore unsafe. The caller should make sure it passes the right pointers and sizes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn Encapsulate( + public_key: *const u8, + public_key_length: usize, + encapsulated: *mut u8, + encapsulated_length: usize, + secret: *mut u8, + secret_length: usize, + version: u16, +) -> i64 { + unsafe { + if public_key.is_null() || encapsulated.is_null() || secret.is_null() { + return Error::NullPointer.error_code(); + }; + + let version = match KeyEncapsulationVersion::try_from(version) { + Ok(v) => v, + Err(_) => return Error::UnknownVersion.error_code(), + }; + + if encapsulated_length != EncapsulateSize(version as u16) as usize + || secret_length != GenerateSecretKeySize() as usize + { + return Error::InvalidOutputLength.error_code(); + } + + let public_key = + match PublicKey::try_from(slice::from_raw_parts(public_key, public_key_length)) { + Ok(k) => k, + Err(e) => return e.error_code(), + }; + + let encapsulated = slice::from_raw_parts_mut(encapsulated, encapsulated_length); + let secret = slice::from_raw_parts_mut(secret, secret_length); + + match encapsulate(&public_key, version) { + Ok((encapsulated_key, secret_key)) => { + let encapsulated_bytes: Vec = encapsulated_key.into(); + let secret_bytes: Zeroizing> = Zeroizing::new(secret_key.into()); + + if encapsulated_bytes.len() > encapsulated.len() + || secret_bytes.len() > secret.len() + { + return Error::InvalidOutputLength.error_code(); + } + + encapsulated[0..encapsulated_bytes.len()].copy_from_slice(&encapsulated_bytes); + secret[0..secret_bytes.len()].copy_from_slice(&secret_bytes); + 0 + } + Err(e) => e.error_code(), + } + } +} + +/// Get the size of an encapsulated key. +/// # Arguments +/// * `version` - Key encapsulation version. Use 0 for the latest one. +/// # Returns +/// Returns the length to input as `encapsulated_length` in `Encapsulate()`. +#[unsafe(no_mangle)] +pub extern "C" fn EncapsulateSize(version: u16) -> i64 { + match version { + 0 | 1 => 8 + 1120, // header + X-Wing ciphertext + _ => Error::UnknownVersion.error_code(), + } +} + +/// Recover the shared secret from an encapsulated key (key encapsulation mechanism). +/// Note that a wrong private key does not return an error: the result is a different secret. +/// # Arguments +/// * `private_key` - Pointer to the private key. It must be a version 2 (X-Wing) key. +/// * `private_key_length` - Length of the private key. +/// * `encapsulated` - Pointer to the encapsulated key produced by `Encapsulate()`. +/// * `encapsulated_length` - Length of the encapsulated key. +/// * `secret` - Pointer to the buffer to write the shared secret key to. +/// * `secret_length` - Length of the buffer to write the shared secret key to. +/// You can get the value by calling `GenerateSecretKeySize()` beforehand. +/// # Returns +/// Returns 0 if the operation worked. If there is an error, +/// it will return the appropriate error code defined in DevoCryptoError. +/// # Safety +/// This method is made to be called by C, so it is therefore unsafe. The caller should make sure it passes the right pointers and sizes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn Decapsulate( + private_key: *const u8, + private_key_length: usize, + encapsulated: *const u8, + encapsulated_length: usize, + secret: *mut u8, + secret_length: usize, +) -> i64 { + unsafe { + if private_key.is_null() || encapsulated.is_null() || secret.is_null() { + return Error::NullPointer.error_code(); + }; + + if secret_length != GenerateSecretKeySize() as usize { + return Error::InvalidOutputLength.error_code(); + } + + let private_key = + match PrivateKey::try_from(slice::from_raw_parts(private_key, private_key_length)) { + Ok(k) => k, + Err(e) => return e.error_code(), + }; + + let encapsulated = match EncapsulatedKey::try_from(slice::from_raw_parts( + encapsulated, + encapsulated_length, + )) { + Ok(k) => k, + Err(e) => return e.error_code(), + }; + + let secret = slice::from_raw_parts_mut(secret, secret_length); + + match decapsulate(&private_key, &encapsulated) { + Ok(secret_key) => { + let secret_bytes: Zeroizing> = Zeroizing::new(secret_key.into()); + + if secret_bytes.len() > secret.len() { + return Error::InvalidOutputLength.error_code(); + } + + secret[0..secret_bytes.len()].copy_from_slice(&secret_bytes); + 0 + } + Err(e) => e.error_code(), + } + } +} + /// Generates a secret key shared amongst multiple actor. /// # Arguments /// * n_shares - The number of shares to generate. @@ -2650,6 +2897,156 @@ pub unsafe extern "C" fn Version(output: *mut u8, output_length: usize) -> i64 { } } +#[test] +fn test_post_quantum_ffi() { + let private_size = GenerateKeyPairPrivateSize(2) as usize; + let public_size = GenerateKeyPairPublicSize(2) as usize; + assert_eq!(GenerateKeyPairPrivateSize(0), 40); + assert_eq!(GenerateKeyPairPublicSize(0), 40); + assert_eq!( + GenerateKeyPairPublicSize(3), + Error::UnknownVersion.error_code() + ); + + let mut private = vec![0u8; private_size]; + let mut public = vec![0u8; public_size]; + unsafe { + assert_eq!( + GenerateKeyPairWithVersion( + private.as_mut_ptr(), + private.len(), + public.as_mut_ptr(), + public.len(), + 2 + ), + 0 + ); + // wrong sizes are rejected, not truncated + assert_eq!( + GenerateKeyPairWithVersion( + private.as_mut_ptr(), + private.len(), + public.as_mut_ptr(), + 40, + 2 + ), + Error::InvalidOutputLength.error_code() + ); + } + + // Asymmetric V3 + let data = b"post-quantum data"; + let size = EncryptAsymmetricSize(data.len(), 3); + assert!(size > 0); + let mut ciphertext = vec![0u8; size as usize]; + unsafe { + let written = EncryptAsymmetric( + data.as_ptr(), + data.len(), + public.as_ptr(), + public.len(), + std::ptr::null(), + 0, + ciphertext.as_mut_ptr(), + ciphertext.len(), + 3, + ); + assert_eq!(written, size); + + let mut plaintext = vec![0u8; ciphertext.len()]; + let n = DecryptAsymmetric( + ciphertext.as_ptr(), + ciphertext.len(), + private.as_ptr(), + private.len(), + std::ptr::null(), + 0, + plaintext.as_mut_ptr(), + plaintext.len(), + ); + assert_eq!(&plaintext[..n as usize], data); + + // V3 with a V1 public key must fail cleanly + let mut v1_private = vec![0u8; 40]; + let mut v1_public = vec![0u8; 40]; + assert_eq!( + GenerateKeyPairWithVersion(v1_private.as_mut_ptr(), 40, v1_public.as_mut_ptr(), 40, 1), + 0 + ); + let written = EncryptAsymmetric( + data.as_ptr(), + data.len(), + v1_public.as_ptr(), + v1_public.len(), + std::ptr::null(), + 0, + ciphertext.as_mut_ptr(), + ciphertext.len(), + 3, + ); + assert_eq!(written, Error::InvalidDataType.error_code()); + } + + // Key encapsulation + let encapsulated_size = EncapsulateSize(0) as usize; + assert_eq!(encapsulated_size, 1128); + let secret_size = GenerateSecretKeySize() as usize; + let mut encapsulated = vec![0u8; encapsulated_size]; + let mut sender_secret = vec![0u8; secret_size]; + let mut recipient_secret = vec![0u8; secret_size]; + unsafe { + assert_eq!( + Encapsulate( + public.as_ptr(), + public.len(), + encapsulated.as_mut_ptr(), + encapsulated.len(), + sender_secret.as_mut_ptr(), + sender_secret.len(), + 0 + ), + 0 + ); + assert_eq!( + Decapsulate( + private.as_ptr(), + private.len(), + encapsulated.as_ptr(), + encapsulated.len(), + recipient_secret.as_mut_ptr(), + recipient_secret.len() + ), + 0 + ); + assert_eq!(sender_secret, recipient_secret); + + assert_eq!( + Encapsulate( + public.as_ptr(), + public.len(), + encapsulated.as_mut_ptr(), + 10, + sender_secret.as_mut_ptr(), + sender_secret.len(), + 0 + ), + Error::InvalidOutputLength.error_code() + ); + assert_eq!( + Encapsulate( + public.as_ptr(), + public.len(), + encapsulated.as_mut_ptr(), + encapsulated.len(), + sender_secret.as_mut_ptr(), + sender_secret.len(), + 9 + ), + Error::UnknownVersion.error_code() + ); + } +} + #[test] fn test_encrypt_length() { let key = b"supersecret"; diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 2eb45385..647ebd5b 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -303,3 +303,24 @@ path = "fuzz_targets/derive_encrypt/decrypt_with_password.rs" test = false doc = false bench = false + +[[bin]] +name = "encapsulated_key_deserialization" +path = "fuzz_targets/key_encapsulation/encapsulated_key_deserialization.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "encapsulate" +path = "fuzz_targets/key_encapsulation/encapsulate.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "decapsulate" +path = "fuzz_targets/key_encapsulation/decapsulate.rs" +test = false +doc = false +bench = false diff --git a/fuzz/fuzz_targets/key_encapsulation/decapsulate.rs b/fuzz/fuzz_targets/key_encapsulation/decapsulate.rs new file mode 100644 index 00000000..0b7c6632 --- /dev/null +++ b/fuzz/fuzz_targets/key_encapsulation/decapsulate.rs @@ -0,0 +1,16 @@ +#![no_main] +use arbitrary::Arbitrary; +use libfuzzer_sys::fuzz_target; + +use devolutions_crypto::key::{PrivateKey, SecretKey}; +use devolutions_crypto::key_encapsulation::{EncapsulatedKey, decapsulate}; + +#[derive(Arbitrary, Clone, Debug)] +struct Input { + encapsulated_key: EncapsulatedKey, + key: PrivateKey, +} + +fuzz_target!(|data: Input| { + let _: Result = decapsulate(&data.key, &data.encapsulated_key); +}); diff --git a/fuzz/fuzz_targets/key_encapsulation/encapsulate.rs b/fuzz/fuzz_targets/key_encapsulation/encapsulate.rs new file mode 100644 index 00000000..c3b81366 --- /dev/null +++ b/fuzz/fuzz_targets/key_encapsulation/encapsulate.rs @@ -0,0 +1,16 @@ +#![no_main] +use arbitrary::Arbitrary; +use libfuzzer_sys::fuzz_target; + +use devolutions_crypto::key::PublicKey; +use devolutions_crypto::key_encapsulation::{KeyEncapsulationVersion, encapsulate}; + +#[derive(Arbitrary, Clone, Debug)] +struct Input { + key: PublicKey, + version: KeyEncapsulationVersion, +} + +fuzz_target!(|data: Input| { + let _ = encapsulate(&data.key, data.version); +}); diff --git a/fuzz/fuzz_targets/key_encapsulation/encapsulated_key_deserialization.rs b/fuzz/fuzz_targets/key_encapsulation/encapsulated_key_deserialization.rs new file mode 100644 index 00000000..3917094e --- /dev/null +++ b/fuzz/fuzz_targets/key_encapsulation/encapsulated_key_deserialization.rs @@ -0,0 +1,10 @@ +#![no_main] +use libfuzzer_sys::fuzz_target; + +use devolutions_crypto::key_encapsulation::EncapsulatedKey; + +use std::convert::TryFrom; + +fuzz_target!(|data: &[u8]| { + let _ = EncapsulatedKey::try_from(data); +}); diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index 1b9a2b12..255e5f7b 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ## [Unreleased] +### Added + +- Post-quantum X-Wing support: `generate_keypair(KeyVersion.V2)`, `encrypt_asymmetric(..., CiphertextVersion.V3)`, and `encapsulate` / `decapsulate`. + ## [0.10.3] - 2026-10-26 ### Breaking changes diff --git a/src/wasm.rs b/src/wasm.rs index bf4d9a68..603e332a 100644 --- a/src/wasm.rs +++ b/src/wasm.rs @@ -17,6 +17,10 @@ use super::{ key, key::{KeyVersion, PrivateKey, PublicKey, SecretKey}, }; +use super::{ + key_encapsulation, + key_encapsulation::{EncapsulatedKey, KeyEncapsulationVersion}, +}; use super::{ password_hash, password_hash::{PasswordHash, PasswordHashVersion}, @@ -306,6 +310,57 @@ pub fn generate_signing_keypair(version: Option) -> SigningKe signing_key::generate_signing_keypair(version.unwrap_or(SigningKeyVersion::Latest)) } +#[wasm_bindgen(inspectable)] +#[derive(Clone)] +pub struct EncapsulationResult { + encapsulated_key: Vec, + secret_key: SecretKey, +} + +#[wasm_bindgen] +impl EncapsulationResult { + #[wasm_bindgen(getter, js_name = "encapsulatedKey")] + pub fn encapsulated_key(&self) -> Vec { + self.encapsulated_key.clone() + } + + #[wasm_bindgen(getter, js_name = "secretKey")] + pub fn secret_key(&self) -> SecretKey { + self.secret_key.clone() + } +} + +/// Encapsulates a fresh shared secret to a version 2 (X-Wing) public key. +#[wasm_bindgen] +pub fn encapsulate( + public_key: &PublicKey, + version: Option, +) -> Result { + let (encapsulated_key, secret_key) = key_encapsulation::encapsulate( + public_key, + version.unwrap_or(KeyEncapsulationVersion::Latest), + )?; + + Ok(EncapsulationResult { + encapsulated_key: encapsulated_key.into(), + secret_key, + }) +} + +/// Recovers the shared secret from an encapsulated key using a version 2 (X-Wing) private key. +/// A wrong private key does not fail: it yields a different secret. +#[wasm_bindgen] +pub fn decapsulate( + private_key: &PrivateKey, + encapsulated_key: &[u8], +) -> Result { + let encapsulated_key = EncapsulatedKey::try_from(encapsulated_key)?; + Ok(key_encapsulation::decapsulate( + private_key, + &encapsulated_key, + )?) +} + #[wasm_bindgen(js_name = "mixKeyExchange")] pub fn mix_key_exchange( private_key: &PrivateKey, diff --git a/uniffi/devolutions-crypto-uniffi/src/key_encapsulation.rs b/uniffi/devolutions-crypto-uniffi/src/key_encapsulation.rs new file mode 100644 index 00000000..eb936734 --- /dev/null +++ b/uniffi/devolutions-crypto-uniffi/src/key_encapsulation.rs @@ -0,0 +1,40 @@ +use crate::KeyEncapsulationVersion; +use crate::Result; +use devolutions_crypto::key::SecretKey; +use devolutions_crypto::key_encapsulation::EncapsulatedKey; + +/// The result of an encapsulation: the blob to send to the recipient and the shared secret key. +#[derive(uniffi::Record)] +pub struct EncapsulationResult { + pub encapsulated_key: Vec, + pub secret_key: Vec, +} + +/// Encapsulates a fresh shared secret to a version 2 (X-Wing) public key. +#[uniffi::export(default(version = None))] +pub fn encapsulate( + public_key: Vec, + version: Option, +) -> Result { + let version = version.unwrap_or(KeyEncapsulationVersion::Latest); + let public_key = public_key.as_slice().try_into()?; + let (encapsulated_key, secret_key) = + devolutions_crypto::key_encapsulation::encapsulate(&public_key, version)?; + + Ok(EncapsulationResult { + encapsulated_key: encapsulated_key.into(), + secret_key: secret_key.into(), + }) +} + +/// Recovers the shared secret key from an encapsulated key using a version 2 (X-Wing) private key. +/// A wrong private key does not fail: it yields a different secret. +#[uniffi::export] +pub fn decapsulate(private_key: Vec, encapsulated_key: Vec) -> Result> { + let private_key = private_key.as_slice().try_into()?; + let encapsulated_key = EncapsulatedKey::try_from(encapsulated_key.as_slice())?; + let secret_key: SecretKey = + devolutions_crypto::key_encapsulation::decapsulate(&private_key, &encapsulated_key)?; + + Ok(secret_key.into()) +} diff --git a/uniffi/devolutions-crypto-uniffi/src/lib.rs b/uniffi/devolutions-crypto-uniffi/src/lib.rs index 641d01fb..782e1399 100644 --- a/uniffi/devolutions-crypto-uniffi/src/lib.rs +++ b/uniffi/devolutions-crypto-uniffi/src/lib.rs @@ -3,6 +3,7 @@ mod ciphertext; mod derive_encrypt; mod key; mod key_derivation; +mod key_encapsulation; mod password_hash; mod secret_sharing; mod signature; @@ -14,6 +15,7 @@ pub use ciphertext::*; pub use derive_encrypt::*; pub use key::*; pub use key_derivation::*; +pub use key_encapsulation::*; pub use password_hash::*; pub use secret_sharing::*; pub use signature::*; @@ -22,8 +24,8 @@ pub use utils::*; pub use devolutions_crypto::{ CiphertextVersion, DataType, Error as DevolutionsCryptoError, KdfEncryptedDataVersion, - KeyDerivationVersion, KeyVersion, PasswordHashVersion, Result, SecretSharingVersion, - SignatureVersion, SigningKeyVersion, + KeyDerivationVersion, KeyEncapsulationVersion, KeyVersion, PasswordHashVersion, Result, + SecretSharingVersion, SignatureVersion, SigningKeyVersion, }; #[uniffi::remote(Enum)] @@ -56,6 +58,12 @@ pub enum KeyVersion { V2, } +#[uniffi::remote(Enum)] +pub enum KeyEncapsulationVersion { + Latest, + V1, +} + #[uniffi::remote(Enum)] pub enum KeyDerivationVersion { Latest, diff --git a/wrappers/csharp/src/EncapsulationResult.cs b/wrappers/csharp/src/EncapsulationResult.cs new file mode 100644 index 00000000..0bb6d18e --- /dev/null +++ b/wrappers/csharp/src/EncapsulationResult.cs @@ -0,0 +1,29 @@ +namespace Devolutions.Cryptography +{ + /// + /// The result of a key encapsulation: the blob to send to the recipient and the local shared secret. + /// + public class EncapsulationResult + { + /// + /// Initializes a new instance of the class. + /// + /// The encapsulated key to send to the recipient. + /// The shared secret key. + public EncapsulationResult(byte[] encapsulatedKey, SecretKey secretKey) + { + this.EncapsulatedKey = encapsulatedKey; + this.SecretKey = secretKey; + } + + /// + /// Gets the encapsulated key to send to the recipient. + /// + public byte[] EncapsulatedKey { get; } + + /// + /// Gets the shared secret key, usable with the symmetric methods. + /// + public SecretKey SecretKey { get; } + } +} diff --git a/wrappers/csharp/src/Enums.cs b/wrappers/csharp/src/Enums.cs index 34e35fc5..8a1bb9c5 100644 --- a/wrappers/csharp/src/Enums.cs +++ b/wrappers/csharp/src/Enums.cs @@ -51,7 +51,49 @@ public enum DataType /// /// A blob containing key derivation parameters and an encrypted ciphertext. /// - KdfEncryptedData = 9 + KdfEncryptedData = 9, + + /// + /// A key encapsulation produced by . + /// + EncapsulatedKey = 10, + } + + /// + /// Devolutions Crypto Asymmetric Key Version. + /// + public enum KeyVersion + { + /// + /// This is the latest classical version. (Currently X25519). Post-quantum keys are opt-in via . + /// + Latest = 0, + + /// + /// X25519. + /// + V1 = 1, + + /// + /// X-Wing hybrid post-quantum KEM (ML-KEM-768 + X25519). + /// + V2 = 2, + } + + /// + /// Devolutions Crypto Key Encapsulation Version. + /// + public enum KeyEncapsulationVersion + { + /// + /// This is the latest version. (Currently X-Wing). + /// + Latest = 0, + + /// + /// X-Wing hybrid post-quantum KEM (ML-KEM-768 + X25519), with HKDF-SHA-256 applied to the shared secret. + /// + V1 = 1, } /// @@ -100,6 +142,11 @@ public enum CipherTextVersion /// XChaCha20Poly1305 with DeriveKey length fix /// V2_5 = 2, + + /// + /// X-Wing hybrid post-quantum KEM (ML-KEM-768 + X25519) with XChaCha20Poly1305. Requires a key. + /// + V3 = 3, } /// diff --git a/wrappers/csharp/src/Managed.cs b/wrappers/csharp/src/Managed.cs index 82b8aecb..31b6eb5e 100644 --- a/wrappers/csharp/src/Managed.cs +++ b/wrappers/csharp/src/Managed.cs @@ -752,6 +752,102 @@ public static KeyPair GenerateKeyPair() }; } + /// + /// Generates a pair of random public and private keys of the requested version. + /// + /// The key version. generates an X-Wing post-quantum key pair. + /// Returns the random public and private keys. + public static KeyPair GenerateKeyPair(KeyVersion version) + { + long privateSize = Native.GenerateKeyPairPrivateSizeNative((ushort)version); + if (privateSize < 0) + { + Utils.HandleError(privateSize); + } + + long publicSize = Native.GenerateKeyPairPublicSizeNative((ushort)version); + if (publicSize < 0) + { + Utils.HandleError(publicSize); + } + + byte[] privateKey = new byte[privateSize]; + byte[] publicKey = new byte[publicSize]; + + long res = Native.GenerateKeyPairWithVersionNative(privateKey, (UIntPtr)privateKey.Length, publicKey, (UIntPtr)publicKey.Length, (ushort)version); + + if (res < 0) + { + Utils.HandleError(res); + } + + return new KeyPair() + { + PublicKey = publicKey, + PrivateKey = privateKey, + }; + } + + /// + /// Encapsulates a fresh shared secret to a public key (KEM). The secret can be recovered with . + /// + /// A public key. + /// The encapsulation version. + /// Returns the encapsulated key to send to the recipient and the local shared secret. + public static EncapsulationResult Encapsulate(byte[] publicKey, KeyEncapsulationVersion version = KeyEncapsulationVersion.Latest) + { + if (publicKey == null) + { + throw new DevolutionsCryptoException(ManagedError.InvalidParameter); + } + + long encapsulatedSize = Native.EncapsulateSizeNative((ushort)version); + if (encapsulatedSize < 0) + { + Utils.HandleError(encapsulatedSize); + } + + long secretSize = Native.GenerateSecretKeySizeNative(); + + byte[] encapsulated = new byte[encapsulatedSize]; + byte[] secret = new byte[secretSize]; + + long res = Native.EncapsulateNative(publicKey, (UIntPtr)publicKey.Length, encapsulated, (UIntPtr)encapsulated.Length, secret, (UIntPtr)secret.Length, (ushort)version); + + if (res < 0) + { + Utils.HandleError(res); + } + + return new EncapsulationResult(encapsulated, new SecretKey(secret)); + } + + /// + /// Recovers the shared secret from an encapsulated key. A wrong private key does not raise an error; it yields a different secret. + /// + /// The private key matching the public key used to encapsulate. + /// The encapsulated key received from the sender. + /// Returns the shared . + public static SecretKey Decapsulate(byte[] privateKey, byte[] encapsulatedKey) + { + if (privateKey == null || encapsulatedKey == null) + { + throw new DevolutionsCryptoException(ManagedError.InvalidParameter); + } + + long secretSize = Native.GenerateSecretKeySizeNative(); + byte[] secret = new byte[secretSize]; + + long res = Native.DecapsulateNative(privateKey, (UIntPtr)privateKey.Length, encapsulatedKey, (UIntPtr)encapsulatedKey.Length, secret, (UIntPtr)secret.Length); + + if (res < 0) + { + Utils.HandleError(res); + } + + return new SecretKey(secret); + } + internal static byte[] GenerateSecretKeyBytes() { long size = Native.GenerateSecretKeySizeNative(); diff --git a/wrappers/csharp/src/Native.Core.cs b/wrappers/csharp/src/Native.Core.cs index aaa19b86..abaabb74 100644 --- a/wrappers/csharp/src/Native.Core.cs +++ b/wrappers/csharp/src/Native.Core.cs @@ -103,6 +103,24 @@ public static partial class Native [DllImport(LibName, EntryPoint = "GenerateKeyPairSize", CallingConvention = CallingConvention.Cdecl)] internal static extern long GenerateKeyPairSizeNative(); + [DllImport(LibName, EntryPoint = "GenerateKeyPairWithVersion", CallingConvention = CallingConvention.Cdecl)] + internal static extern long GenerateKeyPairWithVersionNative(byte[] privateKey, UIntPtr privateKeySize, byte[] publicKey, UIntPtr publicKeySize, ushort version); + + [DllImport(LibName, EntryPoint = "GenerateKeyPairPrivateSize", CallingConvention = CallingConvention.Cdecl)] + internal static extern long GenerateKeyPairPrivateSizeNative(ushort version); + + [DllImport(LibName, EntryPoint = "GenerateKeyPairPublicSize", CallingConvention = CallingConvention.Cdecl)] + internal static extern long GenerateKeyPairPublicSizeNative(ushort version); + + [DllImport(LibName, EntryPoint = "Encapsulate", CallingConvention = CallingConvention.Cdecl)] + internal static extern long EncapsulateNative(byte[] publicKey, UIntPtr publicKeyLength, byte[] encapsulated, UIntPtr encapsulatedLength, byte[] secret, UIntPtr secretLength, ushort version); + + [DllImport(LibName, EntryPoint = "EncapsulateSize", CallingConvention = CallingConvention.Cdecl)] + internal static extern long EncapsulateSizeNative(ushort version); + + [DllImport(LibName, EntryPoint = "Decapsulate", CallingConvention = CallingConvention.Cdecl)] + internal static extern long DecapsulateNative(byte[] privateKey, UIntPtr privateKeyLength, byte[] encapsulated, UIntPtr encapsulatedLength, byte[] secret, UIntPtr secretLength); + [DllImport(LibName, EntryPoint = "GenerateSecretKey", CallingConvention = CallingConvention.Cdecl)] internal static extern long GenerateSecretKeyNative(byte[] result, UIntPtr resultLength); diff --git a/wrappers/csharp/tests/unit-tests/PostQuantum.cs b/wrappers/csharp/tests/unit-tests/PostQuantum.cs new file mode 100644 index 00000000..63603dad --- /dev/null +++ b/wrappers/csharp/tests/unit-tests/PostQuantum.cs @@ -0,0 +1,165 @@ +#pragma warning disable SA1600 // Elements should be documented + +namespace Devolutions.Crypto.Tests +{ + using System; + + using Devolutions.Cryptography; + + using Microsoft.VisualStudio.TestTools.UnitTesting; + + [TestClass] + public class PostQuantum + { + // Fixed X-Wing (KeyVersion.V2) private key shared with the other conformity suites. + private const string XWingPrivateKey = "DQwBAAEAAgA1nERi4Of6p1Sg60NSw98tDx4OuBZtVR+CpVe+n1VDLg=="; + + [TestMethod] + public void GenerateKeyPairV2Sizes() + { + KeyPair keyPair = Managed.GenerateKeyPair(KeyVersion.V2); + Assert.AreEqual(40, keyPair.PrivateKey!.Length); + Assert.AreEqual(1224, keyPair.PublicKey!.Length); + } + + [TestMethod] + public void GenerateKeyPairV1Sizes() + { + KeyPair keyPair = Managed.GenerateKeyPair(KeyVersion.V1); + Assert.AreEqual(40, keyPair.PrivateKey!.Length); + Assert.AreEqual(40, keyPair.PublicKey!.Length); + } + + [TestMethod] + public void EncryptDecryptAsymmetricV3() + { + byte[] data = Utils.StringToUtf8ByteArray("This is some test data")!; + KeyPair keyPair = Managed.GenerateKeyPair(KeyVersion.V2); + + byte[] encrypted = Managed.EncryptAsymmetric(data, keyPair.PublicKey!, version: CipherTextVersion.V3)!; + byte[] decrypted = Managed.DecryptAsymmetric(encrypted, keyPair.PrivateKey!)!; + + Assert.AreEqual(8 + 1120 + 24 + data.Length + 16, encrypted.Length); + CollectionAssert.AreEqual(data, decrypted); + } + + [TestMethod] + public void EncryptDecryptAsymmetricV3Aad() + { + byte[] data = Utils.StringToUtf8ByteArray("This is some test data")!; + byte[] aad = Utils.StringToUtf8ByteArray("This is some public data")!; + KeyPair keyPair = Managed.GenerateKeyPair(KeyVersion.V2); + + byte[] encrypted = Managed.EncryptAsymmetric(data, keyPair.PublicKey!, aad, CipherTextVersion.V3)!; + + CollectionAssert.AreEqual(data, Managed.DecryptAsymmetric(encrypted, keyPair.PrivateKey!, aad)!); + Assert.ThrowsException(() => Managed.DecryptAsymmetric(encrypted, keyPair.PrivateKey!)); + } + + [TestMethod] + public void EncryptAsymmetricV3RejectsV1Key() + { + byte[] data = Utils.StringToUtf8ByteArray("This is some test data")!; + KeyPair v1 = Managed.GenerateKeyPair(KeyVersion.V1); + + Assert.ThrowsException(() => Managed.EncryptAsymmetric(data, v1.PublicKey!, version: CipherTextVersion.V3)); + } + + [TestMethod] + public void DecryptAsymmetricV3WrongKey() + { + byte[] data = Utils.StringToUtf8ByteArray("This is some test data")!; + KeyPair keyPair = Managed.GenerateKeyPair(KeyVersion.V2); + KeyPair other = Managed.GenerateKeyPair(KeyVersion.V2); + byte[] encrypted = Managed.EncryptAsymmetric(data, keyPair.PublicKey!, version: CipherTextVersion.V3)!; + + Assert.ThrowsException(() => Managed.DecryptAsymmetric(encrypted, other.PrivateKey!)); + } + + [TestMethod] + public void MixKeyExchangeRejectsV2() + { + KeyPair v1 = Managed.GenerateKeyPair(KeyVersion.V1); + KeyPair v2 = Managed.GenerateKeyPair(KeyVersion.V2); + + Assert.ThrowsException(() => Managed.MixKeyExchange(v2.PrivateKey, v1.PublicKey)); + } + + [TestMethod] + public void EncapsulateDecapsulate() + { + KeyPair keyPair = Managed.GenerateKeyPair(KeyVersion.V2); + + EncapsulationResult result = Managed.Encapsulate(keyPair.PublicKey!); + SecretKey recovered = Managed.Decapsulate(keyPair.PrivateKey!, result.EncapsulatedKey); + + Assert.AreEqual(1128, result.EncapsulatedKey.Length); + CollectionAssert.AreEqual(result.SecretKey.ToByteArray(), recovered.ToByteArray()); + } + + [TestMethod] + public void DecapsulateWrongKeyGivesDifferentSecret() + { + KeyPair keyPair = Managed.GenerateKeyPair(KeyVersion.V2); + KeyPair other = Managed.GenerateKeyPair(KeyVersion.V2); + + EncapsulationResult result = Managed.Encapsulate(keyPair.PublicKey!, KeyEncapsulationVersion.V1); + SecretKey wrong = Managed.Decapsulate(other.PrivateKey!, result.EncapsulatedKey); + + CollectionAssert.AreNotEqual(result.SecretKey.ToByteArray(), wrong.ToByteArray()); + } + + [TestMethod] + public void EncapsulateRejectsV1Key() + { + KeyPair v1 = Managed.GenerateKeyPair(KeyVersion.V1); + + Assert.ThrowsException(() => Managed.Encapsulate(v1.PublicKey!)); + } + + [TestMethod] + public void SecretKeyUsableForSymmetricEncryption() + { + KeyPair keyPair = Managed.GenerateKeyPair(KeyVersion.V2); + EncapsulationResult result = Managed.Encapsulate(keyPair.PublicKey!); + SecretKey recovered = Managed.Decapsulate(keyPair.PrivateKey!, result.EncapsulatedKey); + byte[] data = Utils.StringToUtf8ByteArray("symmetric")!; + + byte[] encrypted = Managed.Encrypt(data, result.SecretKey)!; + + CollectionAssert.AreEqual(data, Managed.Decrypt(encrypted, recovered)!); + } + + [TestMethod] + public void DecryptAsymmetricV3Conformity() + { + byte[] privateKey = Utils.Base64StringToByteArray(XWingPrivateKey)!; + + byte[] plain = Managed.DecryptAsymmetric( + Utils.Base64StringToByteArray("DQwCAAIAAwCm2YjCoBofihIBaK5pfKKOfY0Ijhhvv+X8fw9bOWk/6TdrsxZBU9bucUBY37Ot1IuMGwBUU+eV5MQz/rTG5NoBai2licmRGBL/EJePhCPg3rgDtOajBszyyOkVVEHt4C+uznm8pJCaiw+zcG3E2pwLBRakUuUhqzHxolzVxRdZ8BaQUhNZSExDB7us3BoEhyiGpkXqKb8JWWrexzWRU/1p/ie6oP+lPG/uHK9ifAZ7k/pOty0lnIaH7R6XQ8tffM6kvyA2Cp7QHqzmGWUct8lKxy4BI7wRzNjpmIbhyHRwgZU5svTiHcccvFIYEqcZoU9gY9zBps9bkJIVK4v9AIlPtY1Ay55kE3Ze5gtHxBEvo1UaXT1+EOmOolqCqIv9Q8sbJIgFhqfkpoX3zy395FfI3C5mgqzTtW+xBOdpzxtnE3u3+M9ModNg4j6cMu6F242TFFJABdKk1CAV+jZ4WeBqt8c3VCnJGvJWGUpM8E6x3UL/C5tsDR7Ifzf39zoYGjEuo6pL9RFfA/VV1iGDFBdRmeVmoGnX0RME5epUuSET/ooymu4Vqn5INi+RTy3nIJwcyeSHEBlMYC0xVWi/veTKkoYfI6qxSYI8oxJXM8uSuZnB7TeKLshMOmt3jFM6bSyarAPlgMkbGDH+zXWe478Po4UcveequOsNABECYNET7fq6J/3ovfZ+6lMBIi2Hl2IMA5Ii/WXAv4N9ihUppFgYdPXooAOaYFoan8ntZKT+88L+s6hzUce9AhTjl+Ts2nWmdowo3rc/sGbH7LqllvGEBVohanBT8Tjy+4Q+zeN6xVpQIW3qcbeI2EE91uRrkVJwTKarB2TEYuTyys2Q3PLUX7/WUj99XBqZfOUEB9Na1F6JgPIL1+PL4Rfe5vSZySsdQi6qk+0bpd9tcauiUQokhzKiBZeLIi9VRimFpU0Qng3NmbopOKPTIBekowLhsyZgrFCqEANdOc8Jhy/OrssharUIu0mNZXgoFfJHn6kmsXgJejQikBRwUq/eUXqoz+k/ddcidKkKlJ1Mr5i7nzMPgODX8/WK229/qsIXq/cGZM9844fgeEcSkQQN9CPwbkw2k/dgWf28xfKGzMAzWpSnUwkC7toubDtYWrMoQWkpTN/nhMbAtTDBn8xrdMu5gkpERf6EeN1QURgje6dx10KpniC6/Vy7Zgg7lmn6h9o3PVpWv4oSlr3YObnUM8UgDsiRommWcBdJ8axl4/yvfeEa882DRJUAWU6nZKyunm1TgSrtFevb/72kJidpNQwmfXmy3gDC/rSXcsmnFSMGdc7a40dNFsmDZVui5386GsQk/Uta6h1zsZmqoW7AuaSDqhNo44Gbr5b57zjSwP9okkPvUVi2avFVIZsZFBl7FlWLVwb0d6HEzWVX93ZRxFosUBo5aS+btaT4YF9z++4xjST83whVU3o4TOQTq9mgAV/91JRB5+fT6P2wkOuuUuPe6gemH50LV0cYbxAHECt4OggFaAjHodoPqW0VRkU74M3xzZbKusiQv0YHDWsvqRzUuJ46XVWl+ZW2jVulTgmaSg/s"), + privateKey)!; + Assert.AreEqual("testdata", Utils.ByteArrayToUtf8String(plain)); + + byte[] plainAad = Managed.DecryptAsymmetric( + Utils.Base64StringToByteArray("DQwCAAIAAwCY+aboyvWcG4QDu2lQfA8io0hemIghqdHjzt2b/nUpa3p9hMmnquY2zaMaugi3Lq1DyQuiau7BgDbv2Ew0fvumqvNi6EtmOOTgTp4cyCFQxBxq1dw4k1o1kdGm4I7W1h+NMnJfQ5tPjE2xRDKgDlkoOqdWv9AJmY2jFBQ65x4tVOl+a2VyicbFUNz6wpjN1vytLaWe3KZhckYt/yx6bZOJ13U7weaGezcGJwbcPTKUALSGqe6apJbOzVYxByq33j5652Cc8uL8USEJJ0pgvAYsukvgAtqycnz9wOi7dtmmmBa/2Ubzyo5U/5O6LBqHjv1irvQ3YIqn4XRXaXJ4Odik70xlG0LsiTBymr0SgGMQq0yIzoxfDOCyM6O5rBDpqlFKs+KdyN/luU+FzvJuSfR+WechpzJKW7pT5iKB/UQefXb5Dr8IMmRH7ca90Miq30yk+2lYnhI7i/K9kDoh/rM6j1xfk/j4UMWWVLnbAK3ZRbioCYF4H47ya1yQqYdrj3ZpiNLcLy2Oj8suAeVDyJFqIwDHFlsCfrgSnr1OJ5kWRkb5y/BW0S/33MTJpewAc7RztE/Dv5UfpY15GjVlY5LRj8L3oXmfF8qOvKAnzoo4pxqZMnKXCZkbyAf8FRYEF2e/J29KrzWBBVTAQMhMNqaHMw55Q2U8Cv5/eioPw1YmHYDU9E7+xmTM0KLK1/fcRQCnmFY8nVs3FRL1DdkIowjSWV5OwiWrHKYcMth5Kc556XI6VN46zOKYCEdjRwNuz9o3bCTCTeHmVFYspcwey3dRkzlKKJpmg69kQlM+5V/Ts+PY4yQ0eY0yx/vcZAmdp3H3t6LddHiOSd811z9SlsGK0YvbDgx8iRjEPFnwRlm6aly1fBYXWlk3JxvzOJCYwTqo2Q3xi4wMRA6FVwDRmDlazMSZ/qu+l5QbkXqfXAK4dCZbxksr9IXUINDcakLK3laD+iTBw+PSRfb0nyKkOoq+KbBZHRI94cGEm1Pn0Q88gPME2X02lfiMNe3fPwGPMlf6D0Ovykl0DI+IvahRqBE5h4LhneHD9j6PKuJzSy83EKsS+uLQ7nuYGoNpZFu/8wNEdkylVpCvY4SRVmAcmzBcbvv8V1NZek6sYY8BtKY7cCeyIr9wq9MMw3ZypMufpE1n5wIaQ+Y9Smgc9Vb8eV5Gsjsaam6mhzwJ38HqKmFBWVf886MhE4OuuBUEI76EeSXf3TAG+MIFqoKwfqJsLmtznDNBGDbmqC9g1J6jTplMH0zPl95LNL0SMS6+ObnVPHxJ/QBExSMbIvW7yTQ03dX2WawBbdNTee41Ev6cGQsXWnMjnOfAlIfwj1z6ZB2umYeAsRtju+0idLmqARnLnVDp2oIn4nAckkT8qoOMOVw81xvL5UYoepJREOr/k3X3tfLtFeaY4VtBrpGvUIo37hWYiDHP/vqdR7yzrkx3O3b/UVsV3JLGTIjf/brGErTsNmly+IAkR4MzwbQfezCxgCcpalHS/3GWUTWf3ureWok3q7aVj8slI7zmfFMpgdLFvVekdKUCVsF/1BMhB6gwfWuV"), + privateKey, + Utils.StringToUtf8ByteArray("this is some public data"))!; + Assert.AreEqual("testdata", Utils.ByteArrayToUtf8String(plainAad)); + } + + [TestMethod] + public void DecapsulateConformity() + { + byte[] privateKey = Utils.Base64StringToByteArray(XWingPrivateKey)!; + + SecretKey secret = Managed.Decapsulate( + privateKey, + Utils.Base64StringToByteArray("DQwKAAAAAQAdAidiTcgaZ0lFtIEr7TWNrzDq7XkFBWLSDqU+tWi68zmUXyMMxwXPSwYuX0QLAhArzNyMJXqo88zrYR+AfAeeeBVpRLq4naSDQ12j0d3gsNFbhBRzCbaa/WL92DwNQSZoA3Z1bSToRkxepJW7kcqTHs6DfFn9WAWBpU8Qul3iHWfiFqY0/xNdzMfb0H61qaLlB9C762rXwqKw5jGF1XOih6q2jwZjKOp7bZK+JNXMTb9X5BqJl/spn3FKLIhOLp85M26hDdJM1THqNCkm8/xYWVA6alkdfL9ZGE1IxJv9i8B5rW0qK7sG54a+ZuGnSaf3TZiSSR1D519WHttp+rlI/yNNWXxsOcmZmOEKw4DX2XQS5IQ9S1wflnaBwRVyBIfMUILnKSWZ0EwlgCs6XKnFy/5NqRNyZbDSAcJ+ROA/8H7yMrYQV0ilwl9mXAx9Ip9AHYsrrjC2IM3iWUBR6gqM7bwmBVb/eN51WwSXK8Vcy4FPeeii6VvFND/rEiiERhmRBRFhDNYmf5tARfBgccPw62GO56IjkBuyZhoUZ6DB8Uy8nVxcoe0hpN6fiCW10S8cBWdKtyJObkl9oqRbxmB21bKSJS/yz3owxqivTSaaLAT5HQy6FnPFYoOoG57Vp/WEIU00C5GwuAs6MUbuBBiZtjeJMRkV/HJidgH1WPIx0Xs7EKf/N4kkVok2D9ZdL9jSbLV69QR5SeW8M2U9B+1oqZghXpI4en20WEvGxEJ1ALCd2VlQqK76vLZpXX0DSlhMv14VzR7fUiZiurpH8S/tbieCMijdeWt9a5lnWTzWE0IVgALHLH2T82R6RJSIbBE7EwWziYO3Vm0Tus3iY+GxzSRqaBHc0oCsbFDFG3BkWF7gWOeTElnz7taRRqKnolN4VWfPRQ6Glmq8ml/83HTQFJfW7yI5jKp+I9vSCZ/tAcOTaneUDZ6cveDfPIWnLsSld2t/1MtXlzbRHGqGfe3nyU94TU7WDeFGcgL+EykMu58RVxVT5yefqy6kTlnkUWqr7j2EwrayzT0D5d4dvzJp38SXDsHL2/W2bsmv7WipnjFAp8xh0gNd5AGysTf+xJ8+Qv6D/aQF8JgEvVn+E7i5JcAWFIHelVupk6JJPX7KNalqN3LqBwuFm2gb7rTkdGWGoFw23+1NeyfuLPWSPq79NuzKw/5PqhQWlwDxOC88aF8lYLB3QrT8AcF/2ciwaT2j33YpRdhkFVbPrwNUPFsVV+LA4NplDywB81mABsfS3JSHhiXPLAKZhHV56qmM2gFG5dicrHOm6k7s3mDAfH/LUNrHgUs7ihYr3BzCocIfq//1RDf9NmJWj9rKbj3NpeAGnkMD68W82xv5SAYpkC6E5mZMJD1O6CBmgnPkLc6/YNxWfE/O37o18gTvjpYZourUxsX1BwAnoLjz+nptQJqJHzJeeA/GqaaoP+8F+LyEAxKFBIFD/XYK4xJ6N3v4OtforFb9QtvwCoMKnZvbeMEd")!); + + // The serialized secret key is an 8-byte header followed by the 32 key bytes. + byte[] raw = new byte[32]; + Array.Copy(secret.ToByteArray(), 8, raw, 0, 32); + Assert.AreEqual("hqnsrwF5qEgJsI0G4aURCcbbsshKl7Z590eicp0ArtA=", Convert.ToBase64String(raw)); + } + } +} diff --git a/wrappers/csharp/tests/unit-tests/nugets/android/android.csproj b/wrappers/csharp/tests/unit-tests/nugets/android/android.csproj index c80183a2..eac21e9c 100644 --- a/wrappers/csharp/tests/unit-tests/nugets/android/android.csproj +++ b/wrappers/csharp/tests/unit-tests/nugets/android/android.csproj @@ -24,6 +24,7 @@ + diff --git a/wrappers/csharp/tests/unit-tests/nugets/dotnet-core/dotnet-core.csproj b/wrappers/csharp/tests/unit-tests/nugets/dotnet-core/dotnet-core.csproj index dec7e990..15bcb932 100644 --- a/wrappers/csharp/tests/unit-tests/nugets/dotnet-core/dotnet-core.csproj +++ b/wrappers/csharp/tests/unit-tests/nugets/dotnet-core/dotnet-core.csproj @@ -21,6 +21,7 @@ + diff --git a/wrappers/csharp/tests/unit-tests/nugets/iOS/iOS.csproj b/wrappers/csharp/tests/unit-tests/nugets/iOS/iOS.csproj index 08b4ba1c..8bb3b393 100644 --- a/wrappers/csharp/tests/unit-tests/nugets/iOS/iOS.csproj +++ b/wrappers/csharp/tests/unit-tests/nugets/iOS/iOS.csproj @@ -21,6 +21,7 @@ + diff --git a/wrappers/kotlin/lib/src/test/kotlin/org/devolutions/crypto/PostQuantumTest.kt b/wrappers/kotlin/lib/src/test/kotlin/org/devolutions/crypto/PostQuantumTest.kt new file mode 100644 index 00000000..a4f17ba9 --- /dev/null +++ b/wrappers/kotlin/lib/src/test/kotlin/org/devolutions/crypto/PostQuantumTest.kt @@ -0,0 +1,136 @@ +package org.devolutions.crypto + +import java.util.Base64 +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class PostQuantumTest { + // Fixed X-Wing (KeyVersion.V2) private key shared with the other conformity suites. + private val xWingPrivateKey = "DQwBAAEAAgA1nERi4Of6p1Sg60NSw98tDx4OuBZtVR+CpVe+n1VDLg==" + + private fun b64(value: String): ByteArray = Base64.getDecoder().decode(value) + + @Test + fun generateKeypairV2SizesTest() { + val keypair = generateKeypair(KeyVersion.V2) + + assertEquals(40, keypair.privateKey.size) + assertEquals(1224, keypair.publicKey.size) + } + + @Test + fun encryptDecryptAsymmetricV3Test() { + val data = "This is some test data".toByteArray(Charsets.UTF_8) + val keypair = generateKeypair(KeyVersion.V2) + + val encrypted = encryptAsymmetric(data, keypair.publicKey, CiphertextVersion.V3) + val decrypted = decryptAsymmetric(encrypted, keypair.privateKey) + + assertEquals(8 + 1120 + 24 + data.size + 16, encrypted.size) + assertContentEquals(data, decrypted) + } + + @Test + fun encryptDecryptAsymmetricV3WithAadTest() { + val data = "This is some test data".toByteArray(Charsets.UTF_8) + val aad = "This is some public data".toByteArray(Charsets.UTF_8) + val wrongAad = "this is some public data".toByteArray(Charsets.UTF_8) + val keypair = generateKeypair(KeyVersion.V2) + + val encrypted = encryptAsymmetricWithAad(data, keypair.publicKey, aad, CiphertextVersion.V3) + + assertContentEquals(data, decryptAsymmetricWithAad(encrypted, keypair.privateKey, aad)) + assertFailsWith { + decryptAsymmetricWithAad(encrypted, keypair.privateKey, wrongAad) + } + } + + @Test + fun encryptAsymmetricV3RejectsV1KeyTest() { + val data = "This is some test data".toByteArray(Charsets.UTF_8) + val keypair = generateKeypair(KeyVersion.V1) + + assertFailsWith { + encryptAsymmetric(data, keypair.publicKey, CiphertextVersion.V3) + } + } + + @Test + fun decryptAsymmetricV3WrongKeyTest() { + val data = "This is some test data".toByteArray(Charsets.UTF_8) + val keypair = generateKeypair(KeyVersion.V2) + val other = generateKeypair(KeyVersion.V2) + + val encrypted = encryptAsymmetric(data, keypair.publicKey, CiphertextVersion.V3) + + assertFailsWith { + decryptAsymmetric(encrypted, other.privateKey) + } + } + + @Test + fun mixKeyExchangeRejectsV2Test() { + val v1 = generateKeypair(KeyVersion.V1) + val v2 = generateKeypair(KeyVersion.V2) + + assertFailsWith { + mixKeyExchange(v2.privateKey, v1.publicKey) + } + } + + @Test + fun encapsulateDecapsulateTest() { + val keypair = generateKeypair(KeyVersion.V2) + + val result = encapsulate(keypair.publicKey) + val recovered = decapsulate(keypair.privateKey, result.encapsulatedKey) + + assertEquals(1128, result.encapsulatedKey.size) + assertContentEquals(result.secretKey, recovered) + } + + @Test + fun decapsulateWrongKeyTest() { + val keypair = generateKeypair(KeyVersion.V2) + val other = generateKeypair(KeyVersion.V2) + + val result = encapsulate(keypair.publicKey, KeyEncapsulationVersion.V1) + val wrong = decapsulate(other.privateKey, result.encapsulatedKey) + + assert(!result.secretKey.contentEquals(wrong)) + } + + @Test + fun encapsulateRejectsV1KeyTest() { + val keypair = generateKeypair(KeyVersion.V1) + + assertFailsWith { + encapsulate(keypair.publicKey) + } + } + + @Test + fun decryptAsymmetricV3ConformityTest() { + val privateKey = b64(xWingPrivateKey) + val aad = "this is some public data".toByteArray(Charsets.UTF_8) + + assertEquals( + "testdata", + String(decryptAsymmetric(b64("DQwCAAIAAwCm2YjCoBofihIBaK5pfKKOfY0Ijhhvv+X8fw9bOWk/6TdrsxZBU9bucUBY37Ot1IuMGwBUU+eV5MQz/rTG5NoBai2licmRGBL/EJePhCPg3rgDtOajBszyyOkVVEHt4C+uznm8pJCaiw+zcG3E2pwLBRakUuUhqzHxolzVxRdZ8BaQUhNZSExDB7us3BoEhyiGpkXqKb8JWWrexzWRU/1p/ie6oP+lPG/uHK9ifAZ7k/pOty0lnIaH7R6XQ8tffM6kvyA2Cp7QHqzmGWUct8lKxy4BI7wRzNjpmIbhyHRwgZU5svTiHcccvFIYEqcZoU9gY9zBps9bkJIVK4v9AIlPtY1Ay55kE3Ze5gtHxBEvo1UaXT1+EOmOolqCqIv9Q8sbJIgFhqfkpoX3zy395FfI3C5mgqzTtW+xBOdpzxtnE3u3+M9ModNg4j6cMu6F242TFFJABdKk1CAV+jZ4WeBqt8c3VCnJGvJWGUpM8E6x3UL/C5tsDR7Ifzf39zoYGjEuo6pL9RFfA/VV1iGDFBdRmeVmoGnX0RME5epUuSET/ooymu4Vqn5INi+RTy3nIJwcyeSHEBlMYC0xVWi/veTKkoYfI6qxSYI8oxJXM8uSuZnB7TeKLshMOmt3jFM6bSyarAPlgMkbGDH+zXWe478Po4UcveequOsNABECYNET7fq6J/3ovfZ+6lMBIi2Hl2IMA5Ii/WXAv4N9ihUppFgYdPXooAOaYFoan8ntZKT+88L+s6hzUce9AhTjl+Ts2nWmdowo3rc/sGbH7LqllvGEBVohanBT8Tjy+4Q+zeN6xVpQIW3qcbeI2EE91uRrkVJwTKarB2TEYuTyys2Q3PLUX7/WUj99XBqZfOUEB9Na1F6JgPIL1+PL4Rfe5vSZySsdQi6qk+0bpd9tcauiUQokhzKiBZeLIi9VRimFpU0Qng3NmbopOKPTIBekowLhsyZgrFCqEANdOc8Jhy/OrssharUIu0mNZXgoFfJHn6kmsXgJejQikBRwUq/eUXqoz+k/ddcidKkKlJ1Mr5i7nzMPgODX8/WK229/qsIXq/cGZM9844fgeEcSkQQN9CPwbkw2k/dgWf28xfKGzMAzWpSnUwkC7toubDtYWrMoQWkpTN/nhMbAtTDBn8xrdMu5gkpERf6EeN1QURgje6dx10KpniC6/Vy7Zgg7lmn6h9o3PVpWv4oSlr3YObnUM8UgDsiRommWcBdJ8axl4/yvfeEa882DRJUAWU6nZKyunm1TgSrtFevb/72kJidpNQwmfXmy3gDC/rSXcsmnFSMGdc7a40dNFsmDZVui5386GsQk/Uta6h1zsZmqoW7AuaSDqhNo44Gbr5b57zjSwP9okkPvUVi2avFVIZsZFBl7FlWLVwb0d6HEzWVX93ZRxFosUBo5aS+btaT4YF9z++4xjST83whVU3o4TOQTq9mgAV/91JRB5+fT6P2wkOuuUuPe6gemH50LV0cYbxAHECt4OggFaAjHodoPqW0VRkU74M3xzZbKusiQv0YHDWsvqRzUuJ46XVWl+ZW2jVulTgmaSg/s"), privateKey), Charsets.UTF_8) + ) + assertEquals( + "testdata", + String(decryptAsymmetricWithAad(b64("DQwCAAIAAwCY+aboyvWcG4QDu2lQfA8io0hemIghqdHjzt2b/nUpa3p9hMmnquY2zaMaugi3Lq1DyQuiau7BgDbv2Ew0fvumqvNi6EtmOOTgTp4cyCFQxBxq1dw4k1o1kdGm4I7W1h+NMnJfQ5tPjE2xRDKgDlkoOqdWv9AJmY2jFBQ65x4tVOl+a2VyicbFUNz6wpjN1vytLaWe3KZhckYt/yx6bZOJ13U7weaGezcGJwbcPTKUALSGqe6apJbOzVYxByq33j5652Cc8uL8USEJJ0pgvAYsukvgAtqycnz9wOi7dtmmmBa/2Ubzyo5U/5O6LBqHjv1irvQ3YIqn4XRXaXJ4Odik70xlG0LsiTBymr0SgGMQq0yIzoxfDOCyM6O5rBDpqlFKs+KdyN/luU+FzvJuSfR+WechpzJKW7pT5iKB/UQefXb5Dr8IMmRH7ca90Miq30yk+2lYnhI7i/K9kDoh/rM6j1xfk/j4UMWWVLnbAK3ZRbioCYF4H47ya1yQqYdrj3ZpiNLcLy2Oj8suAeVDyJFqIwDHFlsCfrgSnr1OJ5kWRkb5y/BW0S/33MTJpewAc7RztE/Dv5UfpY15GjVlY5LRj8L3oXmfF8qOvKAnzoo4pxqZMnKXCZkbyAf8FRYEF2e/J29KrzWBBVTAQMhMNqaHMw55Q2U8Cv5/eioPw1YmHYDU9E7+xmTM0KLK1/fcRQCnmFY8nVs3FRL1DdkIowjSWV5OwiWrHKYcMth5Kc556XI6VN46zOKYCEdjRwNuz9o3bCTCTeHmVFYspcwey3dRkzlKKJpmg69kQlM+5V/Ts+PY4yQ0eY0yx/vcZAmdp3H3t6LddHiOSd811z9SlsGK0YvbDgx8iRjEPFnwRlm6aly1fBYXWlk3JxvzOJCYwTqo2Q3xi4wMRA6FVwDRmDlazMSZ/qu+l5QbkXqfXAK4dCZbxksr9IXUINDcakLK3laD+iTBw+PSRfb0nyKkOoq+KbBZHRI94cGEm1Pn0Q88gPME2X02lfiMNe3fPwGPMlf6D0Ovykl0DI+IvahRqBE5h4LhneHD9j6PKuJzSy83EKsS+uLQ7nuYGoNpZFu/8wNEdkylVpCvY4SRVmAcmzBcbvv8V1NZek6sYY8BtKY7cCeyIr9wq9MMw3ZypMufpE1n5wIaQ+Y9Smgc9Vb8eV5Gsjsaam6mhzwJ38HqKmFBWVf886MhE4OuuBUEI76EeSXf3TAG+MIFqoKwfqJsLmtznDNBGDbmqC9g1J6jTplMH0zPl95LNL0SMS6+ObnVPHxJ/QBExSMbIvW7yTQ03dX2WawBbdNTee41Ev6cGQsXWnMjnOfAlIfwj1z6ZB2umYeAsRtju+0idLmqARnLnVDp2oIn4nAckkT8qoOMOVw81xvL5UYoepJREOr/k3X3tfLtFeaY4VtBrpGvUIo37hWYiDHP/vqdR7yzrkx3O3b/UVsV3JLGTIjf/brGErTsNmly+IAkR4MzwbQfezCxgCcpalHS/3GWUTWf3ureWok3q7aVj8slI7zmfFMpgdLFvVekdKUCVsF/1BMhB6gwfWuV"), privateKey, aad), Charsets.UTF_8) + ) + } + + @Test + fun decapsulateConformityTest() { + val secret = decapsulate(b64(xWingPrivateKey), b64("DQwKAAAAAQAdAidiTcgaZ0lFtIEr7TWNrzDq7XkFBWLSDqU+tWi68zmUXyMMxwXPSwYuX0QLAhArzNyMJXqo88zrYR+AfAeeeBVpRLq4naSDQ12j0d3gsNFbhBRzCbaa/WL92DwNQSZoA3Z1bSToRkxepJW7kcqTHs6DfFn9WAWBpU8Qul3iHWfiFqY0/xNdzMfb0H61qaLlB9C762rXwqKw5jGF1XOih6q2jwZjKOp7bZK+JNXMTb9X5BqJl/spn3FKLIhOLp85M26hDdJM1THqNCkm8/xYWVA6alkdfL9ZGE1IxJv9i8B5rW0qK7sG54a+ZuGnSaf3TZiSSR1D519WHttp+rlI/yNNWXxsOcmZmOEKw4DX2XQS5IQ9S1wflnaBwRVyBIfMUILnKSWZ0EwlgCs6XKnFy/5NqRNyZbDSAcJ+ROA/8H7yMrYQV0ilwl9mXAx9Ip9AHYsrrjC2IM3iWUBR6gqM7bwmBVb/eN51WwSXK8Vcy4FPeeii6VvFND/rEiiERhmRBRFhDNYmf5tARfBgccPw62GO56IjkBuyZhoUZ6DB8Uy8nVxcoe0hpN6fiCW10S8cBWdKtyJObkl9oqRbxmB21bKSJS/yz3owxqivTSaaLAT5HQy6FnPFYoOoG57Vp/WEIU00C5GwuAs6MUbuBBiZtjeJMRkV/HJidgH1WPIx0Xs7EKf/N4kkVok2D9ZdL9jSbLV69QR5SeW8M2U9B+1oqZghXpI4en20WEvGxEJ1ALCd2VlQqK76vLZpXX0DSlhMv14VzR7fUiZiurpH8S/tbieCMijdeWt9a5lnWTzWE0IVgALHLH2T82R6RJSIbBE7EwWziYO3Vm0Tus3iY+GxzSRqaBHc0oCsbFDFG3BkWF7gWOeTElnz7taRRqKnolN4VWfPRQ6Glmq8ml/83HTQFJfW7yI5jKp+I9vSCZ/tAcOTaneUDZ6cveDfPIWnLsSld2t/1MtXlzbRHGqGfe3nyU94TU7WDeFGcgL+EykMu58RVxVT5yefqy6kTlnkUWqr7j2EwrayzT0D5d4dvzJp38SXDsHL2/W2bsmv7WipnjFAp8xh0gNd5AGysTf+xJ8+Qv6D/aQF8JgEvVn+E7i5JcAWFIHelVupk6JJPX7KNalqN3LqBwuFm2gb7rTkdGWGoFw23+1NeyfuLPWSPq79NuzKw/5PqhQWlwDxOC88aF8lYLB3QrT8AcF/2ciwaT2j33YpRdhkFVbPrwNUPFsVV+LA4NplDywB81mABsfS3JSHhiXPLAKZhHV56qmM2gFG5dicrHOm6k7s3mDAfH/LUNrHgUs7ihYr3BzCocIfq//1RDf9NmJWj9rKbj3NpeAGnkMD68W82xv5SAYpkC6E5mZMJD1O6CBmgnPkLc6/YNxWfE/O37o18gTvjpYZourUxsX1BwAnoLjz+nptQJqJHzJeeA/GqaaoP+8F+LyEAxKFBIFD/XYK4xJ6N3v4OtforFb9QtvwCoMKnZvbeMEd")) + + // The serialized secret key is an 8-byte header followed by the 32 key bytes. + assertContentEquals(b64("hqnsrwF5qEgJsI0G4aURCcbbsshKl7Z590eicp0ArtA="), secret.copyOfRange(8, secret.size)) + } +} diff --git a/wrappers/python/tests/post_quantum.py b/wrappers/python/tests/post_quantum.py new file mode 100644 index 00000000..89432511 --- /dev/null +++ b/wrappers/python/tests/post_quantum.py @@ -0,0 +1,117 @@ +import unittest +import devolutions_crypto +from devolutions_crypto import CiphertextVersion, KeyEncapsulationVersion, KeyVersion +from base64 import b64decode + +# Fixed X-Wing (KeyVersion.V2) private key shared with the other conformity suites. +X_WING_PRIVATE_KEY = b64decode(b'DQwBAAEAAgA1nERi4Of6p1Sg60NSw98tDx4OuBZtVR+CpVe+n1VDLg==') + + +class TestPostQuantum(unittest.TestCase): + def test_keypair_sizes(self): + keypair = devolutions_crypto.generate_keypair(KeyVersion.V2) + + self.assertEqual(len(keypair.private_key), 40) + self.assertEqual(len(keypair.public_key), 1224) + + def test_asymmetric_v3(self): + keypair = devolutions_crypto.generate_keypair(KeyVersion.V2) + plaintext = b'Test plaintext' + + ciphertext = devolutions_crypto.encrypt_asymmetric(plaintext, keypair.public_key, CiphertextVersion.V3) + + self.assertEqual(len(ciphertext), 8 + 1120 + 24 + len(plaintext) + 16) + self.assertEqual(devolutions_crypto.decrypt_asymmetric(ciphertext, keypair.private_key), plaintext) + + def test_asymmetric_v3_with_aad(self): + keypair = devolutions_crypto.generate_keypair(KeyVersion.V2) + plaintext = b'Test plaintext' + aad = b'Test AAD' + + ciphertext = devolutions_crypto.encrypt_asymmetric_with_aad(plaintext, keypair.public_key, aad, CiphertextVersion.V3) + + self.assertEqual(devolutions_crypto.decrypt_asymmetric_with_aad(ciphertext, keypair.private_key, aad), plaintext) + + with self.assertRaises(devolutions_crypto.DevolutionsCryptoError): + devolutions_crypto.decrypt_asymmetric_with_aad(ciphertext, keypair.private_key, b'Wrong AAD') + + def test_asymmetric_v3_rejects_wrong_keys(self): + v1_keypair = devolutions_crypto.generate_keypair(KeyVersion.V1) + v2_keypair = devolutions_crypto.generate_keypair(KeyVersion.V2) + other_v2_keypair = devolutions_crypto.generate_keypair(KeyVersion.V2) + + with self.assertRaises(devolutions_crypto.DevolutionsCryptoError): + devolutions_crypto.encrypt_asymmetric(b'data', v1_keypair.public_key, CiphertextVersion.V3) + + ciphertext = devolutions_crypto.encrypt_asymmetric(b'data', v2_keypair.public_key, CiphertextVersion.V3) + + with self.assertRaises(devolutions_crypto.DevolutionsCryptoError): + devolutions_crypto.decrypt_asymmetric(ciphertext, v1_keypair.private_key) + + with self.assertRaises(devolutions_crypto.DevolutionsCryptoError): + devolutions_crypto.decrypt_asymmetric(ciphertext, other_v2_keypair.private_key) + + def test_mix_key_exchange_rejects_v2_keys(self): + v1_keypair = devolutions_crypto.generate_keypair(KeyVersion.V1) + v2_keypair = devolutions_crypto.generate_keypair(KeyVersion.V2) + + with self.assertRaises(devolutions_crypto.DevolutionsCryptoError): + devolutions_crypto.mix_key_exchange(v2_keypair.private_key, v1_keypair.public_key) + + def test_encapsulate_decapsulate(self): + keypair = devolutions_crypto.generate_keypair(KeyVersion.V2) + + result = devolutions_crypto.encapsulate(keypair.public_key) + + self.assertEqual(len(result.encapsulated_key), 1128) + self.assertEqual(len(result.secret_key), 40) + self.assertEqual(devolutions_crypto.decapsulate(keypair.private_key, result.encapsulated_key), result.secret_key) + + explicit = devolutions_crypto.encapsulate(keypair.public_key, KeyEncapsulationVersion.V1) + self.assertNotEqual(explicit.secret_key, result.secret_key) + + def test_decapsulate_with_wrong_key_gives_different_secret(self): + keypair = devolutions_crypto.generate_keypair(KeyVersion.V2) + other_keypair = devolutions_crypto.generate_keypair(KeyVersion.V2) + + result = devolutions_crypto.encapsulate(keypair.public_key) + + self.assertNotEqual(devolutions_crypto.decapsulate(other_keypair.private_key, result.encapsulated_key), result.secret_key) + + def test_encapsulate_rejects_v1_key(self): + keypair = devolutions_crypto.generate_keypair(KeyVersion.V1) + + with self.assertRaises(devolutions_crypto.DevolutionsCryptoError): + devolutions_crypto.encapsulate(keypair.public_key) + + def test_secret_key_usable_for_symmetric_encryption(self): + keypair = devolutions_crypto.generate_keypair(KeyVersion.V2) + result = devolutions_crypto.encapsulate(keypair.public_key) + recovered = devolutions_crypto.decapsulate(keypair.private_key, result.encapsulated_key) + + ciphertext = devolutions_crypto.encrypt_with_secret_key(b'symmetric', result.secret_key) + + self.assertEqual(devolutions_crypto.decrypt_with_secret_key(ciphertext, recovered), b'symmetric') + + def test_decrypt_v3(self): + ciphertext = b64decode(b'DQwCAAIAAwCm2YjCoBofihIBaK5pfKKOfY0Ijhhvv+X8fw9bOWk/6TdrsxZBU9bucUBY37Ot1IuMGwBUU+eV5MQz/rTG5NoBai2licmRGBL/EJePhCPg3rgDtOajBszyyOkVVEHt4C+uznm8pJCaiw+zcG3E2pwLBRakUuUhqzHxolzVxRdZ8BaQUhNZSExDB7us3BoEhyiGpkXqKb8JWWrexzWRU/1p/ie6oP+lPG/uHK9ifAZ7k/pOty0lnIaH7R6XQ8tffM6kvyA2Cp7QHqzmGWUct8lKxy4BI7wRzNjpmIbhyHRwgZU5svTiHcccvFIYEqcZoU9gY9zBps9bkJIVK4v9AIlPtY1Ay55kE3Ze5gtHxBEvo1UaXT1+EOmOolqCqIv9Q8sbJIgFhqfkpoX3zy395FfI3C5mgqzTtW+xBOdpzxtnE3u3+M9ModNg4j6cMu6F242TFFJABdKk1CAV+jZ4WeBqt8c3VCnJGvJWGUpM8E6x3UL/C5tsDR7Ifzf39zoYGjEuo6pL9RFfA/VV1iGDFBdRmeVmoGnX0RME5epUuSET/ooymu4Vqn5INi+RTy3nIJwcyeSHEBlMYC0xVWi/veTKkoYfI6qxSYI8oxJXM8uSuZnB7TeKLshMOmt3jFM6bSyarAPlgMkbGDH+zXWe478Po4UcveequOsNABECYNET7fq6J/3ovfZ+6lMBIi2Hl2IMA5Ii/WXAv4N9ihUppFgYdPXooAOaYFoan8ntZKT+88L+s6hzUce9AhTjl+Ts2nWmdowo3rc/sGbH7LqllvGEBVohanBT8Tjy+4Q+zeN6xVpQIW3qcbeI2EE91uRrkVJwTKarB2TEYuTyys2Q3PLUX7/WUj99XBqZfOUEB9Na1F6JgPIL1+PL4Rfe5vSZySsdQi6qk+0bpd9tcauiUQokhzKiBZeLIi9VRimFpU0Qng3NmbopOKPTIBekowLhsyZgrFCqEANdOc8Jhy/OrssharUIu0mNZXgoFfJHn6kmsXgJejQikBRwUq/eUXqoz+k/ddcidKkKlJ1Mr5i7nzMPgODX8/WK229/qsIXq/cGZM9844fgeEcSkQQN9CPwbkw2k/dgWf28xfKGzMAzWpSnUwkC7toubDtYWrMoQWkpTN/nhMbAtTDBn8xrdMu5gkpERf6EeN1QURgje6dx10KpniC6/Vy7Zgg7lmn6h9o3PVpWv4oSlr3YObnUM8UgDsiRommWcBdJ8axl4/yvfeEa882DRJUAWU6nZKyunm1TgSrtFevb/72kJidpNQwmfXmy3gDC/rSXcsmnFSMGdc7a40dNFsmDZVui5386GsQk/Uta6h1zsZmqoW7AuaSDqhNo44Gbr5b57zjSwP9okkPvUVi2avFVIZsZFBl7FlWLVwb0d6HEzWVX93ZRxFosUBo5aS+btaT4YF9z++4xjST83whVU3o4TOQTq9mgAV/91JRB5+fT6P2wkOuuUuPe6gemH50LV0cYbxAHECt4OggFaAjHodoPqW0VRkU74M3xzZbKusiQv0YHDWsvqRzUuJ46XVWl+ZW2jVulTgmaSg/s') + + self.assertEqual(devolutions_crypto.decrypt_asymmetric(ciphertext, X_WING_PRIVATE_KEY), b'testdata') + + def test_decrypt_v3_with_aad(self): + ciphertext = b64decode(b'DQwCAAIAAwCY+aboyvWcG4QDu2lQfA8io0hemIghqdHjzt2b/nUpa3p9hMmnquY2zaMaugi3Lq1DyQuiau7BgDbv2Ew0fvumqvNi6EtmOOTgTp4cyCFQxBxq1dw4k1o1kdGm4I7W1h+NMnJfQ5tPjE2xRDKgDlkoOqdWv9AJmY2jFBQ65x4tVOl+a2VyicbFUNz6wpjN1vytLaWe3KZhckYt/yx6bZOJ13U7weaGezcGJwbcPTKUALSGqe6apJbOzVYxByq33j5652Cc8uL8USEJJ0pgvAYsukvgAtqycnz9wOi7dtmmmBa/2Ubzyo5U/5O6LBqHjv1irvQ3YIqn4XRXaXJ4Odik70xlG0LsiTBymr0SgGMQq0yIzoxfDOCyM6O5rBDpqlFKs+KdyN/luU+FzvJuSfR+WechpzJKW7pT5iKB/UQefXb5Dr8IMmRH7ca90Miq30yk+2lYnhI7i/K9kDoh/rM6j1xfk/j4UMWWVLnbAK3ZRbioCYF4H47ya1yQqYdrj3ZpiNLcLy2Oj8suAeVDyJFqIwDHFlsCfrgSnr1OJ5kWRkb5y/BW0S/33MTJpewAc7RztE/Dv5UfpY15GjVlY5LRj8L3oXmfF8qOvKAnzoo4pxqZMnKXCZkbyAf8FRYEF2e/J29KrzWBBVTAQMhMNqaHMw55Q2U8Cv5/eioPw1YmHYDU9E7+xmTM0KLK1/fcRQCnmFY8nVs3FRL1DdkIowjSWV5OwiWrHKYcMth5Kc556XI6VN46zOKYCEdjRwNuz9o3bCTCTeHmVFYspcwey3dRkzlKKJpmg69kQlM+5V/Ts+PY4yQ0eY0yx/vcZAmdp3H3t6LddHiOSd811z9SlsGK0YvbDgx8iRjEPFnwRlm6aly1fBYXWlk3JxvzOJCYwTqo2Q3xi4wMRA6FVwDRmDlazMSZ/qu+l5QbkXqfXAK4dCZbxksr9IXUINDcakLK3laD+iTBw+PSRfb0nyKkOoq+KbBZHRI94cGEm1Pn0Q88gPME2X02lfiMNe3fPwGPMlf6D0Ovykl0DI+IvahRqBE5h4LhneHD9j6PKuJzSy83EKsS+uLQ7nuYGoNpZFu/8wNEdkylVpCvY4SRVmAcmzBcbvv8V1NZek6sYY8BtKY7cCeyIr9wq9MMw3ZypMufpE1n5wIaQ+Y9Smgc9Vb8eV5Gsjsaam6mhzwJ38HqKmFBWVf886MhE4OuuBUEI76EeSXf3TAG+MIFqoKwfqJsLmtznDNBGDbmqC9g1J6jTplMH0zPl95LNL0SMS6+ObnVPHxJ/QBExSMbIvW7yTQ03dX2WawBbdNTee41Ev6cGQsXWnMjnOfAlIfwj1z6ZB2umYeAsRtju+0idLmqARnLnVDp2oIn4nAckkT8qoOMOVw81xvL5UYoepJREOr/k3X3tfLtFeaY4VtBrpGvUIo37hWYiDHP/vqdR7yzrkx3O3b/UVsV3JLGTIjf/brGErTsNmly+IAkR4MzwbQfezCxgCcpalHS/3GWUTWf3ureWok3q7aVj8slI7zmfFMpgdLFvVekdKUCVsF/1BMhB6gwfWuV') + aad = b'this is some public data' + + self.assertEqual(devolutions_crypto.decrypt_asymmetric_with_aad(ciphertext, X_WING_PRIVATE_KEY, aad), b'testdata') + + def test_decapsulate_v1(self): + encapsulated_key = b64decode(b'DQwKAAAAAQAdAidiTcgaZ0lFtIEr7TWNrzDq7XkFBWLSDqU+tWi68zmUXyMMxwXPSwYuX0QLAhArzNyMJXqo88zrYR+AfAeeeBVpRLq4naSDQ12j0d3gsNFbhBRzCbaa/WL92DwNQSZoA3Z1bSToRkxepJW7kcqTHs6DfFn9WAWBpU8Qul3iHWfiFqY0/xNdzMfb0H61qaLlB9C762rXwqKw5jGF1XOih6q2jwZjKOp7bZK+JNXMTb9X5BqJl/spn3FKLIhOLp85M26hDdJM1THqNCkm8/xYWVA6alkdfL9ZGE1IxJv9i8B5rW0qK7sG54a+ZuGnSaf3TZiSSR1D519WHttp+rlI/yNNWXxsOcmZmOEKw4DX2XQS5IQ9S1wflnaBwRVyBIfMUILnKSWZ0EwlgCs6XKnFy/5NqRNyZbDSAcJ+ROA/8H7yMrYQV0ilwl9mXAx9Ip9AHYsrrjC2IM3iWUBR6gqM7bwmBVb/eN51WwSXK8Vcy4FPeeii6VvFND/rEiiERhmRBRFhDNYmf5tARfBgccPw62GO56IjkBuyZhoUZ6DB8Uy8nVxcoe0hpN6fiCW10S8cBWdKtyJObkl9oqRbxmB21bKSJS/yz3owxqivTSaaLAT5HQy6FnPFYoOoG57Vp/WEIU00C5GwuAs6MUbuBBiZtjeJMRkV/HJidgH1WPIx0Xs7EKf/N4kkVok2D9ZdL9jSbLV69QR5SeW8M2U9B+1oqZghXpI4en20WEvGxEJ1ALCd2VlQqK76vLZpXX0DSlhMv14VzR7fUiZiurpH8S/tbieCMijdeWt9a5lnWTzWE0IVgALHLH2T82R6RJSIbBE7EwWziYO3Vm0Tus3iY+GxzSRqaBHc0oCsbFDFG3BkWF7gWOeTElnz7taRRqKnolN4VWfPRQ6Glmq8ml/83HTQFJfW7yI5jKp+I9vSCZ/tAcOTaneUDZ6cveDfPIWnLsSld2t/1MtXlzbRHGqGfe3nyU94TU7WDeFGcgL+EykMu58RVxVT5yefqy6kTlnkUWqr7j2EwrayzT0D5d4dvzJp38SXDsHL2/W2bsmv7WipnjFAp8xh0gNd5AGysTf+xJ8+Qv6D/aQF8JgEvVn+E7i5JcAWFIHelVupk6JJPX7KNalqN3LqBwuFm2gb7rTkdGWGoFw23+1NeyfuLPWSPq79NuzKw/5PqhQWlwDxOC88aF8lYLB3QrT8AcF/2ciwaT2j33YpRdhkFVbPrwNUPFsVV+LA4NplDywB81mABsfS3JSHhiXPLAKZhHV56qmM2gFG5dicrHOm6k7s3mDAfH/LUNrHgUs7ihYr3BzCocIfq//1RDf9NmJWj9rKbj3NpeAGnkMD68W82xv5SAYpkC6E5mZMJD1O6CBmgnPkLc6/YNxWfE/O37o18gTvjpYZourUxsX1BwAnoLjz+nptQJqJHzJeeA/GqaaoP+8F+LyEAxKFBIFD/XYK4xJ6N3v4OtforFb9QtvwCoMKnZvbeMEd') + + # The result is a serialized SecretKey: 8-byte header followed by the 32 key bytes. + secret_key = devolutions_crypto.decapsulate(X_WING_PRIVATE_KEY, encapsulated_key) + + self.assertEqual(secret_key[8:], b64decode(b'hqnsrwF5qEgJsI0G4aURCcbbsshKl7Z590eicp0ArtA=')) + + +if __name__ == "__main__": + unittest.main() diff --git a/wrappers/swift/DevolutionsCryptoSwift/Tests/DevolutionsCryptoSwiftTests/PostQuantumTests.swift b/wrappers/swift/DevolutionsCryptoSwift/Tests/DevolutionsCryptoSwiftTests/PostQuantumTests.swift new file mode 100644 index 00000000..c366647d --- /dev/null +++ b/wrappers/swift/DevolutionsCryptoSwift/Tests/DevolutionsCryptoSwiftTests/PostQuantumTests.swift @@ -0,0 +1,117 @@ +import DevolutionsCryptoSwift +import XCTest + +class PostQuantumTests: XCTestCase { + + // Fixed X-Wing (KeyVersion.V2) private key shared with the other conformity suites. + let xWingPrivateKey = "DQwBAAEAAgA1nERi4Of6p1Sg60NSw98tDx4OuBZtVR+CpVe+n1VDLg==" + + func testGenerateKeypairV2Sizes() { + let keypair = generateKeypair(version: .v2) + + XCTAssertEqual(keypair.privateKey.count, 40) + XCTAssertEqual(keypair.publicKey.count, 1224) + } + + func testEncryptDecryptAsymmetricV3() throws { + let data = Data("This is some test data".utf8) + let keypair = generateKeypair(version: .v2) + + let encrypted = try encryptAsymmetric(data: data, key: keypair.publicKey, version: .v3) + let decrypted = try decryptAsymmetric(data: encrypted, key: keypair.privateKey) + + XCTAssertEqual(encrypted.count, 8 + 1120 + 24 + data.count + 16) + XCTAssertEqual(data, decrypted) + } + + func testEncryptDecryptAsymmetricV3WithAad() throws { + let data = Data("This is some test data".utf8) + let aad = Data("This is some public data".utf8) + let wrongAad = Data("this is some public data".utf8) + let keypair = generateKeypair(version: .v2) + + let encrypted = try encryptAsymmetricWithAad( + data: data, key: keypair.publicKey, aad: aad, version: .v3) + + XCTAssertEqual( + try decryptAsymmetricWithAad(data: encrypted, key: keypair.privateKey, aad: aad), data) + XCTAssertThrowsError( + try decryptAsymmetricWithAad(data: encrypted, key: keypair.privateKey, aad: wrongAad) + ) { error in + XCTAssertTrue(error is DevolutionsCryptoError) + } + } + + func testEncryptAsymmetricV3RejectsV1Key() { + let data = Data("This is some test data".utf8) + let keypair = generateKeypair(version: .v1) + + XCTAssertThrowsError(try encryptAsymmetric(data: data, key: keypair.publicKey, version: .v3)) + } + + func testDecryptAsymmetricV3WrongKey() throws { + let data = Data("This is some test data".utf8) + let keypair = generateKeypair(version: .v2) + let other = generateKeypair(version: .v2) + + let encrypted = try encryptAsymmetric(data: data, key: keypair.publicKey, version: .v3) + + XCTAssertThrowsError(try decryptAsymmetric(data: encrypted, key: other.privateKey)) + } + + func testMixKeyExchangeRejectsV2() { + let v1 = generateKeypair(version: .v1) + let v2 = generateKeypair(version: .v2) + + XCTAssertThrowsError(try mixKeyExchange(privateKey: v2.privateKey, publicKey: v1.publicKey)) + } + + func testEncapsulateDecapsulate() throws { + let keypair = generateKeypair(version: .v2) + + let result = try encapsulate(publicKey: keypair.publicKey) + let recovered = try decapsulate( + privateKey: keypair.privateKey, encapsulatedKey: result.encapsulatedKey) + + XCTAssertEqual(result.encapsulatedKey.count, 1128) + XCTAssertEqual(result.secretKey, recovered) + } + + func testDecapsulateWrongKey() throws { + let keypair = generateKeypair(version: .v2) + let other = generateKeypair(version: .v2) + + let result = try encapsulate(publicKey: keypair.publicKey, version: .v1) + let wrong = try decapsulate(privateKey: other.privateKey, encapsulatedKey: result.encapsulatedKey) + + XCTAssertNotEqual(result.secretKey, wrong) + } + + func testEncapsulateRejectsV1Key() { + let keypair = generateKeypair(version: .v1) + + XCTAssertThrowsError(try encapsulate(publicKey: keypair.publicKey)) + } + + func testDecryptAsymmetricV3Conformity() throws { + let privateKey = Data(base64Encoded: xWingPrivateKey)! + let aad = Data("this is some public data".utf8) + + let plain = try decryptAsymmetric( + data: Data(base64Encoded: "DQwCAAIAAwCm2YjCoBofihIBaK5pfKKOfY0Ijhhvv+X8fw9bOWk/6TdrsxZBU9bucUBY37Ot1IuMGwBUU+eV5MQz/rTG5NoBai2licmRGBL/EJePhCPg3rgDtOajBszyyOkVVEHt4C+uznm8pJCaiw+zcG3E2pwLBRakUuUhqzHxolzVxRdZ8BaQUhNZSExDB7us3BoEhyiGpkXqKb8JWWrexzWRU/1p/ie6oP+lPG/uHK9ifAZ7k/pOty0lnIaH7R6XQ8tffM6kvyA2Cp7QHqzmGWUct8lKxy4BI7wRzNjpmIbhyHRwgZU5svTiHcccvFIYEqcZoU9gY9zBps9bkJIVK4v9AIlPtY1Ay55kE3Ze5gtHxBEvo1UaXT1+EOmOolqCqIv9Q8sbJIgFhqfkpoX3zy395FfI3C5mgqzTtW+xBOdpzxtnE3u3+M9ModNg4j6cMu6F242TFFJABdKk1CAV+jZ4WeBqt8c3VCnJGvJWGUpM8E6x3UL/C5tsDR7Ifzf39zoYGjEuo6pL9RFfA/VV1iGDFBdRmeVmoGnX0RME5epUuSET/ooymu4Vqn5INi+RTy3nIJwcyeSHEBlMYC0xVWi/veTKkoYfI6qxSYI8oxJXM8uSuZnB7TeKLshMOmt3jFM6bSyarAPlgMkbGDH+zXWe478Po4UcveequOsNABECYNET7fq6J/3ovfZ+6lMBIi2Hl2IMA5Ii/WXAv4N9ihUppFgYdPXooAOaYFoan8ntZKT+88L+s6hzUce9AhTjl+Ts2nWmdowo3rc/sGbH7LqllvGEBVohanBT8Tjy+4Q+zeN6xVpQIW3qcbeI2EE91uRrkVJwTKarB2TEYuTyys2Q3PLUX7/WUj99XBqZfOUEB9Na1F6JgPIL1+PL4Rfe5vSZySsdQi6qk+0bpd9tcauiUQokhzKiBZeLIi9VRimFpU0Qng3NmbopOKPTIBekowLhsyZgrFCqEANdOc8Jhy/OrssharUIu0mNZXgoFfJHn6kmsXgJejQikBRwUq/eUXqoz+k/ddcidKkKlJ1Mr5i7nzMPgODX8/WK229/qsIXq/cGZM9844fgeEcSkQQN9CPwbkw2k/dgWf28xfKGzMAzWpSnUwkC7toubDtYWrMoQWkpTN/nhMbAtTDBn8xrdMu5gkpERf6EeN1QURgje6dx10KpniC6/Vy7Zgg7lmn6h9o3PVpWv4oSlr3YObnUM8UgDsiRommWcBdJ8axl4/yvfeEa882DRJUAWU6nZKyunm1TgSrtFevb/72kJidpNQwmfXmy3gDC/rSXcsmnFSMGdc7a40dNFsmDZVui5386GsQk/Uta6h1zsZmqoW7AuaSDqhNo44Gbr5b57zjSwP9okkPvUVi2avFVIZsZFBl7FlWLVwb0d6HEzWVX93ZRxFosUBo5aS+btaT4YF9z++4xjST83whVU3o4TOQTq9mgAV/91JRB5+fT6P2wkOuuUuPe6gemH50LV0cYbxAHECt4OggFaAjHodoPqW0VRkU74M3xzZbKusiQv0YHDWsvqRzUuJ46XVWl+ZW2jVulTgmaSg/s")!, key: privateKey) + XCTAssertEqual(plain, Data("testdata".utf8)) + + let plainAad = try decryptAsymmetricWithAad( + data: Data(base64Encoded: "DQwCAAIAAwCY+aboyvWcG4QDu2lQfA8io0hemIghqdHjzt2b/nUpa3p9hMmnquY2zaMaugi3Lq1DyQuiau7BgDbv2Ew0fvumqvNi6EtmOOTgTp4cyCFQxBxq1dw4k1o1kdGm4I7W1h+NMnJfQ5tPjE2xRDKgDlkoOqdWv9AJmY2jFBQ65x4tVOl+a2VyicbFUNz6wpjN1vytLaWe3KZhckYt/yx6bZOJ13U7weaGezcGJwbcPTKUALSGqe6apJbOzVYxByq33j5652Cc8uL8USEJJ0pgvAYsukvgAtqycnz9wOi7dtmmmBa/2Ubzyo5U/5O6LBqHjv1irvQ3YIqn4XRXaXJ4Odik70xlG0LsiTBymr0SgGMQq0yIzoxfDOCyM6O5rBDpqlFKs+KdyN/luU+FzvJuSfR+WechpzJKW7pT5iKB/UQefXb5Dr8IMmRH7ca90Miq30yk+2lYnhI7i/K9kDoh/rM6j1xfk/j4UMWWVLnbAK3ZRbioCYF4H47ya1yQqYdrj3ZpiNLcLy2Oj8suAeVDyJFqIwDHFlsCfrgSnr1OJ5kWRkb5y/BW0S/33MTJpewAc7RztE/Dv5UfpY15GjVlY5LRj8L3oXmfF8qOvKAnzoo4pxqZMnKXCZkbyAf8FRYEF2e/J29KrzWBBVTAQMhMNqaHMw55Q2U8Cv5/eioPw1YmHYDU9E7+xmTM0KLK1/fcRQCnmFY8nVs3FRL1DdkIowjSWV5OwiWrHKYcMth5Kc556XI6VN46zOKYCEdjRwNuz9o3bCTCTeHmVFYspcwey3dRkzlKKJpmg69kQlM+5V/Ts+PY4yQ0eY0yx/vcZAmdp3H3t6LddHiOSd811z9SlsGK0YvbDgx8iRjEPFnwRlm6aly1fBYXWlk3JxvzOJCYwTqo2Q3xi4wMRA6FVwDRmDlazMSZ/qu+l5QbkXqfXAK4dCZbxksr9IXUINDcakLK3laD+iTBw+PSRfb0nyKkOoq+KbBZHRI94cGEm1Pn0Q88gPME2X02lfiMNe3fPwGPMlf6D0Ovykl0DI+IvahRqBE5h4LhneHD9j6PKuJzSy83EKsS+uLQ7nuYGoNpZFu/8wNEdkylVpCvY4SRVmAcmzBcbvv8V1NZek6sYY8BtKY7cCeyIr9wq9MMw3ZypMufpE1n5wIaQ+Y9Smgc9Vb8eV5Gsjsaam6mhzwJ38HqKmFBWVf886MhE4OuuBUEI76EeSXf3TAG+MIFqoKwfqJsLmtznDNBGDbmqC9g1J6jTplMH0zPl95LNL0SMS6+ObnVPHxJ/QBExSMbIvW7yTQ03dX2WawBbdNTee41Ev6cGQsXWnMjnOfAlIfwj1z6ZB2umYeAsRtju+0idLmqARnLnVDp2oIn4nAckkT8qoOMOVw81xvL5UYoepJREOr/k3X3tfLtFeaY4VtBrpGvUIo37hWYiDHP/vqdR7yzrkx3O3b/UVsV3JLGTIjf/brGErTsNmly+IAkR4MzwbQfezCxgCcpalHS/3GWUTWf3ureWok3q7aVj8slI7zmfFMpgdLFvVekdKUCVsF/1BMhB6gwfWuV")!, key: privateKey, aad: aad) + XCTAssertEqual(plainAad, Data("testdata".utf8)) + } + + func testDecapsulateConformity() throws { + let secret = try decapsulate( + privateKey: Data(base64Encoded: xWingPrivateKey)!, + encapsulatedKey: Data(base64Encoded: "DQwKAAAAAQAdAidiTcgaZ0lFtIEr7TWNrzDq7XkFBWLSDqU+tWi68zmUXyMMxwXPSwYuX0QLAhArzNyMJXqo88zrYR+AfAeeeBVpRLq4naSDQ12j0d3gsNFbhBRzCbaa/WL92DwNQSZoA3Z1bSToRkxepJW7kcqTHs6DfFn9WAWBpU8Qul3iHWfiFqY0/xNdzMfb0H61qaLlB9C762rXwqKw5jGF1XOih6q2jwZjKOp7bZK+JNXMTb9X5BqJl/spn3FKLIhOLp85M26hDdJM1THqNCkm8/xYWVA6alkdfL9ZGE1IxJv9i8B5rW0qK7sG54a+ZuGnSaf3TZiSSR1D519WHttp+rlI/yNNWXxsOcmZmOEKw4DX2XQS5IQ9S1wflnaBwRVyBIfMUILnKSWZ0EwlgCs6XKnFy/5NqRNyZbDSAcJ+ROA/8H7yMrYQV0ilwl9mXAx9Ip9AHYsrrjC2IM3iWUBR6gqM7bwmBVb/eN51WwSXK8Vcy4FPeeii6VvFND/rEiiERhmRBRFhDNYmf5tARfBgccPw62GO56IjkBuyZhoUZ6DB8Uy8nVxcoe0hpN6fiCW10S8cBWdKtyJObkl9oqRbxmB21bKSJS/yz3owxqivTSaaLAT5HQy6FnPFYoOoG57Vp/WEIU00C5GwuAs6MUbuBBiZtjeJMRkV/HJidgH1WPIx0Xs7EKf/N4kkVok2D9ZdL9jSbLV69QR5SeW8M2U9B+1oqZghXpI4en20WEvGxEJ1ALCd2VlQqK76vLZpXX0DSlhMv14VzR7fUiZiurpH8S/tbieCMijdeWt9a5lnWTzWE0IVgALHLH2T82R6RJSIbBE7EwWziYO3Vm0Tus3iY+GxzSRqaBHc0oCsbFDFG3BkWF7gWOeTElnz7taRRqKnolN4VWfPRQ6Glmq8ml/83HTQFJfW7yI5jKp+I9vSCZ/tAcOTaneUDZ6cveDfPIWnLsSld2t/1MtXlzbRHGqGfe3nyU94TU7WDeFGcgL+EykMu58RVxVT5yefqy6kTlnkUWqr7j2EwrayzT0D5d4dvzJp38SXDsHL2/W2bsmv7WipnjFAp8xh0gNd5AGysTf+xJ8+Qv6D/aQF8JgEvVn+E7i5JcAWFIHelVupk6JJPX7KNalqN3LqBwuFm2gb7rTkdGWGoFw23+1NeyfuLPWSPq79NuzKw/5PqhQWlwDxOC88aF8lYLB3QrT8AcF/2ciwaT2j33YpRdhkFVbPrwNUPFsVV+LA4NplDywB81mABsfS3JSHhiXPLAKZhHV56qmM2gFG5dicrHOm6k7s3mDAfH/LUNrHgUs7ihYr3BzCocIfq//1RDf9NmJWj9rKbj3NpeAGnkMD68W82xv5SAYpkC6E5mZMJD1O6CBmgnPkLc6/YNxWfE/O37o18gTvjpYZourUxsX1BwAnoLjz+nptQJqJHzJeeA/GqaaoP+8F+LyEAxKFBIFD/XYK4xJ6N3v4OtforFb9QtvwCoMKnZvbeMEd")!) + + // The serialized secret key is an 8-byte header followed by the 32 key bytes. + XCTAssertEqual(secret.suffix(from: secret.startIndex + 8), Data(base64Encoded: "hqnsrwF5qEgJsI0G4aURCcbbsshKl7Z590eicp0ArtA=")!) + } +} diff --git a/wrappers/wasm/demo/src/app/asymmetric/asymmetric.component.html b/wrappers/wasm/demo/src/app/asymmetric/asymmetric.component.html index 869c4137..3abc8438 100644 --- a/wrappers/wasm/demo/src/app/asymmetric/asymmetric.component.html +++ b/wrappers/wasm/demo/src/app/asymmetric/asymmetric.component.html @@ -15,12 +15,23 @@

Asymmetric

Key Generation
+
+ + +
- Result - + Private key + +
+
+ Public key +
@@ -61,6 +72,13 @@

Asymmetric

+
+ + +
@@ -95,4 +113,50 @@

Asymmetric

+
+
Encapsulate (X-Wing)
+
+
+
+ + +
+
+ +
+
+ Encapsulated key (send to the recipient) + +
+
+ Shared secret key (keep local) + +
+
+
+
+ +
+
Decapsulate (X-Wing)
+
+
+
+ + +
+
+ + +
+
+ +
+
+ Shared secret key + +
+
+
+
+ diff --git a/wrappers/wasm/demo/src/app/asymmetric/asymmetric.component.ts b/wrappers/wasm/demo/src/app/asymmetric/asymmetric.component.ts index d38c9b8c..b7c50778 100644 --- a/wrappers/wasm/demo/src/app/asymmetric/asymmetric.component.ts +++ b/wrappers/wasm/demo/src/app/asymmetric/asymmetric.component.ts @@ -5,7 +5,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { faSyncAlt } from '@fortawesome/free-solid-svg-icons'; import * as functions from '../shared/shared.component'; -import { Argon2Parameters, CiphertextVersion, KeyPair, PrivateKey, PublicKey } from '@devolutions/devolutions-crypto-web'; +import { Argon2Parameters, CiphertextVersion, EncapsulationResult, KeyPair, PrivateKey, PublicKey, SecretKey } from '@devolutions/devolutions-crypto-web'; type EncryptionServiceInner = typeof import('../service/encryption.inner.service'); @@ -19,6 +19,8 @@ export class AsymmetricComponent implements OnInit { faSyncAlt = faSyncAlt; keypairGenerationForm: FormGroup; + encapsulateForm: FormGroup; + decapsulateForm: FormGroup; mixKeyForm: FormGroup; encryptAsymmetricForm: FormGroup; decryptAsymmetricForm: FormGroup; @@ -32,7 +34,21 @@ export class AsymmetricComponent implements OnInit { this.encoder = new TextEncoder(); this.keypairGenerationForm = new FormGroup({ - generationResult: new FormControl(''), + keyAlgorithm: new FormControl('x25519'), + generatedPrivateKey: new FormControl(''), + generatedPublicKey: new FormControl(''), + }); + + this.encapsulateForm = new FormGroup({ + publicKeyEncapsulate: new FormControl(''), + encapsulatedKeyResult: new FormControl(''), + secretKeyEncapsulateResult: new FormControl('') + }); + + this.decapsulateForm = new FormGroup({ + encapsulatedKeyDecapsulate: new FormControl(''), + privateKeyDecapsulate: new FormControl(''), + secretKeyDecapsulateResult: new FormControl('') }); this.mixKeyForm = new FormGroup({ @@ -44,7 +60,7 @@ export class AsymmetricComponent implements OnInit { this.encryptAsymmetricForm = new FormGroup({ textToEncrypt: new FormControl(''), publicKeyEncrypt: new FormControl(''), - encryptVersion: new FormControl(''), + encryptVersion: new FormControl('latest'), encryptAsymmetricResult: new FormControl('') }); @@ -110,16 +126,21 @@ export class AsymmetricComponent implements OnInit { async generateKeypair() { const service: EncryptionServiceInner = await this.encryptionService.innerModule; - const keypair: KeyPair = service.generateKeyPair(service.KeyVersion.Latest); + const keyVersion = this.keypairGenerationForm.value.keyAlgorithm === 'xwing' + ? service.KeyVersion.V2 + : service.KeyVersion.Latest; + const keypair: KeyPair = service.generateKeyPair(keyVersion); const privateKey: PrivateKey = keypair.private; const publicKey: PublicKey = keypair.public; if (privateKey === null || publicKey === null) { return; } const privateKeyText: string = service.base64encode(privateKey.bytes); const publicKeyText: string = service.base64encode(publicKey.bytes); - const text = `Private key: ${privateKeyText}\n\nPublic key: ${publicKeyText}`; - this.keypairGenerationForm.setValue({ generationResult: text }); + this.keypairGenerationForm.patchValue({ + generatedPrivateKey: privateKeyText, + generatedPublicKey: publicKeyText + }); } async mixKeys() { @@ -157,17 +178,48 @@ export class AsymmetricComponent implements OnInit { const publicKey: PublicKey = service.PublicKey.fromBytes(publicKeyArray); const textToEncryptArray: Uint8Array = this.encoder.encode(textToEncrypt); - const version: CiphertextVersion = service.CiphertextVersion.Latest; + // A V3 ciphertext requires an X-Wing (post-quantum) public key. + const version: CiphertextVersion = this.encryptAsymmetricForm.value.encryptVersion === 'v3' + ? service.CiphertextVersion.V3 + : service.CiphertextVersion.Latest; const encryptedText: Uint8Array = service.encryptAsymmetric(textToEncryptArray, publicKey, version); - this.encryptAsymmetricForm.setValue({ - textToEncrypt, - publicKeyEncrypt, - encryptVersion: '', + this.encryptAsymmetricForm.patchValue({ encryptAsymmetricResult: service.base64encode(encryptedText) }); } + async encapsulate() { + const service: EncryptionServiceInner = await this.encryptionService.innerModule; + + const publicKeyString: string = this.encapsulateForm.value.publicKeyEncapsulate; + if (publicKeyString === null || publicKeyString === '') { return; } + + const publicKey: PublicKey = service.PublicKey.fromBytes(service.base64decode(publicKeyString.trim())); + const result: EncapsulationResult = service.encapsulate(publicKey); + + this.encapsulateForm.patchValue({ + encapsulatedKeyResult: service.base64encode(result.encapsulatedKey), + secretKeyEncapsulateResult: service.base64encode(result.secretKey.bytes) + }); + } + + async decapsulate() { + const service: EncryptionServiceInner = await this.encryptionService.innerModule; + + const encapsulatedKey: string = this.decapsulateForm.value.encapsulatedKeyDecapsulate; + const privateKeyString: string = this.decapsulateForm.value.privateKeyDecapsulate; + if (encapsulatedKey === null || encapsulatedKey === '' || + privateKeyString === null || privateKeyString === '') { return; } + + const privateKey: PrivateKey = service.PrivateKey.fromBytes(service.base64decode(privateKeyString.trim())); + const secretKey: SecretKey = service.decapsulate(privateKey, service.base64decode(encapsulatedKey.trim())); + + this.decapsulateForm.patchValue({ + secretKeyDecapsulateResult: service.base64encode(secretKey.bytes) + }); + } + async decryptAsymmetric() { const service: EncryptionServiceInner = await this.encryptionService.innerModule; diff --git a/wrappers/wasm/demo/src/app/inspect/inspect.component.ts b/wrappers/wasm/demo/src/app/inspect/inspect.component.ts index 60eb5e24..1f336290 100644 --- a/wrappers/wasm/demo/src/app/inspect/inspect.component.ts +++ b/wrappers/wasm/demo/src/app/inspect/inspect.component.ts @@ -22,6 +22,7 @@ const DATA_TYPE_NAMES: Record = { 7: 'OnlineCiphertext', 8: 'KeyDerivation', 9: 'KdfEncryptedData', + 10: 'EncapsulatedKey', }; const SUBTYPE_NAMES: Record> = { @@ -33,17 +34,19 @@ const SUBTYPE_NAMES: Record> = { 6: { 0: 'None' }, 8: { 0: 'None' }, 9: { 0: 'None' }, + 10: { 0: 'None' }, }; const VERSION_NAMES: Record> = { - 1: { 0: 'Latest', 1: 'V1 – Curve25519 / x25519' }, - 2: { 0: 'Latest', 1: 'V1 – AES256-CBC + HMAC-SHA256', 2: 'V2 – XChaCha20-Poly1305' }, + 1: { 0: 'Latest', 1: 'V1 – Curve25519 / x25519', 2: 'V2 – X-Wing (ML-KEM-768 + x25519)' }, + 2: { 0: 'Latest', 1: 'V1 – AES256-CBC + HMAC-SHA256', 2: 'V2 – XChaCha20-Poly1305', 3: 'V3 – X-Wing KEM + XChaCha20-Poly1305' }, 3: { 0: 'Latest', 1: 'V1 – PBKDF2-HMAC-SHA256', 2: 'V2 – Argon2id' }, 4: { 0: 'Latest', 1: 'V1 – Shamir Secret Sharing over GF256' }, 5: { 0: 'Latest', 1: 'V1 – Ed25519' }, 6: { 0: 'Latest', 1: 'V1 – Ed25519' }, 8: { 0: 'Latest', 1: 'V1 – PBKDF2-HMAC-SHA256', 2: 'V2 – Argon2id' }, 9: { 0: 'Latest', 1: 'V1 – DerivationParameters + Ciphertext' }, + 10: { 0: 'Latest', 1: 'V1 – X-Wing (ML-KEM-768 + x25519)' }, }; export interface PayloadField { @@ -201,6 +204,8 @@ export class InspectComponent implements OnInit { return this.parseDerivationParametersPayload(payload, version, abs); case 9: return this.parseKdfEncryptedDataPayload(payload, abs); + case 10: + return this.parseEncapsulatedKeyPayload(payload, abs); default: return [ { @@ -261,6 +266,54 @@ export class InspectComponent implements OnInit { return fields; } + if (version === 3) { + // V3 (asymmetric only): X-Wing KEM ciphertext(1120) + Nonce(24) + Ciphertext+Tag(var) + if (payload.length < 1160) { + fields.push({ + name: 'Error', + offset: abs(0), + size: payload.length, + hex: toHex(payload), + description: `Payload too short for V3 asymmetric ciphertext (min 1160 bytes, got ${payload.length})`, + }); + return fields; + } + const kemCt = payload.slice(0, 1120); + const nonce = payload.slice(1120, 1144); + const ctWithTag = payload.slice(1144); + const ct = ctWithTag.slice(0, ctWithTag.length - 16); + const tag = ctWithTag.slice(ctWithTag.length - 16); + fields.push({ + name: 'KEM Ciphertext', + offset: abs(0), + size: 1120, + hex: toHex(kemCt, 32), + description: 'X-Wing ciphertext: ML-KEM-768 ciphertext (1088 bytes) + x25519 ephemeral public key (32 bytes)', + }); + fields.push({ + name: 'Nonce', + offset: abs(1120), + size: 24, + hex: toHex(nonce), + description: 'XChaCha20-Poly1305 nonce (24 bytes)', + }); + fields.push({ + name: 'Ciphertext', + offset: abs(1144), + size: ct.length, + hex: toHex(ct, 32), + description: `XChaCha20 encrypted data (${ct.length} bytes)`, + }); + fields.push({ + name: 'Auth Tag (Poly1305)', + offset: abs(1144 + ct.length), + size: 16, + hex: toHex(tag), + description: 'Poly1305 AEAD authentication tag (16 bytes)', + }); + return fields; + } + // V2 (or Latest/0 which resolves to V2): XChaCha20-Poly1305 if (subtype === 2) { // Asymmetric: EphemeralPubKey(32) + Nonce(24) + Ciphertext+Tag(var) @@ -349,6 +402,21 @@ export class InspectComponent implements OnInit { return fields; } + private parseEncapsulatedKeyPayload( + payload: Uint8Array, + abs: (n: number) => number + ): PayloadField[] { + return [ + { + name: 'KEM Ciphertext', + offset: abs(0), + size: payload.length, + hex: toHex(payload, 32), + description: `X-Wing ciphertext: ML-KEM-768 ciphertext (1088 bytes) + x25519 ephemeral public key (32 bytes) (${payload.length} bytes total)`, + }, + ]; + } + private parseKeyPayload( payload: Uint8Array, subtype: number, diff --git a/wrappers/wasm/demo/src/app/service/encryption.inner.service.ts b/wrappers/wasm/demo/src/app/service/encryption.inner.service.ts index 198107ee..bf0b333a 100644 --- a/wrappers/wasm/demo/src/app/service/encryption.inner.service.ts +++ b/wrappers/wasm/demo/src/app/service/encryption.inner.service.ts @@ -1,5 +1,5 @@ -import wasmInit, { CiphertextVersion, KeyVersion, KeyPair, PrivateKey, PublicKey, SecretKey, Argon2Parameters, PasswordHashVersion, KeyDerivationResult, DerivationParameters } from '@devolutions/devolutions-crypto-web'; -export { CiphertextVersion, KeyVersion, KeyPair, PrivateKey, PublicKey, SecretKey, Argon2Parameters, PasswordHashVersion, KeyDerivationResult, DerivationParameters } from '@devolutions/devolutions-crypto-web'; +import wasmInit, { CiphertextVersion, KeyVersion, KeyEncapsulationVersion, EncapsulationResult, KeyPair, PrivateKey, PublicKey, SecretKey, Argon2Parameters, PasswordHashVersion, KeyDerivationResult, DerivationParameters } from '@devolutions/devolutions-crypto-web'; +export { CiphertextVersion, KeyVersion, KeyEncapsulationVersion, EncapsulationResult, KeyPair, PrivateKey, PublicKey, SecretKey, Argon2Parameters, PasswordHashVersion, KeyDerivationResult, DerivationParameters } from '@devolutions/devolutions-crypto-web'; import * as devolutionsCrypto from '@devolutions/devolutions-crypto-web'; // Initialize WASM before any functions are used @@ -13,6 +13,14 @@ export function encryptAsymmetric(data: Uint8Array, publicKey: PublicKey, versio return devolutionsCrypto.encryptAsymmetric(data, publicKey, undefined, version); } +export function encapsulate(publicKey: PublicKey, version?: KeyEncapsulationVersion): EncapsulationResult { + return devolutionsCrypto.encapsulate(publicKey, version); +} + +export function decapsulate(privateKey: PrivateKey, encapsulatedKey: Uint8Array): SecretKey { + return devolutionsCrypto.decapsulate(privateKey, encapsulatedKey); +} + export function decrypt(data: Uint8Array, key: Uint8Array): Uint8Array { return devolutionsCrypto.decrypt(data, key); } diff --git a/wrappers/wasm/tests/package.json b/wrappers/wasm/tests/package.json index a712df66..efab0b79 100644 --- a/wrappers/wasm/tests/package.json +++ b/wrappers/wasm/tests/package.json @@ -4,7 +4,7 @@ "description": "Tests for the devolutions cryptographic library", "type": "module", "scripts": { - "test": "tsx --test tests/asymmetric.ts tests/conformity.ts tests/derive-encrypt.ts tests/hashing.ts tests/key-derivation.ts tests/secret-sharing.ts tests/signature.ts tests/symmetric.ts tests/utils.ts", + "test": "tsx --test tests/asymmetric.ts tests/conformity.ts tests/derive-encrypt.ts tests/hashing.ts tests/key-derivation.ts tests/post-quantum.ts tests/secret-sharing.ts tests/signature.ts tests/symmetric.ts tests/utils.ts", "test:watch": "tsx --test --watch tests/*.ts" }, "repository": { diff --git a/wrappers/wasm/tests/tests/post-quantum.ts b/wrappers/wasm/tests/tests/post-quantum.ts new file mode 100644 index 00000000..f1770416 --- /dev/null +++ b/wrappers/wasm/tests/tests/post-quantum.ts @@ -0,0 +1,124 @@ +import { + KeyPair, PrivateKey, SecretKey, KeyVersion, CiphertextVersion, KeyEncapsulationVersion, + generateKeyPair, encryptAsymmetric, decryptAsymmetric, encapsulate, decapsulate, + encryptWithSecretKey, decryptWithSecretKey, mixKeyExchange, base64decode +} from 'devolutions-crypto' +import { describe, test } from 'node:test' +import assert from 'node:assert/strict' + +const encoder: TextEncoder = new TextEncoder() + +// Fixed X-Wing (KeyVersion.V2) private key shared with the other conformity suites. +const xWingPrivateKey: string = 'DQwBAAEAAgA1nERi4Of6p1Sg60NSw98tDx4OuBZtVR+CpVe+n1VDLg==' + +describe('X-Wing key pair (KeyVersion.V2)', () => { + test('should generate keys of the expected size', () => { + const keypair: KeyPair = generateKeyPair(KeyVersion.V2) + assert.strictEqual(keypair.private.bytes.length, 40) + assert.strictEqual(keypair.public.bytes.length, 1224) + }) + + test('should not be usable for mixKeyExchange', () => { + const v1: KeyPair = generateKeyPair(KeyVersion.V1) + const v2: KeyPair = generateKeyPair(KeyVersion.V2) + assert.throws(() => mixKeyExchange(v2.private, v1.public)) + }) +}) + +describe('encryptAsymmetric/decryptAsymmetric with CiphertextVersion.V3', () => { + test('should be able to encrypt and decrypt', () => { + const input: Uint8Array = encoder.encode('This is some test data') + const keypair: KeyPair = generateKeyPair(KeyVersion.V2) + const encrypted: Uint8Array = encryptAsymmetric(input, keypair.public, undefined, CiphertextVersion.V3) + const decrypted: Uint8Array = decryptAsymmetric(encrypted, keypair.private) + assert.strictEqual(encrypted.length, 8 + 1120 + 24 + input.length + 16) + assert.deepStrictEqual(decrypted, input) + }) + + test('should be able to encrypt and decrypt with an AAD', () => { + const input: Uint8Array = encoder.encode('This is some test data') + const aad: Uint8Array = encoder.encode('This is some public data') + const keypair: KeyPair = generateKeyPair(KeyVersion.V2) + const encrypted: Uint8Array = encryptAsymmetric(input, keypair.public, aad, CiphertextVersion.V3) + assert.deepStrictEqual(decryptAsymmetric(encrypted, keypair.private, aad), input) + assert.throws(() => decryptAsymmetric(encrypted, keypair.private)) + assert.throws(() => decryptAsymmetric(encrypted, keypair.private, encoder.encode('wrong aad'))) + }) + + test('should fail with mismatched keys', () => { + const input: Uint8Array = encoder.encode('This is some test data') + const v1: KeyPair = generateKeyPair(KeyVersion.V1) + const v2: KeyPair = generateKeyPair(KeyVersion.V2) + const otherV2: KeyPair = generateKeyPair(KeyVersion.V2) + + assert.throws(() => encryptAsymmetric(input, v1.public, undefined, CiphertextVersion.V3)) + + const encrypted: Uint8Array = encryptAsymmetric(input, v2.public, undefined, CiphertextVersion.V3) + assert.throws(() => decryptAsymmetric(encrypted, v1.private)) + assert.throws(() => decryptAsymmetric(encrypted, otherV2.private)) + }) + + test('should decrypt the conformity vectors', () => { + // decryptAsymmetric consumes its PrivateKey argument, so build a fresh one per call. + const privateKey = (): PrivateKey => PrivateKey.fromBytes(base64decode(xWingPrivateKey)) + const aad: Uint8Array = encoder.encode('this is some public data') + + assert.deepStrictEqual( + decryptAsymmetric(base64decode('DQwCAAIAAwCm2YjCoBofihIBaK5pfKKOfY0Ijhhvv+X8fw9bOWk/6TdrsxZBU9bucUBY37Ot1IuMGwBUU+eV5MQz/rTG5NoBai2licmRGBL/EJePhCPg3rgDtOajBszyyOkVVEHt4C+uznm8pJCaiw+zcG3E2pwLBRakUuUhqzHxolzVxRdZ8BaQUhNZSExDB7us3BoEhyiGpkXqKb8JWWrexzWRU/1p/ie6oP+lPG/uHK9ifAZ7k/pOty0lnIaH7R6XQ8tffM6kvyA2Cp7QHqzmGWUct8lKxy4BI7wRzNjpmIbhyHRwgZU5svTiHcccvFIYEqcZoU9gY9zBps9bkJIVK4v9AIlPtY1Ay55kE3Ze5gtHxBEvo1UaXT1+EOmOolqCqIv9Q8sbJIgFhqfkpoX3zy395FfI3C5mgqzTtW+xBOdpzxtnE3u3+M9ModNg4j6cMu6F242TFFJABdKk1CAV+jZ4WeBqt8c3VCnJGvJWGUpM8E6x3UL/C5tsDR7Ifzf39zoYGjEuo6pL9RFfA/VV1iGDFBdRmeVmoGnX0RME5epUuSET/ooymu4Vqn5INi+RTy3nIJwcyeSHEBlMYC0xVWi/veTKkoYfI6qxSYI8oxJXM8uSuZnB7TeKLshMOmt3jFM6bSyarAPlgMkbGDH+zXWe478Po4UcveequOsNABECYNET7fq6J/3ovfZ+6lMBIi2Hl2IMA5Ii/WXAv4N9ihUppFgYdPXooAOaYFoan8ntZKT+88L+s6hzUce9AhTjl+Ts2nWmdowo3rc/sGbH7LqllvGEBVohanBT8Tjy+4Q+zeN6xVpQIW3qcbeI2EE91uRrkVJwTKarB2TEYuTyys2Q3PLUX7/WUj99XBqZfOUEB9Na1F6JgPIL1+PL4Rfe5vSZySsdQi6qk+0bpd9tcauiUQokhzKiBZeLIi9VRimFpU0Qng3NmbopOKPTIBekowLhsyZgrFCqEANdOc8Jhy/OrssharUIu0mNZXgoFfJHn6kmsXgJejQikBRwUq/eUXqoz+k/ddcidKkKlJ1Mr5i7nzMPgODX8/WK229/qsIXq/cGZM9844fgeEcSkQQN9CPwbkw2k/dgWf28xfKGzMAzWpSnUwkC7toubDtYWrMoQWkpTN/nhMbAtTDBn8xrdMu5gkpERf6EeN1QURgje6dx10KpniC6/Vy7Zgg7lmn6h9o3PVpWv4oSlr3YObnUM8UgDsiRommWcBdJ8axl4/yvfeEa882DRJUAWU6nZKyunm1TgSrtFevb/72kJidpNQwmfXmy3gDC/rSXcsmnFSMGdc7a40dNFsmDZVui5386GsQk/Uta6h1zsZmqoW7AuaSDqhNo44Gbr5b57zjSwP9okkPvUVi2avFVIZsZFBl7FlWLVwb0d6HEzWVX93ZRxFosUBo5aS+btaT4YF9z++4xjST83whVU3o4TOQTq9mgAV/91JRB5+fT6P2wkOuuUuPe6gemH50LV0cYbxAHECt4OggFaAjHodoPqW0VRkU74M3xzZbKusiQv0YHDWsvqRzUuJ46XVWl+ZW2jVulTgmaSg/s'), privateKey()), + encoder.encode('testdata') + ) + assert.deepStrictEqual( + decryptAsymmetric(base64decode('DQwCAAIAAwCY+aboyvWcG4QDu2lQfA8io0hemIghqdHjzt2b/nUpa3p9hMmnquY2zaMaugi3Lq1DyQuiau7BgDbv2Ew0fvumqvNi6EtmOOTgTp4cyCFQxBxq1dw4k1o1kdGm4I7W1h+NMnJfQ5tPjE2xRDKgDlkoOqdWv9AJmY2jFBQ65x4tVOl+a2VyicbFUNz6wpjN1vytLaWe3KZhckYt/yx6bZOJ13U7weaGezcGJwbcPTKUALSGqe6apJbOzVYxByq33j5652Cc8uL8USEJJ0pgvAYsukvgAtqycnz9wOi7dtmmmBa/2Ubzyo5U/5O6LBqHjv1irvQ3YIqn4XRXaXJ4Odik70xlG0LsiTBymr0SgGMQq0yIzoxfDOCyM6O5rBDpqlFKs+KdyN/luU+FzvJuSfR+WechpzJKW7pT5iKB/UQefXb5Dr8IMmRH7ca90Miq30yk+2lYnhI7i/K9kDoh/rM6j1xfk/j4UMWWVLnbAK3ZRbioCYF4H47ya1yQqYdrj3ZpiNLcLy2Oj8suAeVDyJFqIwDHFlsCfrgSnr1OJ5kWRkb5y/BW0S/33MTJpewAc7RztE/Dv5UfpY15GjVlY5LRj8L3oXmfF8qOvKAnzoo4pxqZMnKXCZkbyAf8FRYEF2e/J29KrzWBBVTAQMhMNqaHMw55Q2U8Cv5/eioPw1YmHYDU9E7+xmTM0KLK1/fcRQCnmFY8nVs3FRL1DdkIowjSWV5OwiWrHKYcMth5Kc556XI6VN46zOKYCEdjRwNuz9o3bCTCTeHmVFYspcwey3dRkzlKKJpmg69kQlM+5V/Ts+PY4yQ0eY0yx/vcZAmdp3H3t6LddHiOSd811z9SlsGK0YvbDgx8iRjEPFnwRlm6aly1fBYXWlk3JxvzOJCYwTqo2Q3xi4wMRA6FVwDRmDlazMSZ/qu+l5QbkXqfXAK4dCZbxksr9IXUINDcakLK3laD+iTBw+PSRfb0nyKkOoq+KbBZHRI94cGEm1Pn0Q88gPME2X02lfiMNe3fPwGPMlf6D0Ovykl0DI+IvahRqBE5h4LhneHD9j6PKuJzSy83EKsS+uLQ7nuYGoNpZFu/8wNEdkylVpCvY4SRVmAcmzBcbvv8V1NZek6sYY8BtKY7cCeyIr9wq9MMw3ZypMufpE1n5wIaQ+Y9Smgc9Vb8eV5Gsjsaam6mhzwJ38HqKmFBWVf886MhE4OuuBUEI76EeSXf3TAG+MIFqoKwfqJsLmtznDNBGDbmqC9g1J6jTplMH0zPl95LNL0SMS6+ObnVPHxJ/QBExSMbIvW7yTQ03dX2WawBbdNTee41Ev6cGQsXWnMjnOfAlIfwj1z6ZB2umYeAsRtju+0idLmqARnLnVDp2oIn4nAckkT8qoOMOVw81xvL5UYoepJREOr/k3X3tfLtFeaY4VtBrpGvUIo37hWYiDHP/vqdR7yzrkx3O3b/UVsV3JLGTIjf/brGErTsNmly+IAkR4MzwbQfezCxgCcpalHS/3GWUTWf3ureWok3q7aVj8slI7zmfFMpgdLFvVekdKUCVsF/1BMhB6gwfWuV'), privateKey(), aad), + encoder.encode('testdata') + ) + }) +}) + +describe('encapsulate/decapsulate', () => { + test('should give the same shared secret on both sides', () => { + const keypair: KeyPair = generateKeyPair(KeyVersion.V2) + const result = encapsulate(keypair.public) + const recovered: SecretKey = decapsulate(keypair.private, result.encapsulatedKey) + + assert.strictEqual(result.encapsulatedKey.length, 1128) + assert.strictEqual(result.secretKey.bytes.length, 40) + assert.deepStrictEqual(recovered.bytes, result.secretKey.bytes) + }) + + test('should accept an explicit version', () => { + const keypair: KeyPair = generateKeyPair(KeyVersion.V2) + const result = encapsulate(keypair.public, KeyEncapsulationVersion.V1) + assert.deepStrictEqual(decapsulate(keypair.private, result.encapsulatedKey).bytes, result.secretKey.bytes) + }) + + test('should give a different secret with the wrong private key', () => { + const keypair: KeyPair = generateKeyPair(KeyVersion.V2) + const other: KeyPair = generateKeyPair(KeyVersion.V2) + const result = encapsulate(keypair.public) + + assert.notDeepStrictEqual(decapsulate(other.private, result.encapsulatedKey).bytes, result.secretKey.bytes) + }) + + test('should reject a V1 public key', () => { + const keypair: KeyPair = generateKeyPair(KeyVersion.V1) + assert.throws(() => encapsulate(keypair.public)) + }) + + test('should produce a secret key usable for symmetric encryption', () => { + const keypair: KeyPair = generateKeyPair(KeyVersion.V2) + const result = encapsulate(keypair.public) + const recovered: SecretKey = decapsulate(keypair.private, result.encapsulatedKey) + const input: Uint8Array = encoder.encode('symmetric') + + const encrypted: Uint8Array = encryptWithSecretKey(input, result.secretKey) + assert.deepStrictEqual(decryptWithSecretKey(encrypted, recovered), input) + }) + + test('should decapsulate the conformity vector', () => { + const privateKey: PrivateKey = PrivateKey.fromBytes(base64decode(xWingPrivateKey)) + const secret: SecretKey = decapsulate(privateKey, base64decode('DQwKAAAAAQAdAidiTcgaZ0lFtIEr7TWNrzDq7XkFBWLSDqU+tWi68zmUXyMMxwXPSwYuX0QLAhArzNyMJXqo88zrYR+AfAeeeBVpRLq4naSDQ12j0d3gsNFbhBRzCbaa/WL92DwNQSZoA3Z1bSToRkxepJW7kcqTHs6DfFn9WAWBpU8Qul3iHWfiFqY0/xNdzMfb0H61qaLlB9C762rXwqKw5jGF1XOih6q2jwZjKOp7bZK+JNXMTb9X5BqJl/spn3FKLIhOLp85M26hDdJM1THqNCkm8/xYWVA6alkdfL9ZGE1IxJv9i8B5rW0qK7sG54a+ZuGnSaf3TZiSSR1D519WHttp+rlI/yNNWXxsOcmZmOEKw4DX2XQS5IQ9S1wflnaBwRVyBIfMUILnKSWZ0EwlgCs6XKnFy/5NqRNyZbDSAcJ+ROA/8H7yMrYQV0ilwl9mXAx9Ip9AHYsrrjC2IM3iWUBR6gqM7bwmBVb/eN51WwSXK8Vcy4FPeeii6VvFND/rEiiERhmRBRFhDNYmf5tARfBgccPw62GO56IjkBuyZhoUZ6DB8Uy8nVxcoe0hpN6fiCW10S8cBWdKtyJObkl9oqRbxmB21bKSJS/yz3owxqivTSaaLAT5HQy6FnPFYoOoG57Vp/WEIU00C5GwuAs6MUbuBBiZtjeJMRkV/HJidgH1WPIx0Xs7EKf/N4kkVok2D9ZdL9jSbLV69QR5SeW8M2U9B+1oqZghXpI4en20WEvGxEJ1ALCd2VlQqK76vLZpXX0DSlhMv14VzR7fUiZiurpH8S/tbieCMijdeWt9a5lnWTzWE0IVgALHLH2T82R6RJSIbBE7EwWziYO3Vm0Tus3iY+GxzSRqaBHc0oCsbFDFG3BkWF7gWOeTElnz7taRRqKnolN4VWfPRQ6Glmq8ml/83HTQFJfW7yI5jKp+I9vSCZ/tAcOTaneUDZ6cveDfPIWnLsSld2t/1MtXlzbRHGqGfe3nyU94TU7WDeFGcgL+EykMu58RVxVT5yefqy6kTlnkUWqr7j2EwrayzT0D5d4dvzJp38SXDsHL2/W2bsmv7WipnjFAp8xh0gNd5AGysTf+xJ8+Qv6D/aQF8JgEvVn+E7i5JcAWFIHelVupk6JJPX7KNalqN3LqBwuFm2gb7rTkdGWGoFw23+1NeyfuLPWSPq79NuzKw/5PqhQWlwDxOC88aF8lYLB3QrT8AcF/2ciwaT2j33YpRdhkFVbPrwNUPFsVV+LA4NplDywB81mABsfS3JSHhiXPLAKZhHV56qmM2gFG5dicrHOm6k7s3mDAfH/LUNrHgUs7ihYr3BzCocIfq//1RDf9NmJWj9rKbj3NpeAGnkMD68W82xv5SAYpkC6E5mZMJD1O6CBmgnPkLc6/YNxWfE/O37o18gTvjpYZourUxsX1BwAnoLjz+nptQJqJHzJeeA/GqaaoP+8F+LyEAxKFBIFD/XYK4xJ6N3v4OtforFb9QtvwCoMKnZvbeMEd')) + + // SecretKey bytes are a serialized key: 8-byte header followed by the 32 key bytes. + assert.deepStrictEqual(secret.bytes.slice(8), base64decode('hqnsrwF5qEgJsI0G4aURCcbbsshKl7Z590eicp0ArtA=')) + }) +})