# 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](https://passport.kabin.sh/docs/en/concepts/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`](https://www.npmjs.com/package/canonicalize) | `node:crypto`                                            |
| Python 3             | [`rfc8785`](https://pypi.org/project/rfc8785/)               | [`cryptography`](https://pypi.org/project/cryptography/) |
| Go                   | [`github.com/gowebpki/jcs`](https://github.com/gowebpki/jcs) | `crypto/ed25519`                                         |

```mermaid
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

1. **Fetch the public JSON**

   Read the passport with [`getPublicPassport`](https://passport.kabin.sh/docs/en/api-reference/public/get-public-passport), or follow the
   Digital Link with `?linkType=describedby`: the resolver answers `307` with the public JSON as the target.
   [`getPublicPassportByProductId`](https://passport.kabin.sh/docs/en/api-reference/public/get-public-passport-by-product-id) returns the
   same document by GTIN and serial number.

   ```bash title="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"
   ```

   ```json title="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.

2. **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.

3. **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.

4. **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`:

   ```json title="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.

5. **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).

> [!WARNING]
> **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.

```ts title="TypeScript"
// 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;
```

```python title="Python"
# verify.py: pip install requests cryptography rfc8785, then: python verify.py <passport-uuid>
import base64
import hashlib
import os
import sys

import requests
import rfc8785
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey

base = os.environ["BATTERYPASS_URL"]  # the BatteryPass address that published the passport
passport_id = sys.argv[1]  # the UUID of urn:dpp:{uuid}


def get_json(url):
    res = requests.get(url, timeout=10)
    res.raise_for_status()
    return res.json()


def b64url(text):
    return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))


passport = get_json(f"{base}/api/v1/public/passports/{passport_id}")
document, verification = passport.get("document"), passport.get("verification")
if not document or not verification:
    sys.exit(f"status {passport['status']}: nothing to verify")

# 1. Canonicalise the signed document (RFC 8785) and compare its SHA-256 with verification.hash.
canonical = rfc8785.dumps(document)
digest = "sha256:" + hashlib.sha256(canonical).hexdigest()
if digest != verification["hash"]:
    sys.exit(f"hash mismatch: {digest}")

# 2. Take the key named by verification.key_id from the published JWK set.
keys = get_json(f"{base}/.well-known/batterypass/keys.json")["keys"]
jwk = next((k for k in keys if k["kid"] == verification["key_id"]), None)
if jwk is None:
    sys.exit(f"unknown key {verification['key_id']}")

# 3. Check the Ed25519 signature (base64url, no padding) over the canonical bytes.
try:
    Ed25519PublicKey.from_public_bytes(b64url(jwk["x"])).verify(b64url(verification["signature"]), canonical)
except InvalidSignature:
    sys.exit("INVALID")
print("valid")
```

```go title="Go"
// verify: go get github.com/gowebpki/jcs, then: go run . <passport-uuid>
package main

import (
	"crypto/ed25519"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"os"

	"github.com/gowebpki/jcs"
)

type publicPassport struct {
	Status       string          `json:"status"`
	Document     json.RawMessage `json:"document"` // kept as received, canonicalised below
	Verification *struct {
		Hash      string `json:"hash"`
		Signature string `json:"signature"`
		KeyID     string `json:"key_id"`
	} `json:"verification"`
}

type jwkSet struct {
	Keys []struct {
		Kid string `json:"kid"`
		X   string `json:"x"`
	} `json:"keys"`
}

func getJSON(url string, v any) error {
	res, err := http.Get(url)
	if err != nil {
		return err
	}
	defer res.Body.Close()
	if res.StatusCode != http.StatusOK {
		return fmt.Errorf("GET %s: %s", url, res.Status)
	}
	return json.NewDecoder(res.Body).Decode(v)
}

func main() {
	base := os.Getenv("BATTERYPASS_URL") // the BatteryPass address that published the passport
	passportID := os.Args[1]             // the UUID of urn:dpp:{uuid}

	var p publicPassport
	if err := getJSON(base+"/api/v1/public/passports/"+passportID, &p); err != nil {
		log.Fatal(err)
	}
	if p.Document == nil || p.Verification == nil {
		log.Fatalf("status %s: nothing to verify", p.Status)
	}

	// 1. Canonicalise the signed document (RFC 8785) and compare its SHA-256 with verification.hash.
	canonical, err := jcs.Transform(p.Document)
	if err != nil {
		log.Fatal(err)
	}
	sum := sha256.Sum256(canonical)
	if hash := "sha256:" + hex.EncodeToString(sum[:]); hash != p.Verification.Hash {
		log.Fatalf("hash mismatch: %s", hash)
	}

	// 2. Take the key named by verification.key_id from the published JWK set.
	var set jwkSet
	if err := getJSON(base+"/.well-known/batterypass/keys.json", &set); err != nil {
		log.Fatal(err)
	}
	var pub []byte
	for _, k := range set.Keys {
		if k.Kid == p.Verification.KeyID {
			pub, _ = base64.RawURLEncoding.DecodeString(k.X)
		}
	}
	if len(pub) != ed25519.PublicKeySize {
		log.Fatalf("unknown key %s", p.Verification.KeyID)
	}

	// 3. Check the Ed25519 signature (base64url, no padding) over the canonical bytes.
	sig, err := base64.RawURLEncoding.DecodeString(p.Verification.Signature)
	if err != nil || !ed25519.Verify(pub, canonical, sig) {
		log.Fatal("INVALID")
	}
	fmt.Println("valid")
}
```

To check many passports at once, see the recipe [Verify signatures of several passports](https://passport.kabin.sh/docs/en/recipes/verify-signatures).

## 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`](https://passport.kabin.sh/docs/en/api-reference/public/get-public-passport-version) 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`](https://passport.kabin.sh/docs/en/api-reference/public/verify-passport) 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.

```bash title="cURL"
curl -sS "$BATTERYPASS_URL/api/v1/public/passports/0192f0c4-7d1e-7a3b-9c55-2f1e8d4a6b10/verify?version_no=3"
```

```json title="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.
