Files
safetynet/scripts/broker-package-contract.mjs

96 lines
5.7 KiB
JavaScript

// 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');}
}