@certnode/sdk
Version:
Minimal Node SDK for CertNode receipt verification
206 lines (171 loc) • 6.68 kB
JavaScript
//---------------------------------------------------------------------
// sdk/node/index.js
// Minimal Node SDK for CertNode receipt verification (ES256 only, no deps)
const crypto = require('crypto');
const { JWKSManager } = require('./jwks-manager');
// Utility functions (copied from api/src/util/)
function isObject(v) {
return v !== null && typeof v === 'object' && !Array.isArray(v);
}
function stringifyCanonical(value) {
if (value === null || typeof value === 'number' || typeof value === 'boolean') {
return JSON.stringify(value);
}
if (typeof value === 'string') {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
const items = value.map((v) => stringifyCanonical(v));
return '[' + items.join(',') + ']';
}
if (isObject(value)) {
const keys = Object.keys(value).sort();
const parts = [];
for (const k of keys) {
const v = value[k];
if (typeof v === 'undefined') continue;
parts.push(JSON.stringify(k) + ':' + stringifyCanonical(v));
}
return '{' + parts.join(',') + '}';
}
return JSON.stringify(value);
}
function canonicalize(value) {
return Buffer.from(stringifyCanonical(value), 'utf8');
}
function b64u(buf) {
return Buffer.from(buf)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
function b64uToBuf(str) {
const padded = str + '='.repeat((4 - str.length % 4) % 4);
return Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
}
function jwkThumbprint(jwk) {
if (!jwk || jwk.kty !== 'EC' || jwk.crv !== 'P-256' || !jwk.x || !jwk.y) {
throw new Error('Only EC P-256 JWK supported for thumbprint');
}
const json = JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y });
return b64u(crypto.createHash('sha256').update(json, 'utf8').digest());
}
function joseToDer(jose) {
if (!Buffer.isBuffer(jose)) jose = Buffer.from(jose, 'base64url');
if (jose.length !== 64) throw new Error('JOSE signature must be 64 bytes for P-256');
const r = jose.slice(0, 32);
const s = jose.slice(32);
function trimLeadingZeros(buf) {
let i = 0;
while (i < buf.length - 1 && buf[i] === 0x00) i++;
return buf.slice(i);
}
const rTrim = trimLeadingZeros(r);
const sTrim = trimLeadingZeros(s);
const rInt = (rTrim[0] & 0x80) ? Buffer.concat([Buffer.from([0x00]), rTrim]) : rTrim;
const sInt = (sTrim[0] & 0x80) ? Buffer.concat([Buffer.from([0x00]), sTrim]) : sTrim;
const rSeq = Buffer.concat([Buffer.from([0x02, rInt.length]), rInt]);
const sSeq = Buffer.concat([Buffer.from([0x02, sInt.length]), sInt]);
const seq = Buffer.concat([Buffer.from([0x30, rSeq.length + sSeq.length]), rSeq, sSeq]);
return seq;
}
function spkiFromP256Jwk(jwk) {
if (!jwk || jwk.kty !== 'EC' || jwk.crv !== 'P-256' || !jwk.x || !jwk.y) {
throw new Error('Invalid P-256 JWK');
}
const xBuf = b64uToBuf(jwk.x);
const yBuf = b64uToBuf(jwk.y);
if (xBuf.length !== 32 || yBuf.length !== 32) {
throw new Error('Invalid coordinate length for P-256');
}
// SPKI prefix for P-256 uncompressed point
const spkiPrefix = Buffer.from([
0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01,
0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00, 0x04
]);
return Buffer.concat([spkiPrefix, xBuf, yBuf]);
}
/**
* Verify a CertNode receipt using a JWKS object
* @param {Object} options - Verification options
* @param {Object} options.receipt - The receipt to verify
* @param {Object} options.jwks - The JWKS containing public keys
* @returns {Object} { ok: boolean, reason?: string }
*/
async function verifyReceipt({ receipt, jwks }) {
try {
// Parse receipt if string
if (typeof receipt === 'string') {
receipt = JSON.parse(receipt);
}
// Validate receipt structure
if (!receipt.protected || !receipt.signature || !('payload' in receipt) || !receipt.kid) {
return { ok: false, reason: 'Missing required receipt fields' };
}
// Decode protected header
const protectedBuf = b64uToBuf(receipt.protected);
const header = JSON.parse(protectedBuf.toString('utf8'));
// Validate header
if (header.alg !== 'ES256') {
return { ok: false, reason: `Unsupported algorithm: ${header.alg}` };
}
if (header.kid !== receipt.kid) {
return { ok: false, reason: 'Kid mismatch between header and receipt' };
}
// Find matching key in JWKS by RFC7638 thumbprint or kid field
let key = null;
for (const k of jwks.keys) {
try {
if (jwkThumbprint(k) === receipt.kid) {
key = k;
break;
}
} catch {
// Try matching by kid field
if (k.kid === receipt.kid) {
key = k;
break;
}
}
}
if (!key) {
return { ok: false, reason: `Key not found in JWKS: ${receipt.kid}` };
}
// Validate JCS hash if present
if (receipt.payload_jcs_sha256) {
const jcsHash = crypto.createHash('sha256').update(canonicalize(receipt.payload)).digest();
const expectedHash = b64uToBuf(receipt.payload_jcs_sha256);
if (!jcsHash.equals(expectedHash)) {
return { ok: false, reason: 'JCS hash mismatch' };
}
}
// Create signing input (protected + '.' + JCS(payload))
const payloadB64u = b64u(canonicalize(receipt.payload));
const signingInput = receipt.protected + '.' + payloadB64u;
// Verify signature
const spki = spkiFromP256Jwk(key);
const publicKey = crypto.createPublicKey({ key: spki, format: 'der', type: 'spki' });
const signatureBuf = b64uToBuf(receipt.signature);
const derSignature = joseToDer(signatureBuf);
const verify = crypto.createVerify('SHA256');
verify.update(signingInput, 'utf8');
const isValid = verify.verify(publicKey, derSignature);
if (!isValid) {
return { ok: false, reason: 'Invalid signature' };
}
// Optional receipt_id check if present
if (receipt.receipt_id) {
const fullReceipt = `${receipt.protected}.${payloadB64u}.${receipt.signature}`;
const computedId = b64u(crypto.createHash('sha256').update(fullReceipt, 'utf8').digest());
if (computedId !== receipt.receipt_id) {
return { ok: false, reason: 'Receipt ID mismatch' };
}
}
return { ok: true };
} catch (error) {
return { ok: false, reason: `Verification failed: ${error.message}` };
}
}
module.exports = { verifyReceipt, JWKSManager };
//---------------------------------------------------------------------