import * as ed25519 from "@suaveplan/crypto-curve/ed25519"; import * as p256 from "@suaveplan/crypto-curve/p256"; import { sha256 } from "@suaveplan/crypto-hash"; import { describe, expect, it } from "@suaveplan/testing/runner"; import type { Ec2Key, OkpKey } from "./cose.types.js"; import { buildSignStructure } from "./sig-structure.js"; import { type CoseSign, decodeCoseSign, encodeCoseSign, verifyCoseSignAny, } from "./sign.js"; describe("COSE_Sign — multi-signer round-trip", () => { it("encodes + decodes with two signers", { timeout: 5000 }, () => { const wire = encodeCoseSign({ protectedHeader: {}, payload: new Uint8Array([1, 2, 3]), signatures: [ { protectedHeader: { alg: "ES256" }, signature: new Uint8Array(64), }, { protectedHeader: { alg: "EdDSA" }, unprotectedHeader: { kid: new Uint8Array([0xab]) }, signature: new Uint8Array(64), }, ], }); const msg = decodeCoseSign(wire); expect(msg.signatures).toHaveLength(2); expect(msg.signatures[0]?.protectedHeader.alg).toBe("ES256"); expect(msg.signatures[1]?.protectedHeader.alg).toBe("EdDSA"); expect( msg.signatures[1]?.unprotectedHeader.kid && Array.from(msg.signatures[1].unprotectedHeader.kid) ).toEqual([0xab]); }); it("supports tagless wire form", { timeout: 5000 }, () => { const wire = encodeCoseSign({ payload: new Uint8Array([1]), signatures: [ { protectedHeader: { alg: "EdDSA" }, signature: new Uint8Array(64), }, ], tagged: false, }); const msg = decodeCoseSign(wire); expect(msg.signatures).toHaveLength(1); }); it("supports null payload", { timeout: 5000 }, () => { const wire = encodeCoseSign({ payload: null, signatures: [ { protectedHeader: { alg: "EdDSA" }, signature: new Uint8Array(64), }, ], }); const msg = decodeCoseSign(wire); expect(msg.payload).toBeNull(); }); }); describe("COSE_Sign — verifyCoseSignAny with real crypto", () => { it("returns the matching signer index for EdDSA", { timeout: 10000, }, () => { const skA = ed25519.randomPrivateKey(); const skB = ed25519.randomPrivateKey(); const pkA = ed25519.getPublicKey(skA); const pkB = ed25519.getPublicKey(skB); const okpA: OkpKey = { kty: "OKP", crv: "Ed25519", x: pkA }; const okpB: OkpKey = { kty: "OKP", crv: "Ed25519", x: pkB }; const payload = new Uint8Array([0x68, 0x69]); // Build draft to capture both protected-raw byte strings const draftWire = encodeCoseSign({ payload, signatures: [ { protectedHeader: { alg: "EdDSA" }, signature: new Uint8Array(64), }, { protectedHeader: { alg: "EdDSA" }, signature: new Uint8Array(64), }, ], }); const draft = decodeCoseSign(draftWire); const sig0Tbs = buildSignStructure( "Signature", draft.protectedRaw, draft.signatures[0]?.protectedRaw ?? new Uint8Array(0), new Uint8Array(0), payload ); const sig1Tbs = buildSignStructure( "Signature", draft.protectedRaw, draft.signatures[1]?.protectedRaw ?? new Uint8Array(0), new Uint8Array(0), payload ); const sig0 = ed25519.sign(sig0Tbs, skA); const sig1 = ed25519.sign(sig1Tbs, skB); const wire = encodeCoseSign({ payload, signatures: [ { protectedHeader: { alg: "EdDSA" }, signature: sig0 }, { protectedHeader: { alg: "EdDSA" }, signature: sig1 }, ], }); const msg = decodeCoseSign(wire); // First signer match expect(verifyCoseSignAny(msg, [okpA, okpB])).toBe(0); // If we reverse, both indices verify but the function returns the first-match (still 0) // To force second-match-only we mismatch the first slot: const wrong: OkpKey = { kty: "OKP", crv: "Ed25519", x: ed25519.getPublicKey(ed25519.randomPrivateKey()), }; expect(verifyCoseSignAny(msg, [wrong, okpB])).toBe(1); // No matches expect(verifyCoseSignAny(msg, [wrong, wrong])).toBe(-1); }); it("verifies an ES256 signer", { timeout: 10000 }, () => { const sk = p256.randomPrivateKey(); const pk = p256.getPublicKey(sk); const ec2: Ec2Key = { kty: "EC2", crv: "P-256", x: pk.subarray(1, 33), y: new Uint8Array(32), }; ec2.y[31] = (pk[0] as number) === 0x03 ? 1 : 0; const payload = new Uint8Array([1, 2]); const draft = decodeCoseSign( encodeCoseSign({ payload, signatures: [ { protectedHeader: { alg: "ES256" }, signature: new Uint8Array(64), }, ], }) ); const tbs = buildSignStructure( "Signature", draft.protectedRaw, draft.signatures[0]?.protectedRaw ?? new Uint8Array(0), new Uint8Array(0), payload ); const sig = p256.sign(sha256(tbs), sk); const msg: CoseSign = { ...draft, signatures: [{ ...(draft.signatures[0] as never), signature: sig }], }; expect(verifyCoseSignAny(msg, [ec2])).toBe(0); }); it("skips signers with no alg in protected header", { timeout: 10000, }, () => { const sk = ed25519.randomPrivateKey(); const pk = ed25519.getPublicKey(sk); const okp: OkpKey = { kty: "OKP", crv: "Ed25519", x: pk }; const payload = new Uint8Array([1]); const draft = decodeCoseSign( encodeCoseSign({ payload, signatures: [ { protectedHeader: {}, signature: new Uint8Array(64) }, { protectedHeader: { alg: "EdDSA" }, signature: new Uint8Array(64), }, ], }) ); const tbs = buildSignStructure( "Signature", draft.protectedRaw, draft.signatures[1]?.protectedRaw ?? new Uint8Array(0), new Uint8Array(0), payload ); const sig = ed25519.sign(tbs, sk); const msg: CoseSign = { ...draft, signatures: [ draft.signatures[0] as never, { ...(draft.signatures[1] as never), signature: sig }, ], }; // Slot 0 has no alg → skipped; slot 1 verifies expect(verifyCoseSignAny(msg, [okp, okp])).toBe(1); }); it("returns -1 for an unsupported alg even with valid signature material", { timeout: 5000, }, () => { const draft = decodeCoseSign( encodeCoseSign({ payload: new Uint8Array([1]), signatures: [ { protectedHeader: { alg: "RS256" }, signature: new Uint8Array(256), }, ], }) ); expect(verifyCoseSignAny(draft, [new Uint8Array(32)])).toBe(-1); }); }); describe("COSE_Sign — additional coverage paths", () => { it("encodes with non-empty body protected header", { timeout: 5000, }, () => { const wire = encodeCoseSign({ protectedHeader: { contentType: "application/cbor" }, payload: new Uint8Array([1]), signatures: [ { protectedHeader: {}, signature: new Uint8Array(64) }, ], }); const msg = decodeCoseSign(wire); expect(msg.protectedHeader.contentType).toBe("application/cbor"); expect(msg.signatures[0]?.protectedHeader).toEqual({}); }); it("ES256 verify accepts raw byte-string public key", { timeout: 10000, }, () => { const sk = p256.randomPrivateKey(); const pk = p256.getPublicKey(sk); const payload = new Uint8Array([1, 2]); const draft = decodeCoseSign( encodeCoseSign({ payload, signatures: [ { protectedHeader: { alg: "ES256" }, signature: new Uint8Array(64), }, ], }) ); const tbs = buildSignStructure( "Signature", draft.protectedRaw, draft.signatures[0]?.protectedRaw ?? new Uint8Array(0), new Uint8Array(0), payload ); const sig = p256.sign(sha256(tbs), sk); const msg: CoseSign = { ...draft, signatures: [{ ...(draft.signatures[0] as never), signature: sig }], }; expect(verifyCoseSignAny(msg, [pk])).toBe(0); }); it("ES256 verify exercises both y-parity branches", { timeout: 15000, }, () => { let sawEven = false; let sawOdd = false; for (let i = 0; i < 8 && !(sawEven && sawOdd); i++) { const sk = p256.randomPrivateKey(); const pk = p256.getPublicKey(sk); const prefix = pk[0] as number; const ec2: Ec2Key = { kty: "EC2", crv: "P-256", x: pk.subarray(1, 33), y: new Uint8Array(32), }; ec2.y[31] = prefix === 0x03 ? 1 : 0; const payload = new Uint8Array([1]); const draft = decodeCoseSign( encodeCoseSign({ payload, signatures: [ { protectedHeader: { alg: "ES256" }, signature: new Uint8Array(64), }, ], }) ); const tbs = buildSignStructure( "Signature", draft.protectedRaw, draft.signatures[0]?.protectedRaw ?? new Uint8Array(0), new Uint8Array(0), payload ); const sig = p256.sign(sha256(tbs), sk); const msg: CoseSign = { ...draft, signatures: [ { ...(draft.signatures[0] as never), signature: sig }, ], }; expect(verifyCoseSignAny(msg, [ec2])).toBe(0); if (prefix === 0x02) sawEven = true; if (prefix === 0x03) sawOdd = true; } expect(sawEven && sawOdd).toBe(true); }); it("ES256 verify with OKP key returns -1 (kty mismatch)", { timeout: 5000, }, () => { const draft = decodeCoseSign( encodeCoseSign({ payload: new Uint8Array([1]), signatures: [ { protectedHeader: { alg: "ES256" }, signature: new Uint8Array(64), }, ], }) ); const okp: OkpKey = { kty: "OKP", crv: "Ed25519", x: new Uint8Array(32), }; expect(verifyCoseSignAny(draft, [okp])).toBe(-1); }); it("ES256 verify with malformed EC2 x dimension returns -1", { timeout: 5000, }, () => { const draft = decodeCoseSign( encodeCoseSign({ payload: new Uint8Array([1]), signatures: [ { protectedHeader: { alg: "ES256" }, signature: new Uint8Array(64), }, ], }) ); const badEc2: Ec2Key = { kty: "EC2", crv: "P-256", x: new Uint8Array(16), y: new Uint8Array(32), }; expect(verifyCoseSignAny(draft, [badEc2])).toBe(-1); }); it("ES256 verify with malformed EC2 y dimension returns -1", { timeout: 5000, }, () => { const draft = decodeCoseSign( encodeCoseSign({ payload: new Uint8Array([1]), signatures: [ { protectedHeader: { alg: "ES256" }, signature: new Uint8Array(64), }, ], }) ); const badEc2: Ec2Key = { kty: "EC2", crv: "P-256", x: new Uint8Array(32), y: new Uint8Array(16), // wrong y length }; expect(verifyCoseSignAny(draft, [badEc2])).toBe(-1); }); it("encodes signer with no protected header (undefined → default)", { timeout: 5000, }, () => { const wire = encodeCoseSign({ payload: new Uint8Array([1]), // signer has no protectedHeader at all → triggers the `?? {}` default branch signatures: [{ signature: new Uint8Array(64) }], }); const msg = decodeCoseSign(wire); expect(msg.signatures[0]?.protectedHeader).toEqual({}); }); it("EdDSA verify with EC2 key returns -1", { timeout: 5000 }, () => { const draft = decodeCoseSign( encodeCoseSign({ payload: new Uint8Array([1]), signatures: [ { protectedHeader: { alg: "EdDSA" }, signature: new Uint8Array(64), }, ], }) ); const ec2: Ec2Key = { kty: "EC2", crv: "P-256", x: new Uint8Array(32), y: new Uint8Array(32), }; expect(verifyCoseSignAny(draft, [ec2])).toBe(-1); }); it("EdDSA verify accepts raw byte-string public key", { timeout: 10000, }, () => { const sk = ed25519.randomPrivateKey(); const pk = ed25519.getPublicKey(sk); const payload = new Uint8Array([1]); const draft = decodeCoseSign( encodeCoseSign({ payload, signatures: [ { protectedHeader: { alg: "EdDSA" }, signature: new Uint8Array(64), }, ], }) ); const tbs = buildSignStructure( "Signature", draft.protectedRaw, draft.signatures[0]?.protectedRaw ?? new Uint8Array(0), new Uint8Array(0), payload ); const sig = ed25519.sign(tbs, sk); const msg: CoseSign = { ...draft, signatures: [{ ...(draft.signatures[0] as never), signature: sig }], }; expect(verifyCoseSignAny(msg, [pk])).toBe(0); }); }); describe("COSE_Sign — error paths", () => { it("verify throws when keys length mismatches signers", { timeout: 5000, }, () => { const draft = decodeCoseSign( encodeCoseSign({ payload: new Uint8Array([1]), signatures: [ { protectedHeader: { alg: "EdDSA" }, signature: new Uint8Array(64), }, { protectedHeader: { alg: "EdDSA" }, signature: new Uint8Array(64), }, ], }) ); expect(() => verifyCoseSignAny(draft, [new Uint8Array(32)])).toThrow( /UNEXPECTED_TYPE/ ); }); it("verify throws on detached payload", { timeout: 5000 }, () => { const draft = decodeCoseSign( encodeCoseSign({ payload: null, signatures: [ { protectedHeader: { alg: "EdDSA" }, signature: new Uint8Array(64), }, ], }) ); expect(() => verifyCoseSignAny(draft, [new Uint8Array(32)])).toThrow( /UNEXPECTED_TYPE/ ); }); it("decode rejects non-array shape", { timeout: 5000 }, () => { expect(() => decodeCoseSign(new Uint8Array([0xa0]))).toThrow( /UNEXPECTED_TYPE/ ); }); it("decode rejects non-byte-string protected", { timeout: 5000 }, () => { const bad = new Uint8Array([0x84, 0x60, 0xa0, 0x40, 0x80]); expect(() => decodeCoseSign(bad)).toThrow(/UNEXPECTED_TYPE/); }); it("decode rejects non-map unprotected", { timeout: 5000 }, () => { const bad = new Uint8Array([0x84, 0x40, 0x60, 0x40, 0x80]); expect(() => decodeCoseSign(bad)).toThrow(/UNEXPECTED_TYPE/); }); it("decode rejects non-byte-string payload (not null)", { timeout: 5000, }, () => { const bad = new Uint8Array([0x84, 0x40, 0xa0, 0x60, 0x80]); expect(() => decodeCoseSign(bad)).toThrow(/UNEXPECTED_TYPE/); }); it("decode rejects empty signatures array", { timeout: 5000 }, () => { const bad = new Uint8Array([0x84, 0x40, 0xa0, 0x40, 0x80]); // [bytes, map, bytes, []] expect(() => decodeCoseSign(bad)).toThrow(/UNEXPECTED_TYPE/); }); it("decode rejects malformed signature entry shape", { timeout: 5000, }, () => { // payload = bytes 0; signatures = [["x"]] (1-element instead of 3) const bad = new Uint8Array([0x84, 0x40, 0xa0, 0x40, 0x81, 0x81, 0x60]); expect(() => decodeCoseSign(bad)).toThrow(/UNEXPECTED_TYPE/); }); it("decode rejects malformed signature[0] (non-bytes)", { timeout: 5000, }, () => { // signatures = [[, {}, ]] const bad = new Uint8Array([ 0x84, 0x40, 0xa0, 0x40, 0x81, 0x83, 0x60, 0xa0, 0x40, ]); expect(() => decodeCoseSign(bad)).toThrow(/UNEXPECTED_TYPE/); }); it("decode rejects malformed signature[1] (non-map)", { timeout: 5000, }, () => { const bad = new Uint8Array([ 0x84, 0x40, 0xa0, 0x40, 0x81, 0x83, 0x40, 0x60, 0x40, ]); expect(() => decodeCoseSign(bad)).toThrow(/UNEXPECTED_TYPE/); }); it("decode rejects malformed signature[2] (non-bytes)", { timeout: 5000, }, () => { const bad = new Uint8Array([ 0x84, 0x40, 0xa0, 0x40, 0x81, 0x83, 0x40, 0xa0, 0x60, ]); expect(() => decodeCoseSign(bad)).toThrow(/UNEXPECTED_TYPE/); }); });