Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file not shown.
Binary file modified artifacts/lez/programs/amm.bin
Binary file not shown.
Binary file modified artifacts/lez/programs/associated_token_account.bin
Binary file not shown.
Binary file modified artifacts/lez/programs/authenticated_transfer.bin
Binary file not shown.
Binary file modified artifacts/lez/programs/bridge.bin
Binary file not shown.
Binary file modified artifacts/lez/programs/bridge_lock.bin
Binary file not shown.
Binary file modified artifacts/lez/programs/clock.bin
Binary file not shown.
Binary file modified artifacts/lez/programs/cross_zone_inbox.bin
Binary file not shown.
Binary file modified artifacts/lez/programs/cross_zone_outbox.bin
Binary file not shown.
Binary file modified artifacts/lez/programs/fee.bin
Binary file not shown.
Binary file modified artifacts/lez/programs/ping_receiver.bin
Binary file not shown.
Binary file modified artifacts/lez/programs/ping_sender.bin
Binary file not shown.
Binary file modified artifacts/lez/programs/sequencer_stake.bin
Binary file not shown.
Binary file modified artifacts/lez/programs/token.bin
Binary file not shown.
Binary file modified artifacts/lez/programs/wrapped_token.bin
Binary file not shown.
19 changes: 17 additions & 2 deletions integration_tests/tests/private_transaction_padding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@ use anyhow::Result;
use integration_tests::{TestContext, fetch_privacy_preserving_tx, private_mention};
use lee::AccountId;
use tokio::test;
use wallet::cli::{
Command, SubcommandReturnValue, programs::native_token_transfer::AuthTransferSubcommand,
use wallet::{
CIPHERTEXT_PAD_SIZE,
cli::{
Command, SubcommandReturnValue, programs::native_token_transfer::AuthTransferSubcommand,
},
};

#[test]
Expand Down Expand Up @@ -37,5 +40,17 @@ async fn private_transaction_pads_notes_to_max() -> Result<()> {

assert_eq!(tx.message.private_actions.len(), 7);

let expected = usize::try_from(CIPHERTEXT_PAD_SIZE).expect("pad size fits in usize");
let lengths: Vec<usize> = tx
.message
.private_actions
.iter()
.map(|action| action.encrypted_post_state.ciphertext.as_bytes().len())
.collect();
assert!(
lengths.iter().all(|&len| len == expected),
"notes under the {expected}-byte pad must all reach it, got {lengths:?}"
);

Ok(())
}
2 changes: 2 additions & 0 deletions lee/privacy_preserving_circuit/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ fn main() {
account_identities,
program_account_id,
dummy_inputs,
ciphertext_padding,
initial_pre_states,
program_image_claims,
} = borsh::from_slice(&read_input_frame()).expect("circuit input must be valid borsh");
Expand All @@ -26,6 +27,7 @@ fn main() {
execution_state,
&account_identities,
dummy_inputs,
ciphertext_padding,
program_image_claims,
);

Expand Down
20 changes: 18 additions & 2 deletions lee/privacy_preserving_circuit/src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ pub fn compute_circuit_output(
execution_state: ExecutionState,
account_identities: &[InputAccountIdentity],
dummy_inputs: Vec<DummyInput>,
ciphertext_padding: Option<u32>,
program_image_claims: Vec<ProgramImageClaim>,
) -> PrivacyPreservingCircuitOutput {
let (block_validity_window, timestamp_validity_window, pda_seed_by_position, states_iter) =
Expand Down Expand Up @@ -152,13 +153,14 @@ pub fn compute_circuit_output(
random_seed,
new_nullifier,
new_nonce,
ciphertext_padding,
);
}
}
}

for dummy in dummy_inputs {
emit_dummy_output(&mut output, dummy);
emit_dummy_output(&mut output, dummy, ciphertext_padding);
}

obfuscate_output_ordering(&mut output);
Expand All @@ -183,7 +185,18 @@ fn obfuscate_output_ordering(output: &mut PrivacyPreservingCircuitOutput) {
}
}

