Artikel

OAuth-Token verschlüsseln, bevor jemand danach fragt

Wer sich über Discord anmeldet, hinterlässt bei mir ein Zugriffstoken. Was damit möglich wäre, wie ich es verschlüssele — und der Fehler, den ich dabei jahrelang im Code hatte.

BlackZackBlackzack

916 Wörter · 5 Min. Lesezeit

  • sicherheit
  • yurna
  • architektur

Wenn sich jemand über Discord bei meinem Dashboard anmeldet, bekomme ich zwei Dinge: ein Zugriffstoken und ein Erneuerungstoken. Mit dem ersten kann ich in seinem Namen bestimmte Sachen abfragen, mit dem zweiten mir ein neues besorgen, wenn das erste abläuft.

Das sind fremde Zugangsdaten. Sie gehören mir nicht, ich verwahre sie nur — und dieser Gedanke hat den ganzen Umbau ausgelöst.

Was ein Angreifer damit anfangen könnte

Der Umfang hängt an den Bereichen, die man bei der Anmeldung angefragt hat. Bei mir sind es drei: Wer ist das, auf welchen Servern ist die Person, und Servern beitreten.

Mit einem gestohlenen Token könnte jemand also:

  • Die Identität und Serverliste einer Person auslesen.
  • Diese Person Servern hinzufügen, ohne dass sie zustimmt.

Das ist keine Katastrophe, aber es ist eine Menge für Daten, die eigentlich nur als technisches Nebenprodukt anfallen. Und es ist die Art von Problem, bei der ich nicht darauf vertrauen will, dass niemand an die Datenbank kommt.

Die Umsetzung

Verschlüsselt wird mit einem authentifizierten Verfahren, der Schlüssel kommt aus der Umgebung, und jeder Wert bekommt eine eigene Nonce.

function verschluesseln(klartext) {
  const nonce = randomBytes(12);
  const chiffre = createCipheriv("aes-256-gcm", schluessel, nonce);
  const daten = Buffer.concat([chiffre.update(klartext, "utf8"), chiffre.final()]);
  const pruefwert = chiffre.getAuthTag();
  const teile = [nonce, daten, pruefwert].map((t) => t.toString("base64"));
  return `enc:v1:${teile.join(":")}`;
}

Drei Entscheidungen darin, die ich bewusst getroffen habe:

Das Präfix enc:v1:. Damit erkennt der Code, ob ein Wert verschlüsselt ist, und man kann alte unverschlüsselte Werte während einer Übergangszeit weiter lesen. Ohne diese Kennzeichnung ist die Umstellung ein harter Schnitt.

Ein Verfahren mit Prüfwert. Verschlüsselung schützt vor Mitlesen. Ein Prüfwert schützt zusätzlich davor, dass jemand den Geheimtext verändert, ohne den Schlüssel zu kennen.

Base64 statt Hex. Ein Drittel kürzer, bei vielen Datensätzen ein spürbarer Unterschied.

Der Fehler, den ich jahrelang im Code hatte

Beim Umbau bin ich über eine Stelle gestolpert, die schlimmer war als fehlende Verschlüsselung.

In einem Zweig meiner Anmeldebehandlung wurde das komplette Tokenobjekt in die Sitzung übernommen:

// so war es — falsch
session.user = { ...token };

Sitzungsdaten sind über eine Adresse im Browser abrufbar. Das bedeutet: Alles, was in der Sitzung landet, ist für den angemeldeten Nutzer im Klartext lesbar — und für jedes Skript, das in seinem Browser läuft. In diesem Objekt steckten unter anderem das Erneuerungstoken und weitere Werte, die nie den Server hätten verlassen dürfen.

Die Schutzwirkung, die man sich durch Cookies erkauft, auf die JavaScript nicht zugreifen kann, ist damit vollständig umgangen.

Der Fix ist trivial und die Lehre die eigentliche Erkenntnis:

// so ist es richtig — nur das, was der Client braucht
session.user = { id: token.sub, name: token.name, bild: token.picture };

Warnung

