Skip to content

Commit bbdc514

Browse files
authored
feat(common): isolate Tempo session key storage (#14878)
* feat: isolate Tempo session key storage * fix: clear expired Tempo session keys * fix: clear terminal Tempo session keys
1 parent 74d2944 commit bbdc514

1 file changed

Lines changed: 230 additions & 3 deletions

File tree

crates/common/src/tempo/session.rs

Lines changed: 230 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
//! Tempo session registry and local lifecycle metadata.
22
3-
use super::{registry::*, tempo_home};
3+
use super::{KeyType, registry::*, tempo_home};
44
use alloy_primitives::{Address, B256, Selector};
55
use serde::{Deserialize, Serialize};
66
use 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)]
4872
pub 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

83111
impl 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.
150212
pub 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)]
172249
mod 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]]\nkey = \"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

Comments
 (0)