A GET /v1/audit/export response is signed with Ed25519. Anyone can verify the signature against the public key DutyRadar publishes at /.well-known/audit-pubkey.json — no DutyRadar API call is required for verification. This page walks through three implementations of the same five-step protocol.
1 Fetch the canonical JWKS over TLS from /.well-known/audit-pubkey.json.
2 Look up the entry whose kid matches the export's public_key_jwk.kid. Confirm x values match. (If they don't, the export's inline key is not DutyRadar's.)
3 Re-emit the export payload with signature and public_key_jwk removed, in canonical JSON: keys sorted, no whitespace, UTF-8.
4 Decode signature (drop the ed25519: prefix, base64url-decode the rest).
5 Verify the signature against the canonical bytes and the public key. Pass = export is authentic. Fail = bytes were modified, or the export wasn't signed by DutyRadar's key.
Save as verify_export.py; run python verify_export.py export.json. Requires cryptography (pip install cryptography).
import json, hashlib, base64, sys
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.exceptions import InvalidSignature
def b64u_decode(s):
s += "=" * (-len(s) % 4)
return base64.urlsafe_b64decode(s.encode())
def canonicalize(value):
if value is None or isinstance(value, (bool, int, float, str)):
return json.dumps(value, separators=(",", ":"), ensure_ascii=False)
if isinstance(value, list):
return "[" + ",".join(canonicalize(v) for v in value) + "]"
if isinstance(value, dict):
keys = sorted(value.keys())
return "{" + ",".join(json.dumps(k) + ":" + canonicalize(value[k]) for k in keys) + "}"
raise ValueError(f"unhandled type: {type(value)}")
with open(sys.argv[1]) as f:
signed = json.load(f)
# Cross-check the inline key against the canonical JWKS (skip if you
# trust the bytes you received; include this for the regulator case).
import urllib.request
jwks = json.load(urllib.request.urlopen("https://dutyradar.com/.well-known/audit-pubkey.json"))
canonical_x = next((k["x"] for k in jwks["keys"] if k["kid"] == signed["public_key_jwk"]["kid"]), None)
assert canonical_x == signed["public_key_jwk"]["x"], "inline key does not match published JWKS"
sig = signed["signature"].removeprefix("ed25519:")
sig_bytes = b64u_decode(sig)
pub = Ed25519PublicKey.from_public_bytes(b64u_decode(signed["public_key_jwk"]["x"]))
payload = {k: v for k, v in signed.items() if k not in ("signature", "public_key_jwk")}
message = canonicalize(payload).encode()
try:
pub.verify(sig_bytes, message)
print("VALID — signature matches, payload not tampered with")
except InvalidSignature:
print("INVALID — signature does not match payload")
sys.exit(1)
Save as verify_export.mjs; run node verify_export.mjs export.json. No npm dependencies; Node ≥ 18.
import { readFileSync } from "node:fs";
import { createPublicKey, verify } from "node:crypto";
function b64uDecode(s) {
return Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((-s.length) & 3), "base64");
}
function canonicalize(v) {
if (v === null || typeof v !== "object") return JSON.stringify(v);
if (Array.isArray(v)) return "[" + v.map(canonicalize).join(",") + "]";
const keys = Object.keys(v).sort();
return "{" + keys.map((k) => JSON.stringify(k) + ":" + canonicalize(v[k])).join(",") + "}";
}
const signed = JSON.parse(readFileSync(process.argv[2], "utf8"));
// Cross-check the inline key against the published JWKS.
const jwks = await fetch("https://dutyradar.com/.well-known/audit-pubkey.json").then((r) => r.json());
const canonical = jwks.keys.find((k) => k.kid === signed.public_key_jwk.kid);
if (!canonical || canonical.x !== signed.public_key_jwk.x) {
console.error("inline key does not match published JWKS");
process.exit(1);
}
const pub = createPublicKey({ key: { ...signed.public_key_jwk }, format: "jwk" });
const sigBytes = b64uDecode(signed.signature.replace(/^ed25519:/, ""));
const { signature, public_key_jwk, ...payload } = signed;
const ok = verify(null, Buffer.from(canonicalize(payload)), pub, sigBytes);
console.log(ok ? "VALID — signature matches" : "INVALID");
process.exit(ok ? 0 : 1);
Run from a directory containing export.json. Requires openssl, jq, and curl.
# Step 1. Pull the canonical public key.
curl -s https://dutyradar.com/.well-known/audit-pubkey.json > jwks.json
# Step 2. Confirm the inline kid matches the published JWKS.
jq '.keys[] | select(.kid == "'$(jq -r .public_key_jwk.kid export.json)'") | .x' jwks.json
jq -r .public_key_jwk.x export.json
# (the two must match — if they don't, the export's inline key is not DutyRadar's)
# Step 3. Extract the public key in raw form (Ed25519 = 32 bytes) and
# convert to a DER SubjectPublicKeyInfo OpenSSL can read.
jq -r .public_key_jwk.x export.json \
| tr '_-' '/+' \
| base64 -d > pub.raw
printf '\x30\x2a\x30\x05\x06\x03\x2b\x65\x70\x03\x21\x00' > pub.der
cat pub.raw >> pub.der
openssl pkey -inform DER -pubin -in pub.der -out pub.pem
# Step 4. Re-emit the canonical JSON (sorted keys, no whitespace) of
# the signed payload. jq with --sort-keys gives canonical sorting.
jq -cS 'del(.signature, .public_key_jwk)' export.json > canonical.json
# Step 5. Decode the signature from base64url and verify.
jq -r .signature export.json | sed 's/^ed25519://' \
| tr '_-' '/+' | base64 -d > sig.bin
openssl pkeyutl -verify -pubin -inkey pub.pem -rawin -in canonical.json -sigfile sig.bin
# Output: "Signature Verified Successfully" (or "Signature Verification Failure")
A valid verification proves three things:
A The bytes in the export's events list, signed_at, period, api_key_prefix, and truncated fields have not been altered since signing.
B The signature was produced by the holder of the Ed25519 private key whose public half is published at /.well-known/audit-pubkey.json.
C If you fetched the JWKS over TLS from dutyradar.com, you have a chain of authentication from the certificate authority's signed cert, through DNSSEC if enabled, to the public key — and from there to the signature.
What it does not prove: that the events list is complete, that DutyRadar's classification was correct at the time, or that the regulator who receives this export will treat it as dispositive evidence. Those are different questions.