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::{jcs, Aid, Timestamp};
9use aitp_crypto::AitpSigningKey;
10use aitp_tct::TctClaims;
11use serde::Serialize;
12use sha2::{Digest, Sha256};
13use uuid::Uuid;
14
15/// `version` constant for v0.2 bundles.
16pub const DEFAULT_BUNDLE_VERSION: &str = "aitp/0.2";
17
18/// Decode a participant TCT's claims without verification. Builder- and
19/// invariant-level peeks only; full verification (signature, typ, alg
20/// pin) happens in [`crate::verify_session_bundle`] via
21/// [`aitp_tct::verify_tct`].
22pub(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
29/// Fluent builder for issuing a [`SessionTrustBundle`] as the
30/// coordinator.
31pub struct SessionBundleBuilder<'a> {
32    coordinator_key: &'a AitpSigningKey,
33    session_id: Option<Uuid>,
34    participants: Vec<ParticipantEntry>,
35    issued_at: Option<Timestamp>,
36}
37
38impl<'a> SessionBundleBuilder<'a> {
39    /// Begin a new bundle, signed by `coordinator_key`.
40    pub fn new(coordinator_key: &'a AitpSigningKey) -> Self {
41        Self {
42            coordinator_key,
43            session_id: None,
44            participants: Vec::new(),
45            issued_at: None,
46        }
47    }
48
49    /// Set the session ID (UUIDv4). If unset, a fresh one is generated
50    /// at `build()` time.
51    pub fn session_id(mut self, id: Uuid) -> Self {
52        self.session_id = Some(id);
53        self
54    }
55
56    /// Override `issued_at`. Tests / fixtures only.
57    pub fn issued_at(mut self, ts: Timestamp) -> Self {
58        self.issued_at = Some(ts);
59        self
60    }
61
62    /// Add a participant. The TCT (compact JWS, carried verbatim) MUST
63    /// be coordinator-issued (`iss == coordinator_key.aid()`) with
64    /// `aud == aid`. These invariants are checked in `build()`.
65    pub fn participant(mut self, aid: Aid, tct: String) -> Self {
66        self.participants.push(ParticipantEntry { aid, tct });
67        self
68    }
69
70    /// Construct, sign, and return the bundle.
71    pub fn build(self) -> Result<SessionTrustBundle, SessionBundleError> {
72        if self.participants.is_empty() {
73            return Err(SessionBundleError::EmptyParticipants);
74        }
75
76        let coordinator = self.coordinator_key.aid().clone();
77        let session_id = self.session_id.unwrap_or_else(Uuid::new_v4);
78        let issued_at = self.issued_at.unwrap_or_else(Timestamp::now);
79
80        // Validate every participant entry up front so build() returns
81        // a structurally-correct bundle. The coordinator minted these
82        // tokens itself; the peek is for invariant enforcement, not
83        // trust.
84        let mut min_exp: Option<Timestamp> = None;
85        for entry in &self.participants {
86            let claims = peek_tct_claims(&entry.tct)?;
87            if claims.iss != coordinator {
88                return Err(SessionBundleError::CoordinatorIssuerMismatch);
89            }
90            if claims.aud != entry.aid {
91                return Err(SessionBundleError::AudienceMismatch);
92            }
93            min_exp = Some(match min_exp {
94                Some(m) if m.0 <= claims.exp.0 => m,
95                _ => claims.exp,
96            });
97        }
98
99        // expires_at = min(participant TCT expiries) per RFC §6.
100        let expires_at = min_exp.ok_or(SessionBundleError::EmptyParticipants)?;
101
102        let canonical = bundle_signing_bytes(&BundleSigningBody {
103            version: DEFAULT_BUNDLE_VERSION,
104            session_id: &session_id,
105            coordinator: &coordinator,
106            issued_at: &issued_at,
107            expires_at: &expires_at,
108            participants: &self.participants,
109        })?;
110        let digest = Sha256::digest(&canonical);
111        let signature = self.coordinator_key.sign(&digest);
112
113        Ok(SessionTrustBundle {
114            version: DEFAULT_BUNDLE_VERSION.to_string(),
115            session_id,
116            coordinator,
117            issued_at,
118            expires_at,
119            participants: self.participants,
120            signature: signature.into_string(),
121        })
122    }
123}
124
125/// The canonical signing input for a session trust bundle.
126///
127/// **The single definition of what gets signed.** The builder, the
128/// verifier and the known-answer test all route through this, so signer,
129/// verifier and test cannot drift apart.
130///
131/// The input is the JCS canonicalization of the bundle body **excluding
132/// `signature`** (RFC-AITP-0001 §5.4.1, RFC-AITP-0010 §3). The
133/// `{"session_bundle": …}` wrapper is the transport shape and is NOT
134/// signed; RFC-AITP-0010 §5 step 6 requires verification against the inner
135/// body and gives no allowance for accepting the wrapped form.
136///
137/// This deliberately states the rule and cites the RFCs rather than
138/// pointing at a sibling artifact. The comment this replaced read "same
139/// convention as the revocation snapshot", and that cross-reference is how
140/// one misread vector became two divergent artifacts: correcting one and
141/// leaving a pointer to it in the other keeps the propagation path open.
142pub(crate) fn bundle_signing_bytes(
143    body: &BundleSigningBody<'_>,
144) -> Result<Vec<u8>, SessionBundleError> {
145    jcs::canonicalize_serializable(body)
146        .map_err(|e| SessionBundleError::Canonicalization(e.to_string()))
147}
148
149/// The signed body — every [`SessionTrustBundle`] field except
150/// `signature`. Unlike the revocation snapshot, whose `signature` is a
151/// sibling of the body, the bundle's `signature` is a member of it, so
152/// this projection is what performs the exclusion.
153#[derive(Serialize)]
154pub(crate) struct BundleSigningBody<'a> {
155    pub version: &'a str,
156    pub session_id: &'a Uuid,
157    pub coordinator: &'a Aid,
158    pub issued_at: &'a Timestamp,
159    pub expires_at: &'a Timestamp,
160    pub participants: &'a [ParticipantEntry],
161}
162
163#[cfg(test)]
164mod signing_input_tests {
165    use super::*;
166    use crate::types::SessionTrustBundle;
167
168    /// Drive the **production** signing-input helper against the spec's
169    /// pinned `kat-session-bundle-001` bytes.
170    ///
171    /// This lives in-crate deliberately: `bundle_signing_bytes` is
172    /// `pub(crate)`, so an integration test cannot reach it and would have
173    /// to re-implement the canonicalization — which is exactly how a test
174    /// ends up agreeing with itself instead of with the implementation.
175    /// Verification measured the consequence: with the bundle KAT
176    /// canonicalizing locally, a regression in `bundle_signing_bytes` was
177    /// caught by only ONE test in the whole workspace. This restores the
178    /// same redundancy the revocation path has.
179    #[test]
180    fn production_signing_bytes_match_the_pinned_vector() {
181        let kat_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
182            .parent()
183            .and_then(|p| p.parent())
184            .expect("repo root")
185            .join("tests/schemas/known-answer/jcs-sha256.json");
186        let kat: serde_json::Value =
187            serde_json::from_slice(&std::fs::read(kat_path).expect("read kat")).unwrap();
188        let v = kat["vectors"]
189            .as_array()
190            .unwrap()
191            .iter()
192            .find(|v| v["id"].as_str() == Some("kat-session-bundle-001"))
193            .expect("kat-session-bundle-001 present");
194
195        // The vector's `object` is the body without `signature`; add a
196        // placeholder so it deserializes into the wire type, then project
197        // it back out through the production body struct.
198        let mut obj = v["object"].as_object().expect("object").clone();
199        obj.insert("signature".into(), serde_json::Value::from(""));
200        let bundle: SessionTrustBundle =
201            serde_json::from_value(serde_json::Value::Object(obj)).expect("vector deserializes");
202
203        let canonical = bundle_signing_bytes(&BundleSigningBody {
204            version: &bundle.version,
205            session_id: &bundle.session_id,
206            coordinator: &bundle.coordinator,
207            issued_at: &bundle.issued_at,
208            expires_at: &bundle.expires_at,
209            participants: &bundle.participants,
210        })
211        .expect("canonicalize");
212
213        assert_eq!(
214            canonical.len(),
215            v["jcs_canonical_len_bytes"].as_u64().unwrap() as usize,
216            "production signing bytes diverge in length from kat-session-bundle-001"
217        );
218        assert_eq!(
219            hex::encode(&canonical),
220            v["jcs_canonical_hex"].as_str().unwrap(),
221            "production signing bytes diverge from kat-session-bundle-001 — the \
222             implementation canonicalizes a different shape than the spec signs"
223        );
224    }
225}