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 { expected: [u8; 32], got: [u8; 32] },
51    /// Caller attempted UPDATE or DELETE on a `KIND blockchain` collection.
52    /// Surface as HTTP 409 (`BlockchainCollectionImmutable`).
53    Immutable,
54}
55
56impl std::fmt::Display for BlockchainError {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        match self {
59            Self::ConflictRetry { .. } => f.write_str("BlockchainConflictRetry"),
60            Self::Immutable => f.write_str("BlockchainCollectionImmutable"),
61        }
62    }
63}
64
65impl std::error::Error for BlockchainError {}
66
67/// Result of walking a chain end-to-end. `Inconsistent` reports the FIRST
68/// block whose stored fields disagree with a recomputed hash; the chain is
69/// not walked past the first failure.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum VerifyReport {
72    Ok,
73    Inconsistent { block_height: u64, reason: String },
74}
75
76/// Canonical hash preimage:
77///
78///   prev_hash (32)
79///   || block_height (u64 big-endian)
80///   || timestamp_ms (u64 big-endian)
81///   || payload_len   (u64 big-endian)
82///   || payload bytes
83///   || [if signed]
84///        signer_pubkey (32)
85///        || sig_len   (u64 big-endian)
86///        || signature bytes
87///
88/// Length prefixes make the encoding unambiguous: a signed block with empty
89/// payload cannot collide with an unsigned block whose payload happens to
90/// equal the signer/signature concatenation.
91pub fn compute_block_hash(
92    prev_hash: &[u8; 32],
93    block_height: u64,
94    timestamp_ms: u64,
95    payload: &[u8],
96    signed: Option<&SignedFields>,
97) -> [u8; 32] {
98    let mut h = Sha256::new();
99    h.update(prev_hash);
100    h.update(&block_height.to_be_bytes());
101    h.update(&timestamp_ms.to_be_bytes());
102    h.update(&(payload.len() as u64).to_be_bytes());
103    h.update(payload);
104    if let Some(s) = signed {
105        h.update(&s.signer_pubkey);
106        h.update(&(s.signature.len() as u64).to_be_bytes());
107        h.update(&s.signature);
108    }
109    h.finalize()
110}
111
112/// Walk `blocks` in order. Returns the first inconsistency or `Ok` if every
113/// block's stored hash matches the recomputed hash AND links the previous
114/// block.
115pub fn verify_chain(blocks: &[Block]) -> VerifyReport {
116    let mut expected_prev: [u8; 32] = GENESIS_PREV_HASH;
117    let mut expected_height: u64 = 0;
118    for block in blocks {
119        if block.block_height != expected_height {
120            return VerifyReport::Inconsistent {
121                block_height: block.block_height,
122                reason: format!(
123                    "block_height mismatch: expected {expected_height}, got {}",
124                    block.block_height
125                ),
126            };
127        }
128        if block.prev_hash != expected_prev {
129            return VerifyReport::Inconsistent {
130                block_height: block.block_height,
131                reason: "prev_hash does not link previous block".to_string(),
132            };
133        }
134        let recomputed = compute_block_hash(
135            &block.prev_hash,
136            block.block_height,
137            block.timestamp_ms,
138            &block.payload,
139            block.signed.as_ref(),
140        );
141        if recomputed != block.hash {
142            return VerifyReport::Inconsistent {
143                block_height: block.block_height,
144                reason: "stored hash does not match recomputed hash".to_string(),
145            };
146        }
147        expected_prev = block.hash;
148        expected_height = block.block_height.saturating_add(1);
149    }
150    VerifyReport::Ok
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    fn make_block(height: u64, prev: [u8; 32], payload: &[u8]) -> Block {
158        let ts = 1_700_000_000_000 + height;
159        let hash = compute_block_hash(&prev, height, ts, payload, None);
160        Block {
161            block_height: height,
162            prev_hash: prev,
163            timestamp_ms: ts,
164            payload: payload.to_vec(),
165            signed: None,
166            hash,
167        }
168    }
169
170    fn build_chain(n: u64) -> Vec<Block> {
171        let mut out = Vec::new();
172        let mut prev = GENESIS_PREV_HASH;
173        for i in 0..n {
174            let payload = format!("payload-{i}");
175            let b = make_block(i, prev, payload.as_bytes());
176            prev = b.hash;
177            out.push(b);
178        }
179        out
180    }
181
182    #[test]
183    fn genesis_prev_hash_is_zero() {
184        assert_eq!(GENESIS_PREV_HASH, [0u8; 32]);
185    }
186
187    #[test]
188    fn five_block_chain_verifies_ok() {
189        let chain = build_chain(5);
190        assert_eq!(verify_chain(&chain), VerifyReport::Ok);
191        assert_eq!(chain[0].block_height, 0);
192        assert_eq!(chain[0].prev_hash, GENESIS_PREV_HASH);
193        assert_eq!(chain[4].block_height, 4);
194    }
195
196    #[test]
197    fn corrupting_block_two_payload_is_reported() {
198        let mut chain = build_chain(5);
199        chain[2].payload = b"tampered".to_vec();
200        match verify_chain(&chain) {
201            VerifyReport::Inconsistent { block_height, .. } => {
202                assert_eq!(block_height, 2);
203            }
204            VerifyReport::Ok => panic!("tampered chain reported Ok"),
205        }
206    }
207
208    #[test]
209    fn corrupting_prev_hash_breaks_chain() {
210        let mut chain = build_chain(3);
211        chain[1].prev_hash = [0xAAu8; 32];
212        // Recompute hash so the per-block hash check passes; the linkage
213        // check must still fail.
214        chain[1].hash = compute_block_hash(
215            &chain[1].prev_hash,
216            chain[1].block_height,
217            chain[1].timestamp_ms,
218            &chain[1].payload,
219            None,
220        );
221        match verify_chain(&chain) {
222            VerifyReport::Inconsistent {
223                block_height,
224                reason,
225            } => {
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}