Files
safetynet/scripts/provision-broker-runtime.mjs
T

97 lines
8.2 KiB
JavaScript

// Official standalone Node executable gives the broker a distinct Windows
// application identity; Electron utilityProcess shares the browser executable.
// This provisioning helper is not yet wired into production packaging.
import {createHash} from 'node:crypto';
import {createReadStream,createWriteStream} from 'node:fs';
import {lstat,mkdir,mkdtemp,readFile,rename,writeFile,readdir} from 'node:fs/promises';
import {pipeline} from 'node:stream/promises';
import {Readable,Transform} from 'node:stream';
import {spawn} from 'node:child_process';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
export const BROKER_RUNTIME=Object.freeze({version:'24.21.0',platform:'win32',arch:'x64',
archive:'node-v24.21.0-win-x64.zip',
archiveSha256:'158f7685b44de51f6c0df1d153526cbcd3e1bc739a8dfc607721cef75de9e541',
executableSha256:'ba4e6d110e8c1592a1ecd390f6b05f3da124b13871a5be62b341a07a853c6c32',
licenseSha256:'ed34dd8e3f0a78dbaf00d0444ce8e285b015b765379c2e17880455f70370f8e9',
baseUrl:'https://nodejs.org/dist/v24.21.0/'});
function fail(code){throw Object.assign(new Error(code),{code});}
async function sha256(file){const hash=createHash('sha256');for await(const chunk of createReadStream(file))hash.update(chunk);return hash.digest('hex');}
export function verifyOfficialChecksums(text){
if(typeof text!=='string'||text.length>65536)fail('broker_runtime_checksums_invalid');
for(const [name,expected] of [[BROKER_RUNTIME.archive,BROKER_RUNTIME.archiveSha256],['win-x64/node.exe',BROKER_RUNTIME.executableSha256]]){
const rows=text.split(/\r?\n/).filter(row=>row.endsWith(' '+name));
if(rows.length!==1||rows[0]!==expected+' '+name)fail('broker_runtime_checksums_mismatch');
}
}
export async function downloadOfficialArchive(destination){
const checksumResponse=await fetch(BROKER_RUNTIME.baseUrl+'SHASUMS256.txt',{redirect:'error',signal:AbortSignal.timeout(30000)});
if(!checksumResponse.ok)fail('broker_runtime_download_failed');
const chunks=[];let checksumBytes=0;
for await(const chunk of checksumResponse.body){checksumBytes+=chunk.length;if(checksumBytes>65536)fail('broker_runtime_checksums_invalid');chunks.push(Buffer.from(chunk));}
const checksumText=Buffer.concat(chunks).toString();verifyOfficialChecksums(checksumText);
const response=await fetch(BROKER_RUNTIME.baseUrl+BROKER_RUNTIME.archive,{redirect:'error',signal:AbortSignal.timeout(120000)});
if(!response.ok)fail('broker_runtime_download_failed');
let bytes=0;const hash=createHash('sha256');
const bounded=new Transform({transform(chunk,_encoding,callback){bytes+=chunk.length;if(bytes>67108864){callback(new Error('broker_runtime_archive_too_large'));return;}hash.update(chunk);callback(null,chunk);}});
await pipeline(Readable.fromWeb(response.body),bounded,createWriteStream(destination,{flags:'wx',mode:0o600}));
if(hash.digest('hex')!==BROKER_RUNTIME.archiveSha256)fail('broker_runtime_archive_mismatch');
return {bytes,archiveSha256:BROKER_RUNTIME.archiveSha256,checksumText};
}
function psQuote(value){return "'"+value.replaceAll("'","''")+"'";}
async function extractSelected(archive,destination){
// Extract only exact reviewed members from the already hash-verified ZIP.
const script=`$ErrorActionPreference='Stop'; Add-Type -AssemblyName System.IO.Compression.FileSystem; $zip=[IO.Compression.ZipFile]::OpenRead(${psQuote(archive)}); try { foreach($name in @('node.exe','LICENSE')) { $member='node-v${BROKER_RUNTIME.version}-win-x64/'+$name; $entries=@($zip.Entries | Where-Object { $_.FullName -ceq $member }); if($entries.Count -ne 1 -or $entries[0].Length -gt 100000000) { throw 'broker_runtime_archive_members_invalid' }; $inputStream=$entries[0].Open(); $outputStream=[IO.File]::Open([IO.Path]::Combine(${psQuote(destination)},$name),[IO.FileMode]::CreateNew); try { $inputStream.CopyTo($outputStream) } finally { $outputStream.Dispose(); $inputStream.Dispose() } } } finally { $zip.Dispose() }`;
const child=spawn('powershell.exe',['-NoProfile','-NonInteractive','-EncodedCommand',Buffer.from(script,'utf16le').toString('base64')],{windowsHide:true,stdio:'ignore'});
const code=await new Promise((resolve,reject)=>{child.once('error',reject);child.once('exit',resolve);});
if(code!==0)fail('broker_runtime_extraction_failed');
}
async function verifyDirectoryAncestors(directory){
for(let current=path.resolve(directory);;current=path.dirname(current)){
const stat=await lstat(current);if(stat.isSymbolicLink()||!stat.isDirectory())fail('broker_runtime_path_unsafe');
if(path.dirname(current)===current)break;
}
}
export async function verifyBrokerRuntime(directory){
await verifyDirectoryAncestors(directory);
const manifestPath=path.join(directory,'runtime-manifest.json'),manifestStat=await lstat(manifestPath);
if(manifestStat.isSymbolicLink()||!manifestStat.isFile()||manifestStat.size>16384)fail('broker_runtime_manifest_invalid');
let manifest;try{manifest=JSON.parse(await readFile(manifestPath,'utf8'));}catch{fail('broker_runtime_manifest_invalid');}
if(!manifest||typeof manifest!=='object')fail('broker_runtime_manifest_invalid');
if(manifest.format!=='safetynet-node-broker-runtime-v1'||manifest.version!==BROKER_RUNTIME.version||manifest.arch!=='x64'||manifest.platform!=='win32'||manifest.archiveSha256!==BROKER_RUNTIME.archiveSha256)fail('broker_runtime_manifest_invalid');
if(!Array.isArray(manifest.files)||manifest.files.length!==2||manifest.files.some(file=>!file||typeof file.path!=='string')||new Set(manifest.files.map(file=>file.path)).size!==2)fail('broker_runtime_manifest_invalid');
const files=await readdir(directory);if(JSON.stringify(files.sort())!==JSON.stringify(['LICENSE','node.exe','runtime-manifest.json'].sort()))fail('broker_runtime_inventory_mismatch');
for(const name of ['node.exe','LICENSE']){
const stat=await lstat(path.join(directory,name));if(stat.isSymbolicLink()||!stat.isFile())fail('broker_runtime_path_unsafe');
const record=manifest.files?.find(item=>item.path===name);
if(!record||record.bytes!==stat.size||!/^[a-f0-9]{64}$/.test(record.sha256)||await sha256(path.join(directory,name))!==record.sha256)fail('broker_runtime_integrity_mismatch');
if(record.sha256!==(name==='node.exe'?BROKER_RUNTIME.executableSha256:BROKER_RUNTIME.licenseSha256))fail('broker_runtime_integrity_mismatch');
}
return manifest;
}
export async function provisionBrokerRuntime(workspace){
if(process.platform!=='win32'||process.arch!=='x64')fail('broker_runtime_platform_unsupported');
const parent=path.join(path.resolve(workspace),'.runtime/windows');await mkdir(parent,{recursive:true});
await verifyDirectoryAncestors(parent);
const destination=path.join(parent,'broker');
try{await lstat(destination);return await verifyBrokerRuntime(destination);}catch(error){if(error.code!=='ENOENT')throw error;}
const staging=await mkdtemp(path.join(parent,'broker-provision-'));
const archive=path.join(staging,BROKER_RUNTIME.archive),payload=path.join(staging,'payload');await mkdir(payload);
// Failed and verified archives/proof remain ignored for diagnosis; no
// unverified executable is started and no global Node installation changes.
const download=await downloadOfficialArchive(archive);
await writeFile(path.join(staging,'SHASUMS256.txt'),download.checksumText);
await extractSelected(archive,payload);
const manifest={format:'safetynet-node-broker-runtime-v1',version:BROKER_RUNTIME.version,platform:'win32',arch:'x64',archiveSha256:BROKER_RUNTIME.archiveSha256,files:[]};
for(const name of ['node.exe','LICENSE'])manifest.files.push({path:name,bytes:(await lstat(path.join(payload,name))).size,sha256:await sha256(path.join(payload,name))});
await writeFile(path.join(payload,'runtime-manifest.json'),JSON.stringify(manifest,null,2)+'\n');
await verifyBrokerRuntime(payload);await rename(payload,destination);
return await verifyBrokerRuntime(destination);
}
if(process.argv[1]&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url)){
const workspace=path.resolve(fileURLToPath(new URL('..',import.meta.url)));
const result=process.argv.includes('--verify')?await verifyBrokerRuntime(path.join(workspace,'.runtime/windows/broker')):await provisionBrokerRuntime(workspace);
console.log(JSON.stringify({version:result.version,files:result.files.length,verified:true}));
}