feat: release OTP and Passkey Tools 0.1.0
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("runs from a nested path and keeps authentication material local", async ({
|
||||
page,
|
||||
}) => {
|
||||
const requests: string[] = [];
|
||||
page.on("request", (request) => requests.push(request.url()));
|
||||
await page.goto("/deep/nested/auth/");
|
||||
await expect(page.getByText("Sensitive session · memory only")).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Credential parameters" }),
|
||||
).toBeVisible();
|
||||
await page.getByLabel("Issuer").fill("Example");
|
||||
await page.getByLabel("Account").fill("alice@example.test");
|
||||
await expect(page.getByText("TOTP code")).toBeVisible();
|
||||
await expect(page.locator(".qr svg")).toBeVisible();
|
||||
expect(
|
||||
requests.every((url) => new URL(url).origin === "http://127.0.0.1:4173"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("computes an RFC OCRA vector and decodes client data", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/deep/nested/auth/");
|
||||
await page.getByRole("button", { name: "OCRA challenge" }).click();
|
||||
await page
|
||||
.getByLabel("Base32 shared secret")
|
||||
.fill("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ");
|
||||
await page.getByRole("button", { name: "Compute OCRA response" }).click();
|
||||
await expect(page.getByText("237653")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: /WebAuthn \/ Passkeys/ }).click();
|
||||
const client = btoa(
|
||||
JSON.stringify({
|
||||
type: "webauthn.get",
|
||||
challenge: "YQ",
|
||||
origin: "https://example.test",
|
||||
}),
|
||||
)
|
||||
.replaceAll("+", "-")
|
||||
.replaceAll("/", "_")
|
||||
.replace(/=+$/u, "");
|
||||
await page.getByLabel("Encoded input").fill(client);
|
||||
await page.getByRole("button", { name: "Decode locally" }).click();
|
||||
await expect(page.locator(".diagnostic-output")).toContainText(
|
||||
"webauthn.get",
|
||||
);
|
||||
});
|
||||
|
||||
test("shared origin exposes inspection but not live credential creation", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/deep/nested/auth/");
|
||||
await page.getByRole("button", { name: /WebAuthn \/ Passkeys/ }).click();
|
||||
await page.getByRole("button", { name: "Live ceremony" }).click();
|
||||
await expect(page.getByText("Inspect only")).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Create test credential" }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Workbench } from "../../src/components/Workbench";
|
||||
|
||||
describe("authentication workbench", () => {
|
||||
it("switches independent workspaces and clears sensitive session state", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Workbench />);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Credential parameters" }),
|
||||
).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: /OCRA challenge/iu }));
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "OCRA suite and key" }),
|
||||
).toBeInTheDocument();
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /WebAuthn \/ Passkeys/iu }),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "WebAuthn structure" }),
|
||||
).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Clear session" }));
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "WebAuthn structure" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps live ceremonies disabled on the shared/non-dedicated origin", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Workbench />);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /WebAuthn \/ Passkeys/iu }),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Live ceremony" }));
|
||||
expect(screen.getByText("Inspect only")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Create test credential" }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
base32ToBytes,
|
||||
base64UrlToBytes,
|
||||
bytesToBase32,
|
||||
bytesToBase64Url,
|
||||
bytesToHex,
|
||||
hexToBytes,
|
||||
utf8ToBytes,
|
||||
} from "../../src/crypto/encoding";
|
||||
|
||||
describe("encoding", () => {
|
||||
it("round-trips RFC 4648-style encodings", () => {
|
||||
const bytes = utf8ToBytes("Hello!\u{1f512}");
|
||||
expect([...base32ToBytes(bytesToBase32(bytes))]).toEqual([...bytes]);
|
||||
expect([...base64UrlToBytes(bytesToBase64Url(bytes))]).toEqual([...bytes]);
|
||||
expect([...hexToBytes(bytesToHex(bytes))]).toEqual([...bytes]);
|
||||
});
|
||||
|
||||
it("rejects non-zero trailing Base32 bits", () => {
|
||||
expect(() => base32ToBytes("AB")).toThrow(/trailing bits/iu);
|
||||
});
|
||||
|
||||
it("accepts deliberate manual separators only when enabled", () => {
|
||||
expect(() => base32ToBytes("JBSW Y3DP")).toThrow();
|
||||
expect([...base32ToBytes("JBSW-Y3DP", { allowSeparators: true })]).toEqual([
|
||||
...utf8ToBytes("Hello"),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
bytesToArrayBuffer,
|
||||
bytesToHex,
|
||||
utf8ToBytes,
|
||||
} from "../../src/crypto/encoding";
|
||||
import { encodeQr, qrSvg } from "../../src/qr/encoder";
|
||||
|
||||
async function matrixHash(text: string): Promise<string> {
|
||||
const bits = encodeQr(text)
|
||||
.map((row) => row.map((value) => (value ? "1" : "0")).join(""))
|
||||
.join("");
|
||||
return bytesToHex(
|
||||
new Uint8Array(
|
||||
await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
bytesToArrayBuffer(utf8ToBytes(bits)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe("QR encoder", () => {
|
||||
it("matches the reviewed reference matrices", async () => {
|
||||
await expect(matrixHash("Hello")).resolves.toBe(
|
||||
"ac69828947e29c188c38459b3eab69d0071aeedc825c41277d82ca3141033afe",
|
||||
);
|
||||
await expect(
|
||||
matrixHash(
|
||||
"otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example",
|
||||
),
|
||||
).resolves.toBe(
|
||||
"d39d6eed126c0bc3ca92a851a062613e8cfa178adf9a8e2b3c399ba77cbb0187",
|
||||
);
|
||||
});
|
||||
|
||||
it("emits a self-contained SVG with a quiet zone", () => {
|
||||
const svg = qrSvg("Hello");
|
||||
expect(svg).toMatch(/^<svg xmlns=/u);
|
||||
expect(svg).toContain("<rect");
|
||||
expect(svg).not.toContain("script");
|
||||
});
|
||||
|
||||
it("rejects oversized values instead of allocating unbounded matrices", () => {
|
||||
expect(() => encodeQr("x".repeat(500))).toThrow(/version 10/iu);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { decodeCompleteCbor } from "../../src/webauthn/cbor";
|
||||
|
||||
describe("bounded CBOR decoder", () => {
|
||||
it("decodes deterministic maps and byte strings", () => {
|
||||
const value = decodeCompleteCbor(
|
||||
Uint8Array.from([
|
||||
0xa2, 0x01, 0x42, 0xaa, 0xbb, 0x63, 0x66, 0x6d, 0x74, 0x64, 0x6e, 0x6f,
|
||||
0x6e, 0x65,
|
||||
]),
|
||||
);
|
||||
expect(value).toBeInstanceOf(Map);
|
||||
expect((value as Map<unknown, unknown>).get("fmt")).toBe("none");
|
||||
});
|
||||
|
||||
it("rejects indefinite, duplicate and trailing encodings", () => {
|
||||
expect(() => decodeCompleteCbor(Uint8Array.from([0x9f, 0xff]))).toThrow(
|
||||
/Indefinite/iu,
|
||||
);
|
||||
expect(() =>
|
||||
decodeCompleteCbor(Uint8Array.from([0xa2, 0x01, 0x01, 0x01, 0x02])),
|
||||
).toThrow(/duplicate/iu);
|
||||
expect(() =>
|
||||
decodeCompleteCbor(
|
||||
Uint8Array.from([0xa2, 0x41, 0xaa, 0x01, 0x41, 0xaa, 0x02]),
|
||||
),
|
||||
).toThrow(/duplicate/iu);
|
||||
expect(() => decodeCompleteCbor(Uint8Array.from([0x01, 0x02]))).toThrow(
|
||||
/trailing/iu,
|
||||
);
|
||||
});
|
||||
|
||||
it("enforces depth and item limits", () => {
|
||||
expect(() =>
|
||||
decodeCompleteCbor(
|
||||
Uint8Array.from([...Array.from({ length: 34 }, () => 0x81), 0x00]),
|
||||
),
|
||||
).toThrow(/deep/iu);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
bytesToArrayBuffer,
|
||||
bytesToBase64Url,
|
||||
utf8ToBytes,
|
||||
} from "../../src/crypto/encoding";
|
||||
import {
|
||||
parseAuthenticatorData,
|
||||
parseClientData,
|
||||
} from "../../src/webauthn/parser";
|
||||
|
||||
describe("WebAuthn parsers", () => {
|
||||
it("parses collected client data without normalizing security fields", () => {
|
||||
const json = utf8ToBytes(
|
||||
JSON.stringify({
|
||||
type: "webauthn.get",
|
||||
challenge: "YWJj",
|
||||
origin: "https://example.test",
|
||||
crossOrigin: false,
|
||||
}),
|
||||
);
|
||||
expect(parseClientData(bytesToBase64Url(json))).toMatchObject({
|
||||
type: "webauthn.get",
|
||||
origin: "https://example.test",
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts flags and the unsigned signature counter", async () => {
|
||||
const rpHash = new Uint8Array(
|
||||
await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
bytesToArrayBuffer(utf8ToBytes("example.test")),
|
||||
),
|
||||
);
|
||||
const bytes = Uint8Array.from([...rpHash, 0x05, 0x00, 0x00, 0x00, 0x09]);
|
||||
const parsed = parseAuthenticatorData(bytes);
|
||||
expect(parsed.flags).toMatchObject({
|
||||
userPresent: true,
|
||||
userVerified: true,
|
||||
});
|
||||
expect(parsed.signCount).toBe(9);
|
||||
});
|
||||
|
||||
it("rejects inconsistent backup flags and undeclared trailing data", () => {
|
||||
expect(() =>
|
||||
parseAuthenticatorData(
|
||||
Uint8Array.from([...new Uint8Array(32), 0x10, 0, 0, 0, 0]),
|
||||
),
|
||||
).toThrow(/backup/iu);
|
||||
expect(() =>
|
||||
parseAuthenticatorData(
|
||||
Uint8Array.from([...new Uint8Array(32), 0x01, 0, 0, 0, 0, 0]),
|
||||
),
|
||||
).toThrow(/not described/iu);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
base64UrlToBytes,
|
||||
bytesToArrayBuffer,
|
||||
bytesToBase64Url,
|
||||
utf8ToBytes,
|
||||
} from "../../src/crypto/encoding";
|
||||
import { verifyAssertion } from "../../src/webauthn/verify";
|
||||
import type { CborValue } from "../../src/webauthn/cbor";
|
||||
|
||||
function rawEcdsaToDer(raw: Uint8Array): Uint8Array {
|
||||
const integer = (part: Uint8Array): number[] => {
|
||||
let offset = 0;
|
||||
while (offset < part.length - 1 && part[offset] === 0) offset += 1;
|
||||
const value = [...part.slice(offset)];
|
||||
if (value[0]! & 0x80) value.unshift(0);
|
||||
return [0x02, value.length, ...value];
|
||||
};
|
||||
const r = integer(raw.slice(0, 32));
|
||||
const s = integer(raw.slice(32));
|
||||
return Uint8Array.from([0x30, r.length + s.length, ...r, ...s]);
|
||||
}
|
||||
|
||||
describe("assertion verification", () => {
|
||||
it("checks ceremony bindings and a WebAuthn DER ECDSA signature", async () => {
|
||||
const pair = await crypto.subtle.generateKey(
|
||||
{ name: "ECDSA", namedCurve: "P-256" },
|
||||
true,
|
||||
["sign", "verify"],
|
||||
);
|
||||
const jwk = await crypto.subtle.exportKey("jwk", pair.publicKey);
|
||||
const challenge = bytesToBase64Url(
|
||||
crypto.getRandomValues(new Uint8Array(32)),
|
||||
);
|
||||
const origin = "https://auth.example.test";
|
||||
const rpId = "auth.example.test";
|
||||
const clientBytes = utf8ToBytes(
|
||||
JSON.stringify({
|
||||
type: "webauthn.get",
|
||||
challenge,
|
||||
origin,
|
||||
crossOrigin: false,
|
||||
}),
|
||||
);
|
||||
const rpHash = new Uint8Array(
|
||||
await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
bytesToArrayBuffer(utf8ToBytes(rpId)),
|
||||
),
|
||||
);
|
||||
const authenticator = Uint8Array.from([...rpHash, 0x05, 0, 0, 0, 7]);
|
||||
const clientHash = new Uint8Array(
|
||||
await crypto.subtle.digest("SHA-256", bytesToArrayBuffer(clientBytes)),
|
||||
);
|
||||
const signed = Uint8Array.from([...authenticator, ...clientHash]);
|
||||
const rawSignature = new Uint8Array(
|
||||
await crypto.subtle.sign(
|
||||
{ name: "ECDSA", hash: "SHA-256" },
|
||||
pair.privateKey,
|
||||
bytesToArrayBuffer(signed),
|
||||
),
|
||||
);
|
||||
const cose = new Map<CborValue, CborValue>([
|
||||
[1, 2],
|
||||
[3, -7],
|
||||
[-1, 1],
|
||||
[-2, base64UrlToBytes(jwk.x!)],
|
||||
[-3, base64UrlToBytes(jwk.y!)],
|
||||
]);
|
||||
const result = await verifyAssertion({
|
||||
clientDataJSON: bytesToBase64Url(clientBytes),
|
||||
authenticatorData: bytesToBase64Url(authenticator),
|
||||
signature: bytesToBase64Url(rawEcdsaToDer(rawSignature)),
|
||||
credentialPublicKey: cose,
|
||||
expectedChallenge: challenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRpId: rpId,
|
||||
requireUserVerification: true,
|
||||
previousSignCount: 6,
|
||||
});
|
||||
expect(result.verified).toBe(true);
|
||||
expect(result.checks.every((check) => check.status !== "fail")).toBe(true);
|
||||
});
|
||||
|
||||
it("fails expected origin independently", async () => {
|
||||
await expect(
|
||||
verifyAssertion({
|
||||
clientDataJSON: bytesToBase64Url(
|
||||
utf8ToBytes(
|
||||
JSON.stringify({
|
||||
type: "webauthn.get",
|
||||
challenge: "YQ",
|
||||
origin: "https://wrong.test",
|
||||
}),
|
||||
),
|
||||
),
|
||||
authenticatorData: bytesToBase64Url(
|
||||
Uint8Array.from([...new Uint8Array(32), 1, 0, 0, 0, 0]),
|
||||
),
|
||||
signature: "",
|
||||
credentialPublicKey: "{}",
|
||||
expectedChallenge: "YQ",
|
||||
expectedOrigin: "https://right.test",
|
||||
expectedRpId: "right.test",
|
||||
}),
|
||||
).resolves.toMatchObject({ verified: false });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user