Skip to Content
AI agents & assistants — machine-readable index at /llms.txt
Engineering BlogRFC 3161 vs OpenTimestamps: What Each Timestamp Proves

RFC 3161 vs OpenTimestamps: What Each Timestamp Proves

Date: September 16, 2026 · Author: Dmitrii Zatona 


TL;DR

  • A time-stamp token and an OpenTimestamps proof make the same claim and end at different things: a Time Stamping Authority’s private key, or a Bitcoin block header the verifier reads from its own node (Sections 2 and 4).
  • Verifying a token is six checks from RFC 3161 Section 2.2. Two of them are the ones that go missing: the single critical id-kp-timeStamping extended key usage, and the certificate’s status as of genTime rather than as of today (Section 3).
  • An OpenTimestamps proof is a path of append, prepend and hash operations from the document digest to 32 bytes that must equal a block’s Merkle root. The path is self-contained; the header is not in it (Sections 4 and 5).
  • genTime plus accuracy is an interval a named party asserts. A block header’s time has a consensus floor the chain still proves — the median of the previous eleven headers — and a two-hour ceiling that was applied against the clocks of whoever was validating and left nothing behind to recheck (Section 6).
  • The two fail for unrelated reasons, which is the argument for carrying both. The acceptance rule that follows, at the anchor level and at the receipt level, is that one confirmed anchor is enough and nothing confirmed is indeterminate rather than false (Sections 5 and 8).
  • Neither proves the content, nor that the data did not exist earlier, nor that you were the one who stamped it (Section 11).

I write and maintain ATL, a transparency-log protocol whose receipts carry both kinds of anchor, and the verifier for it. This article is what I had to settle to write that verifier: what is actually in each artefact, which checks a library will not make for you, and what sentence about time each one supports. Tamper-evident audit logs covers the layer above — why a log anchors a checkpoint at all, and on what cadence. It stops where this one starts. Everything below is by the specification text and by the reference implementations, and the Rust compiles against published crates.

1. Two claims that look identical

Both artefacts assert one thing: this hash existed no later than time T.

An RFC 3161 time-stamp token is a CMS SignedData over a small ASN.1 structure that contains the hash, a time and a policy identifier, signed by a Time Stamping Authority — a TSA. To believe it you have to believe the TSA’s key, which means reassembling a certificate chain and a revocation decision as they stood on the day the token was made.

An OpenTimestamps proof, usually a .ots file, contains no signature. It is a list of byte operations that turn the document’s digest into the Merkle root of a Bitcoin block, plus the height of that block. To believe it you have to have that block’s header, from a source you are willing to treat as the chain.

The two do not degrade in the same way, and neither is a drop-in substitute for the other. What follows is the artefacts first, then the checking, then the two sentences about time you can defend afterwards.

Two trust paths from an anchored value: one ending at a TSA key and its certificate chain, one ending at a Bitcoin block header

2. What is inside an RFC 3161 token

Two ASN.1 structures matter. The request the client sends, TimeStampReq, is defined in RFC 3161 Section 2.4.1; the structure inside the token, TSTInfo, in Section 2.4.2.

The request is five fields after the version.

FieldOptionalWhat it isWhat it decides later
messageImprintno”a hash algorithm OID and the hash value of the data to be time-stamped”The TSA never sees the data. The algorithm OID is part of what the token echoes back, so it is part of what the verifier compares
reqPolicyyesThe TSA policy the token SHOULD be issued underIf it is present and the TSA cannot honour it, the TSA must return unacceptedPolicy, never a token under a different policy
nonceyesA large random integer, there to “verify the timeliness of the response when no local clock is available”The only replay defence that works without a trusted local clock. If present, the TSA must echo it
certReqdefault falseAsk for the TSA certificate in the responseIf false, the certificates field “MUST not be present in the response”. A token requested without it cannot be checked by anyone who does not already hold the certificate
extensionsyesAdditional informationAn extension the server does not recognise makes it refuse the whole request with unacceptedExtension

TSTInfo, the signed content, has ten fields.

FieldOptionalSemanticsWhat a verifier does with it
versionnov1Local. Reject anything else. The RFC obliges servers to produce v1 and requesters to recognise it; refusing a future version is the verifier’s own choice
policyno”MUST indicate the TSA’s policy under which the response was produced”Required (Section 2.2, check 6). Compare against the list this application accepts
messageImprintno”MUST have the same value as the similar field in TimeStampReq”Required (check 2). Compare the whole structure, algorithm identifier included
serialNumbernoUnique per TSA; users “MUST be ready to accommodate integers up to 160 bits”Local. Store it. TSA name plus serial identifies the token, which is why the RFC makes the TSA keep it unique across restarts
genTimeno”the time at which the time-stamp token has been created by the TSA”, UTC, seconds mandatory, fractional seconds allowedThe time being claimed. Required (check 4) only as an input to the timeliness comparison
accuracyyesDeviation around genTime in seconds, milliseconds (1–999) and microseconds (1–999); a missing component counts as zeroLocal. Adding it to genTime gives an upper limit and subtracting it a lower one; how wide an interval the application tolerates is its own rule
orderingdefault falseWhen true, tokens from that one TSA “can always be ordered based on the genTime field”Local. Decides whether two tokens from one TSA can be ordered when their accuracy intervals overlap
nonceyes”MUST be present if the similar field was present in TimeStampReq” and must equal itRequired (check 4). Compare
tsayes”give a hint in identifying the name of the TSA”; if present it MUST correspond to one of the subject names in the certificate used to verify the tokenRequired in that narrow form, and no further: identification happens through the certificate identifier in the signerInfo, not here
extensionsyesFuture useRequired only in that requesters must recognise version 1 tokens with all optional fields present, without understanding any extension’s semantics

Two fields deserve a note beyond the table.

ordering is the only way RFC 3161 gives you a total order from one TSA. With ordering false or absent, the specification says two tokens can be ordered only when the gap between their genTime values exceeds the sum of their accuracies. With it true, genTime orders tokens from that TSA “regardless of the genTime accuracy” — and only from that TSA. Nothing in the format orders tokens from two different authorities.

tsa is a name, and a name cannot select a key. RFC 5816, which updates RFC 3161, restates where identification comes from: the ESSCertID inside a SigningCertificate attribute, or the ESSCertIDv2 inside a SigningCertificateV2 attribute, in the signerInfo (Section 2.2.2). A verifier that matches tsa against an expected name and calls the signer identified has matched a string. That does not make the field safe to skip: when present it must be one of the subject names of the certificate the token verifies against, so a token naming somebody else is malformed. Section 3 checks it after the signature, not instead of it. The check is a name comparison, which is its own subject: RFC 5280 Section 7.1 asks for “a more comprehensive handling of comparison” than binary equality, because equivalent distinguished names can differ in ASN.1 form and DNS names compare without case. The listing compares structurally, errs strict, and says so; a production verifier normalises first.

Building the request in Rust is x509-tsp plus der plus a hash. The crate gives the ASN.1 types and nothing else: version 0.1.0, published 2023-06-26, and it verifies no signatures.

