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>
158 lines
5.5 KiB
TypeScript
158 lines
5.5 KiB
TypeScript
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));
|
||
});
|
||
});
|