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
6 changes: 5 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ on:
push:
branches: [main]
pull_request:
branches: [main]
# Every pull request is tested, not only those targeting `main`.
# Stacked PRs (where one change is based on the branch of another so the
# diffs stay reviewable) previously got no checks at all, which is
# precisely when a reviewer most wants them.
branches: ['**']

jobs:
dependency-policy:
Expand Down
41 changes: 33 additions & 8 deletions docs/current/SPECIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -361,30 +361,55 @@ The key is the `delivery_secret` established at subscription time.

**Receiving platforms MUST:**
1. Verify the signature using `crypto.timingSafeEqual()` (or equivalent constant-time comparison) to prevent timing attacks.
2. Reject requests where the `webhook-timestamp` is more than **60 seconds** in the past or future to prevent replay attacks.
3. Return HTTP `200` within 10 seconds, or the publisher will treat the delivery as failed.
2. Compare buffers of equal length only. `crypto.timingSafeEqual()` **throws** when its arguments differ in length, so implementations MUST check the length first and return a verification failure — never propagate the exception. A caller that omits this check turns an attacker-supplied truncated `webhook-signature` into an unhandled error rather than an authentication failure.
3. Accept a `webhook-signature` header carrying **multiple** space-delimited signatures (`v1,<sigA> v1,<sigB>`) and treat the request as verified if **any** of them matches. Publishers send more than one signature while rotating a `delivery_secret`; a receiver that parses only the first value rejects valid traffic for the duration of every rotation.
4. Reject requests where the `webhook-timestamp` is more than **60 seconds** in the past or future to prevent replay attacks.
5. Return HTTP `200` within 10 seconds, or the publisher will treat the delivery as failed.

**Example verification (Node.js):**
```typescript
import { createHmac, timingSafeEqual } from 'crypto';

const TOLERANCE_SECONDS = 60;

function verifyWebhook(
rawBody: string,
webhookId: string,
webhookTimestamp: string,
webhookSignature: string,
secret: string
): boolean {
// 1. Reject stale or future-dated deliveries (replay protection).
const timestamp = Number.parseInt(webhookTimestamp, 10);
if (!Number.isFinite(timestamp)) return false;
const age = Math.floor(Date.now() / 1000) - timestamp;
if (Math.abs(age) > TOLERANCE_SECONDS) return false;

// 2. Compute the expected signature over the RAW body bytes.
const signedContent = `${webhookId}.${webhookTimestamp}.${rawBody}`;
const expected = createHmac('sha256', secret)
.update(signedContent)
.digest('base64');

const incoming = webhookSignature.replace('v1,', '');
return timingSafeEqual(Buffer.from(expected), Buffer.from(incoming));
const expected = Buffer.from(
`v1,${createHmac('sha256', secret).update(signedContent, 'utf8').digest('base64')}`
);

// 3. Compare against every offered signature. The length guard is
// required: timingSafeEqual throws a RangeError on a length
// mismatch, which would surface as a 500 instead of a 401.
for (const candidate of webhookSignature.split(' ')) {
const incoming = Buffer.from(candidate);
if (incoming.length === expected.length && timingSafeEqual(incoming, expected)) {
return true;
}
}

return false;
}
```

> The reference implementation of this algorithm is
> [`@eep-dev/signer`](https://www.npmjs.com/package/@eep-dev/signer) (`EEPSigner.verify`)
> and its Python sibling `eep-signer`. Prefer importing it over
> re-implementing the comparison by hand.

### 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
21 changes: 21 additions & 0 deletions packages/@eep-dev/compliance-cli/src/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -440,5 +440,26 @@ describe('@eep-dev/compliance-cli helpers', () => {
expect(result.valid).toBe(fx.expected.valid);
expect(result.reason).toBe('ok_via_multi_signature');
});

// A truncated token is attacker-controlled input whose only purpose
// is to reach `timingSafeEqual` with mismatched buffer lengths. The
// verifier MUST return a failure rather than throw — a propagated
// RangeError surfaces as HTTP 500 instead of 401. See
// SPECIFICATION.md §5.3, receiving-platform requirement 2.
it('matches fixture: signature/truncated-signature → invalid, and does not throw', () => {
const fx = loadFixture('truncated-signature');
const call = () =>
verifyWebhookSignature({
webhookId: fx.headers['webhook-id'],
timestamp: fx.headers['webhook-timestamp'],
rawBody: fx.body,
secret: fx.secret,
signatureHeader: fx.headers['webhook-signature'],
});
expect(call).not.toThrow();
const result = call();
expect(result.valid).toBe(fx.expected.valid);
expect(result.reason).toBe('signature_mismatch');
});
});
});
26 changes: 26 additions & 0 deletions packages/@eep-dev/signer/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,5 +221,31 @@ describe('@eep-dev/signer', () => {
const shortSig = 'v1,YQ==';
expect(signer.verify(WEBHOOK_ID, now, shortSig, BODY)).toBe(false);
});

