crtx-ledger 0.1.1

Append-only event log, hash chain, trace assembly, and audit records.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
//! OpenTimestamps proof parser quarantine boundary (operator decisions
//! #3 + #4, ADR 0013 Gate 4).
//!
//! This module is the **only** place in cortex that may reference the
//! `opentimestamps` crate. The quarantine rule is enforced by code review
//! plus the doctrine note in the workspace `Cargo.toml`: any direct
//! `use opentimestamps::...` outside [`DefaultOtsParser`] is a doctrine
//! violation.
//!
//! Why a trait wrapper:
//!
//! 1. **Hostile-upstream containment.** The `opentimestamps` crate is low
//!    maintenance (last meaningful release April 2023). The IANA-registered
//!    wire format is stable because it is rooted in Bitcoin consensus, but a
//!    future malicious or buggy release could try to widen what counts as
//!    a valid attestation. The trait wrapper enforces a **tag whitelist**
//!    before the parsed result reaches any trust-path consumer, so
//!    extension tags are mechanically refused regardless of what upstream
//!    accepts.
//! 2. **Test substitutability.** [`OtsParser`] makes the live adapter
//!    drop-in replaceable for the verifier so unit tests do not need the
//!    network or a Bitcoin block-header fixture.
//!
//! Tag whitelist (hard rule, no operator override):
//!
//! - Bitcoin attestation tag: `\x05\x88\x96\x0d\x73\xd7\x19\x01`
//! - Pending attestation tag: `\x83\xdf\xe3\x0d\x2e\xf9\x0c\x8e`
//!
//! Any other 8-byte tag (the `opentimestamps` crate models these as
//! `Attestation::Unknown { tag, data }`) is mapped to
//! [`OtsError::UnknownTag`] and fails closed. This blocks the
//! "attack via extension tag" vector before it can reach the trust path.

pub mod adapter;

use std::io::Cursor;

use chrono::{DateTime, Utc};

// Quarantine import: this is the only `use opentimestamps::...` line in
// the entire cortex tree. The trait below is the boundary.
use opentimestamps::attestation::Attestation as OtsAttestation;
use opentimestamps::ser::DetachedTimestampFile;
use opentimestamps::timestamp::{Step, StepData};

/// 8-byte tag stored in front of every OTS attestation record.
/// `\x05\x88\x96\x0d\x73\xd7\x19\x01` is the Bitcoin attestation tag.
pub const BITCOIN_ATTESTATION_TAG: [u8; 8] = [0x05, 0x88, 0x96, 0x0d, 0x73, 0xd7, 0x19, 0x01];

/// 8-byte tag stored in front of every OTS attestation record.
/// `\x83\xdf\xe3\x0d\x2e\xf9\x0c\x8e` is the Pending (calendar) tag.
pub const PENDING_ATTESTATION_TAG: [u8; 8] = [0x83, 0xdf, 0xe3, 0x0d, 0x2e, 0xf9, 0x0c, 0x8e];

/// Stable invariant emitted when a Pending OTS proof is accepted but has
/// not yet upgraded to a Bitcoin attestation. Surfaced verbatim by the
/// CLI and the live adapter so downstream consumers can grep for the
/// exact token rather than parsing prose.
pub const OTS_PENDING_NO_BITCOIN_ATTESTATION_YET_INVARIANT: &str =
    "ots.pending.no_bitcoin_attestation_yet";

/// Stable invariant emitted when a Bitcoin-confirmed OTS proof cites a
/// block header whose stored bytes do not match the operator-supplied
/// (or RPC-fetched) header.
pub const OTS_BITCOIN_CONFIRMED_BLOCK_HEADER_MISMATCH_INVARIANT: &str =
    "ots.bitcoin_confirmed.block_header_mismatch";

/// Stable invariant emitted when a Bitcoin-confirmed OTS proof's
/// commitment-op chain does not recompute to the attested merkle leaf.
pub const OTS_BITCOIN_CONFIRMED_MERKLE_PATH_INVALID_INVARIANT: &str =
    "ots.bitcoin_confirmed.merkle_path_invalid";

