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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +37 to +42
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This takes auth args as inputs, right? If so, I'd explicitly drop them here for safety.

# ---- 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)]
Comment on lines +44 to +48
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
exec.tx::get_tx_script_root
# => [TX_SCRIPT_ROOT, pad(16)]
padw exec.word::eq
# => [no_tx_script, pad(16)]
exec.tx::get_tx_script_root
# => [TX_SCRIPT_ROOT, pad(16)]
exec.word::eqz
# => [has_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)]
Comment on lines +59 to +61
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# => [i, pad(16)]
sub.1
# => [i-1, pad(16)]
# => [note_idx+1, pad(16)]
sub.1
# => [note_idx, pad(16)]

Nit: We could rename i to note_idx for more clarity.


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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
padw exec.word::eq not
exec.word::eqz not

Nit

# => [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
3 changes: 3 additions & 0 deletions crates/miden-standards/src/account/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
181 changes: 181 additions & 0 deletions crates/miden-standards/src/account/auth/network_account.rs
Original file line number Diff line number Diff line change
@@ -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<StorageSlotName> = LazyLock::new(|| {
StorageSlotName::new("miden::standards::auth::network_account::whitelist")
.expect("storage slot name should be valid")
});
Comment on lines +25 to +28
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I'd call this "allow list", which is a bit more consistent with "block list" (used in other contexts #2819).


// 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<Word>,
}

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<Word>) -> Self {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes me wish we had a NoteScriptRoot newtype wrapper like AccountProcedureRoot for more type-safety. Maybe we should create an issue to introduce this?

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",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"Allowed input-note script roots",
"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<NetworkAccount> 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"
);
}
}
20 changes: 20 additions & 0 deletions crates/miden-standards/src/account/components/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,15 @@ static NO_AUTH_LIBRARY: LazyLock<Library> = 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<Library> = 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
// ================================================================================================

Expand Down Expand Up @@ -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
// ================================================================================================

Expand All @@ -234,6 +248,7 @@ pub enum StandardAccountComponent {
AuthMultisig,
AuthGuardedMultisig,
AuthNoAuth,
AuthNetworkAccount,
}

impl StandardAccountComponent {
Expand All @@ -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
Expand Down Expand Up @@ -309,6 +325,9 @@ impl StandardAccountComponent {
Self::AuthNoAuth => {
component_interface_vec.push(AccountComponentInterface::AuthNoAuth)
},
Self::AuthNetworkAccount => {
component_interface_vec.push(AccountComponentInterface::AuthNetworkAccount)
},
}
}
}
Expand All @@ -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);
}
}
Loading
Loading