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::{AitpSigner, CryptoError, Digest32, DynAsyncAitpSigner, Signature};
12use aitp_tct::{TctClaims, TCT_CLAIMS_MEMBERS};
13use serde::Serialize;
14use uuid::Uuid;
15
16/// `version` constant for bundles: the protocol revision
17/// ([`aitp_core::PROTOCOL_VERSION`]).
18pub const DEFAULT_BUNDLE_VERSION: &str = aitp_core::PROTOCOL_VERSION;
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.
59///
60/// With an async (KMS, HSM) coordinator signer, start from
61/// [`SessionBundleBuilder::new_async`] and finish with
62/// [`SessionBundleBuilder::build_async`]; the setters are shared. The
63/// signer kind is a type parameter, so a sync builder has no
64/// `build_async` and an async one has no `build`.
65pub struct SessionBundleBuilder<'a, S: ?Sized + 'a = dyn AitpSigner + 'a> {
66    coordinator_key: &'a S,
67    session_id: Option<Uuid>,
68    participants: Vec<ParticipantEntry>,
69    issued_at: Option<Timestamp>,
70    extensions: Option<ExtensionsMap>,
71}
72
73impl<'a> SessionBundleBuilder<'a, dyn AitpSigner + 'a> {
74    /// Begin a new bundle, signed by `coordinator_key`.
75    ///
76    /// Accepts any [`AitpSigner`] (in-process key, KMS, HSM); a backend
77    /// failure surfaces from [`Self::build`] as
78    /// `SessionBundleError::Crypto(CryptoError::Signer(_))`.
79    pub fn new(coordinator_key: &'a dyn AitpSigner) -> Self {
80        Self::with_coordinator(coordinator_key)
81    }
82
83    /// Construct, sign, and return the bundle.
84    pub fn build(self) -> Result<SessionTrustBundle, SessionBundleError> {
85        let (draft, signer) = self.draft()?;
86        let signature =
87            aitp_crypto::sign_jcs(signer, &draft.digest).map_err(CryptoError::Signer)?;
88        Ok(draft.finish(signature))
89    }
90}
91
92impl<'a> SessionBundleBuilder<'a, dyn DynAsyncAitpSigner + 'a> {
93    /// Begin a new bundle signed by an async backend, for example
94    /// `SessionBundleBuilder::new_async(&*arc_kms_signer)`. Finish with
95    /// [`Self::build_async`].
96    ///
97    /// A backend failure surfaces from [`Self::build_async`] as
98    /// `SessionBundleError::Crypto(CryptoError::Signer(_))`.
99    pub fn new_async(coordinator_key: &'a dyn DynAsyncAitpSigner) -> Self {
100        Self::with_coordinator(coordinator_key)
101    }
102
103    /// As [`SessionBundleBuilder::build`], awaiting the backend.
104    /// Validation and output are identical; the future is `Send`.
105    pub async fn build_async(self) -> Result<SessionTrustBundle, SessionBundleError> {
106        let (draft, signer) = self.draft()?;
107        let signature = aitp_crypto::sign_jcs_async(signer, &draft.digest)
108            .await
109            .map_err(CryptoError::Signer)?;
110        Ok(draft.finish(signature))
111    }
112}
113
114/// The coordinator's AID, whichever signer kind holds it.
115trait CoordinatorAid {
116    fn coordinator_aid(&self) -> &Aid;
117}
118impl CoordinatorAid for dyn AitpSigner + '_ {
119    fn coordinator_aid(&self) -> &Aid {
120        self.aid()
121    }
122}
123impl CoordinatorAid for dyn DynAsyncAitpSigner + '_ {
124    fn coordinator_aid(&self) -> &Aid {
125        self.aid()
126    }
127}
128
129/// A validated bundle body and the digest its signature covers.
130struct Draft {
131    digest: Digest32,
132    session_id: Uuid,
133    coordinator: Aid,
134    issued_at: Timestamp,
135    expires_at: Timestamp,
136    participants: Vec<ParticipantEntry>,
137    extensions: Option<ExtensionsMap>,
138}
139
140impl Draft {
141    fn finish(self, signature: Signature) -> SessionTrustBundle {
142        SessionTrustBundle {
143            version: DEFAULT_BUNDLE_VERSION.to_string(),
144            session_id: self.session_id,
145            coordinator: self.coordinator,
146            issued_at: self.issued_at,
147            expires_at: self.expires_at,
148            participants: self.participants,
149            extensions: self.extensions,
150            signature: signature.into_string(),
151        }
152    }
153}
154
155#[allow(private_bounds)]
156impl<'a, S: ?Sized + CoordinatorAid> SessionBundleBuilder<'a, S> {
157    fn with_coordinator(coordinator_key: &'a S) -> Self {
158        Self {
159            coordinator_key,
160            session_id: None,
161            participants: Vec::new(),
162            issued_at: None,
163            extensions: None,
164        }
165    }
166
167    /// Set the session ID (UUIDv4). If unset, a fresh one is generated
168    /// at `build()` time.
169    pub fn session_id(mut self, id: Uuid) -> Self {
170        self.session_id = Some(id);
171        self
172    }
173
174    /// Override `issued_at`. Tests / fixtures only.
175    pub fn issued_at(mut self, ts: Timestamp) -> Self {
176        self.issued_at = Some(ts);
177        self
178    }
179
180    /// Set the `extensions` namespace (RFC-AITP-0001 §7).
181    ///
182    /// Unset by default, which serializes as an absent `extensions` key —
183    /// not `"extensions":{}`. Passing `ExtensionsMap::new()` here instead
184    /// deliberately produces the present-but-empty form; the two are
185    /// different signing inputs, so choose the one the caller means.
186    pub fn extensions(mut self, extensions: ExtensionsMap) -> Self {
187        self.extensions = Some(extensions);
188        self
189    }
190
191    /// Add a participant. The TCT (compact JWS, carried verbatim) MUST
192    /// be coordinator-issued (`iss == coordinator_key.aid()`) with
193    /// `aud == aid`. These invariants are checked in `build()`.
194    pub fn participant(mut self, aid: Aid, tct: String) -> Self {
195        self.participants.push(ParticipantEntry { aid, tct });
196        self
197    }
198
199    /// Validate and canonicalize the body; signing is the caller's.
200    fn draft(self) -> Result<(Draft, &'a S), SessionBundleError> {
201        if self.participants.is_empty() {
202            return Err(SessionBundleError::EmptyParticipants);
203        }
204
205        let coordinator = self.coordinator_key.coordinator_aid().clone();
206        let session_id = self.session_id.unwrap_or_else(Uuid::new_v4);
207        let issued_at = self.issued_at.unwrap_or_else(Timestamp::now);
208
209        // Validate every participant entry up front so build() returns
210        // a structurally-correct bundle. The coordinator minted these
211        // tokens itself; the peek is for invariant enforcement, not
212        // trust.
213        let mut min_exp: Option<Timestamp> = None;
214        for entry in &self.participants {
215            let claims = peek_tct_claims(&entry.tct)?;
216            if claims.iss != coordinator {
217                return Err(SessionBundleError::CoordinatorIssuerMismatch);
218            }
219            if claims.aud != entry.aid {
220                return Err(SessionBundleError::AudienceMismatch);
221            }
222            min_exp = Some(match min_exp {
223                Some(m) if m.0 <= claims.exp.0 => m,
224                _ => claims.exp,
225            });
226        }
227
228        // expires_at = min(participant TCT expiries) per RFC §6.
229        let expires_at = min_exp.ok_or(SessionBundleError::EmptyParticipants)?;
230
231        let canonical = bundle_signing_bytes(&BundleSigningBody {
232            version: DEFAULT_BUNDLE_VERSION,
233            session_id: &session_id,
234            coordinator: &coordinator,
235            issued_at: &issued_at,
236            expires_at: &expires_at,
237            participants: &self.participants,
238            extensions: self.extensions.as_ref(),
239        })?;
240        let digest = Digest32::sha256(&canonical);
241        Ok((
242            Draft {
243                digest,
244                session_id,
245                coordinator,
246                issued_at,
247                expires_at,
248                participants: self.participants,
249                extensions: self.extensions,
250            },
251            self.coordinator_key,
252        ))
253    }
254}
255
256/// The canonical signing input for a session trust bundle.
257///
258/// **The single definition of what gets signed.** The builder, the
259/// verifier and the known-answer test all route through this, so signer,
260/// verifier and test cannot drift apart.
261///
262/// The input is the JCS canonicalization of the bundle body **excluding
263/// `signature`** (RFC-AITP-0001 §5.4.1, RFC-AITP-0010 §3). The
264/// `{"session_bundle": …}` wrapper is the transport shape and is NOT
265/// signed; RFC-AITP-0010 §5 step 6 requires verification against the inner
266/// body and gives no allowance for accepting the wrapped form.
267///
268/// This deliberately states the rule and cites the RFCs rather than
269/// pointing at a sibling artifact. The comment this replaced read "same
270/// convention as the revocation snapshot", and that cross-reference is how
271/// one misread vector became two divergent artifacts: correcting one and
272/// leaving a pointer to it in the other keeps the propagation path open.
273pub(crate) fn bundle_signing_bytes(
274    body: &BundleSigningBody<'_>,
275) -> Result<Vec<u8>, SessionBundleError> {
276    jcs::canonicalize_serializable(body)
277        .map_err(|e| SessionBundleError::Canonicalization(e.to_string()))
278}
279
280/// The signed body — every [`SessionTrustBundle`] field except
281/// `signature`. Unlike the revocation snapshot, whose `signature` is a
282/// sibling of the body, the bundle's `signature` is a member of it, so
283/// this projection is what performs the exclusion.
284#[derive(Serialize)]
285pub(crate) struct BundleSigningBody<'a> {
286    pub version: &'a str,
287    pub session_id: &'a Uuid,
288    pub coordinator: &'a Aid,
289    pub issued_at: &'a Timestamp,
290    pub expires_at: &'a Timestamp,
291    pub participants: &'a [ParticipantEntry],
292    /// Mirrors [`SessionTrustBundle::extensions`]'s presence-sensitivity:
293    /// `None` omits the key, `Some(_)` (even an empty map) serializes it —
294    /// never collapse one into the other here.
295    #[serde(skip_serializing_if = "Option::is_none")]
296    pub extensions: Option<&'a ExtensionsMap>,
297}
298
299#[cfg(test)]
300mod signing_input_tests {
301    use super::*;
302    use crate::types::SessionTrustBundle;
303
304    /// Drive the **production** signing-input helper against the spec's
305    /// pinned `kat-session-bundle-001` bytes.
306    ///
307    /// This lives in-crate deliberately: `bundle_signing_bytes` is
308    /// `pub(crate)`, so an integration test cannot reach it and would have
309    /// to re-implement the canonicalization — which is exactly how a test
310    /// ends up agreeing with itself instead of with the implementation.
311    /// Verification measured the consequence: with the bundle KAT
312    /// canonicalizing locally, a regression in `bundle_signing_bytes` was
313    /// caught by only ONE test in the whole workspace. This restores the
314    /// same redundancy the revocation path has.
315    #[test]
316    fn production_signing_bytes_match_the_pinned_vector() {
317        let kat_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
318            .parent()
319            .and_then(|p| p.parent())
320            .expect("repo root")
321            .join("tests/schemas/known-answer/jcs-sha256.json");
322        let kat: serde_json::Value =
323            serde_json::from_slice(&std::fs::read(kat_path).expect("read kat")).unwrap();
324        let v = kat["vectors"]
325            .as_array()
326            .unwrap()
327            .iter()
328            .find(|v| v["id"].as_str() == Some("kat-session-bundle-001"))
329            .expect("kat-session-bundle-001 present");
330
331        // The vector's `object` is the body without `signature`; add a
332        // placeholder so it deserializes into the wire type, then project
333        // it back out through the production body struct.
334        let mut obj = v["object"].as_object().expect("object").clone();
335        obj.insert("signature".into(), serde_json::Value::from(""));
336        let bundle: SessionTrustBundle =
337            serde_json::from_value(serde_json::Value::Object(obj)).expect("vector deserializes");
338
339        let canonical = bundle_signing_bytes(&BundleSigningBody {
340            version: &bundle.version,
341            session_id: &bundle.session_id,
342            coordinator: &bundle.coordinator,
343            issued_at: &bundle.issued_at,
344            expires_at: &bundle.expires_at,
345            participants: &bundle.participants,
346            extensions: bundle.extensions.as_ref(),
347        })
348        .expect("canonicalize");
349
350        assert_eq!(
351            canonical.len(),
352            v["jcs_canonical_len_bytes"].as_u64().unwrap() as usize,
353            "production signing bytes diverge in length from kat-session-bundle-001"
354        );
355        assert_eq!(
356            hex::encode(&canonical),
357            v["jcs_canonical_hex"].as_str().unwrap(),
358            "production signing bytes diverge from kat-session-bundle-001 — the \
359             implementation canonicalizes a different shape than the spec signs"
360        );
361    }
362}