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:
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Adressen der Anwendung nach aussen (#004, aus Laufzettel uebernommen).
|
||||
*
|
||||
* Massgeblich ist SSHKV_APP_URL, nicht die eingehende Anfrage: hinter einem Reverse
|
||||
* Proxy traegt die Anfrage den internen Host, und eine daraus gebaute Umleitung oder
|
||||
* OIDC-Rueckruf-Adresse passt nicht zu der, die der Benutzer erreicht.
|
||||
*/
|
||||
export function oeffentlicheAdresse(pfad: string, ersatzHerkunft: string): URL {
|
||||
const basis = process.env["SSHKV_APP_URL"]?.trim();
|
||||
if (basis) {
|
||||
try {
|
||||
return new URL(pfad, basis);
|
||||
} catch {
|
||||
// Eine unbrauchbare SSHKV_APP_URL soll die Anmeldung nicht blockieren.
|
||||
}
|
||||
}
|
||||
return new URL(pfad, ersatzHerkunft);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Verschluesselt Geheimnisse, die die Anwendung im Klartext wieder braucht (#004):
|
||||
* heute das LDAP-Bind-Passwort, spaeter Zugangsdaten von Connectoren. Fuer
|
||||
* Anmeldepasswoerter von Benutzern ist das der falsche Weg -- die werden gehasht
|
||||
* (argon2) und nie entschluesselt.
|
||||
*
|
||||
* AES-256-GCM, weil es neben der Verschluesselung auch die Unverfaelschtheit
|
||||
* bezeugt: ein veraenderter Geheimtext wirft beim Entschluesseln, statt still
|
||||
* Unsinn zu liefern.
|
||||
*/
|
||||
const ALGORITHMUS = "aes-256-gcm";
|
||||
|
||||
/** GCM ist auf 96 Bit IV ausgelegt; andere Laengen kosten eine zusaetzliche Ableitung. */
|
||||
const IV_LAENGE = 12;
|
||||
|
||||
const AUTH_TAG_LAENGE = 16;
|
||||
|
||||
const SCHLUESSEL_LAENGE = 32;
|
||||
|
||||
/**
|
||||
* Festes Salz, absichtlich hier im Code.
|
||||
*
|
||||
* Ein Salz soll Angriffe mit vorberechneten Tabellen ueber viele schwache
|
||||
* Passwoerter hinweg verteuern. Hier gibt es genau ein Geheimnis, `SSHKV_APP_SECRET`
|
||||
* ist mit `openssl rand -base64 48` erzeugt und damit hochentropisch -- eine
|
||||
* Tabelle dafuer vorzuberechnen ist aussichtslos. Umgekehrt muss der Schluessel
|
||||
* ueber Neustarts und Container-Neubauten hinweg genau derselbe sein, sonst sind
|
||||
* alle gespeicherten Geheimnisse nach einem Deploy wertlos. Ein zufaelliges Salz
|
||||
* muesste also ohnehin neben dem Geheimtext liegen und schuetzte nichts
|
||||
* zusaetzlich.
|
||||
*/
|
||||
const SALZ = "sshkv.geheimnisse.v1";
|
||||
|
||||
/** Ein Versionspraefix, damit ein spaeterer Formatwechsel alte Werte noch lesen kann. */
|
||||
const FORMAT_VERSION = "v1";
|
||||
|
||||
const MINDESTLAENGE = 32;
|
||||
|
||||
const FORMAT_HINWEIS = `Erwartet wird '${FORMAT_VERSION}.<iv>.<authTag>.<geheimtext>' mit base64-kodierten Teilen.`;
|
||||
|
||||
export class GeheimnisFehler extends Error {
|
||||
constructor(nachricht: string, optionen?: ErrorOptions) {
|
||||
super(nachricht, optionen);
|
||||
this.name = "GeheimnisFehler";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Schluessel wird erst beim ersten Aufruf abgeleitet, nicht beim Import:
|
||||
* `next build` laeuft ohne gesetztes `SSHKV_APP_SECRET`, und `scryptSync` ist
|
||||
* absichtlich langsam. Gemerkt wird die Quelle mit, damit ein geaendertes
|
||||
* `SSHKV_APP_SECRET` (in Tests, nach einer Rotation) nicht am Zwischenspeicher
|
||||
* vorbeilaeuft.
|
||||
*/
|
||||
let abgeleitet: { quelle: string; schluessel: Buffer } | undefined;
|
||||
|
||||
function schluesselHolen(): Buffer {
|
||||
const quelle = process.env["SSHKV_APP_SECRET"] ?? "";
|
||||
if (quelle.length < MINDESTLAENGE) {
|
||||
// Die tatsaechliche Laenge steht bewusst nicht in der Meldung: sie koennte in
|
||||
// einem Log oder einer Fehlerseite landen und verriete etwas ueber das
|
||||
// Geheimnis. Der Hinweis auf die Mindestlaenge genuegt zum Beheben.
|
||||
throw new GeheimnisFehler(
|
||||
`SSHKV_APP_SECRET fehlt oder ist zu kurz (mindestens ${MINDESTLAENGE} Zeichen). ` +
|
||||
"Erzeugen mit: openssl rand -base64 48",
|
||||
);
|
||||
}
|
||||
if (abgeleitet?.quelle !== quelle) {
|
||||
abgeleitet = { quelle, schluessel: scryptSync(quelle, SALZ, SCHLUESSEL_LAENGE) };
|
||||
}
|
||||
return abgeleitet.schluessel;
|
||||
}
|
||||
|
||||
export function verschluesseln(klartext: string): string {
|
||||
const schluessel = schluesselHolen();
|
||||
// Je Aufruf ein neuer IV. Ein zweites Mal denselben IV mit demselben
|
||||
// Schluessel zu benutzen bricht GCM vollstaendig.
|
||||
const iv = randomBytes(IV_LAENGE);
|
||||
const chiffre = createCipheriv(ALGORITHMUS, schluessel, iv, { authTagLength: AUTH_TAG_LAENGE });
|
||||
const geheim = Buffer.concat([chiffre.update(klartext, "utf8"), chiffre.final()]);
|
||||
// base64 verwendet keinen Punkt, der Punkt taugt also als Trenner.
|
||||
return [
|
||||
FORMAT_VERSION,
|
||||
iv.toString("base64"),
|
||||
chiffre.getAuthTag().toString("base64"),
|
||||
geheim.toString("base64"),
|
||||
].join(".");
|
||||
}
|
||||
|
||||
export function entschluesseln(geheimtext: string): string {
|
||||
const teile = geheimtext.split(".");
|
||||
const [version, ivTeil, tagTeil, datenTeil] = teile;
|
||||
if (
|
||||
teile.length !== 4 ||
|
||||
version === undefined ||
|
||||
ivTeil === undefined ||
|
||||
tagTeil === undefined ||
|
||||
datenTeil === undefined
|
||||
) {
|
||||
throw new GeheimnisFehler(`Geheimtext hat ein unbekanntes Format. ${FORMAT_HINWEIS}`);
|
||||
}
|
||||
if (version !== FORMAT_VERSION) {
|
||||
throw new GeheimnisFehler(`Geheimtext traegt die unbekannte Version '${version}'. ${FORMAT_HINWEIS}`);
|
||||
}
|
||||
|
||||
const iv = Buffer.from(ivTeil, "base64");
|
||||
const authTag = Buffer.from(tagTeil, "base64");
|
||||
if (iv.length !== IV_LAENGE || authTag.length !== AUTH_TAG_LAENGE) {
|
||||
throw new GeheimnisFehler(
|
||||
`Geheimtext ist unvollstaendig: IV oder Auth-Tag haben die falsche Laenge. ${FORMAT_HINWEIS}`,
|
||||
);
|
||||
}
|
||||
|
||||
const schluessel = schluesselHolen();
|
||||
try {
|
||||
const entschluessler = createDecipheriv(ALGORITHMUS, schluessel, iv, {
|
||||
authTagLength: AUTH_TAG_LAENGE,
|
||||
});
|
||||
entschluessler.setAuthTag(authTag);
|
||||
const klar = Buffer.concat([
|
||||
entschluessler.update(Buffer.from(datenTeil, "base64")),
|
||||
// `final` prueft das Auth-Tag und wirft, wenn Geheimtext oder Tag
|
||||
// veraendert wurden oder der Schluessel nicht passt.
|
||||
entschluessler.final(),
|
||||
]);
|
||||
return klar.toString("utf8");
|
||||
} catch (ursache) {
|
||||
throw new GeheimnisFehler(
|
||||
"Geheimtext liess sich nicht entschluesseln: Auth-Tag passt nicht. Entweder wurde der " +
|
||||
"Wert veraendert, oder SSHKV_APP_SECRET ist nicht mehr dasselbe wie beim Verschluesseln.",
|
||||
{ cause: ursache },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { scryptSync } from "node:crypto";
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
|
||||
/**
|
||||
* Kurzlebige, signierte Nutzlast fuer Zustaende, die zwischen zwei Anfragen
|
||||
* ueberleben muessen (#004) – heute der OIDC-Anmeldefluss mit `state`, `nonce`,
|
||||
* PKCE-Pruefwert und dem urspruenglich angeforderten Ziel.
|
||||
*
|
||||
* Bewusst getrennt von der Sitzung in src/lib/sitzung.ts: das hier lebt Minuten
|
||||
* statt Stunden, ist einmalig und traegt keine Identitaet. Beide sind ueber
|
||||
* `audience` gegeneinander abgegrenzt, ein Token kann also nicht an der falschen
|
||||
* Stelle durchrutschen.
|
||||
*/
|
||||
const ALGORITHMUS = "HS256";
|
||||
|
||||
const HERAUSGEBER = "sshkv";
|
||||
|
||||
const SALZ = "sshkv.kurzzeit.v1";
|
||||
|
||||
const MINDESTLAENGE = 32;
|
||||
|
||||
export class KurzzeitFehler extends Error {
|
||||
constructor(nachricht: string) {
|
||||
super(nachricht);
|
||||
this.name = "KurzzeitFehler";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Schluessel wird erst beim ersten Aufruf abgeleitet, damit `next build` ohne
|
||||
* gesetztes SSHKV_APP_SECRET laeuft. Die Quelle wird mitgemerkt, damit ein geaendertes
|
||||
* Geheimnis nicht am Zwischenspeicher vorbeilaeuft.
|
||||
*/
|
||||
let abgeleitet: { quelle: string; schluessel: Uint8Array } | undefined;
|
||||
|
||||
function schluesselHolen(): Uint8Array {
|
||||
const quelle = process.env["SSHKV_APP_SECRET"] ?? "";
|
||||
if (quelle.length < MINDESTLAENGE) {
|
||||
throw new KurzzeitFehler(
|
||||
`SSHKV_APP_SECRET fehlt oder ist zu kurz (mindestens ${MINDESTLAENGE} Zeichen). ` +
|
||||
"Erzeugen mit: openssl rand -base64 48",
|
||||
);
|
||||
}
|
||||
if (abgeleitet?.quelle !== quelle) {
|
||||
abgeleitet = { quelle, schluessel: new Uint8Array(scryptSync(quelle, SALZ, 32)) };
|
||||
}
|
||||
return abgeleitet.schluessel;
|
||||
}
|
||||
|
||||
/** Signiert eine Nutzlast fuer den angegebenen Zweck und die angegebene Dauer. */
|
||||
export async function kurzzeitTokenErzeugen(
|
||||
zweck: string,
|
||||
nutzlast: Record<string, string>,
|
||||
gueltigkeitSekunden: number,
|
||||
): Promise<string> {
|
||||
return new SignJWT(nutzlast)
|
||||
.setProtectedHeader({ alg: ALGORITHMUS })
|
||||
.setIssuedAt()
|
||||
.setIssuer(HERAUSGEBER)
|
||||
.setAudience(zweck)
|
||||
.setExpirationTime(`${gueltigkeitSekunden}s`)
|
||||
.sign(schluesselHolen());
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest eine Nutzlast zurueck. Gibt `undefined` zurueck, wenn das Token fehlt,
|
||||
* abgelaufen, verfaelscht oder fuer einen anderen Zweck ausgestellt ist – der
|
||||
* Aufrufer behandelt all das gleich.
|
||||
*/
|
||||
export async function kurzzeitTokenLesen(
|
||||
zweck: string,
|
||||
token: string | undefined,
|
||||
): Promise<Record<string, string> | undefined> {
|
||||
if (!token) return undefined;
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, schluesselHolen(), {
|
||||
algorithms: [ALGORITHMUS],
|
||||
issuer: HERAUSGEBER,
|
||||
audience: zweck,
|
||||
});
|
||||
const werte: Record<string, string> = {};
|
||||
for (const [schluessel, wert] of Object.entries(payload)) {
|
||||
if (typeof wert === "string") werte[schluessel] = wert;
|
||||
}
|
||||
return werte;
|
||||
} catch (fehler) {
|
||||
// Ein Konfigurationsfehler ist etwas anderes als ein ungueltiges Token.
|
||||
if (fehler instanceof KurzzeitFehler) throw fehler;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
|
||||
/**
|
||||
* Das signierte Sitzungscookie (#004).
|
||||
*
|
||||
* Absichtlich framework-frei: kein `next/headers`, kein Prisma, kein React. Das Setzen und
|
||||
* Loeschen des Cookies gehoert in die Next-Schicht, das Nachladen von Rolle und Aktiv-Status
|
||||
* in die Datenbankschicht. Hier steht nur, wie ein Token entsteht und wie es geprueft wird --
|
||||
* so laesst sich beides ohne Next und ohne Datenbank testen.
|
||||
*
|
||||
* Im Token stehen deshalb nur Angaben, die sich nicht hinter dem Ruecken der Anwendung
|
||||
* aendern: wer angemeldet ist und woher die Anmeldung kam. Rolle und Aktiv-Status stehen
|
||||
* bewusst *nicht* darin, sonst wirkte ein Rechteentzug erst nach Ablauf der Sitzung.
|
||||
*/
|
||||
export const SITZUNG_COOKIE = "sshkv_sitzung";
|
||||
|
||||
/**
|
||||
* Acht Stunden statt eines vollen Tages: sshkv hat root auf allen Hosts, eine offen
|
||||
* gelassene Sitzung ist entsprechend mehr wert als in anderen Anwendungen. Kritische
|
||||
* Aktionen verlangen ohnehin eine frische Bestaetigung (5 Minuten).
|
||||
*/
|
||||
export const SITZUNG_LAUFZEIT_STUNDEN = 8;
|
||||
|
||||
export type SitzungsHerkunft = "notfall" | "oidc";
|
||||
|
||||
export type Sitzung = {
|
||||
benutzerId: string;
|
||||
email: string;
|
||||
name: string;
|
||||
herkunft: SitzungsHerkunft;
|
||||
/** Sekunden seit Epoche, aus dem iat-Anspruch. */
|
||||
ausgestelltAm: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* `issuer` und `audience` werden beim Lesen geprueft, damit ein mit demselben Geheimnis
|
||||
* signiertes Token aus einem anderen Zweck (etwa ein Einladungslink) nicht als Sitzung
|
||||
* durchrutscht.
|
||||
*/
|
||||
const AUSSTELLER = "sshkv";
|
||||
const ZIELGRUPPE = "sshkv";
|
||||
|
||||
/** HS256 genuegt: derselbe Dienst signiert und prueft, ein Schluesselpaar braucht es nicht. */
|
||||
const ALGORITHMUS = "HS256";
|
||||
|
||||
const MINDESTLAENGE = 32;
|
||||
|
||||
const ERLAUBTE_HERKUNFT: readonly SitzungsHerkunft[] = ["notfall", "oidc"];
|
||||
|
||||
export class SitzungsFehler extends Error {
|
||||
constructor(nachricht: string, optionen?: ErrorOptions) {
|
||||
super(nachricht, optionen);
|
||||
this.name = "SitzungsFehler";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Schluessel wird erst beim ersten Aufruf abgeleitet, nicht beim Import: `next build`
|
||||
* laeuft ohne gesetztes `SSHKV_APP_SECRET` und wuerde sonst abbrechen. Gemerkt wird die Quelle mit,
|
||||
* damit ein geaendertes `SSHKV_APP_SECRET` (in Tests, nach einer Rotation) nicht am Zwischenspeicher
|
||||
* vorbeilaeuft -- genau wie in `src/lib/geheimnisse.ts`.
|
||||
*/
|
||||
let abgeleitet: { quelle: string; schluessel: Uint8Array } | undefined;
|
||||
|
||||
function schluesselHolen(): Uint8Array {
|
||||
const quelle = process.env["SSHKV_APP_SECRET"] ?? "";
|
||||
if (quelle.length < MINDESTLAENGE) {
|
||||
// Die tatsaechliche Laenge steht bewusst nicht in der Meldung: sie koennte in einem Log
|
||||
// landen und verriete etwas ueber das Geheimnis.
|
||||
throw new SitzungsFehler(
|
||||
`SSHKV_APP_SECRET fehlt oder ist zu kurz (mindestens ${MINDESTLAENGE} Zeichen). ` +
|
||||
"Erzeugen mit: openssl rand -base64 48",
|
||||
);
|
||||
}
|
||||
if (abgeleitet?.quelle !== quelle) {
|
||||
abgeleitet = { quelle, schluessel: new TextEncoder().encode(quelle) };
|
||||
}
|
||||
return abgeleitet.schluessel;
|
||||
}
|
||||
|
||||
export async function sitzungErzeugen(daten: Omit<Sitzung, "ausgestelltAm">): Promise<string> {
|
||||
const schluessel = schluesselHolen();
|
||||
return await new SignJWT({
|
||||
email: daten.email,
|
||||
name: daten.name,
|
||||
herkunft: daten.herkunft,
|
||||
})
|
||||
.setProtectedHeader({ alg: ALGORITHMUS })
|
||||
// Die Benutzerkennung steht im `sub`-Anspruch, dem dafuer vorgesehenen Feld -- so ist das
|
||||
// Token auch mit fremdem Werkzeug lesbar.
|
||||
.setSubject(daten.benutzerId)
|
||||
.setIssuer(AUSSTELLER)
|
||||
.setAudience(ZIELGRUPPE)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(`${SITZUNG_LAUFZEIT_STUNDEN}h`)
|
||||
.sign(schluessel);
|
||||
}
|
||||
|
||||
/** Gibt undefined zurueck, wenn das Token fehlt, abgelaufen, verfaelscht oder fremd ist -- wirft nicht. */
|
||||
export async function sitzungLesen(jwt: string | undefined): Promise<Sitzung | undefined> {
|
||||
// Absichtlich vor dem `try`: ein zu kurzes `SSHKV_APP_SECRET` ist ein Konfigurationsfehler und
|
||||
// darf nicht als "nicht angemeldet" durchgehen, sonst sucht niemand die Ursache.
|
||||
const schluessel = schluesselHolen();
|
||||
if (jwt === undefined || jwt.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const { payload } = await jwtVerify(jwt, schluessel, {
|
||||
// Ohne diese Einschraenkung waere ein Token mit `alg: none` oder einem anderen
|
||||
// Verfahren denkbar. `issuer` und `audience` haelt jose selbst gegen.
|
||||
algorithms: [ALGORITHMUS],
|
||||
issuer: AUSSTELLER,
|
||||
audience: ZIELGRUPPE,
|
||||
});
|
||||
|
||||
const { sub, iat, email, name, herkunft } = payload;
|
||||
// Signatur und Ablauf sind geprueft, der Inhalt aber noch nicht: ein aelteres oder von
|
||||
// Hand gebautes Token kann Felder vermissen. Ein halb gefuelltes Sitzungsobjekt waere
|
||||
// schlimmer als keines.
|
||||
if (
|
||||
typeof sub !== "string" ||
|
||||
sub.length === 0 ||
|
||||
typeof iat !== "number" ||
|
||||
typeof email !== "string" ||
|
||||
typeof name !== "string" ||
|
||||
typeof herkunft !== "string" ||
|
||||
!ERLAUBTE_HERKUNFT.includes(herkunft as SitzungsHerkunft)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
benutzerId: sub,
|
||||
email,
|
||||
name,
|
||||
herkunft: herkunft as SitzungsHerkunft,
|
||||
ausgestelltAm: iat,
|
||||
};
|
||||
} catch {
|
||||
// Fehlende Signatur, falscher Schluessel, Ablauf, fremder Aussteller, kaputtes base64 --
|
||||
// fuer den Aufrufer ist das alles dasselbe: nicht angemeldet.
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
+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