Skip to main content

luct_core/tiling/
checkpoint.rs

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        // Check that origin line matches the logs submission url
39        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        // Find exactly one matching key in the list of keys
46        // TODO: Precompute id once during initialization, rather than recomputer it here all the time
47        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        // Parse the key and reconstruct the `TreeHeadSignature`
60        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        // Validate the signature
69        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        // Parse the origin
124        let origin = data
125            .next()
126            .ok_or(ParseCheckpointError::MissingField {
127                field_name: "origin",
128            })?
129            .to_string();
130
131        // Parse the tree size
132        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        // Parse the root hash
143        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        // Check that there is an empty line
159        let separator = data.next().ok_or(ParseCheckpointError::NoSignatures)?;
160        if !separator.is_empty() {
161            return Err(ParseCheckpointError::UnexpectedExtensions);
162        }
163
164        // Parse the signatures
165        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    // TODO: `as_string` function and roundtrip test
185}
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    // TODO: `as_string` function
211}
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}