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
+40
View File
@@ -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);
});
});
+56
View File
@@ -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);
});
});
+108
View File
@@ -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 });
});
});