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
50 changes: 44 additions & 6 deletions lib/receiptSigning.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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 {
Expand Down
80 changes: 80 additions & 0 deletions tests/receiptSigning.test.js
Original file line number Diff line number Diff line change
@@ -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),
);
});
Loading