use crate::ots::proof::{OtsError, OtsProof, OtsVerification};
use sha2::{Digest, Sha256};
pub const DEFAULT_CALENDAR_SERVERS: &[&str] = &[
"https://a.pool.opentimestamps.org",
"https://b.pool.opentimestamps.org",
"https://a.pool.eternitywall.com",
"https://ots.btc.catallaxy.com",
];
pub struct OtsClient {
calendar_servers: Vec<String>,
}
impl OtsClient {
pub fn new() -> Self {
Self {
calendar_servers: DEFAULT_CALENDAR_SERVERS
.iter()
.map(|s| s.to_string())
.collect(),
}
}
pub fn with_servers(servers: Vec<String>) -> Self {
Self {
calendar_servers: servers,
}
}
pub fn calendar_servers(&self) -> &[String] {
&self.calendar_servers
}
#[cfg(feature = "calendar")]
pub fn stamp_wire(&self, hash: [u8; 32]) -> Result<crate::ots::wire::OtsFile, OtsError> {
let agent = calendar_agent();
let mut last_err = None;
for server in &self.calendar_servers {
let url = format!("{server}/timestamp");
let mut response = agent
.post(&url)
.header("Content-Type", "application/octet-stream")
.send(hash.to_vec())
.map_err(|e| OtsError::CalendarUnreachable(format!("{url}: {e}")))?;
if !response.status().is_success() {
return Err(OtsError::CalendarUnreachable(format!(
"{url} returned {}",
response.status()
)));
}
let mut bytes = Vec::with_capacity(1024);
use std::io::Read;
response
.body_mut()
.as_reader()
.read_to_end(&mut bytes)
.map_err(|e| OtsError::CalendarUnreachable(format!("{url}: body: {e}")))?;
match crate::ots::wire::parse(&hash, &bytes) {
Ok(file) => return Ok(file),
Err(e) => {
last_err = Some(OtsError::InvalidProof(format!("calendar response: {e}")))
}
}
}
Err(last_err.unwrap_or_else(|| {
OtsError::CalendarUnreachable("no calendar servers configured".into())
}))
}
#[cfg(feature = "calendar")]
pub fn upgrade(
&self,
file: &crate::ots::wire::OtsFile,
) -> Result<crate::ots::wire::OtsFile, OtsError> {
let uris: Vec<String> = crate::ots::wire::replay(file)
.map_err(|e| OtsError::InvalidProof(e.to_string()))?
.into_iter()
.filter_map(|(_, a)| match a {
crate::ots::wire::Attestation::Pending(uri) => Some(uri),
_ => None,
})
.collect();
if uris.is_empty() {
return Err(OtsError::InvalidProof(
"no pending calendar attestation to upgrade".into(),
));
}
let digest_hex = hex::encode(&file.digest);
let agent = calendar_agent();
for uri in uris {
let url = format!("{uri}/timestamp/{digest_hex}");
let mut response = agent
.get(&url)
.call()
.map_err(|e| OtsError::CalendarUnreachable(format!("{url}: {e}")))?;
if !response.status().is_success() {
continue;
}
let mut bytes = Vec::with_capacity(1024);
use std::io::Read;
response
.body_mut()
.as_reader()
.read_to_end(&mut bytes)
.map_err(|e| OtsError::CalendarUnreachable(format!("{url}: body: {e}")))?;
if let Ok(upgraded) = crate::ots::wire::parse(&file.digest, &bytes) {
return Ok(upgraded);
}
}
Err(OtsError::CalendarUnreachable(
"no calendar returned an upgraded proof".into(),
))
}
#[cfg(feature = "calendar")]
pub fn verify_wire(
&self,
file: &crate::ots::wire::OtsFile,
) -> Result<crate::ots::wire::OtsWireVerification, OtsError> {
crate::ots::wire::verify(file).map_err(|e| OtsError::InvalidProof(e.to_string()))
}
pub async fn verify<F>(
&self,
proof: &OtsProof,
bitcoin_block_at_height: F,
) -> Result<OtsVerification, OtsError>
where
F: Fn(u32) -> Result<[u8; 32], String>,
{
let _block_hash =
bitcoin_block_at_height(proof.bitcoin_height).map_err(OtsError::BitcoinBackend)?;
let mut current = proof.hash;
for sibling in &proof.merkle_branch {
let mut h = Sha256::new();
h.update(current);
h.update(sibling);
let mut out = [0u8; 32];
out.copy_from_slice(&h.finalize());
let mut h2 = Sha256::new();
h2.update(out);
current.copy_from_slice(&h2.finalize());
}
let valid = !proof.merkle_branch.is_empty() && current == proof.merkle_root;
Ok(OtsVerification {
valid,
bitcoin_height: proof.bitcoin_height,
block_timestamp: None,
})
}
}
#[cfg(feature = "calendar")]
fn calendar_agent() -> ureq::Agent {
let config = ureq::config::Config::builder()
.user_agent("confium-ots/0.8")
.timeout_global(Some(std::time::Duration::from_secs(30)))
.build();
ureq::Agent::new_with_config(config)
}
impl Default for OtsClient {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn client_has_default_servers() {
let client = OtsClient::new();
assert!(!client.calendar_servers().is_empty());
}
#[cfg(feature = "calendar")]
static STUB_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(feature = "calendar")]
fn calendar_stub(port: u16, digest: [u8; 32]) -> std::thread::JoinHandle<()> {
use std::io::{Read as _, Write as _};
std::thread::spawn(move || {
let listener = std::net::TcpListener::bind(("127.0.0.1", port)).unwrap();
let (mut stream, _) = listener.accept().unwrap();
let mut buf = Vec::new();
let mut chunk = [0u8; 512];
loop {
let n = stream.read(&mut chunk).unwrap();
if n == 0 {
break;
}
buf.extend_from_slice(&chunk[..n]);
if let Ok(headers_end) = find_headers_end(&buf) {
let body_len = content_length(&buf[..headers_end]);
if buf.len() >= headers_end + body_len {
break;
}
}
}
let proof = canned_proof(&digest, port);
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
proof.len()
);
stream.write_all(response.as_bytes()).unwrap();
stream.write_all(&proof).unwrap();
std::thread::sleep(std::time::Duration::from_millis(150));
})
}
#[cfg(feature = "calendar")]
fn find_headers_end(buf: &[u8]) -> Result<usize, ()> {
buf.windows(4)
.position(|w| w == b"\r\n\r\n")
.map(|p| p + 4)
.ok_or(())
}
#[cfg(feature = "calendar")]
fn content_length(headers: &[u8]) -> usize {
let text = String::from_utf8_lossy(headers);
for line in text.lines() {
if let Some(v) = line.strip_prefix("Content-Length:") {
return v.trim().parse().unwrap_or(0);
}
}
0
}
#[cfg(feature = "calendar")]
fn canned_proof(digest: &[u8; 32], port: u16) -> Vec<u8> {
let file = crate::ots::wire::OtsFile {
digest: digest.to_vec(),
root: crate::ots::wire::TimestampNode {
attestations: vec![],
ops: vec![(
crate::ots::wire::Op::Sha256,
crate::ots::wire::TimestampNode {
attestations: vec![crate::ots::wire::Attestation::Pending(format!(
"http://127.0.0.1:{port}"
))],
ops: vec![],
},
)],
},
};
crate::ots::wire::serialize(&file).unwrap()
}
#[cfg(feature = "calendar")]
#[test]
fn stamp_wire_round_trips_against_local_calendar() {
let _guard = STUB_LOCK.lock().unwrap();
let probe = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = probe.local_addr().unwrap().port();
drop(probe);
let digest = [7u8; 32];
let handle = calendar_stub(port, digest);
let client = OtsClient::with_servers(vec![format!("http://127.0.0.1:{port}")]);
let file = client.stamp_wire(digest).unwrap();
handle.join().unwrap();
assert_eq!(file.digest, digest.to_vec());
let verification = client.verify_wire(&file).unwrap();
assert!(verification.has_attestation());
assert_eq!(verification.pending.len(), 1);
let (msg, uri) = &verification.pending[0];
assert_eq!(uri, &format!("http://127.0.0.1:{port}"));
use sha2::Digest as _;
let mut h = Sha256::new();
h.update(digest);
assert_eq!(msg, &h.finalize().to_vec());
}
#[cfg(feature = "calendar")]
#[test]
fn stamp_wire_rejects_garbage_response() {
let _guard = STUB_LOCK.lock().unwrap();
use std::io::{Read as _, Write as _};
let probe = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = probe.local_addr().unwrap().port();
drop(probe);
let handle = std::thread::spawn(move || {
let listener = std::net::TcpListener::bind(("127.0.0.1", port)).unwrap();
let (mut stream, _) = listener.accept().unwrap();
let mut buf = Vec::new();
let mut chunk = [0u8; 512];
loop {
let n = stream.read(&mut chunk).unwrap();
if n == 0 {
break;
}
buf.extend_from_slice(&chunk[..n]);
if let Ok(headers_end) = find_headers_end(&buf) {
let body_len = content_length(&buf[..headers_end]);
if buf.len() >= headers_end + body_len {
break;
}
}
}
let body = b"not-an-ots-proof";
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
stream.write_all(response.as_bytes()).unwrap();
stream.write_all(body).unwrap();
std::thread::sleep(std::time::Duration::from_millis(150));
});
let client = OtsClient::with_servers(vec![format!("http://127.0.0.1:{port}")]);
let result = client.stamp_wire([9u8; 32]);
handle.join().unwrap();
assert!(
matches!(result, Err(OtsError::InvalidProof(_))),
"got: {result:?}"
);
}
#[tokio::test]
async fn verify_empty_branch_is_rejected() {
let client = OtsClient::new();
let hash = [1u8; 32];
let proof = OtsProof::new(hash, 800_000);
let result = client.verify(&proof, |_| Ok([0u8; 32])).await.unwrap();
assert!(!result.valid);
}
}