Files
safetynet/scripts/package-broker-resources.mjs
T

69 lines
5.6 KiB
JavaScript
Raw Normal View History

// Mandatory component packaging only; this does not activate a broker or
// install/attest its Windows network boundary.
import {createRequire} from 'node:module';
import {randomUUID} from 'node:crypto';
import {createHash} from 'node:crypto';
import {createReadStream} from 'node:fs';
import {readdir,lstat} from 'node:fs/promises';
import path from 'node:path';
import {stageBrokerPayload,BROKER_PAYLOAD,verifySourceSnapshot} from './stage-broker-payload.mjs';
import {verifyBrokerRuntime} from './provision-broker-runtime.mjs';
import {verifyBrokerPackage} from './broker-package-contract.mjs';
const require=createRequire(import.meta.url);
const RESOURCE_FROM='.runtime/windows/broker-payload',MANIFEST_FROM=RESOURCE_FROM+'.manifest.json';
const EMBEDDED_MANIFEST='apps/browser/broker-package-manifest.json';
function fail(code){throw Object.assign(new Error(code),{code});}
export function brokerResourceConfiguration(build,{payloadRoot,manifestPath}){
if(!build||!Array.isArray(build.extraResources)||!Array.isArray(build.files))fail('broker_package_configuration_missing');
const destination=value=>typeof value==='string'?value.replaceAll('\\','/').replace(/\/+$/,'').toLowerCase():'';
const resources=build.extraResources.filter(item=>item&&typeof item==='object'&&(destination(item.to)==='broker'||destination(item.to).startsWith('broker/')));
const manifests=build.files.filter(item=>item&&typeof item==='object'&&destination(item.to)===EMBEDDED_MANIFEST);
if(resources.length!==1||resources[0].from!==RESOURCE_FROM||manifests.length!==1||manifests[0].from!==MANIFEST_FROM)fail('broker_package_configuration_missing');
if(!path.isAbsolute(payloadRoot)||!path.isAbsolute(manifestPath))fail('broker_package_configuration_invalid');
return {...structuredClone(build),extraResources:build.extraResources.map(item=>item===resources[0]?{from:payloadRoot,to:'broker',filter:['**/*']}:structuredClone(item)),files:build.files.map(item=>item===manifests[0]?{from:manifestPath,to:EMBEDDED_MANIFEST}:structuredClone(item))};
}
export async function prepareBrokerResources({root,build,dependencyArchive}){
await verifyBrokerRuntime(path.join(root,'.runtime/windows/broker'));
const destination=path.join(root,'.runtime/windows','broker-payload-package-'+randomUUID());
const staged=await stageBrokerPayload({root,destination,dependencyArchive});
const configuration=brokerResourceConfiguration(build,{payloadRoot:staged.root,manifestPath:staged.root+'.manifest.json'});
return {configuration,manifest:staged.manifest,sourceSnapshot:staged.sources,
verifySources:()=>verifySourceSnapshot(root,staged.sources)};
}
export async function verifyPackagedBroker({resources,expectedManifest,readEmbeddedManifest}){
if(typeof readEmbeddedManifest!=='function')fail('broker_package_manifest_reader_required');
// Production supplies the ASAR extractor after embedded ASAR integrity/fuse
// verification. Never read expected state from resources/broker metadata.
let embedded;try{const bytes=await readEmbeddedManifest(EMBEDDED_MANIFEST);if(!Buffer.isBuffer(bytes)||bytes.length>1048576)fail('broker_package_manifest_invalid');embedded=JSON.parse(bytes.toString('utf8'));}catch{fail('broker_package_manifest_invalid');}
if(JSON.stringify(embedded)!==JSON.stringify(expectedManifest))fail('broker_package_manifest_mismatch');
return verifyBrokerPackage(path.join(resources,'broker'),embedded,BROKER_PAYLOAD);
}
export function embeddedBrokerManifestReader(archive){
const {extractFile}=require('@electron/asar');
return relative=>extractFile(archive,path.normalize(relative));
}
const scannerRoots=[['apps/inspector','inspector'],['.runtime/windows/python','python'],['.runtime/windows/semantic','semantic']];
async function fileDigest(file){const stat=await lstat(file);if(stat.isSymbolicLink()||!stat.isFile())fail('scanner_package_path_unsafe');const hash=createHash('sha256');for await(const chunk of createReadStream(file))hash.update(chunk);const after=await lstat(file);if(stat.size!==after.size||stat.mtimeMs!==after.mtimeMs||stat.ctimeMs!==after.ctimeMs)fail('scanner_package_changed');return {bytes:stat.size,sha256:hash.digest('hex')};}
async function scannerInventory(base,component,filter){
const records=[];
async function walk(directory,prefix=''){
for(const entry of await readdir(directory,{withFileTypes:true})){
if(filter&&(entry.name==='__pycache__'||component==='inspector'&&(entry.name.startsWith('test')||entry.name==='appcontainer_probe.py')))continue;
const file=path.join(directory,entry.name),relative=prefix+entry.name;
if(entry.isSymbolicLink())fail('scanner_package_path_unsafe');
if(entry.isDirectory())await walk(file,relative+'/');else if(entry.isFile())records.push({path:component+'/'+relative,...await fileDigest(file)});
else fail('scanner_package_path_unsafe');
if(records.length>20000)fail('scanner_package_too_large');
}
}
const stat=await lstat(base);if(stat.isSymbolicLink()||!stat.isDirectory())fail('scanner_package_path_unsafe');
await walk(base);return records.sort((a,b)=>a.path.localeCompare(b.path));
}
export async function snapshotScannerResources(root){
const records=[];for(const [from,to] of scannerRoots)records.push(...await scannerInventory(path.join(root,...from.split('/')),to,to!=='semantic'));
return records;
}
export async function verifyScannerResources(resources,expected){
const actual=[];for(const [,component] of scannerRoots)actual.push(...await scannerInventory(path.join(resources,component),component,false));
if(JSON.stringify(actual)!==JSON.stringify(expected))fail('scanner_package_integrity_mismatch');return true;
}