diff --git a/mina-frost-client/src/cipher.rs b/mina-frost-client/src/cipher.rs index c446fdc..edb8fb4 100644 --- a/mina-frost-client/src/cipher.rs +++ b/mina-frost-client/src/cipher.rs @@ -248,3 +248,103 @@ impl Cipher { }) } } + +#[cfg(test)] +mod tests { + use super::*; + use mina_tx::{ + network_id::{NetworkId, NetworkIdEnvelope}, + TransactionEnvelope, + }; + + #[cfg(not(feature = "mesa-hardfork"))] + #[test] + fn test_encrypt_small_transaction() { + // A small payment-style zkapp transaction — should fit within Noise limits + let json = include_str!("../../mina-tx/tests/data/payment-zkapp.json"); + let envelope = TransactionEnvelope::from_str_network( + json, + NetworkIdEnvelope::from(NetworkId::Testnet), + ) + .unwrap(); + let message_bytes = envelope.serialize().unwrap(); + + let msg_size = message_bytes.len(); + eprintln!("Small tx serialized size: {} bytes", msg_size); + assert!(msg_size < 65535, "small tx should be under Noise limit"); + + // Verify encryption works + let (privkey_a, _pubkey_a) = Cipher::generate_keypair().unwrap(); + let (_privkey_b, pubkey_b) = Cipher::generate_keypair().unwrap(); + + let mut cipher_a = Cipher::new(privkey_a, vec![pubkey_b.clone()]).unwrap(); + let encrypted = cipher_a.encrypt(Some(&pubkey_b), message_bytes.clone()); + assert!(encrypted.is_ok(), "small tx encryption should succeed"); + } + + #[cfg(not(feature = "mesa-hardfork"))] + #[test] + fn test_encrypt_large_signing_package() { + // The deploy-v0.0.4 transaction with verification keys is large. + // After being wrapped in a TransactionEnvelope, serialized, then put into a + // SigningPackage and serialized AGAIN (with hex encoding of the message bytes), + // the payload exceeds the Noise protocol's 65535-byte message limit. + let json = include_str!("../../mina-tx/tests/data/deploy-v0.0.4-unsigned.json"); + let envelope = TransactionEnvelope::from_str_network( + json, + NetworkIdEnvelope::from(NetworkId::Testnet), + ) + .unwrap(); + let message_bytes = envelope.serialize().unwrap(); + + let msg_size = message_bytes.len(); + eprintln!("Deploy tx serialized envelope size: {} bytes", msg_size); + + // Simulate what the coordinator does: wrap in SendSigningPackageArgs and serialize. + // The SigningPackage contains commitments + message, then gets serde_json serialized. + // frost-core hex-encodes the message bytes, roughly doubling the size. + // We can't easily construct a real SigningPackage here without commitments, + // but we can check the raw envelope size and the hex-encoded size. + let hex_encoded_size = msg_size * 2; // serdect hex encoding + eprintln!( + "Estimated hex-encoded message size: {} bytes", + hex_encoded_size + ); + eprintln!("Noise limit: {} bytes", api::MAX_MSG_SIZE); + + // Directly test: can we encrypt a payload this size? + let (privkey_a, _pubkey_a) = Cipher::generate_keypair().unwrap(); + let (_privkey_b, pubkey_b) = Cipher::generate_keypair().unwrap(); + + let mut cipher = Cipher::new(privkey_a, vec![pubkey_b.clone()]).unwrap(); + + // Try encrypting the raw envelope bytes (46KB) — this might work on its own + let raw_result = cipher.encrypt(Some(&pubkey_b), message_bytes.clone()); + eprintln!( + "Raw envelope encrypt ({}B): {}", + msg_size, + if raw_result.is_ok() { "OK" } else { "FAILED" } + ); + + // Now try a payload at the size it would be after serde_json serialization + // of SendSigningPackageArgs (hex-encoded message + commitments + JSON overhead) + let large_payload = vec![0u8; hex_encoded_size]; + // Need a fresh cipher since Noise_K state is consumed after first write + let (privkey_c, _pubkey_c) = Cipher::generate_keypair().unwrap(); + let (_privkey_d, pubkey_d) = Cipher::generate_keypair().unwrap(); + let mut cipher2 = Cipher::new(privkey_c, vec![pubkey_d.clone()]).unwrap(); + let large_result = cipher2.encrypt(Some(&pubkey_d), large_payload); + eprintln!( + "Hex-sized payload encrypt ({}B): {}", + hex_encoded_size, + if large_result.is_ok() { "OK" } else { "FAILED" } + ); + + assert!( + large_result.is_err(), + "Encrypting a payload the size of the hex-encoded deploy-v0.0.4 ({} bytes) \ + fails because it exceeds the Noise 65535-byte message limit", + hex_encoded_size + ); + } +} diff --git a/mina-frost-client/src/coordinator/comms/http.rs b/mina-frost-client/src/coordinator/comms/http.rs index 98b89b6..9eab6c7 100644 --- a/mina-frost-client/src/coordinator/comms/http.rs +++ b/mina-frost-client/src/coordinator/comms/http.rs @@ -28,6 +28,10 @@ use crate::{ use super::super::config::Config; use super::Comms; +/// Noise_K handshake overhead: 32-byte ephemeral key + 16-byte AEAD tag. +const NOISE_K_OVERHEAD: usize = 48; +const MAX_CHUNK_PLAINTEXT: usize = api::MAX_MSG_SIZE - NOISE_K_OVERHEAD; + pub struct HTTPComms { client: Client, session_id: Option, @@ -195,20 +199,41 @@ impl Comms for HTTPComms { // We need to send a message separately for each recipient even if the // message is the same, because they are (possibly) encrypted // individually for each recipient. + // + // Large payloads (e.g. deploy transactions with verification keys) may + // exceed frostd's MAX_MSG_SIZE (65535 bytes) after JSON serialization and + // encryption. We chunk the serialized payload so each encrypted chunk fits + // in a single frostd message. The first message sent to each recipient is + // a 4-byte big-endian chunk count header, followed by the encrypted chunks. + let serialized = serde_json::to_vec(&send_signing_package_config)?; + let plaintext_chunks: Vec<&[u8]> = serialized.chunks(MAX_CHUNK_PLAINTEXT).collect(); + let num_chunks = plaintext_chunks.len() as u32; + let pubkeys: Vec<_> = self.pubkeys.keys().cloned().collect(); for recipient in pubkeys { - let msg = cipher.encrypt( - Some(&recipient), - serde_json::to_vec(&send_signing_package_config)?, - )?; + // Send chunk count header (encrypted) + let header = cipher.encrypt(Some(&recipient), num_chunks.to_be_bytes().to_vec())?; let _r = self .client .send(&api::SendArgs { session_id: self.session_id.unwrap(), recipients: vec![recipient.clone()], - msg, + msg: header, }) .await?; + + // Send each chunk (encrypted) + for chunk in &plaintext_chunks { + let encrypted = cipher.encrypt(Some(&recipient), chunk.to_vec())?; + let _r = self + .client + .send(&api::SendArgs { + session_id: self.session_id.unwrap(), + recipients: vec![recipient.clone()], + msg: encrypted, + }) + .await?; + } } eprintln!("Waiting for participants to send their SignatureShares..."); @@ -288,3 +313,169 @@ impl Comms for HTTPComms { Ok(()) } } + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::MAX_CHUNK_PLAINTEXT; + use crate::api::{self, SendSigningPackageArgs}; + use crate::cipher::Cipher; + use frost_bluepallas::keys::generate_with_dealer; + use frost_core::keys::{IdentifierList, KeyPackage}; + use mina_tx::{ + network_id::{NetworkId, NetworkIdEnvelope}, + pallas_message::PallasMessage, + TransactionEnvelope, + }; + use rand::thread_rng; + + /// Helper: build serialized SendSigningPackageArgs from a transaction fixture + fn serialize_signing_package_for_tx(tx_json: &str) -> Vec { + let envelope = TransactionEnvelope::from_str_network( + tx_json, + NetworkIdEnvelope::from(NetworkId::Testnet), + ) + .unwrap(); + let message_bytes = envelope.serialize().unwrap(); + + let mut rng = thread_rng(); + let (shares, _) = + generate_with_dealer::(2, 2, IdentifierList::Default, &mut rng) + .unwrap(); + let (id, share) = shares.iter().next().unwrap(); + let key_package = KeyPackage::try_from(share.clone()).unwrap(); + let (_nonces, commitments) = + frost_bluepallas::round1::commit(key_package.signing_share(), &mut rng); + let mut commitments_map = BTreeMap::new(); + commitments_map.insert(*id, commitments); + + let signing_package = + frost_bluepallas::SigningPackage::new(commitments_map, &message_bytes); + let send_args = SendSigningPackageArgs { + signing_package: vec![signing_package], + aux_msg: Default::default(), + }; + serde_json::to_vec(&send_args).unwrap() + } + + /// Small transaction: serialized signing package fits in a single frostd message + #[cfg(not(feature = "mesa-hardfork"))] + #[test] + fn test_small_signing_package_fits_frostd_limit() { + let serialized = serialize_signing_package_for_tx(include_str!( + "../../../../mina-tx/tests/data/payment-zkapp.json" + )); + eprintln!( + "Small tx SendSigningPackageArgs: {} bytes", + serialized.len() + ); + assert!( + serialized.len() <= api::MAX_MSG_SIZE, + "Small tx serialized ({} bytes) should fit in frostd limit ({})", + serialized.len(), + api::MAX_MSG_SIZE + ); + + // Encryption should also succeed + let (privkey, _) = Cipher::generate_keypair().unwrap(); + let (_, pubkey) = Cipher::generate_keypair().unwrap(); + let mut cipher = Cipher::new(privkey, vec![pubkey.clone()]).unwrap(); + let encrypted = cipher.encrypt(Some(&pubkey), serialized).unwrap(); + assert!( + encrypted.len() <= api::MAX_MSG_SIZE, + "Small tx encrypted ({} bytes) should fit in frostd limit ({})", + encrypted.len(), + api::MAX_MSG_SIZE + ); + } + + /// Large transaction with verification keys: the serialized signing package exceeds + /// frostd's MAX_MSG_SIZE because frost-core hex-encodes the message bytes inside + /// SigningPackage, roughly doubling the 46KB deploy-v0.0.4 transaction to 92KB. + #[cfg(not(feature = "mesa-hardfork"))] + #[test] + fn test_large_signing_package_exceeds_frostd_limit() { + let serialized = serialize_signing_package_for_tx(include_str!( + "../../../../mina-tx/tests/data/deploy-v0.0.4-unsigned.json" + )); + eprintln!( + "Deploy tx SendSigningPackageArgs: {} bytes (frostd limit: {})", + serialized.len(), + api::MAX_MSG_SIZE + ); + assert!( + serialized.len() > api::MAX_MSG_SIZE, + "The deploy-v0.0.4 serialized signing package ({} bytes) should exceed the frostd limit ({})", + serialized.len(), + api::MAX_MSG_SIZE + ); + + // Encryption also fails because the plaintext exceeds the Noise single-frame limit + let (privkey, _) = Cipher::generate_keypair().unwrap(); + let (_, pubkey) = Cipher::generate_keypair().unwrap(); + let mut cipher = Cipher::new(privkey, vec![pubkey.clone()]).unwrap(); + assert!( + cipher.encrypt(Some(&pubkey), serialized).is_err(), + "Encrypting the deploy-v0.0.4 signing package should fail because it exceeds the Noise frame limit" + ); + } + + /// Chunking the serialized payload, encrypting each chunk separately, and + /// reassembling after decryption should produce the original payload. + /// Each encrypted chunk must fit within frostd's MAX_MSG_SIZE. + #[cfg(not(feature = "mesa-hardfork"))] + #[test] + fn test_chunked_encrypt_decrypt_roundtrip() { + let serialized = serialize_signing_package_for_tx(include_str!( + "../../../../mina-tx/tests/data/deploy-v0.0.4-unsigned.json" + )); + + let chunks: Vec<&[u8]> = serialized.chunks(MAX_CHUNK_PLAINTEXT).collect(); + eprintln!( + "Deploy tx: {} bytes, {} chunks (max {} bytes each)", + serialized.len(), + chunks.len(), + MAX_CHUNK_PLAINTEXT + ); + + let (privkey_a, pubkey_a) = Cipher::generate_keypair().unwrap(); + let (privkey_b, pubkey_b) = Cipher::generate_keypair().unwrap(); + let mut cipher_a = Cipher::new(privkey_a, vec![pubkey_b.clone()]).unwrap(); + let mut cipher_b = Cipher::new(privkey_b, vec![pubkey_a.clone()]).unwrap(); + + // Coordinator side: encrypt each chunk, verify each fits in frostd limit + let mut encrypted_chunks = Vec::new(); + for chunk in &chunks { + let encrypted = cipher_a + .encrypt(Some(&pubkey_b), chunk.to_vec()) + .expect("each chunk should encrypt"); + assert!( + encrypted.len() <= api::MAX_MSG_SIZE, + "encrypted chunk ({} bytes) exceeds frostd limit ({})", + encrypted.len(), + api::MAX_MSG_SIZE + ); + encrypted_chunks.push(encrypted); + } + + // Participant side: decrypt each chunk, reassemble + let mut reassembled = Vec::new(); + for encrypted in encrypted_chunks { + let decrypted = cipher_b + .decrypt(api::Msg { + sender: pubkey_a.clone(), + msg: encrypted, + }) + .expect("each chunk should decrypt"); + reassembled.extend_from_slice(&decrypted.msg); + } + + assert_eq!(reassembled, serialized); + + // Verify the reassembled bytes deserialize back correctly + let deserialized: SendSigningPackageArgs = + serde_json::from_slice(&reassembled).unwrap(); + assert_eq!(deserialized.signing_package.len(), 1); + } +} diff --git a/mina-frost-client/src/participant/comms.rs b/mina-frost-client/src/participant/comms.rs index 33b38c2..7ec9d6b 100644 --- a/mina-frost-client/src/participant/comms.rs +++ b/mina-frost-client/src/participant/comms.rs @@ -19,6 +19,11 @@ use frost::{ Identifier, }; +/// The length of the header for each chunk of data sent to the server. +/// This is used as a fix when sending large messages to the server, +/// particularly with large Mina contract deployments +pub(crate) const CHUNK_HEADER_LEN: usize = 4; + #[derive(Serialize, Deserialize)] #[serde(crate = "self::serde")] #[serde(bound = "C: Ciphersuite")] diff --git a/mina-frost-client/src/participant/comms/http.rs b/mina-frost-client/src/participant/comms/http.rs index c56a15b..acfe485 100644 --- a/mina-frost-client/src/participant/comms/http.rs +++ b/mina-frost-client/src/participant/comms/http.rs @@ -13,9 +13,12 @@ use frost_core::{round1::SigningCommitments, round2::SignatureShare, Ciphersuite use rand::thread_rng; use snow::{HandshakeState, TransportState}; -use crate::api::{self, SendSigningPackageArgs, Uuid}; use crate::cipher::Cipher; use crate::client::Client; +use crate::{ + api::{self, SendSigningPackageArgs, Uuid}, + participant::comms::CHUNK_HEADER_LEN, +}; use super::super::config::Config; use super::Comms; @@ -210,9 +213,10 @@ where eprint!("Waiting for coordinator to send signing package..."); - // Receive SigningPackage from Coordinator - - let r: SendSigningPackageArgs = loop { + // The coordinator sends a 4-byte big-endian chunk count header, then N + // encrypted chunks. Poll until the header arrives, capturing any chunks + // that arrived in the same batch as leftovers. + let (num_chunks, leftover) = loop { let r = self .client .receive(&api::ReceiveArgs { @@ -224,12 +228,54 @@ where tokio::time::sleep(Duration::from_secs(2)).await; eprint!("."); } else { - eprintln!("\nSigning package received"); let msg = cipher.decrypt(r.msgs[0].clone())?; - break serde_json::from_slice(&msg.msg)?; + let header: [u8; CHUNK_HEADER_LEN] = msg + .msg + .as_slice() + .try_into() + .map_err(|_| eyre::eyre!("invalid chunk count header"))?; + break (u32::from_be_bytes(header) as usize, r.msgs[1..].to_vec()); } }; + // Collect all chunks: drain any that arrived with the header, then poll + // for the rest. + let mut reassembled = Vec::new(); + let mut collected = 0; + + for m in leftover { + let decrypted = cipher.decrypt(m)?; + reassembled.extend_from_slice(&decrypted.msg); + collected += 1; + if collected == num_chunks { + break; + } + } + + while collected < num_chunks { + let r = self + .client + .receive(&api::ReceiveArgs { + session_id, + as_coordinator: false, + }) + .await?; + for m in r.msgs { + let decrypted = cipher.decrypt(m)?; + reassembled.extend_from_slice(&decrypted.msg); + collected += 1; + if collected == num_chunks { + break; + } + } + if collected < num_chunks { + tokio::time::sleep(Duration::from_secs(2)).await; + eprint!("."); + } + } + eprintln!("\nSigning package received ({} chunks)", collected); + + let r: SendSigningPackageArgs = serde_json::from_slice(&reassembled)?; Ok(r) }