Skip to main content

aitp_session_bundle/
verifier.rs

1//! Session Trust Bundle verification (RFC-AITP-0010 §5-§6).
2
3use crate::builder::{BundleSigningView, DEFAULT_BUNDLE_VERSION};
4use crate::error::SessionBundleError;
5use crate::types::SessionTrustBundle;
6use aitp_core::{jcs, Aid, Timestamp};
7use aitp_crypto::{AitpVerifyingKey, Signature};
8use aitp_tct::{verify_tct, 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.1"`.
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.
82    let computed_min = bundle
83        .participants
84        .iter()
85        .map(|p| p.tct.expires_at)
86        .min_by_key(|t| t.0)
87        .ok_or(SessionBundleError::EmptyParticipants)?;
88    if computed_min.0 != bundle.expires_at.0 {
89        return Err(SessionBundleError::ExpiryWindowInvariant);
90    }
91
92    // 4.
93    let verifier_present = bundle
94        .participants
95        .iter()
96        .any(|p| &p.aid == ctx.verifier_aid);
97    if !verifier_present {
98        return Err(SessionBundleError::NotMember);
99    }
100
101    // 5. Outer signature.
102    let coord_key = AitpVerifyingKey::from_aid(&bundle.coordinator)?;
103    let view = BundleSigningView {
104        version: &bundle.version,
105        session_id: &bundle.session_id,
106        coordinator: &bundle.coordinator,
107        issued_at: &bundle.issued_at,
108        expires_at: &bundle.expires_at,
109        participants: &bundle.participants,
110    };
111    let canonical = jcs::canonicalize_serializable(&view)
112        .map_err(|e| SessionBundleError::Canonicalization(e.to_string()))?;
113    let digest = Sha256::digest(&canonical);
114    let outer_sig =
115        Signature::parse(&bundle.signature).map_err(|_| SessionBundleError::InvalidSignature)?;
116    coord_key
117        .verify(&digest, &outer_sig)
118        .map_err(|_| SessionBundleError::InvalidSignature)?;
119
120    // 6. Per-participant TCT verification. The coordinator's pubkey
121    // is the issuer's pubkey for every embedded TCT (§3 invariant).
122    let mut active = Vec::with_capacity(bundle.participants.len());
123    let mut dropped = Vec::new();
124
125    for entry in &bundle.participants {
126        // Coordinator is the issuer of every embedded TCT.
127        if entry.tct.issuer != bundle.coordinator {
128            return Err(SessionBundleError::CoordinatorIssuerMismatch);
129        }
130        // The bundle redistributes each participant's OWN TCT — so
131        // audience MUST be the entry's AID.
132        if entry.tct.audience != entry.aid {
133            return Err(SessionBundleError::AudienceMismatch);
134        }
135        // Full TCT verification (issuer signature, expiry, binding).
136        let tct_ctx = TctVerifyContext {
137            expected_audience: &entry.aid,
138            issuer_pubkey: &coord_key,
139            now: ctx.now,
140            issuer_manifest_expires_at: None,
141            revocation_check: None, // applied separately below for §7
142        };
143        verify_tct(&entry.tct, &tct_ctx).map_err(SessionBundleError::TctVerification)?;
144
145        // 7. Per-pair revocation: drop revoked participants but don't
146        // fail the whole bundle.
147        let is_revoked = ctx
148            .revocation_check
149            .map(|check| check(&entry.tct.jti))
150            .unwrap_or(false);
151        if is_revoked {
152            dropped.push(entry.aid.clone());
153        } else {
154            active.push(entry.aid.clone());
155        }
156    }
157
158    if dropped.is_empty() {
159        Ok(BundleOutcome::Clear {
160            active_aids: active,
161        })
162    } else {
163        Ok(BundleOutcome::DegradedSubset {
164            active_aids: active,
165            dropped_aids: dropped,
166        })
167    }
168}