Skip to main content

confium_transparency/ots/
wire.rs

1//! OpenTimestamps proof-file wire format — parse, serialize, replay.
2//!
3//! Implements the op-stream format from python-opentimestamps (the
4//! reference implementation): a file header magic, then a recursive
5//! timestamp tree where each node holds attestations and op-edges,
6//! and every edge's child is keyed by the operation's result on the
7//! current message. Verification replays the ops from the stamped
8//! digest; attestations hang off terminal messages.
9//!
10//! Wire details (pinned from the reference; see the audit ledger's
11//! OTS item for the full table):
12//!
13//! - file magic: `\\0OpenTimestamps\\0\\0Proof\\0` + 8 salt bytes
14//!   (bf 89 e2 e8 84 e8 92 94 — the final two were dropped in the
15//!   first transcription; caught by the gem's cross-checked Ruby
16//!   spec), then
17//!   major version `0x01`
18//! - `0xFF` separators precede every tag except the last sibling
19//! - tag `0x00` introduces an attestation: 8-byte tag + payload
20//! - op tags: SHA256 `0x08`, APPEND `0xF0`, PREPEND `0xF1`,
21//!   REVERSE `0xF2`, HEXLIFY `0xF3` (unknown tags are rejected —
22//!   extend the enum when a real proof needs them)
23//! - varuint is unsigned LEB128; varbytes is varuint-length + bytes
24
25use sha2::Digest;
26use sha2::Sha256;
27
28/// Maximum op payload / message length accepted on deserialization —
29/// matches the reference implementation's guard against maliciously
30/// large proofs.
31pub const MAX_RESULT_LENGTH: usize = 8192;
32
33/// Deserialization recursion limit — the reference caps tree depth to
34/// keep hostile inputs from blowing the stack.
35const RECURSION_LIMIT: u32 = 256;
36
37/// File header magic: `\0OpenTimestamps\0\0Proof\0` + 8 salt bytes.
38pub const FILE_MAGIC: [u8; 31] = [
39    0x00, 0x4f, 0x70, 0x65, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, //
40    0x61, 0x6d, 0x70, 0x73, 0x00, 0x00, 0x50, 0x72, 0x6f, 0x6f, 0x66, //
41    0x00, 0xbf, 0x89, 0xe2, 0xe8, 0x84, 0xe8, 0x92, 0x94,
42];
43
44const MAJOR_VERSION: u8 = 0x01;
45
46/// A timestamp operation.
47#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
48pub enum Op {
49    /// SHA-256 digest of the current message.
50    Sha256,
51    /// Append a suffix.
52    Append(Vec<u8>),
53    /// Prepend a prefix.
54    Prepend(Vec<u8>),
55    /// Reverse the message bytes.
56    Reverse,
57    /// Hex-encode the message.
58    Hexlify,
59}
60
61impl Op {
62    fn tag(&self) -> u8 {
63        match self {
64            Self::Sha256 => 0x08,
65            Self::Append(_) => 0xf0,
66            Self::Prepend(_) => 0xf1,
67            Self::Reverse => 0xf2,
68            Self::Hexlify => 0xf3,
69        }
70    }
71
72    /// Apply the operation to `msg`.
73    pub fn apply(&self, msg: &[u8]) -> Result<Vec<u8>, WireError> {
74        match self {
75            Self::Sha256 => {
76                let mut h = Sha256::new();
77                h.update(msg);
78                Ok(h.finalize().to_vec())
79            }
80            Self::Append(suffix) => {
81                let mut out = Vec::with_capacity(msg.len() + suffix.len());
82                out.extend_from_slice(msg);
83                out.extend_from_slice(suffix);
84                Ok(out)
85            }
86            Self::Prepend(prefix) => {
87                let mut out = Vec::with_capacity(msg.len() + prefix.len());
88                out.extend_from_slice(prefix);
89                out.extend_from_slice(msg);
90                Ok(out)
91            }
92            Self::Reverse => {
93                if msg.is_empty() {
94                    return Err(WireError::InvalidMessage(
95                        "cannot reverse an empty message".into(),
96                    ));
97                }
98                Ok(msg.iter().rev().copied().collect())
99            }
100            Self::Hexlify => Ok(hex::encode(msg).into_bytes()),
101        }
102    }
103}
104
105/// An attestation on a terminal message of the proof tree.
106#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
107pub enum Attestation {
108    /// Commitment recorded at a remote calendar for future
109    /// attestation (URI).
110    Pending(String),
111    /// Anchored in the Bitcoin block header chain at a height.
112    BitcoinBlockHeader(u32),
113    /// Anchored in the Litecoin block header chain at a height.
114    LitecoinBlockHeader(u32),
115}
116
117impl Attestation {
118    fn tag(&self) -> [u8; 8] {
119        match self {
120            Self::Pending(_) => [0x83, 0xdf, 0xe3, 0x0d, 0x2e, 0xf9, 0x0c, 0x8e],
121            Self::BitcoinBlockHeader(_) => [0x05, 0x88, 0x96, 0x0d, 0x73, 0xd7, 0x19, 0x01],
122            Self::LitecoinBlockHeader(_) => [0x06, 0x86, 0x9a, 0x0d, 0x73, 0xd7, 0x1b, 0x45],
123        }
124    }
125}
126
127/// One node of the timestamp tree: attestations plus op-edges.
128#[derive(Debug, Clone, Default, PartialEq, Eq)]
129pub struct TimestampNode {
130    /// Attestations on this node's message.
131    pub attestations: Vec<Attestation>,
132    /// Op edges; each child continues from the op's result.
133    pub ops: Vec<(Op, TimestampNode)>,
134}
135
136impl TimestampNode {
137    /// An empty node (no attestations, no ops) cannot be serialized.
138    pub fn is_empty(&self) -> bool {
139        self.attestations.is_empty() && self.ops.is_empty()
140    }
141}
142
143/// A parsed OTS proof file: header + root timestamp node.
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct OtsFile {
146    /// The stamped digest the proof starts from (needed for replay;
147    /// not part of the serialized file).
148    pub digest: Vec<u8>,
149    /// Root node.
150    pub root: TimestampNode,
151}
152
153/// Errors from the wire format layer.
154#[derive(Debug, thiserror::Error)]
155pub enum WireError {
156    /// Malformed bytes.
157    #[error("malformed OTS proof: {0}")]
158    Malformed(String),
159    /// Wrong or unknown tag.
160    #[error("unknown op tag 0x{0:02x}")]
161    UnknownTag(u8),
162    /// Truncated stream.
163    #[error("truncated OTS proof: {0}")]
164    Truncated(String),
165    /// Message invalid for an operation during replay.
166    #[error("invalid message for op: {0}")]
167    InvalidMessage(String),
168    /// Structure too deep.
169    #[error("recursion limit exceeded")]
170    RecursionLimit,
171}
172
173/// Replay the proof tree from `file.digest`, yielding every
174/// `(terminal_message, attestation)` pair.
175///
176/// This is the verification core: an attestation is only meaningful
177/// for the message the op-chain actually computes from the digest —
178/// replay computes those messages.
179pub fn replay(file: &OtsFile) -> Result<Vec<(Vec<u8>, Attestation)>, WireError> {
180    let mut out = Vec::new();
181    replay_node(&file.root, &file.digest, &mut out, 0)?;
182    Ok(out)
183}
184
185fn replay_node(
186    node: &TimestampNode,
187    msg: &[u8],
188    out: &mut Vec<(Vec<u8>, Attestation)>,
189    depth: u32,
190) -> Result<(), WireError> {
191    if depth > RECURSION_LIMIT {
192        return Err(WireError::RecursionLimit);
193    }
194    for attestation in &node.attestations {
195        out.push((msg.to_vec(), attestation.clone()));
196    }
197    for (op, child) in &node.ops {
198        let next = op.apply(msg)?;
199        replay_node(child, &next, out, depth + 1)?;
200    }
201    Ok(())
202}
203
204/// Replay summary: every attestation paired with the terminal
205/// message it actually commits.
206#[derive(Debug, Clone, Default, PartialEq, Eq)]
207pub struct OtsWireVerification {
208    /// Pending calendar attestations: (committed message, URI).
209    pub pending: Vec<(Vec<u8>, String)>,
210    /// Bitcoin header attestations: (committed message, height).
211    pub bitcoin: Vec<(Vec<u8>, u32)>,
212    /// Litecoin header attestations: (committed message, height).
213    pub litecoin: Vec<(Vec<u8>, u32)>,
214}
215
216impl OtsWireVerification {
217    /// Whether the proof carries any attestation at all.
218    pub fn has_attestation(&self) -> bool {
219        !self.pending.is_empty() || !self.bitcoin.is_empty() || !self.litecoin.is_empty()
220    }
221}
222
223/// Replay and classify: partition every attestation by kind, paired
224/// with the message the op-chain computed for it. A proof whose tree
225/// yields no attestations verifies nothing.
226pub fn verify(file: &OtsFile) -> Result<OtsWireVerification, WireError> {
227    let pairs = replay(file)?;
228    let mut out = OtsWireVerification::default();
229    for (msg, attestation) in pairs {
230        match attestation {
231            Attestation::Pending(uri) => out.pending.push((msg, uri)),
232            Attestation::BitcoinBlockHeader(h) => out.bitcoin.push((msg, h)),
233            Attestation::LitecoinBlockHeader(h) => out.litecoin.push((msg, h)),
234        }
235    }
236    Ok(out)
237}
238
239/// Parse an OTS proof file for `digest`.
240pub fn parse(digest: &[u8], bytes: &[u8]) -> Result<OtsFile, WireError> {
241    if bytes.len() < FILE_MAGIC.len() + 1 {
242        return Err(WireError::Truncated("shorter than the file header".into()));
243    }
244    if bytes[..FILE_MAGIC.len()] != FILE_MAGIC {
245        return Err(WireError::Malformed("bad file header magic".into()));
246    }
247    let mut pos = FILE_MAGIC.len();
248    if bytes[pos] != MAJOR_VERSION {
249        return Err(WireError::Malformed(format!(
250            "unsupported major version {}",
251            bytes[pos]
252        )));
253    }
254    pos += 1;
255
256    let mut cursor = Cursor { bytes, pos };
257    let root = parse_node(&mut cursor)?;
258    if cursor.pos != bytes.len() {
259        return Err(WireError::Malformed(format!(
260            "{} trailing bytes after the proof tree",
261            bytes.len() - cursor.pos
262        )));
263    }
264    Ok(OtsFile {
265        digest: digest.to_vec(),
266        root,
267    })
268}
269
270struct Cursor<'a> {
271    bytes: &'a [u8],
272    pos: usize,
273}
274
275impl Cursor<'_> {
276    fn u8(&mut self) -> Result<u8, WireError> {
277        let b = *self
278            .bytes
279            .get(self.pos)
280            .ok_or_else(|| WireError::Truncated("expected a byte".into()))?;
281        self.pos += 1;
282        Ok(b)
283    }
284
285    fn take(&mut self, n: usize) -> Result<&[u8], WireError> {
286        let end = self
287            .pos
288            .checked_add(n)
289            .ok_or_else(|| WireError::Malformed("length overflow".into()))?;
290        let s = self
291            .bytes
292            .get(self.pos..end)
293            .ok_or_else(|| WireError::Truncated(format!("expected {n} bytes")))?;
294        self.pos = end;
295        Ok(s)
296    }
297
298    fn varuint(&mut self) -> Result<u64, WireError> {
299        let mut value: u64 = 0;
300        let mut shift = 0u32;
301        loop {
302            let b = self.u8()?;
303            value |= u64::from(b & 0x7f) << shift;
304            if b & 0x80 == 0 {
305                return Ok(value);
306            }
307            shift += 7;
308            if shift > 63 {
309                return Err(WireError::Malformed("varuint too long".into()));
310            }
311        }
312    }
313
314    fn varbytes(&mut self, max: usize) -> Result<Vec<u8>, WireError> {
315        let len = self.varuint()?;
316        if len > max as u64 {
317            return Err(WireError::Malformed(format!(
318                "payload length {len} exceeds {max}"
319            )));
320        }
321        Ok(self.take(len as usize)?.to_vec())
322    }
323}
324
325fn parse_node(cur: &mut Cursor<'_>) -> Result<TimestampNode, WireError> {
326    parse_node_depth(cur, 0)
327}
328
329fn parse_node_depth(cur: &mut Cursor<'_>, depth: u32) -> Result<TimestampNode, WireError> {
330    if depth > RECURSION_LIMIT {
331        return Err(WireError::RecursionLimit);
332    }
333    let mut node = TimestampNode::default();
334
335    let mut tag = cur.u8()?;
336    while tag == 0xff {
337        // Separator: the following tag is a non-last sibling.
338        tag = cur.u8()?;
339        apply_tag(cur, &mut node, tag, depth)?;
340        tag = cur.u8()?;
341    }
342    apply_tag(cur, &mut node, tag, depth)?;
343    Ok(node)
344}
345
346fn apply_tag(
347    cur: &mut Cursor<'_>,
348    node: &mut TimestampNode,
349    tag: u8,
350    depth: u32,
351) -> Result<(), WireError> {
352    match tag {
353        0x00 => {
354            node.attestations.push(parse_attestation(cur)?);
355        }
356        0x08 => {
357            let child = parse_node_depth(cur, depth + 1)?;
358            node.ops.push((Op::Sha256, child));
359        }
360        0xf0 => {
361            let arg = cur.varbytes(MAX_RESULT_LENGTH)?;
362            if arg.is_empty() {
363                return Err(WireError::Malformed("append arg can't be empty".into()));
364            }
365            let child = parse_node_depth(cur, depth + 1)?;
366            node.ops.push((Op::Append(arg), child));
367        }
368        0xf1 => {
369            let arg = cur.varbytes(MAX_RESULT_LENGTH)?;
370            if arg.is_empty() {
371                return Err(WireError::Malformed("prepend arg can't be empty".into()));
372            }
373            let child = parse_node_depth(cur, depth + 1)?;
374            node.ops.push((Op::Prepend(arg), child));
375        }
376        0xf2 => {
377            let child = parse_node_depth(cur, depth + 1)?;
378            node.ops.push((Op::Reverse, child));
379        }
380        0xf3 => {
381            let child = parse_node_depth(cur, depth + 1)?;
382            node.ops.push((Op::Hexlify, child));
383        }
384        other => return Err(WireError::UnknownTag(other)),
385    }
386    Ok(())
387}
388
389fn parse_attestation(cur: &mut Cursor<'_>) -> Result<Attestation, WireError> {
390    let tag: [u8; 8] = cur.take(8)?.try_into().expect("take(8) yields 8 bytes");
391    match tag {
392        [0x83, 0xdf, 0xe3, 0x0d, 0x2e, 0xf9, 0x0c, 0x8e] => {
393            let uri = cur.varbytes(1000)?;
394            let uri = String::from_utf8(uri)
395                .map_err(|_| WireError::Malformed("pending attestation URI is not UTF-8".into()))?;
396            Ok(Attestation::Pending(uri))
397        }
398        [0x05, 0x88, 0x96, 0x0d, 0x73, 0xd7, 0x19, 0x01] => {
399            let h = cur.take(4)?;
400            Ok(Attestation::BitcoinBlockHeader(u32::from_le_bytes(
401                h.try_into().expect("take(4) yields 4 bytes"),
402            )))
403        }
404        [0x06, 0x86, 0x9a, 0x0d, 0x73, 0xd7, 0x1b, 0x45] => {
405            let h = cur.take(4)?;
406            Ok(Attestation::LitecoinBlockHeader(u32::from_le_bytes(
407                h.try_into().expect("take(4) yields 4 bytes"),
408            )))
409        }
410        _ => Err(WireError::UnknownTag(tag[0])),
411    }
412}
413
414/// Serialize an OTS proof file (canonical ordering: attestations
415/// sorted, ops sorted by tag).
416pub fn serialize(file: &OtsFile) -> Result<Vec<u8>, WireError> {
417    let mut out = Vec::new();
418    out.extend_from_slice(&FILE_MAGIC);
419    out.push(MAJOR_VERSION);
420    serialize_node(&file.root, &mut out)?;
421    Ok(out)
422}
423
424fn serialize_node(node: &TimestampNode, out: &mut Vec<u8>) -> Result<(), WireError> {
425    if node.is_empty() {
426        return Err(WireError::Malformed(
427            "an empty timestamp node can't be serialized".into(),
428        ));
429    }
430    let mut attestations = node.attestations.clone();
431    attestations.sort();
432
433    let mut ops = node.ops.clone();
434    ops.sort_by(|a, b| a.0.cmp(&b.0));
435
436    let total = attestations.len() + ops.len();
437    let mut emitted = 0usize;
438
439    for attestation in &attestations {
440        if emitted + 1 < total {
441            out.extend_from_slice(&[0xff]);
442        }
443        out.push(0x00);
444        serialize_attestation(attestation, out);
445        emitted += 1;
446    }
447    for (op, child) in &ops {
448        if emitted + 1 < total {
449            out.extend_from_slice(&[0xff]);
450        }
451        serialize_op(op, out);
452        serialize_node(child, out)?;
453        emitted += 1;
454    }
455    Ok(())
456}
457
458fn serialize_op(op: &Op, out: &mut Vec<u8>) {
459    match op {
460        Op::Sha256 | Op::Reverse | Op::Hexlify => out.push(op.tag()),
461        Op::Append(arg) | Op::Prepend(arg) => {
462            out.push(op.tag());
463            write_varbytes(arg, out);
464        }
465    }
466}
467
468fn serialize_attestation(attestation: &Attestation, out: &mut Vec<u8>) {
469    match attestation {
470        Attestation::Pending(uri) => {
471            out.extend_from_slice(&attestation.tag());
472            write_varbytes(uri.as_bytes(), out);
473        }
474        Attestation::BitcoinBlockHeader(h) | Attestation::LitecoinBlockHeader(h) => {
475            out.extend_from_slice(&attestation.tag());
476            out.extend_from_slice(&h.to_le_bytes());
477        }
478    }
479}
480
481fn write_varbytes(bytes: &[u8], out: &mut Vec<u8>) {
482    write_varuint(bytes.len() as u64, out);
483    out.extend_from_slice(bytes);
484}
485
486fn write_varuint(mut value: u64, out: &mut Vec<u8>) {
487    loop {
488        let mut b = (value & 0x7f) as u8;
489        value >>= 7;
490        if value != 0 {
491            b |= 0x80;
492        }
493        out.push(b);
494        if value == 0 {
495            return;
496        }
497    }
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503
504    pub(super) fn digest(b: u8) -> Vec<u8> {
505        vec![b; 32]
506    }
507
508    #[test]
509    fn round_trip_sha256_append_pending() {
510        let file = OtsFile {
511            digest: digest(1),
512            root: TimestampNode {
513                attestations: vec![],
514                ops: vec![(
515                    Op::Sha256,
516                    TimestampNode {
517                        attestations: vec![],
518                        ops: vec![(
519                            Op::Append(vec![0xaa, 0xbb]),
520                            TimestampNode {
521                                attestations: vec![Attestation::Pending(
522                                    "https://alice.btc.calendar.opentimestamps.org".into(),
523                                )],
524                                ops: vec![],
525                            },
526                        )],
527                    },
528                )],
529            },
530        };
531        let bytes = serialize(&file).unwrap();
532        let parsed = parse(&digest(1), &bytes).unwrap();
533        assert_eq!(parsed, file);
534
535        let pairs = replay(&file).unwrap();
536        assert_eq!(pairs.len(), 1);
537        let (msg, att) = &pairs[0];
538        assert!(matches!(att, Attestation::Pending(_)));
539        // The op chain is sha256, then append: the attested message is
540        // sha256(digest) || aabb.
541        let mut h = Sha256::new();
542        h.update(digest(1));
543        let mut expected = h.finalize().to_vec();
544        expected.extend_from_slice(&[0xaa, 0xbb]);
545        assert_eq!(msg, &expected);
546    }
547
548    #[test]
549    fn round_trip_multiple_siblings_and_bitcoin_attestation() {
550        let file = OtsFile {
551            digest: digest(2),
552            root: TimestampNode {
553                attestations: vec![Attestation::BitcoinBlockHeader(800_123)],
554                // Ops in canonical (tag) order: Prepend (0xf1) sorts
555                // before Reverse (0xf2), so construct the tree sorted
556                // to compare with the parsed form.
557                ops: vec![
558                    (
559                        Op::Prepend(vec![0x01]),
560                        TimestampNode {
561                            attestations: vec![],
562                            ops: vec![(
563                                Op::Hexlify,
564                                TimestampNode {
565                                    attestations: vec![Attestation::Pending(
566                                        "https://hex.cal".into(),
567                                    )],
568                                    ops: vec![],
569                                },
570                            )],
571                        },
572                    ),
573                    (
574                        Op::Reverse,
575                        TimestampNode {
576                            attestations: vec![Attestation::Pending("https://example.org".into())],
577                            ops: vec![],
578                        },
579                    ),
580                ],
581            },
582        };
583        let bytes = serialize(&file).unwrap();
584        let parsed = parse(&digest(2), &bytes).unwrap();
585        assert_eq!(parsed, file);
586
587        let pairs = replay(&file).unwrap();
588        // root attestation + reverse-child + hexlify-child
589        assert_eq!(pairs.len(), 3);
590        assert!(
591            pairs
592                .iter()
593                .any(|(_, a)| matches!(a, Attestation::BitcoinBlockHeader(800_123)))
594        );
595        // hexlify child message is hex(0x01 || digest)
596        let hex_msg = pairs
597            .iter()
598            .map(|(m, _)| m)
599            .find(|m| m.len() == 66)
600            .expect("hexlified message present");
601        let mut expected = vec![0x01];
602        expected.extend(digest(2));
603        assert_eq!(hex_msg, hex::encode(expected).as_bytes());
604    }
605
606    #[test]
607    fn file_magic_is_the_canonical_31_bytes() {
608        // python-opentimestamps HEADER_MAGIC:
609        // b'\0OpenTimestamps\0\0Proof\0\xbf\x89\xe2\xe8\x84\xe8\x92\x94'
610        assert_eq!(FILE_MAGIC.len(), 31);
611        assert_eq!(
612            &FILE_MAGIC[23..],
613            &[0xbf, 0x89, 0xe2, 0xe8, 0x84, 0xe8, 0x92, 0x94]
614        );
615    }
616
617    #[test]
618    fn varuint_leb128_round_trip() {
619        for value in [0u64, 1, 127, 128, 300, 8192, 1 << 20, u32::MAX as u64] {
620            let mut buf = Vec::new();
621            write_varuint(value, &mut buf);
622            let mut cur = Cursor {
623                bytes: &buf,
624                pos: 0,
625            };
626            assert_eq!(cur.varuint().unwrap(), value);
627        }
628    }
629
630    #[test]
631    fn rejects_bad_magic() {
632        let mut bytes = Vec::new();
633        bytes.extend_from_slice(&FILE_MAGIC);
634        bytes.push(MAJOR_VERSION);
635        bytes.push(0x00); // attestation tag start
636        bytes.extend_from_slice(&[0x83, 0xdf, 0xe3, 0x0d, 0x2e, 0xf9, 0x0c, 0x8e]);
637        write_varbytes(b"https://example.org", &mut bytes);
638
639        let mut bad = bytes.clone();
640        bad[0] = 0x01;
641        assert!(matches!(
642            parse(&digest(9), &bad),
643            Err(WireError::Malformed(_))
644        ));
645        assert!(parse(&digest(9), &bytes).is_ok());
646    }
647
648    #[test]
649    fn rejects_truncated_stream() {
650        let bytes = {
651            let mut b = Vec::new();
652            b.extend_from_slice(&FILE_MAGIC);
653            b.push(MAJOR_VERSION);
654            b.push(0x08); // sha256 op, then missing child
655            b
656        };
657        assert!(matches!(
658            parse(&digest(9), &bytes),
659            Err(WireError::Truncated(_))
660        ));
661    }
662
663    #[test]
664    fn rejects_unknown_op_tag() {
665        let mut bytes = Vec::new();
666        bytes.extend_from_slice(&FILE_MAGIC);
667        bytes.push(MAJOR_VERSION);
668        bytes.push(0x67); // keccak256 — not in this implementation's subset
669        assert!(matches!(
670            parse(&digest(9), &bytes),
671            Err(WireError::UnknownTag(0x67))
672        ));
673    }
674
675    #[test]
676    fn rejects_trailing_bytes() {
677        let mut bytes = Vec::new();
678        bytes.extend_from_slice(&FILE_MAGIC);
679        bytes.push(MAJOR_VERSION);
680        bytes.push(0x00);
681        bytes.extend_from_slice(&[0x83, 0xdf, 0xe3, 0x0d, 0x2e, 0xf9, 0x0c, 0x8e]);
682        write_varbytes(b"https://example.org", &mut bytes);
683        bytes.push(0xff);
684        assert!(matches!(
685            parse(&digest(9), &bytes),
686            Err(WireError::Malformed(_))
687        ));
688    }
689
690    #[test]
691    fn rejects_oversized_append_payload() {
692        let mut bytes = Vec::new();
693        bytes.extend_from_slice(&FILE_MAGIC);
694        bytes.push(MAJOR_VERSION);
695        bytes.push(0xf0);
696        write_varbytes(&vec![0u8; MAX_RESULT_LENGTH + 1], &mut bytes);
697        assert!(matches!(
698            parse(&digest(9), &bytes),
699            Err(WireError::Malformed(_))
700        ));
701    }
702
703    #[test]
704    fn replay_diverges_on_tampered_op() {
705        let file = OtsFile {
706            digest: digest(5),
707            root: TimestampNode {
708                attestations: vec![],
709                ops: vec![(
710                    Op::Append(vec![0x01]),
711                    TimestampNode {
712                        attestations: vec![Attestation::BitcoinBlockHeader(1)],
713                        ops: vec![],
714                    },
715                )],
716            },
717        };
718        let pairs = replay(&file).unwrap();
719        let (msg, _) = &pairs[0];
720
721        let tampered = OtsFile {
722            digest: digest(6), // different digest
723            ..file.clone()
724        };
725        let pairs2 = replay(&tampered).unwrap();
726        assert_ne!(msg, &pairs2[0].0);
727    }
728
729    #[test]
730    fn empty_node_cannot_serialize() {
731        let file = OtsFile {
732            digest: digest(1),
733            root: TimestampNode::default(),
734        };
735        assert!(matches!(serialize(&file), Err(WireError::Malformed(_))));
736    }
737}
738
739#[cfg(test)]
740mod adversarial_tests {
741    //! Paired rejects-forgery tests for replay-based verification.
742
743    use super::tests::digest;
744    use super::*;
745
746    #[test]
747    fn replay_rejects_reverse_of_empty_intermediate() {
748        // append(0x01) -> hexlify ("" cannot happen from 33-byte input,
749        // so build reverse directly over an empty message via a
750        // handcrafted tree: root has Reverse with empty digest input.
751        let file = OtsFile {
752            digest: Vec::new(),
753            root: TimestampNode {
754                attestations: vec![],
755                ops: vec![(
756                    Op::Reverse,
757                    TimestampNode {
758                        attestations: vec![Attestation::Pending("https://x".into())],
759                        ops: vec![],
760                    },
761                )],
762            },
763        };
764        assert!(matches!(replay(&file), Err(WireError::InvalidMessage(_))));
765    }
766
767    #[test]
768    fn parse_rejects_empty_append_argument() {
769        let mut bytes = Vec::new();
770        bytes.extend_from_slice(&FILE_MAGIC);
771        bytes.push(MAJOR_VERSION);
772        bytes.push(0xf0);
773        write_varbytes(b"", &mut bytes); // zero-length arg — invalid
774        bytes.push(0x00);
775        assert!(matches!(
776            parse(&digest(9), &bytes),
777            Err(WireError::Malformed(_))
778        ));
779    }
780}