aitp_session_bundle/
builder.rs1use 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
17pub const DEFAULT_BUNDLE_VERSION: &str = "aitp/0.2";
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> {
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 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 pub fn session_id(mut self, id: Uuid) -> Self {
82 self.session_id = Some(id);
83 self
84 }
85
86 pub fn issued_at(mut self, ts: Timestamp) -> Self {
88 self.issued_at = Some(ts);
89 self
90 }
91
92 pub fn extensions(mut self, extensions: ExtensionsMap) -> Self {
99 self.extensions = Some(extensions);
100 self
101 }
102
103 pub fn participant(mut self, aid: Aid, tct: String) -> Self {
107 self.participants.push(ParticipantEntry { aid, tct });
108 self
109 }
110
111 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 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 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
168pub(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#[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 #[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 #[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 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}