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}