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