Initial Commit
This commit is contained in:
563
packages/foundation/flow/cbor/src/cose/sign.test.ts
Normal file
563
packages/foundation/flow/cbor/src/cose/sign.test.ts
Normal file
@@ -0,0 +1,563 @@
|
||||
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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user