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/0.2";
18
19pub(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 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
56pub 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 pub fn new(coordinator_key: &'a dyn AitpSigner) -> Self {
79 Self::with_coordinator(coordinator_key)
80 }
81
82 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 pub fn new_async(coordinator_key: &'a dyn DynAsyncAitpSigner) -> Self {
99 Self::with_coordinator(coordinator_key)
100 }
101
102 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
113trait 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
128struct 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 pub fn session_id(mut self, id: Uuid) -> Self {
169 self.session_id = Some(id);
170 self
171 }
172
173 pub fn issued_at(mut self, ts: Timestamp) -> Self {
175 self.issued_at = Some(ts);
176 self
177 }
178
179 pub fn extensions(mut self, extensions: ExtensionsMap) -> Self {
186 self.extensions = Some(extensions);
187 self
188 }
189
190 pub fn participant(mut self, aid: Aid, tct: String) -> Self {
194 self.participants.push(ParticipantEntry { aid, tct });
195 self
196 }
197
198 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 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 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
255pub(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#[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 #[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 #[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 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}