Skip to main content

aitp_session_bundle/
builder.rs

1//! [`SessionTrustBundle`] builder (RFC-AITP-0010 §4).
2//!
3//! Coordinator-side: collect each participant's coordinator-issued TCT
4//! (one per bilateral handshake), assemble the bundle body, JCS-sign.
5
6use crate::error::SessionBundleError;
7use crate::types::{ParticipantEntry, SessionTrustBundle};
8use aitp_core::{jcs, Aid, Timestamp};
9use aitp_crypto::AitpSigningKey;
10use aitp_tct::TctClaims;
11use serde::Serialize;
12use sha2::{Digest, Sha256};
13use uuid::Uuid;
14
15/// `version` constant for v0.2 bundles.
16pub const DEFAULT_BUNDLE_VERSION: &str = "aitp/0.2";
17
18/// Decode a participant TCT's claims without verification. Builder- and
19/// invariant-level peeks only; full verification (signature, typ, alg
20/// pin) happens in [`crate::verify_session_bundle`] via
21/// [`aitp_tct::verify_tct`].
22pub(crate) fn peek_tct_claims(token: &str) -> Result<TctClaims, SessionBundleError> {
23    let payload = aitp_crypto::jws::decode_payload_unverified(token)
24        .map_err(|e| SessionBundleError::Canonicalization(format!("participant tct: {e}")))?;
25    serde_json::from_slice(&payload)
26        .map_err(|e| SessionBundleError::Canonicalization(format!("participant tct claims: {e}")))
27}
28
29/// Fluent builder for issuing a [`SessionTrustBundle`] as the
30/// coordinator.
31pub struct SessionBundleBuilder<'a> {
32    coordinator_key: &'a AitpSigningKey,
33    session_id: Option<Uuid>,
34    participants: Vec<ParticipantEntry>,
35    issued_at: Option<Timestamp>,
36}
37
38impl<'a> SessionBundleBuilder<'a> {
39    /// Begin a new bundle, signed by `coordinator_key`.
40    pub fn new(coordinator_key: &'a AitpSigningKey) -> Self {
41        Self {
42            coordinator_key,
43            session_id: None,
44            participants: Vec::new(),
45            issued_at: None,
46        }
47    }
48
49    /// Set the session ID (UUIDv4). If unset, a fresh one is generated
50    /// at `build()` time.
51    pub fn session_id(mut self, id: Uuid) -> Self {
52        self.session_id = Some(id);
53        self
54    }
55
56    /// Override `issued_at`. Tests / fixtures only.
57    pub fn issued_at(mut self, ts: Timestamp) -> Self {
58        self.issued_at = Some(ts);
59        self
60    }
61
62    /// Add a participant. The TCT (compact JWS, carried verbatim) MUST
63    /// be coordinator-issued (`iss == coordinator_key.aid()`) with
64    /// `aud == aid`. These invariants are checked in `build()`.
65    pub fn participant(mut self, aid: Aid, tct: String) -> Self {
66        self.participants.push(ParticipantEntry { aid, tct });
67        self
68    }
69
70    /// Construct, sign, and return the bundle.
71    pub fn build(self) -> Result<SessionTrustBundle, SessionBundleError> {
72        if self.participants.is_empty() {
73            return Err(SessionBundleError::EmptyParticipants);
74        }
75
76        let coordinator = self.coordinator_key.aid().clone();
77        let session_id = self.session_id.unwrap_or_else(Uuid::new_v4);
78        let issued_at = self.issued_at.unwrap_or_else(Timestamp::now);
79
80        // Validate every participant entry up front so build() returns
81        // a structurally-correct bundle. The coordinator minted these
82        // tokens itself; the peek is for invariant enforcement, not
83        // trust.
84        let mut min_exp: Option<Timestamp> = None;
85        for entry in &self.participants {
86            let claims = peek_tct_claims(&entry.tct)?;
87            if claims.iss != coordinator {
88                return Err(SessionBundleError::CoordinatorIssuerMismatch);
89            }
90            if claims.aud != entry.aid {
91                return Err(SessionBundleError::AudienceMismatch);
92            }
93            min_exp = Some(match min_exp {
94                Some(m) if m.0 <= claims.exp.0 => m,
95                _ => claims.exp,
96            });
97        }
98
99        // expires_at = min(participant TCT expiries) per RFC §6.
100        let expires_at = min_exp.ok_or(SessionBundleError::EmptyParticipants)?;
101
102        let view = BundleSigningView {
103            session_bundle: BundleSigningBody {
104                version: DEFAULT_BUNDLE_VERSION,
105                session_id: &session_id,
106                coordinator: &coordinator,
107                issued_at: &issued_at,
108                expires_at: &expires_at,
109                participants: &self.participants,
110            },
111        };
112        let canonical = jcs::canonicalize_serializable(&view)
113            .map_err(|e| SessionBundleError::Canonicalization(e.to_string()))?;
114        let digest = Sha256::digest(&canonical);
115        let signature = self.coordinator_key.sign(&digest);
116
117        Ok(SessionTrustBundle {
118            version: DEFAULT_BUNDLE_VERSION.to_string(),
119            session_id,
120            coordinator,
121            issued_at,
122            expires_at,
123            participants: self.participants,
124            signature: signature.into_string(),
125        })
126    }
127}
128
129/// Signing view: the wrapped `{"session_bundle": {...}}` form (the
130/// envelope minus `signature`), per the v0.2 `kat-session-bundle-001`
131/// vector — same convention as the revocation snapshot.
132#[derive(Serialize)]
133pub(crate) struct BundleSigningView<'a> {
134    pub session_bundle: BundleSigningBody<'a>,
135}
136
137/// Inner body of [`BundleSigningView`] — every [`SessionTrustBundle`]
138/// field except `signature`.
139#[derive(Serialize)]
140pub(crate) struct BundleSigningBody<'a> {
141    pub version: &'a str,
142    pub session_id: &'a Uuid,
143    pub coordinator: &'a Aid,
144    pub issued_at: &'a Timestamp,
145    pub expires_at: &'a Timestamp,
146    pub participants: &'a [ParticipantEntry],
147}