Verify a passport signature offline
Check with the public JSON and the published keys alone that the public data of a passport is exactly what its BatteryPass instance signed.
Every published passport version carries an Ed25519 signature over its public document. You can check it
without an API key and without asking BatteryPass whether the data is genuine: the public JSON and the key
set at /.well-known/batterypass/keys.json are all you need. The background is in
Signatures.
What you need
- The passport: its id (the UUID of
urn:dpp:{uuid}) or the GS1 Digital Link from its QR code. - The BatteryPass address that published it, as
BATTERYPASS_URL. Public reads need no key. - An RFC 8785 (JCS) implementation and Ed25519 for your language:
| Language | JSON canonicalisation (RFC 8785) | Ed25519 |
|---|---|---|
| TypeScript, Node 20+ | canonicalize | node:crypto |
| Python 3 | rfc8785 | cryptography |
| Go | github.com/gowebpki/jcs | crypto/ed25519 |
flowchart LR
J["Public JSON"] --> D["document"]
J --> V["verification"]
D -->|"RFC 8785"| C["Canonical bytes"]
C -->|"SHA-256"| H{"Equals verification.hash?"}
K["keys.json"] -->|"kid = verification.key_id"| P["Ed25519 public key"]
C --> S{"verification.signature valid?"}
P --> S
Step by step
Fetch the public JSON
Read the passport with getPublicPassport, or follow the
Digital Link with ?linkType=describedby: the resolver answers 307 with the public JSON as the target.
getPublicPassportByProductId returns the
same document by GTIN and serial number.
curl -sS "$BATTERYPASS_URL/api/v1/public/passports/0192f0c4-7d1e-7a3b-9c55-2f1e8d4a6b10"
# or from the QR code (-L follows the redirect)
curl -sSL "https://batterypass.example.com/01/04012345000023/21/SV2027-000184?linkType=describedby"{
"passport_id": "urn:dpp:0192f0c4-7d1e-7a3b-9c55-2f1e8d4a6b10",
"status": "published",
"document": {
"passport_id": "urn:dpp:0192f0c4-7d1e-7a3b-9c55-2f1e8d4a6b10",
"gtin": "04012345000023",
"serial_number": "SV2027-000184",
"gs1_digital_link": "https://batterypass.example.com/01/04012345000023/21/SV2027-000184",
"model_name": "SolarVolt Home 10",
"tier": "public",
"version": { "no": 3, "published_at": "2026-10-20T09:12:44Z", "registry_version": "0.5.0" },
"category": {
"key": "stationary",
"label": { "de": "Stationärer Energiespeicher (> 2 kWh)", "en": "Stationary storage battery (> 2 kWh)" }
},
"sections": [
{
"key": "performance",
"label": { "de": "Leistung & Haltbarkeit", "en": "Performance & durability" },
"fields": [
{
"key": "performance.nominal_voltage",
"label": { "de": "Nennspannung", "en": "Nominal voltage" },
"tier": "public",
"level": "model",
"status": "provided",
"value": 51.2,
"unit": "V"
}
]
}
],
"restricted_fields_present": ["condition.state_of_charge"]
},
"verification": {
"algorithm": "Ed25519",
"canonicalization": "RFC8785",
"hash": "sha256:9c1f0e5b7d4a3c2e1f0a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e",
"signature": "q2v0Yb3m8yJ1kQe7Zt5nC4xL9pW2sR6uH0aF3dG8jK1oM5iT7vX4zB2cN6eQ9rS1wY3uA5gD8hJ0lP4fK7mV2Q",
"key_id": "2026-10-k1",
"signed_at": "2026-10-20T09:12:44Z",
"keys_url": "https://batterypass.example.com/.well-known/batterypass/keys.json"
},
"links": {
"page": "https://batterypass.example.com/pass/0192f0c4-7d1e-7a3b-9c55-2f1e8d4a6b10",
"json": "https://batterypass.example.com/api/v1/public/passports/0192f0c4-7d1e-7a3b-9c55-2f1e8d4a6b10",
"keys": "https://batterypass.example.com/.well-known/batterypass/keys.json",
"gs1_digital_link": "https://batterypass.example.com/01/04012345000023/21/SV2027-000184"
}
}Only document is signed. passport_id, status, valid_until, verification and links change over
the passport's life and are not covered by the signature. A passport that is not public (qr_locked,
suspended, withdrawn, deleted_recycled) answers with its status and without document and
verification: there is nothing to verify.
Canonicalise the document
Parse the response with a standard JSON parser and serialise the document object with JCS (RFC 8785).
JCS sorts the members, removes whitespace and writes numbers and strings in one fixed form, so the same data
always gives the same bytes, whatever order or spacing the response had. Canonicalise the parsed object; do
not hash the response bytes or the output of your own JSON writer.
Compare the hash
Compute SHA-256 over the canonical bytes and write it as sha256: followed by the lowercase hex digest. It
must equal verification.hash. If it differs, the document you hold is not the one that was signed.
Pick the key
Load the JWK set from /.well-known/batterypass/keys.json of the BatteryPass address you expect the passport
to come from, and take the key whose kid equals verification.key_id:
{
"keys": [
{ "kty": "OKP", "crv": "Ed25519", "x": "WuoaQRoQuh2kPs5yYB6HxNz3Y58ndE8fRHnzG10NxoU", "kid": "2026-10-k1", "use": "sig", "alg": "EdDSA" }
]
}The set needs no key, may be cached for an hour and also lists retired keys, so signatures of old versions keep verifying after a key change.
Check the signature
Decode x (32 bytes) and verification.signature (64 bytes) from base64url without padding, and verify the
Ed25519 signature over the canonical bytes (not over the hash).
Where the keys come from
verification.keys_url and links.keys are not signed. Load the keys from the address you trust (the
manufacturer's BatteryPass address, or a key set you stored earlier), and treat a keys_url that points
somewhere else as a failed check.
Complete code
Each program takes the passport UUID as its argument and prints valid, or stops with the reason.
// verify.mts: npm install canonicalize, then: npx tsx verify.mts <passport-uuid>
import { createHash, createPublicKey, verify } from 'node:crypto';
import canonicalize from 'canonicalize';
const base = process.env.BATTERYPASS_URL; // the BatteryPass address that published the passport
const passportId = process.argv[2]; // the UUID of urn:dpp:{uuid}
async function getJSON(url: string) {
const res = await fetch(url);
if (!res.ok) throw new Error(`GET ${url}: ${res.status}`);
return res.json();
}
const passport = await getJSON(`${base}/api/v1/public/passports/${passportId}`);
const { document, verification } = passport;
if (!document || !verification) throw new Error(`status ${passport.status}: nothing to verify`);
// 1. Canonicalise the signed document (RFC 8785) and compare its SHA-256 with verification.hash.
const canonical = Buffer.from(canonicalize(document) ?? '', 'utf8');
const hash = `sha256:${createHash('sha256').update(canonical).digest('hex')}`;
if (hash !== verification.hash) throw new Error(`hash mismatch: ${hash}`);
// 2. Take the key named by verification.key_id from the published JWK set.
const { keys } = await getJSON(`${base}/.well-known/batterypass/keys.json`);
const jwk = keys.find((k: { kid: string }) => k.kid === verification.key_id);
if (!jwk) throw new Error(`unknown key ${verification.key_id}`);
// 3. Check the Ed25519 signature (base64url, no padding) over the canonical bytes.
const publicKey = createPublicKey({ key: jwk, format: 'jwk' });
const valid = verify(null, canonical, publicKey, Buffer.from(verification.signature, 'base64url'));
console.log(valid ? 'valid' : 'INVALID');
process.exitCode = valid ? 0 : 1;To check many passports at once, see the recipe Verify signatures of several passports.
When the check fails
| Result | Meaning | What to do |
|---|---|---|
No document or verification | The passport is not public at the moment; status says why | Show the status; there is nothing to verify |
| Hash mismatch | The document is not the one that was signed, or it was not canonicalised correctly | Canonicalise the parsed document with an RFC 8785 library; if it still differs, treat the data as not verified |
Unknown key_id | The key set you loaded has no such key | Reload the key set (yours may be older than the signature); if it is still missing, the check fails |
| Invalid signature | The document was not signed with that key | Treat the data as not verified |
Older versions
getPublicPassportVersion returns the public JSON
of the version that was valid at a point in time (?at=2026-09-01T00:00:00Z), with valid_until set once a
later version replaced it. Verify it in exactly the same way: versions never change after publication, and
retired keys stay in the key set.
Server-side check
verifyPassport runs the same check on the server and, in
addition, checks the full snapshot of the version, which a scanner never receives. Use it for a quick look or
a monitoring job; the offline check above is the one that does not rely on the server's word.
curl -sS "$BATTERYPASS_URL/api/v1/public/passports/0192f0c4-7d1e-7a3b-9c55-2f1e8d4a6b10/verify?version_no=3"{
"valid": true,
"version_no": 3,
"key_id": "2026-10-k1",
"public_document_valid": true,
"snapshot_valid": true,
"signed_at": "2026-10-20T09:12:44Z",
"checked_at": "2026-10-24T08:00:00Z"
}Without version_no, it checks the current version. valid is true only when both signatures verify and
both hashes match.