Skip to main content

luct_client/request/
v1.rs

1//! This module contains the low-level call API
2//!
3//! Each function does exactly one call, parses and validates the
4//! returned data.
5
6use crate::{Client, ClientError, CtClient};
7use base64::{Engine, prelude::BASE64_STANDARD};
8use luct_core::{
9    Certificate, Version,
10    store::Hashable,
11    tree::{AuditProof, ConsistencyProof, TreeHead},
12    v1::{
13        MerkleTreeLeaf, SignedCertificateTimestamp, SignedTreeHead,
14        responses::{
15            GetProofByHashResponse, GetRootsResponse, GetSthConsistencyResponse, GetSthResponse,
16        },
17    },
18};
19use std::cmp::Ordering;
20use url::Url;
21
22impl<C: Client> CtClient<C> {
23    #[tracing::instrument(level = "trace")]
24    pub async fn get_sth_v1(&self) -> Result<SignedTreeHead, ClientError> {
25        self.assert_v1()?;
26        let url = self.get_full_v1_url().join("get-sth").unwrap();
27
28        // Fetch and parse the signed tree head
29        let (status, response) = self.client.get(&url, &[]).await?;
30        self.check_status(&url, status, &response)?;
31        let response: GetSthResponse = serde_json::from_str(&response)?;
32        let response = SignedTreeHead::try_from(response).map_err(|_| ClientError::SthError)?;
33
34        // Validate tree head signature against key
35        self.log
36            .validate_sth_v1(&response)
37            .map_err(|err| ClientError::SignatureValidationFailed("STH", err))?;
38
39        tracing::debug!("fetched and validated STH {:?} from url {}", response, url);
40
41        Ok(response)
42    }
43
44    #[tracing::instrument(level = "trace")]
45    pub async fn update_sth_v1(
46        &self,
47        old_sth: Option<&SignedTreeHead>,
48    ) -> Result<SignedTreeHead, ClientError> {
49        let new_sth = self.get_sth_v1().await?;
50
51        // If we have no old sth, simply return the new one
52        let Some(old_sth) = old_sth else {
53            return Ok(new_sth);
54        };
55
56        if old_sth == &new_sth {
57            return Ok(new_sth);
58        }
59
60        self.check_consistency_v1(old_sth, &new_sth).await?;
61
62        Ok(new_sth)
63    }
64
65    #[tracing::instrument(level = "trace")]
66    pub async fn check_consistency_v1(
67        &self,
68        first: &SignedTreeHead,
69        second: &SignedTreeHead,
70    ) -> Result<(), ClientError> {
71        self.assert_v1()?;
72
73        // Swap first and second if second < first
74        let (first, second) = match first.tree_size().cmp(&second.tree_size()) {
75            Ordering::Less => (first, second),
76            Ordering::Equal => return Ok(()),
77            Ordering::Greater => (second, first),
78        };
79
80        let first_idx = first.tree_size().to_string();
81        let second_idx = second.tree_size().to_string();
82
83        // Fetch and parse inclusion proof
84        let url = self.get_full_v1_url().join("get-sth-consistency").unwrap();
85        let (status, response) = self
86            .client
87            .get(&url, &[("first", &first_idx), ("second", &second_idx)])
88            .await?;
89        self.check_status(&url, status, &response)?;
90
91        let response: GetSthConsistencyResponse = serde_json::from_str(&response)?;
92        let proof =
93            ConsistencyProof::try_from(response).map_err(ClientError::ConsistencyProofError)?;
94
95        let first = TreeHead::from(first);
96        let second = TreeHead::from(second);
97
98        // Validate inclusion proof
99        proof
100            .validate(&first, &second)
101            .map_err(ClientError::ConsistencyProofError)?;
102
103        tracing::debug!(
104            "fetched and validated consistency proof for tree sizes {} to {}",
105            first.tree_size(),
106            second.tree_size()
107        );
108
109        Ok(())
110    }
111
112    #[tracing::instrument(level = "trace")]
113    pub async fn check_sct_inclusion_v1(
114        &self,
115        sct: &SignedCertificateTimestamp,
116        sth: &SignedTreeHead,
117        leaf: &MerkleTreeLeaf,
118    ) -> Result<u64, ClientError> {
119        self.assert_v1()?;
120
121        let leaf_hash = leaf.hash();
122        let leaf_hash: String = BASE64_STANDARD.encode(leaf_hash);
123
124        let tree_size = sth.tree_size().to_string();
125
126        // Fetch and parse inclusion proof
127        let url = self.get_full_v1_url().join("get-proof-by-hash").unwrap();
128        let (status, response) = self
129            .client
130            .get(&url, &[("hash", &leaf_hash), ("tree_size", &tree_size)])
131            .await?;
132        self.check_status(&url, status, &response)?;
133
134        let response: GetProofByHashResponse = serde_json::from_str(&response)?;
135        let proof = AuditProof::try_from(response).map_err(ClientError::AuditProofError)?;
136        let tree_head = TreeHead::from(sth);
137
138        // Validate inclusion proof
139        proof
140            .validate(&tree_head, leaf)
141            .map_err(ClientError::AuditProofError)?;
142
143        tracing::debug!(
144            "fetched and validated embedded SCT {:?} for tree size {}",
145            sct,
146            sth.tree_size()
147        );
148
149        Ok(proof.index())
150    }
151
152    #[tracing::instrument(level = "trace")]
153    pub async fn get_roots_v1(&self) -> Result<Vec<Certificate>, ClientError> {
154        self.assert_v1()?;
155
156        let url = self.get_full_v1_url().join("get-roots").unwrap();
157        let (status, response) = self.client.get(&url, &[]).await?;
158        self.check_status(&url, status, &response)?;
159
160        let response: GetRootsResponse = serde_json::from_str(&response)?;
161
162        tracing::debug!("fetched roots from url {}", url);
163
164        Ok((&response).into())
165    }
166
167    fn get_full_v1_url(&self) -> Url {
168        let base_url = self.log().config().fetch_url();
169        base_url.join("ct/v1/").unwrap()
170    }
171
172    pub(crate) fn assert_v1(&self) -> Result<(), ClientError> {
173        match self.log().config().version() {
174            Version::V1 => Ok(()),
175            #[allow(unreachable_patterns)]
176            _ => Err(ClientError::UnsupportedVersion),
177        }
178    }
179}
180
181// TODO: Low level get entries call
182
183#[cfg(all(test, feature = "reqwest"))]
184mod tests {
185    use super::*;
186    use crate::reqwest::ReqwestClient;
187    use luct_core::{
188        CertificateChain, CtLogConfig,
189        v1::{SignedTreeHead, responses::GetSthResponse},
190    };
191
192    const ARGON2025H2: &str = "{
193        \"description\": \"Google Argon\",
194        \"version\": 1,
195        \"url\": \"https://ct.googleapis.com/logs/us1/argon2025h2/\",
196        \"key\": \"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEr+TzlCzfpie1/rJhgxnIITojqKk9VK+8MZoc08HjtsLzD8e5yjsdeWVhIiWCVk6Y6KomKTYeKGBv6xVu93zQug==\",
197        \"mmd\": 86400
198        }
199    ";
200
201    const ARGON2025H2_STH_0506: &str = "{
202        \"tree_size\":1329315675,
203        \"timestamp\":1751738269891,
204        \"sha256_root_hash\":\"NEFqldTJt2+wE/aaaQuXeADdWVV8IGbwhLublI7QaMY=\",
205        \"tree_head_signature\":\"BAMARjBEAiA9rna9/avaKTald7hHrldq8FfB4FDAaNyB44pplv71agIgeD0jj2AhLnvlaWavfFZ3BdUglauz36rFpGLYuLBs/O8=\"
206    }";
207    const CERT_CHAIN_GOOGLE_COM: &str = include_str!("../../../testdata/google-chain.pem");
208
209    #[tokio::test]
210    #[ignore = "Makes an HTTP call, for manual testing only"]
211    async fn sth_consistency() {
212        let client = get_client();
213
214        let old_sth: GetSthResponse = serde_json::from_str(ARGON2025H2_STH_0506).unwrap();
215        let old_sth = SignedTreeHead::try_from(old_sth).unwrap();
216
217        client.update_sth_v1(Some(&old_sth)).await.unwrap();
218    }
219
220    #[tokio::test]
221    #[ignore = "Makes an HTTP call, for manual testing only"]
222    async fn sct_inclusion() {
223        let client = get_client();
224
225        let cert = CertificateChain::from_pem_chain(CERT_CHAIN_GOOGLE_COM).unwrap();
226        cert.verify_chain().unwrap();
227        let scts = cert.cert().extract_scts_v1().unwrap();
228
229        let sth = client.get_sth_v1().await.unwrap();
230        client
231            .check_sct_inclusion_v1(&scts[0], &sth, &cert.as_leaf_v1(&scts[0], true).unwrap())
232            .await
233            .unwrap();
234    }
235
236    fn get_client() -> CtClient<ReqwestClient> {
237        let config: CtLogConfig = serde_json::from_str(ARGON2025H2).unwrap();
238        let client = ReqwestClient::new("luct-test");
239        CtClient::new(config, client)
240    }
241
242    #[tokio::test]
243    #[ignore = "Makes an HTTP call, for manual testing only"]
244    async fn get_roots() {
245        let client = get_client();
246
247        let roots = client.get_roots_v1().await.unwrap();
248        assert!(!roots.is_empty())
249    }
250}