Skip to content

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:
LanguageJSON canonicalisation (RFC 8785)Ed25519
TypeScript, Node 20+canonicalizenode:crypto
Python 3rfc8785cryptography
Gogithub.com/gowebpki/jcscrypto/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
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"
200
{
  "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.json
{
  "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

ResultMeaningWhat to do
No document or verificationThe passport is not public at the moment; status says whyShow the status; there is nothing to verify
Hash mismatchThe document is not the one that was signed, or it was not canonicalised correctlyCanonicalise the parsed document with an RFC 8785 library; if it still differs, treat the data as not verified
Unknown key_idThe key set you loaded has no such keyReload the key set (yours may be older than the signature); if it is still missing, the check fails
Invalid signatureThe document was not signed with that keyTreat 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
curl -sS "$BATTERYPASS_URL/api/v1/public/passports/0192f0c4-7d1e-7a3b-9c55-2f1e8d4a6b10/verify?version_no=3"
200
{
  "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.

On this page