pub mod adapter;
use std::io::Cursor;
use chrono::{DateTime, Utc};
use opentimestamps::attestation::Attestation as OtsAttestation;
use opentimestamps::ser::DetachedTimestampFile;
use opentimestamps::timestamp::{Step, StepData};
pub const BITCOIN_ATTESTATION_TAG: [u8; 8] = [0x05, 0x88, 0x96, 0x0d, 0x73, 0xd7, 0x19, 0x01];
pub const PENDING_ATTESTATION_TAG: [u8; 8] = [0x83, 0xdf, 0xe3, 0x0d, 0x2e, 0xf9, 0x0c, 0x8e];
pub const OTS_PENDING_NO_BITCOIN_ATTESTATION_YET_INVARIANT: &str =
"ots.pending.no_bitcoin_attestation_yet";
pub const OTS_BITCOIN_CONFIRMED_BLOCK_HEADER_MISMATCH_INVARIANT: &str =
"ots.bitcoin_confirmed.block_header_mismatch";
pub const OTS_BITCOIN_CONFIRMED_MERKLE_PATH_INVALID_INVARIANT: &str =
"ots.bitcoin_confirmed.merkle_path_invalid";
pub const OTS_TAG_WHITELIST_UNKNOWN_TAG_INVARIANT: &str = "ots.tag_whitelist.unknown_tag";
pub const OTS_DISJOINT_AUTHORITY_QUORUM_NOT_MET_INVARIANT: &str =
"ots.disjoint_authority.quorum_not_met";
pub const OTS_BITCOIN_HEADER_QUORUM_PROVIDERS_DISAGREE_INVARIANT: &str =
"ots.bitcoin_header_quorum.providers_disagree";
pub const OTS_BITCOIN_HEADER_QUORUM_UNREACHABLE_INVARIANT: &str =
"ots.bitcoin_header_quorum.unreachable";
pub const OTS_BITCOIN_HEADER_POW_INVALID_INVARIANT: &str = "ots.bitcoin_header_quorum.pow_invalid";
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
}
pub trait OtsParser {
fn parse(&self, bytes: &[u8]) -> Result<TypedOtsProof, OtsError>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TypedOtsProof {
Pending {
calendar_url: String,
submitted_at: DateTime<Utc>,
},
BitcoinConfirmed {
block_height: u64,
merkle_path_digest: String,
calendar_url: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum OtsError {
#[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 {
observed: [u8; 8],
},
#[error("malformed OTS header: {reason}")]
MalformedHeader {
reason: String,
},
#[error("unknown OTS commitment-op tag 0x{tag:02x}")]
UnknownCommitmentOp {
tag: u8,
},
#[error("empty OTS proof: input had zero bytes")]
EmptyProof,
#[error("upstream opentimestamps parse error: {0}")]
OtsCrateError(String),
}
#[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 {
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)?;
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(),
});
}
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(),
});
}
let calendar_url = pending_url.expect("non-empty attestations without unknown tags");
Ok(TypedOtsProof::Pending {
calendar_url,
submitted_at,
})
}
}
enum CollectedAttestation {
Pending { uri: String },
Bitcoin { height: u64, output: Vec<u8> },
}
struct BitcoinAttestationPayload {
height: u64,
output: Vec<u8>,
}
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 {
height: *height as u64,
output: step.output.clone(),
});
}
OtsAttestation::Unknown { tag, .. } => {
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(),
},
other => OtsError::OtsCrateError(format!("{other}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn whitelist_tag_constants_match_upstream() {
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(),);
}
}