confium_transparency/ots/
client.rs1use crate::ots::proof::{OtsError, OtsProof, OtsVerification};
18use sha2::{Digest, Sha256};
19
20pub const DEFAULT_CALENDAR_SERVERS: &[&str] = &[
22 "https://a.pool.opentimestamps.org",
23 "https://b.pool.opentimestamps.org",
24 "https://a.pool.eternitywall.com",
25 "https://ots.btc.catallaxy.com",
26];
27
28pub struct OtsClient {
30 calendar_servers: Vec<String>,
31}
32
33impl OtsClient {
34 pub fn new() -> Self {
36 Self {
37 calendar_servers: DEFAULT_CALENDAR_SERVERS
38 .iter()
39 .map(|s| s.to_string())
40 .collect(),
41 }
42 }
43
44 pub fn with_servers(servers: Vec<String>) -> Self {
46 Self {
47 calendar_servers: servers,
48 }
49 }
50
51 pub fn calendar_servers(&self) -> &[String] {
53 &self.calendar_servers
54 }
55
56 #[cfg(feature = "calendar")]
64 pub fn stamp_wire(&self, hash: [u8; 32]) -> Result<crate::ots::wire::OtsFile, OtsError> {
65 let agent = calendar_agent();
66 let mut last_err = None;
67 for server in &self.calendar_servers {
68 let url = format!("{server}/timestamp");
69 let mut response = agent
70 .post(&url)
71 .header("Content-Type", "application/octet-stream")
72 .send(hash.to_vec())
73 .map_err(|e| OtsError::CalendarUnreachable(format!("{url}: {e}")))?;
74 if !response.status().is_success() {
75 return Err(OtsError::CalendarUnreachable(format!(
76 "{url} returned {}",
77 response.status()
78 )));
79 }
80 let mut bytes = Vec::with_capacity(1024);
81 use std::io::Read;
82 response
83 .body_mut()
84 .as_reader()
85 .read_to_end(&mut bytes)
86 .map_err(|e| OtsError::CalendarUnreachable(format!("{url}: body: {e}")))?;
87 match crate::ots::wire::parse(&hash, &bytes) {
88 Ok(file) => return Ok(file),
89 Err(e) => {
90 last_err = Some(OtsError::InvalidProof(format!("calendar response: {e}")))
92 }
93 }
94 }
95 Err(last_err.unwrap_or_else(|| {
96 OtsError::CalendarUnreachable("no calendar servers configured".into())
97 }))
98 }
99
100 #[cfg(feature = "calendar")]
104 pub fn upgrade(
105 &self,
106 file: &crate::ots::wire::OtsFile,
107 ) -> Result<crate::ots::wire::OtsFile, OtsError> {
108 let uris: Vec<String> = crate::ots::wire::replay(file)
109 .map_err(|e| OtsError::InvalidProof(e.to_string()))?
110 .into_iter()
111 .filter_map(|(_, a)| match a {
112 crate::ots::wire::Attestation::Pending(uri) => Some(uri),
113 _ => None,
114 })
115 .collect();
116 if uris.is_empty() {
117 return Err(OtsError::InvalidProof(
118 "no pending calendar attestation to upgrade".into(),
119 ));
120 }
121 let digest_hex = hex::encode(&file.digest);
122 let agent = calendar_agent();
123 for uri in uris {
124 let url = format!("{uri}/timestamp/{digest_hex}");
125 let mut response = agent
126 .get(&url)
127 .call()
128 .map_err(|e| OtsError::CalendarUnreachable(format!("{url}: {e}")))?;
129 if !response.status().is_success() {
130 continue;
131 }
132 let mut bytes = Vec::with_capacity(1024);
133 use std::io::Read;
134 response
135 .body_mut()
136 .as_reader()
137 .read_to_end(&mut bytes)
138 .map_err(|e| OtsError::CalendarUnreachable(format!("{url}: body: {e}")))?;
139 if let Ok(upgraded) = crate::ots::wire::parse(&file.digest, &bytes) {
140 return Ok(upgraded);
141 }
142 }
143 Err(OtsError::CalendarUnreachable(
144 "no calendar returned an upgraded proof".into(),
145 ))
146 }
147
148 #[cfg(feature = "calendar")]
154 pub fn verify_wire(
155 &self,
156 file: &crate::ots::wire::OtsFile,
157 ) -> Result<crate::ots::wire::OtsWireVerification, OtsError> {
158 crate::ots::wire::verify(file).map_err(|e| OtsError::InvalidProof(e.to_string()))
159 }
160
161 pub async fn verify<F>(
167 &self,
168 proof: &OtsProof,
169 bitcoin_block_at_height: F,
170 ) -> Result<OtsVerification, OtsError>
171 where
172 F: Fn(u32) -> Result<[u8; 32], String>,
173 {
174 let _block_hash =
176 bitcoin_block_at_height(proof.bitcoin_height).map_err(OtsError::BitcoinBackend)?;
177
178 let mut current = proof.hash;
182 for sibling in &proof.merkle_branch {
183 let mut h = Sha256::new();
184 h.update(current);
185 h.update(sibling);
186 let mut out = [0u8; 32];
187 out.copy_from_slice(&h.finalize());
188 let mut h2 = Sha256::new();
190 h2.update(out);
191 current.copy_from_slice(&h2.finalize());
192 }
193
194 let valid = !proof.merkle_branch.is_empty() && current == proof.merkle_root;
198 Ok(OtsVerification {
199 valid,
200 bitcoin_height: proof.bitcoin_height,
201 block_timestamp: None,
202 })
203 }
204}
205
206#[cfg(feature = "calendar")]
207fn calendar_agent() -> ureq::Agent {
208 let config = ureq::config::Config::builder()
209 .user_agent("confium-ots/0.8")
210 .timeout_global(Some(std::time::Duration::from_secs(30)))
211 .build();
212 ureq::Agent::new_with_config(config)
213}
214
215impl Default for OtsClient {
216 fn default() -> Self {
217 Self::new()
218 }
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224
225 #[test]
226 fn client_has_default_servers() {
227 let client = OtsClient::new();
228 assert!(!client.calendar_servers().is_empty());
229 }
230
231 #[cfg(feature = "calendar")]
235 static STUB_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
236
237 #[cfg(feature = "calendar")]
240 fn calendar_stub(port: u16, digest: [u8; 32]) -> std::thread::JoinHandle<()> {
241 use std::io::{Read as _, Write as _};
242 std::thread::spawn(move || {
243 let listener = std::net::TcpListener::bind(("127.0.0.1", port)).unwrap();
244 let (mut stream, _) = listener.accept().unwrap();
245 let mut buf = Vec::new();
246 let mut chunk = [0u8; 512];
247 loop {
248 let n = stream.read(&mut chunk).unwrap();
249 if n == 0 {
250 break;
251 }
252 buf.extend_from_slice(&chunk[..n]);
253 if let Ok(headers_end) = find_headers_end(&buf) {
254 let body_len = content_length(&buf[..headers_end]);
255 if buf.len() >= headers_end + body_len {
256 break;
257 }
258 }
259 }
260 let proof = canned_proof(&digest, port);
261 let response = format!(
262 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
263 proof.len()
264 );
265 stream.write_all(response.as_bytes()).unwrap();
266 stream.write_all(&proof).unwrap();
267 std::thread::sleep(std::time::Duration::from_millis(150));
271 })
272 }
273
274 #[cfg(feature = "calendar")]
275 fn find_headers_end(buf: &[u8]) -> Result<usize, ()> {
276 buf.windows(4)
277 .position(|w| w == b"\r\n\r\n")
278 .map(|p| p + 4)
279 .ok_or(())
280 }
281
282 #[cfg(feature = "calendar")]
283 fn content_length(headers: &[u8]) -> usize {
284 let text = String::from_utf8_lossy(headers);
285 for line in text.lines() {
286 if let Some(v) = line.strip_prefix("Content-Length:") {
287 return v.trim().parse().unwrap_or(0);
288 }
289 }
290 0
291 }
292
293 #[cfg(feature = "calendar")]
294 fn canned_proof(digest: &[u8; 32], port: u16) -> Vec<u8> {
295 let file = crate::ots::wire::OtsFile {
296 digest: digest.to_vec(),
297 root: crate::ots::wire::TimestampNode {
298 attestations: vec![],
299 ops: vec![(
300 crate::ots::wire::Op::Sha256,
301 crate::ots::wire::TimestampNode {
302 attestations: vec![crate::ots::wire::Attestation::Pending(format!(
303 "http://127.0.0.1:{port}"
304 ))],
305 ops: vec![],
306 },
307 )],
308 },
309 };
310 crate::ots::wire::serialize(&file).unwrap()
311 }
312
313 #[cfg(feature = "calendar")]
314 #[test]
315 fn stamp_wire_round_trips_against_local_calendar() {
316 let _guard = STUB_LOCK.lock().unwrap();
317 let probe = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
319 let port = probe.local_addr().unwrap().port();
320 drop(probe);
321
322 let digest = [7u8; 32];
323 let handle = calendar_stub(port, digest);
324 let client = OtsClient::with_servers(vec![format!("http://127.0.0.1:{port}")]);
327 let file = client.stamp_wire(digest).unwrap();
328 handle.join().unwrap();
329
330 assert_eq!(file.digest, digest.to_vec());
331 let verification = client.verify_wire(&file).unwrap();
332 assert!(verification.has_attestation());
333 assert_eq!(verification.pending.len(), 1);
334 let (msg, uri) = &verification.pending[0];
335 assert_eq!(uri, &format!("http://127.0.0.1:{port}"));
336 use sha2::Digest as _;
338 let mut h = Sha256::new();
339 h.update(digest);
340 assert_eq!(msg, &h.finalize().to_vec());
341 }
342
343 #[cfg(feature = "calendar")]
344 #[test]
345 fn stamp_wire_rejects_garbage_response() {
346 let _guard = STUB_LOCK.lock().unwrap();
347 use std::io::{Read as _, Write as _};
348 let probe = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
349 let port = probe.local_addr().unwrap().port();
350 drop(probe);
351 let handle = std::thread::spawn(move || {
352 let listener = std::net::TcpListener::bind(("127.0.0.1", port)).unwrap();
353 let (mut stream, _) = listener.accept().unwrap();
354 let mut buf = Vec::new();
359 let mut chunk = [0u8; 512];
360 loop {
361 let n = stream.read(&mut chunk).unwrap();
362 if n == 0 {
363 break;
364 }
365 buf.extend_from_slice(&chunk[..n]);
366 if let Ok(headers_end) = find_headers_end(&buf) {
367 let body_len = content_length(&buf[..headers_end]);
368 if buf.len() >= headers_end + body_len {
369 break;
370 }
371 }
372 }
373 let body = b"not-an-ots-proof";
374 let response = format!(
375 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
376 body.len()
377 );
378 stream.write_all(response.as_bytes()).unwrap();
379 stream.write_all(body).unwrap();
380 std::thread::sleep(std::time::Duration::from_millis(150));
381 });
382 let client = OtsClient::with_servers(vec![format!("http://127.0.0.1:{port}")]);
383 let result = client.stamp_wire([9u8; 32]);
384 handle.join().unwrap();
385 assert!(
386 matches!(result, Err(OtsError::InvalidProof(_))),
387 "got: {result:?}"
388 );
389 }
390
391 #[tokio::test]
392 async fn verify_empty_branch_is_rejected() {
393 let client = OtsClient::new();
395 let hash = [1u8; 32];
396 let proof = OtsProof::new(hash, 800_000);
397 let result = client.verify(&proof, |_| Ok([0u8; 32])).await.unwrap();
398 assert!(!result.valid);
399 }
400}