use const_oid::db::rfc5912::ID_SHA_256; use der::asn1::{Int, Null, OctetString}; use der::{Any, Encode}; use sha2::{Digest, Sha256}; use x509_cert::spki::AlgorithmIdentifier; use x509_tsp::{MessageImprint, TimeStampReq, TspVersion}; /// A nonce is an ASN.1 INTEGER, and DER integers are signed. Eight random /// bytes whose top bit is set encode as a negative number unless a zero octet /// is prepended, and the token comes back carrying that octet: a verifier that /// compares the token's nonce against its own random bytes then fails on a /// token that is correct. fn nonce_integer(random: &[u8; 8]) -> der::Result<Int> { let mut v = Vec::with_capacity(9); if random[0] & 0x80 != 0 { v.push(0); } v.extend_from_slice(random); Int::new(&v) } /// What the client has to keep between sending the request and checking the /// response. Checks 2 and 4 of Section 3 compare the token against these, so a /// verifier that discards them can no longer make those checks. `sent_at_ms` /// is the lower end of the window the response has to fall in, so it is a /// parameter and never a default: left at zero, check 4 accepts any genTime /// after 1970. pub struct PendingRequest { pub der: Vec<u8>, pub imprint: MessageImprint, pub nonce: Vec<u8>, pub sent_at_ms: i64, } pub fn build_request(data: &[u8], nonce: &[u8; 8], sent_at_ms: i64) -> der::Result<PendingRequest> { let digest = Sha256::digest(data); let nonce = nonce_integer(nonce)?; let imprint = MessageImprint { hash_algorithm: AlgorithmIdentifier { oid: ID_SHA_256, parameters: Some(Any::from(Null)), }, hashed_message: OctetString::new(digest.as_slice())?, }; let req = TimeStampReq { version: TspVersion::V1, message_imprint: imprint.clone(), req_policy: None, nonce: Some(nonce.clone()), // Without this the TSA must not return its certificate, and the token // is uncheckable by anyone who does not already hold it. cert_req: true, extensions: None, }; Ok(PendingRequest { der: req.to_der()?, imprint, nonce: nonce.as_bytes().to_vec(), sent_at_ms, }) }

That comment describes a failure the test suite behind this article hit. A nonce whose first byte is 0xb3 comes back from the TSA as 02 09 00 b3 2b 88 …, and a byte-for-byte comparison against the original eight bytes rejects a token that is correct.

Statuses

The response wraps the token in a PKIStatusInfo. RFC 3161 Section 2.4.2 defines six status values — granted (0), grantedWithMods (1), rejection (2), waiting (3), revocationWarning (4), revocationNotification (5) — and states that a token is present for zero and one and absent for everything else. Failure detail comes in PKIFailureInfo: badAlg (0), badRequest (2), badDataFormat (5), timeNotAvailable (14), unacceptedPolicy (15), unacceptedExtension (16), addInfoNotAvailable (17), systemFailure (25). The specification adds a rule that decides how a client handles anything else: “Compliant clients MUST generate an error if values it does not understand are present.”

grantedWithMods carries a token too, and the specification says only that it is present “with modifications”. Nothing says which. A client that treats one as the other has accepted a token that differs from its request in a way nobody read, and every field check in Section 3 then runs against a request that was not the one honoured.

That rule has a concrete consequence in Rust. The status field of x509-tsp’s TimeStampResp is cmpv2::status::PkiStatusInfo, whose PkiStatus enum is RFC 4210’s: it names 0 Accepted rather than granted, and it has a seventh value, keyUpdateWarning (6), that RFC 3161 does not define. The type decodes it. The client has to reject it.

3. Verifying the token: six checks, and the two that get skipped

RFC 3161 Section 2.2 lists what the requester does on receipt, and the order is the specification’s.

  1. Verify the status; on no error, verify the fields and the signature.
  2. “verify that what was time-stamped corresponds to what was requested.”
  3. Verify that the token carries the correct certificate identifier of the TSA, the correct data imprint and the correct hash algorithm OID.
  4. Verify timeliness: either the time in the response against a local trusted time reference, or the nonce against the one sent.
  5. Because the TSA’s certificate may have been revoked, “the status of the certificate SHOULD be checked”.
  6. Check the policy field against what the application accepts.

Here it is as code. Two arguments carry the work that no crate in this stack does, and naming them as arguments is the point.

/// RFC 3161 Section 2.2, in order. Two arguments carry the work no crate in /// this stack does: `verify_signature` over the CMS SignedData, and /// `status_at`, which has to answer for the moment in `genTime`, not for now. #[allow(clippy::too_many_arguments)] pub fn open_token( resp_der: &[u8], req: &PendingRequest, accepted_policies: &[ObjectIdentifier], pinned_certs: &[Certificate], local_now_ms: i64, max_skew_ms: i64, verify_signature: &dyn Fn(&SignedData, &Certificate) -> bool, status_at: &dyn Fn(&Certificate, i64) -> CertStatus, ) -> Result<Accepted, Reject> { let resp = TimeStampResp::from_der(resp_der).map_err(|_| Reject::Malformed("TimeStampResp"))?; // Check 1. Only `granted` is a token this code accepts without further // work. `grantedWithMods` also carries a token, and RFC 3161 says that // token differs from what was asked for; accepting it silently means // accepting a modification nobody read. Everything else, including // keyUpdateWarning (6), which RFC 4210 defines and RFC 3161 does not, // is an error: a compliant client errors on values it does not understand. if resp.status.status != PkiStatus::Accepted { return Err(Reject::Status(resp.status.status)); } let token = resp.time_stamp_token.ok_or(Reject::Malformed("no token"))?; let signed_data = signed_data_of(&token)?; let econtent = signed_data .encap_content_info .econtent .as_ref() .ok_or(Reject::Malformed("no eContent"))?; if signed_data.encap_content_info.econtent_type != ID_CT_TST_INFO { return Err(Reject::Malformed("eContentType is not id-ct-TSTInfo")); } let tst = TstInfo::from_der(econtent.value()).map_err(|_| Reject::Malformed("TSTInfo"))?; if tst.version != TspVersion::V1 { return Err(Reject::Malformed("TSTInfo version")); } // Check 2. What was time-stamped is what was requested: the whole // MessageImprint, algorithm identifier included, not only the digest. if !same_imprint(&tst.message_imprint, &req.imprint) { return Err(Reject::ImprintMismatch); } // Check 3. Identify the signer, then verify the signature with the key in // that certificate. Identification comes from the certificate identifier // in the signerInfo; the `tsa` field is a hint and cannot select a key. // But the hint is not free-form: RFC 3161 Section 2.4.2 says that if it is // present it "MUST correspond to one of the subject names included in the // certificate that is to be used to verify the token", so a token whose // name points somewhere else is malformed and gets rejected here. let signer = signer_certificate(&signed_data, pinned_certs).ok_or(Reject::SignerNotFound)?; if !verify_signature(&signed_data, &signer) { return Err(Reject::Signature); } if let Some(name) = tst.tsa.as_ref() { if !tsa_name_matches(name, &signer) { return Err(Reject::TsaNameMismatch); } } // Check 4. Timeliness, by the nonce and by the local clock. The nonce is // the only one of the two that works without a trusted local clock. match &tst.nonce { Some(n) if n.as_bytes() == req.nonce.as_slice() => {} _ => return Err(Reject::NonceMismatch), } let gen_time_ms = unix_millis(&tst).ok_or(Reject::Malformed("genTime"))?; let stated = accuracy_millis(&tst)?; // For the window, an unstated accuracy contributes no slack. That is the // strict choice and it belongs here, in a comparison against the client's // own clock; what it must not do is travel onward as if the token had // claimed an interval of zero, which is why `bound` keeps the difference. // Saturating, because every operand comes off the wire: an overflow that // wrapped would move a bound past the value it is supposed to bound, and // the check would pass for the wrong reason. let slack = stated.unwrap_or(0); let not_before_ms = gen_time_ms.saturating_sub(slack); let not_after_ms = gen_time_ms.saturating_add(slack); if not_before_ms > local_now_ms.saturating_add(max_skew_ms) || not_after_ms < req.sent_at_ms.saturating_sub(max_skew_ms) { return Err(Reject::NotTimely); } // Check 5. The certificate's status at genTime. `local_now_ms` is the // wrong question: a certificate revoked last week was valid when the token // was made, and a CRL fetched today may no longer list it at all. match status_at(&signer, gen_time_ms) { CertStatus::Good | CertStatus::RevokedAfterWithReason => {} CertStatus::RevokedBefore => return Err(Reject::Revoked), CertStatus::Unknown => return Err(Reject::RevocationUnknown), } // Check 5 continued, and the one most often missing: RFC 3161 Section 2.3 // requires exactly one extended key usage, id-kp-timeStamping, marked // critical. A certificate that also carries serverAuth is not a TSA // certificate under this specification. check_eku(&signer)?; // Check 6. The policy the token was issued under is one this application // accepts. The list is a deployment decision, and an empty list means the // check was skipped rather than passed. if !accepted_policies.contains(&tst.policy) { return Err(Reject::Policy); } Ok(Accepted { gen_time_ms, bound: match stated { Some(_) => Bound::Stated { not_before_ms, not_after_ms, }, None => Bound::Unstated, }, ordering: tst.ordering, policy: tst.policy, serial: tst.serial_number.as_bytes().to_vec(), }) }

