Skip to main content

polyc_crypto/
session.rs

1//! Stateless, signed web-session tokens and their logout denylist.
2//!
3//! A session token binds a [`SessionSubject`] — a persona-credential login
4//! (a `persona_id`) or a wallet-passkey login (a `wallet_address`, and
5//! optionally the persona it currently resolves to) — plus the [`SessionScope`]s
6//! it carries, to an issue/expiry window, and is signed by a dedicated
7//! [`SessionSigner`]. Verification needs only the role's public
8//! key, so a session survives a cold restart of whatever process minted it:
9//! nothing here reads or writes a server-side session store. [`mint_session`]
10//! and [`verify_session`] are the mint/verify pair; [`RevokedTokens`] is the
11//! best-effort, in-memory logout denylist [`verify_session`] consults.
12//!
13//! This module owns only the token's cryptographic shape and the denylist —
14//! it mints no challenges, runs no login ceremony, and knows nothing about
15//! cookies or HTTP. A caller that needs those (e.g. a passkey-authenticated
16//! login flow) builds them on top, choosing its own token TTL and calling
17//! [`mint_session`]/[`verify_session`] directly.
18//!
19//! C1.4 adds the replacement protocol through [`mint_authorized_session`] and
20//! [`verify_authorized_session`]. Its signed claims bind a stable bearer id and
21//! authorization epoch to the same subject/scopes/time window. Verification
22//! remains deliberately pure: a caller must check the resulting id and epoch
23//! against current State before authorizing a request. The v2 path remains
24//! live only until the coordinated caller cutover removes it.
25//!
26//! See ADR 0007 (`docs/decisions/0007-web-sessions-rooted-in-either-passkey.md`)
27//! for why a session may be rooted in either credential, and why
28//! `explorer-read`/`wallet-manage`/`agent-turn` are independent claims rather
29//! than one all-or-nothing grant: revoking a persona-credential must never
30//! revoke a wallet's own sessions, and vice versa.
31
32use std::collections::HashSet;
33use std::sync::Mutex;
34
35use base64::Engine as _;
36use base64::engine::general_purpose::URL_SAFE_NO_PAD;
37
38use crate::signing_role::{RoleTrustSet, SessionRole, SessionSigner, SigningRole as _};
39
40/// Domain-separation prefix prepended to a session token's signed bytes.
41///
42/// The session role has its own private key. The prefix below additionally
43/// prevents one session-shaped artifact from being interpreted as another
44/// artifact under that role. Without an
45/// artifact-local prefix, a captured signed payload of a different kind
46/// could be replayed as a forged session token (or vice versa) whenever the
47/// byte shapes happened to coincide. This prefix (with a trailing NUL so no
48/// legal JSON body can extend it into a prefix collision) makes that
49/// impossible: [`verify_session`] only accepts bytes that begin with it, and
50/// no other signed-artifact kind in this codebase ever prepends it.
51///
52/// `.v2\0`, not `.v1\0`: this is a new prefix (ADR 0007), deliberately
53/// distinct from the pre-existing `polychrome.explorer-session.v1\0` a
54/// persona-only session token used — so that changeover invalidates every
55/// live session at deploy (one re-login each, bounded by the TTL) rather
56/// than needing a dual-accept shim for a widened payload shape, per this
57/// repo's no-legacy-code doctrine. The exact bytes are load-bearing: changing
58/// them again invalidates every session minted under this prefix.
59const SESSION_DOMAIN_PREFIX: &[u8] = b"polychrome.web-session.v2\0";
60
61/// Domain for State-authorized bearer sessions.
62///
63/// Version 3 binds the signed bearer to a stable State row and the
64/// authorization epoch at which State minted that row. It is deliberately
65/// disjoint from v2: the C1.4 cutover invalidates old stateless sessions
66/// instead of running a dual-accept compatibility window.
67const AUTHORIZED_SESSION_DOMAIN_PREFIX: &[u8] = b"polychrome.web-session.v3\0";
68
69/// Which credential rooted a session.
70///
71/// A persona-credential login always resolves a persona (enrollment requires
72/// one). A wallet-passkey login may resolve to a linked, `STATUS_LINKED`
73/// persona, or to none at all — a wallet with no persona is a standing,
74/// permanent state (ADR 0007), not a transitional or error one.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum SessionSubject {
77    /// Rooted in a `PersonaCredential` login.
78    Persona {
79        /// The persona this session is bound to.
80        persona_id: String,
81    },
82    /// Rooted in a wallet-passkey login (a stateless signature-recovery
83    /// check — see `polyc-wallet-delegation`'s envelope verification, ADR
84    /// 0007 — never a control-plane-held credential registry).
85    Wallet {
86        /// The wallet address the presented signature recovered to.
87        wallet_address: String,
88        /// The persona this address currently resolves to locally, if any
89        /// (`crate::persona`'s `wallet_link_by_address` directory —
90        /// instance-local persona resolution, never identity).
91        persona_id: Option<String>,
92    },
93}
94
95impl SessionSubject {
96    /// The persona this subject is bound to, if any — `Some` for every
97    /// [`Self::Persona`] and for a [`Self::Wallet`] that currently resolves
98    /// to one.
99    #[must_use]
100    pub fn persona_id(&self) -> Option<&str> {
101        match self {
102            Self::Persona { persona_id } => Some(persona_id),
103            Self::Wallet { persona_id, .. } => persona_id.as_deref(),
104        }
105    }
106
107    /// The wallet address this subject is bound to, if it is a wallet-rooted
108    /// session.
109    #[must_use]
110    pub fn wallet_address(&self) -> Option<&str> {
111        match self {
112            Self::Persona { .. } => None,
113            Self::Wallet { wallet_address, .. } => Some(wallet_address),
114        }
115    }
116}
117
118/// A capability a session token carries.
119///
120/// Checked independently of any other scope the same token carries (ADR
121/// 0007: possession of a wallet passkey must never implicitly grant
122/// explorer read, and vice versa). Which scopes a given ceremony mints
123/// TOGETHER is a policy decision recorded in that ADR; that two of them
124/// commonly arrive on the same token never licenses a gate to infer one from
125/// another — every gate names the exact scope it requires.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
127pub enum SessionScope {
128    /// Read access to the subject's own persona's conversations/data in the
129    /// explorer (never fleet-wide — see `crate::control-plane`'s
130    /// participation-scoped read gate).
131    ExplorerRead,
132    /// Manage the subject's own linked wallet (spend authorization, linking,
133    /// revocation ceremonies).
134    WalletManage,
135    /// Dispatch an agent turn as the subject's own persona.
136    ///
137    /// Separate from [`Self::ExplorerRead`] because a turn is a write, not a
138    /// read: it spends provider tokens and can trigger tool side effects. The
139    /// two are minted together by POLICY wherever a login ceremony resolves a
140    /// persona (ADR 0007's Consequences records that choice), never by
141    /// implication — a gate authorizing a turn names this scope explicitly and
142    /// infers it from nothing, so the policy is revisable without touching
143    /// every call site, and a future token kind that should read without
144    /// spending (a share link, a machine session) can be minted without it.
145    AgentTurn,
146}
147
148impl SessionScope {
149    /// The wire string this scope serializes to. A fixed, explicit mapping
150    /// (not `serde`'s default enum-tag rendering) so the wire shape is
151    /// independent of Rust identifier casing and stable across a refactor of
152    /// the variant names.
153    const fn as_str(self) -> &'static str {
154        match self {
155            Self::ExplorerRead => "explorer-read",
156            Self::WalletManage => "wallet-manage",
157            Self::AgentTurn => "agent-turn",
158        }
159    }
160
161    /// Parse a scope from its wire string. `None` on anything else — an
162    /// unrecognized scope string in a token must never be silently dropped
163    /// into "no scopes" or, worse, upgraded to every scope; the caller
164    /// ([`decode_session_payload`]) rejects the whole token instead.
165    fn from_str(s: &str) -> Option<Self> {
166        match s {
167            "explorer-read" => Some(Self::ExplorerRead),
168            "wallet-manage" => Some(Self::WalletManage),
169            "agent-turn" => Some(Self::AgentTurn),
170            _ => None,
171        }
172    }
173}
174
175/// A verified session's claims: who it's bound to, what it authorizes, and
176/// its issue/expiry window.
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct SessionClaims {
179    /// Stable signing-role issuer.
180    pub issuer: String,
181    /// Exact key identity selected from the session trust set.
182    pub key_id: String,
183    /// Which credential rooted this session, and what it resolves to.
184    pub subject: SessionSubject,
185    /// The capabilities this token carries. Never empty — [`mint_session`]
186    /// requires at least one scope, since a session that authorizes nothing
187    /// has no reason to exist.
188    pub scopes: Vec<SessionScope>,
189    /// When this session was minted (unix ms).
190    pub issued_ms: u64,
191    /// When this session stops verifying (unix ms).
192    pub expires_ms: u64,
193}
194
195impl SessionClaims {
196    /// Whether this session carries `scope`.
197    #[must_use]
198    pub fn has_scope(&self, scope: SessionScope) -> bool {
199        self.scopes.contains(&scope)
200    }
201}
202
203/// The wire shape of a session token's payload segment — a plain,
204/// unencrypted JSON object (readable by anyone who holds the token; it
205/// carries no secret), canonicalized field-by-field on the verify side
206/// rather than trusted as raw bytes (see [`session_canonical`]).
207#[derive(serde::Serialize, serde::Deserialize)]
208struct WirePayload {
209    issuer: String,
210    key_id: String,
211    #[serde(flatten)]
212    subject: WireSubject,
213    scopes: Vec<String>,
214    issued_ms: u64,
215    expires_ms: u64,
216}
217
218#[derive(serde::Serialize, serde::Deserialize)]
219#[serde(tag = "sub_kind", rename_all = "snake_case")]
220enum WireSubject {
221    Persona {
222        persona_id: String,
223    },
224    Wallet {
225        wallet_address: String,
226        #[serde(skip_serializing_if = "Option::is_none", default)]
227        persona_id: Option<String>,
228    },
229}
230
231impl From<&SessionSubject> for WireSubject {
232    fn from(subject: &SessionSubject) -> Self {
233        match subject {
234            SessionSubject::Persona { persona_id } => Self::Persona {
235                persona_id: persona_id.clone(),
236            },
237            SessionSubject::Wallet {
238                wallet_address,
239                persona_id,
240            } => Self::Wallet {
241                wallet_address: wallet_address.clone(),
242                persona_id: persona_id.clone(),
243            },
244        }
245    }
246}
247
248impl From<WireSubject> for SessionSubject {
249    fn from(wire: WireSubject) -> Self {
250        match wire {
251            WireSubject::Persona { persona_id } => Self::Persona { persona_id },
252            WireSubject::Wallet {
253                wallet_address,
254                persona_id,
255            } => Self::Wallet {
256                wallet_address,
257                persona_id,
258            },
259        }
260    }
261}
262
263/// Build the exact bytes [`mint_session`] signs and [`verify_session`]
264/// recomputes and checks the signature against: [`SESSION_DOMAIN_PREFIX`]
265/// followed by the canonical JSON of the payload fields.
266///
267/// Recomputed from PARSED fields on the verify side (never the raw decoded
268/// payload bytes trusted as-is), so a canonical can never be re-serialized
269/// into a byte-identical-looking but differently-keyed JSON object.
270fn session_canonical(payload: &WirePayload) -> Vec<u8> {
271    let body =
272        serde_json::to_string(payload).expect("WirePayload serializes (no non-finite floats)");
273    let mut bytes = Vec::with_capacity(SESSION_DOMAIN_PREFIX.len() + body.len());
274    bytes.extend_from_slice(SESSION_DOMAIN_PREFIX);
275    bytes.extend_from_slice(body.as_bytes());
276    bytes
277}
278
279/// Mint a stateless, signed session token bound to `subject`, carrying
280/// `scopes`.
281///
282/// The token is `base64url(payload_json) ++ "." ++ base64url(signature)`: the
283/// payload segment is plain (unprefixed) JSON, readable by anyone who holds
284/// the token but unforgeable — [`verify_session`] recomputes the
285/// domain-prefixed canonical from the parsed fields and checks `signer`'s
286/// ed25519 signature over it. `expires_ms` is `now_ms + ttl_ms`, saturating
287/// (never wraps on a pathological `ttl_ms`).
288///
289/// # Panics
290///
291/// Panics if `scopes` is empty — a session that authorizes nothing has no
292/// reason to exist; every caller names at least one scope explicitly.
293#[must_use]
294pub fn mint_session(
295    signer: &SessionSigner,
296    subject: &SessionSubject,
297    scopes: &[SessionScope],
298    now_ms: u64,
299    ttl_ms: u64,
300) -> String {
301    assert!(
302        !scopes.is_empty(),
303        "mint_session requires at least one scope"
304    );
305    let issued_ms = now_ms;
306    let expires_ms = now_ms.saturating_add(ttl_ms);
307    let payload = WirePayload {
308        issuer: SessionRole::ISSUER.to_owned(),
309        key_id: signer.identity().key_id().to_owned(),
310        subject: subject.into(),
311        scopes: scopes.iter().map(|s| s.as_str().to_owned()).collect(),
312        issued_ms,
313        expires_ms,
314    };
315    let payload_json =
316        serde_json::to_string(&payload).expect("WirePayload serializes (no non-finite floats)");
317    let signed_bytes = session_canonical(&payload);
318    let signature = signer.sign(&signed_bytes);
319    format!(
320        "{}.{}",
321        URL_SAFE_NO_PAD.encode(payload_json.as_bytes()),
322        URL_SAFE_NO_PAD.encode(signature)
323    )
324}
325
326/// Decode a session token's `.`-separated payload/signature segments into
327/// the parsed payload plus its raw signature bytes, or `None` on any
328/// malformed shape/base64/JSON/field/unrecognized-scope — the parse half of
329/// [`verify_session`], factored out so [`RevokedTokens::reap`] can read a
330/// denylisted token's own `expires_ms` without duplicating this decode.
331fn decode_session_payload(token: &str) -> Option<(WirePayload, Vec<SessionScope>, Vec<u8>)> {
332    let (payload_b64, signature_b64) = token.split_once('.')?;
333    if payload_b64.is_empty() || signature_b64.is_empty() || signature_b64.contains('.') {
334        return None;
335    }
336    let payload_bytes = URL_SAFE_NO_PAD.decode(payload_b64).ok()?;
337    let signature = URL_SAFE_NO_PAD.decode(signature_b64).ok()?;
338    let payload: WirePayload = serde_json::from_slice(&payload_bytes).ok()?;
339    // An unrecognized scope string fails the whole token closed rather than
340    // being silently dropped (which could shrink an attacker-visible scope
341    // list to look benign) or ignored (which could let a not-yet-understood
342    // future scope slip through unchecked on an old binary).
343    let scopes = payload
344        .scopes
345        .iter()
346        .map(|s| SessionScope::from_str(s))
347        .collect::<Option<Vec<_>>>()?;
348    Some((payload, scopes, signature))
349}
350
351/// Verify a session token minted by [`mint_session`], returning its
352/// [`SessionClaims`] on success.
353///
354/// Fail-closed on ALL of: malformed shape (not exactly one `.`), undecodable
355/// base64/JSON, a missing/wrong-typed/unrecognized-scope field, a signature
356/// that does not verify against `signer_public_key` under the
357/// domain-separated canonical (`session_canonical` — so a signature minted
358/// for any OTHER artifact kind this signer produces can never verify here),
359/// `now_ms >= expires_ms` (expiry is checked against the PARSED
360/// `expires_ms`, not trusted from wall-clock drift elsewhere), or the token
361/// being present in `denylist` (logout). No naive secret comparison happens
362/// here — unforgeability comes from the ed25519 signature check, not from
363/// string equality, so the denylist membership check needs no
364/// constant-time treatment: a token that fails to verify or fails the
365/// denylist check reveals nothing an attacker could use to forge a
366/// different one.
367///
368/// Needs only `signer_public_key` — no server-side session store, so this
369/// survives a cold restart of whatever process minted the token unchanged.
370#[must_use]
371pub fn verify_session(
372    signer_public_key: &[u8],
373    token: &str,
374    now_ms: u64,
375    denylist: &RevokedTokens,
376) -> Option<SessionClaims> {
377    let trusted_signers =
378        RoleTrustSet::<SessionRole>::from_public_keys(vec![signer_public_key.to_vec()]).ok()?;
379    verify_session_with_trust(&trusted_signers, token, now_ms, denylist)
380}
381
382/// Verifies a session against the role's current and retired trust set.
383#[must_use]
384pub fn verify_session_with_trust(
385    trusted_signers: &RoleTrustSet<SessionRole>,
386    token: &str,
387    now_ms: u64,
388    denylist: &RevokedTokens,
389) -> Option<SessionClaims> {
390    let (payload, scopes, signature) = decode_session_payload(token)?;
391    let signed_bytes = session_canonical(&payload);
392    if payload.issuer != SessionRole::ISSUER
393        || !trusted_signers.verify(&payload.key_id, &signed_bytes, &signature)
394    {
395        return None;
396    }
397    if now_ms >= payload.expires_ms {
398        return None;
399    }
400    if denylist.contains(token) {
401        return None;
402    }
403    Some(SessionClaims {
404        issuer: payload.issuer,
405        key_id: payload.key_id,
406        subject: payload.subject.into(),
407        scopes,
408        issued_ms: payload.issued_ms,
409        expires_ms: payload.expires_ms,
410    })
411}
412
413/// Claims signed into a bearer whose current authorization lives in State.
414///
415/// Signature verification proves only that these claims came from the
416/// session signer and remain inside their time window. An admission boundary
417/// must additionally read the `session_id` and credential-root epoch from
418/// State and fail closed unless both still authorize this bearer.
419#[derive(Debug, Clone, PartialEq, Eq)]
420pub struct AuthorizedSessionClaims {
421    /// Stable signing-role issuer.
422    pub issuer: String,
423    /// Exact key identity selected from the session trust set.
424    pub key_id: String,
425    /// Stable identifier of the bearer record in State.
426    pub session_id: String,
427    /// Credential-root epoch recorded when State minted the bearer.
428    pub authorization_epoch: u64,
429    /// Credential root and its current persona resolution.
430    pub subject: SessionSubject,
431    /// Independently checked capabilities.
432    pub scopes: Vec<SessionScope>,
433    /// Writer-attested mint time.
434    pub issued_ms: u64,
435    /// Natural expiry; durable revocation may refuse it earlier.
436    pub expires_ms: u64,
437}
438
439impl AuthorizedSessionClaims {
440    /// Whether this bearer carries `scope`.
441    #[must_use]
442    pub fn has_scope(&self, scope: SessionScope) -> bool {
443        self.scopes.contains(&scope)
444    }
445}
446
447#[derive(serde::Serialize, serde::Deserialize)]
448struct AuthorizedWirePayload {
449    issuer: String,
450    key_id: String,
451    session_id: String,
452    authorization_epoch: u64,
453    #[serde(flatten)]
454    subject: WireSubject,
455    scopes: Vec<String>,
456    issued_ms: u64,
457    expires_ms: u64,
458}
459
460fn authorized_session_canonical(payload: &AuthorizedWirePayload) -> Vec<u8> {
461    let body = serde_json::to_string(payload)
462        .expect("AuthorizedWirePayload serializes (no non-finite floats)");
463    let mut bytes = Vec::with_capacity(AUTHORIZED_SESSION_DOMAIN_PREFIX.len() + body.len());
464    bytes.extend_from_slice(AUTHORIZED_SESSION_DOMAIN_PREFIX);
465    bytes.extend_from_slice(body.as_bytes());
466    bytes
467}
468
469/// Mints a signed bearer bound to the durable record State already accepted.
470///
471/// The caller obtains `session_id` and `authorization_epoch` from the exact
472/// State mint command/receipt before signing. This function never invents
473/// either authority fact and performs no storage I/O.
474///
475/// # Panics
476///
477/// Panics for an empty session identity or scope set; neither can represent an
478/// authorizable bearer.
479#[must_use]
480pub fn mint_authorized_session(
481    signer: &SessionSigner,
482    session_id: &str,
483    authorization_epoch: u64,
484    subject: &SessionSubject,
485    scopes: &[SessionScope],
486    now_ms: u64,
487    ttl_ms: u64,
488) -> String {
489    assert!(!session_id.is_empty(), "authorized session id is not empty");
490    assert!(
491        !scopes.is_empty(),
492        "mint_authorized_session requires at least one scope"
493    );
494    let payload = AuthorizedWirePayload {
495        issuer: SessionRole::ISSUER.to_owned(),
496        key_id: signer.identity().key_id().to_owned(),
497        session_id: session_id.to_owned(),
498        authorization_epoch,
499        subject: subject.into(),
500        scopes: scopes
501            .iter()
502            .map(|scope| scope.as_str().to_owned())
503            .collect(),
504        issued_ms: now_ms,
505        expires_ms: now_ms.saturating_add(ttl_ms),
506    };
507    let body = serde_json::to_string(&payload)
508        .expect("AuthorizedWirePayload serializes (no non-finite floats)");
509    let signature = signer.sign(&authorized_session_canonical(&payload));
510    format!(
511        "{}.{}",
512        URL_SAFE_NO_PAD.encode(body.as_bytes()),
513        URL_SAFE_NO_PAD.encode(signature)
514    )
515}
516
517/// Verifies only the signed and time-bounded half of a State-backed bearer.
518///
519/// A successful return is not authorization. The caller must derive the State
520/// principal from [`AuthorizedSessionClaims::subject`], then require the
521/// durable bearer record to be live and minted at
522/// [`AuthorizedSessionClaims::authorization_epoch`]. Keeping this function
523/// pure prevents a crypto primitive from hiding an unbounded network call.
524#[must_use]
525pub fn verify_authorized_session(
526    signer_public_key: &[u8],
527    token: &str,
528    now_ms: u64,
529) -> Option<AuthorizedSessionClaims> {
530    let trusted_signers =
531        RoleTrustSet::<SessionRole>::from_public_keys(vec![signer_public_key.to_vec()]).ok()?;
532    verify_authorized_session_with_trust(&trusted_signers, token, now_ms)
533}
534
535/// Verifies a State-backed bearer against current and retired session keys.
536#[must_use]
537pub fn verify_authorized_session_with_trust(
538    trusted_signers: &RoleTrustSet<SessionRole>,
539    token: &str,
540    now_ms: u64,
541) -> Option<AuthorizedSessionClaims> {
542    let (payload_b64, signature_b64) = token.split_once('.')?;
543    if payload_b64.is_empty() || signature_b64.is_empty() || signature_b64.contains('.') {
544        return None;
545    }
546    let payload_bytes = URL_SAFE_NO_PAD.decode(payload_b64).ok()?;
547    let signature = URL_SAFE_NO_PAD.decode(signature_b64).ok()?;
548    let payload: AuthorizedWirePayload = serde_json::from_slice(&payload_bytes).ok()?;
549    if payload.session_id.is_empty() || now_ms >= payload.expires_ms {
550        return None;
551    }
552    let scopes = payload
553        .scopes
554        .iter()
555        .map(|scope| SessionScope::from_str(scope))
556        .collect::<Option<Vec<_>>>()?;
557    if scopes.is_empty()
558        || payload.issuer != SessionRole::ISSUER
559        || !trusted_signers.verify(
560            &payload.key_id,
561            &authorized_session_canonical(&payload),
562            &signature,
563        )
564    {
565        return None;
566    }
567    Some(AuthorizedSessionClaims {
568        issuer: payload.issuer,
569        key_id: payload.key_id,
570        session_id: payload.session_id,
571        authorization_epoch: payload.authorization_epoch,
572        subject: payload.subject.into(),
573        scopes,
574        issued_ms: payload.issued_ms,
575        expires_ms: payload.expires_ms,
576    })
577}
578
579/// Best-effort, in-memory logout denylist for session tokens.
580///
581/// Deliberately NOT durable: a stateless token's only revocation cost is the
582/// window between logout and its natural expiry, bounded by the caller's own
583/// TTL policy; losing this set on restart means a logged-out token near the
584/// end of its TTL might verify again after a restart, which callers of this
585/// module accept as the tradeoff for a session that needs no durable store.
586pub struct RevokedTokens {
587    /// Revoked token strings. A full-token set (not a derived "stable token
588    /// id") — simplest correct shape, and the whole token is what
589    /// [`verify_session`] has in hand to check.
590    tokens: Mutex<HashSet<String>>,
591}
592
593impl RevokedTokens {
594    /// An empty denylist.
595    #[must_use]
596    pub fn new() -> Self {
597        Self {
598            tokens: Mutex::new(HashSet::new()),
599        }
600    }
601
602    /// Adds `token` to the denylist (logout). Idempotent.
603    pub fn revoke(&self, token: &str) {
604        if let Ok(mut tokens) = self.tokens.lock() {
605            tokens.insert(token.to_owned());
606        }
607    }
608
609    /// Whether `token` has been revoked.
610    #[must_use]
611    fn contains(&self, token: &str) -> bool {
612        self.tokens
613            .lock()
614            .is_ok_and(|tokens| tokens.contains(token))
615    }
616
617    /// Evict every denylisted token that could never verify again anyway: a
618    /// token past its OWN embedded `expires_ms` already fails
619    /// [`verify_session`]'s expiry check regardless of denylist membership,
620    /// so keeping it here forever only grows the set. A token that fails to
621    /// even PARSE (`decode_session_payload` returns `None` — never
622    /// produced by [`mint_session`], but [`Self::revoke`] accepts whatever
623    /// string a caller posts) can likewise never match a real session and is
624    /// dropped too. Bounds this set's memory to sessions logged out within
625    /// their own TTL window, not the lifetime of the process.
626    pub fn reap(&self, now_ms: u64) {
627        if let Ok(mut tokens) = self.tokens.lock() {
628            tokens.retain(|token| {
629                decode_session_payload(token)
630                    .is_some_and(|(payload, _, _)| now_ms < payload.expires_ms)
631            });
632        }
633    }
634
635    /// Count of denylisted tokens currently stored — test-only.
636    #[cfg(test)]
637    fn len(&self) -> usize {
638        self.tokens.lock().map_or(0, |t| t.len())
639    }
640}
641
642impl Default for RevokedTokens {
643    fn default() -> Self {
644        Self::new()
645    }
646}
647
648#[cfg(test)]
649mod tests {
650    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
651
652    use super::*;
653
654    const NOW: u64 = 1_700_000_000_000;
655    /// Arbitrary TTL for tests that don't exercise expiry itself.
656    const TEST_TTL_MS: u64 = 8 * 60 * 60 * 1000;
657
658    fn test_signer() -> SessionSigner {
659        SessionSigner::from_key_bytes(&[7u8; 32]).expect("valid 32-byte ed25519 key material")
660    }
661
662    fn persona_subject(persona_id: &str) -> SessionSubject {
663        SessionSubject::Persona {
664            persona_id: persona_id.to_owned(),
665        }
666    }
667
668    fn mint_persona_session(
669        signer: &SessionSigner,
670        persona_id: &str,
671        now_ms: u64,
672        ttl_ms: u64,
673    ) -> String {
674        mint_session(
675            signer,
676            &persona_subject(persona_id),
677            &[SessionScope::ExplorerRead],
678            now_ms,
679            ttl_ms,
680        )
681    }
682
683    fn mint_authorized_persona(signer: &SessionSigner) -> String {
684        mint_authorized_session(
685            signer,
686            "session-1",
687            7,
688            &persona_subject("persona-1"),
689            &[SessionScope::ExplorerRead, SessionScope::AgentTurn],
690            NOW,
691            TEST_TTL_MS,
692        )
693    }
694
695    #[test]
696    fn authorized_bearer_round_trips_every_state_binding() {
697        let signer = test_signer();
698        let token = mint_authorized_persona(&signer);
699        let claims = verify_authorized_session(&signer.public_key_bytes(), &token, NOW)
700            .expect("fresh State-backed bearer verifies cryptographically");
701        assert_eq!(claims.session_id, "session-1");
702        assert_eq!(claims.authorization_epoch, 7);
703        assert_eq!(claims.subject, persona_subject("persona-1"));
704        assert!(claims.has_scope(SessionScope::ExplorerRead));
705        assert!(claims.has_scope(SessionScope::AgentTurn));
706        assert_eq!(claims.issued_ms, NOW);
707        assert_eq!(claims.expires_ms, NOW + TEST_TTL_MS);
708    }
709
710    #[test]
711    fn authorized_wallet_keeps_wallet_as_its_independent_root() {
712        let signer = test_signer();
713        let subject = SessionSubject::Wallet {
714            wallet_address: "0xabc".into(),
715            persona_id: Some("persona-1".into()),
716        };
717        let token = mint_authorized_session(
718            &signer,
719            "wallet-session",
720            3,
721            &subject,
722            &[SessionScope::WalletManage, SessionScope::ExplorerRead],
723            NOW,
724            TEST_TTL_MS,
725        );
726        let claims = verify_authorized_session(&signer.public_key_bytes(), &token, NOW)
727            .expect("wallet bearer verifies");
728        assert_eq!(claims.subject.wallet_address(), Some("0xabc"));
729        assert_eq!(claims.subject.persona_id(), Some("persona-1"));
730        assert_eq!(claims.authorization_epoch, 3);
731    }
732
733    #[test]
734    fn every_authority_field_is_covered_by_the_signature() {
735        let signer = test_signer();
736        let token = mint_authorized_persona(&signer);
737        let (payload_b64, signature) = token.split_once('.').expect("token shape");
738        let original: serde_json::Value =
739            serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload_b64).expect("payload base64"))
740                .expect("payload json");
741        let mutations = [
742            ("session_id", serde_json::json!("session-2")),
743            ("authorization_epoch", serde_json::json!(8)),
744            ("persona_id", serde_json::json!("persona-2")),
745            ("scopes", serde_json::json!(["wallet-manage"])),
746            ("issued_ms", serde_json::json!(NOW + 1)),
747            ("expires_ms", serde_json::json!(NOW + TEST_TTL_MS + 1)),
748        ];
749        for (field, replacement) in mutations {
750            let mut changed = original.clone();
751            changed[field] = replacement;
752            let forged = format!(
753                "{}.{}",
754                URL_SAFE_NO_PAD.encode(serde_json::to_vec(&changed).expect("json")),
755                signature
756            );
757            assert!(
758                verify_authorized_session(&signer.public_key_bytes(), &forged, NOW).is_none(),
759                "changing {field} kept the signature valid"
760            );
761        }
762    }
763
764    #[test]
765    fn valid_signatures_with_unknown_role_identity_fail_closed() {
766        let signer = test_signer();
767        let token = mint_authorized_persona(&signer);
768        let (payload_b64, _) = token.split_once('.').expect("token shape");
769        let payload_bytes = URL_SAFE_NO_PAD.decode(payload_b64).expect("payload base64");
770        for (issuer, key_id) in [
771            (Some("polychrome.control.approval"), None),
772            (None, Some("unknown-key-id")),
773        ] {
774            let mut payload: AuthorizedWirePayload =
775                serde_json::from_slice(&payload_bytes).expect("payload json");
776            if let Some(issuer) = issuer {
777                payload.issuer = issuer.to_owned();
778            }
779            if let Some(key_id) = key_id {
780                payload.key_id = key_id.to_owned();
781            }
782            let body = serde_json::to_vec(&payload).expect("payload json");
783            let signature = signer.sign(&authorized_session_canonical(&payload));
784            let forged = format!(
785                "{}.{}",
786                URL_SAFE_NO_PAD.encode(body),
787                URL_SAFE_NO_PAD.encode(signature)
788            );
789            assert!(verify_authorized_session(&signer.public_key_bytes(), &forged, NOW).is_none());
790        }
791    }
792
793    #[test]
794    fn v2_and_v3_are_never_cross_accepted() {
795        let signer = test_signer();
796        let v2 = mint_persona_session(&signer, "persona-1", NOW, TEST_TTL_MS);
797        let v3 = mint_authorized_persona(&signer);
798        assert!(verify_authorized_session(&signer.public_key_bytes(), &v2, NOW).is_none());
799        assert!(
800            verify_session(&signer.public_key_bytes(), &v3, NOW, &RevokedTokens::new()).is_none()
801        );
802    }
803
804    #[test]
805    fn authorized_bearer_expiry_and_malformed_shapes_fail_closed() {
806        let signer = test_signer();
807        let token = mint_authorized_session(
808            &signer,
809            "session-1",
810            0,
811            &persona_subject("persona-1"),
812            &[SessionScope::ExplorerRead],
813            NOW,
814            1,
815        );
816        assert!(verify_authorized_session(&signer.public_key_bytes(), &token, NOW).is_some());
817        assert!(verify_authorized_session(&signer.public_key_bytes(), &token, NOW + 1).is_none());
818        for malformed in ["", ".", "a.b.c", "not-base64.signature"] {
819            assert!(
820                verify_authorized_session(&signer.public_key_bytes(), malformed, NOW).is_none()
821            );
822        }
823    }
824
825    // ---- session token: tamper / expiry / domain confusion ------------------
826
827    #[test]
828    fn tampered_payload_is_rejected() {
829        let signer = test_signer();
830        let denylist = RevokedTokens::new();
831        let token = mint_persona_session(&signer, "persona-1", NOW, TEST_TTL_MS);
832        let (payload_b64, sig_b64) = token.split_once('.').expect("shape");
833        let mut payload = URL_SAFE_NO_PAD.decode(payload_b64).expect("decodes");
834        // Flip a byte inside the persona_id string.
835        let last = payload.len() - 1;
836        payload[last] ^= 0xFF;
837        let tampered = format!("{}.{sig_b64}", URL_SAFE_NO_PAD.encode(payload));
838
839        assert!(
840            verify_session(&signer.public_key_bytes(), &tampered, NOW, &denylist).is_none(),
841            "a tampered payload must not verify"
842        );
843    }
844
845    #[test]
846    fn tampered_signature_is_rejected() {
847        let signer = test_signer();
848        let denylist = RevokedTokens::new();
849        let token = mint_persona_session(&signer, "persona-1", NOW, TEST_TTL_MS);
850        let (payload_b64, sig_b64) = token.split_once('.').expect("shape");
851        let mut sig = URL_SAFE_NO_PAD.decode(sig_b64).expect("decodes");
852        let last = sig.len() - 1;
853        sig[last] ^= 0xFF;
854        let tampered = format!("{payload_b64}.{}", URL_SAFE_NO_PAD.encode(sig));
855
856        assert!(
857            verify_session(&signer.public_key_bytes(), &tampered, NOW, &denylist).is_none(),
858            "a tampered signature must not verify"
859        );
860    }
861
862    #[test]
863    fn expired_session_is_rejected() {
864        let signer = test_signer();
865        let denylist = RevokedTokens::new();
866        let token = mint_persona_session(&signer, "persona-1", NOW, 1_000);
867
868        assert!(
869            verify_session(&signer.public_key_bytes(), &token, NOW + 1_000, &denylist).is_none(),
870            "now_ms == expires_ms must already be rejected (boundary, not just past it)"
871        );
872        assert!(
873            verify_session(&signer.public_key_bytes(), &token, NOW + 999, &denylist).is_some(),
874            "one ms before expiry must still verify"
875        );
876    }
877
878    #[test]
879    fn domain_confusion_is_rejected() {
880        // A signature minted from the same raw key over a non-session message (the
881        // `approval_response` family's canonical shape) must never verify as
882        // a session token — proves the domain-separation prefix actually
883        // separates artifact kinds, not just that signatures verify at all.
884        let signer = test_signer();
885        let denylist = RevokedTokens::new();
886        let (payload, signature, _pk) = crate::approval::response_payload(
887            "req-1",
888            "some_tool",
889            "{}",
890            "",
891            true,
892            false,
893            &[],
894            "persona-1",
895            "",
896            "default",
897            "",
898            "",
899            "conv-1",
900            "nonce-1",
901            "",
902            &signer.relabel_for_test(),
903        );
904        let _ = payload; // the full approval payload is irrelevant here
905        let forged = format!(
906            "{}.{}",
907            URL_SAFE_NO_PAD.encode(
908                serde_json::json!({
909                    "issuer": SessionRole::ISSUER,
910                    "key_id": signer.identity().key_id(),
911                    "sub_kind": "persona",
912                    "persona_id": "persona-1",
913                    "scopes": ["explorer-read"],
914                    "issued_ms": NOW,
915                    "expires_ms": NOW + TEST_TTL_MS,
916                })
917                .to_string()
918            ),
919            URL_SAFE_NO_PAD.encode(signature)
920        );
921
922        assert!(
923            verify_session(&signer.public_key_bytes(), &forged, NOW, &denylist).is_none(),
924            "an approval_response signature must not verify as a session token"
925        );
926    }
927
928    #[test]
929    fn a_v1_session_token_from_before_adr_0007_no_longer_verifies() {
930        // Hand-built under the OLD domain prefix + payload shape
931        // (`polychrome.explorer-session.v1\0`, `{persona_id, issued_ms,
932        // expires_ms}`, no scopes/sub_kind) — this is what a live session
933        // minted before this change looks like. It must fail closed under
934        // the new verifier rather than silently mis-parsing into some
935        // default subject/scope, per the no-legacy-code, no-dual-accept
936        // choice documented on `SESSION_DOMAIN_PREFIX`.
937        let signer = test_signer();
938        let denylist = RevokedTokens::new();
939        let old_prefix = b"polychrome.explorer-session.v1\0";
940        let old_body = serde_json::json!({
941            "persona_id": "persona-1",
942            "issued_ms": NOW,
943            "expires_ms": NOW + TEST_TTL_MS,
944        })
945        .to_string();
946        let mut signed_bytes = Vec::new();
947        signed_bytes.extend_from_slice(old_prefix);
948        signed_bytes.extend_from_slice(old_body.as_bytes());
949        let signature = signer.sign(&signed_bytes);
950        let old_token = format!(
951            "{}.{}",
952            URL_SAFE_NO_PAD.encode(old_body.as_bytes()),
953            URL_SAFE_NO_PAD.encode(signature)
954        );
955
956        assert!(
957            verify_session(&signer.public_key_bytes(), &old_token, NOW, &denylist).is_none(),
958            "a pre-ADR-0007 v1 session token must not verify under the new prefix/shape"
959        );
960    }
961
962    // ---- wallet subject + scopes ------------------------------------------
963
964    #[test]
965    fn a_wallet_session_with_no_linked_persona_verifies_wallet_manage_only() {
966        let signer = test_signer();
967        let denylist = RevokedTokens::new();
968        let subject = SessionSubject::Wallet {
969            wallet_address: "0xabc".to_owned(),
970            persona_id: None,
971        };
972        let token = mint_session(
973            &signer,
974            &subject,
975            &[SessionScope::WalletManage],
976            NOW,
977            TEST_TTL_MS,
978        );
979
980        let claims = verify_session(&signer.public_key_bytes(), &token, NOW, &denylist)
981            .expect("a fresh wallet-only session verifies");
982        assert_eq!(claims.subject.persona_id(), None);
983        assert_eq!(claims.subject.wallet_address(), Some("0xabc"));
984        assert!(claims.has_scope(SessionScope::WalletManage));
985        assert!(!claims.has_scope(SessionScope::ExplorerRead));
986    }
987
988    #[test]
989    fn a_wallet_session_with_a_linked_persona_can_carry_both_scopes() {
990        let signer = test_signer();
991        let denylist = RevokedTokens::new();
992        let subject = SessionSubject::Wallet {
993            wallet_address: "0xabc".to_owned(),
994            persona_id: Some("persona-1".to_owned()),
995        };
996        let token = mint_session(
997            &signer,
998            &subject,
999            &[SessionScope::ExplorerRead, SessionScope::WalletManage],
1000            NOW,
1001            TEST_TTL_MS,
1002        );
1003
1004        let claims =
1005            verify_session(&signer.public_key_bytes(), &token, NOW, &denylist).expect("verifies");
1006        assert_eq!(claims.subject.persona_id(), Some("persona-1"));
1007        assert!(claims.has_scope(SessionScope::ExplorerRead));
1008        assert!(claims.has_scope(SessionScope::WalletManage));
1009    }
1010
1011    // ---- agent-turn scope --------------------------------------------------
1012
1013    #[test]
1014    fn an_agent_turn_scope_round_trips_through_its_wire_string() {
1015        // Pins the wire string itself, not just the enum: `agent-turn` is what
1016        // a live token carries, so renaming the variant must not move it.
1017        let signer = test_signer();
1018        let denylist = RevokedTokens::new();
1019        let token = mint_session(
1020            &signer,
1021            &persona_subject("persona-1"),
1022            &[SessionScope::ExplorerRead, SessionScope::AgentTurn],
1023            NOW,
1024            TEST_TTL_MS,
1025        );
1026        let (payload_b64, _) = token.split_once('.').expect("shape");
1027        let payload = URL_SAFE_NO_PAD.decode(payload_b64).expect("decodes");
1028        let json: serde_json::Value = serde_json::from_slice(&payload).expect("json");
1029        assert_eq!(
1030            json["scopes"],
1031            serde_json::json!(["explorer-read", "agent-turn"]),
1032            "the on-the-wire scope string must be exactly `agent-turn`"
1033        );
1034
1035        let claims =
1036            verify_session(&signer.public_key_bytes(), &token, NOW, &denylist).expect("verifies");
1037        assert!(claims.has_scope(SessionScope::AgentTurn));
1038    }
1039
1040    #[test]
1041    fn explorer_read_alone_does_not_carry_agent_turn() {
1042        // The load-bearing one: a turn is a write that spends money, so a
1043        // read-only session must answer `false` for `AgentTurn` rather than
1044        // having it fall out of "holds any scope at all". ADR 0007's
1045        // independence rule lives or dies here.
1046        let signer = test_signer();
1047        let denylist = RevokedTokens::new();
1048        let token = mint_persona_session(&signer, "persona-1", NOW, TEST_TTL_MS);
1049
1050        let claims =
1051            verify_session(&signer.public_key_bytes(), &token, NOW, &denylist).expect("verifies");
1052        assert!(claims.has_scope(SessionScope::ExplorerRead));
1053        assert!(
1054            !claims.has_scope(SessionScope::AgentTurn),
1055            "an explorer-read session must never imply turn dispatch"
1056        );
1057    }
1058
1059    #[test]
1060    fn an_unrecognized_scope_string_fails_the_whole_token_closed() {
1061        // A hand-built payload with a scope string this binary doesn't
1062        // recognize (e.g. a scope a NEWER binary minted) must refuse the
1063        // whole token, not silently drop the unknown scope and verify with
1064        // whatever scopes it DOES recognize.
1065        let signer = test_signer();
1066        let denylist = RevokedTokens::new();
1067        let body = serde_json::json!({
1068            "issuer": SessionRole::ISSUER,
1069            "key_id": signer.identity().key_id(),
1070            "sub_kind": "persona",
1071            "persona_id": "persona-1",
1072            "scopes": ["explorer-read", "some-future-scope"],
1073            "issued_ms": NOW,
1074            "expires_ms": NOW + TEST_TTL_MS,
1075        })
1076        .to_string();
1077        let mut signed_bytes = Vec::new();
1078        signed_bytes.extend_from_slice(SESSION_DOMAIN_PREFIX);
1079        signed_bytes.extend_from_slice(body.as_bytes());
1080        let signature = signer.sign(&signed_bytes);
1081        let token = format!(
1082            "{}.{}",
1083            URL_SAFE_NO_PAD.encode(body.as_bytes()),
1084            URL_SAFE_NO_PAD.encode(signature)
1085        );
1086
1087        assert!(
1088            verify_session(&signer.public_key_bytes(), &token, NOW, &denylist).is_none(),
1089            "an unrecognized scope must refuse the whole token, not drop just that scope"
1090        );
1091    }
1092
1093    #[test]
1094    fn a_session_minted_before_rotation_verifies_only_with_historical_trust() {
1095        let retired = SessionSigner::from_seed(91);
1096        let current = SessionSigner::from_seed(92);
1097        let token = mint_authorized_session(
1098            &retired,
1099            "session-before-rotation",
1100            4,
1101            &persona_subject("persona-1"),
1102            &[SessionScope::ExplorerRead],
1103            NOW,
1104            TEST_TTL_MS,
1105        );
1106        let current_only = RoleTrustSet::<SessionRole>::current(&current);
1107        let historical =
1108            RoleTrustSet::<SessionRole>::checked(vec![current.identity(), retired.identity()])
1109                .expect("valid rotation history");
1110
1111        assert!(verify_authorized_session_with_trust(&current_only, &token, NOW).is_none());
1112        assert!(verify_authorized_session_with_trust(&historical, &token, NOW).is_some());
1113    }
1114
1115    #[test]
1116    #[should_panic(expected = "at least one scope")]
1117    fn mint_session_panics_on_an_empty_scope_list() {
1118        let signer = test_signer();
1119        let _ = mint_session(
1120            &signer,
1121            &persona_subject("persona-1"),
1122            &[],
1123            NOW,
1124            TEST_TTL_MS,
1125        );
1126    }
1127
1128    // ---- reaper ---------------------------------------------------------------
1129
1130    #[test]
1131    fn reap_evicts_an_expired_denylisted_token_but_keeps_a_live_one() {
1132        let signer = test_signer();
1133        let denylist = RevokedTokens::new();
1134        let expired = mint_persona_session(&signer, "persona-1", NOW, 1_000);
1135        let live = mint_persona_session(&signer, "persona-2", NOW, TEST_TTL_MS);
1136        denylist.revoke(&expired);
1137        denylist.revoke(&live);
1138        assert_eq!(
1139            denylist.len(),
1140            2,
1141            "both revoked tokens are stored before reap"
1142        );
1143
1144        // `expired`'s own embedded `expires_ms` (NOW + 1_000) has passed at
1145        // this instant; `live`'s (NOW + TEST_TTL_MS) has not.
1146        denylist.reap(NOW + 1_000);
1147
1148        assert_eq!(
1149            denylist.len(),
1150            1,
1151            "reap must drop the token whose own expiry has passed and keep the still-live one"
1152        );
1153    }
1154
1155    #[test]
1156    fn reap_drops_an_unparseable_denylisted_entry() {
1157        // `revoke` accepts whatever string a caller posts (no `mint_session`
1158        // origin check), so the denylist can hold garbage a malicious or
1159        // buggy caller revoked. Such an entry can never match a real session
1160        // anyway, so `reap` treats "fails to parse" the same as "expired".
1161        let denylist = RevokedTokens::new();
1162        denylist.revoke("not-a-real-session-token");
1163        assert_eq!(denylist.len(), 1);
1164
1165        denylist.reap(NOW);
1166
1167        assert_eq!(
1168            denylist.len(),
1169            0,
1170            "an unparseable entry is reaped unconditionally"
1171        );
1172    }
1173}