# Signaturen mehrerer Pässe prüfen

> Prüfen Sie die Ed25519-Signaturen einer Liste öffentlicher Pässe offline, laden Sie das Schlüsselset nur einmal und geben Sie je Pass ein Ergebnis aus.

Das Skript erwartet Pass-UUIDs (den Teil nach `urn:dpp:`) als Argumente, lädt das Schlüsselset von
`/.well-known/batterypass/keys.json` einmal und prüft jeden Pass wie in
[Passsignatur offline prüfen](https://passport.kabin.sh/docs/de/guides/verify-signature) beschrieben: `document` nach RFC 8785
kanonisieren, den SHA-256 mit `verification.hash` vergleichen und die Ed25519-Signatur mit dem Schlüssel prüfen,
den `verification.key_id` nennt. Einen API-Schlüssel braucht es nicht. Ist ein Pass nicht `valid`, endet es mit
dem Status 1; es passt also in einen Monitoring-Job oder eine Wareneingangsprüfung.

Der Tab cURL nutzt stattdessen [`verifyPassport`](https://passport.kabin.sh/docs/de/api-reference/public/verify-passport): Dort prüft der
Server die Signaturen für Sie. Das geht schnell, verlässt sich aber auf die Antwort des Servers.

```bash title="cURL"
#!/usr/bin/env bash
# verify-many.sh <passport-uuid>...: the server-side check with verifyPassport (needs curl and jq)
for id in "$@"; do
  curl -sS "$BATTERYPASS_URL/api/v1/public/passports/$id/verify" |
    jq -r --arg id "$id" '"\($id)\t\(if .valid == true then "valid (version \(.version_no))" elif .code then .code else "INVALID" end)"'
done
```

```ts title="TypeScript"
// verify-many.mts: npm install canonicalize, then: npx tsx verify-many.mts <passport-uuid>...
import { createHash, createPublicKey, verify, type KeyObject } from 'node:crypto';
import canonicalize from 'canonicalize';

const base = process.env.BATTERYPASS_URL;
const ids = process.argv.slice(2); // passport UUIDs

const getJSON = async (url: string) => {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`${res.status}`);
  return res.json();
};

// Load the key set once.
const keys = new Map<string, KeyObject>();
for (const jwk of (await getJSON(`${base}/.well-known/batterypass/keys.json`)).keys) {
  keys.set(jwk.kid, createPublicKey({ key: jwk, format: 'jwk' }));
}

async function check(id: string): Promise<string> {
  const { status, document, verification: v } = await getJSON(`${base}/api/v1/public/passports/${id}`);
  if (!document || !v) return `not checked (${status})`;
  const canonical = Buffer.from(canonicalize(document) ?? '', 'utf8');
  if (`sha256:${createHash('sha256').update(canonical).digest('hex')}` !== v.hash) return 'HASH MISMATCH';
  const key = keys.get(v.key_id);
  if (!key) return `UNKNOWN KEY ${v.key_id}`;
  return verify(null, canonical, key, Buffer.from(v.signature, 'base64url')) ? 'valid' : 'INVALID SIGNATURE';
}

let bad = 0;
for (const id of ids) {
  const result = await check(id).catch((err) => `error ${err.message}`);
  if (result !== 'valid') bad++;
  console.log(`${id}\t${result}`);
}
process.exitCode = bad > 0 ? 1 : 0;
```

```python title="Python"
# verify_many.py: pip install requests cryptography rfc8785, then: python verify_many.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"]


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


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


# Load the key set once.
keys = {
    jwk["kid"]: Ed25519PublicKey.from_public_bytes(b64url(jwk["x"]))
    for jwk in get_json(f"{base}/.well-known/batterypass/keys.json")["keys"]
}


def check(passport_id):
    passport = get_json(f"{base}/api/v1/public/passports/{passport_id}")
    document, v = passport.get("document"), passport.get("verification")
    if not document or not v:
        return f"not checked ({passport['status']})"
    canonical = rfc8785.dumps(document)
    if "sha256:" + hashlib.sha256(canonical).hexdigest() != v["hash"]:
        return "HASH MISMATCH"
    if v["key_id"] not in keys:
        return f"UNKNOWN KEY {v['key_id']}"
    try:
        keys[v["key_id"]].verify(b64url(v["signature"]), canonical)
        return "valid"
    except InvalidSignature:
        return "INVALID SIGNATURE"


bad = 0
for passport_id in sys.argv[1:]:  # passport UUIDs
    try:
        result = check(passport_id)
    except requests.RequestException as err:
        result = f"error {err}"
    bad += result != "valid"
    print(f"{passport_id}\t{result}")
sys.exit(1 if bad else 0)
```

```go title="Go"
// verify-many: 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"
	"net/http"
	"os"

	"github.com/gowebpki/jcs"
)

var base = os.Getenv("BATTERYPASS_URL")

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("%s", res.Status)
	}
	return json.NewDecoder(res.Body).Decode(v)
}

func check(id string, keys map[string]ed25519.PublicKey) string {
	var p struct {
		Status       string          `json:"status"`
		Document     json.RawMessage `json:"document"`
		Verification *struct {
			Hash, Signature string
			KeyID           string `json:"key_id"`
		} `json:"verification"`
	}
	if err := getJSON(base+"/api/v1/public/passports/"+id, &p); err != nil {
		return "error " + err.Error()
	}
	if p.Document == nil || p.Verification == nil {
		return "not checked (" + p.Status + ")"
	}
	canonical, err := jcs.Transform(p.Document)
	if err != nil {
		return "error " + err.Error()
	}
	sum := sha256.Sum256(canonical)
	if "sha256:"+hex.EncodeToString(sum[:]) != p.Verification.Hash {
		return "HASH MISMATCH"
	}
	pub, ok := keys[p.Verification.KeyID]
	if !ok {
		return "UNKNOWN KEY " + p.Verification.KeyID
	}
	sig, err := base64.RawURLEncoding.DecodeString(p.Verification.Signature)
	if err != nil || !ed25519.Verify(pub, canonical, sig) {
		return "INVALID SIGNATURE"
	}
	return "valid"
}

func main() {
	// Load the key set once.
	var set struct {
		Keys []struct{ Kid, X string } `json:"keys"`
	}
	if err := getJSON(base+"/.well-known/batterypass/keys.json", &set); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
	keys := map[string]ed25519.PublicKey{}
	for _, k := range set.Keys {
		if x, err := base64.RawURLEncoding.DecodeString(k.X); err == nil && len(x) == ed25519.PublicKeySize {
			keys[k.Kid] = x
		}
	}

	bad := 0
	for _, id := range os.Args[1:] { // passport UUIDs
		result := check(id, keys)
		if result != "valid" {
			bad++
		}
		fmt.Printf("%s\t%s\n", id, result)
	}
	if bad > 0 {
		os.Exit(1)
	}
}
```

```text title="Ausgabe"
0192f0c4-7d1e-7a3b-9c55-2f1e8d4a6b10	valid
0192f0c4-7d1e-7a3b-9c55-2f1e8d4a6b11	not checked (qr_locked)
0192f0c4-7d1e-7a3b-9c55-2f1e8d4a6b12	error 404
```

`not checked` heißt, dass der Pass gerade nicht öffentlich ist (sein `status` steht dabei); ein signiertes Dokument
gibt es dann nicht. Öffentliche Abrufe sind auf 300 Anfragen pro Minute und Client-Adresse begrenzt; bei langen
Listen legen Sie eine Pause ein oder behandeln `429` wie unter
[Fehler und Wiederholungen](https://passport.kabin.sh/docs/de/guides/handle-errors). Um Pässe eines anderen Herstellers zu prüfen, setzen
Sie `BATTERYPASS_URL` auf die Adresse, von der dessen Pässe stammen.
