Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions docs/SIGNED_WEBHOOK_REPLAY_PROTECTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Signed webhook replay protection

Predictify webhook deliveries can be verified with a versioned header:

```text
v1=<key-id>,t=<unix-seconds>,n=<nonce>,s=<hex-hmac>
```

The HMAC input is deliberately unambiguous and byte-preserving:

```text
predictify-webhook-v1\n<key-id>\n<timestamp>\n<nonce>\n<raw-body>
```

Receivers must retain the raw request bytes. Parsing and serialising JSON
before verification can change whitespace or property ordering and is not
equivalent to the signed message.

## Rotation

Each key has an activation time and optional expiry. The newest active key is
used for signing. During rotation, keep the old key active until the maximum
delivery/retry window has elapsed; this permits in-flight deliveries to finish
without making the new key depend on the old key. A key is rejected outside its
window, even if its HMAC is otherwise valid.

The `SignedWebhookSecurity` class accepts a bounded key ring, supports explicit
rotation/removal, and exposes only public key metadata for operations. Secrets
are copied into private buffers and are never returned by `listKeys`.

## Verification order

1. Parse the bounded header and reject malformed fields.
2. Resolve the key and check its activation/expiry window.
3. Check timestamp skew against the configured tolerance.
4. Compute the expected HMAC over the exact raw bytes and compare with
`timingSafeEqual` after validating equal fixed-length digest sizes.
5. Atomically claim the nonce in the bounded replay store.

Invalid signatures do not consume nonce entries. A valid signature can be used
only once per key and nonce while the replay entry is alive. The in-memory
nonce store is suitable for the current single-process test/development path;
multi-process deployments should implement `NonceStore` with a shared,
conditional insert and expiry (for example, Redis `SET NX EX`).

## Failure handling

All verification failures return a stable reason internally, but callers should
use one generic client-facing 401/400 response so an attacker cannot learn
whether a key id, timestamp, nonce, or signature was almost valid. Log only a
request correlation id and the reason at a protected server-side log level.

The dispatcher keeps the legacy single-secret signing path when no rotating
security object is configured. Passing `SignedWebhookSecurity` opts a caller
into timestamped signatures and persists the timestamp/nonce headers alongside
the delivery, so retries reuse the same signed message and cannot accidentally
receive a new signature for the same body.

For incident response, rotate to a new key id and retain the prior key through
the configured overlap period. Removing a key immediately invalidates every
message signed by it, including deliveries already queued for retry. Operators
should therefore coordinate key removal with queue depth and the longest
configured retry window. Nonce-store expiry should exceed the timestamp
tolerance so a valid message cannot become acceptable again after its replay
record has expired.
Alert on repeated verification failures without logging payloads or secrets.
91 changes: 91 additions & 0 deletions src/services/signedWebhookDispatcher.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, expect, it } from "@jest/globals";
import { InMemoryWebhookStore } from "./webhookStore";
import { WebhookDispatcher } from "./webhookDispatcher";
import { SignedWebhookSecurity } from "./signedWebhookSecurity";

function security() {
return new SignedWebhookSecurity({
keys: [{
id: "primary",
secret: Buffer.from("predictify-test-secret-0123456789"),
activeFrom: 0,
}],
});
}

