diff --git a/CHANGELOG.md b/CHANGELOG.md index f23a0b4bed..3d6b220c72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ - Fixed `output_note::add_asset` and `output_note::set_attachment` to no longer accept invalid note indices ([#2824](https://github.com/0xMiden/protocol/pull/2824)). - [BREAKING] Keyed AggLayer faucet token registry by `(origin_token_address, origin_network)` instead of `origin_token_address` alone, preventing same-address cross-network mint collisions on CLAIM ([#2860](https://github.com/0xMiden/protocol/pull/2860)). - [BREAKING] Replaced `NoAuth` with the new `AuthNetworkAccount` auth component on the AggLayer bridge and AggLayer faucet, closing the forged-MINT attack surface where any transaction against the bridge could emit a bridge-authored MINT note ([#2797](https://github.com/0xMiden/protocol/issues/2797), [#2818](https://github.com/0xMiden/protocol/pull/2818)). +- Enforced `NoteType::Public` for B2AGG bridge-out notes so a recipient-identical private note can no longer be folded into the Local Exit Tree and desync the aggkit mirror ([#2988](https://github.com/0xMiden/protocol/pull/2988)). ## 0.14.3 (2026-04-07) diff --git a/crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm b/crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm index c0bcff19af..4933d9c4e3 100644 --- a/crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm +++ b/crates/miden-agglayer/asm/agglayer/bridge/bridge_out.masm @@ -22,6 +22,7 @@ use agglayer::common::eth_address::EthereumAddressFormat # ================================================================================================= const ERR_B2AGG_DESTINATION_NETWORK_IS_MIDEN = "B2AGG note destination network ID must not be Miden's AggLayer network ID" +const ERR_B2AGG_NOTE_MUST_BE_PUBLIC = "B2AGG note bridged to AggLayer must be of public note type" # CONSTANTS # ================================================================================================= @@ -97,6 +98,8 @@ const BURN_NOTE_NUM_STORAGE_ITEMS=0 #! 4. Computes Keccak hash of the leaf data and appends it to the Local Exit Tree #! 5. Creates a BURN note with the bridged out asset #! +#! The note being bridged out must be public so its pre-image is observable off-chain. +#! #! Inputs: [ASSET_KEY, ASSET_VALUE, dest_network_id, dest_address(5), pad(2)] #! Outputs: [pad(16)] #! @@ -107,11 +110,16 @@ const BURN_NOTE_NUM_STORAGE_ITEMS=0 #! - dest_address(5) are 5 u32 values representing a 20-byte Ethereum address. #! #! Panics if: +#! - the note being bridged out is not public. #! - destination network ID is Miden's AggLayer network ID. #! #! Invocation: call @locals(15) pub proc bridge_out + # The note being bridged out must be public (see above) + exec.active_note::is_public assert.err=ERR_B2AGG_NOTE_MUST_BE_PUBLIC + # => [ASSET_KEY, ASSET_VALUE, dest_network_id, dest_address(5), pad(2)] + # Save ASSET to local memory for later BURN note creation locaddr.BRIDGE_OUT_BURN_ASSET_LOC exec.asset::store diff --git a/crates/miden-agglayer/asm/note_scripts/B2AGG.masm b/crates/miden-agglayer/asm/note_scripts/B2AGG.masm index e4605dc74e..8b98314cce 100644 --- a/crates/miden-agglayer/asm/note_scripts/B2AGG.masm +++ b/crates/miden-agglayer/asm/note_scripts/B2AGG.masm @@ -50,6 +50,7 @@ const ERR_B2AGG_TARGET_ACCOUNT_MISMATCH="B2AGG note attachment target account do #! - The note does not contain exactly 6 storage items. #! - The note does not contain exactly 1 asset. #! - The note attachment does not target the consuming account. +#! - The note is not public (enforced by `bridge_out`). #! - The destination network ID equals Miden's AggLayer network ID. begin dropw diff --git a/crates/miden-protocol/asm/protocol/active_note.masm b/crates/miden-protocol/asm/protocol/active_note.masm index cf47a36aa1..b8691485a4 100644 --- a/crates/miden-protocol/asm/protocol/active_note.masm +++ b/crates/miden-protocol/asm/protocol/active_note.masm @@ -8,6 +8,8 @@ use miden::protocol::kernel_proc_offsets::INPUT_NOTE_GET_METADATA_OFFSET use miden::protocol::kernel_proc_offsets::INPUT_NOTE_GET_SERIAL_NUMBER_OFFSET use miden::protocol::kernel_proc_offsets::INPUT_NOTE_GET_SCRIPT_ROOT_OFFSET use miden::protocol::note +use miden::protocol::util::note::NOTE_TYPE_PUBLIC +use miden::protocol::util::note::NOTE_TYPE_PRIVATE # ERRORS # ================================================================================================= @@ -166,6 +168,66 @@ pub proc get_metadata # => [NOTE_ATTACHMENT, METADATA_HEADER] end +#! Returns whether the active note is public. +#! +#! The note type is stored in the metadata header rather than the recipient commitment, so this +#! reads the active note's metadata and compares the note type against the public encoding. +#! +#! Inputs: [] +#! Outputs: [is_public] +#! +#! Where: +#! - is_public is 1 if the active note is public, 0 otherwise. +#! +#! Panics if: +#! - no note is currently active. +#! +#! Invocation: exec +pub proc is_public + exec.get_metadata + # => [NOTE_ATTACHMENT, METADATA_HEADER] + + # drop the attachment word, keeping the metadata header + dropw + # => [METADATA_HEADER] + + exec.note::extract_note_type_from_metadata + # => [note_type] + + eq.NOTE_TYPE_PUBLIC + # => [is_public] +end + +#! Returns whether the active note is private. +#! +#! The note type is stored in the metadata header rather than the recipient commitment, so this +#! reads the active note's metadata and compares the note type against the private encoding. +#! +#! Inputs: [] +#! Outputs: [is_private] +#! +#! Where: +#! - is_private is 1 if the active note is private, 0 otherwise. +#! +#! Panics if: +#! - no note is currently active. +#! +#! Invocation: exec +pub proc is_private + exec.get_metadata + # => [NOTE_ATTACHMENT, METADATA_HEADER] + + # drop the attachment word, keeping the metadata header + dropw + # => [METADATA_HEADER] + + exec.note::extract_note_type_from_metadata + # => [note_type] + + eq.NOTE_TYPE_PRIVATE + # => [is_private] +end + #! Returns the sender of the active note. #! #! Inputs: [] diff --git a/crates/miden-protocol/asm/protocol/note.masm b/crates/miden-protocol/asm/protocol/note.masm index 8962a86263..e7aa136f48 100644 --- a/crates/miden-protocol/asm/protocol/note.masm +++ b/crates/miden-protocol/asm/protocol/note.masm @@ -217,3 +217,30 @@ pub proc extract_attachment_info_from_metadata u32split swap # => [attachment_kind, attachment_scheme] end + +#! Extracts the note type from the provided metadata header. +#! +#! Inputs: [METADATA_HEADER] +#! Outputs: [note_type] +#! +#! Where: +#! - METADATA_HEADER is the metadata of a note. +#! - note_type is the note type of the note. It is stored in the low byte of the merged +#! sender_id_suffix_and_note_type felt and is not part of the recipient commitment. +#! +#! Invocation: exec +pub proc extract_note_type_from_metadata + # => [sender_id_suffix_and_note_type, sender_id_prefix, tag, attachment_kind_scheme] + + # keep the merged suffix/note_type felt and drop the other three header felts + movdn.3 drop drop drop + # => [sender_id_suffix_and_note_type] + + # take the low 32 bits of the felt + u32split swap drop + # => [suffix_and_note_type_lo] + + # mask to the low byte, which holds the note type + u32and.0xff + # => [note_type] +end diff --git a/crates/miden-protocol/asm/shared_utils/util/note.masm b/crates/miden-protocol/asm/shared_utils/util/note.masm index 066dfcd2fb..bd7a311840 100644 --- a/crates/miden-protocol/asm/shared_utils/util/note.masm +++ b/crates/miden-protocol/asm/shared_utils/util/note.masm @@ -4,6 +4,11 @@ # The maximum number of storage values associated with a single note. pub const MAX_NOTE_STORAGE_ITEMS = 1024 +# Note type encoding, mirroring the Rust `NoteType` definition in `note/note_type.rs` +# (`PUBLIC = 0b01`, `PRIVATE = 0b10`). +pub const NOTE_TYPE_PUBLIC=1 +pub const NOTE_TYPE_PRIVATE=2 + #! Signals the absence of a note attachment. pub const ATTACHMENT_KIND_NONE=0 #! A note attachment consisting of a single Word. diff --git a/crates/miden-testing/src/kernel_tests/tx/test_active_note.rs b/crates/miden-testing/src/kernel_tests/tx/test_active_note.rs index 3130c622f5..05ae2c87ed 100644 --- a/crates/miden-testing/src/kernel_tests/tx/test_active_note.rs +++ b/crates/miden-testing/src/kernel_tests/tx/test_active_note.rs @@ -21,12 +21,12 @@ use miden_protocol::testing::account_id::{ ACCOUNT_ID_SENDER, }; use miden_protocol::transaction::memory::{ASSET_SIZE, ASSET_VALUE_OFFSET}; -use miden_protocol::{EMPTY_WORD, Felt, ONE, WORD_SIZE, Word}; +use miden_protocol::{EMPTY_WORD, Felt, ONE, WORD_SIZE, Word, ZERO}; use miden_standards::code_builder::CodeBuilder; use miden_standards::testing::mock_account::MockAccountExt; use crate::kernel_tests::tx::ExecutionOutputExt; -use crate::utils::create_public_p2any_note; +use crate::utils::{create_p2any_note, create_public_p2any_note}; use crate::{ Auth, MockChain, @@ -128,6 +128,72 @@ async fn test_active_note_get_metadata() -> anyhow::Result<()> { Ok(()) } +/// `is_public` / `is_private` return the correct flag for the active note's type. +#[rstest::rstest] +#[case::public(NoteType::Public)] +#[case::private(NoteType::Private)] +#[tokio::test] +async fn test_active_note_is_public_and_is_private( + #[case] note_type: NoteType, +) -> anyhow::Result<()> { + let tx_context = { + let account = + Account::mock(ACCOUNT_ID_REGULAR_PUBLIC_ACCOUNT_UPDATABLE_CODE, Auth::IncrNonce); + let mut rng = RandomCoin::new(Default::default()); + let input_note = create_p2any_note( + ACCOUNT_ID_SENDER.try_into().unwrap(), + note_type, + [FungibleAsset::mock(100)], + &mut rng, + ); + TransactionContextBuilder::new(account) + .extend_input_notes(vec![input_note]) + .build()? + }; + + let (expected_public, expected_private) = match note_type { + NoteType::Public => (ONE, ZERO), + NoteType::Private => (ZERO, ONE), + }; + + let code = format!( + r#" + use $kernel::prologue + use $kernel::note->note_internal + use miden::protocol::active_note + + begin + exec.prologue::prepare_transaction + exec.note_internal::prepare_note + dropw dropw dropw dropw + + # check whether the active note is public + exec.active_note::is_public + # => [is_public] + + push.{expected_public} + assert_eq.err="active note public flag did not match expected value" + # => [] + + # check whether the active note is private + exec.active_note::is_private + # => [is_private] + + push.{expected_private} + assert_eq.err="active note private flag did not match expected value" + # => [] + + # truncate the stack + swapw dropw + end + "#, + ); + + tx_context.execute_code(&code).await?; + + Ok(()) +} + #[tokio::test] async fn test_active_note_get_sender() -> anyhow::Result<()> { let tx_context = { diff --git a/crates/miden-testing/tests/agglayer/bridge_out.rs b/crates/miden-testing/tests/agglayer/bridge_out.rs index 4438ee2aa1..2f433f5e61 100644 --- a/crates/miden-testing/tests/agglayer/bridge_out.rs +++ b/crates/miden-testing/tests/agglayer/bridge_out.rs @@ -2,6 +2,7 @@ extern crate alloc; use miden_agglayer::errors::{ ERR_B2AGG_DESTINATION_NETWORK_IS_MIDEN, + ERR_B2AGG_NOTE_MUST_BE_PUBLIC, ERR_B2AGG_TARGET_ACCOUNT_MISMATCH, ERR_FAUCET_NOT_REGISTERED, }; @@ -29,12 +30,13 @@ use miden_protocol::account::{ StorageMapKey, }; use miden_protocol::asset::{Asset, FungibleAsset}; -use miden_protocol::note::{NoteAssets, NoteType}; +use miden_protocol::errors::MasmError; +use miden_protocol::note::{Note, NoteAssets, NoteAttachment, NoteMetadata, NoteType}; use miden_protocol::transaction::RawOutputNote; use miden_protocol::{Felt, Word}; use miden_standards::account::faucets::TokenMetadata; use miden_standards::account::mint_policies::OwnerControlledInitConfig; -use miden_standards::note::StandardNote; +use miden_standards::note::{NetworkAccountTarget, NoteExecutionHint, StandardNote}; use miden_testing::{Auth, MockChain, assert_transaction_executor_error}; use miden_tx::utils::hex_to_bytes; use rand::rngs::StdRng; @@ -541,10 +543,37 @@ async fn test_bridge_out_fails_with_unregistered_faucet() -> anyhow::Result<()> Ok(()) } -/// B2AGG / bridge-out must reject a note whose `destination_network` equals the Miden network ID, -/// even when the faucet is registered and the rest of the bridge-out path would otherwise succeed. +/// The kind of malformation applied to an otherwise-valid B2AGG note in +/// [`test_bridge_out_rejects_invalid_b2agg_note`]. +#[derive(Debug, Clone, Copy)] +enum InvalidB2aggNote { + /// `destination_network` equals Miden's AggLayer network ID. + DestinationIsMiden, + /// Recipient-identical note marked `NoteType::Private` instead of public. + PrivateNoteType, +} + +/// B2AGG / bridge-out must reject malformed notes even when the faucet is registered and the rest +/// of the bridge-out path would otherwise succeed. +/// +/// - `DestinationIsMiden`: the destination network equals Miden's own network ID, which +/// `bridge_out` rejects outright. +/// - `PrivateNoteType`: the note type lives in `NoteMetadata`, not in the recipient commitment, so +/// an attacker can assemble a note with an identical recipient/attachment/asset but +/// `NoteType::Private`. If accepted, the leaf would be folded into the on-chain Local Exit Tree +/// while AggLayer's off-chain indexer (aggkit) could never recover the pre-image, permanently +/// desyncing the LET mirror. The bridge branch must reject it. +#[rstest::rstest] +#[case::destination_is_miden( + InvalidB2aggNote::DestinationIsMiden, + ERR_B2AGG_DESTINATION_NETWORK_IS_MIDEN +)] +#[case::private_note_type(InvalidB2aggNote::PrivateNoteType, ERR_B2AGG_NOTE_MUST_BE_PUBLIC)] #[tokio::test] -async fn test_bridge_out_fails_when_destination_is_miden_network() -> anyhow::Result<()> { +async fn test_bridge_out_rejects_invalid_b2agg_note( + #[case] invalid_note: InvalidB2aggNote, + #[case] expected_err: MasmError, +) -> anyhow::Result<()> { let mut builder = MockChain::builder(); // CREATE BRIDGE ADMIN ACCOUNT (sends CONFIG_AGG_BRIDGE notes) @@ -607,9 +636,7 @@ async fn test_bridge_out_fails_when_destination_is_miden_network() -> anyhow::Re )?; builder.add_output_note(RawOutputNote::Full(config_note.clone())); - // CREATE B2AGG NOTE (targets the bridge) - // Set destination_network to exactly `AggLayerBridge::MIDEN_NETWORK_ID` so `bridge_out` - // fails immediately. + // CREATE THE INVALID B2AGG NOTE (targets the bridge) // -------------------------------------------------------------------------------------------- let amount = Felt::new(100); let bridge_asset: Asset = @@ -617,14 +644,39 @@ async fn test_bridge_out_fails_when_destination_is_miden_network() -> anyhow::Re let eth_address = EthAddress::from_hex(&vectors.destination_addresses[0]).expect("valid destination address"); - let b2agg_note = B2AggNote::create( - AggLayerBridge::MIDEN_NETWORK_ID, - eth_address, - NoteAssets::new(vec![bridge_asset])?, - bridge_account.id(), - faucet.id(), - builder.rng_mut(), - )?; + let b2agg_note = match invalid_note { + // Destination network equals Miden's own network ID, which `bridge_out` rejects. + InvalidB2aggNote::DestinationIsMiden => B2AggNote::create( + AggLayerBridge::MIDEN_NETWORK_ID, + eth_address, + NoteAssets::new(vec![bridge_asset])?, + bridge_account.id(), + faucet.id(), + builder.rng_mut(), + )?, + // Build a legitimate (public) B2AGG note with a valid destination network, then assemble an + // attack note reusing its exact recipient and assets but flipping the note type to Private. + // The note type is not part of the recipient commitment, so the two notes share a recipient + // and are indistinguishable to consensus apart from the type bit. + InvalidB2aggNote::PrivateNoteType => { + let public_note = B2AggNote::create( + origin_network, + eth_address, + NoteAssets::new(vec![bridge_asset])?, + bridge_account.id(), + faucet.id(), + builder.rng_mut(), + )?; + + let attachment = NoteAttachment::from( + NetworkAccountTarget::new(bridge_account.id(), NoteExecutionHint::Always) + .map_err(|e| anyhow::anyhow!(e.to_string()))?, + ); + let metadata = + NoteMetadata::new(faucet.id(), NoteType::Private).with_attachment(attachment); + Note::new(public_note.assets().clone(), metadata, public_note.recipient().clone()) + }, + }; builder.add_output_note(RawOutputNote::Full(b2agg_note.clone())); @@ -644,7 +696,7 @@ async fn test_bridge_out_fails_when_destination_is_miden_network() -> anyhow::Re mock_chain.add_pending_executed_transaction(&config_executed)?; mock_chain.prove_next_block()?; - // TX1: EXECUTE B2AGG NOTE AGAINST BRIDGE (must fail: destination_network is Miden's ID) + // TX1: EXECUTE THE INVALID B2AGG NOTE AGAINST BRIDGE (must fail) // -------------------------------------------------------------------------------------------- let foreign_account_inputs = mock_chain.get_foreign_account_inputs(faucet.id())?; @@ -655,7 +707,7 @@ async fn test_bridge_out_fails_when_destination_is_miden_network() -> anyhow::Re .execute() .await; - assert_transaction_executor_error!(result, ERR_B2AGG_DESTINATION_NETWORK_IS_MIDEN); + assert_transaction_executor_error!(result, expected_err); Ok(()) }