Signierte Anfragen
Ein Schlüssel allein ist wie ein Passwort: Wer ihn abgreift, kann ihn benutzen. Mit einer Signatur beweist jede Anfrage zusätzlich, dass sie von dir kommt, gerade eben erstellt wurde und unterwegs nicht verändert wurde. Der private Schlüssel dafür verlässt deinen Rechner nie.
Einrichten
Erzeuge ein Schlüsselpaar mit OpenSSL:
openssl genpkey -algorithm ed25519 -out privat.pem
openssl pkey -in privat.pem -puboutDen öffentlichen Schlüssel (der Block mit BEGIN PUBLIC KEY) trägst du im Portal bei deiner App ein. Setzt du dort zusätzlich Nur signierte Anfragen annehmen, lehnt die API alles ohne gültige Signatur ab; ein gestohlener Schlüssel ist dann wertlos.
Was signiert wird
Für jede Anfrage baust du diesen Text, Zeilen getrennt mit \n, ohne Zeilenumbruch am Ende:
bt1
GET
/v1/stations/8011160/departures?duration=30&products=regional
1792080000
k3Jf8sYqZr2xLw0aPq7N1g
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855| Zeile 1 | immer bt1 |
| Zeile 2 | Methode in Großbuchstaben |
| Zeile 3 | Pfad und Query. Die Query-Parameter nach Name (bei gleichem Namen nach Wert) sortiert und mit encodeURIComponent kodiert |
| Zeile 4 | Zeitstempel in Unix-Sekunden; darf höchstens 5 Minuten von unserer Uhr abweichen |
| Zeile 5 | Einmalwert: 16 bis 64 Zeichen A–Z a–z 0–9 - _, jedes Mal neu |
| Zeile 6 | SHA-256 des Körpers als Hex; ohne Körper der Hash der leeren Eingabe (wie oben) |
Die Ed25519-Signatur dieses Textes schickst du als Base64url mit, zusammen mit Zeitstempel und Einmalwert:
X-BT-Timestamp: 1792080000
X-BT-Nonce: k3Jf8sYqZr2xLw0aPq7N1g
X-BT-Signature: 7k9q…Jeder Einmalwert gilt nur einmal. Eine mitgeschnittene Anfrage lässt sich also nicht noch einmal abschicken.
Beispiele
import crypto from "node:crypto";
import fs from "node:fs";
const privat = crypto.createPrivateKey(fs.readFileSync("privat.pem"));
function signieren(methode, adresse, koerper = "") {
const u = new URL(adresse);
const query = [...u.searchParams]
.sort(([a, x], [b, y]) => (a < b ? -1 : a > b ? 1 : x < y ? -1 : x > y ? 1 : 0))
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&");
const zeit = String(Math.floor(Date.now() / 1000));
const nonce = crypto.randomBytes(18).toString("base64url");
const hash = crypto.createHash("sha256").update(koerper).digest("hex");
const text = ["bt1", methode, u.pathname + (query ? "?" + query : ""), zeit, nonce, hash].join("\n");
return {
"X-BT-Timestamp": zeit,
"X-BT-Nonce": nonce,
"X-BT-Signature": crypto.sign(null, Buffer.from(text), privat).toString("base64url"),
};
}
const adresse = "https://developer.bahnhoftafel.de/v1/stations/8011160/departures?duration=30";
const antwort = await fetch(adresse, {
headers: { Authorization: `Bearer ${process.env.BT_SCHLUESSEL}`, ...signieren("GET", adresse) },
});import base64, hashlib, os, time, urllib.parse
import requests
from cryptography.hazmat.primitives.serialization import load_pem_private_key
privat = load_pem_private_key(open("privat.pem", "rb").read(), None)
def signieren(methode, adresse, koerper=b""):
u = urllib.parse.urlsplit(adresse)
paare = sorted(urllib.parse.parse_qsl(u.query, keep_blank_values=True))
kodiert = lambda s: urllib.parse.quote(s, safe="!*'()")
query = "&".join(f"{kodiert(k)}={kodiert(v)}" for k, v in paare)
zeit = str(int(time.time()))
nonce = base64.urlsafe_b64encode(os.urandom(18)).rstrip(b"=").decode()
text = "\n".join(["bt1", methode, u.path + ("?" + query if query else ""), zeit, nonce,
hashlib.sha256(koerper).hexdigest()])
signatur = base64.urlsafe_b64encode(privat.sign(text.encode())).rstrip(b"=").decode()
return {"X-BT-Timestamp": zeit, "X-BT-Nonce": nonce, "X-BT-Signature": signatur}
adresse = "https://developer.bahnhoftafel.de/v1/stations/8011160/departures?duration=30"
antwort = requests.get(adresse, timeout=30, headers={
"Authorization": f"Bearer {os.environ['BT_SCHLUESSEL']}", **signieren("GET", adresse)})Wenn es nicht klappt
Die API antwortet mit 401 invalid_signature und sagt in message, was nicht stimmt: Header fehlen, Zeitstempel zu alt, Einmalwert schon benutzt oder Signatur passt nicht. Im letzten Fall liegt es fast immer an Zeile 3: Prüf, ob die Query genau so sortiert und kodiert ist wie beschrieben, und ob du den Pfad ohne Basisadresse nimmst.