Skip to main content

continuity_receipt/
anchor.rs

1//! OpenTimestamps proof verification — 0.3 anchor companion tool.
2//!
3//! Mirrors `continuity_receipt/anchor.py`: replay a detached `.ots` proof from
4//! the file digest to every attestation, and verify a Bitcoin attestation
5//! against an 80-byte block header by exact byte equality with the header's
6//! merkle root (bytes 36..68). Header *chain* validation (proof-of-work,
7//! confirmation depth, reorg handling) is out of scope by design: supply the
8//! header from a source you trust and read `status=verified` as "this digest
9//! is the merkle root of that header", not "that header is buried under N
10//! blocks".
11//!
12//! Wire format: python-opentimestamps `DetachedTimestampFile` — magic, uint8
13//! major version, file-hash op tag, digest, then the timestamp tree. Varints
14//! are LEB128 (unsigned little-endian base-128), *not* Bitcoin CompactSize.
15//!
16//! Statuses: `verified` / `unverified` / `mismatch` / `invalid` with
17//! machine-readable codes, exactly as the Python companion.
18
19use std::collections::{BTreeMap, BTreeSet};
20use std::fmt;
21
22use ripemd::Ripemd160;
23use serde_json::{json, Map, Value};
24use sha1::Sha1;
25use sha2::{Digest as _, Sha256};
26
27use crate::verify::receipt_digest;
28
29pub const MAGIC: &[u8] = b"\x00OpenTimestamps\x00\x00Proof\x00\xbf\x89\xe2\xe8\x84\xe8\x92\x94";
30pub const MAJOR_VERSION: u8 = 1;
31
32const MAX_MSG: usize = 4096;
33const MAX_RESULT: usize = 4096;
34const MAX_ATT_PAYLOAD: usize = 8192;
35const MAX_URI: usize = 1000;
36const MAX_NODES: usize = 10_000;
37const MAX_DEPTH: usize = 256;
38const MAX_VARUINT_BYTES: usize = 10;
39
40const KECCAK_TAG: u8 = 0x67;
41const ATT_PENDING: [u8; 8] = [0x83, 0xdf, 0xe3, 0x0d, 0x2e, 0xf9, 0x0c, 0x8e];
42const ATT_BITCOIN: [u8; 8] = [0x05, 0x88, 0x96, 0x0d, 0x73, 0xd7, 0x19, 0x01];
43const ATT_LITECOIN: [u8; 8] = [0x06, 0x86, 0x9a, 0x0d, 0x73, 0xd7, 0x1b, 0x45];
44
45const HEADER_LEN: usize = 80;
46const MERKLE_OFFSET: usize = 36;
47const TIME_OFFSET: usize = 68;
48
49/// Proof-format error; `code` is a machine-readable identifier.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct AnchorError {
52    pub code: String,
53    pub detail: String,
54}
55
56impl AnchorError {
57    fn new(code: &str, detail: impl Into<String>) -> Self {
58        Self {
59            code: code.to_string(),
60            detail: detail.into(),
61        }
62    }
63}
64
65impl fmt::Display for AnchorError {
66    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
67        write!(formatter, "{}: {}", self.code, self.detail)
68    }
69}
70
71impl std::error::Error for AnchorError {}
72
73fn hash_op(tag: u8) -> Option<(&'static str, usize)> {
74    match tag {
75        0x02 => Some(("sha1", 20)),
76        0x03 => Some(("ripemd160", 20)),
77        0x08 => Some(("sha256", 32)),
78        _ => None,
79    }
80}
81
82fn binary_op(tag: u8) -> Option<&'static str> {
83    match tag {
84        0xF0 => Some("append"),
85        0xF1 => Some("prepend"),
86        _ => None,
87    }
88}
89
90fn unary_op(tag: u8) -> Option<&'static str> {
91    match tag {
92        0xF2 => Some("reverse"),
93        0xF3 => Some("hexlify"),
94        _ => None,
95    }
96}
97
98struct Reader<'a> {
99    data: &'a [u8],
100    pos: usize,
101}
102
103impl<'a> Reader<'a> {
104    fn new(data: &'a [u8]) -> Self {
105        Self { data, pos: 0 }
106    }
107
108    fn read(&mut self, count: usize) -> Result<&'a [u8], AnchorError> {
109        if self.pos + count > self.data.len() {
110            return Err(AnchorError::new("truncated", "proof is truncated"));
111        }
112        let out = &self.data[self.pos..self.pos + count];
113        self.pos += count;
114        Ok(out)
115    }
116
117    fn read_varuint(&mut self) -> Result<u64, AnchorError> {
118        let mut value: u64 = 0;
119        let mut shift = 0;
120        for _ in 0..MAX_VARUINT_BYTES {
121            let byte = self.read(1)?[0];
122            value |= u64::from(byte & 0x7F) << shift;
123            if byte & 0x80 == 0 {
124                return Ok(value);
125            }
126            shift += 7;
127        }
128        Err(AnchorError::new("invalid_varuint", "varuint exceeds 10 bytes"))
129    }
130
131    fn read_varbytes(&mut self, max_len: usize, min_len: usize) -> Result<&'a [u8], AnchorError> {
132        let length = usize::try_from(self.read_varuint()?).unwrap_or(usize::MAX);
133        if length > max_len {
134            return Err(AnchorError::new(
135                "invalid_length",
136                format!("varbytes length {length} exceeds {max_len}"),
137            ));
138        }
139        if length < min_len {
140            return Err(AnchorError::new(
141                "invalid_length",
142                format!("varbytes length {length} below {min_len}"),
143            ));
144        }
145        self.read(length)
146    }
147
148    fn assert_eof(&self) -> Result<(), AnchorError> {
149        if self.pos != self.data.len() {
150            return Err(AnchorError::new(
151                "trailing_data",
152                "unexpected bytes after proof node",
153            ));
154        }
155        Ok(())
156    }
157}
158
159/// One parsed attestation leaf.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub enum Attestation {
162    Pending(String),
163    Bitcoin(u64),
164    Litecoin(u64),
165    Unknown { tag: String, payload: String },
166}
167
168impl Attestation {
169    #[must_use]
170    pub fn kind(&self) -> &'static str {
171        match self {
172            Self::Pending(_) => "pending",
173            Self::Bitcoin(_) => "bitcoin",
174            Self::Litecoin(_) => "litecoin",
175            Self::Unknown { .. } => "unknown",
176        }
177    }
178
179    #[must_use]
180    pub fn as_dict(&self) -> Value {
181        match self {
182            Self::Pending(uri) => json!({"kind": "pending", "value": uri}),
183            Self::Bitcoin(height) => json!({"kind": "bitcoin", "value": height}),
184            Self::Litecoin(height) => json!({"kind": "litecoin", "value": height}),
185            Self::Unknown { tag, payload } => {
186                json!({"kind": "unknown", "tag": tag, "payload": payload})
187            }
188        }
189    }
190}
191
192/// Parsed detached proof: version, file-hash op, digest, and leaves.
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct ParsedProof {
195    pub version: u8,
196    pub file_hash_op: String,
197    pub file_digest: Vec<u8>,
198    pub leaves: Vec<(Vec<u8>, Attestation)>,
199}
200
201/// Verification result, mirroring the Python `AnchorResult` JSON shape.
202#[derive(Debug, Clone, PartialEq, Eq)]
203pub struct AnchorResult {
204    pub status: String,
205    pub code: String,
206    pub detail: String,
207    pub file_digest: String,
208    pub attestations: Vec<Value>,
209    pub confirmed: Option<Value>,
210}
211
212impl AnchorResult {
213    fn new(status: &str, code: &str, detail: impl Into<String>) -> Self {
214        Self {
215            status: status.to_string(),
216            code: code.to_string(),
217            detail: detail.into(),
218            file_digest: String::new(),
219            attestations: Vec::new(),
220            confirmed: None,
221        }
222    }
223
224    #[must_use]
225    pub fn ok(&self) -> bool {
226        self.status == "verified"
227    }
228
229    #[must_use]
230    pub fn as_dict(&self) -> Value {
231        let mut out = Map::new();
232        out.insert("status".to_string(), json!(self.status));
233        out.insert("code".to_string(), json!(self.code));
234        out.insert("detail".to_string(), json!(self.detail));
235        out.insert("file_digest".to_string(), json!(self.file_digest));
236        out.insert("attestations".to_string(), json!(self.attestations));
237        if let Some(confirmed) = &self.confirmed {
238            out.insert("confirmed".to_string(), confirmed.clone());
239        }
240        Value::Object(out)
241    }
242}
243
244fn hex_encode(bytes: &[u8]) -> String {
245    let mut out = String::with_capacity(bytes.len() * 2);
246    for byte in bytes {
247        out.push_str(&format!("{byte:02x}"));
248    }
249    out
250}
251
252fn hex_decode(text: &str) -> Option<Vec<u8>> {
253    let bytes = text.as_bytes();
254    if !bytes.len().is_multiple_of(2) {
255        return None;
256    }
257    let mut out = Vec::with_capacity(bytes.len() / 2);
258    let mut index = 0;
259    while index < bytes.len() {
260        let high = (bytes[index] as char).to_digit(16)?;
261        let low = (bytes[index + 1] as char).to_digit(16)?;
262        out.push(((high << 4) | low) as u8);
263        index += 2;
264    }
265    Some(out)
266}
267
268/// Normalize hex / `sha256:<hex>` / `0x`-prefixed text into a raw digest.
269#[must_use]
270pub fn digest_bytes(value: &Value) -> Option<Vec<u8>> {
271    let text = value.as_str()?.trim();
272    let text = match text.split_once(':') {
273        Some(("sha256", rest)) => rest,
274        Some(_) => return None,
275        None => text,
276    };
277    let text = text.strip_prefix("0x").unwrap_or(text);
278    hex_decode(text)
279}
280
281fn apply_op(name: &str, arg: Option<&[u8]>, message: &[u8]) -> Result<Vec<u8>, AnchorError> {
282    if message.len() > MAX_MSG {
283        return Err(AnchorError::new(
284            "message_too_long",
285            format!("operation input exceeds {MAX_MSG} bytes"),
286        ));
287    }
288    let result = match name {
289        "append" => {
290            let mut out = message.to_vec();
291            out.extend_from_slice(arg.unwrap_or(&[]));
292            out
293        }
294        "prepend" => {
295            let mut out = arg.unwrap_or(&[]).to_vec();
296            out.extend_from_slice(message);
297            out
298        }
299        "reverse" => message.iter().rev().copied().collect(),
300        "hexlify" => hex_encode(message).into_bytes(),
301        "sha1" => Sha1::digest(message).to_vec(),
302        "ripemd160" => Ripemd160::digest(message).to_vec(),
303        "sha256" => Sha256::digest(message).to_vec(),
304        other => {
305            return Err(AnchorError::new(
306                "unsupported_op",
307                format!("hash {other} unavailable"),
308            ))
309        }
310    };
311    if result.is_empty() || result.len() > MAX_RESULT {
312        return Err(AnchorError::new(
313            "invalid_result",
314            format!("{name} produced {} bytes", result.len()),
315        ));
316    }
317    Ok(result)
318}
319
320fn parse_attestation(payload_tag: &[u8], payload: &[u8]) -> Result<Attestation, AnchorError> {
321    let mut reader = Reader::new(payload);
322    if payload_tag == ATT_PENDING.as_slice() {
323        let uri = reader.read_varbytes(MAX_URI, 0)?;
324        reader.assert_eof()?;
325        return Ok(Attestation::Pending(
326            String::from_utf8_lossy(uri).into_owned(),
327        ));
328    }
329    if payload_tag == ATT_BITCOIN.as_slice() || payload_tag == ATT_LITECOIN.as_slice() {
330        let height = reader.read_varuint()?;
331        reader.assert_eof()?;
332        return Ok(if payload_tag == ATT_BITCOIN.as_slice() {
333            Attestation::Bitcoin(height)
334        } else {
335            Attestation::Litecoin(height)
336        });
337    }
338    Ok(Attestation::Unknown {
339        tag: hex_encode(payload_tag),
340        payload: hex_encode(payload),
341    })
342}
343
344fn handle_tag(
345    reader: &mut Reader<'_>,
346    tag: &[u8],
347    message: &[u8],
348    leaves: &mut Vec<(Vec<u8>, Attestation)>,
349    nodes: &mut usize,
350    depth: usize,
351) -> Result<(), AnchorError> {
352    if tag == [0x00] {
353        let tag8 = reader.read(8)?.to_vec();
354        let payload = reader.read_varbytes(MAX_ATT_PAYLOAD, 0)?.to_vec();
355        leaves.push((message.to_vec(), parse_attestation(&tag8, &payload)?));
356        return Ok(());
357    }
358    let tag_byte = tag[0];
359    if tag_byte == KECCAK_TAG {
360        return Err(AnchorError::new(
361            "unsupported_op",
362            "keccak256 proofs are not supported",
363        ));
364    }
365    if let Some((name, _)) = hash_op(tag_byte) {
366        let next = apply_op(name, None, message)?;
367        return parse_timestamp(reader, &next, leaves, nodes, depth + 1);
368    }
369    if let Some(name) = binary_op(tag_byte) {
370        let arg = reader.read_varbytes(MAX_RESULT, 1)?.to_vec();
371        let next = apply_op(name, Some(&arg), message)?;
372        return parse_timestamp(reader, &next, leaves, nodes, depth + 1);
373    }
374    if let Some(name) = unary_op(tag_byte) {
375        let next = apply_op(name, None, message)?;
376        return parse_timestamp(reader, &next, leaves, nodes, depth + 1);
377    }
378    Err(AnchorError::new(
379        "unknown_op",
380        format!("unknown operation tag 0x{tag_byte:02x}"),
381    ))
382}
383
384fn parse_timestamp(
385    reader: &mut Reader<'_>,
386    message: &[u8],
387    leaves: &mut Vec<(Vec<u8>, Attestation)>,
388    nodes: &mut usize,
389    depth: usize,
390) -> Result<(), AnchorError> {
391    if depth > MAX_DEPTH {
392        return Err(AnchorError::new(
393            "recursion_limit",
394            "timestamp tree exceeds depth limit",
395        ));
396    }
397    *nodes += 1;
398    if *nodes > MAX_NODES {
399        return Err(AnchorError::new(
400            "too_many_nodes",
401            "timestamp tree exceeds node limit",
402        ));
403    }
404
405    let mut tag = reader.read(1)?;
406    while tag == [0xFF] {
407        let next = reader.read(1)?.to_vec();
408        handle_tag(reader, &next, message, leaves, nodes, depth)?;
409        tag = reader.read(1)?;
410    }
411    handle_tag(reader, tag, message, leaves, nodes, depth)
412}
413
414/// Parse a detached `.ots` proof (magic, version, file-hash op, tree).
415pub fn parse_detached(data: &[u8]) -> Result<ParsedProof, AnchorError> {
416    let mut reader = Reader::new(data);
417    if reader.read(MAGIC.len())? != MAGIC {
418        return Err(AnchorError::new(
419            "bad_magic",
420            "not an OpenTimestamps detached proof",
421        ));
422    }
423    let version = reader.read(1)?[0];
424    if version != MAJOR_VERSION {
425        return Err(AnchorError::new(
426            "unsupported_version",
427            format!("major version {version} not supported"),
428        ));
429    }
430    let hash_tag = reader.read(1)?[0];
431    let Some((file_hash_op, digest_len)) = hash_op(hash_tag) else {
432        return Err(AnchorError::new(
433            "unknown_op",
434            format!("unsupported file hash op 0x{hash_tag:02x}"),
435        ));
436    };
437    let file_digest = reader.read(digest_len)?.to_vec();
438    let mut leaves = Vec::new();
439    let mut nodes = 0usize;
440    parse_timestamp(&mut reader, &file_digest, &mut leaves, &mut nodes, 0)?;
441    reader.assert_eof()?;
442    Ok(ParsedProof {
443        version,
444        file_hash_op: file_hash_op.to_string(),
445        file_digest,
446        leaves,
447    })
448}
449
450/// Verify a detached proof against a digest and optional headers.
451///
452/// `block_headers` maps Bitcoin block height -> 80-byte header. When provided,
453/// a matching-height Bitcoin attestation is checked by merkle-root equality;
454/// other heights are reported but not verified.
455#[must_use]
456pub fn verify_proof(
457    data: &[u8],
458    expected: &Value,
459    block_headers: Option<&Map<String, Value>>,
460) -> AnchorResult {
461    let Some(expected) = digest_bytes(expected) else {
462        return AnchorResult::new(
463            "invalid",
464            "bad_digest",
465            "expected digest is not bytes/hex/sha256:<hex>",
466        );
467    };
468
469    let proof = match parse_detached(data) {
470        Ok(proof) => proof,
471        Err(error) => return AnchorResult::new("invalid", &error.code, error.detail),
472    };
473
474    let file_digest = format!("{}:{}", proof.file_hash_op, hex_encode(&proof.file_digest));
475    if expected.len() != proof.file_digest.len() || expected != proof.file_digest {
476        let mut result = AnchorResult::new(
477            "mismatch",
478            "digest_mismatch",
479            "proof was not created for the expected digest",
480        );
481        result.file_digest = file_digest;
482        return result;
483    }
484
485    let mut headers: BTreeMap<u64, Vec<u8>> = BTreeMap::new();
486    if let Some(supplied) = block_headers {
487        for (height, header) in supplied {
488            let Ok(height) = height.parse::<u64>() else {
489                return AnchorResult::new(
490                    "invalid",
491                    "bad_header",
492                    format!("header height {height:?} is not an integer"),
493                );
494            };
495            let decoded = digest_bytes(header).unwrap_or_default();
496            if decoded.len() != HEADER_LEN {
497                return AnchorResult::new(
498                    "invalid",
499                    "bad_header",
500                    format!("header for height {height} is not 80 bytes"),
501                );
502            }
503            headers.insert(height, decoded);
504        }
505    }
506
507    let attestations: Vec<Value> = proof
508        .leaves
509        .iter()
510        .map(|(_, attestation)| attestation.as_dict())
511        .collect();
512    let bitcoins: Vec<(&Vec<u8>, u64)> = proof
513        .leaves
514        .iter()
515        .filter_map(|(message, attestation)| match attestation {
516            Attestation::Bitcoin(height) => Some((message, *height)),
517            _ => None,
518        })
519        .collect();
520
521    for (message, height) in &bitcoins {
522        let Some(header) = headers.get(height) else {
523            continue;
524        };
525        if message.as_slice() == &header[MERKLE_OFFSET..MERKLE_OFFSET + 32] {
526            let mut time = [0u8; 4];
527            time.copy_from_slice(&header[TIME_OFFSET..TIME_OFFSET + 4]);
528            let confirmed = json!({
529                "block_height": height,
530                "merkle_root": hex_encode(message),
531                "header_time": u32::from_le_bytes(time),
532            });
533            let mut result = AnchorResult::new(
534                "verified",
535                "anchor_verified",
536                format!("digest is the merkle root of Bitcoin block {height}"),
537            );
538            result.file_digest = file_digest;
539            result.attestations = attestations;
540            result.confirmed = Some(confirmed);
541            return result;
542        }
543    }
544
545    if !bitcoins.is_empty() && !headers.is_empty() {
546        let heights = bitcoins
547            .iter()
548            .map(|(_, height)| height.to_string())
549            .collect::<Vec<_>>()
550            .join(", ");
551        let mut result = AnchorResult::new(
552            "mismatch",
553            "header_mismatch",
554            format!("supplied header(s) do not match the proof (attested height(s): {heights})"),
555        );
556        result.file_digest = file_digest;
557        result.attestations = attestations;
558        return result;
559    }
560
561    if !bitcoins.is_empty() {
562        let mut result = AnchorResult::new(
563            "unverified",
564            "anchor_unverified",
565            "proof reaches a Bitcoin attestation; supply the 80-byte block header to confirm",
566        );
567        result.file_digest = file_digest;
568        result.attestations = attestations;
569        return result;
570    }
571
572    let kinds: BTreeSet<&str> = proof
573        .leaves
574        .iter()
575        .map(|(_, attestation)| attestation.kind())
576        .collect();
577    if kinds.contains("pending") {
578        let mut result = AnchorResult::new(
579            "unverified",
580            "anchor_pending",
581            "proof reaches only pending calendar attestations; upgrade it after confirmation",
582        );
583        result.file_digest = file_digest;
584        result.attestations = attestations;
585        return result;
586    }
587    if kinds.contains("litecoin") {
588        let mut result = AnchorResult::new(
589            "unverified",
590            "anchor_unverified",
591            "proof reaches a Litecoin attestation (not checked by this tool)",
592        );
593        result.file_digest = file_digest;
594        result.attestations = attestations;
595        return result;
596    }
597    if !proof.leaves.is_empty() {
598        let mut result = AnchorResult::new(
599            "unverified",
600            "anchor_unknown_type",
601            "proof reaches only unknown attestation types",
602        );
603        result.file_digest = file_digest;
604        result.attestations = attestations;
605        return result;
606    }
607    let mut result = AnchorResult::new("invalid", "anchor_empty", "proof contains no attestations");
608    result.file_digest = file_digest;
609    result
610}
611
612/// Convenience wrapper for raw-byte digests and headers (no JSON ceremony).
613#[must_use]
614pub fn verify_proof_raw(
615    data: &[u8],
616    expected: &[u8],
617    block_headers: &BTreeMap<u64, Vec<u8>>,
618) -> AnchorResult {
619    let headers: Map<String, Value> = block_headers
620        .iter()
621        .map(|(height, header)| (height.to_string(), json!(hex_encode(header))))
622        .collect();
623    verify_proof(data, &json!(hex_encode(expected)), Some(&headers))
624}
625
626/// Receipt digest and anchor binding for `receipt_id` inside a bundle.
627///
628/// Returns `(receipt_digest, binding)` where `binding` is the bundle `anchors`
629/// entry whose `anchor.type` is `opentimestamps`, or `None`.
630pub fn bundle_digest(
631    bundle_path: &str,
632    target: &str,
633) -> Result<(String, Option<Value>), AnchorError> {
634    let text = std::fs::read_to_string(bundle_path)
635        .map_err(|error| AnchorError::new("io_error", error.to_string()))?;
636    let bundle: Value = serde_json::from_str(&text)
637        .map_err(|error| AnchorError::new("io_error", error.to_string()))?;
638    let receipts = bundle
639        .get("receipts")
640        .and_then(Value::as_array)
641        .cloned()
642        .unwrap_or_default();
643    for receipt in &receipts {
644        if receipt.get("receipt_id").and_then(Value::as_str) != Some(target) {
645            continue;
646        }
647        let binding = bundle
648            .get("anchors")
649            .and_then(Value::as_array)
650            .and_then(|anchors| {
651                anchors.iter().find(|anchor| {
652                    anchor.get("target").and_then(Value::as_str) == Some(target)
653                        && anchor
654                            .get("anchor")
655                            .and_then(Value::as_object)
656                            .and_then(|meta| meta.get("type"))
657                            .and_then(Value::as_str)
658                            == Some("opentimestamps")
659                })
660            })
661            .cloned();
662        let object = receipt.as_object().ok_or_else(|| {
663            AnchorError::new("invalid_receipt", "receipt is not an object")
664        })?;
665        let digest = receipt_digest(object).map_err(|error| AnchorError::new("invalid_receipt", error.0))?;
666        return Ok((digest, binding));
667    }
668    Err(AnchorError::new(
669        "unknown_target",
670        format!("no receipt {target:?} in bundle"),
671    ))
672}