// Preparation for a separately packaged broker; NOT wired into production yet. // The expected manifest must be embedded in the integrity-checked application, // never accepted from the writable broker directory. Hashes alone do not replace // installation ACLs, signed distribution, or a handle-bound process launch. import {lstat, readdir, open} from 'node:fs/promises'; import {createHash} from 'node:crypto'; import path from 'node:path'; const MAX_FILES=4096; function fail(code,failureFacts){throw Object.assign(new Error(code),{code,...(failureFacts?{failureFacts}:{})});} function version(value){return typeof value==='string'&&/^\d{1,5}\.\d{1,5}\.\d{1,5}$/.test(value);} function relative(value){ return typeof value==='string'&&value.length<=240&&!value.includes('\\')&&value.split('/').every(segment=> /^[a-zA-Z0-9_.-]+$/.test(segment)&&segment!=='.'&&segment!=='..'&&!/[. ]$/.test(segment)&& !/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(segment)); } function validate(manifest,expected){ if(!manifest||manifest.format!=='safetynet-broker-package-v1'||!version(manifest.version)|| !Number.isSafeInteger(manifest.protocol)||manifest.protocol<1||manifest.protocol>65535|| !relative(manifest.entrypoint)||!manifest.entrypoint.endsWith('.exe')|| !Array.isArray(manifest.files)||!manifest.files.length||manifest.files.length>MAX_FILES)fail('broker_manifest_invalid'); if(expected&&(manifest.version!==expected.version||manifest.protocol!==expected.protocol))fail('broker_package_incompatible'); const names=new Set(); for(const file of manifest.files){ if(!file||!relative(file.path)||!Number.isSafeInteger(file.bytes)||file.bytes<0|| !/^[a-f0-9]{64}$/.test(file.sha256)||names.has(file.path.toLowerCase()))fail('broker_manifest_invalid'); names.add(file.path.toLowerCase()); } if(!names.has(manifest.entrypoint.toLowerCase()))fail('broker_manifest_invalid'); } async function inventory(root){ const files=[];let entries=0; // Reject symlinks/junctions at every ancestor, including the supplied root. for(let current=path.resolve(root);;current=path.dirname(current)){ const stat=await lstat(current); if(stat.isSymbolicLink()||!stat.isDirectory())fail('broker_path_unsafe'); if(path.dirname(current)===current)break; } async function walk(directory,prefix=''){ for(const item of await readdir(directory,{withFileTypes:true})){ if(++entries>MAX_FILES)fail('broker_package_too_large'); const name=prefix+item.name; if(!relative(name))fail('broker_path_unsafe'); const stat=await lstat(path.join(root,...name.split('/'))); if(stat.isSymbolicLink())fail('broker_path_unsafe'); if(stat.isDirectory())await walk(path.join(directory,item.name),name+'/'); else if(stat.isFile()){ files.push(name);if(files.length>MAX_FILES)fail('broker_package_too_large'); }else fail('broker_path_unsafe'); } } await walk(root); if(new Set(files.map(file=>file.toLowerCase())).size!==files.length)fail('broker_path_unsafe'); return files.sort(); } async function digest(root,name){ const handle=await open(path.join(root,...name.split('/')),'r'); try{ const before=await handle.stat();if(!before.isFile())fail('broker_path_unsafe'); const hash=createHash('sha256');let bytes=0; for await(const chunk of handle.createReadStream({autoClose:false})){hash.update(chunk);bytes+=chunk.length;} const after=await handle.stat(); const changed=[]; if(before.size!==bytes)changed.push('size-before-read'); if(after.size!==bytes)changed.push('size-after-read'); if(before.mtimeMs!==after.mtimeMs)changed.push('modification-time'); if(before.ctimeMs!==after.ctimeMs)changed.push('change-time'); if(before.dev!==after.dev||before.ino!==after.ino)changed.push('file-identity'); // Fixed bounded facts preserve the reason for a failed check without paths, // file content, raw timestamps, OS error text or automatic retry. if(changed.length)fail('broker_package_changed',Object.freeze({changed:Object.freeze(changed)})); return {path:name,bytes,sha256:hash.digest('hex')}; }finally{await handle.close();} } export async function buildBrokerPackageManifest(root,{version:release,protocol,entrypoint}){ try{ const manifest={format:'safetynet-broker-package-v1',version:release,protocol,entrypoint,files:[]}; // Validate metadata before touching executable payloads. validate({...manifest,files:[{path:entrypoint,bytes:0,sha256:'0'.repeat(64)}]}); for(const name of await inventory(root))manifest.files.push(await digest(root,name)); validate(manifest);return manifest; }catch(error){if(error.code?.startsWith('broker_'))throw error;fail('broker_package_unavailable');} } export async function verifyBrokerPackage(root,trustedManifest,{version:release,protocol}){ try{ // Copy before await so a caller cannot mutate the trusted contract mid-check. const manifest=structuredClone(trustedManifest);validate(manifest,{version:release,protocol}); const actual=await inventory(root),expected=manifest.files.map(file=>file.path).sort(); if(JSON.stringify(actual)!==JSON.stringify(expected))fail('broker_package_inventory_mismatch'); for(const file of manifest.files){const observed=await digest(root,file.path); if(observed.bytes!==file.bytes||observed.sha256!==file.sha256)fail('broker_package_integrity_mismatch');} // Verification is an installation/startup check, not an atomic launch lease. return Object.freeze({version:manifest.version,protocol:manifest.protocol,entrypoint:path.join(path.resolve(root),...manifest.entrypoint.split('/')),files:manifest.files.length}); }catch(error){if(error.code?.startsWith('broker_'))throw error;fail('broker_package_unavailable');} }