//! E2E-encrypted credential issuance helpers. //! //! Uses X25519 ECDH + HKDF-SHA256 + ChaCha20-Poly1305 to encrypt //! blind commitments and signatures between the user and the admin, //! so the server (acting as a relay) cannot read or substitute them. use chacha20poly1305::aead::{Aead, KeyInit}; use chacha20poly1305::ChaCha20Poly1305; use hkdf::Hkdf; use rand::rngs::OsRng; use rand::{CryptoRng, RngCore}; use sha2::Sha256; use subtle::ConstantTimeEq; use x25519_dalek::{EphemeralSecret, PublicKey as X25519Public, StaticSecret}; use crate::{Error, Result}; const ISSUANCE_HKDF_INFO_USER_TO_ADMIN: &[u8] = b"zkac-issuance-v1:user-to-admin"; const ISSUANCE_HKDF_INFO_ADMIN_TO_USER: &[u8] = b"zkac-issuance-v1:admin-to-user"; const NONCE_LEN: usize = 12; /// Admin-side issuance keypair (X25519 static secret for DH). pub struct IssuanceKeypair { secret: StaticSecret, public: X25519Public, } impl IssuanceKeypair { pub fn generate(rng: &mut R) -> Self { let secret = StaticSecret::random_from_rng(rng); let public = X25519Public::from(&secret); Self { secret, public } } pub fn public_key_bytes(&self) -> [u8; 32] { *self.public.as_bytes() } pub fn from_secret_bytes(bytes: &[u8; 32]) -> Self { let secret = StaticSecret::from(*bytes); let public = X25519Public::from(&secret); Self { secret, public } } pub fn secret_bytes(&self) -> [u8; 32] { self.secret.to_bytes() } /// Decrypt a blob sent by a user using their ephemeral public key. pub fn decrypt(&self, eph_pk_bytes: &[u8; 32], ciphertext: &[u8]) -> Result> { let eph_pk = X25519Public::from(*eph_pk_bytes); let shared = self.secret.diffie_hellman(&eph_pk); if is_zero(shared.as_bytes()) { return Err(Error::CredentialError("issuance DH produced zero shared secret".into())); } let key = derive_key(shared.as_bytes(), ISSUANCE_HKDF_INFO_USER_TO_ADMIN); let cipher = ChaCha20Poly1305::new_from_slice(&key) .map_err(|_| Error::CredentialError("issuance key derivation failed".into()))?; decrypt_with_prefixed_nonce(&cipher, ciphertext) } /// Encrypt a response to the user (uses same shared secret). pub fn encrypt(&self, eph_pk_bytes: &[u8; 32], plaintext: &[u8]) -> Result> { let eph_pk = X25519Public::from(*eph_pk_bytes); let shared = self.secret.diffie_hellman(&eph_pk); if is_zero(shared.as_bytes()) { return Err(Error::CredentialError("issuance DH produced zero shared secret".into())); } let key = derive_key(shared.as_bytes(), ISSUANCE_HKDF_INFO_ADMIN_TO_USER); let cipher = ChaCha20Poly1305::new_from_slice(&key) .map_err(|_| Error::CredentialError("issuance key derivation failed".into()))?; encrypt_with_random_nonce(&cipher, plaintext) } } /// User-side: encrypt a commitment for the admin's issuance public key. /// Returns `(ephemeral_public_key, ciphertext)`. pub fn encrypt_for_admin( rng: R, admin_issuance_pk: &[u8; 32], plaintext: &[u8], ) -> Result<([u8; 32], Vec)> { let eph_secret = EphemeralSecret::random_from_rng(rng); let eph_public = X25519Public::from(&eph_secret); let admin_pk = X25519Public::from(*admin_issuance_pk); let shared = eph_secret.diffie_hellman(&admin_pk); if is_zero(shared.as_bytes()) { return Err(Error::CredentialError("issuance DH produced zero shared secret".into())); } let key = derive_key(shared.as_bytes(), ISSUANCE_HKDF_INFO_USER_TO_ADMIN); let cipher = ChaCha20Poly1305::new_from_slice(&key) .map_err(|_| Error::CredentialError("issuance key derivation failed".into()))?; let ciphertext = encrypt_with_random_nonce(&cipher, plaintext)?; Ok((*eph_public.as_bytes(), ciphertext)) } /// User-side: decrypt the admin's response using the same shared secret. pub fn decrypt_from_admin( eph_secret_bytes: &[u8; 32], admin_issuance_pk: &[u8; 32], ciphertext: &[u8], ) -> Result> { let eph_secret = StaticSecret::from(*eph_secret_bytes); let admin_pk = X25519Public::from(*admin_issuance_pk); let shared = eph_secret.diffie_hellman(&admin_pk); if is_zero(shared.as_bytes()) { return Err(Error::CredentialError("issuance DH produced zero shared secret".into())); } let key = derive_key(shared.as_bytes(), ISSUANCE_HKDF_INFO_ADMIN_TO_USER); let cipher = ChaCha20Poly1305::new_from_slice(&key) .map_err(|_| Error::CredentialError("issuance key derivation failed".into()))?; decrypt_with_prefixed_nonce(&cipher, ciphertext) } fn derive_key(shared_secret: &[u8], info: &[u8]) -> [u8; 32] { let hk = Hkdf::::new(None, shared_secret); let mut key = [0u8; 32]; hk.expand(info, &mut key) .expect("HKDF expand should not fail for 32 bytes"); key } fn encrypt_with_random_nonce(cipher: &ChaCha20Poly1305, plaintext: &[u8]) -> Result> { let mut nonce = [0u8; NONCE_LEN]; OsRng.fill_bytes(&mut nonce); let mut out = Vec::with_capacity(NONCE_LEN + plaintext.len() + 16); out.extend_from_slice(&nonce); let mut ct = cipher .encrypt(&nonce.into(), plaintext) .map_err(|_| Error::CredentialError("issuance encryption failed".into()))?; out.append(&mut ct); Ok(out) } fn decrypt_with_prefixed_nonce(cipher: &ChaCha20Poly1305, blob: &[u8]) -> Result> { if blob.len() < NONCE_LEN { return Err(Error::DecryptionFailed); } let (nonce, ciphertext) = blob.split_at(NONCE_LEN); let mut nonce_arr = [0u8; NONCE_LEN]; nonce_arr.copy_from_slice(nonce); cipher .decrypt(&nonce_arr.into(), ciphertext) .map_err(|_| Error::DecryptionFailed) } fn is_zero(bytes: &[u8; 32]) -> bool { bytes.ct_eq(&[0u8; 32]).into() } #[cfg(test)] mod tests { use super::*; use rand::rngs::OsRng; #[test] fn user_admin_roundtrip() { let admin_kp = IssuanceKeypair::generate(&mut OsRng); let admin_pk = admin_kp.public_key_bytes(); let plaintext = b"commitment_with_proof data here"; let (eph_pk, ciphertext) = encrypt_for_admin(OsRng, &admin_pk, plaintext).unwrap(); let decrypted = admin_kp.decrypt(&eph_pk, &ciphertext).unwrap(); assert_eq!(decrypted, plaintext); // Admin responds let response = b"blind_signature_bytes"; let encrypted_response = admin_kp.encrypt(&eph_pk, response).unwrap(); // User needs the ephemeral secret to decrypt the response. // In practice, the user saves the EphemeralSecret bytes. // For testing, generate a fresh pair and use StaticSecret path. // Here we test the admin encrypt path is valid ciphertext: assert!(!encrypted_response.is_empty()); } #[test] fn full_issuance_e2e() { // Simulate the full flow with saved ephemeral secret let admin_kp = IssuanceKeypair::generate(&mut OsRng); let admin_pk = admin_kp.public_key_bytes(); // User side: use StaticSecret so we can save the bytes let user_secret = StaticSecret::random_from_rng(&mut OsRng); let user_public = X25519Public::from(&user_secret); let user_secret_bytes = user_secret.to_bytes(); let eph_pk = *user_public.as_bytes(); // Encrypt commitment let shared = user_secret.diffie_hellman(&X25519Public::from(admin_pk)); let key = derive_key(shared.as_bytes(), ISSUANCE_HKDF_INFO_USER_TO_ADMIN); let cipher = ChaCha20Poly1305::new_from_slice(&key).unwrap(); let commitment = b"test commitment"; let encrypted = encrypt_with_random_nonce(&cipher, commitment.as_slice()).unwrap(); // Admin decrypts let decrypted = admin_kp.decrypt(&eph_pk, &encrypted).unwrap(); assert_eq!(decrypted, commitment); // Admin encrypts response let response = b"blind sig"; let enc_response = admin_kp.encrypt(&eph_pk, response).unwrap(); // User decrypts response let dec_response = decrypt_from_admin(&user_secret_bytes, &admin_pk, &enc_response).unwrap(); assert_eq!(dec_response, response); } #[test] fn encrypt_uses_random_nonce_prefix() { let admin_kp = IssuanceKeypair::generate(&mut OsRng); let admin_pk = admin_kp.public_key_bytes(); let payload = b"same payload"; let (_eph_pk_a, c1) = encrypt_for_admin(OsRng, &admin_pk, payload).unwrap(); let (_eph_pk_b, c2) = encrypt_for_admin(OsRng, &admin_pk, payload).unwrap(); assert!(c1.len() > NONCE_LEN); assert!(c2.len() > NONCE_LEN); assert_ne!(&c1[..NONCE_LEN], &c2[..NONCE_LEN]); assert_ne!(c1, c2); } #[test] fn wrong_key_fails() { let admin_kp = IssuanceKeypair::generate(&mut OsRng); let other_kp = IssuanceKeypair::generate(&mut OsRng); let admin_pk = admin_kp.public_key_bytes(); let (eph_pk, ciphertext) = encrypt_for_admin(OsRng, &admin_pk, b"secret").unwrap(); assert!(other_kp.decrypt(&eph_pk, &ciphertext).is_err()); } #[test] fn keypair_serialization() { let kp = IssuanceKeypair::generate(&mut OsRng); let secret = kp.secret_bytes(); let pk = kp.public_key_bytes(); let kp2 = IssuanceKeypair::from_secret_bytes(&secret); assert_eq!(kp2.public_key_bytes(), pk); } }