Skip to main content

vti_common/auth/
session.rs

1use crate::error::AppError;
2use crate::store::KeyspaceHandle;
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use std::time::{SystemTime, UNIX_EPOCH};
6use tracing::debug;
7
8/// Session lifecycle state.
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
11pub enum SessionState {
12    ChallengeSent,
13    Authenticated,
14}
15
16/// A session record stored in fjall under `session:{session_id}`.
17///
18/// `Debug` is hand-written below to redact the `refresh_token`. The raw
19/// derive would render it inline — any `tracing::debug!("{session:?}")`,
20/// panic backtrace, or `dbg!()` call holding a `Session` would otherwise
21/// exfiltrate a bearer-equivalent secret to logs.
22#[derive(Clone, Serialize, Deserialize)]
23pub struct Session {
24    pub session_id: String,
25    pub did: String,
26    pub challenge: String,
27    pub state: SessionState,
28    pub created_at: u64,
29    /// Wall-clock epoch seconds of the most recent authenticated request on
30    /// this session. Intrinsic-sender (DIDComm/TSP) sessions carry no refresh
31    /// token, so this drives their idle-TTL expiry in
32    /// [`cleanup_expired_sessions`]. REST sessions set it too but are bounded
33    /// by `refresh_expires_at`. `#[serde(default)]` so rows written before this
34    /// field existed deserialise with `0`; the sweeper falls back to
35    /// `created_at` in that case.
36    #[serde(default)]
37    pub last_seen: u64,
38    pub refresh_token: Option<String>,
39    pub refresh_expires_at: Option<u64>,
40    /// Whether the **challenge issued for this session** was accompanied
41    /// by a successful TEE attestation. Distinct from "this VTA was built
42    /// with the TEE feature": a TEE binary running in `TeeMode::Optional`
43    /// can serve unattested challenges when the provider errors out, and
44    /// the resulting JWT must reflect that.
45    ///
46    /// `#[serde(default)]` so older session records (written before this
47    /// field existed) deserialize as `false` — the conservative default.
48    #[serde(default)]
49    pub tee_attested: bool,
50    /// AAL claims persisted across token rotation. Mirrors the JWT's
51    /// `amr` / `acr` so [`/auth/refresh`] mints a new access token at
52    /// the same authentication-method-references and assurance level
53    /// the session was last issued at. Without this, a session that
54    /// was step-upped to `aal2` would be silently dropped back to
55    /// `aal1` on every 15-minute refresh.
56    ///
57    /// `#[serde(default)]` on both: a session row written before this
58    /// field landed deserialises with empty vectors / empty string,
59    /// which the refresh handler treats as "unknown AAL — fall back
60    /// to `aal1`". Same behaviour as pre-migration; the holder can
61    /// re-step-up if needed.
62    #[serde(default)]
63    pub amr: Vec<String>,
64    #[serde(default)]
65    pub acr: String,
66    /// Epoch-seconds deadline after which a step-up elevation lapses. Set when
67    /// a step-up ceremony elevates the session; `None` for a session that was
68    /// never stepped up.
69    ///
70    /// This — not `acr` — is what "a second factor was confirmed **just now**"
71    /// means. `acr` records the level the session *reached* and stays there for
72    /// its whole life (a passkey sign-in is `aal2` from its first request, and
73    /// refresh preserves it), so it cannot express freshness on its own.
74    ///
75    /// Read by both transports, in the shape each needs:
76    /// - **Intrinsic-sender (DIDComm/TSP)** — [`resolve_did_session`] reads
77    ///   `acr` off this row on every message, so it rewrites the row via
78    ///   [`Session::downgrade_lapsed_elevation`] once the window closes.
79    /// - **REST** — [`StepUpAuth`](crate::auth::extractor::StepUpAuth) reads the
80    ///   deadline directly via [`Session::elevation_active`], so a stale `acr`
81    ///   on the row can never satisfy the gate and there is nothing to rewrite.
82    ///   `acr` is left alone, which keeps a passkey login honestly reported as
83    ///   `aal2` rather than being downgraded below the level it logged in at.
84    ///
85    /// `#[serde(default)]` for back-compat with pre-existing rows — which
86    /// deserialise as "never stepped up", the fail-closed reading.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub acr_expires_at: Option<u64>,
89    /// JWT `jti` rotation pin. Set per-token-issue so old JWTs are
90    /// immediately invalidated when a new token is minted for the
91    /// same session — the `AuthClaims` extractor compares the JWT's
92    /// `jti` against this field and rejects mismatches.
93    ///
94    /// Optional because not every consumer uses per-token-issue
95    /// rotation; the canonical extractor checks this only when
96    /// `Some(_)`. `#[skip_serializing_if = "Option::is_none"]`
97    /// keeps the field out of the serialised form when unused so
98    /// existing storage rows do not gain a `token_id: null` column.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub token_id: Option<String>,
101    /// Ephemeral session pubkey for Data Integrity proof binding
102    /// (`eddsa-jcs-2022`). Ed25519 multikey, base58btc with the
103    /// `z` prefix (e.g. `z6MkfBwQrx…`). The corresponding
104    /// `did:key:<this>` is the verificationMethod the holder uses
105    /// when signing trust-task envelopes for this session.
106    ///
107    /// `None` for clients that did not register a session pubkey;
108    /// REQUIRED-spec dispatch then rejects proofless envelopes per
109    /// the trust-task framework's IS_PROOF_REQUIRED gate.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub session_pubkey_b58btc: Option<String>,
112}
113
114impl Session {
115    /// Whether a step-up elevation is **currently live** on this session.
116    ///
117    /// True only when [`acr_expires_at`](Self::acr_expires_at) names a deadline
118    /// that has not yet passed. An absent deadline is *not* a live elevation —
119    /// it means this session was never stepped up (a passkey *login*, for
120    /// instance, is `aal2` from its first request and carries no window). Gates
121    /// that need "a second factor was confirmed **for this operation, just
122    /// now**" must consult this rather than `acr`, which stays elevated for the
123    /// whole session and therefore cannot express freshness.
124    ///
125    /// Fails closed: an unknown elevation time never reads as a recent one.
126    pub fn elevation_active(&self, now: u64) -> bool {
127        self.acr_expires_at.is_some_and(|deadline| now < deadline)
128    }
129
130    /// Drop a **lapsed** step-up elevation back to the un-elevated baseline,
131    /// reporting whether anything changed.
132    ///
133    /// Used by the intrinsic-sender resolver ([`resolve_did_session`]), where
134    /// `acr` is read straight off this row on every message and so must be
135    /// rewritten once the window closes. REST callers do not need this: their
136    /// gate ([`StepUpAuth`](crate::auth::extractor::StepUpAuth)) reads the
137    /// deadline itself, so a stale `acr` on the row can never satisfy it.
138    ///
139    /// The baseline is the single `did` factor at `aal1` — the level every
140    /// intrinsic-sender session starts at in [`resolve_did_session`].
141    pub fn downgrade_lapsed_elevation(&mut self, now: u64) -> bool {
142        match self.acr_expires_at {
143            Some(deadline) if now >= deadline => {
144                self.acr = "aal1".to_string();
145                self.acr_expires_at = None;
146                self.amr = vec!["did".to_string()];
147                true
148            }
149            _ => false,
150        }
151    }
152}
153
154impl std::fmt::Debug for Session {
155    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        f.debug_struct("Session")
157            .field("session_id", &self.session_id)
158            .field("did", &self.did)
159            .field("challenge", &"<redacted>")
160            .field("state", &self.state)
161            .field("created_at", &self.created_at)
162            .field("last_seen", &self.last_seen)
163            .field(
164                "refresh_token",
165                &self.refresh_token.as_ref().map(|_| "<redacted>"),
166            )
167            .field("refresh_expires_at", &self.refresh_expires_at)
168            .field("tee_attested", &self.tee_attested)
169            .field("amr", &self.amr)
170            .field("acr", &self.acr)
171            .field("acr_expires_at", &self.acr_expires_at)
172            .field("token_id", &self.token_id.as_ref().map(|_| "<redacted>"))
173            .field("session_pubkey_b58btc", &self.session_pubkey_b58btc)
174            .finish()
175    }
176}
177
178fn session_key(session_id: &str) -> String {
179    format!("session:{session_id}")
180}
181
182/// Key the refresh-token reverse-index by SHA-256 of the token rather
183/// than the token itself. An attacker with raw read access to the
184/// sessions keyspace (storage dump, vsock proxy compromise) sees only
185/// hashes, not live tokens. The lookup path hashes the presented token
186/// before probing the store.
187///
188/// Hash length (32 bytes → 64 hex chars) is fine for collision
189/// resistance; UUIDv4 refresh tokens have 122 bits of entropy, so
190/// pre-image resistance is what we rely on here, not second-preimage.
191fn refresh_key(token: &str) -> String {
192    let digest = Sha256::digest(token.as_bytes());
193    format!("refresh:{}", hex_lower(&digest))
194}
195
196fn hex_lower(bytes: &[u8]) -> String {
197    const TABLE: &[u8; 16] = b"0123456789abcdef";
198    let mut out = String::with_capacity(bytes.len() * 2);
199    for &b in bytes {
200        out.push(TABLE[(b >> 4) as usize] as char);
201        out.push(TABLE[(b & 0x0f) as usize] as char);
202    }
203    out
204}
205
206/// Store a new session in the `sessions` keyspace.
207pub async fn store_session(sessions: &KeyspaceHandle, session: &Session) -> Result<(), AppError> {
208    sessions
209        .insert(session_key(&session.session_id), session)
210        .await?;
211    debug!(session_id = %session.session_id, did = %session.did, "session stored");
212    Ok(())
213}
214
215/// Load a session by session_id.
216pub async fn get_session(
217    sessions: &KeyspaceHandle,
218    session_id: &str,
219) -> Result<Option<Session>, AppError> {
220    sessions.get(session_key(session_id)).await
221}
222
223/// Update an existing session (overwrites).
224pub async fn update_session(sessions: &KeyspaceHandle, session: &Session) -> Result<(), AppError> {
225    sessions
226        .insert(session_key(&session.session_id), session)
227        .await
228}
229
230/// Idle lifetime for an intrinsic-sender (DIDComm/TSP) session. Such a session
231/// carries no refresh token, so it is reaped this many seconds after its last
232/// authenticated request rather than at a refresh-token deadline. REST sessions
233/// are bounded by `refresh_expires_at` and ignore this.
234pub const INTRINSIC_SESSION_IDLE_TTL_SECS: u64 = 86_400; // 24h
235
236/// Resolve the canonical session for an intrinsic-sender (DIDComm/TSP) caller,
237/// creating it on first sight. Keyed on the authenticated `did` so the same
238/// identity resolves **one** persistent session across messages and transports.
239/// That persistence is what lets a step-up elevation performed while handling
240/// one message be observed by the caller's subsequent messages — the whole
241/// point of a transport-agnostic session.
242///
243/// Semantics:
244/// - **Absent** → create an `Authenticated`, `aal1` session (single `did`
245///   factor), stamped `created_at = last_seen = now`, no refresh token.
246/// - **Present** → bump `last_seen`; if a step-up elevation has lapsed
247///   (`acr_expires_at` now in the past) downgrade `acr` back to `aal1` and drop
248///   the elevated factors, so the caller must re-step-up.
249///
250/// Returns the session as the caller should be seen *now* (post-downgrade) and
251/// persists any mutation. The returned `acr`/`amr` are what the AAL-gating
252/// handlers must trust — not a hardcoded `aal1`.
253pub async fn resolve_did_session(
254    sessions: &KeyspaceHandle,
255    did: &str,
256    now: u64,
257) -> Result<Session, AppError> {
258    if let Some(mut session) = get_session(sessions, did).await? {
259        session.last_seen = now;
260        session.downgrade_lapsed_elevation(now);
261        update_session(sessions, &session).await?;
262        Ok(session)
263    } else {
264        let session = Session {
265            session_id: did.to_string(),
266            did: did.to_string(),
267            challenge: String::new(),
268            state: SessionState::Authenticated,
269            created_at: now,
270            last_seen: now,
271            refresh_token: None,
272            refresh_expires_at: None,
273            tee_attested: false,
274            amr: vec!["did".to_string()],
275            acr: "aal1".to_string(),
276            acr_expires_at: None,
277            token_id: None,
278            session_pubkey_b58btc: None,
279        };
280        store_session(sessions, &session).await?;
281        Ok(session)
282    }
283}
284
285/// Store a reverse index from refresh token to session_id.
286pub async fn store_refresh_index(
287    sessions: &KeyspaceHandle,
288    token: &str,
289    session_id: &str,
290) -> Result<(), AppError> {
291    sessions
292        .insert_raw(refresh_key(token), session_id.as_bytes().to_vec())
293        .await
294}
295
296/// Look up a session_id by refresh token.
297pub async fn get_session_by_refresh(
298    sessions: &KeyspaceHandle,
299    token: &str,
300) -> Result<Option<String>, AppError> {
301    match sessions.get_raw(refresh_key(token)).await? {
302        Some(bytes) => {
303            let session_id = String::from_utf8(bytes)
304                .map_err(|e| AppError::Internal(format!("invalid session_id bytes: {e}")))?;
305            Ok(Some(session_id))
306        }
307        None => Ok(None),
308    }
309}
310
311/// Delete a refresh-token reverse index entry. Used by the rotation
312/// path on `/auth/refresh` so a presented refresh token works exactly
313/// once — replay returns "refresh token not found", same as a stolen-
314/// then-revoked token.
315pub async fn delete_refresh_index(sessions: &KeyspaceHandle, token: &str) -> Result<(), AppError> {
316    sessions.remove(refresh_key(token)).await
317}
318
319/// Atomically claim-and-delete the `refresh_token → session_id`
320/// reverse index. The classic Redis-`GETDEL` shape — exactly one
321/// concurrent caller observes `Some`, even under retries.
322///
323/// Used by the canonical `/auth/refresh` handler to close the
324/// rotation TOCTOU: a leaked refresh token cannot be presented
325/// twice. On single-process fjall the atomicity comes from
326/// running both ops in one `blocking_with_timeout` closure; on
327/// the vsock backend the fallback is non-atomic
328/// (see [`crate::store::KeyspaceHandle::take_raw`]).
329pub async fn take_session_id_by_refresh(
330    sessions: &KeyspaceHandle,
331    token: &str,
332) -> Result<Option<String>, AppError> {
333    match sessions.take_raw(refresh_key(token)).await? {
334        Some(bytes) => {
335            let session_id = String::from_utf8(bytes)
336                .map_err(|e| AppError::Internal(format!("invalid session_id bytes: {e}")))?;
337            Ok(Some(session_id))
338        }
339        None => Ok(None),
340    }
341}
342
343/// Count `ChallengeSent` sessions belonging to `did`. The
344/// canonical `/auth/challenge` handler invokes this to enforce
345/// `AuthBackend::max_pending_challenges_per_did` and reject
346/// callers that try to exhaust the keyspace with a churn of
347/// pending challenges.
348///
349/// Default implementation is an O(N) prefix scan over `session:`.
350/// Backends with a per-DID tracker keyspace (like did-hosting's
351/// `pending_challenges:` index) can override the corresponding
352/// `SessionStore::count_pending_challenges` method to return O(1).
353/// Suitable for the current keyspace sizes vti-common consumers
354/// operate at; revisit when sessions cross five-figure cardinality.
355pub async fn count_pending_challenges(
356    sessions: &KeyspaceHandle,
357    did: &str,
358) -> Result<usize, AppError> {
359    let entries = sessions.prefix_iter_raw("session:").await?;
360    let mut count = 0usize;
361    for (_key, value) in entries {
362        if let Ok(s) = serde_json::from_slice::<Session>(&value)
363            && s.did == did
364            && s.state == SessionState::ChallengeSent
365        {
366            count += 1;
367        }
368    }
369    Ok(count)
370}
371
372/// Returns the current UNIX epoch timestamp in seconds.
373pub fn now_epoch() -> u64 {
374    SystemTime::now()
375        .duration_since(UNIX_EPOCH)
376        .unwrap()
377        .as_secs()
378}
379
380/// Delete a single session and its refresh index.
381pub async fn delete_session(sessions: &KeyspaceHandle, session_id: &str) -> Result<(), AppError> {
382    let session: Option<Session> = sessions.get(session_key(session_id)).await?;
383    if let Some(session) = session {
384        if let Some(ref token) = session.refresh_token {
385            sessions.remove(refresh_key(token)).await?;
386        }
387        sessions.remove(session_key(session_id)).await?;
388        debug!(session_id, "session deleted");
389    }
390    Ok(())
391}
392
393/// List all active sessions.
394pub async fn list_sessions(sessions: &KeyspaceHandle) -> Result<Vec<Session>, AppError> {
395    let raw = sessions.prefix_iter_raw("session:").await?;
396    let mut result = Vec::with_capacity(raw.len());
397    for (_key, value) in raw {
398        if let Ok(session) = serde_json::from_slice::<Session>(&value) {
399            result.push(session);
400        }
401    }
402    Ok(result)
403}
404
405/// Remove expired sessions from the store.
406///
407/// - `ChallengeSent` sessions expire after `challenge_ttl` seconds from `created_at`.
408/// - `Authenticated` **REST** sessions (UUID `session_id`) expire when
409///   `refresh_expires_at` has passed — unchanged.
410/// - `Authenticated` **intrinsic-sender** sessions (DIDComm/TSP), identified by
411///   `session_id == did`, expire after [`INTRINSIC_SESSION_IDLE_TTL_SECS`] of
412///   idle since `last_seen` (falling back to `created_at` for rows written
413///   before the `last_seen` field existed). Without this branch such a session
414///   — which has `refresh_expires_at == None` — would hit the REST rule and be
415///   swept on the very next pass.
416pub async fn cleanup_expired_sessions(
417    sessions: &KeyspaceHandle,
418    challenge_ttl: u64,
419) -> Result<(), AppError> {
420    let entries = sessions.prefix_iter_raw("session:").await?;
421    let now = now_epoch();
422    let mut removed = 0u64;
423    let mut live_sessions: std::collections::HashSet<String> =
424        std::collections::HashSet::with_capacity(entries.len());
425
426    for (key, value) in entries {
427        let session: Session = match serde_json::from_slice(&value) {
428            Ok(s) => s,
429            Err(_) => continue,
430        };
431
432        let expired = match session.state {
433            SessionState::ChallengeSent => now.saturating_sub(session.created_at) > challenge_ttl,
434            SessionState::Authenticated => {
435                if session.refresh_token.is_some() {
436                    // REST/JWT session (has a refresh token) — bounded by its
437                    // refresh-token deadline. Now that REST sessions are also
438                    // DID-keyed, the presence of a refresh token, not the key
439                    // shape, is what marks a JWT session.
440                    session
441                        .refresh_expires_at
442                        .is_none_or(|expires| now > expires)
443                } else if session.session_id == session.did {
444                    // Intrinsic-sender (DIDComm/TSP) canonical session — keyed on
445                    // the DID, no refresh token, reaped on idle. Fall back to
446                    // created_at for pre-migration rows whose last_seen is 0.
447                    let last = session.last_seen.max(session.created_at);
448                    now.saturating_sub(last) > INTRINSIC_SESSION_IDLE_TTL_SECS
449                } else {
450                    // Transient authenticated rows with neither a refresh token
451                    // nor a DID key (e.g. VTC cross-community recognise sessions)
452                    // — unchanged: expire on the next pass.
453                    session
454                        .refresh_expires_at
455                        .is_none_or(|expires| now > expires)
456                }
457            }
458        };
459
460        if expired {
461            sessions.remove(key).await?;
462            if let Some(ref token) = session.refresh_token {
463                sessions.remove(refresh_key(token)).await?;
464            }
465            removed += 1;
466        } else {
467            live_sessions.insert(session.session_id);
468        }
469    }
470
471    // GC orphan `nonce:{challenge}` index entries. `auth::challenge` writes
472    // these on every challenge issue but never deletes them, so without
473    // this sweep the keyspace grows unbounded over a long-running TEE.
474    // A nonce is orphan if its session record is gone (either expired-in-
475    // this-pass or already cleaned up). Decoding the value is safe because
476    // it's UTF-8 ASCII (the session_id) by construction.
477    let nonce_entries = sessions.prefix_iter_raw("nonce:").await?;
478    let mut nonce_removed = 0u64;
479    for (key, value) in nonce_entries {
480        let session_id = match std::str::from_utf8(&value) {
481            Ok(s) => s,
482            Err(_) => {
483                // Malformed; treat as orphan and clean up.
484                sessions.remove(key).await?;
485                nonce_removed += 1;
486                continue;
487            }
488        };
489        if !live_sessions.contains(session_id) {
490            sessions.remove(key).await?;
491            nonce_removed += 1;
492        }
493    }
494
495    debug!(
496        removed,
497        nonces_removed = nonce_removed,
498        "session cleanup complete"
499    );
500
501    Ok(())
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507    use crate::config::StoreConfig;
508    use crate::store::Store;
509
510    fn temp_sessions_ks() -> (KeyspaceHandle, tempfile::TempDir) {
511        let dir = tempfile::tempdir().expect("tempdir");
512        let config = StoreConfig {
513            data_dir: dir.path().to_path_buf(),
514        };
515        let store = Store::open(&config).expect("open store");
516        let ks = store.keyspace("sessions").expect("keyspace");
517        (ks, dir)
518    }
519
520    fn sample_session(session_id: &str, did: &str, state: SessionState) -> Session {
521        Session {
522            session_id: session_id.to_string(),
523            did: did.to_string(),
524            challenge: "test-challenge-hex".into(),
525            state,
526            created_at: now_epoch(),
527            last_seen: now_epoch(),
528            refresh_token: None,
529            refresh_expires_at: None,
530            tee_attested: false,
531            amr: Vec::new(),
532            acr: String::new(),
533            acr_expires_at: None,
534            token_id: None,
535            session_pubkey_b58btc: None,
536        }
537    }
538
539    #[test]
540    fn debug_redacts_refresh_token() {
541        // Regression: Session derives a manual Debug that must hide
542        // refresh_token. A `tracing::debug!("{session:?}")` in any code
543        // path holding a Session must not exfiltrate the bearer-
544        // equivalent secret.
545        let mut s = sample_session("sess-1", "did:key:zA", SessionState::Authenticated);
546        s.refresh_token = Some("super-secret-refresh-uuid".into());
547        let rendered = format!("{s:?}");
548        assert!(
549            !rendered.contains("super-secret-refresh-uuid"),
550            "raw refresh token must not appear in Debug output, got: {rendered}"
551        );
552        assert!(
553            rendered.contains("<redacted>"),
554            "expected redaction marker, got: {rendered}"
555        );
556    }
557
558    // ── Elevation window ────────────────────────────────────────────
559
560    #[test]
561    fn elevation_is_active_only_inside_the_window() {
562        let now = now_epoch();
563        let mut s = sample_session("sess-1", "did:key:zA", SessionState::Authenticated);
564
565        // Never stepped up: fail closed. This is the case that matters — a
566        // passkey *login* reaches aal2 with no window, and must not read as a
567        // recent step-up.
568        s.acr = "aal2".into();
569        assert!(!s.elevation_active(now), "absent deadline is not elevation");
570
571        s.acr_expires_at = Some(now + 900);
572        assert!(s.elevation_active(now));
573        assert!(s.elevation_active(now + 899));
574
575        // The deadline itself is already lapsed (the check is `now < deadline`,
576        // matching the intrinsic resolver's `now >= deadline` downgrade).
577        assert!(!s.elevation_active(now + 900));
578        assert!(!s.elevation_active(now + 901));
579    }
580
581    #[test]
582    fn downgrade_lapsed_elevation_resets_only_once_lapsed() {
583        let now = now_epoch();
584        let mut s = sample_session("sess-1", "did:key:zA", SessionState::Authenticated);
585        s.acr = "aal2".into();
586        s.amr = vec!["did".into(), "passkey".into()];
587        s.acr_expires_at = Some(now + 900);
588
589        assert!(
590            !s.downgrade_lapsed_elevation(now),
591            "still inside the window"
592        );
593        assert_eq!(s.acr, "aal2");
594
595        assert!(s.downgrade_lapsed_elevation(now + 900), "window closed");
596        assert_eq!(s.acr, "aal1");
597        assert_eq!(s.acr_expires_at, None);
598        assert_eq!(s.amr, vec!["did".to_string()]);
599
600        // Idempotent: a second call has nothing left to downgrade.
601        assert!(!s.downgrade_lapsed_elevation(now + 1800));
602    }
603
604    // ── Session key helpers ─────────────────────────────────────────
605
606    #[test]
607    fn session_key_is_prefixed_for_scan() {
608        assert_eq!(session_key("abc"), "session:abc");
609    }
610
611    #[test]
612    fn refresh_key_hashes_token_not_stores_raw() {
613        // S-7 invariant: a storage dump must not yield live refresh
614        // tokens. The reverse-index key is keyed by SHA-256 hex, not
615        // the raw token. Regressions that revert to raw-token keying
616        // leak credentials on any backup / memory dump.
617        let key = refresh_key("very-secret-uuid-v4-12345");
618        assert!(
619            key.starts_with("refresh:"),
620            "prefix must survive for prefix scans"
621        );
622        let hash_part = key.strip_prefix("refresh:").unwrap();
623        assert_eq!(
624            hash_part.len(),
625            64,
626            "SHA-256 as hex is 64 chars; got {hash_part}"
627        );
628        assert!(
629            !hash_part.contains("very-secret"),
630            "raw token must not appear in the index key — got {key}"
631        );
632
633        // Same input → same hash (deterministic lookup).
634        assert_eq!(refresh_key("very-secret-uuid-v4-12345"), key);
635        // Different input → different hash.
636        assert_ne!(refresh_key("other-token"), key);
637    }
638
639    // ── Store round-trip ────────────────────────────────────────────
640
641    #[tokio::test]
642    async fn store_and_load_session() {
643        let (ks, _dir) = temp_sessions_ks();
644        let session = sample_session("sess-1", "did:key:zA", SessionState::ChallengeSent);
645        store_session(&ks, &session).await.unwrap();
646
647        let loaded = get_session(&ks, "sess-1")
648            .await
649            .unwrap()
650            .expect("session must be present");
651        assert_eq!(loaded.session_id, "sess-1");
652        assert_eq!(loaded.did, "did:key:zA");
653        assert_eq!(loaded.state, SessionState::ChallengeSent);
654    }
655
656    #[tokio::test]
657    async fn get_session_returns_none_for_missing() {
658        let (ks, _dir) = temp_sessions_ks();
659        let result = get_session(&ks, "never-existed").await.unwrap();
660        assert!(result.is_none());
661    }
662
663    #[tokio::test]
664    async fn update_session_overwrites_state() {
665        let (ks, _dir) = temp_sessions_ks();
666        let mut session = sample_session("sess-1", "did:key:zA", SessionState::ChallengeSent);
667        store_session(&ks, &session).await.unwrap();
668
669        session.state = SessionState::Authenticated;
670        update_session(&ks, &session).await.unwrap();
671
672        let loaded = get_session(&ks, "sess-1").await.unwrap().unwrap();
673        assert_eq!(loaded.state, SessionState::Authenticated);
674    }
675
676    // ── Refresh-token index ─────────────────────────────────────────
677
678    #[tokio::test]
679    async fn refresh_index_lookup_round_trip() {
680        let (ks, _dir) = temp_sessions_ks();
681        store_refresh_index(&ks, "refresh-token-abc", "sess-1")
682            .await
683            .unwrap();
684
685        let session_id = get_session_by_refresh(&ks, "refresh-token-abc")
686            .await
687            .unwrap()
688            .expect("refresh token must resolve to session id");
689        assert_eq!(session_id, "sess-1");
690    }
691
692    #[tokio::test]
693    async fn refresh_index_returns_none_for_unknown_token() {
694        let (ks, _dir) = temp_sessions_ks();
695        let result = get_session_by_refresh(&ks, "bogus-token").await.unwrap();
696        assert!(result.is_none());
697    }
698
699    #[tokio::test]
700    async fn delete_refresh_index_removes_only_the_named_token() {
701        // Rotation invariant: deleting a presented refresh token's
702        // index must not affect any other live tokens. Two sessions
703        // with separate tokens — deleting one leaves the other usable.
704        let (ks, _dir) = temp_sessions_ks();
705        store_refresh_index(&ks, "token-a", "sess-a").await.unwrap();
706        store_refresh_index(&ks, "token-b", "sess-b").await.unwrap();
707
708        delete_refresh_index(&ks, "token-a").await.unwrap();
709
710        assert!(
711            get_session_by_refresh(&ks, "token-a")
712                .await
713                .unwrap()
714                .is_none(),
715            "deleted token must no longer resolve"
716        );
717        assert_eq!(
718            get_session_by_refresh(&ks, "token-b")
719                .await
720                .unwrap()
721                .as_deref(),
722            Some("sess-b"),
723            "untouched token must still resolve"
724        );
725    }
726
727    #[tokio::test]
728    async fn delete_refresh_index_is_idempotent() {
729        // Deleting a token that was never stored — and deleting twice —
730        // must succeed silently. The rotation path calls delete on the
731        // presented token after writing the new index; a double-call
732        // (e.g. retry after partial failure) must not error.
733        let (ks, _dir) = temp_sessions_ks();
734        delete_refresh_index(&ks, "never-existed").await.unwrap();
735
736        store_refresh_index(&ks, "once", "sess-x").await.unwrap();
737        delete_refresh_index(&ks, "once").await.unwrap();
738        delete_refresh_index(&ks, "once").await.unwrap();
739    }
740
741    #[tokio::test]
742    async fn refresh_index_is_keyed_by_hash_not_raw_token() {
743        // Integration-level assertion of S-7: the stored key contains
744        // the hash, not the raw token. A `prefix_iter_raw("refresh:")`
745        // on a compromised store must not yield a usable token.
746        let (ks, _dir) = temp_sessions_ks();
747        store_refresh_index(&ks, "super-secret-token-value", "sess-xyz")
748            .await
749            .unwrap();
750
751        let all: Vec<_> = ks.prefix_iter_raw("refresh:").await.unwrap();
752        assert_eq!(all.len(), 1, "exactly one refresh index entry");
753        let (key_bytes, _value_bytes) = &all[0];
754        let key = String::from_utf8_lossy(key_bytes);
755        assert!(
756            !key.contains("super-secret-token-value"),
757            "raw token must not appear in stored key — got {key}"
758        );
759    }
760
761    // ── Delete ──────────────────────────────────────────────────────
762
763    #[tokio::test]
764    async fn delete_session_removes_session_and_refresh_index() {
765        let (ks, _dir) = temp_sessions_ks();
766        let mut session = sample_session("sess-1", "did:key:zA", SessionState::Authenticated);
767        session.refresh_token = Some("refresh-token-abc".into());
768        session.refresh_expires_at = Some(now_epoch() + 86400);
769        store_session(&ks, &session).await.unwrap();
770        store_refresh_index(&ks, "refresh-token-abc", "sess-1")
771            .await
772            .unwrap();
773
774        delete_session(&ks, "sess-1").await.unwrap();
775
776        assert!(get_session(&ks, "sess-1").await.unwrap().is_none());
777        assert!(
778            get_session_by_refresh(&ks, "refresh-token-abc")
779                .await
780                .unwrap()
781                .is_none(),
782            "refresh-index entry must be removed alongside the session"
783        );
784    }
785
786    #[tokio::test]
787    async fn delete_missing_session_is_a_noop() {
788        let (ks, _dir) = temp_sessions_ks();
789        // No session with this id; delete must succeed silently.
790        delete_session(&ks, "never-existed")
791            .await
792            .expect("delete of missing session must not error");
793    }
794
795    // ── List ────────────────────────────────────────────────────────
796
797    #[tokio::test]
798    async fn list_sessions_returns_all_records() {
799        let (ks, _dir) = temp_sessions_ks();
800        for i in 0..3 {
801            let session = sample_session(
802                &format!("sess-{i}"),
803                &format!("did:key:z{i}"),
804                SessionState::Authenticated,
805            );
806            store_session(&ks, &session).await.unwrap();
807        }
808
809        let listed = list_sessions(&ks).await.unwrap();
810        assert_eq!(listed.len(), 3);
811    }
812
813    #[tokio::test]
814    async fn list_sessions_ignores_refresh_index_entries() {
815        // Both session:... and refresh:... share the keyspace. The
816        // "session:" prefix scan must not pull refresh entries into
817        // the listing, or the JSON decode would silently skip them
818        // (fine) but an off-by-one in the prefix would break the scan.
819        let (ks, _dir) = temp_sessions_ks();
820        store_session(
821            &ks,
822            &sample_session("sess-1", "did:key:zA", SessionState::Authenticated),
823        )
824        .await
825        .unwrap();
826        store_refresh_index(&ks, "refresh-token-1", "sess-1")
827            .await
828            .unwrap();
829
830        let listed = list_sessions(&ks).await.unwrap();
831        assert_eq!(listed.len(), 1, "only the session entry should appear");
832        assert_eq!(listed[0].session_id, "sess-1");
833    }
834
835    // ── Cleanup ─────────────────────────────────────────────────────
836
837    #[tokio::test]
838    async fn cleanup_removes_challenge_sent_past_ttl() {
839        let (ks, _dir) = temp_sessions_ks();
840        let challenge_ttl = 300u64;
841
842        let mut expired = sample_session("sess-stale", "did:key:zA", SessionState::ChallengeSent);
843        expired.created_at = now_epoch().saturating_sub(challenge_ttl + 60);
844        store_session(&ks, &expired).await.unwrap();
845
846        let mut fresh = sample_session("sess-fresh", "did:key:zB", SessionState::ChallengeSent);
847        fresh.created_at = now_epoch();
848        store_session(&ks, &fresh).await.unwrap();
849
850        cleanup_expired_sessions(&ks, challenge_ttl).await.unwrap();
851
852        assert!(
853            get_session(&ks, "sess-stale").await.unwrap().is_none(),
854            "stale ChallengeSent session must be removed"
855        );
856        assert!(
857            get_session(&ks, "sess-fresh").await.unwrap().is_some(),
858            "fresh ChallengeSent session must remain"
859        );
860    }
861
862    #[tokio::test]
863    async fn cleanup_removes_authenticated_past_refresh_expiry() {
864        let (ks, _dir) = temp_sessions_ks();
865
866        let mut expired = sample_session("sess-expired", "did:key:zA", SessionState::Authenticated);
867        expired.refresh_token = Some("expired-token".into());
868        expired.refresh_expires_at = Some(now_epoch().saturating_sub(10));
869        store_session(&ks, &expired).await.unwrap();
870        store_refresh_index(&ks, "expired-token", "sess-expired")
871            .await
872            .unwrap();
873
874        cleanup_expired_sessions(&ks, 300).await.unwrap();
875
876        assert!(
877            get_session(&ks, "sess-expired").await.unwrap().is_none(),
878            "expired Authenticated session must be removed"
879        );
880        assert!(
881            get_session_by_refresh(&ks, "expired-token")
882                .await
883                .unwrap()
884                .is_none(),
885            "refresh index must be cleaned up alongside the session"
886        );
887    }
888
889    #[tokio::test]
890    async fn cleanup_removes_authenticated_with_no_refresh_expiry() {
891        // A defensive invariant: Authenticated sessions without a
892        // refresh_expires_at should be treated as expired (the None
893        // branch uses `is_none_or` which returns true). This prevents
894        // a buggy code path from leaving sessions that never expire.
895        let (ks, _dir) = temp_sessions_ks();
896        let mut odd = sample_session("sess-odd", "did:key:zA", SessionState::Authenticated);
897        odd.refresh_token = Some("odd-token".into());
898        odd.refresh_expires_at = None;
899        store_session(&ks, &odd).await.unwrap();
900
901        cleanup_expired_sessions(&ks, 300).await.unwrap();
902
903        assert!(
904            get_session(&ks, "sess-odd").await.unwrap().is_none(),
905            "Authenticated session with no expiry must be garbage-collected"
906        );
907    }
908
909    #[tokio::test]
910    async fn cleanup_gc_orphan_nonce_indices() {
911        // Regression: `auth::challenge` writes `nonce:{challenge}` →
912        // `session_id` reverse indexes but never deletes them. Without
913        // this sweep, the keyspace grows linearly with every challenge
914        // ever issued — significant in a long-running TEE.
915        let (ks, _dir) = temp_sessions_ks();
916
917        // Live session: `nonce:` index for it must survive.
918        let live = sample_session("sess-live", "did:key:zA", SessionState::ChallengeSent);
919        store_session(&ks, &live).await.unwrap();
920        ks.insert_raw("nonce:live-challenge".to_string(), b"sess-live".to_vec())
921            .await
922            .unwrap();
923
924        // Orphan: nonce points at a session_id that doesn't exist.
925        ks.insert_raw(
926            "nonce:orphan-challenge".to_string(),
927            b"sess-vanished".to_vec(),
928        )
929        .await
930        .unwrap();
931
932        // Stale challenge: session past TTL — its nonce should be cleaned
933        // up alongside the session itself.
934        let mut stale = sample_session("sess-stale", "did:key:zB", SessionState::ChallengeSent);
935        stale.created_at = now_epoch().saturating_sub(3600);
936        store_session(&ks, &stale).await.unwrap();
937        ks.insert_raw("nonce:stale-challenge".to_string(), b"sess-stale".to_vec())
938            .await
939            .unwrap();
940
941        cleanup_expired_sessions(&ks, 300).await.unwrap();
942
943        let nonces = ks.prefix_iter_raw("nonce:").await.unwrap();
944        let nonce_keys: Vec<String> = nonces
945            .iter()
946            .map(|(k, _)| String::from_utf8_lossy(k).into_owned())
947            .collect();
948
949        assert!(
950            nonce_keys.iter().any(|k| k == "nonce:live-challenge"),
951            "live session's nonce must survive — got {nonce_keys:?}"
952        );
953        assert!(
954            !nonce_keys.iter().any(|k| k == "nonce:orphan-challenge"),
955            "orphan nonce must be removed — got {nonce_keys:?}"
956        );
957        assert!(
958            !nonce_keys.iter().any(|k| k == "nonce:stale-challenge"),
959            "stale-session nonce must be removed — got {nonce_keys:?}"
960        );
961    }
962
963    #[tokio::test]
964    async fn cleanup_preserves_active_authenticated_session() {
965        let (ks, _dir) = temp_sessions_ks();
966        let mut active = sample_session("sess-live", "did:key:zA", SessionState::Authenticated);
967        active.refresh_token = Some("live-token".into());
968        active.refresh_expires_at = Some(now_epoch() + 86400);
969        store_session(&ks, &active).await.unwrap();
970        store_refresh_index(&ks, "live-token", "sess-live")
971            .await
972            .unwrap();
973
974        cleanup_expired_sessions(&ks, 300).await.unwrap();
975
976        let loaded = get_session(&ks, "sess-live").await.unwrap();
977        assert!(loaded.is_some(), "live session must not be cleaned up");
978    }
979
980    // ── resolve_did_session (intrinsic-sender / DIDComm-TSP) ─────────
981
982    #[tokio::test]
983    async fn resolve_did_session_creates_aal1_keyed_on_did() {
984        let (ks, _dir) = temp_sessions_ks();
985        let did = "did:key:zResolveNew";
986        let now = now_epoch();
987        let s = resolve_did_session(&ks, did, now).await.unwrap();
988        assert_eq!(s.session_id, did, "canonical session_id is the DID itself");
989        assert_eq!(s.did, did);
990        assert_eq!(s.state, SessionState::Authenticated);
991        assert_eq!(s.acr, "aal1");
992        assert_eq!(s.amr, vec!["did".to_string()]);
993        assert!(
994            s.refresh_token.is_none(),
995            "intrinsic session has no refresh token"
996        );
997        assert_eq!(s.last_seen, now);
998        // Persisted under session:{did}: a second resolve reads the same row.
999        assert!(get_session(&ks, did).await.unwrap().is_some());
1000    }
1001
1002    #[tokio::test]
1003    async fn resolve_did_session_reports_elevation_within_window() {
1004        let (ks, _dir) = temp_sessions_ks();
1005        let did = "did:key:zResolveElevated";
1006        let now = now_epoch();
1007        // Create, then elevate the row exactly as the step-up handler does.
1008        let mut s = resolve_did_session(&ks, did, now).await.unwrap();
1009        s.acr = "aal2".into();
1010        s.amr.push("did-signed".into());
1011        s.acr_expires_at = Some(now + 900);
1012        update_session(&ks, &s).await.unwrap();
1013        // A later message (within the window) observes the elevation — the
1014        // property that makes intrinsic-sender step-up take effect at all.
1015        let seen = resolve_did_session(&ks, did, now + 60).await.unwrap();
1016        assert_eq!(seen.acr, "aal2");
1017        assert!(seen.amr.iter().any(|m| m == "did-signed"));
1018    }
1019
1020    #[tokio::test]
1021    async fn resolve_did_session_downgrades_after_window() {
1022        let (ks, _dir) = temp_sessions_ks();
1023        let did = "did:key:zResolveLapsed";
1024        let now = now_epoch();
1025        let mut s = resolve_did_session(&ks, did, now).await.unwrap();
1026        s.acr = "aal2".into();
1027        s.amr = vec!["did".into(), "did-signed".into()];
1028        s.acr_expires_at = Some(now + 900);
1029        update_session(&ks, &s).await.unwrap();
1030        // Past the deadline → downgraded to aal1, factors reset, downgrade
1031        // persisted so a single approval can't grant permanent aal2.
1032        let seen = resolve_did_session(&ks, did, now + 901).await.unwrap();
1033        assert_eq!(seen.acr, "aal1");
1034        assert_eq!(seen.acr_expires_at, None);
1035        assert_eq!(seen.amr, vec!["did".to_string()]);
1036        let stored = get_session(&ks, did).await.unwrap().unwrap();
1037        assert_eq!(stored.acr, "aal1");
1038        assert_eq!(stored.acr_expires_at, None);
1039    }
1040
1041    #[tokio::test]
1042    async fn intrinsic_session_reaped_only_after_idle_ttl() {
1043        let (ks, _dir) = temp_sessions_ks();
1044        let did = "did:key:zIdle";
1045        let mut s = resolve_did_session(&ks, did, now_epoch()).await.unwrap();
1046        // Fresh: survives a sweep (session_id == did → idle-TTL branch).
1047        cleanup_expired_sessions(&ks, 60).await.unwrap();
1048        assert!(
1049            get_session(&ks, did).await.unwrap().is_some(),
1050            "a just-seen intrinsic session must not be reaped"
1051        );
1052        // Idle beyond the TTL: reaped.
1053        s.last_seen = now_epoch().saturating_sub(INTRINSIC_SESSION_IDLE_TTL_SECS + 10);
1054        s.created_at = s.last_seen;
1055        update_session(&ks, &s).await.unwrap();
1056        cleanup_expired_sessions(&ks, 60).await.unwrap();
1057        assert!(
1058            get_session(&ks, did).await.unwrap().is_none(),
1059            "an idle intrinsic session must be reaped"
1060        );
1061    }
1062
1063    #[tokio::test]
1064    async fn rest_session_sweep_rule_unchanged_for_uuid_sessions() {
1065        let (ks, _dir) = temp_sessions_ks();
1066        // UUID session_id != did → REST branch, bounded by refresh deadline.
1067        let mut live = sample_session("uuid-live", "did:key:zRest", SessionState::Authenticated);
1068        live.refresh_token = Some("rt-live".into());
1069        live.refresh_expires_at = Some(now_epoch() + 3600);
1070        store_session(&ks, &live).await.unwrap();
1071        let mut dead = sample_session("uuid-dead", "did:key:zRest", SessionState::Authenticated);
1072        dead.refresh_token = Some("rt-dead".into());
1073        dead.refresh_expires_at = Some(now_epoch().saturating_sub(10));
1074        store_session(&ks, &dead).await.unwrap();
1075        cleanup_expired_sessions(&ks, 60).await.unwrap();
1076        assert!(get_session(&ks, "uuid-live").await.unwrap().is_some());
1077        assert!(get_session(&ks, "uuid-dead").await.unwrap().is_none());
1078    }
1079
1080    // ── now_epoch ───────────────────────────────────────────────────
1081
1082    #[test]
1083    fn now_epoch_is_monotonic() {
1084        // Guard against the fallback path (0 on clock < UNIX_EPOCH)
1085        // silently returning without the test noticing. If this test
1086        // fires on a machine with a broken clock, the fallback is
1087        // doing its job — rerun on a sane host.
1088        let t = now_epoch();
1089        assert!(t > 1_700_000_000, "epoch should be post-2023; got {t}");
1090    }
1091}