No account · No TrustBeat software · No trust in us required

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

FieldPurpose
hashSHA-256 of your original document (hex)
merkle_rootRoot of the Merkle batch that was timestamped (hex)
proof_pathSibling hashes needed to re-derive the root from your leaf
leaf_indexPosition of your hash in the batch tree
tokenRFC 3161 TimeStampToken from an EU Trusted List QTSP (base64 DER)
providerWhich qualified TSA issued the token — decides the certificate chain in Step 3c
tsa_serialSerial number of the TSA token, in decimal — openssl prints it in hex (Step 3b)
anchored_atISO-8601 timestamp from the TSA
merkle_algorithmWhich tree construction was used — decides how Step 2 folds. Absent means trustbeat-legacy-sha256
archive_stampsRe-stamps added before a TSA certificate expires (empty until then)
bundle_typeOnly in NIS2 and AI Act bundles (trustbeat.log.proof, trustbeat.ai.proof); absent for document anchors
log_contextNIS2 only: original log hash + metadata + combined hash
ai_contextAI Act only: input/output hashes + decision metadata + combined hash
Step 1

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.

bash — Linux / macOS
sha256sum your-document.pdf
# macOS alternative:
shasum -a 256 your-document.pdf
powershell — Windows
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.

python3
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.

python3
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"])
All hash values you need are inside proof.json — nothing is fetched from the internet.
Step 2

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.

python3
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)
Step 3

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

python3
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

bash
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:

bash
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:

bash
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.

bash — SK ID Solutions
# 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
bash — EuroCert
# 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:

bash
# 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.pem
A successful response prints: Verification: OK
Do not hash the root again. RFC 3161 asks the client for a hash of the data to be stamped. The Merkle root already is a SHA-256 value, so TrustBeat sends it to the TSA unchanged, and the token's MessageImprint is the merkle_root itself. Passing SHA-256(merkle_root) to -digest fails with message imprint mismatch.
Step 4

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:

python3 — extract and check all archive stamps
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:

bash
# 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

StepWhat you verifyTool
1file SHA-256 == bundle.hashsha256sum / shasum / PowerShell
2Merkle path re-derives merkle_rootPython 3 (stdlib only)
3atoken decodes to valid DERPython 3
3btoken covers merkle_rootopenssl ts -reply -token_in -text
3ctoken signature valid (issuing TSA's chain)openssl ts -verify
4archive proof reaches archive_merkle_root; each archive token verifiesPython 3 + openssl ts -verify
5*NIS2 combined hash formulaPython 3 — only for log proofs
6*AI Act combined hash formulaPython 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