Skip to main content

krabka_audit/
checkpoint.rs

1//! Signed chain-checkpoint records.
2//!
3//! A checkpoint is a special audit record with `event_class = "checkpoint"`. It
4//! is NOT part of the hash-chain. It asserts and signs the chain head at its
5//! position, so an attacker without the signing key cannot forge a consistent
6//! rewrite of the records before it.
7
8use {
9    crate::{
10        chain::{from_hex32, to_hex},
11        event::AuditEventClass,
12        ids::{EpochMs, Seq},
13        signing::{FileEd25519Signer, checkpoint_signing_bytes, verify_signature},
14        sink::AuditRecord,
15    },
16    serde_json::json,
17};
18
19/// `event_class` header value that identifies a checkpoint record.
20pub const EVENT_CLASS_CHECKPOINT: &str = "checkpoint";
21
22/// A signed checkpoint over the chain head at sequence `seq_high`.
23#[derive(Debug, Clone)]
24pub struct Checkpoint {
25    pub key_id: String,
26    pub seq_high: Seq,
27    pub chain_head: [u8; 32],
28    pub time_ms: EpochMs,
29    pub signature: Vec<u8>,
30    pub public_key: Vec<u8>,
31}
32
33impl Checkpoint {
34    /// Build and sign a checkpoint with `signer`.
35    #[must_use]
36    #[tracing::instrument(
37        level = "debug",
38        skip_all,
39        fields(key_id = %signer.key_id(), seq_high, time_ms)
40    )]
41    pub fn signed(
42        signer: &FileEd25519Signer,
43        seq_high: Seq,
44        chain_head: &[u8; 32],
45        time_ms: EpochMs,
46    ) -> Self {
47        let msg = checkpoint_signing_bytes(signer.key_id(), seq_high, chain_head, time_ms);
48        Self {
49            key_id: signer.key_id().to_owned(),
50            seq_high,
51            chain_head: *chain_head,
52            time_ms,
53            signature: signer.sign(&msg),
54            public_key: signer.public_key(),
55        }
56    }
57
58    /// Verify this checkpoint's signature against a trusted public key.
59    #[must_use]
60    #[tracing::instrument(
61        level = "debug",
62        skip_all,
63        fields(key_id = %self.key_id, seq_high = self.seq_high.0)
64    )]
65    pub fn verify(&self, public_key: &[u8]) -> bool {
66        let msg =
67            checkpoint_signing_bytes(&self.key_id, self.seq_high, &self.chain_head, self.time_ms);
68        verify_signature(public_key, &msg, &self.signature)
69    }
70
71    /// Encode this checkpoint as an audit record for the audit topic.
72    #[must_use]
73    pub fn to_record(&self) -> AuditRecord {
74        let value = serde_json::to_vec(&json!({
75            "type": "checkpoint",
76            "key_id": self.key_id,
77            "seq_high": self.seq_high.0,
78            "chain_head": to_hex(&self.chain_head),
79            "time": self.time_ms.0,
80            "signature": to_hex(&self.signature),
81            "public_key": to_hex(&self.public_key),
82        }))
83        .unwrap_or_else(|_| b"{}".to_vec());
84        AuditRecord {
85            class: AuditEventClass::Checkpoint,
86            value,
87            headers: vec![(
88                "event_class".to_owned(),
89                EVENT_CLASS_CHECKPOINT.as_bytes().to_vec(),
90            )],
91        }
92    }
93
94    /// Parse a checkpoint from its record value JSON.
95    ///
96    /// Returns `None` if any field is missing or malformed.
97    #[must_use]
98    pub fn from_value(v: &serde_json::Value) -> Option<Self> {
99        let key_id = v.get("key_id")?.as_str()?.to_owned();
100        let seq_high = Seq(v.get("seq_high")?.as_u64()?);
101        let chain_head = from_hex32(v.get("chain_head")?.as_str()?)?;
102        let time_ms = EpochMs(v.get("time")?.as_i64()?);
103        let signature = hex::decode(v.get("signature")?.as_str()?).ok()?;
104        let public_key = hex::decode(v.get("public_key")?.as_str()?).ok()?;
105        Some(Self {
106            key_id,
107            seq_high,
108            chain_head,
109            time_ms,
110            signature,
111            public_key,
112        })
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use {
119        super::*,
120        crate::{
121            event::AuditEventClass,
122            test_support::{TestSigner, signer},
123        },
124        assert2::check,
125    };
126
127    #[test]
128    fn signed_checkpoint_round_trips_through_record_and_verifies() {
129        let TestSigner {
130            signer: s,
131            public_key: pubkey,
132        } = signer();
133        let head = [3u8; 32];
134        let cp = Checkpoint::signed(&s, Seq(41), &head, EpochMs(1_700_000_000_000));
135        check!(cp.verify(&pubkey));
136
137        // serialize to a record, parse back from its value, still verifies
138        let rec = cp.to_record();
139        check!(rec.class == AuditEventClass::Checkpoint);
140        check!(
141            rec.headers
142                .iter()
143                .any(|(k, v)| k == "event_class" && v == b"checkpoint")
144        );
145        let value: serde_json::Value = serde_json::from_slice(&rec.value).unwrap();
146        let parsed = Checkpoint::from_value(&value).expect("parse");
147        check!(
148            (parsed.seq_high, parsed.chain_head, parsed.verify(&pubkey)) == (Seq(41), head, true)
149        );
150    }
151
152    #[test]
153    fn tampered_checkpoint_fails_verification() {
154        let TestSigner {
155            signer: s,
156            public_key: pubkey,
157        } = signer();
158        let mut cp = Checkpoint::signed(&s, Seq(41), &[3u8; 32], EpochMs(10));
159        // flip the head the signature was computed over
160        cp.chain_head = [4u8; 32];
161        check!(!cp.verify(&pubkey));
162    }
163
164    #[yare::parameterized(
165        non_ascii_pair = { "a\u{e9}a" },
166        sign_prefix = { "+f" },
167        odd_length = { "abc" },
168    )]
169    fn from_value_rejects_malformed_hex_without_panicking(bad: &str) {
170        let TestSigner { signer: s, .. } = signer();
171        let cp = Checkpoint::signed(&s, Seq(1), &[5u8; 32], EpochMs(10));
172        let mut v: serde_json::Value = serde_json::from_slice(&cp.to_record().value).unwrap();
173        v["signature"] = serde_json::Value::from(bad);
174        check!(Checkpoint::from_value(&v).is_none());
175    }
176}