Check 2 has a trap of the nonce’s family. The token’s messageImprint must have “the same value as the similar field in TimeStampReq”, and that is the whole structure: digest and AlgorithmIdentifier. Comparing the two DER encodings byte for byte is the literal reading, and for SHA-2 it rejects correct tokens. RFC 5754 Section 2 settles that case: implementations “MUST generate SHA2 AlgorithmIdentifiers with absent parameters” and must accept both the absent form and an explicit NULL, so the two encodings are one algorithm. The exception stops there — under RFC 4055 an algorithm’s parameters can carry meaning, RSASSA-PSS being the obvious case — so the normalisation is scoped to a list of OIDs and everything else is compared exactly.

/// RFC 3161 Section 2.4.2: the token's `messageImprint` "MUST have the same /// value as the similar field in TimeStampReq" — the whole structure, not just /// the digest. Comparing the DER byte for byte is the literal reading and it /// rejects correct tokens, but only for one family of algorithms, so the /// exception is scoped to that family and everything else matches exactly. fn same_imprint(token: &MessageImprint, request: &MessageImprint) -> bool { token.hashed_message.as_bytes() == request.hashed_message.as_bytes() && token.hash_algorithm.oid == request.hash_algorithm.oid && params_equal(&token.hash_algorithm, &request.hash_algorithm) } /// The SHA-2 identifiers are the exception. RFC 5754 Section 2 requires /// implementations to "accept SHA2 AlgorithmIdentifiers with absent /// parameters" and equally with NULL, while generating the absent form, so /// both encodings mean the same algorithm and either may arrive. /// /// This does not generalise. Under RFC 4055 an algorithm's parameters can /// carry meaning — RSASSA-PSS is the obvious case — and treating an absent /// field as equal to a present one there would erase part of the algorithm. /// So the normalisation applies to this list of OIDs and to nothing else. const SHA2_OIDS: [ObjectIdentifier; 4] = [ID_SHA_224, ID_SHA_256, ID_SHA_384, ID_SHA_512]; fn params_equal(a: &AlgorithmIdentifier<Any>, b: &AlgorithmIdentifier<Any>) -> bool { if SHA2_OIDS.contains(&a.oid) { return absent_or_null(a) == absent_or_null(b); } match (a.parameters.as_ref(), b.parameters.as_ref()) { (None, None) => true, (Some(x), Some(y)) => x == y, _ => false, } } /// True when the parameters field is absent or an explicit NULL. fn absent_or_null(alg: &AlgorithmIdentifier<Any>) -> bool { match alg.parameters.as_ref() { None => true, Some(p) => p.tag() == Tag::Null, } } /// RFC 3161 Section 2.4.2 on the `tsa` field: "If present, it MUST correspond /// to one of the subject names included in the certificate that is to be used /// to verify the token." The subject names are the certificate's `subject` and /// the entries of its subjectAltName extension. /// /// This compares the decoded names structurally, which is not what a complete /// verifier does. RFC 5280 Section 7.1 requires "a more comprehensive handling /// of comparison" than binary equality: distinguished names that are equivalent /// can differ in their ASN.1 representation, and DNS names compare without /// regard to case. So this listing will reject some correct tokens. It errs in /// the strict direction, which is the survivable one, but a production /// verifier replaces it with a normalising comparison rather than shipping it. pub fn tsa_name_matches(tsa: &GeneralName, cert: &Certificate) -> bool { if let GeneralName::DirectoryName(name) = tsa { if name == &cert.tbs_certificate.subject { return true; } } let Some(exts) = cert.tbs_certificate.extensions.as_ref() else { return false; }; exts.iter() .filter(|e| e.extn_id == SubjectAltName::OID) .filter_map(|e| SubjectAltName::from_der(e.extn_value.as_bytes()).ok()) .any(|san| san.0.iter().any(|n| n == tsa)) }

The extended key usage

RFC 3161 Section 2.3 is one sentence carrying three requirements: the TSA certificate “MUST contain only one instance of the extended key usage field extension”, that instance has id-kp-timeStamping as its purpose, and “This extension MUST be critical.” All three are load-bearing. A certificate carrying id-kp-timeStamping alongside serverAuth is not a TSA certificate here, because the purpose is meant to be exclusive. One whose extended key usage is not critical can be accepted by software that does not understand the extension at all. And one carrying the extension twice breaks RFC 5280 Section 4.2 — “A certificate MUST NOT include more than one instance of a particular extension” — so the verifier is the thing that finds out, and code that takes the first match and stops will happily read the harmless copy.

/// RFC 3161 Section 2.3: the certificate "MUST contain only one instance of /// the extended key usage field extension", with `id-kp-timeStamping` as its /// only purpose, and "This extension MUST be critical." All three are checked. /// Parsing `ExtendedKeyUsage` out of the first matching extension would check /// none of them: RFC 5280 forbids a repeated extension, and a verifier is the /// thing that finds out when a certificate breaks that rule. fn check_eku(cert: &Certificate) -> Result<(), Reject> { let exts = cert .tbs_certificate .extensions .as_ref() .ok_or(Reject::Eku("no extensions"))?; let mut matching = exts.iter().filter(|e| e.extn_id == ExtendedKeyUsage::OID); let ext = matching.next().ok_or(Reject::Eku("no extended key usage"))?; if matching.next().is_some() { return Err(Reject::Eku("more than one extended key usage extension")); } if !ext.critical { return Err(Reject::Eku("extended key usage is not critical")); } let eku = ExtendedKeyUsage::from_der(ext.extn_value.as_bytes()) .map_err(|_| Reject::Eku("malformed extended key usage"))?; match eku.0.as_slice() { [only] if *only == ID_KP_TIME_STAMPING => Ok(()), _ => Err(Reject::Eku("not exactly one id-kp-timeStamping purpose")), } }

Revocation as of genTime

