EEP Security Model
This document explains the "why" and "how" behind every security decision in EEP.
1. Threat Model
| Threat | Classification | Mitigation |
|---|---|---|
| Spoofed events | Integrity | DID-based source verification + payload HMAC |
| Replay attacks | Freshness | Single-use nonce + 60-second timestamp validation window |
| SSRF via webhooks | Network | Strict IP allowlist validation before dispatch |
| DDoS amplification | Availability | WebSub intent verification before any delivery |
| Subscription harvesting | Privacy | Subscriptions are private; never exposed in public APIs |
| Timing attacks on HMAC | Cryptography | timingSafeEqual mandatory |
| Stale SSE data | Freshness | Mandatory Last-Event-ID replay mechanism |
| Double-spend / payment replay | Integrity | Payment hash consumed-ledger; each tx hash accepted once only |
| Key compromise / session hijack | Integrity | session.revoked event triggers immediate termination of all matching WebSocket sessions |
| Harvest-now decrypt-later (quantum) | Cryptography | PQC-ready: hybrid EdDSA + ML-DSA signatures; ML-KEM transport declared via pqc_ready manifest flag |
2. Webhook Security: HMAC-SHA256
EEP adopts the Standard Webhooks specification for payload signing.
Why HMAC-SHA256?
- Symmetric — Same secret to sign and verify. No PKI needed.
- Fast — Milliseconds overhead per dispatch.
- Universal — Every major language has built-in HMAC support.
Signing Algorithm
import { createHmac } from 'crypto';const content = `${webhookId}.${timestamp}.${rawBody}`;const signature = createHmac('sha256', secret).update(content).digest('base64');// Header: webhook-signature: v1,<signature>
Timing-Safe Comparison (Mandatory)
// ✅ CORRECT — timing-safeimport { timingSafeEqual } from 'crypto';const valid = timingSafeEqual(Buffer.from(expected, 'base64'),Buffer.from(received, 'base64'));// ❌ WRONG — timing leakconst valid = expected === received;
3. SSRF Prevention
If a subscriber registers an internal URL, the publisher could be tricked into making requests to private services.
Required Protections
- DNS resolution before connection — Resolve hostname to IP before connecting
- IP blocklist validation — Reject loopback, private networks, link-local, and invalid ranges
- HTTPS only — Only
https://URLs in production - No redirect following — Could bypass IP blocklist
SSRF Validation
import { validateSSRF } from '@eep-dev/validator';try {await validateSSRF(subscriberUrl);// Safe to connect} catch (err) {// SSRFError: blocked IP, private network, etc.console.error('SSRF blocked:', err.message);}
Blocked IP Ranges
| Range | Reason |
|---|---|
127.0.0.0/8 | Loopback (localhost) |
::1/128 | IPv6 loopback |
10.0.0.0/8 | Private network (RFC 1918) |
172.16.0.0/12 | Private network (RFC 1918) |
192.168.0.0/16 | Private network (RFC 1918) |
169.254.0.0/16 | Link-local (AWS metadata) |
fc00::/7 and fd00::/8 | IPv6 Unique Local Address (private) |
fe80::/10 | IPv6 link-local |
4. WebSub Intent Verification
Prevents DDoS amplification by verifying the subscriber controls the delivery URL.
// Publisher sends challengeconst challenge = crypto.randomBytes(16).toString('hex');const verifyUrl = new URL(deliveryUrl);verifyUrl.searchParams.set('hub.mode', 'subscribe');verifyUrl.searchParams.set('hub.topic', sourceDid);verifyUrl.searchParams.set('hub.challenge', challenge);const res = await fetch(verifyUrl.toString());const body = await res.text();if (res.status === 200 && body === challenge) {// Subscription activated}
5. SSE Stream Security
- SSE streams MUST require authentication (
Authorization: Beareror query parameter) - Events flagged as
privateare only accessible to the entity owner - Server-side filtering mandatory — never rely on client-side filtering
- Maximum concurrent SSE connections per API key (recommended: 5 free, 20 paid)
6. WebSocket Security
- Authentication MUST be verified at the HTTP Upgrade request, before handshake
- JWT re-authentication for long-lived connections (see Specification §6.4)
- All received messages MUST be validated against JSON schema before processing
7. Gate and Commerce Security
| Threat | Mitigation |
|---|---|
| Tier escalation | @eep-dev/gates matches proofs to tiers and returns only the highest qualifying tier |
| Proof replay | Structural validation checks expires_at and rejects future-dated issued_at |
| Config manipulation | parseGateConfig() validates all constraints before use |
| Commerce state skipping | Negotiation state machine rejects invalid transitions |
| Double receipt | Use atomic payment-hash consumption (consumeIfFresh) for distributed safety |
| Missing semantic verifier | resolveAccess is strict fail-closed by default; unmet verifier blocks grant |
| Fake reviews | Verify reviewer completed a transaction for the service |
Two-Phase Validation
import { resolveAccess, build402Response, ProofVerifierRegistry } from '@eep-dev/gates';const registry = new ProofVerifierRegistry();registry.register({supportedTypes: ['payment'],verify: async (proof) => {return await stripe.paymentIntents.retrieve(proof.token).then(pi => pi.status === 'succeeded');},});const result = await resolveAccess(proofs, config, resource, registry);if (!result.granted) {return res.json(await build402Response(config, resource, proofs), 402);}
8. Delegation Proof Chains
When a delegated agent (sub-agent acting on behalf of an owner) presents a gate proof, it must attach a Delegation Proofalongside its own DID signature. A Delegation Proof is a W3C Verifiable Credential issued by the owner's DID to the agent's DID, specifying permitted actions, permitted endpoints, and an expiry time.
The publisher must verify:
- The Delegation Proof is signed by the owner DID (not the agent DID)
- The requested action falls within the explicitly declared delegation scope
- The credential has not expired (
expirationDateis in the future) - The chain terminates at a DID that directly owns the resource
// Delegation Proof — Verifiable Credential structure
{
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": ["VerifiableCredential", "EEPDelegationCredential"],
"issuer": "did:web:owner.example.com", // owner DID
"credentialSubject": {
"id": "did:web:agent.example.com", // agent DID
"permittedActions": ["subscribe", "read:events"],
"permittedEndpoints": ["https://api.example.com/eep/*"],
"expirationDate": "2026-03-07T00:00:00Z"
}
}Security rule: Delegation credentials without explicit permittedActionsscope restrictions MUST be rejected. Agents without explicit scope cannot claim unlimited authority.9. Side-Channel and Constant-Time Security
All gate requirement verifications — credential checks, signature verifications, nonce consumptions — MUST be performed in constant time. Variable-time comparisons leak information about partial matches via timing side-channels.
- Use
timingSafeEqual(Node.js) /hmac.compare_digest(Python) for all cryptographic equality checks - Error responses for failed verification MUST NOT vary in detail or timing based on why verification failed — a failed signature and a failed nonce check must return the same HTTP status, same response latency, and generic error body
- Specific failure reasons are logged internally but never surfaced to the requesting agent
// ✅ Correct — constant-time
import { timingSafeEqual } from 'crypto';
const expected = Buffer.from(computeExpectedSig(secret, content), 'base64');
const actual = Buffer.from(receivedSig, 'base64');
const valid = expected.length === actual.length && timingSafeEqual(expected, actual);
// ❌ Wrong — variable-time (leaks timing info)
const valid = computeExpectedSig(secret, content) === receivedSig;