diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b7dd1e7b1..cf1d6264a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ - [BREAKING] Removed unused `payback_attachment` from `SwapNoteStorage` and `attachment` from `MintNoteStorage` ([#2789](https://github.com/0xMiden/protocol/pull/2789)). - Automatically enable `concurrent` feature in `miden-tx` for `std` context ([#2791](https://github.com/0xMiden/protocol/pull/2791)). - Added `tx::get_tx_script_root` kernel procedure returning the root of the executed transaction script (zero word if no script was executed) ([#2816](https://github.com/0xMiden/protocol/pull/2816)). +- Added `NetworkAccount` auth component that rejects transactions which execute a tx script or consume input notes outside of a fixed whitelist of note script roots ([#2817](https://github.com/0xMiden/protocol/pull/2817)). - Added `TransactionScript::from_package()` method to create `TransactionScript` from `miden-mast-package::Package` ([#2779](https://github.com/0xMiden/protocol/pull/2779)). ### Fixes diff --git a/crates/miden-standards/asm/account_components/auth/network_account.masm b/crates/miden-standards/asm/account_components/auth/network_account.masm new file mode 100644 index 0000000000..60be5d8bbb --- /dev/null +++ b/crates/miden-standards/asm/account_components/auth/network_account.masm @@ -0,0 +1,106 @@ +# The MASM code of the NetworkAccount authentication component. +# +# See the `NetworkAccount` Rust type's documentation for more details. + +use miden::protocol::active_account +use miden::protocol::native_account +use miden::protocol::tx +use miden::protocol::input_note +use miden::core::word + +# CONSTANTS +# ================================================================================================ + +# The slot holding the map of whitelisted input-note script roots. Keys are the 4-felt note script +# roots; any non-empty value marks a root as whitelisted. +const WHITELIST_SLOT = word("miden::standards::auth::network_account::whitelist") + +# ERRORS +# ================================================================================================ + +const ERR_NETWORK_ACCOUNT_TX_SCRIPT_NOT_ALLOWED="transactions with a tx script cannot be executed against a network account" +const ERR_NETWORK_ACCOUNT_NOTE_NOT_WHITELISTED="input note script root is not in the network account whitelist" + +# AUTH PROCEDURE +# ================================================================================================ + +#! Authenticates a transaction against a NetworkAccount. +#! +#! Enforces two invariants: +#! 1. No transaction script may have been executed in this transaction. +#! 2. Every consumed input note must have a script root present in the whitelist stored at +#! `WHITELIST_SLOT`. +#! +#! If both checks pass, the nonce is incremented when the account state changed or the account is +#! new, matching the behavior of the NoAuth and SingleSig components. +#! +#! Inputs: [pad(16)] +#! Outputs: [pad(16)] +#! +#! Invocation: call +@auth_script +pub proc auth_tx_network_account + # ---- Reject transactions that executed a tx script ---- + exec.tx::get_tx_script_root + # => [TX_SCRIPT_ROOT, pad(16)] + + padw exec.word::eq + # => [no_tx_script, pad(16)] + + assert.err=ERR_NETWORK_ACCOUNT_TX_SCRIPT_NOT_ALLOWED + # => [pad(16)] + + # ---- Reject any input note whose script root is not whitelisted ---- + exec.tx::get_num_input_notes + # => [num_input_notes, pad(16)] + + dup neq.0 + while.true + # => [i, pad(16)] + sub.1 + # => [i-1, pad(16)] + + dup exec.input_note::get_script_root + # => [NOTE_SCRIPT_ROOT, i-1, pad(16)] + + push.WHITELIST_SLOT[0..2] + # => [slot_prefix, slot_suffix, NOTE_SCRIPT_ROOT, i-1, pad(16)] + + exec.active_account::get_map_item + # => [VALUE, i-1, pad(16)] + + padw exec.word::eq not + # => [is_whitelisted, i-1, pad(16)] + + assert.err=ERR_NETWORK_ACCOUNT_NOTE_NOT_WHITELISTED + # => [i-1, pad(16)] + + dup neq.0 + # => [should_continue, i-1, pad(16)] + end + # => [0, pad(16)] + + drop + # => [pad(16)] + + # ---- Increment nonce iff the account state changed or the account is new ---- + exec.active_account::get_initial_commitment + # => [INITIAL_COMMITMENT, pad(16)] + + exec.active_account::compute_commitment + # => [CURRENT_COMMITMENT, INITIAL_COMMITMENT, pad(16)] + + exec.word::eq not + # => [has_account_state_changed, pad(16)] + + exec.active_account::get_nonce eq.0 + # => [is_new_account, has_account_state_changed, pad(16)] + + or + # => [should_increment_nonce, pad(16)] + + if.true + exec.native_account::incr_nonce drop + end + # => [pad(16)] +end diff --git a/crates/miden-standards/src/account/auth/mod.rs b/crates/miden-standards/src/account/auth/mod.rs index 4a526bb77d..fe18e8935a 100644 --- a/crates/miden-standards/src/account/auth/mod.rs +++ b/crates/miden-standards/src/account/auth/mod.rs @@ -12,3 +12,6 @@ pub use multisig::{AuthMultisig, AuthMultisigConfig}; mod guarded_multisig; pub use guarded_multisig::{AuthGuardedMultisig, AuthGuardedMultisigConfig, GuardianConfig}; + +mod network_account; +pub use network_account::NetworkAccount; diff --git a/crates/miden-standards/src/account/auth/network_account.rs b/crates/miden-standards/src/account/auth/network_account.rs new file mode 100644 index 0000000000..94395a7805 --- /dev/null +++ b/crates/miden-standards/src/account/auth/network_account.rs @@ -0,0 +1,181 @@ +use alloc::vec::Vec; + +use miden_protocol::account::component::{ + AccountComponentMetadata, + SchemaType, + StorageSchema, + StorageSlotSchema, +}; +use miden_protocol::account::{ + AccountComponent, + AccountType, + StorageMap, + StorageMapKey, + StorageSlot, + StorageSlotName, +}; +use miden_protocol::utils::sync::LazyLock; +use miden_protocol::{Felt, Word}; + +use crate::account::components::network_account_library; + +// CONSTANTS +// ================================================================================================ + +static WHITELIST_SLOT_NAME: LazyLock = LazyLock::new(|| { + StorageSlotName::new("miden::standards::auth::network_account::whitelist") + .expect("storage slot name should be valid") +}); + +// A "sentinel value" is a placeholder value whose only job is to be distinguishable from a known +// default, letting readers of the data detect a condition (here: "this key is present"). We call +// this constant a sentinel because we only ever check whether the stored value differs from the +// empty word; its actual contents carry no information. +// +// Storage maps treat an empty word (`[0, 0, 0, 0]`) as "key absent", so the MASM presence check +// compares the looked-up value against the empty word. Any non-empty word would serve as the +// sentinel; we pick `[1, 0, 0, 0]` for readability when inspecting storage. +const WHITELIST_SENTINEL: Word = + Word::new([Felt::new(1), Felt::new(0), Felt::new(0), Felt::new(0)]); + +// NETWORK ACCOUNT +// ================================================================================================ + +/// An [`AccountComponent`] implementing the authentication scheme used by network-owned accounts +/// such as network faucets and the AggLayer bridge. +/// +/// The component exports a single auth procedure, `auth_tx_network_account`, that rejects the +/// transaction unless: +/// - no transaction script was executed, and +/// - every consumed input note has a script root present in the component's whitelist. +/// +/// The whitelist is stored in a storage map at a well-known slot (see [`Self::whitelist_slot`]) +/// so off-chain services can identify a network account by inspecting its storage. +/// +/// The whitelist is fixed at account creation; there is intentionally no procedure to mutate it +/// after deployment. +pub struct NetworkAccount { + allowed_script_roots: Vec, +} + +impl NetworkAccount { + /// The name of the component. + pub const NAME: &'static str = "miden::standards::components::auth::network_account"; + + /// Creates a new [`NetworkAccount`] component with the provided list of allowed input-note + /// script roots. + pub fn new(allowed_script_roots: Vec) -> Self { + Self { allowed_script_roots } + } + + /// Returns the storage slot holding the whitelist of allowed input-note script roots. + pub fn whitelist_slot() -> &'static StorageSlotName { + &WHITELIST_SLOT_NAME + } + + /// Returns the storage slot schema for the whitelist slot. + pub fn whitelist_slot_schema() -> (StorageSlotName, StorageSlotSchema) { + ( + Self::whitelist_slot().clone(), + StorageSlotSchema::map( + "Allowed input-note script roots", + SchemaType::native_word(), + SchemaType::native_word(), + ), + ) + } + + /// Returns the [`AccountComponentMetadata`] for this component. + pub fn component_metadata() -> AccountComponentMetadata { + let storage_schema = StorageSchema::new(vec![Self::whitelist_slot_schema()]) + .expect("storage schema should be valid"); + + AccountComponentMetadata::new(Self::NAME, AccountType::all()) + .with_description( + "Authentication component for network accounts that restricts input notes to a \ + fixed whitelist and forbids tx scripts", + ) + .with_storage_schema(storage_schema) + } +} + +impl From for AccountComponent { + fn from(network_account: NetworkAccount) -> Self { + let map_entries = network_account + .allowed_script_roots + .into_iter() + .map(|root| (StorageMapKey::new(root), WHITELIST_SENTINEL)); + + let storage_slots = vec![StorageSlot::with_map( + NetworkAccount::whitelist_slot().clone(), + StorageMap::with_entries(map_entries) + .expect("whitelist entries should produce a valid storage map"), + )]; + + let metadata = NetworkAccount::component_metadata(); + + AccountComponent::new(network_account_library(), storage_slots, metadata).expect( + "NetworkAccount component should satisfy the requirements of a valid account component", + ) + } +} + +// TESTS +// ================================================================================================ + +#[cfg(test)] +mod tests { + use miden_protocol::account::{AccountBuilder, StorageMapKey}; + + use super::*; + use crate::account::wallets::BasicWallet; + + #[test] + fn network_account_component_builds() { + let root_a = Word::from([1u32, 2, 3, 4]); + let root_b = Word::from([5u32, 6, 7, 8]); + + let _account = AccountBuilder::new([0; 32]) + .with_auth_component(NetworkAccount::new(vec![root_a, root_b])) + .with_component(BasicWallet) + .build() + .expect("account building with NetworkAccount failed"); + } + + #[test] + fn network_account_with_empty_whitelist_builds() { + let _account = AccountBuilder::new([0; 32]) + .with_auth_component(NetworkAccount::new(Vec::new())) + .with_component(BasicWallet) + .build() + .expect("account building with empty NetworkAccount whitelist failed"); + } + + #[test] + fn whitelist_storage_contains_expected_entries() { + use miden_protocol::account::StorageSlotContent; + + let root_a = Word::from([1u32, 2, 3, 4]); + let root_b = Word::from([5u32, 6, 7, 8]); + + let component: AccountComponent = NetworkAccount::new(vec![root_a, root_b]).into(); + + let storage_slots = component.storage_slots(); + assert_eq!(storage_slots.len(), 1); + + let StorageSlotContent::Map(map) = storage_slots[0].content() else { + panic!("whitelist slot must be a map"); + }; + + assert_eq!( + map.get(&StorageMapKey::new(root_a)), + WHITELIST_SENTINEL, + "root_a should resolve to the sentinel value" + ); + assert_eq!( + map.get(&StorageMapKey::new(root_b)), + WHITELIST_SENTINEL, + "root_b should resolve to the sentinel value" + ); + } +} diff --git a/crates/miden-standards/src/account/components/mod.rs b/crates/miden-standards/src/account/components/mod.rs index e8a8bea866..4e81edbc28 100644 --- a/crates/miden-standards/src/account/components/mod.rs +++ b/crates/miden-standards/src/account/components/mod.rs @@ -76,6 +76,15 @@ static NO_AUTH_LIBRARY: LazyLock = LazyLock::new(|| { Library::read_from_bytes(bytes).expect("Shipped NoAuth library is well-formed") }); +// Initialize the NetworkAccount library only once. +static NETWORK_ACCOUNT_LIBRARY: LazyLock = LazyLock::new(|| { + let bytes = include_bytes!(concat!( + env!("OUT_DIR"), + "/assets/account_components/auth/network_account.masl" + )); + Library::read_from_bytes(bytes).expect("Shipped NetworkAccount library is well-formed") +}); + // FAUCET LIBRARIES // ================================================================================================ @@ -219,6 +228,11 @@ pub fn no_auth_library() -> Library { NO_AUTH_LIBRARY.clone() } +/// Returns the NetworkAccount Library. +pub fn network_account_library() -> Library { + NETWORK_ACCOUNT_LIBRARY.clone() +} + // STANDARD ACCOUNT COMPONENTS // ================================================================================================ @@ -234,6 +248,7 @@ pub enum StandardAccountComponent { AuthMultisig, AuthGuardedMultisig, AuthNoAuth, + AuthNetworkAccount, } impl StandardAccountComponent { @@ -249,6 +264,7 @@ impl StandardAccountComponent { Self::AuthMultisig => MULTISIG_LIBRARY.as_ref(), Self::AuthGuardedMultisig => GUARDED_MULTISIG_LIBRARY.as_ref(), Self::AuthNoAuth => NO_AUTH_LIBRARY.as_ref(), + Self::AuthNetworkAccount => NETWORK_ACCOUNT_LIBRARY.as_ref(), }; library @@ -309,6 +325,9 @@ impl StandardAccountComponent { Self::AuthNoAuth => { component_interface_vec.push(AccountComponentInterface::AuthNoAuth) }, + Self::AuthNetworkAccount => { + component_interface_vec.push(AccountComponentInterface::AuthNetworkAccount) + }, } } } @@ -328,5 +347,6 @@ impl StandardAccountComponent { Self::AuthGuardedMultisig.extract_component(procedures_set, component_interface_vec); Self::AuthMultisig.extract_component(procedures_set, component_interface_vec); Self::AuthNoAuth.extract_component(procedures_set, component_interface_vec); + Self::AuthNetworkAccount.extract_component(procedures_set, component_interface_vec); } } diff --git a/crates/miden-standards/src/account/interface/component.rs b/crates/miden-standards/src/account/interface/component.rs index f810f80e24..2d38eac436 100644 --- a/crates/miden-standards/src/account/interface/component.rs +++ b/crates/miden-standards/src/account/interface/component.rs @@ -44,6 +44,12 @@ pub enum AccountComponentInterface { /// This authentication scheme provides no cryptographic authentication and only increments /// the nonce if the account state has actually changed during transaction execution. AuthNoAuth, + /// Exposes procedures from the + /// [`NetworkAccount`][crate::account::auth::NetworkAccount] module. + /// + /// This authentication scheme is intended for network-owned accounts. It rejects transactions + /// that executed a tx script or consumed input notes outside of a fixed whitelist. + AuthNetworkAccount, /// A non-standard, custom interface which exposes the contained procedures. /// /// Custom interface holds all procedures which are not part of some standard interface which is @@ -72,6 +78,7 @@ impl AccountComponentInterface { AccountComponentInterface::AuthMultisig => "Multisig".to_string(), AccountComponentInterface::AuthGuardedMultisig => "Guarded Multisig".to_string(), AccountComponentInterface::AuthNoAuth => "No Auth".to_string(), + AccountComponentInterface::AuthNetworkAccount => "Network Account".to_string(), AccountComponentInterface::Custom(proc_root_vec) => { let result = proc_root_vec .iter() @@ -94,6 +101,7 @@ impl AccountComponentInterface { | AccountComponentInterface::AuthMultisig | AccountComponentInterface::AuthGuardedMultisig | AccountComponentInterface::AuthNoAuth + | AccountComponentInterface::AuthNetworkAccount ) } @@ -127,6 +135,7 @@ impl AccountComponentInterface { )] }, AccountComponentInterface::AuthNoAuth => vec![AuthMethod::NoAuth], + AccountComponentInterface::AuthNetworkAccount => vec![AuthMethod::NoAuth], _ => vec![], // Non-auth components return empty vector } } diff --git a/crates/miden-standards/src/account/interface/extension.rs b/crates/miden-standards/src/account/interface/extension.rs index 52c3f51cae..094e6f9835 100644 --- a/crates/miden-standards/src/account/interface/extension.rs +++ b/crates/miden-standards/src/account/interface/extension.rs @@ -16,6 +16,7 @@ use crate::account::components::{ fungible_token_metadata_library, guarded_multisig_library, multisig_library, + network_account_library, network_fungible_faucet_library, no_auth_library, singlesig_acl_library, @@ -127,6 +128,10 @@ impl AccountInterfaceExt for AccountInterface { component_proc_digests .extend(no_auth_library().mast_forest().procedure_digests()); }, + AccountComponentInterface::AuthNetworkAccount => { + component_proc_digests + .extend(network_account_library().mast_forest().procedure_digests()); + }, AccountComponentInterface::Custom(custom_procs) => { component_proc_digests .extend(custom_procs.iter().map(|info| *info.mast_root())); diff --git a/crates/miden-testing/tests/auth/mod.rs b/crates/miden-testing/tests/auth/mod.rs index 752dfd9700..59a7bffe40 100644 --- a/crates/miden-testing/tests/auth/mod.rs +++ b/crates/miden-testing/tests/auth/mod.rs @@ -5,3 +5,5 @@ mod multisig; mod hybrid_multisig; mod guarded_multisig; + +mod network_account; diff --git a/crates/miden-testing/tests/auth/network_account.rs b/crates/miden-testing/tests/auth/network_account.rs new file mode 100644 index 0000000000..d0f29f4904 --- /dev/null +++ b/crates/miden-testing/tests/auth/network_account.rs @@ -0,0 +1,124 @@ +use core::slice; + +use miden_protocol::Word; +use miden_protocol::account::{Account, AccountBuilder, AccountStorageMode, AccountType}; +use miden_protocol::transaction::RawOutputNote; +use miden_standards::account::auth::NetworkAccount; +use miden_standards::account::wallets::BasicWallet; +use miden_standards::code_builder::CodeBuilder; +use miden_standards::errors::standards::{ + ERR_NETWORK_ACCOUNT_NOTE_NOT_WHITELISTED, + ERR_NETWORK_ACCOUNT_TX_SCRIPT_NOT_ALLOWED, +}; +use miden_standards::testing::note::NoteBuilder; +use miden_testing::{MockChain, assert_transaction_executor_error}; + +// HELPER FUNCTIONS +// ================================================================================================ + +/// Builds a minimal account that uses the [`NetworkAccount`] auth component with the provided +/// whitelist of input-note script roots. +fn build_network_account(allowed_script_roots: Vec) -> anyhow::Result { + Ok(AccountBuilder::new([0; 32]) + .with_auth_component(NetworkAccount::new(allowed_script_roots)) + .with_component(BasicWallet) + .account_type(AccountType::RegularAccountUpdatableCode) + .storage_mode(AccountStorageMode::Public) + .build_existing()?) +} + +// TESTS +// ================================================================================================ + +/// A transaction that executes a tx script must be rejected by `NetworkAccount`, even if the +/// whitelist and input notes are otherwise valid. +#[tokio::test] +async fn test_network_account_rejects_tx_script() -> anyhow::Result<()> { + let account = build_network_account(Vec::new())?; + + let mut builder = MockChain::builder(); + builder.add_account(account.clone())?; + let mock_chain = builder.build()?; + + let tx_script = CodeBuilder::default().compile_tx_script("begin nop end")?; + + let result = mock_chain + .build_tx_context(account.id(), &[], &[])? + .tx_script(tx_script) + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_NETWORK_ACCOUNT_TX_SCRIPT_NOT_ALLOWED); + + Ok(()) +} + +/// Consuming an input note whose script root is not in the whitelist must be rejected. +#[tokio::test] +async fn test_network_account_rejects_non_whitelisted_note() -> anyhow::Result<()> { + // Whitelist is a dummy root that no real note will ever match. + let dummy_root = Word::from([0xdeadu32, 0xbeef, 0xcafe, 0xf00d]); + let account = build_network_account(vec![dummy_root])?; + + let mut builder = MockChain::builder(); + builder.add_account(account.clone())?; + + let note = NoteBuilder::new(account.id(), &mut rand::rng()) + .build() + .expect("failed to build mock input note"); + builder.add_output_note(RawOutputNote::Full(note.clone())); + + let mock_chain = builder.build()?; + + let result = mock_chain + .build_tx_context(account.id(), &[], slice::from_ref(¬e))? + .build()? + .execute() + .await; + + assert_transaction_executor_error!(result, ERR_NETWORK_ACCOUNT_NOTE_NOT_WHITELISTED); + + Ok(()) +} + +/// Consuming an input note whose script root is in the whitelist must succeed. +#[tokio::test] +async fn test_network_account_accepts_whitelisted_note() -> anyhow::Result<()> { + // First build a template note so we know its script root, then use that root to configure the + // account's whitelist. + let bootstrap_account = build_network_account(Vec::new())?; + let template_note = NoteBuilder::new(bootstrap_account.id(), &mut rand::rng()) + .build() + .expect("failed to build template note"); + let allowed_root = template_note.script().root(); + + // Now build the real account with the whitelist containing that root. + let account = build_network_account(vec![allowed_root])?; + + let mut builder = MockChain::builder(); + builder.add_account(account.clone())?; + + // Build a note that uses the same code but is sent from the real account so its script root + // matches `allowed_root`. + let note = NoteBuilder::new(account.id(), &mut rand::rng()) + .build() + .expect("failed to build input note"); + assert_eq!( + note.script().root(), + allowed_root, + "NoteBuilder with default code should produce a fixed script root" + ); + builder.add_output_note(RawOutputNote::Full(note.clone())); + + let mock_chain = builder.build()?; + + mock_chain + .build_tx_context(account.id(), &[], slice::from_ref(¬e))? + .build()? + .execute() + .await + .expect("consuming a whitelisted note should succeed"); + + Ok(()) +}