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, ExtensionsMap, 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    extensions: Option<ExtensionsMap>,
37}
38
39impl<'a> SessionBundleBuilder<'a> {
40    /// Begin a new bundle, signed by `coordinator_key`.
41    pub fn new(coordinator_key: &'a AitpSigningKey) -> Self {
42        Self {
43            coordinator_key,
44            session_id: None,
45            participants: Vec::new(),
46            issued_at: None,
47            extensions: None,
48        }
49    }
50
51    /// Set the session ID (UUIDv4). If unset, a fresh one is generated
52    /// at `build()` time.
53    pub fn session_id(mut self, id: Uuid) -> Self {
54        self.session_id = Some(id);
55        self
56    }
57
58    /// Override `issued_at`. Tests / fixtures only.
59    pub fn issued_at(mut self, ts: Timestamp) -> Self {
60        self.issued_at = Some(ts);
61        self
62    }
63
64    /// Set the `extensions` namespace (RFC-AITP-0001 §7).
65    ///
66    /// Unset by default, which serializes as an absent `extensions` key —
67    /// not `"extensions":{}`. Passing `ExtensionsMap::new()` here instead
68    /// deliberately produces the present-but-empty form; the two are
69    /// different signing inputs, so choose the one the caller means.
70    pub fn extensions(mut self, extensions: ExtensionsMap) -> Self {
71        self.extensions = Some(extensions);
72        self
73    }
74
75    /// Add a participant. The TCT (compact JWS, carried verbatim) MUST
76    /// be coordinator-issued (`iss == coordinator_key.aid()`) with
77    /// `aud == aid`. These invariants are checked in `build()`.
78    pub fn participant(mut self, aid: Aid, tct: String) -> Self {
79        self.participants.push(ParticipantEntry { aid, tct });
80        self
81    }
82
83    /// Construct, sign, and return the bundle.
84    pub fn build(self) -> Result<SessionTrustBundle, SessionBundleError> {
85        if self.participants.is_empty() {
86            return Err(SessionBundleError::EmptyParticipants);
87        }
88
89        let coordinator = self.coordinator_key.aid().clone();
90        let session_id = self.session_id.unwrap_or_else(Uuid::new_v4);
91        let issued_at = self.issued_at.unwrap_or_else(Timestamp::now);
92
93        // Validate every participant entry up front so build() returns
94        // a structurally-correct bundle. The coordinator minted these
95        // tokens itself; the peek is for invariant enforcement, not
96        // trust.
97        let mut min_exp: Option<Timestamp> = None;
98        for entry in &self.participants {
99            let claims = peek_tct_claims(&entry.tct)?;
100            if claims.iss != coordinator {
101                return Err(SessionBundleError::CoordinatorIssuerMismatch);
102            }
103            if claims.aud != entry.aid {
104                return Err(SessionBundleError::AudienceMismatch);
105            }
106            min_exp = Some(match min_exp {
107                Some(m) if m.0 <= claims.exp.0 => m,
108                _ => claims.exp,
109            });
110        }
111
112        // expires_at = min(participant TCT expiries) per RFC §6.
113        let expires_at = min_exp.ok_or(SessionBundleError::EmptyParticipants)?;
114
115        let canonical = bundle_signing_bytes(&BundleSigningBody {
116            version: DEFAULT_BUNDLE_VERSION,
117            session_id: &session_id,
118            coordinator: &coordinator,
119            issued_at: &issued_at,
120            expires_at: &expires_at,
121            participants: &self.participants,
122            extensions: self.extensions.as_ref(),
123        })?;
124        let digest = Sha256::digest(&canonical);
125        let signature = self.coordinator_key.sign(&digest);
126
127        Ok(SessionTrustBundle {
128            version: DEFAULT_BUNDLE_VERSION.to_string(),
129            session_id,
130            coordinator,
131            issued_at,
132            expires_at,
133            participants: self.participants,
134            extensions: self.extensions,
135            signature: signature.into_string(),
136        })
137    }
138}
139
140/// The canonical signing input for a session trust bundle.
141///
142/// **The single definition of what gets signed.** The builder, the
143/// verifier and the known-answer test all route through this, so signer,
144/// verifier and test cannot drift apart.
145///
146/// The input is the JCS canonicalization of the bundle body **excluding
147/// `signature`** (RFC-AITP-0001 §5.4.1, RFC-AITP-0010 §3). The
148/// `{"session_bundle": …}` wrapper is the transport shape and is NOT
149/// signed; RFC-AITP-0010 §5 step 6 requires verification against the inner
150/// body and gives no allowance for accepting the wrapped form.
151///
152/// This deliberately states the rule and cites the RFCs rather than
153/// pointing at a sibling artifact. The comment this replaced read "same
154/// convention as the revocation snapshot", and that cross-reference is how
155/// one misread vector became two divergent artifacts: correcting one and
156/// leaving a pointer to it in the other keeps the propagation path open.
157pub(crate) fn bundle_signing_bytes(
158    body: &BundleSigningBody<'_>,
159) -> Result<Vec<u8>, SessionBundleError> {
160    jcs::canonicalize_serializable(body)
161        .map_err(|e| SessionBundleError::Canonicalization(e.to_string()))
162}
163
164/// The signed body — every [`SessionTrustBundle`] field except
165/// `signature`. Unlike the revocation snapshot, whose `signature` is a
166/// sibling of the body, the bundle's `signature` is a member of it, so
167/// this projection is what performs the exclusion.
168#[derive(Serialize)]
169pub(crate) struct BundleSigningBody<'a> {
170    pub version: &'a str,
171    pub session_id: &'a Uuid,
172    pub coordinator: &'a Aid,
173    pub issued_at: &'a Timestamp,
174    pub expires_at: &'a Timestamp,
175    pub participants: &'a [ParticipantEntry],
176    /// Mirrors [`SessionTrustBundle::extensions`]'s presence-sensitivity:
177    /// `None` omits the key, `Some(_)` (even an empty map) serializes it —
178    /// never collapse one into the other here.
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub extensions: Option<&'a ExtensionsMap>,
181}
182
183#[cfg(test)]
184mod signing_input_tests {
185    use super::*;
186    use crate::types::SessionTrustBundle;
187
188    /// Drive the **production** signing-input helper against the spec's
189    /// pinned `kat-session-bundle-001` bytes.
190    ///
191    /// This lives in-crate deliberately: `bundle_signing_bytes` is
192    /// `pub(crate)`, so an integration test cannot reach it and would have
193    /// to re-implement the canonicalization — which is exactly how a test
194    /// ends up agreeing with itself instead of with the implementation.
195    /// Verification measured the consequence: with the bundle KAT
196    /// canonicalizing locally, a regression in `bundle_signing_bytes` was
197    /// caught by only ONE test in the whole workspace. This restores the
198    /// same redundancy the revocation path has.
199    #[test]
200    fn production_signing_bytes_match_the_pinned_vector() {
201        let kat_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
202            .parent()
203            .and_then(|p| p.parent())
204            .expect("repo root")
205            .join("tests/schemas/known-answer/jcs-sha256.json");
206        let kat: serde_json::Value =
207            serde_json::from_slice(&std::fs::read(kat_path).expect("read kat")).unwrap();
208        let v = kat["vectors"]
209            .as_array()
210            .unwrap()
211            .iter()
212            .find(|v| v["id"].as_str() == Some("kat-session-bundle-001"))
213            .expect("kat-session-bundle-001 present");
214
215        // The vector's `object` is the body without `signature`; add a
216        // placeholder so it deserializes into the wire type, then project
217        // it back out through the production body struct.
218        let mut obj = v["object"].as_object().expect("object").clone();
219        obj.insert("signature".into(), serde_json::Value::from(""));
220        let bundle: SessionTrustBundle =
221            serde_json::from_value(serde_json::Value::Object(obj)).expect("vector deserializes");
222
223        let canonical = bundle_signing_bytes(&BundleSigningBody {
224            version: &bundle.version,
225            session_id: &bundle.session_id,
226            coordinator: &bundle.coordinator,
227            issued_at: &bundle.issued_at,
228            expires_at: &bundle.expires_at,
229            participants: &bundle.participants,
230            extensions: bundle.extensions.as_ref(),
231        })
232        .expect("canonicalize");
233
234        assert_eq!(
235            canonical.len(),
236            v["jcs_canonical_len_bytes"].as_u64().unwrap() as usize,
237            "production signing bytes diverge in length from kat-session-bundle-001"
238        );
239        assert_eq!(
240            hex::encode(&canonical),
241            v["jcs_canonical_hex"].as_str().unwrap(),
242            "production signing bytes diverge from kat-session-bundle-001 — the \
243             implementation canonicalizes a different shape than the spec signs"
244        );
245    }
246}