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:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user