// Regression guard for SPECIFICATION.md §5.3 requirement 2: a
// truncated prefix of the *correct* signature is the input that
// makes an unguarded `timingSafeEqual` raise RangeError. Verifying
// it MUST return false, never throw — otherwise attacker-controlled
// bytes turn a 401 into a 500. Mirrors the conformance fixture
// `tests/conformance-fixtures/signature/truncated-signature`.
it('should return false, not throw, for a truncated valid signature', () => {
const signer = new EEPSigner(SECRET);
const now = Math.floor(Date.now() / 1000).toString();
const real = signer.sign(WEBHOOK_ID, now, BODY);
for (const cut of [4, 10, 20, real.length - 1]) {
const truncated = real.slice(0, cut);
expect(() => signer.verify(WEBHOOK_ID, now, truncated, BODY)).not.toThrow();
expect(signer.verify(WEBHOOK_ID, now, truncated, BODY)).toBe(false);
}
});

// Same guarantee for a signature that is LONGER than expected.
it('should return false, not throw, for an over-long signature', () => {
const signer = new EEPSigner(SECRET);
const now = Math.floor(Date.now() / 1000).toString();
const padded = signer.sign(WEBHOOK_ID, now, BODY) + 'AAAA';
expect(() => signer.verify(WEBHOOK_ID, now, padded, BODY)).not.toThrow();
expect(signer.verify(WEBHOOK_ID, now, padded, BODY)).toBe(false);
});
});
});
36 changes: 36 additions & 0 deletions packages/eep-signer-python/tests/test_signer.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,39 @@ def test_invalid_secret_length_returns_false(self):
"webhook-signature": "v1,fake",
}
assert verify_eep_webhook(BODY, headers, "short") is False


class TestTruncatedSignature:
"""SPECIFICATION.md §5.3 requirement 2: a length-mismatched signature
MUST produce a verification failure, never an exception.

A truncated prefix of the *correct* signature is the attacker-controlled
input that makes an unguarded constant-time comparison raise instead of
returning False — turning an authentication failure (401) into a server
error (500). Mirrors the conformance fixture
``tests/conformance-fixtures/signature/truncated-signature``.
"""

def test_truncated_signature_returns_false(self):
signer = EEPSigner(SECRET)
ts = str(int(time.time()))
real = signer.sign(WEBHOOK_ID, ts, BODY)
for cut in (4, 10, 20, len(real) - 1):
assert signer.verify(WEBHOOK_ID, ts, real[:cut], BODY) is False

def test_overlong_signature_returns_false(self):
signer = EEPSigner(SECRET)
ts = str(int(time.time()))
padded = signer.sign(WEBHOOK_ID, ts, BODY) + "AAAA"
assert signer.verify(WEBHOOK_ID, ts, padded, BODY) is False