Check 5 cannot be made later from nothing: a CRL fetched today answers a question about today. RFC 3161 Section 4 shows why the distinction changes the verdict instead of refining it. When a TSA is withdrawn without key compromise, its certificate is revoked with a reasonCode of unspecified, affiliationChanged, superseded or cessationOfOperation, and then “tokens generated before the revocation time will remain valid.” When the reasonCode extension is absent from the CRL entry, every token “signed with the corresponding key SHALL be considered as invalid.” Same certificate, same CRL, opposite outcomes, decided by an extension on the revocation entry.

So three things have to be preserved next to the token, on the day the token arrives: the certificate chain up to a root that will still be trusted, the CRL or OCSP response covering genTime with its reasonCode where present, and the time that revocation data was obtained. RFC 4998’s Evidence Record Syntax keeps the same material inside an evidence record, and Section 9 comes back to the renewal built on it. What matters here is the shape of the answer: Good, RevokedBefore, RevokedAfterWithReason, and Unknown as a fourth outcome, never a synonym for the first.

RFC 5816, and the token that parses but does not validate

RFC 3161 shipped in 2001 identifying the signer’s certificate by ESSCertID, which “only allows SHA-1 to be used as the hash algorithm to generate the identifier value” (Section 1). RFC 5816, March 2010, allows ESSCertIDv2 instead, with the note an implementer needs: SigningCertificateV2 “MUST be used if any algorithm other than SHA-1 is used”, and should not be used for SHA-1 (Section 2.2.1). Both identifiers may be present for backwards compatibility.

So a verifier written strictly from RFC 3161, looking only for SigningCertificate, finds no certificate identifier in a token from a TSA that uses SHA-256, which is what tokens look like now. The structure parses; the identification step has nothing to work with. RFC 5816 is not optional reading.

What the ecosystem does and does not give you

The crates above stop at parsing. cms 0.2.3 defines SignedData, SignerInfo and the certificate set and contains no verification code at all; neither does the 0.3.0-pre.2 preview. x509-tsp 0.1.0 is the ASN.1 of RFC 3161 and nothing more. The signature check, the chain, the extended key usage and the revocation decision are yours.

One published crate does most of it. sigstore-tsa 0.11.0 (2026-07-08) exposes verify_timestamp_response, which checks the message imprint, verifies the CMS signature, and validates the certificate chain with webpki requiring id-kp-timeStamping — as of the token’s own genTime rather than as of now, which is check 5’s first half done right. The revocation half is left out on purpose, and the source says so on the line that would carry it: “No CRL/OCSP revocation checking”. It also takes no request, so the nonce comparison and the policy check of checks 4 and 6 are not its job. Which side of the line each of the six checks falls on is worth writing down before the first token arrives.

The signature check has one trap independent of any crate. It is over the signed attributes, not over TSTInfo directly, and RFC 5652 Section 5.4 changes the encoding for that computation: the implicit [0] tag on signedAttrs is not used, “rather an EXPLICIT SET OF tag is used”. Re-encode, digest, verify — and check that the messageDigest attribute inside those attributes equals the digest of the eContent you parsed, or the signature covers attributes with nothing to do with the token you are holding.

4. What is inside an OpenTimestamps proof

The OpenTimestamps format has no specification document. The reference implementation is python-opentimestamps, and the authoritative statements are in opentimestamps/core/op.py, notary.py, timestamp.py and serialize.py.

A proof is a path: a sequence of operations applied to the document’s digest, each producing the input to the next, ending at an attestation that says what the final value should be. There is no signature anywhere in it. Timestamp in core/timestamp.py describes the shape: “a tree, with each node being a message, and the edges being operations”.

Eight operations exist, each a one-byte tag (core/op.py).

OperationTagEffect
append0xf0Append the argument to the message
prepend0xf1Prepend the argument to the message
reverse0xf2Reverse the bytes; carries a pending-deprecation warning in the source
hexlify0xf3Lower-case hex of the message
sha10x0220 bytes
ripemd1600x0320 bytes
sha2560x0832 bytes
keccak2560x6732 bytes

append and prepend carry an argument, serialised as a length-prefixed byte string; the other six are the tag alone. Two limits bound a verifier’s memory: MAX_RESULT_LENGTH and MAX_MSG_LENGTH are both 4096, and hexlify halves the message limit to 2048 because it doubles its input. The source says what the limit is for: it bounds “the maximum amount of memory you need at any one time”.

An attestation is an eight-byte tag followed by a length-prefixed payload, capped at 8192 bytes (core/notary.py). Three concern a verifier.

AttestationTagPayloadMeaning
Pending83dfe30d2ef90c8eA calendar URI”Commitment has been recorded in a remote calendar for future attestation”
Bitcoin block header0588960d73d71901A block height, as a varint”The commitment digest will be the merkleroot of the blockheader”
Unknownany otherKept verbatimAnything this build does not know is preserved, tag and payload intact

The pending URI is not a free-form URL. PendingAttestation caps it at 1000 bytes and restricts it to A-Za-z0-9-._/:, which leaves out query strings, fragments, percent-encoding, IPv6 bracket notation and @ login notation. The comment gives the reason: the software fetches this URI under some circumstances.

The Bitcoin attestation carries a height and nothing else, and the source explains the omission as a design choice against implementations taking a shortcut: a verifier is meant to fetch the header by height, check the Merkle roots match and read the time off the header, because “Providing more data would encourage implementations to cheat.” There are two further attestation tags: Litecoin, 06869a0d73d71b45, whose verify_against_blockheader raises NotImplementedError in the Python implementation, and Ethereum, 30fe8087b5c7ead7, which lives in core/dubious/notary.py — the module name is the reference implementation’s own classification.

The bytes

A .ots file is a 31-byte magic, a one-byte major version, then the file-hash operation tag, the digest, and the path. DetachedTimestampFile fixes the magic at \x00OpenTimestamps\x00\x00Proof\x00\xbf\x89\xe2\xe8\x84\xe8\x92\x94 and MAJOR_VERSION at 1; a file digest is between 20 and 32 bytes. Integers are LEB128 — the serialiser’s own comment is “unsigned little-endian base128 format (LEB128)” — and byte strings are a LEB128 length followed by the bytes.

Inside the path, two tag values are not operations. 0x00 means an attestation follows and this branch ends; 0xff means the path forks and another branch follows. Everything else is an operation tag. Here is the complete 265-byte two-calendars.txt.ots from the client’s examples, annotated:

00..1e 00 4f 70 65 6e 54 69 6d 65 73 74 61 6d 70 73 00 HEADER_MAGIC, 31 bytes 00 50 72 6f 6f 66 00 bf 89 e2 e8 84 e8 92 94 1f 01 MAJOR_VERSION 20 08 file hash op: sha256 21..40 ef aa 17 4f ... 29 40 4d db the file's SHA-256, 32 bytes 41 f0 10 83 90 37 ee f4 49 de c6 da c3 22 ca 97 34 append, 16-byte argument 7c 45 53 08 sha256 54 ff fork: two branches follow 55 f0 10 6b 40 23 b6 ed d3 a0 ee eb 09 e5 d7 18 72 branch 1: append 16 bytes 3b 9e 67 08 sha256 68 f1 04 57 d4 65 15 prepend 4 bytes 6e f0 08 ea dd 66 b1 68 8d 55 74 append 8 bytes 78 00 attestation follows, branch ends 79 83 df e3 0d 2e f9 0c 8e pending attestation tag 81 2e payload: 46 bytes 82 2d 68 74 74 70 73 3a 2f 2f 61 6c 69 63 65 ... URI: 45 bytes, https://alice.btc.calendar.opentimestamps.org b0 f0 10 a3 ad 70 1e ... branch 2, from the fork at 0x54, ending at a pending attestation for bob

