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>
This commit is contained in:
+106
@@ -0,0 +1,106 @@
|
||||
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()}`;
|
||||
}
|
||||
Reference in New Issue
Block a user