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