Files
sshkeyvault/src/lib/totp.ts
T
florian.wildeandClaude Opus 5.5 5ef90310e1 feat(auth): Anmeldung per OIDC und Notfall-Admin mit TOTP (#004)
Sitzung, OIDC-Fluss und Rueckruf aus Laufzettel uebernommen und auf
sshkv angepasst. Abweichungen mit Absicht:

- Ohne passende sshkv-Gruppe gibt es keinen Zugang, auch keinen
  Lesezugriff: schon die Hostliste verraet, wer wohin darf.
- Notfall-Admin nur mit TOTP (Freigabe Frage 5). Ein richtiges
  Passwort fuehrt beim ersten Mal nur zur Einrichtung. Jeder Code gilt
  einmal, fuenf Fehlversuche sperren 15 Minuten.
- Kritische Rechte verlangen eine Bestaetigung, die hoechstens fuenf
  Minuten alt ist. Bei OIDC mit prompt=login, max_age=0 und Pruefung
  von auth_time, sonst zaehlte eine laufende SSO-Sitzung als Beweis.
- Sitzungen laufen acht statt zwoelf Stunden.

Die Seiten folgen, sobald das Design-System (#002) steht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:17:44 +02:00

107 lines
3.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()}`;
}