That layout is why an OpenTimestamps proof is small and why a proof can hold several anchors at once. The fork costs one byte. The client submits to more than one calendar by default and the path splits after the first sha256, so losing one calendar loses one branch.

Reading it in Rust is the opentimestamps crate, version 0.2.0, published 2023-04-12. It parses and serialises the format and executes the operations while parsing; it compares nothing against a block header, which is correct, because it has no way to obtain one. Two gaps are worth knowing before you depend on it: its Op enum has no keccak256, so tag 0x67 is a BadOpTag error rather than an operation, and its Attestation enum knows only Bitcoin and pending, so Litecoin and Ethereum attestations arrive as Unknown with their tag and payload intact.

5. Verifying the proof: replaying operations into a block header

The whole procedure: hash the document, run the operations, and compare the 32 bytes you end up with against the Merkle root of the block at the stated height. The time is then the header’s nTime field.

Three things make that harder than it reads. The parser hands you the operation results it computed itself, so a verifier that reads step.output has checked nothing. The header has to come from somewhere, and where it comes from is the whole trust decision, so it belongs in the function’s signature. And a proof can carry several attestations: folding their outcomes into one Result throws away the difference between a proof nobody could check and a proof that did not check out.

/// What the caller has to bring: a block header per height, from its own node. /// Nothing in the proof, and nothing in this crate, can supply it. pub trait HeaderSource { /// Merkle root in internal byte order, and the header's `nTime`, for the /// block at `height` on the chain this source considers best. fn header(&self, height: usize) -> Option<([u8; 32], i64)>; } /// One Bitcoin attestation, after the header lookup. The three non-confirming /// outcomes are different facts and a caller that folds them together loses /// the only information it has about why. #[derive(Debug, PartialEq, Eq)] pub enum AttestationOutcome { /// A header exists at that height and commits to the value. Confirmed { height: usize, block_time: i64 }, /// The caller's node has no header at that height: an inability. Reasons /// include a node still syncing and a height beyond its tip. NoHeader { height: usize }, /// A header exists at that height and its Merkle root is something else. /// Causes include a reorg that moved the committing block off this node's /// best chain, a header source that is stale or wrong, and a proof that /// does not belong to this document after all. RootMismatch { height: usize }, /// The path reached a Bitcoin attestation with something other than 32 /// bytes, which cannot be a Merkle root. NotADigest { height: usize, len: usize }, } #[derive(Debug, PartialEq, Eq)] pub enum Verdict { /// At least one attestation confirmed. The earliest confirming block is /// the bound the proof supports. A proof can be `Anchored` and still carry /// a mismatch elsewhere; the rule is existential, and `attestations` keeps /// the rest for whoever is watching. Anchored { height: usize, block_time: i64 }, /// Nothing confirmed, and at least one thing was never looked at: a header /// the caller does not have, an attestation whose value is not a digest, /// or a path still waiting on a calendar. An inability, not a finding. Indeterminate, /// Nothing confirmed and nothing left unlooked-at: every attestation in /// the proof — including the ones this build cannot interpret, of which /// there must be none for this verdict — was compared against a header and /// none matched. Still not proof that anyone forged anything, see /// `AttestationOutcome`, but it is the only case where the evidence is /// against the proof rather than absent. Contradicted, } /// The verdict plus everything that produced it. A caller that only reads the /// verdict is throwing away its alarms: a proof with one confirming and one /// mismatching attestation is `Anchored`, and it is also something to look at. #[derive(Debug)] pub struct Outcome { pub verdict: Verdict, pub attestations: Vec<AttestationOutcome>, pub calendars: Vec<String>, pub unknown_tags: Vec<Vec<u8>>, } #[derive(Debug)] pub enum OtsError { /// The proof is for a different document, or for a digest algorithm this /// code does not handle. DigestMismatch, /// An operation's stored result differs from replaying it. StepMismatch, /// Nothing in the proof leads anywhere: no attestation of any kind. NoAttestation, } /// Walk every path in the proof, recomputing each operation rather than /// trusting the result the parser stored next to it. /// /// Depth is bounded before this runs: `Timestamp::deserialize` in the /// `opentimestamps` crate refuses to nest more than `RECURSION_LIMIT` (256) /// levels and returns `Error::StackOverflow`, so a tree that reaches this /// function is already shallow enough to recurse over. pub fn walk(step: &Step, msg: Vec<u8>, out: &mut Reached) -> Result<(), OtsError> { let next_msg = match &step.data { StepData::Fork => msg, StepData::Op(op) => { let computed = op.execute(&msg); if computed != step.output { return Err(OtsError::StepMismatch); } computed } StepData::Attestation(a) => { match a { // The length check belongs at the header comparison, where the // outcome is reported per attestation instead of failing the // whole proof. Attestation::Bitcoin { height } => out.bitcoin.push(BitcoinCommitment { height: *height, reached: msg.clone(), }), Attestation::Pending { uri } => out.pending.push(uri.clone()), Attestation::Unknown { tag, .. } => out.unknown.push(tag.clone()), } msg } }; for child in &step.next { walk(child, next_msg.clone(), out)?; } Ok(()) } /// The whole verification: hash the document, replay the path, compare each /// result with a header the caller's node supplied, and read the time off the /// header. Every attestation is tried and every outcome is kept; one that /// confirms is enough for the verdict, which is the same acceptance rule a /// receipt carrying two kinds of anchor needs. pub fn verify( ots: &DetachedTimestampFile, document: &[u8], headers: &dyn HeaderSource, ) -> Result<Outcome, OtsError> { if ots.digest_type != DigestType::Sha256 { return Err(OtsError::DigestMismatch); } let digest: [u8; 32] = Sha256::digest(document).into(); if digest.as_slice() != ots.timestamp.start_digest.as_slice() { return Err(OtsError::DigestMismatch); } let mut reached = Reached::default(); walk(&ots.timestamp.first_step, digest.to_vec(), &mut reached)?; if reached.bitcoin.is_empty() && reached.pending.is_empty() && reached.unknown.is_empty() { return Err(OtsError::NoAttestation); } let mut outcomes = Vec::with_capacity(reached.bitcoin.len()); let mut best: Option<(usize, i64)> = None; for c in &reached.bitcoin { if c.reached.len() != 32 { outcomes.push(AttestationOutcome::NotADigest { height: c.height, len: c.reached.len(), }); continue; } let outcome = match headers.header(c.height) { None => AttestationOutcome::NoHeader { height: c.height }, Some((root, time)) if root.as_slice() == c.reached.as_slice() => { if best.map_or(true, |(h, _)| c.height < h) { best = Some((c.height, time)); } AttestationOutcome::Confirmed { height: c.height, block_time: time, } } Some(_) => AttestationOutcome::RootMismatch { height: c.height }, }; outcomes.push(outcome); } // `Contradicted` is reserved for the case where every attestation in the // proof was looked at and none of them matched. Anything left unlooked-at // keeps the answer at "I could not establish this": a header the caller // could not fetch, a value that is not a digest, a calendar still // outstanding — and an attestation whose tag this build does not know, // which is the easiest one to forget, because it never reaches the loop // above at all. let unchecked = !reached.pending.is_empty() || !reached.unknown.is_empty() || outcomes.iter().any(|o| { matches!( o, AttestationOutcome::NoHeader { .. } | AttestationOutcome::NotADigest { .. } ) }); let verdict = match best { Some((height, block_time)) => Verdict::Anchored { height, block_time }, None if unchecked => Verdict::Indeterminate, None if outcomes.is_empty() => Verdict::Indeterminate, None => Verdict::Contradicted, }; Ok(Outcome { verdict, attestations: outcomes, calendars: reached.pending, unknown_tags: reached.unknown, }) }

