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 };