1use 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 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 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 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 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 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 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 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 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#[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}