feat: release OTP and Passkey Tools 0.1.0

This commit is contained in:
2026-08-19 12:19:35 +02:00
commit f1cb2d7151
67 changed files with 12802 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import {
exportCsv,
importCsv,
importOtpAuthList,
importPlainPskc,
} from "../../src/otp/migration";
import { utf8ToBytes } from "../../src/crypto/encoding";
import type { OtpProfile } from "../../src/otp/profile";
const profile: OtpProfile = {
kind: "totp",
account: 'alice,"admin"',
issuer: "Example",
secret: utf8ToBytes("12345678901234567890"),
algorithm: "SHA-256",
digits: 8,
period: 45,
counter: 0n,
extensions: new Map(),
};
describe("OTP migrations", () => {
it("round-trips the documented CSV including quoting", () => {
const result = importCsv(exportCsv([profile]));
expect(result.profiles[0]).toMatchObject({
account: profile.account,
issuer: "Example",
algorithm: "SHA-256",
digits: 8,
period: 45,
});
expect([...result.profiles[0]!.secret]).toEqual([...profile.secret]);
});
it("neutralizes spreadsheet formulas without changing a round trip", () => {
const dangerous = { ...profile, account: '=HYPERLINK("https://bad")' };
const csv = exportCsv([dangerous]);
expect(csv).toContain("'=HYPERLINK");
expect(importCsv(csv).profiles[0]!.account).toBe(dangerous.account);
});
it("reports the failing line in URI lists", () => {
expect(() =>
importOtpAuthList("otpauth://totp/Good?secret=JBSWY3DPEHPK3PXP\nnope"),
).toThrow(/Line 2/iu);
});
it("imports plain-secret PSKC and refuses encrypted keys", () => {
const xml = `<KeyContainer xmlns="urn:ietf:params:xml:ns:keyprov:pskc"><KeyPackage><Key Id="alice" Algorithm="urn:ietf:params:xml:ns:keyprov:pskc:totp"><Issuer>Example</Issuer><Data><Secret><PlainValue>MTIzNDU2Nzg5MDEyMzQ1Njc4OTA=</PlainValue></Secret><TimeInterval><PlainValue>30</PlainValue></TimeInterval></Data><Policy><KeyUsage>OTP</KeyUsage></Policy><ResponseFormat Length="6" Encoding="DECIMAL"/></Key></KeyPackage></KeyContainer>`;
expect(importPlainPskc(xml).profiles[0]).toMatchObject({
kind: "totp",
account: "alice",
issuer: "Example",
digits: 6,
});
expect(() =>
importPlainPskc(
xml.replace(
"<PlainValue>MTIzNDU2Nzg5MDEyMzQ1Njc4OTA=</PlainValue>",
"<EncryptedValue/>",
),
),
).toThrow(/encrypted/iu);
});
});
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import { hexToBytes, utf8ToBytes } from "../../src/crypto/encoding";
import { hashOcraPassword, ocra, parseOcraSuite } from "../../src/otp/ocra";
describe("OCRA", () => {
it("parses complete suites and rejects reordered inputs", () => {
expect(
parseOcraSuite("OCRA-1:HOTP-SHA512-8:C-QN08-PSHA1-S064-T1M"),
).toMatchObject({
algorithm: "SHA-512",
digits: 8,
counter: true,
questionFormat: "numeric",
passwordAlgorithm: "SHA-1",
sessionLength: 64,
timeStepSeconds: 60,
});
expect(() => parseOcraSuite("OCRA-1:HOTP-SHA1-6:QN08-T1M-PSHA1")).toThrow(
/order/iu,
);
});
it("matches RFC 6287 one-way challenge vectors", async () => {
const secret = utf8ToBytes("12345678901234567890");
const expected = [
"237653",
"243178",
"653583",
"740991",
"608993",
"388898",
"816933",
"224598",
"750600",
"294470",
];
for (let index = 0; index < expected.length; index += 1) {
await expect(
ocra({
suite: "OCRA-1:HOTP-SHA1-6:QN08",
secret,
question: String(index).repeat(8),
}),
).resolves.toBe(expected[index]);
}
});
it("matches RFC 6287 counter/PIN and timestamp vectors", async () => {
const secret32 = utf8ToBytes("12345678901234567890123456789012");
const pinHash = await hashOcraPassword("1234", "SHA-1");
expect([...pinHash]).toEqual([
...hexToBytes("7110eda4d09e062aa5e4a390b0a572ac0d2c0220"),
]);
await expect(
ocra({
suite: "OCRA-1:HOTP-SHA256-8:C-QN08-PSHA1",
secret: secret32,
counter: 0n,
question: "12345678",
passwordHash: pinHash,
}),
).resolves.toBe("65347737");
const secret64 = utf8ToBytes(
"1234567890123456789012345678901234567890123456789012345678901234",
);
await expect(
ocra({
suite: "OCRA-1:HOTP-SHA512-8:QN08-T1M",
secret: secret64,
question: "00000000",
timeStep: 0x132d0b6n,
}),
).resolves.toBe("95209754");
});
});
+106
View File
@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import { utf8ToBytes } from "../../src/crypto/encoding";
import {
hotp,
totp,
totpCounter,
verifyHotp,
verifyTotp,
} from "../../src/otp/otp";
describe("HOTP", () => {
it("matches every RFC 4226 test value", async () => {
const secret = utf8ToBytes("12345678901234567890");
const expected = [
"755224",
"287082",
"359152",
"969429",
"338314",
"254676",
"287922",
"162583",
"399871",
"520489",
];
await expect(
Promise.all(
expected.map((_, counter) =>
hotp({ secret, counter: BigInt(counter) }),
),
),
).resolves.toEqual(expected);
});
it("searches forward without losing leading zeroes", async () => {
const secret = utf8ToBytes("12345678901234567890");
const match = await verifyHotp("338314", {
secret,
counter: 1n,
lookAhead: 5,
});
expect(match).toMatchObject({ counter: 4n, delta: 3 });
});
});
describe("TOTP", () => {
const cases = [
[59, "94287082", "46119246", "90693936"],
[1_111_111_109, "07081804", "68084774", "25091201"],
[1_111_111_111, "14050471", "67062674", "99943326"],
[1_234_567_890, "89005924", "91819424", "93441116"],
[2_000_000_000, "69279037", "90698825", "38618901"],
[20_000_000_000, "65353130", "77737706", "47863826"],
] as const;
it("matches RFC 6238 SHA-1, SHA-256 and SHA-512 vectors", async () => {
const secrets = {
"SHA-1": utf8ToBytes("12345678901234567890"),
"SHA-256": utf8ToBytes("12345678901234567890123456789012"),
"SHA-512": utf8ToBytes(
"1234567890123456789012345678901234567890123456789012345678901234",
),
} as const;
for (const [timestamp, sha1, sha256, sha512] of cases) {
await expect(
totp({
secret: secrets["SHA-1"],
timestamp,
digits: 8,
algorithm: "SHA-1",
}),
).resolves.toBe(sha1);
await expect(
totp({
secret: secrets["SHA-256"],
timestamp,
digits: 8,
algorithm: "SHA-256",
}),
).resolves.toBe(sha256);
await expect(
totp({
secret: secrets["SHA-512"],
timestamp,
digits: 8,
algorithm: "SHA-512",
}),
).resolves.toBe(sha512);
}
});
it("uses integer counters beyond 2038", () => {
expect(totpCounter(20_000_000_000)).toBe(666_666_666n);
});
it("diagnoses a bounded clock delta", async () => {
const secret = utf8ToBytes("12345678901234567890");
const code = await totp({ secret, timestamp: 1_111_111_109 });
const match = await verifyTotp(code, {
secret,
timestamp: 1_111_111_109 + 60,
window: 3,
});
expect(match?.delta).toBe(-2);
});
});
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { parseOtpAuth, serializeOtpAuth } from "../../src/otp/profile";
describe("otpauth profiles", () => {
it("parses and serializes a TOTP profile without losing extensions", () => {
const parsed = parseOtpAuth(
"otpauth://totp/Example:alice%40example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example&algorithm=SHA256&digits=8&period=45&image=ignored",
);
expect(parsed.profile).toMatchObject({
kind: "totp",
issuer: "Example",
account: "alice@example.com",
algorithm: "SHA-256",
digits: 8,
period: 45,
});
expect(parsed.profile.extensions.get("image")).toBe("ignored");
expect(serializeOtpAuth(parsed.profile)).toContain("image=ignored");
});
it("requires an HOTP counter and preserves 64-bit values", () => {
expect(() =>
parseOtpAuth("otpauth://hotp/Example?secret=JBSWY3DPEHPK3PXP"),
).toThrow(/counter/iu);
const parsed = parseOtpAuth(
"otpauth://hotp/Example?secret=JBSWY3DPEHPK3PXP&counter=18446744073709551615",
);
expect(parsed.profile.counter).toBe((1n << 64n) - 1n);
});
it("rejects duplicate known parameters", () => {
expect(() =>
parseOtpAuth(
"otpauth://totp/Example?secret=JBSWY3DPEHPK3PXP&secret=JBSWY3DPEHPK3PXP",
),
).toThrow(/duplicate/iu);
});
it("rejects partially numeric parameters and unsafe labels", () => {
expect(() =>
parseOtpAuth(
"otpauth://totp/Example?secret=JBSWY3DPEHPK3PXP&digits=6oops",
),
).toThrow(/integer/iu);
const parsed = parseOtpAuth(
"otpauth://totp/Example?secret=JBSWY3DPEHPK3PXP",
);
parsed.profile.account = "unsafe:label";
expect(() => serializeOtpAuth(parsed.profile)).toThrow(/colon/iu);
});
it("warns without silently reconciling issuer mismatch", () => {
const result = parseOtpAuth(
"otpauth://totp/Display:alice?secret=JBSWY3DPEHPK3PXP&issuer=canonical.example",
);
expect(result.profile.issuer).toBe("canonical.example");
expect(result.warnings.some(({ code }) => code === "issuer-mismatch")).toBe(
true,
);
});
});