Verifying a Due Diligence Statement offline
Four checks. BLAKE3, CIDv1, ed25519, and a canonical JSON form named below. No network calls to our infrastructure required. The verification reduces to hashing bytes and verifying a signature against a public key you can read off the agent card.
This page is for auditors and operators who need to confirm that a Due Diligence Statement they received was actually issued by eudr.dev, has not been tampered with, and corresponds to a real emem-signed evidence set.
It assumes nothing about your toolchain. Anything you can do with a Rust binary you can also do with a Python notebook, a curl one-liner, or a printout and a calculator.
eudr-canonical-json/1, and how it differs from RFC 8785
You do not need this to verify a receipt. canonical_b64 carries the bytes that were signed, so the four checks below never re-derive them. You need it only if you want to rebuild those bytes from payload yourself — and then the differences matter.
This page used to call the form "JCS-canonical JSON". It is not JCS, and the difference is not cosmetic: a verifier written with an RFC 8785 library will produce different bytes for some of our receipts and report a good signature as bad. Named truthfully as of 2026-10-11, and frozen — the bytes already signed are authoritative, and any change is a new name and a new receipt version.
eudr-canonical-json/1 | RFC 8785 (JCS) | |
|---|---|---|
| Key order | the key's UTF-8 bytes, which is code-point order | UTF-16 code units |
4800.0 | 4800.0 | 4800 |
-0.0 | -0.0 | 0 |
1e21 | 1e+21 | 1e+21 |
| Strings | UTF-8, minimal JSON escaping | the same |
| Whitespace | none, and no trailing newline | the same |
The key-order difference shows up only for a key above the Basic Multilingual Plane: U+10000 sorts after U+E000 here and before it under JCS, because JCS compares the surrogate pair D800 DC00. Annex II keys are ASCII, so this has never changed a signed byte in practice — it is written down because a verifier author should not have to find out by experiment.
The number difference is the one that bites. JavaScript has a single number type, so parsing a receipt and reserialising it turns 1.0 into 1; that is how our own verify page came to tell operators their statements had been edited after signing. Work from canonical_b64.
crates/eudr-receipts/tests/canonical_json.rs is the executable statement of all of this.
The one rule that makes this work
A signed receipt carries the exact bytes that were signed, base64- encoded, in a field called canonical_b64. The CID and the ed25519 signature are both computed over those bytes — not over the payload on its own, and not over the CID. Decode canonical_b64 once and every check below is a pure function of that byte string. payload is a human-readable view; you can recompute the bytes from it as a cross- check, but you never need to trust it.
What you are about to verify
A signed DDS receipt is a JSON object with this shape:
{
"schema": "eu.eudr.dds.v2025_2650+slim",
"cid": "bafkr4ih5mabbtoysc2odmszbnugm2jxj4twe7n3m7yk55rkhbeeozm6e2q",
"payload": { /* human-readable Annex II JSON */ },
"canonical_b64": "eyJwYXlsb2FkIjp7IjFfb3BlcmF0b3IiOnsiYWRk…MjY1MCtzbGltIn0=",
"signature_b64": "KEg/AmyCbo/VN7peZBw2bOnOMiHgnP848UHTYBFk…",
"signed_at": "2026-06-04T04:36:23.165878990Z",
"signer": {
"did": "did:web:eudr.dev",
"pubkey_b32": "rc6pbb6bxrfhpt4jr6yhmklkjubresa4kc4uofdxp33qncmpi2vq",
"pubkey_b64": "iLzwh8G8SnfPiY+wdilqTQMSSBxQuUcUd373BomPRqs="
}
}
Field notes (these matter — earlier drafts of this page got them wrong):
cidis a CIDv1 with therawcodec and a BLAKE3-256 multihash. Its multibase prefix isbafkr4i…, not thebafkrei…/bafybei…you see from IPFS's SHA-256 CIDs.canonical_b64is base64 of the wrapper object{"payload": …, "schema": …}canonicalised aseudr-canonical-json/1(below) — not ofpayloadalone.signature_b64is ed25519 over those same canonical bytes.- The signer lives under
signer:signer.did,signer.pubkey_b32,signer.pubkey_b64.pubkey_b32andpubkey_b64are the same 32-byte ed25519 public key in two encodings. - The timestamp field is
signed_at(RFC 3339, UTC).
The four checks below prove, independently:
- The signed bytes (and therefore the Annex II payload) have not been tampered with since signing.
- The signature was produced by the holder of the corresponding private key.
- The signing identity is the one advertised at
https://eudr.dev/.well-known/agent-card.json. - The upstream Earth-observation evidence (the per-cell facts from emem.dev) is itself genuine and unchanged.
You can stop at check #3 if you trust our published DID. Check #4 proves the evidence underneath. The deep audit case.
The zero-code path
Open /verify and paste the receipt: the check runs in your browser against eudr.dev's published key, and says separately whether the signature holds, whether the readable payload is what was signed, and whether eudr.dev signed it.
The eudr command-line tool (eudr verify <receipt.json>) does the same offline, but it is not published on crates.io or as a public release; it exists for operators who build from source.
eudr verify prints the cid, the signer block, and "valid": true when the bytes are intact and the signature checks out — for example:
{
"cid": "bafkr4ih5mabbtoysc2odmszbnugm2jxj4twe7n3m7yk55rkhbeeozm6e2q",
"signer": {
"did": "did:web:eudr.dev",
"pubkey_b32": "rc6pbb6bxrfhpt4jr6yhmklkjubresa4kc4uofdxp33qncmpi2vq",
"pubkey_b64": "iLzwh8G8SnfPiY+wdilqTQMSSBxQuUcUd373BomPRqs="
},
"valid": true
}
There is also a browser verifier at /verify that runs the same checks client-side. The rest of this page is for people who want to re-implement the verification by hand.
The canonical form
Both the CID and the signature are taken over the canonical-JSON encoding of the wrapper object. The reference implementation lives in crates/eudr-receipts/src/lib.rs::canonical_json (write_canonical). It is ~40 lines; the rules are:
- The signed value is the wrapper
{"payload": <payload>, "schema": <schema>}— the receipt's top-levelschemastring plus itspayloadobject, and nothing else. Mint builds exactly this object and canonicalises it. - Object keys sorted by Unicode code point of the key string (a plain byte/lexicographic sort of the UTF-8 key —
"1_operator"sorts before"2_product"before"schema"). - No insignificant whitespace. Separators are
,between elements and:between key and value, with nothing around them. - No trailing newline.
- Strings are emitted by
serde_json's string serialiser: standard JSON escapes (\n,\t,\",\\,\uXXXXfor control chars), and non-ASCII characters are kept as raw UTF-8, not\u-escaped (equivalent to Python'sensure_ascii=False). Soéin "Soubré" is two UTF-8 bytes in the signed stream, noté. - Numbers use
serde_json::Number::to_string. Integers have no decimal point or exponent; a value that was a JSON float keeps its fractional form — e.g.4800.0stays4800.0, it does not collapse to4800. (This is why the receipt shipscanonical_b64: it pins the exact float spelling so a re-serialisation can't drift.) - Arrays preserve their order.
In Python the wrapper-object encoding reproduces byte-for-byte with:
json.dumps({"payload": payload, "schema": schema},
sort_keys=True, separators=(",", ":"),
ensure_ascii=False).encode("utf-8")
This was checked against a real receipt: the recompute is identical to base64.b64decode(canonical_b64), all 74021 bytes. See the executed results at the bottom of this page.
One caveat on the float rule:
json.dumpsreproduces4800.0because the value arrived as a JSON float and Python'sreprof that float happens to matchserde_json's. For the receipts this engine emits that holds, and thecanonical_b64cross-check below will catch any divergence before you rely on it. If you only need certainty and not a from-scratch recompute, skip straight to decodingcanonical_b64— that is the authoritative byte string.
Check 1. Re-derive the CID
The CID is a deterministic hash of the canonical bytes. Re-hash those bytes the way the engine did and you get the same CID, which proves the signed content is unchanged.
Algorithm, exactly:
canon = base64-decode(canonical_b64)— the signed bytes.digest = BLAKE3-256(canon)— 32 bytes.- Prepend the CIDv1 + multihash header bytes:
| byte | meaning |
|---|---|
0x01 | CID version 1 |
0x55 | multicodec raw |
0x1e | multihash code blake3 (0x1e) |
0x20 | multihash digest length = 32 bytes |
| … | the 32 BLAKE3-256 digest bytes |
i.e. cid_bin = bytes([0x01, 0x55, 0x1e, 0x20]) + digest (36 bytes).
- Multibase-encode with base32: lowercase RFC 4648 alphabet, no padding, and prepend the multibase tag
b. The result startsbafkr4i…and must equal the receipt'scid.
import base64
from blake3 import blake3 # pip package "blake3"
canon = base64.b64decode(r["canonical_b64"])
digest = blake3(canon).digest() # 32 bytes
cid_bin = bytes([0x01, 0x55, 0x1e, 0x20]) + digest # CIDv1 raw + blake3-256
b32 = base64.b32encode(cid_bin).decode().rstrip("=").lower()
recomputed = "b" + b32
assert recomputed == r["cid"], f"CID mismatch: {recomputed} != {r['cid']}"
print("CID check passed ✓")
If the CID matches, the signed bytes are exactly what was hashed. Change any byte — even a stray space inside payload — and re-deriving from those altered bytes yields a different CID.
Check 2. Verify the ed25519 signature
signature_b64 is an ed25519 signature over the canonical bytes (the decoded canonical_b64) — not over the CID, and not over payload directly. Verify it against signer.pubkey_b64.
import base64
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
canon = base64.b64decode(r["canonical_b64"]) # the signed bytes
pub = base64.b64decode(r["signer"]["pubkey_b64"]) # 32-byte ed25519 key
sig = base64.b64decode(r["signature_b64"]) # 64-byte signature
Ed25519PublicKey.from_public_bytes(pub).verify(sig, canon) # raises on failure
print("Signature check passed ✓")
signer.pubkey_b32 is the same public key, encoded differently: RFC 4648 base32, lowercase, no padding (the engine produces it as BASE32_NOPAD.encode(pubkey).to_lowercase()). You can confirm the two encodings agree, which is a cheap guard against a swapped key:
assert base64.b32encode(pub).decode().rstrip("=").lower() \
== r["signer"]["pubkey_b32"]
If the signature verifies, the holder of the private key signed these bytes. The only entity holding that key is the engine operator (VORTX AI PRIVATE LIMITED for the hosted eudr.dev).
Full corrected snippet
import json, base64
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
with open("receipt.json") as f:
r = json.load(f)
# (a) The exact signed bytes.
canon = base64.b64decode(r["canonical_b64"])
# (b) OPTIONAL cross-check: re-derive the canonical bytes from {payload, schema}.
# Must equal `canon`. (See the float caveat above.)
body = {"payload": r["payload"], "schema": r["schema"]}
recomputed = json.dumps(body, sort_keys=True, separators=(",", ":"),
ensure_ascii=False).encode("utf-8")
assert recomputed == canon, "canonical-bytes cross-check failed"
# (c) CID = multibase-base32lower( 0x01 0x55 0x1e 0x20 || BLAKE3-256(canon) ).
try:
from blake3 import blake3
digest = blake3(canon).digest()
cid_bin = bytes([0x01, 0x55, 0x1e, 0x20]) + digest
cid = "b" + base64.b32encode(cid_bin).decode().rstrip("=").lower()
assert cid == r["cid"], f"CID mismatch: {cid} != {r['cid']}"
print("CID check passed ✓")
except ImportError:
print("CID check skipped: install the `blake3` package, or run `eudr verify`.")
# (d) ed25519 over the canonical bytes, against signer.pubkey_b64.
pub = base64.b64decode(r["signer"]["pubkey_b64"])
sig = base64.b64decode(r["signature_b64"])
Ed25519PublicKey.from_public_bytes(pub).verify(sig, canon)
assert base64.b32encode(pub).decode().rstrip("=").lower() \
== r["signer"]["pubkey_b32"]
print("Signature check passed ✓")
Check 3. Confirm the signing identity
A valid signature is only meaningful if you know whose key you just trusted. The signer's identity is published at the agent card:
curl -sS https://eudr.dev/.well-known/agent-card.json | jq '.identity'
You expect output like:
{
"did": "did:web:eudr.dev",
"pubkey_b32": "rc6pbb6bxrfhpt4jr6yhmklkjubresa4kc4uofdxp33qncmpi2vq",
"pubkey_b64": "iLzwh8G8SnfPiY+wdilqTQMSSBxQuUcUd373BomPRqs="
}
Confirm:
identity.didmatches thesigner.didin your receipt.identity.pubkey_b64(and/orpubkey_b32) matches the same field undersignerin your receipt — and is the key the signature in Check 2 verified against.
If they match, the receipt was signed by the entity that publishes the agent card at https://eudr.dev. The DID is did:web:eudr.dev, which by the DID Web specification ties the identity to control over that domain's TLS certificate + its agent-card document.
The receipt's signer.did should always be did:web:eudr.dev. A receipt that carries any other DID was not issued by this service.
Check 4. Verify the underlying Earth-observation evidence
This is the deep audit. The receipt's payload carries the per-cell fact CIDs from emem.dev. Each CID dereferences to a content-addressed fact at emem.dev.
For each cell CID:
curl -sS https://emem.dev/v1/facts/<cid> > fact.json
Then apply the same verification to the emem fact:
- Re-derive the CID from the fact's
canonical_b64(same BLAKE3 + CIDv1-raw algorithm as Check 1). - Verify the ed25519 signature over the fact's canonical bytes.
- Confirm the pubkey against emem's agent card at
https://emem.dev/.well-known/agent-card.json. - Inspect the band data — Hansen loss-year, ESA WorldCover class, JRC GFC2020 binary — and confirm the verdict logic in our methodology gives the same per-cell verdict.
The two cryptographic layers are independent: even if our DID rotated tomorrow, the emem facts under a historical receipt remain verifiable from emem.dev's side. That's the value of content-addressed evidence — it survives identity changes.
What you have when all four checks pass
You have proven, without trusting our infrastructure for anything other than DNS + TLS on the eudr.dev domain:
- The DDS payload is byte-for-byte what we signed.
- We signed it (under our published DID).
- The per-cell evidence comes from emem.dev.
- The per-cell evidence has not been tampered with.
What you have NOT proven:
- That the emem data is true. emem.dev is itself a methodology layered on top of Hansen / ESA / JRC. To go deeper, you inspect the upstream COGs (the receipt carries the source URLs).
- That the operator's declared geometry is accurate. The Regulation requires the operator to attest to that; the engine renders the attestation. Ground truth is a question for the on-the-ground audit.
This is the same evidentiary boundary every chain-of-custody audit faces. The engine narrows the trust footprint as much as cryptography allows.
Practical scenarios
"An auditor I trust sent me this DDS. Should I trust it?"
Run eudr verify <receipt.json>. If it prints "valid": true, the bytes are what was signed and the signature is from the DID under signer. Cross-check that DID against the deployment that they say issued it.
"I want to verify without internet access."
You can. eudr verify is fully offline. The agent-card lookup (Check 3) is the only network step; do it once, cache the pubkey, and use it forever. CID and signature verification need no network at all.
"The CID doesn't match."
The signed bytes you hashed are not the ones that were signed. Possible causes:
- You hashed
payloadalone instead of the decodedcanonical_b64(the wrapper{"payload", "schema"}bytes). Hashcanonical_b64. - You used SHA-256 / a CIDv0 layout instead of BLAKE3-256 + CIDv1-raw. A correct CID begins
bafkr4i…. - The receipt was edited, or re-serialised by a tool that dropped
canonical_b64and re-ordered keys without canonicalising.
Run eudr verify first; it is the reference implementation. If the CLI passes but your re-implementation fails, your canonicalisation or CID layout has drifted.
"The signature doesn't verify."
Either the canonical bytes changed, or the signature and key are from a different pair. Make sure you are verifying over the decoded canonical_b64 (not the CID, not payload) and against signer.pubkey_b64. If it still fails, the receipt is tampered with or was never genuine.
When to escalate
If a receipt you received from us fails to verify, please email avijeet@vortx.ai with:
- The full receipt JSON.
- The verification command you ran + its output.
- The version of any third-party verification tool you used.
We will respond within 72 hours. A failing verification on a hosted eudr.dev receipt is a security-grade incident, not a support ticket; we treat it accordingly.
Appendix: what was actually executed against a real receipt
The recipe on this page was checked against a live receipt (schema: eu.eudr.dds.v2025_2650+slim, cid: bafkr4ih5mabbtoysc2odmszbnugm2jxj4twe7n3m7yk55rkhbeeozm6e2q) on a host with Python cryptography 46.0.5 but without a blake3 module. Observed results:
- Canonical-bytes recompute (executed):
json.dumps({"payload": payload, "schema": schema}, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")produced 74021 bytes that are byte-for-byte identical tobase64.b64decode(canonical_b64). - ed25519 over canonical bytes (executed):
Ed25519PublicKey. from_public_bytes(pubkey_b64).verify(signature_b64, canon)passed. Verifying the same signature over the CID bytes failed — a direct demonstration that the signature is over the canonical bytes, not the CID. - pubkey_b32 ↔ pubkey_b64 (executed):
base32(pubkey).lower()without padding equalssigner.pubkey_b32. - CID re-derivation: not executed in Python on this host (no
blake3module; nothing was installed). It was confirmed instead by the reference binaryeudr verify, which independently re-derivedbafkr4ih5mab…fromcanonical_b64and reported"valid": true, and by source-readingcid_blake3_raw/Cid::new_v1(0x55, blake3(canon))incrates/eudr-receipts/src/lib.rs.
See also
- About EUDR. The regulation in plain English
- Methodology. Hansen + ESA + JRC
- Architecture. Pipeline + receipts + CIDs
- Independence + scope of service
- The reference implementation:
crates/eudr-receipts/src/lib.rs - The verify page (browser):
/verify