describe("WebhookDispatcher rotating signature integration", () => {
it("persists timestamp and nonce headers with the signed delivery", async () => {
const store = new InMemoryWebhookStore();
const signer = security();
const dispatcher = new WebhookDispatcher({
store,
signingSecret: "legacy-secret",
security: signer,
});
const payload = Buffer.from('{"event":"market.resolved"}');
const delivery = await dispatcher.enqueue({
eventId: "event-1",
eventType: "market.resolved",
targetUrl: "https://subscriber.example.test/events",
payload,
});
expect(delivery.signature).toMatch(/^v1=primary,t=/);
expect(delivery.headers).toEqual(expect.objectContaining({
"x-predictify-timestamp": expect.any(String),
"x-predictify-nonce": expect.any(String),
}));
expect(signer.verify(payload, delivery.signature).ok).toBe(true);
});

it("retains legacy signing when no rotating signer is configured", async () => {
const store = new InMemoryWebhookStore();
const dispatcher = new WebhookDispatcher({ store, signingSecret: "legacy-secret" });
const delivery = await dispatcher.enqueue({
eventId: "event-legacy",
eventType: "market.created",
targetUrl: "https://subscriber.example.test/events",
payload: Buffer.from("legacy"),
});
expect(delivery.signature).toMatch(/^[a-f0-9]{64}$/);
expect(delivery.headers).toBeNull();
});

it("exposes a safe negative result when verification is not configured", () => {
const dispatcher = new WebhookDispatcher({
store: new InMemoryWebhookStore(),
signingSecret: "legacy-secret",
});
expect(dispatcher.verifySigned(Buffer.from("payload"), "bad-header")).toEqual({
ok: false,
reason: "unknown_key",
});
});

it("uses the stored signed header unchanged for retries", async () => {
const store = new InMemoryWebhookStore();
const signer = security();
const sent: Array<Record<string, string>> = [];
const dispatcher = new WebhookDispatcher({
store,
signingSecret: "legacy-secret",
security: signer,
send: async ({ headers }) => {
sent.push(headers);
return { status: 500 };
},
backoffMs: () => 0,
});
const delivery = await dispatcher.enqueue({
eventId: "event-retry",
eventType: "market.resolved",
targetUrl: "https://subscriber.example.test/events",
payload: Buffer.from("retry"),
maxAttempts: 2,
});
expect(await dispatcher.attemptDelivery(delivery.id)).toBe("retry");
expect(await dispatcher.attemptDelivery(delivery.id)).toBe("dead-lettered");
expect(sent).toHaveLength(2);
expect(sent[0]?.["x-predictify-signature"]).toBe(sent[1]?.["x-predictify-signature"]);
expect(sent[0]?.["x-predictify-timestamp"]).toBe(sent[1]?.["x-predictify-timestamp"]);
});
});
126 changes: 126 additions & 0 deletions src/services/signedWebhookSecurity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { randomBytes } from "node:crypto";
import { describe, expect, it } from "@jest/globals";
import {
DEFAULT_TIMESTAMP_TOLERANCE_SECONDS,
InMemoryNonceStore,
SignedWebhookSecurity,
} from "./signedWebhookSecurity";

const key = (id: string, activeFrom = 0, expiresAt?: number) => ({
id,
secret: Buffer.from(`${id}-webhook-secret-0123456789`),
activeFrom,
...(expiresAt === undefined ? {} : { expiresAt }),
});

