11//! Tempo session registry and local lifecycle metadata.
22
3- use super :: { registry:: * , tempo_home} ;
3+ use super :: { KeyType , registry:: * , tempo_home} ;
44use alloy_primitives:: { Address , B256 , Selector } ;
55use serde:: { Deserialize , Serialize } ;
66use std:: path:: PathBuf ;
@@ -43,6 +43,30 @@ pub struct SessionTokenLimit {
4343 pub limit : String ,
4444}
4545
46+ /// Private key material for a temporary session access key.
47+ ///
48+ /// Session keys live with their lifecycle record in `wallet/sessions.toml`.
49+ /// Persistent Tempo wallet login keys remain in `wallet/keys.toml`, so creating
50+ /// or cleaning up a session cannot replace a user's long-lived access key.
51+ #[ derive( Debug , Clone , PartialEq , Eq , Deserialize , Serialize ) ]
52+ pub struct SessionKeyMaterial {
53+ #[ serde( default ) ]
54+ pub key_type : KeyType ,
55+ /// Hex-encoded private key for the temporary session access key.
56+ pub key : String ,
57+ /// RLP-encoded signed key authorization, if the key still needs inline
58+ /// provisioning on first use.
59+ #[ serde( default , skip_serializing_if = "Option::is_none" ) ]
60+ pub key_authorization : Option < String > ,
61+ }
62+
63+ impl SessionKeyMaterial {
64+ /// Returns `true` when the entry carries a non-empty private key.
65+ pub fn has_inline_key ( & self ) -> bool {
66+ !self . key . trim ( ) . is_empty ( )
67+ }
68+ }
69+
4670/// A single selector rule in a session scope.
4771#[ derive( Debug , Clone , PartialEq , Eq , Deserialize , Serialize ) ]
4872pub struct SessionSelectorRule {
@@ -78,13 +102,27 @@ pub struct SessionEntry {
78102 pub limits : Vec < SessionTokenLimit > ,
79103 #[ serde( default ) ]
80104 pub status : SessionStatus ,
105+ /// Session-scoped key material. This is intentionally separate from
106+ /// `wallet/keys.toml`, which stores persistent access keys.
107+ #[ serde( default , skip_serializing_if = "Option::is_none" ) ]
108+ pub key : Option < SessionKeyMaterial > ,
81109}
82110
83111impl SessionEntry {
84112 /// Returns `true` if the session has passed its expiry timestamp.
85113 pub const fn is_expired_at ( & self , now : u64 ) -> bool {
86114 now >= self . expiry
87115 }
116+
117+ /// Returns `true` if this session has usable local key material.
118+ pub fn has_inline_key ( & self ) -> bool {
119+ self . key . as_ref ( ) . is_some_and ( SessionKeyMaterial :: has_inline_key)
120+ }
121+
122+ /// Returns `true` if this session is active, unexpired, and has key material.
123+ pub fn has_live_key_at ( & self , now : u64 ) -> bool {
124+ self . status == SessionStatus :: Active && !self . is_expired_at ( now) && self . has_inline_key ( )
125+ }
88126}
89127
90128/// Top-level registry persisted in `wallet/sessions.toml`.
@@ -113,12 +151,31 @@ impl SessionRecord {
113151 self . sessions . len ( ) != before
114152 }
115153
154+ /// Returns a session by id.
155+ pub fn get ( & self , session_id : B256 ) -> Option < & SessionEntry > {
156+ self . sessions . iter ( ) . find ( |session| session. session_id == session_id)
157+ }
158+
159+ /// Returns an active session with usable local key material by id.
160+ pub fn live_key ( & self , session_id : B256 , now : u64 ) -> Option < & SessionEntry > {
161+ self . get ( session_id) . filter ( |session| session. has_live_key_at ( now) )
162+ }
163+
116164 /// Mark expired live entries as expired. Returns the number updated.
117165 pub fn mark_expired ( & mut self , now : u64 ) -> usize {
118166 let mut updated = 0 ;
119167 for session in & mut self . sessions {
120- if session. status . is_live ( ) && session. is_expired_at ( now) {
168+ let should_expire = session. status . is_live ( ) && session. is_expired_at ( now) ;
169+ let should_clear_key =
170+ session. key . is_some ( ) && ( should_expire || session. status . is_terminal ( ) ) ;
171+
172+ if should_expire {
121173 session. status = SessionStatus :: Expired ;
174+ }
175+ if should_clear_key {
176+ session. key = None ;
177+ }
178+ if should_expire || should_clear_key {
122179 updated += 1 ;
123180 }
124181 }
@@ -146,6 +203,11 @@ pub fn read_session_record() -> Option<SessionRecord> {
146203 }
147204}
148205
206+ /// Read a live session-scoped key entry by session id.
207+ pub fn read_live_session_key ( session_id : B256 , now : u64 ) -> Option < SessionEntry > {
208+ read_session_record ( ) ?. live_key ( session_id, now) . cloned ( )
209+ }
210+
149211/// Atomically upsert a [`SessionEntry`] into the session registry.
150212pub fn upsert_session_entry ( entry : SessionEntry ) -> eyre:: Result < ( ) > {
151213 let path =
@@ -168,6 +230,21 @@ pub fn remove_session_entry(session_id: B256) -> eyre::Result<bool> {
168230 Ok ( removed)
169231}
170232
233+ /// Mark expired live sessions in the registry and persist the status updates.
234+ pub fn mark_expired_session_entries ( now : u64 ) -> eyre:: Result < usize > {
235+ let path =
236+ session_registry_path ( ) . ok_or_else ( || eyre:: eyre!( "could not resolve tempo home" ) ) ?;
237+ let Some ( mut record) = read_toml_file :: < SessionRecord > ( & path, "tempo sessions" ) ? else {
238+ return Ok ( 0 ) ;
239+ } ;
240+
241+ let updated = record. mark_expired ( now) ;
242+ if updated != 0 {
243+ write_toml_file_atomic ( & path, & record, SESSIONS_HEADER ) ?;
244+ }
245+ Ok ( updated)
246+ }
247+
171248#[ cfg( test) ]
172249mod tests {
173250 use super :: * ;
@@ -193,6 +270,18 @@ mod tests {
193270 limit: "0" . to_string( ) ,
194271 } ] ,
195272 status,
273+ key : None ,
274+ }
275+ }
276+
277+ fn sample_entry_with_key ( session_id : B256 , expiry : u64 , status : SessionStatus ) -> SessionEntry {
278+ SessionEntry {
279+ key : Some ( SessionKeyMaterial {
280+ key_type : KeyType :: Secp256k1 ,
281+ key : "0xdeadbeef" . to_string ( ) ,
282+ key_authorization : Some ( "0xfeed" . to_string ( ) ) ,
283+ } ) ,
284+ ..sample_entry ( session_id, expiry, status)
196285 }
197286 }
198287
@@ -254,17 +343,142 @@ mod tests {
254343
255344 #[ test]
256345 fn session_entry_roundtrips_scope_limits_and_status ( ) {
257- let entry = sample_entry ( B256 :: from ( [ 0x66 ; 32 ] ) , 1234 , SessionStatus :: Revoking ) ;
346+ let entry = sample_entry_with_key ( B256 :: from ( [ 0x66 ; 32 ] ) , 1234 , SessionStatus :: Revoking ) ;
258347 let toml = toml:: to_string ( & entry) . unwrap ( ) ;
259348 let decoded: SessionEntry = toml:: from_str ( & toml) . unwrap ( ) ;
260349
261350 assert_eq ! ( decoded. session_id, entry. session_id) ;
262351 assert_eq ! ( decoded. scope. len( ) , 1 ) ;
263352 assert_eq ! ( decoded. limits. len( ) , 1 ) ;
264353 assert_eq ! ( decoded. status, SessionStatus :: Revoking ) ;
354+ assert_eq ! ( decoded. key. as_ref( ) . unwrap( ) . key, "0xdeadbeef" ) ;
355+ assert ! ( decoded. has_inline_key( ) ) ;
265356 assert ! ( decoded. is_expired_at( 1234 ) ) ;
266357 }
267358
359+ #[ test]
360+ fn live_session_key_requires_key_material_live_status_and_unexpired_entry ( ) {
361+ let live_id = B256 :: from ( [ 0x01 ; 32 ] ) ;
362+ let expired_id = B256 :: from ( [ 0x02 ; 32 ] ) ;
363+ let revoked_id = B256 :: from ( [ 0x03 ; 32 ] ) ;
364+ let no_key_id = B256 :: from ( [ 0x04 ; 32 ] ) ;
365+ let pending_id = B256 :: from ( [ 0x05 ; 32 ] ) ;
366+
367+ let record = SessionRecord {
368+ sessions : vec ! [
369+ sample_entry_with_key( live_id, 200 , SessionStatus :: Active ) ,
370+ sample_entry_with_key( expired_id, 100 , SessionStatus :: Active ) ,
371+ sample_entry_with_key( revoked_id, 200 , SessionStatus :: Revoked ) ,
372+ sample_entry( no_key_id, 200 , SessionStatus :: Active ) ,
373+ sample_entry_with_key( pending_id, 200 , SessionStatus :: Pending ) ,
374+ ] ,
375+ } ;
376+
377+ assert_eq ! ( record. live_key( live_id, 100 ) . unwrap( ) . session_id, live_id) ;
378+ assert ! ( record. live_key( expired_id, 100 ) . is_none( ) ) ;
379+ assert ! ( record. live_key( revoked_id, 100 ) . is_none( ) ) ;
380+ assert ! ( record. live_key( no_key_id, 100 ) . is_none( ) ) ;
381+ assert ! ( record. live_key( pending_id, 100 ) . is_none( ) ) ;
382+ }
383+
384+ #[ test]
385+ fn session_key_storage_does_not_replace_persistent_keys_file ( ) {
386+ with_tempo_home ( || {
387+ let keys_path = crate :: tempo:: tempo_keys_path ( ) . unwrap ( ) ;
388+ fs:: create_dir_all ( keys_path. parent ( ) . unwrap ( ) ) . unwrap ( ) ;
389+ let original_keys = r#"[[keys]]
390+ wallet_type = "local"
391+ wallet_address = "0x0000000000000000000000000000000000000001"
392+ chain_id = 4217
393+ key_type = "secp256k1"
394+ key_address = "0x0000000000000000000000000000000000000001"
395+ key = "0x1111"
396+ expiry = 999
397+ "# ;
398+ fs:: write ( & keys_path, original_keys) . unwrap ( ) ;
399+
400+ let session_id = B256 :: from ( [ 0x99 ; 32 ] ) ;
401+ upsert_session_entry ( sample_entry_with_key ( session_id, 200 , SessionStatus :: Active ) )
402+ . unwrap ( ) ;
403+
404+ assert_eq ! ( fs:: read_to_string( & keys_path) . unwrap( ) , original_keys) ;
405+ let session = read_live_session_key ( session_id, 100 ) . unwrap ( ) ;
406+ assert_eq ! ( session. key. unwrap( ) . key, "0xdeadbeef" ) ;
407+ } ) ;
408+ }
409+
410+ #[ test]
411+ fn removing_session_key_preserves_persistent_key ( ) {
412+ with_tempo_home ( || {
413+ let keys_path = crate :: tempo:: tempo_keys_path ( ) . unwrap ( ) ;
414+ fs:: create_dir_all ( keys_path. parent ( ) . unwrap ( ) ) . unwrap ( ) ;
415+ let original_keys = r#"[[keys]]
416+ wallet_type = "local"
417+ wallet_address = "0x0000000000000000000000000000000000000001"
418+ chain_id = 4217
419+ key_type = "secp256k1"
420+ key_address = "0x0000000000000000000000000000000000000001"
421+ key = "0x1111"
422+ "# ;
423+ fs:: write ( & keys_path, original_keys) . unwrap ( ) ;
424+
425+ let session_id = B256 :: from ( [ 0xaa ; 32 ] ) ;
426+ upsert_session_entry ( sample_entry_with_key ( session_id, 200 , SessionStatus :: Active ) )
427+ . unwrap ( ) ;
428+ assert ! ( remove_session_entry( session_id) . unwrap( ) ) ;
429+
430+ assert_eq ! ( fs:: read_to_string( & keys_path) . unwrap( ) , original_keys) ;
431+ assert ! ( read_session_record( ) . unwrap( ) . is_empty( ) ) ;
432+ } ) ;
433+ }
434+
435+ #[ test]
436+ fn mark_expired_session_entries_persists_status_without_touching_keys_file ( ) {
437+ with_tempo_home ( || {
438+ let keys_path = crate :: tempo:: tempo_keys_path ( ) . unwrap ( ) ;
439+ fs:: create_dir_all ( keys_path. parent ( ) . unwrap ( ) ) . unwrap ( ) ;
440+ let original_keys = "[[keys]]\n key = \" 0x1111\" \n " ;
441+ fs:: write ( & keys_path, original_keys) . unwrap ( ) ;
442+
443+ let session_id = B256 :: from ( [ 0xbb ; 32 ] ) ;
444+ upsert_session_entry ( sample_entry_with_key ( session_id, 100 , SessionStatus :: Active ) )
445+ . unwrap ( ) ;
446+
447+ assert_eq ! ( mark_expired_session_entries( 100 ) . unwrap( ) , 1 ) ;
448+ let record = read_session_record ( ) . unwrap ( ) ;
449+ let session = record. get ( session_id) . unwrap ( ) ;
450+ assert_eq ! ( session. status, SessionStatus :: Expired ) ;
451+ assert ! ( session. key. is_none( ) ) ;
452+ assert ! ( read_live_session_key( session_id, 100 ) . is_none( ) ) ;
453+ assert_eq ! ( fs:: read_to_string( & keys_path) . unwrap( ) , original_keys) ;
454+ } ) ;
455+ }
456+
457+ #[ test]
458+ fn mark_expired_session_entries_clears_terminal_session_keys ( ) {
459+ with_tempo_home ( || {
460+ let expired_id = B256 :: from ( [ 0xbc ; 32 ] ) ;
461+ let revoked_id = B256 :: from ( [ 0xbd ; 32 ] ) ;
462+ let failed_id = B256 :: from ( [ 0xbe ; 32 ] ) ;
463+
464+ upsert_session_entry ( sample_entry_with_key ( expired_id, 100 , SessionStatus :: Expired ) )
465+ . unwrap ( ) ;
466+ upsert_session_entry ( sample_entry_with_key ( revoked_id, 200 , SessionStatus :: Revoked ) )
467+ . unwrap ( ) ;
468+ upsert_session_entry ( sample_entry_with_key ( failed_id, 200 , SessionStatus :: Failed ) )
469+ . unwrap ( ) ;
470+
471+ assert_eq ! ( mark_expired_session_entries( 100 ) . unwrap( ) , 3 ) ;
472+ let record = read_session_record ( ) . unwrap ( ) ;
473+ for session_id in [ expired_id, revoked_id, failed_id] {
474+ assert ! ( record. get( session_id) . unwrap( ) . key. is_none( ) ) ;
475+ }
476+ assert_eq ! ( record. get( expired_id) . unwrap( ) . status, SessionStatus :: Expired ) ;
477+ assert_eq ! ( record. get( revoked_id) . unwrap( ) . status, SessionStatus :: Revoked ) ;
478+ assert_eq ! ( record. get( failed_id) . unwrap( ) . status, SessionStatus :: Failed ) ;
479+ } ) ;
480+ }
481+
268482 #[ test]
269483 fn upsert_fails_closed_when_session_file_is_corrupt ( ) {
270484 with_tempo_home ( || {
@@ -294,4 +508,17 @@ mod tests {
294508 assert_eq ! ( fs:: read_to_string( & path) . unwrap( ) , original) ;
295509 } ) ;
296510 }
511+
512+ #[ test]
513+ fn mark_expired_fails_closed_when_session_file_is_corrupt ( ) {
514+ with_tempo_home ( || {
515+ let path = session_registry_path ( ) . unwrap ( ) ;
516+ fs:: create_dir_all ( path. parent ( ) . unwrap ( ) ) . unwrap ( ) ;
517+ fs:: write ( & path, "sessions = [" ) . unwrap ( ) ;
518+ let original = fs:: read_to_string ( & path) . unwrap ( ) ;
519+
520+ assert ! ( mark_expired_session_entries( 100 ) . is_err( ) ) ;
521+ assert_eq ! ( fs:: read_to_string( & path) . unwrap( ) , original) ;
522+ } ) ;
523+ }
297524}
0 commit comments