Manual Proof Verification
A TrustBeat proof bundle is self-contained. Every cryptographic claim inside it can be checked with standard tools — openssl and Python 3 — without calling the TrustBeat API or trusting this website.
Easiest — the European Commission's DSS demonstration tool (no software)
Documents anchored since September 2026 have an RFC 4998 evidence record. On the verify page, download it (.ers), then open the European Commission's DSS Demonstration WebApp: upload the.ers as "Signed file", add your original file under "Original file(s)" and set "Send original file(s) as" to SHA256, so only its hash leaves your computer. A valid record shows PASSED and "Qualified timestamp". The WebApp's reports name your file only by its file name: to record which data passed, open its "Diagnostic tree" tab and check that DigestValue equals the "Data hash (base64)" in the evidence record report and DataIntact is true. The steps below verify the proof bundle itself, fully offline.
The DSS Demonstration WebApp is the European Commission's free demonstration of its open-source EU DSS library, not a qualified validation service; the Commission accepts no liability for its results. Any validator based on EU DSS performs the same checks.
Before you start — export your proof bundle
All data used in the steps below comes from a single proof bundle JSON file. To get it, open the verify page, enter your anchor ID, and click Export proof bundle. Save the downloaded trustbeat-proof-*.json as proof.json before running any command below.
The bundle contains everything: the anchored hash, Merkle path, RFC 3161 token, archive stamps, and — for NIS2 or AI Act anchors — the full domain context (log metadata or AI decision metadata).
What's in a proof bundle
| Field | Purpose |
|---|---|
| hash | SHA-256 of your original document (hex) |
| merkle_root | Root of the Merkle batch that was timestamped (hex) |
| proof_path | Sibling hashes needed to re-derive the root from your leaf |
| leaf_index | Position of your hash in the batch tree |
| token | RFC 3161 TimeStampToken from an EU Trusted List QTSP (base64 DER) |
| provider | Which qualified TSA issued the token — decides the certificate chain in Step 3c |
| tsa_serial | Serial number of the TSA token, in decimal — openssl prints it in hex (Step 3b) |
| anchored_at | ISO-8601 timestamp from the TSA |
| merkle_algorithm | Which tree construction was used — decides how Step 2 folds. Absent means trustbeat-legacy-sha256 |
| archive_stamps | Re-stamps added before a TSA certificate expires (empty until then) |
| bundle_type | Only in NIS2 and AI Act bundles (trustbeat.log.proof, trustbeat.ai.proof); absent for document anchors |
| log_context | NIS2 only: original log hash + metadata + combined hash |
| ai_context | AI Act only: input/output hashes + decision metadata + combined hash |
Verify your original data
Confirm what was anchored matches what you have. The method differs by anchor type.
Document anchor — no bundle_type field
SHA-256 your original file and compare to bundle["hash"]. Any difference means the document was modified after anchoring.
sha256sum your-document.pdf # macOS alternative: shasum -a 256 your-document.pdf
Get-FileHash your-document.pdf -Algorithm SHA256
NIS2 log anchor — bundle_type: trustbeat.log.proof
The Merkle leaf is a combined hash, not the raw log hash. Compare bundle["log_context"]["log_hash"] against the SHA-256 of your original log file. bundle["hash"] holds the combined hash — verified in Step 5.
import hashlib
print(hashlib.sha256(open("your-logfile.log","rb").read()).hexdigest())
# compare to bundle["log_context"]["log_hash"]AI Act anchor — bundle_type: trustbeat.ai.proof
Two hashes are anchored: the model input and the model output/decision. Compare each against bundle["ai_context"]["input_hash"] and bundle["ai_context"]["output_hash"]. bundle["hash"] holds the combined hash — verified in Step 6.
import hashlib, json
bundle = json.load(open("proof.json"))
ac = bundle["ai_context"]
print("input_hash :", hashlib.sha256(open("model-input.bin","rb").read()).hexdigest())
print("expected :", ac["input_hash"])
print("output_hash:", hashlib.sha256(open("model-output.bin","rb").read()).hexdigest())
print("expected :", ac["output_hash"])proof.json — nothing is fetched from the internet.Re-derive the Merkle root
Proves your hash was included in the batch that was timestamped.
First read merkle_algorithm. If the field is absent, the proof predates it — treat it as trustbeat-legacy-sha256. An algorithm you do not recognise means cannot verify here, never invalid.
Start with your hash as the current value — under rfc6962-sha256, start with SHA-256(0x00 ‖ hash) instead. Then for each step in proof_path, where P is empty for the legacy tree and 0x01 for RFC 6962:
- side = "left"→
current = SHA-256(P ‖ sibling ‖ current) - side = "right"→
current = SHA-256(P ‖ current ‖ sibling)
The final value must equal merkle_root.
import hashlib, json, sys
bundle = json.load(open("proof.json"))
algorithm = bundle.get("merkle_algorithm", "trustbeat-legacy-sha256")
if algorithm == "trustbeat-legacy-sha256":
leaf_prefix, node_prefix = b"", b""
elif algorithm == "rfc6962-sha256":
leaf_prefix, node_prefix = b"\x00", b"\x01"
else:
sys.exit(f"unsupported merkle_algorithm: {algorithm!r} — cannot verify with this guide")
current = hashlib.sha256(leaf_prefix + bytes.fromhex(bundle["hash"])).digest() \
if leaf_prefix else bytes.fromhex(bundle["hash"])
for step in bundle["proof_path"]:
sibling = bytes.fromhex(step["sibling"])
if step["side"] == "left":
combined = sibling + current
else:
combined = current + sibling
current = hashlib.sha256(node_prefix + combined).digest()
computed = current.hex()
expected = bundle["merkle_root"]
if computed == expected:
print("✓ Merkle root OK —", computed)
else:
print("✗ MISMATCH")
print(" computed:", computed)
print(" expected:", expected)
sys.exit(1)Verify the RFC 3161 timestamp token
The token was issued by an EU Trusted List QTSP and covers the Merkle root.
The token field is a base64-encoded DER-encoded RFC 3161 TimeStampToken. It binds the merkle_root to a qualified electronic time-stamp, which eIDAS Art. 41 presumes accurate in date and time and in the integrity of the data it binds.
3a — Decode the token to a file
import base64, json
bundle = json.load(open("proof.json"))
open("token.der", "wb").write(base64.b64decode(bundle["token"]))
print("token.der written")3b — Inspect what the token contains
openssl ts -reply -token_in -in token.der -text
Look for Message data — it is the merkle_root itself, byte for byte, with Hash Algorithm: sha256. TSA names the time-stamping unit (SK does; EuroCert leaves it unspecified — its unit is in the embedded certificate). Cross-check the Serial number, which openssl prints in hex, against bundle["tsa_serial"], which is decimal:
python3 -c "import json; print(hex(int(json.load(open('proof.json'))['tsa_serial'])))"3c — Verify the token signature
First get the certificate chain of the TSA that issued the token. Which one is named in provider:
python3 -c "import json; print(json.load(open('proof.json'))['provider'])"Download the chain from the trust service provider itself — never take the root from the token or this bundle, because whoever controls those could supply their own — and compare the root's fingerprint before using it. This is the only step that needs the internet, once per provider.
# provider "SK ID Solutions (Estonia)" curl -sO http://c.sk.ee/SK_TSA_CA_2023E.der.crt curl -sO http://c.sk.ee/SK_ID_Solutions_ROOT_G1E.der.crt openssl x509 -inform DER -in SK_TSA_CA_2023E.der.crt -out sk-ca.pem openssl x509 -inform DER -in SK_ID_Solutions_ROOT_G1E.der.crt -out sk-root.pem cat sk-ca.pem sk-root.pem > tsa-chain.pem # Check the root before trusting it: openssl x509 -in sk-root.pem -noout -fingerprint -sha256 # sha256 Fingerprint=A2:72:DE:10:2A:80:38:F7:E1:00:EA:CF:B6:DB:1C:93:0D:3C:B9:F5:1D:76:B2:18:DE:3A:4A:43:3B:CF:D1:82
# provider "EuroCert QTSA (Poland)" — the TSA certificate is issued by the Polish national root (NCCert) curl -sO https://www.nccert.pl/files/nccert2016.crt openssl x509 -inform DER -in nccert2016.crt -out tsa-chain.pem # Check the root before trusting it: openssl x509 -in tsa-chain.pem -noout -fingerprint -sha256 # sha256 Fingerprint=75:89:DE:11:E0:F5:90:FE:25:59:88:8E:22:52:52:9D:B1:83:4E:C9:72:FE:C2:93:FD:1E:1A:85:76:C1:2B:EA
Then verify the token:
# The Merkle root, as hex — this is exactly what the TSA signed
ROOT=$(python3 -c "import json; print(json.load(open('proof.json'))['merkle_root'])")
# Verify the token signature against the issuing TSA's certificate chain
openssl ts -verify \
-token_in \
-in token.der \
-digest "$ROOT" \
-CAfile tsa-chain.pemmerkle_root itself. Passing SHA-256(merkle_root) to -digest fails with message imprint mismatch.Verify archive stamps (long-term validity)
Each archive stamp extends validity before the previous TSA certificate expires.
The archive_stamps array may be empty for recently anchored proofs. Before a TSA certificate expires, TrustBeat adds an archive stamp: a new RFC 3161 token over covered_hash, a hash of the previous token and its validation data. Each round keeps the previous one verifiable.
For document anchors, the 30-year preservation itself is the RFC 4998 evidence record (the .ers above), renewed under the preservation profile published at trustbeat.eu/preservation.
Each stamp carries its own merkle_algorithm, and it may differ from the anchor proof's. Archive rounds are stamped when they run, not when the batch was anchored, so a batch anchored before the RFC 6962 cutover can legitimately carry rfc6962-sha256 archive stamps while its own proof_path is still trustbeat-legacy-sha256. Walk each archive_merkle_proof with that stamp's algorithm, not the proof's.
Archive stamps of several batches usually share one TSA call. Then the token covers archive_merkle_root, not covered_hash: fold archive_merkle_proof from covered_hash exactly as in Step 2 to reach it. The script does this and prints the digest each token must cover:
import base64, hashlib, json, sys
PREFIXES = {"trustbeat-legacy-sha256": (b"", b""), "rfc6962-sha256": (b"\x00", b"\x01")}
bundle = json.load(open("proof.json"))
for i, stamp in enumerate(bundle.get("archive_stamps", [])):
fname = f"archive_{i+1}.der"
open(fname, "wb").write(base64.b64decode(stamp["token"]))
root = stamp.get("archive_merkle_root")
if root is None: # re-stamped on its own: the token covers covered_hash
digest = stamp["covered_hash"]
else: # re-stamped with other batches: walk to the archive root
algorithm = stamp.get("merkle_algorithm", "trustbeat-legacy-sha256")
if algorithm not in PREFIXES:
sys.exit(f"round {stamp['round']}: unsupported merkle_algorithm {algorithm!r} — cannot verify here")
leaf_prefix, node_prefix = PREFIXES[algorithm]
current = bytes.fromhex(stamp["covered_hash"])
if leaf_prefix:
current = hashlib.sha256(leaf_prefix + current).digest()
for step in stamp.get("archive_merkle_proof") or []:
sibling = bytes.fromhex(step["sibling"])
pair = sibling + current if step["side"] == "left" else current + sibling
current = hashlib.sha256(node_prefix + pair).digest()
if current.hex() != root:
sys.exit(f"✗ round {stamp['round']}: archive_merkle_proof does not reach archive_merkle_root")
digest = root
print(f"Round {stamp['round']} → {fname} provider: {stamp.get('provider')} -digest {digest}")Verify each archive_N.der as in Step 3c, with the printed digest and the chain of that stamp's provider:
# For each stamp: the -digest printed above, and the chain of that stamp's provider openssl ts -verify -token_in -in archive_1.der -digest <digest> -CAfile tsa-chain.pem
Verification checklist
| Step | What you verify | Tool |
|---|---|---|
| 1 | file SHA-256 == bundle.hash | sha256sum / shasum / PowerShell |
| 2 | Merkle path re-derives merkle_root | Python 3 (stdlib only) |
| 3a | token decodes to valid DER | Python 3 |
| 3b | token covers merkle_root | openssl ts -reply -token_in -text |
| 3c | token signature valid (issuing TSA's chain) | openssl ts -verify |
| 4 | archive proof reaches archive_merkle_root; each archive token verifies | Python 3 + openssl ts -verify |
| 5* | NIS2 combined hash formula | Python 3 — only for log proofs |
| 6* | AI Act combined hash formula | Python 3 — only for AI Act proofs |
* Step 5 applies only to bundle_type = trustbeat.log.proof · Step 6 applies only to bundle_type = trustbeat.ai.proof
Powered by TrustBeat · eIDAS · RFC 3161 · Verify online · Inspect token