Skip to main content

foundry_common/tempo/
session.rs

1//! Tempo temporary access-key lifecycle backed by the canonical Accounts store.
2
3use super::KeyType;
4use alloy_primitives::{Address, B256, Selector, U256};
5use eyre::ensure;
6use foundry_wallets::TempoAccountsWallet;
7use serde::{Deserialize, Serialize};
8use std::{fmt, num::NonZeroU64, time::SystemTime};
9use tempo_alloy::accounts::TempoAccountsStore;
10use tempo_primitives::transaction::{
11    CallScope, KeyAuthorization, SelectorRule, SignatureType, SignedKeyAuthorization, TokenLimit,
12};
13
14/// Status derived from a managed access key's Accounts record.
15#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
16#[serde(rename_all = "snake_case")]
17pub enum SessionStatus {
18    #[default]
19    Pending,
20    Active,
21    Revoked,
22    Expired,
23}
24
25/// Spending limit attached to a managed access key.
26#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
27pub struct SessionTokenLimit {
28    pub currency: Address,
29    pub limit: String,
30}
31
32/// Transient key material used while adding an access key to the Accounts store.
33#[derive(Clone, PartialEq, Eq, Deserialize, Serialize)]
34pub struct SessionKeyMaterial {
35    #[serde(default)]
36    pub key_type: KeyType,
37    pub key: String,
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub key_authorization: Option<String>,
40}
41
42impl fmt::Debug for SessionKeyMaterial {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        f.debug_struct("SessionKeyMaterial")
45            .field("key_type", &self.key_type)
46            .field("key", &super::redacted_debug(&self.key))
47            .field(
48                "key_authorization",
49                &self.key_authorization.as_deref().map(super::redacted_debug),
50            )
51            .finish()
52    }
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
56pub struct SessionSelectorRule {
57    pub selector: Selector,
58    #[serde(default, skip_serializing_if = "Vec::is_empty")]
59    pub recipients: Vec<Address>,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
63pub struct SessionCallScope {
64    pub target: Address,
65    #[serde(default, skip_serializing_if = "Vec::is_empty")]
66    pub selector_rules: Vec<SessionSelectorRule>,
67}
68
69/// Foundry's command-facing view of one Accounts access key.
70///
71/// This is not a persistence schema. The source of truth is always
72/// `$TEMPO_HOME/wallet/store.json`.
73#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
74pub struct SessionEntry {
75    pub session_id: B256,
76    pub root_account: Address,
77    pub chain_id: u64,
78    pub key_address: Address,
79    pub expiry: u64,
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub scope: Option<Vec<SessionCallScope>>,
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub limits: Option<Vec<SessionTokenLimit>>,
84    #[serde(default)]
85    pub status: SessionStatus,
86    /// Present only before the entry is written to the Accounts store.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub key: Option<SessionKeyMaterial>,
89}
90
91impl SessionEntry {
92    pub const fn is_expired_at(&self, now: u64) -> bool {
93        now >= self.expiry
94    }
95
96    pub fn has_live_key_at(&self, now: u64) -> bool {
97        self.status == SessionStatus::Active && !self.is_expired_at(now)
98    }
99}
100
101/// A live managed key pinned into an Accounts wallet.
102#[derive(Debug)]
103pub struct ResolvedSessionSigner {
104    pub session: SessionEntry,
105    pub access_key: TempoAccountsWallet,
106}
107
108pub fn read_session_entry(session_id: B256) -> eyre::Result<Option<SessionEntry>> {
109    let now =
110        SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default().as_secs();
111    Ok(read_session_entries(now)?
112        .and_then(|entries| entries.into_iter().find(|entry| entry.session_id == session_id)))
113}
114
115/// Resolve a live managed key from the Accounts store and pin it for signing.
116pub fn resolve_live_session_signer(
117    session_id: B256,
118    now: u64,
119) -> eyre::Result<Option<ResolvedSessionSigner>> {
120    mark_expired_session_entries(now)?;
121    let Some(session) = read_session_entries(now)?
122        .and_then(|entries| entries.into_iter().find(|entry| entry.session_id == session_id))
123    else {
124        return Ok(None);
125    };
126    if !session.has_live_key_at(now) {
127        return Ok(None);
128    }
129
130    let wallet = TempoAccountsWallet::from_default_store()?.with_chain_id(session.chain_id);
131    let selected =
132        wallet.access_key(session.root_account, session.chain_id, session.key_address)?;
133    let access_key = TempoAccountsWallet::from_access_key(selected);
134    Ok(Some(ResolvedSessionSigner { session, access_key }))
135}
136
137/// Validate that a signed authorization describes the command-facing managed key.
138pub(crate) fn validate_signed_session_authorization(
139    session: &SessionEntry,
140    expected_key_type: SignatureType,
141    authorization: &SignedKeyAuthorization,
142) -> eyre::Result<()> {
143    let auth = &authorization.authorization;
144    ensure!(
145        auth.key_id == session.key_address,
146        "session {} key_authorization key_id is {}, expected {}",
147        session.session_id,
148        auth.key_id,
149        session.key_address
150    );
151    ensure!(
152        auth.chain_id == session.chain_id,
153        "session {} key_authorization chain_id is {}, expected {}",
154        session.session_id,
155        auth.chain_id,
156        session.chain_id
157    );
158    ensure!(
159        auth.key_type == expected_key_type,
160        "session {} key_authorization key_type is {:?}, expected {:?}",
161        session.session_id,
162        auth.key_type,
163        expected_key_type
164    );
165    ensure!(!auth.is_admin(), "session access key cannot be an admin key");
166    if let Some(account) = auth.account {
167        ensure!(
168            account == session.root_account,
169            "session authorization is bound to account {account}, expected {}",
170            session.root_account
171        );
172    }
173    ensure!(
174        auth.witness == Some(session.session_id),
175        "session authorization witness is {:?}, expected {}",
176        auth.witness,
177        session.session_id
178    );
179    ensure!(
180        authorization.recover_signer()? == session.root_account,
181        "session authorization was not signed by {}",
182        session.root_account
183    );
184    validate_session_authorization_policy(session, auth)
185}
186
187fn validate_session_authorization_policy(
188    session: &SessionEntry,
189    authorization: &KeyAuthorization,
190) -> eyre::Result<()> {
191    let expiry = NonZeroU64::new(session.expiry)
192        .ok_or_else(|| eyre::eyre!("session expiry cannot be zero"))?;
193    ensure!(authorization.expiry == Some(expiry), "session authorization expiry does not match");
194
195    let expected_limits = canonical_session_limits(session)?;
196    let actual_limits = authorization.limits.as_deref().map(canonical_authorization_limits);
197    ensure!(actual_limits == expected_limits, "session authorization limits do not match");
198
199    let expected_scope = canonical_session_scope(session);
200    let actual_scope = authorization.allowed_calls.as_deref().map(canonical_authorization_scope);
201    ensure!(actual_scope == expected_scope, "session authorization call scope does not match");
202    Ok(())
203}
204
205#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
206struct CanonicalTokenLimit {
207    token: Address,
208    limit: U256,
209    period: u64,
210}
211
212#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
213struct CanonicalCallScope {
214    target: Address,
215    selector_rules: Vec<CanonicalSelectorRule>,
216}
217
218#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
219struct CanonicalSelectorRule {
220    selector: [u8; 4],
221    recipients: Vec<Address>,
222}
223
224fn canonical_session_limits(
225    session: &SessionEntry,
226) -> eyre::Result<Option<Vec<CanonicalTokenLimit>>> {
227    let Some(limits) = session.limits.as_deref() else {
228        return Ok(None);
229    };
230    let mut limits = limits
231        .iter()
232        .map(|limit| {
233            Ok(CanonicalTokenLimit {
234                token: limit.currency,
235                limit: parse_limit(&limit.limit)?,
236                period: 0,
237            })
238        })
239        .collect::<eyre::Result<Vec<_>>>()?;
240    limits.sort();
241    Ok(Some(limits))
242}
243
244fn canonical_authorization_limits(limits: &[TokenLimit]) -> Vec<CanonicalTokenLimit> {
245    let mut limits = limits
246        .iter()
247        .map(|limit| CanonicalTokenLimit {
248            token: limit.token,
249            limit: limit.limit,
250            period: limit.period,
251        })
252        .collect::<Vec<_>>();
253    limits.sort();
254    limits
255}
256
257fn canonical_session_scope(session: &SessionEntry) -> Option<Vec<CanonicalCallScope>> {
258    let mut scopes = session
259        .scope
260        .as_deref()?
261        .iter()
262        .map(|scope| CanonicalCallScope {
263            target: scope.target,
264            selector_rules: canonical_session_selector_rules(&scope.selector_rules),
265        })
266        .collect::<Vec<_>>();
267    scopes.sort();
268    Some(scopes)
269}
270
271fn canonical_authorization_scope(scopes: &[CallScope]) -> Vec<CanonicalCallScope> {
272    let mut scopes = scopes
273        .iter()
274        .map(|scope| CanonicalCallScope {
275            target: scope.target,
276            selector_rules: canonical_authorization_selector_rules(&scope.selector_rules),
277        })
278        .collect::<Vec<_>>();
279    scopes.sort();
280    scopes
281}
282
283fn canonical_session_selector_rules(rules: &[SessionSelectorRule]) -> Vec<CanonicalSelectorRule> {
284    let mut rules = rules
285        .iter()
286        .map(|rule| {
287            let mut recipients = rule.recipients.clone();
288            recipients.sort();
289            CanonicalSelectorRule { selector: rule.selector.into(), recipients }
290        })
291        .collect::<Vec<_>>();
292    rules.sort();
293    rules
294}
295
296fn canonical_authorization_selector_rules(rules: &[SelectorRule]) -> Vec<CanonicalSelectorRule> {
297    let mut rules = rules
298        .iter()
299        .map(|rule| {
300            let mut recipients = rule.recipients.clone();
301            recipients.sort();
302            CanonicalSelectorRule { selector: rule.selector, recipients }
303        })
304        .collect::<Vec<_>>();
305    rules.sort();
306    rules
307}
308
309fn parse_limit(raw: &str) -> eyre::Result<U256> {
310    let raw = raw.trim();
311    if let Some(hex) = raw.strip_prefix("0x") { U256::from_str_radix(hex, 16) } else { raw.parse() }
312        .map_err(|err| eyre::eyre!("invalid session spending limit `{raw}`: {err}"))
313}
314
315/// Add a generated access key to the canonical Accounts store.
316pub fn upsert_session_entry(entry: SessionEntry) -> eyre::Result<()> {
317    let key = entry
318        .key
319        .as_ref()
320        .ok_or_else(|| eyre::eyre!("managed access key has no local signing material"))?;
321    ensure!(key.key_type == KeyType::Secp256k1, "only secp256k1 managed access keys are supported");
322    let signer = foundry_wallets::utils::create_local_signer(&key.key)?;
323    ensure!(
324        signer.address() == entry.key_address,
325        "managed access key resolves to {}, expected {}",
326        signer.address(),
327        entry.key_address
328    );
329    let encoded = key
330        .key_authorization
331        .as_deref()
332        .ok_or_else(|| eyre::eyre!("managed access key has no signed authorization"))?;
333    let authorization = super::decode_key_authorization::<SignedKeyAuthorization>(encoded)?;
334    validate_signed_session_authorization(&entry, SignatureType::Secp256k1, &authorization)?;
335    TempoAccountsStore::default_path()?.upsert_secp256k1_access_key(
336        entry.root_account,
337        &signer,
338        &authorization,
339    )?;
340    Ok(())
341}
342
343/// Retire local signing material in `store.json` for the selected managed key.
344///
345/// The non-secret account, chain, policy, and authorization witness remain available for
346/// on-chain revoke retries.
347pub fn retire_session_entry(session_id: B256) -> eyre::Result<bool> {
348    let Some(entry) = read_session_entry(session_id)? else {
349        return Ok(false);
350    };
351    TempoAccountsStore::default_path()?
352        .retire_access_key(entry.root_account, entry.chain_id, entry.key_address)
353        .map_err(Into::into)
354}
355
356/// Retire expired managed access keys and return the number changed.
357pub fn mark_expired_session_entries(now: u64) -> eyre::Result<usize> {
358    let Some(entries) = read_session_entries(now)? else {
359        return Ok(0);
360    };
361    let store = TempoAccountsStore::default_path()?;
362    let mut retired = 0;
363    for entry in entries {
364        if entry.status == SessionStatus::Expired
365            && store.retire_access_key(entry.root_account, entry.chain_id, entry.key_address)?
366        {
367            retired += 1;
368        }
369    }
370    Ok(retired)
371}
372
373fn read_session_entries(now: u64) -> eyre::Result<Option<Vec<SessionEntry>>> {
374    let Some(store) = TempoAccountsStore::try_open_default()? else {
375        return Ok(None);
376    };
377    let sessions = store
378        .access_keys()?
379        .into_iter()
380        .filter_map(|key| {
381            let authorization = key.key_authorization()?;
382            let session_id = key.authorization_witness()?;
383            let expiry = key.expiry()?;
384            let status = if expiry <= now {
385                SessionStatus::Expired
386            } else if key.is_locally_signable() {
387                SessionStatus::Active
388            } else {
389                SessionStatus::Revoked
390            };
391            Some(SessionEntry {
392                session_id,
393                root_account: key.account(),
394                chain_id: key.chain_id(),
395                key_address: key.address(),
396                expiry,
397                scope: authorization.allowed_calls.as_ref().map(|scopes| {
398                    scopes
399                        .iter()
400                        .map(|scope| SessionCallScope {
401                            target: scope.target,
402                            selector_rules: scope
403                                .selector_rules
404                                .iter()
405                                .map(|rule| SessionSelectorRule {
406                                    selector: rule.selector.into(),
407                                    recipients: rule.recipients.clone(),
408                                })
409                                .collect(),
410                        })
411                        .collect()
412                }),
413                limits: authorization.limits.as_ref().map(|limits| {
414                    limits
415                        .iter()
416                        .map(|limit| SessionTokenLimit {
417                            currency: limit.token,
418                            limit: limit.limit.to_string(),
419                        })
420                        .collect()
421                }),
422                status,
423                key: None,
424            })
425        })
426        .collect();
427    Ok(Some(sessions))
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    #[test]
435    fn debug_redacts_transient_key_material() {
436        let key = SessionKeyMaterial {
437            key_type: KeyType::Secp256k1,
438            key: "0xPRIVATE_KEY_MUST_NOT_LEAK".into(),
439            key_authorization: Some("0xAUTH_MUST_NOT_LEAK".into()),
440        };
441        let rendered = format!("{key:?}");
442        assert!(!rendered.contains("PRIVATE_KEY_MUST_NOT_LEAK"));
443        assert!(!rendered.contains("AUTH_MUST_NOT_LEAK"));
444        assert!(rendered.contains("<redacted>"));
445    }
446}