feat(auth): Zwei-Faktor-Anmeldung per TOTP

Ein Einmalkennwort zusaetzlich zum Passwort, mit QR-Code zum Einrichten und
zehn Wiederherstellungscodes fuer den Fall, dass das Geraet verloren geht.

Die Rechnung nach RFC 6238 steht in lib/auth/totp.ts statt in einer
Abhaengigkeit. Es sind wenige Zeilen, das Verfahren liegt seit 2011 fest, und
der RFC bringt Testvektoren mit - die Richtigkeit ist damit belegt und nicht
nur behauptet. Fuer den QR-Code kommt qrcode dazu; ihn selbst zu zeichnen
waere unverhaeltnismaessig.

Einrichtung in zwei Schritten: der Schluessel wird hinterlegt, aber erst ein
bestaetigter Code schaltet ihn scharf. Wer den QR-Code nicht scannen konnte
und abbricht, sperrt sich sonst beim naechsten Anmelden selbst aus.

Zwischen Passwort und Code steht ein eigenes Modell statt einer halbfertigen
Sitzung. Eine Sitzungszeile mit einem "noch nicht gueltig"-Merker waere eine
Zeile, die bei einem vergessenen Filter irgendwo doch als Anmeldung
durchgeht; dieses Versehen kann hier nicht passieren. Nach fuenf
Fehlversuchen ist der Vorgang verbrannt, damit sechs Stellen nicht in Ruhe
durchprobiert werden.

Die Wiederherstellungscodes liegen als SHA-256 statt als scrypt: sie sind
zufaellig erzeugt und lang genug, dass Raten aussichtslos ist - ein
langsames Verfahren brauchte bei jeder Anmeldung zehn teure Vergleiche und
kaufte nichts dafuer. Das Alphabet laesst O, I und B weg, weil die Codes von
Hand abgeschrieben werden.

Abschalten und Neuerzeugen verlangen das Passwort. Ein unbeaufsichtigter
Rechner mit offener Sitzung soll den zweiten Faktor nicht im Vorbeigehen
entfernen koennen.

