Skip to main content

aitp_session_bundle/
types.rs

1//! Wire types for Session Trust Bundle (RFC-AITP-0010 §3).
2
3use aitp_core::{Aid, ExtensionsMap, Timestamp};
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7/// Coordinator-attested session membership artifact (RFC-AITP-0010 §3).
8///
9/// The schema is `additionalProperties: false`, with an explicit
10/// `extensions` slot reserved per RFC-AITP-0001 §7.
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12#[serde(deny_unknown_fields)]
13pub struct SessionTrustBundle {
14    /// MUST be `"aitp/0.2"` for this RFC.
15    pub version: String,
16    /// UUID v4 unique to this session. Used as a replay-binding scope.
17    pub session_id: Uuid,
18    /// Coordinator's AID. MUST match the `issuer` of every embedded TCT.
19    pub coordinator: Aid,
20    /// When this bundle was signed.
21    pub issued_at: Timestamp,
22    /// When the bundle MUST NOT be used after. MUST equal
23    /// `min(participants[*].tct.expires_at)` (RFC-AITP-0010 §6).
24    pub expires_at: Timestamp,
25    /// One entry per session participant.
26    pub participants: Vec<ParticipantEntry>,
27    /// Forward-compatible extension namespace (RFC-AITP-0001 §7).
28    ///
29    /// **Presence-sensitive**, deliberately modeled as `Option<ExtensionsMap>`
30    /// rather than a defaulted empty map with `skip_serializing_if =
31    /// "is_empty"`. Under RFC 8785 canonicalization, absent (`None`) emits
32    /// no `extensions` key at all, while present-but-empty
33    /// (`Some(ExtensionsMap::new())`) emits `"extensions":{}` — different
34    /// bytes, different digest, different signature. Silently normalizing
35    /// one into the other would change the signing input and break
36    /// verification against a conformant peer (RFC-AITP-0001 §5.4.1).
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub extensions: Option<ExtensionsMap>,
39    /// Coordinator's signature over the canonical bundle JSON
40    /// excluding `signature`. JCS rules per RFC-AITP-0001 §5.4.1.
41    pub signature: String,
42}
43
44/// One participant in a [`SessionTrustBundle`]: their AID + the
45/// coordinator-issued TCT they received during their bilateral
46/// handshake.
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
48#[serde(deny_unknown_fields)]
49pub struct ParticipantEntry {
50    /// Participant's AID. MUST equal the embedded TCT's `aud` claim.
51    pub aid: Aid,
52    /// Coordinator → participant TCT as an **opaque compact JWS
53    /// string** (`typ: aitp-tct+jwt`, RFC-AITP-0001 §5.4.5), carried
54    /// verbatim — the outer bundle signature covers it byte-for-byte.
55    pub tct: String,
56}
57
58/// HTTP/transport-wrapped form (the `{"session_bundle": {...}}` shape
59/// that matches the JSON Schema `$id`).
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
61#[serde(deny_unknown_fields)]
62pub struct SessionBundleEnvelope {
63    /// The signed inner bundle.
64    pub session_bundle: SessionTrustBundle,
65}
66
67#[cfg(test)]
68mod tests {
69    use super::*;
70    use serde_json::json;
71
72    #[test]
73    fn rejects_unknown_top_field() {
74        let v = json!({
75            "version": "aitp/0.2",
76            "session_id": "00000000-0000-4000-8000-000000000000",
77            "coordinator": "aid:pubkey:O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik",
78            "issued_at": 1_700_000_000,
79            "expires_at": 1_700_010_000,
80            "participants": [],
81            "signature": "A".repeat(86),
82            "rogue": 1,
83        });
84        assert!(serde_json::from_value::<SessionTrustBundle>(v).is_err());
85    }
86}