/// Stable invariant emitted when the OTS proof carries an attestation
/// tag that is not on the whitelist. Hard rule: no operator override.
pub const OTS_TAG_WHITELIST_UNKNOWN_TAG_INVARIANT: &str = "ots.tag_whitelist.unknown_tag";

/// Stable invariant emitted when a receipt that would otherwise promote
/// to `FullChainVerified` is held at `Partial` because the receipt
/// history did not present at least two witnesses drawn from disjoint
/// administrative authorities. Council 2026-05-12 Decision Q1
/// (UNANIMOUS); hard rule — no operator override path is authorized.
pub const OTS_DISJOINT_AUTHORITY_QUORUM_NOT_MET_INVARIANT: &str =
    "ots.disjoint_authority.quorum_not_met";

/// Stable invariant emitted by the HTTPS Bitcoin header transport when
/// two or more reachable providers returned non-byte-identical header
/// bytes for the same block height. Council 2026-05-12 Decision Q3
/// (UNANIMOUS): provider disagreement is the structural defense
/// against a withholding / stale-tip attack and MUST fail closed.
pub const OTS_BITCOIN_HEADER_QUORUM_PROVIDERS_DISAGREE_INVARIANT: &str =
    "ots.bitcoin_header_quorum.providers_disagree";

/// Stable invariant emitted by the HTTPS Bitcoin header transport when
/// fewer than the required number of providers were reachable for a
/// given block height (default `N = 2`). Council 2026-05-12 Decision
/// Q3 (UNANIMOUS).
pub const OTS_BITCOIN_HEADER_QUORUM_UNREACHABLE_INVARIANT: &str =
    "ots.bitcoin_header_quorum.unreachable";

/// Stable invariant emitted when a quorum-fetched Bitcoin header fails
/// local proof-of-work verification (SHA-256d of the 80-byte header is
/// not ≤ the `nBits`-encoded target). Council 2026-05-12 Decision Q3
/// requires local PoW verify on top of quorum.
pub const OTS_BITCOIN_HEADER_POW_INVALID_INVARIANT: &str = "ots.bitcoin_header_quorum.pow_invalid";

/// Hex-encode a byte slice into lowercase ASCII without bringing in an
/// extra crate. Kept module-private because the rest of the ledger uses
/// the `sha256_hex` helper for SHA-256 hex and `blake3` for chain hashes.
fn hex_lower(bytes: &[u8]) -> String {
    const HEX: &[u8; 16] = b"0123456789abcdef";
    let mut out = String::with_capacity(bytes.len() * 2);
    for byte in bytes {
        out.push(HEX[(byte >> 4) as usize] as char);
        out.push(HEX[(byte & 0x0f) as usize] as char);
    }
    out
}

/// Trait wrapper around the `opentimestamps` crate. Any direct use of
/// `opentimestamps::*` outside this module is a doctrine violation.
///
/// Implementors MUST enforce the tag whitelist defined by
/// [`BITCOIN_ATTESTATION_TAG`] and [`PENDING_ATTESTATION_TAG`]. Anything
/// else MUST surface as [`OtsError::UnknownTag`].
pub trait OtsParser {
    /// Parse a raw `.ots` binary proof and return the typed shape. The
    /// caller is responsible for supplying the bytes; this trait never
    /// touches the network or the filesystem.
    fn parse(&self, bytes: &[u8]) -> Result<TypedOtsProof, OtsError>;
}

