A hash chain proves a record was not modified after it was written. It does not prove when it was written, and it does nothing against someone who controls the server and can re-sign the whole chain. RFC 3161 trusted timestamps close that gap. This is a practical look at what they are, how to wire them in, and where the footguns are.
The gap a hash chain leaves open
If you hash-chain your records — hash[n] = H(payload[n] || hash[n-1]) — anyone can later walk the chain and prove no entry was altered. Tamper with payload[2] and every subsequent link breaks. That property is real and useful.
But it assumes one thing the chain itself cannot guarantee: trust in when the signing happened.
- It can't prove when a record was created. The whole chain could have been generated yesterday and backfilled.
- It can't stop a server operator with the signing key from regenerating the entire chain from scratch, picking whatever
payloadthey like, and presenting it as the original. - It can't defend against "we lost the logs for March, so we rebuilt them" — a story auditors hear more often than they should.
A hash chain is integrity. It is not time and it is not non-repudiation. Those need an anchor outside your control.
What RFC 3161 actually gives you
RFC 3161 defines a protocol for a Time Stamp Authority (TSA): a third party you trust to vouch for "this exact hash existed at this exact time."
The flow is small:
- You compute the hash of whatever you want to anchor (one record, or the head hash of your whole chain).
- You send that hash to the TSA — not the data, just the digest. The TSA never sees your content.
- The TSA returns a Time Stamp Token: a CMS signature (PKCS#7) that binds your hash to a timestamp, signed with the TSA's private key.
- You store the token next to the record (or the chain head).
The token says, in effect: "A hash equal to this one was presented to me at 2026-09-10T14:03:11Z, signed, TSA: Acme-Time." Because it's signed by the TSA, you can't forge it, and because the hash is inside the signed blob, you can't swap the content after the fact.
Two consequences worth stating plainly:
- The TSA attests to the hash, not the data. You still store the data yourself; the token only proves the data's digest existed at a time.
- The token proves external, third-party time. Even if an attacker owns your server and your signing keys, they can't produce a valid token for a hash dated before they took over — unless they also compromised the TSA, which is the whole point of outsourcing that trust.
Two anchoring strategies
Per-record stamping. Stamp every record as it arrives. Maximum granularity, maximum TSA calls. Fine for low-volume, high-value events (a compliance audit log, a signing certificate log). You store token[n] alongside record[n].
Periodic chain-head stamping. At a fixed interval (every minute, every flush), take the current head hash of the chain and stamp that. One token anchors everything written since the previous token. This is usually the right trade-off: a single TSA call amortizes over thousands of records, and it still proves the entire chain up to that head existed at that time.
For most systems I'd reach for periodic head-stamping, with per-record stamping reserved for the few events where "exactly when" is itself the evidence (a key rotation, a privilege grant).
What a request looks like
Using Bouncy Castle (Java), a minimal stamp request:
import org.bouncycastle.tsp.TimeStampRequestGenerator;
import org.bouncycastle.tsp.TimeStampRequest;
import org.bouncycastle.tsp.TimeStampResponse;
import org.bouncycastle.tsp.TimeStampToken;
import org.bouncycastle.cms.CMSSignedData;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.security.MessageDigest;
import java.util.Base64;
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(payload); // the record's hash, or your chain head
TimeStampRequestGenerator gen = new TimeStampRequestGenerator();
gen.setCertReq(true); // ask the TSA to include its cert in the response
TimeStampRequest req = gen.generate(
org.bouncycastle.tsp.TSPAlgorithms.SHA256, digest);
byte[] der = req.getEncoded(); // DER-encoded ASN.1 request
URL tsu = new URL("https://tsa.example.com/tsa");
HttpURLConnection c = (HttpURLConnection) tsu.openConnection();
c.setRequestMethod("POST");
c.setRequestProperty("Content-Type", "application/timestamp-query");
c.setDoOutput(true);
try (OutputStream os = c.getOutputStream()) { os.write(der); }
byte[] respDer = c.getInputStream().readAllBytes();
TimeStampResponse tsr = new TimeStampResponse(respDer);
tsr.validate(req); // throws if the response doesn't match your request
TimeStampToken token = tsr.getTimeStampToken();
byte[] tokenDer = token.getEncoded(); // store this
String tokenB64 = Base64.getEncoder().encodeToString(tokenDer);
What you persist is tokenB64 (or the raw DER). The TSA URL, the digest algorithm, and the request nonce are configuration, not secrets.
Verification
Verification is the part auditors actually run, so it must be boring and offline-capable:
// tokenDer = the stored DER; digest = the hash you recompute from the data
import java.security.MessageDigest;
import java.security.cert.X509Certificate;
import java.util.Collections;
import java.util.Date;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.jcajce.JcaCertStore;
import org.bouncycastle.cms.CMSSignedData;
import org.bouncycastle.tsp.TimeStampToken;
import org.bouncycastle.util.Store;
// Validate against YOUR pinned TSA root, not the certs bundled in the
// token. Trusting the embedded certs would let a rogue TSA vouch for itself.
X509Certificate tsaRoot = loadTrustedCert("tsa-root.pem"); // pinned, from config
Store<X509CertificateHolder> trust =
new JcaCertStore(Collections.singletonList(tsaRoot));
TimeStampToken token = new TimeStampToken(new CMSSignedData(tokenDer));
// 1) the token's embedded hash must equal the data's current hash
byte[] signedDigest = token.getTimeStampInfo().getMessageImprintDigest();
if (!MessageDigest.isEqual(signedDigest, digest)) {
throw new IllegalStateException("hash mismatch - data was altered");
}
// 2) the TSA's signature must validate against your trusted store
token.validate(trust); // throws on bad signature or untrusted issuer
// 3) the time must be within the window you expect
Date t = token.getTimeStampInfo().getGenTime();
// compare t against the record's claimed time / policy window
Three checks, in order: the imprint matches your data, the TSA signature is valid against a cert you trust, and the time falls where policy says it should. If all three pass, you have third-party proof the data existed at t.
The footguns
This is where most implementations quietly break.
TSA availability is now your dependency. If the TSA is down, you can't anchor. Run a periodic head-stamp on a schedule with retries and backoff; don't block your write path on the TSA. Stamp asynchronously, store the pending state, reconcile when the TSA comes back.
Network egress may be restricted. Many compliance environments block outbound traffic by default. The TSA call is outbound HTTPS to a specific host — it needs an explicit allowlist entry, or your anchoring silently stops working.
Trust the cert, not the URL. Validate the token against a TSA certificate you pinned (or a known root), not against the domain you happened to call, and never against the certs returned inside the token. A MITM on the TSA endpoint who returns a self-signed token should fail step 2.
Token lifetime outlives the TSA's certificate. TSA signing certs expire (typically 1–10 years). A token signed in 2026 with a cert valid to 2030 is fine today, but in 2035 the cert is expired and naive signature checks may reject it. For long-term evidence you need either:
- a long-lived, qualified TSA whose root you trust indefinitely, or
- LTANS / evidence-record renewal: periodically re-anchor an older token inside a newer one, building a verifiable chain of timestamps so the evidence stays provable after any single cert expires. RFC 4998 (ERS) covers this formally.
Skip this and your "proof it existed in 2026" becomes unverifiable in 2035. For audit evidence with a multi-year retention requirement, this is not optional.
Don't stamp the wrong thing. Stamping payload before you've computed the chain hash, or stamping a mutable field, anchors the wrong bytes. Stamp the immutable digest — the chain head, or the record's canonical hash — never a re-serialized object that might differ byte-for-byte on reload.
Free vs paid TSAs. Public free TSAs exist (some CAs offer one) but have rate limits and no SLA; for production evidence use a commercial or qualified TSA. The cost is per-stamp and usually trivial, but it is a real external bill.
Where this leaves you
After anchoring, tampering requires compromising both your server and the TSA — and if you use a qualified TSA, that's a regulated entity with its own audit trail. The chain gives you integrity; the timestamp gives you when and non-repudiation by an outside party. Together they answer the three questions an auditor actually asks: was it changed, when was it written, and can you prove that without trusting the person who wrote it.
The hash chain is the easy part. The timestamp is what makes it hold up in front of someone who doesn't trust you.
Top comments (0)