fix(auth): Einmalkennwoerter nicht zweimal annehmen

Ein TOTP-Code blieb rund 90 Sekunden gueltig und liess sich darin beliebig
oft verwenden. RFC 6238 verlangt in Abschnitt 5.2 ausdruecklich das
Gegenteil: derselbe Code darf kein zweites Mal angenommen werden.

Ohne diese Sperre kann ein abgefangener Code - ueber die Schulter gesehen,
von einer weiterleitenden Phishing-Seite, von Schadsoftware vom Bildschirm
gelesen - die restliche Gueltigkeitsdauer ueber wiederverwendet werden.

verifyTotp gibt dafuer jetzt ueber matchTotp das Zeitfenster zurueck, das
gepasst hat; angenommen wird nur, was neuer ist als das zuletzt eingeloeste.
Der Vergleich bleibt dabei in konstanter Zeit - es wird weiterhin nicht
fruehzeitig abgebrochen.

Auch die Aktivierung verbrennt ihr Fenster: der Code, der eben
scharfgeschaltet hat, soll nicht Sekunden spaeter noch zum Anmelden taugen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-27 00:14:10 +02:00
co-authored by Claude Opus 5
parent 72e5eb43c4
commit 27f51b6be8
7 changed files with 101 additions and 21 deletions
+3
View File
@@ -75,6 +75,8 @@ export interface PendingLoginState {
isActive: boolean;
/** Entschluesselter TOTP-Schluessel; null, wenn er nicht lesbar ist. */
totpSecret: string | null;
/** Zuletzt eingeloestes Zeitfenster – gegen die Wiederverwendung. */
totpLastCounter: number | null;
};
}
@@ -104,6 +106,7 @@ export async function readPendingLogin(): Promise<PendingLoginState | null> {
role: pending.user.role,
isActive: pending.user.isActive,
totpSecret: tryDecryptSecret(pending.user.totpSecretCipher),
totpLastCounter: pending.user.totpLastCounter,
},
};
}
+31
View File
@@ -6,6 +6,7 @@ import {
counterFor,
generateTotpSecret,
groupSecret,
matchTotp,
otpauthUri,
totpCode,
verifyTotp,
@@ -124,3 +125,33 @@ describe("Einrichtung", () => {
expect(groupSecret("ABCDEFGH")).toBe("ABCD EFGH");
});
});
describe("matchTotp", () => {
const SECRET = base32Encode(Buffer.from("12345678901234567890", "utf8"));
const JETZT = new Date(1111111109_000);
/**
* Der Zaehler ist noetig, um einen verbrauchten Code zu entwerten. RFC 6238
* verlangt das in Abschnitt 5.2 ausdruecklich – sonst laesst sich ein
* abgefangener Code die restliche Gueltigkeitsdauer ueber wiederverwenden.
*/
it("gibt das Zeitfenster zurueck, das gepasst hat", () => {
expect(matchTotp(SECRET, "081804", JETZT)).toBe(37037036);
});
it("nennt bei den Nachbarfenstern deren eigene Nummer", () => {
// Genau daran haengt die Entwertung: der Code aus dem vorigen Fenster
// darf den Merker nicht auf einen hoeheren Stand setzen.
const naechstes = new Date(JETZT.getTime() + TOTP_PERIOD * 1000);
expect(matchTotp(SECRET, "081804", naechstes)).toBe(37037036);
});
it("liefert null bei falschem Code", () => {
expect(matchTotp(SECRET, "000000", JETZT)).toBeNull();
});
it("stimmt mit verifyTotp ueberein", () => {
expect(matchTotp(SECRET, "081804", JETZT) !== null).toBe(verifyTotp(SECRET, "081804", JETZT));
expect(matchTotp(SECRET, "000000", JETZT) !== null).toBe(verifyTotp(SECRET, "000000", JETZT));
});
});
+31 -13
View File
@@ -102,26 +102,44 @@ export function counterFor(at: Date = new Date()): number {
}
/**
* Prueft einen eingegebenen Code. Vergleicht in konstanter Zeit und probiert
* die Nachbarfenster mit, damit eine leicht abweichende Uhr nicht stoert.
* Prueft einen eingegebenen Code und gibt das Zeitfenster zurueck, das
* gepasst hat – oder null.
*
* Das Fenster wird gebraucht, um einen verbrauchten Code zu entwerten:
* RFC 6238 verlangt in Abschnitt 5.2 ausdruecklich, dass derselbe Code kein
* zweites Mal angenommen wird. Ohne diese Zahl liesse sich ein abgefangener
* Code die restliche Gueltigkeitsdauer ueber wiederverwenden.
*
* Verglichen wird in konstanter Zeit, und die Nachbarfenster werden
* mitprobiert, damit eine leicht abweichende Uhr nicht stoert.
*/
export function matchTotp(
secret: string,
code: string,
at: Date = new Date(),
window: number = TOTP_WINDOW,
): number | null {
const eingabe = code.replace(/\s/g, "");
if (!/^\d+$/.test(eingabe) || eingabe.length !== TOTP_DIGITS) return null;
const jetzt = counterFor(at);
let treffer: number | null = null;
for (let schritt = -window; schritt <= window; schritt += 1) {
// Nicht fruehzeitig abbrechen: sonst haengt die Laufzeit davon ab,
// welches Fenster gepasst hat.
if (constantEquals(totpCode(secret, jetzt + schritt), eingabe)) treffer = jetzt + schritt;
}
return treffer;
}
/** Wie matchTotp, wenn nur interessiert *ob* der Code stimmt. */
export function verifyTotp(
secret: string,
code: string,
at: Date = new Date(),
window: number = TOTP_WINDOW,
): boolean {
const eingabe = code.replace(/\s/g, "");
if (!/^\d+$/.test(eingabe) || eingabe.length !== TOTP_DIGITS) return false;
const jetzt = counterFor(at);
let treffer = false;
for (let schritt = -window; schritt <= window; schritt += 1) {
// Nicht fruehzeitig abbrechen: sonst haengt die Laufzeit davon ab,
// welches Fenster gepasst hat.
if (constantEquals(totpCode(secret, jetzt + schritt), eingabe)) treffer = true;
}
return treffer;
return matchTotp(secret, code, at, window) !== null;
}
function constantEquals(a: string, b: string): boolean {