/// Typed result of [`OtsParser::parse`]. Operators key trust decisions on
/// this variant; any other tag in the file fails closed at [`OtsError::UnknownTag`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TypedOtsProof {
    /// Proof was accepted by a calendar but Bitcoin has not yet rolled the
    /// commitment into a block. ALWAYS maps to
    /// [`crate::external_sink::ots::adapter::OtsVerificationOutcome::Partial`] —
    /// never `FullChainVerified`.
    Pending {
        /// Calendar URI advertised inside the Pending attestation.
        calendar_url: String,
        /// Operator-recorded submission timestamp from the receipt
        /// envelope. The OTS binary format does NOT carry its own
        /// timestamp; this is propagated by the live adapter.
        submitted_at: DateTime<Utc>,
    },
    /// Proof was upgraded to a Bitcoin block attestation. Trust still
    /// depends on a Bitcoin block-header cross-check, performed by the
    /// adapter not by this trait.
    BitcoinConfirmed {
        /// Bitcoin block height the attestation cites.
        block_height: u64,
        /// Lowercase hex digest the commitment-op chain produces. The
        /// adapter compares this against the block-header merkle root
        /// (after applying the leaf-extraction rule) — a mismatch maps
        /// to [`OTS_BITCOIN_CONFIRMED_MERKLE_PATH_INVALID_INVARIANT`].
        merkle_path_digest: String,
        /// Calendar URI the proof was submitted to, if observed in any
        /// preceding Pending attestation. May be empty when the proof
        /// was constructed from the upgraded calendar directly.
        calendar_url: String,
    },
}

/// Errors emitted by the [`OtsParser`] trait surface.
///
/// Errors are split by **origin**: parser-internal violations
/// ([`MalformedHeader`], [`UnknownCommitmentOp`], [`EmptyProof`]) versus
/// **whitelist** violations ([`UnknownTag`]) versus everything else that
/// surfaced inside the `opentimestamps` crate ([`OtsCrateError`]). The
/// split matters because [`UnknownTag`] is the hostile-upstream
/// containment edge — the trait wrapper rejects it even when the
/// upstream crate accepted the bytes.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum OtsError {
    /// Tag stored in the attestation was not on the whitelist
    /// (Bitcoin / Pending only).
    #[error(
        "{invariant}: unknown OTS attestation tag {tag} (only Bitcoin and Pending tags are accepted)",
        invariant = OTS_TAG_WHITELIST_UNKNOWN_TAG_INVARIANT,
        tag = hex_lower(observed),
    )]
    UnknownTag {
        /// Tag bytes as parsed from the file.
        observed: [u8; 8],
    },
    /// Magic, version, or digest-type framing did not parse as a valid
    /// `DetachedTimestampFile`.
    #[error("malformed OTS header: {reason}")]
    MalformedHeader {
        /// Human-readable parse failure detail.
        reason: String,
    },
    /// A commitment-op tag inside the timestamp walk was not one of the
    /// upstream-recognized ops. Wrapped here so downstream surfaces see
    /// a stable type rather than the upstream `Error` variant.
    #[error("unknown OTS commitment-op tag 0x{tag:02x}")]
    UnknownCommitmentOp {
        /// Op tag byte the upstream parser flagged.
        tag: u8,
    },
    /// Input bytes were empty. The wrapper rejects this before invoking
    /// the upstream parser so the error stays inside the trait surface
    /// rather than leaking through as an upstream `Io` error.
    #[error("empty OTS proof: input had zero bytes")]
    EmptyProof,
    /// Any other failure surfaced inside the `opentimestamps` crate.
    /// Wrapped with a stable [`String`] so a hostile upstream cannot
    /// expand the variant set on us.
    #[error("upstream opentimestamps parse error: {0}")]
    OtsCrateError(String),
}

/// Default [`OtsParser`] implementation. Wraps the upstream
/// `opentimestamps` crate behind the whitelist.
#[derive(Debug, Default, Clone, Copy)]
pub struct DefaultOtsParser;

impl OtsParser for DefaultOtsParser {
    fn parse(&self, bytes: &[u8]) -> Result<TypedOtsProof, OtsError> {
        self.parse_with_submitted_at(bytes, Utc::now())
    }
}

