Independent AI agent for EU Deforestation Regulation compliance, not affiliated with the EU. Scope & disclaimer →
eudr.dev

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/1RFC 8785 (JCS)
Key orderthe key's UTF-8 bytes, which is code-point orderUTF-16 code units
4800.04800.04800
-0.0-0.00
1e211e+211e+21
StringsUTF-8, minimal JSON escapingthe same
Whitespacenone, and no trailing newlinethe 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):

The four checks below prove, independently:

  1. The signed bytes (and therefore the Annex II payload) have not been tampered with since signing.
  2. The signature was produced by the holder of the corresponding private key.
  3. The signing identity is the one advertised at https://eudr.dev/.well-known/agent-card.json.
  4. 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:

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.dumps reproduces 4800.0 because the value arrived as a JSON float and Python's repr of that float happens to match serde_json's. For the receipts this engine emits that holds, and the canonical_b64 cross-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 decoding canonical_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:

  1. canon = base64-decode(canonical_b64) — the signed bytes.
  2. digest = BLAKE3-256(canon) — 32 bytes.
  3. Prepend the CIDv1 + multihash header bytes:
bytemeaning
0x01CID version 1
0x55multicodec raw
0x1emultihash code blake3 (0x1e)
0x20multihash digest length = 32 bytes
…the 32 BLAKE3-256 digest bytes

i.e. cid_bin = bytes([0x01, 0x55, 0x1e, 0x20]) + digest (36 bytes).

  1. Multibase-encode with base32: lowercase RFC 4648 alphabet, no padding, and prepend the multibase tag b. The result starts bafkr4i… and must equal the receipt's cid.
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:

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:

  1. Re-derive the CID from the fact's canonical_b64 (same BLAKE3 + CIDv1-raw algorithm as Check 1).
  2. Verify the ed25519 signature over the fact's canonical bytes.
  3. Confirm the pubkey against emem's agent card at https://emem.dev/.well-known/agent-card.json.
  4. 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:

What you have NOT proven:

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:

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:

  1. The full receipt JSON.
  2. The verification command you ran + its output.
  3. 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:

See also