#!/usr/bin/env node /** * Check a Cyphrex export bundle offline. * * node verify_export_bundle.js bundle.zip --public-key export-public.pem * node verify_export_bundle.js bundle.zip --public-key-url https://cyphrex.io/.well-known/cyphrex-export-key * * Prints UNSIGNED when the manifest says the bundle has no signature. * Prints SIGNATURE OK only when the detached signature matches the manifest bytes. * Node built-ins only. No network unless --public-key-url is set. */ const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const DEFAULT_KEY_URL = 'https://cyphrex.io/.well-known/cyphrex-export-key'; const SCHEMA = 'cyphrex.regulator-export/1'; function crc32(buf) { let c = 0xffffffff; for (let i = 0; i < buf.length; i += 1) { c ^= buf[i]; for (let k = 0; k < 8; k += 1) c = (c >>> 1) ^ (0xedb88320 & -(c & 1)); } return (c ^ 0xffffffff) >>> 0; } function readZipStore(data) { const entries = new Map(); let offset = 0; while (offset + 30 <= data.length) { const sig = data.readUInt32LE(offset); if (sig === 0x02014b50 || sig === 0x06054b50) break; if (sig !== 0x04034b50) throw new Error('Not a stored ZIP bundle'); const method = data.readUInt16LE(offset + 8); const crc = data.readUInt32LE(offset + 14); const compressed = data.readUInt32LE(offset + 18); const uncompressed = data.readUInt32LE(offset + 22); const nameLen = data.readUInt16LE(offset + 26); const extraLen = data.readUInt16LE(offset + 28); const nameStart = offset + 30; const dataStart = nameStart + nameLen + extraLen; const dataEnd = dataStart + compressed; if (dataEnd > data.length) throw new Error('Truncated ZIP bundle'); if (method !== 0 || compressed !== uncompressed) { throw new Error('ZIP entry is compressed; this verifier expects a stored bundle'); } const name = data.slice(nameStart, nameStart + nameLen).toString('utf8'); const file = data.slice(dataStart, dataEnd); if (crc32(file) !== crc) throw new Error(`CRC mismatch for ${name}`); entries.set(name, file); offset = dataEnd; } if (!entries.size) throw new Error('ZIP bundle has no files'); return entries; } function readBundle(target) { const stat = fs.statSync(target); if (stat.isDirectory()) { const entries = new Map(); for (const name of fs.readdirSync(target)) { const full = path.join(target, name); if (fs.statSync(full).isFile()) entries.set(name, fs.readFileSync(full)); } return entries; } return readZipStore(fs.readFileSync(target)); } function sha256Hex(buf) { return crypto.createHash('sha256').update(buf).digest('hex'); } function countJsonl(text) { if (!text) return 0; return text.split('\n').filter((line) => line.length > 0).length; } function countCsvRecords(text) { let rows = 0; let inQuotes = false; let rowHasCell = false; for (let i = 0; i < text.length; i += 1) { const c = text[i]; if (inQuotes) { if (c === '"') { if (text[i + 1] === '"') i += 1; else inQuotes = false; } continue; } if (c === '"') { inQuotes = true; rowHasCell = true; continue; } if (c === ',') { rowHasCell = true; continue; } if (c === '\n' || c === '\r') { if (c === '\r' && text[i + 1] === '\n') i += 1; if (rowHasCell) rows += 1; rowHasCell = false; continue; } rowHasCell = true; } if (rowHasCell) rows += 1; return Math.max(0, rows - 1); } function recordCount(name, buf) { const text = buf.toString('utf8'); if (name.endsWith('.csv')) return countCsvRecords(text); return countJsonl(text); } function verifySignature(manifestBytes, signatureText, publicKeyPem) { const sig = Buffer.from(String(signatureText || '').trim(), 'base64'); if (!sig.length || !publicKeyPem) return false; const key = crypto.createPublicKey({ key: publicKeyPem, format: 'pem', type: 'spki' }); return crypto.verify(null, manifestBytes, key, sig); } function verifyEntries(entries, publicKeyPem) { const manifestBuf = entries.get('manifest.json'); if (!manifestBuf) return { ok: false, message: 'manifest.json is missing' }; let manifest; try { manifest = JSON.parse(manifestBuf.toString('utf8')); } catch { return { ok: false, message: 'manifest.json is not JSON' }; } if (!manifest || manifest.schema !== SCHEMA) { return { ok: false, message: 'unexpected manifest schema' }; } const files = Array.isArray(manifest.files) ? manifest.files : []; if (!files.length) return { ok: false, message: 'manifest lists no files' }; for (const file of files) { const buf = entries.get(file.name); if (!buf) return { ok: false, message: `missing ${file.name}` }; if (sha256Hex(buf) !== file.sha256) return { ok: false, message: `sha256 mismatch for ${file.name}` }; if (buf.length !== file.bytes) return { ok: false, message: `size mismatch for ${file.name}` }; if (recordCount(file.name, buf) !== file.recordCount) { return { ok: false, message: `record count mismatch for ${file.name}` }; } } if (manifest.recordCount !== files[0].recordCount) { return { ok: false, message: 'record count mismatch' }; } const claimsSigned = manifest.signed === true || (manifest.signature && manifest.signature.status === 'signed'); if (!claimsSigned) { if (manifest.signed !== false || !manifest.signature || manifest.signature.status !== 'unsigned') { return { ok: false, message: 'manifest does not say whether it is signed' }; } if (entries.has('manifest.sig')) { return { ok: false, message: 'unsigned manifest includes manifest.sig' }; } return { ok: true, signed: false, message: 'UNSIGNED' }; } const sigBuf = entries.get('manifest.sig'); if (!sigBuf) return { ok: false, message: 'manifest says signed but manifest.sig is missing' }; if (!publicKeyPem) return { ok: false, message: 'a public key is required to check a signed manifest' }; if (!verifySignature(manifestBuf, sigBuf.toString('utf8'), publicKeyPem)) { return { ok: false, message: 'signature mismatch' }; } return { ok: true, signed: true, message: 'SIGNATURE OK' }; } function argValue(argv, flag) { const index = argv.indexOf(flag); if (index === -1) return null; return argv[index + 1] || null; } async function loadPublicKey(argv) { const file = argValue(argv, '--public-key'); if (file) return fs.readFileSync(file, 'utf8'); const urlFlag = argv.includes('--public-key-url'); if (!urlFlag) return null; const url = argValue(argv, '--public-key-url') || DEFAULT_KEY_URL; const res = await fetch(url); if (!res.ok) throw new Error(`public key fetch failed (${res.status})`); return res.text(); } function positionalArgs(argv) { const out = []; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (arg === '--public-key' || arg === '--public-key-url') { i += 1; continue; } if (!arg.startsWith('-')) out.push(arg); } return out; } async function main(argv) { const target = positionalArgs(argv)[0]; if (!target) { console.error('Usage: node verify_export_bundle.js [--public-key file.pem] [--public-key-url URL]'); return 2; } const entries = readBundle(path.resolve(target)); const publicKeyPem = await loadPublicKey(argv); const result = verifyEntries(entries, publicKeyPem); if (!result.ok) { console.error(result.message); return 1; } console.log(result.message); return 0; } if (require.main === module) { main(process.argv.slice(2)) .then((code) => process.exit(code)) .catch((err) => { console.error(err.message || err); process.exit(1); }); } module.exports = { verifyEntries, readZipStore, loadPublicKey, DEFAULT_KEY_URL };