aitp_session_bundle/verifier.rs
1//! Session Trust Bundle verification (RFC-AITP-0010 §5-§6).
2
3use crate::builder::{peek_tct_claims, DEFAULT_BUNDLE_VERSION};
4use crate::error::SessionBundleError;
5use crate::types::SessionTrustBundle;
6use aitp_core::{Aid, Timestamp};
7use aitp_crypto::{AitpVerifyingKey, Signature};
8use aitp_tct::{verify_tct, TctError, TctVerifyContext};
9use uuid::Uuid;
10
11/// Inputs for [`verify_session_bundle`].
12pub struct VerifySessionBundleContext<'a> {
13 /// Verifier's own AID. The bundle MUST list this AID in
14 /// `participants[]`; otherwise verification returns
15 /// [`SessionBundleError::NotMember`].
16 pub verifier_aid: &'a Aid,
17 /// Current time, for expiry checks.
18 pub now: Timestamp,
19 /// Optional revocation lookup against the verifier's deny list.
20 /// Returns `true` if the JTI is revoked. Per-pair degradation
21 /// (RFC-AITP-0010 §6): a revoked participant is dropped from the
22 /// active set, but the bundle as a whole remains usable so long
23 /// as the verifier's own TCT is still valid.
24 pub revocation_check: Option<&'a dyn Fn(&Uuid) -> bool>,
25}
26
27/// Outcome of verifying a Session Trust Bundle.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum BundleOutcome {
30 /// Every participant TCT verified clean. The full membership set
31 /// is usable for coordinator-attested trust.
32 Clear {
33 /// All AIDs in the bundle.
34 active_aids: Vec<Aid>,
35 },
36 /// At least one participant's TCT was revoked. Their AID is
37 /// removed from `active_aids`. The verifier's own AID is still in
38 /// the active set (otherwise the bundle is useless to this
39 /// verifier and we'd return [`SessionBundleError::NotMember`]
40 /// equivalent — but keep this strictly informational; callers
41 /// decide policy).
42 DegradedSubset {
43 /// AIDs whose TCTs verified.
44 active_aids: Vec<Aid>,
45 /// AIDs whose TCTs were revoked.
46 dropped_aids: Vec<Aid>,
47 },
48}
49
50/// Verify a session bundle.
51///
52/// Order of checks:
53/// 1. `version == "aitp/0.2"`.
54/// 2. `expires_at` not in the past.
55/// 3. `expires_at == min(participants[*].tct.expires_at)` invariant.
56/// 4. Verifier's AID is present in `participants[]`.
57/// 5. Outer bundle signature against `coordinator`'s key.
58/// 6. Each participant TCT: issuer == coordinator, audience == entry.aid,
59/// [`verify_tct`] passes.
60/// 7. Per-pair revocation degradation: if any TCT JTI is in the deny
61/// list, that participant is dropped from `active_aids`.
62pub fn verify_session_bundle(
63 bundle: &SessionTrustBundle,
64 ctx: &VerifySessionBundleContext<'_>,
65) -> Result<BundleOutcome, SessionBundleError> {
66 // 1.
67 if bundle.version != DEFAULT_BUNDLE_VERSION {
68 return Err(SessionBundleError::VersionMismatch);
69 }
70
71 // 2.
72 if bundle.expires_at.is_in_the_past(ctx.now) {
73 return Err(SessionBundleError::Expired);
74 }
75
76 if bundle.participants.is_empty() {
77 return Err(SessionBundleError::EmptyParticipants);
78 }
79
80 // 3. Expiry-window invariant against the (still-unverified) claim
81 // peeks — the embedded token strings become coordinator-attested
82 // once the outer signature verifies in step 5, and each is then
83 // fully verified in step 6.
84 let mut computed_min: Option<Timestamp> = None;
85 for p in &bundle.participants {
86 let exp = peek_tct_claims(&p.tct)?.exp;
87 computed_min = Some(match computed_min {
88 Some(m) if m.0 <= exp.0 => m,
89 _ => exp,
90 });
91 }
92 let computed_min = computed_min.ok_or(SessionBundleError::EmptyParticipants)?;
93 if computed_min.0 != bundle.expires_at.0 {
94 return Err(SessionBundleError::ExpiryWindowInvariant);
95 }
96
97 // 4.
98 let verifier_present = bundle
99 .participants
100 .iter()
101 .any(|p| &p.aid == ctx.verifier_aid);
102 if !verifier_present {
103 return Err(SessionBundleError::NotMember);
104 }
105
106 // 5. Outer signature.
107 let coord_key = AitpVerifyingKey::from_aid(&bundle.coordinator)?;
108 let canonical = crate::builder::bundle_signing_bytes(&crate::builder::BundleSigningBody {
109 version: &bundle.version,
110 session_id: &bundle.session_id,
111 coordinator: &bundle.coordinator,
112 issued_at: &bundle.issued_at,
113 expires_at: &bundle.expires_at,
114 participants: &bundle.participants,
115 extensions: bundle.extensions.as_ref(),
116 })?;
117 let digest = aitp_crypto::Digest32::sha256(&canonical);
118 let outer_sig =
119 Signature::parse(&bundle.signature).map_err(|_| SessionBundleError::InvalidSignature)?;
120 coord_key
121 .verify_jcs(&digest, &outer_sig)
122 .map_err(|_| SessionBundleError::InvalidSignature)?;
123
124 // 6. Per-participant TCT verification. The coordinator's pubkey
125 // is the issuer's pubkey for every embedded TCT (§3 invariant).
126 let mut active = Vec::with_capacity(bundle.participants.len());
127 let mut dropped = Vec::new();
128
129 for entry in &bundle.participants {
130 // Full TCT verification (typ, alg pin, signature, claims).
131 // `verify_tct` pins the issuer to the coordinator's AID and
132 // the audience to the entry's AID — the §3 invariants surface
133 // as the bundle-specific error codes below.
134 let tct_ctx = TctVerifyContext::builder(&entry.aid, &bundle.coordinator, ctx.now)
135 // §7 per-pair revocation is applied separately below, after
136 // signature verification (RFC-AITP-0008 §3.3 ordering).
137 .accept_unchecked_revocation_dangerous()
138 // Participant TCTs are coordinator-attested within the bundle;
139 // no per-participant issuer Manifest is resolved here, so the
140 // Manifest-expiry cap does not apply (the bundle's own
141 // expiry-window invariant bounds lifetimes instead).
142 .skip_manifest_expiry_cap_dangerous()
143 .build()
144 .expect("both verify decisions are made above");
145 let verified = match verify_tct(&entry.tct, &tct_ctx) {
146 Ok(v) => v,
147 Err(TctError::IssuerMismatch) => {
148 return Err(SessionBundleError::CoordinatorIssuerMismatch)
149 }
150 Err(TctError::AudienceMismatch) => return Err(SessionBundleError::AudienceMismatch),
151 Err(e) => return Err(SessionBundleError::TctVerification(e)),
152 };
153
154 // 7. Per-pair revocation: drop revoked participants but don't
155 // fail the whole bundle.
156 let is_revoked = ctx
157 .revocation_check
158 .map(|check| check(&verified.claims.jti))
159 .unwrap_or(false);
160 if is_revoked {
161 dropped.push(entry.aid.clone());
162 } else {
163 active.push(entry.aid.clone());
164 }
165 }
166
167 if dropped.is_empty() {
168 Ok(BundleOutcome::Clear {
169 active_aids: active,
170 })
171 } else {
172 Ok(BundleOutcome::DegradedSubset {
173 active_aids: active,
174 dropped_aids: dropped,
175 })
176 }
177}