passkey-crypto.test.ts

6.0 KB · raw

1import { describe, expect, it } from "vitest";23import {4  buildAuthenticatorData,5  buildClientDataJSON,6  createAttestationObject,7  generatePasskeyKeypair,8  importPasskeyPrivateKey,9  publicKeyJwkToCose,10  signPasskeyAssertion,11} from "./passkey-crypto";1213// Inverse of the DER encoder inside passkey-crypto: parse SEQUENCE { INTEGER r,14// INTEGER s } back into a fixed-width r||s buffer of 2*coordSize bytes.15function derEcdsaToP1363(der: Uint8Array, coordSize: number): Uint8Array {16  if (der[0] !== 0x30) throw new Error("not a SEQUENCE");17  let off = 2;18  if (der[1]! & 0x80) off = 2 + (der[1]! & 0x7f);19  if (der[off] !== 0x02) throw new Error("missing r INTEGER");20  const rLen = der[off + 1]!;21  let r = der.slice(off + 2, off + 2 + rLen);22  off = off + 2 + rLen;23  if (der[off] !== 0x02) throw new Error("missing s INTEGER");24  const sLen = der[off + 1]!;25  let s = der.slice(off + 2, off + 2 + sLen);26  if (r.length > coordSize) r = r.slice(r.length - coordSize);27  if (s.length > coordSize) s = s.slice(s.length - coordSize);28  const out = new Uint8Array(coordSize * 2);29  out.set(r, coordSize - r.length);30  out.set(s, coordSize * 2 - s.length);31  return out;32}3334describe("passkey crypto", () => {35  it("generates importable ES256 passkey material", async () => {36    const generated = await generatePasskeyKeypair(-7);37    const privateKey = await crypto.subtle.importKey(38      "jwk",39      generated.privateKeyJwk,40      { name: "ECDSA", namedCurve: "P-256" },41      true,42      ["sign"]43    );44    const pkcs8 = new Uint8Array(await crypto.subtle.exportKey("pkcs8", privateKey));45    const imported = await importPasskeyPrivateKey(pkcs8);4647    expect(imported.algorithm).toBe(-7);48    expect(imported.privateKeyJwk.kty).toBe("EC");49    expect(imported.publicKeyCose.length).toBeGreaterThan(32);50    expect(generated.publicKeySpki.length).toBeGreaterThan(64);51  });5253  it("generatePasskeyKeypair returns unique keypairs across calls", async () => {54    const first = await generatePasskeyKeypair(-7);55    const second = await generatePasskeyKeypair(-7);5657    expect(first.privateKeyJwk.d).not.toBe(second.privateKeyJwk.d);58    expect(first.publicKeySpki).not.toEqual(second.publicKeySpki);59  });6061  it("builds authenticator data and signs an assertion", async () => {62    const generated = await generatePasskeyKeypair(-7);63    const credentialId = crypto.getRandomValues(new Uint8Array(32));64    const registrationAuthData = await buildAuthenticatorData({65      rpId: "webauthn.io",66      flags: 0x4d,67      signCount: 0,68      credentialId,69      publicKeyCose: generated.publicKeyCose,70    });71    const assertionAuthData = await buildAuthenticatorData({72      rpId: "webauthn.io",73      flags: 0x05,74      signCount: 1,75    });76    const clientDataJSON = buildClientDataJSON(77      "webauthn.get",78      "ZmFrZS1jaGFsbGVuZ2U",79      "https://webauthn.io"80    );81    const clientDataHash = new Uint8Array(82      await crypto.subtle.digest("SHA-256", new Uint8Array(clientDataJSON))83    );84    const signature = await signPasskeyAssertion(85      generated.privateKeyJwk,86      -7,87      assertionAuthData,88      clientDataHash89    );9091    expect(registrationAuthData.length).toBeGreaterThan(80);92    expect(assertionAuthData.length).toBe(37);93    expect(signature.length).toBeGreaterThan(40);94  });9596  it("signPasskeyAssertion produces a signature verifiable with the corresponding public key", async () => {97    const generated = await generatePasskeyKeypair(-7);98    const authenticatorData = await buildAuthenticatorData({99      rpId: "webauthn.io",100      flags: 0x05,101      signCount: 1,102    });103    const clientDataJSON = buildClientDataJSON(104      "webauthn.get",105      "ZmFrZS1jaGFsbGVuZ2U",106      "https://webauthn.io"107    );108    const clientDataHash = new Uint8Array(109      await crypto.subtle.digest("SHA-256", clientDataJSON.buffer as ArrayBuffer)110    );111    const signature = await signPasskeyAssertion(112      generated.privateKeyJwk,113      -7,114      authenticatorData,115      clientDataHash116    );117    const publicKey = await crypto.subtle.importKey(118      "spki",119      generated.publicKeySpki.buffer as ArrayBuffer,120      { name: "ECDSA", namedCurve: "P-256" },121      false,122      ["verify"]123    );124125    // signPasskeyAssertion returns DER (WebAuthn-spec format). SubtleCrypto.verify126    // expects raw IEEE P1363 (r||s) for ECDSA. Convert before verifying.127    const raw = derEcdsaToP1363(signature, 32);128    const verified = await crypto.subtle.verify(129      { name: "ECDSA", hash: "SHA-256" },130      publicKey,131      raw.buffer as ArrayBuffer,132      new Uint8Array([...authenticatorData, ...clientDataHash]).buffer as ArrayBuffer133    );134135    expect(verified).toBe(true);136  });137138  it("buildAuthenticatorData embeds the rpId hash, flags, and signCount", async () => {139    const authData = await buildAuthenticatorData({140      rpId: "webauthn.io",141      flags: 0x05,142      signCount: 7,143    });144    const expectedHash = new Uint8Array(145      await crypto.subtle.digest(146        "SHA-256",147        new TextEncoder().encode("webauthn.io").buffer as ArrayBuffer148      )149    );150151    expect(authData.slice(0, 32)).toEqual(expectedHash);152    expect(authData[32]).toBe(0x05);153    expect(new DataView(authData.buffer).getUint32(33, false)).toBe(7);154  });155156  it("createAttestationObject includes fmt:none and the supplied authData", async () => {157    const authData = await buildAuthenticatorData({158      rpId: "webauthn.io",159      flags: 0x41,160      signCount: 0,161    });162    const attestation = createAttestationObject(authData);163    const ascii = new TextDecoder().decode(attestation);164165    expect(ascii).toContain("fmt");166    expect(ascii).toContain("none");167    expect(ascii).toContain("authData");168    expect(attestation.includes(authData[0])).toBe(true);169  });170171  it("publicKeyJwkToCose encodes a P-256 public key into COSE bytes", async () => {172    const generated = await generatePasskeyKeypair(-7);173    const cose = publicKeyJwkToCose(generated.privateKeyJwk, -7);174175    expect(cose[0]).toBeGreaterThanOrEqual(0xa0);176    expect(cose.length).toBeGreaterThan(16);177  });178});