import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; /** * TOTP nach RFC 6238 fuer den Notfall-Admin (#004). * * Selbst geschrieben statt als Abhaengigkeit: es sind zwanzig Zeilen, die Testvektoren * stehen im RFC, und der Notfallzugang soll von moeglichst wenig fremdem Code abhaengen. * SHA-1, 6 Stellen, 30 Sekunden – das, was jede Authenticator-App ohne Rueckfrage kann. */ export const TOTP_SCHRITT_SEKUNDEN = 30; export const TOTP_STELLEN = 6; /** Ein Schritt Toleranz je Richtung faengt Uhrenabweichung und Tippzeit ab. */ const TOLERANZ_SCHRITTE = 1; const BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; export function base32Kodieren(daten: Buffer): string { let bits = 0; let wert = 0; let ausgabe = ""; for (const byte of daten) { wert = (wert << 8) | byte; bits += 8; while (bits >= 5) { ausgabe += BASE32[(wert >>> (bits - 5)) & 31]; bits -= 5; } } if (bits > 0) ausgabe += BASE32[(wert << (5 - bits)) & 31]; return ausgabe; } export function base32Dekodieren(text: string): Buffer { const sauber = text.replace(/[\s=]/g, "").toUpperCase(); let bits = 0; let wert = 0; const bytes: number[] = []; for (const zeichen of sauber) { const index = BASE32.indexOf(zeichen); if (index < 0) throw new Error("Ungültiges Base32-Zeichen im TOTP-Geheimnis."); wert = (wert << 5) | index; bits += 5; if (bits >= 8) { bytes.push((wert >>> (bits - 8)) & 255); bits -= 8; } } return Buffer.from(bytes); } /** 160 Bit, wie von RFC 4226 fuer SHA-1 empfohlen. */ export function totpGeheimnisErzeugen(): string { return base32Kodieren(randomBytes(20)); } export function totpSchritt(zeitpunktMs: number): number { return Math.floor(zeitpunktMs / 1000 / TOTP_SCHRITT_SEKUNDEN); } export function totpCode(geheimnis: Buffer, schritt: number, stellen = TOTP_STELLEN): string { const zaehler = Buffer.alloc(8); zaehler.writeBigUInt64BE(BigInt(schritt)); const hmac = createHmac("sha1", geheimnis).update(zaehler).digest(); const versatz = (hmac[hmac.length - 1] as number) & 0x0f; const zahl = hmac.readUInt32BE(versatz) & 0x7fffffff; return String(zahl % 10 ** stellen).padStart(stellen, "0"); } /** * Prueft einen Code und liefert den getroffenen Zeitschritt – oder undefined. * * `letzterSchritt` verhindert, dass ein mitgelesener Code innerhalb seines Fensters ein * zweites Mal gilt: angenommen wird nur ein Schritt **nach** dem zuletzt benutzten. */ export function totpPruefen( geheimnisBase32: string, code: string, jetztMs: number, letzterSchritt?: number | null, ): number | undefined { const eingabe = code.replace(/\s/g, ""); if (!/^\d{6}$/.test(eingabe)) return undefined; const geheimnis = base32Dekodieren(geheimnisBase32); const aktuell = totpSchritt(jetztMs); for (let abstand = -TOLERANZ_SCHRITTE; abstand <= TOLERANZ_SCHRITTE; abstand += 1) { const schritt = aktuell + abstand; if (letzterSchritt !== undefined && letzterSchritt !== null && schritt <= letzterSchritt) continue; const erwartet = Buffer.from(totpCode(geheimnis, schritt)); if (timingSafeEqual(erwartet, Buffer.from(eingabe))) return schritt; } return undefined; } /** Adresse fuer den QR-Code der Authenticator-App. */ export function totpAdresse(geheimnisBase32: string, konto: string): string { const aussteller = "SSHKeyVault"; const etikett = encodeURIComponent(`${aussteller}:${konto}`); const parameter = new URLSearchParams({ secret: geheimnisBase32, issuer: aussteller, algorithm: "SHA1", digits: String(TOTP_STELLEN), period: String(TOTP_SCHRITT_SEKUNDEN), }); return `otpauth://totp/${etikett}?${parameter.toString()}`; }