From 263718272522e7f2a768192ce1c4514c79f34afa Mon Sep 17 00:00:00 2001 From: Greg Soucy Date: Sat, 23 May 2026 16:35:45 -0400 Subject: [PATCH] Normalize receipt signing key formats for Coinbase webhook --- lib/receiptSigning.js | 50 +++++++++++++++++++--- tests/receiptSigning.test.js | 80 ++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 6 deletions(-) create mode 100644 tests/receiptSigning.test.js diff --git a/lib/receiptSigning.js b/lib/receiptSigning.js index 56300ee..79c1ac5 100644 --- a/lib/receiptSigning.js +++ b/lib/receiptSigning.js @@ -18,6 +18,48 @@ function normalizePemValue(value) { return String(value).replace(/\\n/g, '\n'); } +function wrapPemBody(body) { + const normalizedBody = String(body).replace(/\s+/g, ''); + if (!normalizedBody) return null; + const lines = normalizedBody.match(/.{1,64}/g) || []; + return `-----BEGIN PRIVATE KEY-----\n${lines.join('\n')}\n-----END PRIVATE KEY-----`; +} + +function toPossibleUtf8Base64(value) { + try { + const decoded = Buffer.from(String(value), 'base64').toString('utf8'); + return decoded.trim() ? decoded : null; + } catch { + return null; + } +} + +function normalizePrivateKeyPem(raw) { + const candidates = []; + const pushCandidate = (candidate) => { + if (candidate && typeof candidate === 'string') { + const normalized = normalizePemValue(candidate).trim(); + if (normalized) candidates.push(normalized); + } + }; + + pushCandidate(raw); + pushCandidate(toPossibleUtf8Base64(raw)); + + for (const candidate of candidates) { + if (candidate.includes('BEGIN PRIVATE KEY') && candidate.includes('END PRIVATE KEY')) { + return candidate; + } + } + + for (const candidate of candidates) { + const wrapped = wrapPemBody(candidate); + if (wrapped) return wrapped; + } + + return null; +} + function resolveFirstEnv(names) { for (const name of names) { const value = process.env[name]; @@ -55,13 +97,9 @@ function resolveReceiptSigningConfigFromEnv() { let privateKeyPem = null; if (pemValue) { - privateKeyPem = normalizePemValue(pemValue); + privateKeyPem = normalizePrivateKeyPem(pemValue); } else if (b64Value) { - try { - privateKeyPem = normalizePemValue(Buffer.from(b64Value, 'base64').toString('utf8')); - } catch { - privateKeyPem = null; - } + privateKeyPem = normalizePrivateKeyPem(b64Value); } return { diff --git a/tests/receiptSigning.test.js b/tests/receiptSigning.test.js new file mode 100644 index 0000000..10c3375 --- /dev/null +++ b/tests/receiptSigning.test.js @@ -0,0 +1,80 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); + +const { + resolveReceiptSigningConfigFromEnv, + hasValidSigningConfig, + signReceipt, +} = require('../lib/receiptSigning'); + +const originalEnv = { ...process.env }; + +function stripPemHeaders(pem) { + return String(pem) + .replace('-----BEGIN PRIVATE KEY-----', '') + .replace('-----END PRIVATE KEY-----', '') + .replace(/\s+/g, ''); +} + +test.beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.RECEIPT_SIGNER_ID; + delete process.env.RECEIPT_SIGNING_KID; + delete process.env.RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64; +}); + +test.after(() => { + process.env = originalEnv; +}); + +test('supports existing RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64 style (base64 PEM with literal \\n)', () => { + const { privateKey } = crypto.generateKeyPairSync('ed25519'); + const pemWithEscapedNewlines = privateKey.export({ type: 'pkcs8', format: 'pem' }).replace(/\n/g, '\\n'); + + process.env.RECEIPT_SIGNER_ID = 'runtime.commandlayer.eth'; + process.env.RECEIPT_SIGNING_KID = 'kid-existing-style'; + process.env.RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64 = Buffer.from(pemWithEscapedNewlines, 'utf8').toString('base64'); + + const cfg = resolveReceiptSigningConfigFromEnv(); + assert.equal(hasValidSigningConfig(cfg), true); + assert.match(cfg.privateKeyPem, /BEGIN PRIVATE KEY/); + assert.match(cfg.privateKeyPem, /END PRIVATE KEY/); +}); + +test('supports base64(full PEM) and raw PEM body in RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64', async () => { + const { privateKey } = crypto.generateKeyPairSync('ed25519'); + const fullPem = privateKey.export({ type: 'pkcs8', format: 'pem' }); + const pemBody = stripPemHeaders(fullPem); + + process.env.RECEIPT_SIGNER_ID = 'runtime.commandlayer.eth'; + process.env.RECEIPT_SIGNING_KID = 'kid-normalization'; + + process.env.RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64 = Buffer.from(fullPem, 'utf8').toString('base64'); + let cfg = resolveReceiptSigningConfigFromEnv(); + assert.equal(hasValidSigningConfig(cfg), true); + let signed = await signReceipt({ signer: cfg.signerId, verb: 'observe', input: {}, output: {}, execution: {}, ts: new Date().toISOString() }, cfg); + assert.equal(signed.metadata.proof.signature.alg, 'Ed25519'); + + process.env.RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64 = pemBody; + cfg = resolveReceiptSigningConfigFromEnv(); + assert.equal(hasValidSigningConfig(cfg), true); + signed = await signReceipt({ signer: cfg.signerId, verb: 'observe', input: {}, output: {}, execution: {}, ts: new Date().toISOString() }, cfg); + assert.equal(signed.metadata.proof.signature.alg, 'Ed25519'); +}); + + +test('invalid key returns signing_unavailable-safe path (sign fails without crash)', async () => { + process.env.RECEIPT_SIGNER_ID = 'runtime.commandlayer.eth'; + process.env.RECEIPT_SIGNING_KID = 'kid-invalid'; + process.env.RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64 = Buffer.from('not a valid private key', 'utf8').toString('base64'); + + const cfg = resolveReceiptSigningConfigFromEnv(); + assert.equal(hasValidSigningConfig(cfg), true); + + await assert.rejects( + signReceipt({ signer: cfg.signerId, verb: 'observe', input: {}, output: {}, execution: {}, ts: new Date().toISOString() }, cfg), + ); +});