Skip to content
Open
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
54 changes: 54 additions & 0 deletions docs/current/SPECIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -642,6 +642,60 @@ function verifyWebhook(
> and its Python sibling `eep-signer`. Prefer importing it over
> re-implementing the comparison by hand.

### 5.3.1 Asymmetric signatures (normative)

HMAC gives integrity but not attribution. Publisher and subscriber share one
`delivery_secret`, so an HMAC signature proves only that *someone holding that
secret* sent the event — and the subscriber is one of them. Four consequences
follow, and they matter more the further EEP goes:

1. **Events are not non-repudiable.** A subscriber can forge an event and
attribute it to the publisher. §15's commerce state machine and §16's
signed audit trail both ride on this signature.
2. **No third party can verify.** A regulator or counterparty cannot check an
audit entry without being handed the subscriber's secret — which would let
them forge entries too.
3. **Rotation has no surface.** There is no key id and no published key set,
so rotation is manual per-subscription secret juggling.
4. **PQC readiness stops at the gate.** §11.7 defines algorithm negotiation,
including PQ-hybrid signatures — but only for agent→publisher *gate
proofs*. The path that carries every event has no asymmetric option at all.

Publishers SHOULD therefore support Ed25519 delivery signatures in addition to
HMAC, and MUST support them to advertise `signing_algorithms` containing
`EdDSA` for delivery.

**Wire format.** The signed content is unchanged —
`{webhook-id}.{webhook-timestamp}.{raw-body}` — so §5.3's replay rules apply
identically. The `webhook-signature` header carries a `v1a` token:

```http
webhook-signature: v1a,key-2026-08:BASE64_ED25519_SIGNATURE
```

The `kid` before the colon is OPTIONAL and lets a receiver select a key from
the published set instead of trial-verifying against every key.

**Coexistence.** A publisher MAY sign one delivery with both schemes and send
both tokens space-delimited. A verifier for one scheme MUST ignore tokens
belonging to the other rather than treating them as failures — otherwise
dual-signing, which is the whole migration path, breaks both verifiers.

**Key publication.** Publishers that sign asymmetrically MUST publish their
public keys as a JWKS document and MUST advertise its location as
`signing_jwks_url` in the manifest. Keys use `kty: OKP`, `crv: Ed25519`,
`alg: EdDSA`, `use: sig`.

**Rotation.** Publishers MUST add the incoming key to the JWKS *before*
signing with it, and MUST keep the outgoing key published for at least the
maximum retry span in §5.4 (~6 hours), so a delivery signed before the
rotation can still be verified when its last retry lands. During the overlap
they SHOULD sign with both.

Publishers MUST NOT reuse a `delivery_secret` as an Ed25519 key, and MUST NOT
derive one from the other: the security properties differ precisely because
the subscriber may hold the symmetric key and must never hold the private one.

### 5.4 Retry policy (exponential backoff)

If a webhook delivery fails (non-2xx response or timeout), the publisher MUST retry with exponential backoff:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { EEPSigner } from "@eep-dev/signer";
import { EEPSigner, generateSigningKeyPair, verifyEd25519 } from "@eep-dev/signer";
import { WebhookDispatcher, DEFAULT_RETRY_SCHEDULE_MS, type WebhookHttpClient } from "./webhook-dispatcher.js";
import { InMemoryDBAdapter } from "../db/in-memory.js";
import { InMemoryEventBusAdapter } from "../event-bus/in-memory.js";
Expand Down Expand Up @@ -59,6 +59,82 @@ describe("WebhookDispatcher", () => {
expect(DEFAULT_RETRY_SCHEDULE_MS).toEqual([0, 5_000, 30_000, 120_000, 900_000, 3_600_000, 21_600_000]);
});

// SPECIFICATION.md §5.3.1 — HMAC proves only that someone holding the
// shared secret sent the event, and the subscriber is one of them. Ed25519
// makes deliveries attributable and verifiable by third parties.
describe("asymmetric delivery signatures (§5.3.1)", () => {
const deliver = async (options: { signingPrivateKey?: string; signingKeyId?: string }) => {
const db = new InMemoryDBAdapter();
await db.saveSubscription(subscription());
const { client, calls } = mockClient([200]);
const dispatcher = new WebhookDispatcher({
db,
httpClient: client,
retryScheduleMs: NO_DELAY,
...options
});
await dispatcher.dispatch(event());
return calls[0]!;
};

it("signs with HMAC only when no key is configured", async () => {
const call = await deliver({});
expect(call.headers["webhook-signature"]).toMatch(/^v1,/);
expect(call.headers["webhook-signature"]).not.toContain("v1a,");
});

it("dual-signs when an Ed25519 key is configured", async () => {
const { privateKey, publicKey } = generateSigningKeyPair();
const call = await deliver({ signingPrivateKey: privateKey });
const header = call.headers["webhook-signature"]!;

// Both schemes present, space-delimited.
expect(header.split(" ")).toHaveLength(2);
// The HMAC token still verifies for subscribers that have not migrated.
expect(
new EEPSigner(SECRET).verify(
call.headers["webhook-id"]!,
call.headers["webhook-timestamp"]!,
header,
call.body
)
).toBe(true);
// And the Ed25519 token verifies for those that have.
expect(
verifyEd25519(publicKey, call.headers["webhook-id"]!, call.headers["webhook-timestamp"]!, header, call.body)
.valid
).toBe(true);
});

it("carries the configured key id so a receiver can select from the JWKS", async () => {
const { privateKey, publicKey } = generateSigningKeyPair();
const call = await deliver({ signingPrivateKey: privateKey, signingKeyId: "key-2026-08" });
const result = verifyEd25519(
publicKey,
call.headers["webhook-id"]!,
call.headers["webhook-timestamp"]!,
call.headers["webhook-signature"]!,
call.body
);
expect(result).toEqual({ valid: true, keyId: "key-2026-08" });
});

it("does not verify under an unrelated public key", async () => {
const { privateKey } = generateSigningKeyPair();
const other = generateSigningKeyPair();
const call = await deliver({ signingPrivateKey: privateKey });
expect(
verifyEd25519(
other.publicKey,
call.headers["webhook-id"]!,
call.headers["webhook-timestamp"]!,
call.headers["webhook-signature"]!,
call.body
).valid
).toBe(false);
});
});

// SPECIFICATION.md §5.1.3 — the filter narrows what event_types already
// selected. Before this, a subscriber interested in one field of one object
// still received every event of that type and discarded the rest, after
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { EEPSigner } from "@eep-dev/signer";
import { EEPSigner, signEd25519 } from "@eep-dev/signer";
import { matchesAnyPattern } from "@eep-dev/validator";
import { TEST_DELIVERY_EVENT_TYPE } from "../core/request-handler.js";
import type { EventStore } from "../core/event-store.js";
Expand Down Expand Up @@ -98,6 +98,15 @@ export type WebhookDispatcherOptions = {
* endpoint rejected it" — otherwise unanswerable from its side.
*/
eventStore?: EventStore;
/**
* Ed25519 private key (`whsk_`-prefixed) for asymmetric delivery signatures
* (SPECIFICATION.md §5.3.1). When set, every delivery carries a `v1a` token
* alongside the HMAC one, so subscribers can migrate at their own pace and
* events become verifiable by third parties.
*/
signingPrivateKey?: string;
/** Key id advertised in the JWKS, carried in the signature token. */
signingKeyId?: string;
};

const defaultHttpClient: WebhookHttpClient = async (url, { headers, body, signal }) => {
Expand Down Expand Up @@ -160,6 +169,8 @@ export class WebhookDispatcher {
private readonly deliveryTimeoutMs: number;
private readonly onDeliveryResult?: (result: DeliveryResult) => void;
private readonly eventStore?: EventStore;
private readonly signingPrivateKey?: string;
private readonly signingKeyId?: string;
private stopped = false;

constructor(options: WebhookDispatcherOptions) {
Expand All @@ -174,6 +185,8 @@ export class WebhookDispatcher {
this.deliveryTimeoutMs = options.deliveryTimeoutMs ?? DEFAULT_DELIVERY_TIMEOUT_MS;
this.onDeliveryResult = options.onDeliveryResult;
this.eventStore = options.eventStore;
this.signingPrivateKey = options.signingPrivateKey;
this.signingKeyId = options.signingKeyId;
}

/**
Expand Down Expand Up @@ -312,6 +325,19 @@ export class WebhookDispatcher {
let signature: string;
try {
signature = new EEPSigner(secret).sign(webhookId, timestamp, body);
// Dual-sign when an Ed25519 key is configured (§5.3.1). Both tokens
// travel space-delimited; a verifier for either scheme ignores the
// other's token, which is what makes the migration path work.
if (this.signingPrivateKey) {
const asymmetric = signEd25519(
this.signingPrivateKey,
webhookId,
timestamp,
body,
this.signingKeyId
);
signature = `${signature} ${asymmetric}`;
}
} catch {
return { ok: false };
}
Expand Down
174 changes: 174 additions & 0 deletions packages/@eep-dev/signer/src/asymmetric.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { describe, expect, it } from 'vitest';
import {
generateSigningKeyPair,
signEd25519,
verifyEd25519,
toJwks,
EEPAsymmetricError,
PRIVATE_KEY_PREFIX,
PUBLIC_KEY_PREFIX,
ED25519_SIGNATURE_VERSION,
} from './asymmetric.js';
import { EEPSigner } from './index.js';

const WEBHOOK_ID = 'msg_01HN3QK7GX';
const TS_ = '1708123456';
const BODY = '{"specversion":"1.0","id":"evt-1"}';

describe('Ed25519 delivery signatures (§5.3.1)', () => {
it('generates a key pair in the Standard Webhooks encoding', () => {
const { privateKey, publicKey } = generateSigningKeyPair();
expect(privateKey.startsWith(PRIVATE_KEY_PREFIX)).toBe(true);
expect(publicKey.startsWith(PUBLIC_KEY_PREFIX)).toBe(true);
// Raw Ed25519 keys are 32 bytes.
expect(Buffer.from(privateKey.slice(PRIVATE_KEY_PREFIX.length), 'base64')).toHaveLength(32);
expect(Buffer.from(publicKey.slice(PUBLIC_KEY_PREFIX.length), 'base64')).toHaveLength(32);
});

it('round-trips sign and verify', () => {
const { privateKey, publicKey } = generateSigningKeyPair();
const sig = signEd25519(privateKey, WEBHOOK_ID, TS_, BODY);
expect(sig.startsWith(`${ED25519_SIGNATURE_VERSION},`)).toBe(true);
expect(verifyEd25519(publicKey, WEBHOOK_ID, TS_, sig, BODY).valid).toBe(true);
});

// The whole point: only the holder of the private key can produce a valid
// signature, so a subscriber cannot forge events attributed to the
// publisher the way it can under a shared HMAC secret.
it('does not verify under a different key pair', () => {
const a = generateSigningKeyPair();
const b = generateSigningKeyPair();
const sig = signEd25519(a.privateKey, WEBHOOK_ID, TS_, BODY);
expect(verifyEd25519(b.publicKey, WEBHOOK_ID, TS_, sig, BODY).valid).toBe(false);
});

it.each([
['a tampered body', () => ({ body: `${BODY} ` })],
['a different webhook id', () => ({ webhookId: 'msg_other' })],
['a different timestamp', () => ({ timestamp: '1708123999' })],
])('rejects %s', (_label, mutate) => {
const { privateKey, publicKey } = generateSigningKeyPair();
const sig = signEd25519(privateKey, WEBHOOK_ID, TS_, BODY);
const m = mutate() as { body?: string; webhookId?: string; timestamp?: string };
const result = verifyEd25519(
publicKey,
m.webhookId ?? WEBHOOK_ID,
m.timestamp ?? TS_,
sig,
m.body ?? BODY
);
expect(result.valid).toBe(false);
});

describe('key ids and rotation', () => {
it('carries a kid through the signature token', () => {
const { privateKey, publicKey } = generateSigningKeyPair();
const sig = signEd25519(privateKey, WEBHOOK_ID, TS_, BODY, 'key-2026-08');
const result = verifyEd25519(publicKey, WEBHOOK_ID, TS_, sig, BODY);
expect(result).toEqual({ valid: true, keyId: 'key-2026-08' });
});

// During rotation a publisher signs with both keys; a receiver holding
// either one must still verify.
it('verifies a dual-signed delivery with either key', () => {
const outgoing = generateSigningKeyPair();
const incoming = generateSigningKeyPair();
const header = [
signEd25519(outgoing.privateKey, WEBHOOK_ID, TS_, BODY, 'old'),
signEd25519(incoming.privateKey, WEBHOOK_ID, TS_, BODY, 'new'),
].join(' ');
expect(verifyEd25519(outgoing.publicKey, WEBHOOK_ID, TS_, header, BODY).valid).toBe(true);
expect(verifyEd25519(incoming.publicKey, WEBHOOK_ID, TS_, header, BODY).valid).toBe(true);
});

it('accepts a key set and picks the one that verifies', () => {
const wrong = generateSigningKeyPair();
const right = generateSigningKeyPair();
const sig = signEd25519(right.privateKey, WEBHOOK_ID, TS_, BODY);
expect(verifyEd25519([wrong.publicKey, right.publicKey], WEBHOOK_ID, TS_, sig, BODY).valid).toBe(true);
});

// One bad entry in a configured key set must not stop the good ones
// from being tried.
it('skips a malformed configured key rather than aborting', () => {
const { privateKey, publicKey } = generateSigningKeyPair();
const sig = signEd25519(privateKey, WEBHOOK_ID, TS_, BODY);
expect(verifyEd25519(['whpk_not-a-key', publicKey], WEBHOOK_ID, TS_, sig, BODY).valid).toBe(true);
});
});

describe('coexistence with HMAC', () => {
// A dual-signed delivery carries both schemes. Each verifier must
// ignore the other's token instead of failing on it.
const SECRET = 'this-is-a-test-secret-at-least-16';

it('ignores HMAC tokens when verifying Ed25519', () => {
const { privateKey, publicKey } = generateSigningKeyPair();
const header = [
new EEPSigner(SECRET).sign(WEBHOOK_ID, TS_, BODY),
signEd25519(privateKey, WEBHOOK_ID, TS_, BODY),
].join(' ');
expect(verifyEd25519(publicKey, WEBHOOK_ID, TS_, header, BODY).valid).toBe(true);
});

it('returns false when only HMAC tokens are present', () => {
const { publicKey } = generateSigningKeyPair();
const header = new EEPSigner(SECRET).sign(WEBHOOK_ID, TS_, BODY);
expect(verifyEd25519(publicKey, WEBHOOK_ID, TS_, header, BODY).valid).toBe(false);
});
});

describe('hostile input', () => {
it.each([
['an empty header', ''],
['a truncated signature', 'v1a,AAAA'],
['a non-base64 signature', 'v1a,!!!!'],
['an unknown scheme', 'v9,AAAA'],
])('returns false, and does not throw, for %s', (_label, header) => {
const { publicKey } = generateSigningKeyPair();
expect(() => verifyEd25519(publicKey, WEBHOOK_ID, TS_, header, BODY)).not.toThrow();
expect(verifyEd25519(publicKey, WEBHOOK_ID, TS_, header, BODY).valid).toBe(false);
});

it('returns false when no keys are configured', () => {
const { privateKey } = generateSigningKeyPair();
const sig = signEd25519(privateKey, WEBHOOK_ID, TS_, BODY);
expect(verifyEd25519([], WEBHOOK_ID, TS_, sig, BODY).valid).toBe(false);
});

it.each([
['no prefix', 'AAAA'],
['wrong prefix', 'whpk_AAAA'],
])('throws a typed error when signing with %s', (_label, key) => {
expect(() => signEd25519(key, WEBHOOK_ID, TS_, BODY)).toThrow(EEPAsymmetricError);
});
});

describe('JWKS rendering', () => {
it('renders a publishable key set', () => {
const { publicKey } = generateSigningKeyPair();
const jwks = toJwks([{ publicKey, keyId: 'key-1' }]);
expect(jwks.keys).toHaveLength(1);
expect(jwks.keys[0]).toMatchObject({ kty: 'OKP', crv: 'Ed25519', use: 'sig', alg: 'EdDSA', kid: 'key-1' });
// RFC 8037: JWK `x` is base64url without padding.
expect(jwks.keys[0]!.x).not.toContain('=');
expect(jwks.keys[0]!.x).not.toContain('+');
expect(jwks.keys[0]!.x).not.toContain('/');
});

it('renders several keys so a rotation is visible to subscribers', () => {
const a = generateSigningKeyPair();
const b = generateSigningKeyPair();
const jwks = toJwks([
{ publicKey: a.publicKey, keyId: 'old' },
{ publicKey: b.publicKey, keyId: 'new' },
]);
expect(jwks.keys.map((k) => k.kid)).toEqual(['old', 'new']);
});

it('omits kid when none was supplied', () => {
const { publicKey } = generateSigningKeyPair();
expect(toJwks([{ publicKey }]).keys[0]).not.toHaveProperty('kid');
});
});
});
Loading