97 lines
8.2 KiB
JavaScript
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}));
|
|
}
|