From b7fcbf974a72f4ba151059dec0d54e15645fad5e Mon Sep 17 00:00:00 2001 From: Greg Soucy Date: Fri, 22 May 2026 20:24:17 -0400 Subject: [PATCH] Add signed Coinbase webhook example endpoint with tests --- api/examples/coinbase-webhook.js | 103 +++++++++++ .../coinbase-cdp-webhook-receipts.md | 20 +++ lib/coinbaseWebhook.js | 85 +++++++++ lib/receiptSigning.js | 51 ++++++ tests/api-coinbase-webhook.test.js | 161 ++++++++++++++++++ 5 files changed, 420 insertions(+) create mode 100644 api/examples/coinbase-webhook.js create mode 100644 lib/coinbaseWebhook.js create mode 100644 lib/receiptSigning.js create mode 100644 tests/api-coinbase-webhook.test.js diff --git a/api/examples/coinbase-webhook.js b/api/examples/coinbase-webhook.js new file mode 100644 index 0000000..8815227 --- /dev/null +++ b/api/examples/coinbase-webhook.js @@ -0,0 +1,103 @@ +'use strict'; + +const { verifyCoinbaseWebhook } = require('../../lib/coinbaseWebhook'); +const { signReceipt } = require('../../lib/receiptSigning'); + +const seenReceipts = new Map(); + +function missingSigningConfig() { + return !process.env.CL_RECEIPT_SIGNER_ID || !process.env.CL_RECEIPT_SIGNING_PRIVATE_KEY_PEM || !process.env.CL_RECEIPT_SIGNING_KID; +} + +function normalizeReceipt(event) { + const eventId = event?.id || event?.event_id || 'unknown'; + const eventType = event?.type || 'coinbase.unknown'; + const txHash = event?.data?.transactionHash || event?.transactionHash || null; + return { + receipt_id: `rcpt:coinbase_cdp:${eventId}`, + signer: process.env.CL_RECEIPT_SIGNER_ID, + verb: 'observe', + source: 'coinbase.cdp.webhook', + subject: { + type: eventType, + id: txHash || eventId, + }, + input: { + raw_event_summary: { + id: eventId, + type: eventType, + transactionHash: txHash, + }, + }, + output: { + observation: { + accepted: true, + provider: 'coinbase_cdp', + event_type: eventType, + }, + }, + execution: { + status: 'succeeded', + }, + ts: new Date().toISOString(), + metadata: { + trace: { + trace_id: `coinbase:${eventId}`, + span_id: 'coinbase.webhook.verified', + timestamp: new Date().toISOString(), + tags: { + provider: 'coinbase_cdp', + event_type: eventType, + }, + }, + }, + }; +} + +module.exports = async function handler(req, res) { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + + if (req.method !== 'POST') { + res.setHeader('Allow', 'POST'); + return res.status(405).json({ ok: false, status: 'METHOD_NOT_ALLOWED' }); + } + + const secret = process.env.COINBASE_WEBHOOK_SECRET; + if (!secret) { + return res.status(503).json({ ok: false, status: 'configuration_unavailable' }); + } + + const verified = verifyCoinbaseWebhook(req, secret); + if (!verified.ok) { + return res.status(verified.httpStatus).json({ ok: false, status: verified.code }); + } + + if (missingSigningConfig()) { + return res.status(503).json({ ok: false, status: 'signing_unavailable' }); + } + + const eventId = verified.event?.id || verified.event?.event_id; + if (!eventId) { + return res.status(400).json({ ok: false, status: 'malformed_payload' }); + } + + if (seenReceipts.has(eventId)) { + return res.status(200).json({ ok: true, status: 'WEBHOOK_VERIFIED_AND_SIGNED', duplicate: true, receipt: seenReceipts.get(eventId) }); + } + + const unsignedReceipt = normalizeReceipt(verified.event); + const receipt = await signReceipt(unsignedReceipt, { + signerId: process.env.CL_RECEIPT_SIGNER_ID, + privateKeyPem: process.env.CL_RECEIPT_SIGNING_PRIVATE_KEY_PEM, + kid: process.env.CL_RECEIPT_SIGNING_KID, + }); + + seenReceipts.set(eventId, receipt); + return res.status(200).json({ ok: true, status: 'WEBHOOK_VERIFIED_AND_SIGNED', duplicate: false, receipt }); +}; + +module.exports._internal = { + clearSeen: () => seenReceipts.clear(), + seenReceipts, +}; diff --git a/docs/integrations/coinbase-cdp-webhook-receipts.md b/docs/integrations/coinbase-cdp-webhook-receipts.md index da1fe94..a86e307 100644 --- a/docs/integrations/coinbase-cdp-webhook-receipts.md +++ b/docs/integrations/coinbase-cdp-webhook-receipts.md @@ -234,3 +234,23 @@ Design intent: payment-trigger logic should depend on CommandLayer receipt seman - Third parties cannot independently validate Coinbase authenticity without the shared secret. - Public verifiability begins when CommandLayer signs the receipt (Ed25519) and distributes that signed artifact. + +## Signed example endpoint + +A server-side example endpoint is available at `api/examples/coinbase-webhook.js`. + +Processing order is strict: +1. Verify Coinbase webhook authenticity first (`X-Hook0-Signature` HMAC over raw body + signed headers). +2. Normalize the accepted event into a CLAS-style `observe` receipt. +3. Sign the normalized receipt with CommandLayer runtime Ed25519 signing material. + +This ordering matters: receipt signing happens **only** for accepted, HMAC-verified Coinbase events. Invalid or stale webhook signatures are rejected before parsing JSON and before any receipt signature work. + +Required environment variables: +- `COINBASE_WEBHOOK_SECRET` +- `COINBASE_WEBHOOK_MAX_AGE_SECONDS` (optional, defaults to 300) +- `CL_RECEIPT_SIGNER_ID` +- `CL_RECEIPT_SIGNING_PRIVATE_KEY_PEM` +- `CL_RECEIPT_SIGNING_KID` + +Public portability begins after CommandLayer signs the normalized receipt artifact. Third-party verification depends on signer public key distribution (for example ENS text records expected by local verifier logic). diff --git a/lib/coinbaseWebhook.js b/lib/coinbaseWebhook.js new file mode 100644 index 0000000..d3ed94e --- /dev/null +++ b/lib/coinbaseWebhook.js @@ -0,0 +1,85 @@ +'use strict'; + +const crypto = require('node:crypto'); + +const DEFAULT_MAX_AGE_SECONDS = 300; + +function getHeader(req, name) { + if (!req || !req.headers) return undefined; + const direct = req.headers[name]; + if (direct !== undefined) return direct; + return req.headers[name.toLowerCase()]; +} + +function parseHookSignature(headerValue) { + if (!headerValue || typeof headerValue !== 'string') return null; + const parts = headerValue.split(',').map((p) => p.trim()).filter(Boolean); + const map = new Map(); + for (const part of parts) { + const idx = part.indexOf('='); + if (idx <= 0) continue; + map.set(part.slice(0, idx), part.slice(idx + 1)); + } + const t = map.get('t'); + const h = map.get('h'); + const v1 = map.get('v1'); + if (!t || !h || !v1) return null; + const ts = Number.parseInt(t, 10); + if (!Number.isFinite(ts)) return null; + const signedHeaderNames = h.split(' ').map((v) => v.trim()).filter(Boolean); + if (!signedHeaderNames.length || !/^[0-9a-fA-F]+$/.test(v1)) return null; + return { timestamp: ts, signedHeaderNames, signatureHex: v1.toLowerCase() }; +} + +function buildSignedPayload({ timestamp, signedHeaderNames, req, rawBody }) { + const signedHeaderNamesStr = signedHeaderNames.join(' '); + const signedHeaderValues = signedHeaderNames + .map((headerName) => String(getHeader(req, headerName) ?? '')) + .join('.'); + return `${timestamp}.${signedHeaderNamesStr}.${signedHeaderValues}.${rawBody}`; +} + +function getRawBodyString(req) { + if (typeof req.rawBody === 'string') return req.rawBody; + if (Buffer.isBuffer(req.rawBody)) return req.rawBody.toString('utf8'); + if (typeof req.body === 'string') return req.body; + if (Buffer.isBuffer(req.body)) return req.body.toString('utf8'); + if (req.body && typeof req.body === 'object') return JSON.stringify(req.body); + return ''; +} + +function verifyCoinbaseWebhook(req, secret) { + const signatureHeader = getHeader(req, 'x-hook0-signature'); + if (!signatureHeader) return { ok: false, code: 'missing_signature', httpStatus: 400 }; + + const parsed = parseHookSignature(signatureHeader); + if (!parsed) return { ok: false, code: 'malformed_signature', httpStatus: 400 }; + + const maxAge = Number.parseInt(process.env.COINBASE_WEBHOOK_MAX_AGE_SECONDS || '', 10); + const maxAgeSeconds = Number.isFinite(maxAge) && maxAge >= 0 ? maxAge : DEFAULT_MAX_AGE_SECONDS; + const nowSeconds = Math.floor(Date.now() / 1000); + if (Math.abs(nowSeconds - parsed.timestamp) > maxAgeSeconds) { + return { ok: false, code: 'stale_signature', httpStatus: 400 }; + } + + const rawBody = getRawBodyString(req); + const signedPayload = buildSignedPayload({ ...parsed, req, rawBody }); + const expectedHex = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex'); + + const expectedBuf = Buffer.from(expectedHex, 'hex'); + const actualBuf = Buffer.from(parsed.signatureHex, 'hex'); + if (expectedBuf.length !== actualBuf.length || !crypto.timingSafeEqual(expectedBuf, actualBuf)) { + return { ok: false, code: 'invalid_signature', httpStatus: 400 }; + } + + let event; + try { + event = JSON.parse(rawBody); + } catch { + return { ok: false, code: 'malformed_payload', httpStatus: 400 }; + } + + return { ok: true, event, rawBody }; +} + +module.exports = { verifyCoinbaseWebhook, parseHookSignature, buildSignedPayload, getRawBodyString }; diff --git a/lib/receiptSigning.js b/lib/receiptSigning.js new file mode 100644 index 0000000..278d5ba --- /dev/null +++ b/lib/receiptSigning.js @@ -0,0 +1,51 @@ +'use strict'; + +const crypto = require('node:crypto'); +const { webcrypto } = crypto; + +function canonicalize(value) { + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonicalize).join(',')}]`; + return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonicalize(value[k])}`).join(',')}}`; +} + +async function sha256Hex(text) { + const digest = await webcrypto.subtle.digest('SHA-256', new TextEncoder().encode(text)); + return Buffer.from(digest).toString('hex'); +} + +async function signReceipt(receipt, cfg) { + const canonicalPayload = { + signer: receipt?.signer, + verb: receipt?.verb, + input: receipt?.input, + output: receipt?.output, + execution: receipt?.execution, + ts: receipt?.ts, + }; + const canonicalStr = canonicalize(canonicalPayload); + const hashHex = await sha256Hex(canonicalStr); + + const privateKey = crypto.createPrivateKey(cfg.privateKeyPem); + const signature = crypto.sign(null, Buffer.from(hashHex, 'utf8'), privateKey).toString('base64'); + + return { + ...receipt, + metadata: { + ...(receipt.metadata || {}), + proof: { + canonicalization: 'json.sorted_keys.v1', + hash: { alg: 'SHA-256', value: hashHex }, + signature: { + alg: 'Ed25519', + kid: cfg.kid, + value: signature, + role: 'runtime', + }, + signer_id: cfg.signerId, + }, + }, + }; +} + +module.exports = { signReceipt, canonicalize, sha256Hex }; diff --git a/tests/api-coinbase-webhook.test.js b/tests/api-coinbase-webhook.test.js new file mode 100644 index 0000000..32718d2 --- /dev/null +++ b/tests/api-coinbase-webhook.test.js @@ -0,0 +1,161 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); + +const handler = require('../api/examples/coinbase-webhook'); +const { verifyReceipt } = require('../lib/verifyReceipt'); + +function makeRes() { + return { + statusCode: 200, + headers: {}, + body: null, + setHeader(name, value) { this.headers[name.toLowerCase()] = value; }, + status(code) { this.statusCode = code; return this; }, + json(payload) { this.body = payload; return this; }, + }; +} + +function signHook({ secret, timestamp, headers, rawBody, signedHeaderNames = ['content-type'] }) { + const headerNamesString = signedHeaderNames.join(' '); + const signedHeaderValues = signedHeaderNames.map((h) => String(headers[h] ?? headers[h.toLowerCase()] ?? '')).join('.'); + const payload = `${timestamp}.${headerNamesString}.${signedHeaderValues}.${rawBody}`; + const v1 = crypto.createHmac('sha256', secret).update(payload).digest('hex'); + return `t=${timestamp},h=${headerNamesString},v1=${v1}`; +} + +const originalEnv = { ...process.env }; + +test.beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.COINBASE_WEBHOOK_SECRET; + delete process.env.CL_RECEIPT_SIGNER_ID; + delete process.env.CL_RECEIPT_SIGNING_PRIVATE_KEY_PEM; + delete process.env.CL_RECEIPT_SIGNING_KID; + delete process.env.COINBASE_WEBHOOK_MAX_AGE_SECONDS; + handler._internal.clearSeen(); +}); + +test.after(() => { + process.env = originalEnv; +}); + +test('GET returns 405', async () => { + const res = makeRes(); + await handler({ method: 'GET', headers: {} }, res); + assert.equal(res.statusCode, 405); +}); + +test('missing Coinbase secret returns 503', async () => { + const res = makeRes(); + await handler({ method: 'POST', headers: {}, body: '{}' }, res); + assert.equal(res.statusCode, 503); + assert.equal(res.body.status, 'configuration_unavailable'); +}); + +test('missing signature returns 400', async () => { + process.env.COINBASE_WEBHOOK_SECRET = 'test_secret'; + const res = makeRes(); + await handler({ method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }, res); + assert.equal(res.statusCode, 400); + assert.equal(res.body.status, 'missing_signature'); +}); + +test('invalid signature returns 400', async () => { + process.env.COINBASE_WEBHOOK_SECRET = 'test_secret'; + const rawBody = JSON.stringify({ id: 'evt_1', type: 'onchain.activity.detected' }); + const res = makeRes(); + await handler({ + method: 'POST', + headers: { 'content-type': 'application/json', 'x-hook0-signature': `t=${Math.floor(Date.now()/1000)},h=content-type,v1=deadbeef` }, + body: rawBody, + }, res); + assert.equal(res.statusCode, 400); + assert.equal(res.body.status, 'invalid_signature'); + assert.equal(handler._internal.seenReceipts.size, 0); +}); + +test('stale timestamp returns 400', async () => { + process.env.COINBASE_WEBHOOK_SECRET = 'test_secret'; + const timestamp = Math.floor(Date.now() / 1000) - 1000; + const rawBody = JSON.stringify({ id: 'evt_1', type: 'onchain.activity.detected' }); + const headers = { 'content-type': 'application/json' }; + const sig = signHook({ secret: process.env.COINBASE_WEBHOOK_SECRET, timestamp, headers, rawBody }); + const res = makeRes(); + await handler({ method: 'POST', headers: { ...headers, 'x-hook0-signature': sig }, body: rawBody }, res); + assert.equal(res.statusCode, 400); + assert.equal(res.body.status, 'stale_signature'); +}); + +test('malformed JSON after valid HMAC returns 400', async () => { + process.env.COINBASE_WEBHOOK_SECRET = 'test_secret'; + const rawBody = '{bad json'; + const timestamp = Math.floor(Date.now() / 1000); + const headers = { 'content-type': 'application/json' }; + const sig = signHook({ secret: process.env.COINBASE_WEBHOOK_SECRET, timestamp, headers, rawBody }); + const res = makeRes(); + await handler({ method: 'POST', headers: { ...headers, 'x-hook0-signature': sig }, body: rawBody }, res); + assert.equal(res.statusCode, 400); + assert.equal(res.body.status, 'malformed_payload'); +}); + +test('missing signing env returns 503 after valid HMAC', async () => { + process.env.COINBASE_WEBHOOK_SECRET = 'test_secret'; + const rawBody = JSON.stringify({ id: 'evt_2', type: 'onchain.activity.detected' }); + const timestamp = Math.floor(Date.now() / 1000); + const headers = { 'content-type': 'application/json' }; + const sig = signHook({ secret: process.env.COINBASE_WEBHOOK_SECRET, timestamp, headers, rawBody }); + const res = makeRes(); + await handler({ method: 'POST', headers: { ...headers, 'x-hook0-signature': sig }, body: rawBody }, res); + assert.equal(res.statusCode, 503); + assert.equal(res.body.status, 'signing_unavailable'); +}); + +test('valid payload returns signed receipt and duplicate returns same receipt', async () => { + process.env.COINBASE_WEBHOOK_SECRET = 'test_secret'; + process.env.CL_RECEIPT_SIGNER_ID = 'runtime.commandlayer.eth'; + process.env.CL_RECEIPT_SIGNING_KID = 'test-kid-1'; + const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519'); + process.env.CL_RECEIPT_SIGNING_PRIVATE_KEY_PEM = privateKey.export({ type: 'pkcs8', format: 'pem' }); + + const pubRaw = publicKey.export({ format: 'der', type: 'spki' }).subarray(-32).toString('base64'); + const rawBody = JSON.stringify({ id: 'evt_3', type: 'wallet.transaction', data: { transactionHash: '0xabc' } }); + const timestamp = Math.floor(Date.now() / 1000); + const headers = { 'content-type': 'application/json' }; + const sig = signHook({ secret: process.env.COINBASE_WEBHOOK_SECRET, timestamp, headers, rawBody }); + + const res1 = makeRes(); + await handler({ method: 'POST', headers: { ...headers, 'x-hook0-signature': sig }, body: rawBody }, res1); + + assert.equal(res1.statusCode, 200); + assert.equal(res1.body.status, 'WEBHOOK_VERIFIED_AND_SIGNED'); + assert.equal(res1.body.duplicate, false); + assert.equal(res1.body.receipt.metadata.trace.tags.provider, 'coinbase_cdp'); + assert.equal(res1.body.receipt.metadata.proof.hash.alg, 'SHA-256'); + assert.equal(res1.body.receipt.metadata.proof.signature.alg, 'Ed25519'); + assert.equal(res1.body.receipt.metadata.proof.signature.role, 'runtime'); + + const verification = await verifyReceipt(res1.body.receipt, { + ens: { + textResolver: async (name, key) => { + if (name !== 'runtime.commandlayer.eth') return null; + const records = { + 'cl.sig.pub': `ed25519:${pubRaw}`, + 'cl.sig.kid': 'test-kid-1', + 'cl.sig.canonical': 'json.sorted_keys.v1', + 'cl.receipt.signer': 'runtime.commandlayer.eth', + }; + return records[key] || null; + }, + }, + }); + assert.equal(verification.ok, true); + + const res2 = makeRes(); + await handler({ method: 'POST', headers: { ...headers, 'x-hook0-signature': sig }, body: rawBody }, res2); + assert.equal(res2.statusCode, 200); + assert.equal(res2.body.duplicate, true); + assert.deepEqual(res2.body.receipt, res1.body.receipt); +});