fn emit_dummy_output(output: &mut PrivacyPreservingCircuitOutput, dummy: DummyInput) {
fn emit_dummy_output(
output: &mut PrivacyPreservingCircuitOutput,
dummy: DummyInput,
ciphertext_padding: Option<u32>,
) {
if let Some(padding) = ciphertext_padding {
assert!(
dummy.note.ciphertext.as_bytes().len()
>= usize::try_from(padding).expect("pad length fits in usize"),
"Dummy note shorter than the requested ciphertext padding"
);
}
// Note: the nullifiers and commitments are generated from seeds.
// The prover is responsible for their randomness.
let nullifier = Nullifier::for_dummy(&dummy.nullifier_seed);
Expand Down Expand Up @@ -216,6 +229,7 @@ fn emit_private_output(
random_seed: &[u8; 32],
new_nullifier: (Nullifier, CommitmentSetDigest),
new_nonce: Nonce,
ciphertext_padding: Option<u32>,
) {
let mut post_with_updated_nonce = post_state;
post_with_updated_nonce.nonce = new_nonce;
Expand All @@ -230,6 +244,7 @@ fn emit_private_output(
kind,
&shared_secret,
&new_nullifier.0,
ciphertext_padding,
);

output.private_actions.push(PrivateAction {
Expand Down Expand Up @@ -272,6 +287,7 @@ mod tests {
&PrivateAccountKind::Regular(0),
&SharedSecretKey([0; 32]),
&nullifier,
None,
);
PrivateAction {
nullifier,
Expand Down
3 changes: 3 additions & 0 deletions lee/state_machine/core/src/circuit_io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ pub struct PrivacyPreservingCircuitInput {
/// The top-level call's own dispatch address.
pub program_account_id: AccountId,
pub dummy_inputs: Vec<DummyInput>,
/// Minimum length of each note the guest encrypts, capped at `MAX_CIPHERTEXT_PADDING`.
/// `dummy_inputs` carry their own ciphertexts and are checked against it, not padded.
pub ciphertext_padding: Option<u32>,
/// `account_id`s the top-level call was invoked with. Every one must still appear somewhere
/// in the final accumulated pre-states, or the guest rejects — catches a chained call
/// silently dropping an account from its own output.
Expand Down
5 changes: 5 additions & 0 deletions lee/state_machine/core/src/encoding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,11 @@ impl Ciphertext {
bytes
}

#[must_use]
pub fn as_bytes(&self) -> &[u8] {
&self.0
}

#[cfg(feature = "host")]
#[must_use]
pub fn into_inner(self) -> Vec<u8> {
Expand Down
139 changes: 138 additions & 1 deletion lee/state_machine/core/src/encryption/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ pub mod shared_key_derivation;
/// Length in bytes of an ML-KEM-768 ciphertext (the `EphemeralPublicKey` payload).
pub const ML_KEM_768_CIPHERTEXT_LEN: usize = 1088;

/// Upper bound on a requested note pad.
///
/// Keeps a prover from inflating its own transaction into a whole block at the flat
/// private-transaction storage fee. Plaintexts longer than this are unaffected, the pad is only
/// a floor.
pub const MAX_CIPHERTEXT_PADDING: u32 = 8 * 1024;

pub type Scalar = [u8; 32];

#[derive(Serialize, Deserialize, Clone, Copy)]
Expand Down Expand Up @@ -114,17 +121,39 @@ impl EncryptedAccountData {
}

impl EncryptionScheme {
/// Encrypts a note: the `kind` header followed by the account bytes, under a keystream keyed
/// by the shared secret and the nullifier.
///
/// `pad_to_len` is a floor: shorter plaintexts are zero-extended to it before encryption,
/// longer ones keep their own length. Decryption needs no counterpart, `Account` bytes are
/// length-prefixed. Pass `None` off the note path, where the ciphertext is never published
/// and its length carries nothing.
///
/// # Panics
///
/// If `pad_to_len` exceeds [`MAX_CIPHERTEXT_PADDING`].
#[must_use]
pub fn encrypt(
account: &Account,
kind: &PrivateAccountKind,
shared_secret: &SharedSecretKey,
nullifier: &Nullifier,
pad_to_len: Option<u32>,
) -> Ciphertext {
// Plaintext: PrivateAccountKind::HEADER_LEN bytes header || account bytes.
// Both variants produce the same header length — see PrivateAccountKind::to_header_bytes.
let mut buffer = kind.to_header_bytes().to_vec();
buffer.extend_from_slice(&account.to_bytes());
if let Some(pad_to_len) = pad_to_len {
assert!(
pad_to_len <= MAX_CIPHERTEXT_PADDING,
"ciphertext padding exceeds the maximum"
);
let pad_to_len = usize::try_from(pad_to_len).expect("pad length fits in usize");
if pad_to_len > buffer.len() {
buffer.resize(pad_to_len, 0);
}
}
Self::symmetric_transform(&mut buffer, shared_secret, nullifier);
Ciphertext(buffer)
}
Expand Down Expand Up @@ -206,6 +235,7 @@ mod tests {
&PrivateAccountKind::Regular(42),
&secret,
&nullifier,
None,
);
let pda_ct = EncryptionScheme::encrypt(
&account,
Expand All @@ -216,11 +246,118 @@ mod tests {
},
&secret,
&nullifier,
None,
);

assert_eq!(account_ct.0.len(), pda_ct.0.len());
}

fn account_with_data(data_len: usize) -> Account {
Account {
data: vec![7_u8; data_len].try_into().expect("data fits"),
..Account::default()
}
}

fn plaintext_len(account: &Account) -> u32 {
let len = PrivateAccountKind::HEADER_LEN
.checked_add(account.to_bytes().len())
.expect("plaintext length fits in usize");
u32::try_from(len).expect("plaintext length fits in u32")
}

#[test]
fn encrypt_pads_short_plaintext_to_requested_length() {
let secret = SharedSecretKey([0_u8; 32]);
let nullifier = Nullifier::for_account_initialization(&AccountId::new([0_u8; 32]));
let kind = PrivateAccountKind::Regular(0);

for data_len in [0, 10, 100, 300] {
let account = account_with_data(data_len);
let base = plaintext_len(&account);
// exact fit (no-op), one byte over (tightest pad), and a loose pad
for delta in [0, 1, 1000] {
let pad = base.saturating_add(delta);
let ct = EncryptionScheme::encrypt(&account, &kind, &secret, &nullifier, Some(pad));
assert_eq!(
ct.as_bytes().len(),
usize::try_from(pad).expect("pad fits in usize"),
"data_len {data_len}, pad {pad}"
);
}
}
}

#[test]
fn encrypt_leaves_plaintext_longer_than_the_pad_alone() {
let secret = SharedSecretKey([0_u8; 32]);
let nullifier = Nullifier::for_account_initialization(&AccountId::new([0_u8; 32]));
let kind = PrivateAccountKind::Regular(0);
let account = account_with_data(1000);
let base = plaintext_len(&account);

let pad = base.saturating_sub(1);
let ct = EncryptionScheme::encrypt(&account, &kind, &secret, &nullifier, Some(pad));

assert_eq!(
ct.as_bytes().len(),
usize::try_from(base).expect("plaintext length fits in usize")
);
}

#[cfg(feature = "host")]
#[test]
fn padded_note_round_trips() {
const PAD: u32 = 512;

let d = [3_u8; 32];
let z = [4_u8; 32];
let vpk = shared_key_derivation::ViewingPublicKey::from_seed(&d, &z);
let (sender_ss, epk) = SharedSecretKey::encapsulate(&vpk);
let receiver_ss = SharedSecretKey::decapsulate(&epk, &d, &z).unwrap();

let account = Account {
balance: 42,
..account_with_data(37)
};
let kind = PrivateAccountKind::Pda {
account_id: AccountId::new([1_u8; 32]),
seed: PdaSeed::new([2_u8; 32]),
identifier: 9,
};
let nullifier = Nullifier::for_account_initialization(&AccountId::new([7_u8; 32]));

let ct = EncryptionScheme::encrypt(&account, &kind, &sender_ss, &nullifier, Some(PAD));
assert_eq!(
ct.as_bytes().len(),
usize::try_from(PAD).expect("pad fits in usize")
);

let (decoded_kind, decoded_account) =
EncryptionScheme::decrypt(&ct, &receiver_ss, &nullifier)
.expect("a padded note must decrypt");

assert_eq!(decoded_account, account);
assert_eq!(decoded_kind, kind);

// Padding before the keystream, not after it: zeros bolted onto the ciphertext would
// republish the plaintext length, which is the leak the pad exists to close.
let tail = usize::try_from(plaintext_len(&account)).expect("plaintext fits in usize");
assert!(ct.as_bytes()[tail..].iter().any(|byte| *byte != 0));
}

#[test]
#[should_panic(expected = "ciphertext padding exceeds the maximum")]
fn encrypt_rejects_padding_above_the_maximum() {
let _ct = EncryptionScheme::encrypt(
&Account::default(),
&PrivateAccountKind::Regular(0),
&SharedSecretKey([0_u8; 32]),
&Nullifier::for_account_initialization(&AccountId::new([0_u8; 32])),
Some(MAX_CIPHERTEXT_PADDING.saturating_add(1)),
);
}

/// Verifies the full account-note pipeline: ML-KEM-768 encapsulation/decapsulation
/// feeds the correct shared secret into the SHA-256 KDF and `ChaCha20` round-trip.
#[cfg(feature = "host")]
Expand All @@ -241,7 +378,7 @@ mod tests {
let kind = PrivateAccountKind::Regular(0);
let nullifier = Nullifier::for_account_initialization(&AccountId::new([7_u8; 32]));

let ct = EncryptionScheme::encrypt(&account, &kind, &sender_ss, &nullifier);
let ct = EncryptionScheme::encrypt(&account, &kind, &sender_ss, &nullifier, None);
let (decoded_kind, decoded_account) =
EncryptionScheme::decrypt(&ct, &receiver_ss, &nullifier)
.expect("decryption must succeed with correct shared secret");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ pub fn execute_and_prove(
instruction_data,
account_identities,
vec![],
None,
program_with_dependencies,
)
}
Expand All @@ -111,6 +112,7 @@ pub fn execute_and_prove_with_padded_inputs(
instruction_data: InstructionData,
account_identities: Vec<InputAccountIdentity>,
dummy_inputs: Vec<DummyInput>,
ciphertext_padding: Option<u32>,
program_with_dependencies: &ProgramWithDependencies,
) -> Result<(PrivacyPreservingCircuitOutput, Proof), LeeError> {
let ProgramWithDependencies {
Expand Down Expand Up @@ -326,6 +328,7 @@ pub fn execute_and_prove_with_padded_inputs(
account_identities,
program_account_id: *initial_account_id,
dummy_inputs,
ciphertext_padding,
initial_pre_states,
program_image_claims,
};
Expand Down
Loading
Loading