Some checks failed
CI / Frontend Lint (pull_request) Failing after 8s
CI / Frontend Type Check (pull_request) Failing after 6s
CI / Frontend Build (pull_request) Failing after 6s
CI / Frontend E2E Tests (pull_request) Failing after 7s
CI / Orchestrator Build (pull_request) Failing after 6s
CI / Contracts Compile (pull_request) Failing after 5s
CI / Contracts Test (pull_request) Failing after 6s
Code Quality / SonarQube Analysis (pull_request) Failing after 21s
Code Quality / Code Quality Checks (pull_request) Failing after 5s
Security Scan / Dependency Vulnerability Scan (pull_request) Failing after 4s
Security Scan / OWASP ZAP Scan (pull_request) Failing after 4s
Closes gap-analysis v2 §7.5 and §10.5.
- services/eventSigner.ts — three interchangeable strategies
selected via EVENT_SIGNING_MODE:
hmac (default; back-compat) HMAC-SHA256 via EVENT_BUS_HMAC_SECRET
eip712 EIP-712 typed data signed by ORCHESTRATOR_PRIVATE_KEY
(ethers Wallet) or via services/hsm.ts when
EVENT_SIGNING_HSM_KEY_ID is set. Domain pinned to
CurrenciCombo/1/chain-138/NOTARY_REGISTRY_ADDRESS.
jws Compact JWS (HS256), useful when the signature has to
traverse a JWT-aware infra layer.
- services/eventBus.ts — publish() now delegates to getEventSigner();
verifyChain() uses the active signer, falling through to legacyHmac
for rows written before PR O so the historical tail still verifies.
- legacyHmac() helper + payloadHashOf() re-exported.
- 13 unit tests across mode resolution, HMAC round-trip, JWS
round-trip + tamper rejection, EIP-712 round-trip + ethers address
recovery, and cross-event replay rejection.
- Full suite 93/93 green; tsc --noEmit clean.
188 lines
5.8 KiB
TypeScript
188 lines
5.8 KiB
TypeScript
/**
|
|
* PR O — EIP-712 / JWS event signatures (gap-analysis v2 §7.5 / §10.5).
|
|
*
|
|
* Covers:
|
|
* - HMAC default mode (back-compat) — round-trip sign/verify
|
|
* - JWS compact mode — valid signature, payload-tamper detection,
|
|
* signature-tamper detection
|
|
* - EIP-712 mode — round-trip via ethers wallet, tamper detection
|
|
* - Mode switching via EVENT_SIGNING_MODE + getEventSigner()
|
|
* - legacyHmac helper preserves the pre-PR-O signature shape
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from "@jest/globals";
|
|
import { ethers } from "ethers";
|
|
import {
|
|
getEventSigner,
|
|
resolveSigningMode,
|
|
legacyHmac,
|
|
__resetSignerForTests,
|
|
type EventSignInput,
|
|
} from "../../src/services/eventSigner";
|
|
|
|
const TEST_KEY = "0x" + "ab".repeat(32); // deterministic test wallet
|
|
|
|
function makeInput(overrides: Partial<EventSignInput> = {}): EventSignInput {
|
|
return {
|
|
planId: "plan-x",
|
|
type: "transaction.created",
|
|
payloadHash: "a".repeat(64),
|
|
prevHash: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("eventSigner — mode resolution", () => {
|
|
beforeEach(() => {
|
|
__resetSignerForTests();
|
|
delete process.env.EVENT_SIGNING_MODE;
|
|
delete process.env.ORCHESTRATOR_PRIVATE_KEY;
|
|
});
|
|
|
|
it("defaults to hmac when env var absent", () => {
|
|
expect(resolveSigningMode()).toBe("hmac");
|
|
expect(getEventSigner().mode).toBe("hmac");
|
|
});
|
|
|
|
it("honours eip712 / jws / hmac values", () => {
|
|
for (const mode of ["eip712", "jws", "hmac"] as const) {
|
|
__resetSignerForTests();
|
|
process.env.EVENT_SIGNING_MODE = mode;
|
|
expect(resolveSigningMode()).toBe(mode);
|
|
expect(getEventSigner().mode).toBe(mode);
|
|
}
|
|
});
|
|
|
|
it("falls through to hmac on unknown mode", () => {
|
|
process.env.EVENT_SIGNING_MODE = "bogus";
|
|
expect(resolveSigningMode()).toBe("hmac");
|
|
});
|
|
});
|
|
|
|
describe("eventSigner — HMAC round-trip", () => {
|
|
beforeEach(() => {
|
|
__resetSignerForTests();
|
|
process.env.EVENT_SIGNING_MODE = "hmac";
|
|
process.env.EVENT_BUS_HMAC_SECRET = "test-secret";
|
|
});
|
|
|
|
it("signs + verifies", async () => {
|
|
const signer = getEventSigner();
|
|
const input = makeInput();
|
|
const sig = await signer.sign(input);
|
|
expect(sig).toMatch(/^[0-9a-f]{64}$/);
|
|
expect(await signer.verify(input, sig)).toBe(true);
|
|
});
|
|
|
|
it("rejects a tampered payload hash", async () => {
|
|
const signer = getEventSigner();
|
|
const input = makeInput();
|
|
const sig = await signer.sign(input);
|
|
expect(
|
|
await signer.verify({ ...input, payloadHash: "b".repeat(64) }, sig),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("legacyHmac produces the same hex as the HMAC signer", async () => {
|
|
const input = makeInput();
|
|
const sig = await getEventSigner().sign(input);
|
|
expect(legacyHmac(input)).toBe(sig);
|
|
});
|
|
});
|
|
|
|
describe("eventSigner — JWS compact", () => {
|
|
beforeEach(() => {
|
|
__resetSignerForTests();
|
|
process.env.EVENT_SIGNING_MODE = "jws";
|
|
process.env.EVENT_BUS_HMAC_SECRET = "jws-secret";
|
|
});
|
|
|
|
it("produces header.body.sig shape", async () => {
|
|
const sig = await getEventSigner().sign(makeInput());
|
|
expect(sig.split(".")).toHaveLength(3);
|
|
});
|
|
|
|
it("round-trips sign/verify", async () => {
|
|
const signer = getEventSigner();
|
|
const input = makeInput();
|
|
expect(await signer.verify(input, await signer.sign(input))).toBe(true);
|
|
});
|
|
|
|
it("rejects a different event being presented with a valid sig", async () => {
|
|
const signer = getEventSigner();
|
|
const signed = await signer.sign(makeInput());
|
|
expect(
|
|
await signer.verify(makeInput({ type: "transaction.aborted" }), signed),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("rejects a truncated signature", async () => {
|
|
const signer = getEventSigner();
|
|
const sig = await signer.sign(makeInput());
|
|
expect(await signer.verify(makeInput(), sig.slice(0, -3))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("eventSigner — EIP-712 (ethers wallet)", () => {
|
|
beforeEach(() => {
|
|
__resetSignerForTests();
|
|
process.env.EVENT_SIGNING_MODE = "eip712";
|
|
process.env.ORCHESTRATOR_PRIVATE_KEY = TEST_KEY;
|
|
process.env.CHAIN_138_CHAIN_ID = "138";
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete process.env.ORCHESTRATOR_PRIVATE_KEY;
|
|
});
|
|
|
|
it("produces a 65-byte 0x-prefixed ECDSA signature", async () => {
|
|
const sig = await getEventSigner().sign(makeInput());
|
|
expect(sig).toMatch(/^0x[0-9a-fA-F]{130}$/);
|
|
});
|
|
|
|
it("verifies as the signing wallet", async () => {
|
|
const signer = getEventSigner();
|
|
const input = makeInput();
|
|
const sig = await signer.sign(input);
|
|
expect(await signer.verify(input, sig)).toBe(true);
|
|
// Recovered address should match the wallet derived from TEST_KEY.
|
|
const expectedAddress = new ethers.Wallet(TEST_KEY).address;
|
|
const recovered = ethers.verifyTypedData(
|
|
{
|
|
name: "CurrenciCombo",
|
|
version: "1",
|
|
chainId: 138,
|
|
verifyingContract:
|
|
process.env.NOTARY_REGISTRY_ADDRESS ??
|
|
"0x0000000000000000000000000000000000000000",
|
|
},
|
|
{
|
|
Event: [
|
|
{ name: "planId", type: "string" },
|
|
{ name: "eventType", type: "string" },
|
|
{ name: "payloadHash", type: "bytes32" },
|
|
{ name: "prevHash", type: "bytes32" },
|
|
],
|
|
},
|
|
{
|
|
planId: input.planId,
|
|
eventType: input.type,
|
|
payloadHash: `0x${input.payloadHash}`,
|
|
prevHash: "0x" + "0".repeat(64),
|
|
},
|
|
sig,
|
|
);
|
|
expect(recovered.toLowerCase()).toBe(expectedAddress.toLowerCase());
|
|
});
|
|
|
|
it("rejects a signature for a different event", async () => {
|
|
const signer = getEventSigner();
|
|
const sig = await signer.sign(makeInput());
|
|
// Valid signature but rebound to a different event → fails because
|
|
// the recovered address won't be our wallet.
|
|
expect(
|
|
await signer.verify(makeInput({ type: "transaction.committed" }), sig),
|
|
).toBe(false);
|
|
});
|
|
});
|