· 6 Min. Lesezeit
Coffee Time: eine Passkey-PWA für die Kaffeekasse im Büro
Die Strichliste am Kühlschrank, verlegt auf das Telefon, das ohnehin alle in der Hand haben: Coffee Time ist eine installierbare PWA für die gemeinsame Kaffeekasse im Büro. Sie läuft auf PHP 8.2 ohne Framework, braucht in der Produktion keine Node-Laufzeitumgebung, speichert standardmäßig in SQLite und steht unter MIT-Lizenz. Ihr gesamter Datenbestand ist eine Namensliste der Belegschaft mit dem offenen Betrag daneben. Diese Liste liegt auf einem Server, um den sich niemand hauptberuflich kümmert, und genau dort sollte sie nicht lesbar sein. Aus dieser Randbedingung ergeben sich die meisten der folgenden Entscheidungen.
Namen, die der Server nicht entschlüsseln kann
Kontonamen werden vor dem Speichern mit dem öffentlichen RSA-Schlüssel der Administration verschlüsselt. Der Server hält weder den privaten Schlüssel noch irgendeinen Code zum Entschlüsseln. Die Administrationsseite importiert einen PKCS#8-Schlüssel über Web Crypto als nicht-extrahierbaren Schlüssel und entschlüsselt Namen nur im Speicher, auch für den CSV-Export; keine Anfrage transportiert je eine Klartextliste. Für die Buchhaltung erledigt ein Offline-CLI dasselbe direkt auf einer SQLite-Datei.
Doppelte Registrierungen müssen trotzdem abgewiesen werden; der Server muss also einen Namen wiedererkennen, den er nicht lesen kann. users.name_hash ist HMAC-SHA256(namePepper, normalizedName). Namen stammen aus einer kleinen, erratbaren Grundmenge, und das macht den Wert zu einem deterministischen Fingerabdruck, nicht zu einer Einwegfunktion: Wer Fingerabdrücke und Pepper zusammen besitzt, kann die Liste rekonstruieren, indem er Kandidaten aus einem Mitarbeiterverzeichnis durchhasht. Beides gehört deshalb nicht an denselben Ort. Der Pepper steht in config.php und bleibt damit aus Datenbank-Dumps heraus. Die eine Ausnahme ist der Einrichtungsassistent: Er existiert, damit keine Datei von Hand bearbeitet werden muss, und hat keinen anderen Ablageort für den Wert. Ein Backup aus einer so eingerichteten Installation enthält den Schlüssel zu seinen eigenen Fingerabdrücken. Die RSA-versiegelten Namen bleiben in beiden Fällen geschützt, und ein fehlender oder als Platzhalter belassener Pepper wird bei der Initialisierung abgelehnt, nicht still zu nachrechenbaren Fingerabdrücken verarbeitet.
Weder der öffentliche Schlüssel noch der Pepper lassen sich rotieren. Ein neuer RSA-Schlüssel kann nicht entschlüsseln, was der alte versiegelt hat; ein neuer Pepper entwertet jeden gespeicherten Fingerabdruck und damit die Dublettenprüfung. Nach der Ersteinrichtung verweigert die Anwendung deshalb jede Änderung der beiden Werte. Rotieren heißt in der Praxis: die entschlüsselte Namensliste mit dem alten privaten Schlüssel exportieren und mit einer frischen Datenbank beginnen.
Passkeys ohne Benutzernamen
Registrierung und Anmeldung laufen über WebAuthn mit auffindbaren Credentials und Nutzerverifikation. Die Anmelde-Ceremony sendet eine leere Allow-List, der Authenticator wählt das Konto aus: kein Feld für einen Benutzernamen, keine E-Mail-Adresse und für reguläre Konten nirgends im Schema ein Passwort. Der Benutzer-Datensatz und sein erster Passkey entstehen in einer einzigen Transaktion. Auf zwei verteilt, würde ein Fehler dazwischen einen Namen belegen, unter dem sich nie ein Credential anmelden kann; beim allerersten Konto läge zusätzlich das Admin-Flag auf einem unbrauchbaren Datensatz, bei bereits geschlossenem Einrichtungsassistenten.
Ein angemeldetes Gerät kann einen kurzlebigen Code (15 Minuten) ausstellen, um auf einem zweiten Gerät einen Passkey hinzuzufügen. Die Administration kann einen längeren (60 Minuten) für jemanden ausstellen, der alle Geräte verloren hat. Codes sind einmal verwendbar; gespeichert wird nur ihr Hash. Das Einlösen eines Administrations-Codes beendet zusätzlich die übrigen Sitzungen des Kontos — erst dieser Schritt entzieht dem verlorenen Gerät wirklich den Zugriff. Ein selbst ausgestellter Code lässt die anderen Sitzungen bewusst unangetastet. Sitzungen verlängern sich bei Nutzung, enden über dem 30-Tage-Leerlauffenster aber spätestens nach 180 Tagen; ein entwendetes Token bleibt nicht allein durch Benutzung unbegrenzt gültig.
Von der reinen Passkey-Anmeldung gibt es eine Ausnahme. Verwaltete Arbeitsplatzrechner blockieren Authenticator-Zugriffe mitunter vollständig; gesperrt wäre dann ausgerechnet die Person, die die Kasse führt. Administrationskonten können deshalb ein Passwort für das eigene Konto setzen: nur dort, optional, wieder entfernbar, mindestens zwölf Zeichen, und geprüft nicht nur beim Setzen, sondern auch am Anmelde-Endpunkt. Ohne Benutzernamen im Schema wird das Konto über denselben geschlüsselten HMAC gefunden, den die Dublettenprüfung verwendet. Ein unbekannter Name, ein Konto ohne Passwort und ein falsches Passwort beantworten alle ein schlichtes 401; die ersten beiden Fälle prüfen gegen einen Dummy-Hash und antworten nicht messbar schneller. Zwei Zähler begrenzen die Versuche, einer pro Aufrufer und einer pro Konto. Raten aus einem Adress-Pool ist damit nicht günstiger als von einer einzelnen Adresse. Passwörter werden vor password_hash() mit SHA-256 vorgehasht: PASSWORD_DEFAULT ist auf den anvisierten Shared Hosts weiterhin bcrypt, und bcrypt schneidet bei 72 Byte ab.
Eingefrorene Preise und ein begrenztes Undo
Jede Buchung liest den aktuellen Preis einmal und friert ihn an zwei Stellen ein: im Ereignis-Datensatz und im laufenden Saldo des Kontos. Eine spätere Preisänderung wirkt nur auf Buchungen nach ihr; der offene Betrag ist der Saldo abzüglich verbuchter Zahlungen und wird nie aus dem aktuellen Preis neu berechnet. Ein Undo nimmt den eingefrorenen Preis genau des Ereignisses zurück, das es entfernt.
Undo ist standardmäßig auf fünf Minuten begrenzt, konfigurierbar zwischen 30 Sekunden und 24 Stunden. Es ist für den Fehlgriff und den Doppeltipp gedacht. Ohne Begrenzung ist es außerdem ein Weg, den eigenen Zähler Tastendruck für Tastendruck auf null zurückzuführen — wofür es auch benutzt wurde. Das Zeitfenster wird in derselben Transaktion ausgewertet, die das Ereignis entfernt; gleichzeitige Eingaben von zwei Geräten können nie mehr Buchungen zurücknehmen, als vorhanden sind. Die verbleibende Zeit wird als relativer Wert gemeldet, weil ein Gerät mit abweichender Uhr einen absoluten Zeitstempel falsch einordnen würde. Eine zu spät eintreffende Anfrage erhält 409 undo_expired.
Die Tagesgrenze für Serien, das 28-Tage-Diagramm und die Monatsend-Erinnerung ist ein konfigurierbarer Versatz in Minuten östlich von UTC; ein Kaffee um elf Uhr abends zählt zu dem Tag, an dem er getrunken wurde. Der Versatz ist fest und folgt keiner Sommerzeitumstellung. Er ist ein Konfigurationswert, keine Zeitzonendatenbank.
Offline-Buchungen auf einem geteilten Gerät
Der Service Worker cacht ausschließlich die statische App-Shell; API-Aufrufe gehen immer ins Netz. Ein ohne Verbindung gebuchter Kaffee landet mit einer clientseitig erzeugten Ereignis-ID im localStorage und wird unter derselben ID wiederholt, bis er durchgeht. Der Server hält einen eindeutigen Index auf diese ID und behandelt eine Wiederholung als No-op, der den aktuellen Stand zurückgibt. Genau das macht Wiederholungen bei instabiler Verbindung ungefährlich. Der Index ist pro Konto eindeutig, nicht global: Die ID kommt vom Client, und ein globaler Schlüssel ließe die ID eines Kontos mit der eines anderen kollidieren, sodass die zweite Buchung hinter einer Erfolgsantwort verschwände. Existenzprüfung und Insert sind kein atomarer Schritt; eine Dublette, die den Index doch erreicht, wird abgefangen und als der idempotente Erfolg gemeldet, der sie ist.
Warteschlangeneinträge halten außerdem fest, unter welchem Konto sie entstanden sind, und die Warteschlange wird beim Abmelden geleert. Das Zielgerät ist ein Tablet in einer Küche, das mehrere Personen benutzen. Ohne diese beiden Maßnahmen würde ein offline gebuchter Kaffee, der nach dem Anmelden einer anderen Person übertragen wird, deren Konto belastet.
Build und Deployment
Das Frontend ist in TypeScript unter frontend/ geschrieben und wird zu den Skripten kompiliert, die der Server ausliefert. Der Produktionshost hat keine Node-Laufzeitumgebung; das Kompilat ist deshalb wie jedes andere statische Asset eingecheckt. Die CI führt denselben Build aus und vergleicht ihn mit dem Commit; ein veralteter Stand lässt die Pipeline scheitern und erreicht die Produktion nicht. SQLite ist der Standard, MySQL/MariaDB eine Option. Die Migrationsschritte sind je Treiber aufgeführt, additiv und idempotent; der MySQL-Pfad nimmt eine Advisory Lock, weil dessen DDL nicht in einer Transaktion laufen kann und gleichzeitige Kaltstarts einander sonst durch die Schritte überholen würden.
Das Deployment besteht aus composer install --no-dev und einem Web-Root, der auf public/ zeigt. Jeder grüne Build auf main veröffentlicht zusätzlich ein Multi-Architektur-Image in der GitHub Container Registry, mit signierter Build-Provenance-Attestierung. Fußzeile und GET /api/version melden den tatsächlich laufenden Commit, gelesen vom Server statt aus der gecachten Shell. Quellcode, dokumentierte Architekturentscheidungen und Changelog liegen auf GitHub.