Skip to main content

reddb_server/runtime/
signed_chain.rs

1//! Issue #526 — composition of `KIND blockchain` + `SIGNED_BY (...)`.
2//!
3//! Locks the contract a `KIND blockchain SIGNED_BY (...)` collection ships:
4//!
5//! * The block hash binds the chain fields AND the row's signer pubkey +
6//!   signature. Tampering with either reserved column breaks `verify_chain`
7//!   at that height — the hash is now a function of `(prev_hash,
8//!   block_height, timestamp, canonical(payload), signer_pubkey,
9//!   signature)`.
10//! * Genesis is exempt: `block_height == 0` carries the all-zero pubkey
11//!   and an empty signature so the collection can be created before any
12//!   signer registers a row. Every subsequent block MUST carry a
13//!   non-genesis (allowed-signer) signature.
14//! * `verify_chain_with_signatures` walks the chain and additionally
15//!   re-verifies the Ed25519 signature on each non-genesis block, so an
16//!   integrity scan flags signature tampering even when the stored
17//!   `hash` was recomputed to "match" the tampered bytes.
18//!
19//! This module is pure logic on top of the audited primitives in
20//! [`storage::blockchain`](crate::storage::blockchain) and
21//! [`storage::signed_writes`](crate::storage::signed_writes). Runtime
22//! wiring (INSERT pipeline composition, DDL persistence of the registry
23//! on a `KIND blockchain` collection, REST error mapping) is owned by
24//! the parent issues #522 and #524 and is consumed by this module via
25//! the same primitives once both land.
26
27use crate::storage::blockchain::{
28    compute_block_hash, verify_chain, Block, SignedFields, VerifyReport, GENESIS_PREV_HASH,
29};
30use crate::storage::schema::Value;
31use crate::storage::signed_writes::{
32    reverify_row, RESERVED_SIGNATURE_COL, RESERVED_SIGNER_PUBKEY_COL, SIGNATURE_LEN,
33    SIGNER_PUBKEY_LEN,
34};
35
36use super::blockchain_kind::{
37    COL_BLOCK_HEIGHT, COL_HASH, COL_PREV_HASH, COL_TIMESTAMP,
38};
39
40/// All-zero pubkey marker recorded on the genesis row of a signed chain.
41/// Documented exemption: the genesis block predates any signer's first
42/// `INSERT` so it cannot itself carry a real signature.
43pub const GENESIS_SIGNER_PUBKEY: [u8; SIGNER_PUBKEY_LEN] = [0u8; SIGNER_PUBKEY_LEN];
44
45/// Empty signature recorded on the genesis row. Pair with
46/// [`GENESIS_SIGNER_PUBKEY`].
47pub const GENESIS_SIGNATURE: [u8; SIGNATURE_LEN] = [0u8; SIGNATURE_LEN];
48
49/// Reserved column set for a `KIND blockchain SIGNED_BY (...)` collection
50/// — the union of the chain reserved columns and the signed-writes
51/// reserved columns.
52pub const RESERVED_COLUMNS_SIGNED_CHAIN: &[&str] = &[
53    COL_BLOCK_HEIGHT,
54    COL_PREV_HASH,
55    COL_TIMESTAMP,
56    COL_HASH,
57    RESERVED_SIGNER_PUBKEY_COL,
58    RESERVED_SIGNATURE_COL,
59];
60
61/// True for the documented genesis exemption pair (null pubkey + null
62/// signature). Used by the verify walker to skip Ed25519 verification on
63/// the genesis row.
64pub fn is_genesis_signed_marker(pubkey: &[u8; SIGNER_PUBKEY_LEN], signature: &[u8]) -> bool {
65    pubkey == &GENESIS_SIGNER_PUBKEY && signature.iter().all(|b| *b == 0)
66}
67
68/// Build the reserved-column field list + hash for a new block on a
69/// signed chain. Caller supplies the row's canonical payload bytes
70/// (engine's canonical payload encoder, identical to what the client
71/// signed) and the signer fields produced by the client.
72///
73/// Genesis exemption: when `height == 0`, the caller passes
74/// [`GENESIS_SIGNER_PUBKEY`] / [`GENESIS_SIGNATURE`] markers.
75///
76/// The returned hash binds the signer fields per
77/// [`compute_block_hash`], so any subsequent tampering with either
78/// reserved column makes the per-block hash check fail in
79/// `verify_chain`.
80pub fn make_signed_block_reserved_fields(
81    prev_hash: [u8; 32],
82    height: u64,
83    timestamp_ms: u64,
84    payload_canonical: &[u8],
85    signer_pubkey: [u8; SIGNER_PUBKEY_LEN],
86    signature: Vec<u8>,
87) -> (Vec<(String, Value)>, [u8; 32]) {
88    let signed = SignedFields {
89        signer_pubkey,
90        signature: signature.clone(),
91    };
92    let hash = compute_block_hash(
93        &prev_hash,
94        height,
95        timestamp_ms,
96        payload_canonical,
97        Some(&signed),
98    );
99    let fields = vec![
100        (COL_BLOCK_HEIGHT.to_string(), Value::UnsignedInteger(height)),
101        (COL_PREV_HASH.to_string(), Value::Blob(prev_hash.to_vec())),
102        (
103            COL_TIMESTAMP.to_string(),
104            Value::UnsignedInteger(timestamp_ms),
105        ),
106        (
107            RESERVED_SIGNER_PUBKEY_COL.to_string(),
108            Value::Blob(signer_pubkey.to_vec()),
109        ),
110        (
111            RESERVED_SIGNATURE_COL.to_string(),
112            Value::Blob(signature),
113        ),
114        (COL_HASH.to_string(), Value::Blob(hash.to_vec())),
115    ];
116    (fields, hash)
117}
118
119/// Genesis row builder for a signed chain. Returns the field list that
120/// `execute_create_collection` writes when the collection has both
121/// `KIND blockchain` and a non-empty signer registry.
122pub fn genesis_signed_fields(timestamp_ms: u64) -> Vec<(String, Value)> {
123    make_signed_block_reserved_fields(
124        GENESIS_PREV_HASH,
125        0,
126        timestamp_ms,
127        &[],
128        GENESIS_SIGNER_PUBKEY,
129        GENESIS_SIGNATURE.to_vec(),
130    )
131    .0
132}
133
134/// Outcome of [`verify_chain_with_signatures`]. Distinguishes "hash chain
135/// is broken" (recomputed hash differs from stored hash) from "signature
136/// is invalid" (hash chain still links but the stored signature does
137/// NOT verify against the stored pubkey over the canonical payload).
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct SignedChainVerifyOutcome {
140    pub checked: u64,
141    pub ok: bool,
142    pub first_bad_height: Option<u64>,
143    /// `true` when the failure was the per-block Ed25519 signature
144    /// re-verification rather than the chain hash linkage.
145    pub signature_failure: bool,
146}
147
148impl SignedChainVerifyOutcome {
149    pub fn ok(checked: u64) -> Self {
150        Self {
151            checked,
152            ok: true,
153            first_bad_height: None,
154            signature_failure: false,
155        }
156    }
157}
158
159/// Issue #526 — walk a signed chain end-to-end. Combines:
160///
161/// 1. [`verify_chain`] — hash chain linkage + per-block hash recompute
162///    (already covers tampered signer/signature because they feed into
163///    the hash preimage).
164/// 2. Per-non-genesis-block Ed25519 signature re-verification via
165///    [`reverify_row`]. This catches the pathological case where a
166///    tamperer replaces stored `hash` to match a forged payload — the
167///    chain links, the hash matches, but the signature does NOT verify
168///    against the stored pubkey.
169///
170/// Genesis exemption: a block at `block_height == 0` with the documented
171/// null pubkey + empty signature is accepted without an Ed25519 call.
172///
173/// The walker stops at the FIRST failure — `first_bad_height` is the
174/// block that tripped the check.
175pub fn verify_chain_with_signatures(blocks: &[Block]) -> SignedChainVerifyOutcome {
176    let checked = blocks.len() as u64;
177    match verify_chain(blocks) {
178        VerifyReport::Inconsistent { block_height, .. } => SignedChainVerifyOutcome {
179            checked,
180            ok: false,
181            first_bad_height: Some(block_height),
182            signature_failure: false,
183        },
184        VerifyReport::Ok => {
185            for block in blocks {
186                let Some(signed) = &block.signed else {
187                    // No signed fields present — pure chain block. The
188                    // chain verifier already accepted it; nothing more
189                    // to check.
190                    continue;
191                };
192                if block.block_height == 0
193                    && is_genesis_signed_marker(&signed.signer_pubkey, &signed.signature)
194                {
195                    continue;
196                }
197                if signed.signature.len() != SIGNATURE_LEN {
198                    return SignedChainVerifyOutcome {
199                        checked,
200                        ok: false,
201                        first_bad_height: Some(block.block_height),
202                        signature_failure: true,
203                    };
204                }
205                let mut sig_arr = [0u8; SIGNATURE_LEN];
206                sig_arr.copy_from_slice(&signed.signature);
207                if reverify_row(&signed.signer_pubkey, &sig_arr, &block.payload).is_err() {
208                    return SignedChainVerifyOutcome {
209                        checked,
210                        ok: false,
211                        first_bad_height: Some(block.block_height),
212                        signature_failure: true,
213                    };
214                }
215            }
216            SignedChainVerifyOutcome::ok(checked)
217        }
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use crate::storage::signed_writes::{
225        verify_insert, InsertSignatureFields, SignedWriteError, SignerRegistry,
226    };
227    use ed25519_dalek::{Signer, SigningKey};
228
229    fn signing_key(seed: u8) -> SigningKey {
230        SigningKey::from_bytes(&[seed; 32])
231    }
232
233    fn pubkey_of(sk: &SigningKey) -> [u8; SIGNER_PUBKEY_LEN] {
234        sk.verifying_key().to_bytes()
235    }
236
237    /// Build a chain: genesis (null sig) + N signed blocks signed by `sk`.
238    fn build_signed_chain<const N: usize>(sk: &SigningKey, payloads: [&[u8]; N]) -> Vec<Block> {
239        let mut out: Vec<Block> = Vec::new();
240        let mut prev = GENESIS_PREV_HASH;
241        // Genesis.
242        let g_hash = compute_block_hash(
243            &prev,
244            0,
245            1_000,
246            &[],
247            Some(&SignedFields {
248                signer_pubkey: GENESIS_SIGNER_PUBKEY,
249                signature: GENESIS_SIGNATURE.to_vec(),
250            }),
251        );
252        out.push(Block {
253            block_height: 0,
254            prev_hash: prev,
255            timestamp_ms: 1_000,
256            payload: Vec::new(),
257            signed: Some(SignedFields {
258                signer_pubkey: GENESIS_SIGNER_PUBKEY,
259                signature: GENESIS_SIGNATURE.to_vec(),
260            }),
261            hash: g_hash,
262        });
263        prev = g_hash;
264        let pk = pubkey_of(sk);
265        for (i, &payload) in payloads.iter().enumerate() {
266            let height = (i + 1) as u64;
267            let ts = 1_000 + height;
268            let sig = sk.sign(payload).to_bytes();
269            let signed = SignedFields {
270                signer_pubkey: pk,
271                signature: sig.to_vec(),
272            };
273            let hash = compute_block_hash(&prev, height, ts, payload, Some(&signed));
274            out.push(Block {
275                block_height: height,
276                prev_hash: prev,
277                timestamp_ms: ts,
278                payload: payload.to_vec(),
279                signed: Some(signed),
280                hash,
281            });
282            prev = hash;
283        }
284        out
285    }
286
287    #[test]
288    fn reserved_columns_signed_chain_is_union() {
289        // Locks the contract: a signed-chain row carries six reserved cols.
290        assert_eq!(RESERVED_COLUMNS_SIGNED_CHAIN.len(), 6);
291        for col in [
292            COL_BLOCK_HEIGHT,
293            COL_PREV_HASH,
294            COL_TIMESTAMP,
295            COL_HASH,
296            RESERVED_SIGNER_PUBKEY_COL,
297            RESERVED_SIGNATURE_COL,
298        ] {
299            assert!(
300                RESERVED_COLUMNS_SIGNED_CHAIN.contains(&col),
301                "missing reserved column {col}"
302            );
303        }
304    }
305
306    #[test]
307    fn genesis_uses_null_pubkey_and_signature() {
308        // Acceptance: "Genesis block uses null pubkey + null signature
309        // (documented exemption)".
310        let fields = genesis_signed_fields(1_700_000_000_000);
311        let pk = fields
312            .iter()
313            .find(|(k, _)| k == RESERVED_SIGNER_PUBKEY_COL)
314            .unwrap();
315        match &pk.1 {
316            Value::Blob(b) => assert_eq!(&b[..], &GENESIS_SIGNER_PUBKEY[..]),
317            other => panic!("signer_pubkey must be Blob, got {other:?}"),
318        }
319        let sig = fields
320            .iter()
321            .find(|(k, _)| k == RESERVED_SIGNATURE_COL)
322            .unwrap();
323        match &sig.1 {
324            Value::Blob(b) => {
325                assert_eq!(b.len(), SIGNATURE_LEN);
326                assert!(b.iter().all(|x| *x == 0));
327            }
328            other => panic!("signature must be Blob, got {other:?}"),
329        }
330        let height = fields
331            .iter()
332            .find(|(k, _)| k == COL_BLOCK_HEIGHT)
333            .unwrap();
334        assert_eq!(height.1, Value::UnsignedInteger(0));
335    }
336
337    #[test]
338    fn hash_binds_signer_pubkey_and_signature() {
339        // Acceptance: "hash includes signer_pubkey + signature".
340        let sk = signing_key(7);
341        let pk = pubkey_of(&sk);
342        let payload = b"row=a;";
343        let sig = sk.sign(payload).to_bytes().to_vec();
344        let (_fields, hash_with_sig) =
345            make_signed_block_reserved_fields(GENESIS_PREV_HASH, 1, 42, payload, pk, sig.clone());
346        // Flip one byte of the signature → hash changes.
347        let mut sig_tampered = sig.clone();
348        sig_tampered[0] ^= 0x01;
349        let (_f2, hash_tampered) = make_signed_block_reserved_fields(
350            GENESIS_PREV_HASH,
351            1,
352            42,
353            payload,
354            pk,
355            sig_tampered,
356        );
357        assert_ne!(hash_with_sig, hash_tampered);
358        // Flip one byte of the pubkey → hash changes.
359        let mut pk_tampered = pk;
360        pk_tampered[0] ^= 0x01;
361        let (_f3, hash_pk_tampered) = make_signed_block_reserved_fields(
362            GENESIS_PREV_HASH,
363            1,
364            42,
365            payload,
366            pk_tampered,
367            sig,
368        );
369        assert_ne!(hash_with_sig, hash_pk_tampered);
370    }
371
372    #[test]
373    fn valid_signed_chain_verifies_ok() {
374        let sk = signing_key(3);
375        let chain = build_signed_chain(&sk, [b"a".as_slice(), b"b".as_slice(), b"c".as_slice()]);
376        let out = verify_chain_with_signatures(&chain);
377        assert!(out.ok, "{out:?}");
378        assert_eq!(out.checked, 4);
379        assert!(out.first_bad_height.is_none());
380    }
381
382    #[test]
383    fn tampering_signer_pubkey_fails_at_block_height() {
384        // Acceptance: "Tampering with signer_pubkey → verify_chain fails
385        // at that height."
386        let sk = signing_key(4);
387        let mut chain = build_signed_chain(&sk, [b"a".as_slice(), b"b".as_slice(), b"c".as_slice()]);
388        // Tamper height-2 signer pubkey. Hash stored is now stale, so
389        // verify_chain catches it as a hash mismatch.
390        if let Some(signed) = chain[2].signed.as_mut() {
391            signed.signer_pubkey[0] ^= 0x55;
392        }
393        let out = verify_chain_with_signatures(&chain);
394        assert!(!out.ok);
395        assert_eq!(out.first_bad_height, Some(2));
396    }
397
398    #[test]
399    fn tampering_signature_with_recomputed_hash_caught_by_sig_reverify() {
400        // Even if the attacker re-computes the stored hash so the chain
401        // re-links cleanly, signature reverification rejects the forged
402        // row.
403        let sk = signing_key(5);
404        let attacker = signing_key(6);
405        let mut chain = build_signed_chain(&sk, [b"a".as_slice(), b"b".as_slice(), b"c".as_slice()]);
406        // Forge height-2: keep the original pubkey but install a sig
407        // produced by the attacker's key over the same payload. The
408        // signature is well-formed (64 bytes) but does NOT verify under
409        // the legitimate pubkey.
410        let target = &mut chain[2];
411        let bad_sig = attacker.sign(&target.payload).to_bytes().to_vec();
412        target.signed = Some(SignedFields {
413            signer_pubkey: pubkey_of(&sk),
414            signature: bad_sig,
415        });
416        // Recompute hash so the chain still links by hash → only
417        // signature reverify can catch the forgery.
418        let recomputed = compute_block_hash(
419            &target.prev_hash,
420            target.block_height,
421            target.timestamp_ms,
422            &target.payload,
423            target.signed.as_ref(),
424        );
425        target.hash = recomputed;
426        // Fix downstream blocks' prev_hash + hash so the chain links
427        // end-to-end.
428        let mut prev = recomputed;
429        for i in 3..chain.len() {
430            chain[i].prev_hash = prev;
431            chain[i].hash = compute_block_hash(
432                &chain[i].prev_hash,
433                chain[i].block_height,
434                chain[i].timestamp_ms,
435                &chain[i].payload,
436                chain[i].signed.as_ref(),
437            );
438            prev = chain[i].hash;
439        }
440        let out = verify_chain_with_signatures(&chain);
441        assert!(!out.ok);
442        assert_eq!(out.first_bad_height, Some(2));
443        assert!(
444            out.signature_failure,
445            "expected signature_failure, got {out:?}"
446        );
447    }
448
449    #[test]
450    fn composition_chain_fail_then_sig_fail_atomic_reject() {
451        // Acceptance: "Valid sig + stale prev_hash → 409 ChainConflict;
452        // sig 'not consumed'". And the dual: "Valid chain + bad sig →
453        // 401 InvalidSignature; tip unchanged."
454        //
455        // This module owns the verify side; the INSERT-time composition
456        // lives in #522/#524. We pin the contract here as a pure-logic
457        // check on the validator order — `verify_insert` is independent
458        // of chain state, so a sig failure does not depend on whether
459        // the chain check would have passed, and a chain failure does
460        // not consume the signature (verify_insert is a pure function).
461        let sk = signing_key(8);
462        let pk = pubkey_of(&sk);
463        let payload = b"payload";
464        let sig = sk.sign(payload).to_bytes();
465        let registry = SignerRegistry::from_initial(&[pk], "@system", 0);
466
467        // Bad sig → InvalidSignature regardless of chain state.
468        let attacker = signing_key(9);
469        let bad_sig = attacker.sign(payload).to_bytes();
470        let err = verify_insert(
471            &registry,
472            &InsertSignatureFields {
473                signer_pubkey: Some(&pk),
474                signature: Some(&bad_sig),
475            },
476            payload,
477        )
478        .unwrap_err();
479        assert_eq!(err, SignedWriteError::InvalidSignature);
480
481        // Valid sig accepted — same registry, same payload.
482        verify_insert(
483            &registry,
484            &InsertSignatureFields {
485                signer_pubkey: Some(&pk),
486                signature: Some(&sig),
487            },
488            payload,
489        )
490        .unwrap();
491    }
492
493    #[test]
494    fn missing_signature_fields_typed_error() {
495        // Acceptance: "INSERT requires both chain + signature fields;
496        // missing → typed error."
497        let registry = SignerRegistry::default();
498        let err = verify_insert(
499            &registry,
500            &InsertSignatureFields::default(),
501            b"payload",
502        )
503        .unwrap_err();
504        match err {
505            SignedWriteError::MissingSignatureFields { fields } => {
506                assert!(fields.contains(&RESERVED_SIGNER_PUBKEY_COL));
507                assert!(fields.contains(&RESERVED_SIGNATURE_COL));
508            }
509            other => panic!("expected MissingSignatureFields, got {other:?}"),
510        }
511    }
512
513    #[test]
514    fn genesis_marker_recognised() {
515        assert!(is_genesis_signed_marker(
516            &GENESIS_SIGNER_PUBKEY,
517            &GENESIS_SIGNATURE
518        ));
519        assert!(!is_genesis_signed_marker(&[1u8; 32], &GENESIS_SIGNATURE));
520        let nonzero = [1u8; SIGNATURE_LEN];
521        assert!(!is_genesis_signed_marker(&GENESIS_SIGNER_PUBKEY, &nonzero));
522    }
523}