Files
suaveplan/packages/foundation/flow/cbor/src/cose/sign.test.ts
2026-05-27 23:47:53 +01:00

564 lines
19 KiB
TypeScript

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 = [[<text>, {}, <bytes>]]
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/);
});
});