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
20 changes: 7 additions & 13 deletions api/examples/coinbase-webhook.js
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down Expand Up @@ -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' });
}

Expand All @@ -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 });
Expand Down
1 change: 1 addition & 0 deletions docs/integrations/coinbase-cdp-webhook-receipts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
69 changes: 68 additions & 1 deletion lib/receiptSigning.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -48,4 +109,10 @@ async function signReceipt(receipt, cfg) {
};
}

module.exports = { signReceipt, canonicalize, sha256Hex };
module.exports = {
signReceipt,
canonicalize,
sha256Hex,
resolveReceiptSigningConfigFromEnv,
hasValidSigningConfig,
};
40 changes: 40 additions & 0 deletions tests/api-coinbase-webhook.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading