Skip to main content

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