API-Referenz
Basis-URL ist die eigene Instanz. Alle Bodies sind JSON, Binärwerte
stehen als base64url ohne Padding. Authentifizierte Endpunkte erwarten
Authorization: Bearer <access_token>.
Die Konto-ID wird ausschließlich dem Token entnommen. Eine Angabe in
Body, Query oder Header wird ignoriert. Fehlerantworten haben immer
genau die Form { "error": "<code>" } — nie ein
Stack Trace, nie ein Feldpfad, nie eine SQL-Meldung.
Statuscodes
| Status | Bedeutung |
|---|---|
| 400 | validation_failed, bad_request |
| 401 | fehlender, ungültiger oder abgelaufener Token; fehlgeschlagener Beweis |
| 404 | nicht vorhanden oder fremdes Konto — ununterscheidbar, mit Absicht |
| 409 | fachlicher Konflikt (epoch_mismatch, sas_not_confirmed, …) |
| 413 | payload_too_large |
| 429 | too_many_requests |
Öffentliche Endpunkte
/healthz200 { "status": "ok" }. Bewusst ohne jede Zahl über Konten oder Datensätze.
/account
Legt Konto und erstes Gerät an. Ist ein Registrierungstoken
konfiguriert, ist der Header X-Registration-Token Pflicht.
Antwort 201 { account_id, device_id, epoch }, 5 Anfragen
pro Stunde.
{
"device": {
"name": "Arbeitsrechner",
"pk_x25519": "…",
"pk_ed25519": "…",
"wrapped_vk": "…"
},
"recovery": {
"wrapped_vk": "…",
"kdf_params": { "algo": "argon2id", "salt": "…", "m": 65536, "t": 3, "p": 4, "v": 19 },
"auth_hash": "…"
}
}
/auth/challenge?device_id=dev_…
200 { nonce, expires_at, context }. Einmalig, 60 Sekunden
gültig, 10 pro Minute. Wird auch für unbekannte Geräte ausgestellt —
kein Existenz-Orakel.
/auth/verify
{ device_id, nonce, signature } →
{ access_token, refresh_token, expires_in, token_type, account_id, epoch }
/auth/refresh
{ refresh_token } → neues Paar. Die Wiederverwendung eines
rotierten Tokens invalidiert die gesamte Kette
(401 refresh_reused).
/auth/logout{ refresh_token } → 204. Immer 204, auch bei unbekanntem Token.
/pairing
{ name, pk_x25519, pk_ed25519, nonce } →
201 { pairing_id, expires_at, sas_context }
/pairing/:id/status
{ state: "waiting" } ·
{ state: "claimed" | "confirmed", pk_a_x25519 } ·
{ state: "complete", account_id, device_id, epoch, wrapped_vk }
— danach ist die Sitzung weg. Dieser Endpunkt wird gepollt und
erlaubt darum 120 Anfragen pro Minute statt der 10 der übrigen
Pairing-Routen; das neue Gerät fragt alle zwei Sekunden und weicht
nach einem 429 zurück, statt die Kopplung abzubrechen.
/pairing/:id/confirm{ sas: "481205" } → 200 { state: "confirmed" }, höchstens drei Versuche.
/pairing/:id204. Abbruch durch eine der beiden Seiten.
/recovery/begin{ account_id } → 200 { kdf_params, epoch }. Kein Ciphertext.
/recovery/complete
{ account_id, auth_key } →
200 { wrapped_vk, kdf_params, epoch, ticket, ticket_expires_at }.
Fünf Versuche pro Konto und Stunde.
Dies ist der einzige Endpunkt, der eine Konto-ID aus der Anfrage verwendet. Das ist unvermeidbar — es gibt zu diesem Zeitpunkt kein Gerät und damit keinen Token. Autorisiert wird über den Beweisschlüssel, nicht über die Konto-ID.
/recovery/device
{ ticket, device: { name, pk_x25519, pk_ed25519, wrapped_vk } }
→ Konto, Gerät und Tokens. Das Ticket ist einmalig und zehn Minuten
gültig.
Authentifizierte Endpunkte
/sync?since=<seq>&limit=<n>
limit 1 bis 500, Vorgabe 200. Grabsteine
(deleted: true) werden mitgeliefert.
{
"epoch": 1,
"records": [ { "id", "seq", "type", "epoch", "nonce", "ciphertext", "hlc", "deleted" } ],
"next": 42,
"has_more": false,
"server_seq": 42
}
/sync
{ records: [ … ] }, 1 bis 500 Records, Ciphertext je
höchstens 256 KiB. Antwort
{ applied, stale, server_seq, epoch }.
stale enthält Records, deren Uhr nicht neuer war als der
gespeicherte Stand. Sie wurden nicht angewendet — der
Client muss den Serverstand ziehen und neu mergen. Alle Records eines
Pushs müssen auf der aktuellen Epoch liegen, sonst
409 epoch_mismatch.
/devices
{ devices: [{ id, name, epoch, created_at, last_seen, is_self }] }
— keine Schlüssel, keine Wraps.
/devices/:id
→ 200 { revoked, current_epoch, epoch_rotation_required: true }.
409 cannot_revoke_self, 409 last_device,
404 bei fremdem Gerät. Der Token des entzogenen Geräts ist
sofort tot.
/vault/key
→ { epoch, account_epoch, wrapped_vk } für das eigene
Gerät. epoch < account_epoch heißt: Es gab eine
Rotation, der Wrap ist veraltet.
/vault/key{ wrapped_vk, epoch } → 200 { epoch }. Nur auf der aktuellen Epoch.
/vault/rotateKörperlimit 16 MB statt der üblichen 2 MB.
{
"epoch": 2,
"devices": [ { "device_id": "dev_…", "wrapped_vk": "…" } ],
"recovery": { "wrapped_vk": "…", "kdf_params": { … }, "auth_hash": "…" },
"records": [ /* ALLE Records, neu verschlüsselt, inklusive Grabsteine */ ]
}
Ablehnungen: epoch_out_of_order,
devices_not_covered,
unknown_device_in_rotation,
rotation_incomplete.
/events WebSocket
Authentifizierung über den Authorization-Header des
Upgrade-Requests — nicht über einen Query-Parameter,
der im Zugriffslog landen würde.
{ "type": "hello", "device_id": "dev_…" }
{ "type": "sync", "seq": 42 }
{ "type": "epoch", "epoch": 2, "seq": 44 }
{ "type": "device_added", "device_id": "dev_…" }
{ "type": "pong" }
Nutzdaten gehen nie über diesen Kanal. Clientseitig erlaubt ist
{ "type": "ping" } — alles andere wird ignoriert.