109 lines
3.4 KiB
TypeScript
109 lines
3.4 KiB
TypeScript
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 });
|
|
});
|
|
});
|