1use 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
16pub const DEFAULT_BUNDLE_VERSION: &str = aitp_core::PROTOCOL_VERSION;
19
20pub(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 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
57pub 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 pub fn new(coordinator_key: &'a dyn AitpSigner) -> Self {
80 Self::with_coordinator(coordinator_key)
81 }
82
83 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 pub fn new_async(coordinator_key: &'a dyn DynAsyncAitpSigner) -> Self {
100 Self::with_coordinator(coordinator_key)
101 }
102
103 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
114trait 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
129struct 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 pub fn session_id(mut self, id: Uuid) -> Self {
170 self.session_id = Some(id);
171 self
172 }
173
174 pub fn issued_at(mut self, ts: Timestamp) -> Self {
176 self.issued_at = Some(ts);
177 self
178 }
179
180 pub fn extensions(mut self, extensions: ExtensionsMap) -> Self {
187 self.extensions = Some(extensions);
188 self
189 }
190
191 pub fn participant(mut self, aid: Aid, tct: String) -> Self {
195 self.participants.push(ParticipantEntry { aid, tct });
196 self
197 }
198
199 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 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 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
256pub(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#[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 #[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 #[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 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}