564 lines
19 KiB
TypeScript
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/);
|
|
});
|
|
});
|