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    // A session uses a limited access key; T6 admin keys must never be used as a session key.
368    ensure!(
369        !auth.is_admin(),
370        "session {} key_authorization is an admin key, expected a limited access key",
371        session.session_id
372    );
373    // A T6 account-bound authorization must target this session's root account (no cross-account
374    // replay).
375    if let Some(account) = auth.account {
376        ensure!(
377            account == session.root_account,
378            "session {} key_authorization is bound to account {}, expected {}",
379            session.session_id,
380            account,
381            session.root_account
382        );
383    }
384    // `session_id` is local metadata; the signed binding lives in the authorization witness.
385    ensure!(
386        auth.witness == Some(session.session_id),
387        "session {} key_authorization witness is {:?}, expected {}",
388        session.session_id,
389        auth.witness,
390        session.session_id
391    );
392    let recovered = authorization
393        .recover_signer()
394        .map_err(|err| eyre::eyre!("failed to recover session key_authorization signer: {err}"))?;
395    ensure!(
396        recovered == session.root_account,
397        "session {} key_authorization signer is {}, expected {}",
398        session.session_id,
399        recovered,
400        session.root_account
401    );
402    validate_session_authorization_policy(session, auth)
403}
404
405/// Ensures authorization expiry, limits, and call scope match the stored session policy.
406fn validate_session_authorization_policy(
407    session: &SessionEntry,
408    auth: &KeyAuthorization,
409) -> eyre::Result<()> {
410    let expected_expiry = NonZeroU64::new(session.expiry)
411        .ok_or_else(|| eyre::eyre!("session {} has invalid zero expiry", session.session_id))?;
412    ensure!(
413        auth.expiry == Some(expected_expiry),
414        "session {} key_authorization expiry is {:?}, expected {}",
415        session.session_id,
416        auth.expiry.map(NonZeroU64::get),
417        session.expiry
418    );
419
420    let expected_limits = session_authorization_limits(session)?;
421    let actual_limits = auth.limits.as_deref().map(authorization_limits);
422    ensure!(
423        actual_limits == expected_limits,
424        "session {} key_authorization limits do not match session limits",
425        session.session_id
426    );
427
428    let expected_scope = session_authorization_scope(session);
429    let actual_scope = auth.allowed_calls.as_deref().map(authorization_scope);
430    ensure!(
431        actual_scope == expected_scope,
432        "session {} key_authorization allowed_calls do not match session scope",
433        session.session_id
434    );
435
436    Ok(())
437}
438
439/// Canonical spending limit used for order-independent policy comparisons.
440#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
441struct CanonicalTokenLimit {
442    token: Address,
443    limit: U256,
444    period: u64,
445}
446
447/// Canonical target scope used for order-independent policy comparisons.
448#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
449struct CanonicalCallScope {
450    target: Address,
451    selector_rules: Vec<CanonicalSelectorRule>,
452}
453
454/// Canonical selector rule used for order-independent policy comparisons.
455#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
456struct CanonicalSelectorRule {
457    selector: [u8; 4],
458    recipients: Vec<Address>,
459}
460
461/// Converts stored session limits into canonical form for authorization comparison.
462fn session_authorization_limits(
463    session: &SessionEntry,
464) -> eyre::Result<Option<Vec<CanonicalTokenLimit>>> {
465    let Some(limits) = session.limits.as_deref() else {
466        return Ok(None);
467    };
468    let mut limits = limits
469        .iter()
470        .map(|limit| {
471            Ok(CanonicalTokenLimit {
472                token: limit.currency,
473                limit: parse_session_limit(&limit.limit)?,
474                period: 0,
475            })
476        })
477        .collect::<eyre::Result<Vec<_>>>()?;
478    limits.sort();
479    Ok(Some(limits))
480}
481
482/// Converts signed authorization limits into canonical form for session comparison.
483fn authorization_limits(limits: &[TokenLimit]) -> Vec<CanonicalTokenLimit> {
484    let mut limits = limits
485        .iter()
486        .map(|limit| CanonicalTokenLimit {
487            token: limit.token,
488            limit: limit.limit,
489            period: limit.period,
490        })
491        .collect::<Vec<_>>();
492    limits.sort();
493    limits
494}
495
496/// Parses a stored session spending limit from decimal or 0x-prefixed hex.
497fn parse_session_limit(raw: &str) -> eyre::Result<U256> {
498    let raw = raw.trim();
499    if let Some(hex) = raw.strip_prefix("0x") { U256::from_str_radix(hex, 16) } else { raw.parse() }
500        .map_err(|err| eyre::eyre!("invalid session spending limit `{raw}`: {err}"))
501}
502
503/// Converts stored session scope into canonical form for authorization comparison.
504fn session_authorization_scope(session: &SessionEntry) -> Option<Vec<CanonicalCallScope>> {
505    let mut scope = session
506        .scope
507        .as_deref()?
508        .iter()
509        .map(|scope| CanonicalCallScope {
510            target: scope.target,
511            selector_rules: session_authorization_selector_rules(&scope.selector_rules),
512        })
513        .collect::<Vec<_>>();
514    scope.sort();
515    Some(scope)
516}
517
518/// Converts signed authorization scope into canonical form for session comparison.
519fn authorization_scope(scope: &[CallScope]) -> Vec<CanonicalCallScope> {
520    let mut scope = scope
521        .iter()
522        .map(|scope| CanonicalCallScope {
523            target: scope.target,
524            selector_rules: authorization_selector_rules(&scope.selector_rules),
525        })
526        .collect::<Vec<_>>();
527    scope.sort();
528    scope
529}
530
531/// Converts stored selector rules into canonical form for authorization comparison.
532fn session_authorization_selector_rules(
533    rules: &[SessionSelectorRule],
534) -> Vec<CanonicalSelectorRule> {
535    let mut rules = rules
536        .iter()
537        .map(|rule| {
538            let mut recipients = rule.recipients.clone();
539            recipients.sort();
540            CanonicalSelectorRule { selector: rule.selector.into(), recipients }
541        })
542        .collect::<Vec<_>>();
543    rules.sort();
544    rules
545}
546
547/// Converts signed authorization selector rules into canonical form for session comparison.
548fn authorization_selector_rules(rules: &[SelectorRule]) -> Vec<CanonicalSelectorRule> {
549    let mut rules = rules
550        .iter()
551        .map(|rule| {
552            let mut recipients = rule.recipients.clone();
553            recipients.sort();
554            CanonicalSelectorRule { selector: rule.selector, recipients }
555        })
556        .collect::<Vec<_>>();
557    rules.sort();
558    rules
559}
560
561/// Maps stored session key types to Tempo authorization signature types.
562const fn key_type_to_signature_type(key_type: KeyType) -> SignatureType {
563    match key_type {
564        KeyType::Secp256k1 => SignatureType::Secp256k1,
565        KeyType::P256 => SignatureType::P256,
566        KeyType::WebAuthn => SignatureType::WebAuthn,
567    }
568}
569
570fn mutate_session_record<R>(f: impl FnOnce(&mut SessionRecord) -> (R, bool)) -> eyre::Result<R> {
571    let path =
572        session_registry_path().ok_or_else(|| eyre::eyre!("could not resolve tempo home"))?;
573    let mut record = read_toml_file::<SessionRecord>(&path, "tempo sessions")?.unwrap_or_default();
574    let (result, changed) = f(&mut record);
575    if changed {
576        write_toml_file_atomic(&path, &record, SESSIONS_HEADER)?;
577    }
578    Ok(result)
579}
580
581/// Atomically upsert a [`SessionEntry`] into the session registry.
582pub fn upsert_session_entry(entry: SessionEntry) -> eyre::Result<()> {
583    mutate_session_record(|record| {
584        record.upsert(entry);
585        ((), true)
586    })
587}
588
589/// Atomically update a session status in the registry.
590///
591/// Cleanup and terminal statuses (`revoking`, `revoked`, `expired`, `failed`) also clear the
592/// session-scoped private key material. Returns `true` when an entry was found and changed.
593pub fn update_session_status(session_id: B256, status: SessionStatus) -> eyre::Result<bool> {
594    mutate_session_record(|record| {
595        let changed = record.set_status(session_id, status);
596        (changed, changed)
597    })
598}
599
600/// Atomically update a session status only when the current status matches `current`.
601///
602/// Returns `true` when an entry was found with the expected current status. The
603/// registry is only rewritten when the matched entry actually changes.
604pub fn update_session_status_if(
605    session_id: B256,
606    current: SessionStatus,
607    status: SessionStatus,
608) -> eyre::Result<bool> {
609    mutate_session_record(|record| {
610        let Some(session) =
611            record.sessions.iter_mut().find(|session| session.session_id == session_id)
612        else {
613            return (false, false);
614        };
615        if session.status != current {
616            return (false, false);
617        }
618
619        let changed = set_session_status(session, status);
620        (true, changed)
621    })
622}
623
624/// Atomically remove a session from the registry.
625pub fn remove_session_entry(session_id: B256) -> eyre::Result<bool> {
626    mutate_session_record(|record| {
627        let removed = record.remove(session_id);
628        (removed, removed)
629    })
630}
631
632/// Mark expired live sessions in the registry and persist the status updates.
633pub fn mark_expired_session_entries(now: u64) -> eyre::Result<usize> {
634    mutate_session_record(|record| {
635        let updated = record.mark_expired(now);
636        (updated, updated != 0)
637    })
638}
639
640#[cfg(test)]
641mod tests {
642    use super::*;
643    use crate::tempo::with_tempo_home;
644    use alloy_primitives::hex;
645    use alloy_rlp::Encodable;
646    use alloy_signer::SignerSync;
647    use alloy_signer_local::PrivateKeySigner;
648    use std::{fs, str::FromStr};
649    use tempo_primitives::transaction::PrimitiveSignature;
650
651    const ROOT_PRIVATE_KEY: &str =
652        "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
653    const SESSION_PRIVATE_KEY: &str =
654        "0x59c6995e998f97a5a004497e5da3b5d2b2b66a87f064d39c44da0b6d6e4f8ff0";
655
656    #[test]
657    fn debug_redacts_session_key_material() {
658        // Distinctive sentinels so a leak can't accidentally pass.
659        let mut entry = sample_entry_with_key(B256::from([0x77; 32]), 200, SessionStatus::Active);
660        let key = entry.key.as_mut().unwrap();
661        key.key = "0xPRIVATE_KEY_MUST_NOT_LEAK".to_string();
662        key.key_authorization = Some("0xKEY_AUTH_MUST_NOT_LEAK".to_string());
663
664        let entry_dbg = format!("{entry:?}");
665        let record_dbg = format!("{:?}", SessionRecord { sessions: vec![entry] });
666
667        for rendered in [&entry_dbg, &record_dbg] {
668            assert!(!rendered.contains("PRIVATE_KEY_MUST_NOT_LEAK"), "key leaked in: {rendered}");
669            assert!(!rendered.contains("KEY_AUTH_MUST_NOT_LEAK"), "auth leaked in: {rendered}");
670        }
671        assert!(entry_dbg.contains("key: \"<redacted>\""), "got: {entry_dbg}");
672        assert!(entry_dbg.contains("key_authorization: Some(\"<redacted>\")"), "got: {entry_dbg}");
673        // Non-secret metadata is still visible for diagnostics.
674        assert!(entry_dbg.contains("key_type"));
675    }
676
677    fn sample_entry(session_id: B256, expiry: u64, status: SessionStatus) -> SessionEntry {
678        SessionEntry {
679            session_id,
680            root_account: Address::from_str("0x0000000000000000000000000000000000000001").unwrap(),
681            chain_id: 4217,
682            key_address: Address::from_str("0x0000000000000000000000000000000000000abc").unwrap(),
683            expiry,
684            scope: Some(vec![SessionCallScope {
685                target: Address::from_str("0x00000000000000000000000000000000000000aa").unwrap(),
686                selector_rules: vec![SessionSelectorRule {
687                    selector: Selector::from_slice(&[0x12, 0x34, 0x56, 0x78]),
688                    recipients: vec![],
689                }],
690            }]),
691            limits: Some(vec![SessionTokenLimit {
692                currency: Address::from_str("0x00000000000000000000000000000000000000ff").unwrap(),
693                limit: "0".to_string(),
694            }]),
695            status,
696            key: None,
697        }
698    }
699
700    fn sample_entry_with_key(session_id: B256, expiry: u64, status: SessionStatus) -> SessionEntry {
701        SessionEntry {
702            key: Some(SessionKeyMaterial {
703                key_type: KeyType::Secp256k1,
704                key: "0xdeadbeef".to_string(),
705                key_authorization: Some("0xfeed".to_string()),
706            }),
707            ..sample_entry(session_id, expiry, status)
708        }
709    }
710
711    /// Builds a session entry with matching root and session key material.
712    fn sample_entry_with_valid_key(
713        session_id: B256,
714        expiry: u64,
715        status: SessionStatus,
716    ) -> SessionEntry {
717        let root_signer: PrivateKeySigner = ROOT_PRIVATE_KEY.parse().unwrap();
718        let signer = foundry_wallets::utils::create_private_key_signer(SESSION_PRIVATE_KEY)
719            .expect("valid test private key");
720        SessionEntry {
721            root_account: root_signer.address(),
722            key_address: signer.address(),
723            key: Some(SessionKeyMaterial {
724                key_type: KeyType::Secp256k1,
725                key: SESSION_PRIVATE_KEY.to_string(),
726                key_authorization: None,
727            }),
728            ..sample_entry(session_id, expiry, status)
729        }
730    }
731
732    /// Encodes a signed key authorization that matches the supplied session entry.
733    fn signed_key_authorization_hex(entry: &SessionEntry) -> String {
734        signed_key_authorization_hex_with(entry, std::convert::identity)
735    }
736
737    /// Encodes a signed key authorization after applying a test-specific mutation.
738    fn signed_key_authorization_hex_with(
739        entry: &SessionEntry,
740        update: impl FnOnce(KeyAuthorization) -> KeyAuthorization,
741    ) -> String {
742        let root_signer: PrivateKeySigner = ROOT_PRIVATE_KEY.parse().unwrap();
743        let auth = update(session_key_authorization(entry));
744        let signature = root_signer.sign_hash_sync(&auth.signature_hash()).unwrap();
745        let signed = auth.into_signed(PrimitiveSignature::Secp256k1(signature));
746        let mut buf = Vec::new();
747        signed.encode(&mut buf);
748        hex::encode_prefixed(buf)
749    }
750
751    /// Builds a key authorization that mirrors the session entry policy.
752    fn session_key_authorization(entry: &SessionEntry) -> KeyAuthorization {
753        let mut authorization = KeyAuthorization::unrestricted(
754            entry.chain_id,
755            SignatureType::Secp256k1,
756            entry.key_address,
757        )
758        .with_expiry(entry.expiry)
759        .with_witness(entry.session_id);
760        if let Some(limits) = &entry.limits {
761            authorization = authorization.with_limits(
762                limits
763                    .iter()
764                    .map(|limit| TokenLimit {
765                        token: limit.currency,
766                        limit: parse_session_limit(&limit.limit).unwrap(),
767                        period: 0,
768                    })
769                    .collect(),
770            );
771        }
772        if let Some(scope) = &entry.scope {
773            authorization = authorization.with_allowed_calls(
774                scope
775                    .iter()
776                    .map(|scope| CallScope {
777                        target: scope.target,
778                        selector_rules: scope
779                            .selector_rules
780                            .iter()
781                            .map(|rule| SelectorRule {
782                                selector: rule.selector.into(),
783                                recipients: rule.recipients.clone(),
784                            })
785                            .collect(),
786                    })
787                    .collect(),
788            );
789        }
790        authorization
791    }
792
793    #[test]
794    fn session_registry_is_separate_from_keys_registry() {
795        with_tempo_home(|| {
796            let session_id = B256::from([0x11; 32]);
797            upsert_session_entry(sample_entry(session_id, 100, SessionStatus::Pending)).unwrap();
798
799            let session_path = session_registry_path().unwrap();
800            let keys_path = crate::tempo::tempo_keys_path().unwrap();
801            assert_eq!(session_path.file_name().and_then(|s| s.to_str()), Some("sessions.toml"));
802            assert_eq!(keys_path.file_name().and_then(|s| s.to_str()), Some("keys.toml"));
803            assert_ne!(session_path, keys_path);
804
805            let record = read_session_record().unwrap();
806            assert_eq!(record.sessions.len(), 1);
807            assert_eq!(record.sessions[0].session_id, session_id);
808        });
809    }
810
811    #[test]
812    fn session_registry_upsert_replaces_matching_session_id() {
813        with_tempo_home(|| {
814            let session_id = B256::from([0x22; 32]);
815            upsert_session_entry(sample_entry(session_id, 100, SessionStatus::Pending)).unwrap();
816            upsert_session_entry(sample_entry(session_id, 200, SessionStatus::Active)).unwrap();
817
818            let record = read_session_record().unwrap();
819            assert_eq!(record.sessions.len(), 1);
820            assert_eq!(record.sessions[0].expiry, 200);
821            assert_eq!(record.sessions[0].status, SessionStatus::Active);
822        });
823    }
824
825    #[test]
826    fn session_registry_remove_deletes_entry() {
827        with_tempo_home(|| {
828            let session_id = B256::from([0x33; 32]);
829            upsert_session_entry(sample_entry(session_id, 100, SessionStatus::Active)).unwrap();
830            assert!(remove_session_entry(session_id).unwrap());
831            assert!(read_session_record().unwrap().is_empty());
832        });
833    }
834
835    #[test]
836    fn session_record_marks_expired_live_entries() {
837        let mut record = SessionRecord {
838            sessions: vec![
839                sample_entry(B256::from([0x44; 32]), 10, SessionStatus::Pending),
840                sample_entry(B256::from([0x55; 32]), 10, SessionStatus::Revoked),
841            ],
842        };
843
844        assert_eq!(record.mark_expired(11), 1);
845        assert_eq!(record.sessions[0].status, SessionStatus::Expired);
846        assert_eq!(record.sessions[1].status, SessionStatus::Revoked);
847    }
848
849    #[test]
850    fn session_record_status_updates_clear_cleanup_and_terminal_keys() {
851        let active_id = B256::from([0x67; 32]);
852        let revoking_id = B256::from([0x68; 32]);
853        let revoked_id = B256::from([0x69; 32]);
854        let failed_id = B256::from([0x6a; 32]);
855        let missing_id = B256::from([0x6b; 32]);
856        let mut record = SessionRecord {
857            sessions: vec![
858                sample_entry_with_key(active_id, 200, SessionStatus::Pending),
859                sample_entry_with_key(revoking_id, 200, SessionStatus::Active),
860                sample_entry_with_key(revoked_id, 200, SessionStatus::Revoking),
861                sample_entry_with_key(failed_id, 200, SessionStatus::Pending),
862            ],
863        };
864
865        assert!(record.set_status(active_id, SessionStatus::Active));
866        assert!(record.set_status(revoking_id, SessionStatus::Revoking));
867        assert!(record.set_status(revoked_id, SessionStatus::Revoked));
868        assert!(record.set_status(failed_id, SessionStatus::Failed));
869        assert!(!record.set_status(missing_id, SessionStatus::Active));
870        assert!(!record.set_status(active_id, SessionStatus::Active));
871
872        assert_eq!(record.get(active_id).unwrap().status, SessionStatus::Active);
873        assert!(record.get(active_id).unwrap().key.is_some());
874        assert_eq!(record.get(revoking_id).unwrap().status, SessionStatus::Revoking);
875        assert!(record.get(revoking_id).unwrap().key.is_none());
876        assert_eq!(record.get(revoked_id).unwrap().status, SessionStatus::Revoked);
877        assert!(record.get(revoked_id).unwrap().key.is_none());
878        assert_eq!(record.get(failed_id).unwrap().status, SessionStatus::Failed);
879        assert!(record.get(failed_id).unwrap().key.is_none());
880    }
881
882    #[test]
883    fn session_entry_roundtrips_scope_limits_and_status() {
884        let entry = sample_entry(B256::from([0x66; 32]), 1234, SessionStatus::Revoking);
885        let toml = toml::to_string(&entry).unwrap();
886        let decoded: SessionEntry = toml::from_str(&toml).unwrap();
887
888        assert_eq!(decoded.session_id, entry.session_id);
889        assert_eq!(decoded.scope.as_ref().unwrap().len(), 1);
890        assert_eq!(decoded.limits.as_ref().unwrap().len(), 1);
891        assert_eq!(decoded.status, SessionStatus::Revoking);
892        assert!(decoded.key.is_none());
893        assert!(!decoded.has_inline_key());
894        assert!(decoded.is_expired_at(1234));
895    }
896
897    #[test]
898    fn live_session_key_requires_key_material_live_status_and_unexpired_entry() {
899        let live_id = B256::from([0x01; 32]);
900        let expired_id = B256::from([0x02; 32]);
901        let revoked_id = B256::from([0x03; 32]);
902        let no_key_id = B256::from([0x04; 32]);
903        let pending_id = B256::from([0x05; 32]);
904        let revoking_id = B256::from([0x06; 32]);
905
906        let record = SessionRecord {
907            sessions: vec![
908                sample_entry_with_key(live_id, 200, SessionStatus::Active),
909                sample_entry_with_key(expired_id, 100, SessionStatus::Active),
910                sample_entry_with_key(revoked_id, 200, SessionStatus::Revoked),
911                sample_entry(no_key_id, 200, SessionStatus::Active),
912                sample_entry_with_key(pending_id, 200, SessionStatus::Pending),
913                sample_entry_with_key(revoking_id, 200, SessionStatus::Revoking),
914            ],
915        };
916
917        assert_eq!(record.live_key(live_id, 100).unwrap().session_id, live_id);
918        assert!(record.live_key(expired_id, 100).is_none());
919        assert!(record.live_key(revoked_id, 100).is_none());
920        assert!(record.live_key(no_key_id, 100).is_none());
921        assert!(record.live_key(pending_id, 100).is_none());
922        assert!(record.live_key(revoking_id, 100).is_none());
923    }
924
925    #[test]
926    fn resolve_live_session_signer_returns_signer_and_access_key_config() {
927        with_tempo_home(|| {
928            let session_id = B256::from([0x06; 32]);
929            let entry = sample_entry_with_valid_key(session_id, 200, SessionStatus::Active);
930            upsert_session_entry(entry.clone()).unwrap();
931
932            let resolved = resolve_live_session_signer(session_id, 100).unwrap().unwrap();
933
934            assert_eq!(resolved.session, entry);
935            assert_eq!(Signer::address(&resolved.signer), entry.key_address);
936            assert_eq!(resolved.access_key.wallet_address, entry.root_account);
937            assert_eq!(resolved.access_key.key_address, entry.key_address);
938            assert!(resolved.access_key.key_authorization.is_none());
939        });
940    }
941
942    #[test]
943    fn resolve_live_session_signer_rejects_mismatched_private_key() {
944        with_tempo_home(|| {
945            let session_id = B256::from([0x07; 32]);
946            let mut entry = sample_entry_with_valid_key(session_id, 200, SessionStatus::Active);
947            entry.key_address =
948                Address::from_str("0x0000000000000000000000000000000000000abc").unwrap();
949            upsert_session_entry(entry).unwrap();
950
951            let error = resolve_live_session_signer(session_id, 100).unwrap_err();
952
953            assert!(error.to_string().contains("key material resolves to"));
954        });
955    }
956
957    #[test]
958    fn resolve_live_session_signer_expires_stale_entries_before_resolving() {
959        with_tempo_home(|| {
960            let session_id = B256::from([0x08; 32]);
961            upsert_session_entry(sample_entry_with_valid_key(
962                session_id,
963                100,
964                SessionStatus::Active,
965            ))
966            .unwrap();
967
968            assert!(resolve_live_session_signer(session_id, 100).unwrap().is_none());
969
970            let record = read_session_record().unwrap();
971            let session = record.get(session_id).unwrap();
972            assert_eq!(session.status, SessionStatus::Expired);
973            assert!(session.key.is_none());
974        });
975    }
976
977    #[test]
978    fn resolve_live_session_signer_decodes_and_validates_key_authorization() {
979        with_tempo_home(|| {
980            let session_id = B256::from([0x09; 32]);
981            let mut entry = sample_entry_with_valid_key(session_id, 200, SessionStatus::Active);
982            let auth = signed_key_authorization_hex(&entry);
983            entry.key.as_mut().unwrap().key_authorization = Some(auth);
984            upsert_session_entry(entry.clone()).unwrap();
985
986            let resolved = resolve_live_session_signer(session_id, 100).unwrap().unwrap();
987            let key_authorization = resolved.access_key.key_authorization.unwrap();
988
989            assert_eq!(key_authorization.authorization.key_id, entry.key_address);
990            assert_eq!(key_authorization.authorization.chain_id, entry.chain_id);
991            assert_eq!(key_authorization.authorization.key_type, SignatureType::Secp256k1);
992            assert_eq!(key_authorization.authorization.expiry.unwrap().get(), entry.expiry);
993            assert!(key_authorization.authorization.limits.is_some());
994            assert!(key_authorization.authorization.allowed_calls.is_some());
995            assert_eq!(key_authorization.recover_signer().unwrap(), entry.root_account);
996        });
997    }
998
999    #[test]
1000    fn resolve_live_session_signer_accepts_unrestricted_authorization_when_policy_is_omitted() {
1001        with_tempo_home(|| {
1002            let session_id = B256::from([0x13; 32]);
1003            let mut entry = sample_entry_with_valid_key(session_id, 200, SessionStatus::Active);
1004            entry.limits = None;
1005            entry.scope = None;
1006            entry.key.as_mut().unwrap().key_authorization =
1007                Some(signed_key_authorization_hex(&entry));
1008            upsert_session_entry(entry.clone()).unwrap();
1009
1010            let resolved = resolve_live_session_signer(session_id, 100).unwrap().unwrap();
1011            let key_authorization = resolved.access_key.key_authorization.unwrap();
1012
1013            assert!(key_authorization.authorization.limits.is_none());
1014            assert!(key_authorization.authorization.allowed_calls.is_none());
1015        });
1016    }
1017
1018    #[test]
1019    fn resolve_live_session_signer_rejects_unrestricted_authorization_when_policy_is_empty() {
1020        with_tempo_home(|| {
1021            let session_id = B256::from([0x14; 32]);
1022            let mut entry = sample_entry_with_valid_key(session_id, 200, SessionStatus::Active);
1023            let mut auth_entry = entry.clone();
1024            auth_entry.limits = None;
1025            auth_entry.scope = None;
1026            entry.limits = Some(vec![]);
1027            entry.scope = Some(vec![]);
1028            entry.key.as_mut().unwrap().key_authorization =
1029                Some(signed_key_authorization_hex(&auth_entry));
1030            upsert_session_entry(entry).unwrap();
1031
1032            let error = resolve_live_session_signer(session_id, 100).unwrap_err();
1033
1034            assert!(error.to_string().contains("limits"));
1035        });
1036    }
1037
1038    #[test]
1039    fn resolve_live_session_signer_rejects_invalid_key_authorization() {
1040        with_tempo_home(|| {
1041            let session_id = B256::from([0x0a; 32]);
1042            let mut entry = sample_entry_with_valid_key(session_id, 200, SessionStatus::Active);
1043            entry.key.as_mut().unwrap().key_authorization = Some("0xdeadbeef".to_string());
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("key_authorization"));
1049        });
1050    }
1051
1052    #[test]
1053    fn resolve_live_session_signer_rejects_authorization_for_wrong_chain() {
1054        with_tempo_home(|| {
1055            let session_id = B256::from([0x0b; 32]);
1056            let mut entry = sample_entry_with_valid_key(session_id, 200, SessionStatus::Active);
1057            let mut auth_entry = entry.clone();
1058            auth_entry.chain_id += 1;
1059            entry.key.as_mut().unwrap().key_authorization =
1060                Some(signed_key_authorization_hex(&auth_entry));
1061            upsert_session_entry(entry).unwrap();
1062
1063            let error = resolve_live_session_signer(session_id, 100).unwrap_err();
1064
1065            assert!(error.to_string().contains("chain_id"));
1066        });
1067    }
1068
1069    #[test]
1070    fn resolve_live_session_signer_rejects_authorization_without_session_expiry() {
1071        with_tempo_home(|| {
1072            let session_id = B256::from([0x0d; 32]);
1073            let mut entry = sample_entry_with_valid_key(session_id, 200, SessionStatus::Active);
1074            entry.key.as_mut().unwrap().key_authorization =
1075                Some(signed_key_authorization_hex_with(&entry, |mut auth| {
1076                    auth.expiry = None;
1077                    auth
1078                }));
1079            upsert_session_entry(entry).unwrap();
1080
1081            let error = resolve_live_session_signer(session_id, 100).unwrap_err();
1082
1083            assert!(error.to_string().contains("expiry"));
1084        });
1085    }
1086
1087    #[test]
1088    fn resolve_live_session_signer_rejects_authorization_without_session_limits() {
1089        with_tempo_home(|| {
1090            let session_id = B256::from([0x0e; 32]);
1091            let mut entry = sample_entry_with_valid_key(session_id, 200, SessionStatus::Active);
1092            entry.key.as_mut().unwrap().key_authorization =
1093                Some(signed_key_authorization_hex_with(&entry, |mut auth| {
1094                    auth.limits = None;
1095                    auth
1096                }));
1097            upsert_session_entry(entry).unwrap();
1098
1099            let error = resolve_live_session_signer(session_id, 100).unwrap_err();
1100
1101            assert!(error.to_string().contains("limits"));
1102        });
1103    }
1104
1105    #[test]
1106    fn resolve_live_session_signer_rejects_authorization_without_session_scope() {
1107        with_tempo_home(|| {
1108            let session_id = B256::from([0x0f; 32]);
1109            let mut entry = sample_entry_with_valid_key(session_id, 200, SessionStatus::Active);
1110            entry.key.as_mut().unwrap().key_authorization =
1111                Some(signed_key_authorization_hex_with(&entry, |mut auth| {
1112                    auth.allowed_calls = None;
1113                    auth
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("allowed_calls"));
1120        });
1121    }
1122
1123    #[test]
1124    fn resolve_live_session_signer_rejects_authorization_for_wrong_session_id() {
1125        with_tempo_home(|| {
1126            let session_id = B256::from([0x15; 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, |auth| {
1130                    auth.with_witness(B256::from([0x16; 32]))
1131                }));
1132            upsert_session_entry(entry).unwrap();
1133
1134            let error = resolve_live_session_signer(session_id, 100).unwrap_err();
1135
1136            assert!(error.to_string().contains("witness"));
1137        });
1138    }
1139
1140    #[test]
1141    fn resolve_rejects_admin_key_authorization() {
1142        with_tempo_home(|| {
1143            let session_id = B256::from([0x17; 32]);
1144            let mut entry = sample_entry_with_valid_key(session_id, 200, SessionStatus::Active);
1145            // A session must use a limited access key, never a T6 admin key.
1146            entry.key.as_mut().unwrap().key_authorization =
1147                Some(signed_key_authorization_hex_with(&entry, |mut auth| {
1148                    auth.is_admin = true;
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("admin key"), "got: {error}");
1156        });
1157    }
1158
1159    #[test]
1160    fn resolve_rejects_account_bound_to_other_account() {
1161        with_tempo_home(|| {
1162            let session_id = B256::from([0x18; 32]);
1163            let mut entry = sample_entry_with_valid_key(session_id, 200, SessionStatus::Active);
1164            // An account-bound authorization minted for another account must not be replayable.
1165            entry.key.as_mut().unwrap().key_authorization =
1166                Some(signed_key_authorization_hex_with(&entry, |auth| {
1167                    auth.with_account(
1168                        Address::from_str("0x000000000000000000000000000000000000dead").unwrap(),
1169                    )
1170                }));
1171            upsert_session_entry(entry).unwrap();
1172
1173            let error = resolve_live_session_signer(session_id, 100).unwrap_err();
1174
1175            assert!(error.to_string().contains("bound to account"), "got: {error}");
1176        });
1177    }
1178
1179    #[test]
1180    fn resolve_accepts_account_bound_to_root() {
1181        with_tempo_home(|| {
1182            let session_id = B256::from([0x19; 32]);
1183            let mut entry = sample_entry_with_valid_key(session_id, 200, SessionStatus::Active);
1184            let root_account = entry.root_account;
1185            // An account binding that targets the session root is valid (backward compatible).
1186            entry.key.as_mut().unwrap().key_authorization =
1187                Some(signed_key_authorization_hex_with(&entry, |auth| {
1188                    auth.with_account(root_account)
1189                }));
1190            upsert_session_entry(entry).unwrap();
1191
1192            assert!(resolve_live_session_signer(session_id, 100).is_ok());
1193        });
1194    }
1195
1196    #[test]
1197    fn resolve_live_session_signer_rejects_authorization_with_wider_session_limit() {
1198        with_tempo_home(|| {
1199            let session_id = B256::from([0x10; 32]);
1200            let mut entry = sample_entry_with_valid_key(session_id, 200, SessionStatus::Active);
1201            entry.key.as_mut().unwrap().key_authorization =
1202                Some(signed_key_authorization_hex_with(&entry, |mut auth| {
1203                    auth.limits.as_mut().unwrap()[0].limit = U256::from(1);
1204                    auth
1205                }));
1206            upsert_session_entry(entry).unwrap();
1207
1208            let error = resolve_live_session_signer(session_id, 100).unwrap_err();
1209
1210            assert!(error.to_string().contains("limits"));
1211        });
1212    }
1213
1214    #[test]
1215    fn resolve_live_session_signer_rejects_authorization_with_wider_session_scope() {
1216        with_tempo_home(|| {
1217            let session_id = B256::from([0x12; 32]);
1218            let mut entry = sample_entry_with_valid_key(session_id, 200, SessionStatus::Active);
1219            entry.key.as_mut().unwrap().key_authorization =
1220                Some(signed_key_authorization_hex_with(&entry, |mut auth| {
1221                    auth.allowed_calls.as_mut().unwrap()[0].selector_rules.clear();
1222                    auth
1223                }));
1224            upsert_session_entry(entry).unwrap();
1225
1226            let error = resolve_live_session_signer(session_id, 100).unwrap_err();
1227
1228            assert!(error.to_string().contains("allowed_calls"));
1229        });
1230    }
1231
1232    #[test]
1233    fn resolve_live_session_signer_fails_closed_when_session_file_is_corrupt() {
1234        with_tempo_home(|| {
1235            let path = session_registry_path().unwrap();
1236            fs::create_dir_all(path.parent().unwrap()).unwrap();
1237            fs::write(&path, "sessions = [").unwrap();
1238            let original = fs::read_to_string(&path).unwrap();
1239
1240            assert!(resolve_live_session_signer(B256::from([0x0c; 32]), 100).is_err());
1241            assert_eq!(fs::read_to_string(&path).unwrap(), original);
1242        });
1243    }
1244
1245    #[test]
1246    fn session_key_storage_does_not_replace_persistent_keys_file() {
1247        with_tempo_home(|| {
1248            let keys_path = crate::tempo::tempo_keys_path().unwrap();
1249            fs::create_dir_all(keys_path.parent().unwrap()).unwrap();
1250            let original_keys = r#"[[keys]]
1251wallet_type = "local"
1252wallet_address = "0x0000000000000000000000000000000000000001"
1253chain_id = 4217
1254key_type = "secp256k1"
1255key_address = "0x0000000000000000000000000000000000000001"
1256key = "0x1111"
1257expiry = 999
1258"#;
1259            fs::write(&keys_path, original_keys).unwrap();
1260
1261            let session_id = B256::from([0x99; 32]);
1262            upsert_session_entry(sample_entry_with_key(session_id, 200, SessionStatus::Active))
1263                .unwrap();
1264
1265            assert_eq!(fs::read_to_string(&keys_path).unwrap(), original_keys);
1266            let session = read_live_session_key(session_id, 100).unwrap();
1267            assert_eq!(session.key.unwrap().key, "0xdeadbeef");
1268        });
1269    }
1270
1271    #[test]
1272    fn removing_session_key_preserves_persistent_key() {
1273        with_tempo_home(|| {
1274            let keys_path = crate::tempo::tempo_keys_path().unwrap();
1275            fs::create_dir_all(keys_path.parent().unwrap()).unwrap();
1276            let original_keys = r#"[[keys]]
1277wallet_type = "local"
1278wallet_address = "0x0000000000000000000000000000000000000001"
1279chain_id = 4217
1280key_type = "secp256k1"
1281key_address = "0x0000000000000000000000000000000000000001"
1282key = "0x1111"
1283"#;
1284            fs::write(&keys_path, original_keys).unwrap();
1285
1286            let session_id = B256::from([0xaa; 32]);
1287            upsert_session_entry(sample_entry_with_key(session_id, 200, SessionStatus::Active))
1288                .unwrap();
1289            assert!(remove_session_entry(session_id).unwrap());
1290
1291            assert_eq!(fs::read_to_string(&keys_path).unwrap(), original_keys);
1292            assert!(read_session_record().unwrap().is_empty());
1293        });
1294    }
1295
1296    #[test]
1297    fn mark_expired_session_entries_persists_status_without_touching_keys_file() {
1298        with_tempo_home(|| {
1299            let keys_path = crate::tempo::tempo_keys_path().unwrap();
1300            fs::create_dir_all(keys_path.parent().unwrap()).unwrap();
1301            let original_keys = "[[keys]]\nkey = \"0x1111\"\n";
1302            fs::write(&keys_path, original_keys).unwrap();
1303
1304            let session_id = B256::from([0xbb; 32]);
1305            upsert_session_entry(sample_entry_with_key(session_id, 100, SessionStatus::Active))
1306                .unwrap();
1307
1308            assert_eq!(mark_expired_session_entries(100).unwrap(), 1);
1309            let record = read_session_record().unwrap();
1310            let session = record.get(session_id).unwrap();
1311            assert_eq!(session.status, SessionStatus::Expired);
1312            assert!(session.key.is_none());
1313            assert!(read_live_session_key(session_id, 100).is_none());
1314            assert_eq!(fs::read_to_string(&keys_path).unwrap(), original_keys);
1315        });
1316    }
1317
1318    #[test]
1319    fn mark_expired_session_entries_clears_unusable_session_keys() {
1320        with_tempo_home(|| {
1321            let expired_id = B256::from([0xbc; 32]);
1322            let revoked_id = B256::from([0xbd; 32]);
1323            let failed_id = B256::from([0xbe; 32]);
1324            let revoking_id = B256::from([0xbf; 32]);
1325
1326            upsert_session_entry(sample_entry_with_key(expired_id, 100, SessionStatus::Expired))
1327                .unwrap();
1328            upsert_session_entry(sample_entry_with_key(revoked_id, 200, SessionStatus::Revoked))
1329                .unwrap();
1330            upsert_session_entry(sample_entry_with_key(failed_id, 200, SessionStatus::Failed))
1331                .unwrap();
1332            upsert_session_entry(sample_entry_with_key(revoking_id, 200, SessionStatus::Revoking))
1333                .unwrap();
1334
1335            assert_eq!(mark_expired_session_entries(100).unwrap(), 4);
1336            let record = read_session_record().unwrap();
1337            for session_id in [expired_id, revoked_id, failed_id, revoking_id] {
1338                assert!(record.get(session_id).unwrap().key.is_none());
1339            }
1340            assert_eq!(record.get(expired_id).unwrap().status, SessionStatus::Expired);
1341            assert_eq!(record.get(revoked_id).unwrap().status, SessionStatus::Revoked);
1342            assert_eq!(record.get(failed_id).unwrap().status, SessionStatus::Failed);
1343            assert_eq!(record.get(revoking_id).unwrap().status, SessionStatus::Revoking);
1344        });
1345    }
1346
1347    #[test]
1348    fn update_session_status_persists_lifecycle_state_and_key_cleanup() {
1349        with_tempo_home(|| {
1350            let session_id = B256::from([0xbf; 32]);
1351            upsert_session_entry(sample_entry_with_key(session_id, 200, SessionStatus::Pending))
1352                .unwrap();
1353
1354            assert!(update_session_status(session_id, SessionStatus::Active).unwrap());
1355            let record = read_session_record().unwrap();
1356            let session = record.get(session_id).unwrap();
1357            assert_eq!(session.status, SessionStatus::Active);
1358            assert!(session.key.is_some());
1359
1360            assert!(update_session_status(session_id, SessionStatus::Revoking).unwrap());
1361            let record = read_session_record().unwrap();
1362            let session = record.get(session_id).unwrap();
1363            assert_eq!(session.status, SessionStatus::Revoking);
1364            assert!(session.key.is_none());
1365
1366            assert!(update_session_status(session_id, SessionStatus::Revoked).unwrap());
1367            let record = read_session_record().unwrap();
1368            let session = record.get(session_id).unwrap();
1369            assert_eq!(session.status, SessionStatus::Revoked);
1370            assert!(session.key.is_none());
1371            assert!(read_live_session_key(session_id, 100).is_none());
1372
1373            assert!(!update_session_status(session_id, SessionStatus::Revoked).unwrap());
1374            assert!(!update_session_status(B256::from([0xc0; 32]), SessionStatus::Failed).unwrap());
1375        });
1376    }
1377
1378    #[test]
1379    fn update_session_status_to_failed_clears_key_material() {
1380        with_tempo_home(|| {
1381            let session_id = B256::from([0xc1; 32]);
1382            upsert_session_entry(sample_entry_with_key(session_id, 200, SessionStatus::Active))
1383                .unwrap();
1384
1385            assert!(update_session_status(session_id, SessionStatus::Failed).unwrap());
1386
1387            let record = read_session_record().unwrap();
1388            let session = record.get(session_id).unwrap();
1389            assert_eq!(session.status, SessionStatus::Failed);
1390            assert!(session.key.is_none());
1391        });
1392    }
1393
1394    #[test]
1395    fn update_session_status_if_only_updates_matching_current_status() {
1396        with_tempo_home(|| {
1397            let session_id = B256::from([0xc2; 32]);
1398            upsert_session_entry(sample_entry_with_key(session_id, 200, SessionStatus::Active))
1399                .unwrap();
1400
1401            assert!(
1402                update_session_status_if(
1403                    session_id,
1404                    SessionStatus::Active,
1405                    SessionStatus::Revoking,
1406                )
1407                .unwrap()
1408            );
1409            let record = read_session_record().unwrap();
1410            let session = record.get(session_id).unwrap();
1411            assert_eq!(session.status, SessionStatus::Revoking);
1412            assert!(session.key.is_none());
1413
1414            assert!(!update_session_status_if(
1415                session_id,
1416                SessionStatus::Active,
1417                SessionStatus::Failed,
1418            )
1419            .unwrap());
1420            assert_eq!(
1421                read_session_record().unwrap().get(session_id).unwrap().status,
1422                SessionStatus::Revoking
1423            );
1424        });
1425    }
1426
1427    #[test]
1428    fn upsert_fails_closed_when_session_file_is_corrupt() {
1429        with_tempo_home(|| {
1430            let path = session_registry_path().unwrap();
1431            fs::create_dir_all(path.parent().unwrap()).unwrap();
1432            fs::write(&path, "sessions = [").unwrap();
1433            let original = fs::read_to_string(&path).unwrap();
1434
1435            let session_id = B256::from([0x77; 32]);
1436            let entry = sample_entry(session_id, 100, SessionStatus::Pending);
1437
1438            assert!(read_session_record().is_none());
1439            assert!(upsert_session_entry(entry).is_err());
1440            assert_eq!(fs::read_to_string(&path).unwrap(), original);
1441        });
1442    }
1443
1444    #[test]
1445    fn remove_fails_closed_when_session_file_is_corrupt() {
1446        with_tempo_home(|| {
1447            let path = session_registry_path().unwrap();
1448            fs::create_dir_all(path.parent().unwrap()).unwrap();
1449            fs::write(&path, "sessions = [").unwrap();
1450            let original = fs::read_to_string(&path).unwrap();
1451
1452            assert!(remove_session_entry(B256::from([0x88; 32])).is_err());
1453            assert_eq!(fs::read_to_string(&path).unwrap(), original);
1454        });
1455    }
1456
1457    #[test]
1458    fn mark_expired_fails_closed_when_session_file_is_corrupt() {
1459        with_tempo_home(|| {
1460            let path = session_registry_path().unwrap();
1461            fs::create_dir_all(path.parent().unwrap()).unwrap();
1462            fs::write(&path, "sessions = [").unwrap();
1463            let original = fs::read_to_string(&path).unwrap();
1464
1465            assert!(mark_expired_session_entries(100).is_err());
1466            assert_eq!(fs::read_to_string(&path).unwrap(), original);
1467        });
1468    }
1469
1470    #[test]
1471    fn update_session_status_fails_closed_when_session_file_is_corrupt() {
1472        with_tempo_home(|| {
1473            let path = session_registry_path().unwrap();
1474            fs::create_dir_all(path.parent().unwrap()).unwrap();
1475            fs::write(&path, "sessions = [").unwrap();
1476            let original = fs::read_to_string(&path).unwrap();
1477
1478            assert!(update_session_status(B256::from([0xc2; 32]), SessionStatus::Failed).is_err());
1479            assert_eq!(fs::read_to_string(&path).unwrap(), original);
1480        });
1481    }
1482}