Verifying an evidence pack
Who this guide is for
This guide walks an auditor, a counterparty or any third party through verifying an
Exedra Gate evidence pack independently: no account, no network access to Exedra Gate,
and no proprietary software. Everything in a pack uses standard formats, SHA-256 for
hashes, Ed25519 for the signature and RFC 3161 for the timestamp token, so every check
below runs with ordinary tooling: a shell, jq, openssl, and Python 3 with the
cryptography package for the signature step.
Verification is offline by design. The pack is self-contained: the public key, the key id, the checksums and the timestamp token are inside it.
What is in a pack
An envelope evidence pack is a folder of five files:
| File | What it is |
|---|---|
evidence-pack.json | The complete signing record: envelope metadata, signer records, document hashes and the ordered audit trail |
evidence-pack.pdf | A human-readable rendering of the same record |
compliance-summary.pdf | A compliance summary |
manifest.json | Checksums of the other files, the pack hash, the Ed25519 signature and the timestamp result |
tsa.token | The raw RFC 3161 timestamp token, present when timestamping succeeded |
Case packs are a single JSON artefact with the same signature discipline; they carry a
signing_provenance block naming which key signed and under whose custody.
Step 1: check the file hashes
Compute the SHA-256 hash of each file and compare it with the checksum recorded in the manifest. Run this in the pack folder:
jq -r '.checksums' manifest.jsonshasum -a 256 evidence-pack.json evidence-pack.pdf compliance-summary.pdfEach computed hash must exactly match the corresponding manifest value: json_hash for
evidence-pack.json, pdf_hash for evidence-pack.pdf and compliance_pdf_hash for
compliance-summary.pdf. If tsa.token is present, check it too:
jq -r '.checksums.tsa_token_hash' manifest.jsonshasum -a 256 tsa.tokenA mismatch on any file means the file has been altered or corrupted since the pack was generated, and the rest of the verification cannot be trusted for that file.
Step 2: check the signature
The pack hash is signed with Ed25519. The signature block appears identically in
manifest.json and evidence-pack.json: signature.value is the signature,
signature.signed_content is the pack hash that was signed, and signature.public_key
is the 32-byte public key, all Base64URL encoded. This script verifies it:
python3 - <<'EOF'import base64, jsonfrom cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
def b64url(s): return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
sig = json.load(open("manifest.json"))["signature"]key = Ed25519PublicKey.from_public_bytes(b64url(sig["public_key"]))key.verify(b64url(sig["value"]), sig["signed_content"].encode())print("Ed25519 signature verifies over pack hash", sig["signed_content"])EOFAn exception means the signature does not verify. Also confirm that
signature.signed_content equals the pack_hash field in both files.
Verifying against the embedded key proves internal consistency. To go further, pin the
key: signature.key_id names the signing key record, and you can ask Exedra Gate, or
the client for a delegated key, to confirm the public key for that key id out of band.
For a pack signed under signing delegation, the key is the client's own: the pack still
verifies the same way, but key custody is the client's, under their controls, and the
platform's attestation covers the event chain, the pack hash and which key identifier
signed, not how that key was managed.
Step 3: check the independent timestamp
When tsa.status is ok, the pack carries an RFC 3161 timestamp token from an
independent timestamp authority. The token covers tsa.timestamped_hash:
TS_HASH=$(jq -r '.tsa.timestamped_hash' manifest.json)openssl ts -verify -digest "$TS_HASH" -in tsa.token -CAfile tsa-ca.pemExpected output: Verification: OK. The tsa-ca.pem file is the certificate chain of
the timestamp authority; the token itself embeds the chain (it is requested with
certReq), and you can inspect it with openssl ts -reply -in tsa.token -token_in -text. The reference verifier ships a trust-anchor bundle for the default authority.
Read this carefully before comparing hashes: tsa.timestamped_hash is a pre-manifest
hash, taken before the PDFs and the TSA result were written into the manifest, so it
does not equal the final checksums.manifest_hash, by construction. The binding of the
timestamped hash to this pack rests on the fact that the pre-manifest already contained
the pack hash, the Ed25519 signature block and the JSON hash.
What the timestamp establishes: the timestamped content existed at the token's time, on the authority of the timestamp authority's clock, independent of Exedra Gate's clock. The token stays verifiable even if the pack signing key were later compromised. A non-qualified timestamp is admissible evidence, not presumed accurate; under eIDAS the legal presumption attaches to a qualified timestamp only, and Exedra Gate does not claim one for the default token.
Step 4: walk the audit chain
The audit trail in evidence-pack.json is hash chained: every event carries an
event_hash and the previous_hash of the event before it, with the first event at
null or the literal GENESIS. Walk it:
python3 - <<'EOF'import jsonevents = json.load(open("evidence-pack.json"))["audit_trail"]prev = Nonefor e in events: if prev is None: assert e.get("previous_hash") in (None, "GENESIS"), "first event is not genesis" else: assert e.get("previous_hash") == prev, f"chain breaks at sequence {e['sequence_number']}" prev = e["event_hash"]print(f"hash chain intact across {len(events)} events")EOFA broken link dates and places the alteration: the chain fails at the exact event where
an entry was edited, inserted or removed. The pack also carries the generator's own
chain_verification summary; recompute rather than trust it.
One boundary to know: the pack records its own generation as a final
evidence_pack_generated event, appended after signing, because its payload contains
the pack hash and therefore cannot be inside the signed content. The signed prefix is
declared in chain_snapshot.event_count: the rendered trail has one more event than
the pack hash covers, and that is expected, not a defect.
Step 5: recompute the pack hash
The pack hash is a SHA-256 over a canonical JSON string with a fixed literal key order:
{ envelope_id, created_at, documents: [document hashes], signers: [signer emails in sort order], audit_events: chain_snapshot.event_count, last_event_hash: hash of the last event inside that prefix }Recompute it from the data in evidence-pack.json, using the chain_snapshot boundary
from step 4, and compare with pack_hash in both files. Determinism comes from the
fixed key order, not from key sorting. The pack's own verification.instructions field
names the exact field list for its pack type, and the reference verifier implements the
recompute for both envelope and case packs, so the practical path is to run it and read
its per-check output. Packs generated from placeholder data carry test_mode: true
inside the hashed content, so a stripped or edited flag breaks the manifest hash and
the timestamp rather than passing quietly.
Verifying the access-event ledger
Client workspaces also keep a second hash-chained record, the access-event ledger: IP
sign-in denials, network rule changes, enforcement toggles and sovereign module
switches. It uses the same primitives as evidence packs. Given the rows for one
workspace ordered by sequence_number ascending, the walk is:
- Check continuity: the first row's
prev_record_hashis the literalGENESIS, and every later row'sprev_record_hashequals the previous row'srecord_hash. - Recompute each
record_hashand compare. Two canonicalisation versions exist. v1, used by rows written before the 2026-08-25 cutover, hashes only the six identifier fields as compact JSON in fixed alphabetical order:client_id,event_type,occurred_at,prev_record_hash,ref_id,sequence_number. v2, used by every row from the cutover on, hashes the full evidentiary row: the six v1 fields plusevent_data,actor_id,actor_email,ip_addressanduser_agent, as canonical JSON with recursively sorted keys, no insignificant whitespace, absent optional fields as explicitnull, and the markercanon_version: 2inside the hashed bytes. A verifier detects the version by recompute and reports which one matched. - Apply the monotonic-version rule: once one row in a chain verifies as v2, every later row must verify as v2. This blocks the downgrade attack in which a tampered v2 payload is re-presented so that only its v1 fields verify.
- Where
signatureis present, verify it as Ed25519 over therecord_hashstring against the platform public key, exactly as in step 2. - Where
tsa_timestampis present, decode the Base64 token and verify it as an RFC 3161 token overrecord_hash, exactly as in step 3.
A row with a null signature or tsa_timestamp is a flagged degraded record, not a
chain break: the ledger write must never gate a security decision, so signing and
timestamping on this rail are best effort and the hash chain is the load-bearing tamper
evidence. A degraded row is visible as such; a rewritten row breaks the chain.
What verification establishes
| Established | By |
|---|---|
| Alteration of the pack after generation is detectable | Ed25519 signature and file checksums |
| The pack existed at a specific time | RFC 3161 timestamp, when tsa.status is ok |
| Alteration of audit events is detectable | SHA-256 hash chain |
| Document content matches its recorded hash | The document hash fields |
| The signing sequence is complete and ordered | Sequence numbers in the chained trail |
| Which key identifier signed the pack | signature.key_id plus the custody record |
The honest summary: verification establishes existence and integrity at a point in time. The record as it stood is reconstructable, and a record altered afterwards stops verifying.
What verification does not establish
It does not establish that the contents of a signed document are true, that a signer understood what they signed, or that anyone was legally entitled to sign. Signer identity assurance depends on the identity checks that were configured, which the record describes but the mathematics does not prove. For a pack signed under delegated key custody, verification does not establish how the client generated, stored, rotated or destroyed their key. The pack is a record of what happened, not a judgement about it.
The reference verifier
Exedra Gate publishes a reference offline verifier, exedragate-verify, a Deno command
line tool that runs eight independent checks with no network and no database: pack hash
recompute, Ed25519 signature, RFC 3161 token, hash format and test_mode consistency,
the self-attestation event, attribution blocks, a PDF cross-check and dual-key
attestation re-verification. Exit code 0 is a pass, 1 is a failed check, 2 is a
fail-closed condition or tool error. --keys-file pins verification to known key ids,
and --ca-bundle pins the timestamp trust anchors. Using it is optional: every check
it automates is one of the manual steps above.