Skip to content
Merged
100 changes: 100 additions & 0 deletions mina-frost-client/src/cipher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}
}
201 changes: 196 additions & 5 deletions mina-frost-client/src/coordinator/comms/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<C: Ciphersuite> {
client: Client,
session_id: Option<Uuid>,
Expand Down Expand Up @@ -195,20 +199,41 @@ impl<C: Ciphersuite + 'static> Comms<C> for HTTPComms<C> {
// 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...");
Expand Down Expand Up @@ -288,3 +313,169 @@ impl<C: Ciphersuite + 'static> Comms<C> for HTTPComms<C> {
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<u8> {
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::<PallasMessage, _>(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<crate::BluePallasSuite> =
serde_json::from_slice(&reassembled).unwrap();
assert_eq!(deserialized.signing_package.len(), 1);
}
}
5 changes: 5 additions & 0 deletions mina-frost-client/src/participant/comms.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
Loading