Skip to main content

aitp_session_bundle/
wire.rs

1//! Wire-form parsing for the Session Trust Bundle transport envelope
2//! (RFC-AITP-0010 §3, §5).
3
4use crate::error::SessionBundleError;
5use crate::types::{SessionBundleEnvelope, SessionTrustBundle, SESSION_BUNDLE_MEMBERS};
6use aitp_core::{check_members, from_serde_error, CheckedJson};
7use serde_json::Value;
8
9/// Full member set of the `{"session_bundle": {...}}` transport
10/// wrapper. Per RFC-AITP-0010 §3 the wrapper is `additionalProperties:
11/// false`: no member besides `session_bundle` is ever permitted, not
12/// even in a future schema revision, since the wrapper's sole purpose
13/// is to carry the signed body.
14const SESSION_BUNDLE_ENVELOPE_MEMBERS: &[&str] = &["session_bundle"];
15
16/// Parse a session bundle from its on-the-wire JSON.
17///
18/// Accepts the spec transport envelope `{"session_bundle": {<body incl.
19/// `signature`>}}` and, for internal callers that never wrap, a bare
20/// body. This function is where RFC-AITP-0010's two DISTINCT failure
21/// classes are told apart, by WHERE a violation occurs:
22///
23/// - **Wrapper-level** (§3): when a `{"session_bundle": …}` wrapper is
24///   present, its member set MUST be exactly `{session_bundle}`. Any
25///   sibling member — notably the pre-erratum shape where `signature`
26///   sat OUTSIDE the wrapped body — is a wire-FORM defect:
27///   [`SessionBundleError::WireFormInvalid`], which the error-code
28///   mapping surfaces as `SESSION_BUNDLE_INVALID`.
29/// - **Body-level** (§5): once the wrapper shape (if any) is confirmed
30///   valid, the inner body's member set is checked against
31///   [`SESSION_BUNDLE_MEMBERS`]. A member outside that set is not a wire
32///   defect — the wrapper/no-wrapper shape was correct — but an unknown
33///   member of the signed artifact itself:
34///   [`SessionBundleError::UnknownField`], the **core** `UNKNOWN_FIELD`
35///   code (RFC-AITP-0001 §7), even though the session-bundle artifact
36///   is itself draft. This check runs before any cryptographic work,
37///   including the coordinator key resolution used to verify the outer
38///   signature (RFC-AITP-0010 §5 step 5) — a bundle with an unknown body
39///   member is rejected without ever deriving that key.
40/// - Once both member-set checks pass, the value is typed-deserialized.
41///   A residual `#[serde(deny_unknown_fields)]` error at that point can
42///   only come from a NESTED closed object (a `participants[]` entry);
43///   [`from_serde_error`] recovers the offending field name so it, too,
44///   reports [`SessionBundleError::UnknownField`] rather than a generic
45///   parse failure.
46///
47/// The bare-body path (no wrapper, used by internal callers) applies the
48/// SAME body-level member-set check as the wrapped form, so the two
49/// entry points never disagree about what a valid body looks like.
50pub fn parse_session_bundle_wire(
51    value: &CheckedJson,
52) -> Result<SessionTrustBundle, SessionBundleError> {
53    let value: &Value = value.as_value();
54    if value.get("session_bundle").is_some() {
55        check_members(
56            "SessionBundleEnvelope",
57            value,
58            SESSION_BUNDLE_ENVELOPE_MEMBERS,
59        )
60        .map_err(|e| {
61            SessionBundleError::WireFormInvalid(format!(
62                "transport envelope: unknown field `{}` (RFC-AITP-0010 §3 places \
63                 `signature` inside the signed body, never beside the wrapper)",
64                e.field
65            ))
66        })?;
67
68        let body = value.get("session_bundle").expect("checked above");
69        check_members("SessionTrustBundle", body, SESSION_BUNDLE_MEMBERS)
70            .map_err(|e| SessionBundleError::UnknownField(e.field))?;
71
72        let env: SessionBundleEnvelope = serde_json::from_value(value.clone()).map_err(|e| {
73            if let Some(field) = from_serde_error(&e) {
74                SessionBundleError::UnknownField(field)
75            } else {
76                SessionBundleError::WireFormInvalid(format!("transport envelope: {e}"))
77            }
78        })?;
79        Ok(env.session_bundle)
80    } else {
81        check_members("SessionTrustBundle", value, SESSION_BUNDLE_MEMBERS)
82            .map_err(|e| SessionBundleError::UnknownField(e.field))?;
83
84        serde_json::from_value(value.clone()).map_err(|e| {
85            if let Some(field) = from_serde_error(&e) {
86                SessionBundleError::UnknownField(field)
87            } else {
88                SessionBundleError::WireFormInvalid(format!("bundle body: {e}"))
89            }
90        })
91    }
92}
93
94/// Parse a session bundle that MUST arrive in the `{"session_bundle":
95/// {…}}` transport envelope — the HTTP ingest path (RFC-AITP-0010
96/// §4.3.1: "A bare (unwrapped) `session_bundle` body MUST be rejected at
97/// this endpoint").
98///
99/// Identical to [`parse_session_bundle_wire`] except that a value with
100/// no `session_bundle` member is rejected with
101/// [`SessionBundleError::WireFormInvalid`] (`SESSION_BUNDLE_INVALID`)
102/// instead of being read as a bare body. The coordinator signs the inner
103/// body and "add\[s\] the wrapper only when transmitting" (§4.2 step 4),
104/// so every transport receives the wrapped form; the bare-body path of
105/// [`parse_session_bundle_wire`] is for in-process library callers only.
106pub fn parse_session_bundle_envelope(
107    value: &CheckedJson,
108) -> Result<SessionTrustBundle, SessionBundleError> {
109    if value.as_value().get("session_bundle").is_none() {
110        return Err(SessionBundleError::WireFormInvalid(
111            "missing `session_bundle` transport wrapper (RFC-AITP-0010 §4.3.1 \
112             rejects a bare body)"
113                .into(),
114        ));
115    }
116    parse_session_bundle_wire(value)
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use serde_json::json;
123
124    /// Re-enter an in-memory `Value` through `CheckedJson`'s text-only
125    /// constructor (a `Value` has no duplicate key, so this cannot fail).
126    fn checked(v: &serde_json::Value) -> aitp_core::CheckedJson {
127        v.to_string().parse().expect("valid JSON")
128    }
129
130    #[test]
131    fn envelope_parser_rejects_bare_body() {
132        match parse_session_bundle_envelope(&checked(&valid_body())) {
133            Err(SessionBundleError::WireFormInvalid(msg)) => {
134                assert!(msg.contains("session_bundle"), "got {msg}")
135            }
136            other => panic!("expected WireFormInvalid, got {other:?}"),
137        }
138    }
139
140    #[test]
141    fn envelope_parser_accepts_wrapped_body() {
142        let wire = json!({ "session_bundle": valid_body() });
143        assert!(parse_session_bundle_envelope(&checked(&wire)).is_ok());
144    }
145
146    fn valid_body() -> Value {
147        json!({
148            "version": "aitp/0.3",
149            "session_id": "00000000-0000-4000-8000-000000000000",
150            "coordinator": "aid:pubkey:O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik",
151            "issued_at": 1_700_000_000,
152            "expires_at": 1_700_010_000,
153            "participants": [],
154            "signature": "A".repeat(86),
155        })
156    }
157
158    #[test]
159    fn wrapped_with_signature_inside_parses() {
160        let wire = json!({ "session_bundle": valid_body() });
161        assert!(parse_session_bundle_wire(&checked(&wire)).is_ok());
162    }
163
164    #[test]
165    fn bare_body_parses() {
166        assert!(parse_session_bundle_wire(&checked(&valid_body())).is_ok());
167    }
168
169    #[test]
170    fn sibling_signature_is_rejected() {
171        let mut body = valid_body();
172        let signature = body.as_object_mut().unwrap().remove("signature").unwrap();
173        let wire = json!({ "session_bundle": body, "signature": signature });
174        assert!(matches!(
175            parse_session_bundle_wire(&checked(&wire)),
176            Err(SessionBundleError::WireFormInvalid(_))
177        ));
178    }
179
180    #[test]
181    fn unexpected_sibling_key_is_rejected() {
182        let wire = json!({ "session_bundle": valid_body(), "extra": "nope" });
183        assert!(matches!(
184            parse_session_bundle_wire(&checked(&wire)),
185            Err(SessionBundleError::WireFormInvalid(_))
186        ));
187    }
188
189    #[test]
190    fn bare_body_missing_signature_is_rejected() {
191        let mut body = valid_body();
192        body.as_object_mut().unwrap().remove("signature");
193        assert!(matches!(
194            parse_session_bundle_wire(&checked(&body)),
195            Err(SessionBundleError::WireFormInvalid(_))
196        ));
197    }
198
199    /// `bundle-006-unknown-field-rejected`: an unknown member of the
200    /// INNER body (not inside `extensions`, not a wrapper sibling) is a
201    /// body-level defect, distinct from the wrapper-level
202    /// `WireFormInvalid` cases above.
203    #[test]
204    fn unknown_body_member_is_unknown_field() {
205        let mut body = valid_body();
206        body.as_object_mut()
207            .unwrap()
208            .insert("coordinator_note".into(), json!("primary-region"));
209        let wire = json!({ "session_bundle": body });
210        match parse_session_bundle_wire(&checked(&wire)) {
211            Err(SessionBundleError::UnknownField(field)) => {
212                assert_eq!(field, "coordinator_note")
213            }
214            other => panic!("expected UnknownField(\"coordinator_note\"), got {other:?}"),
215        }
216    }
217
218    /// Same body-level defect via the bare-body entry point: the two
219    /// entry points must not disagree about what a valid body looks
220    /// like.
221    #[test]
222    fn unknown_bare_body_member_is_unknown_field() {
223        let mut body = valid_body();
224        body.as_object_mut()
225            .unwrap()
226            .insert("rogue".into(), json!(1));
227        match parse_session_bundle_wire(&checked(&body)) {
228            Err(SessionBundleError::UnknownField(field)) => assert_eq!(field, "rogue"),
229            other => panic!("expected UnknownField(\"rogue\"), got {other:?}"),
230        }
231    }
232
233    /// A member inside `extensions` is never inspected — the MUST-ignore
234    /// half of RFC-AITP-0001 §7 — even though the body-level member-set
235    /// check now runs (`bundle-005-extensions-accepted`'s ignore-half
236    /// guard, at the wire-parsing layer rather than full verification).
237    #[test]
238    fn extensions_contents_are_never_treated_as_unknown_members() {
239        let mut body = valid_body();
240        body.as_object_mut()
241            .unwrap()
242            .insert("extensions".into(), json!({ "tee": { "platform": "sgx" } }));
243        let wire = json!({ "session_bundle": body });
244        assert!(parse_session_bundle_wire(&checked(&wire)).is_ok());
245    }
246
247    /// A nested closed object (`participants[]` entry) with an unknown
248    /// member is not caught by the top-level [`SESSION_BUNDLE_MEMBERS`]
249    /// check — that only inspects the body's own keys — but the residual
250    /// `deny_unknown_fields` error from `ParticipantEntry` is still
251    /// recovered via `from_serde_error` and reported as `UnknownField`,
252    /// never a generic parse failure.
253    #[test]
254    fn unknown_participant_entry_member_is_unknown_field_via_from_serde_error() {
255        let mut body = valid_body();
256        body["participants"] = json!([{
257            "aid": "aid:pubkey:O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik",
258            "tct": "a.b.c",
259            "rogue": "nope",
260        }]);
261        let wire = json!({ "session_bundle": body });
262        match parse_session_bundle_wire(&checked(&wire)) {
263            Err(SessionBundleError::UnknownField(field)) => assert_eq!(field, "rogue"),
264            other => panic!("expected UnknownField(\"rogue\"), got {other:?}"),
265        }
266    }
267
268    /// The security property RFC-AITP-0010 §5 places this check for: even
269    /// a bundle whose `coordinator` is not a resolvable AID at all is
270    /// rejected on its unknown body member BEFORE anything downstream
271    /// would attempt to interpret — let alone resolve — that field. The
272    /// member-set check operates purely on the raw JSON key set, so it
273    /// never reaches, parses, or resolves `coordinator`.
274    #[test]
275    fn unknown_body_member_is_rejected_before_coordinator_key_resolution() {
276        let mut body = valid_body();
277        // Not a well-formed AID at all — if this were ever parsed or a
278        // key derived from it, that would panic or error with something
279        // other than UnknownField. It never gets that far.
280        body["coordinator"] = json!("not-an-aid-and-not-even-attempted");
281        body.as_object_mut()
282            .unwrap()
283            .insert("rogue".into(), json!("nope"));
284        let wire = json!({ "session_bundle": body });
285        match parse_session_bundle_wire(&checked(&wire)) {
286            Err(SessionBundleError::UnknownField(field)) => assert_eq!(field, "rogue"),
287            other => panic!(
288                "expected the unknown-member check to short-circuit before `coordinator` is \
289                 ever interpreted, got {other:?}"
290            ),
291        }
292    }
293}