From b0d35e75f104710ec20366062d3bdfeef1de23c8 Mon Sep 17 00:00:00 2001 From: derekpierre Date: Thu, 30 Oct 2025 11:08:13 -0400 Subject: [PATCH 1/9] Move Session related secrets/keys to its own module so that it can be shared between dkg encrypted requests/responses and the eventual encrypted signing requests/responses. --- nucypher-core/src/dkg.rs | 464 +---------------------------------- nucypher-core/src/lib.rs | 10 +- nucypher-core/src/session.rs | 458 ++++++++++++++++++++++++++++++++++ 3 files changed, 474 insertions(+), 458 deletions(-) create mode 100644 nucypher-core/src/session.rs diff --git a/nucypher-core/src/dkg.rs b/nucypher-core/src/dkg.rs index 11fb7882..8488fffb 100644 --- a/nucypher-core/src/dkg.rs +++ b/nucypher-core/src/dkg.rs @@ -1,353 +1,18 @@ use alloc::boxed::Box; use alloc::string::String; -use core::fmt; -use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng}; -use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; use ferveo::api::{CiphertextHeader, FerveoVariant}; -use generic_array::typenum::Unsigned; use serde::{Deserialize, Serialize}; -use umbral_pre::serde_bytes; // TODO should this be in umbral? +use umbral_pre::serde_bytes; use crate::access_control::AccessControlPolicy; use crate::conditions::Context; -use crate::dkg::session::{SessionSharedSecret, SessionStaticKey}; +use crate::session::key::{SessionSharedSecret, SessionStaticKey}; +use crate::session::{decrypt_with_shared_secret, encrypt_with_shared_secret, DecryptionError}; use crate::versioning::{ - messagepack_deserialize, messagepack_serialize, DeserializationError, ProtocolObject, - ProtocolObjectInner, + messagepack_deserialize, messagepack_serialize, ProtocolObject, ProtocolObjectInner, }; -/// Errors during encryption. -#[derive(Debug)] -pub enum EncryptionError { - /// Given plaintext is too large for the backend to handle. - PlaintextTooLarge, -} - -impl fmt::Display for EncryptionError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::PlaintextTooLarge => write!(f, "Plaintext is too large to encrypt"), - } - } -} - -/// Errors during decryption. -#[derive(Debug)] -pub enum DecryptionError { - /// Ciphertext (which should be prepended by the nonce) is shorter than the nonce length. - CiphertextTooShort, - /// The ciphertext and the attached authentication data are inconsistent. - /// This can happen if: - /// - an incorrect key is used, - /// - the ciphertext is modified or cut short, - /// - an incorrect authentication data is provided on decryption. - AuthenticationFailed, - /// Unable to create object from decrypted ciphertext - DeserializationFailed(DeserializationError), -} - -impl fmt::Display for DecryptionError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::CiphertextTooShort => write!(f, "The ciphertext must include the nonce"), - Self::AuthenticationFailed => write!( - f, - "Decryption of ciphertext failed: \ - either someone tampered with the ciphertext or \ - you are using an incorrect decryption key." - ), - Self::DeserializationFailed(err) => write!(f, "deserialization failed: {err}"), - } - } -} - -type NonceSize = ::NonceSize; - -fn encrypt_with_shared_secret( - shared_secret: &SessionSharedSecret, - plaintext: &[u8], -) -> Result, EncryptionError> { - let key = Key::from_slice(shared_secret.as_ref()); - let cipher = ChaCha20Poly1305::new(key); - let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng); - let mut result = nonce.to_vec(); - let ciphertext = cipher - .encrypt(&nonce, plaintext.as_ref()) - .map_err(|_err| EncryptionError::PlaintextTooLarge)?; - result.extend(ciphertext); - Ok(result.into_boxed_slice()) -} - -fn decrypt_with_shared_secret( - shared_secret: &SessionSharedSecret, - ciphertext: &[u8], -) -> Result, DecryptionError> { - let nonce_size = ::to_usize(); - let buf_size = ciphertext.len(); - if buf_size < nonce_size { - return Err(DecryptionError::CiphertextTooShort); - } - let nonce = Nonce::from_slice(&ciphertext[..nonce_size]); - let encrypted_data = &ciphertext[nonce_size..]; - - let key = Key::from_slice(shared_secret.as_ref()); - let cipher = ChaCha20Poly1305::new(key); - let plaintext = cipher - .decrypt(nonce, encrypted_data) - .map_err(|_err| DecryptionError::AuthenticationFailed)?; - Ok(plaintext.into_boxed_slice()) -} - -/// Module for session key objects. -pub mod session { - use alloc::boxed::Box; - use alloc::string::String; - use core::fmt; - - use generic_array::{ - typenum::{Unsigned, U32}, - GenericArray, - }; - use rand::SeedableRng; - use rand_chacha::ChaCha20Rng; - use rand_core::{CryptoRng, OsRng, RngCore}; - use serde::{Deserialize, Deserializer, Serialize, Serializer}; - use umbral_pre::serde_bytes; - use x25519_dalek::{PublicKey, SharedSecret, StaticSecret}; - use zeroize::ZeroizeOnDrop; - - use crate::secret_box::{kdf, SecretBox}; - use crate::versioning::{ - messagepack_deserialize, messagepack_serialize, ProtocolObject, ProtocolObjectInner, - }; - - /// A Diffie-Hellman shared secret - #[derive(ZeroizeOnDrop)] - pub struct SessionSharedSecret { - derived_bytes: [u8; 32], - } - - /// Implementation of Diffie-Hellman shared secret - impl SessionSharedSecret { - /// Create new shared secret from underlying library. - pub fn new(shared_secret: SharedSecret) -> Self { - let info = b"SESSION_SHARED_SECRET_DERIVATION/"; - let derived_key = kdf::(shared_secret.as_bytes(), Some(info)); - let derived_bytes = <[u8; 32]>::try_from(derived_key.as_secret().as_slice()).unwrap(); - Self { derived_bytes } - } - - /// View this shared secret as a byte array. - pub fn as_bytes(&self) -> &[u8; 32] { - &self.derived_bytes - } - } - - impl AsRef<[u8]> for SessionSharedSecret { - /// View this shared secret as a byte array. - fn as_ref(&self) -> &[u8] { - self.as_bytes() - } - } - - impl fmt::Display for SessionSharedSecret { - /// Format shared secret information. - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "SessionSharedSecret...") - } - } - - /// A session public key. - #[derive(PartialEq, Eq, Hash, Copy, Clone, Debug)] - pub struct SessionStaticKey(PublicKey); - - /// Implementation of session static key - impl SessionStaticKey { - /// Convert this public key to a byte array. - pub fn to_bytes(&self) -> [u8; 32] { - self.0.to_bytes() - } - } - - impl AsRef<[u8]> for SessionStaticKey { - /// View this public key as a byte array. - fn as_ref(&self) -> &[u8] { - self.0.as_bytes() - } - } - - impl fmt::Display for SessionStaticKey { - /// Format public key information. - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "SessionStaticKey: {}", hex::encode(&self.as_ref()[..8])) - } - } - - impl Serialize for SessionStaticKey { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - serde_bytes::as_hex::serialize(self.0.as_bytes(), serializer) - } - } - - impl serde_bytes::TryFromBytes for SessionStaticKey { - type Error = core::array::TryFromSliceError; - fn try_from_bytes(bytes: &[u8]) -> Result { - let array: [u8; 32] = bytes.try_into()?; - Ok(SessionStaticKey(PublicKey::from(array))) - } - } - - impl<'a> Deserialize<'a> for SessionStaticKey { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'a>, - { - serde_bytes::as_hex::deserialize(deserializer) - } - } - - impl<'a> ProtocolObjectInner<'a> for SessionStaticKey { - fn version() -> (u16, u16) { - (2, 0) - } - - fn brand() -> [u8; 4] { - *b"TSSk" - } - - fn unversioned_to_bytes(&self) -> Box<[u8]> { - messagepack_serialize(&self) - } - - fn unversioned_from_bytes( - minor_version: u16, - bytes: &[u8], - ) -> Option> { - if minor_version == 0 { - Some(messagepack_deserialize(bytes)) - } else { - None - } - } - } - - impl<'a> ProtocolObject<'a> for SessionStaticKey {} - - /// A session secret key. - #[derive(ZeroizeOnDrop)] - pub struct SessionStaticSecret(pub(crate) StaticSecret); - - impl SessionStaticSecret { - /// Perform diffie-hellman - pub fn derive_shared_secret( - &self, - their_public_key: &SessionStaticKey, - ) -> SessionSharedSecret { - let shared_secret = self.0.diffie_hellman(&their_public_key.0); - SessionSharedSecret::new(shared_secret) - } - - /// Create secret key from RNG. - pub fn random_from_rng(csprng: &mut (impl RngCore + CryptoRng)) -> Self { - let secret_key = StaticSecret::random_from_rng(csprng); - Self(secret_key) - } - - /// Create random secret key. - pub fn random() -> Self { - Self::random_from_rng(&mut OsRng) - } - - /// Returns a public key corresponding to this secret key. - pub fn public_key(&self) -> SessionStaticKey { - let public_key = PublicKey::from(&self.0); - SessionStaticKey(public_key) - } - } - - impl fmt::Display for SessionStaticSecret { - /// Format information above secret key. - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "SessionStaticSecret:...") - } - } - - // the size of the seed material for key derivation - type SessionSecretFactorySeedSize = U32; - // the size of the derived key - type SessionSecretFactoryDerivedKeySize = U32; - type SessionSecretFactorySeed = GenericArray; - - /// Error thrown when invalid random seed provided for creating key factory. - #[derive(Debug)] - pub struct InvalidSessionSecretFactorySeedLength; - - impl fmt::Display for InvalidSessionSecretFactorySeedLength { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "Invalid seed length") - } - } - - /// This class handles keyring material for session keys, by allowing deterministic - /// derivation of `SessionStaticSecret` objects based on labels. - #[derive(Clone, ZeroizeOnDrop, PartialEq)] - pub struct SessionSecretFactory(SecretBox); - - impl SessionSecretFactory { - /// Creates a session secret factory using the given RNG. - pub fn random_with_rng(rng: &mut (impl CryptoRng + RngCore)) -> Self { - let mut bytes = SecretBox::new(SessionSecretFactorySeed::default()); - rng.fill_bytes(bytes.as_mut_secret()); - Self(bytes) - } - - /// Creates a session secret factory using the default RNG. - pub fn random() -> Self { - Self::random_with_rng(&mut OsRng) - } - - /// Returns the seed size required by - pub fn seed_size() -> usize { - SessionSecretFactorySeedSize::to_usize() - } - - /// Creates a `SessionSecretFactory` using the given random bytes. - /// - /// **Warning:** make sure the given seed has been obtained - /// from a cryptographically secure source of randomness! - pub fn from_secure_randomness( - seed: &[u8], - ) -> Result { - if seed.len() != Self::seed_size() { - return Err(InvalidSessionSecretFactorySeedLength); - } - Ok(Self(SecretBox::new(*SessionSecretFactorySeed::from_slice( - seed, - )))) - } - - /// Creates a `SessionStaticSecret` deterministically from the given label. - pub fn make_key(&self, label: &[u8]) -> SessionStaticSecret { - let prefix = b"SESSION_KEY_DERIVATION/"; - let info = [prefix, label].concat(); - let seed = kdf::(self.0.as_secret(), Some(&info)); - let mut rng = - ChaCha20Rng::from_seed(<[u8; 32]>::try_from(seed.as_secret().as_slice()).unwrap()); - SessionStaticSecret::random_from_rng(&mut rng) - } - } - - impl fmt::Display for SessionSecretFactory { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "SessionSecretFactory:...") - } - } -} - /// A request for an Ursula to derive a decryption share. #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize)] pub struct ThresholdDecryptionRequest { @@ -593,128 +258,17 @@ impl<'a> ProtocolObject<'a> for EncryptedThresholdDecryptionResponse {} #[cfg(test)] mod tests { - use ferveo::api::{encrypt as ferveo_encrypt, FerveoVariant, SecretBox}; - use generic_array::typenum::Unsigned; - use rand_core::RngCore; - use crate::access_control::AccessControlPolicy; use crate::conditions::{Conditions, Context}; - use crate::dkg::session::SessionStaticSecret; - use crate::dkg::{ - decrypt_with_shared_secret, encrypt_with_shared_secret, DecryptionError, NonceSize, - }; + use crate::session::key::SessionStaticSecret; use crate::test_utils::util::random_dkg_pubkey; - use crate::versioning::{ProtocolObject, ProtocolObjectInner}; + use crate::versioning::ProtocolObject; use crate::{ AuthenticatedData, EncryptedThresholdDecryptionRequest, - EncryptedThresholdDecryptionResponse, SessionSecretFactory, SessionStaticKey, - ThresholdDecryptionRequest, ThresholdDecryptionResponse, + EncryptedThresholdDecryptionResponse, ThresholdDecryptionRequest, + ThresholdDecryptionResponse, }; - - #[test] - fn decryption_with_shared_secret() { - let service_secret = SessionStaticSecret::random(); - - let requester_secret = SessionStaticSecret::random(); - let requester_public_key = requester_secret.public_key(); - - let service_shared_secret = service_secret.derive_shared_secret(&requester_public_key); - - let ciphertext = b"1".to_vec().into_boxed_slice(); // length less than nonce size - let nonce_size = ::to_usize(); - assert!(ciphertext.len() < nonce_size); - - assert!(matches!( - decrypt_with_shared_secret(&service_shared_secret, &ciphertext).unwrap_err(), - DecryptionError::CiphertextTooShort - )); - } - - #[test] - fn request_key_factory() { - let secret_factory = SessionSecretFactory::random(); - - // ensure that shared secret derived from factory can be used correctly - let label_1 = b"label_1".to_vec().into_boxed_slice(); - let service_secret_key = secret_factory.make_key(label_1.as_ref()); - let service_public_key = service_secret_key.public_key(); - - let label_2 = b"label_2".to_vec().into_boxed_slice(); - let requester_secret_key = secret_factory.make_key(label_2.as_ref()); - let requester_public_key = requester_secret_key.public_key(); - - let service_shared_secret = service_secret_key.derive_shared_secret(&requester_public_key); - let requester_shared_secret = - requester_secret_key.derive_shared_secret(&service_public_key); - - let data_to_encrypt = b"The Tyranny of Merit".to_vec().into_boxed_slice(); - let ciphertext = - encrypt_with_shared_secret(&requester_shared_secret, data_to_encrypt.as_ref()).unwrap(); - let decrypted_data = - decrypt_with_shared_secret(&service_shared_secret, &ciphertext).unwrap(); - assert_eq!(decrypted_data, data_to_encrypt); - - // ensure same key can be generated by the same factory using the same seed - let same_requester_secret_key = secret_factory.make_key(label_2.as_ref()); - let same_requester_public_key = same_requester_secret_key.public_key(); - assert_eq!(requester_public_key, same_requester_public_key); - - // ensure different key generated using same seed but using different factory - let other_secret_factory = SessionSecretFactory::random(); - let not_same_requester_secret_key = other_secret_factory.make_key(label_2.as_ref()); - let not_same_requester_public_key = not_same_requester_secret_key.public_key(); - assert_ne!(requester_public_key, not_same_requester_public_key); - - // ensure that two secret factories with the same seed generate the same keys - let mut secret_factory_seed = [0u8; 32]; - rand::thread_rng().fill_bytes(&mut secret_factory_seed); - let seeded_secret_factory_1 = - SessionSecretFactory::from_secure_randomness(&secret_factory_seed).unwrap(); - let seeded_secret_factory_2 = - SessionSecretFactory::from_secure_randomness(&secret_factory_seed).unwrap(); - - let key_label = b"seeded_factory_key_label".to_vec().into_boxed_slice(); - let sk_1 = seeded_secret_factory_1.make_key(&key_label); - let pk_1 = sk_1.public_key(); - - let sk_2 = seeded_secret_factory_2.make_key(&key_label); - let pk_2 = sk_2.public_key(); - - assert_eq!(pk_1, pk_2); - - // test secure randomness - let bytes = [0u8; 32]; - let factory = SessionSecretFactory::from_secure_randomness(&bytes); - assert!(factory.is_ok()); - - let bytes = [0u8; 31]; - let factory = SessionSecretFactory::from_secure_randomness(&bytes); - assert!(factory.is_err()); - } - - #[test] - fn session_static_key() { - let public_key_1: SessionStaticKey = SessionStaticSecret::random().public_key(); - let public_key_2: SessionStaticKey = SessionStaticSecret::random().public_key(); - - let public_key_1_bytes = public_key_1.unversioned_to_bytes(); - let public_key_2_bytes = public_key_2.unversioned_to_bytes(); - - // serialized public keys should always have the same length - assert_eq!(public_key_1_bytes.len(), public_key_2_bytes.len()); - - let deserialized_public_key_1 = - SessionStaticKey::unversioned_from_bytes(0, &public_key_1_bytes) - .unwrap() - .unwrap(); - let deserialized_public_key_2 = - SessionStaticKey::unversioned_from_bytes(0, &public_key_2_bytes) - .unwrap() - .unwrap(); - - assert_eq!(public_key_1, deserialized_public_key_1); - assert_eq!(public_key_2, deserialized_public_key_2); - } + use ferveo::api::{encrypt as ferveo_encrypt, FerveoVariant, SecretBox}; #[test] fn threshold_decryption_request() { diff --git a/nucypher-core/src/lib.rs b/nucypher-core/src/lib.rs index 58f2ddb3..1fb324d9 100644 --- a/nucypher-core/src/lib.rs +++ b/nucypher-core/src/lib.rs @@ -20,6 +20,7 @@ mod reencryption; mod retrieval_kit; mod revocation_order; mod secret_box; +mod session; mod signature_request; mod test_utils; mod threshold_message_kit; @@ -34,9 +35,8 @@ pub use access_control::{encrypt_for_dkg, AccessControlPolicy, AuthenticatedData pub use address::Address; pub use conditions::{Conditions, Context}; pub use dkg::{ - session::{SessionSecretFactory, SessionSharedSecret, SessionStaticKey, SessionStaticSecret}, - DecryptionError, EncryptedThresholdDecryptionRequest, EncryptedThresholdDecryptionResponse, - EncryptionError, ThresholdDecryptionRequest, ThresholdDecryptionResponse, + EncryptedThresholdDecryptionRequest, EncryptedThresholdDecryptionResponse, + ThresholdDecryptionRequest, ThresholdDecryptionResponse, }; pub use fleet_state::FleetStateChecksum; pub use hrac::HRAC; @@ -48,6 +48,10 @@ pub use node_metadata::{ pub use reencryption::{ReencryptionRequest, ReencryptionResponse}; pub use retrieval_kit::RetrievalKit; pub use revocation_order::RevocationOrder; +pub use session::{ + key::{SessionSecretFactory, SessionSharedSecret, SessionStaticKey, SessionStaticSecret}, + DecryptionError, EncryptionError, +}; pub use signature_request::{ deserialize_signature_request, AAVersion, BaseSignatureRequest, DirectSignatureRequest, PackedUserOperation, PackedUserOperationSignatureRequest, SignatureRequestType, diff --git a/nucypher-core/src/session.rs b/nucypher-core/src/session.rs new file mode 100644 index 00000000..5cc596cc --- /dev/null +++ b/nucypher-core/src/session.rs @@ -0,0 +1,458 @@ +use alloc::boxed::Box; +use core::fmt; + +use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng}; +use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; +use generic_array::typenum::Unsigned; + +use crate::session::key::SessionSharedSecret; +use crate::versioning::DeserializationError; + +/// Errors during encryption. +#[derive(Debug)] +pub enum EncryptionError { + /// Given plaintext is too large for the backend to handle. + PlaintextTooLarge, +} + +impl fmt::Display for EncryptionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::PlaintextTooLarge => write!(f, "Plaintext is too large to encrypt"), + } + } +} + +#[derive(Debug)] +/// Errors during decryption. +pub enum DecryptionError { + /// Ciphertext (which should be prepended by the nonce) is shorter than the nonce length. + CiphertextTooShort, + /// The ciphertext and the attached authentication data are inconsistent. + /// This can happen if: + /// - an incorrect key is used, + /// - the ciphertext is modified or cut short, + /// - an incorrect authentication data is provided on decryption. + AuthenticationFailed, + /// Unable to create object from decrypted ciphertext + DeserializationFailed(DeserializationError), +} + +impl fmt::Display for DecryptionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::CiphertextTooShort => write!(f, "The ciphertext must include the nonce"), + Self::AuthenticationFailed => write!( + f, + "Decryption of ciphertext failed: \ + either someone tampered with the ciphertext or \ + you are using an incorrect decryption key." + ), + Self::DeserializationFailed(err) => write!(f, "deserialization failed: {err}"), + } + } +} + +type NonceSize = ::NonceSize; + +pub fn encrypt_with_shared_secret( + shared_secret: &SessionSharedSecret, + plaintext: &[u8], +) -> Result, EncryptionError> { + let key = Key::from_slice(shared_secret.as_ref()); + let cipher = ChaCha20Poly1305::new(key); + let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng); + let mut result = nonce.to_vec(); + let ciphertext = cipher + .encrypt(&nonce, plaintext.as_ref()) + .map_err(|_err| EncryptionError::PlaintextTooLarge)?; + result.extend(ciphertext); + Ok(result.into_boxed_slice()) +} + +pub fn decrypt_with_shared_secret( + shared_secret: &SessionSharedSecret, + ciphertext: &[u8], +) -> Result, DecryptionError> { + let nonce_size = ::to_usize(); + let buf_size = ciphertext.len(); + if buf_size < nonce_size { + return Err(DecryptionError::CiphertextTooShort); + } + let nonce = Nonce::from_slice(&ciphertext[..nonce_size]); + let encrypted_data = &ciphertext[nonce_size..]; + + let key = Key::from_slice(shared_secret.as_ref()); + let cipher = ChaCha20Poly1305::new(key); + let plaintext = cipher + .decrypt(nonce, encrypted_data) + .map_err(|_err| DecryptionError::AuthenticationFailed)?; + Ok(plaintext.into_boxed_slice()) +} + +/// Module for session key objects. +pub mod key { + use alloc::boxed::Box; + use alloc::string::String; + use core::fmt; + + use generic_array::{ + typenum::{Unsigned, U32}, + GenericArray, + }; + use rand::SeedableRng; + use rand_chacha::ChaCha20Rng; + use rand_core::{CryptoRng, OsRng, RngCore}; + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + use umbral_pre::serde_bytes; + use x25519_dalek::{PublicKey, SharedSecret, StaticSecret}; + use zeroize::ZeroizeOnDrop; + + use crate::secret_box::{kdf, SecretBox}; + use crate::versioning::{ + messagepack_deserialize, messagepack_serialize, ProtocolObject, ProtocolObjectInner, + }; + + /// A Diffie-Hellman shared secret + #[derive(ZeroizeOnDrop)] + pub struct SessionSharedSecret { + derived_bytes: [u8; 32], + } + + /// Implementation of Diffie-Hellman shared secret + impl SessionSharedSecret { + /// Create new shared secret from underlying library. + pub fn new(shared_secret: SharedSecret) -> Self { + let info = b"SESSION_SHARED_SECRET_DERIVATION/"; + let derived_key = kdf::(shared_secret.as_bytes(), Some(info)); + let derived_bytes = <[u8; 32]>::try_from(derived_key.as_secret().as_slice()).unwrap(); + Self { derived_bytes } + } + + /// View this shared secret as a byte array. + pub fn as_bytes(&self) -> &[u8; 32] { + &self.derived_bytes + } + } + + impl AsRef<[u8]> for SessionSharedSecret { + /// View this shared secret as a byte array. + fn as_ref(&self) -> &[u8] { + self.as_bytes() + } + } + + impl fmt::Display for SessionSharedSecret { + /// Format shared secret information. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "SessionSharedSecret...") + } + } + + /// A session public key. + #[derive(PartialEq, Eq, Hash, Copy, Clone, Debug)] + pub struct SessionStaticKey(PublicKey); + + /// Implementation of session static key + impl SessionStaticKey { + /// Convert this public key to a byte array. + pub fn to_bytes(&self) -> [u8; 32] { + self.0.to_bytes() + } + } + + impl AsRef<[u8]> for SessionStaticKey { + /// View this public key as a byte array. + fn as_ref(&self) -> &[u8] { + self.0.as_bytes() + } + } + + impl fmt::Display for SessionStaticKey { + /// Format public key information. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "SessionStaticKey: {}", hex::encode(&self.as_ref()[..8])) + } + } + + impl Serialize for SessionStaticKey { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serde_bytes::as_hex::serialize(self.0.as_bytes(), serializer) + } + } + + impl serde_bytes::TryFromBytes for SessionStaticKey { + type Error = core::array::TryFromSliceError; + fn try_from_bytes(bytes: &[u8]) -> Result { + let array: [u8; 32] = bytes.try_into()?; + Ok(SessionStaticKey(PublicKey::from(array))) + } + } + + impl<'a> Deserialize<'a> for SessionStaticKey { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'a>, + { + serde_bytes::as_hex::deserialize(deserializer) + } + } + + impl<'a> ProtocolObjectInner<'a> for SessionStaticKey { + fn version() -> (u16, u16) { + (2, 0) + } + + fn brand() -> [u8; 4] { + *b"TSSk" + } + + fn unversioned_to_bytes(&self) -> Box<[u8]> { + messagepack_serialize(&self) + } + + fn unversioned_from_bytes( + minor_version: u16, + bytes: &[u8], + ) -> Option> { + if minor_version == 0 { + Some(messagepack_deserialize(bytes)) + } else { + None + } + } + } + + impl<'a> ProtocolObject<'a> for SessionStaticKey {} + + /// A session secret key. + #[derive(ZeroizeOnDrop)] + pub struct SessionStaticSecret(pub(crate) StaticSecret); + + impl SessionStaticSecret { + /// Perform diffie-hellman + pub fn derive_shared_secret( + &self, + their_public_key: &SessionStaticKey, + ) -> SessionSharedSecret { + let shared_secret = self.0.diffie_hellman(&their_public_key.0); + SessionSharedSecret::new(shared_secret) + } + + /// Create secret key from RNG. + pub fn random_from_rng(csprng: &mut (impl RngCore + CryptoRng)) -> Self { + let secret_key = StaticSecret::random_from_rng(csprng); + Self(secret_key) + } + + /// Create random secret key. + pub fn random() -> Self { + Self::random_from_rng(&mut OsRng) + } + + /// Returns a public key corresponding to this secret key. + pub fn public_key(&self) -> SessionStaticKey { + let public_key = PublicKey::from(&self.0); + SessionStaticKey(public_key) + } + } + + impl fmt::Display for SessionStaticSecret { + /// Format information above secret key. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "SessionStaticSecret:...") + } + } + + // the size of the seed material for key derivation + type SessionSecretFactorySeedSize = U32; + // the size of the derived key + type SessionSecretFactoryDerivedKeySize = U32; + type SessionSecretFactorySeed = GenericArray; + + /// Error thrown when invalid random seed provided for creating key factory. + #[derive(Debug)] + pub struct InvalidSessionSecretFactorySeedLength; + + impl fmt::Display for InvalidSessionSecretFactorySeedLength { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Invalid seed length") + } + } + + /// This class handles keyring material for session keys, by allowing deterministic + /// derivation of `SessionStaticSecret` objects based on labels. + #[derive(Clone, ZeroizeOnDrop, PartialEq)] + pub struct SessionSecretFactory(SecretBox); + + impl SessionSecretFactory { + /// Creates a session secret factory using the given RNG. + pub fn random_with_rng(rng: &mut (impl CryptoRng + RngCore)) -> Self { + let mut bytes = SecretBox::new(SessionSecretFactorySeed::default()); + rng.fill_bytes(bytes.as_mut_secret()); + Self(bytes) + } + + /// Creates a session secret factory using the default RNG. + pub fn random() -> Self { + Self::random_with_rng(&mut OsRng) + } + + /// Returns the seed size required by + pub fn seed_size() -> usize { + SessionSecretFactorySeedSize::to_usize() + } + + /// Creates a `SessionSecretFactory` using the given random bytes. + /// + /// **Warning:** make sure the given seed has been obtained + /// from a cryptographically secure source of randomness! + pub fn from_secure_randomness( + seed: &[u8], + ) -> Result { + if seed.len() != Self::seed_size() { + return Err(InvalidSessionSecretFactorySeedLength); + } + Ok(Self(SecretBox::new(*SessionSecretFactorySeed::from_slice( + seed, + )))) + } + + /// Creates a `SessionStaticSecret` deterministically from the given label. + pub fn make_key(&self, label: &[u8]) -> SessionStaticSecret { + let prefix = b"SESSION_KEY_DERIVATION/"; + let info = [prefix, label].concat(); + let seed = kdf::(self.0.as_secret(), Some(&info)); + let mut rng = + ChaCha20Rng::from_seed(<[u8; 32]>::try_from(seed.as_secret().as_slice()).unwrap()); + SessionStaticSecret::random_from_rng(&mut rng) + } + } + + impl fmt::Display for SessionSecretFactory { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "SessionSecretFactory:...") + } + } +} + +#[cfg(test)] +mod tests { + use generic_array::typenum::Unsigned; + use rand_core::RngCore; + + use crate::session::key::SessionStaticSecret; + use crate::session::{ + decrypt_with_shared_secret, encrypt_with_shared_secret, DecryptionError, NonceSize, + }; + use crate::versioning::ProtocolObjectInner; + use crate::{SessionSecretFactory, SessionStaticKey}; + + #[test] + fn decryption_with_shared_secret() { + let service_secret = SessionStaticSecret::random(); + + let requester_secret = SessionStaticSecret::random(); + let requester_public_key = requester_secret.public_key(); + + let service_shared_secret = service_secret.derive_shared_secret(&requester_public_key); + + let ciphertext = b"1".to_vec().into_boxed_slice(); // length less than nonce size + let nonce_size = ::to_usize(); + assert!(ciphertext.len() < nonce_size); + + assert!(matches!( + decrypt_with_shared_secret(&service_shared_secret, &ciphertext).unwrap_err(), + DecryptionError::CiphertextTooShort + )); + } + + #[test] + fn request_key_factory() { + let secret_factory = SessionSecretFactory::random(); + + // ensure that shared secret derived from factory can be used correctly + let label_1 = b"label_1".to_vec().into_boxed_slice(); + let service_secret_key = secret_factory.make_key(label_1.as_ref()); + let service_public_key = service_secret_key.public_key(); + + let label_2 = b"label_2".to_vec().into_boxed_slice(); + let requester_secret_key = secret_factory.make_key(label_2.as_ref()); + let requester_public_key = requester_secret_key.public_key(); + + let service_shared_secret = service_secret_key.derive_shared_secret(&requester_public_key); + let requester_shared_secret = + requester_secret_key.derive_shared_secret(&service_public_key); + + let data_to_encrypt = b"The Tyranny of Merit".to_vec().into_boxed_slice(); + let ciphertext = + encrypt_with_shared_secret(&requester_shared_secret, data_to_encrypt.as_ref()).unwrap(); + let decrypted_data = + decrypt_with_shared_secret(&service_shared_secret, &ciphertext).unwrap(); + assert_eq!(decrypted_data, data_to_encrypt); + + // ensure same key can be generated by the same factory using the same seed + let same_requester_secret_key = secret_factory.make_key(label_2.as_ref()); + let same_requester_public_key = same_requester_secret_key.public_key(); + assert_eq!(requester_public_key, same_requester_public_key); + + // ensure different key generated using same seed but using different factory + let other_secret_factory = SessionSecretFactory::random(); + let not_same_requester_secret_key = other_secret_factory.make_key(label_2.as_ref()); + let not_same_requester_public_key = not_same_requester_secret_key.public_key(); + assert_ne!(requester_public_key, not_same_requester_public_key); + + // ensure that two secret factories with the same seed generate the same keys + let mut secret_factory_seed = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut secret_factory_seed); + let seeded_secret_factory_1 = + SessionSecretFactory::from_secure_randomness(&secret_factory_seed).unwrap(); + let seeded_secret_factory_2 = + SessionSecretFactory::from_secure_randomness(&secret_factory_seed).unwrap(); + + let key_label = b"seeded_factory_key_label".to_vec().into_boxed_slice(); + let sk_1 = seeded_secret_factory_1.make_key(&key_label); + let pk_1 = sk_1.public_key(); + + let sk_2 = seeded_secret_factory_2.make_key(&key_label); + let pk_2 = sk_2.public_key(); + + assert_eq!(pk_1, pk_2); + + // test secure randomness + let bytes = [0u8; 32]; + let factory = SessionSecretFactory::from_secure_randomness(&bytes); + assert!(factory.is_ok()); + + let bytes = [0u8; 31]; + let factory = SessionSecretFactory::from_secure_randomness(&bytes); + assert!(factory.is_err()); + } + + #[test] + fn session_static_key() { + let public_key_1: SessionStaticKey = SessionStaticSecret::random().public_key(); + let public_key_2: SessionStaticKey = SessionStaticSecret::random().public_key(); + + let public_key_1_bytes = public_key_1.unversioned_to_bytes(); + let public_key_2_bytes = public_key_2.unversioned_to_bytes(); + + // serialized public keys should always have the same length + assert_eq!(public_key_1_bytes.len(), public_key_2_bytes.len()); + + let deserialized_public_key_1 = + SessionStaticKey::unversioned_from_bytes(0, &public_key_1_bytes) + .unwrap() + .unwrap(); + let deserialized_public_key_2 = + SessionStaticKey::unversioned_from_bytes(0, &public_key_2_bytes) + .unwrap() + .unwrap(); + + assert_eq!(public_key_1, deserialized_public_key_1); + assert_eq!(public_key_2, deserialized_public_key_2); + } +} From 1949b5d92848cefa877b4a1540251684ea1d848f Mon Sep 17 00:00:00 2001 From: derekpierre Date: Thu, 30 Oct 2025 14:50:46 -0400 Subject: [PATCH 2/9] Initial core implementation of EncryptedThresholdSignatureRequest. Added tests. --- nucypher-core/src/signature_request.rs | 217 ++++++++++++++++++++++++- 1 file changed, 215 insertions(+), 2 deletions(-) diff --git a/nucypher-core/src/signature_request.rs b/nucypher-core/src/signature_request.rs index 262619e2..60b3f263 100644 --- a/nucypher-core/src/signature_request.rs +++ b/nucypher-core/src/signature_request.rs @@ -11,8 +11,14 @@ use umbral_pre::serde_bytes; use crate::address::Address; use crate::conditions::Context; +use crate::session::{ + decrypt_with_shared_secret, encrypt_with_shared_secret, + key::{SessionSharedSecret, SessionStaticKey}, + DecryptionError, +}; use crate::versioning::{ - messagepack_deserialize, messagepack_serialize, ProtocolObject, ProtocolObjectInner, + messagepack_deserialize, messagepack_serialize, DeserializationError, ProtocolObject, + ProtocolObjectInner, }; /// Enum for different signature types. @@ -107,6 +113,12 @@ pub trait BaseSignatureRequest: Serialize + for<'de> Deserialize<'de> { fn signature_type(&self) -> SignatureRequestType; /// Returns the optional context for this signature request fn context(&self) -> Option<&Context>; + /// Encrypts the signature request. + fn encrypt( + &self, + shared_secret: &SessionSharedSecret, + requester_public_key: &SessionStaticKey, + ) -> EncryptedThresholdSignatureRequest; } /// UserOperation for signature requests - https://eips.ethereum.org/EIPS/eip-4337 @@ -234,6 +246,18 @@ impl BaseSignatureRequest for UserOperationSignatureRequest { fn context(&self) -> Option<&Context> { self.context.as_ref() } + + fn encrypt( + &self, + shared_secret: &SessionSharedSecret, + requester_public_key: &SessionStaticKey, + ) -> EncryptedThresholdSignatureRequest { + EncryptedThresholdSignatureRequest::new( + &DirectSignatureRequest::UserOp(self.clone()), + shared_secret, + requester_public_key, + ) + } } /// Packed UserOperation for signature requests @@ -600,6 +624,18 @@ impl BaseSignatureRequest for PackedUserOperationSignatureRequest { fn context(&self) -> Option<&Context> { self.context.as_ref() } + + fn encrypt( + &self, + shared_secret: &SessionSharedSecret, + requester_public_key: &SessionStaticKey, + ) -> EncryptedThresholdSignatureRequest { + EncryptedThresholdSignatureRequest::new( + &DirectSignatureRequest::PackedUserOp(self.clone()), + shared_secret, + requester_public_key, + ) + } } /// Signature response @@ -807,7 +843,7 @@ impl DirectSignatureRequest { } } - /// Get the chain ID for this request + /// Get the chain ID for this request pub fn chain_id(&self) -> u64 { match self { Self::UserOp(req) => req.chain_id(), @@ -822,6 +858,18 @@ impl DirectSignatureRequest { Self::PackedUserOp(req) => req.context(), } } + + /// Encrypts the signature request. + pub fn encrypt( + &self, + shared_secret: &SessionSharedSecret, + requester_public_key: &SessionStaticKey, + ) -> EncryptedThresholdSignatureRequest { + match self { + Self::UserOp(req) => req.encrypt(shared_secret, requester_public_key), + Self::PackedUserOp(req) => req.encrypt(shared_secret, requester_public_key), + } + } } /// Utility function to deserialize any signature request from bytes @@ -829,9 +877,88 @@ pub fn deserialize_signature_request(bytes: &[u8]) -> Result Box<[u8]> { + match request { + DirectSignatureRequest::UserOp(req) => req.to_bytes(), + DirectSignatureRequest::PackedUserOp(req) => req.to_bytes(), + } +} + +/// An encrypted signature request for Ursula to process and sign. +#[derive(PartialEq, Debug, Clone, Serialize, Deserialize)] +pub struct EncryptedThresholdSignatureRequest { + /// ID of the cohort + pub cohort_id: u32, + + /// Public key of requester + pub requester_public_key: SessionStaticKey, + + #[serde(with = "serde_bytes::as_base64")] + /// Encrypted request + ciphertext: Box<[u8]>, +} + +impl EncryptedThresholdSignatureRequest { + fn new( + request: &DirectSignatureRequest, + shared_secret: &SessionSharedSecret, + requester_public_key: &SessionStaticKey, + ) -> Self { + let serialization_bytes = serialize_signature_request(request); + let ciphertext = encrypt_with_shared_secret(shared_secret, &serialization_bytes) + .expect("Encryption failed - out of memory?"); + Self { + cohort_id: request.cohort_id(), + requester_public_key: *requester_public_key, + ciphertext, + } + } + + /// Decrypts the decryption request + pub fn decrypt( + &self, + shared_secret: &SessionSharedSecret, + ) -> Result { + let decryption_request_bytes = decrypt_with_shared_secret(shared_secret, &self.ciphertext)?; + let decryption_request = + deserialize_signature_request(&decryption_request_bytes).map_err(|err| { + DecryptionError::DeserializationFailed(DeserializationError::BadPayload { + error_msg: err.to_string(), + }) + })?; + Ok(decryption_request) + } +} + +impl<'a> ProtocolObjectInner<'a> for EncryptedThresholdSignatureRequest { + fn version() -> (u16, u16) { + (1, 0) + } + + fn brand() -> [u8; 4] { + *b"ETSR" + } + + fn unversioned_to_bytes(&self) -> Box<[u8]> { + messagepack_serialize(&self) + } + + fn unversioned_from_bytes(minor_version: u16, bytes: &[u8]) -> Option> { + if minor_version == 0 { + Some(messagepack_deserialize(bytes)) + } else { + None + } + } +} + +impl<'a> ProtocolObject<'a> for EncryptedThresholdSignatureRequest {} + #[cfg(test)] mod tests { use super::*; + use crate::session::key::SessionStaticSecret; use alloc::string::ToString; #[test] @@ -1422,4 +1549,90 @@ mod tests { let mdt_message = eip712_struct_mdt.get("message").unwrap(); validate_eip712_message(mdt_message.as_object().unwrap(), &packed_user_op, false); } + + #[test] + fn test_encrypted_threshold_signing_request() { + let sender = Address::from_str("0x1234567890123456789012345678901234567890").unwrap(); + let cohort_id = 1; + let chain_id = 137; + + let user_op = UserOperation::new( + sender, + 100, + b"execution_data", + 150000, + 250000, + 60000, + 30_000_000_000, + 2_000_000_000, + None, + None, + None, + None, + None, + None, + ); + let user_op_request = UserOperationSignatureRequest::new( + user_op.clone(), + cohort_id, + chain_id, + AAVersion::V08, + None, + ); + + let packed_user_op = PackedUserOperation::from_user_operation(&user_op); + let packed_user_op_request = PackedUserOperationSignatureRequest::new( + packed_user_op, + cohort_id, + chain_id, + AAVersion::MDT, + None, + ); + + // generate service and requester keys + let service_secret = SessionStaticSecret::random(); + + let requester_secret = SessionStaticSecret::random(); + let requester_public_key = requester_secret.public_key(); + + // requester encrypts request to send to service + let service_public_key = service_secret.public_key(); + let requester_shared_secret = requester_secret.derive_shared_secret(&service_public_key); + + // test requests + for request in [ + DirectSignatureRequest::UserOp(user_op_request), + DirectSignatureRequest::PackedUserOp(packed_user_op_request), + ] { + let encrypted_request = + request.encrypt(&requester_shared_secret, &requester_public_key); + + // mimic serialization/deserialization over the wire + let encrypted_request_bytes = encrypted_request.to_bytes(); + let encrypted_request_from_bytes = + EncryptedThresholdSignatureRequest::from_bytes(&encrypted_request_bytes).unwrap(); + + assert_eq!(encrypted_request_from_bytes.cohort_id, cohort_id); + assert_eq!( + encrypted_request_from_bytes.requester_public_key, + requester_public_key + ); + + // service decrypts request + let service_shared_secret = service_secret + .derive_shared_secret(&encrypted_request_from_bytes.requester_public_key); + let decrypted_request = encrypted_request_from_bytes + .decrypt(&service_shared_secret) + .unwrap(); + assert_eq!(decrypted_request, request); + + // wrong shared key used + let random_secret_key = SessionStaticSecret::random(); + let random_shared_secret = + random_secret_key.derive_shared_secret(&requester_public_key); + assert!(encrypted_request_from_bytes + .decrypt(&random_shared_secret) + .is_err()); + } + } } From 2a242c35ba8a70cc4ebdfc1bdf67ab4f8d4ea76d Mon Sep 17 00:00:00 2001 From: derekpierre Date: Thu, 30 Oct 2025 15:32:53 -0400 Subject: [PATCH 3/9] Initial implementation of EncryptedThresholdSignatureResponse. Add tests. --- nucypher-core/src/signature_request.rs | 108 +++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/nucypher-core/src/signature_request.rs b/nucypher-core/src/signature_request.rs index 60b3f263..815c0622 100644 --- a/nucypher-core/src/signature_request.rs +++ b/nucypher-core/src/signature_request.rs @@ -668,6 +668,14 @@ impl SignatureResponse { signature_type, } } + + /// Encrypts the signature response. + pub fn encrypt( + &self, + shared_secret: &SessionSharedSecret, + ) -> EncryptedThresholdSignatureResponse { + EncryptedThresholdSignatureResponse::new(self, shared_secret) + } } // ProtocolObject implementations @@ -955,6 +963,57 @@ impl<'a> ProtocolObjectInner<'a> for EncryptedThresholdSignatureRequest { impl<'a> ProtocolObject<'a> for EncryptedThresholdSignatureRequest {} +/// An encrypted response from Ursula with a signature. +#[derive(PartialEq, Debug, Clone, Serialize, Deserialize)] +pub struct EncryptedThresholdSignatureResponse { + #[serde(with = "serde_bytes::as_base64")] + ciphertext: Box<[u8]>, +} + +impl EncryptedThresholdSignatureResponse { + fn new(response: &SignatureResponse, shared_secret: &SessionSharedSecret) -> Self { + let ciphertext = encrypt_with_shared_secret(shared_secret, &response.to_bytes()) + .expect("Encryption failed - out of memory?"); + Self { ciphertext } + } + + /// Decrypts the decryption request + pub fn decrypt( + &self, + shared_secret: &SessionSharedSecret, + ) -> Result { + let decryption_response_bytes = + decrypt_with_shared_secret(shared_secret, &self.ciphertext)?; + let decryption_response = SignatureResponse::from_bytes(&decryption_response_bytes) + .map_err(DecryptionError::DeserializationFailed)?; + Ok(decryption_response) + } +} + +impl<'a> ProtocolObjectInner<'a> for EncryptedThresholdSignatureResponse { + fn version() -> (u16, u16) { + (1, 0) + } + + fn brand() -> [u8; 4] { + *b"ETRe" + } + + fn unversioned_to_bytes(&self) -> Box<[u8]> { + messagepack_serialize(&self) + } + + fn unversioned_from_bytes(minor_version: u16, bytes: &[u8]) -> Option> { + if minor_version == 0 { + Some(messagepack_deserialize(bytes)) + } else { + None + } + } +} + +impl<'a> ProtocolObject<'a> for EncryptedThresholdSignatureResponse {} + #[cfg(test)] mod tests { use super::*; @@ -1635,4 +1694,53 @@ mod tests { .is_err()); } } + + #[test] + fn test_encrypted_threshold_signing_response() { + let service_secret = SessionStaticSecret::random(); + let requester_secret = SessionStaticSecret::random(); + + let signer = Address::from_str("0x1234567890123456789012345678901234567890").unwrap(); + let response = SignatureResponse::new( + signer, + b"response_hash", + b"response_signature", + SignatureRequestType::UserOp, + ); + + // service encrypts response to send back + let requester_public_key = requester_secret.public_key(); + + let service_shared_secret = service_secret.derive_shared_secret(&requester_public_key); + let encrypted_response = response.encrypt(&service_shared_secret); + + // mimic serialization/deserialization over the wire + let encrypted_response_bytes = encrypted_response.to_bytes(); + let encrypted_response_from_bytes = + EncryptedThresholdSignatureResponse::from_bytes(&encrypted_response_bytes).unwrap(); + + // requester decrypts response + let service_public_key = service_secret.public_key(); + let requester_shared_secret = requester_secret.derive_shared_secret(&service_public_key); + assert_eq!( + requester_shared_secret.as_bytes(), + service_shared_secret.as_bytes() + ); + let decrypted_response = encrypted_response_from_bytes + .decrypt(&requester_shared_secret) + .unwrap(); + assert_eq!(response, decrypted_response); + // just to be sure, check fields + assert_eq!(decrypted_response.signature, response.signature); + assert_eq!(decrypted_response.signer, response.signer); + assert_eq!(decrypted_response.hash, response.hash); + assert_eq!(decrypted_response.signature_type, response.signature_type); + + // wrong shared key used + let random_secret_key = SessionStaticSecret::random(); + let random_shared_secret = random_secret_key.derive_shared_secret(&requester_public_key); + assert!(encrypted_response_from_bytes + .decrypt(&random_shared_secret) + .is_err()); + } } From 7d3ed002eb0d4c8085d2e68c2c9f7ab7d11c8c68 Mon Sep 17 00:00:00 2001 From: derekpierre Date: Thu, 30 Oct 2025 16:42:57 -0400 Subject: [PATCH 4/9] python bindings implementation for EncryptedThresholdSignatureRequest/Response. --- .../nucypher_core/__init__.py | 2 + .../nucypher_core/__init__.pyi | 60 ++++++- nucypher-core-python/src/lib.rs | 161 +++++++++++++++--- nucypher-core/src/lib.rs | 5 +- 4 files changed, 199 insertions(+), 29 deletions(-) diff --git a/nucypher-core-python/nucypher_core/__init__.py b/nucypher-core-python/nucypher_core/__init__.py index c48867a1..d7c4a704 100644 --- a/nucypher-core-python/nucypher_core/__init__.py +++ b/nucypher-core-python/nucypher_core/__init__.py @@ -34,6 +34,8 @@ PackedUserOperation, PackedUserOperationSignatureRequest, SignatureResponse, + EncryptedThresholdSignatureRequest, + EncryptedThresholdSignatureResponse, deserialize_signature_request, ) diff --git a/nucypher-core-python/nucypher_core/__init__.pyi b/nucypher-core-python/nucypher_core/__init__.pyi index bc1d7844..88314722 100644 --- a/nucypher-core-python/nucypher_core/__init__.pyi +++ b/nucypher-core-python/nucypher_core/__init__.pyi @@ -707,7 +707,7 @@ class SessionSecretFactory: @final class SignatureRequestType: USER_OP: int = 0 - PACKED_USER_OP: int = 1 + PACKED_USER_OP: int = 1 @final @@ -857,6 +857,13 @@ class UserOperationSignatureRequest: def signature_type(self) -> int: ... + def encrypt( + self, + shared_secret: SessionSharedSecret, + requester_public_key: SessionStaticKey + ) -> EncryptedThresholdSignatureRequest: + ... + @staticmethod def from_bytes(data: bytes) -> UserOperationSignatureRequest: ... @@ -979,6 +986,13 @@ class PackedUserOperationSignatureRequest: def signature_type(self) -> int: ... + def encrypt( + self, + shared_secret: SessionSharedSecret, + requester_public_key: SessionStaticKey + ) -> EncryptedThresholdSignatureRequest: + ... + @staticmethod def from_bytes(data: bytes) -> PackedUserOperationSignatureRequest: ... @@ -1016,7 +1030,13 @@ class SignatureResponse: def signature_type(self) -> int: """Get the signature type as integer.""" ... - + + def encrypt( + self, + shared_secret: SessionSharedSecret + ) -> EncryptedThresholdSignatureResponse: + ... + @staticmethod def from_bytes(data: bytes) -> SignatureResponse: """Deserialize from bytes.""" @@ -1035,3 +1055,39 @@ def deserialize_signature_request( from bytes and return the specific type directly. """ ... + + +@final +class EncryptedThresholdSignatureRequest: + requester_public_key: SessionStaticKey + + cohort_id: int + + def decrypt( + self, + shared_secret: SessionSharedSecret + ) -> Union[UserOperationSignatureRequest, PackedUserOperationSignatureRequest]: + ... + + @staticmethod + def from_bytes(data: bytes) -> EncryptedThresholdSignatureRequest: + ... + + def __bytes__(self) -> bytes: + ... + + +@final +class EncryptedThresholdSignatureResponse: + def decrypt( + self, + shared_secret: SessionSharedSecret + ) -> SignatureResponse: + ... + + @staticmethod + def from_bytes(data: bytes) -> EncryptedThresholdSignatureResponse: + ... + + def __bytes__(self) -> bytes: + ... diff --git a/nucypher-core-python/src/lib.rs b/nucypher-core-python/src/lib.rs index 203228d7..66b078c4 100644 --- a/nucypher-core-python/src/lib.rs +++ b/nucypher-core-python/src/lib.rs @@ -27,7 +27,7 @@ use rust_nucypher_core::{ UserOperation as SignatureRequestUserOperation, }; -use nucypher_core::ProtocolObject; +use nucypher_core::{BaseSignatureRequest, ProtocolObject}; fn to_bytes<'a, T, U>(obj: &T) -> PyObject where @@ -1781,6 +1781,19 @@ impl UserOperationSignatureRequest { self.backend.signature_type.as_u8() } + pub fn encrypt( + &self, + shared_secret: &SessionSharedSecret, + requester_public_key: &SessionStaticKey, + ) -> EncryptedThresholdSignatureRequest { + let encrypted_request = self + .backend + .encrypt(shared_secret.as_ref(), requester_public_key.as_ref()); + EncryptedThresholdSignatureRequest { + backend: encrypted_request, + } + } + #[staticmethod] pub fn from_bytes(data: &[u8]) -> PyResult { from_bytes::<_, nucypher_core::UserOperationSignatureRequest>(data) @@ -1983,6 +1996,19 @@ impl PackedUserOperationSignatureRequest { self.backend.signature_type as u8 } + pub fn encrypt( + &self, + shared_secret: &SessionSharedSecret, + requester_public_key: &SessionStaticKey, + ) -> EncryptedThresholdSignatureRequest { + let encrypted_request = self + .backend + .encrypt(shared_secret.as_ref(), requester_public_key.as_ref()); + EncryptedThresholdSignatureRequest { + backend: encrypted_request, + } + } + #[staticmethod] pub fn from_bytes(data: &[u8]) -> PyResult { from_bytes::<_, nucypher_core::PackedUserOperationSignatureRequest>(data) @@ -2046,6 +2072,15 @@ impl SignatureResponse { self.backend.signature_type.as_u8() } + pub fn encrypt( + &self, + shared_secret: &SessionSharedSecret, + ) -> EncryptedThresholdSignatureResponse { + EncryptedThresholdSignatureResponse { + backend: self.backend.encrypt(shared_secret.as_ref()), + } + } + #[staticmethod] pub fn from_bytes(data: &[u8]) -> PyResult { from_bytes::<_, nucypher_core::SignatureResponse>(data) @@ -2056,6 +2091,104 @@ impl SignatureResponse { } } +// +// Encrypted Threshold Signature Request +// + +#[pyclass(module = "nucypher_core")] +#[derive(derive_more::From, derive_more::AsRef)] +pub struct EncryptedThresholdSignatureRequest { + backend: nucypher_core::EncryptedThresholdSignatureRequest, +} + +#[pymethods] +impl EncryptedThresholdSignatureRequest { + #[getter] + pub fn requester_public_key(&self) -> SessionStaticKey { + self.backend.requester_public_key.into() + } + + #[getter] + pub fn cohort_id(&self) -> u32 { + self.backend.cohort_id + } + + pub fn decrypt(&self, shared_secret: &SessionSharedSecret) -> PyResult { + self.backend + .decrypt(shared_secret.as_ref()) + .map(direct_request_to_specific_type) + .map_err(|err| PyValueError::new_err(format!("{err}")))? + } + + #[staticmethod] + pub fn from_bytes(data: &[u8]) -> PyResult { + from_bytes::<_, nucypher_core::EncryptedThresholdSignatureRequest>(data) + } + + fn __bytes__(&self) -> PyObject { + to_bytes(self) + } +} + +// +// Encrypted Threshold Signature Response +// + +#[pyclass(module = "nucypher_core")] +#[derive(derive_more::From, derive_more::AsRef)] +pub struct EncryptedThresholdSignatureResponse { + backend: nucypher_core::EncryptedThresholdSignatureResponse, +} + +#[pymethods] +impl EncryptedThresholdSignatureResponse { + pub fn decrypt(&self, shared_secret: &SessionSharedSecret) -> PyResult { + self.backend + .decrypt(shared_secret.as_ref()) + .map(SignatureResponse::from) + .map_err(|err| PyValueError::new_err(format!("{err}"))) + } + + #[staticmethod] + pub fn from_bytes(data: &[u8]) -> PyResult { + from_bytes::<_, nucypher_core::EncryptedThresholdSignatureResponse>(data) + } + + fn __bytes__(&self) -> PyObject { + to_bytes(self) + } +} + +// +// Signature Request Deserializer +// + +/// Utility function to deserialize any signature request from bytes - returns specific type directly +#[pyfunction] +pub fn deserialize_signature_request(data: &[u8]) -> PyResult { + let direct_request = nucypher_core::deserialize_signature_request(data).map_err(|err| { + PyValueError::new_err(format!("Failed to deserialize signature request: {}", err)) + })?; + + // Convert to the specific Python type + direct_request_to_specific_type(direct_request) +} + +fn direct_request_to_specific_type( + direct_request: nucypher_core::DirectSignatureRequest, +) -> PyResult { + match direct_request { + nucypher_core::DirectSignatureRequest::UserOp(req) => Python::with_gil(|py| { + let python_req = UserOperationSignatureRequest { backend: req }; + Ok(python_req.into_py(py)) + }), + nucypher_core::DirectSignatureRequest::PackedUserOp(req) => Python::with_gil(|py| { + let python_req = PackedUserOperationSignatureRequest { backend: req }; + Ok(python_req.into_py(py)) + }), + } +} + /// A Python module implemented in Rust. #[pymodule] fn _nucypher_core(py: Python, core_module: &PyModule) -> PyResult<()> { @@ -2095,6 +2228,8 @@ fn _nucypher_core(py: Python, core_module: &PyModule) -> PyResult<()> { core_module.add_class::()?; core_module.add_class::()?; core_module.add_class::()?; + core_module.add_class::()?; + core_module.add_class::()?; core_module.add_function(wrap_pyfunction!( deserialize_signature_request, core_module @@ -2171,30 +2306,6 @@ fn json_to_pyobject(py: Python, value: &serde_json::Value) -> PyResult } } -// -// Signature Request Deserializer -// - -/// Utility function to deserialize any signature request from bytes - returns specific type directly -#[pyfunction] -pub fn deserialize_signature_request(data: &[u8]) -> PyResult { - let direct_request = nucypher_core::deserialize_signature_request(data).map_err(|err| { - PyValueError::new_err(format!("Failed to deserialize signature request: {}", err)) - })?; - - // Convert to the specific Python type - match direct_request { - nucypher_core::DirectSignatureRequest::UserOp(req) => Python::with_gil(|py| { - let python_req = UserOperationSignatureRequest { backend: req }; - Ok(python_req.into_py(py)) - }), - nucypher_core::DirectSignatureRequest::PackedUserOp(req) => Python::with_gil(|py| { - let python_req = PackedUserOperationSignatureRequest { backend: req }; - Ok(python_req.into_py(py)) - }), - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/nucypher-core/src/lib.rs b/nucypher-core/src/lib.rs index 1fb324d9..22e91a80 100644 --- a/nucypher-core/src/lib.rs +++ b/nucypher-core/src/lib.rs @@ -54,8 +54,9 @@ pub use session::{ }; pub use signature_request::{ deserialize_signature_request, AAVersion, BaseSignatureRequest, DirectSignatureRequest, - PackedUserOperation, PackedUserOperationSignatureRequest, SignatureRequestType, - SignatureResponse, UserOperation, UserOperationSignatureRequest, + EncryptedThresholdSignatureRequest, EncryptedThresholdSignatureResponse, PackedUserOperation, + PackedUserOperationSignatureRequest, SignatureRequestType, SignatureResponse, UserOperation, + UserOperationSignatureRequest, }; pub use threshold_message_kit::ThresholdMessageKit; pub use treasure_map::{EncryptedTreasureMap, TreasureMap}; From fa730ffdd92c0c0c918de5f694ee54b89d5b9621 Mon Sep 17 00:00:00 2001 From: derekpierre Date: Fri, 31 Oct 2025 09:12:26 -0400 Subject: [PATCH 5/9] WASM bindings implementation for EncryptedThresholdSignatureRequest/Response. --- nucypher-core-wasm/src/lib.rs | 100 +++++++++++++++++++++++++++++++++- 1 file changed, 99 insertions(+), 1 deletion(-) diff --git a/nucypher-core-wasm/src/lib.rs b/nucypher-core-wasm/src/lib.rs index 7bdeaba1..7d885571 100644 --- a/nucypher-core-wasm/src/lib.rs +++ b/nucypher-core-wasm/src/lib.rs @@ -25,7 +25,7 @@ use wasm_bindgen::prelude::{wasm_bindgen, JsValue}; use wasm_bindgen::JsCast; use wasm_bindgen_derive::TryFromJsValue; -use nucypher_core::ProtocolObject; +use nucypher_core::{BaseSignatureRequest, ProtocolObject}; // Re-export certain types so they can be used from `nucypher-core` WASM bindings directly. pub use ferveo::bindings_wasm::{FerveoPublicKey, Keypair}; @@ -1807,6 +1807,17 @@ impl UserOperationSignatureRequest { pub fn context(&self) -> Option { self.0.context.clone().map(Context) } + + pub fn encrypt( + &self, + shared_secret: &SessionSharedSecret, + requester_public_key: &SessionStaticKey, + ) -> EncryptedThresholdSignatureRequest { + EncryptedThresholdSignatureRequest( + self.0 + .encrypt(shared_secret.as_ref(), requester_public_key.as_ref()), + ) + } } /// PackedUserOperationSignatureRequest @@ -1864,6 +1875,17 @@ impl PackedUserOperationSignatureRequest { pub fn context(&self) -> Option { self.0.context.clone().map(Context) } + + pub fn encrypt( + &self, + shared_secret: &SessionSharedSecret, + requester_public_key: &SessionStaticKey, + ) -> EncryptedThresholdSignatureRequest { + EncryptedThresholdSignatureRequest( + self.0 + .encrypt(shared_secret.as_ref(), requester_public_key.as_ref()), + ) + } } /// SignatureResponse @@ -1914,4 +1936,80 @@ impl SignatureResponse { pub fn signature_type(&self) -> u8 { self.0.signature_type.as_u8() } + + pub fn encrypt( + &self, + shared_secret: &SessionSharedSecret, + ) -> EncryptedThresholdSignatureResponse { + EncryptedThresholdSignatureResponse(self.0.encrypt(shared_secret.as_ref())) + } +} + +// +// EncryptedThresholdSignatureRequest +// + +#[wasm_bindgen] +extern "C" { + // This is just a *type alias* for TypeScript consumers. + #[wasm_bindgen( + typescript_type = "UserOperationSignatureRequest | PackedUserOperationSignatureRequest" + )] + pub type SignatureRequestUnion; +} + +#[wasm_bindgen] +#[derive(PartialEq, Debug, derive_more::From, derive_more::AsRef)] +pub struct EncryptedThresholdSignatureRequest(nucypher_core::EncryptedThresholdSignatureRequest); + +generate_from_bytes!(EncryptedThresholdSignatureRequest); +generate_to_bytes!(EncryptedThresholdSignatureRequest); + +#[wasm_bindgen] +impl EncryptedThresholdSignatureRequest { + #[wasm_bindgen(getter, js_name = requesterPublicKey)] + pub fn requester_public_key(&self) -> SessionStaticKey { + SessionStaticKey::from(self.0.requester_public_key) + } + + #[wasm_bindgen(getter, js_name = cohortId)] + pub fn cohort_id(&self) -> u32 { + self.0.cohort_id + } + + pub fn decrypt( + &self, + shared_secret: &SessionSharedSecret, + ) -> Result { + let direct_request = self.0.decrypt(shared_secret.as_ref()).map_err(map_js_err)?; + match direct_request { + nucypher_core::DirectSignatureRequest::UserOp(req) => { + Ok(JsValue::from(UserOperationSignatureRequest(req)).into()) + } + nucypher_core::DirectSignatureRequest::PackedUserOp(req) => { + Ok(JsValue::from(PackedUserOperationSignatureRequest(req)).into()) + } + } + } +} + +// +// EncryptedThresholdSignatureResponse +// + +#[wasm_bindgen] +#[derive(PartialEq, Debug, derive_more::From, derive_more::AsRef)] +pub struct EncryptedThresholdSignatureResponse(nucypher_core::EncryptedThresholdSignatureResponse); + +generate_from_bytes!(EncryptedThresholdSignatureResponse); +generate_to_bytes!(EncryptedThresholdSignatureResponse); + +#[wasm_bindgen] +impl EncryptedThresholdSignatureResponse { + pub fn decrypt(&self, shared_secret: &SessionSharedSecret) -> Result { + self.0 + .decrypt(shared_secret.as_ref()) + .map_err(map_js_err) + .map(SignatureResponse) + } } From a885fda92acfa9457f0e0b8ee0f3955d2363b414 Mon Sep 17 00:00:00 2001 From: derekpierre Date: Fri, 31 Oct 2025 09:17:02 -0400 Subject: [PATCH 6/9] Update CHANGELOG based on latest additions for encrypted signing requests/responses. --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7e8d07a..f2c995ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,11 +11,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Expose `UserOperation`, `PackedUserOperation`, `UserOperationSigningRequest`, `PackedUserOperationSigningRequest`, `SignatureResponse` common objects and bindings for TACo Action Control. ([#113]) +- Added `UserOperation`, `PackedUserOperation`, `UserOperationSigningRequest`, `PackedUserOperationSigningRequest`, `SignatureResponse` types and bindings for TACo Action Control. ([#113]) +- Added `EncryptedThresholdSignatureRequest`/`EncryptedThresholdSignatureRequest` types and bindings to facilitate end-to-end encrypted requests/responses for TACo Action Control. ([#116]) [#123]: https://github.com/nucypher/nucypher-core/pull/123 [#113]: https://github.com/nucypher/nucypher-core/pull/113 +[#116]: https://github.com/nucypher/nucypher-core/pull/116 + ## [0.15.0] - 2025-08-15 From 7a3a5f7ccc7381d5cf03dedd6d9e250c4553235c Mon Sep 17 00:00:00 2001 From: derekpierre Date: Fri, 31 Oct 2025 11:40:02 -0400 Subject: [PATCH 7/9] Add python bindings tests for encrypting/decrypting of signature requests/responses. --- nucypher-core-python/src/lib.rs | 208 ++++++++++++++++++++++++++++++-- 1 file changed, 196 insertions(+), 12 deletions(-) diff --git a/nucypher-core-python/src/lib.rs b/nucypher-core-python/src/lib.rs index 66b078c4..3f708837 100644 --- a/nucypher-core-python/src/lib.rs +++ b/nucypher-core-python/src/lib.rs @@ -2510,13 +2510,21 @@ mod tests { assert_eq!(paymaster_data.as_bytes(), b"paymaster_data"); // serialization/deserialization test - let serialized: &PyAny = user_op_instance.call_method0("__bytes__").unwrap(); - let serialized_bytes: &PyBytes = serialized.extract().unwrap(); + let serialized_bytes: &PyBytes = user_op_instance + .call_method0("__bytes__") + .unwrap() + .call_method0("__bytes__") + .unwrap() + .extract::<&PyBytes>() + .unwrap(); let deserialized_instance = user_op_class .call_method1("from_bytes", (serialized_bytes.as_bytes(),)) .unwrap(); - let reserialized: &PyAny = deserialized_instance.call_method0("__bytes__").unwrap(); - let reserialized_bytes: &PyBytes = reserialized.extract().unwrap(); + let reserialized_bytes: &PyBytes = deserialized_instance + .call_method0("__bytes__") + .unwrap() + .extract::<&PyBytes>() + .unwrap(); assert_eq!(reserialized_bytes.as_bytes(), serialized_bytes.as_bytes()); }); } @@ -2614,13 +2622,19 @@ mod tests { assert_eq!(paymaster_and_data.as_bytes(), b"paymaster_data"); // serialization/deserialization test - let serialized: &PyAny = packed_user_op_instance.call_method0("__bytes__").unwrap(); - let serialized_bytes: &PyBytes = serialized.extract().unwrap(); + let serialized_bytes: &PyBytes = packed_user_op_instance + .call_method0("__bytes__") + .unwrap() + .extract::<&PyBytes>() + .unwrap(); let deserialized_instance = packed_user_op_class .call_method1("from_bytes", (serialized_bytes.as_bytes(),)) .unwrap(); - let reserialized: &PyAny = deserialized_instance.call_method0("__bytes__").unwrap(); - let reserialized_bytes: &PyBytes = reserialized.extract().unwrap(); + let reserialized_bytes: &PyBytes = deserialized_instance + .call_method0("__bytes__") + .unwrap() + .extract::<&PyBytes>() + .unwrap(); assert_eq!(reserialized_bytes.as_bytes(), serialized_bytes.as_bytes()); }); } @@ -2710,7 +2724,9 @@ mod tests { .unwrap(); assert_eq!(aa_version, "0.8.0"); + // // serialization/deserialization test + // let serialized_bytes: &PyBytes = user_op_signature_request_instance .call_method0("__bytes__") .unwrap() @@ -2723,6 +2739,69 @@ mod tests { deserialized_instance.call_method0(py, "__bytes__").unwrap(); let reserialized_bytes: &PyBytes = reserialized.extract(py).unwrap(); assert_eq!(reserialized_bytes.as_bytes(), serialized_bytes.as_bytes()); + + // + // encryption/decryption of request + // + let session_static_secret_class = core_module.getattr("SessionStaticSecret").unwrap(); + + let service_secret = session_static_secret_class.call_method0("random").unwrap(); + + let requester_secret = session_static_secret_class.call_method0("random").unwrap(); + let requester_public_key = requester_secret.call_method0("public_key").unwrap(); + + // derive shared secret + let shared_secret = service_secret + .call_method1("derive_shared_secret", (requester_public_key,)) + .unwrap(); + + // encrypt request + let encrypted_request = user_op_signature_request_instance + .call_method1("encrypt", (shared_secret, requester_public_key)) + .unwrap(); + assert_eq!( + encrypted_request + .getattr("cohort_id") + .unwrap() + .extract::() + .unwrap(), + user_op_signature_request_instance + .getattr("cohort_id") + .unwrap() + .extract::() + .unwrap() + ); + + let encrypted_requester_key = + encrypted_request.getattr("requester_public_key").unwrap(); + let encrypted_key_bytes: &PyBytes = encrypted_requester_key + .call_method0("__bytes__") + .unwrap() + .extract() + .unwrap(); + let original_key_bytes: &PyBytes = requester_public_key + .call_method0("__bytes__") + .unwrap() + .extract() + .unwrap(); + assert_eq!( + encrypted_key_bytes.as_bytes(), + original_key_bytes.as_bytes() + ); + + // decrypt request + let decrypted_request = encrypted_request + .call_method1("decrypt", (shared_secret,)) + .unwrap(); + let decrypted_request_bytes = decrypted_request + .call_method0("__bytes__") + .unwrap() + .extract::<&PyBytes>() + .unwrap(); + assert_eq!( + decrypted_request_bytes.as_bytes(), + serialized_bytes.as_bytes() + ); }); } @@ -2763,6 +2842,7 @@ mod tests { let packed_user_op_signature_request_class = core_module .getattr("PackedUserOperationSignatureRequest") .unwrap(); + // Create a PackedUserOperationSignatureRequest instance using keyword arguments let request_kwargs = PyDict::new(py); request_kwargs @@ -2815,7 +2895,9 @@ mod tests { .unwrap(); assert_eq!(aa_version, "mdt"); + // // serialization/deserialization test + // let serialized_bytes: &PyBytes = packed_user_op_signature_request_instance .call_method0("__bytes__") .unwrap() @@ -2828,6 +2910,68 @@ mod tests { deserialized_instance.call_method0(py, "__bytes__").unwrap(); let reserialized_bytes: &PyBytes = reserialized.extract(py).unwrap(); assert_eq!(reserialized_bytes.as_bytes(), serialized_bytes.as_bytes()); + + // + // encryption/decryption of request + // + let session_static_secret_class = core_module.getattr("SessionStaticSecret").unwrap(); + + let service_secret = session_static_secret_class.call_method0("random").unwrap(); + + let requester_secret = session_static_secret_class.call_method0("random").unwrap(); + let requester_public_key = requester_secret.call_method0("public_key").unwrap(); + + // derive shared secret + let shared_secret = service_secret + .call_method1("derive_shared_secret", (requester_public_key,)) + .unwrap(); + + // encrypt request + let encrypted_request = packed_user_op_signature_request_instance + .call_method1("encrypt", (shared_secret, requester_public_key)) + .unwrap(); + assert_eq!( + encrypted_request + .getattr("cohort_id") + .unwrap() + .extract::() + .unwrap(), + packed_user_op_signature_request_instance + .getattr("cohort_id") + .unwrap() + .extract::() + .unwrap() + ); + let encrypted_requester_key = + encrypted_request.getattr("requester_public_key").unwrap(); + let encrypted_key_bytes: &PyBytes = encrypted_requester_key + .call_method0("__bytes__") + .unwrap() + .extract() + .unwrap(); + let original_key_bytes: &PyBytes = requester_public_key + .call_method0("__bytes__") + .unwrap() + .extract() + .unwrap(); + assert_eq!( + encrypted_key_bytes.as_bytes(), + original_key_bytes.as_bytes() + ); + + // decrypt request + let decrypted_request = encrypted_request + .call_method1("decrypt", (shared_secret,)) + .unwrap(); + let decrypted_request_bytes = decrypted_request + .call_method0("__bytes__") + .unwrap() + .extract::<&PyBytes>() + .unwrap(); + assert_eq!( + decrypted_request_bytes.as_bytes(), + serialized_bytes.as_bytes() + ); }); } @@ -2886,17 +3030,57 @@ mod tests { .unwrap(); assert_eq!(signature_type, 1u8); + // // serialization/deserialization test - let serialized: &PyAny = signature_response_instance + // + let serialized_bytes: &PyBytes = signature_response_instance .call_method0("__bytes__") + .unwrap() + .extract::<&PyBytes>() .unwrap(); - let serialized_bytes: &PyBytes = serialized.extract().unwrap(); let deserialized_instance = signature_response_class .call_method1("from_bytes", (serialized_bytes.as_bytes(),)) .unwrap(); - let reserialized: &PyAny = deserialized_instance.call_method0("__bytes__").unwrap(); - let reserialized_bytes: &PyBytes = reserialized.extract().unwrap(); + let reserialized_bytes: &PyBytes = deserialized_instance + .call_method0("__bytes__") + .unwrap() + .extract::<&PyBytes>() + .unwrap(); assert_eq!(reserialized_bytes.as_bytes(), serialized_bytes.as_bytes()); + + // + // encryption/decryption of response + // + let session_static_secret_class = core_module.getattr("SessionStaticSecret").unwrap(); + + let service_secret = session_static_secret_class.call_method0("random").unwrap(); + + let requester_secret = session_static_secret_class.call_method0("random").unwrap(); + let requester_public_key = requester_secret.call_method0("public_key").unwrap(); + + // derive shared secret + let shared_secret = service_secret + .call_method1("derive_shared_secret", (requester_public_key,)) + .unwrap(); + + // encrypt response + let encrypted_response = signature_response_instance + .call_method1("encrypt", (shared_secret,)) + .unwrap(); + + // decrypt response + let decrypted_response = encrypted_response + .call_method1("decrypt", (shared_secret,)) + .unwrap(); + let decrypted_response_bytes = decrypted_response + .call_method0("__bytes__") + .unwrap() + .extract::<&PyBytes>() + .unwrap(); + assert_eq!( + decrypted_response_bytes.as_bytes(), + serialized_bytes.as_bytes() + ); }); } } From fd2f36a0a70d71011c325f27235a2101a32592ab Mon Sep 17 00:00:00 2001 From: derekpierre Date: Fri, 31 Oct 2025 13:56:59 -0400 Subject: [PATCH 8/9] Add WASM bindings tests for encrypting/decrypting of signature requests/responses. --- nucypher-core-wasm/tests/wasm.rs | 121 +++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/nucypher-core-wasm/tests/wasm.rs b/nucypher-core-wasm/tests/wasm.rs index e2212c90..14d54272 100644 --- a/nucypher-core-wasm/tests/wasm.rs +++ b/nucypher-core-wasm/tests/wasm.rs @@ -1046,6 +1046,47 @@ fn user_operation_signature_request() { ); let err = result.unwrap_err(); assert_eq!(err.message(), "Invalid AA version: invalid_version"); + + // + // encrypt/decrypt request + // + let service_secret = SessionStaticSecret::random(); + let service_public_key = service_secret.public_key(); + let requester_secret = SessionStaticSecret::random(); + let shared_secret = requester_secret.derive_shared_secret(&service_public_key); + + // mimic encrypted request going over the wire + let requester_public_key = requester_secret.public_key(); + let encrypted_request = v8_request.encrypt(&shared_secret, &requester_public_key); + let encrypted_request_bytes = encrypted_request.to_bytes(); + let encrypted_request_from_bytes = + EncryptedThresholdSignatureRequest::from_bytes(&encrypted_request_bytes).unwrap(); + assert_eq!(encrypted_request_from_bytes, encrypted_request); + assert_eq!( + encrypted_request_from_bytes.cohort_id(), + v8_request.cohort_id() + ); + assert_eq!( + encrypted_request_from_bytes.requester_public_key(), + requester_public_key + ); + + // service decrypts request + let service_shared_secret = + service_secret.derive_shared_secret(&encrypted_request_from_bytes.requester_public_key()); + let decrypted_request_union = encrypted_request_from_bytes + .decrypt(&service_shared_secret) + .unwrap(); + + // Verify that we got back a valid request by converting to JsValue + let _decrypted_js: JsValue = decrypted_request_union.into(); + + // wrong key used + let random_secret_key = SessionStaticSecret::random(); + let random_shared_secret = random_secret_key.derive_shared_secret(&service_public_key); + assert!(encrypted_request_from_bytes + .decrypt(&random_shared_secret) + .is_err()); } #[wasm_bindgen_test] @@ -1180,6 +1221,48 @@ fn packed_user_operation_signature_request() { ); let err = result.unwrap_err(); assert_eq!(err.message(), "Invalid AA version: invalid_version"); + + // + // encrypt/decrypt request + // + let service_secret = SessionStaticSecret::random(); + let service_public_key = service_secret.public_key(); + let requester_secret = SessionStaticSecret::random(); + let shared_secret = requester_secret.derive_shared_secret(&service_public_key); + + // mimic encrypted request going over the wire + let requester_public_key = requester_secret.public_key(); + let encrypted_request = v8_request.encrypt(&shared_secret, &requester_public_key); + + let encrypted_request_bytes = encrypted_request.to_bytes(); + let encrypted_request_from_bytes = + EncryptedThresholdSignatureRequest::from_bytes(&encrypted_request_bytes).unwrap(); + assert_eq!(encrypted_request_from_bytes, encrypted_request); + assert_eq!( + encrypted_request_from_bytes.cohort_id(), + v8_request.cohort_id() + ); + assert_eq!( + encrypted_request_from_bytes.requester_public_key(), + requester_public_key + ); + + // service decrypts request + let service_shared_secret = + service_secret.derive_shared_secret(&encrypted_request_from_bytes.requester_public_key()); + let decrypted_request_union = encrypted_request_from_bytes + .decrypt(&service_shared_secret) + .unwrap(); + + // Verify that we got back a valid request by converting to JsValue + let _decrypted_js: JsValue = decrypted_request_union.into(); + + // wrong key used + let random_secret_key = SessionStaticSecret::random(); + let random_shared_secret = random_secret_key.derive_shared_secret(&service_public_key); + assert!(encrypted_request_from_bytes + .decrypt(&random_shared_secret) + .is_err()); } #[wasm_bindgen_test] @@ -1213,4 +1296,42 @@ fn signature_response() { let deserialized_packed_response = SignatureResponse::from_bytes(&serialized_packed_response).unwrap(); assert_eq!(packed_response, deserialized_packed_response); + + // + // encrypt/decrypt response + // + let service_secret = SessionStaticSecret::random(); + + let requester_secret = SessionStaticSecret::random(); + let requester_public_key = requester_secret.public_key(); + // service encrypts response to send back + let service_shared_secret = service_secret.derive_shared_secret(&requester_public_key); + let encrypted_response = response.encrypt(&service_shared_secret); + + // mimic serialization/deserialization over the wire + let encrypted_response_bytes = encrypted_response.to_bytes(); + let encrypted_response_from_bytes = + EncryptedThresholdSignatureResponse::from_bytes(&encrypted_response_bytes).unwrap(); + + // requester decrypts response + let service_public_key = service_secret.public_key(); + let requester_shared_secret = requester_secret.derive_shared_secret(&service_public_key); + let decrypted_response = encrypted_response_from_bytes + .decrypt(&requester_shared_secret) + .unwrap(); + assert_eq!(response, decrypted_response); + assert_eq!(response.signer(), signer); + assert_eq!(response.hash(), decrypted_response.hash()); + assert_eq!(response.signature(), decrypted_response.signature()); + assert_eq!( + response.signature_type(), + decrypted_response.signature_type() + ); + + // wrong secret key used + let random_secret_key = SessionStaticSecret::random(); + let random_shared_secret = random_secret_key.derive_shared_secret(&service_public_key); + assert!(encrypted_response_from_bytes + .decrypt(&random_shared_secret) + .is_err()); } From 367e9f42fa2ae21293050a7a714fe0de609e1edd Mon Sep 17 00:00:00 2001 From: derekpierre Date: Fri, 7 Nov 2025 16:04:10 -0500 Subject: [PATCH 9/9] Bump version for 0.15.1-dev.2. --- .bumpversion.cfg | 2 +- Cargo.lock | 6 +++--- nucypher-core-python/Cargo.toml | 2 +- nucypher-core-python/setup.py | 2 +- nucypher-core-wasm-bundler/package-lock.json | 4 ++-- nucypher-core-wasm-bundler/package.json | 2 +- nucypher-core-wasm/Cargo.toml | 2 +- nucypher-core-wasm/package.template.json | 2 +- nucypher-core/Cargo.toml | 2 +- 9 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 9dc4121c..26b5d267 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.15.1-dev.1 +current_version = 0.15.1-dev.2 commit = True tag = True parse = (?P\d+)\.(?P\d+)\.(?P\d+)(-(?P[^.]*)\.(?P\d+))? diff --git a/Cargo.lock b/Cargo.lock index 4569be9c..3abd5f4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2364,7 +2364,7 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] name = "nucypher-core" -version = "0.15.1-dev.1" +version = "0.15.1-dev.2" dependencies = [ "ark-std", "chacha20poly1305", @@ -2389,7 +2389,7 @@ dependencies = [ [[package]] name = "nucypher-core-python" -version = "0.15.1-dev.1" +version = "0.15.1-dev.2" dependencies = [ "derive_more 0.99.20", "ferveo-nucypher", @@ -2403,7 +2403,7 @@ dependencies = [ [[package]] name = "nucypher-core-wasm" -version = "0.15.1-dev.1" +version = "0.15.1-dev.2" dependencies = [ "ark-std", "console_error_panic_hook", diff --git a/nucypher-core-python/Cargo.toml b/nucypher-core-python/Cargo.toml index 38696cd8..9d84914b 100644 --- a/nucypher-core-python/Cargo.toml +++ b/nucypher-core-python/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "nucypher-core-python" authors = ["Bogdan Opanchuk "] -version = "0.15.1-dev.1" +version = "0.15.1-dev.2" edition = "2018" [lib] diff --git a/nucypher-core-python/setup.py b/nucypher-core-python/setup.py index e378e224..cb92fd86 100644 --- a/nucypher-core-python/setup.py +++ b/nucypher-core-python/setup.py @@ -10,7 +10,7 @@ description="Protocol structures of Nucypher network", long_description=long_description, long_description_content_type="text/markdown", - version="0.15.1-dev.1", + version="0.15.1-dev.2", author="Bogdan Opanchuk", author_email="bogdan@opanchuk.net", url="https://github.com/nucypher/nucypher-core/tree/main/nucypher-core-python", diff --git a/nucypher-core-wasm-bundler/package-lock.json b/nucypher-core-wasm-bundler/package-lock.json index cbbbdbfb..e8ffe488 100644 --- a/nucypher-core-wasm-bundler/package-lock.json +++ b/nucypher-core-wasm-bundler/package-lock.json @@ -1,12 +1,12 @@ { "name": "@nucypher/nucypher-core", - "version": "0.15.1-dev.1", + "version": "0.15.1-dev.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@nucypher/nucypher-core", - "version": "0.15.1-dev.1", + "version": "0.15.1-dev.2", "license": "GPL-3.0-only", "devDependencies": { "@rollup/plugin-typescript": "^11.1.3", diff --git a/nucypher-core-wasm-bundler/package.json b/nucypher-core-wasm-bundler/package.json index 44fd8cc2..122fdab8 100644 --- a/nucypher-core-wasm-bundler/package.json +++ b/nucypher-core-wasm-bundler/package.json @@ -1,6 +1,6 @@ { "name": "@nucypher/nucypher-core", - "version": "0.15.1-dev.1", + "version": "0.15.1-dev.2", "license": "GPL-3.0-only", "sideEffects": false, "type": "module", diff --git a/nucypher-core-wasm/Cargo.toml b/nucypher-core-wasm/Cargo.toml index 4e84fc3f..03184fc6 100644 --- a/nucypher-core-wasm/Cargo.toml +++ b/nucypher-core-wasm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nucypher-core-wasm" -version = "0.15.1-dev.1" +version = "0.15.1-dev.2" authors = [ "Bogdan Opanchuk ", "Piotr Roslaniec " diff --git a/nucypher-core-wasm/package.template.json b/nucypher-core-wasm/package.template.json index 8b0e29db..17ee4543 100644 --- a/nucypher-core-wasm/package.template.json +++ b/nucypher-core-wasm/package.template.json @@ -5,7 +5,7 @@ "Bogdan Opanchuk " ], "description": "NuCypher network core data structures", - "version": "0.15.1-dev.1", + "version": "0.15.1-dev.2", "license": "GPL-3.0-only", "repository": { "type": "git", diff --git a/nucypher-core/Cargo.toml b/nucypher-core/Cargo.toml index c20aab8b..66bb7b44 100644 --- a/nucypher-core/Cargo.toml +++ b/nucypher-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nucypher-core" -version = "0.15.1-dev.1" +version = "0.15.1-dev.2" authors = ["Bogdan Opanchuk "] edition = "2021" license = "GPL-3.0-only"