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

StatusBedeutung
400validation_failed, bad_request
401fehlender, ungültiger oder abgelaufener Token; fehlgeschlagener Beweis
404nicht vorhanden oder fremdes Konto — ununterscheidbar, mit Absicht
409fachlicher Konflikt (epoch_mismatch, sas_not_confirmed, …)
413payload_too_large
429too_many_requests

Öffentliche Endpunkte

GET/healthz

200 { "status": "ok" }. Bewusst ohne jede Zahl über Konten oder Datensätze.

POST/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.

Body
{
  "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": "…"
  }
}
GET/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.

POST/auth/verify

{ device_id, nonce, signature }{ access_token, refresh_token, expires_in, token_type, account_id, epoch }

POST/auth/refresh

{ refresh_token } → neues Paar. Die Wiederverwendung eines rotierten Tokens invalidiert die gesamte Kette (401 refresh_reused).

POST/auth/logout

{ refresh_token }204. Immer 204, auch bei unbekanntem Token.

POST/pairing

{ name, pk_x25519, pk_ed25519, nonce }201 { pairing_id, expires_at, sas_context }

GET/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.

POST/pairing/:id/confirm

{ sas: "481205" }200 { state: "confirmed" }, höchstens drei Versuche.

DELETE/pairing/:id

204. Abbruch durch eine der beiden Seiten.

POST/recovery/begin

{ account_id }200 { kdf_params, epoch }. Kein Ciphertext.

POST/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.

POST/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

GET/sync?since=<seq>&limit=<n>

limit 1 bis 500, Vorgabe 200. Grabsteine (deleted: true) werden mitgeliefert.

Antwort
{
  "epoch": 1,
  "records": [ { "id", "seq", "type", "epoch", "nonce", "ciphertext", "hlc", "deleted" } ],
  "next": 42,
  "has_more": false,
  "server_seq": 42
}
POST/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.

GET/devices

{ devices: [{ id, name, epoch, created_at, last_seen, is_self }] } — keine Schlüssel, keine Wraps.

DELETE/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.

GET/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.

PUT/vault/key

{ wrapped_vk, epoch }200 { epoch }. Nur auf der aktuellen Epoch.

POST/vault/rotate

Körperlimit 16 MB statt der üblichen 2 MB.

Body
{
  "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.

GET/events WebSocket

Authentifizierung über den Authorization-Header des Upgrade-Requests — nicht über einen Query-Parameter, der im Zugriffslog landen würde.

Serverseitige Nachrichten
{ "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.