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_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}