1use {
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
19pub const EVENT_CLASS_CHECKPOINT: &str = "checkpoint";
21
22#[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 #[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 #[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 #[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 #[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 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 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}