1use 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
40const SESSION_DOMAIN_PREFIX: &[u8] = b"polychrome.web-session.v2\0";
60
61const AUTHORIZED_SESSION_DOMAIN_PREFIX: &[u8] = b"polychrome.web-session.v3\0";
68
69#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum SessionSubject {
77 Persona {
79 persona_id: String,
81 },
82 Wallet {
86 wallet_address: String,
88 persona_id: Option<String>,
92 },
93}
94
95impl SessionSubject {
96 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
127pub enum SessionScope {
128 ExplorerRead,
132 WalletManage,
135 AgentTurn,
146}
147
148impl SessionScope {
149 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 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#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct SessionClaims {
179 pub issuer: String,
181 pub key_id: String,
183 pub subject: SessionSubject,
185 pub scopes: Vec<SessionScope>,
189 pub issued_ms: u64,
191 pub expires_ms: u64,
193}
194
195impl SessionClaims {
196 #[must_use]
198 pub fn has_scope(&self, scope: SessionScope) -> bool {
199 self.scopes.contains(&scope)
200 }
201}
202
203#[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
263fn 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#[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
326fn 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 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#[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#[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#[derive(Debug, Clone, PartialEq, Eq)]
420pub struct AuthorizedSessionClaims {
421 pub issuer: String,
423 pub key_id: String,
425 pub session_id: String,
427 pub authorization_epoch: u64,
429 pub subject: SessionSubject,
431 pub scopes: Vec<SessionScope>,
433 pub issued_ms: u64,
435 pub expires_ms: u64,
437}
438
439impl AuthorizedSessionClaims {
440 #[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#[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#[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#[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
579pub struct RevokedTokens {
587 tokens: Mutex<HashSet<String>>,
591}
592
593impl RevokedTokens {
594 #[must_use]
596 pub fn new() -> Self {
597 Self {
598 tokens: Mutex::new(HashSet::new()),
599 }
600 }
601
602 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 #[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 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 #[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 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 #[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 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 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 &signer.relabel_for_test(),
902 );
903 let _ = payload; let forged = format!(
905 "{}.{}",
906 URL_SAFE_NO_PAD.encode(
907 serde_json::json!({
908 "issuer": SessionRole::ISSUER,
909 "key_id": signer.identity().key_id(),
910 "sub_kind": "persona",
911 "persona_id": "persona-1",
912 "scopes": ["explorer-read"],
913 "issued_ms": NOW,
914 "expires_ms": NOW + TEST_TTL_MS,
915 })
916 .to_string()
917 ),
918 URL_SAFE_NO_PAD.encode(signature)
919 );
920
921 assert!(
922 verify_session(&signer.public_key_bytes(), &forged, NOW, &denylist).is_none(),
923 "an approval_response signature must not verify as a session token"
924 );
925 }
926
927 #[test]
928 fn a_v1_session_token_from_before_adr_0007_no_longer_verifies() {
929 let signer = test_signer();
937 let denylist = RevokedTokens::new();
938 let old_prefix = b"polychrome.explorer-session.v1\0";
939 let old_body = serde_json::json!({
940 "persona_id": "persona-1",
941 "issued_ms": NOW,
942 "expires_ms": NOW + TEST_TTL_MS,
943 })
944 .to_string();
945 let mut signed_bytes = Vec::new();
946 signed_bytes.extend_from_slice(old_prefix);
947 signed_bytes.extend_from_slice(old_body.as_bytes());
948 let signature = signer.sign(&signed_bytes);
949 let old_token = format!(
950 "{}.{}",
951 URL_SAFE_NO_PAD.encode(old_body.as_bytes()),
952 URL_SAFE_NO_PAD.encode(signature)
953 );
954
955 assert!(
956 verify_session(&signer.public_key_bytes(), &old_token, NOW, &denylist).is_none(),
957 "a pre-ADR-0007 v1 session token must not verify under the new prefix/shape"
958 );
959 }
960
961 #[test]
964 fn a_wallet_session_with_no_linked_persona_verifies_wallet_manage_only() {
965 let signer = test_signer();
966 let denylist = RevokedTokens::new();
967 let subject = SessionSubject::Wallet {
968 wallet_address: "0xabc".to_owned(),
969 persona_id: None,
970 };
971 let token = mint_session(
972 &signer,
973 &subject,
974 &[SessionScope::WalletManage],
975 NOW,
976 TEST_TTL_MS,
977 );
978
979 let claims = verify_session(&signer.public_key_bytes(), &token, NOW, &denylist)
980 .expect("a fresh wallet-only session verifies");
981 assert_eq!(claims.subject.persona_id(), None);
982 assert_eq!(claims.subject.wallet_address(), Some("0xabc"));
983 assert!(claims.has_scope(SessionScope::WalletManage));
984 assert!(!claims.has_scope(SessionScope::ExplorerRead));
985 }
986
987 #[test]
988 fn a_wallet_session_with_a_linked_persona_can_carry_both_scopes() {
989 let signer = test_signer();
990 let denylist = RevokedTokens::new();
991 let subject = SessionSubject::Wallet {
992 wallet_address: "0xabc".to_owned(),
993 persona_id: Some("persona-1".to_owned()),
994 };
995 let token = mint_session(
996 &signer,
997 &subject,
998 &[SessionScope::ExplorerRead, SessionScope::WalletManage],
999 NOW,
1000 TEST_TTL_MS,
1001 );
1002
1003 let claims =
1004 verify_session(&signer.public_key_bytes(), &token, NOW, &denylist).expect("verifies");
1005 assert_eq!(claims.subject.persona_id(), Some("persona-1"));
1006 assert!(claims.has_scope(SessionScope::ExplorerRead));
1007 assert!(claims.has_scope(SessionScope::WalletManage));
1008 }
1009
1010 #[test]
1013 fn an_agent_turn_scope_round_trips_through_its_wire_string() {
1014 let signer = test_signer();
1017 let denylist = RevokedTokens::new();
1018 let token = mint_session(
1019 &signer,
1020 &persona_subject("persona-1"),
1021 &[SessionScope::ExplorerRead, SessionScope::AgentTurn],
1022 NOW,
1023 TEST_TTL_MS,
1024 );
1025 let (payload_b64, _) = token.split_once('.').expect("shape");
1026 let payload = URL_SAFE_NO_PAD.decode(payload_b64).expect("decodes");
1027 let json: serde_json::Value = serde_json::from_slice(&payload).expect("json");
1028 assert_eq!(
1029 json["scopes"],
1030 serde_json::json!(["explorer-read", "agent-turn"]),
1031 "the on-the-wire scope string must be exactly `agent-turn`"
1032 );
1033
1034 let claims =
1035 verify_session(&signer.public_key_bytes(), &token, NOW, &denylist).expect("verifies");
1036 assert!(claims.has_scope(SessionScope::AgentTurn));
1037 }
1038
1039 #[test]
1040 fn explorer_read_alone_does_not_carry_agent_turn() {
1041 let signer = test_signer();
1046 let denylist = RevokedTokens::new();
1047 let token = mint_persona_session(&signer, "persona-1", NOW, TEST_TTL_MS);
1048
1049 let claims =
1050 verify_session(&signer.public_key_bytes(), &token, NOW, &denylist).expect("verifies");
1051 assert!(claims.has_scope(SessionScope::ExplorerRead));
1052 assert!(
1053 !claims.has_scope(SessionScope::AgentTurn),
1054 "an explorer-read session must never imply turn dispatch"
1055 );
1056 }
1057
1058 #[test]
1059 fn an_unrecognized_scope_string_fails_the_whole_token_closed() {
1060 let signer = test_signer();
1065 let denylist = RevokedTokens::new();
1066 let body = serde_json::json!({
1067 "issuer": SessionRole::ISSUER,
1068 "key_id": signer.identity().key_id(),
1069 "sub_kind": "persona",
1070 "persona_id": "persona-1",
1071 "scopes": ["explorer-read", "some-future-scope"],
1072 "issued_ms": NOW,
1073 "expires_ms": NOW + TEST_TTL_MS,
1074 })
1075 .to_string();
1076 let mut signed_bytes = Vec::new();
1077 signed_bytes.extend_from_slice(SESSION_DOMAIN_PREFIX);
1078 signed_bytes.extend_from_slice(body.as_bytes());
1079 let signature = signer.sign(&signed_bytes);
1080 let token = format!(
1081 "{}.{}",
1082 URL_SAFE_NO_PAD.encode(body.as_bytes()),
1083 URL_SAFE_NO_PAD.encode(signature)
1084 );
1085
1086 assert!(
1087 verify_session(&signer.public_key_bytes(), &token, NOW, &denylist).is_none(),
1088 "an unrecognized scope must refuse the whole token, not drop just that scope"
1089 );
1090 }
1091
1092 #[test]
1093 fn a_session_minted_before_rotation_verifies_only_with_historical_trust() {
1094 let retired = SessionSigner::from_seed(91);
1095 let current = SessionSigner::from_seed(92);
1096 let token = mint_authorized_session(
1097 &retired,
1098 "session-before-rotation",
1099 4,
1100 &persona_subject("persona-1"),
1101 &[SessionScope::ExplorerRead],
1102 NOW,
1103 TEST_TTL_MS,
1104 );
1105 let current_only = RoleTrustSet::<SessionRole>::current(¤t);
1106 let historical =
1107 RoleTrustSet::<SessionRole>::checked(vec![current.identity(), retired.identity()])
1108 .expect("valid rotation history");
1109
1110 assert!(verify_authorized_session_with_trust(¤t_only, &token, NOW).is_none());
1111 assert!(verify_authorized_session_with_trust(&historical, &token, NOW).is_some());
1112 }
1113
1114 #[test]
1115 #[should_panic(expected = "at least one scope")]
1116 fn mint_session_panics_on_an_empty_scope_list() {
1117 let signer = test_signer();
1118 let _ = mint_session(
1119 &signer,
1120 &persona_subject("persona-1"),
1121 &[],
1122 NOW,
1123 TEST_TTL_MS,
1124 );
1125 }
1126
1127 #[test]
1130 fn reap_evicts_an_expired_denylisted_token_but_keeps_a_live_one() {
1131 let signer = test_signer();
1132 let denylist = RevokedTokens::new();
1133 let expired = mint_persona_session(&signer, "persona-1", NOW, 1_000);
1134 let live = mint_persona_session(&signer, "persona-2", NOW, TEST_TTL_MS);
1135 denylist.revoke(&expired);
1136 denylist.revoke(&live);
1137 assert_eq!(
1138 denylist.len(),
1139 2,
1140 "both revoked tokens are stored before reap"
1141 );
1142
1143 denylist.reap(NOW + 1_000);
1146
1147 assert_eq!(
1148 denylist.len(),
1149 1,
1150 "reap must drop the token whose own expiry has passed and keep the still-live one"
1151 );
1152 }
1153
1154 #[test]
1155 fn reap_drops_an_unparseable_denylisted_entry() {
1156 let denylist = RevokedTokens::new();
1161 denylist.revoke("not-a-real-session-token");
1162 assert_eq!(denylist.len(), 1);
1163
1164 denylist.reap(NOW);
1165
1166 assert_eq!(
1167 denylist.len(),
1168 0,
1169 "an unparseable entry is reaped unconditionally"
1170 );
1171 }
1172}