Skip to main content

vti_common/audit/
envelope.rs

1//! [`AuditEnvelope`] — the wire-stable shape that wraps every
2//! [`AuditEvent`].
3//!
4//! Carries:
5//!
6//! - the event id, timestamps, version stamps
7//! - HMAC-hashed + plaintext pairs for actor + (optional) target
8//!   identifiers
9//! - the tagged event payload
10//!
11//! RTBF mechanics: nulling the `*_plain` field while retaining the
12//! HMAC hash keeps the envelope correlatable across the audit log
13//! without re-leaking the DID. Rotating the `audit_key` (see
14//! [`super::key_store::AuditKeyStore`]) makes pre-rotation hashes
15//! opaque to anyone who doesn't hold the prior key. See spec §11.1.
16
17use chrono::{DateTime, Utc};
18use serde::{Deserialize, Serialize};
19use sha2::{Digest, Sha256};
20use uuid::Uuid;
21
22use super::event::AuditEvent;
23use super::key_store::KeyId;
24
25/// Envelope schema version. Bumps **only** on a breaking-shape change
26/// to [`AuditEnvelope`] itself. Phase 0 shipped v1; v2 adds the
27/// tamper-evidence hash chain ([`AuditEnvelope::prev_hash`] /
28/// [`AuditEnvelope::entry_hash`]).
29pub const SCHEMA_VERSION: u32 = 2;
30
31/// Domain-separation tag mixed into every [`AuditEnvelope::chain_digest`]
32/// so a digest can never be confused with any other SHA-256 in the
33/// system. Bump the suffix if the digest's covered-field set changes.
34const CHAIN_DOMAIN: &[u8] = b"vtc-audit-chain/v1\0";
35
36/// The all-zero hash that anchors the chain: the `prev_hash` of the
37/// first chained envelope (and the `entry_hash` left on pre-v2
38/// envelopes that predate the chain).
39pub const GENESIS_HASH: [u8; 32] = [0u8; 32];
40
41/// serde `default` for the chain-hash fields so envelopes written
42/// before SCHEMA_VERSION 2 (which lack the fields entirely) still
43/// deserialize — they come back anchored at [`GENESIS_HASH`].
44fn genesis_hash() -> [u8; 32] {
45    GENESIS_HASH
46}
47
48/// Default event version, applied when an event variant doesn't pin
49/// its own value. Per-variant overrides are added when an existing
50/// variant's payload shape changes (callers bump just that variant
51/// and bake the new version into the constructor).
52pub const EVENT_VERSION: u32 = 1;
53
54/// A persisted audit-log entry.
55//
56// Note: deliberately NOT `ToSchema`. The `AuditEvent` payload enum fans out
57// into a large tree of variant-specific shapes; the single `/audit` read
58// endpoint documents its response body as an opaque object rather than drag
59// that whole surface into the OpenAPI components.
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
61pub struct AuditEnvelope {
62    /// Stable identifier for this specific event. Becomes the
63    /// secondary-key component for cursor-based queries.
64    pub event_id: Uuid,
65
66    /// Variant-specific schema version. Bumped on a breaking change
67    /// to a particular variant's payload; the envelope's
68    /// [`SCHEMA_VERSION`] tracks the wrapper-shape version
69    /// independently.
70    pub event_version: u32,
71
72    /// Envelope-shape version. See [`SCHEMA_VERSION`] for the
73    /// semantics.
74    pub schema_version: u32,
75
76    /// Wall-clock at write time. Drives the primary `<timestamp>:<event_id>`
77    /// audit-keyspace ordering.
78    pub timestamp: DateTime<Utc>,
79
80    /// Identifier of the audit_key used to compute the hashes below.
81    /// Verifiers walk the key history newest-first; this lets them
82    /// skip the search when they already know the right key.
83    pub audit_key_id: KeyId,
84
85    /// HMAC-SHA256 of the actor DID under [`Self::audit_key_id`].
86    /// Always present so RTBF can null the plaintext without losing
87    /// the correlation handle.
88    #[serde(with = "hash32_b64")]
89    pub actor_did_hash: [u8; 32],
90
91    /// Plaintext actor DID. `None` after an RTBF override has redacted
92    /// this row.
93    pub actor_did_plain: Option<String>,
94
95    /// HMAC-SHA256 of the target DID, if the event has one. `None`
96    /// for events whose target is the community itself (e.g.
97    /// `CommunityProfileUpdated`).
98    #[serde(with = "hash32_opt_b64")]
99    pub target_did_hash: Option<[u8; 32]>,
100
101    /// Plaintext target DID. Same null-on-RTBF semantics as
102    /// [`Self::actor_did_plain`].
103    pub target_did_plain: Option<String>,
104
105    /// Tamper-evidence chain: the [`Self::entry_hash`] of the
106    /// immediately-preceding envelope, or [`GENESIS_HASH`] for the
107    /// first one. Linking each entry to its predecessor makes a
108    /// reorder/drop/duplicate of any envelope detectable by
109    /// [`verify_chain`]. `default` keeps pre-v2 rows deserializable.
110    #[serde(with = "hash32_b64", default = "genesis_hash")]
111    pub prev_hash: [u8; 32],
112
113    /// Tamper-evidence chain: SHA-256 commitment to this envelope's
114    /// **immutable** content (see [`Self::chain_digest`]). Stamped at
115    /// write time; the next envelope's [`Self::prev_hash`] points here.
116    /// `default` keeps pre-v2 rows deserializable (they come back as
117    /// [`GENESIS_HASH`], i.e. unchained).
118    #[serde(with = "hash32_b64", default = "genesis_hash")]
119    pub entry_hash: [u8; 32],
120
121    /// The tagged event payload.
122    pub event: AuditEvent,
123}
124
125impl AuditEnvelope {
126    /// SHA-256 commitment over this envelope's **immutable** content,
127    /// used to stamp [`Self::entry_hash`] at write time and to
128    /// re-derive it during [`verify_chain`].
129    ///
130    /// Deliberately covers `prev_hash` (so the link is part of the
131    /// commitment) but **excludes**:
132    /// - `entry_hash` itself (it *is* this digest — self-reference),
133    /// - `actor_did_plain` / `target_did_plain`, which RTBF nulls
134    ///   after the fact. The HMAC *hashes* are covered, so attribution
135    ///   stays chained while a redaction of the plaintext does not
136    ///   break the chain.
137    ///
138    /// The `event` payload is hashed via its `serde_json` encoding,
139    /// which is canonical in this workspace (maps serialize sorted,
140    /// and the store round-trips through the same encoder).
141    pub fn chain_digest(&self) -> [u8; 32] {
142        let mut h = Sha256::new();
143        h.update(CHAIN_DOMAIN);
144        h.update(self.prev_hash);
145        h.update(self.event_id.as_bytes());
146        h.update(self.event_version.to_be_bytes());
147        h.update(self.schema_version.to_be_bytes());
148        h.update(self.timestamp.to_rfc3339().as_bytes());
149        h.update(self.audit_key_id.0.as_bytes());
150        h.update(self.actor_did_hash);
151        match self.target_did_hash {
152            Some(t) => {
153                h.update([1u8]);
154                h.update(t);
155            }
156            None => h.update([0u8]),
157        }
158        let event_bytes = serde_json::to_vec(&self.event).expect("AuditEvent serializes");
159        h.update((event_bytes.len() as u64).to_be_bytes());
160        h.update(&event_bytes);
161        h.finalize().into()
162    }
163}
164
165/// A detected break in the audit hash chain.
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub enum ChainBreak {
168    /// `envelopes[index].entry_hash` does not match a recompute of
169    /// [`AuditEnvelope::chain_digest`] — the envelope's content was
170    /// altered after it was written.
171    TamperedEntry { index: usize, event_id: Uuid },
172    /// `envelopes[index].prev_hash` does not point at the previous
173    /// envelope's `entry_hash` — an entry was reordered, dropped, or
174    /// inserted.
175    BrokenLink { index: usize, event_id: Uuid },
176}
177
178/// Verify the tamper-evidence chain over `envelopes`, which must be in
179/// ascending write order (the audit keyspace's `<timestamp>:<event_id>`
180/// key order). Each v2+ envelope must (a) re-derive its own
181/// `entry_hash` and (b) carry a `prev_hash` equal to the previous v2+
182/// envelope's `entry_hash` (or [`GENESIS_HASH`] for the first link).
183///
184/// Pre-v2 envelopes (written before the chain existed) carry no hashes
185/// and are skipped; the chain re-anchors at the first v2 envelope.
186pub fn verify_chain(envelopes: &[AuditEnvelope]) -> Result<(), ChainBreak> {
187    let mut verifier = ChainVerifier::new();
188    for env in envelopes {
189        verifier.push(env)?;
190    }
191    Ok(())
192}
193
194/// Incremental form of [`verify_chain`], for callers that stream the
195/// audit log rather than materialising it.
196///
197/// A full audit log does not fit comfortably in memory on a busy
198/// community, and the verification is a strict left fold — each
199/// envelope needs only its predecessor's `entry_hash`. Feeding
200/// envelopes in ascending write order through [`Self::push`] keeps
201/// memory constant regardless of log size.
202///
203/// The fold state is just `(prev_hash, index)`, so verification can
204/// also be **resumed** across pages via [`Self::resume`] — pass the
205/// [`Self::head`] and [`Self::index`] returned by the previous page.
206/// A resumed verifier is only as trustworthy as the head it is given:
207/// resuming from a head an adversary supplied verifies nothing about
208/// the entries before it.
209#[derive(Debug, Clone)]
210pub struct ChainVerifier {
211    prev: Option<[u8; 32]>,
212    index: usize,
213    verified: usize,
214    skipped_legacy: usize,
215}
216
217impl Default for ChainVerifier {
218    fn default() -> Self {
219        Self::new()
220    }
221}
222
223impl ChainVerifier {
224    /// Start at the genesis anchor — the first v2+ envelope pushed
225    /// must carry [`GENESIS_HASH`] as its `prev_hash`.
226    pub fn new() -> Self {
227        Self {
228            prev: None,
229            index: 0,
230            verified: 0,
231            skipped_legacy: 0,
232        }
233    }
234
235    /// Resume from a previously-returned [`Self::head`] and
236    /// [`Self::index`], so a page of envelopes can be verified as a
237    /// continuation of the page before it.
238    pub fn resume(head: [u8; 32], index: usize) -> Self {
239        Self {
240            prev: Some(head),
241            index,
242            verified: 0,
243            skipped_legacy: 0,
244        }
245    }
246
247    /// Verify one envelope as the successor of everything pushed so
248    /// far. Envelopes must arrive in ascending write order.
249    ///
250    /// On `Err` the verifier is left at the breaking entry and must
251    /// not be reused — the chain past a break carries no meaning.
252    pub fn push(&mut self, env: &AuditEnvelope) -> Result<(), ChainBreak> {
253        let index = self.index;
254        self.index += 1;
255
256        if env.schema_version < 2 {
257            // Predates the chain — nothing to verify, and it must not
258            // become the predecessor of a v2 link.
259            self.skipped_legacy += 1;
260            return Ok(());
261        }
262        if env.chain_digest() != env.entry_hash {
263            return Err(ChainBreak::TamperedEntry {
264                index,
265                event_id: env.event_id,
266            });
267        }
268        let expected_prev = self.prev.unwrap_or(GENESIS_HASH);
269        if env.prev_hash != expected_prev {
270            return Err(ChainBreak::BrokenLink {
271                index,
272                event_id: env.event_id,
273            });
274        }
275        self.prev = Some(env.entry_hash);
276        self.verified += 1;
277        Ok(())
278    }
279
280    /// `entry_hash` of the last verified v2+ envelope — the head of
281    /// the chain so far. `None` if nothing chainable has been pushed.
282    pub fn head(&self) -> Option<[u8; 32]> {
283        self.prev
284    }
285
286    /// Number of envelopes pushed, chainable or not. Pass to
287    /// [`Self::resume`] so a continuation reports absolute indices.
288    pub fn index(&self) -> usize {
289        self.index
290    }
291
292    /// Count of v2+ envelopes that verified.
293    pub fn verified(&self) -> usize {
294        self.verified
295    }
296
297    /// Count of pre-v2 envelopes skipped as unchainable.
298    ///
299    /// **Read this, don't ignore it.** Legacy rows are skipped rather
300    /// than verified, so they are an insertion point: a forged
301    /// envelope marked `schema_version: 1` passes untouched. A store
302    /// that should hold no legacy rows reporting a non-zero count
303    /// here is itself the finding.
304    pub fn skipped_legacy(&self) -> usize {
305        self.skipped_legacy
306    }
307}
308
309// ---------------------------------------------------------------------------
310// Serde helpers — base64url for 32-byte hashes
311// ---------------------------------------------------------------------------
312
313pub(crate) const B64: base64::engine::general_purpose::GeneralPurpose =
314    base64::engine::general_purpose::URL_SAFE_NO_PAD;
315
316pub(super) mod hash32_b64 {
317    use super::B64;
318    use base64::Engine as _;
319    use serde::{Deserialize, Deserializer, Serializer};
320
321    pub fn serialize<S: Serializer>(bytes: &[u8; 32], s: S) -> Result<S::Ok, S::Error> {
322        s.serialize_str(&B64.encode(bytes))
323    }
324
325    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 32], D::Error> {
326        let s = String::deserialize(d)?;
327        let v = B64.decode(&s).map_err(serde::de::Error::custom)?;
328        v.try_into()
329            .map_err(|_| serde::de::Error::custom("expected 32-byte hash"))
330    }
331}
332
333pub(super) mod hash32_opt_b64 {
334    use super::B64;
335    use base64::Engine as _;
336    use serde::{Deserialize, Deserializer, Serializer};
337
338    pub fn serialize<S: Serializer>(bytes: &Option<[u8; 32]>, s: S) -> Result<S::Ok, S::Error> {
339        match bytes {
340            Some(b) => s.serialize_some(&B64.encode(b)),
341            None => s.serialize_none(),
342        }
343    }
344
345    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<[u8; 32]>, D::Error> {
346        let opt: Option<String> = Option::deserialize(d)?;
347        match opt {
348            Some(s) => {
349                let v = B64.decode(&s).map_err(serde::de::Error::custom)?;
350                let arr = v
351                    .try_into()
352                    .map_err(|_| serde::de::Error::custom("expected 32-byte hash"))?;
353                Ok(Some(arr))
354            }
355            None => Ok(None),
356        }
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    fn sample_envelope() -> AuditEnvelope {
365        AuditEnvelope {
366            event_id: Uuid::nil(),
367            event_version: EVENT_VERSION,
368            schema_version: SCHEMA_VERSION,
369            timestamp: DateTime::parse_from_rfc3339("2026-05-11T00:00:00Z")
370                .unwrap()
371                .with_timezone(&Utc),
372            audit_key_id: KeyId::nil(),
373            actor_did_hash: [0xAB; 32],
374            actor_did_plain: Some("did:key:z6Mk".into()),
375            target_did_hash: Some([0xCD; 32]),
376            target_did_plain: Some("did:key:z6Mk2".into()),
377            prev_hash: GENESIS_HASH,
378            entry_hash: GENESIS_HASH,
379            event: AuditEvent::CommunityProfileUpdated(
380                crate::audit::event::CommunityProfileUpdatedData {
381                    fields_changed: vec!["name".into()],
382                    ..Default::default()
383                },
384            ),
385        }
386    }
387
388    /// Build a sample envelope whose `entry_hash` is correctly stamped
389    /// from `prev`, as the writer does.
390    fn chained_envelope(prev: [u8; 32], seed: u8) -> AuditEnvelope {
391        let mut e = sample_envelope();
392        e.event_id = Uuid::from_bytes([seed; 16]);
393        e.actor_did_hash = [seed; 32];
394        e.prev_hash = prev;
395        e.entry_hash = e.chain_digest();
396        e
397    }
398
399    #[test]
400    fn envelope_roundtrips_through_serde() {
401        let e = sample_envelope();
402        let s = serde_json::to_string(&e).unwrap();
403        let back: AuditEnvelope = serde_json::from_str(&s).unwrap();
404        assert_eq!(back, e);
405    }
406
407    #[test]
408    fn hashes_serialize_as_base64url_strings() {
409        let e = sample_envelope();
410        let v: serde_json::Value = serde_json::to_value(&e).unwrap();
411        // 32 bytes base64url-encoded with no padding = 43 chars
412        let actor = v["actor_did_hash"].as_str().unwrap();
413        assert_eq!(actor.len(), 43);
414        // All-0xAB bytes encode deterministically.
415        assert_eq!(actor, "q6urq6urq6urq6urq6urq6urq6urq6urq6urq6urq6s");
416        let target = v["target_did_hash"].as_str().unwrap();
417        assert_eq!(target.len(), 43);
418    }
419
420    #[test]
421    fn null_target_hash_omits_or_nulls_in_json() {
422        let mut e = sample_envelope();
423        e.target_did_hash = None;
424        e.target_did_plain = None;
425        let v: serde_json::Value = serde_json::to_value(&e).unwrap();
426        assert!(v["target_did_hash"].is_null());
427        assert!(v["target_did_plain"].is_null());
428    }
429
430    #[test]
431    fn rtbf_redaction_preserves_hashes() {
432        let mut e = sample_envelope();
433        // Simulate RTBF: null the plaintext, keep the hashes.
434        e.actor_did_plain = None;
435        e.target_did_plain = None;
436        let s = serde_json::to_string(&e).unwrap();
437        let back: AuditEnvelope = serde_json::from_str(&s).unwrap();
438        assert!(back.actor_did_plain.is_none());
439        assert!(back.target_did_plain.is_none());
440        assert_eq!(back.actor_did_hash, [0xAB; 32]);
441        assert_eq!(back.target_did_hash, Some([0xCD; 32]));
442    }
443
444    #[test]
445    fn rtbf_redaction_does_not_break_the_chain() {
446        // entry_hash excludes the plaintext fields, so nulling them
447        // (RTBF) must leave the chain intact.
448        let e = chained_envelope(GENESIS_HASH, 0x11);
449        let mut redacted = e.clone();
450        redacted.actor_did_plain = None;
451        redacted.target_did_plain = None;
452        assert_eq!(redacted.chain_digest(), e.entry_hash);
453        assert!(verify_chain(&[redacted]).is_ok());
454    }
455
456    #[test]
457    fn well_formed_chain_verifies() {
458        let a = chained_envelope(GENESIS_HASH, 0x01);
459        let b = chained_envelope(a.entry_hash, 0x02);
460        let c = chained_envelope(b.entry_hash, 0x03);
461        assert!(verify_chain(&[a, b, c]).is_ok());
462    }
463
464    #[test]
465    fn tampered_entry_is_detected() {
466        let a = chained_envelope(GENESIS_HASH, 0x01);
467        let mut b = chained_envelope(a.entry_hash, 0x02);
468        // Mutate covered content without restamping entry_hash.
469        b.event =
470            AuditEvent::CommunityProfileUpdated(crate::audit::event::CommunityProfileUpdatedData {
471                fields_changed: vec!["logoUrl".into()],
472                ..Default::default()
473            });
474        match verify_chain(&[a, b]) {
475            Err(ChainBreak::TamperedEntry { index, .. }) => assert_eq!(index, 1),
476            other => panic!("expected TamperedEntry, got {other:?}"),
477        }
478    }
479
480    #[test]
481    fn dropped_entry_breaks_the_link() {
482        let a = chained_envelope(GENESIS_HASH, 0x01);
483        let b = chained_envelope(a.entry_hash, 0x02);
484        let c = chained_envelope(b.entry_hash, 0x03);
485        // Drop `b`: c.prev_hash now points at a missing predecessor.
486        match verify_chain(&[a, c]) {
487            Err(ChainBreak::BrokenLink { index, .. }) => assert_eq!(index, 1),
488            other => panic!("expected BrokenLink, got {other:?}"),
489        }
490    }
491
492    #[test]
493    fn reordered_entries_break_the_link() {
494        let a = chained_envelope(GENESIS_HASH, 0x01);
495        let b = chained_envelope(a.entry_hash, 0x02);
496        match verify_chain(&[b, a]) {
497            Err(ChainBreak::BrokenLink { index, .. }) => assert_eq!(index, 0),
498            other => panic!("expected BrokenLink, got {other:?}"),
499        }
500    }
501
502    #[test]
503    fn pre_v2_envelopes_are_skipped_then_chain_reanchors() {
504        // A legacy row (schema_version 1, no hashes) followed by a
505        // fresh v2 chain that anchors at genesis.
506        let mut legacy = sample_envelope();
507        legacy.schema_version = 1;
508        legacy.prev_hash = GENESIS_HASH;
509        legacy.entry_hash = GENESIS_HASH;
510        let a = chained_envelope(GENESIS_HASH, 0x01);
511        let b = chained_envelope(a.entry_hash, 0x02);
512        assert!(verify_chain(&[legacy, a, b]).is_ok());
513    }
514
515    #[test]
516    fn incremental_verifier_matches_the_slice_form() {
517        let a = chained_envelope(GENESIS_HASH, 0x01);
518        let b = chained_envelope(a.entry_hash, 0x02);
519        let c = chained_envelope(b.entry_hash, 0x03);
520
521        let mut v = ChainVerifier::new();
522        for env in [&a, &b, &c] {
523            v.push(env).expect("chain verifies");
524        }
525        assert_eq!(v.verified(), 3);
526        assert_eq!(v.index(), 3);
527        assert_eq!(v.skipped_legacy(), 0);
528        assert_eq!(v.head(), Some(c.entry_hash));
529        assert!(verify_chain(&[a, b, c]).is_ok(), "slice form agrees");
530    }
531
532    #[test]
533    fn resumed_verifier_continues_across_a_page_boundary() {
534        // The point of `resume`: page 1 verified separately from page
535        // 2 must reach the same verdict as one contiguous pass.
536        let a = chained_envelope(GENESIS_HASH, 0x01);
537        let b = chained_envelope(a.entry_hash, 0x02);
538        let c = chained_envelope(b.entry_hash, 0x03);
539
540        let mut page1 = ChainVerifier::new();
541        page1.push(&a).expect("a verifies");
542        page1.push(&b).expect("b verifies");
543
544        let mut page2 = ChainVerifier::resume(page1.head().expect("head"), page1.index());
545        page2
546            .push(&c)
547            .expect("c continues the chain from page 1's head");
548        assert_eq!(page2.head(), Some(c.entry_hash));
549        // Absolute index carries across, so a break on page 2 reports
550        // its position in the whole log rather than within the page.
551        assert_eq!(page2.index(), 3);
552    }
553
554    #[test]
555    fn resumed_verifier_rejects_a_page_that_does_not_follow() {
556        let a = chained_envelope(GENESIS_HASH, 0x01);
557        let b = chained_envelope(a.entry_hash, 0x02);
558        // `c` chains from `b`, but we resume from `a` — a dropped
559        // entry across the page boundary must still be caught.
560        let c = chained_envelope(b.entry_hash, 0x03);
561
562        let mut page2 = ChainVerifier::resume(a.entry_hash, 1);
563        match page2.push(&c) {
564            Err(ChainBreak::BrokenLink { index, .. }) => assert_eq!(index, 1),
565            other => panic!("expected BrokenLink, got {other:?}"),
566        }
567    }
568
569    #[test]
570    fn legacy_rows_are_counted_not_silently_dropped() {
571        // The skip is an insertion point, so the count has to be
572        // observable — an operator seeing a non-zero legacy count on a
573        // v2-only store is looking at the finding itself.
574        let mut legacy = sample_envelope();
575        legacy.schema_version = 1;
576        legacy.prev_hash = GENESIS_HASH;
577        legacy.entry_hash = GENESIS_HASH;
578        let a = chained_envelope(GENESIS_HASH, 0x01);
579
580        let mut v = ChainVerifier::new();
581        v.push(&legacy)
582            .expect("legacy row is skipped, not an error");
583        v.push(&a).expect("chain anchors at genesis after the skip");
584        assert_eq!(v.skipped_legacy(), 1);
585        assert_eq!(v.verified(), 1);
586        assert_eq!(v.index(), 2, "index counts skipped rows too");
587    }
588
589    #[test]
590    fn pre_v2_envelope_deserializes_without_hash_fields() {
591        // Old wire form: an envelope JSON object missing prev_hash /
592        // entry_hash entirely must still parse (serde default).
593        let mut v = serde_json::to_value(sample_envelope()).unwrap();
594        let obj = v.as_object_mut().unwrap();
595        obj.remove("prev_hash");
596        obj.remove("entry_hash");
597        let back: AuditEnvelope = serde_json::from_value(v).unwrap();
598        assert_eq!(back.prev_hash, GENESIS_HASH);
599        assert_eq!(back.entry_hash, GENESIS_HASH);
600    }
601}