Skip to main content

cougr_core/accounts/
kernel.rs

1use soroban_sdk::{Address, Env};
2
3use super::error::AccountError;
4use super::intent::{AuthMethod, AuthResult, IntentSigner, SignedIntent};
5use super::policy::{IntentContext, IntentExpiryPolicy, Policy, SessionContext, SessionPolicy};
6use super::replay::ReplayProtection;
7use super::signer::{AccountSigner, DirectAuthSigner, Secp256r1PasskeySigner, SessionAuthSigner};
8use super::storage::SessionStorage;
9
10/// Account kernel that separates signer verification, policy evaluation and replay protection.
11pub struct AccountKernel {
12    owner: Address,
13}
14
15impl AccountKernel {
16    pub fn new(owner: Address) -> Self {
17        Self { owner }
18    }
19
20    pub fn owner(&self) -> &Address {
21        &self.owner
22    }
23
24    pub fn authorize_direct(
25        &self,
26        env: &Env,
27        intent: &SignedIntent,
28    ) -> Result<AuthResult, AccountError> {
29        self.ensure_target(intent)?;
30        self.ensure_hash(env, intent)?;
31
32        let signer = DirectAuthSigner;
33        signer.verify(env, &self.owner, intent)?;
34
35        let policy = IntentExpiryPolicy;
36        policy.evaluate(
37            env,
38            &IntentContext {
39                account: &self.owner,
40                intent,
41            },
42        )?;
43
44        let consumed =
45            ReplayProtection::verify_and_consume_account_nonce(env, &self.owner, intent.nonce)?;
46
47        Ok(AuthResult {
48            method: AuthMethod::Direct,
49            nonce_consumed: consumed,
50            session_key_id: zero_key(env),
51            remaining_operations: 0,
52        })
53    }
54
55    pub fn authorize_session(
56        &self,
57        env: &Env,
58        intent: &SignedIntent,
59    ) -> Result<AuthResult, AccountError> {
60        self.ensure_target(intent)?;
61        self.ensure_hash(env, intent)?;
62
63        let signer = SessionAuthSigner;
64        signer.verify(env, &self.owner, intent)?;
65
66        let policy = SessionPolicy;
67        policy.evaluate(
68            env,
69            &SessionContext {
70                account: &self.owner,
71                intent,
72            },
73        )?;
74
75        let updated = SessionStorage::consume_authorized_session(
76            env,
77            &self.owner,
78            &intent.signer.session_key_id,
79        )?;
80
81        Ok(AuthResult {
82            method: AuthMethod::Session,
83            nonce_consumed: updated.next_nonce - 1,
84            session_key_id: updated.key_id,
85            remaining_operations: updated.scope.max_operations - updated.operations_used,
86        })
87    }
88
89    pub fn authorize_passkey(
90        &self,
91        env: &Env,
92        intent: &SignedIntent,
93    ) -> Result<AuthResult, AccountError> {
94        self.ensure_target(intent)?;
95        self.ensure_hash(env, intent)?;
96
97        let signer = Secp256r1PasskeySigner;
98        signer.verify(env, &self.owner, intent)?;
99
100        let policy = IntentExpiryPolicy;
101        policy.evaluate(
102            env,
103            &IntentContext {
104                account: &self.owner,
105                intent,
106            },
107        )?;
108
109        let consumed =
110            ReplayProtection::verify_and_consume_account_nonce(env, &self.owner, intent.nonce)?;
111
112        Ok(AuthResult {
113            method: AuthMethod::Passkey,
114            nonce_consumed: consumed,
115            session_key_id: zero_key(env),
116            remaining_operations: 0,
117        })
118    }
119
120    pub fn authorize(&self, env: &Env, intent: &SignedIntent) -> Result<AuthResult, AccountError> {
121        match intent.signer.kind {
122            IntentSigner::Direct => self.authorize_direct(env, intent),
123            IntentSigner::Session => self.authorize_session(env, intent),
124            IntentSigner::Passkey => self.authorize_passkey(env, intent),
125        }
126    }
127
128    fn ensure_target(&self, intent: &SignedIntent) -> Result<(), AccountError> {
129        if intent.account != self.owner {
130            return Err(AccountError::Unauthorized);
131        }
132        Ok(())
133    }
134
135    fn ensure_hash(&self, env: &Env, intent: &SignedIntent) -> Result<(), AccountError> {
136        if intent.recompute_hash(env) != intent.action_hash {
137            return Err(AccountError::InvalidIntent);
138        }
139        Ok(())
140    }
141}
142
143fn zero_key(env: &Env) -> soroban_sdk::BytesN<32> {
144    soroban_sdk::BytesN::from_array(env, &[0u8; 32])
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use crate::accounts::intent::SignedIntent;
151    use crate::accounts::multi_device::{DeviceManager, DevicePolicy, MultiDeviceProvider};
152    use crate::accounts::policy::{
153        ActiveDevicePolicy, DeviceContext, GuardianPolicy, RecoveryContext,
154    };
155    use crate::accounts::recovery::{RecoverableAccount, RecoveryConfig, RecoveryProvider};
156    use crate::accounts::storage::SessionStorage;
157    use crate::accounts::types::{GameAction, SessionKey, SessionScope};
158    use soroban_sdk::{
159        contract, contractimpl, symbol_short, testutils::Address as _, vec, Address, Bytes, BytesN,
160        Env,
161    };
162
163    #[contract]
164    pub struct TestContract;
165
166    #[contractimpl]
167    impl TestContract {}
168
169    fn make_action(env: &Env, name: &str) -> GameAction {
170        GameAction {
171            system_name: soroban_sdk::Symbol::new(env, name),
172            data: Bytes::new(env),
173        }
174    }
175
176    #[test]
177    fn test_direct_intent_consumes_account_nonce() {
178        let env = Env::default();
179        env.mock_all_auths();
180        let contract_id = env.register(TestContract, ());
181        let owner = Address::generate(&env);
182
183        env.as_contract(&contract_id, || {
184            let kernel = AccountKernel::new(owner.clone());
185            let action = make_action(&env, "move");
186            let intent = SignedIntent::direct(&env, owner, action, 0, 99999);
187
188            let result = kernel.authorize(&env, &intent).unwrap();
189            assert_eq!(result.method, AuthMethod::Direct);
190            assert_eq!(
191                ReplayProtection::next_account_nonce(&env, kernel.owner()),
192                1
193            );
194        });
195    }
196
197    #[test]
198    fn test_session_intent_enforces_scope_budget_and_nonce() {
199        let env = Env::default();
200        let contract_id = env.register(TestContract, ());
201        let owner = Address::generate(&env);
202        let key_id = BytesN::from_array(&env, &[7u8; 32]);
203
204        env.as_contract(&contract_id, || {
205            let kernel = AccountKernel::new(owner.clone());
206            let session = SessionKey {
207                key_id: key_id.clone(),
208                scope: SessionScope {
209                    allowed_actions: vec![&env, symbol_short!("move")],
210                    max_operations: 2,
211                    expires_at: 99999,
212                },
213                created_at: 0,
214                operations_used: 0,
215                next_nonce: 0,
216            };
217            SessionStorage::store(&env, &owner, &session);
218
219            let move_1 = SignedIntent::session(
220                &env,
221                owner.clone(),
222                &key_id,
223                make_action(&env, "move"),
224                0,
225                99999,
226            );
227            let result_1 = kernel.authorize(&env, &move_1).unwrap();
228            assert_eq!(result_1.remaining_operations, 1);
229
230            let move_2 = SignedIntent::session(
231                &env,
232                owner.clone(),
233                &key_id,
234                make_action(&env, "move"),
235                1,
236                99999,
237            );
238            let result_2 = kernel.authorize(&env, &move_2).unwrap();
239            assert_eq!(result_2.remaining_operations, 0);
240
241            let replay = SignedIntent::session(
242                &env,
243                owner.clone(),
244                &key_id,
245                make_action(&env, "move"),
246                1,
247                99999,
248            );
249            assert_eq!(
250                kernel.authorize(&env, &replay),
251                Err(AccountError::SessionBudgetExceeded)
252            );
253        });
254    }
255
256    #[test]
257    fn test_session_intent_rejects_wrong_nonce_before_budget_consumption() {
258        let env = Env::default();
259        let contract_id = env.register(TestContract, ());
260        let owner = Address::generate(&env);
261        let key_id = BytesN::from_array(&env, &[8u8; 32]);
262
263        env.as_contract(&contract_id, || {
264            let kernel = AccountKernel::new(owner.clone());
265            let session = SessionKey {
266                key_id: key_id.clone(),
267                scope: SessionScope {
268                    allowed_actions: vec![&env, symbol_short!("move")],
269                    max_operations: 3,
270                    expires_at: 99999,
271                },
272                created_at: 0,
273                operations_used: 0,
274                next_nonce: 2,
275            };
276            SessionStorage::store(&env, &owner, &session);
277
278            let wrong_nonce =
279                SignedIntent::session(&env, owner, &key_id, make_action(&env, "move"), 1, 99999);
280            assert_eq!(
281                kernel.authorize(&env, &wrong_nonce),
282                Err(AccountError::NonceMismatch)
283            );
284        });
285    }
286
287    #[test]
288    fn test_device_and_guardian_policies_share_same_policy_model() {
289        let env = Env::default();
290        let contract_id = env.register(TestContract, ());
291        let owner = Address::generate(&env);
292
293        env.as_contract(&contract_id, || {
294            let mut devices = DeviceManager::new(
295                owner.clone(),
296                DevicePolicy {
297                    max_devices: 2,
298                    auto_revoke_after: 0,
299                },
300                &env,
301            );
302            let device_key = BytesN::from_array(&env, &[5u8; 32]);
303            devices
304                .register_device(&env, device_key.clone(), symbol_short!("phone"))
305                .unwrap();
306
307            let mut recovery = RecoverableAccount::new(
308                owner.clone(),
309                RecoveryConfig {
310                    threshold: 1,
311                    timelock_period: 0,
312                    max_guardians: 2,
313                },
314                &env,
315            );
316            let guardian = Address::generate(&env);
317            recovery.add_guardian(&env, guardian.clone()).unwrap();
318
319            let device_policy = ActiveDevicePolicy;
320            device_policy
321                .evaluate(
322                    &env,
323                    &DeviceContext {
324                        account: &owner,
325                        key_id: &device_key,
326                    },
327                )
328                .unwrap();
329
330            let guardian_policy = GuardianPolicy;
331            guardian_policy
332                .evaluate(
333                    &env,
334                    &RecoveryContext {
335                        account: &owner,
336                        guardian: &guardian,
337                    },
338                )
339                .unwrap();
340        });
341    }
342}