// T-015: Ed25519 passkey round-trip // // Why this test exists: passkey-crypto.ts supports two COSE algorithms: // -7 (ECDSA P-256) and -8 (Ed25519/EdDSA). // The Ed25519 path (algorithm -8) is tested separately because: // (a) its COSE structure differs from P-256 (kty=1/OKP vs kty=2/EC2), // (b) its signature output is a flat 64 bytes with no DER conversion, and // (c) crypto.subtle.generateKey("Ed25519") is a newer API that may not be // available in all environments — the test exercises that availability. // // Tests cover: // 1. generatePasskeyKeypair(-8) returns the expected COSE structure. // 2. The COSE encoding matches what a relying party would receive. // 3. signPasskeyAssertion produces a 64-byte signature. // 4. The signature is verifiable against the generated public key. // 5. publicKeyJwkToCose and ed25519RawToCose produce the same COSE bytes. import { beforeAll, describe, expect, it } from "vitest"; import { base64urlDecode, ed25519RawToCose, generatePasskeyKeypair, publicKeyJwkToCose, signPasskeyAssertion, } from "./passkey-crypto"; // --------------------------------------------------------------------------- // CBOR decode helpers (minimal — only what we need for assertion) // --------------------------------------------------------------------------- // Decode a CBOR map with integer keys into a Map. // Covers only the subset of CBOR used by the passkey COSE key encoding. function decodeCoseKeyMap(bytes: Uint8Array): Map { const map = new Map(); let pos = 0; function readByte(): number { return bytes[pos++]!; } function readUint(info: number): number { if (info < 24) return info; if (info === 24) return readByte(); if (info === 25) { const v = (readByte() << 8) | readByte(); return v; } throw new Error("CBOR uint too wide for test"); } function readNegint(info: number): number { return -1 - readUint(info); } function readItem(): Uint8Array | number { const initial = readByte(); const majorType = initial >> 5; const info = initial & 0x1f; switch (majorType) { case 0: return readUint(info); case 1: return readNegint(info); case 2: { const len = readUint(info); const slice = bytes.slice(pos, pos + len); pos += len; return slice; } default: throw new Error(`Unsupported CBOR major type ${majorType} in test decoder`); } } const initial = readByte(); const majorType = initial >> 5; const count = initial & 0x1f; // works for maps <= 23 entries if (majorType !== 5) throw new Error("Expected CBOR map"); for (let i = 0; i < count; i++) { const key = readItem() as number; const value = readItem(); map.set(key, value); } return map; } // --------------------------------------------------------------------------- // Test state // --------------------------------------------------------------------------- let keypair: { publicKeyCose: Uint8Array; publicKeySpki: Uint8Array; privateKeyJwk: JsonWebKey; }; describe("T-015: Ed25519 passkey key generation and COSE encoding", () => { beforeAll(async () => { keypair = await generatePasskeyKeypair(-8); }); // T-015a: Ed25519 keypair generates without error and returns all three // required fields. This checks that crypto.subtle.generateKey("Ed25519") // works in the test environment. it("T-015a: generatePasskeyKeypair(-8) returns publicKeyCose, publicKeySpki, privateKeyJwk", () => { expect(keypair.publicKeyCose).toBeInstanceOf(Uint8Array); expect(keypair.publicKeySpki).toBeInstanceOf(Uint8Array); expect(typeof keypair.privateKeyJwk).toBe("object"); expect(keypair.publicKeyCose.length).toBeGreaterThan(0); expect(keypair.publicKeySpki.length).toBeGreaterThan(0); }); // T-015b: COSE key has the correct Ed25519 structure. // Per COSE Key Structure (RFC 8152 §13.2) for Ed25519: // kty (1) = 1 (OKP) // alg (3) = -8 (EdDSA) // crv (-1) = 6 (Ed25519) // x (-2) = 32-byte raw public key // // Bug this catches: using the P-256 COSE template (kty=2, crv=1) for Ed25519 // would produce a COSE key that a relying party rejects outright. it("T-015b: COSE key structure matches Ed25519 COSE map (kty=1, alg=-8, crv=6, x=32 bytes)", () => { const map = decodeCoseKeyMap(keypair.publicKeyCose); expect(map.get(1)).toBe(1); // kty = OKP expect(map.get(3)).toBe(-8); // alg = EdDSA expect(map.get(-1)).toBe(6); // crv = Ed25519 const x = map.get(-2); expect(x).toBeInstanceOf(Uint8Array); expect((x as Uint8Array).length).toBe(32); }); // T-015c: publicKeyJwkToCose and ed25519RawToCose produce the same COSE bytes // for the same public key. They take different inputs (JWK vs raw bytes) but // must produce identical output — they are two paths to the same encoding. // // Bug this catches: divergent encoding in one of the two functions causes a // mismatch between a key generated at create-time (JWK path) and a key // re-encoded for assertion verification (raw SPKI path). it("T-015c: publicKeyJwkToCose and ed25519RawToCose produce identical COSE bytes", () => { const fromJwk = publicKeyJwkToCose(keypair.privateKeyJwk, -8); // The JWK 'x' field is the base64url-encoded raw public key. const rawPub = base64urlDecode(keypair.privateKeyJwk.x!); const fromRaw = ed25519RawToCose(rawPub); expect(fromJwk).toEqual(fromRaw); }); // T-015d: ed25519RawToCose rejects keys that are not exactly 32 bytes. // The check guards against passing a SPKI-wrapped key (which is longer) // or a truncated raw key. it("T-015d: ed25519RawToCose throws on non-32-byte input", () => { expect(() => ed25519RawToCose(new Uint8Array(31))).toThrow(/invalid/i); expect(() => ed25519RawToCose(new Uint8Array(33))).toThrow(/invalid/i); expect(() => ed25519RawToCose(new Uint8Array(64))).toThrow(/invalid/i); }); }); describe("T-015: Ed25519 passkey signature round-trip", () => { beforeAll(async () => { keypair = await generatePasskeyKeypair(-8); }); // T-015e: signPasskeyAssertion(-8) returns a 64-byte signature. // Ed25519 signatures are always exactly 64 bytes — no DER wrapping. // Bug this catches: using the ECDSA path (which applies p1363ToDer and // returns a variable-length DER signature) for Ed25519 produces a signature // the relying party can't verify. it("T-015e: signPasskeyAssertion with Ed25519 returns a 64-byte signature", async () => { const authData = new Uint8Array(37).fill(0xaa); const clientDataHash = new Uint8Array(32).fill(0xbb); const sig = await signPasskeyAssertion(keypair.privateKeyJwk, -8, authData, clientDataHash); expect(sig).toBeInstanceOf(Uint8Array); expect(sig.length).toBe(64); }); // T-015f: The produced signature verifies against the generated public key. // This end-to-end check ensures generatePasskeyKeypair and signPasskeyAssertion // use the SAME underlying key material — a mis-keyed sign/verify pair would // produce a signature that always fails verification. it("T-015f: Ed25519 signature verifies against the generated public key", async () => { const authData = new Uint8Array(37).fill(0xcc); const clientDataHash = new Uint8Array(32).fill(0xdd); const sig = new Uint8Array(await signPasskeyAssertion(keypair.privateKeyJwk, -8, authData, clientDataHash)); // Reconstruct the public key for verification from the JWK 'x' coordinate. const rawPub = new Uint8Array(base64urlDecode(keypair.privateKeyJwk.x!)); const pubKey = await crypto.subtle.importKey( "raw", rawPub, { name: "Ed25519" }, false, ["verify"], ); // The signInput in signPasskeyAssertion is concat(authData, clientDataHash). const signInput = new Uint8Array(authData.length + clientDataHash.length); signInput.set(authData, 0); signInput.set(clientDataHash, authData.length); const valid = await crypto.subtle.verify("Ed25519", pubKey, sig, signInput); expect(valid).toBe(true); }); // T-015g: A signature produced for one payload does not verify against a // different payload. This checks that signPasskeyAssertion actually commits // to the clientDataHash (the nonce the RP chose for this assertion). // // Bug this catches: signing a fixed/constant value instead of the actual // signInput (concat(authData, clientDataHash)) would cause signatures to // be replay-attackable across different RP challenges. it("T-015g: signature does not verify against a different clientDataHash (replay check)", async () => { const authData = new Uint8Array(37).fill(0xee); const clientDataHash = new Uint8Array(32).fill(0xff); const sig = new Uint8Array(await signPasskeyAssertion(keypair.privateKeyJwk, -8, authData, clientDataHash)); const rawPub = new Uint8Array(base64urlDecode(keypair.privateKeyJwk.x!)); const pubKey = await crypto.subtle.importKey( "raw", rawPub, { name: "Ed25519" }, false, ["verify"], ); // Same authData, different clientDataHash (different RP challenge) const differentClientDataHash = new Uint8Array(32).fill(0x01); const wrongSignInput = new Uint8Array(authData.length + differentClientDataHash.length); wrongSignInput.set(authData, 0); wrongSignInput.set(differentClientDataHash, authData.length); const valid = await crypto.subtle.verify("Ed25519", pubKey, sig, wrongSignInput); expect(valid).toBe(false); }); });