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}