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