Skip to main content

confium_transparency/ots/
client.rs

1//! OTS client — submit digests to calendar servers over the real
2//! OpenTimestamps wire protocol, verify proofs.
3//!
4//! [`OtsClient::stamp_wire`] POSTs the digest to a calendar and
5//! parses the returned partial proof (op stream + pending
6//! attestation) with the [`crate::ots::wire`] format layer.
7//! [`OtsClient::upgrade`] fetches a more complete proof for a pending
8//! attestation. [`OtsClient::verify_wire`] replays the op tree from
9//! the digest and classifies the attestations — an attestation is
10//! only reported for the message the op-chain actually computes.
11//!
12//! [`OtsClient::verify`] (the chain-backed checker) remains
13//! callback-based: confirming a Bitcoin attestation needs a block
14//! header source the caller supplies.
15//!
16
17use crate::ots::proof::{OtsError, OtsProof, OtsVerification};
18use sha2::{Digest, Sha256};
19
20/// Default public calendar servers (free, community-operated).
21pub 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
28/// OTS client.
29pub struct OtsClient {
30    calendar_servers: Vec<String>,
31}
32
33impl OtsClient {
34    /// Construct a new client with default calendar servers.
35    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    /// Construct with custom calendar servers.
45    pub fn with_servers(servers: Vec<String>) -> Self {
46        Self {
47            calendar_servers: servers,
48        }
49    }
50
51    /// Available calendar servers.
52    pub fn calendar_servers(&self) -> &[String] {
53        &self.calendar_servers
54    }
55
56    /// Submit a hash for timestamping over the real wire protocol:
57    /// POST the 32-byte digest to `{server}/timestamp` and parse the
58    /// returned partial proof. Servers are tried in order; the first
59    /// success wins.
60    ///
61    /// The result carries a Pending attestation — Bitcoin confirmation
62    /// arrives hours later; poll with [`Self::upgrade`].
63    #[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                    // Try the next server; the last error surfaces.
91                    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    /// Fetch a more complete proof for a pending attestation: GET
101    /// `{calendar}/timestamp/{digest-hex}`. The returned file replaces
102    /// the pending one (it is a superset by construction).
103    #[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    /// Replay the proof tree from the digest and classify every
149    /// attestation. No chain data needed: a pending attestation is
150    /// reported as such; a Bitcoin attestation reports its height and
151    /// the committed terminal message (the caller checks that against
152    /// the block header's merkle-committed data).
153    #[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    /// Verify a proof against Bitcoin block headers.
162    ///
163    /// Caller provides a `bitcoin_block_header_hash` callback that returns
164    /// the block hash at the given height (real impl: query Bitcoin Core
165    /// RPC, or use a public blockchain API).
166    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        // Verify the Merkle root matches what's in the block.
175        let _block_hash =
176            bitcoin_block_at_height(proof.bitcoin_height).map_err(OtsError::BitcoinBackend)?;
177
178        // Mock: in real impl, parse block header, extract Merkle root,
179        // verify the Merkle branch proves inclusion of `proof.hash` under
180        // that root.
181        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            // Double SHA-256 (Bitcoin convention)
189            let mut h2 = Sha256::new();
190            h2.update(out);
191            current.copy_from_slice(&h2.finalize());
192        }
193
194        // An empty merkle branch proves nothing: without siblings the
195        // claimed root is unverifiable, so an empty branch must NOT
196        // verify (previously treated as valid).
197        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    /// The local-socket stub tests flake when run in parallel (same
232    /// class of port/handler races the net-noise roundtrip tests
233    /// hit); serialize them.
234    #[cfg(feature = "calendar")]
235    static STUB_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
236
237    /// Minimal HTTP calendar stub: serves one POST /timestamp and
238    /// replies with a canned, valid partial proof.
239    #[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            // Let the client read before the socket closes: dropping
268            // immediately can race into an RST on Windows and surface
269            // as a client-side "peer disconnected" transport error.
270            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        // Bind a stub on an OS-assigned port first, then connect.
318        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        // The stub serves exactly one connection; stamp_wire's first
325        // (and only) server is the stub.
326        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        // msg == sha256(digest)
337        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            // Drain the full request before responding: closing with
355            // unread data sends an RST that shows up as a client-side
356            // transport error instead of the proof-parse error we are
357            // testing for.
358            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        // An empty merkle branch proves nothing — it must NOT verify.
394        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}