Skip to main content

auths_keri/
ipex.rs

1//! IPEX — the Issuance & Presentation EXchange protocol.
2//!
3//! IPEX is KERI's standard peer-to-peer handshake for handing over an ACDC
4//! credential. Where a credential *is* an ACDC and its *status* lives in a TEL,
5//! IPEX is the *exchange envelope*: a pair of signed `exn` (peer exchange)
6//! messages that carry the credential from a discloser to a holder and record
7//! the holder's acceptance — the standard way two KERI controllers move a
8//! credential between them, instead of each inventing a bespoke presentation
9//! wire.
10//!
11//! Two messages, mirroring the two roles in a disclosure:
12//!
13//! * **Grant** (discloser → holder): *"here is a credential for you."* An
14//!   [`IpexGrant`] is an `exn` routed `/ipex/grant` whose attributes name the
15//!   recipient and whose embeds block carries the full ACDC. It opens the
16//!   exchange, so it has no prior (`p` is empty).
17//! * **Admit** (holder → discloser): *"I accept it."* An [`IpexAdmit`] is an
18//!   `exn` routed `/ipex/admit` whose prior (`p`) is the grant's SAID, closing
19//!   the loop. It carries no embeds.
20//!
21//! The wire records are byte-exact with keripy 1.3.4's `keri.vc.protocoling`
22//! (`ipexGrantExn` / `ipexAdmitExn` over `keri.peer.exchanging.exchange`). An
23//! `exn` serializes in field order `{v, t:"exn", d, i, rp, p, dt, r, q, a, e}`,
24//! SAID-ified over the whole record (the top-level `d`), and — for a grant — the
25//! embeds block `e` carries its own section SAID (`e.d`) over `{acdc, d}`,
26//! exactly as `exchanging.exchange` saidifies `e` under the `d` label. The
27//! version string is sized `KERI10JSON{size:06x}_` like every KERI record.
28//!
29//! This module is I/O-free: it builds and parses the `exn` wire records and
30//! pulls the embedded ACDC back out, verifying its SAID. Signing the `exn` and
31//! putting it on a transport sit behind ports in the caller (the CLI's IPEX
32//! adapter), so the exchange logic never imports a signer or a socket.
33
34use serde::ser::SerializeMap;
35use serde::{Serialize, Serializer};
36
37use crate::acdc::{Acdc, AcdcError};
38use crate::error::KeriTranslationError;
39use crate::events::KERI_VERSION_PREFIX;
40use crate::said::{Protocol, compute_said_with_protocol, compute_section_said};
41use crate::types::{Prefix, Said};
42
43/// Placeholder version string filled in during saidify (17 chars, like every
44/// KERI record's `v`).
45const KERI_VERSION_PLACEHOLDER: &str = "KERI10JSON000000_";
46
47/// The keripy `exn` ilk — IPEX rides peer exchange messages, not key events.
48const EXN_ILK: &str = "exn";
49
50/// Sizes the version string `KERI10JSON{size:06x}_` to a serialized record — the
51/// same single-pass machinery the OOBI/TEL records use (the `v` field width is
52/// constant, so re-serializing with the placeholder gives the final byte length).
53fn recompute_version_string<T: Serialize>(event: &T) -> Result<String, IpexError> {
54    let bytes = serde_json::to_vec(event).map_err(KeriTranslationError::SerializationFailed)?;
55    Ok(format!("{KERI_VERSION_PREFIX}{:06x}_", bytes.len()))
56}
57
58/// An IPEX grant `exn` — *"discloser `i` grants the embedded ACDC to holder `rcp`."*
59///
60/// Byte-exact with keripy 1.3.4's `ipexGrantExn`: serializes as
61/// `{v, t:"exn", d, i, rp:"", p:"", dt, r:"/ipex/grant", q:{},
62/// a:{m, i:<recipient>}, e:{acdc:<ACDC>, d:<embeds SAID>}}`. The grant opens an
63/// exchange, so `rp` and `p` are empty (keripy's `exchange` leaves them `""` when
64/// no recipient/prior is threaded through `exchange`). Build via
65/// [`IpexGrant::new`] (which saidifies the embeds block then the whole record),
66/// then serialize for the wire.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct IpexGrant {
69    /// Version string `KERI10JSON{size:06x}_`.
70    pub v: String,
71    /// SAID of this grant `exn` (Blake3-256 over the saidified record).
72    pub d: Said,
73    /// Discloser (sender) AID — the controller granting the credential.
74    pub i: Prefix,
75    /// Human-readable disclosure message (`a.m`); empty by default.
76    pub m: String,
77    /// Recipient (holder) AID the credential is granted to (`a.i`).
78    pub recipient: Prefix,
79    /// ISO-8601 datetime stamp (RFC-3339 profile, microsecond precision).
80    pub dt: String,
81    /// The ACDC being disclosed, carried in the `e.acdc` embeds slot.
82    pub acdc: Acdc,
83    /// SAID of the embeds block `e` (`e.d`), over `{acdc, d}`.
84    pub embeds_said: Said,
85}
86
87impl IpexGrant {
88    /// The keripy route for an IPEX grant `exn`.
89    pub const ROUTE: &'static str = "/ipex/grant";
90
91    /// Builds a saidified IPEX grant `exn` disclosing `acdc` to `recipient`.
92    ///
93    /// The ACDC must already be saidified (its own `d`/`a.d` filled) — a grant
94    /// discloses an existing credential, it does not mint one. `message` is the
95    /// optional human-readable note (`a.m`); pass `""` for keripy's default.
96    pub fn new(
97        sender: Prefix,
98        recipient: Prefix,
99        acdc: Acdc,
100        message: impl Into<String>,
101        dt: impl Into<String>,
102    ) -> Result<Self, IpexError> {
103        let mut grant = Self {
104            v: KERI_VERSION_PLACEHOLDER.to_string(),
105            d: Said::default(),
106            i: sender,
107            m: message.into(),
108            recipient,
109            dt: dt.into(),
110            acdc,
111            embeds_said: Said::default(),
112        };
113        grant.saidify()?;
114        Ok(grant)
115    }
116
117    /// Computes the embeds-block SAID (`e.d`) then the top-level grant SAID, in
118    /// place — the two-stage order keripy's `exchange` uses (saidify `e` first,
119    /// substitute `e.d`, then saidify the whole `exn`).
120    fn saidify(&mut self) -> Result<(), IpexError> {
121        // `e.d` is a section SAID over `{acdc, d}` (the embeds block keripy
122        // saidifies under the `d` label), with the ACDC carrying its own SAID.
123        let embeds = self.embeds_value()?;
124        self.embeds_said = compute_section_said(&embeds)?;
125
126        // The top-level `d` is a plain KERI-protocol SAID over the whole record
127        // (`exn` is not an inception, so `i` is kept during hashing).
128        let body =
129            serde_json::to_value(&*self).map_err(KeriTranslationError::SerializationFailed)?;
130        self.d = compute_said_with_protocol(&body, Protocol::Keri)?;
131        self.v = recompute_version_string(&*self)?;
132        Ok(())
133    }
134
135    /// The `e` embeds block as JSON: `{acdc:<ACDC>, d:<embeds SAID>}` (the SAID is
136    /// placeholder-filled by [`compute_section_said`] when it is recomputed).
137    fn embeds_value(&self) -> Result<serde_json::Value, IpexError> {
138        let acdc =
139            serde_json::to_value(&self.acdc).map_err(KeriTranslationError::SerializationFailed)?;
140        let mut e = serde_json::Map::new();
141        e.insert("acdc".to_string(), acdc);
142        e.insert(
143            "d".to_string(),
144            serde_json::Value::String(self.embeds_said.as_str().to_string()),
145        );
146        Ok(serde_json::Value::Object(e))
147    }
148
149    /// Parses a peer's grant `exn` JSON into a typed [`IpexGrant`], verifying both
150    /// the record SAID and the embedded ACDC SAID at the boundary.
151    ///
152    /// Total at the boundary: a record whose route is not `/ipex/grant`, whose
153    /// `d`/`e.d` SAIDs don't recompute, or whose embedded ACDC fails its own SAID
154    /// check never becomes an `IpexGrant`.
155    pub fn parse(json: &str) -> Result<Self, IpexError> {
156        let value: serde_json::Value =
157            serde_json::from_str(json).map_err(KeriTranslationError::SerializationFailed)?;
158        let obj = value.as_object().ok_or(IpexError::NotAnObject)?;
159
160        expect_ilk(obj)?;
161        expect_route(obj, Self::ROUTE)?;
162
163        let sender = parse_prefix(obj, "i")?;
164        let dt = parse_str(obj, "dt")?;
165
166        let a = obj
167            .get("a")
168            .and_then(|v| v.as_object())
169            .ok_or(IpexError::MissingField { field: "a" })?;
170        let message = a
171            .get("m")
172            .and_then(|v| v.as_str())
173            .unwrap_or("")
174            .to_string();
175        let recipient = a
176            .get("i")
177            .and_then(|v| v.as_str())
178            .ok_or(IpexError::MissingField { field: "a.i" })
179            .and_then(|s| {
180                Prefix::new(s.to_string()).map_err(|source| IpexError::Prefix {
181                    field: "a.i",
182                    source,
183                })
184            })?;
185
186        let e = obj
187            .get("e")
188            .and_then(|v| v.as_object())
189            .ok_or(IpexError::MissingField { field: "e" })?;
190        let acdc_value = e
191            .get("acdc")
192            .ok_or(IpexError::MissingField { field: "e.acdc" })?;
193        let acdc: Acdc = serde_json::from_value(acdc_value.clone())
194            .map_err(KeriTranslationError::SerializationFailed)?;
195        // The embedded credential must stand on its own SAID — a grant cannot
196        // launder a tampered ACDC behind the exchange envelope.
197        acdc.verify_said()?;
198
199        let grant = Self::new(sender, recipient, acdc, message, dt)?;
200        verify_said(obj, &grant.d)?;
201        Ok(grant)
202    }
203}
204
205impl Serialize for IpexGrant {
206    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
207        let mut map = serializer.serialize_map(Some(11))?;
208        map.serialize_entry("v", &self.v)?;
209        map.serialize_entry("t", EXN_ILK)?;
210        map.serialize_entry("d", &self.d)?;
211        map.serialize_entry("i", &self.i)?;
212        map.serialize_entry("rp", "")?;
213        map.serialize_entry("p", "")?;
214        map.serialize_entry("dt", &self.dt)?;
215        map.serialize_entry("r", IpexGrant::ROUTE)?;
216        map.serialize_entry("q", &serde_json::Map::<String, serde_json::Value>::new())?;
217        let mut a = serde_json::Map::new();
218        a.insert("m".into(), serde_json::Value::String(self.m.clone()));
219        a.insert(
220            "i".into(),
221            serde_json::Value::String(self.recipient.to_string()),
222        );
223        map.serialize_entry("a", &serde_json::Value::Object(a))?;
224        let mut e = serde_json::Map::new();
225        e.insert(
226            "acdc".into(),
227            serde_json::to_value(&self.acdc).map_err(serde::ser::Error::custom)?,
228        );
229        e.insert(
230            "d".into(),
231            serde_json::Value::String(self.embeds_said.as_str().to_string()),
232        );
233        map.serialize_entry("e", &serde_json::Value::Object(e))?;
234        map.end()
235    }
236}
237
238/// An IPEX admit `exn` — *"holder `i` admits the grant `p`."*
239///
240/// Byte-exact with keripy 1.3.4's `ipexAdmitExn`: serializes as
241/// `{v, t:"exn", d, i, rp:"", p:<grant SAID>, dt, r:"/ipex/admit", q:{},
242/// a:{m}, e:{}}`. The admit responds to a grant, so its prior (`p`) is the grant
243/// SAID and its embeds block `e` is empty (no `e.d`, since there is nothing to
244/// saidify). Build via [`IpexAdmit::new`].
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub struct IpexAdmit {
247    /// Version string `KERI10JSON{size:06x}_`.
248    pub v: String,
249    /// SAID of this admit `exn`.
250    pub d: Said,
251    /// Holder (sender) AID admitting the disclosure.
252    pub i: Prefix,
253    /// Human-readable admission message (`a.m`); empty by default.
254    pub m: String,
255    /// Prior (`p`) — the SAID of the grant this admit responds to.
256    pub prior: Said,
257    /// ISO-8601 datetime stamp.
258    pub dt: String,
259}
260
261impl IpexAdmit {
262    /// The keripy route for an IPEX admit `exn`.
263    pub const ROUTE: &'static str = "/ipex/admit";
264
265    /// Builds a saidified IPEX admit `exn` accepting the grant identified by
266    /// `grant_said`.
267    pub fn new(
268        sender: Prefix,
269        grant_said: Said,
270        message: impl Into<String>,
271        dt: impl Into<String>,
272    ) -> Result<Self, IpexError> {
273        let mut admit = Self {
274            v: KERI_VERSION_PLACEHOLDER.to_string(),
275            d: Said::default(),
276            i: sender,
277            m: message.into(),
278            prior: grant_said,
279            dt: dt.into(),
280        };
281        admit.saidify()?;
282        Ok(admit)
283    }
284
285    fn saidify(&mut self) -> Result<(), IpexError> {
286        let body =
287            serde_json::to_value(&*self).map_err(KeriTranslationError::SerializationFailed)?;
288        self.d = compute_said_with_protocol(&body, Protocol::Keri)?;
289        self.v = recompute_version_string(&*self)?;
290        Ok(())
291    }
292
293    /// Parses a peer's admit `exn` JSON into a typed [`IpexAdmit`], verifying the
294    /// record SAID and that it threads a prior grant SAID at the boundary.
295    pub fn parse(json: &str) -> Result<Self, IpexError> {
296        let value: serde_json::Value =
297            serde_json::from_str(json).map_err(KeriTranslationError::SerializationFailed)?;
298        let obj = value.as_object().ok_or(IpexError::NotAnObject)?;
299
300        expect_ilk(obj)?;
301        expect_route(obj, Self::ROUTE)?;
302
303        let sender = parse_prefix(obj, "i")?;
304        let dt = parse_str(obj, "dt")?;
305        let prior_str = parse_str(obj, "p")?;
306        if prior_str.is_empty() {
307            // An admit with no prior is not threading any grant — it cannot open
308            // an IPEX exchange (keripy's IpexHandler rejects the same way).
309            return Err(IpexError::MissingPrior);
310        }
311        let prior =
312            Said::new(prior_str).map_err(|source| IpexError::Prefix { field: "p", source })?;
313
314        let admit = Self::new(sender, prior, message_of(obj), dt)?;
315        verify_said(obj, &admit.d)?;
316        Ok(admit)
317    }
318}
319
320impl Serialize for IpexAdmit {
321    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
322        let mut map = serializer.serialize_map(Some(10))?;
323        map.serialize_entry("v", &self.v)?;
324        map.serialize_entry("t", EXN_ILK)?;
325        map.serialize_entry("d", &self.d)?;
326        map.serialize_entry("i", &self.i)?;
327        map.serialize_entry("rp", "")?;
328        map.serialize_entry("p", &self.prior)?;
329        map.serialize_entry("dt", &self.dt)?;
330        map.serialize_entry("r", IpexAdmit::ROUTE)?;
331        map.serialize_entry("q", &serde_json::Map::<String, serde_json::Value>::new())?;
332        let mut a = serde_json::Map::new();
333        a.insert("m".into(), serde_json::Value::String(self.m.clone()));
334        map.serialize_entry("a", &serde_json::Value::Object(a))?;
335        map.serialize_entry("e", &serde_json::Map::<String, serde_json::Value>::new())?;
336        map.end()
337    }
338}
339
340/// Reads the `a.m` message field of an `exn`, defaulting to `""`.
341fn message_of(obj: &serde_json::Map<String, serde_json::Value>) -> String {
342    obj.get("a")
343        .and_then(|v| v.as_object())
344        .and_then(|a| a.get("m"))
345        .and_then(|v| v.as_str())
346        .unwrap_or("")
347        .to_string()
348}
349
350/// Rejects a record whose ilk (`t`) is not `exn` at the boundary.
351fn expect_ilk(obj: &serde_json::Map<String, serde_json::Value>) -> Result<(), IpexError> {
352    match obj.get("t").and_then(|v| v.as_str()) {
353        Some(EXN_ILK) => Ok(()),
354        other => Err(IpexError::WrongIlk(other.unwrap_or("").to_string())),
355    }
356}
357
358/// Rejects a record whose route (`r`) is not the expected IPEX route.
359fn expect_route(
360    obj: &serde_json::Map<String, serde_json::Value>,
361    route: &'static str,
362) -> Result<(), IpexError> {
363    match obj.get("r").and_then(|v| v.as_str()) {
364        Some(r) if r == route => Ok(()),
365        other => Err(IpexError::WrongRoute {
366            expected: route,
367            found: other.unwrap_or("").to_string(),
368        }),
369    }
370}
371
372/// Reads a required string field, or errors with its name.
373fn parse_str(
374    obj: &serde_json::Map<String, serde_json::Value>,
375    field: &'static str,
376) -> Result<String, IpexError> {
377    obj.get(field)
378        .and_then(|v| v.as_str())
379        .map(str::to_string)
380        .ok_or(IpexError::MissingField { field })
381}
382
383/// Reads a required prefix field, validating its CESR coding at the boundary.
384fn parse_prefix(
385    obj: &serde_json::Map<String, serde_json::Value>,
386    field: &'static str,
387) -> Result<Prefix, IpexError> {
388    let s = parse_str(obj, field)?;
389    Prefix::new(s).map_err(|source| IpexError::Prefix { field, source })
390}
391
392/// Verifies a parsed record's carried `d` matches the SAID we recomputed.
393fn verify_said(
394    obj: &serde_json::Map<String, serde_json::Value>,
395    computed: &Said,
396) -> Result<(), IpexError> {
397    let found = obj
398        .get("d")
399        .and_then(|v| v.as_str())
400        .ok_or(IpexError::MissingField { field: "d" })?;
401    if found != computed.as_str() {
402        return Err(IpexError::SaidMismatch {
403            computed: computed.as_str().to_string(),
404            found: found.to_string(),
405        });
406    }
407    Ok(())
408}
409
410/// Errors raised while building or parsing an IPEX `exn`.
411#[derive(Debug, thiserror::Error)]
412pub enum IpexError {
413    /// The record was not a JSON object.
414    #[error("IPEX record is not a JSON object")]
415    NotAnObject,
416    /// The record's ilk (`t`) was not `exn`.
417    #[error("IPEX record is not an exn (t = {0:?})")]
418    WrongIlk(String),
419    /// The record's route (`r`) was not the expected IPEX route.
420    #[error("IPEX record route is {found:?}, expected {expected:?}")]
421    WrongRoute {
422        /// The route the parser required.
423        expected: &'static str,
424        /// The route the record actually carried.
425        found: String,
426    },
427    /// A required field was absent.
428    #[error("IPEX record missing field {field}")]
429    MissingField {
430        /// The absent field's path.
431        field: &'static str,
432    },
433    /// An admit carried no prior grant SAID, so it threads no exchange.
434    #[error("IPEX admit has no prior grant (p is empty)")]
435    MissingPrior,
436    /// A prefix/SAID field was not a CESR-valid value.
437    #[error("invalid {field} in IPEX record: {source}")]
438    Prefix {
439        /// Which field failed.
440        field: &'static str,
441        /// The underlying CESR/derivation-code error.
442        source: crate::types::KeriTypeError,
443    },
444    /// The carried `d` SAID did not match the one recomputed from the record.
445    #[error("IPEX record SAID mismatch: computed {computed}, found {found}")]
446    SaidMismatch {
447        /// The SAID recomputed from the record.
448        computed: String,
449        /// The SAID the record carried.
450        found: String,
451    },
452    /// The embedded ACDC failed to build/verify.
453    #[error("IPEX embedded ACDC failed: {0}")]
454    Acdc(#[from] AcdcError),
455    /// A wire record failed to saidify/serialize.
456    #[error("KERI record build failed: {0}")]
457    Record(#[from] KeriTranslationError),
458}
459
460#[cfg(test)]
461#[allow(clippy::unwrap_used, clippy::expect_used)]
462mod tests {
463    use super::*;
464    use crate::acdc::Acdc;
465
466    const SENDER: &str = "EOoC9Auw5kgKLi0d8JZAoTNZH3ULvYAfSVPzhzS6b5CM";
467    const RECP: &str = "EBHnCvYya3Udo4SEGo82HeOPt7WkVDEC0KWfKYnZpupF";
468    const REGISTRY: &str = "EO0_SHla5Gnzc-T3jkTNAclpA1iv1L9k3lQZw5cFOe9o";
469    const SCHEMA: &str = "EMQWEcCnVRk1hatTNyK3sIykYSrrFvafX3bHQ9Gkk1kC";
470    const DT: &str = "2024-01-01T00:00:00.000000+00:00";
471    /// The grant `exn` SAID keripy 1.3.4 computes for the [`fixture_acdc`] grant.
472    const GRANT_SAID: &str = "EGTOcVx8ghSFYwMQT_q4YMjEzlUIh93kKfvnIzgtfgkS";
473
474    /// Builds the same ACDC keripy embedded in the reference grant vector.
475    fn fixture_acdc() -> Acdc {
476        Acdc::new(
477            Prefix::new(SENDER.to_string()).unwrap(),
478            Said::new(REGISTRY.to_string()).unwrap(),
479            Said::new(SCHEMA.to_string()).unwrap(),
480            Prefix::new(RECP.to_string()).unwrap(),
481            DT.to_string(),
482            serde_json::Map::new(),
483        )
484        .saidify()
485        .unwrap()
486    }
487
488    // The grant `exn` must be byte-exact with keripy 1.3.4's `ipexGrantExn`. This
489    // vector was generated from keripy itself (the oracle):
490    //   exchanging.exchange(route="/ipex/grant", payload={m:"", i:RECP},
491    //                       sender=SENDER, embeds={acdc:<ACDC>}, date=DT)
492    #[test]
493    fn grant_exn_byte_exact_keripy() {
494        let grant = IpexGrant::new(
495            Prefix::new(SENDER.to_string()).unwrap(),
496            Prefix::new(RECP.to_string()).unwrap(),
497            fixture_acdc(),
498            "",
499            DT,
500        )
501        .unwrap();
502        let json = serde_json::to_string(&grant).unwrap();
503        let expected = r#"{"v":"KERI10JSON0002d4_","t":"exn","d":"EGTOcVx8ghSFYwMQT_q4YMjEzlUIh93kKfvnIzgtfgkS","i":"EOoC9Auw5kgKLi0d8JZAoTNZH3ULvYAfSVPzhzS6b5CM","rp":"","p":"","dt":"2024-01-01T00:00:00.000000+00:00","r":"/ipex/grant","q":{},"a":{"m":"","i":"EBHnCvYya3Udo4SEGo82HeOPt7WkVDEC0KWfKYnZpupF"},"e":{"acdc":{"v":"ACDC10JSON00017a_","d":"ECK0Ep4HfnszjMpQDgovp19ioPdn1jwxGdnEtNHCN2Sy","i":"EOoC9Auw5kgKLi0d8JZAoTNZH3ULvYAfSVPzhzS6b5CM","ri":"EO0_SHla5Gnzc-T3jkTNAclpA1iv1L9k3lQZw5cFOe9o","s":"EMQWEcCnVRk1hatTNyK3sIykYSrrFvafX3bHQ9Gkk1kC","a":{"d":"EKP_MEtpMtJfInZdMOiivHrYtz3zyObVfjDySEGxGT-V","i":"EBHnCvYya3Udo4SEGo82HeOPt7WkVDEC0KWfKYnZpupF","dt":"2024-01-01T00:00:00.000000+00:00"}},"d":"EOXgGpKt_2f6rr_JyxVwEBT1z6xbACKW0PLDhoULb0ag"}}"#;
504        assert_eq!(json, expected);
505    }
506
507    // The admit `exn` must be byte-exact with keripy 1.3.4's `ipexAdmitExn`:
508    //   exchanging.exchange(route="/ipex/admit", payload={m:""}, sender=RECP,
509    //                       dig=grant.said, date=DT)
510    #[test]
511    fn admit_exn_byte_exact_keripy() {
512        let admit = IpexAdmit::new(
513            Prefix::new(RECP.to_string()).unwrap(),
514            Said::new(GRANT_SAID.to_string()).unwrap(),
515            "",
516            DT,
517        )
518        .unwrap();
519        let json = serde_json::to_string(&admit).unwrap();
520        let expected = r#"{"v":"KERI10JSON000119_","t":"exn","d":"EEAwH5LPMA4bkj5ceowBjGDnpe7aWW1BQ530djvBp1kv","i":"EBHnCvYya3Udo4SEGo82HeOPt7WkVDEC0KWfKYnZpupF","rp":"","p":"EGTOcVx8ghSFYwMQT_q4YMjEzlUIh93kKfvnIzgtfgkS","dt":"2024-01-01T00:00:00.000000+00:00","r":"/ipex/admit","q":{},"a":{"m":""},"e":{}}"#;
521        assert_eq!(json, expected);
522    }
523
524    #[test]
525    fn grant_round_trips_through_parse() {
526        let grant = IpexGrant::new(
527            Prefix::new(SENDER.to_string()).unwrap(),
528            Prefix::new(RECP.to_string()).unwrap(),
529            fixture_acdc(),
530            "",
531            DT,
532        )
533        .unwrap();
534        let json = serde_json::to_string(&grant).unwrap();
535        let parsed = IpexGrant::parse(&json).unwrap();
536        assert_eq!(parsed, grant);
537        // The embedded ACDC came back out intact and self-verifying.
538        assert_eq!(parsed.acdc.d, grant.acdc.d);
539        parsed.acdc.verify_said().unwrap();
540    }
541
542    #[test]
543    fn admit_round_trips_through_parse() {
544        let admit = IpexAdmit::new(
545            Prefix::new(RECP.to_string()).unwrap(),
546            Said::new(GRANT_SAID.to_string()).unwrap(),
547            "",
548            DT,
549        )
550        .unwrap();
551        let json = serde_json::to_string(&admit).unwrap();
552        let parsed = IpexAdmit::parse(&json).unwrap();
553        assert_eq!(parsed, admit);
554    }
555
556    #[test]
557    fn parse_rejects_tampered_grant_said() {
558        let grant = IpexGrant::new(
559            Prefix::new(SENDER.to_string()).unwrap(),
560            Prefix::new(RECP.to_string()).unwrap(),
561            fixture_acdc(),
562            "",
563            DT,
564        )
565        .unwrap();
566        let mut value: serde_json::Value =
567            serde_json::from_str(&serde_json::to_string(&grant).unwrap()).unwrap();
568        value["dt"] = serde_json::Value::String("2099-01-01T00:00:00.000000+00:00".into());
569        let tampered = serde_json::to_string(&value).unwrap();
570        let err = IpexGrant::parse(&tampered).unwrap_err();
571        assert!(matches!(err, IpexError::SaidMismatch { .. }));
572    }
573
574    #[test]
575    fn parse_rejects_tampered_embedded_acdc() {
576        let grant = IpexGrant::new(
577            Prefix::new(SENDER.to_string()).unwrap(),
578            Prefix::new(RECP.to_string()).unwrap(),
579            fixture_acdc(),
580            "",
581            DT,
582        )
583        .unwrap();
584        let mut value: serde_json::Value =
585            serde_json::from_str(&serde_json::to_string(&grant).unwrap()).unwrap();
586        // Tamper a credential claim without fixing its SAID — must be rejected.
587        value["e"]["acdc"]["a"]["dt"] =
588            serde_json::Value::String("2099-01-01T00:00:00.000000+00:00".into());
589        let tampered = serde_json::to_string(&value).unwrap();
590        let err = IpexGrant::parse(&tampered).unwrap_err();
591        assert!(matches!(err, IpexError::Acdc(_)));
592    }
593
594    #[test]
595    fn parse_rejects_wrong_route() {
596        let grant = IpexGrant::new(
597            Prefix::new(SENDER.to_string()).unwrap(),
598            Prefix::new(RECP.to_string()).unwrap(),
599            fixture_acdc(),
600            "",
601            DT,
602        )
603        .unwrap();
604        let json = serde_json::to_string(&grant).unwrap();
605        // A grant body is not an admit.
606        let err = IpexAdmit::parse(&json).unwrap_err();
607        assert!(matches!(err, IpexError::WrongRoute { .. }));
608    }
609
610    #[test]
611    fn admit_parse_rejects_missing_prior() {
612        let admit = IpexAdmit::new(
613            Prefix::new(RECP.to_string()).unwrap(),
614            Said::new(GRANT_SAID.to_string()).unwrap(),
615            "",
616            DT,
617        )
618        .unwrap();
619        let mut value: serde_json::Value =
620            serde_json::from_str(&serde_json::to_string(&admit).unwrap()).unwrap();
621        value["p"] = serde_json::Value::String(String::new());
622        let stripped = serde_json::to_string(&value).unwrap();
623        let err = IpexAdmit::parse(&stripped).unwrap_err();
624        assert!(matches!(err, IpexError::MissingPrior));
625    }
626}