Skip to main content

foundry_common/tempo/
session_policy.rs

1//! Pure Tempo session policy construction.
2
3use super::{
4    KeyType, SessionCallScope, SessionEntry, SessionKeyMaterial, SessionSelectorRule,
5    SessionStatus, SessionTokenLimit, session::validate_signed_session_authorization,
6};
7use alloy_primitives::{Address, B256, U256, hex};
8use alloy_rlp::Encodable;
9use alloy_signer_local::PrivateKeySigner;
10use eyre::ensure;
11use std::{fmt, num::NonZeroU64};
12use tempo_primitives::transaction::{
13    CallScope, KeyAuthorization, SelectorRule, SignatureType, SignedKeyAuthorization, TokenLimit,
14};
15
16/// Typed spending limit for a temporary session access key.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub struct SessionSpendLimit {
19    pub token: Address,
20    pub amount: U256,
21}
22
23/// Typed inputs needed to authorize a temporary session access key.
24///
25/// This intentionally excludes CLI flag grammar, RPC submission, signer selection, and child
26/// process lifecycle. Callers supply already-parsed policy values and handle IO separately.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct SessionAuthorizationRequest {
29    pub session_id: B256,
30    pub root_account: Address,
31    pub chain_id: u64,
32    pub key_address: Address,
33    pub expiry: NonZeroU64,
34    pub scope: Vec<CallScope>,
35    pub spend_limits: Vec<SessionSpendLimit>,
36}
37
38/// Prepared local session metadata plus the Tempo authorization that the root must sign.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct PreparedSessionAuthorization {
41    pub entry: SessionEntry,
42    pub authorization: KeyAuthorization,
43}
44
45impl SessionAuthorizationRequest {
46    /// Validate this request and build the unsigned Tempo [`KeyAuthorization`].
47    pub fn prepare(&self, now: u64) -> eyre::Result<PreparedSessionAuthorization> {
48        ensure!(self.session_id != B256::ZERO, "session id cannot be zero");
49        ensure!(self.root_account != Address::ZERO, "session root account cannot be zero");
50        ensure!(self.chain_id != 0, "session chain id cannot be zero");
51        ensure!(self.key_address != Address::ZERO, "session key address cannot be zero");
52        ensure!(
53            self.key_address != self.root_account,
54            "session key address must differ from the root account"
55        );
56
57        let expiry = self.expiry.get();
58        ensure!(
59            expiry > now,
60            "session expiry {expiry} must be greater than current timestamp {now}"
61        );
62        ensure!(!self.scope.is_empty(), "session authorization requires a call scope");
63
64        let authorization = KeyAuthorization::unrestricted(
65            self.chain_id,
66            SignatureType::Secp256k1,
67            self.key_address,
68        )
69        .with_expiry(expiry)
70        .with_limits(session_spend_limits_to_authorization(&self.spend_limits))
71        .with_allowed_calls(self.scope.clone())
72        .with_witness(self.session_id);
73
74        Ok(PreparedSessionAuthorization {
75            entry: SessionEntry {
76                session_id: self.session_id,
77                root_account: self.root_account,
78                chain_id: self.chain_id,
79                key_address: self.key_address,
80                expiry,
81                scope: Some(session_scopes_to_entry(&self.scope)),
82                limits: Some(session_spend_limits_to_entry(&self.spend_limits)),
83                status: SessionStatus::Pending,
84                key: None,
85            },
86            authorization,
87        })
88    }
89}
90
91impl PreparedSessionAuthorization {
92    /// Attach session key material and a root-signed authorization to the local registry entry.
93    pub fn into_active_entry(
94        mut self,
95        session_key: GeneratedSessionKey,
96        signed_authorization: &SignedKeyAuthorization,
97    ) -> eyre::Result<SessionEntry> {
98        ensure!(
99            session_key.address == self.entry.key_address,
100            "session key material resolves to {}, expected {}",
101            session_key.address,
102            self.entry.key_address
103        );
104        validate_signed_session_authorization(
105            &self.entry,
106            SignatureType::Secp256k1,
107            signed_authorization,
108        )?;
109
110        let mut buf = Vec::new();
111        signed_authorization.encode(&mut buf);
112        self.entry.status = SessionStatus::Active;
113        self.entry.key = Some(SessionKeyMaterial {
114            key_type: KeyType::Secp256k1,
115            key: session_key.private_key,
116            key_authorization: Some(hex::encode_prefixed(buf)),
117        });
118        Ok(self.entry)
119    }
120}
121
122/// Locally generated secp256k1 session key material.
123#[derive(Clone, PartialEq, Eq)]
124pub struct GeneratedSessionKey {
125    address: Address,
126    private_key: String,
127}
128
129impl GeneratedSessionKey {
130    /// Generate a fresh random secp256k1 session key.
131    pub fn random() -> Self {
132        Self::from_signer(&PrivateKeySigner::random())
133    }
134
135    /// Build a session key from an existing 32-byte secp256k1 private key.
136    pub fn from_private_key(private_key: impl AsRef<str>) -> eyre::Result<Self> {
137        let signer = private_key.as_ref().parse::<PrivateKeySigner>()?;
138        Ok(Self::from_signer(&signer))
139    }
140
141    /// The signer address derived from this session key.
142    pub const fn address(&self) -> Address {
143        self.address
144    }
145
146    /// Hex-encoded 32-byte private key with `0x` prefix.
147    pub fn private_key(&self) -> &str {
148        &self.private_key
149    }
150
151    fn from_signer(signer: &PrivateKeySigner) -> Self {
152        Self { address: signer.address(), private_key: hex::encode_prefixed(signer.to_bytes()) }
153    }
154}
155
156impl fmt::Debug for GeneratedSessionKey {
157    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158        f.debug_struct("GeneratedSessionKey")
159            .field("address", &self.address)
160            .finish_non_exhaustive()
161    }
162}
163
164fn session_spend_limits_to_entry(limits: &[SessionSpendLimit]) -> Vec<SessionTokenLimit> {
165    limits
166        .iter()
167        .map(|limit| SessionTokenLimit { currency: limit.token, limit: limit.amount.to_string() })
168        .collect()
169}
170
171fn session_spend_limits_to_authorization(limits: &[SessionSpendLimit]) -> Vec<TokenLimit> {
172    limits
173        .iter()
174        .map(|limit| TokenLimit { token: limit.token, limit: limit.amount, period: 0 })
175        .collect()
176}
177
178fn session_scopes_to_entry(scope: &[CallScope]) -> Vec<SessionCallScope> {
179    scope
180        .iter()
181        .map(|scope| SessionCallScope {
182            target: scope.target,
183            selector_rules: session_selector_rules_to_entry(&scope.selector_rules),
184        })
185        .collect()
186}
187
188fn session_selector_rules_to_entry(rules: &[SelectorRule]) -> Vec<SessionSelectorRule> {
189    rules
190        .iter()
191        .map(|rule| SessionSelectorRule {
192            selector: rule.selector.into(),
193            recipients: rule.recipients.clone(),
194        })
195        .collect()
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use alloy_primitives::Selector;
202    use alloy_signer::SignerSync;
203    use tempo_primitives::transaction::PrimitiveSignature;
204
205    const ROOT_PRIVATE_KEY: &str =
206        "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
207    const SESSION_PRIVATE_KEY: &str =
208        "0x59c6995e998f97a5a004497e5da3b5d2b2b66a87f064d39c44da0b6d6e4f8ff0";
209
210    #[test]
211    fn prepared_session_authorization_builds_entry_and_key_authorization() {
212        let session_id = B256::from([0x42; 32]);
213        let root = Address::from([0x11; 20]);
214        let key = Address::from([0x22; 20]);
215        let target = Address::from([0x33; 20]);
216        let token = Address::from([0x44; 20]);
217        let recipient = Address::from([0x55; 20]);
218
219        let request = SessionAuthorizationRequest {
220            session_id,
221            root_account: root,
222            chain_id: 4217,
223            key_address: key,
224            expiry: NonZeroU64::new(1_700_000_600).unwrap(),
225            scope: vec![CallScope {
226                target,
227                selector_rules: vec![SelectorRule {
228                    selector: [0x12, 0x34, 0x56, 0x78],
229                    recipients: vec![recipient],
230                }],
231            }],
232            spend_limits: vec![SessionSpendLimit { token, amount: U256::ZERO }],
233        };
234
235        let prepared = request.prepare(1_700_000_000).unwrap();
236
237        assert_eq!(prepared.entry.session_id, session_id);
238        assert_eq!(prepared.entry.root_account, root);
239        assert_eq!(prepared.entry.chain_id, 4217);
240        assert_eq!(prepared.entry.key_address, key);
241        assert_eq!(prepared.entry.expiry, 1_700_000_600);
242        assert_eq!(prepared.entry.status, SessionStatus::Pending);
243        assert!(prepared.entry.key.is_none());
244        assert_eq!(
245            prepared.entry.scope,
246            Some(vec![SessionCallScope {
247                target,
248                selector_rules: vec![SessionSelectorRule {
249                    selector: Selector::from_slice(&[0x12, 0x34, 0x56, 0x78]),
250                    recipients: vec![recipient],
251                }],
252            }])
253        );
254        assert_eq!(
255            prepared.entry.limits,
256            Some(vec![SessionTokenLimit { currency: token, limit: "0".to_string() }])
257        );
258
259        assert_eq!(prepared.authorization.chain_id, 4217);
260        assert_eq!(prepared.authorization.key_type, SignatureType::Secp256k1);
261        assert_eq!(prepared.authorization.key_id, key);
262        assert_eq!(prepared.authorization.expiry.map(NonZeroU64::get), Some(1_700_000_600));
263        assert_eq!(prepared.authorization.witness, Some(session_id));
264        assert_eq!(
265            prepared.authorization.limits,
266            Some(vec![TokenLimit { token, limit: U256::ZERO, period: 0 }])
267        );
268        assert_eq!(
269            prepared.authorization.allowed_calls,
270            Some(vec![CallScope {
271                target,
272                selector_rules: vec![SelectorRule {
273                    selector: [0x12, 0x34, 0x56, 0x78],
274                    recipients: vec![recipient],
275                }],
276            }])
277        );
278    }
279
280    #[test]
281    fn prepared_session_authorization_rejects_invalid_policy() {
282        let base = SessionAuthorizationRequest {
283            session_id: B256::from([0x42; 32]),
284            root_account: Address::from([0x11; 20]),
285            chain_id: 4217,
286            key_address: Address::from([0x22; 20]),
287            expiry: NonZeroU64::new(1_700_000_600).unwrap(),
288            scope: vec![CallScope { target: Address::from([0x33; 20]), selector_rules: vec![] }],
289            spend_limits: vec![],
290        };
291
292        let mut expired = base.clone();
293        expired.expiry = NonZeroU64::new(1_700_000_000).unwrap();
294        assert!(expired.prepare(1_700_000_000).is_err());
295
296        let mut no_scope = base.clone();
297        no_scope.scope = vec![];
298        let error = no_scope.prepare(1_700_000_000).unwrap_err();
299        assert!(error.to_string().contains("call scope"));
300
301        let mut zero_root = base.clone();
302        zero_root.root_account = Address::ZERO;
303        assert!(zero_root.prepare(1_700_000_000).is_err());
304
305        let mut zero_key = base.clone();
306        zero_key.key_address = Address::ZERO;
307        assert!(zero_key.prepare(1_700_000_000).is_err());
308
309        let mut root_key = base;
310        root_key.key_address = root_key.root_account;
311        assert!(root_key.prepare(1_700_000_000).is_err());
312    }
313
314    #[test]
315    fn signed_session_authorization_activates_entry_with_key_material() {
316        let root: PrivateKeySigner = ROOT_PRIVATE_KEY.parse().unwrap();
317        let session_key = GeneratedSessionKey::from_private_key(SESSION_PRIVATE_KEY).unwrap();
318        let request = SessionAuthorizationRequest {
319            session_id: B256::from([0x66; 32]),
320            root_account: root.address(),
321            chain_id: 4217,
322            key_address: session_key.address(),
323            expiry: NonZeroU64::new(1_700_000_600).unwrap(),
324            scope: vec![CallScope { target: Address::from([0x33; 20]), selector_rules: vec![] }],
325            spend_limits: vec![],
326        };
327        let prepared = request.prepare(1_700_000_000).unwrap();
328        let signature = root.sign_hash_sync(&prepared.authorization.signature_hash()).unwrap();
329        let signed =
330            prepared.authorization.clone().into_signed(PrimitiveSignature::Secp256k1(signature));
331
332        let entry = prepared.into_active_entry(session_key, &signed).unwrap();
333
334        assert_eq!(entry.status, SessionStatus::Active);
335        let key = entry.key.unwrap();
336        assert_eq!(key.key_type, KeyType::Secp256k1);
337        assert_eq!(key.key, SESSION_PRIVATE_KEY);
338        assert!(key.key_authorization.unwrap().starts_with("0x"));
339    }
340
341    #[test]
342    fn prepared_session_authorization_enforces_empty_spend_policy() {
343        let target = Address::from([0x33; 20]);
344        let request = SessionAuthorizationRequest {
345            session_id: B256::from([0x68; 32]),
346            root_account: Address::from([0x11; 20]),
347            chain_id: 4217,
348            key_address: Address::from([0x22; 20]),
349            expiry: NonZeroU64::new(1_700_000_600).unwrap(),
350            scope: vec![CallScope { target, selector_rules: vec![] }],
351            spend_limits: vec![],
352        };
353
354        let prepared = request.prepare(1_700_000_000).unwrap();
355
356        assert_eq!(
357            prepared.entry.scope,
358            Some(vec![SessionCallScope { target, selector_rules: vec![] }])
359        );
360        assert_eq!(prepared.entry.limits, Some(vec![]));
361        assert_eq!(
362            prepared.authorization.allowed_calls,
363            Some(vec![CallScope { target, selector_rules: vec![] }])
364        );
365        assert_eq!(prepared.authorization.limits, Some(vec![]));
366    }
367
368    #[test]
369    fn signed_session_authorization_rejects_policy_mismatch() {
370        let root: PrivateKeySigner = ROOT_PRIVATE_KEY.parse().unwrap();
371        let session_key = GeneratedSessionKey::from_private_key(SESSION_PRIVATE_KEY).unwrap();
372        let token = Address::from([0x44; 20]);
373        let request = SessionAuthorizationRequest {
374            session_id: B256::from([0x67; 32]),
375            root_account: root.address(),
376            chain_id: 4217,
377            key_address: session_key.address(),
378            expiry: NonZeroU64::new(1_700_000_600).unwrap(),
379            scope: vec![CallScope { target: Address::from([0x33; 20]), selector_rules: vec![] }],
380            spend_limits: vec![SessionSpendLimit { token, amount: U256::ZERO }],
381        };
382        let prepared = request.prepare(1_700_000_000).unwrap();
383        let mut authorization = prepared.authorization.clone();
384        authorization.limits = None;
385        let signature = root.sign_hash_sync(&authorization.signature_hash()).unwrap();
386        let signed = authorization.into_signed(PrimitiveSignature::Secp256k1(signature));
387
388        let error = prepared.into_active_entry(session_key, &signed).unwrap_err();
389
390        assert!(error.to_string().contains("limits"));
391    }
392
393    #[test]
394    fn signed_session_authorization_rejects_session_id_mismatch() {
395        let root: PrivateKeySigner = ROOT_PRIVATE_KEY.parse().unwrap();
396        let session_key = GeneratedSessionKey::from_private_key(SESSION_PRIVATE_KEY).unwrap();
397        let request = SessionAuthorizationRequest {
398            session_id: B256::from([0x70; 32]),
399            root_account: root.address(),
400            chain_id: 4217,
401            key_address: session_key.address(),
402            expiry: NonZeroU64::new(1_700_000_600).unwrap(),
403            scope: vec![CallScope { target: Address::from([0x33; 20]), selector_rules: vec![] }],
404            spend_limits: vec![],
405        };
406        let prepared = request.prepare(1_700_000_000).unwrap();
407        let signature = root.sign_hash_sync(&prepared.authorization.signature_hash()).unwrap();
408        let signed = prepared.authorization.into_signed(PrimitiveSignature::Secp256k1(signature));
409
410        let mut other_request = request;
411        other_request.session_id = B256::from([0x71; 32]);
412        let other_prepared = other_request.prepare(1_700_000_000).unwrap();
413
414        let error = other_prepared.into_active_entry(session_key, &signed).unwrap_err();
415
416        assert!(error.to_string().contains("witness"));
417    }
418
419    #[test]
420    fn signed_session_authorization_accepts_order_independent_policy_match() {
421        let root: PrivateKeySigner = ROOT_PRIVATE_KEY.parse().unwrap();
422        let session_key = GeneratedSessionKey::from_private_key(SESSION_PRIVATE_KEY).unwrap();
423        let token_a = Address::from([0x44; 20]);
424        let token_b = Address::from([0x45; 20]);
425        let target_a = Address::from([0x46; 20]);
426        let target_b = Address::from([0x47; 20]);
427        let recipient_a = Address::from([0x48; 20]);
428        let recipient_b = Address::from([0x49; 20]);
429        let request = SessionAuthorizationRequest {
430            session_id: B256::from([0x69; 32]),
431            root_account: root.address(),
432            chain_id: 4217,
433            key_address: session_key.address(),
434            expiry: NonZeroU64::new(1_700_000_600).unwrap(),
435            scope: vec![
436                CallScope {
437                    target: target_a,
438                    selector_rules: vec![SelectorRule {
439                        selector: [0x12, 0x34, 0x56, 0x78],
440                        recipients: vec![recipient_a, recipient_b],
441                    }],
442                },
443                CallScope { target: target_b, selector_rules: vec![] },
444            ],
445            spend_limits: vec![
446                SessionSpendLimit { token: token_a, amount: U256::from(1) },
447                SessionSpendLimit { token: token_b, amount: U256::from(2) },
448            ],
449        };
450        let prepared = request.prepare(1_700_000_000).unwrap();
451        let mut authorization = prepared.authorization.clone();
452        authorization.limits.as_mut().unwrap().reverse();
453        authorization.allowed_calls.as_mut().unwrap().reverse();
454        authorization.allowed_calls.as_mut().unwrap()[1].selector_rules[0].recipients.reverse();
455        let signature = root.sign_hash_sync(&authorization.signature_hash()).unwrap();
456        let signed = authorization.into_signed(PrimitiveSignature::Secp256k1(signature));
457
458        let entry = prepared.into_active_entry(session_key, &signed).unwrap();
459
460        assert_eq!(entry.status, SessionStatus::Active);
461    }
462
463    #[test]
464    fn generated_session_key_roundtrips_without_debug_leaking_private_key() {
465        let session_key = GeneratedSessionKey::from_private_key(SESSION_PRIVATE_KEY).unwrap();
466
467        assert_eq!(session_key.private_key(), SESSION_PRIVATE_KEY);
468        assert_ne!(session_key.address(), Address::ZERO);
469        assert!(!format!("{session_key:?}").contains(SESSION_PRIVATE_KEY));
470    }
471}