1use 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#[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#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
27pub struct SessionTokenLimit {
28 pub currency: Address,
29 pub limit: String,
30}
31
32#[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#[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 #[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#[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
115pub 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
137pub(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
315pub 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
343pub 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
356pub 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}