Skip to main content

miden_standards/account/auth/
singlesig_acl.rs

1use alloc::vec::Vec;
2
3use miden_protocol::account::auth::{AuthScheme, PublicKeyCommitment};
4use miden_protocol::account::component::{
5    AccountComponentCode,
6    AccountComponentMetadata,
7    FeltSchema,
8    SchemaType,
9    StorageSchema,
10    StorageSlotSchema,
11};
12use miden_protocol::account::{
13    AccountCode,
14    AccountComponent,
15    AccountComponentName,
16    AccountProcedureRoot,
17    StorageMap,
18    StorageMapKey,
19    StorageSlot,
20    StorageSlotName,
21};
22use miden_protocol::errors::AccountError;
23use miden_protocol::utils::sync::LazyLock;
24use miden_protocol::{Felt, Word};
25
26use crate::account::account_component_code;
27
28account_component_code!(SINGLESIG_ACL_CODE, "auth/singlesig_acl.masl");
29
30// CONSTANTS
31// ================================================================================================
32
33static PUBKEY_SLOT_NAME: LazyLock<StorageSlotName> = LazyLock::new(|| {
34    StorageSlotName::new("miden::standards::auth::singlesig_acl::pub_key")
35        .expect("storage slot name should be valid")
36});
37
38static SCHEME_ID_SLOT_NAME: LazyLock<StorageSlotName> = LazyLock::new(|| {
39    StorageSlotName::new("miden::standards::auth::singlesig_acl::scheme")
40        .expect("storage slot name should be valid")
41});
42
43static CONFIG_SLOT_NAME: LazyLock<StorageSlotName> = LazyLock::new(|| {
44    StorageSlotName::new("miden::standards::auth::singlesig_acl::config")
45        .expect("storage slot name should be valid")
46});
47
48static TRIGGER_PROCEDURE_ROOT_SLOT_NAME: LazyLock<StorageSlotName> = LazyLock::new(|| {
49    StorageSlotName::new("miden::standards::auth::singlesig_acl::trigger_procedure_roots")
50        .expect("storage slot name should be valid")
51});
52
53/// Configuration for [`AuthSingleSigAcl`] component.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct AuthSingleSigAclConfig {
56    /// List of procedure roots that require authentication when called.
57    pub auth_trigger_procedures: Vec<AccountProcedureRoot>,
58    /// When `false`, creating output notes (sending notes to other accounts) requires
59    /// authentication. When `true`, output notes can be created without authentication.
60    pub allow_unauthorized_output_notes: bool,
61    /// When `false`, consuming input notes (processing notes sent to this account) requires
62    /// authentication. When `true`, input notes can be consumed without authentication.
63    pub allow_unauthorized_input_notes: bool,
64}
65
66impl AuthSingleSigAclConfig {
67    /// Creates a new configuration with no trigger procedures and both flags set to `false` (most
68    /// restrictive).
69    pub fn new() -> Self {
70        Self {
71            auth_trigger_procedures: vec![],
72            allow_unauthorized_output_notes: false,
73            allow_unauthorized_input_notes: false,
74        }
75    }
76
77    /// Sets the list of procedure roots that require authentication when called.
78    pub fn with_auth_trigger_procedures(mut self, procedures: Vec<AccountProcedureRoot>) -> Self {
79        self.auth_trigger_procedures = procedures;
80        self
81    }
82
83    /// Sets whether unauthorized output notes are allowed.
84    pub fn with_allow_unauthorized_output_notes(mut self, allow: bool) -> Self {
85        self.allow_unauthorized_output_notes = allow;
86        self
87    }
88
89    /// Sets whether unauthorized input notes are allowed.
90    pub fn with_allow_unauthorized_input_notes(mut self, allow: bool) -> Self {
91        self.allow_unauthorized_input_notes = allow;
92        self
93    }
94}
95
96impl Default for AuthSingleSigAclConfig {
97    fn default() -> Self {
98        Self::new()
99    }
100}
101
102/// An [`AccountComponent`] implementing a procedure-based Access Control List (ACL) using either
103/// the EcdsaK256Keccak or Falcon512 Poseidon2 signature scheme for authentication of transactions.
104///
105/// This component provides fine-grained authentication control based on three conditions:
106/// 1. **Procedure-based authentication**: Requires authentication when any of the specified trigger
107///    procedures are called during the transaction.
108/// 2. **Output note authentication**: Controls whether creating output notes requires
109///    authentication. Output notes are new notes created by the account and sent to other accounts
110///    (e.g., when transferring assets). When `allow_unauthorized_output_notes` is `false`, any
111///    transaction that creates output notes must be authenticated, ensuring account owners control
112///    when their account sends assets to other accounts.
113/// 3. **Input note authentication**: Controls whether consuming input notes requires
114///    authentication. Input notes are notes that were sent to this account by other accounts (e.g.,
115///    incoming asset transfers). When `allow_unauthorized_input_notes` is `false`, any transaction
116///    that consumes input notes must be authenticated, ensuring account owners control when their
117///    account processes incoming notes.
118///
119/// ## Authentication Logic
120///
121/// Authentication is required if ANY of the following conditions are true:
122/// - Any trigger procedure from the ACL was called
123/// - Output notes were created AND `allow_unauthorized_output_notes` is `false`
124/// - Input notes were consumed AND `allow_unauthorized_input_notes` is `false`
125///
126/// If none of these conditions are met, only the nonce is incremented without requiring a
127/// signature.
128///
129/// ## Use Cases
130///
131/// - **Restrictive mode** (`allow_unauthorized_output_notes=false`,
132///   `allow_unauthorized_input_notes=false`): All note operations require authentication, providing
133///   maximum security.
134/// - **Selective mode**: Allow some note operations without authentication while protecting
135///   specific procedures, useful for accounts that need to process certain operations
136///   automatically.
137/// - **Procedure-only mode** (`allow_unauthorized_output_notes=true`,
138///   `allow_unauthorized_input_notes=true`): Only specific procedures require authentication,
139///   allowing free note processing.
140///
141/// ## Storage Layout
142/// - [`Self::public_key_slot`]: Public key
143/// - [`Self::config_slot`]: `[num_trigger_procs, allow_unauthorized_output_notes,
144///   allow_unauthorized_input_notes, 0]`
145/// - [`Self::trigger_procedure_roots_slot`]: A map with trigger procedure roots
146///
147/// ## Important Note on Procedure Detection
148/// The procedure-based authentication relies on the `was_procedure_called` kernel function,
149/// which only returns `true` if the procedure in question called into a kernel account API
150/// that is restricted to the account context. Procedures that don't interact with account
151/// state or kernel APIs may not be detected as "called" even if they were executed during
152/// the transaction. This is an important limitation to consider when designing trigger
153/// procedures for authentication.
154pub struct AuthSingleSigAcl {
155    pub_key: PublicKeyCommitment,
156    auth_scheme: AuthScheme,
157    config: AuthSingleSigAclConfig,
158}
159
160impl AuthSingleSigAcl {
161    /// The name of the component.
162    pub const NAME: &'static str = "miden::standards::components::auth::singlesig_acl";
163
164    /// Returns the canonical [`AccountComponentName`] of this component.
165    pub const fn name() -> AccountComponentName {
166        AccountComponentName::from_static_str(Self::NAME)
167    }
168
169    /// Returns the [`AccountComponentCode`] of this component.
170    pub fn code() -> &'static AccountComponentCode {
171        &SINGLESIG_ACL_CODE
172    }
173
174    /// Creates a new [`AuthSingleSigAcl`] component with the given `public_key` and
175    /// configuration.
176    ///
177    /// # Panics
178    /// Panics if more than [AccountCode::MAX_NUM_PROCEDURES] procedures are specified.
179    pub fn new(
180        pub_key: PublicKeyCommitment,
181        auth_scheme: AuthScheme,
182        config: AuthSingleSigAclConfig,
183    ) -> Result<Self, AccountError> {
184        let max_procedures = AccountCode::MAX_NUM_PROCEDURES;
185        if config.auth_trigger_procedures.len() > max_procedures {
186            return Err(AccountError::other(format!(
187                "Cannot track more than {max_procedures} procedures (account limit)"
188            )));
189        }
190
191        Ok(Self { pub_key, auth_scheme, config })
192    }
193
194    /// Returns the [`StorageSlotName`] where the public key is stored.
195    pub fn public_key_slot() -> &'static StorageSlotName {
196        &PUBKEY_SLOT_NAME
197    }
198
199    /// Returns the [`StorageSlotName`] where the scheme ID is stored.
200    pub fn scheme_id_slot() -> &'static StorageSlotName {
201        &SCHEME_ID_SLOT_NAME
202    }
203
204    /// Returns the [`StorageSlotName`] where the component's configuration is stored.
205    pub fn config_slot() -> &'static StorageSlotName {
206        &CONFIG_SLOT_NAME
207    }
208
209    /// Returns the [`StorageSlotName`] where the trigger procedure roots are stored.
210    pub fn trigger_procedure_roots_slot() -> &'static StorageSlotName {
211        &TRIGGER_PROCEDURE_ROOT_SLOT_NAME
212    }
213
214    /// Returns the storage slot schema for the public key slot.
215    pub fn public_key_slot_schema() -> (StorageSlotName, StorageSlotSchema) {
216        (
217            Self::public_key_slot().clone(),
218            StorageSlotSchema::value("Public key commitment", SchemaType::pub_key()),
219        )
220    }
221
222    /// Returns the storage slot schema for the configuration slot.
223    pub fn config_slot_schema() -> (StorageSlotName, StorageSlotSchema) {
224        (
225            Self::config_slot().clone(),
226            StorageSlotSchema::value(
227                "ACL configuration",
228                [
229                    FeltSchema::u32("num_trigger_procs").with_default(Felt::ZERO),
230                    FeltSchema::bool("allow_unauthorized_output_notes").with_default(Felt::ZERO),
231                    FeltSchema::bool("allow_unauthorized_input_notes").with_default(Felt::ZERO),
232                    FeltSchema::new_void(),
233                ],
234            ),
235        )
236    }
237
238    // Returns the storage slot schema for the scheme ID slot.
239    pub fn auth_scheme_slot_schema() -> (StorageSlotName, StorageSlotSchema) {
240        (
241            Self::scheme_id_slot().clone(),
242            StorageSlotSchema::value("Scheme ID", SchemaType::auth_scheme()),
243        )
244    }
245
246    /// Returns the storage slot schema for the trigger procedure roots slot.
247    pub fn trigger_procedure_roots_slot_schema() -> (StorageSlotName, StorageSlotSchema) {
248        (
249            Self::trigger_procedure_roots_slot().clone(),
250            StorageSlotSchema::map(
251                "Trigger procedure roots",
252                SchemaType::u32(),
253                SchemaType::native_word(),
254            ),
255        )
256    }
257
258    /// Returns the [`AccountComponentMetadata`] for this component.
259    pub fn component_metadata() -> AccountComponentMetadata {
260        let storage_schema = StorageSchema::new(vec![
261            Self::public_key_slot_schema(),
262            Self::auth_scheme_slot_schema(),
263            Self::config_slot_schema(),
264            Self::trigger_procedure_roots_slot_schema(),
265        ])
266        .expect("storage schema should be valid");
267
268        AccountComponentMetadata::new(Self::NAME)
269            .with_description(
270                "Authentication component with procedure-based ACL using ECDSA K256 Keccak or Falcon512 Poseidon2 signature scheme",
271            )
272            .with_storage_schema(storage_schema)
273    }
274}
275
276impl From<AuthSingleSigAcl> for AccountComponent {
277    fn from(singlesig_acl: AuthSingleSigAcl) -> Self {
278        let mut storage_slots = Vec::with_capacity(3);
279
280        // Public key slot
281        storage_slots.push(StorageSlot::with_value(
282            AuthSingleSigAcl::public_key_slot().clone(),
283            singlesig_acl.pub_key.into(),
284        ));
285
286        // Scheme ID slot
287        storage_slots.push(StorageSlot::with_value(
288            AuthSingleSigAcl::scheme_id_slot().clone(),
289            Word::from([singlesig_acl.auth_scheme.as_u8(), 0, 0, 0]),
290        ));
291
292        // Config slot
293        let num_procs = singlesig_acl.config.auth_trigger_procedures.len() as u32;
294        storage_slots.push(StorageSlot::with_value(
295            AuthSingleSigAcl::config_slot().clone(),
296            Word::from([
297                num_procs,
298                u32::from(singlesig_acl.config.allow_unauthorized_output_notes),
299                u32::from(singlesig_acl.config.allow_unauthorized_input_notes),
300                0,
301            ]),
302        ));
303
304        // Trigger procedure roots slot
305        // We add the map even if there are no trigger procedures, to always maintain the same
306        // storage layout.
307        let map_entries = singlesig_acl
308            .config
309            .auth_trigger_procedures
310            .iter()
311            .enumerate()
312            .map(|(i, proc_root)| (StorageMapKey::from_index(i as u32), proc_root.as_word()));
313
314        // Safe to unwrap because we know that the map keys are unique.
315        storage_slots.push(StorageSlot::with_map(
316            AuthSingleSigAcl::trigger_procedure_roots_slot().clone(),
317            StorageMap::with_entries(map_entries).unwrap(),
318        ));
319
320        let metadata = AuthSingleSigAcl::component_metadata();
321
322        AccountComponent::new(AuthSingleSigAcl::code().clone(), storage_slots, metadata).expect(
323            "singlesig ACL component should satisfy the requirements of a valid account component",
324        )
325    }
326}
327
328// TESTS
329// ================================================================================================
330
331#[cfg(test)]
332mod tests {
333    use miden_protocol::Word;
334    use miden_protocol::account::AccountBuilder;
335
336    use super::*;
337    use crate::account::components::StandardAccountComponent;
338    use crate::account::wallets::BasicWallet;
339
340    /// Test configuration for parametrized ACL tests
341    struct AclTestConfig {
342        /// Whether to include auth trigger procedures
343        with_procedures: bool,
344        /// Allow unauthorized output notes flag
345        allow_unauthorized_output_notes: bool,
346        /// Allow unauthorized input notes flag
347        allow_unauthorized_input_notes: bool,
348        /// Expected config slot value [num_procs, allow_output, allow_input, 0]
349        expected_config_slot: Word,
350    }
351
352    /// Helper function to get the basic wallet procedures for testing
353    fn get_basic_wallet_procedures() -> Vec<AccountProcedureRoot> {
354        // Get the two trigger procedures from BasicWallet: `receive_asset`, `move_asset_to_note`.
355        let procedures: Vec<AccountProcedureRoot> =
356            StandardAccountComponent::BasicWallet.procedure_roots().collect();
357
358        assert_eq!(procedures.len(), 2);
359        procedures
360    }
361
362    /// Parametrized test helper for ACL component testing
363    fn test_acl_component(config: AclTestConfig) {
364        let public_key = PublicKeyCommitment::from(Word::empty());
365        let auth_scheme = AuthScheme::Falcon512Poseidon2;
366
367        // Build the configuration
368        let mut acl_config = AuthSingleSigAclConfig::new()
369            .with_allow_unauthorized_output_notes(config.allow_unauthorized_output_notes)
370            .with_allow_unauthorized_input_notes(config.allow_unauthorized_input_notes);
371
372        let auth_trigger_procedures: Vec<AccountProcedureRoot> = if config.with_procedures {
373            let procedures = get_basic_wallet_procedures();
374            acl_config = acl_config.with_auth_trigger_procedures(procedures.clone());
375            procedures
376        } else {
377            vec![]
378        };
379
380        // Create component and account
381        let component = AuthSingleSigAcl::new(public_key, auth_scheme, acl_config)
382            .expect("component creation failed");
383
384        let account = AccountBuilder::new([0; 32])
385            .with_auth_component(component)
386            .with_component(BasicWallet)
387            .build()
388            .expect("account building failed");
389
390        // Check public key storage
391        let public_key_slot = account
392            .storage()
393            .get_item(AuthSingleSigAcl::public_key_slot())
394            .expect("public key storage slot access failed");
395        assert_eq!(public_key_slot, public_key.into());
396
397        // Check configuration storage
398        let config_slot = account
399            .storage()
400            .get_item(AuthSingleSigAcl::config_slot())
401            .expect("config storage slot access failed");
402        assert_eq!(config_slot, config.expected_config_slot);
403
404        // Check procedure roots
405        if config.with_procedures {
406            for (i, expected_proc_root) in auth_trigger_procedures.iter().enumerate() {
407                let proc_root = account
408                    .storage()
409                    .get_map_item(
410                        AuthSingleSigAcl::trigger_procedure_roots_slot(),
411                        Word::from([i as u32, 0, 0, 0]),
412                    )
413                    .expect("storage map access failed");
414                assert_eq!(proc_root, expected_proc_root.as_word());
415            }
416        } else {
417            // When no procedures, the map should return empty for key [0,0,0,0]
418            let proc_root = account
419                .storage()
420                .get_map_item(AuthSingleSigAcl::trigger_procedure_roots_slot(), Word::empty())
421                .expect("storage map access failed");
422            assert_eq!(proc_root, Word::empty());
423        }
424    }
425
426    /// Test ACL component with no procedures and both authorization flags set to false
427    #[test]
428    fn test_singlesig_acl_no_procedures() {
429        test_acl_component(AclTestConfig {
430            with_procedures: false,
431            allow_unauthorized_output_notes: false,
432            allow_unauthorized_input_notes: false,
433            expected_config_slot: Word::empty(), // [0, 0, 0, 0]
434        });
435    }
436
437    /// Test ACL component with two procedures and both authorization flags set to false
438    #[test]
439    fn test_singlesig_acl_with_two_procedures() {
440        test_acl_component(AclTestConfig {
441            with_procedures: true,
442            allow_unauthorized_output_notes: false,
443            allow_unauthorized_input_notes: false,
444            expected_config_slot: Word::from([2u32, 0, 0, 0]),
445        });
446    }
447
448    /// Test ACL component with no procedures and allow_unauthorized_output_notes set to true
449    #[test]
450    fn test_ecdsa_k256_keccak_acl_with_allow_unauthorized_output_notes() {
451        test_acl_component(AclTestConfig {
452            with_procedures: false,
453            allow_unauthorized_output_notes: true,
454            allow_unauthorized_input_notes: false,
455            expected_config_slot: Word::from([0u32, 1, 0, 0]),
456        });
457    }
458
459    /// Test ACL component with two procedures and allow_unauthorized_output_notes set to true
460    #[test]
461    fn test_ecdsa_k256_keccak_acl_with_procedures_and_allow_unauthorized_output_notes() {
462        test_acl_component(AclTestConfig {
463            with_procedures: true,
464            allow_unauthorized_output_notes: true,
465            allow_unauthorized_input_notes: false,
466            expected_config_slot: Word::from([2u32, 1, 0, 0]),
467        });
468    }
469
470    /// Test ACL component with no procedures and allow_unauthorized_input_notes set to true
471    #[test]
472    fn test_ecdsa_k256_keccak_acl_with_allow_unauthorized_input_notes() {
473        test_acl_component(AclTestConfig {
474            with_procedures: false,
475            allow_unauthorized_output_notes: false,
476            allow_unauthorized_input_notes: true,
477            expected_config_slot: Word::from([0u32, 0, 1, 0]),
478        });
479    }
480
481    /// Test ACL component with two procedures and both authorization flags set to true
482    #[test]
483    fn test_ecdsa_k256_keccak_acl_with_both_allow_flags() {
484        test_acl_component(AclTestConfig {
485            with_procedures: true,
486            allow_unauthorized_output_notes: true,
487            allow_unauthorized_input_notes: true,
488            expected_config_slot: Word::from([2u32, 1, 1, 0]),
489        });
490    }
491}