Run over hello-world.txt.ots from the client’s examples — 688 bytes, 38 operations — the path starts at the SHA-256 of Hello World!\n, 03ba204e…6ab340, takes a RIPEMD-160, is prepended and appended into a Bitcoin transaction, double-SHA-256s into a transaction id, climbs eleven levels of the block’s Merkle tree, and arrives at 007ee445d23ad061af4a36b809501fab1ac4f2d7e7a739817dd0cbb7ec661b8a with a Bitcoin attestation for height 358391. That is the Merkle root of block 358391 in internal byte order; an explorer prints it reversed, as 8a1b66ec…e47e00. The replay takes microseconds. With a HeaderSource that has nothing at that height, the verdict is Indeterminate and the attestation’s outcome is NoHeader.

Four things follow from the shape of that function.

Four outcomes, not two. Confirmed, NoHeader, RootMismatch and NotADigest are different facts, and a verifier returning a boolean has discarded three of them. NoHeader and NotADigest are inabilities: no header at that height, or a path arriving at something that is not 32 bytes and so cannot be a Merkle root. RootMismatch is where the caller did look and the block at that height commits to something else, and its causes are several — a reorg moved the committing block off this node’s best chain, the header source is stale or wrong, the proof does not belong to this document. A reorg is one of them, which is why the reference implementation stores nothing but the height: “in the event of a reorg the merkleroot will be invalid anyway”. None of the three says anybody forged anything, and a verifier that reports a mismatch as a refutation of the document makes an accusation the data does not support.

The verdict follows, deliberately lopsided. Anchored when at least one attestation confirms, which is the acceptance rule Section 8 needs one level up; a proof can be Anchored and still carry a mismatch elsewhere, which is correct for an existential rule and is also worth an alarm, so every outcome stays in the result. Contradicted only when every attestation in the proof was compared against a header and none matched. One height the caller could not fetch, one value that is not a digest, one calendar still outstanding — and the answer is Indeterminate, because something was never looked at.

One member of that list is easy to drop, and dropping it turns the verdict into a lie. An attestation whose eight-byte tag this build does not recognise never becomes an AttestationOutcome at all: the walk puts it aside as an unknown tag, exactly as the format intends, and the comparison loop never sees it. A verdict that counts only the outcomes it produced will therefore call a proof Contradicted while an attestation nobody could read sits next to it. The Rust crate makes that concrete, not theoretical, by decoding Litecoin and Ethereum attestations as Unknown. So unknown tags count as unlooked-at, and Contradicted means every attestation, not every one this build happens to understand.

A pending path is not a verdict yet. A proof whose paths all end at pending attestations proves nothing about time and may never prove anything: the calendars named in it are the only addresses that can complete it. Why a proof arrives pending at all is the audit-log article’s ground; the operating consequences are here. The client’s own verify calls upgrade_timestamp before it checks anything, so ots verify reaches out to calendars unless the proof is already complete — which means a verifier that believes it is offline may not be. Or take ots stamp --wait, which waits for confirmation at stamping time and produces a proof that needs no calendar afterwards, at the cost of a stamping call that takes hours. The design’s framing of a calendar’s power is the reassuring half — “Calendars aren’t authoritative — at worst they can deny service, not produce false proofs” — and denial of service is exactly what an un-upgraded proof suffers.

The header source is the trust decision. The client’s documentation is blunt about what it needs: “To verify timestamps you need a local Bitcoin Core node (a pruned node is fine).” A pruned node still has every header. Replacing it with a public API returns the problem the anchor was chosen to remove — the verdict depends on a server’s word, as the RFC 3161 path depends on a TSA’s key, except that nobody signed anything and there is no certificate to examine afterwards. Where a deployment genuinely cannot run a node, the honest form is a pinned header set delivered out of band, with a stated provenance and freshness, recorded in the receipt as what it was.

Byte order will cost you an afternoon. The attestation digest is the Merkle root in the internal order the header uses, the reverse of the order everything human-readable prints. A comparison that fails on every proof, with no near-misses, is usually this.

6. Time, precisely

The two mechanisms support two different sentences, and the difference is not one of resolution.

RFC 3161. genTime is a clock reading by the TSA and accuracy is the interval around it: adding it gives an upper limit, subtracting it a lower one, and “a value of zero MUST be taken for the missing field”. The field carries seconds, milliseconds and microseconds, so a verifier that works in whole seconds narrows the interval it reports without saying so. Milliseconds, with sub-millisecond accuracy rounded outward, keep the reported interval at least as wide as the one the RFC describes.

/// RFC 3161 Section 2.4.2: adding accuracy to genTime gives an upper limit and /// subtracting it a lower one, and "a value of zero MUST be taken for the /// missing field" — that rule is about a missing *component*, not a missing /// `accuracy`. `None` here means the token stated no accuracy at all, which /// the caller must not read as zero. /// /// Milliseconds, because the field carries millis and micros; sub-millisecond /// accuracy rounds outward, so the interval is never narrower than the RFC's. /// /// Every number arrives on the wire. `millis` and `micros` are typed `i16` by /// the crate while the RFC constrains them to 1..999, and `seconds` is a `u64` /// with no ceiling at all, so out-of-range values are rejected as malformed /// and the arithmetic is checked. A verifier that clamps instead reports a /// narrower interval than the token claims. fn accuracy_millis(tst: &TstInfo) -> Result<Option<i64>, Reject> { let Some(a) = tst.accuracy.as_ref() else { // Not zero: unstated. RFC 3161 says the bound "may be available // through other means, e.g., the TSAPolicyId", and this function // cannot read a policy document. return Ok(None); }; let secs = i64::try_from(a.seconds.unwrap_or(0)).map_err(|_| Reject::Malformed("accuracy"))?; let millis = in_range(a.millis)?; let micros = in_range(a.micros)?; secs.checked_mul(1000) .and_then(|ms| ms.checked_add(millis)) .and_then(|ms| ms.checked_add((micros + 999) / 1000)) .map(Some) .ok_or(Reject::Malformed("accuracy")) } /// `Accuracy`'s millis and micros are `INTEGER (1..999)` in RFC 3161, and /// absent means zero. Anything else is malformed, including the zero the /// crate's `i16` allows and the negatives it also allows. fn in_range(v: Option<i16>) -> Result<i64, Reject> { match v { None => Ok(0), Some(n) if (1..=999).contains(&n) => Ok(n as i64), Some(_) => Err(Reject::Malformed("accuracy")), } }

Two limits sit on top of that. genTime arrives at whole-second resolution in this stack, because RFC 3161 permits fractional seconds and der’s GeneralizedTime refuses to decode them: a token that uses them does not parse here at all. And accuracy is optional — the live token captured for this article’s tests has no such field — in which case the RFC puts the bound elsewhere: it “may be available through other means, e.g., the TSAPolicyId”.

