passkey.ed25519.test.ts

9.4 KB · raw

1// T-015: Ed25519 passkey round-trip2//3// Why this test exists: passkey-crypto.ts supports two COSE algorithms:4//   -7 (ECDSA P-256) and -8 (Ed25519/EdDSA).5// The Ed25519 path (algorithm -8) is tested separately because:6//   (a) its COSE structure differs from P-256 (kty=1/OKP vs kty=2/EC2),7//   (b) its signature output is a flat 64 bytes with no DER conversion, and8//   (c) crypto.subtle.generateKey("Ed25519") is a newer API that may not be9//       available in all environments — the test exercises that availability.10//11// Tests cover:12//   1. generatePasskeyKeypair(-8) returns the expected COSE structure.13//   2. The COSE encoding matches what a relying party would receive.14//   3. signPasskeyAssertion produces a 64-byte signature.15//   4. The signature is verifiable against the generated public key.16//   5. publicKeyJwkToCose and ed25519RawToCose produce the same COSE bytes.1718import { beforeAll, describe, expect, it } from "vitest";19import {20  base64urlDecode,21  ed25519RawToCose,22  generatePasskeyKeypair,23  publicKeyJwkToCose,24  signPasskeyAssertion,25} from "../../background/passkey-crypto";2627// ---------------------------------------------------------------------------28// CBOR decode helpers (minimal — only what we need for assertion)29// ---------------------------------------------------------------------------3031// Decode a CBOR map with integer keys into a Map<number, Uint8Array | number>.32// Covers only the subset of CBOR used by the passkey COSE key encoding.33function decodeCoseKeyMap(bytes: Uint8Array): Map<number, Uint8Array | number> {34  const map = new Map<number, Uint8Array | number>();35  let pos = 0;3637  function readByte(): number {38    return bytes[pos++]!;39  }4041  function readUint(info: number): number {42    if (info < 24) return info;43    if (info === 24) return readByte();44    if (info === 25) { const v = (readByte() << 8) | readByte(); return v; }45    throw new Error("CBOR uint too wide for test");46  }4748  function readNegint(info: number): number {49    return -1 - readUint(info);50  }5152  function readItem(): Uint8Array | number {53    const initial = readByte();54    const majorType = initial >> 5;55    const info = initial & 0x1f;56    switch (majorType) {57      case 0: return readUint(info);58      case 1: return readNegint(info);59      case 2: {60        const len = readUint(info);61        const slice = bytes.slice(pos, pos + len);62        pos += len;63        return slice;64      }65      default:66        throw new Error(`Unsupported CBOR major type ${majorType} in test decoder`);67    }68  }6970  const initial = readByte();71  const majorType = initial >> 5;72  const count = initial & 0x1f; // works for maps <= 23 entries73  if (majorType !== 5) throw new Error("Expected CBOR map");7475  for (let i = 0; i < count; i++) {76    const key = readItem() as number;77    const value = readItem();78    map.set(key, value);79  }80  return map;81}8283// ---------------------------------------------------------------------------84// Test state85// ---------------------------------------------------------------------------8687let keypair: {88  publicKeyCose: Uint8Array;89  publicKeySpki: Uint8Array;90  privateKeyJwk: JsonWebKey;91};9293describe("T-015: Ed25519 passkey key generation and COSE encoding", () => {94  beforeAll(async () => {95    keypair = await generatePasskeyKeypair(-8);96  });9798  // T-015a: Ed25519 keypair generates without error and returns all three99  // required fields. This checks that crypto.subtle.generateKey("Ed25519")100  // works in the test environment.101  it("T-015a: generatePasskeyKeypair(-8) returns publicKeyCose, publicKeySpki, privateKeyJwk", () => {102    expect(keypair.publicKeyCose).toBeInstanceOf(Uint8Array);103    expect(keypair.publicKeySpki).toBeInstanceOf(Uint8Array);104    expect(typeof keypair.privateKeyJwk).toBe("object");105    expect(keypair.publicKeyCose.length).toBeGreaterThan(0);106    expect(keypair.publicKeySpki.length).toBeGreaterThan(0);107  });108109  // T-015b: COSE key has the correct Ed25519 structure.110  // Per COSE Key Structure (RFC 8152 §13.2) for Ed25519:111  //   kty (1) = 1  (OKP)112  //   alg (3) = -8 (EdDSA)113  //   crv (-1) = 6 (Ed25519)114  //   x (-2) = 32-byte raw public key115  //116  // Bug this catches: using the P-256 COSE template (kty=2, crv=1) for Ed25519117  // would produce a COSE key that a relying party rejects outright.118  it("T-015b: COSE key structure matches Ed25519 COSE map (kty=1, alg=-8, crv=6, x=32 bytes)", () => {119    const map = decodeCoseKeyMap(keypair.publicKeyCose);120    expect(map.get(1)).toBe(1);   // kty = OKP121    expect(map.get(3)).toBe(-8);  // alg = EdDSA122    expect(map.get(-1)).toBe(6);  // crv = Ed25519123    const x = map.get(-2);124    expect(x).toBeInstanceOf(Uint8Array);125    expect((x as Uint8Array).length).toBe(32);126  });127128  // T-015c: publicKeyJwkToCose and ed25519RawToCose produce the same COSE bytes129  // for the same public key. They take different inputs (JWK vs raw bytes) but130  // must produce identical output — they are two paths to the same encoding.131  //132  // Bug this catches: divergent encoding in one of the two functions causes a133  // mismatch between a key generated at create-time (JWK path) and a key134  // re-encoded for assertion verification (raw SPKI path).135  it("T-015c: publicKeyJwkToCose and ed25519RawToCose produce identical COSE bytes", () => {136    const fromJwk = publicKeyJwkToCose(keypair.privateKeyJwk, -8);137    // The JWK 'x' field is the base64url-encoded raw public key.138    const rawPub = base64urlDecode(keypair.privateKeyJwk.x!);139    const fromRaw = ed25519RawToCose(rawPub);140141    expect(fromJwk).toEqual(fromRaw);142  });143144  // T-015d: ed25519RawToCose rejects keys that are not exactly 32 bytes.145  // The check guards against passing a SPKI-wrapped key (which is longer)146  // or a truncated raw key.147  it("T-015d: ed25519RawToCose throws on non-32-byte input", () => {148    expect(() => ed25519RawToCose(new Uint8Array(31))).toThrow(/invalid/i);149    expect(() => ed25519RawToCose(new Uint8Array(33))).toThrow(/invalid/i);150    expect(() => ed25519RawToCose(new Uint8Array(64))).toThrow(/invalid/i);151  });152});153154describe("T-015: Ed25519 passkey signature round-trip", () => {155  beforeAll(async () => {156    keypair = await generatePasskeyKeypair(-8);157  });158159  // T-015e: signPasskeyAssertion(-8) returns a 64-byte signature.160  // Ed25519 signatures are always exactly 64 bytes — no DER wrapping.161  // Bug this catches: using the ECDSA path (which applies p1363ToDer and162  // returns a variable-length DER signature) for Ed25519 produces a signature163  // the relying party can't verify.164  it("T-015e: signPasskeyAssertion with Ed25519 returns a 64-byte signature", async () => {165    const authData = new Uint8Array(37).fill(0xaa);166    const clientDataHash = new Uint8Array(32).fill(0xbb);167    const sig = await signPasskeyAssertion(keypair.privateKeyJwk, -8, authData, clientDataHash);168    expect(sig).toBeInstanceOf(Uint8Array);169    expect(sig.length).toBe(64);170  });171172  // T-015f: The produced signature verifies against the generated public key.173  // This end-to-end check ensures generatePasskeyKeypair and signPasskeyAssertion174  // use the SAME underlying key material — a mis-keyed sign/verify pair would175  // produce a signature that always fails verification.176  it("T-015f: Ed25519 signature verifies against the generated public key", async () => {177    const authData = new Uint8Array(37).fill(0xcc);178    const clientDataHash = new Uint8Array(32).fill(0xdd);179    const sig = await signPasskeyAssertion(keypair.privateKeyJwk, -8, authData, clientDataHash);180181    // Reconstruct the public key for verification from the JWK 'x' coordinate.182    const rawPub = base64urlDecode(keypair.privateKeyJwk.x!);183    const pubKey = await crypto.subtle.importKey(184      "raw",185      rawPub,186      { name: "Ed25519" },187      false,188      ["verify"],189    );190191    // The signInput in signPasskeyAssertion is concat(authData, clientDataHash).192    const signInput = new Uint8Array(authData.length + clientDataHash.length);193    signInput.set(authData, 0);194    signInput.set(clientDataHash, authData.length);195196    const valid = await crypto.subtle.verify("Ed25519", pubKey, sig, signInput);197    expect(valid).toBe(true);198  });199200  // T-015g: A signature produced for one payload does not verify against a201  // different payload. This checks that signPasskeyAssertion actually commits202  // to the clientDataHash (the nonce the RP chose for this assertion).203  //204  // Bug this catches: signing a fixed/constant value instead of the actual205  // signInput (concat(authData, clientDataHash)) would cause signatures to206  // be replay-attackable across different RP challenges.207  it("T-015g: signature does not verify against a different clientDataHash (replay check)", async () => {208    const authData = new Uint8Array(37).fill(0xee);209    const clientDataHash = new Uint8Array(32).fill(0xff);210    const sig = await signPasskeyAssertion(keypair.privateKeyJwk, -8, authData, clientDataHash);211212    const rawPub = base64urlDecode(keypair.privateKeyJwk.x!);213    const pubKey = await crypto.subtle.importKey(214      "raw",215      rawPub,216      { name: "Ed25519" },217      false,218      ["verify"],219    );220221    // Same authData, different clientDataHash (different RP challenge)222    const differentClientDataHash = new Uint8Array(32).fill(0x01);223    const wrongSignInput = new Uint8Array(authData.length + differentClientDataHash.length);224    wrongSignInput.set(authData, 0);225    wrongSignInput.set(differentClientDataHash, authData.length);226227    const valid = await crypto.subtle.verify("Ed25519", pubKey, sig, wrongSignInput);228    expect(valid).toBe(false);229  });230});