Skip to main content

cortex_ledger/external_sink/ots/
mod.rs

1//! OpenTimestamps proof parser quarantine boundary (operator decisions
2//! #3 + #4, ADR 0013 Gate 4).
3//!
4//! This module is the **only** place in cortex that may reference the
5//! `opentimestamps` crate. The quarantine rule is enforced by code review
6//! plus the doctrine note in the workspace `Cargo.toml`: any direct
7//! `use opentimestamps::...` outside [`DefaultOtsParser`] is a doctrine
8//! violation.
9//!
10//! Why a trait wrapper:
11//!
12//! 1. **Hostile-upstream containment.** The `opentimestamps` crate is low
13//!    maintenance (last meaningful release April 2023). The IANA-registered
14//!    wire format is stable because it is rooted in Bitcoin consensus, but a
15//!    future malicious or buggy release could try to widen what counts as
16//!    a valid attestation. The trait wrapper enforces a **tag whitelist**
17//!    before the parsed result reaches any trust-path consumer, so
18//!    extension tags are mechanically refused regardless of what upstream
19//!    accepts.
20//! 2. **Test substitutability.** [`OtsParser`] makes the live adapter
21//!    drop-in replaceable for the verifier so unit tests do not need the
22//!    network or a Bitcoin block-header fixture.
23//!
24//! Tag whitelist (hard rule, no operator override):
25//!
26//! - Bitcoin attestation tag: `\x05\x88\x96\x0d\x73\xd7\x19\x01`
27//! - Pending attestation tag: `\x83\xdf\xe3\x0d\x2e\xf9\x0c\x8e`
28//!
29//! Any other 8-byte tag (the `opentimestamps` crate models these as
30//! `Attestation::Unknown { tag, data }`) is mapped to
31//! [`OtsError::UnknownTag`] and fails closed. This blocks the
32//! "attack via extension tag" vector before it can reach the trust path.
33
34pub mod adapter;
35
36use std::io::Cursor;
37
38use chrono::{DateTime, Utc};
39
40// Quarantine import: this is the only `use opentimestamps::...` line in
41// the entire cortex tree. The trait below is the boundary.
42use opentimestamps::attestation::Attestation as OtsAttestation;
43use opentimestamps::ser::DetachedTimestampFile;
44use opentimestamps::timestamp::{Step, StepData};
45
46/// 8-byte tag stored in front of every OTS attestation record.
47/// `\x05\x88\x96\x0d\x73\xd7\x19\x01` is the Bitcoin attestation tag.
48pub const BITCOIN_ATTESTATION_TAG: [u8; 8] = [0x05, 0x88, 0x96, 0x0d, 0x73, 0xd7, 0x19, 0x01];
49
50/// 8-byte tag stored in front of every OTS attestation record.
51/// `\x83\xdf\xe3\x0d\x2e\xf9\x0c\x8e` is the Pending (calendar) tag.
52pub const PENDING_ATTESTATION_TAG: [u8; 8] = [0x83, 0xdf, 0xe3, 0x0d, 0x2e, 0xf9, 0x0c, 0x8e];
53
54/// Stable invariant emitted when a Pending OTS proof is accepted but has
55/// not yet upgraded to a Bitcoin attestation. Surfaced verbatim by the
56/// CLI and the live adapter so downstream consumers can grep for the
57/// exact token rather than parsing prose.
58pub const OTS_PENDING_NO_BITCOIN_ATTESTATION_YET_INVARIANT: &str =
59    "ots.pending.no_bitcoin_attestation_yet";
60
61/// Stable invariant emitted when a Bitcoin-confirmed OTS proof cites a
62/// block header whose stored bytes do not match the operator-supplied
63/// (or RPC-fetched) header.
64pub const OTS_BITCOIN_CONFIRMED_BLOCK_HEADER_MISMATCH_INVARIANT: &str =
65    "ots.bitcoin_confirmed.block_header_mismatch";
66
67/// Stable invariant emitted when a Bitcoin-confirmed OTS proof's
68/// commitment-op chain does not recompute to the attested merkle leaf.
69pub const OTS_BITCOIN_CONFIRMED_MERKLE_PATH_INVALID_INVARIANT: &str =
70    "ots.bitcoin_confirmed.merkle_path_invalid";
71
72/// Stable invariant emitted when the OTS proof carries an attestation
73/// tag that is not on the whitelist. Hard rule: no operator override.
74pub const OTS_TAG_WHITELIST_UNKNOWN_TAG_INVARIANT: &str = "ots.tag_whitelist.unknown_tag";
75
76/// Stable invariant emitted when a receipt that would otherwise promote
77/// to `FullChainVerified` is held at `Partial` because the receipt
78/// history did not present at least two witnesses drawn from disjoint
79/// administrative authorities. Council 2026-05-12 Decision Q1
80/// (UNANIMOUS); hard rule — no operator override path is authorized.
81pub const OTS_DISJOINT_AUTHORITY_QUORUM_NOT_MET_INVARIANT: &str =
82    "ots.disjoint_authority.quorum_not_met";
83
84/// Stable invariant emitted by the HTTPS Bitcoin header transport when
85/// two or more reachable providers returned non-byte-identical header
86/// bytes for the same block height. Council 2026-05-12 Decision Q3
87/// (UNANIMOUS): provider disagreement is the structural defense
88/// against a withholding / stale-tip attack and MUST fail closed.
89pub const OTS_BITCOIN_HEADER_QUORUM_PROVIDERS_DISAGREE_INVARIANT: &str =
90    "ots.bitcoin_header_quorum.providers_disagree";
91
92/// Stable invariant emitted by the HTTPS Bitcoin header transport when
93/// fewer than the required number of providers were reachable for a
94/// given block height (default `N = 2`). Council 2026-05-12 Decision
95/// Q3 (UNANIMOUS).
96pub const OTS_BITCOIN_HEADER_QUORUM_UNREACHABLE_INVARIANT: &str =
97    "ots.bitcoin_header_quorum.unreachable";
98
99/// Stable invariant emitted when a quorum-fetched Bitcoin header fails
100/// local proof-of-work verification (SHA-256d of the 80-byte header is
101/// not ≤ the `nBits`-encoded target). Council 2026-05-12 Decision Q3
102/// requires local PoW verify on top of quorum.
103pub const OTS_BITCOIN_HEADER_POW_INVALID_INVARIANT: &str = "ots.bitcoin_header_quorum.pow_invalid";
104
105/// Hex-encode a byte slice into lowercase ASCII without bringing in an
106/// extra crate. Kept module-private because the rest of the ledger uses
107/// the `sha256_hex` helper for SHA-256 hex and `blake3` for chain hashes.
108fn hex_lower(bytes: &[u8]) -> String {
109    const HEX: &[u8; 16] = b"0123456789abcdef";
110    let mut out = String::with_capacity(bytes.len() * 2);
111    for byte in bytes {
112        out.push(HEX[(byte >> 4) as usize] as char);
113        out.push(HEX[(byte & 0x0f) as usize] as char);
114    }
115    out
116}
117
118/// Trait wrapper around the `opentimestamps` crate. Any direct use of
119/// `opentimestamps::*` outside this module is a doctrine violation.
120///
121/// Implementors MUST enforce the tag whitelist defined by
122/// [`BITCOIN_ATTESTATION_TAG`] and [`PENDING_ATTESTATION_TAG`]. Anything
123/// else MUST surface as [`OtsError::UnknownTag`].
124pub trait OtsParser {
125    /// Parse a raw `.ots` binary proof and return the typed shape. The
126    /// caller is responsible for supplying the bytes; this trait never
127    /// touches the network or the filesystem.
128    fn parse(&self, bytes: &[u8]) -> Result<TypedOtsProof, OtsError>;
129}
130
131/// Typed result of [`OtsParser::parse`]. Operators key trust decisions on
132/// this variant; any other tag in the file fails closed at [`OtsError::UnknownTag`].
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub enum TypedOtsProof {
135    /// Proof was accepted by a calendar but Bitcoin has not yet rolled the
136    /// commitment into a block. ALWAYS maps to
137    /// [`crate::external_sink::ots::adapter::OtsVerificationOutcome::Partial`] —
138    /// never `FullChainVerified`.
139    Pending {
140        /// Calendar URI advertised inside the Pending attestation.
141        calendar_url: String,
142        /// Operator-recorded submission timestamp from the receipt
143        /// envelope. The OTS binary format does NOT carry its own
144        /// timestamp; this is propagated by the live adapter.
145        submitted_at: DateTime<Utc>,
146    },
147    /// Proof was upgraded to a Bitcoin block attestation. Trust still
148    /// depends on a Bitcoin block-header cross-check, performed by the
149    /// adapter not by this trait.
150    BitcoinConfirmed {
151        /// Bitcoin block height the attestation cites.
152        block_height: u64,
153        /// Lowercase hex digest the commitment-op chain produces. The
154        /// adapter compares this against the block-header merkle root
155        /// (after applying the leaf-extraction rule) — a mismatch maps
156        /// to [`OTS_BITCOIN_CONFIRMED_MERKLE_PATH_INVALID_INVARIANT`].
157        merkle_path_digest: String,
158        /// Calendar URI the proof was submitted to, if observed in any
159        /// preceding Pending attestation. May be empty when the proof
160        /// was constructed from the upgraded calendar directly.
161        calendar_url: String,
162    },
163}
164
165/// Errors emitted by the [`OtsParser`] trait surface.
166///
167/// Errors are split by **origin**: parser-internal violations
168/// ([`MalformedHeader`], [`UnknownCommitmentOp`], [`EmptyProof`]) versus
169/// **whitelist** violations ([`UnknownTag`]) versus everything else that
170/// surfaced inside the `opentimestamps` crate ([`OtsCrateError`]). The
171/// split matters because [`UnknownTag`] is the hostile-upstream
172/// containment edge — the trait wrapper rejects it even when the
173/// upstream crate accepted the bytes.
174#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
175pub enum OtsError {
176    /// Tag stored in the attestation was not on the whitelist
177    /// (Bitcoin / Pending only).
178    #[error(
179        "{invariant}: unknown OTS attestation tag {tag} (only Bitcoin and Pending tags are accepted)",
180        invariant = OTS_TAG_WHITELIST_UNKNOWN_TAG_INVARIANT,
181        tag = hex_lower(observed),
182    )]
183    UnknownTag {
184        /// Tag bytes as parsed from the file.
185        observed: [u8; 8],
186    },
187    /// Magic, version, or digest-type framing did not parse as a valid
188    /// `DetachedTimestampFile`.
189    #[error("malformed OTS header: {reason}")]
190    MalformedHeader {
191        /// Human-readable parse failure detail.
192        reason: String,
193    },
194    /// A commitment-op tag inside the timestamp walk was not one of the
195    /// upstream-recognized ops. Wrapped here so downstream surfaces see
196    /// a stable type rather than the upstream `Error` variant.
197    #[error("unknown OTS commitment-op tag 0x{tag:02x}")]
198    UnknownCommitmentOp {
199        /// Op tag byte the upstream parser flagged.
200        tag: u8,
201    },
202    /// Input bytes were empty. The wrapper rejects this before invoking
203    /// the upstream parser so the error stays inside the trait surface
204    /// rather than leaking through as an upstream `Io` error.
205    #[error("empty OTS proof: input had zero bytes")]
206    EmptyProof,
207    /// Any other failure surfaced inside the `opentimestamps` crate.
208    /// Wrapped with a stable [`String`] so a hostile upstream cannot
209    /// expand the variant set on us.
210    #[error("upstream opentimestamps parse error: {0}")]
211    OtsCrateError(String),
212}
213
214/// Default [`OtsParser`] implementation. Wraps the upstream
215/// `opentimestamps` crate behind the whitelist.
216#[derive(Debug, Default, Clone, Copy)]
217pub struct DefaultOtsParser;
218
219impl OtsParser for DefaultOtsParser {
220    fn parse(&self, bytes: &[u8]) -> Result<TypedOtsProof, OtsError> {
221        self.parse_with_submitted_at(bytes, Utc::now())
222    }
223}
224
225impl DefaultOtsParser {
226    /// Variant of [`OtsParser::parse`] that lets the live adapter inject
227    /// the operator-recorded submission timestamp instead of `Utc::now`.
228    /// The OTS binary format does not carry its own timestamp, so the
229    /// adapter is responsible for stamping the Pending variant.
230    pub fn parse_with_submitted_at(
231        &self,
232        bytes: &[u8],
233        submitted_at: DateTime<Utc>,
234    ) -> Result<TypedOtsProof, OtsError> {
235        if bytes.is_empty() {
236            return Err(OtsError::EmptyProof);
237        }
238        let cursor = Cursor::new(bytes);
239        let file = DetachedTimestampFile::from_reader(cursor).map_err(map_upstream_error)?;
240
241        // Walk the timestamp tree. Two distinct branches are possible:
242        //   * a chain of `Op` steps terminating in an `Attestation` leaf,
243        //   * a `Fork` step splitting into multiple sub-walks.
244        // We prefer a `Bitcoin` attestation over a `Pending` attestation
245        // when both appear (the upstream crate sometimes carries both:
246        // the original calendar Pending and the later Bitcoin upgrade
247        // appear in different forks of the same file). The walk fails
248        // closed on the first unknown-tag attestation.
249        let attestations = collect_attestations(&file.timestamp.first_step)?;
250        if attestations.is_empty() {
251            return Err(OtsError::MalformedHeader {
252                reason: "OTS proof contained no attestation leaves".to_string(),
253            });
254        }
255
256        // Prefer Bitcoin over Pending — the strongest claim wins.
257        let mut pending_url: Option<String> = None;
258        let mut bitcoin_payload: Option<BitcoinAttestationPayload> = None;
259        for attestation in &attestations {
260            match attestation {
261                CollectedAttestation::Pending { uri } => {
262                    if pending_url.is_none() {
263                        pending_url = Some(uri.clone());
264                    }
265                }
266                CollectedAttestation::Bitcoin { height, output } => {
267                    if bitcoin_payload.is_none() {
268                        bitcoin_payload = Some(BitcoinAttestationPayload {
269                            height: *height,
270                            output: output.clone(),
271                        });
272                    }
273                }
274            }
275        }
276
277        if let Some(payload) = bitcoin_payload {
278            return Ok(TypedOtsProof::BitcoinConfirmed {
279                block_height: payload.height,
280                merkle_path_digest: hex_lower(&payload.output),
281                calendar_url: pending_url.unwrap_or_default(),
282            });
283        }
284
285        // No Bitcoin attestation — must be Pending. `attestations` is
286        // non-empty and contains no unknown tags (the walk would have
287        // returned `UnknownTag` already), so a `None` here means the
288        // file held only `Bitcoin` which is handled above.
289        let calendar_url = pending_url.expect("non-empty attestations without unknown tags");
290        Ok(TypedOtsProof::Pending {
291            calendar_url,
292            submitted_at,
293        })
294    }
295}
296
297/// Internal: leaf attestation collected by [`collect_attestations`].
298/// Restricted to the whitelisted shapes; unknown tags surface as
299/// [`OtsError::UnknownTag`] before construction.
300enum CollectedAttestation {
301    Pending { uri: String },
302    Bitcoin { height: u64, output: Vec<u8> },
303}
304
305struct BitcoinAttestationPayload {
306    height: u64,
307    output: Vec<u8>,
308}
309
310/// Walk the recursive step tree and collect every attestation leaf.
311/// Returns `Err(OtsError::UnknownTag)` the first time the walk lands on
312/// an `Attestation::Unknown { tag, .. }` — fail closed, no recovery.
313fn collect_attestations(root: &Step) -> Result<Vec<CollectedAttestation>, OtsError> {
314    let mut acc = Vec::new();
315    collect_recurse(root, &mut acc)?;
316    Ok(acc)
317}
318
319fn collect_recurse(step: &Step, acc: &mut Vec<CollectedAttestation>) -> Result<(), OtsError> {
320    match &step.data {
321        StepData::Fork => {
322            for next in &step.next {
323                collect_recurse(next, acc)?;
324            }
325        }
326        StepData::Op(_) => {
327            for next in &step.next {
328                collect_recurse(next, acc)?;
329            }
330        }
331        StepData::Attestation(attest) => match attest {
332            OtsAttestation::Pending { uri } => {
333                acc.push(CollectedAttestation::Pending { uri: uri.clone() });
334            }
335            OtsAttestation::Bitcoin { height } => {
336                acc.push(CollectedAttestation::Bitcoin {
337                    // upstream stores as `usize`; widen to `u64` for the
338                    // stable typed surface.
339                    height: *height as u64,
340                    output: step.output.clone(),
341                });
342            }
343            OtsAttestation::Unknown { tag, .. } => {
344                // Tag length is fixed by the upstream parser at 8 bytes,
345                // but we double-check before copying so a future hostile
346                // upstream cannot smuggle a different-length tag past us.
347                let mut observed = [0u8; 8];
348                if tag.len() != observed.len() {
349                    return Err(OtsError::MalformedHeader {
350                        reason: format!(
351                            "unknown OTS attestation tag had unexpected length {} (expected 8)",
352                            tag.len()
353                        ),
354                    });
355                }
356                observed.copy_from_slice(tag);
357                return Err(OtsError::UnknownTag { observed });
358            }
359        },
360    }
361    Ok(())
362}
363
364fn map_upstream_error(err: opentimestamps::error::Error) -> OtsError {
365    use opentimestamps::error::Error as OtsCrateErrorKind;
366    match err {
367        OtsCrateErrorKind::BadMagic(observed) => OtsError::MalformedHeader {
368            reason: format!("bad OTS magic bytes (got {} bytes prefix)", observed.len(),),
369        },
370        OtsCrateErrorKind::BadVersion(version) => OtsError::MalformedHeader {
371            reason: format!("OTS version {version} not understood by this parser"),
372        },
373        OtsCrateErrorKind::BadDigestTag(tag) => OtsError::MalformedHeader {
374            reason: format!("invalid OTS digest tag 0x{tag:02x}"),
375        },
376        OtsCrateErrorKind::BadOpTag(tag) => OtsError::UnknownCommitmentOp { tag },
377        OtsCrateErrorKind::BadLength { min, max, val } => OtsError::MalformedHeader {
378            reason: format!("OTS field length {val} out of range [{min},{max}]"),
379        },
380        OtsCrateErrorKind::TrailingBytes => OtsError::MalformedHeader {
381            reason: "OTS file had trailing bytes after the timestamp body".to_string(),
382        },
383        OtsCrateErrorKind::StackOverflow => OtsError::MalformedHeader {
384            reason: "OTS timestamp recursion exceeded parser depth limit".to_string(),
385        },
386        // Anything else (Utf8 / Io / InvalidUriChar) wraps verbatim so a
387        // stable, typed string reaches the trust path.
388        other => OtsError::OtsCrateError(format!("{other}")),
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    #[test]
397    fn whitelist_tag_constants_match_upstream() {
398        // Upstream private constants are mirrored in our public
399        // whitelist; this test guards against a future upstream change
400        // silently relabeling the tag bytes.
401        assert_eq!(BITCOIN_ATTESTATION_TAG.len(), 8);
402        assert_eq!(PENDING_ATTESTATION_TAG.len(), 8);
403        assert_ne!(BITCOIN_ATTESTATION_TAG, PENDING_ATTESTATION_TAG);
404    }
405
406    #[test]
407    fn empty_bytes_reject_before_upstream_parser() {
408        let parser = DefaultOtsParser;
409        let err = parser.parse(&[]).unwrap_err();
410        assert!(matches!(err, OtsError::EmptyProof));
411    }
412
413    #[test]
414    fn malformed_magic_maps_to_malformed_header() {
415        let parser = DefaultOtsParser;
416        let mut bytes = vec![0xff; 32];
417        bytes[0] = 0x55;
418        let err = parser.parse(&bytes).unwrap_err();
419        assert!(
420            matches!(err, OtsError::MalformedHeader { .. }),
421            "got {err:?}"
422        );
423    }
424
425    #[test]
426    fn hex_lower_round_trips_known_byte_string() {
427        assert_eq!(hex_lower(&[0x00, 0xff, 0x10, 0xab]), "00ff10ab".to_string(),);
428    }
429}