use chrono::{DateTime, Utc};
use serde_json::json;
use super::{
DefaultOtsParser, OtsError, OtsParser, TypedOtsProof,
OTS_BITCOIN_CONFIRMED_BLOCK_HEADER_MISMATCH_INVARIANT,
OTS_BITCOIN_CONFIRMED_MERKLE_PATH_INVALID_INVARIANT, OTS_BITCOIN_HEADER_POW_INVALID_INVARIANT,
OTS_BITCOIN_HEADER_QUORUM_PROVIDERS_DISAGREE_INVARIANT,
OTS_BITCOIN_HEADER_QUORUM_UNREACHABLE_INVARIANT,
OTS_DISJOINT_AUTHORITY_QUORUM_NOT_MET_INVARIANT,
OTS_PENDING_NO_BITCOIN_ATTESTATION_YET_INVARIANT,
};
use crate::anchor::LedgerAnchor;
use crate::external_sink::{anchor_text_sha256, ExternalReceipt, ExternalSink};
pub const DEFAULT_OTS_CALENDAR_URL: &str = "https://a.pool.opentimestamps.org";
pub const DEFAULT_OTS_CALENDAR_URLS: &[&str] = &[
"https://a.pool.opentimestamps.org",
"https://alice.btc.calendar.opentimestamps.org",
"https://bob.btc.calendar.opentimestamps.org",
"https://finney.calendar.eternitywall.com",
];
pub const OTS_CALENDAR_OPERATORS: &[(&str, &str)] = &[
("a.pool.opentimestamps.org", "peter_todd"),
("alice.btc.calendar.opentimestamps.org", "peter_todd"),
("bob.btc.calendar.opentimestamps.org", "peter_todd"),
("finney.calendar.eternitywall.com", "eternitywall"),
];
fn calendar_host_lower(url: &str) -> Option<String> {
let after_scheme = {
let idx = url.find("://")?;
let scheme = &url[..idx];
if scheme.is_empty()
|| !scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
|| !scheme
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic())
{
return None;
}
&url[idx + 3..]
};
let authority_end = after_scheme
.find(['/', '?', '#'])
.unwrap_or(after_scheme.len());
let authority = &after_scheme[..authority_end];
if authority.is_empty() {
return None;
}
let host_port = match authority.rfind('@') {
Some(idx) => &authority[idx + 1..],
None => authority,
};
let host = if let Some(stripped) = host_port.strip_prefix('[') {
let close = stripped.find(']')?;
&host_port[..close + 2] } else if let Some(colon) = host_port.rfind(':') {
&host_port[..colon]
} else {
host_port
};
if host.is_empty() {
return None;
}
if host
.chars()
.any(|c| c.is_whitespace() || matches!(c, '/' | '?' | '#' | '@'))
{
return None;
}
Some(host.to_ascii_lowercase())
}
#[must_use]
pub fn calendar_operator(url: &str) -> Option<&'static str> {
let host = calendar_host_lower(url)?;
for (needle, operator) in OTS_CALENDAR_OPERATORS {
debug_assert_eq!(
*needle,
needle.to_ascii_lowercase(),
"OTS_CALENDAR_OPERATORS needles MUST be lower-case to match calendar_host_lower output",
);
if host == *needle {
return Some(*operator);
}
if host.len() > needle.len() + 1
&& host.ends_with(needle)
&& host.as_bytes()[host.len() - needle.len() - 1] == b'.'
{
return Some(*operator);
}
}
None
}
pub trait CalendarClient {
fn submit_digest(&self, calendar_url: &str, digest: &[u8; 32]) -> Result<Vec<u8>, OtsError>;
}
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopCalendarClient;
impl CalendarClient for NoopCalendarClient {
fn submit_digest(&self, calendar_url: &str, _digest: &[u8; 32]) -> Result<Vec<u8>, OtsError> {
Err(OtsError::OtsCrateError(format!(
"no live calendar HTTP client configured for `{calendar_url}` (NoopCalendarClient); \
operator must inject a CalendarClient implementation"
)))
}
}
const UREQ_CALENDAR_CLIENT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
#[derive(Debug, Clone, Copy)]
pub struct UreqCalendarClient {
timeout: std::time::Duration,
}
impl Default for UreqCalendarClient {
fn default() -> Self {
Self::new()
}
}
impl UreqCalendarClient {
#[must_use]
pub const fn new() -> Self {
Self {
timeout: UREQ_CALENDAR_CLIENT_TIMEOUT,
}
}
#[must_use]
pub const fn with_timeout(mut self, timeout: std::time::Duration) -> Self {
self.timeout = timeout;
self
}
}
impl CalendarClient for UreqCalendarClient {
fn submit_digest(&self, calendar_url: &str, digest: &[u8; 32]) -> Result<Vec<u8>, OtsError> {
let agent = ureq::AgentBuilder::new().timeout(self.timeout).build();
let url = format!("{}/digest", calendar_url.trim_end_matches('/'));
let response = agent
.post(&url)
.set("Content-Type", "application/vnd.opentimestamps.v1")
.send_bytes(digest)
.map_err(|err| OtsError::OtsCrateError(format!("POST {url}: {err}")))?;
let mut bytes = Vec::new();
use std::io::Read as _;
response
.into_reader()
.read_to_end(&mut bytes)
.map_err(|err| OtsError::OtsCrateError(format!("read body {url}: {err}")))?;
Ok(bytes)
}
}
pub fn submit<C>(
anchor: &LedgerAnchor,
calendar_url: &str,
submitted_at: DateTime<Utc>,
client: &C,
) -> Result<ExternalReceipt, OtsError>
where
C: CalendarClient + ?Sized,
{
let anchor_text = anchor.to_anchor_text();
let digest = sha256_bytes(anchor_text.as_bytes());
let ots_bytes = client.submit_digest(calendar_url, &digest)?;
DefaultOtsParser.parse_with_submitted_at(&ots_bytes, submitted_at)?;
let receipt_payload = json!({
"ots_proof_base64": base64_standard(&ots_bytes),
"calendar_url": calendar_url,
"submitted_digest_hex": hex_lower(&digest),
});
Ok(ExternalReceipt {
sink: ExternalSink::OpenTimestamps,
anchor_text_sha256: anchor_text_sha256(anchor),
anchor_event_count: anchor.event_count,
anchor_chain_head_hash: anchor.chain_head_hash.clone(),
submitted_at,
sink_endpoint: calendar_url.to_string(),
receipt: receipt_payload,
})
}
pub trait BitcoinHeaderSource {
fn header_for_height(&self, height: u64) -> Result<Vec<u8>, OtsError>;
}
#[derive(Debug, Default, Clone)]
pub struct StaticBitcoinHeaderSource {
headers: Vec<(u64, Vec<u8>)>,
}
impl StaticBitcoinHeaderSource {
#[must_use]
pub const fn new() -> Self {
Self {
headers: Vec::new(),
}
}
#[must_use]
pub fn with_header(mut self, height: u64, header_bytes: Vec<u8>) -> Self {
self.headers.push((height, header_bytes));
self
}
}
impl BitcoinHeaderSource for StaticBitcoinHeaderSource {
fn header_for_height(&self, height: u64) -> Result<Vec<u8>, OtsError> {
self.headers
.iter()
.find(|(h, _)| *h == height)
.map(|(_, bytes)| bytes.clone())
.ok_or_else(|| {
OtsError::OtsCrateError(format!(
"no operator-supplied Bitcoin block header registered for height {height}"
))
})
}
}
pub const DEFAULT_HTTPS_HEADER_PROVIDERS: &[&str] =
&["https://mempool.space", "https://blockstream.info"];
pub const DEFAULT_HTTPS_HEADER_QUORUM_N: usize = 2;
const HTTPS_HEADER_PROVIDER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
#[derive(Debug, Clone)]
pub struct HttpsHeadersBitcoinHeaderSource {
providers: Vec<String>,
quorum_n: usize,
timeout: std::time::Duration,
}
impl HttpsHeadersBitcoinHeaderSource {
#[must_use]
pub fn new(providers: Vec<String>) -> Self {
Self {
providers,
quorum_n: DEFAULT_HTTPS_HEADER_QUORUM_N,
timeout: HTTPS_HEADER_PROVIDER_TIMEOUT,
}
}
#[must_use]
pub const fn with_quorum_n(mut self, quorum_n: usize) -> Self {
self.quorum_n = quorum_n;
self
}
#[must_use]
pub const fn with_timeout(mut self, timeout: std::time::Duration) -> Self {
self.timeout = timeout;
self
}
#[must_use]
pub fn providers(&self) -> &[String] {
&self.providers
}
#[must_use]
pub const fn quorum_n(&self) -> usize {
self.quorum_n
}
fn fetch_one_provider(&self, base: &str, height: u64) -> Result<Vec<u8>, String> {
let agent = ureq::AgentBuilder::new().timeout(self.timeout).build();
let trimmed = base.trim_end_matches('/');
let height_url = format!("{trimmed}/block-height/{height}");
let hash = agent
.get(&height_url)
.call()
.map_err(|err| format!("GET {height_url}: {err}"))?
.into_string()
.map_err(|err| format!("read body {height_url}: {err}"))?;
let hash = hash.trim();
if hash.is_empty() || hash.len() > 128 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(format!(
"provider {height_url} returned non-hex block hash `{hash}`"
));
}
let header_url = format!("{trimmed}/block/{hash}/header");
let header_hex = agent
.get(&header_url)
.call()
.map_err(|err| format!("GET {header_url}: {err}"))?
.into_string()
.map_err(|err| format!("read body {header_url}: {err}"))?;
let header_hex = header_hex.trim();
if header_hex.len() != BITCOIN_BLOCK_HEADER_LEN * 2
|| !header_hex.chars().all(|c| c.is_ascii_hexdigit())
{
return Err(format!(
"provider {header_url} returned malformed header (expected {} hex chars, got {})",
BITCOIN_BLOCK_HEADER_LEN * 2,
header_hex.len()
));
}
decode_hex(header_hex).map_err(|err| format!("provider {header_url} hex decode: {err}"))
}
}
impl BitcoinHeaderSource for HttpsHeadersBitcoinHeaderSource {
fn header_for_height(&self, height: u64) -> Result<Vec<u8>, OtsError> {
let mut responses: Vec<Vec<u8>> = Vec::with_capacity(self.providers.len());
let mut errors: Vec<String> = Vec::new();
for base in &self.providers {
match self.fetch_one_provider(base, height) {
Ok(bytes) => {
if bytes.len() == BITCOIN_BLOCK_HEADER_LEN {
responses.push(bytes);
} else {
errors.push(format!(
"provider {base} returned {} bytes, expected {BITCOIN_BLOCK_HEADER_LEN}",
bytes.len()
));
}
}
Err(err) => errors.push(err),
}
}
if responses.len() < self.quorum_n {
return Err(OtsError::OtsCrateError(format!(
"{OTS_BITCOIN_HEADER_QUORUM_UNREACHABLE_INVARIANT}: only {} of {} providers \
reachable (need {}); errors: [{}]",
responses.len(),
self.providers.len(),
self.quorum_n,
errors.join("; "),
)));
}
let pivot = responses[0].clone();
let matching = responses.iter().filter(|r| **r == pivot).count();
if matching < self.quorum_n {
return Err(OtsError::OtsCrateError(format!(
"{OTS_BITCOIN_HEADER_QUORUM_PROVIDERS_DISAGREE_INVARIANT}: only {} of {} reachable \
providers returned byte-identical headers for height {height}",
matching,
responses.len(),
)));
}
if !verify_pow_target(&pivot) {
return Err(OtsError::OtsCrateError(format!(
"{OTS_BITCOIN_HEADER_POW_INVALID_INVARIANT}: SHA-256d of header for height \
{height} exceeds the target encoded in nBits",
)));
}
Ok(pivot)
}
}
fn verify_pow_target(header: &[u8]) -> bool {
if header.len() != BITCOIN_BLOCK_HEADER_LEN {
return false;
}
let first = sha256_bytes(header);
let second = sha256_bytes(&first);
let mut nbits = [0u8; 4];
nbits.copy_from_slice(&header[72..76]);
let target = expand_nbits_compact(u32::from_le_bytes(nbits));
let mut hash_be = [0u8; 32];
for i in 0..32 {
hash_be[i] = second[31 - i];
}
hash_be <= target
}
fn expand_nbits_compact(nbits: u32) -> [u8; 32] {
let exponent = (nbits >> 24) as u8;
let mantissa = nbits & 0x00ff_ffff;
let mut target = [0u8; 32];
if exponent <= 3 {
let m = mantissa >> (8 * (3 - exponent as u32));
target[29] = ((m >> 16) & 0xff) as u8;
target[30] = ((m >> 8) & 0xff) as u8;
target[31] = (m & 0xff) as u8;
} else {
let shift = exponent as usize - 3;
if shift < 30 {
target[31 - shift - 2] = ((mantissa >> 16) & 0xff) as u8;
target[31 - shift - 1] = ((mantissa >> 8) & 0xff) as u8;
target[31 - shift] = (mantissa & 0xff) as u8;
}
}
target
}
fn decode_hex(s: &str) -> Result<Vec<u8>, String> {
if !s.len().is_multiple_of(2) {
return Err(format!("odd-length hex string ({} chars)", s.len()));
}
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(s.len() / 2);
for i in (0..bytes.len()).step_by(2) {
let hi = match bytes[i] {
b'0'..=b'9' => bytes[i] - b'0',
b'a'..=b'f' => bytes[i] - b'a' + 10,
b'A'..=b'F' => bytes[i] - b'A' + 10,
other => return Err(format!("invalid hex byte {other:#x}")),
};
let lo = match bytes[i + 1] {
b'0'..=b'9' => bytes[i + 1] - b'0',
b'a'..=b'f' => bytes[i + 1] - b'a' + 10,
b'A'..=b'F' => bytes[i + 1] - b'A' + 10,
other => return Err(format!("invalid hex byte {other:#x}")),
};
out.push((hi << 4) | lo);
}
Ok(out)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OtsWitness {
pub class: &'static str,
pub authority_domain: &'static str,
pub tier: &'static str,
pub signer_id: Option<String>,
pub asserted_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OtsBrokenEdge {
pub invariant: &'static str,
pub detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OtsVerificationOutcome {
FullChainVerified {
witnesses: Vec<OtsWitness>,
},
Partial {
reasons: Vec<String>,
witnesses: Vec<OtsWitness>,
},
Broken {
edge: OtsBrokenEdge,
witnesses: Vec<OtsWitness>,
},
}
impl OtsVerificationOutcome {
#[must_use]
pub const fn is_full_chain_verified(&self) -> bool {
matches!(self, Self::FullChainVerified { .. })
}
#[must_use]
pub const fn is_partial(&self) -> bool {
matches!(self, Self::Partial { .. })
}
#[must_use]
pub const fn is_broken(&self) -> bool {
matches!(self, Self::Broken { .. })
}
#[must_use]
pub const fn wire_str(&self) -> &'static str {
match self {
Self::FullChainVerified { .. } => "full_chain_verified",
Self::Partial { .. } => "partial",
Self::Broken { .. } => "broken",
}
}
}
pub fn verify_receipt<P, B>(
receipt: &ExternalReceipt,
parser: &P,
bitcoin_source: Option<&B>,
) -> Result<OtsVerificationOutcome, OtsError>
where
P: OtsParser + ?Sized,
B: BitcoinHeaderSource + ?Sized,
{
if receipt.sink != ExternalSink::OpenTimestamps {
return Err(OtsError::OtsCrateError(format!(
"external receipt sink `{}` is not `opentimestamps`; refusing to verify via OTS adapter",
receipt.sink,
)));
}
let ots_bytes = extract_ots_proof_bytes(receipt)?;
let parsed = parser.parse(&ots_bytes)?;
let witness = OtsWitness {
class: "external_anchor_crossing",
authority_domain: "external_anchor_sink",
tier: "third_party",
signer_id: Some(receipt.sink_endpoint.clone()),
asserted_at: receipt.submitted_at,
};
match parsed {
TypedOtsProof::Pending { .. } => Ok(OtsVerificationOutcome::Partial {
reasons: vec![OTS_PENDING_NO_BITCOIN_ATTESTATION_YET_INVARIANT.to_string()],
witnesses: vec![witness],
}),
TypedOtsProof::BitcoinConfirmed {
block_height,
merkle_path_digest,
..
} => verify_bitcoin_confirmed(block_height, &merkle_path_digest, bitcoin_source, witness),
}
}
pub fn verify_receipt_with_defaults<B>(
receipt: &ExternalReceipt,
bitcoin_source: Option<&B>,
) -> Result<OtsVerificationOutcome, OtsError>
where
B: BitcoinHeaderSource + ?Sized,
{
let parser = DefaultOtsParser;
verify_receipt(receipt, &parser, bitcoin_source)
}
pub const OTS_DISJOINT_AUTHORITY_MIN_OPERATORS: usize = 2;
#[must_use]
pub fn enforce_disjoint_authority_quorum(
candidate: OtsVerificationOutcome,
history_witnesses: &[OtsWitness],
) -> OtsVerificationOutcome {
if !candidate.is_full_chain_verified() {
return candidate;
}
let distinct_operators = distinct_known_operators(history_witnesses);
if distinct_operators >= OTS_DISJOINT_AUTHORITY_MIN_OPERATORS {
return candidate;
}
let witnesses = match &candidate {
OtsVerificationOutcome::FullChainVerified { witnesses } => witnesses.clone(),
OtsVerificationOutcome::Partial { witnesses, .. } => witnesses.clone(),
OtsVerificationOutcome::Broken { witnesses, .. } => witnesses.clone(),
};
OtsVerificationOutcome::Partial {
reasons: vec![OTS_DISJOINT_AUTHORITY_QUORUM_NOT_MET_INVARIANT.to_string()],
witnesses,
}
}
fn distinct_known_operators(witnesses: &[OtsWitness]) -> usize {
let mut seen: Vec<&'static str> = Vec::new();
for witness in witnesses {
let Some(signer) = witness.signer_id.as_deref() else {
continue;
};
let Some(operator) = calendar_operator(signer) else {
continue;
};
if !seen.contains(&operator) {
seen.push(operator);
}
}
seen.len()
}
fn verify_bitcoin_confirmed<B>(
block_height: u64,
merkle_path_digest: &str,
bitcoin_source: Option<&B>,
witness: OtsWitness,
) -> Result<OtsVerificationOutcome, OtsError>
where
B: BitcoinHeaderSource + ?Sized,
{
let Some(source) = bitcoin_source else {
return Ok(OtsVerificationOutcome::Partial {
reasons: vec![OTS_BITCOIN_CONFIRMED_BLOCK_HEADER_MISMATCH_INVARIANT.to_string()],
witnesses: vec![witness],
});
};
let header_bytes = source.header_for_height(block_height)?;
if header_bytes.len() != BITCOIN_BLOCK_HEADER_LEN {
return Err(OtsError::OtsCrateError(format!(
"operator-supplied Bitcoin block header for height {block_height} \
must be {BITCOIN_BLOCK_HEADER_LEN} bytes, got {}",
header_bytes.len(),
)));
}
let merkle_root_hex = extract_merkle_root_hex(&header_bytes);
if merkle_root_hex != merkle_path_digest {
return Ok(OtsVerificationOutcome::Broken {
edge: OtsBrokenEdge {
invariant: OTS_BITCOIN_CONFIRMED_MERKLE_PATH_INVALID_INVARIANT,
detail: format!(
"Bitcoin attestation at height {block_height} declared merkle path digest \
{merkle_path_digest} but operator-supplied block header carries merkle root \
{merkle_root_hex}"
),
},
witnesses: vec![witness],
});
}
Ok(OtsVerificationOutcome::FullChainVerified {
witnesses: vec![witness],
})
}
const BITCOIN_BLOCK_HEADER_LEN: usize = 80;
fn extract_merkle_root_hex(header: &[u8]) -> String {
hex_lower(&header[36..68])
}
fn extract_ots_proof_bytes(receipt: &ExternalReceipt) -> Result<Vec<u8>, OtsError> {
let object = receipt
.receipt
.as_object()
.ok_or_else(|| OtsError::MalformedHeader {
reason: "external receipt body must be a JSON object".to_string(),
})?;
let proof_field = object
.get("ots_proof_base64")
.ok_or_else(|| OtsError::MalformedHeader {
reason: "external receipt body missing `ots_proof_base64`".to_string(),
})?;
let encoded = proof_field
.as_str()
.ok_or_else(|| OtsError::MalformedHeader {
reason: "external receipt `ots_proof_base64` must be a string".to_string(),
})?;
base64_standard_decode(encoded).map_err(|reason| OtsError::MalformedHeader { reason })
}
fn sha256_bytes(input: &[u8]) -> [u8; 32] {
let hex = crate::sha256::sha256_hex(input);
let mut out = [0u8; 32];
for (i, chunk) in hex.as_bytes().chunks(2).enumerate().take(32) {
let high = ascii_hex_value(chunk[0]);
let low = ascii_hex_value(chunk[1]);
out[i] = (high << 4) | low;
}
out
}
fn ascii_hex_value(byte: u8) -> u8 {
match byte {
b'0'..=b'9' => byte - b'0',
b'a'..=b'f' => byte - b'a' + 10,
b'A'..=b'F' => byte - b'A' + 10,
_ => 0,
}
}
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
}
const BASE64_TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
pub(crate) fn base64_standard(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
let mut i = 0;
while i + 3 <= bytes.len() {
let triple = ((bytes[i] as u32) << 16) | ((bytes[i + 1] as u32) << 8) | bytes[i + 2] as u32;
out.push(BASE64_TABLE[((triple >> 18) & 0x3f) as usize] as char);
out.push(BASE64_TABLE[((triple >> 12) & 0x3f) as usize] as char);
out.push(BASE64_TABLE[((triple >> 6) & 0x3f) as usize] as char);
out.push(BASE64_TABLE[(triple & 0x3f) as usize] as char);
i += 3;
}
let remaining = bytes.len() - i;
match remaining {
1 => {
let single = (bytes[i] as u32) << 16;
out.push(BASE64_TABLE[((single >> 18) & 0x3f) as usize] as char);
out.push(BASE64_TABLE[((single >> 12) & 0x3f) as usize] as char);
out.push('=');
out.push('=');
}
2 => {
let pair = ((bytes[i] as u32) << 16) | ((bytes[i + 1] as u32) << 8);
out.push(BASE64_TABLE[((pair >> 18) & 0x3f) as usize] as char);
out.push(BASE64_TABLE[((pair >> 12) & 0x3f) as usize] as char);
out.push(BASE64_TABLE[((pair >> 6) & 0x3f) as usize] as char);
out.push('=');
}
_ => {}
}
out
}
fn base64_standard_decode(encoded: &str) -> Result<Vec<u8>, String> {
if !encoded.len().is_multiple_of(4) {
return Err(format!(
"base64 length {} is not a multiple of 4",
encoded.len()
));
}
let mut out = Vec::with_capacity(encoded.len() / 4 * 3);
let bytes = encoded.as_bytes();
let mut i = 0;
while i + 4 <= bytes.len() {
let v0 = decode_base64_byte(bytes[i])?;
let v1 = decode_base64_byte(bytes[i + 1])?;
let pad2 = bytes[i + 2] == b'=';
let pad3 = bytes[i + 3] == b'=';
let v2 = if pad2 {
0
} else {
decode_base64_byte(bytes[i + 2])?
};
let v3 = if pad3 {
0
} else {
decode_base64_byte(bytes[i + 3])?
};
let quad = ((v0 as u32) << 18) | ((v1 as u32) << 12) | ((v2 as u32) << 6) | v3 as u32;
out.push(((quad >> 16) & 0xff) as u8);
if !pad2 {
out.push(((quad >> 8) & 0xff) as u8);
}
if !pad3 {
out.push((quad & 0xff) as u8);
}
if pad2 && i + 4 != bytes.len() {
return Err("base64 padding may only appear at the end".to_string());
}
i += 4;
}
Ok(out)
}
fn decode_base64_byte(b: u8) -> Result<u8, String> {
match b {
b'A'..=b'Z' => Ok(b - b'A'),
b'a'..=b'z' => Ok(b - b'a' + 26),
b'0'..=b'9' => Ok(b - b'0' + 52),
b'+' => Ok(62),
b'/' => Ok(63),
_ => Err(format!("invalid base64 byte {b:#x}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base64_round_trip_matches_standard_alphabet() {
let cases: &[(&[u8], &str)] = &[
(b"", ""),
(b"f", "Zg=="),
(b"fo", "Zm8="),
(b"foo", "Zm9v"),
(b"foob", "Zm9vYg=="),
(b"fooba", "Zm9vYmE="),
(b"foobar", "Zm9vYmFy"),
];
for (raw, encoded) in cases {
assert_eq!(base64_standard(raw), *encoded, "encode {raw:?}");
assert_eq!(
base64_standard_decode(encoded).expect("decode round-trip"),
raw.to_vec(),
"decode {encoded}"
);
}
}
#[test]
fn sha256_bytes_matches_canonical_hex() {
let bytes = sha256_bytes(b"abc");
let expected = [
0xba, 0x78, 0x16, 0xbf, 0x8f, 0x01, 0xcf, 0xea, 0x41, 0x41, 0x40, 0xde, 0x5d, 0xae,
0x22, 0x23, 0xb0, 0x03, 0x61, 0xa3, 0x96, 0x17, 0x7a, 0x9c, 0xb4, 0x10, 0xff, 0x61,
0xf2, 0x00, 0x15, 0xad,
];
assert_eq!(bytes, expected);
}
#[test]
fn noop_calendar_client_fails_closed_with_obvious_reason() {
let client = NoopCalendarClient;
let err = client
.submit_digest("https://a.pool.opentimestamps.org", &[0u8; 32])
.unwrap_err();
match err {
OtsError::OtsCrateError(reason) => {
assert!(
reason.contains("NoopCalendarClient"),
"noop client must call itself out: {reason}"
);
}
other => panic!("expected OtsCrateError, got {other:?}"),
}
}
fn pending_fixture_bytes() -> Vec<u8> {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join("ots")
.join("pending.ots");
std::fs::read(path).expect("pending fixture present")
}
fn bitcoin_fixture_bytes() -> Vec<u8> {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join("ots")
.join("bitcoin_confirmed.ots");
std::fs::read(path).expect("bitcoin fixture present")
}
fn external_receipt_with(ots_bytes: &[u8], sink: ExternalSink) -> ExternalReceipt {
ExternalReceipt {
sink,
anchor_text_sha256: "a".repeat(64),
anchor_event_count: 1,
anchor_chain_head_hash: "b".repeat(64),
submitted_at: chrono::Utc::now(),
sink_endpoint: DEFAULT_OTS_CALENDAR_URL.to_string(),
receipt: json!({
"ots_proof_base64": base64_standard(ots_bytes),
"calendar_url": DEFAULT_OTS_CALENDAR_URL,
"submitted_digest_hex": "0".repeat(64),
}),
}
}
#[test]
fn pending_receipt_maps_to_partial_with_stable_invariant() {
let receipt = external_receipt_with(&pending_fixture_bytes(), ExternalSink::OpenTimestamps);
let outcome =
verify_receipt_with_defaults(&receipt, None::<&StaticBitcoinHeaderSource>).unwrap();
match outcome {
OtsVerificationOutcome::Partial { reasons, .. } => {
assert!(reasons
.iter()
.any(|r| r == OTS_PENDING_NO_BITCOIN_ATTESTATION_YET_INVARIANT));
}
other => panic!("expected Partial, got {other:?}"),
}
}
#[test]
fn bitcoin_receipt_without_header_source_degrades_to_partial() {
let receipt = external_receipt_with(&bitcoin_fixture_bytes(), ExternalSink::OpenTimestamps);
let outcome =
verify_receipt_with_defaults(&receipt, None::<&StaticBitcoinHeaderSource>).unwrap();
match outcome {
OtsVerificationOutcome::Partial { reasons, .. } => {
assert!(reasons
.iter()
.any(|r| r == OTS_BITCOIN_CONFIRMED_BLOCK_HEADER_MISMATCH_INVARIANT));
}
other => panic!("expected Partial, got {other:?}"),
}
}
#[test]
fn bitcoin_receipt_with_mismatched_header_fails_closed_broken() {
let receipt = external_receipt_with(&bitcoin_fixture_bytes(), ExternalSink::OpenTimestamps);
let header = vec![0u8; 80];
let source = StaticBitcoinHeaderSource::new().with_header(824_321, header);
let outcome = verify_receipt_with_defaults(&receipt, Some(&source)).unwrap();
match outcome {
OtsVerificationOutcome::Broken { edge, .. } => {
assert_eq!(
edge.invariant,
OTS_BITCOIN_CONFIRMED_MERKLE_PATH_INVALID_INVARIANT
);
}
other => panic!("expected Broken, got {other:?}"),
}
}
#[test]
fn bitcoin_receipt_with_matching_header_full_chain_verified() {
let receipt = external_receipt_with(&bitcoin_fixture_bytes(), ExternalSink::OpenTimestamps);
let parsed = DefaultOtsParser
.parse(&bitcoin_fixture_bytes())
.expect("bitcoin fixture parses");
let merkle_hex = match parsed {
TypedOtsProof::BitcoinConfirmed {
merkle_path_digest, ..
} => merkle_path_digest,
other => panic!("expected BitcoinConfirmed, got {other:?}"),
};
let mut merkle_bytes = Vec::with_capacity(32);
for chunk in merkle_hex.as_bytes().chunks(2) {
let h = ascii_hex_value(chunk[0]);
let l = ascii_hex_value(chunk[1]);
merkle_bytes.push((h << 4) | l);
}
let mut header = vec![0u8; 80];
header[36..68].copy_from_slice(&merkle_bytes);
let source = StaticBitcoinHeaderSource::new().with_header(824_321, header);
let outcome = verify_receipt_with_defaults(&receipt, Some(&source)).unwrap();
assert!(
outcome.is_full_chain_verified(),
"matching header must produce FullChainVerified, got {outcome:?}",
);
}
#[test]
fn non_opentimestamps_sink_refused_before_parser() {
let receipt = external_receipt_with(&pending_fixture_bytes(), ExternalSink::Rekor);
let err =
verify_receipt_with_defaults(&receipt, None::<&StaticBitcoinHeaderSource>).unwrap_err();
match err {
OtsError::OtsCrateError(reason) => {
assert!(reason.contains("rekor"), "{reason}");
}
other => panic!("expected OtsCrateError, got {other:?}"),
}
}
#[test]
fn submit_round_trip_uses_anchor_text_sha256_as_digest_and_round_trips_parser() {
struct MockClient {
bytes: Vec<u8>,
}
impl CalendarClient for MockClient {
fn submit_digest(
&self,
_calendar_url: &str,
_digest: &[u8; 32],
) -> Result<Vec<u8>, OtsError> {
Ok(self.bytes.clone())
}
}
let client = MockClient {
bytes: pending_fixture_bytes(),
};
let anchor =
LedgerAnchor::new(chrono::Utc::now(), 42, "c".repeat(64)).expect("anchor builds");
let receipt = submit(
&anchor,
DEFAULT_OTS_CALENDAR_URL,
chrono::Utc::now(),
&client,
)
.expect("submit accepts canonical Pending bytes");
assert_eq!(receipt.sink, ExternalSink::OpenTimestamps);
assert_eq!(receipt.anchor_event_count, 42);
let parsed_bytes = extract_ots_proof_bytes(&receipt).expect("receipt round-trips");
assert_eq!(parsed_bytes, pending_fixture_bytes());
}
fn make_witness(endpoint: &str) -> OtsWitness {
OtsWitness {
class: "external_anchor_crossing",
authority_domain: "external_anchor_sink",
tier: "third_party",
signer_id: Some(endpoint.to_string()),
asserted_at: chrono::Utc::now(),
}
}
#[test]
fn calendar_operator_classifies_default_set() {
assert_eq!(
calendar_operator("https://a.pool.opentimestamps.org"),
Some("peter_todd"),
);
assert_eq!(
calendar_operator("https://alice.btc.calendar.opentimestamps.org"),
Some("peter_todd"),
);
assert_eq!(
calendar_operator("https://bob.btc.calendar.opentimestamps.org/"),
Some("peter_todd"),
);
assert_eq!(
calendar_operator("https://finney.calendar.eternitywall.com"),
Some("eternitywall"),
);
assert!(calendar_operator("https://unknown.example.org").is_none());
}
#[test]
fn calendar_operator_rejects_deceptive_substring_in_path() {
assert!(
calendar_operator("https://attacker.example/?h=alice.btc.calendar.opentimestamps.org")
.is_none(),
"deceptive query-string substring must not classify as peter_todd",
);
assert!(
calendar_operator("https://attacker.example/?h=finney.calendar.eternitywall.com")
.is_none(),
"deceptive query-string substring must not classify as eternitywall",
);
assert!(
calendar_operator("https://attacker.example/a.pool.opentimestamps.org/digest")
.is_none(),
"deceptive path-segment substring must not classify",
);
assert!(
calendar_operator("https://xfinney.calendar.eternitywall.com").is_none(),
"sibling host that ends with needle but lacks leading dot must not classify",
);
assert!(calendar_operator("not a url").is_none());
assert!(calendar_operator("").is_none());
assert!(calendar_operator("alice.btc.calendar.opentimestamps.org").is_none());
}
#[test]
fn calendar_operator_accepts_exact_host() {
assert_eq!(
calendar_operator("https://calendar.opentimestamps.org/digest"),
None,
"`calendar.opentimestamps.org` is not a listed needle; only the \
three Todd subdomains are",
);
assert_eq!(
calendar_operator("https://a.pool.opentimestamps.org/"),
Some("peter_todd"),
);
assert_eq!(
calendar_operator("https://a.pool.opentimestamps.org/digest?x=1"),
Some("peter_todd"),
);
assert_eq!(
calendar_operator("https://finney.calendar.eternitywall.com/digest"),
Some("eternitywall"),
);
}
#[test]
fn calendar_operator_accepts_legitimate_subdomain() {
assert_eq!(
calendar_operator("https://shard-1.alice.btc.calendar.opentimestamps.org/digest"),
Some("peter_todd"),
"deeper subdomain of a listed needle must inherit the operator",
);
assert_eq!(
calendar_operator("https://shard-1.finney.calendar.eternitywall.com/"),
Some("eternitywall"),
);
assert_eq!(
calendar_operator("HTTPS://A.POOL.OPENTIMESTAMPS.ORG/digest"),
Some("peter_todd"),
);
}
#[test]
fn disjoint_quorum_rejects_two_attacker_urls_with_deceptive_substrings() {
let witnesses = vec![
make_witness("https://attacker.example/?h=alice.btc.calendar.opentimestamps.org"),
make_witness("https://attacker.example/?h=finney.calendar.eternitywall.com"),
];
assert_eq!(
distinct_known_operators(&witnesses),
0,
"attacker URLs MUST contribute zero known operators",
);
let candidate = OtsVerificationOutcome::FullChainVerified {
witnesses: witnesses.clone(),
};
let gated = enforce_disjoint_authority_quorum(candidate, &witnesses);
match gated {
OtsVerificationOutcome::Partial { reasons, .. } => {
assert!(
reasons
.iter()
.any(|r| r == OTS_DISJOINT_AUTHORITY_QUORUM_NOT_MET_INVARIANT),
"expected disjoint-authority quorum-not-met invariant, \
got reasons {reasons:?}",
);
}
other => {
panic!("expected Partial downgrade for attacker-URL witness set, got {other:?}",)
}
}
}
#[test]
fn disjoint_quorum_two_todd_witnesses_holds_at_partial() {
let witnesses = vec![
make_witness("https://alice.btc.calendar.opentimestamps.org"),
make_witness("https://bob.btc.calendar.opentimestamps.org"),
];
let candidate = OtsVerificationOutcome::FullChainVerified {
witnesses: witnesses.clone(),
};
let gated = enforce_disjoint_authority_quorum(candidate, &witnesses);
match gated {
OtsVerificationOutcome::Partial { reasons, .. } => {
assert!(reasons
.iter()
.any(|r| r == OTS_DISJOINT_AUTHORITY_QUORUM_NOT_MET_INVARIANT));
}
other => panic!("expected Partial downgrade, got {other:?}"),
}
}
#[test]
fn disjoint_quorum_todd_plus_eternitywall_promotes() {
let witnesses = vec![
make_witness("https://a.pool.opentimestamps.org"),
make_witness("https://finney.calendar.eternitywall.com"),
];
let candidate = OtsVerificationOutcome::FullChainVerified {
witnesses: witnesses.clone(),
};
let gated = enforce_disjoint_authority_quorum(candidate, &witnesses);
assert!(
gated.is_full_chain_verified(),
"two disjoint operators must promote, got {gated:?}",
);
}
#[test]
fn disjoint_quorum_single_witness_unreachable_at_full_chain_verified() {
let witnesses = vec![make_witness("https://a.pool.opentimestamps.org")];
let candidate = OtsVerificationOutcome::FullChainVerified {
witnesses: witnesses.clone(),
};
let gated = enforce_disjoint_authority_quorum(candidate, &witnesses);
match gated {
OtsVerificationOutcome::Partial { reasons, .. } => {
assert!(reasons
.iter()
.any(|r| r == OTS_DISJOINT_AUTHORITY_QUORUM_NOT_MET_INVARIANT));
}
other => {
panic!("single-witness history must downgrade to Partial, got {other:?}")
}
}
}
#[test]
fn disjoint_quorum_does_not_upgrade_partial() {
let witnesses = vec![
make_witness("https://a.pool.opentimestamps.org"),
make_witness("https://finney.calendar.eternitywall.com"),
];
let candidate = OtsVerificationOutcome::Partial {
reasons: vec![OTS_BITCOIN_CONFIRMED_BLOCK_HEADER_MISMATCH_INVARIANT.to_string()],
witnesses: witnesses.clone(),
};
let gated = enforce_disjoint_authority_quorum(candidate.clone(), &witnesses);
assert_eq!(gated, candidate);
}
#[test]
fn disjoint_quorum_unknown_operator_does_not_count_toward_quorum() {
let witnesses = vec![
make_witness("https://a.pool.opentimestamps.org"),
make_witness("https://alice.btc.calendar.opentimestamps.org"),
make_witness("https://unclassified.example.org"),
];
let candidate = OtsVerificationOutcome::FullChainVerified {
witnesses: witnesses.clone(),
};
let gated = enforce_disjoint_authority_quorum(candidate, &witnesses);
match gated {
OtsVerificationOutcome::Partial { reasons, .. } => {
assert!(reasons
.iter()
.any(|r| r == OTS_DISJOINT_AUTHORITY_QUORUM_NOT_MET_INVARIANT));
}
other => panic!("expected Partial, got {other:?}"),
}
}
#[test]
fn default_calendar_url_is_first_entry_of_url_list() {
assert_eq!(DEFAULT_OTS_CALENDAR_URL, DEFAULT_OTS_CALENDAR_URLS[0]);
}
#[test]
fn default_calendar_urls_include_eternitywall_finney() {
assert!(
DEFAULT_OTS_CALENDAR_URLS
.iter()
.any(|u| u.contains("finney.calendar.eternitywall.com")),
"default calendar list must contain Eternitywall Finney",
);
}
#[test]
fn ots_calendar_operators_covers_default_list() {
for url in DEFAULT_OTS_CALENDAR_URLS {
assert!(
calendar_operator(url).is_some(),
"default calendar `{url}` must have an operator entry",
);
}
}
#[test]
fn decode_hex_round_trips_known_byte_string() {
let raw = decode_hex("00ff10ab").expect("decode round-trip");
assert_eq!(raw, vec![0x00, 0xff, 0x10, 0xab]);
assert!(decode_hex("zz").is_err());
assert!(decode_hex("0").is_err());
}
#[test]
fn https_headers_source_quorum_unreachable_when_zero_providers() {
let source = HttpsHeadersBitcoinHeaderSource::new(vec![]).with_quorum_n(2);
let err = source.header_for_height(824_321).unwrap_err();
match err {
OtsError::OtsCrateError(reason) => {
assert!(
reason.contains(OTS_BITCOIN_HEADER_QUORUM_UNREACHABLE_INVARIANT),
"expected unreachable invariant, got {reason}",
);
}
other => panic!("expected OtsCrateError, got {other:?}"),
}
}
#[test]
fn expand_nbits_compact_recovers_genesis_difficulty() {
let target = expand_nbits_compact(0x1d00_ffff);
assert_eq!(target[0], 0x00);
assert_eq!(target[1], 0x00);
assert_eq!(target[2], 0x00);
assert_eq!(target[3], 0x00);
assert_eq!(target[4], 0xff);
assert_eq!(target[5], 0xff);
for byte in &target[6..] {
assert_eq!(*byte, 0x00);
}
}
}