# Passsignatur offline prüfen

> Prüfen Sie allein mit dem öffentlichen JSON und den veröffentlichten Schlüsseln, dass die öffentlichen Daten eines Passes genau so signiert wurden.

Jede veröffentlichte Passversion trägt eine Ed25519-Signatur über ihr öffentliches Dokument. Sie können sie
ohne API-Schlüssel prüfen und müssen BatteryPass nicht fragen, ob die Daten echt sind: Das öffentliche JSON und
das Schlüsselset unter `/.well-known/batterypass/keys.json` genügen. Den Hintergrund erklärt
[Signaturen](https://passport.kabin.sh/docs/de/concepts/signatures).

## Was Sie brauchen

- Den Pass: seine ID (die UUID aus `urn:dpp:{uuid}`) oder den GS1 Digital Link aus seinem QR-Code.
- Die BatteryPass-Adresse, die ihn veröffentlicht hat, als `BATTERYPASS_URL`. Öffentliche Abfragen brauchen
  keinen Schlüssel.
- Eine Implementierung von RFC 8785 (JCS) und Ed25519 für Ihre Sprache:

| Sprache              | JSON-Kanonisierung (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["Öffentliches JSON"] --> D["document"]
  J --> V["verification"]
  D -->|"RFC 8785"| C["Kanonische Bytes"]
  C -->|"SHA-256"| H{"Gleich verification.hash?"}
  K["keys.json"] -->|"kid = verification.key_id"| P["Öffentlicher Ed25519-Schlüssel"]
  C --> S{"verification.signature gültig?"}
  P --> S
```

## Schritt für Schritt

1. **Öffentliches JSON abrufen**

   Lesen Sie den Pass mit [`getPublicPassport`](https://passport.kabin.sh/docs/de/api-reference/public/get-public-passport) oder folgen Sie
   dem Digital Link mit `?linkType=describedby`: Der Resolver antwortet mit `307` und dem öffentlichen JSON als
   Ziel. [`getPublicPassportByProductId`](https://passport.kabin.sh/docs/de/api-reference/public/get-public-passport-by-product-id) liefert
   dasselbe Dokument über GTIN und Seriennummer.

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

   # oder ausgehend vom QR-Code (-L folgt der Weiterleitung)
   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"
     }
   }
   ```

   Signiert ist nur `document`. `passport_id`, `status`, `valid_until`, `verification` und `links` ändern sich im
   Leben eines Passes und fallen nicht unter die Signatur. Ein Pass, der gerade nicht öffentlich ist (`qr_locked`,
   `suspended`, `withdrawn`, `deleted_recycled`), antwortet nur mit seinem Status, ohne `document` und
   `verification`: Dann gibt es nichts zu prüfen.

2. **Dokument kanonisieren**

   Lesen Sie die Antwort mit einem gewöhnlichen JSON-Parser und serialisieren Sie das Objekt `document` mit JCS
   (RFC 8785). JCS sortiert die Felder, entfernt Leerraum und schreibt Zahlen und Zeichenketten in einer festen
   Form. Dieselben Daten ergeben so immer dieselben Bytes, egal in welcher Reihenfolge und mit welchen Leerzeichen
   die Antwort kam. Kanonisieren Sie das geparste Objekt; hashen Sie weder die Bytes der Antwort noch die Ausgabe
   Ihres eigenen JSON-Writers.

3. **Hash vergleichen**

   Berechnen Sie SHA-256 über die kanonischen Bytes und schreiben Sie das Ergebnis als `sha256:` gefolgt vom
   Hex-Digest in Kleinbuchstaben. Es muss `verification.hash` entsprechen. Weicht es ab, ist Ihr Dokument nicht
   das signierte.

4. **Schlüssel wählen**

   Laden Sie das JWK-Set von `/.well-known/batterypass/keys.json` der BatteryPass-Adresse, von der der Pass stammen
   soll, und nehmen Sie den Schlüssel, dessen `kid` gleich `verification.key_id` ist:

   ```json title="keys.json"
   {
     "keys": [
       { "kty": "OKP", "crv": "Ed25519", "x": "WuoaQRoQuh2kPs5yYB6HxNz3Y58ndE8fRHnzG10NxoU", "kid": "2026-10-k1", "use": "sig", "alg": "EdDSA" }
     ]
   }
   ```

   Das Set braucht keinen Schlüssel, darf eine Stunde zwischengespeichert werden und enthält auch abgelöste
   Schlüssel. Signaturen alter Versionen bleiben so nach einem Schlüsselwechsel prüfbar.

5. **Signatur prüfen**

   Dekodieren Sie `x` (32 Bytes) und `verification.signature` (64 Bytes) aus base64url ohne Padding und prüfen Sie
   die Ed25519-Signatur über die kanonischen Bytes, nicht über den Hash.

> [!WARNING]
> **Woher die Schlüssel kommen**
>
> `verification.keys_url` und `links.keys` sind nicht signiert. Laden Sie die Schlüssel von der Adresse, der Sie
> vertrauen (der BatteryPass-Adresse des Herstellers oder einem früher gespeicherten Schlüsselset), und werten Sie
> eine `keys_url`, die woanders hinzeigt, als fehlgeschlagene Prüfung.

## Vollständiger Code

Jedes Programm erwartet die UUID des Passes als Argument und gibt `valid` aus oder bricht mit dem Grund ab.

```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")
}
```

Viele Pässe auf einmal prüft das Rezept [Signaturen mehrerer Pässe prüfen](https://passport.kabin.sh/docs/de/recipes/verify-signatures).

## Wenn die Prüfung fehlschlägt

| Ergebnis                            | Bedeutung                                                                 | Was zu tun ist                                                                                                                       |
| ----------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| Kein `document` oder `verification` | Der Pass ist gerade nicht öffentlich; `status` nennt den Grund            | Status anzeigen; es gibt nichts zu prüfen                                                                                            |
| Hash weicht ab                      | Das Dokument ist nicht das signierte oder wurde nicht richtig kanonisiert | Das geparste `document` mit einer RFC-8785-Bibliothek kanonisieren; weicht der Hash dann noch ab, gelten die Daten als nicht geprüft |
| Unbekannte `key_id`                 | Das geladene Schlüsselset kennt diesen Schlüssel nicht                    | Schlüsselset neu laden (Ihres ist vielleicht älter als die Signatur); fehlt er weiterhin, ist die Prüfung fehlgeschlagen             |
| Signatur ungültig                   | Das Dokument wurde nicht mit diesem Schlüssel signiert                    | Die Daten gelten als nicht geprüft                                                                                                   |

## Ältere Versionen

[`getPublicPassportVersion`](https://passport.kabin.sh/docs/de/api-reference/public/get-public-passport-version) liefert das öffentliche
JSON der Version, die zu einem Zeitpunkt galt (`?at=2026-09-01T00:00:00Z`); sobald eine spätere Version sie
abgelöst hat, ist `valid_until` gesetzt. Sie prüfen sie genauso: Versionen ändern sich nach der Veröffentlichung
nie, und abgelöste Schlüssel bleiben im Schlüsselset.

## Prüfung auf dem Server

[`verifyPassport`](https://passport.kabin.sh/docs/de/api-reference/public/verify-passport) führt dieselbe Prüfung auf dem Server aus und
prüft zusätzlich den vollständigen Snapshot der Version, den ein Scanner nie erhält. Das eignet sich für einen
schnellen Blick oder ein Monitoring. Unabhängig vom Wort des Servers ist nur die Offline-Prüfung oben.

```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"
}
```

Ohne `version_no` prüft der Server die aktuelle Version. `valid` ist nur dann `true`, wenn beide Signaturen
gültig sind und beide Hashes stimmen.