describe("SignedWebhookSecurity", () => {
it("signs and verifies exact raw bytes", () => {
const security = new SignedWebhookSecurity({ keys: [key("primary")] });
const body = Buffer.from('{"marketId":"m-1","outcome":"yes"}');
const signed = security.sign(body, 100, "a1b2c3d4");
expect(signed.header).toMatch(/^v1=primary,t=100,n=a1b2c3d4,s=[a-f0-9]{64}$/);
expect(security.verify(body, signed.header, 100)).toEqual({
ok: true, keyId: "primary", timestamp: 100, nonce: "a1b2c3d4",
});
});

it("rejects byte changes even when parsed JSON would be equivalent", () => {
const security = new SignedWebhookSecurity({ keys: [key("primary")] });
const signed = security.sign(Buffer.from('{"a":1,"b":2}'), 100, "abcdef12");
expect(security.verify(Buffer.from('{"b":2,"a":1}'), signed.header, 100)).toEqual({
ok: false, reason: "invalid_signature",
});
});

it("rejects a nonce replay after a valid first verification", () => {
const security = new SignedWebhookSecurity({ keys: [key("primary")] });
const signed = security.sign(Buffer.from("payload"), 100, "deadbeef");
expect(security.verify(Buffer.from("payload"), signed.header, 100).ok).toBe(true);
expect(security.verify(Buffer.from("payload"), signed.header, 100)).toEqual({
ok: false, reason: "nonce_replayed",
});
});

it("rejects old and future timestamps at the tolerance boundary", () => {
const security = new SignedWebhookSecurity({ keys: [key("primary")] });
const old = security.sign(Buffer.from("old"), 1000, "11111111");
const future = security.sign(Buffer.from("future"), 1000, "22222222");
expect(security.verify(Buffer.from("old"), old.header, 1000 + DEFAULT_TIMESTAMP_TOLERANCE_SECONDS).ok).toBe(true);
expect(security.verify(Buffer.from("future"), future.header, 1000 - DEFAULT_TIMESTAMP_TOLERANCE_SECONDS - 1)).toEqual({
ok: false, reason: "timestamp_out_of_range",
});
});

it("accepts an old key during its overlap window", () => {
const security = new SignedWebhookSecurity({ keys: [key("old", 0, 200), key("new", 100)] });
const old = security.sign(Buffer.from("old"), 50, "33333333");
expect(security.verify(Buffer.from("old"), old.header, 50).ok).toBe(true);
expect(security.verify(Buffer.from("old"), old.header, 200)).toEqual({ ok: false, reason: "key_not_active" });
});

it("uses the newest active key after rotation", () => {
const security = new SignedWebhookSecurity({ keys: [key("old", 0), key("new", 100)] });
expect(security.sign(Buffer.from("payload"), 99, "44444444").keyId).toBe("old");
expect(security.sign(Buffer.from("payload"), 100, "55555555").keyId).toBe("new");
});

it("rejects unknown keys before consuming a nonce", () => {
const security = new SignedWebhookSecurity({ keys: [key("primary")] });
const external = new SignedWebhookSecurity({ keys: [key("foreign")] });
const signed = external.sign(Buffer.from("payload"), 100, "66666666");
expect(security.verify(Buffer.from("payload"), signed.header, 100)).toEqual({ ok: false, reason: "unknown_key" });
expect(security.nonceCount).toBe(0);
});

it("rejects malformed headers without throwing", () => {
const security = new SignedWebhookSecurity({ keys: [key("primary")] });
for (const malformed of ["", "sha256=bad", "v1=primary,t=nope", "v1=primary,t=1,n=xyz,s=bad", "v1=primary,t=1,n=abcdef12,s=" + "0".repeat(63)]) {
expect(security.verify(Buffer.from("payload"), malformed, 1)).toEqual({ ok: false, reason: "malformed" });
}
});

it("does not consume a nonce when the body signature is invalid", () => {
const security = new SignedWebhookSecurity({ keys: [key("primary")] });
const signed = security.sign(Buffer.from("payload"), 100, "77777777");
const tampered = signed.header.replace(/s=[a-f0-9]+$/, `s=${"0".repeat(64)}`);
expect(security.verify(Buffer.from("payload"), tampered, 100)).toEqual({ ok: false, reason: "invalid_signature" });
expect(security.nonceCount).toBe(0);
});

it("expires nonce entries and permits the nonce after expiry", () => {
const nonceStore = new InMemoryNonceStore();
const security = new SignedWebhookSecurity({
keys: [key("primary")], nonceStore, timestampToleranceSeconds: 1_000,
nonceTtlSeconds: 10,
});
const signed = security.sign(Buffer.from("payload"), 100, "88888888");
expect(security.verify(Buffer.from("payload"), signed.header, 100).ok).toBe(true);
expect(nonceStore.prune(110)).toBe(1);
expect(security.verify(Buffer.from("payload"), signed.header, 110).ok).toBe(true);
});

it("bounds nonce cache growth by evicting the oldest entry", () => {
const nonceStore = new InMemoryNonceStore(2);
expect(nonceStore.claim("key", "one", 100, 0)).toBe(true);
expect(nonceStore.claim("key", "two", 100, 0)).toBe(true);
expect(nonceStore.claim("key", "three", 100, 0)).toBe(true);
expect(nonceStore.size()).toBe(2);
expect(nonceStore.claim("key", "one", 100, 0)).toBe(true);
});

it("supports explicit rotation and removal", () => {
const security = new SignedWebhookSecurity({ keys: [key("primary")] });
security.rotate(key("secondary", 100));
expect(security.listKeys().map((item) => item.id)).toEqual(["primary", "secondary"]);
expect(security.removeKey("primary")).toBe(true);
expect(security.removeKey("missing")).toBe(false);
expect(security.listKeys().map((item) => item.id)).toEqual(["secondary"]);
});

it("rejects weak key configuration", () => {
expect(() => new SignedWebhookSecurity({ keys: [] })).toThrow();
expect(() => new SignedWebhookSecurity({ keys: [key("bad id!")] })).toThrow();
expect(() => new SignedWebhookSecurity({ keys: [{ ...key("weak"), secret: randomBytes(4) }] })).toThrow();
expect(() => new SignedWebhookSecurity({ keys: [key("bad-window", 10, 10)] })).toThrow();
});
});
Loading
Loading