Skip to main content

auths_keri/
said.rs

1use crate::error::KeriTranslationError;
2use crate::types::Said;
3
4/// The 44-character `#` placeholder injected into the `d` field (and `i` field
5/// for inception events) before hashing. Matches the length of a CESR-qualified
6/// Blake3-256 digest (`E` + 43 chars base64url = 44 chars).
7pub const SAID_PLACEHOLDER: &str = "############################################";
8
9/// The 17-character protocol/version tag families used by SAID-ification.
10///
11/// KERI events (KEL: `icp`/`rot`/`ixn`/`dip`/`drt`) carry `KERI10JSON…`; ACDC
12/// credentials carry `ACDC10JSON…`. Both share the identical 17-char layout
13/// (`<TAG>10JSON{size:06x}_`), so the two-pass size assertion in
14/// [`compute_said_with_protocol`] holds unchanged for either family.
15///
16/// Usage:
17/// ```ignore
18/// let said = compute_said_with_protocol(&acdc_json, Protocol::Acdc)?;
19/// ```
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Protocol {
22    /// KERI key-event protocol (`KERI10JSON…`) — the default for all KEL events.
23    Keri,
24    /// ACDC credential protocol (`ACDC10JSON…`).
25    Acdc,
26}
27
28impl Protocol {
29    /// The 17-char placeholder version string for this protocol (size field zeroed).
30    fn version_placeholder(self) -> &'static str {
31        match self {
32            Protocol::Keri => "KERI10JSON000000_",
33            Protocol::Acdc => "ACDC10JSON000000_",
34        }
35    }
36
37    /// The 4-char protocol code prefixing the version string (`KERI` / `ACDC`).
38    fn code(self) -> &'static str {
39        match self {
40            Protocol::Keri => "KERI",
41            Protocol::Acdc => "ACDC",
42        }
43    }
44
45    /// Whether this protocol's inception events *can* carry a self-addressing
46    /// prefix in `i` (a prefix derived from the event SAID, blanked during
47    /// SAID-ification).
48    ///
49    /// KERI inception events (`icp`/`dip`) and backerless TEL registry inception
50    /// (`vcp`) derive their prefix from the SAID, so a self-addressing `i` is
51    /// blanked. ACDC `i` is the *issuer* AID (an external reference), so it is
52    /// never blanked — only event protocols consult the `t` field.
53    ///
54    /// Note: this gates only the protocol/event-type; whether `i` is *actually*
55    /// self-addressing for a given event is decided per-value by
56    /// [`prefix_is_self_addressing`], because KERI also admits basic-prefix
57    /// inceptions where `i` is a public key and must be kept during hashing.
58    fn blanks_inception_prefix(self) -> bool {
59        matches!(self, Protocol::Keri)
60    }
61}
62
63/// Whether an inception event's current `i` value is a *self-addressing* prefix
64/// (one derived from the event SAID) — the only case in which `i` is blanked
65/// before hashing.
66///
67/// KERI admits two inception prefix kinds for the same keys:
68///
69/// * **self-addressing** — `i` is the SAID itself (a Blake3-256 digest, CESR
70///   code `E`), or, on the emit path, the as-yet-unfilled SAID
71///   [`SAID_PLACEHOLDER`]. keripy blanks `i` along with `d` before hashing.
72/// * **basic** — `i` is the controlling public key (e.g. an Ed25519 verkey,
73///   CESR code `D`/`B`, or a P-256/secp256k1 key, codes `1AAB`/`1AAC`…). It is
74///   *not* derived from the SAID, so keripy 1.3.4 keeps `i` present during
75///   hashing exactly as any other field.
76///
77/// Classifying by the value's CESR derivation code (parse, don't validate)
78/// rather than by event type lets auths reproduce keripy's SAID byte-exact for
79/// *either* prefix kind it ingests, while still emitting only self-addressing
80/// AIDs itself.
81///
82/// Self-addressing `i` is one of:
83/// * **empty** — the auths *emit* path (`finalize_icp_event`) hashes the event
84///   with `i` unset, then fills `i = d` afterwards; an unset `i` is an
85///   as-yet-underived self-addressing prefix, never a basic one (auths emits
86///   only self-addressing AIDs).
87/// * the [`SAID_PLACEHOLDER`] — the explicit "`d`/`i` to be filled" marker.
88/// * a digest prefix (`E…`, Blake3-256) — an already-filled self-addressing AID.
89///
90/// Anything else is a key prefix (a verkey: `D`/`B`/`1AAB`…) and therefore a
91/// *basic* prefix, which keripy keeps during hashing — so auths must too. This
92/// mirrors the discriminator `finalize_icp_event` already uses to decide
93/// whether to set `i = d`.
94fn prefix_is_self_addressing(i: &str) -> bool {
95    i.is_empty() || i == SAID_PLACEHOLDER || i.starts_with('E')
96}
97
98/// Computes a spec-compliant SAID for a KERI event (`KERI10JSON` protocol tag).
99///
100/// Thin wrapper over [`compute_said_with_protocol`] pinned to [`Protocol::Keri`];
101/// every existing KEL call site stays on this default so KEL SAIDs are unchanged.
102///
103/// The algorithm (Trust over IP KERI v0.9):
104/// 1. Set `d` to the 44-char `#` placeholder.
105/// 2. For self-addressing inception events (`t` in `icp`/`dip`/`vcp`), also set
106///    `i` to the placeholder.
107/// 3. Remove the `x` field entirely (signatures are detached from the digest).
108/// 4. Serialize with `serde_json::to_vec` (insertion-order, NOT json-canon).
109/// 5. Blake3-256 hash the bytes.
110/// 6. CESR-encode the digest: `E` derivation code + base64url-no-pad.
111///
112/// **Why insertion-order, not canonical JSON?** KERI specifies that SAIDs
113/// are computed over the insertion-order serialization of the event object.
114/// Using `json_canon` (RFC 8785 sorted keys) would produce different SAIDs
115/// and break interoperability with other KERI implementations. This depends
116/// on `serde_json`'s `preserve_order` feature being enabled in Cargo.toml
117/// (which activates `IndexMap` instead of `BTreeMap` for `serde_json::Map`).
118///
119/// Note: Attestation SAIDs (in `auths-id/src/keri/anchor.rs`) use `json_canon`
120/// — that is correct because attestations are an auths-specific format not
121/// constrained by the KERI spec.
122///
123/// Args:
124/// * `event`: The event as a JSON object.
125pub fn compute_said(event: &serde_json::Value) -> Result<Said, KeriTranslationError> {
126    compute_said_with_protocol(event, Protocol::Keri)
127}
128
129/// Computes a spec-compliant SAID for a SAID'd JSON object under a chosen protocol.
130///
131/// Generalises [`compute_said`] over the protocol/version tag (D7): KEL events use
132/// [`Protocol::Keri`] (`KERI10JSON…`); ACDC credentials use [`Protocol::Acdc`]
133/// (`ACDC10JSON…`). The placeholder + two-pass size machinery is identical because
134/// both tags are exactly 17 chars wide.
135///
136/// Args:
137/// * `event`: The SAID'd object as JSON (must contain or accept a `d` field).
138/// * `protocol`: Which protocol/version tag and self-addressing rules to apply.
139///
140/// Usage:
141/// ```ignore
142/// let said = compute_said_with_protocol(&acdc_json, Protocol::Acdc)?;
143/// ```
144pub fn compute_said_with_protocol(
145    event: &serde_json::Value,
146    protocol: Protocol,
147) -> Result<Said, KeriTranslationError> {
148    let obj = event
149        .as_object()
150        .ok_or(KeriTranslationError::MissingField {
151            field: "root object",
152        })?;
153
154    let placeholder = serde_json::Value::String(SAID_PLACEHOLDER.to_string());
155    let event_type = obj.get("t").and_then(|v| v.as_str()).unwrap_or("");
156    // Blank `i` only when this is a self-addressing inception: the event type is
157    // an inception (`icp`/`dip`/`vcp`) AND its `i` is actually derived from the
158    // SAID (a digest prefix or the unfilled placeholder). A basic-prefix
159    // inception carries a public key in `i`, which keripy keeps during hashing —
160    // so auths must keep it too, or it computes a confidently-wrong SAID.
161    let inception_prefix = obj.get("i").and_then(|v| v.as_str()).unwrap_or("");
162    let blank_prefix = protocol.blanks_inception_prefix()
163        && matches!(event_type, "icp" | "dip" | "vcp")
164        && prefix_is_self_addressing(inception_prefix);
165
166    // Rebuild the map with spec-compliant placeholders and field ordering.
167    let mut new_obj = serde_json::Map::new();
168
169    for (k, v) in obj {
170        if k == "x" {
171            // Signatures are detached from the digest (legacy field, skip)
172            continue;
173        } else if k == "d" {
174            new_obj.insert("d".to_string(), placeholder.clone());
175        } else if k == "i" && blank_prefix {
176            // Inception events are self-addressing (prefix == SAID), including
177            // delegated inception (`dip`) and backerless TEL registry inception
178            // (`vcp`): blank `i` so the digest is computed over the placeholder,
179            // not the derived prefix.
180            new_obj.insert("i".to_string(), placeholder.clone());
181        } else {
182            new_obj.insert(k.clone(), v.clone());
183        }
184    }
185
186    // Ensure d is always present (in case input omitted it)
187    if !new_obj.contains_key("d") {
188        new_obj.insert("d".to_string(), placeholder.clone());
189    }
190
191    // Two-pass version string: compute byte count then re-serialize
192    let version_placeholder = protocol.version_placeholder();
193    new_obj.insert(
194        "v".to_string(),
195        serde_json::Value::String(version_placeholder.to_string()),
196    );
197
198    let pass1 = serde_json::to_vec(&serde_json::Value::Object(new_obj.clone()))
199        .map_err(KeriTranslationError::SerializationFailed)?;
200
201    // Size is stable: placeholder and real version string are both 17 chars
202    let version_string = format!("{}10JSON{:06x}_", protocol.code(), pass1.len());
203    debug_assert_eq!(version_string.len(), version_placeholder.len());
204    new_obj.insert("v".to_string(), serde_json::Value::String(version_string));
205
206    let serialized = serde_json::to_vec(&serde_json::Value::Object(new_obj))
207        .map_err(KeriTranslationError::SerializationFailed)?;
208
209    let hash = blake3::hash(&serialized);
210    // CESR-encode the digest (keripy-identical alignment), not naive `E`+base64url.
211    #[allow(clippy::expect_used)] // INVARIANT: a 32-byte Blake3 digest always CESR-encodes
212    let said = crate::cesr_encode::encode_blake3_digest(hash.as_bytes())
213        .expect("32-byte Blake3 digest always encodes as a CESR Blake3_256 SAID");
214    Ok(Said::new_unchecked(said))
215}
216
217/// Computes the SAID of a nested SAID'd section that carries no version string.
218///
219/// Unlike [`compute_said`], a section (e.g. an ACDC `a` attributes block) has no
220/// `v` field — its SAID is a plain Blake3-256 over the insertion-order
221/// serialization with `d` placeholder-filled. This mirrors keripy's
222/// `Saider.saidify(sad=section, label="d")` for blockless sub-objects.
223///
224/// Args:
225/// * `section`: The section as a JSON object (`d` is placeholder-filled before hashing).
226///
227/// Usage:
228/// ```ignore
229/// let attr_said = compute_section_said(&attributes_json)?;
230/// ```
231pub fn compute_section_said(section: &serde_json::Value) -> Result<Said, KeriTranslationError> {
232    let obj = section
233        .as_object()
234        .ok_or(KeriTranslationError::MissingField {
235            field: "section object",
236        })?;
237
238    let placeholder = serde_json::Value::String(SAID_PLACEHOLDER.to_string());
239    let mut new_obj = serde_json::Map::new();
240    for (k, v) in obj {
241        if k == "d" {
242            new_obj.insert("d".to_string(), placeholder.clone());
243        } else {
244            new_obj.insert(k.clone(), v.clone());
245        }
246    }
247    if !new_obj.contains_key("d") {
248        new_obj.insert("d".to_string(), placeholder.clone());
249    }
250
251    let serialized = serde_json::to_vec(&serde_json::Value::Object(new_obj))
252        .map_err(KeriTranslationError::SerializationFailed)?;
253    let hash = blake3::hash(&serialized);
254    #[allow(clippy::expect_used)] // INVARIANT: a 32-byte Blake3 digest always CESR-encodes
255    let said = crate::cesr_encode::encode_blake3_digest(hash.as_bytes())
256        .expect("32-byte Blake3 digest always encodes as a CESR Blake3_256 SAID");
257    Ok(Said::new_unchecked(said))
258}
259
260/// Verifies that an event's `d` field matches the spec-compliant SAID.
261///
262/// Args:
263/// * `event`: The event JSON with a populated `d` field.
264pub fn verify_said(event: &serde_json::Value) -> Result<(), KeriTranslationError> {
265    let found = event
266        .get("d")
267        .and_then(|v| v.as_str())
268        .ok_or(KeriTranslationError::MissingField { field: "d" })?
269        .to_string();
270
271    let computed = compute_said(event)?;
272
273    if computed.as_str() != found {
274        return Err(KeriTranslationError::SaidMismatch {
275            computed: computed.into_inner(),
276            found,
277        });
278    }
279
280    Ok(())
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn said_has_correct_length() {
289        let event = serde_json::json!({
290            "v": "KERI10JSON000000_",
291            "t": "icp",
292            "d": "",
293            "i": "",
294            "s": "0",
295            "kt": "1",
296            "k": ["DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"],
297            "nt": "1",
298            "n": ["EAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"],
299            "bt": "0",
300            "b": [],
301            "a": []
302        });
303        let said = compute_said(&event).unwrap();
304        assert_eq!(said.as_str().len(), 44);
305        assert!(said.as_str().starts_with('E'));
306    }
307
308    #[test]
309    fn said_is_deterministic() {
310        let event = serde_json::json!({
311            "v": "KERI10JSON000000_",
312            "t": "rot",
313            "d": "",
314            "i": "EExistingPrefix",
315            "s": "1",
316            "p": "EPreviousSaid",
317            "kt": "1",
318            "k": ["DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"],
319            "nt": "1",
320            "n": ["EAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"],
321            "bt": "0",
322            "b": [],
323            "a": []
324        });
325        let said1 = compute_said(&event).unwrap();
326        let said2 = compute_said(&event).unwrap();
327        assert_eq!(said1, said2);
328    }
329
330    #[test]
331    fn said_ignores_x_field() {
332        let event_with_x = serde_json::json!({
333            "v": "KERI10JSON000000_",
334            "t": "icp",
335            "d": "",
336            "i": "",
337            "s": "0",
338            "kt": "1",
339            "k": ["DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"],
340            "nt": "1",
341            "n": ["EAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"],
342            "bt": "0",
343            "b": [],
344            "a": [],
345            "x": "abcdef1234567890"
346        });
347        let event_without_x = serde_json::json!({
348            "v": "KERI10JSON000000_",
349            "t": "icp",
350            "d": "",
351            "i": "",
352            "s": "0",
353            "kt": "1",
354            "k": ["DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"],
355            "nt": "1",
356            "n": ["EAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"],
357            "bt": "0",
358            "b": [],
359            "a": []
360        });
361        let said_with = compute_said(&event_with_x).unwrap();
362        let said_without = compute_said(&event_without_x).unwrap();
363        assert_eq!(said_with, said_without, "x field must not affect SAID");
364    }
365
366    /// A *self-addressing* inception blanks `i`, so its SAID is independent of
367    /// the particular (digest-coded) `i` value it carries — including the two
368    /// emit-path forms, the [`SAID_PLACEHOLDER`] and an already-filled `E…`
369    /// prefix.
370    ///
371    /// This is the corrected invariant: it holds *only* for self-addressing
372    /// prefixes. A basic prefix (a verkey in `i`) is kept during hashing and so
373    /// is NOT interchangeable — see [`basic_prefix_inception_keeps_i_matches_keripy`].
374    #[test]
375    fn self_addressing_inception_said_independent_of_i() {
376        let event_placeholder = serde_json::json!({
377            "v": "KERI10JSON000000_",
378            "t": "icp",
379            "d": "",
380            "i": SAID_PLACEHOLDER,
381            "s": "0",
382            "kt": "1",
383            "k": ["DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"],
384            "nt": "1",
385            "n": ["EAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"],
386            "bt": "0",
387            "b": [],
388            "a": []
389        });
390        let event_digest = serde_json::json!({
391            "v": "KERI10JSON000000_",
392            "t": "icp",
393            "d": "",
394            "i": "EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J",
395            "s": "0",
396            "kt": "1",
397            "k": ["DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"],
398            "nt": "1",
399            "n": ["EAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"],
400            "bt": "0",
401            "b": [],
402            "a": []
403        });
404        let said_placeholder = compute_said(&event_placeholder).unwrap();
405        let said_digest = compute_said(&event_digest).unwrap();
406        assert_eq!(
407            said_placeholder, said_digest,
408            "self-addressing inception SAID must be independent of the digest i value"
409        );
410    }
411
412    #[test]
413    fn verify_said_accepts_correct() {
414        let event = serde_json::json!({
415            "v": "KERI10JSON000000_",
416            "t": "rot",
417            "d": "",
418            "i": "EExistingPrefix",
419            "s": "1",
420            "p": "EPreviousSaid",
421            "kt": "1",
422            "k": ["DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"],
423            "nt": "1",
424            "n": ["EAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"],
425            "bt": "0",
426            "b": [],
427            "a": []
428        });
429        let said = compute_said(&event).unwrap();
430        let mut event_with_said = event.clone();
431        event_with_said["d"] = serde_json::Value::String(said.into_inner());
432        assert!(verify_said(&event_with_said).is_ok());
433    }
434
435    #[test]
436    fn verify_said_rejects_wrong() {
437        let event = serde_json::json!({
438            "v": "KERI10JSON000000_",
439            "t": "rot",
440            "d": "Ewrong_said_value_that_doesnt_match_at_all!",
441            "i": "EExistingPrefix",
442            "s": "1",
443            "p": "EPreviousSaid",
444            "kt": "1",
445            "k": ["DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"],
446            "nt": "1",
447            "n": ["EAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"],
448            "bt": "0",
449            "b": [],
450            "a": []
451        });
452        assert!(verify_said(&event).is_err());
453    }
454
455    /// keripy oracle (1.3.4): a *basic-prefix* inception keeps `i` (the verkey)
456    /// during hashing, so its SAID differs from the self-addressing form.
457    ///
458    /// Vector: `eventing.incept(keys=[ed.qb64])` (default basic prefix), where
459    /// `ed.qb64 == "DAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"`. Cross-checked
460    /// by the interop suite (`interop/vectors/kel/icp-basic.json`, gap IOP-L1d).
461    #[test]
462    fn basic_prefix_inception_keeps_i_matches_keripy() {
463        let raw = r#"{"v":"KERI10JSON0000fd_","t":"icp","d":"EAAD4cS7l9pm_N8JM9UsVeAZhwCIaDkSU341hbhHJbSf","i":"DAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f","s":"0","kt":"1","k":["DAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"],"nt":"0","n":[],"bt":"0","b":[],"c":[],"a":[]}"#;
464        let event: serde_json::Value = serde_json::from_str(raw).unwrap();
465        let said = compute_said(&event).unwrap();
466        assert_eq!(
467            said.as_str(),
468            "EAAD4cS7l9pm_N8JM9UsVeAZhwCIaDkSU341hbhHJbSf",
469            "basic-prefix icp SAID must match keripy (i kept, not blanked)"
470        );
471        // verify_said must accept the keripy event as-is.
472        assert!(verify_said(&event).is_ok());
473    }
474
475    /// keripy oracle (1.3.4): a *self-addressing* inception still blanks `i`
476    /// (it is the SAID), so this path is unchanged by the basic-prefix fix.
477    ///
478    /// Vector: `eventing.incept(keys=[ed.qb64], code=Blake3_256)` — `i == d`.
479    /// Cross-checked by `interop/vectors/kel/icp-selfaddr.json` (gap IOP-L1a).
480    #[test]
481    fn self_addressing_inception_blanks_i_matches_keripy() {
482        let raw = r#"{"v":"KERI10JSON0000fd_","t":"icp","d":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","i":"EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J","s":"0","kt":"1","k":["DAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"],"nt":"0","n":[],"bt":"0","b":[],"c":[],"a":[]}"#;
483        let event: serde_json::Value = serde_json::from_str(raw).unwrap();
484        let said = compute_said(&event).unwrap();
485        assert_eq!(
486            said.as_str(),
487            "EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J",
488            "self-addressing icp SAID must still blank i and match keripy"
489        );
490        assert!(verify_said(&event).is_ok());
491    }
492
493    /// The emit path (auths minting its own AID) fills `i` only after the SAID is
494    /// known, so `compute_said` sees the [`SAID_PLACEHOLDER`] in `i` and must
495    /// still treat it as self-addressing (blank it). Equivalently, an empty `i`
496    /// or the placeholder produce the same SAID as the filled self-addressing `i`.
497    #[test]
498    fn placeholder_inception_prefix_is_self_addressing() {
499        let base = serde_json::json!({
500            "v": "KERI10JSON000000_",
501            "t": "icp",
502            "d": "",
503            "s": "0",
504            "kt": "1",
505            "k": ["DAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"],
506            "nt": "0",
507            "n": [],
508            "bt": "0",
509            "b": [],
510            "c": [],
511            "a": []
512        });
513        // i = placeholder
514        let mut with_placeholder = base.clone();
515        with_placeholder["i"] = serde_json::Value::String(SAID_PLACEHOLDER.to_string());
516        // i = a digest prefix (E-coded)
517        let mut with_digest = base.clone();
518        with_digest["i"] =
519            serde_json::Value::String("EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J".to_string());
520        assert_eq!(
521            compute_said(&with_placeholder).unwrap(),
522            compute_said(&with_digest).unwrap(),
523            "placeholder and E-coded i are both self-addressing — same SAID"
524        );
525        assert!(prefix_is_self_addressing(SAID_PLACEHOLDER));
526        assert!(prefix_is_self_addressing(
527            "EOoC9AuwxiwcyUDsa2yNAaZOVWqfiAt4o3R31_8K2Z1J"
528        ));
529        assert!(!prefix_is_self_addressing(
530            "DAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f"
531        ));
532    }
533
534    /// Guard: `serde_json::Map` must use `IndexMap` (preserve insertion order).
535    ///
536    /// If the `preserve_order` feature is accidentally removed from
537    /// `auths-keri/Cargo.toml`, `serde_json::Map` falls back to `BTreeMap`
538    /// (sorted keys), silently breaking all existing SAIDs. This test catches
539    /// that by verifying key order survives a round-trip.
540    #[test]
541    fn serde_json_map_preserves_insertion_order() {
542        let json = r#"{"z":"last","a":"first","m":"middle"}"#;
543        let parsed: serde_json::Value = serde_json::from_str(json).unwrap();
544        let keys: Vec<&str> = parsed
545            .as_object()
546            .unwrap()
547            .keys()
548            .map(|k| k.as_str())
549            .collect();
550        assert_eq!(
551            keys,
552            vec!["z", "a", "m"],
553            "serde_json::Map must preserve insertion order (preserve_order feature required)"
554        );
555    }
556}