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