That second case is where a verifier quietly lies to whoever reads its output. An absent accuracy is not an accuracy of zero, and a result type with not_before and not_after fields cannot say so: it reports genTime ± 0, which reads as a verified interval and is a bound nobody claimed. So the function returns Option and the result carries Bound::Unstated. It is the same disease as reporting an unverifiable anchor as valid, one scale down: an interval without a stated accuracy is not a narrow interval, it is not an interval.

The sentence you can put into an audit procedure is: this hash existed no later than genTime plus accuracy, as asserted by this TSA under this policy OID, subject to the TSA’s key not having been compromised before that moment. The policy is part of the claim, not decoration.

Bitcoin. A block header’s time is a field a miner fills in, and two consensus rules constrain it. Bitcoin Core v31.1 src/validation.cpp, in ContextualCheckBlockHeader, rejects a header whose time is at or below the previous block’s median time past — time-too-old — and rejects one whose time exceeds the validating node’s own clock by more than MAX_FUTURE_BLOCK_TIME, which src/chain.h sets to 2 * 60 * 60 seconds — time-too-new. Median time past is the median of eleven headers: nMedianTimeSpan is 11 in the same file. The developer reference states both rules: the time “Must be strictly greater than the median time of the previous 11 blocks”, and full nodes reject headers more than two hours ahead of their own clock.

The two rules give a verifier very different things.

The floor is a property of the chain. It is computed from eleven headers that anyone holding the chain also holds, so a verifier in 2036 recomputes it from its own header set and gets the same answer. That is evidence.

The ceiling is not. It was applied by whichever nodes were validating when the block arrived, against their own clocks, and it left nothing behind. Core’s code says so twice. The rejection carries BLOCK_TIME_FUTURE, which src/consensus/validation.h describes as “block timestamp was > 2 hours in the future (or our clock is bad)” — the parenthesis is the specification admitting whose property the check is. And that is a different result from BLOCK_INVALID_HEADER, which the same enum reserves for “invalid proof of work or time too old”: too far ahead is deferred, too old is invalid for good. The ceiling is therefore a statement about the clocks of parties a verifier cannot name, at a moment it cannot revisit.

Block 358391 from Section 5 shows what the floor leaves open. Its header claims 2015-05-28 15:41:18 UTC; the median of the eleven headers ending at block 358390 is 14:29:06 UTC, which puts 72 minutes and 12 seconds between the floor and the claim. Those two values were read from block explorers on 2026-09-16, and a verifier reads them from its own node. The floor is strict and one-sided: it rules out every time at or below 14:29:06 UTC and rules out nothing above it, so the chain records no upper limit on what the miner could have written there.

So the defensible sentence is: this hash existed no later than the moment the network built a block committing to it; that block’s header claims 15:41:18 UTC, and the chain still proves only that the claim is above the eleven-header floor of 14:29:06 UTC. Use the header time if the procedure needs one number, and say next to it that the time is the miner’s claim and that the two-hour ceiling was enforced at acceptance rather than recorded. For a bound on how early the value could have been backdated to, the eleven-header median is the only part a verifier can check for itself.

RFC 3161 gives an interval that a named party asserts and can be held to. OpenTimestamps gives a floor that anyone with the headers can recompute, and above it a time that a miner did assert — with no identity attached to the assertion, nobody to answer for it, and nobody able to take it back.

7. What each one proves, side by side

AxisRFC 3161 tokenOpenTimestamps proof
Trust ends atThe TSA’s private key, its certificate chain, and a revocation decision as of genTimeThe block header at the stated height on the chain the verifier’s node follows
What the verifier needs offlineThe token, the TSA certificate and chain, revocation data captured at stamping time, and a root it still trustsThe proof, and block headers from its own node or a pinned, out-of-band header set
Time claimedgenTime ± accuracy, under a named policy, asserted by the TSAThe header’s nTime, which the chain still proves to be above the eleven-header median floor. The two-hour ceiling was applied at acceptance and is not recoverable from the chain (Section 6)
Ready whenUsually with the response. RFC 3161 also defines a waiting status and polling, so “synchronous” is a property of the deployment’s profile, not of the protocolHours, because a calendar commits many digests in one transaction. The proof arrives pending and needs an upgrade, unless the client waits for confirmation
If the operator disappears, before upgradeRenewal and revocation data end with it. Tokens signed earlier stay checkable only if the chain and the CRL or OCSP response were capturedA pending proof whose calendars are all gone cannot be completed. The path to a block was never in the file
If the operator disappears, after upgradeSame as above: the token is only as checkable as the material captured beside itNo effect on the proof. The path to the block is in the file and the calendar was a delivery route
If the key is compromisedEvery token that key signed becomes untrustworthy, including the honest onesNo key signs an OTS proof, so the question does not arise
What a third party learnsThe imprint only. RFC 3161 Section 4 notes that identical imprints let an observer infer that stamps refer to the same underlying dataThe digest only: “a remote calendar learns nothing about the contents of anything you timestamp”. Stamping several files at once makes their paths share operations
Work per anchored pointOne request and one responseOne submission per calendar; one Bitcoin transaction covers every digest in that round
After ten yearsDepends on reassembling trust material, on the token’s hash and signature algorithms, and on renewal having happened before the certificate expiredDepends on the headers being available and on SHA-256

The last rows are one fact seen several ways. The RFC 3161 column concentrates risk in a key and the organisation holding it; the OpenTimestamps column concentrates it in the availability of a header set and in one hash function.

8. Why a production log carries both

The two anchors fail for unrelated reasons. Revoking a TSA certificate, losing its issuer, or an operator closing down does not touch a proof already committed to a block. A calendar outage, an upgrade job that never ran, or a verifier with no node does not touch a token already in hand. That is not full independence, and a design document should say what the two still share: the receipt itself has to survive, the hash algorithm underneath both has to hold, and one buggy verifier can get both wrong at once. What it does mean is that no single event in the list above takes away both. RFC 3161 Section 4 proposes the pairing in its weaker form, as a mitigation for key compromise: “Two time-stamp tokens from two different TSAs is another way to address this issue.” Two different mechanisms is the same argument with a wider gap between the failures.

The cadences differ, which is what makes two anchors practical at all: a token is a request and a response, so it fits the write path; a Bitcoin anchor is hours late and covers a batch. How a log arranges that — how often it signs a checkpoint, how many checkpoints one anchor spans, and why the anchor goes over a root rather than over entries — is the audit-log article’s subject and is upstream of everything here.

What goes in the receipt follows from Sections 3 and 5, which is to say it is everything the verifier will not be able to fetch later:

  • The token itself, the TSA certificate chain, and the revocation data that covered genTime with the time it was obtained. Without the third item, check 5 is unanswerable and the honest verdict is Unknown.
  • The OpenTimestamps proof after upgrade, not before. A pending proof in a receipt is a promise, and it needs a job that turns it into a proof and an alarm for when that job stops.
  • A note of which hash algorithms each anchor used, per Section 9.

Then the acceptance rule, which is the Anchored rule of Section 5 applied one level up. A receipt is valid when at least one anchor verifies against trust material the caller supplied; a receipt where no anchor could be checked is indeterminate, not valid and not refuted. The distinction is the difference between an inability and a refutation, and collapsing it in either direction produces a verifier that lies. Collapsing towards valid accepts receipts nobody checked. Collapsing towards invalid turns a missing CRL into an accusation.

9. Ten years later

Ten years is the horizon these mechanisms are bought for, and each one has a different thing that decays.

