Files
safetynet/apps/network-broker/service-store-client.cjs
T

63 lines
5.6 KiB
JavaScript

'use strict';
const fs=require('node:fs/promises');
const path=require('node:path');
const {spawn}=require('node:child_process');
const {TextDecoder}=require('node:util');
const {StateSchema}=require('./service-acceptance.cjs');
const MAX=2097152;
const SID=/^(?:S-1-5-21|S-1-12-1)-(?:(?:0|[1-9][0-9]{0,9})-){3}(?:0|[1-9][0-9]{0,9})$/;
const exact=(v,keys)=>v&&typeof v==='object'&&!Array.isArray(v)&&Object.keys(v).sort().join(',')===keys.sort().join(',');
const fail=()=>Error('broker-bootstrap-invalid');
// These paths derive exclusively from the reviewed installed module location.
// Native helper verifies its service SID and retained signed Node parent. A JSON
// SID alone is never evidence of peer authentication.
const installationRoot=path.resolve(__dirname,'../../../..');
async function readEnvelope(root,name,io=fs){
if(!['release-envelope.json','deployment-envelope.json'].includes(name))throw fail();
const cap=Math.ceil((name==='release-envelope.json'?2097152:262144)*4/3)+256;
const target=path.join(root,name);let handle;
try{
let current=path.parse(target).root;
for(const component of target.slice(current.length).split(path.sep)){current=path.join(current,component);const info=await io.lstat(current);if(info.isSymbolicLink())throw fail();}
handle=await io.open(target,'r');const info=await handle.stat();
if(!info.isFile()||info.size<2||info.size>cap)throw fail();
const bytes=Buffer.alloc(info.size+1);let count=0;
try{
while(count<info.size){const {bytesRead}=await handle.read(bytes,count,info.size-count,count);if(!bytesRead)throw fail();count+=bytesRead;}
if((await handle.read(bytes,info.size,1,info.size)).bytesRead!==0)throw fail();
const after=await handle.stat(),named=await io.lstat(target);
for(const key of ['dev','ino','size','mtimeMs','ctimeMs'])if(info[key]!==after[key]||info[key]!==named[key])throw fail();
if(named.isSymbolicLink())throw fail();
const value=JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(bytes.subarray(0,count)));if(!exact(value,['payload','signature'])||typeof value.payload!=='string'||typeof value.signature!=='string')throw fail();return value;
}finally{bytes.fill(0);}
}catch{throw fail();}finally{await handle?.close();}
}
function invokeHelper(mode,input,{launch=spawn,root=installationRoot,deadlineMs=10000}={}){
if(!['--trust-snapshot','--commit-acceptance'].includes(mode)||typeof launch!=='function'||!Number.isSafeInteger(deadlineMs)||deadlineMs<1||deadlineMs>30000)throw fail();
return new Promise((resolve,reject)=>{
let child,timer,failed=false,closed=false,total=0;const limit=mode==='--commit-acceptance'?65536:MAX,output=Buffer.alloc(limit+4);
const stop=()=>{failed=true;try{child?.kill();}catch{}};
try{
const env={SystemRoot:process.env.SystemRoot,WINDIR:process.env.WINDIR},sid=process.env.SAFETYNET_SERVICE_CLIENT_SID;
if(sid!==undefined){if(!SID.test(sid)||sid.split('-').slice(4).some(n=>Number(n)>4294967295))throw fail();env.SAFETYNET_SERVICE_CLIENT_SID=sid;}
child=launch(path.join(root,'safetynet-service-host.exe'),[mode],{cwd:root,windowsHide:true,shell:false,stdio:['pipe','pipe','ignore'],env});
timer=setTimeout(stop,deadlineMs);
child.once('error',stop);child.stdout.on('error',stop);child.stdin.on('error',stop);
child.stdout.on('data',chunk=>{if(failed)return;if(!Buffer.isBuffer(chunk)||total+chunk.length>limit+4){stop();return;}chunk.copy(output,total);total+=chunk.length;if(total>=4){const size=output.readUInt32LE(0);if(size<2||size>limit||total>size+4)stop();}});
child.once('close',(code,signal)=>{
closed=true;clearTimeout(timer);
try{if(failed||code!==0||signal||total<6||output.readUInt32LE(0)!==total-4)throw fail();const value=JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(output.subarray(4,total)));resolve(value);}catch{reject(fail());}finally{output.fill(0);}
});
if(input===undefined)child.stdin.end();else{const payload=Buffer.from(JSON.stringify(input));if(payload.length<2||payload.length>MAX)throw fail();const frame=Buffer.alloc(payload.length+4);frame.writeUInt32LE(payload.length);payload.copy(frame,4);payload.fill(0);child.stdin.end(frame,()=>frame.fill(0));}
}catch{stop();if(!child||closed){clearTimeout(timer);output.fill(0);reject(fail());}}
// Timeout cannot accept output or invent process termination. A killed child
// must emit close; an unconfirmed terminal state remains a failed pending job.
});
}
function createServiceStoreClient({launch=spawn,root=installationRoot,deadlineMs=10000}={}){
const invoke=(mode,input)=>invokeHelper(mode,input,{launch,root,deadlineMs});
const safe=fn=>async(...args)=>{try{return await fn(...args);}catch{throw fail();}};
return Object.freeze({snapshot:safe(async()=>{const value=await invoke('--trust-snapshot');if(!exact(value,['version','authenticatedSid','releaseAnchor','acceptanceState'])||value.version!==1||typeof value.authenticatedSid!=='string'||typeof value.releaseAnchor!=='string'||value.releaseAnchor.length>16384)throw fail();StateSchema.parse(value.acceptanceState);return value;}),commit:safe(async request=>{if(!exact(request,['expectedPreviousSHA','candidateState'])||!/^[a-f0-9]{64}$/.test(request.expectedPreviousSHA))throw fail();StateSchema.parse(request.candidateState);const value=await invoke('--commit-acceptance',request);if(!exact(value,['persisted','stateSha256'])||value.persisted!==true||!/^[a-f0-9]{64}$/.test(value.stateSha256))throw fail();return value;}),envelopes:safe(async()=>({releaseEnvelope:await readEnvelope(root,'release-envelope.json'),deploymentEnvelope:await readEnvelope(root,'deployment-envelope.json')}))});
}
module.exports={createServiceStoreClient,invokeHelper,installationRoot,readEnvelope};