def test_truncated_signature_via_convenience_helper(self):
signer = EEPSigner(SECRET)
ts = str(int(time.time()))
real = signer.sign(WEBHOOK_ID, ts, BODY)
headers = {
"webhook-id": WEBHOOK_ID,
"webhook-timestamp": ts,
"webhook-signature": real[:20],
}
assert verify_eep_webhook(BODY, headers, SECRET) is False
8 changes: 8 additions & 0 deletions tests/conformance-fixtures.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,14 @@ describe.each(signedBundles)('signed-bundle fixture: $id', (entry) => {
// Recorded sig was produced with a different secret. The
// recomputation with the verifier's secret MUST NOT match.
expect(expectedSig).not.toBe(recomputed);
} else if (entry.id === 'signature-truncated-signature') {
// The recorded token is a strict prefix of the real signature.
// It MUST be shorter, because the whole point of the fixture is
// to exercise the length guard that keeps timingSafeEqual from
// throwing RangeError on attacker-controlled input.
expect(expectedSig).not.toBe(recomputed);
expect(expectedSig.length).toBeLessThan(recomputed.length);
expect(recomputed.startsWith(expectedSig)).toBe(true);
} else if (entry.id === 'signature-multi-header') {
// The header is "FAKE REAL". The real one MUST match recompute.
const tokens = expectedSig.split(' ');
Expand Down
41 changes: 25 additions & 16 deletions tests/conformance-fixtures/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"id": "discovery-well-known-eep-valid",
"category": "discovery",
"tier": "Core",
"spec_section": "§4 Discovery",
"spec_section": "\u00a74 Discovery",
"schema": "schemas/v0.1/eep-manifest.json",
"input": "discovery/well-known-eep-valid.input.json",
"expected": "discovery/well-known-eep-valid.expected.json",
Expand All @@ -20,7 +20,7 @@
"id": "discovery-well-known-eep-missing-version",
"category": "discovery",
"tier": "Core",
"spec_section": "§4 Discovery",
"spec_section": "\u00a74 Discovery",
"schema": "schemas/v0.1/eep-manifest.json",
"input": "discovery/well-known-eep-missing-version.input.json",
"expected": "discovery/well-known-eep-missing-version.expected.json",
Expand All @@ -31,7 +31,7 @@
"id": "envelope-valid-cloudevents",
"category": "envelope",
"tier": "Core",
"spec_section": "§5 Event envelope",
"spec_section": "\u00a75 Event envelope",
"schema": "schemas/v0.1/event.envelope.json",
"input": "envelope/valid-cloudevents.input.json",
"expected": "envelope/valid-cloudevents.expected.json",
Expand All @@ -42,7 +42,7 @@
"id": "envelope-invalid-missing-source",
"category": "envelope",
"tier": "Core",
"spec_section": "§5 Event envelope",
"spec_section": "\u00a75 Event envelope",
"schema": "schemas/v0.1/event.envelope.json",
"input": "envelope/invalid-missing-source.input.json",
"expected": "envelope/invalid-missing-source.expected.json",
Expand All @@ -53,7 +53,7 @@
"id": "signature-valid-fresh",
"category": "signature",
"tier": "Standard",
"spec_section": "§5.3 Signing / Standard Webhooks",
"spec_section": "\u00a75.3 Signing / Standard Webhooks",
"path": "signature/valid-fresh-signature",
"shape": "signed-bundle",
"asserts_valid": true
Expand All @@ -62,7 +62,7 @@
"id": "signature-expired-timestamp",
"category": "signature",
"tier": "Standard",
"spec_section": "§5.3 Signing / Replay window",
"spec_section": "\u00a75.3 Signing / Replay window",
"path": "signature/expired-timestamp",
"shape": "signed-bundle",
"asserts_valid": false
Expand All @@ -71,7 +71,7 @@
"id": "signature-wrong-secret",
"category": "signature",
"tier": "Standard",
"spec_section": "§5.3 Signing",
"spec_section": "\u00a75.3 Signing",
"path": "signature/wrong-secret",
"shape": "signed-bundle",
"asserts_valid": false
Expand All @@ -80,7 +80,7 @@
"id": "signature-short-secret-rejected",
"category": "signature",
"tier": "Standard",
"spec_section": "§5.3 Signing secret minimum length",
"spec_section": "\u00a75.3 Signing \u2014 secret minimum length",
"path": "signature/short-secret-rejected",
"shape": "signed-bundle",
"asserts_valid": false
Expand All @@ -89,16 +89,25 @@
"id": "signature-multi-header",
"category": "signature",
"tier": "Standard",
"spec_section": "§5.3 Signing multi-value header",
"spec_section": "\u00a75.3 Signing \u2014 multi-value header",
"path": "signature/multi-signature-header",
"shape": "signed-bundle",
"asserts_valid": true
},
{
"id": "signature-truncated-signature",
"category": "signature",
"tier": "Standard",
"spec_section": "\u00a75.3 Signing / constant-time comparison",
"path": "signature/truncated-signature",
"shape": "signed-bundle",
"asserts_valid": false
},
{
"id": "gates-402-payment-required",
"category": "gates",
"tier": "Standard",
"spec_section": "§7 Gates and access control",
"spec_section": "\u00a77 Gates and access control",
"schema": "schemas/v0.1/gate.402-response.json",
"input": "gates/402-payment-required.input.json",
"expected": "gates/402-payment-required.expected.json",
Expand All @@ -109,7 +118,7 @@
"id": "gates-403-tier-mismatch",
"category": "gates",
"tier": "Standard",
"spec_section": "§7 Gates and access control",
"spec_section": "\u00a77 Gates and access control",
"schema": "schemas/v0.1/gate.403-response.json",
"input": "gates/403-tier-mismatch.input.json",
"expected": "gates/403-tier-mismatch.expected.json",
Expand All @@ -120,7 +129,7 @@
"id": "gates-429-rate-limited",
"category": "gates",
"tier": "Core",
"spec_section": "§9 Rate limiting",
"spec_section": "\u00a79 Rate limiting",
"schema": "schemas/v0.1/gate.429-response.json",
"input": "gates/429-rate-limited.input.json",
"expected": "gates/429-rate-limited.expected.json",
Expand All @@ -131,7 +140,7 @@
"id": "gates-451-unavailable-for-legal-reasons",
"category": "gates",
"tier": "Standard",
"spec_section": "§7 Gates / legal restrictions",
"spec_section": "\u00a77 Gates / legal restrictions",
"schema": "schemas/v0.1/gate.451-response.json",
"input": "gates/451-unavailable-for-legal-reasons.input.json",
"expected": "gates/451-unavailable-for-legal-reasons.expected.json",
Expand All @@ -142,7 +151,7 @@
"id": "subscription-valid-webhook-request",
"category": "subscription",
"tier": "Core",
"spec_section": "§6 Subscription lifecycle",
"spec_section": "\u00a76 Subscription lifecycle",
"schema": "schemas/v0.1/subscription.request.json",
"input": "subscription/valid-webhook-request.input.json",
"expected": "subscription/valid-webhook-request.expected.json",
Expand All @@ -153,7 +162,7 @@
"id": "subscription-ssrf-private-ip-rejected",
"category": "subscription",
"tier": "Standard",
"spec_section": "§11 Security SSRF",
"spec_section": "\u00a711 Security \u2014 SSRF",
"input": "subscription/ssrf-private-ip-rejected.input.json",
"expected": "subscription/ssrf-private-ip-rejected.expected.json",
"shape": "json-pair",
Expand All @@ -163,7 +172,7 @@
"id": "discovery-crosswalk-host-bundle",
"category": "discovery",
"tier": "Informative",
"spec_section": "§12 Discovery (informative crosswalk)",
"spec_section": "\u00a712 Discovery (informative crosswalk)",
"path": "discovery/crosswalk-host",
"expected": "discovery/crosswalk-host/expected.json",
"shape": "bundle",
Expand Down
12 changes: 12 additions & 0 deletions tests/conformance-fixtures/signature/truncated-signature/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
Signature header carries a truncated token, shorter than a real
HMAC-SHA256/base64 signature. A verifier MUST return a verification
failure (valid=false).

It MUST NOT propagate an exception: Node's `crypto.timingSafeEqual()`
throws `RangeError` when its two buffers differ in length, so an
implementation that compares without a length guard turns this
attacker-controlled input into an unhandled error (HTTP 500) instead
of an authentication failure (HTTP 401). See SPECIFICATION.md §5.3,
receiving-platform requirement 2.

The full, correct signature for this bundle is `v1,fsFPyN/nacY2QTb0lQLeMbEr6YmtCCmdKBJf5hrH+q8=`.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"specversion":"1.0","type":"com.example.entity.updated","source":"did:web:test.eep.dev:u:alice","id":"evt-5","time":"2026-05-09T12:00:00Z","data":{}}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"valid": false,
"reason": "signature_length_mismatch_must_not_throw"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"webhook-id": "msg_01HN3QK7GXFIXTURE0005",
"webhook-timestamp": "1778414400",
"webhook-signature": "v1,fsFPyN/nacY2QTb0lQLe",
"content-type": "application/json"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
1778414400
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
super-secret-test-key-1234
8 changes: 8 additions & 0 deletions tests/cross-impl/test_conformance_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,14 @@ def test_signed_bundle_round_trips(entry: dict) -> None:

if entry["id"] == "signature-wrong-secret":
assert recorded_sig != recomputed
elif entry["id"] == "signature-truncated-signature":
# The recorded token is a strict prefix of the real signature. It
# MUST be shorter — the fixture exists to exercise the length guard
# that keeps a constant-time comparison from raising on
# attacker-controlled input.
assert recorded_sig != recomputed
assert len(recorded_sig) < len(recomputed)
assert recomputed.startswith(recorded_sig)
elif entry["id"] == "signature-multi-header":
tokens = recorded_sig.split(" ")
assert len(tokens) >= 2
Expand Down