diff --git a/Cargo.toml b/Cargo.toml index 95ef517..8d5e8d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "libpep" edition = "2021" -version = "0.10.1" +version = "0.11.0" authors = ["Bernard van Gastel ", "Job Doesburg "] homepage = "https://github.com/NOLAI/libpep" repository = "https://github.com/NOLAI/libpep" diff --git a/package.json b/package.json index 74ef726..a45a6e7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@nolai/libpep-wasm", - "version": "0.10.1", + "version": "0.11.0", "description": "Library for polymorphic encryption and pseudonymization (in WASM)", "repository": { "type": "git", diff --git a/src/lib/client/prelude.rs b/src/lib/client/prelude.rs index f5893d9..70f1b79 100644 --- a/src/lib/client/prelude.rs +++ b/src/lib/client/prelude.rs @@ -6,6 +6,5 @@ pub use super::{decrypt, encrypt, Client}; #[cfg(feature = "offline")] pub use super::{encrypt_global, OfflineClient}; pub use crate::data::simple::{Attribute, EncryptedAttribute, EncryptedPseudonym, Pseudonym}; -pub use crate::factors::contexts::EncryptionContext; -pub use crate::factors::EncryptionSecret; +pub use crate::factors::contexts::{EncryptionContext, PseudonymizationDomain}; pub use crate::keys::{GlobalPublicKeys, SessionKeys}; diff --git a/src/lib/data/json/builder.rs b/src/lib/data/json/builder.rs index 67d4d8c..01b0d7c 100644 --- a/src/lib/data/json/builder.rs +++ b/src/lib/data/json/builder.rs @@ -90,9 +90,101 @@ impl PEPJSONBuilder { /// Add a string field as a pseudonym. pub fn pseudonym(mut self, key: &str, value: &str) -> Self { - let pseudo = LongPseudonym::from_string_padded(value); - self.fields - .insert(key.to_string(), PEPJSONValue::Pseudonym(pseudo)); + use crate::data::padding::Padded; + use crate::data::simple::{ElGamalEncryptable, Pseudonym}; + + // Try to decode as a direct 32-byte pseudonym value (hex string) + if let Some(pseudo) = Pseudonym::from_hex(value) { + self.fields + .insert(key.to_string(), PEPJSONValue::Pseudonym(pseudo)); + return self; + } + + // Try to decode as multi-block pseudonym (hex string with multiple 64-char blocks) + // Each block is 32 bytes = 64 hex chars + if value.len() > 64 && value.len().is_multiple_of(64) { + let num_blocks = value.len() / 64; + let mut blocks = Vec::with_capacity(num_blocks); + let mut all_decoded = true; + + for i in 0..num_blocks { + let start = i * 64; + let end = start + 64; + if let Some(block) = Pseudonym::from_hex(&value[start..end]) { + blocks.push(block); + } else { + all_decoded = false; + break; + } + } + + if all_decoded { + self.fields.insert( + key.to_string(), + PEPJSONValue::LongPseudonym(LongPseudonym(blocks)), + ); + return self; + } + } + + // Try to decode as 32 raw bytes + if value.len() == 32 { + if let Some(pseudo) = Pseudonym::from_slice(value.as_bytes()) { + self.fields + .insert(key.to_string(), PEPJSONValue::Pseudonym(pseudo)); + return self; + } + } + + // Try to decode as multi-block pseudonym (raw bytes, multiple of 32) + let bytes = value.as_bytes(); + if bytes.len() > 32 && bytes.len().is_multiple_of(32) { + let num_blocks = bytes.len() / 32; + let mut blocks = Vec::with_capacity(num_blocks); + let mut all_decoded = true; + + for i in 0..num_blocks { + let start = i * 32; + let end = start + 32; + if let Some(block) = Pseudonym::from_slice(&bytes[start..end]) { + blocks.push(block); + } else { + all_decoded = false; + break; + } + } + + if all_decoded { + self.fields.insert( + key.to_string(), + PEPJSONValue::LongPseudonym(LongPseudonym(blocks)), + ); + return self; + } + } + + // Check if it fits in a single block with PKCS#7 padding (≤15 bytes) + if bytes.len() <= 15 { + // Use PKCS#7 padding for short strings + match Pseudonym::from_string_padded(value) { + Ok(pseudo) => { + self.fields + .insert(key.to_string(), PEPJSONValue::Pseudonym(pseudo)); + } + Err(_) => { + // Fallback to long pseudonym if padding fails + let pseudo = LongPseudonym::from_string_padded(value); + self.fields + .insert(key.to_string(), PEPJSONValue::LongPseudonym(pseudo)); + } + } + } else { + // Use long pseudonym for strings > 15 bytes + let pseudo = LongPseudonym::from_string_padded(value); + self.fields + .insert(key.to_string(), PEPJSONValue::LongPseudonym(pseudo)); + } + self } diff --git a/src/lib/data/json/data.rs b/src/lib/data/json/data.rs index d3f99a4..1c8a4d0 100644 --- a/src/lib/data/json/data.rs +++ b/src/lib/data/json/data.rs @@ -7,7 +7,7 @@ use crate::data::long::{ LongAttribute, LongEncryptedAttribute, LongEncryptedPseudonym, LongPseudonym, }; use crate::data::padding::Padded; -use crate::data::simple::{Attribute, EncryptedAttribute}; +use crate::data::simple::{Attribute, EncryptedAttribute, EncryptedPseudonym, Pseudonym}; use crate::data::traits::{Encryptable, Encrypted, Transcryptable}; use crate::factors::RerandomizeFactor; use crate::factors::TranscryptionInfo; @@ -60,8 +60,14 @@ pub enum PEPJSONValue { Null, Bool(Attribute), Number(Attribute), - String(LongAttribute), - Pseudonym(LongPseudonym), + /// Short string that fits in a single block (≤15 bytes) + String(Attribute), + /// Long string that requires multiple blocks + LongString(LongAttribute), + /// Short pseudonym from 32-byte value + Pseudonym(Pseudonym), + /// Long pseudonym (multiple 32-byte values or lizard-encoded) + LongPseudonym(LongPseudonym), Array(Vec), Object(HashMap), } @@ -83,8 +89,14 @@ pub enum EncryptedPEPJSONValue { Null, Bool(EncryptedAttribute), Number(EncryptedAttribute), - String(LongEncryptedAttribute), - Pseudonym(LongEncryptedPseudonym), + /// Short string that fits in a single block (≤15 bytes) + String(EncryptedAttribute), + /// Long string that requires multiple blocks + LongString(LongEncryptedAttribute), + /// Short pseudonym from 32-byte value + Pseudonym(EncryptedPseudonym), + /// Long pseudonym (multiple 32-byte values or lizard-encoded) + LongPseudonym(LongEncryptedPseudonym), Array(Vec), Object(HashMap), } @@ -126,12 +138,24 @@ impl PEPJSONValue { .map_err(|e| JsonError::StringPadding(format!("{e:?}")))?; Ok(Value::String(string_val)) } + Self::LongString(attr) => { + let string_val = attr + .to_string_padded() + .map_err(|e| JsonError::StringPadding(format!("{e:?}")))?; + Ok(Value::String(string_val)) + } Self::Pseudonym(pseudo) => { let string_val = pseudo .to_string_padded() .unwrap_or_else(|_| pseudo.to_hex()); Ok(Value::String(string_val)) } + Self::LongPseudonym(pseudo) => { + let string_val = pseudo + .to_string_padded() + .unwrap_or_else(|_| pseudo.to_hex()); + Ok(Value::String(string_val)) + } Self::Array(arr) => { let json_arr = arr .iter() @@ -172,7 +196,19 @@ impl PEPJSONValue { .expect("9 bytes always fits in 16-byte block"); Self::Number(attr) } - Value::String(s) => Self::String(LongAttribute::from_string_padded(s)), + Value::String(s) => { + // Check if string fits in a single block (≤15 bytes with PKCS#7 padding) + if s.len() <= 15 { + // Try to create a short string + match Attribute::from_string_padded(s) { + Ok(attr) => Self::String(attr), + Err(_) => Self::LongString(LongAttribute::from_string_padded(s)), + } + } else { + // Use long string for strings > 15 bytes + Self::LongString(LongAttribute::from_string_padded(s)) + } + } Value::Array(arr) => { let mut out = Vec::with_capacity(arr.len()); out.extend(arr.iter().map(Self::from_value)); @@ -207,12 +243,18 @@ impl Encryptable for PEPJSONValue { PEPJSONValue::Number(attr) => { EncryptedPEPJSONValue::Number(attr.encrypt(&keys.attribute.public, rng)) } - PEPJSONValue::String(long_attr) => { - EncryptedPEPJSONValue::String(long_attr.encrypt(&keys.attribute.public, rng)) + PEPJSONValue::String(attr) => { + EncryptedPEPJSONValue::String(attr.encrypt(&keys.attribute.public, rng)) } - PEPJSONValue::Pseudonym(long_pseudo) => { - EncryptedPEPJSONValue::Pseudonym(long_pseudo.encrypt(&keys.pseudonym.public, rng)) + PEPJSONValue::LongString(long_attr) => { + EncryptedPEPJSONValue::LongString(long_attr.encrypt(&keys.attribute.public, rng)) } + PEPJSONValue::Pseudonym(pseudo) => { + EncryptedPEPJSONValue::Pseudonym(pseudo.encrypt(&keys.pseudonym.public, rng)) + } + PEPJSONValue::LongPseudonym(long_pseudo) => EncryptedPEPJSONValue::LongPseudonym( + long_pseudo.encrypt(&keys.pseudonym.public, rng), + ), PEPJSONValue::Array(arr) => EncryptedPEPJSONValue::Array( arr.iter().map(|item| item.encrypt(keys, rng)).collect(), ), @@ -237,10 +279,16 @@ impl Encryptable for PEPJSONValue { PEPJSONValue::Number(attr) => { EncryptedPEPJSONValue::Number(attr.encrypt_global(&public_key.attribute, rng)) } - PEPJSONValue::String(long_attr) => { - EncryptedPEPJSONValue::String(long_attr.encrypt_global(&public_key.attribute, rng)) + PEPJSONValue::String(attr) => { + EncryptedPEPJSONValue::String(attr.encrypt_global(&public_key.attribute, rng)) } - PEPJSONValue::Pseudonym(long_pseudo) => EncryptedPEPJSONValue::Pseudonym( + PEPJSONValue::LongString(long_attr) => EncryptedPEPJSONValue::LongString( + long_attr.encrypt_global(&public_key.attribute, rng), + ), + PEPJSONValue::Pseudonym(pseudo) => { + EncryptedPEPJSONValue::Pseudonym(pseudo.encrypt_global(&public_key.pseudonym, rng)) + } + PEPJSONValue::LongPseudonym(long_pseudo) => EncryptedPEPJSONValue::LongPseudonym( long_pseudo.encrypt_global(&public_key.pseudonym, rng), ), PEPJSONValue::Array(arr) => EncryptedPEPJSONValue::Array( @@ -277,9 +325,15 @@ impl Encrypted for EncryptedPEPJSONValue { EncryptedPEPJSONValue::String(enc) => { Some(PEPJSONValue::String(enc.decrypt(&keys.attribute.secret)?)) } + EncryptedPEPJSONValue::LongString(enc) => Some(PEPJSONValue::LongString( + enc.decrypt(&keys.attribute.secret)?, + )), EncryptedPEPJSONValue::Pseudonym(enc) => Some(PEPJSONValue::Pseudonym( enc.decrypt(&keys.pseudonym.secret)?, )), + EncryptedPEPJSONValue::LongPseudonym(enc) => Some(PEPJSONValue::LongPseudonym( + enc.decrypt(&keys.pseudonym.secret)?, + )), EncryptedPEPJSONValue::Array(arr) => { let mut out = Vec::with_capacity(arr.len()); for item in arr { @@ -309,9 +363,15 @@ impl Encrypted for EncryptedPEPJSONValue { EncryptedPEPJSONValue::String(enc) => { PEPJSONValue::String(enc.decrypt(&keys.attribute.secret)) } + EncryptedPEPJSONValue::LongString(enc) => { + PEPJSONValue::LongString(enc.decrypt(&keys.attribute.secret)) + } EncryptedPEPJSONValue::Pseudonym(enc) => { PEPJSONValue::Pseudonym(enc.decrypt(&keys.pseudonym.secret)) } + EncryptedPEPJSONValue::LongPseudonym(enc) => { + PEPJSONValue::LongPseudonym(enc.decrypt(&keys.pseudonym.secret)) + } EncryptedPEPJSONValue::Array(arr) => { PEPJSONValue::Array(arr.iter().map(|x| x.decrypt(keys)).collect()) } @@ -340,9 +400,15 @@ impl Encrypted for EncryptedPEPJSONValue { EncryptedPEPJSONValue::String(enc) => Some(PEPJSONValue::String( enc.decrypt_global(&secret_key.attribute)?, )), + EncryptedPEPJSONValue::LongString(enc) => Some(PEPJSONValue::LongString( + enc.decrypt_global(&secret_key.attribute)?, + )), EncryptedPEPJSONValue::Pseudonym(enc) => Some(PEPJSONValue::Pseudonym( enc.decrypt_global(&secret_key.pseudonym)?, )), + EncryptedPEPJSONValue::LongPseudonym(enc) => Some(PEPJSONValue::LongPseudonym( + enc.decrypt_global(&secret_key.pseudonym)?, + )), EncryptedPEPJSONValue::Array(arr) => { let mut out = Vec::with_capacity(arr.len()); for item in arr { @@ -374,9 +440,15 @@ impl Encrypted for EncryptedPEPJSONValue { EncryptedPEPJSONValue::String(enc) => { PEPJSONValue::String(enc.decrypt_global(&secret_key.attribute)) } + EncryptedPEPJSONValue::LongString(enc) => { + PEPJSONValue::LongString(enc.decrypt_global(&secret_key.attribute)) + } EncryptedPEPJSONValue::Pseudonym(enc) => { PEPJSONValue::Pseudonym(enc.decrypt_global(&secret_key.pseudonym)) } + EncryptedPEPJSONValue::LongPseudonym(enc) => { + PEPJSONValue::LongPseudonym(enc.decrypt_global(&secret_key.pseudonym)) + } EncryptedPEPJSONValue::Array(arr) => { PEPJSONValue::Array(arr.iter().map(|x| x.decrypt_global(secret_key)).collect()) } @@ -423,9 +495,15 @@ impl Encrypted for EncryptedPEPJSONValue { EncryptedPEPJSONValue::String(enc) => { EncryptedPEPJSONValue::String(enc.rerandomize_known(factor)) } + EncryptedPEPJSONValue::LongString(enc) => { + EncryptedPEPJSONValue::LongString(enc.rerandomize_known(factor)) + } EncryptedPEPJSONValue::Pseudonym(enc) => { EncryptedPEPJSONValue::Pseudonym(enc.rerandomize_known(factor)) } + EncryptedPEPJSONValue::LongPseudonym(enc) => { + EncryptedPEPJSONValue::LongPseudonym(enc.rerandomize_known(factor)) + } EncryptedPEPJSONValue::Array(arr) => EncryptedPEPJSONValue::Array( arr.iter().map(|x| x.rerandomize_known(factor)).collect(), ), @@ -454,9 +532,15 @@ impl Encrypted for EncryptedPEPJSONValue { EncryptedPEPJSONValue::String(enc) => EncryptedPEPJSONValue::String( enc.rerandomize_known(&public_key.attribute.public, factor), ), + EncryptedPEPJSONValue::LongString(enc) => EncryptedPEPJSONValue::LongString( + enc.rerandomize_known(&public_key.attribute.public, factor), + ), EncryptedPEPJSONValue::Pseudonym(enc) => EncryptedPEPJSONValue::Pseudonym( enc.rerandomize_known(&public_key.pseudonym.public, factor), ), + EncryptedPEPJSONValue::LongPseudonym(enc) => EncryptedPEPJSONValue::LongPseudonym( + enc.rerandomize_known(&public_key.pseudonym.public, factor), + ), EncryptedPEPJSONValue::Array(arr) => EncryptedPEPJSONValue::Array( arr.iter() .map(|x| x.rerandomize_known(public_key, factor)) @@ -484,9 +568,15 @@ impl Transcryptable for EncryptedPEPJSONValue { EncryptedPEPJSONValue::String(enc) => { EncryptedPEPJSONValue::String(enc.transcrypt(info)) } + EncryptedPEPJSONValue::LongString(enc) => { + EncryptedPEPJSONValue::LongString(enc.transcrypt(info)) + } EncryptedPEPJSONValue::Pseudonym(enc) => { EncryptedPEPJSONValue::Pseudonym(enc.transcrypt(info)) } + EncryptedPEPJSONValue::LongPseudonym(enc) => { + EncryptedPEPJSONValue::LongPseudonym(enc.transcrypt(info)) + } EncryptedPEPJSONValue::Array(arr) => { EncryptedPEPJSONValue::Array(arr.iter().map(|x| x.transcrypt(info)).collect()) } diff --git a/src/lib/data/json/structure.rs b/src/lib/data/json/structure.rs index 0001f5a..5010bfe 100644 --- a/src/lib/data/json/structure.rs +++ b/src/lib/data/json/structure.rs @@ -31,8 +31,10 @@ impl EncryptedPEPJSONValue { EncryptedPEPJSONValue::Null => JSONStructure::Null, EncryptedPEPJSONValue::Bool(_) => JSONStructure::Bool, EncryptedPEPJSONValue::Number(_) => JSONStructure::Number, - EncryptedPEPJSONValue::String(enc) => JSONStructure::String(enc.len()), - EncryptedPEPJSONValue::Pseudonym(enc) => JSONStructure::Pseudonym(enc.len()), + EncryptedPEPJSONValue::String(_enc) => JSONStructure::String(1), + EncryptedPEPJSONValue::LongString(enc) => JSONStructure::String(enc.len()), + EncryptedPEPJSONValue::Pseudonym(_enc) => JSONStructure::Pseudonym(1), + EncryptedPEPJSONValue::LongPseudonym(enc) => JSONStructure::Pseudonym(enc.len()), EncryptedPEPJSONValue::Array(arr) => { JSONStructure::Array(arr.iter().map(|item| item.structure()).collect()) } diff --git a/tests/json.rs b/tests/json.rs index 278c803..eefb28e 100644 --- a/tests/json.rs +++ b/tests/json.rs @@ -21,8 +21,8 @@ fn test_json_transcryption_with_macro() { let pseudo_secret = PseudonymizationSecret::from("pseudo-secret".as_bytes().to_vec()); let enc_secret = EncryptionSecret::from("encryption-secret".as_bytes().to_vec()); - let domain_a = PseudonymizationDomain::from("hospital-a"); - let domain_b = PseudonymizationDomain::from("hospital-b"); + let domain_a = PseudonymizationDomain::from("domain-a"); + let domain_b = PseudonymizationDomain::from("domain-b"); let session = EncryptionContext::from("session-1"); let session_keys = make_session_keys(&global_secret, &session, &enc_secret); @@ -49,7 +49,7 @@ fn test_json_transcryption_with_macro() { assert_eq!(json_original["patient_id"], "patient-12345"); assert_eq!(json_original["diagnosis"], "Flu"); - // Transcrypt from hospital A to hospital B + // Transcrypt from domain A to domain B let transcryption_info = TranscryptionInfo::new( &domain_a, &domain_b, @@ -339,8 +339,8 @@ fn test_json_transcryption_with_client_and_transcryptor() { let pseudo_secret = PseudonymizationSecret::from("pseudo-secret".as_bytes().to_vec()); let enc_secret = EncryptionSecret::from("encryption-secret".as_bytes().to_vec()); - let domain_a = PseudonymizationDomain::from("hospital-a"); - let domain_b = PseudonymizationDomain::from("hospital-b"); + let domain_a = PseudonymizationDomain::from("domain-a"); + let domain_b = PseudonymizationDomain::from("domain-b"); let session = EncryptionContext::from("session-1"); let session_keys = make_session_keys(&global_secret, &session, &enc_secret); @@ -380,7 +380,7 @@ fn test_json_transcryption_with_client_and_transcryptor() { assert_eq!(json_original["diagnosis"], "Healthy"); assert_eq!(json_original["temperature"].as_f64().unwrap(), 36.6); - // Transcrypt from hospital A to hospital B using the transcryptor + // Transcrypt from domain A to domain B using the transcryptor let transcryption_info = transcryptor.transcryption_info(&domain_a, &domain_b, &session, &session); @@ -412,3 +412,342 @@ fn test_json_transcryption_with_client_and_transcryptor() { "Pseudonym should be different after cross-domain transcryption" ); } + +/// Test full round-trip: PEPJSON → Encrypt → Transcrypt → Decrypt → JSON → PEPJSON → Repeat +/// +/// This test demonstrates that pseudonyms maintain their correct type (short vs long) +/// after transcryption and JSON serialization round-trips. +#[test] +fn test_pseudonym_roundtrip_with_json_serialization() { + let mut rng = rand::rng(); + + // Setup keys and secrets + let (_global_public, global_secret) = make_global_keys(&mut rng); + let pseudonymization_secret = PseudonymizationSecret::from("pseudo-secret".as_bytes().to_vec()); + let encryption_secret = EncryptionSecret::from("encryption-secret".as_bytes().to_vec()); + + let session = EncryptionContext::from("session-1"); + let session_keys = make_session_keys(&global_secret, &session, &encryption_secret); + + // Create client and transcryptor + let client = libpep::client::Client::new(session_keys); + let transcryptor = libpep::transcryptor::Transcryptor::new( + pseudonymization_secret.clone(), + encryption_secret.clone(), + ); + + // Create PEPJSON with both short and long pseudonyms using pep_json! macro + let original_pep = pep_json!({ + "short_id": pseudonym("john"), // 4 bytes → single Pseudonym + "long_id": pseudonym("user@example.com"), // 17 bytes → LongPseudonym (2 blocks) + "name": "Alice", + "age": 30 + }); + + // 1. Encrypt the PEPJSON using the client + let encrypted = client.encrypt(&original_pep, &mut rng); + + // 2. Transcrypt (pseudonymize + rekey) the pseudonyms + let domain_from = PseudonymizationDomain::from("domain-a"); + let domain_to = PseudonymizationDomain::from("domain-b"); + + let transcryption_info = + transcryptor.transcryption_info(&domain_from, &domain_to, &session, &session); + + let transcrypted = transcryptor.transcrypt(&encrypted, &transcryption_info); + + // 3. Decrypt back to PEPJSONValue using the client + #[cfg(feature = "elgamal3")] + let decrypted = client.decrypt(&transcrypted).unwrap(); + #[cfg(not(feature = "elgamal3"))] + let decrypted = client.decrypt(&transcrypted); + + // Verify structure is preserved (name and age should still be there) + let json_value = decrypted.to_value().unwrap(); + assert_eq!(json_value["name"], "Alice"); + assert_eq!(json_value["age"], 30); + + // Pseudonyms should be hex-encoded (64 chars for short, 128 for long) + let short_id_hex = json_value["short_id"].as_str().unwrap(); + let long_id_hex = json_value["long_id"].as_str().unwrap(); + assert_eq!( + short_id_hex.len(), + 64, + "Short pseudonym should be 64 hex chars" + ); + assert_eq!( + long_id_hex.len(), + 128, + "Long pseudonym should be 128 hex chars" + ); + + // 4. Create PEPJSON from the hex values using pep_json! macro + let transcrypted_pep = pep_json!({ + "short_id": pseudonym(short_id_hex), // Reconstructs as Pseudonym (1 block) + "long_id": pseudonym(long_id_hex), // Reconstructs as LongPseudonym (2 blocks) + "name": "Alice", + "age": 30 + }); + + // 5. Encrypt the transcrypted PEPJSON + let encrypted_b = client.encrypt(&transcrypted_pep, &mut rng); + + // 6. Transcrypt back (domain-b → domain-a) + let transcryption_info_back = + transcryptor.transcryption_info(&domain_to, &domain_from, &session, &session); + + let transcrypted_back = transcryptor.transcrypt(&encrypted_b, &transcryption_info_back); + + // 7. Decrypt and verify we get back the original values + #[cfg(feature = "elgamal3")] + let decrypted_back = client.decrypt(&transcrypted_back).unwrap(); + #[cfg(not(feature = "elgamal3"))] + let decrypted_back = client.decrypt(&transcrypted_back); + + let json_back = decrypted_back.to_value().unwrap(); + + // Should have the original pseudonym values + assert_eq!(json_back["short_id"], "john"); + assert_eq!(json_back["long_id"], "user@example.com"); + assert_eq!(json_back["name"], "Alice"); + assert_eq!(json_back["age"], 30); +} + +/// Test full round-trip using PEPJSONBuilder: Build → Encrypt → Transcrypt → Decrypt → Build → Repeat +/// +/// This test demonstrates the same flow as test_pseudonym_roundtrip_with_json_serialization +/// but uses the builder API instead of the pep_json! macro. +#[test] +fn test_pseudonym_roundtrip_with_builder() { + use libpep::data::json::data::PEPJSONValue; + + let mut rng = rand::rng(); + + // Setup keys and secrets + let (_global_public, global_secret) = make_global_keys(&mut rng); + let pseudonymization_secret = PseudonymizationSecret::from("pseudo-secret".as_bytes().to_vec()); + let encryption_secret = EncryptionSecret::from("encryption-secret".as_bytes().to_vec()); + + let session = EncryptionContext::from("session-1"); + let session_keys = make_session_keys(&global_secret, &session, &encryption_secret); + + // Create client and transcryptor + let client = libpep::client::Client::new(session_keys); + let transcryptor = libpep::transcryptor::Transcryptor::new( + pseudonymization_secret.clone(), + encryption_secret.clone(), + ); + + // 1. Create PEPJSON with both short and long pseudonyms using the builder + let original_pep = PEPJSONBuilder::new() + .pseudonym("short_id", "john") // 4 bytes → Pseudonym (1 block) + .pseudonym("long_id", "user@example.com") // 17 bytes → LongPseudonym (2 blocks) + .attribute("name", json!("Alice")) + .attribute("age", json!(30)) + .build(); + + // Verify the types are correct after initial construction + if let PEPJSONValue::Object(fields) = &original_pep { + assert!(matches!( + fields.get("short_id"), + Some(PEPJSONValue::Pseudonym(_)) + )); + assert!( + matches!(fields.get("long_id"), Some(PEPJSONValue::LongPseudonym(lp)) if lp.len() == 2) + ); + } + + // 2. Encrypt the PEPJSON using the client + let encrypted = client.encrypt(&original_pep, &mut rng); + + // 3. Transcrypt (pseudonymize + rekey) the pseudonyms + let domain_from = PseudonymizationDomain::from("domain-a"); + let domain_to = PseudonymizationDomain::from("domain-b"); + + let transcryption_info = + transcryptor.transcryption_info(&domain_from, &domain_to, &session, &session); + + let transcrypted = transcryptor.transcrypt(&encrypted, &transcryption_info); + + // 4. Decrypt back to PEPJSONValue using the client + #[cfg(feature = "elgamal3")] + let decrypted = client.decrypt(&transcrypted).unwrap(); + #[cfg(not(feature = "elgamal3"))] + let decrypted = client.decrypt(&transcrypted); + + // Verify structure is preserved (name and age should still be there) + let json_value = decrypted.to_value().unwrap(); + assert_eq!(json_value["name"], "Alice"); + assert_eq!(json_value["age"], 30); + + // Pseudonyms should be hex-encoded (64 chars for short, 128 for long) + let short_id_hex = json_value["short_id"].as_str().unwrap(); + let long_id_hex = json_value["long_id"].as_str().unwrap(); + assert_eq!( + short_id_hex.len(), + 64, + "Short pseudonym should be 64 hex chars" + ); + assert_eq!( + long_id_hex.len(), + 128, + "Long pseudonym should be 128 hex chars" + ); + + // 5. Rebuild PEPJSON from the hex values using the builder + let transcrypted_pep = PEPJSONBuilder::new() + .pseudonym("short_id", short_id_hex) // Reconstructs as Pseudonym (1 block) + .pseudonym("long_id", long_id_hex) // Reconstructs as LongPseudonym (2 blocks) + .attribute("name", json!("Alice")) + .attribute("age", json!(30)) + .build(); + + // Verify the types are correct after reconstruction + if let PEPJSONValue::Object(fields) = &transcrypted_pep { + assert!(matches!( + fields.get("short_id"), + Some(PEPJSONValue::Pseudonym(_)) + )); + assert!(matches!( + fields.get("long_id"), + Some(PEPJSONValue::LongPseudonym(lp)) if lp.len() == 2 + )); + } + + // 6. Encrypt the transcrypted PEPJSON + let encrypted_b = client.encrypt(&transcrypted_pep, &mut rng); + + // 7. Transcrypt back (domain-b → domain-a) + let transcryption_info_back = + transcryptor.transcryption_info(&domain_to, &domain_from, &session, &session); + + let transcrypted_back = transcryptor.transcrypt(&encrypted_b, &transcryption_info_back); + + // 8. Decrypt and verify we get back the original values + #[cfg(feature = "elgamal3")] + let decrypted_back = client.decrypt(&transcrypted_back).unwrap(); + #[cfg(not(feature = "elgamal3"))] + let decrypted_back = client.decrypt(&transcrypted_back); + + let json_back = decrypted_back.to_value().unwrap(); + + // Should have the original pseudonym values + assert_eq!(json_back["short_id"], "john"); + assert_eq!(json_back["long_id"], "user@example.com"); + assert_eq!(json_back["name"], "Alice"); + assert_eq!(json_back["age"], 30); +} + +/// Test that unicode characters work correctly in pseudonyms and attributes +#[test] +fn test_unicode_pseudonyms_and_attributes() { + let mut rng = rand::rng(); + + // Setup keys and secrets + let (_global_public, global_secret) = make_global_keys(&mut rng); + let pseudonymization_secret = PseudonymizationSecret::from("pseudo-secret".as_bytes().to_vec()); + let encryption_secret = EncryptionSecret::from("encryption-secret".as_bytes().to_vec()); + + let session = EncryptionContext::from("session-1"); + let session_keys = make_session_keys(&global_secret, &session, &encryption_secret); + + // Create client and transcryptor + let client = libpep::client::Client::new(session_keys); + let transcryptor = libpep::transcryptor::Transcryptor::new( + pseudonymization_secret.clone(), + encryption_secret.clone(), + ); + + // Create PEPJSON with unicode characters + let original_pep = pep_json!({ + "emoji_id": pseudonym("🔒👤"), // Emoji (short, 8 bytes UTF-8) + "chinese_id": pseudonym("用户@例子.中国"), // Chinese email (long, ~21 bytes) + "arabic_name": "مرحبا بك", // Arabic attribute + "cyrillic_name": "Здравствуй", // Cyrillic attribute + "mixed": "Café™ ñoño 你好 🏥", // Mixed unicode attribute + "age": 25 + }); + + // 1. Encrypt + let encrypted = client.encrypt(&original_pep, &mut rng); + + // 2. Transcrypt to another domain + let domain_from = PseudonymizationDomain::from("domain-a"); + let domain_to = PseudonymizationDomain::from("domain-b"); + + let transcryption_info = + transcryptor.transcryption_info(&domain_from, &domain_to, &session, &session); + + let transcrypted = transcryptor.transcrypt(&encrypted, &transcryption_info); + + // 3. Decrypt + #[cfg(feature = "elgamal3")] + let decrypted = client.decrypt(&transcrypted).unwrap(); + #[cfg(not(feature = "elgamal3"))] + let decrypted = client.decrypt(&transcrypted); + + let json_value = decrypted.to_value().unwrap(); + + // Verify attributes with unicode preserved + assert_eq!(json_value["arabic_name"], "مرحبا بك"); + assert_eq!(json_value["cyrillic_name"], "Здравствуй"); + assert_eq!(json_value["mixed"], "Café™ ñoño 你好 🏥"); + assert_eq!(json_value["age"], 25); + + // Pseudonyms should be hex-encoded + let emoji_id_hex = json_value["emoji_id"].as_str().unwrap(); + let chinese_id_hex = json_value["chinese_id"].as_str().unwrap(); + + // Emoji fits in 1 block (8 bytes UTF-8) + assert_eq!( + emoji_id_hex.len(), + 64, + "Emoji pseudonym should be 64 hex chars" + ); + + // Chinese email is longer and needs multiple blocks + assert!( + chinese_id_hex.len() >= 64, + "Chinese email should need at least 1 block" + ); + assert_eq!( + chinese_id_hex.len() % 64, + 0, + "Should be multiple of 64 chars" + ); + + // 4. Reconstruct PEPJSON from hex values + let transcrypted_pep = pep_json!({ + "emoji_id": pseudonym(emoji_id_hex), + "chinese_id": pseudonym(chinese_id_hex), + "arabic_name": "مرحبا بك", + "cyrillic_name": "Здравствуй", + "mixed": "Café™ ñoño 你好 🏥", + "age": 25 + }); + + // 5. Encrypt again + let encrypted_b = client.encrypt(&transcrypted_pep, &mut rng); + + // 6. Transcrypt back + let transcryption_info_back = + transcryptor.transcryption_info(&domain_to, &domain_from, &session, &session); + + let transcrypted_back = transcryptor.transcrypt(&encrypted_b, &transcryption_info_back); + + // 7. Decrypt and verify original unicode values restored + #[cfg(feature = "elgamal3")] + let decrypted_back = client.decrypt(&transcrypted_back).unwrap(); + #[cfg(not(feature = "elgamal3"))] + let decrypted_back = client.decrypt(&transcrypted_back); + + let json_back = decrypted_back.to_value().unwrap(); + + // Verify all unicode preserved through full round-trip + assert_eq!(json_back["emoji_id"], "🔒👤"); + assert_eq!(json_back["chinese_id"], "用户@例子.中国"); + assert_eq!(json_back["arabic_name"], "مرحبا بك"); + assert_eq!(json_back["cyrillic_name"], "Здравствуй"); + assert_eq!(json_back["mixed"], "Café™ ñoño 你好 🏥"); + assert_eq!(json_back["age"], 25); +}