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