impl DefaultOtsParser {
    /// Variant of [`OtsParser::parse`] that lets the live adapter inject
    /// the operator-recorded submission timestamp instead of `Utc::now`.
    /// The OTS binary format does not carry its own timestamp, so the
    /// adapter is responsible for stamping the Pending variant.
    pub fn parse_with_submitted_at(
        &self,
        bytes: &[u8],
        submitted_at: DateTime<Utc>,
    ) -> Result<TypedOtsProof, OtsError> {
        if bytes.is_empty() {
            return Err(OtsError::EmptyProof);
        }
        let cursor = Cursor::new(bytes);
        let file = DetachedTimestampFile::from_reader(cursor).map_err(map_upstream_error)?;

        // Walk the timestamp tree. Two distinct branches are possible:
        //   * a chain of `Op` steps terminating in an `Attestation` leaf,
        //   * a `Fork` step splitting into multiple sub-walks.
        // We prefer a `Bitcoin` attestation over a `Pending` attestation
        // when both appear (the upstream crate sometimes carries both:
        // the original calendar Pending and the later Bitcoin upgrade
        // appear in different forks of the same file). The walk fails
        // closed on the first unknown-tag attestation.
        let attestations = collect_attestations(&file.timestamp.first_step)?;
        if attestations.is_empty() {
            return Err(OtsError::MalformedHeader {
                reason: "OTS proof contained no attestation leaves".to_string(),
            });
        }

        // Prefer Bitcoin over Pending — the strongest claim wins.
        let mut pending_url: Option<String> = None;
        let mut bitcoin_payload: Option<BitcoinAttestationPayload> = None;
        for attestation in &attestations {
            match attestation {
                CollectedAttestation::Pending { uri } => {
                    if pending_url.is_none() {
                        pending_url = Some(uri.clone());
                    }
                }
                CollectedAttestation::Bitcoin { height, output } => {
                    if bitcoin_payload.is_none() {
                        bitcoin_payload = Some(BitcoinAttestationPayload {
                            height: *height,
                            output: output.clone(),
                        });
                    }
                }
            }
        }

        if let Some(payload) = bitcoin_payload {
            return Ok(TypedOtsProof::BitcoinConfirmed {
                block_height: payload.height,
                merkle_path_digest: hex_lower(&payload.output),
                calendar_url: pending_url.unwrap_or_default(),
            });
        }

        // No Bitcoin attestation — must be Pending. `attestations` is
        // non-empty and contains no unknown tags (the walk would have
        // returned `UnknownTag` already), so a `None` here means the
        // file held only `Bitcoin` which is handled above.
        let calendar_url = pending_url.expect("non-empty attestations without unknown tags");
        Ok(TypedOtsProof::Pending {
            calendar_url,
            submitted_at,
        })
    }
}

/// Internal: leaf attestation collected by [`collect_attestations`].
/// Restricted to the whitelisted shapes; unknown tags surface as
/// [`OtsError::UnknownTag`] before construction.
enum CollectedAttestation {
    Pending { uri: String },
    Bitcoin { height: u64, output: Vec<u8> },
}

struct BitcoinAttestationPayload {
    height: u64,
    output: Vec<u8>,
}

/// Walk the recursive step tree and collect every attestation leaf.
/// Returns `Err(OtsError::UnknownTag)` the first time the walk lands on
/// an `Attestation::Unknown { tag, .. }` — fail closed, no recovery.
fn collect_attestations(root: &Step) -> Result<Vec<CollectedAttestation>, OtsError> {
    let mut acc = Vec::new();
    collect_recurse(root, &mut acc)?;
    Ok(acc)
}

