import * as OTPAuth from "otpauth"; import QRCode from "qrcode"; import { randomBytes, createHash } from "node:crypto"; const ISSUER = "Wabenhain"; /** Erzeugt ein neues, zufaelliges Base32-TOTP-Secret. */ export function generateTotpSecret(): string { return new OTPAuth.Secret({ size: 20 }).base32; } function buildTotp(secret: string, accountLabel: string): OTPAuth.TOTP { return new OTPAuth.TOTP({ issuer: ISSUER, label: accountLabel, algorithm: "SHA1", digits: 6, period: 30, secret, }); } /** * Prueft einen 6-stelligen TOTP-Code gegen das Secret. `window: 1` erlaubt * eine Toleranz von einem 30-Sekunden-Schritt vor/zurueck fuer leichte * Uhrenabweichungen zwischen Server und Authenticator-App. */ export function verifyTotpCode(secret: string, code: string, accountLabel: string): boolean { const totp = buildTotp(secret, accountLabel); return totp.validate({ token: code.trim(), window: 1 }) !== null; } /** * Wie verifyTotpCode, gibt bei einem gueltigen Code zusaetzlich den * absoluten Zeitschritt zurueck (Unix-Zeit / Periode). Fuer Replay-Schutz * beim Login: der Aufrufer vergleicht/speichert diesen Wert gegen * Customer.lastTotpStep, damit ein mitgelesener Code nicht ein zweites Mal * innerhalb desselben oder eines frueheren Zeitfensters funktioniert. */ export function verifyTotpCodeAndGetStep(secret: string, code: string, accountLabel: string): number | null { const totp = buildTotp(secret, accountLabel); const delta = totp.validate({ token: code.trim(), window: 1 }); if (delta === null) return null; return Math.floor(Date.now() / 1000 / totp.period) + delta; } /** Data-URI eines QR-Codes fuer die otpauth://-URI, direkt in einsetzbar. */ export async function generateTotpQrCodeDataUrl(secret: string, accountLabel: string): Promise { const totp = buildTotp(secret, accountLabel); return QRCode.toDataURL(totp.toString(), { margin: 1, width: 240 }); } const BACKUP_CODE_COUNT = 8; function hashBackupCode(code: string): string { return createHash("sha256").update(code).digest("hex"); } /** Erzeugt N lesbare Einmal-Codes (Klartext) plus deren Hashes zum Speichern. */ export function generateBackupCodes(): { plain: string[]; hashes: string[] } { const plain = Array.from({ length: BACKUP_CODE_COUNT }, () => randomBytes(5).toString("hex").toUpperCase().match(/.{1,5}/g)!.join("-") ); return { plain, hashes: plain.map(hashBackupCode) }; } export { hashBackupCode };