1use std::io::{Cursor, Read, Write};
2
3use crate::{
4 CtLog, LogId, SignatureValidationError, Version,
5 signature::Signature as Signed,
6 tree::{HashOutput, TreeHead},
7 utils::codec::{CodecError, Decode, Encode},
8 v1::{SignedTreeHead, sth::TreeHeadSignature},
9};
10use base64::{Engine, prelude::BASE64_STANDARD};
11use sha2::{Digest, Sha256};
12use thiserror::Error;
13use url::Url;
14
15#[derive(Debug, Clone, PartialEq, Eq, Error)]
16pub enum ParseCheckpointError {
17 #[error("No {field_name} contained in the note")]
18 MissingField { field_name: &'static str },
19
20 #[error("{field_name} could not be parsed")]
21 MalformedField { field_name: &'static str },
22
23 #[error("Unexpected extensions appended to the note. We only expect notes with 3 fields")]
24 UnexpectedExtensions,
25
26 #[error("The note contains no signatures.")]
27 NoSignatures,
28
29 #[error("The signature at index {index} is malformed")]
30 MalformedSignature { index: usize },
31}
32
33impl CtLog {
34 pub fn validate_checkpoint(
35 &self,
36 checkpoint: &Checkpoint,
37 ) -> Result<SignedTreeHead, SignatureValidationError> {
38 let origin = Self::url_to_origin(self.config().url())
40 .ok_or(SignatureValidationError::MalformedKey)?;
41 if origin != checkpoint.origin {
42 return Err(SignatureValidationError::MalformedKey);
43 }
44
45 let id = Self::compute_checkpoint_key_id(&origin, self.log_id());
48 let sigs = checkpoint
49 .signatures
50 .iter()
51 .filter(|sig| sig.name == checkpoint.origin)
52 .filter(|sig| sig.id == id)
53 .collect::<Vec<_>>();
54 if sigs.len() != 1 {
55 return Err(SignatureValidationError::MalformedSignature);
56 }
57 let sig = sigs[0];
58
59 let note_sig = NoteSignature::decode(&mut Cursor::new(&sig.body))?;
61 let tree_head = TreeHeadSignature {
62 version: Version::V1,
63 timestamp: note_sig.timestamp,
64 tree_size: checkpoint.tree_size,
65 sha256_root_hash: checkpoint.root_hash,
66 };
67
68 note_sig
70 .signature
71 .validate(&tree_head, &self.config().key)?;
72
73 Ok(SignedTreeHead {
74 tree_size: checkpoint.tree_size,
75 timestamp: note_sig.timestamp,
76 sha256_root_hash: checkpoint.root_hash,
77 tree_head_signature: note_sig.signature,
78 })
79 }
80
81 fn compute_checkpoint_key_id(origin: &str, log_id: &LogId) -> [u8; 4] {
82 let mut hash = Sha256::new();
83 hash.update(origin);
84 hash.update([0x0A, 0x05]);
85
86 match log_id {
87 LogId::V1(log_id) => hash.update(log_id.0),
88 }
89
90 let hash: [u8; 32] = hash.finalize().into();
91 let id: [u8; 4] = hash[0..4].try_into().unwrap();
92
93 id
94 }
95
96 fn url_to_origin(url: &Url) -> Option<String> {
97 let path = url.path().strip_suffix("/")?;
98 url.host_str().map(|host| format!("{}{}", host, path))
99 }
100}
101
102#[derive(Debug, Clone)]
103pub struct Checkpoint {
104 origin: String,
105 tree_size: u64,
106 root_hash: HashOutput,
107 signatures: Vec<Signature>,
108}
109
110impl From<Checkpoint> for TreeHead {
111 fn from(checkpoint: Checkpoint) -> Self {
112 TreeHead {
113 tree_size: checkpoint.tree_size,
114 head: checkpoint.root_hash,
115 }
116 }
117}
118
119impl Checkpoint {
120 pub fn parse_checkpoint(data: &str) -> Result<Self, ParseCheckpointError> {
121 let mut data = data.lines();
122
123 let origin = data
125 .next()
126 .ok_or(ParseCheckpointError::MissingField {
127 field_name: "origin",
128 })?
129 .to_string();
130
131 let tree_size = data.next().ok_or(ParseCheckpointError::MissingField {
133 field_name: "tree_size",
134 })?;
135 let tree_size =
136 tree_size
137 .parse::<u64>()
138 .map_err(|_| ParseCheckpointError::MalformedField {
139 field_name: "tree_size",
140 })?;
141
142 let root_hash = data.next().ok_or(ParseCheckpointError::MissingField {
144 field_name: "root_hash",
145 })?;
146 let root_hash = BASE64_STANDARD.decode(root_hash).map_err(|_| {
147 ParseCheckpointError::MalformedField {
148 field_name: "root_hash",
149 }
150 })?;
151 let root_hash: HashOutput =
152 root_hash
153 .try_into()
154 .map_err(|_| ParseCheckpointError::MalformedField {
155 field_name: "root_hash",
156 })?;
157
158 let separator = data.next().ok_or(ParseCheckpointError::NoSignatures)?;
160 if !separator.is_empty() {
161 return Err(ParseCheckpointError::UnexpectedExtensions);
162 }
163
164 let signatures = data
166 .enumerate()
167 .map(|(index, signature)| {
168 Signature::from_str(signature)
169 .ok_or(ParseCheckpointError::MalformedSignature { index })
170 })
171 .collect::<Result<Vec<_>, _>>()?;
172 if signatures.is_empty() {
173 return Err(ParseCheckpointError::NoSignatures);
174 }
175
176 Ok(Self {
177 origin,
178 tree_size,
179 root_hash,
180 signatures,
181 })
182 }
183
184 }
186
187#[derive(Debug, Clone, PartialEq, Eq)]
188struct Signature {
189 name: String,
190 id: [u8; 4],
191 body: Vec<u8>,
192}
193
194impl Signature {
195 fn from_str(data: &str) -> Option<Self> {
196 let mut data = data.strip_prefix("— ")?.split(" ");
197 let name = data.next()?.to_string();
198
199 let mut data = BASE64_STANDARD.decode(data.next()?).ok()?;
200 if data.len() < 4 {
201 return None;
202 }
203
204 let body = data.split_off(4);
205 let id: [u8; 4] = data.try_into().unwrap();
206
207 Some(Self { name, id, body })
208 }
209
210 }
212
213#[derive(Debug, Clone, PartialEq, Eq)]
214struct NoteSignature {
215 timestamp: u64,
216 signature: Signed<TreeHeadSignature>,
217}
218
219impl Encode for NoteSignature {
220 fn encode(&self, mut writer: impl Write) -> Result<(), CodecError> {
221 self.timestamp.encode(&mut writer)?;
222 self.signature.encode(&mut writer)?;
223
224 Ok(())
225 }
226}
227
228impl Decode for NoteSignature {
229 fn decode(mut reader: impl Read) -> Result<Self, CodecError> {
230 Ok(Self {
231 timestamp: u64::decode(&mut reader)?,
232 signature: Signed::decode(&mut reader)?,
233 })
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240
241 const ARCHE2026H1_CHECKPOINT: &str =
242 include_str!("../../../testdata/arche2026h1-signed-note.txt");
243
244 const ARCHE2026H1: &str = "
245 {
246 \"description\": \"Google 'Arche2026h1' log\",
247 \"key\": \"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEZ+3YKoZTMruov4cmlImbk4MckBNzEdCyMuHlwGgJ8BUrzFLlR5U0619xDDXIXespkpBgCNVQAkhMTTXakM6KMg==\",
248 \"url\": \"https://arche2026h1.staging.ct.transparency.dev/\",
249 \"tile_url\": \"https://storage.googleapis.com/static-ct-staging-arche2026h1-bucket/\",
250 \"mmd\": 60
251 }
252 ";
253
254 const SYCAMORE2026H1_CHECKPOINT: &str =
255 include_str!("../../../testdata/sycamore2026h1-signed-note.txt");
256
257 const SYCAMORE2026H1: &str = "{
258 \"description\": \"Let's Encrypt 'Sycamore2026h1'\",
259 \"key\": \"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEfEEe0JZknA91/c6eNl1aexgeKzuGQUMvRCXPXg9L227O5I4Pi++Abcpq6qxlVUKPYafAJelAnMfGzv3lHCc8gA==\",
260 \"url\": \"https://log.sycamore.ct.letsencrypt.org/2026h1/\",
261 \"tile_url\": \"https://mon.sycamore.ct.letsencrypt.org/2026h1/\",
262 \"mmd\": 60
263 }
264 ";
265
266 #[test]
267 fn parse_and_validate_checkpoint_arche2026h1() {
268 let checkpoint = Checkpoint::parse_checkpoint(ARCHE2026H1_CHECKPOINT).unwrap();
269
270 assert_eq!(checkpoint.origin, "arche2026h1.staging.ct.transparency.dev");
271 assert_eq!(checkpoint.tree_size, 1822167730);
272
273 let config = serde_json::from_str(ARCHE2026H1).unwrap();
274 let log = CtLog::new(config);
275
276 log.validate_checkpoint(&checkpoint).unwrap();
277 }
278
279 #[test]
280 fn parse_and_validate_checkpoint_sycamore2026h1() {
281 let checkpoint = Checkpoint::parse_checkpoint(SYCAMORE2026H1_CHECKPOINT).unwrap();
282
283 assert_eq!(checkpoint.origin, "log.sycamore.ct.letsencrypt.org/2026h1");
284 assert_eq!(checkpoint.tree_size, 804475391);
285
286 let config = serde_json::from_str(SYCAMORE2026H1).unwrap();
287 let log = CtLog::new(config);
288
289 log.validate_checkpoint(&checkpoint).unwrap();
290 }
291}