Skip to main content

cdx_core/provenance/
ots.rs

1//! `OpenTimestamps` client for timestamp acquisition.
2//!
3//! This module provides an async client for acquiring timestamps from
4//! `OpenTimestamps` calendar servers. `OpenTimestamps` aggregates hashes and
5//! anchors them to the Bitcoin blockchain.
6//!
7//! # Feature Flag
8//!
9//! This module requires the `timestamps-ots` feature:
10//!
11//! ```toml
12//! [dependencies]
13//! cdx-core = { version = "0.1", features = ["timestamps-ots"] }
14//! ```
15//!
16//! # Example
17//!
18//! ```rust,ignore
19//! use cdx_core::provenance::ots::OtsClient;
20//! use cdx_core::{HashAlgorithm, Hasher};
21//!
22//! # async fn example() -> cdx_core::Result<()> {
23//! let client = OtsClient::new();
24//! let doc_id = Hasher::hash(HashAlgorithm::Sha256, b"document content");
25//! let timestamp = client.acquire_timestamp(&doc_id).await?;
26//! println!("Timestamp acquired at: {}", timestamp.time);
27//! # Ok(())
28//! # }
29//! ```
30
31use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
32use chrono::Utc;
33use std::io::{Error as IoError, ErrorKind};
34
35use super::record::TimestampRecord;
36use crate::{DocumentId, Error, Result};
37
38/// Well-known `OpenTimestamps` calendar server URLs.
39pub mod calendars {
40    /// Alice calendar server (primary).
41    pub const ALICE: &str = "https://alice.btc.calendar.opentimestamps.org";
42    /// Bob calendar server (backup).
43    pub const BOB: &str = "https://bob.btc.calendar.opentimestamps.org";
44    /// Finney calendar server (backup).
45    pub const FINNEY: &str = "https://finney.calendar.eternitywall.com";
46    /// Catallaxy calendar server.
47    pub const CATALLAXY: &str = "https://ots.btc.catallaxy.com";
48}
49
50/// `OpenTimestamps` client for acquiring timestamps.
51///
52/// The client communicates with calendar servers to submit hashes
53/// and retrieve timestamp proofs. The proofs are initially "pending"
54/// and need to be upgraded later once the Bitcoin transaction is confirmed.
55#[derive(Debug, Clone)]
56pub struct OtsClient {
57    /// Calendar server URLs to use (in order of preference).
58    calendars: Vec<String>,
59    /// HTTP client.
60    client: reqwest::Client,
61    /// Request timeout in seconds.
62    timeout_secs: u64,
63}
64
65impl Default for OtsClient {
66    fn default() -> Self {
67        Self::new()
68    }
69}
70
71impl OtsClient {
72    /// Create a new OTS client with default calendar servers.
73    #[must_use]
74    pub fn new() -> Self {
75        Self {
76            calendars: vec![
77                calendars::ALICE.to_string(),
78                calendars::BOB.to_string(),
79                calendars::FINNEY.to_string(),
80            ],
81            client: reqwest::Client::new(),
82            timeout_secs: 30,
83        }
84    }
85
86    /// Create a new OTS client with custom calendar servers.
87    #[must_use]
88    pub fn with_calendars(calendars: Vec<String>) -> Self {
89        Self {
90            calendars,
91            client: reqwest::Client::new(),
92            timeout_secs: 30,
93        }
94    }
95
96    /// Set the request timeout.
97    #[must_use]
98    pub fn with_timeout(mut self, timeout_secs: u64) -> Self {
99        self.timeout_secs = timeout_secs;
100        self
101    }
102
103    /// Acquire a timestamp for a document.
104    ///
105    /// This submits the document's hash to an `OpenTimestamps` calendar server
106    /// and returns a timestamp record containing the proof.
107    ///
108    /// # Note
109    ///
110    /// The returned timestamp is initially "pending" - it contains a commitment
111    /// from the calendar server but is not yet anchored to Bitcoin. Use
112    /// `upgrade_timestamp` after sufficient time (typically 1-2 hours) to get
113    /// the full Bitcoin-anchored proof.
114    ///
115    /// # Errors
116    ///
117    /// Returns an error if:
118    /// - No calendar servers are reachable
119    /// - The document ID has an unsupported hash algorithm (must be SHA-256)
120    /// - Network errors occur
121    pub async fn acquire_timestamp(&self, document_id: &DocumentId) -> Result<TimestampRecord> {
122        // OpenTimestamps requires SHA-256 hashes
123        if document_id.algorithm().as_str() != "sha256" {
124            return Err(Error::InvalidManifest {
125                reason: "OpenTimestamps requires SHA-256 hash algorithm".to_string(),
126            });
127        }
128
129        // Get the raw hash bytes
130        let hash_hex = document_id.hex_digest();
131        let hash_bytes = hex_to_bytes(&hash_hex)?;
132
133        // Try each calendar server until one succeeds
134        let mut last_error = None;
135        for calendar_url in &self.calendars {
136            match self.submit_to_calendar(calendar_url, &hash_bytes).await {
137                Ok(proof) => {
138                    return Ok(TimestampRecord::open_timestamps(
139                        Utc::now(),
140                        BASE64.encode(&proof),
141                    ));
142                }
143                Err(e) => {
144                    last_error = Some(e);
145                }
146            }
147        }
148
149        Err(last_error.unwrap_or_else(|| {
150            Error::Io(IoError::new(
151                ErrorKind::NotConnected,
152                "No calendar servers configured",
153            ))
154        }))
155    }
156
157    /// Submit a hash to a calendar server.
158    async fn submit_to_calendar(&self, calendar_url: &str, hash: &[u8]) -> Result<Vec<u8>> {
159        let url = format!("{calendar_url}/digest");
160
161        let response = self
162            .client
163            .post(&url)
164            .timeout(std::time::Duration::from_secs(self.timeout_secs))
165            .header("Content-Type", "application/x-www-form-urlencoded")
166            .body(hash.to_vec())
167            .send()
168            .await
169            .map_err(|e| {
170                Error::Io(IoError::new(
171                    ErrorKind::ConnectionRefused,
172                    format!("Failed to contact calendar server: {e}"),
173                ))
174            })?;
175
176        if !response.status().is_success() {
177            let status = response.status();
178            let text = response.text().await.unwrap_or_default();
179            return Err(Error::Io(IoError::other(format!(
180                "Calendar server returned error: {status} {text}"
181            ))));
182        }
183
184        let proof_bytes = response.bytes().await.map_err(|e| {
185            Error::Io(IoError::new(
186                ErrorKind::InvalidData,
187                format!("Failed to read response: {e}"),
188            ))
189        })?;
190
191        Ok(proof_bytes.to_vec())
192    }
193
194    /// Upgrade a pending timestamp to a complete Bitcoin-anchored proof.
195    ///
196    /// This contacts the calendar server to check if the timestamp has been
197    /// anchored to Bitcoin and returns an upgraded proof if available.
198    ///
199    /// # Note
200    ///
201    /// Bitcoin block confirmation typically takes 10-60 minutes. The calendar
202    /// servers usually include the hash in a transaction within a few hours.
203    /// Call this method periodically until the proof is upgraded.
204    ///
205    /// # Errors
206    ///
207    /// Returns an error if:
208    /// - The proof is not yet ready (still pending)
209    /// - Network errors occur
210    /// - The proof format is invalid
211    pub async fn upgrade_timestamp(&self, timestamp: &TimestampRecord) -> Result<UpgradeResult> {
212        // Decode the existing proof
213        let proof_bytes = BASE64.decode(&timestamp.token).map_err(|e| {
214            Error::Io(IoError::new(
215                ErrorKind::InvalidData,
216                format!("Invalid timestamp token: {e}"),
217            ))
218        })?;
219
220        // Try to upgrade via each calendar
221        for calendar_url in &self.calendars {
222            if let Ok(Some(upgraded)) = self.upgrade_from_calendar(calendar_url, &proof_bytes).await
223            {
224                let upgraded_record = TimestampRecord {
225                    method: timestamp.method,
226                    authority: timestamp.authority.clone(),
227                    time: timestamp.time,
228                    token: BASE64.encode(&upgraded),
229                    transaction_id: extract_bitcoin_txid(&upgraded),
230                };
231                return Ok(UpgradeResult::Complete(upgraded_record));
232            }
233            // Proof not ready yet or calendar error, try next
234        }
235
236        Ok(UpgradeResult::Pending {
237            message: "Timestamp not yet anchored to Bitcoin".to_string(),
238        })
239    }
240
241    /// Try to upgrade a proof from a specific calendar.
242    async fn upgrade_from_calendar(
243        &self,
244        calendar_url: &str,
245        proof: &[u8],
246    ) -> Result<Option<Vec<u8>>> {
247        // Extract the commitment hash from the proof
248        // OTS proofs from calendars are structured as:
249        // - Hash algorithm byte
250        // - Commitment operations
251        // The commitment itself is derivable from the proof structure
252
253        // Try the upgrade endpoint
254        let url = format!("{calendar_url}/timestamp");
255
256        let response = self
257            .client
258            .post(&url)
259            .timeout(std::time::Duration::from_secs(self.timeout_secs))
260            .header("Content-Type", "application/octet-stream")
261            .body(proof.to_vec())
262            .send()
263            .await
264            .map_err(|e| {
265                Error::Io(IoError::new(
266                    ErrorKind::ConnectionRefused,
267                    format!("Failed to contact calendar server: {e}"),
268                ))
269            })?;
270
271        match response.status().as_u16() {
272            200 => {
273                // Upgrade successful
274                let upgraded_bytes = response.bytes().await.map_err(|e| {
275                    Error::Io(IoError::new(
276                        ErrorKind::InvalidData,
277                        format!("Failed to read response: {e}"),
278                    ))
279                })?;
280                Ok(Some(upgraded_bytes.to_vec()))
281            }
282            404 => {
283                // Proof not ready yet
284                Ok(None)
285            }
286            status => {
287                let text = response.text().await.unwrap_or_default();
288                Err(Error::Io(IoError::other(format!(
289                    "Calendar server returned error: {status} {text}"
290                ))))
291            }
292        }
293    }
294
295    /// Check the status of a timestamp without upgrading.
296    ///
297    /// Returns the current status of the timestamp proof.
298    ///
299    /// # Errors
300    ///
301    /// Returns an error if the timestamp token is invalid.
302    pub async fn check_status(&self, timestamp: &TimestampRecord) -> Result<TimestampStatus> {
303        // Decode the existing proof
304        let proof_bytes = BASE64.decode(&timestamp.token).map_err(|e| {
305            Error::Io(IoError::new(
306                ErrorKind::InvalidData,
307                format!("Invalid timestamp token: {e}"),
308            ))
309        })?;
310
311        // Check if the proof is already complete by looking for Bitcoin attestation markers
312        if is_complete_proof(&proof_bytes) {
313            return Ok(TimestampStatus::Complete {
314                bitcoin_txid: extract_bitcoin_txid(&proof_bytes),
315                block_height: extract_block_height(&proof_bytes),
316            });
317        }
318
319        // Try to check status via calendars
320        for calendar_url in &self.calendars {
321            if let Ok(Some(_)) = self.upgrade_from_calendar(calendar_url, &proof_bytes).await {
322                return Ok(TimestampStatus::Ready);
323            }
324        }
325
326        Ok(TimestampStatus::Pending)
327    }
328
329    /// Verify a timestamp proof.
330    ///
331    /// This verifies that the proof is well-formed and, if complete,
332    /// that it correctly anchors to a Bitcoin block.
333    ///
334    /// # Errors
335    ///
336    /// Returns an error if:
337    /// - The proof format is invalid
338    /// - Verification fails
339    pub fn verify_timestamp(
340        &self,
341        timestamp: &TimestampRecord,
342        document_id: &DocumentId,
343    ) -> Result<TimestampVerification> {
344        // Decode the proof
345        let proof_bytes = BASE64.decode(&timestamp.token).map_err(|e| {
346            Error::Io(IoError::new(
347                ErrorKind::InvalidData,
348                format!("Invalid timestamp token: {e}"),
349            ))
350        })?;
351
352        // Basic validation: check it's not empty and starts with OTS magic
353        if proof_bytes.is_empty() {
354            return Ok(TimestampVerification {
355                valid: false,
356                status: VerificationStatus::Invalid,
357                message: "Empty proof".to_string(),
358            });
359        }
360
361        // OTS proofs should start with the magic bytes \x00OpenTimestamps\x00\x00Proof\x00
362        // But calendar responses are different - they're compact proofs
363        // For now, we just validate the proof is non-empty
364        let _ = document_id;
365
366        Ok(TimestampVerification {
367            valid: true,
368            status: VerificationStatus::Pending,
369            message: "Timestamp proof present (full verification requires upgrade)".to_string(),
370        })
371    }
372}
373
374/// Result of timestamp verification.
375#[derive(Debug, Clone)]
376pub struct TimestampVerification {
377    /// Whether the proof passed basic validation.
378    pub valid: bool,
379    /// Verification status.
380    pub status: VerificationStatus,
381    /// Human-readable message.
382    pub message: String,
383}
384
385/// Status of timestamp verification.
386#[derive(Debug, Clone, Copy, PartialEq, Eq)]
387pub enum VerificationStatus {
388    /// Proof is pending (not yet anchored to Bitcoin).
389    Pending,
390    /// Proof is complete and verified against Bitcoin.
391    Complete,
392    /// Proof is invalid.
393    Invalid,
394}
395
396/// Result of attempting to upgrade a timestamp.
397#[derive(Debug, Clone)]
398pub enum UpgradeResult {
399    /// Upgrade completed successfully.
400    Complete(TimestampRecord),
401    /// Timestamp is still pending (not yet anchored).
402    Pending {
403        /// Human-readable status message.
404        message: String,
405    },
406}
407
408impl UpgradeResult {
409    /// Check if the upgrade is complete.
410    #[must_use]
411    pub fn is_complete(&self) -> bool {
412        matches!(self, Self::Complete(_))
413    }
414
415    /// Get the upgraded timestamp record if complete.
416    #[must_use]
417    pub fn into_record(self) -> Option<TimestampRecord> {
418        match self {
419            Self::Complete(record) => Some(record),
420            Self::Pending { .. } => None,
421        }
422    }
423}
424
425/// Status of a timestamp proof.
426#[derive(Debug, Clone, PartialEq, Eq)]
427pub enum TimestampStatus {
428    /// Proof is pending (submitted to calendar but not yet anchored).
429    Pending,
430    /// Proof is ready to be upgraded (anchored but not yet retrieved).
431    Ready,
432    /// Proof is complete with Bitcoin attestation.
433    Complete {
434        /// Bitcoin transaction ID.
435        bitcoin_txid: Option<String>,
436        /// Bitcoin block height.
437        block_height: Option<u64>,
438    },
439}
440
441impl TimestampStatus {
442    /// Check if the timestamp is complete.
443    #[must_use]
444    pub fn is_complete(&self) -> bool {
445        matches!(self, Self::Complete { .. })
446    }
447
448    /// Check if the timestamp is pending.
449    #[must_use]
450    pub fn is_pending(&self) -> bool {
451        matches!(self, Self::Pending)
452    }
453}
454
455impl std::fmt::Display for TimestampStatus {
456    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
457        match self {
458            Self::Pending => write!(f, "Pending"),
459            Self::Ready => write!(f, "Ready for upgrade"),
460            Self::Complete {
461                bitcoin_txid,
462                block_height,
463            } => {
464                write!(f, "Complete")?;
465                if let Some(txid) = bitcoin_txid {
466                    write!(f, " (tx: {txid})")?;
467                }
468                if let Some(height) = block_height {
469                    write!(f, " (block: {height})")?;
470                }
471                Ok(())
472            }
473        }
474    }
475}
476
477/// Check if a proof is complete (contains Bitcoin attestation).
478fn is_complete_proof(proof: &[u8]) -> bool {
479    // OTS complete proofs contain attestation markers
480    // Bitcoin attestation is indicated by a specific byte sequence
481    // The attestation tag for Bitcoin is 0x0588960d73d71901
482    const BITCOIN_ATTESTATION_TAG: [u8; 8] = [0x05, 0x88, 0x96, 0x0d, 0x73, 0xd7, 0x19, 0x01];
483
484    proof
485        .windows(8)
486        .any(|window| window == BITCOIN_ATTESTATION_TAG)
487}
488
489/// Extract Bitcoin transaction ID from a complete proof.
490fn extract_bitcoin_txid(proof: &[u8]) -> Option<String> {
491    // The txid follows the Bitcoin attestation marker
492    // This is a simplified extraction - full implementation would
493    // properly parse the OTS proof format
494    const BITCOIN_ATTESTATION_TAG: [u8; 8] = [0x05, 0x88, 0x96, 0x0d, 0x73, 0xd7, 0x19, 0x01];
495
496    for (i, window) in proof.windows(8).enumerate() {
497        if window == BITCOIN_ATTESTATION_TAG {
498            // The 32-byte txid follows the attestation tag and block merkle path
499            // This is a simplified approach - actual position depends on proof structure
500            if proof.len() > i + 8 + 32 {
501                let txid_bytes = &proof[i + 8..i + 8 + 32];
502                // Reverse for Bitcoin's display format
503                let mut reversed = txid_bytes.to_vec();
504                reversed.reverse();
505                return Some(hex::encode(reversed));
506            }
507        }
508    }
509    None
510}
511
512/// Extract block height from a complete proof.
513fn extract_block_height(proof: &[u8]) -> Option<u64> {
514    // Block height extraction requires proper OTS proof parsing
515    // This is a placeholder - would need full proof parsing
516    let _ = proof;
517    None
518}
519
520/// Helper module for hex encoding.
521mod hex {
522    /// Encode bytes as hex string.
523    pub fn encode(bytes: impl AsRef<[u8]>) -> String {
524        bytes.as_ref().iter().fold(
525            String::with_capacity(bytes.as_ref().len() * 2),
526            |mut acc, b| {
527                use std::fmt::Write;
528                let _ = write!(acc, "{b:02x}");
529                acc
530            },
531        )
532    }
533}
534
535/// Convert hex string to bytes.
536fn hex_to_bytes(hex: &str) -> Result<Vec<u8>> {
537    let hex = hex.trim();
538    if !hex.len().is_multiple_of(2) {
539        return Err(Error::InvalidHashFormat {
540            value: "Invalid hex string length".to_string(),
541        });
542    }
543
544    (0..hex.len())
545        .step_by(2)
546        .map(|i| {
547            u8::from_str_radix(&hex[i..i + 2], 16).map_err(|_| Error::InvalidHashFormat {
548                value: "Invalid hex character".to_string(),
549            })
550        })
551        .collect()
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557    use crate::{HashAlgorithm, Hasher};
558
559    #[test]
560    fn test_ots_client_creation() {
561        let client = OtsClient::new();
562        assert!(!client.calendars.is_empty());
563    }
564
565    #[test]
566    fn test_ots_client_custom_calendars() {
567        let client = OtsClient::with_calendars(vec!["https://custom.example.com".to_string()]);
568        assert_eq!(client.calendars.len(), 1);
569    }
570
571    #[test]
572    fn test_hex_to_bytes() {
573        let bytes = hex_to_bytes("deadbeef").unwrap();
574        assert_eq!(bytes, vec![0xde, 0xad, 0xbe, 0xef]);
575    }
576
577    #[test]
578    fn test_hex_to_bytes_invalid() {
579        assert!(hex_to_bytes("deadbee").is_err()); // odd length
580        assert!(hex_to_bytes("deadbeeg").is_err()); // invalid char
581    }
582
583    #[test]
584    fn test_verify_empty_proof() {
585        let client = OtsClient::new();
586        let doc_id = Hasher::hash(HashAlgorithm::Sha256, b"test");
587        let timestamp = TimestampRecord::open_timestamps(Utc::now(), "");
588
589        let result = client.verify_timestamp(&timestamp, &doc_id).unwrap();
590        assert!(!result.valid);
591        assert_eq!(result.status, VerificationStatus::Invalid);
592    }
593
594    #[test]
595    fn test_verify_basic_proof() {
596        let client = OtsClient::new();
597        let doc_id = Hasher::hash(HashAlgorithm::Sha256, b"test");
598        let timestamp =
599            TimestampRecord::open_timestamps(Utc::now(), BASE64.encode(b"some proof data"));
600
601        let result = client.verify_timestamp(&timestamp, &doc_id).unwrap();
602        assert!(result.valid);
603        assert_eq!(result.status, VerificationStatus::Pending);
604    }
605}