Nachgewiesen im Browser: Einrichtung, Anmeldung mit Code, Anmeldung mit
Wiederherstellungscode, derselbe Code ein zweites Mal abgelehnt, Abschalten
mit falschem und richtigem Passwort.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-26 12:36:37 +02:00
co-authored by Claude Opus 5
parent af562584ad
commit 2bff6faf3d
17 changed files with 1729 additions and 5 deletions
+158
View File
@@ -0,0 +1,158 @@
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
/**
* Zeitbasierte Einmalkennwoerter nach RFC 6238 (TOTP über HMAC-SHA1).
*
* Selbst gerechnet statt als Abhaengigkeit: es sind wenige Zeilen, das
* Verfahren steht seit 2011 fest, und der RFC bringt Testvektoren mit –
* die Richtigkeit laesst sich also beweisen statt nur behaupten
* (siehe totp.test.ts).
*
* SHA-1 ist hier kein Mangel. Es geht nicht um Kollisionsresistenz, sondern
* um HMAC mit einem geheimen Schluessel, und jede Authenticator-App der Welt
* erwartet genau diese Voreinstellung.
*/
/** Stellenzahl des Codes. */
export const TOTP_DIGITS = 6;
/** Laenge eines Zeitfensters in Sekunden. */
export const TOTP_PERIOD = 30;
/**
* Wie viele Fenster in jede Richtung zusaetzlich gelten. Eines deckt eine um
* bis zu 30 Sekunden falsch gehende Uhr ab – mehr wuerde den Zeitraum
* unnoetig verbreitern, in dem ein abgefangener Code noch taugt.
*/
export const TOTP_WINDOW = 1;
const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
/** Base32 nach RFC 4648, ohne Auffuellzeichen – so erwarten es die Apps. */
export function base32Encode(data: Buffer): string {
let bits = 0;
let value = 0;
let output = "";
for (const byte of data) {
value = (value << 8) | byte;
bits += 8;
while (bits >= 5) {
output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) output += BASE32_ALPHABET[(value << (5 - bits)) & 31];
return output;
}
export function base32Decode(input: string): Buffer {
// Leerzeichen und Auffuellzeichen grosszuegig hinnehmen: Benutzer tippen
// den Schluessel gelegentlich aus einer Anzeige in Vierergruppen ab.
const clean = input.toUpperCase().replace(/[\s-]/g, "").replace(/=+$/, "");
let bits = 0;
let value = 0;
const bytes: number[] = [];
for (const char of clean) {
const index = BASE32_ALPHABET.indexOf(char);
if (index === -1) throw new Error("Ungueltiges Zeichen im Base32-Schluessel");
value = (value << 5) | index;
bits += 5;
if (bits >= 8) {
bytes.push((value >>> (bits - 8)) & 255);
bits -= 8;
}
}
return Buffer.from(bytes);
}
/**
* Neuer Schluessel. 20 Byte entsprechen der Empfehlung des RFC fuer
* HMAC-SHA1 und ergeben 32 Base32-Zeichen.
*/
export function generateTotpSecret(): string {
return base32Encode(randomBytes(20));
}
/** Der Code fuer ein bestimmtes Zeitfenster. */
export function totpCode(secret: string, counter: number): string {
const key = base32Decode(secret);
// Zaehler als 8-Byte-Zahl in Big-Endian. BigInt, weil er theoretisch
// ueber 2^32 hinausgeht – praktisch erst in einigen tausend Jahren, aber
// die Bitschieberei mit Zahlen waere hier ohnehin heikel.
const buffer = Buffer.alloc(8);
buffer.writeBigUInt64BE(BigInt(counter));
const digest = createHmac("sha1", key).update(buffer).digest();
// Dynamic Truncation nach RFC 4226, Abschnitt 5.3.
const offset = digest[digest.length - 1] & 0x0f;
const binary =
((digest[offset] & 0x7f) << 24) |
((digest[offset + 1] & 0xff) << 16) |
((digest[offset + 2] & 0xff) << 8) |
(digest[offset + 3] & 0xff);
return String(binary % 10 ** TOTP_DIGITS).padStart(TOTP_DIGITS, "0");
}
/** Das Zeitfenster, in dem ein Zeitpunkt liegt. */
export function counterFor(at: Date = new Date()): number {
return Math.floor(at.getTime() / 1000 / TOTP_PERIOD);
}
/**
* Prueft einen eingegebenen Code. Vergleicht in konstanter Zeit und probiert
* die Nachbarfenster mit, damit eine leicht abweichende Uhr nicht stoert.
*/
export function verifyTotp(
secret: string,
code: string,
at: Date = new Date(),
window: number = TOTP_WINDOW,
): boolean {
const eingabe = code.replace(/\s/g, "");
if (!/^\d+$/.test(eingabe) || eingabe.length !== TOTP_DIGITS) return false;
const jetzt = counterFor(at);
let treffer = false;
for (let schritt = -window; schritt <= window; schritt += 1) {
// Nicht fruehzeitig abbrechen: sonst haengt die Laufzeit davon ab,
// welches Fenster gepasst hat.
if (constantEquals(totpCode(secret, jetzt + schritt), eingabe)) treffer = true;
}
return treffer;
}
function constantEquals(a: string, b: string): boolean {
const left = Buffer.from(a, "utf8");
const right = Buffer.from(b, "utf8");
if (left.length !== right.length) return false;
return timingSafeEqual(left, right);
}
/**
* Die URI, die als QR-Code angezeigt wird. Das Label enthaelt sowohl den
* Aussteller als auch das Konto – so schreiben es die Authenticator-Apps
* vor, sonst steht in der Liste nur ein nackter Benutzername.
*/
export function otpauthUri(options: {
secret: string;
account: string;
issuer: string;
}): string {
const label = `${options.issuer}:${options.account}`;
const query = new URLSearchParams({
secret: options.secret,
issuer: options.issuer,
algorithm: "SHA1",
digits: String(TOTP_DIGITS),
period: String(TOTP_PERIOD),
});
return `otpauth://totp/${encodeURIComponent(label)}?${query.toString()}`;
}
/** Der Schluessel in Vierergruppen – zum Abtippen, wenn die Kamera streikt. */
export function groupSecret(secret: string): string {
return secret.replace(/(.{4})/g, "$1 ").trim();
}