Skip to content

feat(signer,spec): Ed25519 delivery signatures with JWKS key publication - #108

Open
ucekmez wants to merge 1 commit into
feat/subscription-content-filtersfrom
feat/asymmetric-delivery-signatures
Open

feat(signer,spec): Ed25519 delivery signatures with JWKS key publication#108
ucekmez wants to merge 1 commit into
feat/subscription-content-filtersfrom
feat/asymmetric-delivery-signatures

Conversation

@ucekmez

@ucekmez ucekmez commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Summary

PR 16 of a stacked series. Base is #107. Not for merge without review.

Delivery signing is 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.

Four consequences, 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. No key id, no published key set; 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 had no asymmetric option at all.

Standard Webhooks — which @eep-dev/signer's own header comment cites — already specifies Ed25519 with whsk_/whpk_ prefixes and a published key set. EEP implemented the symmetric half and inherited the claim for both.

What changed

@eep-dev/signergenerateSigningKeyPair, signEd25519, verifyEd25519, toJwks.

The signed content is unchanged ({webhook-id}.{webhook-timestamp}.{raw-body}), so §5.3's replay rules apply identically and a dual-signing publisher builds the payload once.

webhook-signature: v1,BASE64_HMAC v1a,key-2026-08:BASE64_ED25519

Verifiers for one scheme skip the other scheme's tokens rather than failing on them. Without that, dual-signing — the entire migration path — breaks both verifiers. There are tests on both sides of it.

New §5.3.1, including 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. That interaction is easy to miss and produces failures hours after the rotation looks complete.

signing_jwks_url on the manifest, REQUIRED when signing asymmetrically.

WebhookDispatcher dual-signs when a key is configured; byte-identical behaviour otherwise.

Scope

  • Spec / schema only
  • TypeScript package(s)
  • Python package(s)
  • Tests / CI
  • Docs / examples

Checklist

  • I read CONTRIBUTING.md and CODE_OF_CONDUCT.md.
  • Tests added or updated where appropriate.
  • Breaking change? No. Ed25519 is opt-in; a dispatcher without signingPrivateKey produces exactly the header it did before. HMAC remains fully supported.
  • Documentation updated for user-visible behavior.

Verification

Suite Result
@eep-dev/signer 76 passed (was 54)
@eep-dev/middleware 233 passed (was 229)
tests/ 199 passed
tests/cross-impl/ 56 passed, 3 skipped (against a running gate publisher)
codegen-schema-types --check no drift
check-draft-schema-parity

Notes for reviewers

One rule stated explicitly because it is a tempting shortcut: publishers MUST NOT reuse a delivery_secret as an Ed25519 key, or 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 — deriving would silently hand every subscriber the publisher's signing identity.

Robustness choices worth a look:

  • A malformed key in a configured key set is skipped, not fatal — one bad entry must not stop the good ones being tried.
  • Every hostile-input path returns false rather than throwing; signature bytes are attacker-controlled. Tested for empty, truncated, non-base64, and unknown-scheme headers.
  • Signature length is checked before reaching the crypto layer.

Not done here, flagged rather than skipped:

  • Python eep-signer parity — the TS and Python signers now differ in capability.
  • No /.well-known/jwks.json route in the middleware. toJwks() renders the document, but wiring a route needs a key-management story (where the private key lives, how rotation is triggered) that I did not want to invent inside this PR.
  • §11.7's PQ-hybrid algorithms are still gate-proof-only. This PR closes the classical half of the gap; hybrid delivery signatures are a larger change.

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 <ucekmez@gmail.com>
Copilot AI lite review requested due to automatic review settings August 26, 2026 19:43

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants