Skip to main content

foundry_common/tempo/
session.rs

1//! Tempo session registry and local lifecycle metadata.
2
3use super::{KeyType, registry::*, tempo_home};
4use alloy_primitives::{Address, B256, Selector, U256};
5use alloy_signer::Signer;
6use eyre::ensure;
7use foundry_wallets::{TempoAccessKeyConfig, WalletSigner};
8use serde::{Deserialize, Serialize};
9use std::{fmt, num::NonZeroU64, path::PathBuf};
10use tempo_primitives::transaction::{
11    CallScope, KeyAuthorization, SelectorRule, SignatureType, SignedKeyAuthorization, TokenLimit,
12};
13
14/// Relative path from Tempo home to the session registry file.
15pub const WALLET_SESSIONS_PATH: &str = "wallet/sessions.toml";
16
17const SESSIONS_HEADER: &str =
18    "# Tempo session registry — managed by Foundry / Tempo CLI.\n# Do not edit manually.";
19
20/// Status of a local session entry.
21#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
22#[serde(rename_all = "snake_case")]
23pub enum SessionStatus {
24    #[default]
25    Pending,
26    Active,
27    /// Local use has stopped and key material has been erased, but on-chain revoke is still
28    /// pending or retryable.
29    Revoking,
30    Revoked,
31    Expired,
32    Failed,
33}
34
35impl SessionStatus {
36    /// Returns `true` if the session is no longer expected to be usable.
37    pub const fn is_terminal(self) -> bool {
38        matches!(self, Self::Revoked | Self::Expired | Self::Failed)
39    }
40
41    /// Returns `true` if entering this status must erase local key material.
42    const fn clears_key_material(self) -> bool {
43        matches!(self, Self::Revoking) || self.is_terminal()
44    }
45
46    /// Returns `true` if the session is not terminal. This does not imply usable key material:
47    /// [`Self::Revoking`] is in-flight cleanup state and has no local signing key.
48    pub const fn is_live(self) -> bool {
49        !self.is_terminal()
50    }
51}
52
53/// Spending limit stored for a session entry.
54#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
55pub struct SessionTokenLimit {
56    pub currency: Address,
57    pub limit: String,
58}
59
60/// Private key material for a temporary session access key.
61///
62/// Session keys live with their lifecycle record in `wallet/sessions.toml`.
63/// Persistent Tempo wallet login keys remain in `wallet/keys.toml`, so creating
64/// or cleaning up a session cannot replace a user's long-lived access key.
65#[derive(Clone, PartialEq, Eq, Deserialize, Serialize)]
66pub struct SessionKeyMaterial {
67    #[serde(default)]
68    pub key_type: KeyType,
69    /// Hex-encoded private key for the temporary session access key.
70    pub key: String,
71    /// RLP-encoded signed key authorization, if the key still needs inline
72    /// provisioning on first use.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub key_authorization: Option<String>,
75}
76
77// Manual `Debug` redacts the secret key material; propagates to containers.
78impl fmt::Debug for SessionKeyMaterial {
79    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80        f.debug_struct("SessionKeyMaterial")
81            .field("key_type", &self.key_type)
82            .field("key", &super::redacted_debug(&self.key))
83            .field(
84                "key_authorization",
85                &self.key_authorization.as_deref().map(super::redacted_debug),
86            )
87            .finish()
88    }
89}
90
91impl SessionKeyMaterial {
92    /// Returns `true` when the entry carries a non-empty private key.
93    pub fn has_inline_key(&self) -> bool {
94        !self.key.trim().is_empty()
95    }
96}
97
98/// A single selector rule in a session scope.
99#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
100pub struct SessionSelectorRule {
101    pub selector: Selector,
102    #[serde(default, skip_serializing_if = "Vec::is_empty")]
103    pub recipients: Vec<Address>,
104}
105
106/// A single target scope in a session entry.
107#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
108pub struct SessionCallScope {
109    pub target: Address,
110    /// Empty selector list means wildcard access for the target.
111    #[serde(default, skip_serializing_if = "Vec::is_empty")]
112    pub selector_rules: Vec<SessionSelectorRule>,
113}
114
115/// Persisted metadata for one temporary session.
116#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
117pub struct SessionEntry {
118    pub session_id: B256,
119    pub root_account: Address,
120    pub chain_id: u64,
121    pub key_address: Address,
122    /// Unix timestamp in seconds when the session expires.
123    ///
124    /// Tempo sessions are always bounded-lifetime. `0` is not a "never
125    /// expires" sentinel; it is already expired.
126    pub expiry: u64,
127    /// Call scope policy for the session key. `None` means unrestricted;
128    /// `Some([])` means no calls are allowed.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub scope: Option<Vec<SessionCallScope>>,
131    /// Spending limit policy for the session key. `None` means unrestricted;
132    /// `Some([])` means no token spending is allowed.
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub limits: Option<Vec<SessionTokenLimit>>,
135    #[serde(default)]
136    pub status: SessionStatus,
137    /// Session-scoped key material. This is intentionally separate from
138    /// `wallet/keys.toml`, which stores persistent access keys.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub key: Option<SessionKeyMaterial>,
141}
142
143impl SessionEntry {
144    /// Returns `true` if the session has passed its expiry timestamp.
145    pub const fn is_expired_at(&self, now: u64) -> bool {
146        now >= self.expiry
147    }
148
149    /// Returns `true` if this session has usable local key material.
150    pub fn has_inline_key(&self) -> bool {
151        self.key.as_ref().is_some_and(SessionKeyMaterial::has_inline_key)
152    }
153
154    /// Returns `true` if this session is active, unexpired, and has key material.
155    pub fn has_live_key_at(&self, now: u64) -> bool {
156        self.status == SessionStatus::Active && !self.is_expired_at(now) && self.has_inline_key()
157    }
158}
159
160/// Top-level registry persisted in `wallet/sessions.toml`.
161#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, Serialize)]
162pub struct SessionRecord {
163    #[serde(default)]
164    pub sessions: Vec<SessionEntry>,
165}
166
167impl SessionRecord {
168    /// Returns `true` if the registry has no session entries.
169    pub const fn is_empty(&self) -> bool {
170        self.sessions.is_empty()
171    }
172
173    /// Insert or replace a session by `session_id`.
174    pub fn upsert(&mut self, entry: SessionEntry) {
175        self.sessions.retain(|session| session.session_id != entry.session_id);
176        self.sessions.push(entry);
177    }
178
179    /// Remove a session by `session_id`. Returns `true` if an entry was removed.
180    pub fn remove(&mut self, session_id: B256) -> bool {
181        let before = self.sessions.len();
182        self.sessions.retain(|session| session.session_id != session_id);
183        self.sessions.len() != before
184    }
185
186    /// Returns a session by id.
187    pub fn get(&self, session_id: B256) -> Option<&SessionEntry> {
188        self.sessions.iter().find(|session| session.session_id == session_id)
189    }
190
191    /// Returns an active session with usable local key material by id.
192    pub fn live_key(&self, session_id: B256, now: u64) -> Option<&SessionEntry> {
193        self.get(session_id).filter(|session| session.has_live_key_at(now))
194    }
195
196    /// Update a session status by id. Cleanup and terminal statuses clear local key material.
197    ///
198    /// Returns `true` when the record changed. Missing sessions and idempotent
199    /// updates return `false`.
200    pub fn set_status(&mut self, session_id: B256, status: SessionStatus) -> bool {
201        let Some(session) =
202            self.sessions.iter_mut().find(|session| session.session_id == session_id)
203        else {
204            return false;
205        };
206
207        set_session_status(session, status)
208    }
209
210    /// Mark expired live entries as expired. Returns the number updated.
211    pub fn mark_expired(&mut self, now: u64) -> usize {
212        let mut updated = 0;
213        for session in &mut self.sessions {
214            let should_expire = session.status.is_live() && session.is_expired_at(now);
215            let should_clear_key =
216                session.key.is_some() && (should_expire || session.status.clears_key_material());
217
218            if should_expire {
219                session.status = SessionStatus::Expired;
220            }
221            if should_clear_key {
222                session.key = None;
223            }
224            if should_expire || should_clear_key {
225                updated += 1;
226            }
227        }
228        updated
229    }
230}
231
232fn set_session_status(session: &mut SessionEntry, status: SessionStatus) -> bool {
233    let changed = session.status != status || status.clears_key_material() && session.key.is_some();
234    if !changed {
235        return false;
236    }
237
238    session.status = status;
239    if status.clears_key_material() {
240        session.key = None;
241    }
242    true
243}
244
245/// A live session key resolved into the signer and Tempo access-key metadata.
246#[derive(Debug)]
247pub struct ResolvedSessionSigner {
248    pub session: SessionEntry,
249    pub signer: WalletSigner,
250    pub access_key: TempoAccessKeyConfig,
251}
252
253/// Returns the path to the Tempo session registry file.
254pub fn session_registry_path() -> Option<PathBuf> {
255    tempo_home().map(|home| home.join(WALLET_SESSIONS_PATH))
256}
257
258/// Read and parse the Tempo session registry.
259///
260/// Returns `None` if the file doesn't exist or can't be read/parsed.
261/// Errors are logged as warnings.
262pub fn read_session_record() -> Option<SessionRecord> {
263    let path = session_registry_path()?;
264    match read_toml_file(&path, "tempo sessions") {
265        Ok(value) => value,
266        Err(e) => {
267            tracing::warn!(?path, %e, "failed to load tempo sessions file");
268            None
269        }
270    }
271}
272
273/// Read a live session-scoped key entry by session id.
274pub fn read_live_session_key(session_id: B256, now: u64) -> Option<SessionEntry> {
275    read_session_record()?.live_key(session_id, now).cloned()
276}
277
278/// Read a session entry by id, returning parse/read errors to the caller.
279pub fn read_session_entry(session_id: B256) -> eyre::Result<Option<SessionEntry>> {
280    let path =
281        session_registry_path().ok_or_else(|| eyre::eyre!("could not resolve tempo home"))?;
282    Ok(read_toml_file::<SessionRecord>(&path, "tempo sessions")?
283        .and_then(|record| record.get(session_id).cloned()))
284}
285
286/// Resolve a live session key into a signer and access-key configuration.
287pub fn resolve_live_session_signer(
288    session_id: B256,
289    now: u64,
290) -> eyre::Result<Option<ResolvedSessionSigner>> {
291    mark_expired_session_entries(now)?;
292
293    let path =
294        session_registry_path().ok_or_else(|| eyre::eyre!("could not resolve tempo home"))?;
295    let Some(record) = read_toml_file::<SessionRecord>(&path, "tempo sessions")? else {
296        return Ok(None);
297    };
298    let Some(session) = record.live_key(session_id, now).cloned() else {
299        return Ok(None);
300    };
301    let key =
302        session.key.as_ref().ok_or_else(|| eyre::eyre!("live session has no key material"))?;
303
304    let signer = foundry_wallets::utils::create_private_key_signer(&key.key)?;
305    let signer_address = signer.address();
306    if signer_address != session.key_address {
307        eyre::bail!(
308            "session {} key material resolves to {}, expected {}",
309            session.session_id,
310            signer_address,
311            session.key_address
312        );
313    }
314
315    let key_authorization = key
316        .key_authorization
317        .as_deref()
318        .map(|raw| {
319            super::decode_key_authorization::<SignedKeyAuthorization>(raw)
320                .map_err(|err| eyre::eyre!("failed to decode session key_authorization: {err}"))
321        })
322        .transpose()?;
323    if let Some(auth) = &key_authorization {
324        validate_signed_session_authorization(
325            &session,
326            key_type_to_signature_type(key.key_type),
327            auth,
328        )?;
329    }
330    let access_key = TempoAccessKeyConfig {
331        wallet_address: session.root_account,
332        key_address: session.key_address,
333        key_authorization,
334    };
335
336    Ok(Some(ResolvedSessionSigner { session, signer, access_key }))
337}
338
339/// Ensures a signed authorization matches stored session identity, key type, signer, and policy.
340pub(crate) fn validate_signed_session_authorization(
341    session: &SessionEntry,
342    expected_key_type: SignatureType,
343    authorization: &SignedKeyAuthorization,
344) -> eyre::Result<()> {
345    let auth = &authorization.authorization;
346    ensure!(
347        auth.key_id == session.key_address,
348        "session {} key_authorization key_id is {}, expected {}",
349        session.session_id,
350        auth.key_id,
351        session.key_address
352    );
353    ensure!(
354        auth.chain_id == session.chain_id,
355        "session {} key_authorization chain_id is {}, expected {}",
356        session.session_id,
357        auth.chain_id,
358        session.chain_id
359    );
360    ensure!(
361        auth.key_type == expected_key_type,
362        "session {} key_authorization key_type is {:?}, expected {:?}",
363        session.session_id,
364        auth.key_type,
365        expected_key_type
366    );
367    // `session_id` is local metadata; the signed binding lives in the authorization witness.
368    ensure!(
369        auth.witness == Some(session.session_id),
370        "session {} key_authorization witness is {:?}, expected {}",
371        session.session_id,
372        auth.witness,
373        session.session_id
374    );
375    let recovered = authorization
376        .recover_signer()
377        .map_err(|err| eyre::eyre!("failed to recover session key_authorization signer: {err}"))?;
378    ensure!(
379        recovered == session.root_account,
380        "session {} key_authorization signer is {}, expected {}",
381        session.session_id,
382        recovered,
383        session.root_account
384    );
385    validate_session_authorization_policy(session, auth)
386}
387
388/// Ensures authorization expiry, limits, and call scope match the stored session policy.
389fn validate_session_authorization_policy(
390    session: &SessionEntry,
391    auth: &KeyAuthorization,
392) -> eyre::Result<()> {
393    let expected_expiry = NonZeroU64::new(session.expiry)
394        .ok_or_else(|| eyre::eyre!("session {} has invalid zero expiry", session.session_id))?;
395    ensure!(
396        auth.expiry == Some(expected_expiry),
397        "session {} key_authorization expiry is {:?}, expected {}",
398        session.session_id,
399        auth.expiry.map(NonZeroU64::get),
400        session.expiry
401    );
402
403    let expected_limits = session_authorization_limits(session)?;
404    let actual_limits = auth.limits.as_deref().map(authorization_limits);
405    ensure!(
406        actual_limits == expected_limits,
407        "session {} key_authorization limits do not match session limits",
408        session.session_id
409    );
410
411    let expected_scope = session_authorization_scope(session);
412    let actual_scope = auth.allowed_calls.as_deref().map(authorization_scope);
413    ensure!(
414        actual_scope == expected_scope,
415        "session {} key_authorization allowed_calls do not match session scope",
416        session.session_id
417    );
418
419    Ok(())
420}
421
422/// Canonical spending limit used for order-independent policy comparisons.
423#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
424struct CanonicalTokenLimit {
425    token: Address,
426    limit: U256,
427    period: u64,
428}
429
430/// Canonical target scope used for order-independent policy comparisons.
431#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
432struct CanonicalCallScope {
433    target: Address,
434    selector_rules: Vec<CanonicalSelectorRule>,
435}
436
437/// Canonical selector rule used for order-independent policy comparisons.
438#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
439struct CanonicalSelectorRule {
440    selector: [u8; 4],
441    recipients: Vec<Address>,
442}
443
444/// Converts stored session limits into canonical form for authorization comparison.
445fn session_authorization_limits(
446    session: &SessionEntry,
447) -> eyre::Result<Option<Vec<CanonicalTokenLimit>>> {
448    let Some(limits) = session.limits.as_deref() else {
449        return Ok(None);
450    };
451    let mut limits = limits
452        .iter()
453        .map(|limit| {
454            Ok(CanonicalTokenLimit {
455                token: limit.currency,
456                limit: parse_session_limit(&limit.limit)?,
457                period: 0,
458            })
459        })
460        .collect::<eyre::Result<Vec<_>>>()?;
461    limits.sort();
462    Ok(Some(limits))
463}
464
465/// Converts signed authorization limits into canonical form for session comparison.
466fn authorization_limits(limits: &[TokenLimit]) -> Vec<CanonicalTokenLimit> {
467    let mut limits = limits
468        .iter()
469        .map(|limit| CanonicalTokenLimit {
470            token: limit.token,
471            limit: limit.limit,
472            period: limit.period,
473        })
474        .collect::<Vec<_>>();
475    limits.sort();
476    limits
477}
478
479/// Parses a stored session spending limit from decimal or 0x-prefixed hex.
480fn parse_session_limit(raw: &str) -> eyre::Result<U256> {
481    let raw = raw.trim();
482    if let Some(hex) = raw.strip_prefix("0x") { U256::from_str_radix(hex, 16) } else { raw.parse() }
483        .map_err(|err| eyre::eyre!("invalid session spending limit `{raw}`: {err}"))
484}
485
486/// Converts stored session scope into canonical form for authorization comparison.
487fn session_authorization_scope(session: &SessionEntry) -> Option<Vec<CanonicalCallScope>> {
488    let mut scope = session
489        .scope
490        .as_deref()?
491        .iter()
492        .map(|scope| CanonicalCallScope {
493            target: scope.target,
494            selector_rules: session_authorization_selector_rules(&scope.selector_rules),
495        })
496        .collect::<Vec<_>>();
497    scope.sort();
498    Some(scope)
499}
500
501/// Converts signed authorization scope into canonical form for session comparison.
502fn authorization_scope(scope: &[CallScope]) -> Vec<CanonicalCallScope> {
503    let mut scope = scope
504        .iter()
505        .map(|scope| CanonicalCallScope {
506            target: scope.target,
507            selector_rules: authorization_selector_rules(&scope.selector_rules),
508        })
509        .collect::<Vec<_>>();
510    scope.sort();
511    scope
512}
513
514/// Converts stored selector rules into canonical form for authorization comparison.
515fn session_authorization_selector_rules(
516    rules: &[SessionSelectorRule],
517) -> Vec<CanonicalSelectorRule> {
518    let mut rules = rules
519        .iter()
520        .map(|rule| {
521            let mut recipients = rule.recipients.clone();
522            recipients.sort();
523            CanonicalSelectorRule { selector: rule.selector.into(), recipients }
524        })
525        .collect::<Vec<_>>();
526    rules.sort();
527    rules
528}
529
530/// Converts signed authorization selector rules into canonical form for session comparison.
531fn authorization_selector_rules(rules: &[SelectorRule]) -> Vec<CanonicalSelectorRule> {
532    let mut rules = rules
533        .iter()
534        .map(|rule| {
535            let mut recipients = rule.recipients.clone();
536            recipients.sort();
537            CanonicalSelectorRule { selector: rule.selector, recipients }
538        })
539        .collect::<Vec<_>>();
540    rules.sort();
541    rules
542}
543
544/// Maps stored session key types to Tempo authorization signature types.
545const fn key_type_to_signature_type(key_type: KeyType) -> SignatureType {
546    match key_type {
547        KeyType::Secp256k1 => SignatureType::Secp256k1,
548        KeyType::P256 => SignatureType::P256,
549        KeyType::WebAuthn => SignatureType::WebAuthn,
550    }
551}
552
553fn mutate_session_record<R>(f: impl FnOnce(&mut SessionRecord) -> (R, bool)) -> eyre::Result<R> {
554    let path =
555        session_registry_path().ok_or_else(|| eyre::eyre!("could not resolve tempo home"))?;
556    let mut record = read_toml_file::<SessionRecord>(&path, "tempo sessions")?.unwrap_or_default();
557    let (result, changed) = f(&mut record);
558    if changed {
559        write_toml_file_atomic(&path, &record, SESSIONS_HEADER)?;
560    }
561    Ok(result)
562}
563
564/// Atomically upsert a [`SessionEntry`] into the session registry.
565pub fn upsert_session_entry(entry: SessionEntry) -> eyre::Result<()> {
566    mutate_session_record(|record| {
567        record.upsert(entry);
568        ((), true)
569    })
570}
571
572/// Atomically update a session status in the registry.
573///
574/// Cleanup and terminal statuses (`revoking`, `revoked`, `expired`, `failed`) also clear the
575/// session-scoped private key material. Returns `true` when an entry was found and changed.
576pub fn update_session_status(session_id: B256, status: SessionStatus) -> eyre::Result<bool> {
577    mutate_session_record(|record| {
578        let changed = record.set_status(session_id, status);
579        (changed, changed)
580    })
581}
582
583/// Atomically update a session status only when the current status matches `current`.
584///
585/// Returns `true` when an entry was found with the expected current status. The
586/// registry is only rewritten when the matched entry actually changes.
587pub fn update_session_status_if(
588    session_id: B256,
589    current: SessionStatus,
590    status: SessionStatus,
591) -> eyre::Result<bool> {
592    mutate_session_record(|record| {
593        let Some(session) =
594            record.sessions.iter_mut().find(|session| session.session_id == session_id)
595        else {
596            return (false, false);
597        };
598        if session.status != current {
599            return (false, false);
600        }
601
602        let changed = set_session_status(session, status);
603        (true, changed)
604    })
605}
606
607/// Atomically remove a session from the registry.
608pub fn remove_session_entry(session_id: B256) -> eyre::Result<bool> {
609    mutate_session_record(|record| {
610        let removed = record.remove(session_id);
611        (removed, removed)
612    })
613}
614
615/// Mark expired live sessions in the registry and persist the status updates.
616pub fn mark_expired_session_entries(now: u64) -> eyre::Result<usize> {
617    mutate_session_record(|record| {
618        let updated = record.mark_expired(now);
619        (updated, updated != 0)
620    })
621}
622
623#[cfg(test)]
624mod tests {
625    use super::*;
626    use crate::tempo::with_tempo_home;
627    use alloy_primitives::hex;
628    use alloy_rlp::Encodable;
629    use alloy_signer::SignerSync;
630    use alloy_signer_local::PrivateKeySigner;
631    use std::{fs, str::FromStr};
632    use tempo_primitives::transaction::PrimitiveSignature;
633
634    const ROOT_PRIVATE_KEY: &str =
635        "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
636    const SESSION_PRIVATE_KEY: &str =
637        "0x59c6995e998f97a5a004497e5da3b5d2b2b66a87f064d39c44da0b6d6e4f8ff0";
638
639    #[test]
640    fn debug_redacts_session_key_material() {
641        // Distinctive sentinels so a leak can't accidentally pass.
642        let mut entry = sample_entry_with_key(B256::from([0x77; 32]), 200, SessionStatus::Active);
643        let key = entry.key.as_mut().unwrap();
644        key.key = "0xPRIVATE_KEY_MUST_NOT_LEAK".to_string();
645        key.key_authorization = Some("0xKEY_AUTH_MUST_NOT_LEAK".to_string());
646
647        let entry_dbg = format!("{entry:?}");
648        let record_dbg = format!("{:?}", SessionRecord { sessions: vec![entry] });
649
650        for rendered in [&entry_dbg, &record_dbg] {
651            assert!(!rendered.contains("PRIVATE_KEY_MUST_NOT_LEAK"), "key leaked in: {rendered}");
652            assert!(!rendered.contains("KEY_AUTH_MUST_NOT_LEAK"), "auth leaked in: {rendered}");
653        }
654        assert!(entry_dbg.contains("key: \"<redacted>\""), "got: {entry_dbg}");
655        assert!(entry_dbg.contains("key_authorization: Some(\"<redacted>\")"), "got: {entry_dbg}");
656        // Non-secret metadata is still visible for diagnostics.
657        assert!(entry_dbg.contains("key_type"));
658    }
659
660    fn sample_entry(session_id: B256, expiry: u64, status: SessionStatus) -> SessionEntry {
661        SessionEntry {
662            session_id,
663            root_account: Address::from_str("0x0000000000000000000000000000000000000001").unwrap(),
664            chain_id: 4217,
665            key_address: Address::from_str("0x0000000000000000000000000000000000000abc").unwrap(),
666            expiry,
667            scope: Some(vec![SessionCallScope {
668                target: Address::from_str("0x00000000000000000000000000000000000000aa").unwrap(),
669                selector_rules: vec![SessionSelectorRule {
670                    selector: Selector::from_slice(&[0x12, 0x34, 0x56, 0x78]),
671                    recipients: vec![],
672                }],
673            }]),
674            limits: Some(vec![SessionTokenLimit {
675                currency: Address::from_str("0x00000000000000000000000000000000000000ff").unwrap(),
676                limit: "0".to_string(),
677            }]),
678            status,
679            key: None,
680        }
681    }
682
683    fn sample_entry_with_key(session_id: B256, expiry: u64, status: SessionStatus) -> SessionEntry {
684        SessionEntry {
685            key: Some(SessionKeyMaterial {
686                key_type: KeyType::Secp256k1,
687                key: "0xdeadbeef".to_string(),
688                key_authorization: Some("0xfeed".to_string()),
689            }),
690            ..sample_entry(session_id, expiry, status)
691        }
692    }
693
694    /// Builds a session entry with matching root and session key material.
695    fn sample_entry_with_valid_key(
696        session_id: B256,
697        expiry: u64,
698        status: SessionStatus,
699    ) -> SessionEntry {
700        let root_signer: PrivateKeySigner = ROOT_PRIVATE_KEY.parse().unwrap();
701        let signer = foundry_wallets::utils::create_private_key_signer(SESSION_PRIVATE_KEY)
702            .expect("valid test private key");
703        SessionEntry {
704            root_account: root_signer.address(),
705            key_address: signer.address(),
706            key: Some(SessionKeyMaterial {
707                key_type: KeyType::Secp256k1,
708                key: SESSION_PRIVATE_KEY.to_string(),
709                key_authorization: None,
710            }),
711            ..sample_entry(session_id, expiry, status)
712        }
713    }
714
715    /// Encodes a signed key authorization that matches the supplied session entry.
716    fn signed_key_authorization_hex(entry: &SessionEntry) -> String {
717        signed_key_authorization_hex_with(entry, std::convert::identity)
718    }
719
720    /// Encodes a signed key authorization after applying a test-specific mutation.
721    fn signed_key_authorization_hex_with(
722        entry: &SessionEntry,
723        update: impl FnOnce(KeyAuthorization) -> KeyAuthorization,
724    ) -> String {
725        let root_signer: PrivateKeySigner = ROOT_PRIVATE_KEY.parse().unwrap();
726        let auth = update(session_key_authorization(entry));
727        let signature = root_signer.sign_hash_sync(&auth.signature_hash()).unwrap();
728        let signed = auth.into_signed(PrimitiveSignature::Secp256k1(signature));
729        let mut buf = Vec::new();
730        signed.encode(&mut buf);
731        hex::encode_prefixed(buf)
732    }
733
734    /// Builds a key authorization that mirrors the session entry policy.
735    fn session_key_authorization(entry: &SessionEntry) -> KeyAuthorization {
736        let mut authorization = KeyAuthorization::unrestricted(
737            entry.chain_id,
738            SignatureType::Secp256k1,
739            entry.key_address,
740        )
741        .with_expiry(entry.expiry)
742        .with_witness(entry.session_id);
743        if let Some(limits) = &entry.limits {
744            authorization = authorization.with_limits(
745                limits
746                    .iter()
747                    .map(|limit| TokenLimit {
748                        token: limit.currency,
749                        limit: parse_session_limit(&limit.limit).unwrap(),
750                        period: 0,
751                    })
752                    .collect(),
753            );
754        }
755        if let Some(scope) = &entry.scope {
756            authorization = authorization.with_allowed_calls(
757                scope
758                    .iter()
759                    .map(|scope| CallScope {
760                        target: scope.target,
761                        selector_rules: scope
762                            .selector_rules
763                            .iter()
764                            .map(|rule| SelectorRule {
765                                selector: rule.selector.into(),
766                                recipients: rule.recipients.clone(),
767                            })
768                            .collect(),
769                    })
770                    .collect(),
771            );
772        }
773        authorization
774    }
775
776    #[test]
777    fn session_registry_is_separate_from_keys_registry() {
778        with_tempo_home(|| {
779            let session_id = B256::from([0x11; 32]);
780            upsert_session_entry(sample_entry(session_id, 100, SessionStatus::Pending)).unwrap();
781
782            let session_path = session_registry_path().unwrap();
783            let keys_path = crate::tempo::tempo_keys_path().unwrap();
784            assert_eq!(session_path.file_name().and_then(|s| s.to_str()), Some("sessions.toml"));
785            assert_eq!(keys_path.file_name().and_then(|s| s.to_str()), Some("keys.toml"));
786            assert_ne!(session_path, keys_path);
787
788            let record = read_session_record().unwrap();
789            assert_eq!(record.sessions.len(), 1);
790            assert_eq!(record.sessions[0].session_id, session_id);
791        });
792    }
793
794    #[test]
795    fn session_registry_upsert_replaces_matching_session_id() {
796        with_tempo_home(|| {
797            let session_id = B256::from([0x22; 32]);
798            upsert_session_entry(sample_entry(session_id, 100, SessionStatus::Pending)).unwrap();
799            upsert_session_entry(sample_entry(session_id, 200, SessionStatus::Active)).unwrap();
800
801            let record = read_session_record().unwrap();
802            assert_eq!(record.sessions.len(), 1);
803            assert_eq!(record.sessions[0].expiry, 200);
804            assert_eq!(record.sessions[0].status, SessionStatus::Active);
805        });
806    }
807
808    #[test]
809    fn session_registry_remove_deletes_entry() {
810        with_tempo_home(|| {
811            let session_id = B256::from([0x33; 32]);
812            upsert_session_entry(sample_entry(session_id, 100, SessionStatus::Active)).unwrap();
813            assert!(remove_session_entry(session_id).unwrap());
814            assert!(read_session_record().unwrap().is_empty());
815        });
816    }
817
818    #[test]
819    fn session_record_marks_expired_live_entries() {
820        let mut record = SessionRecord {
821            sessions: vec![
822                sample_entry(B256::from([0x44; 32]), 10, SessionStatus::Pending),
823                sample_entry(B256::from([0x55; 32]), 10, SessionStatus::Revoked),
824            ],
825        };
826
827        assert_eq!(record.mark_expired(11), 1);
828        assert_eq!(record.sessions[0].status, SessionStatus::Expired);
829        assert_eq!(record.sessions[1].status, SessionStatus::Revoked);
830    }
831
832    #[test]
833    fn session_record_status_updates_clear_cleanup_and_terminal_keys() {
834        let active_id = B256::from([0x67; 32]);
835        let revoking_id = B256::from([0x68; 32]);
836        let revoked_id = B256::from([0x69; 32]);
837        let failed_id = B256::from([0x6a; 32]);
838        let missing_id = B256::from([0x6b; 32]);
839        let mut record = SessionRecord {
840            sessions: vec![
841                sample_entry_with_key(active_id, 200, SessionStatus::Pending),
842                sample_entry_with_key(revoking_id, 200, SessionStatus::Active),
843                sample_entry_with_key(revoked_id, 200, SessionStatus::Revoking),
844                sample_entry_with_key(failed_id, 200, SessionStatus::Pending),
845            ],
846        };
847
848        assert!(record.set_status(active_id, SessionStatus::Active));
849        assert!(record.set_status(revoking_id, SessionStatus::Revoking));
850        assert!(record.set_status(revoked_id, SessionStatus::Revoked));
851        assert!(record.set_status(failed_id, SessionStatus::Failed));
852        assert!(!record.set_status(missing_id, SessionStatus::Active));
853        assert!(!record.set_status(active_id, SessionStatus::Active));
854
855        assert_eq!(record.get(active_id).unwrap().status, SessionStatus::Active);
856        assert!(record.get(active_id).unwrap().key.is_some());
857        assert_eq!(record.get(revoking_id).unwrap().status, SessionStatus::Revoking);
858        assert!(record.get(revoking_id).unwrap().key.is_none());
859        assert_eq!(record.get(revoked_id).unwrap().status, SessionStatus::Revoked);
860        assert!(record.get(revoked_id).unwrap().key.is_none());
861        assert_eq!(record.get(failed_id).unwrap().status, SessionStatus::Failed);
862        assert!(record.get(failed_id).unwrap().key.is_none());
863    }
864
865    #[test]
866    fn session_entry_roundtrips_scope_limits_and_status() {
867        let entry = sample_entry(B256::from([0x66; 32]), 1234, SessionStatus::Revoking);
868        let toml = toml::to_string(&entry).unwrap();
869        let decoded: SessionEntry = toml::from_str(&toml).unwrap();
870
871        assert_eq!(decoded.session_id, entry.session_id);
872        assert_eq!(decoded.scope.as_ref().unwrap().len(), 1);
873        assert_eq!(decoded.limits.as_ref().unwrap().len(), 1);
874        assert_eq!(decoded.status, SessionStatus::Revoking);
875        assert!(decoded.key.is_none());
876        assert!(!decoded.has_inline_key());
877        assert!(decoded.is_expired_at(1234));
878    }
879
880    #[test]
881    fn live_session_key_requires_key_material_live_status_and_unexpired_entry() {
882        let live_id = B256::from([0x01; 32]);
883        let expired_id = B256::from([0x02; 32]);
884        let revoked_id = B256::from([0x03; 32]);
885        let no_key_id = B256::from([0x04; 32]);
886        let pending_id = B256::from([0x05; 32]);
887        let revoking_id = B256::from([0x06; 32]);
888
889        let record = SessionRecord {
890            sessions: vec![
891                sample_entry_with_key(live_id, 200, SessionStatus::Active),
892                sample_entry_with_key(expired_id, 100, SessionStatus::Active),
893                sample_entry_with_key(revoked_id, 200, SessionStatus::Revoked),
894                sample_entry(no_key_id, 200, SessionStatus::Active),
895                sample_entry_with_key(pending_id, 200, SessionStatus::Pending),
896                sample_entry_with_key(revoking_id, 200, SessionStatus::Revoking),
897            ],
898        };
899
900        assert_eq!(record.live_key(live_id, 100).unwrap().session_id, live_id);
901        assert!(record.live_key(expired_id, 100).is_none());
902        assert!(record.live_key(revoked_id, 100).is_none());
903        assert!(record.live_key(no_key_id, 100).is_none());
904        assert!(record.live_key(pending_id, 100).is_none());
905        assert!(record.live_key(revoking_id, 100).is_none());
906    }
907
908    #[test]
909    fn resolve_live_session_signer_returns_signer_and_access_key_config() {
910        with_tempo_home(|| {
911            let session_id = B256::from([0x06; 32]);
912            let entry = sample_entry_with_valid_key(session_id, 200, SessionStatus::Active);
913            upsert_session_entry(entry.clone()).unwrap();
914
915            let resolved = resolve_live_session_signer(session_id, 100).unwrap().unwrap();
916
917            assert_eq!(resolved.session, entry);
918            assert_eq!(Signer::address(&resolved.signer), entry.key_address);
919            assert_eq!(resolved.access_key.wallet_address, entry.root_account);
920            assert_eq!(resolved.access_key.key_address, entry.key_address);
921            assert!(resolved.access_key.key_authorization.is_none());
922        });
923    }
924
925    #[test]
926    fn resolve_live_session_signer_rejects_mismatched_private_key() {
927        with_tempo_home(|| {
928            let session_id = B256::from([0x07; 32]);
929            let mut entry = sample_entry_with_valid_key(session_id, 200, SessionStatus::Active);
930            entry.key_address =
931                Address::from_str("0x0000000000000000000000000000000000000abc").unwrap();
932            upsert_session_entry(entry).unwrap();
933
934            let error = resolve_live_session_signer(session_id, 100).unwrap_err();
935
936            assert!(error.to_string().contains("key material resolves to"));
937        });
938    }
939
940    #[test]
941    fn resolve_live_session_signer_expires_stale_entries_before_resolving() {
942        with_tempo_home(|| {
943            let session_id = B256::from([0x08; 32]);
944            upsert_session_entry(sample_entry_with_valid_key(
945                session_id,
946                100,
947                SessionStatus::Active,
948            ))
949            .unwrap();
950
951            assert!(resolve_live_session_signer(session_id, 100).unwrap().is_none());
952
953            let record = read_session_record().unwrap();
954            let session = record.get(session_id).unwrap();
955            assert_eq!(session.status, SessionStatus::Expired);
956            assert!(session.key.is_none());
957        });
958    }
959
960    #[test]
961    fn resolve_live_session_signer_decodes_and_validates_key_authorization() {
962        with_tempo_home(|| {
963            let session_id = B256::from([0x09; 32]);
964            let mut entry = sample_entry_with_valid_key(session_id, 200, SessionStatus::Active);
965            let auth = signed_key_authorization_hex(&entry);
966            entry.key.as_mut().unwrap().key_authorization = Some(auth);
967            upsert_session_entry(entry.clone()).unwrap();
968
969            let resolved = resolve_live_session_signer(session_id, 100).unwrap().unwrap();
970            let key_authorization = resolved.access_key.key_authorization.unwrap();
971
972            assert_eq!(key_authorization.authorization.key_id, entry.key_address);
973            assert_eq!(key_authorization.authorization.chain_id, entry.chain_id);
974            assert_eq!(key_authorization.authorization.key_type, SignatureType::Secp256k1);
975            assert_eq!(key_authorization.authorization.expiry.unwrap().get(), entry.expiry);
976            assert!(key_authorization.authorization.limits.is_some());
977            assert!(key_authorization.authorization.allowed_calls.is_some());
978            assert_eq!(key_authorization.recover_signer().unwrap(), entry.root_account);
979        });
980    }
981
982    #[test]
983    fn resolve_live_session_signer_accepts_unrestricted_authorization_when_policy_is_omitted() {
984        with_tempo_home(|| {
985            let session_id = B256::from([0x13; 32]);
986            let mut entry = sample_entry_with_valid_key(session_id, 200, SessionStatus::Active);
987            entry.limits = None;
988            entry.scope = None;
989            entry.key.as_mut().unwrap().key_authorization =
990                Some(signed_key_authorization_hex(&entry));
991            upsert_session_entry(entry.clone()).unwrap();
992
993            let resolved = resolve_live_session_signer(session_id, 100).unwrap().unwrap();
994            let key_authorization = resolved.access_key.key_authorization.unwrap();
995
996            assert!(key_authorization.authorization.limits.is_none());
997            assert!(key_authorization.authorization.allowed_calls.is_none());
998        });
999    }
1000
1001    #[test]
1002    fn resolve_live_session_signer_rejects_unrestricted_authorization_when_policy_is_empty() {
1003        with_tempo_home(|| {
1004            let session_id = B256::from([0x14; 32]);
1005            let mut entry = sample_entry_with_valid_key(session_id, 200, SessionStatus::Active);
1006            let mut auth_entry = entry.clone();
1007            auth_entry.limits = None;
1008            auth_entry.scope = None;
1009            entry.limits = Some(vec![]);
1010            entry.scope = Some(vec![]);
1011            entry.key.as_mut().unwrap().key_authorization =
1012                Some(signed_key_authorization_hex(&auth_entry));
1013            upsert_session_entry(entry).unwrap();
1014
1015            let error = resolve_live_session_signer(session_id, 100).unwrap_err();
1016
1017            assert!(error.to_string().contains("limits"));
1018        });
1019    }
1020
1021    #[test]
1022    fn resolve_live_session_signer_rejects_invalid_key_authorization() {
1023        with_tempo_home(|| {
1024            let session_id = B256::from([0x0a; 32]);
1025            let mut entry = sample_entry_with_valid_key(session_id, 200, SessionStatus::Active);
1026            entry.key.as_mut().unwrap().key_authorization = Some("0xdeadbeef".to_string());
1027            upsert_session_entry(entry).unwrap();
1028
1029            let error = resolve_live_session_signer(session_id, 100).unwrap_err();
1030
1031            assert!(error.to_string().contains("key_authorization"));
1032        });
1033    }
1034
1035    #[test]
1036    fn resolve_live_session_signer_rejects_authorization_for_wrong_chain() {
1037        with_tempo_home(|| {
1038            let session_id = B256::from([0x0b; 32]);
1039            let mut entry = sample_entry_with_valid_key(session_id, 200, SessionStatus::Active);
1040            let mut auth_entry = entry.clone();
1041            auth_entry.chain_id += 1;
1042            entry.key.as_mut().unwrap().key_authorization =
1043                Some(signed_key_authorization_hex(&auth_entry));
1044            upsert_session_entry(entry).unwrap();
1045
1046            let error = resolve_live_session_signer(session_id, 100).unwrap_err();
1047
1048            assert!(error.to_string().contains("chain_id"));
1049        });
1050    }
1051
1052    #[test]
1053    fn resolve_live_session_signer_rejects_authorization_without_session_expiry() {
1054        with_tempo_home(|| {
1055            let session_id = B256::from([0x0d; 32]);
1056            let mut entry = sample_entry_with_valid_key(session_id, 200, SessionStatus::Active);
1057            entry.key.as_mut().unwrap().key_authorization =
1058                Some(signed_key_authorization_hex_with(&entry, |mut auth| {
1059                    auth.expiry = None;
1060                    auth
1061                }));
1062            upsert_session_entry(entry).unwrap();
1063
1064            let error = resolve_live_session_signer(session_id, 100).unwrap_err();
1065
1066            assert!(error.to_string().contains("expiry"));
1067        });
1068    }
1069
1070    #[test]
1071    fn resolve_live_session_signer_rejects_authorization_without_session_limits() {
1072        with_tempo_home(|| {
1073            let session_id = B256::from([0x0e; 32]);
1074            let mut entry = sample_entry_with_valid_key(session_id, 200, SessionStatus::Active);
1075            entry.key.as_mut().unwrap().key_authorization =
1076                Some(signed_key_authorization_hex_with(&entry, |mut auth| {
1077                    auth.limits = None;
1078                    auth
1079                }));
1080            upsert_session_entry(entry).unwrap();
1081
1082            let error = resolve_live_session_signer(session_id, 100).unwrap_err();
1083
1084            assert!(error.to_string().contains("limits"));
1085        });
1086    }
1087
1088    #[test]
1089    fn resolve_live_session_signer_rejects_authorization_without_session_scope() {
1090        with_tempo_home(|| {
1091            let session_id = B256::from([0x0f; 32]);
1092            let mut entry = sample_entry_with_valid_key(session_id, 200, SessionStatus::Active);
1093            entry.key.as_mut().unwrap().key_authorization =
1094                Some(signed_key_authorization_hex_with(&entry, |mut auth| {
1095                    auth.allowed_calls = None;
1096                    auth
1097                }));
1098            upsert_session_entry(entry).unwrap();
1099
1100            let error = resolve_live_session_signer(session_id, 100).unwrap_err();
1101
1102            assert!(error.to_string().contains("allowed_calls"));
1103        });
1104    }
1105
1106    #[test]
1107    fn resolve_live_session_signer_rejects_authorization_for_wrong_session_id() {
1108        with_tempo_home(|| {
1109            let session_id = B256::from([0x15; 32]);
1110            let mut entry = sample_entry_with_valid_key(session_id, 200, SessionStatus::Active);
1111            entry.key.as_mut().unwrap().key_authorization =
1112                Some(signed_key_authorization_hex_with(&entry, |auth| {
1113                    auth.with_witness(B256::from([0x16; 32]))
1114                }));
1115            upsert_session_entry(entry).unwrap();
1116
1117            let error = resolve_live_session_signer(session_id, 100).unwrap_err();
1118
1119            assert!(error.to_string().contains("witness"));
1120        });
1121    }
1122
1123    #[test]
1124    fn resolve_live_session_signer_rejects_authorization_with_wider_session_limit() {
1125        with_tempo_home(|| {
1126            let session_id = B256::from([0x10; 32]);
1127            let mut entry = sample_entry_with_valid_key(session_id, 200, SessionStatus::Active);
1128            entry.key.as_mut().unwrap().key_authorization =
1129                Some(signed_key_authorization_hex_with(&entry, |mut auth| {
1130                    auth.limits.as_mut().unwrap()[0].limit = U256::from(1);
1131                    auth
1132                }));
1133            upsert_session_entry(entry).unwrap();
1134
1135            let error = resolve_live_session_signer(session_id, 100).unwrap_err();
1136
1137            assert!(error.to_string().contains("limits"));
1138        });
1139    }
1140
1141    #[test]
1142    fn resolve_live_session_signer_rejects_authorization_with_wider_session_scope() {
1143        with_tempo_home(|| {
1144            let session_id = B256::from([0x12; 32]);
1145            let mut entry = sample_entry_with_valid_key(session_id, 200, SessionStatus::Active);
1146            entry.key.as_mut().unwrap().key_authorization =
1147                Some(signed_key_authorization_hex_with(&entry, |mut auth| {
1148                    auth.allowed_calls.as_mut().unwrap()[0].selector_rules.clear();
1149                    auth
1150                }));
1151            upsert_session_entry(entry).unwrap();
1152
1153            let error = resolve_live_session_signer(session_id, 100).unwrap_err();
1154
1155            assert!(error.to_string().contains("allowed_calls"));
1156        });
1157    }
1158
1159    #[test]
1160    fn resolve_live_session_signer_fails_closed_when_session_file_is_corrupt() {
1161        with_tempo_home(|| {
1162            let path = session_registry_path().unwrap();
1163            fs::create_dir_all(path.parent().unwrap()).unwrap();
1164            fs::write(&path, "sessions = [").unwrap();
1165            let original = fs::read_to_string(&path).unwrap();
1166
1167            assert!(resolve_live_session_signer(B256::from([0x0c; 32]), 100).is_err());
1168            assert_eq!(fs::read_to_string(&path).unwrap(), original);
1169        });
1170    }
1171
1172    #[test]
1173    fn session_key_storage_does_not_replace_persistent_keys_file() {
1174        with_tempo_home(|| {
1175            let keys_path = crate::tempo::tempo_keys_path().unwrap();
1176            fs::create_dir_all(keys_path.parent().unwrap()).unwrap();
1177            let original_keys = r#"[[keys]]
1178wallet_type = "local"
1179wallet_address = "0x0000000000000000000000000000000000000001"
1180chain_id = 4217
1181key_type = "secp256k1"
1182key_address = "0x0000000000000000000000000000000000000001"
1183key = "0x1111"
1184expiry = 999
1185"#;
1186            fs::write(&keys_path, original_keys).unwrap();
1187
1188            let session_id = B256::from([0x99; 32]);
1189            upsert_session_entry(sample_entry_with_key(session_id, 200, SessionStatus::Active))
1190                .unwrap();
1191
1192            assert_eq!(fs::read_to_string(&keys_path).unwrap(), original_keys);
1193            let session = read_live_session_key(session_id, 100).unwrap();
1194            assert_eq!(session.key.unwrap().key, "0xdeadbeef");
1195        });
1196    }
1197
1198    #[test]
1199    fn removing_session_key_preserves_persistent_key() {
1200        with_tempo_home(|| {
1201            let keys_path = crate::tempo::tempo_keys_path().unwrap();
1202            fs::create_dir_all(keys_path.parent().unwrap()).unwrap();
1203            let original_keys = r#"[[keys]]
1204wallet_type = "local"
1205wallet_address = "0x0000000000000000000000000000000000000001"
1206chain_id = 4217
1207key_type = "secp256k1"
1208key_address = "0x0000000000000000000000000000000000000001"
1209key = "0x1111"
1210"#;
1211            fs::write(&keys_path, original_keys).unwrap();
1212
1213            let session_id = B256::from([0xaa; 32]);
1214            upsert_session_entry(sample_entry_with_key(session_id, 200, SessionStatus::Active))
1215                .unwrap();
1216            assert!(remove_session_entry(session_id).unwrap());
1217
1218            assert_eq!(fs::read_to_string(&keys_path).unwrap(), original_keys);
1219            assert!(read_session_record().unwrap().is_empty());
1220        });
1221    }
1222
1223    #[test]
1224    fn mark_expired_session_entries_persists_status_without_touching_keys_file() {
1225        with_tempo_home(|| {
1226            let keys_path = crate::tempo::tempo_keys_path().unwrap();
1227            fs::create_dir_all(keys_path.parent().unwrap()).unwrap();
1228            let original_keys = "[[keys]]\nkey = \"0x1111\"\n";
1229            fs::write(&keys_path, original_keys).unwrap();
1230
1231            let session_id = B256::from([0xbb; 32]);
1232            upsert_session_entry(sample_entry_with_key(session_id, 100, SessionStatus::Active))
1233                .unwrap();
1234
1235            assert_eq!(mark_expired_session_entries(100).unwrap(), 1);
1236            let record = read_session_record().unwrap();
1237            let session = record.get(session_id).unwrap();
1238            assert_eq!(session.status, SessionStatus::Expired);
1239            assert!(session.key.is_none());
1240            assert!(read_live_session_key(session_id, 100).is_none());
1241            assert_eq!(fs::read_to_string(&keys_path).unwrap(), original_keys);
1242        });
1243    }
1244
1245    #[test]
1246    fn mark_expired_session_entries_clears_unusable_session_keys() {
1247        with_tempo_home(|| {
1248            let expired_id = B256::from([0xbc; 32]);
1249            let revoked_id = B256::from([0xbd; 32]);
1250            let failed_id = B256::from([0xbe; 32]);
1251            let revoking_id = B256::from([0xbf; 32]);
1252
1253            upsert_session_entry(sample_entry_with_key(expired_id, 100, SessionStatus::Expired))
1254                .unwrap();
1255            upsert_session_entry(sample_entry_with_key(revoked_id, 200, SessionStatus::Revoked))
1256                .unwrap();
1257            upsert_session_entry(sample_entry_with_key(failed_id, 200, SessionStatus::Failed))
1258                .unwrap();
1259            upsert_session_entry(sample_entry_with_key(revoking_id, 200, SessionStatus::Revoking))
1260                .unwrap();
1261
1262            assert_eq!(mark_expired_session_entries(100).unwrap(), 4);
1263            let record = read_session_record().unwrap();
1264            for session_id in [expired_id, revoked_id, failed_id, revoking_id] {
1265                assert!(record.get(session_id).unwrap().key.is_none());
1266            }
1267            assert_eq!(record.get(expired_id).unwrap().status, SessionStatus::Expired);
1268            assert_eq!(record.get(revoked_id).unwrap().status, SessionStatus::Revoked);
1269            assert_eq!(record.get(failed_id).unwrap().status, SessionStatus::Failed);
1270            assert_eq!(record.get(revoking_id).unwrap().status, SessionStatus::Revoking);
1271        });
1272    }
1273
1274    #[test]
1275    fn update_session_status_persists_lifecycle_state_and_key_cleanup() {
1276        with_tempo_home(|| {
1277            let session_id = B256::from([0xbf; 32]);
1278            upsert_session_entry(sample_entry_with_key(session_id, 200, SessionStatus::Pending))
1279                .unwrap();
1280
1281            assert!(update_session_status(session_id, SessionStatus::Active).unwrap());
1282            let record = read_session_record().unwrap();
1283            let session = record.get(session_id).unwrap();
1284            assert_eq!(session.status, SessionStatus::Active);
1285            assert!(session.key.is_some());
1286
1287            assert!(update_session_status(session_id, SessionStatus::Revoking).unwrap());
1288            let record = read_session_record().unwrap();
1289            let session = record.get(session_id).unwrap();
1290            assert_eq!(session.status, SessionStatus::Revoking);
1291            assert!(session.key.is_none());
1292
1293            assert!(update_session_status(session_id, SessionStatus::Revoked).unwrap());
1294            let record = read_session_record().unwrap();
1295            let session = record.get(session_id).unwrap();
1296            assert_eq!(session.status, SessionStatus::Revoked);
1297            assert!(session.key.is_none());
1298            assert!(read_live_session_key(session_id, 100).is_none());
1299
1300            assert!(!update_session_status(session_id, SessionStatus::Revoked).unwrap());
1301            assert!(!update_session_status(B256::from([0xc0; 32]), SessionStatus::Failed).unwrap());
1302        });
1303    }
1304
1305    #[test]
1306    fn update_session_status_to_failed_clears_key_material() {
1307        with_tempo_home(|| {
1308            let session_id = B256::from([0xc1; 32]);
1309            upsert_session_entry(sample_entry_with_key(session_id, 200, SessionStatus::Active))
1310                .unwrap();
1311
1312            assert!(update_session_status(session_id, SessionStatus::Failed).unwrap());
1313
1314            let record = read_session_record().unwrap();
1315            let session = record.get(session_id).unwrap();
1316            assert_eq!(session.status, SessionStatus::Failed);
1317            assert!(session.key.is_none());
1318        });
1319    }
1320
1321    #[test]
1322    fn update_session_status_if_only_updates_matching_current_status() {
1323        with_tempo_home(|| {
1324            let session_id = B256::from([0xc2; 32]);
1325            upsert_session_entry(sample_entry_with_key(session_id, 200, SessionStatus::Active))
1326                .unwrap();
1327
1328            assert!(
1329                update_session_status_if(
1330                    session_id,
1331                    SessionStatus::Active,
1332                    SessionStatus::Revoking,
1333                )
1334                .unwrap()
1335            );
1336            let record = read_session_record().unwrap();
1337            let session = record.get(session_id).unwrap();
1338            assert_eq!(session.status, SessionStatus::Revoking);
1339            assert!(session.key.is_none());
1340
1341            assert!(!update_session_status_if(
1342                session_id,
1343                SessionStatus::Active,
1344                SessionStatus::Failed,
1345            )
1346            .unwrap());
1347            assert_eq!(
1348                read_session_record().unwrap().get(session_id).unwrap().status,
1349                SessionStatus::Revoking
1350            );
1351        });
1352    }
1353
1354    #[test]
1355    fn upsert_fails_closed_when_session_file_is_corrupt() {
1356        with_tempo_home(|| {
1357            let path = session_registry_path().unwrap();
1358            fs::create_dir_all(path.parent().unwrap()).unwrap();
1359            fs::write(&path, "sessions = [").unwrap();
1360            let original = fs::read_to_string(&path).unwrap();
1361
1362            let session_id = B256::from([0x77; 32]);
1363            let entry = sample_entry(session_id, 100, SessionStatus::Pending);
1364
1365            assert!(read_session_record().is_none());
1366            assert!(upsert_session_entry(entry).is_err());
1367            assert_eq!(fs::read_to_string(&path).unwrap(), original);
1368        });
1369    }
1370
1371    #[test]
1372    fn remove_fails_closed_when_session_file_is_corrupt() {
1373        with_tempo_home(|| {
1374            let path = session_registry_path().unwrap();
1375            fs::create_dir_all(path.parent().unwrap()).unwrap();
1376            fs::write(&path, "sessions = [").unwrap();
1377            let original = fs::read_to_string(&path).unwrap();
1378
1379            assert!(remove_session_entry(B256::from([0x88; 32])).is_err());
1380            assert_eq!(fs::read_to_string(&path).unwrap(), original);
1381        });
1382    }
1383
1384    #[test]
1385    fn mark_expired_fails_closed_when_session_file_is_corrupt() {
1386        with_tempo_home(|| {
1387            let path = session_registry_path().unwrap();
1388            fs::create_dir_all(path.parent().unwrap()).unwrap();
1389            fs::write(&path, "sessions = [").unwrap();
1390            let original = fs::read_to_string(&path).unwrap();
1391
1392            assert!(mark_expired_session_entries(100).is_err());
1393            assert_eq!(fs::read_to_string(&path).unwrap(), original);
1394        });
1395    }
1396
1397    #[test]
1398    fn update_session_status_fails_closed_when_session_file_is_corrupt() {
1399        with_tempo_home(|| {
1400            let path = session_registry_path().unwrap();
1401            fs::create_dir_all(path.parent().unwrap()).unwrap();
1402            fs::write(&path, "sessions = [").unwrap();
1403            let original = fs::read_to_string(&path).unwrap();
1404
1405            assert!(update_session_status(B256::from([0xc2; 32]), SessionStatus::Failed).is_err());
1406            assert_eq!(fs::read_to_string(&path).unwrap(), original);
1407        });
1408    }
1409}