From b4e7c566cc209e65f2edbca26215b0f026e47511 Mon Sep 17 00:00:00 2001 From: Ugur Cekmez Date: Wed, 26 Aug 2026 22:43:22 +0300 Subject: [PATCH] feat(signer,spec): Ed25519 delivery signatures with JWKS key publication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delivery signing was HMAC-SHA256 only: publisher and subscriber share one `delivery_secret`, so a signature proves that *someone holding that secret* sent the event — and the subscriber is one of them. 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: no key id, no published key set. 4. PQC readiness stopped at the gate. §11.7 defines algorithm negotiation including PQ-hybrid signatures — but only for agent→publisher gate proofs. The path carrying every event had no asymmetric option at all. Standard Webhooks, which this package claims alignment with, already specifies Ed25519 with `whsk_`/`whpk_` prefixes and a published key set. EEP implemented the symmetric half and inherited the claim for both. - `@eep-dev/signer` gains `generateSigningKeyPair`, `signEd25519`, `verifyEd25519` and `toJwks`. The signed content is unchanged, so §5.3's replay rules apply identically and a dual-signing publisher builds the payload once. - Signature tokens are `v1a,[kid:]base64`. Verifiers for one scheme skip the other scheme's tokens rather than failing on them — otherwise dual-signing, which is the entire migration path, breaks both verifiers. - A malformed key in a configured key set is skipped, not fatal, so one bad entry cannot stop the good ones from being tried. All hostile input returns false rather than throwing. - New §5.3.1, plus a rotation rule with a concrete bound: keep the outgoing key published for at least the §5.4 retry span (~6 hours), so a delivery signed before a rotation still verifies when its last retry lands. - `signing_jwks_url` on the manifest, REQUIRED when signing asymmetrically. - `WebhookDispatcher` dual-signs when a key is configured; unchanged otherwise. Explicitly prohibited: reusing a `delivery_secret` as an Ed25519 key or deriving one from the other. The security properties differ precisely because the subscriber may hold the symmetric key and must never hold the private one. Refs: EEP audit 2026-08 findings B3, O8 Signed-off-by: Ugur Cekmez --- docs/current/SPECIFICATION.md | 54 +++++ .../src/dispatcher/webhook-dispatcher.test.ts | 78 +++++- .../src/dispatcher/webhook-dispatcher.ts | 28 ++- .../@eep-dev/signer/src/asymmetric.test.ts | 174 +++++++++++++ packages/@eep-dev/signer/src/asymmetric.ts | 228 ++++++++++++++++++ packages/@eep-dev/signer/src/index.ts | 21 ++ schemas/v0.1/eep-manifest.json | 8 + tests/types/eep-schemas.d.ts | 4 + 8 files changed, 593 insertions(+), 2 deletions(-) create mode 100644 packages/@eep-dev/signer/src/asymmetric.test.ts create mode 100644 packages/@eep-dev/signer/src/asymmetric.ts diff --git a/docs/current/SPECIFICATION.md b/docs/current/SPECIFICATION.md index a6296f9..693ea3d 100644 --- a/docs/current/SPECIFICATION.md +++ b/docs/current/SPECIFICATION.md @@ -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: diff --git a/packages/@eep-dev/middleware/src/dispatcher/webhook-dispatcher.test.ts b/packages/@eep-dev/middleware/src/dispatcher/webhook-dispatcher.test.ts index 432d244..6d652dc 100644 --- a/packages/@eep-dev/middleware/src/dispatcher/webhook-dispatcher.test.ts +++ b/packages/@eep-dev/middleware/src/dispatcher/webhook-dispatcher.test.ts @@ -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"; @@ -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 diff --git a/packages/@eep-dev/middleware/src/dispatcher/webhook-dispatcher.ts b/packages/@eep-dev/middleware/src/dispatcher/webhook-dispatcher.ts index 85fc4cc..bc104b0 100644 --- a/packages/@eep-dev/middleware/src/dispatcher/webhook-dispatcher.ts +++ b/packages/@eep-dev/middleware/src/dispatcher/webhook-dispatcher.ts @@ -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"; @@ -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 }) => { @@ -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) { @@ -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; } /** @@ -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 }; } diff --git a/packages/@eep-dev/signer/src/asymmetric.test.ts b/packages/@eep-dev/signer/src/asymmetric.test.ts new file mode 100644 index 0000000..d5c9826 --- /dev/null +++ b/packages/@eep-dev/signer/src/asymmetric.test.ts @@ -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'); + }); + }); +}); diff --git a/packages/@eep-dev/signer/src/asymmetric.ts b/packages/@eep-dev/signer/src/asymmetric.ts new file mode 100644 index 0000000..b4eb533 --- /dev/null +++ b/packages/@eep-dev/signer/src/asymmetric.ts @@ -0,0 +1,228 @@ +/** + * Ed25519 asymmetric webhook signing (SPECIFICATION.md §5.3.1). + * + * EEP delivery signatures were HMAC-SHA256 only: publisher and subscriber + * share one secret. Four consequences followed. + * + * 1. **No non-repudiation.** The subscriber holds the same key the publisher + * signs with, so it can forge any event and attribute it to the publisher. + * §15 defines a commerce state machine and §16 signed audit trails; both + * ride on this signature. + * 2. **No third-party verification.** A regulator or counterparty cannot + * verify an audit entry without being handed the subscriber's signing + * secret — which would let them forge entries too. + * 3. **No key rotation surface.** Rotation is manual per-subscription secret + * juggling, with no `kid` and no published key set. + * 4. **PQC readiness stopped at the gate.** §11.7 defines algorithm + * negotiation including PQ-hybrid signatures, but only for agent→publisher + * *gate proofs*. The publisher→subscriber path — which carries every event + * the protocol exists to deliver — had no asymmetric option at all. + * + * Standard Webhooks, which `@eep-dev/signer` claims alignment with, already + * specifies Ed25519 with `whsk_`/`whpk_` key prefixes and a published key set. + * EEP implemented the symmetric half and inherited the claim for both. + */ +import { + createPrivateKey, + createPublicKey, + generateKeyPairSync, + sign as nodeSign, + verify as nodeVerify, + type KeyObject, +} from 'node:crypto'; + +/** Standard Webhooks key prefixes. */ +export const PRIVATE_KEY_PREFIX = 'whsk_'; +export const PUBLIC_KEY_PREFIX = 'whpk_'; + +/** Signature scheme identifier carried in the `webhook-signature` header. */ +export const ED25519_SIGNATURE_VERSION = 'v1a'; + +export class EEPAsymmetricError extends Error { + constructor(message: string) { + super(`EEPAsymmetricError: ${message}`); + this.name = 'EEPAsymmetricError'; + } +} + +export interface EEPKeyPair { + /** Base64 raw private key, `whsk_`-prefixed. */ + privateKey: string; + /** Base64 raw public key, `whpk_`-prefixed. */ + publicKey: string; +} + +/** DER prefixes for raw Ed25519 keys, so we can move between raw and KeyObject. */ +const PKCS8_ED25519_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex'); +const SPKI_ED25519_PREFIX = Buffer.from('302a300506032b6570032100', 'hex'); + +/** Generate an Ed25519 key pair in the Standard Webhooks prefixed encoding. */ +export function generateSigningKeyPair(): EEPKeyPair { + const { privateKey, publicKey } = generateKeyPairSync('ed25519'); + const rawPrivate = privateKey.export({ format: 'der', type: 'pkcs8' }).subarray(PKCS8_ED25519_PREFIX.length); + const rawPublic = publicKey.export({ format: 'der', type: 'spki' }).subarray(SPKI_ED25519_PREFIX.length); + return { + privateKey: `${PRIVATE_KEY_PREFIX}${rawPrivate.toString('base64')}`, + publicKey: `${PUBLIC_KEY_PREFIX}${rawPublic.toString('base64')}`, + }; +} + +function decodeKey(value: string, prefix: string, label: string): Buffer { + if (typeof value !== 'string' || !value.startsWith(prefix)) { + throw new EEPAsymmetricError(`${label} must be a base64 string prefixed with '${prefix}'`); + } + const raw = Buffer.from(value.slice(prefix.length), 'base64'); + if (raw.length !== 32) { + throw new EEPAsymmetricError(`${label} must decode to 32 bytes, got ${raw.length}`); + } + return raw; +} + +function toPrivateKeyObject(privateKey: string): KeyObject { + const raw = decodeKey(privateKey, PRIVATE_KEY_PREFIX, 'private key'); + return createPrivateKey({ + key: Buffer.concat([PKCS8_ED25519_PREFIX, raw]), + format: 'der', + type: 'pkcs8', + }); +} + +function toPublicKeyObject(publicKey: string): KeyObject { + const raw = decodeKey(publicKey, PUBLIC_KEY_PREFIX, 'public key'); + return createPublicKey({ + key: Buffer.concat([SPKI_ED25519_PREFIX, raw]), + format: 'der', + type: 'spki', + }); +} + +/** + * Sign a delivery with Ed25519. + * + * The signed content is identical to the HMAC scheme — + * `{webhook-id}.{webhook-timestamp}.{raw-body}` — so the two schemes differ + * only in the key and the version tag. That keeps a dual-signing publisher + * from having to build the payload twice, and keeps §5.3's replay rules + * unchanged. + */ +export function signEd25519( + privateKey: string, + webhookId: string, + timestamp: string, + rawBody: string, + keyId?: string +): string { + const key = toPrivateKeyObject(privateKey); + const signedContent = `${webhookId}.${timestamp}.${rawBody}`; + const signature = nodeSign(null, Buffer.from(signedContent, 'utf8'), key).toString('base64'); + // `kid` rides in the token so a receiver can select the right key from a + // key set without trial-verifying against every published key. + return keyId ? `${ED25519_SIGNATURE_VERSION},${keyId}:${signature}` : `${ED25519_SIGNATURE_VERSION},${signature}`; +} + +export interface Ed25519VerifyResult { + valid: boolean; + /** Key id from the matching token, when one was present. */ + keyId?: string; +} + +/** + * Verify an Ed25519 delivery signature against one or more public keys. + * + * Accepts the space-delimited multi-signature header form, so a publisher can + * sign with the outgoing and incoming key during a rotation and a receiver + * that holds either still verifies. Tokens for other schemes (`v1,` HMAC) are + * skipped rather than treated as failures — a dual-signed delivery carries + * both, and rejecting it because one scheme is unrecognised would defeat the + * point of dual-signing. + */ +export function verifyEd25519( + publicKeys: string | string[], + webhookId: string, + timestamp: string, + signatureHeader: string, + rawBody: string +): Ed25519VerifyResult { + if (typeof signatureHeader !== 'string' || signatureHeader.length === 0) { + return { valid: false }; + } + const keys = (Array.isArray(publicKeys) ? publicKeys : [publicKeys]).filter( + (k) => typeof k === 'string' && k.length > 0 + ); + if (keys.length === 0) return { valid: false }; + + const signedContent = Buffer.from(`${webhookId}.${timestamp}.${rawBody}`, 'utf8'); + + for (const token of signatureHeader.split(' ')) { + if (!token.startsWith(`${ED25519_SIGNATURE_VERSION},`)) continue; + const payload = token.slice(ED25519_SIGNATURE_VERSION.length + 1); + const separator = payload.indexOf(':'); + const keyId = separator === -1 ? undefined : payload.slice(0, separator); + const encoded = separator === -1 ? payload : payload.slice(separator + 1); + + let signature: Buffer; + try { + signature = Buffer.from(encoded, 'base64'); + } catch { + continue; + } + // Ed25519 signatures are always 64 bytes; anything else cannot verify + // and is not worth handing to the crypto layer. + if (signature.length !== 64) continue; + + for (const publicKey of keys) { + let keyObject: KeyObject; + try { + keyObject = toPublicKeyObject(publicKey); + } catch { + // A malformed configured key must not abort verification + // against the remaining well-formed ones. + continue; + } + try { + if (nodeVerify(null, signedContent, keyObject, signature)) { + return keyId ? { valid: true, keyId } : { valid: true }; + } + } catch { + // Treat a crypto-layer rejection as a failed candidate, never + // as an exception escaping to the caller: the input is + // attacker-controlled. + } + } + } + + return { valid: false }; +} + +export interface JwksKey { + kty: 'OKP'; + crv: 'Ed25519'; + x: string; + kid?: string; + use: 'sig'; + alg: 'EdDSA'; +} + +/** + * Render public keys as a JWKS document for `/.well-known/jwks.json`. + * + * Publishing the key set is what makes rotation an operation a subscriber can + * follow without coordination: it re-reads the document and finds the new key + * already there. + */ +export function toJwks(keys: Array<{ publicKey: string; keyId?: string }>): { keys: JwksKey[] } { + return { + keys: keys.map(({ publicKey, keyId }) => { + const raw = decodeKey(publicKey, PUBLIC_KEY_PREFIX, 'public key'); + const jwk: JwksKey = { + kty: 'OKP', + crv: 'Ed25519', + // JWK uses base64url without padding (RFC 8037). + x: raw.toString('base64url'), + use: 'sig', + alg: 'EdDSA', + }; + return keyId ? { ...jwk, kid: keyId } : jwk; + }), + }; +} diff --git a/packages/@eep-dev/signer/src/index.ts b/packages/@eep-dev/signer/src/index.ts index b1ab7d2..9412891 100644 --- a/packages/@eep-dev/signer/src/index.ts +++ b/packages/@eep-dev/signer/src/index.ts @@ -150,3 +150,24 @@ function getHeader(headers: Record, key: if (!value) return null; return Array.isArray(value) ? value[0] : value; } + +// ── Asymmetric signing (SPECIFICATION.md §5.3.1) ───────────────────────────── +// +// HMAC gives integrity but not attribution: publisher and subscriber share the +// key, so a signature proves only that *someone holding the secret* sent the +// event. Ed25519 makes deliveries verifiable by third parties and +// non-repudiable, which matters for the commerce (§15) and audit (§16) events +// that ride on this signature. +export { + generateSigningKeyPair, + signEd25519, + verifyEd25519, + toJwks, + EEPAsymmetricError, + PRIVATE_KEY_PREFIX, + PUBLIC_KEY_PREFIX, + ED25519_SIGNATURE_VERSION, + type EEPKeyPair, + type Ed25519VerifyResult, + type JwksKey, +} from './asymmetric.js'; diff --git a/schemas/v0.1/eep-manifest.json b/schemas/v0.1/eep-manifest.json index c365322..2f740df 100644 --- a/schemas/v0.1/eep-manifest.json +++ b/schemas/v0.1/eep-manifest.json @@ -229,6 +229,14 @@ ] ] }, + "signing_jwks_url": { + "type": "string", + "description": "Absolute https URL of the publisher's JWKS document, carrying the public keys used for asymmetric delivery signatures (SPECIFICATION.md \u00a75.3.1). REQUIRED when the publisher signs deliveries with Ed25519. Publishing the key set is what makes rotation something a subscriber can follow without coordination.", + "format": "uri", + "examples": [ + "https://api.example.com/.well-known/jwks.json" + ] + }, "x402_enabled": { "type": "boolean", "description": "Whether this entity accepts x402 protocol payments (ref27) Absent means false: the publisher does not advertise HTTP 402 payment gating.", diff --git a/tests/types/eep-schemas.d.ts b/tests/types/eep-schemas.d.ts index 811e9d7..3faf703 100644 --- a/tests/types/eep-schemas.d.ts +++ b/tests/types/eep-schemas.d.ts @@ -993,6 +993,10 @@ export interface EEPManifest { | 'hybrid-EdDSA-ML-DSA-87' )[] ]; + /** + * Absolute https URL of the publisher's JWKS document, carrying the public keys used for asymmetric delivery signatures (SPECIFICATION.md §5.3.1). REQUIRED when the publisher signs deliveries with Ed25519. Publishing the key set is what makes rotation something a subscriber can follow without coordination. + */ + signing_jwks_url?: string; /** * Whether this entity accepts x402 protocol payments (ref27) Absent means false: the publisher does not advertise HTTP 402 payment gating. */