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_manifest::{verify_manifest, Manifest, VerifyManifestContext};
9use aitp_tct::{verify_tct, TctError, TctVerifyContext};
10use uuid::Uuid;
11
12/// How [`verify_session_bundle`] resolves the coordinator's public key
13/// (RFC-AITP-0010 §5 step 5).
14#[derive(Clone, Copy)]
15pub enum CoordinatorKeySource<'a> {
16 /// The coordinator's Manifest, as fetched by the caller (for example
17 /// with `aitp_transport_http::ManifestFetcher::fetch`). The verifier
18 /// re-runs [`aitp_manifest::verify_manifest`] on it (RFC-AITP-0003 §5),
19 /// requires `manifest.aid == bundle.coordinator`, and resolves the key
20 /// from `manifest.aid`. The Manifest's `expires_at` also caps every
21 /// embedded TCT's `exp` (RFC-AITP-0005 §10.4), since the coordinator
22 /// is their issuer.
23 Manifest(&'a Manifest),
24 /// Skip the Manifest step and derive the key from the
25 /// `bundle.coordinator` AID alone. **Not RFC-AITP-0010 §5 step 5
26 /// conformant**: nothing establishes that the coordinator publishes a
27 /// valid, unexpired Manifest. Only for callers that pin the
28 /// coordinator out of band and have no Manifest to hand (offline
29 /// verification, tests). The conformance adapter and the language
30 /// bindings never use it: they require the coordinator's Manifest.
31 UnverifiedAidDangerous,
32}
33
34/// Inputs for [`verify_session_bundle`].
35pub struct VerifySessionBundleContext<'a> {
36 /// Verifier's own AID. The bundle MUST list this AID in
37 /// `participants[]`; otherwise verification returns
38 /// [`SessionBundleError::NotMember`].
39 pub verifier_aid: &'a Aid,
40 /// Current time, for expiry checks.
41 pub now: Timestamp,
42 /// Optional revocation lookup against the verifier's deny list.
43 /// Returns `true` if the JTI is revoked. Per-pair degradation
44 /// (RFC-AITP-0010 §7): a revoked participant is dropped from the
45 /// active set, but the bundle as a whole remains usable so long
46 /// as the verifier's own TCT is still valid.
47 pub revocation_check: Option<&'a dyn Fn(&Uuid) -> bool>,
48 /// Where the coordinator's key comes from (§5 step 5).
49 pub coordinator_key: CoordinatorKeySource<'a>,
50}
51
52/// Outcome of verifying a Session Trust Bundle.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum BundleOutcome {
55 /// Every participant TCT verified clean. The full membership set
56 /// is usable for coordinator-attested trust.
57 Clear {
58 /// All AIDs in the bundle.
59 active_aids: Vec<Aid>,
60 },
61 /// At least one participant's TCT was revoked. Their AID is
62 /// removed from `active_aids`. The verifier's own AID is still in
63 /// the active set (otherwise the bundle is useless to this
64 /// verifier and we'd return [`SessionBundleError::NotMember`]
65 /// equivalent — but keep this strictly informational; callers
66 /// decide policy).
67 DegradedSubset {
68 /// AIDs whose TCTs verified.
69 active_aids: Vec<Aid>,
70 /// AIDs whose TCTs were revoked.
71 dropped_aids: Vec<Aid>,
72 },
73}
74
75/// Verify a session bundle, in RFC-AITP-0010 §5 order:
76///
77/// 0. Member-set check — done by the wire parser
78/// ([`crate::parse_session_bundle_wire`]) before this runs.
79/// 1. `version == "aitp/0.3"`.
80/// 2. `expires_at` not in the past.
81/// 3. `expires_at == min(participants[*].tct.exp)` invariant. Vacuous on
82/// an empty `participants` array (there is no minimum to exceed), so
83/// an empty bundle is reported by step 4.
84/// 4. `participants` non-empty.
85/// 5. Coordinator key resolution per [`CoordinatorKeySource`]: verify
86/// the coordinator's Manifest (RFC-AITP-0003 §5) and resolve the key
87/// from `manifest.aid`.
88/// 6. Outer bundle signature against that key.
89/// 7. Each participant TCT: issuer == coordinator, audience == entry.aid,
90/// [`verify_tct`] passes. Per-pair revocation (§7): a revoked TCT's
91/// participant is dropped from `active_aids`, not an error.
92/// 8. Verifier's AID is present in `participants[]` (its TCT's `aud`
93/// was pinned to that AID in step 7).
94pub fn verify_session_bundle(
95 bundle: &SessionTrustBundle,
96 ctx: &VerifySessionBundleContext<'_>,
97) -> Result<BundleOutcome, SessionBundleError> {
98 // 1.
99 if bundle.version != DEFAULT_BUNDLE_VERSION {
100 return Err(SessionBundleError::VersionMismatch);
101 }
102
103 // 2.
104 if bundle.expires_at.is_in_the_past(ctx.now) {
105 return Err(SessionBundleError::Expired);
106 }
107
108 // 3. Expiry-window invariant against the (still-unverified) claim
109 // peeks — the embedded token strings become coordinator-attested
110 // once the outer signature verifies in step 6, and each is then
111 // fully verified in step 7.
112 let mut computed_min: Option<Timestamp> = None;
113 for p in &bundle.participants {
114 let exp = peek_tct_claims(&p.tct)?.exp;
115 computed_min = Some(match computed_min {
116 Some(m) if m.0 <= exp.0 => m,
117 _ => exp,
118 });
119 }
120 if let Some(computed_min) = computed_min {
121 if computed_min.0 != bundle.expires_at.0 {
122 return Err(SessionBundleError::ExpiryWindowInvariant);
123 }
124 }
125
126 // 4.
127 if bundle.participants.is_empty() {
128 return Err(SessionBundleError::EmptyParticipants);
129 }
130
131 // 5. Coordinator key resolution.
132 let (coord_key, coordinator_manifest_expires_at) = match ctx.coordinator_key {
133 CoordinatorKeySource::Manifest(manifest) => {
134 verify_manifest(manifest, &VerifyManifestContext { now: ctx.now })
135 .map_err(SessionBundleError::CoordinatorManifest)?;
136 if manifest.aid != bundle.coordinator {
137 return Err(SessionBundleError::CoordinatorManifestMismatch);
138 }
139 (
140 AitpVerifyingKey::from_aid(&manifest.aid)?,
141 Some(manifest.expires_at),
142 )
143 }
144 CoordinatorKeySource::UnverifiedAidDangerous => {
145 (AitpVerifyingKey::from_aid(&bundle.coordinator)?, None)
146 }
147 };
148
149 // 6. Outer signature.
150 let canonical = crate::builder::bundle_signing_bytes(&crate::builder::BundleSigningBody {
151 version: &bundle.version,
152 session_id: &bundle.session_id,
153 coordinator: &bundle.coordinator,
154 issued_at: &bundle.issued_at,
155 expires_at: &bundle.expires_at,
156 participants: &bundle.participants,
157 extensions: bundle.extensions.as_ref(),
158 })?;
159 let digest = aitp_crypto::Digest32::sha256(&canonical);
160 let outer_sig =
161 Signature::parse(&bundle.signature).map_err(|_| SessionBundleError::InvalidSignature)?;
162 coord_key
163 .verify_jcs(&digest, &outer_sig)
164 .map_err(|_| SessionBundleError::InvalidSignature)?;
165
166 // 7. Per-participant TCT verification. The coordinator's pubkey
167 // is the issuer's pubkey for every embedded TCT (§3 invariant).
168 let mut active = Vec::with_capacity(bundle.participants.len());
169 let mut dropped = Vec::new();
170
171 for entry in &bundle.participants {
172 // Full TCT verification (typ, alg pin, signature, claims).
173 // `verify_tct` pins the issuer to the coordinator's AID and
174 // the audience to the entry's AID — the §3 invariants surface
175 // as the bundle-specific error codes below.
176 let tct_ctx = TctVerifyContext::builder(&entry.aid, &bundle.coordinator, ctx.now)
177 // §7 per-pair revocation is applied separately below, after
178 // signature verification (RFC-AITP-0008 §3.3 ordering).
179 .accept_unchecked_revocation_dangerous();
180 // The coordinator issued every embedded TCT, so its verified
181 // Manifest's `expires_at` is the issuer-Manifest cap. Without a
182 // Manifest (`UnverifiedAidDangerous`) the cap cannot apply; the
183 // bundle's own expiry-window invariant still bounds lifetimes.
184 let tct_ctx = match coordinator_manifest_expires_at {
185 Some(expires_at) => tct_ctx.issuer_manifest_expires_at(expires_at),
186 None => tct_ctx.skip_manifest_expiry_cap_dangerous(),
187 }
188 .build()
189 .expect("both verify decisions are made above");
190 let verified = match verify_tct(&entry.tct, &tct_ctx) {
191 Ok(v) => v,
192 Err(TctError::IssuerMismatch) => {
193 return Err(SessionBundleError::CoordinatorIssuerMismatch)
194 }
195 Err(TctError::AudienceMismatch) => return Err(SessionBundleError::AudienceMismatch),
196 Err(e) => return Err(SessionBundleError::TctVerification(e)),
197 };
198
199 // Per-pair revocation (§7): drop revoked participants but don't
200 // fail the whole bundle.
201 let is_revoked = ctx
202 .revocation_check
203 .map(|check| check(&verified.claims.jti))
204 .unwrap_or(false);
205 if is_revoked {
206 dropped.push(entry.aid.clone());
207 } else {
208 active.push(entry.aid.clone());
209 }
210 }
211
212 // 8. Self-membership, last: every embedded TCT has verified, and
213 // step 7 pinned each one's `aud` to its entry's AID.
214 let verifier_present = bundle
215 .participants
216 .iter()
217 .any(|p| &p.aid == ctx.verifier_aid);
218 if !verifier_present {
219 return Err(SessionBundleError::NotMember);
220 }
221
222 if dropped.is_empty() {
223 Ok(BundleOutcome::Clear {
224 active_aids: active,
225 })
226 } else {
227 Ok(BundleOutcome::DegradedSubset {
228 active_aids: active,
229 dropped_aids: dropped,
230 })
231 }
232}