diff --git a/api/examples/coinbase-webhook.js b/api/examples/coinbase-webhook.js index 8815227..b840d9c 100644 --- a/api/examples/coinbase-webhook.js +++ b/api/examples/coinbase-webhook.js @@ -1,21 +1,18 @@ 'use strict'; const { verifyCoinbaseWebhook } = require('../../lib/coinbaseWebhook'); -const { signReceipt } = require('../../lib/receiptSigning'); +const { signReceipt, resolveReceiptSigningConfigFromEnv, hasValidSigningConfig } = 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) { +function normalizeReceipt(event, signerId) { 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, + signer: signerId, verb: 'observe', source: 'coinbase.cdp.webhook', subject: { @@ -73,7 +70,8 @@ module.exports = async function handler(req, res) { return res.status(verified.httpStatus).json({ ok: false, status: verified.code }); } - if (missingSigningConfig()) { + const signingCfg = resolveReceiptSigningConfigFromEnv(); + if (!hasValidSigningConfig(signingCfg)) { return res.status(503).json({ ok: false, status: 'signing_unavailable' }); } @@ -86,12 +84,8 @@ module.exports = async function handler(req, res) { 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, - }); + const unsignedReceipt = normalizeReceipt(verified.event, signingCfg.signerId); + const receipt = await signReceipt(unsignedReceipt, signingCfg); seenReceipts.set(eventId, receipt); return res.status(200).json({ ok: true, status: 'WEBHOOK_VERIFIED_AND_SIGNED', duplicate: false, receipt }); diff --git a/docs/integrations/coinbase-cdp-webhook-receipts.md b/docs/integrations/coinbase-cdp-webhook-receipts.md index a86e307..012db06 100644 --- a/docs/integrations/coinbase-cdp-webhook-receipts.md +++ b/docs/integrations/coinbase-cdp-webhook-receipts.md @@ -251,6 +251,7 @@ Required environment variables: - `COINBASE_WEBHOOK_MAX_AGE_SECONDS` (optional, defaults to 300) - `CL_RECEIPT_SIGNER_ID` - `CL_RECEIPT_SIGNING_PRIVATE_KEY_PEM` +- `RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64` (runtime-compatible alias; base64-encoded 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/receiptSigning.js b/lib/receiptSigning.js index 278d5ba..56300ee 100644 --- a/lib/receiptSigning.js +++ b/lib/receiptSigning.js @@ -14,6 +14,67 @@ async function sha256Hex(text) { return Buffer.from(digest).toString('hex'); } +function normalizePemValue(value) { + return String(value).replace(/\\n/g, '\n'); +} + +function resolveFirstEnv(names) { + for (const name of names) { + const value = process.env[name]; + if (typeof value === 'string' && value.trim()) return value; + } + return null; +} + +function resolveReceiptSigningConfigFromEnv() { + const signerId = resolveFirstEnv([ + 'CL_RECEIPT_SIGNER_ID', + 'RECEIPT_SIGNER_ID', + 'CL_RECEIPT_SIGNER', + ]); + + const kid = resolveFirstEnv([ + 'CL_RECEIPT_SIGNING_KID', + 'RECEIPT_SIGNING_KID', + 'CL_RECEIPT_SIGNING_KEY_ID', + 'CL_KEY_ID', + ]); + + const pemValue = resolveFirstEnv([ + 'CL_RECEIPT_SIGNING_PRIVATE_KEY_PEM', + 'RECEIPT_SIGNING_PRIVATE_KEY_PEM', + 'CL_PRIVATE_KEY_PEM', + ]); + + const b64Value = resolveFirstEnv([ + 'CL_RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64', + 'RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64', + 'RECEIPT_SIGNING_PRIVATE_KEY_B64', + 'CL_PRIVATE_KEY_PEM_B64', + ]); + + let privateKeyPem = null; + if (pemValue) { + privateKeyPem = normalizePemValue(pemValue); + } else if (b64Value) { + try { + privateKeyPem = normalizePemValue(Buffer.from(b64Value, 'base64').toString('utf8')); + } catch { + privateKeyPem = null; + } + } + + return { + signerId, + kid, + privateKeyPem, + }; +} + +function hasValidSigningConfig(cfg) { + return Boolean(cfg?.signerId && cfg?.kid && cfg?.privateKeyPem); +} + async function signReceipt(receipt, cfg) { const canonicalPayload = { signer: receipt?.signer, @@ -48,4 +109,10 @@ async function signReceipt(receipt, cfg) { }; } -module.exports = { signReceipt, canonicalize, sha256Hex }; +module.exports = { + signReceipt, + canonicalize, + sha256Hex, + resolveReceiptSigningConfigFromEnv, + hasValidSigningConfig, +}; diff --git a/tests/api-coinbase-webhook.test.js b/tests/api-coinbase-webhook.test.js index 32718d2..5857ba8 100644 --- a/tests/api-coinbase-webhook.test.js +++ b/tests/api-coinbase-webhook.test.js @@ -159,3 +159,43 @@ test('valid payload returns signed receipt and duplicate returns same receipt', assert.equal(res2.body.duplicate, true); assert.deepEqual(res2.body.receipt, res1.body.receipt); }); + + +test('runtime-compatible alias RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64 signs successfully', async () => { + process.env.COINBASE_WEBHOOK_SECRET = 'test_secret'; + process.env.RECEIPT_SIGNER_ID = 'runtime.commandlayer.eth'; + process.env.RECEIPT_SIGNING_KID = 'test-kid-2'; + const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519'); + process.env.RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64 = Buffer + .from(privateKey.export({ type: 'pkcs8', format: 'pem' }), 'utf8') + .toString('base64'); + + const pubRaw = publicKey.export({ format: 'der', type: 'spki' }).subarray(-32).toString('base64'); + const rawBody = JSON.stringify({ id: 'evt_alias_1', type: 'wallet.transaction', data: { transactionHash: '0xdef' } }); + 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, 200); + assert.equal(res.body.status, 'WEBHOOK_VERIFIED_AND_SIGNED'); + assert.equal(res.body.receipt.metadata.proof.signature.kid, 'test-kid-2'); + + const verification = await verifyReceipt(res.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-2', + 'cl.sig.canonical': 'json.sorted_keys.v1', + 'cl.receipt.signer': 'runtime.commandlayer.eth', + }; + return records[key] || null; + }, + }, + }); + assert.equal(verification.ok, true); +});