┌─────────────────────────────────────────────────────────────────────┐
│ Incoming Webhook Request │
│ │
│ POST /webhooks/stellarsplit │
│ Headers: │
│ - x-stellarsplit-signature: <hmac-sha256-hex> │
│ - x-stellarsplit-timestamp: <unix-timestamp> │
│ - x-stellarsplit-nonce: <unique-id> │
│ Body: { event, timestamp, nonce, data } │
└───────────────────────────────┬─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Step 1: Extract & Validate Headers │
│ │
│ ✓ Check x-stellarsplit-signature exists │
│ ✓ Check x-stellarsplit-timestamp exists │
│ ✓ Check x-stellarsplit-nonce exists │
│ │
│ ❌ Missing → MissingHeaderError (400) │
└───────────────────────────────┬─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Step 2: Validate Timestamp │
│ │
│ now = Math.floor(Date.now() / 1000) │
│ timeDiff = Math.abs(now - timestamp) │
│ │
│ if (timeDiff > toleranceSeconds) { │
│ ❌ TimestampOutOfBoundsError (400) │
│ } │
│ │
│ ✓ Timestamp within tolerance window │
└───────────────────────────────┬─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Step 3: Check Nonce (Replay Prevention) │
│ │
│ if (nonceCache.has(nonce)) { │
│ ❌ ReplayAttackError (400) │
│ } │
│ │
│ ✓ Nonce is unique (not seen before) │
└───────────────────────────────┬─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Step 4: Extract Raw Body │
│ │
│ if (Buffer.isBuffer(req.body)) { │
│ rawBody = req.body.toString('utf8') │
│ } else if (typeof req.body === 'string') { │
│ rawBody = req.body │
│ } else { │
│ rawBody = JSON.stringify(req.body) │
│ } │
│ │
│ ✓ Raw body extracted for signature verification │
└───────────────────────────────┬─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Step 5: Compute Expected HMAC-SHA256 Signature │
│ │
│ key = importKey(secret) │
│ expectedSignature = HMAC-SHA256(key, rawBody) │
│ │
│ ✓ Cryptographic signature computed │
└───────────────────────────────┬─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Step 6: Constant-Time Signature Comparison │
│ │
│ expectedBytes = hexToBytes(expectedSignature) │
│ providedBytes = hexToBytes(signature) │
│ │
│ diff = 0 │
│ for (i = 0; i < length; i++) { │
│ diff |= expectedBytes[i] ^ providedBytes[i] │
│ } │
│ │
│ if (diff !== 0) { │
│ ❌ InvalidSignatureError (400) │
│ } │
│ │
│ ✓ Signature verified (constant-time) │
└───────────────────────────────┬─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Step 7: Parse & Validate Payload │
│ │
│ payload = JSON.parse(rawBody) │
│ │
│ ✓ Check payload.event is valid │
│ ✓ Check payload.timestamp matches header │
│ ✓ Check payload.nonce matches header │
│ ✓ Check payload.data exists │
│ │
│ ❌ Invalid → InvalidPayloadError (400) │
└───────────────────────────────┬─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Step 8: Store Nonce (Prevent Future Replay) │
│ │
│ nonceCache.set(nonce, timestamp) │
│ │
│ If cache full: │
│ - Evict oldest nonce (LRU) │
│ - Add new nonce │
│ │
│ ✓ Nonce stored in cache │
└───────────────────────────────┬─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Step 9: Attach Verified Payload to Request │
│ │
│ req.webhookPayload = payload │
│ req.rawWebhookBody = rawBody │
│ │
│ ✓ Request augmented with verified data │
└───────────────────────────────┬─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Step 10: Pass to Handler │
│ │
│ next() // Call next middleware │
│ │
│ ✓ Webhook verified and ready for processing │
└───────────────────────────────┬─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ User Handler Function │
│ │
│ const { event, data } = req.webhookPayload │
│ │
│ switch (event) { │
│ case 'invoice.paid': │
│ handleInvoicePaid(data) │
│ case 'invoice.released': │
│ handleInvoiceReleased(data) │
│ // ... other events │
│ } │
│ │
│ res.status(200).json({ received: true }) │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ Layer 1: Transport Security (HTTPS/TLS) │
│ ───────────────────────────────────────────────────────────────── │
│ Encryption in transit, certificate validation │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Layer 2: HMAC-SHA256 Signature Verification │
│ ───────────────────────────────────────────────────────────────── │
│ Cryptographic proof of authenticity and integrity │
│ - Prevents payload tampering │
│ - Requires shared secret │
│ - 256-bit security strength │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Layer 3: Constant-Time Comparison │
│ ───────────────────────────────────────────────────────────────── │
│ Timing attack mitigation │
│ - No early returns on mismatch │
│ - Bitwise XOR accumulation │
│ - Comparison time independent of differences │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Layer 4: Timestamp Validation │
│ ───────────────────────────────────────────────────────────────── │
│ Time-based freshness check │
│ - Rejects old requests │
│ - Configurable tolerance (default: 5 min) │
│ - Handles clock skew │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Layer 5: Nonce-Based Replay Prevention │
│ ───────────────────────────────────────────────────────────────── │
│ Request deduplication │
│ - LRU cache tracks seen nonces │
│ - O(1) lookup and insertion │
│ - Automatic eviction of oldest │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Layer 6: Input Validation & Sanitization │
│ ───────────────────────────────────────────────────────────────── │
│ Schema and type validation │
│ - Required fields check │
│ - Event type validation │
│ - Data structure verification │
│ - Header-payload consistency │
└─────────────────────────────────────────────────────────────────────┘
Initial State (empty, capacity=3):
┌─────────┬─────────┬─────────┐
│ Empty │ Empty │ Empty │
└─────────┴─────────┴─────────┘
After nonce_1:
┌─────────┬─────────┬─────────┐
│ nonce_1 │ Empty │ Empty │
└─────────┴─────────┴─────────┘
↑ Oldest Newest →
After nonce_2:
┌─────────┬─────────┬─────────┐
│ nonce_1 │ nonce_2 │ Empty │
└─────────┴─────────┴─────────┘
↑ Oldest Newest →
After nonce_3:
┌─────────┬─────────┬─────────┐
│ nonce_1 │ nonce_2 │ nonce_3 │
└─────────┴─────────┴─────────┘
↑ Oldest Newest →
After nonce_4 (cache full, evict oldest):
┌─────────┬─────────┬─────────┐
│ nonce_2 │ nonce_3 │ nonce_4 │
└─────────┴─────────┴─────────┘
↑ Oldest Newest →
(nonce_1 evicted)
Replay Attempt with nonce_3:
┌─────────┬─────────┬─────────┐
│ nonce_2 │ nonce_3 │ nonce_4 │
└─────────┴─────────┴─────────┘
↑ Found!
❌ ReplayAttackError thrown
Replay Attempt with nonce_1:
┌─────────┬─────────┬─────────┐
│ nonce_2 │ nonce_3 │ nonce_4 │
└─────────┴─────────┴─────────┘
Not found (was evicted)
✓ Allowed (outside window)
// ❌ VULNERABLE: Returns early on first mismatch
function vulnerableCompare(a: Uint8Array, b: Uint8Array): boolean {
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) {
return false; // ❌ Early return leaks timing info
}
}
return true;
}Attack: Attacker can measure response time to determine where bytes differ.
// ✓ SECURE: Always processes all bytes
function constantTimeCompare(a: Uint8Array, b: Uint8Array): boolean {
let diff = 0;
for (let i = 0; i < a.length; i++) {
diff |= (a[i] ?? 0) ^ (b[i] ?? 0); // ✓ Always processes all bytes
}
return diff === 0;
}Protection: Comparison time is always O(n), independent of where differences occur.
Sender Side
┌──────────────────────────────────────────┐
│ │
│ payload = { │
│ event: "invoice.paid", │
│ timestamp: 1721318400, │
│ nonce: "uuid-v4", │
│ data: { ... } │
│ } │
│ │
│ rawPayload = JSON.stringify(payload) │
│ │
│ signature = HMAC-SHA256(secret, rawPayload)
│ │
│ headers = { │
│ "x-stellarsplit-signature": signature,│
│ "x-stellarsplit-timestamp": timestamp,│
│ "x-stellarsplit-nonce": nonce │
│ } │
│ │
│ POST /webhook with headers & body │
│ │
└──────────────┬───────────────────────────┘
│
│ HTTPS Request
│
▼
┌──────────────────────────────────────────┐
│ Receiver Side │
│ │
│ 1. Extract rawPayload from request │
│ 2. Extract signature from header │
│ 3. Compute expectedSig = HMAC-SHA256(secret, rawPayload)
│ 4. Compare expectedSig with signature │
│ (constant-time) │
│ │
│ if (match) { │
│ ✓ Signature valid │
│ ✓ Payload authentic │
│ ✓ Not tampered │
│ } else { │
│ ❌ Signature invalid │
│ } │
└──────────────────────────────────────────┘
┌─────────────────────┐
│ Validation Error │
└──────────┬──────────┘
│
▼
┌─────────────────────────────────────────┐
│ Determine Error Type │
├─────────────────────────────────────────┤
│ • MissingHeaderError │
│ • InvalidSignatureError │
│ • TimestampOutOfBoundsError │
│ • ReplayAttackError │
│ • InvalidPayloadError │
└──────────┬──────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ Format Error Response │
├─────────────────────────────────────────┤
│ { │
│ "error": "InvalidSignatureError", │
│ "message": "Invalid webhook...", │
│ "code": "VALIDATION_ERROR" │
│ } │
└──────────┬──────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ Return HTTP 400 Bad Request │
└─────────────────────────────────────────┘
Timeline:
Request Timestamp
↓
Past ◄──────────────────[●]──────────────────► Future
↑ ↑
-toleranceSeconds +toleranceSeconds
◄───────── Acceptance Window ─────────►
✓ Inside window: Accept
❌ Outside window: TimestampOutOfBoundsError
Cache Size = 3:
Request Stream: A → B → C → D → E → A
Cache State: [A] → [AB] → [ABC] → [BCD] → [CDE] → [CDEA]
↑ A evicted ↑ A re-added
Replay Tests:
- Replay B after D: ✓ Still in cache → ❌ Rejected
- Replay A after D: ✓ Not in cache → ✓ Accepted (evicted)
- Replay E after E: ✓ In cache → ❌ Rejected
Component Time (μs) Notes
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Header Extraction ~1 Constant
Timestamp Check ~1 Arithmetic
Nonce Lookup ~5 Hash map O(1)
HMAC-SHA256 ~50 Crypto operation
Constant-Time Compare ~10 Array iteration
Payload Parse ~20 JSON.parse()
Validation ~5 Type checks
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Total ~92 ~0.1ms per request
Throughput: ~10,000 requests/second
Component Memory Scaling
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Middleware Closure ~1 KB Fixed
LRU Cache (1000) ~50 KB O(nonceWindowSize)
Request Buffer Variable O(payload size)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Per-Request Overhead ~0.1 KB Temporary objects
High-Volume Scenario (10,000 nonces):
LRU Cache: ~500 KB (still very efficient)
Request → Headers OK → Timestamp OK → Nonce Unique
→ Signature Valid → Payload Valid → Handler Called
Missing Header → MissingHeaderError (400)
Old Timestamp → TimestampOutOfBoundsError (400)
Duplicate Nonce → ReplayAttackError (400)
Bad Signature → InvalidSignatureError (400)
Malformed Payload → InvalidPayloadError (400)
✓ Payload Authenticity (via HMAC-SHA256)
✓ Payload Integrity (via HMAC-SHA256)
✓ Request Freshness (via timestamp)
✓ No Replay Attacks (via nonce cache)
✓ No Timing Attacks (via constant-time compare)