Das Muster dahinter ist allgemein: Ein Spread-Operator auf ein Objekt, dessen Inhalt man nicht vollständig kennt, ist gefährlich. Genau dieselbe Struktur habe ich später an anderer Stelle wiedergefunden, in einer API-Antwort. Wer ein Objekt hinausgibt, sollte die Felder aufzählen, die hinaus dürfen — nicht die, die nicht hinaus dürfen. Eine Positivliste bleibt richtig, wenn das Objekt später wächst. Eine Negativliste nicht.

Was dazugehört

Rotation. Ein verschlüsseltes Token ist immer noch ein gültiges Token. Wenn jemand sich abmeldet, wird es entwertet — nicht nur die Sitzung gelöscht.

Ablauf. Zugriffstoken sind zeitlich begrenzt. Bei mir liegt zusätzlich ein Ablaufzeitpunkt daneben, und abgelaufene Werte werden aufgeräumt statt endlos aufbewahrt.

Kein Protokollieren. Die Maskierungsfunktion, die ich für Zugangsdaten gebaut hatte, kennt seitdem auch diese Felder.

Ein Schlüsselwechsel muss möglich sein. Über die Versionsangabe im Präfix ist das machbar: neuer Schlüssel bekommt v2, ein Hintergrundvorgang liest alte Werte, entschlüsselt sie mit dem alten Schlüssel und schreibt sie neu.

Wie ich den Umbau ohne Ausfall gemacht habe

Bestehende Werte zu verschlüsseln ist eine Migration im laufenden Betrieb, und die geht in drei Stufen.

Stufe 1: Lesen kann beides. Die Entschlüsselung erkennt am Präfix, ob ein Wert verschlüsselt ist. Ist er es nicht, wird er unverändert zurückgegeben. Ab jetzt ist die Anwendung mit beiden Zuständen lauffähig.

Stufe 2: Schreiben verschlüsselt immer. Jeder Wert, der neu gesetzt wird, ist verschlüsselt. Die Menge der Klartextwerte schrumpft von selbst.

Stufe 3: Ein Durchlauf über den Rest. Ein Skript, das alle verbliebenen Klartextwerte einliest und verschlüsselt zurückschreibt.

Nach Stufe 3 könnte man die Klartext-Unterstützung entfernen. Ich habe sie gelassen — mit einem Protokolleintrag, der meldet, wenn doch noch einer auftaucht. Seit Monaten kommt keiner mehr, und die Zeile kostet nichts.

Dieses Muster — erst beides lesen können, dann neu schreiben, dann den Rest nachziehen — ist für praktisch jede Formatänderung an bestehenden Daten die richtige Reihenfolge. Es gibt keinen Zeitpunkt, an dem die Anwendung ausfällt, und keinen, an dem ein Rückweg unmöglich wäre.

Was ich beim Nachdenken über Schlüssel gelernt habe

Der Hauptschlüssel liegt im Klartext auf der Maschine, und das ist der Punkt, an dem dieses Verfahren seine Grenze hat.

Was es leistet: Wer eine Datenbanksicherung in die Hände bekommt, kann damit nichts anfangen. Das ist ein realistischer Fall — Sicherungen werden kopiert, verschickt, auf anderen Systemen abgelegt.

Was es nicht leistet: Wer Zugriff auf die laufende Maschine hat, hat auch den Schlüssel.

Diese Unterscheidung sauber zu benennen, ist mir wichtiger als das Verfahren selbst. Sicherheitsmaßnahmen schützen gegen bestimmte Szenarien, nicht gegen alle — und wer nicht sagen kann, gegen welche, hat eine Beruhigung gebaut statt eines Schutzes.

Warum ich es gemacht habe, ohne dass jemand danach gefragt hat

Das ist der Teil, der mir selbst am wichtigsten ist. Es gab keinen Vorfall, keine Meldung, keinen Nutzer, der sich beschwert hat.

Der Auslöser war eine einfache Frage, die ich mir bei einem Durchgang durch das System gestellt habe: Wenn jemand diese Datenbankdatei kopiert — was hat er dann? Bei den eigenen Zugangsdaten hatte ich die Frage schon beantwortet. Bei den fremden nicht.

Diese Frage stelle ich mir seitdem regelmäßig, und sie ist ergiebiger als jede Prüfliste. Sie zwingt dazu, den Inhalt der eigenen Datenbank einmal aus Sicht von jemandem anzusehen, der nichts weiter hat als diese Datei.