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