Files
nebula/src/lib/auth/totp.test.ts
T
florian.wildeandClaude Opus 5 27f51b6be8 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>
2026-07-27 00:14:10 +02:00

158 lines
5.5 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 { describe, expect, it } from "vitest";
import {
TOTP_PERIOD,
base32Decode,
base32Encode,
counterFor,
generateTotpSecret,
groupSecret,
matchTotp,
otpauthUri,
totpCode,
verifyTotp,
} from "./totp";
describe("Base32", () => {
// Testvektoren aus RFC 4648, Abschnitt 10 – ohne die Auffuellzeichen,
// die Authenticator-Apps nicht erwarten.
it.each([
["", ""],
["f", "MY"],
["fo", "MZXQ"],
["foo", "MZXW6"],
["foob", "MZXW6YQ"],
["fooba", "MZXW6YTB"],
["foobar", "MZXW6YTBOI"],
])("kodiert %j als %j", (klartext, erwartet) => {
expect(base32Encode(Buffer.from(klartext, "utf8"))).toBe(erwartet);
});
it("dekodiert zurueck, auch mit Leerzeichen und Auffuellzeichen", () => {
expect(base32Decode("MZXW6YTBOI").toString("utf8")).toBe("foobar");
expect(base32Decode("mzxw 6ytb oi").toString("utf8")).toBe("foobar");
expect(base32Decode("MZXW6YTBOI======").toString("utf8")).toBe("foobar");
});
it("weist fremde Zeichen ab, statt still Unsinn zu liefern", () => {
// 0, 1 und 8 gehoeren nicht zum Alphabet – gerade sie werden aber gern
// mit O, I und B verwechselt.
expect(() => base32Decode("MZXW0YTB")).toThrow();
});
});
describe("TOTP nach RFC 6238", () => {
// Der Schluessel aus Anhang B des RFC: die ASCII-Ziffern 1234567890,
// zweimal hintereinander.
const SECRET = base32Encode(Buffer.from("12345678901234567890", "utf8"));
/**
* Der RFC nennt achtstellige Codes. Sechs Stellen sind davon die letzten
* sechs – gerechnet wird modulo 10^6 statt 10^8.
*/
it.each([
[59, "287082"],
[1111111109, "081804"],
[1111111111, "050471"],
[1234567890, "005924"],
[2000000000, "279037"],
[20000000000, "353130"],
])("liefert zum Zeitpunkt %i den Code %s", (sekunden, erwartet) => {
const counter = Math.floor(sekunden / TOTP_PERIOD);
expect(totpCode(SECRET, counter)).toBe(erwartet);
});
it("rechnet Zeitpunkte in Fenster um", () => {
expect(counterFor(new Date(59_000))).toBe(1);
expect(counterFor(new Date(1111111109_000))).toBe(37037036);
});
});
describe("verifyTotp", () => {
const SECRET = base32Encode(Buffer.from("12345678901234567890", "utf8"));
const JETZT = new Date(1111111109_000);
it("nimmt den Code des aktuellen Fensters an", () => {
expect(verifyTotp(SECRET, "081804", JETZT)).toBe(true);
});
it("nimmt auch das vorige und das naechste Fenster an", () => {
// Eine um bis zu 30 Sekunden falsch gehende Uhr soll niemanden aussperren.
const vorher = new Date(JETZT.getTime() - TOTP_PERIOD * 1000);
const nachher = new Date(JETZT.getTime() + TOTP_PERIOD * 1000);
expect(verifyTotp(SECRET, "081804", vorher)).toBe(true);
expect(verifyTotp(SECRET, "081804", nachher)).toBe(true);
});
it("lehnt ein Fenster weiter ab", () => {
const zuFrueh = new Date(JETZT.getTime() - 2 * TOTP_PERIOD * 1000);
expect(verifyTotp(SECRET, "081804", zuFrueh)).toBe(false);
});
it("lehnt falsche Codes und Unfug ab", () => {
expect(verifyTotp(SECRET, "000000", JETZT)).toBe(false);
expect(verifyTotp(SECRET, "", JETZT)).toBe(false);
expect(verifyTotp(SECRET, "81804", JETZT)).toBe(false);
expect(verifyTotp(SECRET, "abcdef", JETZT)).toBe(false);
// Fuehrende Nullen duerfen nicht als Zahl verlorengehen.
expect(verifyTotp(SECRET, "81804 ", JETZT)).toBe(false);
});
it("sieht ueber Leerzeichen in der Eingabe hinweg", () => {
expect(verifyTotp(SECRET, "081 804", JETZT)).toBe(true);
});
});
describe("Einrichtung", () => {
it("erzeugt Schluessel, die sich unterscheiden und lang genug sind", () => {
const a = generateTotpSecret();
const b = generateTotpSecret();
expect(a).not.toBe(b);
// 20 Byte ergeben 32 Base32-Zeichen.
expect(a).toHaveLength(32);
expect(base32Decode(a)).toHaveLength(20);
});
it("baut eine otpauth-URI mit Aussteller im Label und im Parameter", () => {
const uri = otpauthUri({ secret: "ABCD", account: "chefin", issuer: "Nebula" });
expect(uri).toContain("otpauth://totp/Nebula%3Achefin?");
expect(uri).toContain("secret=ABCD");
expect(uri).toContain("issuer=Nebula");
expect(uri).toContain("digits=6");
expect(uri).toContain("period=30");
});
it("gruppiert den Schluessel zum Abtippen", () => {
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));
});
});