Skip to main content

reddb_server/storage/
blockchain.rs

1//! Blockchain collection kind — pure logic.
2//!
3//! A `KIND blockchain` collection stores append-only rows whose `hash` field
4//! depends on the previous row's hash, forming a tamper-evident chain.
5//! Issue #521 lands the engine integration in a later iteration; this module
6//! ships the deterministic primitives (hash, verify_chain, error types) so the
7//! later wiring is a thin storage adapter on top of audited logic.
8
9use crate::crypto::Sha256;
10
11/// All-zero hash used as `prev_hash` for the genesis block.
12pub const GENESIS_PREV_HASH: [u8; 32] = [0u8; 32];
13
14/// Optional signer fields included in the hash preimage when the collection
15/// has `SIGNED_BY (...)` declared. Issue #520 supplies the signer registry;
16/// the chain hash binds the signature so a replaced signature also breaks the
17/// chain.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct SignedFields {
20    pub signer_pubkey: [u8; 32],
21    pub signature: Vec<u8>,
22}
23
24/// A materialized block as stored. `hash` MUST equal
25/// `compute_block_hash(...)` over the other fields — `verify_chain` enforces
26/// this.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct Block {
29    pub block_height: u64,
30    pub prev_hash: [u8; 32],
31    pub timestamp_ms: u64,
32    pub payload: Vec<u8>,
33    pub signed: Option<SignedFields>,
34    pub hash: [u8; 32],
35}
36
37/// Engine response to `GET /collections/:name/chain-tip`.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct ChainTip {
40    pub block_height: u64,
41    pub hash: [u8; 32],
42    pub timestamp_ms: u64,
43}
44
45/// Operational errors surfaced by the blockchain engine path.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum BlockchainError {
48    /// Client submitted a `prev_hash` that no longer matches the current tip
49    /// (someone else appended first). Surface as HTTP 409.
50    ConflictRetry {
51        expected: [u8; 32],
52        got: [u8; 32],
53    },
54    /// Caller attempted UPDATE or DELETE on a `KIND blockchain` collection.
55    /// Surface as HTTP 409 (`BlockchainCollectionImmutable`).
56    Immutable,
57}
58
59impl std::fmt::Display for BlockchainError {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        match self {
62            Self::ConflictRetry { .. } => f.write_str("BlockchainConflictRetry"),
63            Self::Immutable => f.write_str("BlockchainCollectionImmutable"),
64        }
65    }
66}
67
68impl std::error::Error for BlockchainError {}
69
70/// Result of walking a chain end-to-end. `Inconsistent` reports the FIRST
71/// block whose stored fields disagree with a recomputed hash; the chain is
72/// not walked past the first failure.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum VerifyReport {
75    Ok,
76    Inconsistent { block_height: u64, reason: String },
77}
78
79/// Canonical hash preimage:
80///
81///   prev_hash (32)
82///   || block_height (u64 big-endian)
83///   || timestamp_ms (u64 big-endian)
84///   || payload_len   (u64 big-endian)
85///   || payload bytes
86///   || [if signed]
87///        signer_pubkey (32)
88///        || sig_len   (u64 big-endian)
89///        || signature bytes
90///
91/// Length prefixes make the encoding unambiguous: a signed block with empty
92/// payload cannot collide with an unsigned block whose payload happens to
93/// equal the signer/signature concatenation.
94pub fn compute_block_hash(
95    prev_hash: &[u8; 32],
96    block_height: u64,
97    timestamp_ms: u64,
98    payload: &[u8],
99    signed: Option<&SignedFields>,
100) -> [u8; 32] {
101    let mut h = Sha256::new();
102    h.update(prev_hash);
103    h.update(&block_height.to_be_bytes());
104    h.update(&timestamp_ms.to_be_bytes());
105    h.update(&(payload.len() as u64).to_be_bytes());
106    h.update(payload);
107    if let Some(s) = signed {
108        h.update(&s.signer_pubkey);
109        h.update(&(s.signature.len() as u64).to_be_bytes());
110        h.update(&s.signature);
111    }
112    h.finalize()
113}
114
115/// Walk `blocks` in order. Returns the first inconsistency or `Ok` if every
116/// block's stored hash matches the recomputed hash AND links the previous
117/// block.
118pub fn verify_chain(blocks: &[Block]) -> VerifyReport {
119    let mut expected_prev: [u8; 32] = GENESIS_PREV_HASH;
120    let mut expected_height: u64 = 0;
121    for block in blocks {
122        if block.block_height != expected_height {
123            return VerifyReport::Inconsistent {
124                block_height: block.block_height,
125                reason: format!(
126                    "block_height mismatch: expected {expected_height}, got {}",
127                    block.block_height
128                ),
129            };
130        }
131        if block.prev_hash != expected_prev {
132            return VerifyReport::Inconsistent {
133                block_height: block.block_height,
134                reason: "prev_hash does not link previous block".to_string(),
135            };
136        }
137        let recomputed = compute_block_hash(
138            &block.prev_hash,
139            block.block_height,
140            block.timestamp_ms,
141            &block.payload,
142            block.signed.as_ref(),
143        );
144        if recomputed != block.hash {
145            return VerifyReport::Inconsistent {
146                block_height: block.block_height,
147                reason: "stored hash does not match recomputed hash".to_string(),
148            };
149        }
150        expected_prev = block.hash;
151        expected_height = block.block_height.saturating_add(1);
152    }
153    VerifyReport::Ok
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    fn make_block(height: u64, prev: [u8; 32], payload: &[u8]) -> Block {
161        let ts = 1_700_000_000_000 + height;
162        let hash = compute_block_hash(&prev, height, ts, payload, None);
163        Block {
164            block_height: height,
165            prev_hash: prev,
166            timestamp_ms: ts,
167            payload: payload.to_vec(),
168            signed: None,
169            hash,
170        }
171    }
172
173    fn build_chain(n: u64) -> Vec<Block> {
174        let mut out = Vec::new();
175        let mut prev = GENESIS_PREV_HASH;
176        for i in 0..n {
177            let payload = format!("payload-{i}");
178            let b = make_block(i, prev, payload.as_bytes());
179            prev = b.hash;
180            out.push(b);
181        }
182        out
183    }
184
185    #[test]
186    fn genesis_prev_hash_is_zero() {
187        assert_eq!(GENESIS_PREV_HASH, [0u8; 32]);
188    }
189
190    #[test]
191    fn five_block_chain_verifies_ok() {
192        let chain = build_chain(5);
193        assert_eq!(verify_chain(&chain), VerifyReport::Ok);
194        assert_eq!(chain[0].block_height, 0);
195        assert_eq!(chain[0].prev_hash, GENESIS_PREV_HASH);
196        assert_eq!(chain[4].block_height, 4);
197    }
198
199    #[test]
200    fn corrupting_block_two_payload_is_reported() {
201        let mut chain = build_chain(5);
202        chain[2].payload = b"tampered".to_vec();
203        match verify_chain(&chain) {
204            VerifyReport::Inconsistent { block_height, .. } => {
205                assert_eq!(block_height, 2);
206            }
207            VerifyReport::Ok => panic!("tampered chain reported Ok"),
208        }
209    }
210
211    #[test]
212    fn corrupting_prev_hash_breaks_chain() {
213        let mut chain = build_chain(3);
214        chain[1].prev_hash = [0xAAu8; 32];
215        // Recompute hash so the per-block hash check passes; the linkage
216        // check must still fail.
217        chain[1].hash = compute_block_hash(
218            &chain[1].prev_hash,
219            chain[1].block_height,
220            chain[1].timestamp_ms,
221            &chain[1].payload,
222            None,
223        );
224        match verify_chain(&chain) {
225            VerifyReport::Inconsistent { block_height, reason } => {
226                assert_eq!(block_height, 1);
227                assert!(reason.contains("prev_hash"));
228            }
229            VerifyReport::Ok => panic!("broken linkage reported Ok"),
230        }
231    }
232
233    #[test]
234    fn signed_field_inclusion_changes_hash() {
235        let prev = GENESIS_PREV_HASH;
236        let payload = b"x";
237        let unsigned = compute_block_hash(&prev, 0, 1, payload, None);
238        let signed = compute_block_hash(
239            &prev,
240            0,
241            1,
242            payload,
243            Some(&SignedFields {
244                signer_pubkey: [0x11; 32],
245                signature: vec![0x22; 64],
246            }),
247        );
248        assert_ne!(unsigned, signed);
249    }
250
251    #[test]
252    fn empty_payload_signed_vs_unsigned_disambiguates() {
253        // Length-prefix encoding must prevent a signed block from colliding
254        // with an unsigned block that has the signer bytes inlined in payload.
255        let prev = GENESIS_PREV_HASH;
256        let signer = [0x55u8; 32];
257        let sig = vec![0x66u8; 8];
258        let signed = compute_block_hash(
259            &prev,
260            7,
261            42,
262            b"",
263            Some(&SignedFields {
264                signer_pubkey: signer,
265                signature: sig.clone(),
266            }),
267        );
268        let mut spoof_payload = Vec::new();
269        spoof_payload.extend_from_slice(&signer);
270        spoof_payload.extend_from_slice(&(sig.len() as u64).to_be_bytes());
271        spoof_payload.extend_from_slice(&sig);
272        let unsigned = compute_block_hash(&prev, 7, 42, &spoof_payload, None);
273        assert_ne!(signed, unsigned);
274    }
275
276    #[test]
277    fn conflict_retry_display() {
278        let err = BlockchainError::ConflictRetry {
279            expected: [1u8; 32],
280            got: [2u8; 32],
281        };
282        assert_eq!(err.to_string(), "BlockchainConflictRetry");
283        assert_eq!(
284            BlockchainError::Immutable.to_string(),
285            "BlockchainCollectionImmutable"
286        );
287    }
288}