aitp_session_bundle/
builder.rs1use crate::error::SessionBundleError;
7use crate::types::{ParticipantEntry, SessionTrustBundle};
8use aitp_core::{jcs, Aid, ExtensionsMap, Timestamp};
9use aitp_crypto::AitpSigningKey;
10use aitp_tct::TctClaims;
11use serde::Serialize;
12use sha2::{Digest, Sha256};
13use uuid::Uuid;
14
15pub const DEFAULT_BUNDLE_VERSION: &str = "aitp/0.2";
17
18pub(crate) fn peek_tct_claims(token: &str) -> Result<TctClaims, SessionBundleError> {
23 let payload = aitp_crypto::jws::decode_payload_unverified(token)
24 .map_err(|e| SessionBundleError::Canonicalization(format!("participant tct: {e}")))?;
25 serde_json::from_slice(&payload)
26 .map_err(|e| SessionBundleError::Canonicalization(format!("participant tct claims: {e}")))
27}
28
29pub struct SessionBundleBuilder<'a> {
32 coordinator_key: &'a AitpSigningKey,
33 session_id: Option<Uuid>,
34 participants: Vec<ParticipantEntry>,
35 issued_at: Option<Timestamp>,
36 extensions: Option<ExtensionsMap>,
37}
38
39impl<'a> SessionBundleBuilder<'a> {
40 pub fn new(coordinator_key: &'a AitpSigningKey) -> Self {
42 Self {
43 coordinator_key,
44 session_id: None,
45 participants: Vec::new(),
46 issued_at: None,
47 extensions: None,
48 }
49 }
50
51 pub fn session_id(mut self, id: Uuid) -> Self {
54 self.session_id = Some(id);
55 self
56 }
57
58 pub fn issued_at(mut self, ts: Timestamp) -> Self {
60 self.issued_at = Some(ts);
61 self
62 }
63
64 pub fn extensions(mut self, extensions: ExtensionsMap) -> Self {
71 self.extensions = Some(extensions);
72 self
73 }
74
75 pub fn participant(mut self, aid: Aid, tct: String) -> Self {
79 self.participants.push(ParticipantEntry { aid, tct });
80 self
81 }
82
83 pub fn build(self) -> Result<SessionTrustBundle, SessionBundleError> {
85 if self.participants.is_empty() {
86 return Err(SessionBundleError::EmptyParticipants);
87 }
88
89 let coordinator = self.coordinator_key.aid().clone();
90 let session_id = self.session_id.unwrap_or_else(Uuid::new_v4);
91 let issued_at = self.issued_at.unwrap_or_else(Timestamp::now);
92
93 let mut min_exp: Option<Timestamp> = None;
98 for entry in &self.participants {
99 let claims = peek_tct_claims(&entry.tct)?;
100 if claims.iss != coordinator {
101 return Err(SessionBundleError::CoordinatorIssuerMismatch);
102 }
103 if claims.aud != entry.aid {
104 return Err(SessionBundleError::AudienceMismatch);
105 }
106 min_exp = Some(match min_exp {
107 Some(m) if m.0 <= claims.exp.0 => m,
108 _ => claims.exp,
109 });
110 }
111
112 let expires_at = min_exp.ok_or(SessionBundleError::EmptyParticipants)?;
114
115 let canonical = bundle_signing_bytes(&BundleSigningBody {
116 version: DEFAULT_BUNDLE_VERSION,
117 session_id: &session_id,
118 coordinator: &coordinator,
119 issued_at: &issued_at,
120 expires_at: &expires_at,
121 participants: &self.participants,
122 extensions: self.extensions.as_ref(),
123 })?;
124 let digest = Sha256::digest(&canonical);
125 let signature = self.coordinator_key.sign(&digest);
126
127 Ok(SessionTrustBundle {
128 version: DEFAULT_BUNDLE_VERSION.to_string(),
129 session_id,
130 coordinator,
131 issued_at,
132 expires_at,
133 participants: self.participants,
134 extensions: self.extensions,
135 signature: signature.into_string(),
136 })
137 }
138}
139
140pub(crate) fn bundle_signing_bytes(
158 body: &BundleSigningBody<'_>,
159) -> Result<Vec<u8>, SessionBundleError> {
160 jcs::canonicalize_serializable(body)
161 .map_err(|e| SessionBundleError::Canonicalization(e.to_string()))
162}
163
164#[derive(Serialize)]
169pub(crate) struct BundleSigningBody<'a> {
170 pub version: &'a str,
171 pub session_id: &'a Uuid,
172 pub coordinator: &'a Aid,
173 pub issued_at: &'a Timestamp,
174 pub expires_at: &'a Timestamp,
175 pub participants: &'a [ParticipantEntry],
176 #[serde(skip_serializing_if = "Option::is_none")]
180 pub extensions: Option<&'a ExtensionsMap>,
181}
182
183#[cfg(test)]
184mod signing_input_tests {
185 use super::*;
186 use crate::types::SessionTrustBundle;
187
188 #[test]
200 fn production_signing_bytes_match_the_pinned_vector() {
201 let kat_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
202 .parent()
203 .and_then(|p| p.parent())
204 .expect("repo root")
205 .join("tests/schemas/known-answer/jcs-sha256.json");
206 let kat: serde_json::Value =
207 serde_json::from_slice(&std::fs::read(kat_path).expect("read kat")).unwrap();
208 let v = kat["vectors"]
209 .as_array()
210 .unwrap()
211 .iter()
212 .find(|v| v["id"].as_str() == Some("kat-session-bundle-001"))
213 .expect("kat-session-bundle-001 present");
214
215 let mut obj = v["object"].as_object().expect("object").clone();
219 obj.insert("signature".into(), serde_json::Value::from(""));
220 let bundle: SessionTrustBundle =
221 serde_json::from_value(serde_json::Value::Object(obj)).expect("vector deserializes");
222
223 let canonical = bundle_signing_bytes(&BundleSigningBody {
224 version: &bundle.version,
225 session_id: &bundle.session_id,
226 coordinator: &bundle.coordinator,
227 issued_at: &bundle.issued_at,
228 expires_at: &bundle.expires_at,
229 participants: &bundle.participants,
230 extensions: bundle.extensions.as_ref(),
231 })
232 .expect("canonicalize");
233
234 assert_eq!(
235 canonical.len(),
236 v["jcs_canonical_len_bytes"].as_u64().unwrap() as usize,
237 "production signing bytes diverge in length from kat-session-bundle-001"
238 );
239 assert_eq!(
240 hex::encode(&canonical),
241 v["jcs_canonical_hex"].as_str().unwrap(),
242 "production signing bytes diverge from kat-session-bundle-001 — the \
243 implementation canonicalizes a different shape than the spec signs"
244 );
245 }
246}