fn collect_recurse(step: &Step, acc: &mut Vec<CollectedAttestation>) -> Result<(), OtsError> {
    match &step.data {
        StepData::Fork => {
            for next in &step.next {
                collect_recurse(next, acc)?;
            }
        }
        StepData::Op(_) => {
            for next in &step.next {
                collect_recurse(next, acc)?;
            }
        }
        StepData::Attestation(attest) => match attest {
            OtsAttestation::Pending { uri } => {
                acc.push(CollectedAttestation::Pending { uri: uri.clone() });
            }
            OtsAttestation::Bitcoin { height } => {
                acc.push(CollectedAttestation::Bitcoin {
                    // upstream stores as `usize`; widen to `u64` for the
                    // stable typed surface.
                    height: *height as u64,
                    output: step.output.clone(),
                });
            }
            OtsAttestation::Unknown { tag, .. } => {
                // Tag length is fixed by the upstream parser at 8 bytes,
                // but we double-check before copying so a future hostile
                // upstream cannot smuggle a different-length tag past us.
                let mut observed = [0u8; 8];
                if tag.len() != observed.len() {
                    return Err(OtsError::MalformedHeader {
                        reason: format!(
                            "unknown OTS attestation tag had unexpected length {} (expected 8)",
                            tag.len()
                        ),
                    });
                }
                observed.copy_from_slice(tag);
                return Err(OtsError::UnknownTag { observed });
            }
        },
    }
    Ok(())
}

fn map_upstream_error(err: opentimestamps::error::Error) -> OtsError {
    use opentimestamps::error::Error as OtsCrateErrorKind;
    match err {
        OtsCrateErrorKind::BadMagic(observed) => OtsError::MalformedHeader {
            reason: format!("bad OTS magic bytes (got {} bytes prefix)", observed.len(),),
        },
        OtsCrateErrorKind::BadVersion(version) => OtsError::MalformedHeader {
            reason: format!("OTS version {version} not understood by this parser"),
        },
        OtsCrateErrorKind::BadDigestTag(tag) => OtsError::MalformedHeader {
            reason: format!("invalid OTS digest tag 0x{tag:02x}"),
        },
        OtsCrateErrorKind::BadOpTag(tag) => OtsError::UnknownCommitmentOp { tag },
        OtsCrateErrorKind::BadLength { min, max, val } => OtsError::MalformedHeader {
            reason: format!("OTS field length {val} out of range [{min},{max}]"),
        },
        OtsCrateErrorKind::TrailingBytes => OtsError::MalformedHeader {
            reason: "OTS file had trailing bytes after the timestamp body".to_string(),
        },
        OtsCrateErrorKind::StackOverflow => OtsError::MalformedHeader {
            reason: "OTS timestamp recursion exceeded parser depth limit".to_string(),
        },
        // Anything else (Utf8 / Io / InvalidUriChar) wraps verbatim so a
        // stable, typed string reaches the trust path.
        other => OtsError::OtsCrateError(format!("{other}")),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn whitelist_tag_constants_match_upstream() {
        // Upstream private constants are mirrored in our public
        // whitelist; this test guards against a future upstream change
        // silently relabeling the tag bytes.
        assert_eq!(BITCOIN_ATTESTATION_TAG.len(), 8);
        assert_eq!(PENDING_ATTESTATION_TAG.len(), 8);
        assert_ne!(BITCOIN_ATTESTATION_TAG, PENDING_ATTESTATION_TAG);
    }

    #[test]
    fn empty_bytes_reject_before_upstream_parser() {
        let parser = DefaultOtsParser;
        let err = parser.parse(&[]).unwrap_err();
        assert!(matches!(err, OtsError::EmptyProof));
    }

    #[test]
    fn malformed_magic_maps_to_malformed_header() {
        let parser = DefaultOtsParser;
        let mut bytes = vec![0xff; 32];
        bytes[0] = 0x55;
        let err = parser.parse(&bytes).unwrap_err();
        assert!(
            matches!(err, OtsError::MalformedHeader { .. }),
            "got {err:?}"
        );
    }

    #[test]
    fn hex_lower_round_trips_known_byte_string() {
        assert_eq!(hex_lower(&[0x00, 0xff, 0x10, 0xab]), "00ff10ab".to_string(),);
    }
}