The token’s problem is the key and the chain. A TSA signing key has a finite lifetime, and RFC 3161 Section 4 says what to do about it: any token it signed “SHOULD be time-stamped again (if authentic copies of old CRLs are available)”, or notarized at a later date if they are not. RFC 4998’s Evidence Record Syntax does that in bulk, and the audit-log article covers the operating side. The part that belongs here is the deadline. Renewal has to happen while the old material is still assemblable, which means before the certificate expires and before the signature or hash algorithm stops being acceptable. The constraint is the material, not the company: RFC 3161 makes re-stamping conditional on authentic copies of the old CRLs being available, and a still-trading issuer does not help if those copies were never kept. A token renewed while its validation data can still be assembled is evidence; one renewed after that is a file.

Changing TSA is a renewal event, not a configuration change. Existing tokens do not migrate: each keeps pointing at the old authority’s key. A fresh token from the new authority over the same value does not rescue them, because it proves the value existed by the new token’s genTime and says nothing about the old one’s. What preserves the older claim is stamping the old token itself, or the evidence record it sits in, with the new authority while the old chain and revocation data can still be assembled. Missing that window does not make the old token false; it makes it unverifiable, which for an auditor is the same outcome.

The proof’s problem is smaller and stated more plainly. A Bitcoin attestation needs the header set and SHA-256. The client’s documentation makes the claim explicit: the format is stable, past timestamps stay verifiable, and a 51% attack on Bitcoin is “not sufficient to make Bitcoin timestamps from the past unverifiable”, because the chain is widely witnessed. A full header set for the whole chain is a fixed 80 bytes per block, and keeping your own copy removes the last dependency on anyone else’s availability.

Hash agility is a field, not an intention. python-opentimestamps carries a comment that sets a boundary worth reading exactly: “Remember that for timestamping, hash algorithms with collision attacks are secure!”, the reasoning being that two messages with the same digest still both existed before the anchor. That holds for a collision discovered after the fact. It does not hold against someone who constructs the collision first: a pair built before stamping lets its author anchor one document and later present the other. Neither format carries a mitigation for that. The conclusion that follows from the same premise is that the mitigation has to sit in the log: every checkpoint records the hash algorithm it used, so a future verifier can tell which anchors predate a break, and new anchors move to a new algorithm on a schedule rather than on an incident.

10. eIDAS, briefly

For deployments in the EU, Regulation (EU) No 910/2014 as amended is where these artefacts acquire a legal status. This is not legal advice; the boundary the text draws is the useful part.

Article 3(33) defines an electronic time stamp as data that binds other data to a particular time, “establishing evidence that the latter data existed at that time”. That fits both artefacts here.

Article 41 splits them. Paragraph 1 says an electronic time stamp is not denied legal effect or admissibility “solely on the grounds that it is in an electronic form”, or because it is not qualified. Paragraph 2 gives the qualified kind something extra: “the presumption of the accuracy of the date and the time it indicates”, and of the integrity of the data bound to it.

Article 42(1) says what qualified means: the stamp binds date and time to data so as to “reasonably preclude the possibility of the data being changed undetectably”; it is “based on an accurate time source linked to Coordinated Universal Time”; and it is signed or sealed with an advanced electronic signature or seal of the qualified trust service provider, or by an equivalent method. Paragraph 1a, added by Regulation (EU) 2024/1183, presumes compliance where the binding and the time source follow reference standards the Commission is to list by implementing act.

Where that leaves an OpenTimestamps proof is a narrower answer than it looks. An ordinary proof carries neither an advanced electronic signature nor an advanced electronic seal, and 42(1)(c) wants one of those from the qualified trust service provider “or by some equivalent method”. Whether a commitment in a public chain falls under that phrase the Regulation’s text does not decide, and 42(2) provides for a list of reference standards without saying that a method outside the list cannot qualify. The question of qualified status for this artefact is therefore open, not closed against it, and what follows for an engineer is only this: Article 41(2)‘s presumption attaches to a qualified electronic time stamp, so a design cannot assume it for an OpenTimestamps proof. The Regulation says nothing about who operates a calendar, and neither does the proof.

The symmetric point is easy to lose. An RFC 3161 token does not carry that presumption either, on its own. Article 41(2) attaches to a qualified electronic time stamp, and Article 3(34) defines that as one meeting the requirements of Article 42 — among them 42(1)(c), which names the qualified trust service provider as the signer or sealer. A token from a TSA without that status sits under 41(1) alongside the OpenTimestamps proof: not denied legal effect, and not presumed accurate. So the line to carry out of this section is not token-versus-proof. It is qualified-versus-everything-else, and which side a token falls on depends on its issuer’s status, not on its format. Separately, and about the log rather than either anchor, the same amendment added Article 45l, whose requirements for a qualified electronic ledger include “the unique sequential chronological ordering of data records” and recording data “in such a way that any subsequent change to the data is immediately detectable”.

11. What neither proves

  • The content. Both bind a hash. Nothing in either says that what the data asserts is true, or that the event it describes happened.
  • That the data did not exist earlier. The claim is “no later than”. Both formats are silent about any earlier moment, and a timestamp on backdated data timestamps the backdated data.
  • That the log is complete. An anchor covers the value it was given. An entry the application never submitted leaves nothing behind for an anchor to miss.
  • That you were the one who stamped it. RFC 3161 requires the TSA “not to include any identification of the requesting entity in the time-stamp tokens” (Section 2.1), and an OpenTimestamps proof carries no identity at all. Anyone holding the same hash obtains the same evidence. Binding a stamp to an actor is the log’s job, through what the hashed record contains.
  • That the anchor was ever obtained for the checkpoint being shown. A receipt presented without an anchor is evidence of nothing about time, and a verifier that treats absence as success has a policy bug, not a cryptographic one.
  • That a verifier ten years from now can still assemble the trust material. For a token that means a chain and a revocation decision at genTime; for a proof it means headers. Neither format stores what it needs.
  • Anything about an intrusion you cannot date. The OpenTimestamps announcement states the limit for that case: timestamps “can’t help you if you don’t know when the intrusion happened”, and for records written after it the intruder could have stamped the modified data just as easily.

12. One assembly

In ATL each receipt carries both anchors, and the verifier treats them as two separate pieces of evidence, with no primary and no fallback. The library does no I/O at all: it is handed a receipt and trust material — a certificate chain and revocation data for the token, block headers for the proof — and returns a result in which an inability is a distinct outcome from a refutation. A receipt is accepted when at least one anchor verifies; one whose only anchor is an OpenTimestamps proof is not accepted by the library alone, because the header comparison belongs to the caller that has a node. Its test suite runs at a pinned revision, which is the only way I know to keep “the verifier still behaves the same” a checkable claim.

The code in this article is the shape of both halves without the ATL parts: x509-tsp and cms for the token’s structure, opentimestamps for the proof’s, and the checks that neither crate makes written out as functions with the missing inputs in their signatures. Everything compiles; the RFC 3161 half was exercised against a live token and the OpenTimestamps half against the reference client’s own example proof, which still replays to block 358391 eleven years later. What I would change about my first attempt is the order I wrote the checks in. I built the cryptography first and the certificate-status question last, and it is the certificate-status question that decides what a verifier has to keep on the day a token arrives — by the time it comes up, the thing it needs is already unavailable.


If you are building the verifier rather than the service, and the question is which anchors a receipt has to carry and what has to be stored beside them, that is contract work I take on.

Last updated on