Add packaged DLP broker and protected Windows service foundation
This commit is contained in:
1 parent
95d23f4e0b
commit
7b5057901a
140 files changed
+11215
-60
No files matched your search
@@ -11,20 +11,29 @@ SafetyNet combines an Electron/Chromium desktop browser, an administrator web co
|
||||
The current milestone is the [Windows DLP MVP](docs/windows-dlp-mvp.md): enforce
|
||||
confidential-content checks before browser clipboard release and outbound text
|
||||
or file transmission, with central policy and local semantic inspection.
|
||||
Native public/blocked transfer and restart fixtures pass. Final acceptance is
|
||||
still open: a reproducible Chromium WebRTC renderer crash in initial blank
|
||||
iframes requires a verified native fix. Other platforms and broader features
|
||||
remain outside this milestone.
|
||||
S Browser uses official, unchanged Electron/Chromium releases. It does not require
|
||||
a custom Chromium build or changes to installed Chrome. Browser, DLP broker,
|
||||
private runtimes, scanners and models are intended to ship in one Windows
|
||||
installer; the new broker component package is verified separately.
|
||||
|
||||
Final acceptance is still open: the main browser must be connected to the
|
||||
protected Windows service, with verified installation and network enforcement.
|
||||
A reproducible WebRTC renderer crash remains; the existing restriction stays
|
||||
until its replacement is tested. See the [current acceptance matrix](docs/core-dlp-current-status.md)
|
||||
for completed component tests and remaining integration work. Other platforms
|
||||
and broader features remain outside this milestone.
|
||||
|
||||
[Native transport auditing](docs/native-transport-observability.md) records
|
||||
tested WebTransport lifecycle attempts without exposing raw browser debugging
|
||||
APIs. Its observation channel preserves clipboard protection; unsupported
|
||||
transport payloads remain blocked by the native relay boundary.
|
||||
APIs. Its observation channel preserves clipboard protection. These tests do
|
||||
not establish the new Windows network boundary or complete transport coverage.
|
||||
|
||||
[Bounded diagnostics](docs/diagnostics-backpressure.md) preserve authorized
|
||||
exports through policy-version updates and cancel them when permissions change.
|
||||
Current native clipboard, upload, restart, local Gemma and transport fixtures
|
||||
pass with zero lifetime export failures; historical failures remain documented.
|
||||
Controlled clipboard, upload, restart, local Gemma and transport fixtures have
|
||||
export evidence; historical failures remain documented. Production broker
|
||||
diagnostics, durable audit delivery and final installer acceptance remain separate
|
||||
requirements.
|
||||
|
||||
S Browser uses translucent materials, rounded controls, light/dark themes, and a content-first layout inspired by Safari. Website content is left unchanged.
|
||||
|
||||
@@ -194,7 +203,14 @@ npm run package:win
|
||||
npm run verify:package
|
||||
```
|
||||
|
||||
The packaging workflow produces an unpacked evaluation build in `dist/win-unpacked`. Distribute the complete folder, not the executable alone. Code signing, a production installer and a trusted update service remain unfinished.
|
||||
The existing evaluation build is an unpacked application in `dist/win-unpacked`.
|
||||
The required release deliverable is one Windows installer containing the browser,
|
||||
its separate DLP broker and private runtime, local scanners and semantic models.
|
||||
It must require no separately installed Node/Python or manual broker startup.
|
||||
That complete installation and automatic browser-to-broker integration are still
|
||||
being implemented and verified. An unpacked folder or broker payload alone does
|
||||
not prove this deliverable. Code signing and a trusted update service remain
|
||||
unfinished.
|
||||
|
||||
## Current boundaries
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
'use strict';
|
||||
const {BrokerAuditEventSchema}=require('../../packages/core/broker-audit.cjs');
|
||||
// Parent-only adapter. persistQueue must synchronously encrypt, write, fsync and
|
||||
// commit the complete queue, return undefined on success and throw on failure.
|
||||
// An OTLP send, boolean result or asynchronous enqueue is insufficient.
|
||||
// Bind current() to the authenticated child and the accepted policy generation.
|
||||
function createBrokerAuditPersistence({current,policyVersion,getQueue,persistQueue,onFailure}={}){
|
||||
if(![current,getQueue,persistQueue,onFailure].every(fn=>typeof fn==='function')||!Number.isSafeInteger(policyVersion)||policyVersion<1)throw Error('broker-audit-parent-invalid');
|
||||
let failed=false;
|
||||
const fail=()=>{const first=!failed;failed=true;if(first)try{onFailure();}catch{}throw Error('broker-audit-parent-unavailable');};
|
||||
const active=()=>{try{return !failed&¤t()===true;}catch{return false;}};
|
||||
return Object.freeze({
|
||||
persist(input){
|
||||
if(!active())return fail();
|
||||
let event,queue;
|
||||
try{
|
||||
let nodes=0;
|
||||
const plain=(value,depth=0)=>{
|
||||
if(++nodes>128||depth>6)return false;
|
||||
if(!value||typeof value!=='object')return true;
|
||||
if(![Object.prototype,null].includes(Object.getPrototypeOf(value)))return false;
|
||||
return Object.values(Object.getOwnPropertyDescriptors(value)).every(item=>'value'in item&&plain(item.value,depth+1));
|
||||
};
|
||||
if(!input||typeof input!=='object'||!plain(input))return fail();
|
||||
event=BrokerAuditEventSchema.parse(input);queue=getQueue();
|
||||
if(event.policyVersion!==policyVersion||!Array.isArray(queue)||queue.length>=10000||queue.some(item=>item.eventId===event.eventId))return fail();
|
||||
Object.freeze(event.request);if(event.file)Object.freeze(event.file);if(event.broker)Object.freeze(event.broker);Object.freeze(event);
|
||||
queue.push(event);
|
||||
const result=persistQueue();
|
||||
// Fail closed for an accidentally asynchronous writer; suppress its rejected
|
||||
// promise without issuing a receipt or retrying uncertain persistence.
|
||||
if(result&&typeof result.then==='function'){Promise.resolve(result).catch(()=>{});return fail();}
|
||||
if(result!==undefined)return fail();
|
||||
if(!active())return fail();
|
||||
return Object.freeze({persisted:true,eventId:event.eventId});
|
||||
}catch{return fail();}
|
||||
}
|
||||
});
|
||||
}
|
||||
module.exports={createBrokerAuditPersistence};
|
||||
@@ -0,0 +1,55 @@
|
||||
'use strict';
|
||||
const {X509Certificate}=require('node:crypto');
|
||||
const {isIP}=require('node:net');
|
||||
const fail=()=>{throw Error('broker-certificate-pin-rejected');};
|
||||
function authorityFacts(value){
|
||||
if(typeof value!=='string'||value.length>320||/[^\x21-\x7e]|[@/\\?#%]/.test(value))fail();
|
||||
const match=/^(\[[^\]]+\]|[^:]+):([1-9][0-9]{0,4})$/.exec(value);if(!match||Number(match[2])>65535)fail();
|
||||
let url;try{url=new URL('https://'+value+'/');}catch{fail();}
|
||||
const hostname=url.hostname.startsWith('[')?url.hostname.slice(1,-1):url.hostname;
|
||||
if(!isIP(hostname)&&hostname.split('.').some(label=>!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)))fail();
|
||||
if(`${isIP(hostname)===6?'['+hostname+']':hostname}:${Number(match[2])}`!==value)fail();
|
||||
return Object.freeze({hostname,authority:value});
|
||||
}
|
||||
/** For one managed Electron session only. Electron's verifier has NO port and
|
||||
* caches results: CONNECT/SNI/inner-Host gates must still bind the port. Revoke
|
||||
* must also close existing connections/stop the old broker before replacement.
|
||||
* Management sessions retain the platform verifier and receive no such pins.
|
||||
*/
|
||||
function createBrokerCertificatePins({generation,policyVersion,current,authorizedAuthority,maxPins=128}={}){
|
||||
if(typeof generation!=='string'||!generation||!Number.isSafeInteger(policyVersion)||policyVersion<1||typeof current!=='function'||typeof authorizedAuthority!=='function'||!Number.isInteger(maxPins)||maxPins<1||maxPins>1024)fail();
|
||||
let revoked=false;const pins=new Map();
|
||||
const active=()=>{try{return !revoked&¤t()===true;}catch{return false;}};
|
||||
const authorized=authority=>{try{return authorizedAuthority(authority)===true;}catch{return false;}};
|
||||
return Object.freeze({
|
||||
accept(input){
|
||||
if(!active()||!input||input.generation!==generation||input.policyVersion!==policyVersion||typeof input.cert!=='string'||Buffer.byteLength(input.cert)>16384||!/^-----BEGIN CERTIFICATE-----\r?\n[A-Za-z0-9+/=\r\n]+\r?\n-----END CERTIFICATE-----\s*$/.test(input.cert))fail();
|
||||
const facts=authorityFacts(input.authority);if(!authorized(facts.authority))fail();
|
||||
let certificate;try{certificate=new X509Certificate(input.cert);}catch{fail();}
|
||||
const now=Date.now(),until=Date.parse(certificate.validTo),from=Date.parse(certificate.validFrom);
|
||||
const matches=isIP(facts.hostname)?certificate.checkIP(facts.hostname):certificate.checkHost(facts.hostname,{subject:'never',wildcards:false});
|
||||
if(certificate.ca||!certificate.subjectAltName||certificate.subjectAltName.includes(',')||!matches||!certificate.verify(certificate.publicKey)||!Number.isFinite(until)||!Number.isFinite(from)||from>now||until<=now||until>now+3601000||input.fingerprint256!==certificate.fingerprint256)fail();
|
||||
for(const [key,pin]of pins)if(pin.expiresAt<=now)pins.delete(key);
|
||||
const key=facts.authority;
|
||||
if(!pins.has(key)&&pins.size>=maxPins)fail();
|
||||
if(!active()||!authorized(key))fail();
|
||||
pins.set(key,Object.freeze({...facts,fingerprint:certificate.fingerprint256,expiresAt:until}));return true;
|
||||
},
|
||||
verify(request,callback){
|
||||
let result=-3;
|
||||
try{
|
||||
// Electron exposes Certificate.data/fingerprint, not Node's fingerprint256.
|
||||
// Derive our fingerprint from the actual presented PEM, never a claim.
|
||||
const data=request?.certificate?.data,host=request?.hostname;
|
||||
if(typeof data!=='string'||Buffer.byteLength(data)>16384)throw Error('certificate-data-unavailable');
|
||||
const fingerprint=new X509Certificate(data).fingerprint256;
|
||||
const match=[...pins.values()].find(pin=>pin.hostname===host&&pin.fingerprint===fingerprint);
|
||||
if(match)result=active()&&match.expiresAt>Date.now()&&authorized(match.authority)?0:-2;
|
||||
}catch{result=-3;}
|
||||
callback(result);
|
||||
},
|
||||
revoke(){revoked=true;},
|
||||
status(){return Object.freeze({active:active(),pins:pins.size});}
|
||||
});
|
||||
}
|
||||
module.exports={createBrokerCertificatePins,authorityFacts};
|
||||
@@ -0,0 +1,34 @@
|
||||
'use strict';
|
||||
const path=require('node:path');
|
||||
const {spawn}=require('node:child_process');
|
||||
const {EventEmitter}=require('node:events');
|
||||
const {createBrokerSupervisor}=require('./broker-supervisor.cjs');
|
||||
const {createFramedChannel}=require('../network-broker/framed-channel.cjs');
|
||||
// Privileged browser construction only. Installation verification covers the
|
||||
// fixed native client as well as the signed broker payload. No renderer-supplied
|
||||
// command, path, pipe name, environment or identity enters this transport.
|
||||
function createServiceBrokerSupervisor({installationRoot,verifyInstallation,verifyPackage,persistAudit,acceptCertificate,onQuarantine,launch=spawn,deadlineMs,shutdownMs,traceOperation}={}){
|
||||
if(!path.isAbsolute(installationRoot||'')||typeof verifyInstallation!=='function'||typeof launch!=='function'||typeof verifyPackage!=='function')throw Error('broker-supervisor-invalid');
|
||||
const root=path.resolve(installationRoot),resourceRoot=path.join(root,'resources','broker');
|
||||
function connect(){
|
||||
const env={};for(const name of ['SystemRoot','WINDIR','TEMP','TMP','USERPROFILE','LOCALAPPDATA','APPDATA'])if(process.env[name])env[name]=process.env[name];
|
||||
// --client-stdio authenticates the retained actual browser parent and the
|
||||
// connected service pipe handle in native code, before forwarding frames.
|
||||
const child=launch(path.join(root,'safetynet-service-host.exe'),['--client-stdio'],{cwd:root,env,windowsHide:true,shell:false,stdio:['pipe','pipe','ignore']});
|
||||
if(!child||!child.stdin||!child.stdout||typeof child.kill!=='function')throw Error('broker-supervisor-unavailable');
|
||||
const transport=new EventEmitter();
|
||||
const channel=createFramedChannel({readable:child.stdout,writable:child.stdin,inboundFirstLimit:65536,outboundFirstLimit:2097152});
|
||||
Object.defineProperties(transport,{exitCode:{get:()=>child.exitCode},signalCode:{get:()=>child.signalCode}});
|
||||
channel.on('message',message=>transport.emit('message',message));
|
||||
channel.once('disconnect',()=>transport.emit('disconnect'));
|
||||
child.once('error',()=>{channel.disconnect();transport.emit('error',Error('broker-supervisor-unavailable'));});
|
||||
child.once('exit',(code,signal)=>{channel.disconnect();transport.emit('exit',code,signal);});
|
||||
transport.send=(message,callback)=>channel.send(message,callback);
|
||||
// Only the retained child handle is terminated. The service owns the broker
|
||||
// Job; losing this connection causes its generation to close independently.
|
||||
transport.kill=signal=>{channel.disconnect();return child.kill(signal);};
|
||||
return transport;
|
||||
}
|
||||
return createBrokerSupervisor({resourceRoot,verifyPackage:async location=>await verifyInstallation(root)===true&&await verifyPackage(location)===true,persistAudit,acceptCertificate,onQuarantine,spawn:connect,deadlineMs,shutdownMs,traceOperation});
|
||||
}
|
||||
module.exports={createServiceBrokerSupervisor};
|
||||
@@ -0,0 +1,111 @@
|
||||
'use strict';
|
||||
const {fork}=require('node:child_process');
|
||||
const path=require('node:path');
|
||||
const {randomUUID,X509Certificate}=require('node:crypto');
|
||||
const UUID=/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
||||
const ENTRY='apps/network-broker/production-entry.cjs';
|
||||
const STARTUP_CODES=new Set(['broker-bootstrap-invalid','broker-route-unsupported','broker-startup-inspector','broker-startup-certificate','broker-startup-transport','broker-startup-managed','broker-startup-failed']);
|
||||
// Parent-only: resourceRoot and verifyPackage come from the trusted embedded
|
||||
// installer contract. Never accept executable/module paths from renderer IPC.
|
||||
function createBrokerSupervisor({resourceRoot,verifyPackage,persistAudit,acceptCertificate,onQuarantine,spawn=fork,deadlineMs=15000,shutdownMs=5000,traceOperation=(_name,_attributes,run)=>run()}={}){
|
||||
if(!path.isAbsolute(resourceRoot||'')||![verifyPackage,persistAudit,acceptCertificate,onQuarantine,spawn].every(f=>typeof f==='function')||!Number.isInteger(deadlineMs)||deadlineMs<1||deadlineMs>60000)throw Error('broker-supervisor-invalid');
|
||||
if(!Number.isInteger(shutdownMs)||shutdownMs<1||shutdownMs>30000)throw Error('broker-supervisor-invalid');
|
||||
if(typeof traceOperation!=='function')throw Error('broker-supervisor-invalid');
|
||||
// Instrumentation may neither skip a protected operation nor replace its
|
||||
// result, retry it, or invoke it twice. Only fixed stage names are exported.
|
||||
async function stage(name,operation){
|
||||
let original,called=false;
|
||||
const once=()=>{if(called)throw Error('broker-diagnostics-invalid');called=true;try{original=Promise.resolve(operation());}catch(error){original=Promise.reject(error);}original.catch(()=>{});return original;};
|
||||
try{await traceOperation(name,{component:'network'},once);}catch(error){if(called)throw error;return once();}
|
||||
if(!called)once();return original;
|
||||
}
|
||||
let child,current,failed=false,closed=false,starting=false,timer,readyResolve,readyReject,lastSequence=0,stopping,pendingCertificates=0,startupCode;
|
||||
const stopChild=()=>{
|
||||
clearTimeout(timer);if(stopping)return stopping;
|
||||
const previous=child;if(!previous)return Promise.resolve();
|
||||
previous.removeAllListeners('message');
|
||||
stopping=stage('browser.broker.shutdown',()=>new Promise((resolve,reject)=>{
|
||||
let done=false;
|
||||
const finish=()=>{if(done)return;done=true;clearTimeout(limit);child=undefined;resolve();};
|
||||
const limit=setTimeout(()=>{if(done)return;done=true;failed=true;try{onQuarantine('broker-shutdown-unconfirmed');}catch{}reject(Error('broker-shutdown-unconfirmed'));},shutdownMs);
|
||||
previous.once('exit',finish);
|
||||
// ChildProcess retains ownership state; no numeric PID/taskkill operation
|
||||
// is performed after exit, avoiding a reused-PID tree kill. Native job-based
|
||||
// descendant containment remains an installer/launcher prerequisite.
|
||||
if(previous.exitCode!==null&&previous.exitCode!==undefined||previous.signalCode)finish();
|
||||
else try{previous.kill('SIGKILL');}catch{}
|
||||
}));
|
||||
// Fault callers do not await shutdown, but close() receives its real outcome.
|
||||
stopping.catch(()=>{});return stopping;
|
||||
};
|
||||
const fault=()=>{if(failed||closed)return;failed=true;stopChild();try{onQuarantine('broker-supervisor-unavailable');}catch{}readyReject?.(Error('broker-supervisor-unavailable'));};
|
||||
const send=value=>{if(!child||failed||closed)throw Error('broker-supervisor-unavailable');child.send(value,error=>{if(error)fault();});};
|
||||
const context=()=>({version:1,generation:current.generation,policyVersion:current.policyVersion});
|
||||
const exact=(m,keys)=>Object.keys(m).sort().join(',')===keys.sort().join(',');
|
||||
async function message(m,owner,generation){
|
||||
if(owner!==child||generation!==current.generation)return;
|
||||
try{
|
||||
const bytes=Buffer.byteLength(JSON.stringify(m));
|
||||
if(bytes>65536||!m||typeof m!=='object'||Array.isArray(m)||m.version!==1||m.generation!==current.generation||m.policyVersion!==current.policyVersion)throw Error();
|
||||
const common=['type','version','generation','policyVersion'];
|
||||
if(m.type==='startup-fault'){
|
||||
if(current.ready||!exact(m,[...common,'code'])||!STARTUP_CODES.has(m.code))throw Error();
|
||||
// Bounded local pre-enrollment state only; no raw errors or unauthorised
|
||||
// telemetry exporter. This never grants readiness or permits a retry.
|
||||
startupCode=m.code;fault();return;
|
||||
}
|
||||
if(m.type==='ready'){
|
||||
if(m.sequence!==0||m.authMode!=='basic'||current.ready||!exact(m,[...common,'port','credentials','authMode','sequence'])||!Number.isInteger(m.port)||m.port!==current.port||!m.credentials||!exact(m.credentials,['username','password'])||!/^safetynet-[a-f0-9]{32}$/.test(m.credentials.username)||!/^[A-Za-z0-9_-]{43}$/.test(m.credentials.password))throw Error();
|
||||
current.ready=true;clearTimeout(timer);readyResolve(Object.freeze({generation:current.generation,policyVersion:current.policyVersion,port:m.port,credentials:Object.freeze({...m.credentials})}));return;
|
||||
}
|
||||
if(!UUID.test(m.requestId)||!Number.isSafeInteger(m.sequence)||m.sequence!==lastSequence+1)throw Error();lastSequence=m.sequence;
|
||||
if(m.type==='audit'&&exact(m,[...common,'requestId','sequence','event'])){
|
||||
const receipt=persistAudit(m.event);
|
||||
if(!receipt||receipt.persisted!==true||receipt.eventId!==m.event.eventId||receipt.then)throw Error();
|
||||
if(owner!==child||generation!==current.generation)throw Error();
|
||||
send({type:'audit-receipt',...context(),requestId:m.requestId,sequence:m.sequence,receipt});return;
|
||||
}
|
||||
if(m.type==='certificate'&&exact(m,[...common,'requestId','sequence','authority','cert'])){
|
||||
if(pendingCertificates>=16)throw Error();
|
||||
if(typeof m.authority!=='string'||m.authority.length>300||typeof m.cert!=='string'||m.cert.length>16384||!/^-----BEGIN CERTIFICATE-----/.test(m.cert))throw Error();
|
||||
const certificate=new X509Certificate(m.cert);if(certificate.ca)throw Error();
|
||||
pendingCertificates++;
|
||||
const certificateDeadline=setTimeout(fault,shutdownMs);
|
||||
let accepted;
|
||||
// Admission remains occupied until the ORIGINAL callback settles even if
|
||||
// the deadline quarantines. A timed-out operation cannot free capacity.
|
||||
try{accepted=await acceptCertificate(Object.freeze({...context(),authority:m.authority,cert:m.cert,fingerprint256:certificate.fingerprint256}));}
|
||||
finally{clearTimeout(certificateDeadline);pendingCertificates--;}
|
||||
if(accepted!==true||owner!==child||generation!==current.generation)throw Error();
|
||||
send({type:'certificate-receipt',...context(),requestId:m.requestId,sequence:m.sequence,accepted:true});return;
|
||||
}
|
||||
throw Error();
|
||||
}catch{fault();}
|
||||
}
|
||||
return Object.freeze({
|
||||
async start({policyVersion,port,config}={}){
|
||||
if(failed||closed||child||starting||!Number.isSafeInteger(policyVersion)||policyVersion<1||!Number.isInteger(port)||port<1||port>65535)throw Error('broker-supervisor-invalid');
|
||||
starting=true;
|
||||
try{
|
||||
if(await stage('browser.broker.verify',()=>verifyPackage(resourceRoot))!==true)throw Error();
|
||||
if(failed||closed)throw Error();
|
||||
current={generation:randomUUID(),policyVersion,port,ready:false};lastSequence=0;
|
||||
const envelope={type:'start',...context(),config};if(Buffer.byteLength(JSON.stringify(envelope))>2097152)throw Error();
|
||||
// Allowlisted Windows runtime essentials only; inherited Node injection,
|
||||
// proxy credentials and developer environment never reach the child.
|
||||
const env={};for(const key of ['SystemRoot','WINDIR','TEMP','TMP','USERPROFILE','LOCALAPPDATA','APPDATA'])if(process.env[key])env[key]=process.env[key];
|
||||
const pending=new Promise((resolve,reject)=>{readyResolve=resolve;readyReject=reject;});
|
||||
const owner=spawn(path.join(resourceRoot,ENTRY),[],{execPath:path.join(resourceRoot,'node.exe'),cwd:resourceRoot,env,execArgv:[],windowsHide:true,stdio:['ignore','ignore','ignore','ipc'],serialization:'json'});
|
||||
child=owner;const generation=current.generation;
|
||||
owner.on('message',m=>{void message(m,owner,generation);});
|
||||
for(const event of ['error','exit','disconnect'])owner.once(event,()=>{if(owner===child)fault();});
|
||||
timer=setTimeout(fault,deadlineMs);send(envelope);return await stage('browser.broker.start',()=>pending);
|
||||
}catch{fault();throw Error('broker-supervisor-unavailable');}
|
||||
},
|
||||
async close(){closed=true;readyReject?.(Error('broker-supervisor-closed'));await stopChild();},
|
||||
// Replacement is deliberately stop-and-new-supervisor: no automatic retry
|
||||
// and no stale ready state or credentials survive a policy generation.
|
||||
status(){return Object.freeze({ready:!!current?.ready&&!failed&&!closed,failed,closed,generation:current?.generation,startupCode});}
|
||||
});
|
||||
}
|
||||
module.exports={createBrokerSupervisor};
|
||||
@@ -10,6 +10,8 @@ const {trace,ROOT_CONTEXT,SpanStatusCode,TraceFlags}=require('@opentelemetry/api
|
||||
const names=new Set(['browser.window','browser.network','browser.ipc','browser.agent','browser.management','browser.navigation','browser.dns','browser.request','browser.request.headers','browser.inspect.local','browser.inspect.enterprise','browser.download','browser.clipboard','browser.visual','browser.password.fill','browser.ai','browser.policy','browser.startup','browser.parser','browser.semantic','browser.yara','browser.icap','browser.tab','browser.security','browser.evidence']);
|
||||
const operations=new Set(['audit.deliver','evidence.observer.install','evidence.observer.drain','evidence.observer.dispose','policy.verify','policy.bookmarks','policy.agent-reconcile','policy.page-configure','policy.audit','evidence.capture','evidence.authorize','evidence.challenge','evidence.encrypt','evidence.send','evidence.ack','agent.request','agent.challenge','agent.decrypt','agent.transport','agent.authorize','agent.audit','agent.dispatch','agent.response','agent.project','agent.encrypt','library:organize-plan','library:organize-apply','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','state','authorize-setting','settings-update','window-control','chrome-layout','share-url','bookmarks-mutate','enroll','sso','navigate','back','forward','reload','ai','inspect-file','agent-connection','ai-panel','vault-panel','vault-status','vault-create','vault-unlock','vault-lock','vault-list','vault-save','vault-delete','vault-fill','vault-generate','tab-create','tab-activate','tab-close','tab-group','sessions','read','inspect','scroll','click','type','select','check','screenshot','copy','cut','paste','scan','download','sync','create','activate','close','group','policy','request','dns','parse','embed','yara','icap']);
|
||||
for(const operation of ['visual.probe','visual.capture','visual.inspect.local','visual.inspect.enterprise','visual.audit','visual.validate','visual.project','visual.release'])operations.add(operation);
|
||||
for(const operation of ['broker.request','broker.framing','broker.buffer','broker.inspect','broker.authorize','broker.forward'])operations.add(operation);
|
||||
for(const name of ['browser.broker.verify','browser.broker.start','browser.broker.shutdown'])names.add(name);
|
||||
const components=new Set(['browser','ipc','agent','management','navigation','network','dns','dlp','inspector','parser','semantic','yara','icap','download','clipboard','visual','vault','ai','policy','tabs']);
|
||||
for(const operation of ['text.local','text.enterprise'])operations.add(operation);
|
||||
for(const operation of ['history:list','history:go','window:state','window:control'])operations.add(operation);
|
||||
|
||||
+34
-14
@@ -1,20 +1,40 @@
|
||||
const {app}=require('electron');
|
||||
const {spawn}=require('node:child_process');
|
||||
const path=require('node:path');
|
||||
function capabilityCrypto(operation,input){
|
||||
const root=path.join(__dirname,'../..');
|
||||
const python=app.isPackaged?path.join(process.resourcesPath,'python/python.exe'):path.join(root,'.runtime/windows/python/python.exe');
|
||||
const script=app.isPackaged?path.join(process.resourcesPath,'inspector/dpapi.py'):path.join(root,'apps/inspector/dpapi.py');
|
||||
if(!['protect','unprotect'].includes(operation)||Buffer.byteLength(input)>32768)return Promise.reject(Error('Capability rejected'));
|
||||
function createCapabilityCrypto({packaged=false,resourcesPath,root=path.join(__dirname,'../..'),launch=spawn,environment=process.env}={}){
|
||||
const python=packaged?path.join(resourcesPath,'python/python.exe'):path.join(root,'.runtime/windows/python/python.exe');
|
||||
const script=packaged?path.join(resourcesPath,'inspector/dpapi.py'):path.join(root,'apps/inspector/dpapi.py');
|
||||
const active=new Set();
|
||||
return function convert(operation,input){
|
||||
const inputLimit=operation==='protect'?32768:65536;
|
||||
if(!['protect','unprotect'].includes(operation)||!(typeof input==='string'||Buffer.isBuffer(input))||!Buffer.byteLength(input)||Buffer.byteLength(input)>inputLimit)return Promise.reject(Error('Capability rejected'));
|
||||
if(active.size>=8)return Promise.reject(Error('Windows capability protection failed'));
|
||||
return new Promise((resolve,reject)=>{
|
||||
const environment={...process.env};for(const key of ['ADMIN_TOKEN','OIDC_CLIENT_SECRET','AI_API_KEY','INSPECTOR_TOKEN','SIEM_HTTP_TOKEN','SAFETYNET_PROXY_PASSWORD'])delete environment[key];
|
||||
const child=spawn(python,[script,operation],{windowsHide:true,stdio:['pipe','pipe','pipe'],env:environment});let output='',settled=false;
|
||||
const timer=setTimeout(()=>{child.kill();fail();},5000);
|
||||
function fail(){if(!settled){settled=true;clearTimeout(timer);reject(Error('Windows capability protection failed'));}}
|
||||
child.on('error',fail);child.stdin.on('error',fail);child.stderr.on('data',()=>{});
|
||||
child.stdout.on('data',data=>{output+=data;if(output.length>65536){child.kill();fail();}});
|
||||
child.on('close',code=>{if(code!==0||!output)return fail();if(!settled){settled=true;clearTimeout(timer);resolve(output);}});
|
||||
child.stdin.end(input);
|
||||
// Python startup hooks and inherited secrets must not enter a capability
|
||||
// helper. Isolated mode also disables user-site and Python environment paths.
|
||||
const env={};for(const key of ['SystemRoot','WINDIR','TEMP','TMP','USERPROFILE','LOCALAPPDATA','APPDATA'])if(environment[key])env[key]=environment[key];
|
||||
let child,timer,settled=false,terminal=false,bytes=0;
|
||||
const output=Buffer.alloc(operation==='protect'?65536:32768),slot={};active.add(slot);
|
||||
function fail(){
|
||||
if(settled)return;settled=true;clearTimeout(timer);output.fill(0);
|
||||
// A rejected operation does not free the slot until actual child close.
|
||||
if(child&&!terminal){try{child.kill();}catch{}child.stdin?.destroy?.();child.stdout?.destroy?.();child.stderr?.destroy?.();}
|
||||
reject(Error('Windows capability protection failed'));
|
||||
}
|
||||
try{
|
||||
child=launch(python,['-I','-B',script,operation],{windowsHide:true,shell:false,stdio:['pipe','pipe','pipe'],env});
|
||||
child.once('close',code=>{
|
||||
terminal=true;active.delete(slot);
|
||||
if(settled)return;if(code!==0||!bytes){fail();return;}
|
||||
settled=true;clearTimeout(timer);const result=output.subarray(0,bytes).toString('utf8');output.fill(0);resolve(result);
|
||||
});
|
||||
child.on('error',fail);child.stdin.on('error',fail);child.stdout.on('error',fail);child.stderr.on('error',fail);child.stderr.on('data',()=>{});
|
||||
child.stdout.on('data',data=>{if(settled)return;if(!Buffer.isBuffer(data)||bytes+data.length>output.length){fail();return;}data.copy(output,bytes);bytes+=data.length;});
|
||||
timer=setTimeout(fail,5000);child.stdin.end(input);
|
||||
}catch{if(!child)active.delete(slot);fail();}
|
||||
});
|
||||
};
|
||||
}
|
||||
module.exports={capabilityCrypto};
|
||||
let sharedConverter;
|
||||
function capabilityCrypto(operation,input){sharedConverter??=createCapabilityCrypto({packaged:app.isPackaged,resourcesPath:process.resourcesPath});return sharedConverter(operation,input);}
|
||||
module.exports={capabilityCrypto,createCapabilityCrypto};
|
||||
@@ -102,6 +102,7 @@ let message='Enroll this browser to receive company policy.',syncTimer,localInsp
|
||||
const recentRequests=new Map();
|
||||
const agentFile=()=>path.join(app.getPath('userData'),'agent-connection.json');
|
||||
const agentGrants=require('./agent-grants.cjs');
|
||||
function protectAgentCapability(value){return diagnosticSpan('browser.credential',{operation:'credential.issue',component:'agent',bytes:Buffer.byteLength(value)},()=>require('./dpapi.cjs').capabilityCrypto('protect',value));}
|
||||
const agentActorStorage=new (require('node:async_hooks').AsyncLocalStorage)();
|
||||
let agentServiceMode;
|
||||
function effectiveAgentPolicy(){if(!policy)return policy;const {grants}=agentGrants.reconcileGenerationFloor(policy.agents.grants||[],device?.agentGenerationFloor||{});return {...policy,agents:{...policy.agents,grants}};}
|
||||
@@ -121,7 +122,7 @@ async function pairIndividualAgent(principalId){
|
||||
const grant=approvedAgentGrants().find(g=>g.id===principalId),initial=policy,service=agentService;
|
||||
if(!grant||!service||agentServiceMode!=='individual')throw Error('Agent grant unavailable');
|
||||
const connection=service.issuePrincipal(grant.id,grant.generation,grant.expiresAt);
|
||||
const credentialPath=await agentGrants.persistConnection({filePath:agentGrants.descriptorPath(app.getPath('userData'),grant.id),connection,protect:value=>require('./dpapi.cjs').capabilityCrypto('protect',value),isCurrent:()=>ready&&policy===initial&&agentService===service&&featureAllowed(managedSettings(),'agents')&&actorGrantAllowed({principalId:grant.id,generation:grant.generation},grant.scopes[0])});
|
||||
const credentialPath=await agentGrants.persistConnection({filePath:agentGrants.descriptorPath(app.getPath('userData'),grant.id),connection,protect:protectAgentCapability,isCurrent:()=>ready&&policy===initial&&agentService===service&&featureAllowed(managedSettings(),'agents')&&actorGrantAllowed({principalId:grant.id,generation:grant.generation},grant.scopes[0])});
|
||||
return {address:service.address,credentialPath,principalId:grant.id,generation:grant.generation};
|
||||
}
|
||||
|
||||
@@ -457,7 +458,7 @@ async function ensureAgentAPI(){
|
||||
if(!checkURL(url))return {allowed:false};
|
||||
return {allowed:true,role:'agent',scopes:[scope]};
|
||||
},audit:auditAgent,
|
||||
persistCredential:connection=>agentGrants.persistConnection({filePath:agentFile(),connection,protect:value=>require('./dpapi.cjs').capabilityCrypto('protect',value),isCurrent:()=>ready&&policy?.agents.identityMode==='legacy'&&policy.agents.enabled})
|
||||
persistCredential:connection=>agentGrants.persistConnection({filePath:agentFile(),connection,protect:protectAgentCapability,isCurrent:()=>ready&&policy?.agents.identityMode==='legacy'&&policy.agents.enabled})
|
||||
});emit();
|
||||
}finally{agentStarting=false;}
|
||||
}
|
||||
|
||||
@@ -24,10 +24,15 @@ def convert(data,protect):
|
||||
if __name__=='__main__':
|
||||
try:
|
||||
if sys.platform!='win32' or len(sys.argv)!=2 or sys.argv[1] not in ['protect','unprotect']: raise ValueError()
|
||||
data=sys.stdin.buffer.read(32769)
|
||||
if not data or len(data)>32768:raise ValueError()
|
||||
limit=32768 if sys.argv[1]=='protect' else 65536
|
||||
data=sys.stdin.buffer.read(limit+1)
|
||||
if not data or len(data)>limit:raise ValueError()
|
||||
if sys.argv[1]=='protect':output=base64.b64encode(convert(data,True))
|
||||
else:output=convert(base64.b64decode(data,validate=True),False)
|
||||
else:
|
||||
decoded=base64.b64decode(data,validate=True)
|
||||
if base64.b64encode(decoded)!=data:raise ValueError()
|
||||
output=convert(decoded,False)
|
||||
if len(output)>(65536 if sys.argv[1]=='protect' else 32768):raise ValueError()
|
||||
sys.stdout.buffer.write(output)
|
||||
except Exception:
|
||||
sys.stderr.write('Windows capability protection failed.\n');sys.exit(1)
|
||||
@@ -7,10 +7,13 @@ import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import os
|
||||
import threading
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
sys.dont_write_bytecode=True
|
||||
from windows_appcontainer import AppContainer, SandboxError
|
||||
from windows_containment import contain_inspector
|
||||
|
||||
|
||||
def main():
|
||||
@@ -26,8 +29,21 @@ def main():
|
||||
raise SandboxError('Use the private Python belonging to this installation')
|
||||
roots = [source, runtime]
|
||||
if semantic.is_dir(): roots.append(semantic)
|
||||
sandbox = AppContainer(roots, provision=args.provision)
|
||||
print(json.dumps({'verified': True, 'provisioned': args.provision, 'rootCount': len(roots), 'profile': sandbox.name}))
|
||||
deadline = None
|
||||
if args.provision:
|
||||
# Installer-only: closing/killing this process terminates grant children.
|
||||
# nsExec's own timeout renews on output, so enforce our own hard deadline.
|
||||
containment = contain_inspector()
|
||||
if containment.get('active') is not True or containment.get('killsDescendants') is not True:
|
||||
raise SandboxError('Installer resource containment unavailable')
|
||||
deadline = threading.Timer(240, lambda: os._exit(2))
|
||||
deadline.daemon = True
|
||||
deadline.start()
|
||||
try:
|
||||
sandbox = AppContainer(roots, provision=args.provision)
|
||||
print(json.dumps({'verified': True, 'provisioned': args.provision, 'rootCount': len(roots), 'profile': sandbox.name}))
|
||||
finally:
|
||||
if deadline is not None: deadline.cancel()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
'use strict';
|
||||
const {randomUUID}=require('node:crypto');
|
||||
const {isIP}=require('node:net');
|
||||
const {z}=require('zod');
|
||||
const {FileFactsSchema}=require('../../packages/core/transfer-facts.cjs');
|
||||
const {RequestFactsSchema}=require('../../packages/core/request-transfer-facts.cjs');
|
||||
const {parseAuthority}=require('./tls-authority.cjs');
|
||||
const {GateError}=require('./http-request-gate.cjs');
|
||||
const {BrokerAuditEventSchema,BrokerFactsSchema,BROKER_REASONS}=require('../../packages/core/broker-audit.cjs');
|
||||
const reasons=BROKER_REASONS;
|
||||
const builtinRuleIds=Object.freeze(['private-key','aws-access-key','api-secret','content-blocklist','size-limit','uninspectable','normalization-limit','file-transfer-size-limit','file-transfer-type-blocked','file-transfer-type-not-allowed','file-transfer-unknown','file-transfer-extension-mismatch','file-transfer-mime-mismatch','inspection-incomplete']);
|
||||
const FactsSchema=RequestFactsSchema.extend({host:z.string().max(253).optional(),reason:z.enum(reasons).optional(),matchedRuleId:z.string().min(1).max(100).optional(),broker:BrokerFactsSchema.optional()}).strict();
|
||||
// Existing /api/device/events-compatible subset. Network request digests are
|
||||
// intentionally absent: bodies can contain passwords or credential envelopes.
|
||||
const fail=code=>{throw new GateError(code);};
|
||||
function freeze(event){if(event.request)Object.freeze(event.request);if(event.file)Object.freeze(event.file);if(event.broker)Object.freeze(event.broker);return Object.freeze(event);}
|
||||
/** persist is a trusted durable store adapter, not a telemetry exporter. Its
|
||||
* receipt is issued ONLY after persistence succeeds; an asynchronous queue
|
||||
* enqueue or successful OTLP request is not a durability acknowledgment.
|
||||
* No retry occurs when persistence outcome is uncertain.
|
||||
*/
|
||||
function createBrokerAudit({persist,current,policyVersion,mandatory=true,correlation,allowedRuleIds=[]}={}){
|
||||
if(typeof current!=='function'||!Number.isSafeInteger(policyVersion)||policyVersion<1||typeof mandatory!=='boolean'||persist!==undefined&&typeof persist!=='function'||correlation!==undefined&&typeof correlation!=='function')fail('audit-configuration-invalid');
|
||||
if(!Array.isArray(allowedRuleIds)||allowedRuleIds.length>20000||allowedRuleIds.some(id=>typeof id!=='string'||!id||id.length>100||[...id].some(character=>character.charCodeAt(0)<32||character.charCodeAt(0)===127)))fail('audit-configuration-invalid');
|
||||
// Copy the trusted signed-policy IDs; callers cannot expand authorization
|
||||
// later by mutating their original list. These are IDs, never rule bodies.
|
||||
const knownRules=new Set([...builtinRuleIds,...allowedRuleIds]);
|
||||
const isCurrent=()=>{try{return current()===true;}catch{return false;}};
|
||||
return Object.freeze({
|
||||
async recordDownload(decision,input){
|
||||
const schema=FileFactsSchema.extend({host:z.string().max(253).optional(),matchedRuleId:z.string().min(1).max(100).optional(),broker:BrokerFactsSchema}).strict();
|
||||
let facts;try{if(!input||Object.values(Object.getOwnPropertyDescriptors(input)).some(value=>!('value'in value)))fail('audit-facts-invalid');facts=schema.parse(input);}catch{fail('audit-facts-invalid');}
|
||||
if(!['allow','block'].includes(decision)||facts.matchedRuleId&&(decision==='allow'||!knownRules.has(facts.matchedRuleId)))fail('audit-facts-invalid');
|
||||
if(facts.host!==undefined){try{if(parseAuthority(isIP(facts.host)===6?'['+facts.host+']':facts.host).hostname!==facts.host)fail('audit-facts-invalid');}catch{fail('audit-facts-invalid');}}
|
||||
if(decision==='allow'&&!isCurrent())fail('authority-expired');if(!persist)fail('audit-unavailable');
|
||||
let event;try{event=freeze(BrokerAuditEventSchema.parse({eventId:randomUUID(),kind:decision==='allow'?'download.scan':'download.block',ruleId:facts.matchedRuleId||facts.broker.reason,policyVersion,...(facts.host?{host:facts.host}:{}),file:{bytes:facts.bytes,detectedType:facts.detectedType,sha256:facts.sha256},broker:facts.broker}));}catch{fail('audit-facts-invalid');}
|
||||
let receipt;try{receipt=await persist(event);if(!receipt||Object.values(Object.getOwnPropertyDescriptors(receipt)).some(value=>!('value'in value))||receipt.persisted!==true||receipt.eventId!==event.eventId)fail('audit-persistence-failed');}catch{fail('audit-persistence-failed');}
|
||||
if(decision==='allow'&&!isCurrent()){const error=new GateError('authority-expired');error.committedEventId=event.eventId;throw error;}return Object.freeze({persisted:true,eventId:event.eventId});
|
||||
},
|
||||
async record(decision,input){
|
||||
if(!['allow','block','fault'].includes(decision))fail('audit-facts-invalid');
|
||||
let parsed;
|
||||
try{
|
||||
if(!input||typeof input!=='object'||![Object.prototype,null].includes(Object.getPrototypeOf(input))||Object.values(Object.getOwnPropertyDescriptors(input)).some(descriptor=>!('value'in descriptor)))fail('audit-facts-invalid');
|
||||
parsed=FactsSchema.safeParse(input);
|
||||
}catch{fail('audit-facts-invalid');}
|
||||
if(!parsed.success)fail('audit-facts-invalid');
|
||||
const facts=parsed.data;
|
||||
if(facts.matchedRuleId&&(decision==='allow'||!knownRules.has(facts.matchedRuleId)))fail('audit-facts-invalid');
|
||||
const permittedReasons=facts.broker?{allow:['broker-approved'],block:['broker-scan-timeout','broker-policy-block','broker-authority-expired','broker-connect-denied','broker-auth-denied','broker-framing-block','broker-size-block','broker-request-aborted','broker-request-timeout'],fault:['broker-scan-timeout','broker-inspection-fault','broker-audit-failed','broker-upstream-failed','broker-response-invalid','broker-request-aborted','broker-request-timeout','broker-authority-expired']}:{allow:['broker-approved'],block:['broker-policy-block','broker-authority-expired'],fault:['broker-inspection-fault','broker-audit-failed']};
|
||||
if(facts.reason&&!permittedReasons[decision].includes(facts.reason))fail('audit-facts-invalid');
|
||||
if(facts.host!==undefined){try{if(parseAuthority(isIP(facts.host)===6?'['+facts.host+']':facts.host).hostname!==facts.host)fail('audit-facts-invalid');}catch{fail('audit-facts-invalid');}}
|
||||
if(decision==='allow'&&!isCurrent())fail('authority-expired');
|
||||
if(!persist){if(mandatory)fail('audit-unavailable');return Object.freeze({persisted:false});}
|
||||
const trace={};
|
||||
try{
|
||||
const ids=correlation?.();
|
||||
if(ids&&/^[a-f0-9]{32}$/.test(ids.traceId)&&!/^(0){32}$/.test(ids.traceId)&&/^[a-f0-9]{16}$/.test(ids.spanId)&&!/^(0){16}$/.test(ids.spanId))Object.assign(trace,{traceId:ids.traceId,spanId:ids.spanId});
|
||||
}catch{}
|
||||
// Server schema has one ruleId and no separate reason field. Preserve a
|
||||
// verified matched detector ID there; otherwise use the fixed reason.
|
||||
// Matched IDs are not supplied to diagnostic traces by this helper.
|
||||
let event;try{event=freeze(BrokerAuditEventSchema.parse({eventId:randomUUID(),kind:decision==='allow'?'upload.authorized':'upload.block',ruleId:facts.matchedRuleId||facts.reason||(decision==='allow'?'broker-approved':decision==='block'?'broker-policy-block':'broker-inspection-fault'),policyVersion,...(facts.host?{host:facts.host}:{}),request:{bytes:facts.bytes,detectedType:facts.detectedType},...(facts.broker?{broker:Object.freeze({...facts.broker})}:{}),...trace}));}catch{fail('audit-facts-invalid');}
|
||||
let receipt;try{receipt=await persist(event);}catch{fail('audit-persistence-failed');}
|
||||
try{
|
||||
if(!receipt||Object.values(Object.getOwnPropertyDescriptors(receipt)).some(descriptor=>!('value'in descriptor))||receipt.persisted!==true||receipt.eventId!==event.eventId)fail('audit-persistence-failed');
|
||||
}catch{fail('audit-persistence-failed');}
|
||||
if(decision==='allow'&&!isCurrent()){const error=new GateError('authority-expired');error.committedEventId=event.eventId;throw error;}
|
||||
return Object.freeze({persisted:true,eventId:event.eventId});
|
||||
}
|
||||
});
|
||||
}
|
||||
module.exports={createBrokerAudit,BrokerAuditEventSchema};
|
||||
@@ -0,0 +1,13 @@
|
||||
'use strict';
|
||||
const {GateError}=require('./http-request-gate.cjs');
|
||||
// A separate, server-global control budget prevents an unresponsive scan from
|
||||
// starving its terminal audit while still bounding all unfinished commits.
|
||||
async function recordLifecycle({admission,deadlineMs,context,code,facts,scope}){
|
||||
if(!context)return;
|
||||
let timer;
|
||||
try {
|
||||
const original=admission.run(()=>context.reject(code,facts,scope));
|
||||
return await Promise.race([original,new Promise((_,reject)=>{timer=setTimeout(()=>reject(new GateError('audit-deadline')),deadlineMs);})]);
|
||||
} finally {clearTimeout(timer);}
|
||||
}
|
||||
module.exports={recordLifecycle};
|
||||
@@ -0,0 +1,64 @@
|
||||
'use strict';
|
||||
const http=require('node:http');
|
||||
const {randomBytes,timingSafeEqual}=require('node:crypto');
|
||||
const {bindConnectAuthority}=require('./tls-authority.cjs');
|
||||
const {createInspectionServer}=require('./https-inspection-server.cjs');
|
||||
const {GateError}=require('./http-request-gate.cjs');
|
||||
const {createWorkAdmission}=require('./work-admission.cjs');
|
||||
const {recordLifecycle}=require('./bounded-audit.cjs');
|
||||
|
||||
/** Isolated authenticated CONNECT-to-inspection chain. Scoped certificates
|
||||
* are supplied by a trusted resolver, never generated/trusted globally here.
|
||||
* No opaque tunnel or target connection is opened by this acceptor.
|
||||
*/
|
||||
function createConnectProxy({authority,current,resolveCertificate,inspect,forward,deadlineMs=5000,maxClients=8,maxBytes=1048576,maxResponseBytes=1048576,authMode='basic',lifecycle}) {
|
||||
if(typeof authority!=='string'||!authority||typeof current!=='function'||typeof resolveCertificate!=='function'||typeof inspect!=='function'||typeof forward!=='function'||!Number.isSafeInteger(deadlineMs)||deadlineMs<1||deadlineMs>60000||!Number.isSafeInteger(maxClients)||maxClients<1||maxClients>64||!Number.isSafeInteger(maxBytes)||maxBytes<0||maxBytes>16777216||!Number.isSafeInteger(maxResponseBytes)||maxResponseBytes<0||maxResponseBytes>16777216)throw new GateError('invalid-configuration');
|
||||
if(lifecycle!==undefined&&(!lifecycle||typeof lifecycle.begin!=='function'))throw new GateError('invalid-configuration');
|
||||
if(!['basic','bearer'].includes(authMode))throw new GateError('invalid-configuration');
|
||||
const capability=randomBytes(32).toString('base64url'),username=`safetynet-${randomBytes(16).toString('hex')}`;
|
||||
const credentials=Object.freeze({username,password:capability});
|
||||
const expected=Buffer.from(authMode==='basic'?`Basic ${Buffer.from(`${username}:${capability}`).toString('base64')}`:`Bearer ${capability}`);
|
||||
const sockets=new Set(),legs=new Set();
|
||||
const admission=createWorkAdmission({maxJobs:maxClients});
|
||||
const auditAdmission=createWorkAdmission({maxJobs:maxClients}),contexts=new WeakMap();
|
||||
const contextFor=socket=>{if(!lifecycle)return undefined;if(!contexts.has(socket))contexts.set(socket,lifecycle.begin());return contexts.get(socket);};
|
||||
const rejected=(context,code,facts={bytes:0,detectedType:'unknown'})=>recordLifecycle({admission:auditAdmission,deadlineMs,context,code,facts,scope:'connect'});
|
||||
const server=http.createServer({maxHeaderSize:8192},(req,res)=>{void rejected(contextFor(req.socket),'unsupported-method').catch(()=>{}).finally(()=>{if(!res.destroyed){res.writeHead(403,{'connection':'close'});res.end('connect-required');}});});
|
||||
const reject=(socket,status,code)=>socket.end(`HTTP/1.1 ${status}\r\n${status.startsWith('407')?`Proxy-Authenticate: ${authMode==='basic'?'Basic realm="SafetyNet private broker", charset="UTF-8"':'Bearer realm="SafetyNet private broker"'}\r\n`:''}Connection: close\r\nContent-Length: ${Buffer.byteLength(code)}\r\n\r\n${code}`);
|
||||
server.on('connect',async(req,socket,head)=>{
|
||||
socket.pause();
|
||||
let context,binding;
|
||||
try {
|
||||
context=contextFor(socket);
|
||||
const headers=Object.create(null);
|
||||
for(let index=0;index<req.rawHeaders.length;index+=2){const name=req.rawHeaders[index].toLowerCase();if(Object.hasOwn(headers,name))throw new GateError('duplicate-header');headers[name]=req.rawHeaders[index+1];}
|
||||
const received=Buffer.from(headers['proxy-authorization']||'');
|
||||
if(received.length!==expected.length||!timingSafeEqual(received,expected)){await rejected(context,'authentication-required');reject(socket,'407 Proxy Authentication Required','authentication-required');return;}
|
||||
if(head.length||headers['transfer-encoding']!==undefined||headers['content-length']!==undefined||headers.upgrade!==undefined)throw new GateError('unsupported-connect-framing');
|
||||
binding=bindConnectAuthority(req.url);
|
||||
if(headers.host!==binding.authority)throw new GateError('authority-mismatch');
|
||||
if(current(authority)!==true)throw new GateError('authority-expired');
|
||||
let timer;
|
||||
const resolverController=new AbortController(),cancelResolver=()=>resolverController.abort();
|
||||
socket.once('close',cancelResolver);
|
||||
const resolved=await Promise.race([admission.run(()=>resolveCertificate(binding,{signal:resolverController.signal})),new Promise((_,rejectPromise)=>{timer=setTimeout(()=>{resolverController.abort();rejectPromise(new GateError('certificate-deadline'));},deadlineMs);})]).finally(()=>{clearTimeout(timer);socket.removeListener('close',cancelResolver);resolverController.abort();});
|
||||
if(socket.destroyed||current(authority)!==true)throw new GateError('authority-expired');
|
||||
if(!resolved||resolved.authority!==binding.authority)throw new GateError('destination-unapproved');
|
||||
const leg=createInspectionServer({key:resolved.key,cert:resolved.cert,binding,authority,current,inspect,forward,deadlineMs,maxClients,maxBytes,maxResponseBytes,workAdmission:admission,lifecycle,auditAdmission,connectionContext:context});
|
||||
legs.add(leg);socket.once('close',()=>{legs.delete(leg);void leg.close().catch(()=>{});});
|
||||
socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
||||
leg.acceptAuthenticatedSocket(socket);socket.resume();
|
||||
} catch(error) {
|
||||
try {await rejected(context,error instanceof GateError?error.code:'connect-denied',{bytes:0,detectedType:'unknown',...(binding?{host:binding.hostname}:{})});}catch{}
|
||||
if(!socket.destroyed)reject(socket,'403 Forbidden','connect-denied');
|
||||
}
|
||||
});
|
||||
server.maxConnections=maxClients;server.maxHeadersCount=32;server.headersTimeout=deadlineMs;server.requestTimeout=deadlineMs;
|
||||
server.on('connection',socket=>{sockets.add(socket);socket.setTimeout(deadlineMs*3,()=>socket.destroy());socket.once('close',()=>sockets.delete(socket));});
|
||||
server.on('clientError',(_error,socket)=>{void rejected(contextFor(socket),'invalid-headers').catch(()=>{}).finally(()=>socket.destroy());});
|
||||
return Object.freeze({capability,authMode,credentials,
|
||||
async listen(port=0){if(!Number.isInteger(port)||port<0||port>65535)throw new GateError('invalid-configuration');await new Promise((resolve,rejectPromise)=>{server.once('error',rejectPromise);server.listen(port,'127.0.0.1',()=>{server.removeListener('error',rejectPromise);resolve();});});return server.address().port;},
|
||||
async close(){admission.close();auditAdmission.close();for(const socket of sockets)socket.destroy();await Promise.all([...legs].map(leg=>leg.close()));await new Promise(resolve=>server.close(resolve));}
|
||||
});
|
||||
}
|
||||
module.exports={createConnectProxy};
|
||||
@@ -0,0 +1,28 @@
|
||||
'use strict';
|
||||
// Trusted caller supplies its already policy-authorized diagnostics instance.
|
||||
// This adapter neither creates an exporter nor configures/enables diagnostics.
|
||||
const stages=Object.freeze({
|
||||
request:['browser.network','broker.request'],
|
||||
framing:['browser.request','broker.framing'],
|
||||
buffer:['browser.request','broker.buffer'],
|
||||
inspect:['browser.request','broker.inspect'],
|
||||
authorize:['browser.network','broker.authorize'],
|
||||
forward:['browser.network','broker.forward']
|
||||
});
|
||||
function createBrokerDiagnostics(diagnostics){
|
||||
if(!diagnostics||typeof diagnostics.span!=='function')throw new TypeError('broker_diagnostics_invalid');
|
||||
const span=diagnostics.span.bind(diagnostics);
|
||||
return Object.freeze({
|
||||
wrap(stage,fn,input={}){
|
||||
if(!Object.hasOwn(stages,stage)||typeof fn!=='function')throw new TypeError('broker_diagnostics_stage_invalid');
|
||||
const [name,operation]=stages[stage],attributes={operation};
|
||||
// Data descriptors only: never evaluate untrusted telemetry getters.
|
||||
if(input&&typeof input==='object')for(const key of ['bytes','count','policyVersion']){
|
||||
const descriptor=Object.getOwnPropertyDescriptor(input,key);
|
||||
if(descriptor&&'value'in descriptor&&Number.isSafeInteger(descriptor.value)&&descriptor.value>=0)attributes[key]=descriptor.value;
|
||||
}
|
||||
return span(name,attributes,fn);
|
||||
}
|
||||
});
|
||||
}
|
||||
module.exports={createBrokerDiagnostics};
|
||||
@@ -0,0 +1,24 @@
|
||||
'use strict';
|
||||
const {isIP}=require('node:net');
|
||||
const dns=require('node:dns').promises;
|
||||
// Resolve once in the broker and bind the approved numeric address to the dial.
|
||||
// Active IP policy uses this approved tuple for numeric proxy CONNECT; ordinary
|
||||
// hostname CONNECT remains supported when that origin IP policy is inactive.
|
||||
function createDnsPolicyRoute({lookup=dns.lookup,blocked,current}={}){
|
||||
if(typeof lookup!=='function'||typeof blocked!=='function'||typeof current!=='function')throw Error('broker-route-denied');
|
||||
return async({hostname,port,signal})=>{
|
||||
const check=()=>{if(signal?.aborted||current()!==true)throw Error('broker-route-denied');};
|
||||
try{
|
||||
check();if(typeof hostname!=='string'||!hostname||hostname.length>253||!Number.isSafeInteger(port)||port<1||port>65535)throw Error();
|
||||
const literal=hostname.replace(/^\[|\]$/g,'');
|
||||
if(isIP(literal)){if(blocked(literal))throw Error();check();return Object.freeze({approved:true,address:literal,port});}
|
||||
const records=await lookup(hostname,{all:true,verbatim:true});check();
|
||||
if(!Array.isArray(records)||!records.length||records.length>64||records.some(r=>!r||typeof r.address!=='string'||!isIP(r.address)||blocked(r.address)))throw Error();
|
||||
const address=records[0].address;check();return Object.freeze({approved:true,address,port});
|
||||
}catch{throw Error('broker-route-denied');}
|
||||
};
|
||||
}
|
||||
function assertSupportedDns(policy,network,{numericOriginSupported=false}={}){
|
||||
if(policy.enterprise?.dns?.mode==='secure'||!numericOriginSupported&&network.mode==='fixed_servers'&&policy.security?.dns?.enabled!==false&&policy.security?.dns?.blockedIPs?.length)throw Error('broker-route-unsupported');
|
||||
}
|
||||
module.exports={createDnsPolicyRoute,assertSupportedDns};
|
||||
@@ -0,0 +1,46 @@
|
||||
'use strict';
|
||||
const {createHash}=require('node:crypto');
|
||||
const {GateError}=require('./http-request-gate.cjs');
|
||||
const {validateResponse}=require('./http-response-gate.cjs');
|
||||
const {detectType,inspectFileTransfer}=require('../../packages/core/file-transfer.cjs');
|
||||
const {scanDownloadEnterprise}=require('../browser/enterprise-inspection.cjs');
|
||||
const digest=bytes=>createHash('sha256').update(bytes).digest('hex');
|
||||
function filename(value,url){
|
||||
if(value===undefined){try{return decodeURIComponent(new URL(url).pathname.split('/').pop());}catch{throw new GateError('invalid-upstream-response');}}
|
||||
if(typeof value!=='string'||value.length>8192||!/^\s*(attachment|inline)(?:\s*;|\s*$)/i.test(value))throw new GateError('invalid-upstream-response');
|
||||
const matches=[...value.matchAll(/;\s*filename(\*)?\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/gi)];
|
||||
if(matches.length>1)throw new GateError('invalid-upstream-response');if(!matches.length)return '';
|
||||
const match=matches[0],text=match[2]!==undefined?match[2].replace(/\\(.)/g,'$1'):match[3];
|
||||
if(match[1]){if(!/^UTF-8''/i.test(text))throw new GateError('invalid-upstream-response');try{return decodeURIComponent(text.slice(7));}catch{throw new GateError('invalid-upstream-response');}}return text;
|
||||
}
|
||||
/** A full buffered response is held until document/download analysis succeeds.
|
||||
* Incoming HTML/API text is not classified as outgoing company data. Partial
|
||||
* and compressed representations are unsupported, never counted as covered.
|
||||
*/
|
||||
function createDownloadResponseGate({forward,policy,current,inspector,enterpriseScanner,maxBytes=16777216,auditRequired=true}={}){
|
||||
if(typeof auditRequired!=='boolean'||typeof forward!=='function'||typeof current!=='function'||!policy||!Number.isSafeInteger(maxBytes)||maxBytes<0||maxBytes>16777216)throw new GateError('invalid-configuration');
|
||||
const check=signal=>{if(signal?.aborted)throw new GateError('aborted');if(current()!==true)throw new GateError('authority-expired');};
|
||||
return async input=>{
|
||||
check(input.signal);if(auditRequired&&typeof input.auditContext?.response!=='function')throw new GateError('audit-unavailable');const upstream=await forward(input);check(input.signal);
|
||||
const response=validateResponse(upstream,maxBytes);let scanCopy;
|
||||
const known=new Set([...(policy.dlp?.rules||[]).map(rule=>rule.id),...(policy.dlp?.semantic?.categories||[]).map(rule=>rule.id),...(policy.security?.yara?.rules||[]).map(rule=>rule.id)]);
|
||||
const record=async(decision,reason,ruleId)=>{if(input.auditContext?.response)await input.auditContext.response(decision,{bytes:response.body.length,detectedType:detectType(response.body),sha256:digest(response.body),host:new URL(input.request.url).hostname.replace(/^\[|\]$/g,''),reason,...(known.has(ruleId)?{matchedRuleId:ruleId}:{})});else if(auditRequired)throw new GateError('audit-unavailable');check(input.signal);};
|
||||
try{
|
||||
if(response.statusCode===206)throw new GateError('invalid-upstream-response');
|
||||
const disposition=response.headers['content-disposition'],type=detectType(response.body),mime=response.headers['content-type'];
|
||||
const claimed=filename(disposition,input.request.url);
|
||||
const document=type!=='text'||disposition!==undefined||/^(?:application\/(?:pdf|zip|octet-stream|msword|vnd\.)|image\/)/i.test(mime||'');
|
||||
if(!document)return response;
|
||||
if(/^image\/svg\+xml(?:;|$)/i.test(mime||'')){await record('block','broker-download-inspection-block');throw new GateError('download-inspection-blocked');}
|
||||
if(/^\s*attachment(?:\s*;|\s*$)/i.test(disposition||'')&&policy.browser.allowDownloads!==true){await record('block','broker-download-policy-block');throw new GateError('download-policy-blocked');}
|
||||
const verdict=inspectFileTransfer(response.body,policy,'download',{filename:claimed,mime});if(verdict.decision!=='allow'){await record('block','broker-download-policy-block',verdict.ruleId);throw new GateError('download-policy-blocked');}
|
||||
input.markStage?.('scan');scanCopy=Buffer.from(response.body);const originalDigest=digest(scanCopy);
|
||||
const result=await scanDownloadEnterprise(scanCopy,policy,inspector,enterpriseScanner);check(input.signal);
|
||||
if(digest(scanCopy)!==originalDigest||result?.decision!=='allow'||result.coverage?.complete!==true||result.sha256!==originalDigest||result.bytes!==scanCopy.length){await record('block','broker-download-inspection-block',digest(scanCopy)===originalDigest?result?.ruleId:undefined);throw new GateError('download-inspection-blocked');}
|
||||
input.markStage?.('forward');await record('allow','broker-download-approved');
|
||||
return response;
|
||||
}catch(error){response.body.fill(0);if(error instanceof GateError)throw error;throw new GateError('download-inspection-blocked');}
|
||||
finally{scanCopy?.fill(0);}
|
||||
};
|
||||
}
|
||||
module.exports={createDownloadResponseGate};
|
||||
@@ -0,0 +1,68 @@
|
||||
'use strict';
|
||||
const {EventEmitter}=require('node:events');
|
||||
const {TextDecoder}=require('node:util');
|
||||
const SMALL=65536,LARGE=2097152;
|
||||
// Transport only: the native host and parent must authenticate their pipe peer
|
||||
// before constructing this channel. Framing does not grant policy authority.
|
||||
function createFramedChannel({readable,writable,inboundFirstLimit=SMALL,outboundFirstLimit=SMALL}={}){
|
||||
if(!readable||!writable||typeof readable.on!=='function'||typeof writable.write!=='function'||![SMALL,LARGE].includes(inboundFirstLimit)||![SMALL,LARGE].includes(outboundFirstLimit))throw Error('broker-channel-invalid');
|
||||
const channel=new EventEmitter(),decoder=new TextDecoder('utf-8',{fatal:true});
|
||||
let connected=true,firstRead=true,firstWrite=true,header=Buffer.alloc(4),headerBytes=0,payload,payloadBytes=0,pending=0;
|
||||
const outstanding=new Set();
|
||||
Object.defineProperty(channel,'connected',{get:()=>connected});
|
||||
function disconnect(){
|
||||
if(!connected)return;connected=false;header.fill(0);payload?.fill(0);payload=undefined;
|
||||
readable.off('data',data);readable.off('end',disconnect);readable.off('close',disconnect);
|
||||
// Keep bounded error listeners until destroyed streams settle.
|
||||
readable.destroy?.();if(writable!==readable)writable.destroy?.();
|
||||
for(const record of outstanding)record.notify(true);
|
||||
channel.emit('disconnect');
|
||||
}
|
||||
function data(chunk){
|
||||
if(!connected)return;
|
||||
try{
|
||||
if(!Buffer.isBuffer(chunk))throw Error();
|
||||
let offset=0;
|
||||
while(offset<chunk.length&&connected){
|
||||
if(!payload){
|
||||
const take=Math.min(4-headerBytes,chunk.length-offset);chunk.copy(header,headerBytes,offset,offset+take);headerBytes+=take;offset+=take;
|
||||
if(headerBytes!==4)continue;
|
||||
const length=header.readUInt32LE(0),limit=firstRead?inboundFirstLimit:SMALL;
|
||||
if(length<1||length>limit)throw Error();
|
||||
payload=Buffer.alloc(length);payloadBytes=0;headerBytes=0;header.fill(0);
|
||||
}
|
||||
const take=Math.min(payload.length-payloadBytes,chunk.length-offset);chunk.copy(payload,payloadBytes,offset,offset+take);payloadBytes+=take;offset+=take;
|
||||
if(payloadBytes===payload.length){
|
||||
const completed=payload;payload=undefined;payloadBytes=0;firstRead=false;
|
||||
let message;try{message=JSON.parse(decoder.decode(completed));}finally{completed.fill(0);}
|
||||
if(!message||typeof message!=='object'||Array.isArray(message))throw Error();
|
||||
channel.emit('message',message);
|
||||
}
|
||||
}
|
||||
}catch{disconnect();}
|
||||
}
|
||||
channel.send=(message,callback=()=>{})=>{
|
||||
if(typeof callback!=='function')throw Error('broker-channel-invalid');
|
||||
const notifyFailure=()=>{disconnect();try{callback(Error('broker-channel-unavailable'));}catch{}};
|
||||
if(!connected||pending>=8){notifyFailure();return;}
|
||||
let frame;
|
||||
try{
|
||||
if(!message||typeof message!=='object'||Array.isArray(message))throw Error();
|
||||
const text=JSON.stringify(message),bytes=Buffer.byteLength(text),limit=firstWrite?outboundFirstLimit:SMALL;
|
||||
if(bytes<1||bytes>limit)throw Error();
|
||||
frame=Buffer.alloc(bytes+4);frame.writeUInt32LE(bytes);frame.write(text,4);firstWrite=false;pending++;
|
||||
}catch{notifyFailure();return;}
|
||||
let settled=false,notified=false;
|
||||
const record={notify(error){if(notified)return;notified=true;try{callback(error?Error('broker-channel-unavailable'):undefined);}catch{disconnect();}}};
|
||||
outstanding.add(record);
|
||||
// Completion of the underlying write owns buffer release. Disconnect notifies
|
||||
// failure immediately, but cannot claim that an outstanding write has settled.
|
||||
const done=error=>{if(settled)return;settled=true;pending--;outstanding.delete(record);frame.fill(0);if(error)disconnect();record.notify(error||!connected);};
|
||||
try{writable.write(frame,done);}catch{done(Error());}
|
||||
};
|
||||
channel.disconnect=disconnect;
|
||||
readable.on('data',data);readable.once('end',disconnect);readable.once('close',disconnect);readable.on('error',disconnect);
|
||||
if(writable!==readable){writable.once('close',disconnect);writable.on('error',disconnect);}
|
||||
return channel;
|
||||
}
|
||||
module.exports={createFramedChannel};
|
||||
@@ -0,0 +1,109 @@
|
||||
'use strict';
|
||||
|
||||
const {createHash}=require('node:crypto');
|
||||
const {performance}=require('node:perf_hooks');
|
||||
const digest=body=>createHash('sha256').update(body).digest('hex');
|
||||
class GateError extends Error {
|
||||
constructor(code) { super(code); this.name='GateError'; this.code=code; }
|
||||
}
|
||||
const fail=code=>{throw new GateError(code);};
|
||||
|
||||
function parseRequest(request) {
|
||||
const headers=Object.create(null);
|
||||
if(!Array.isArray(request.rawHeaders)||request.rawHeaders.length%2) fail('invalid-headers');
|
||||
for(let index=0;index<request.rawHeaders.length;index+=2) {
|
||||
const name=request.rawHeaders[index].toLowerCase(),value=request.rawHeaders[index+1];
|
||||
if(headers[name]!==undefined) fail('duplicate-header');
|
||||
headers[name]=value;
|
||||
}
|
||||
if(headers.upgrade!==undefined||headers['transfer-encoding']!==undefined||headers['content-encoding']!==undefined||headers.expect!==undefined) fail('unsupported-framing');
|
||||
if(headers.connection&&headers.connection.toLowerCase()!=='close'&&headers.connection.toLowerCase()!=='keep-alive') fail('unsupported-framing');
|
||||
if(!/^(GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS)$/.test(request.method)) fail('unsupported-method');
|
||||
let destination;
|
||||
try {destination=new URL(request.url);} catch {fail('invalid-destination');}
|
||||
// First gate supports explicit HTTP proxy requests only. TLS CONNECT,
|
||||
// streaming protocols and OS egress confinement need separate enforcement.
|
||||
if(destination.protocol!=='http:'||destination.username||destination.password||destination.hash) fail('invalid-destination');
|
||||
if(!headers.host||headers.host.toLowerCase()!==destination.host.toLowerCase()) fail('authority-mismatch');
|
||||
const length=headers['content-length'];
|
||||
if(length!==undefined&&!/^(0|[1-9][0-9]*)$/.test(length)) fail('invalid-length');
|
||||
const expected=length===undefined?0:Number(length);
|
||||
if(!Number.isSafeInteger(expected)) fail('invalid-length');
|
||||
return Object.freeze({method:request.method,url:destination.href,headers:Object.freeze(headers),expected});
|
||||
}
|
||||
|
||||
/** Provisional trusted broker API, not a public renderer/socket API.
|
||||
* current(authority) MUST synchronously verify enrollment, policy generation,
|
||||
* expiration and revocation. forward is trusted and must make its first dial
|
||||
* synchronously when called, without yielding before using the supplied bytes.
|
||||
* This module never forwards an IncomingMessage or an inspector-owned buffer.
|
||||
*/
|
||||
async function gateRequest(request,{inspect,current,forward,authority,maxBytes=1048576,deadlineMs=5000,signal}) {
|
||||
if(typeof authority!=='string'||!authority||typeof inspect!=='function'||typeof current!=='function'||typeof forward!=='function'||!Number.isSafeInteger(maxBytes)||maxBytes<0||!Number.isSafeInteger(deadlineMs)||deadlineMs<1) fail('invalid-configuration');
|
||||
const snapshot=parseRequest(request);
|
||||
if(snapshot.expected>maxBytes) fail('body-too-large');
|
||||
const isCurrent=()=>{try {return current(authority)===true;} catch {return false;}};
|
||||
if(!isCurrent()) fail('authority-expired');
|
||||
const expires=performance.now()+deadlineMs;
|
||||
let timer,abortListener,requestAbort,requestError,requestClose,data,end;
|
||||
let settled=false,body;
|
||||
const chunks=[];
|
||||
let bytes=0;
|
||||
const stop=new AbortController();
|
||||
try {
|
||||
const buffered=await new Promise((resolve,reject)=>{
|
||||
const deny=code=>{if(settled)return;settled=true;stop.abort();request.pause();reject(new GateError(code));};
|
||||
timer=setTimeout(()=>deny('deadline'),deadlineMs);
|
||||
abortListener=()=>deny('aborted');
|
||||
requestAbort=()=>deny('aborted');
|
||||
requestError=()=>deny('request-error');
|
||||
requestClose=()=>{if(!request.complete)deny('incomplete-body');};
|
||||
data=chunk=>{
|
||||
bytes+=chunk.length;
|
||||
if(bytes>maxBytes||bytes>snapshot.expected){deny('body-too-large');return;}
|
||||
chunks.push(Buffer.from(chunk));
|
||||
};
|
||||
end=()=>{
|
||||
if(settled)return;
|
||||
if(!request.complete||bytes!==snapshot.expected){deny('incomplete-body');return;}
|
||||
resolve(Buffer.concat(chunks));
|
||||
};
|
||||
signal?.addEventListener('abort',abortListener,{once:true});
|
||||
request.on('aborted',requestAbort).on('error',requestError).on('close',requestClose).on('data',data).once('end',end);
|
||||
if(signal?.aborted||request.aborted)deny('aborted');
|
||||
else if(request.readableEnded)end();
|
||||
});
|
||||
body=buffered;
|
||||
if(performance.now()>=expires)fail('deadline');
|
||||
if(!isCurrent())fail('authority-expired');
|
||||
const originalHash=digest(body);
|
||||
// Deadline and abort remain active during asynchronous inspection.
|
||||
const verdict=await Promise.race([
|
||||
Promise.resolve().then(()=>inspect(Object.freeze({request:snapshot,body:Buffer.from(body),signal:stop.signal}))),
|
||||
new Promise((_,reject)=>{
|
||||
if(stop.signal.aborted)reject(new GateError(signal?.aborted?'aborted':'deadline'));
|
||||
else stop.signal.addEventListener('abort',()=>reject(new GateError(signal?.aborted?'aborted':'deadline')),{once:true});
|
||||
})
|
||||
]);
|
||||
if(stop.signal.aborted)fail('aborted');
|
||||
if(performance.now()>=expires)fail('deadline');
|
||||
if(verdict?.decision!=='allow')fail('inspection-denied');
|
||||
if(!isCurrent())fail('authority-expired');
|
||||
if(digest(body)!==originalHash)fail('integrity-failed');
|
||||
settled=true;
|
||||
clearTimeout(timer);
|
||||
// Final verification and call have no asynchronous gap.
|
||||
return await forward(Object.freeze({request:snapshot,body:Buffer.from(body)}));
|
||||
} catch(error) {
|
||||
if(error instanceof GateError)throw error;
|
||||
throw new GateError('inspection-or-forward-failed');
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort',abortListener);
|
||||
request.removeListener('aborted',requestAbort).removeListener('error',requestError).removeListener('close',requestClose).removeListener('data',data).removeListener('end',end);
|
||||
for(const chunk of chunks)chunk.fill(0);
|
||||
body?.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports={gateRequest,GateError};
|
||||
@@ -0,0 +1,37 @@
|
||||
'use strict';
|
||||
const {GateError}=require('./http-request-gate.cjs');
|
||||
const allowed=new Set(['content-disposition','content-type','content-language','cache-control','expires','last-modified','etag','location','set-cookie','vary','content-security-policy','content-security-policy-report-only','referrer-policy','permissions-policy','x-content-type-options','x-frame-options','strict-transport-security']);
|
||||
const invalid=value=>typeof value!=='string'||value.length>8192||[...value].some(character=>character.charCodeAt(0)<32&&character!=='\t'||character.charCodeAt(0)===127);
|
||||
|
||||
/** Provisional buffered response. No streaming/compression forwarding.
|
||||
* Content-Length is recomputed from a private body copy. Hop-by-hop and
|
||||
* unrecognized headers are never forwarded. Set-Cookie may be a bounded
|
||||
* string array; values are forwarded separately without comma combining.
|
||||
*/
|
||||
function validateResponse(record,maxBytes=1048576) {
|
||||
if(!record||!Number.isSafeInteger(record.statusCode)||record.statusCode<200||record.statusCode>599||!Number.isSafeInteger(maxBytes)||maxBytes<0||!Buffer.isBuffer(record.body)||record.body.length>maxBytes||!record.headers||typeof record.headers!=='object'||Array.isArray(record.headers))throw new GateError('invalid-upstream-response');
|
||||
const headers=Object.create(null);
|
||||
const seen=new Set(),hop=new Set();
|
||||
for(const [key,value] of Object.entries(record.headers))if(key.toLowerCase()==='connection') {
|
||||
if(invalid(value))throw new GateError('invalid-upstream-response');
|
||||
for(const token of value.split(',')){if(!/^[a-zA-Z0-9-]+$/.test(token.trim()))throw new GateError('invalid-upstream-response');hop.add(token.trim().toLowerCase());}
|
||||
}
|
||||
let size=0;
|
||||
for(const [key,value] of Object.entries(record.headers)) {
|
||||
if(!/^[a-zA-Z0-9-]+$/.test(key))throw new GateError('invalid-upstream-response');
|
||||
const name=key.toLowerCase();
|
||||
if(seen.has(name))throw new GateError('invalid-upstream-response');seen.add(name);
|
||||
if(name==='content-encoding'&&value!==undefined&&value!=='identity')throw new GateError('unsupported-upstream-encoding');
|
||||
if(!allowed.has(name)||hop.has(name))continue;
|
||||
const values=Array.isArray(value)?value:[value];
|
||||
if(values.length>32||(Array.isArray(value)&&name!=='set-cookie')||values.some(invalid))throw new GateError('invalid-upstream-response');
|
||||
size+=key.length+values.reduce((sum,item)=>sum+item.length,0);
|
||||
if(size>32768)throw new GateError('invalid-upstream-response');
|
||||
headers[name]=Array.isArray(value)?Object.freeze([...value]):value;
|
||||
}
|
||||
if((record.statusCode===204||record.statusCode===304)&&record.body.length)throw new GateError('invalid-upstream-response');
|
||||
const body=Buffer.from(record.body);
|
||||
headers['content-length']=String(body.length);headers.connection='close';
|
||||
return Object.freeze({statusCode:record.statusCode,headers:Object.freeze(headers),body});
|
||||
}
|
||||
module.exports={validateResponse};
|
||||
@@ -0,0 +1,102 @@
|
||||
'use strict';
|
||||
const https=require('node:https');
|
||||
const tls=require('node:tls');
|
||||
const {isIP}=require('node:net');
|
||||
const {parseAuthority}=require('./tls-authority.cjs');
|
||||
const {validateResponse}=require('./http-response-gate.cjs');
|
||||
const {GateError}=require('./http-request-gate.cjs');
|
||||
const {createWorkAdmission}=require('./work-admission.cjs');
|
||||
const methods=new Set(['GET','HEAD','POST','PUT','PATCH','DELETE','OPTIONS']);
|
||||
const hop=new Set(['host','content-length','connection','proxy-connection','proxy-authorization','proxy-authenticate','keep-alive','te','trailer','transfer-encoding','upgrade']);
|
||||
const fail=code=>{throw new GateError(code);};
|
||||
function requestSnapshot(request,body,maxBytes){
|
||||
if(!request||!methods.has(request.method)||!Buffer.isBuffer(body)||body.length>maxBytes||typeof request.url!=='string'||!request.headers||typeof request.headers!=='object')fail('invalid-forward-request');
|
||||
let url,binding;try{
|
||||
const match=/^https:\/\/([^/?#]+)(?:[/?]|$)/.exec(request.url);if(!match)fail('invalid-forward-request');
|
||||
binding=parseAuthority(match[1]);url=new URL(request.url);
|
||||
if(url.username||url.password||url.hash||[...request.url].some(character=>character.charCodeAt(0)<=32||character.charCodeAt(0)===127||character==='\\')||url.protocol!=='https:')fail('invalid-forward-request');
|
||||
}catch{fail('invalid-forward-request');}
|
||||
const headers=Object.create(null),seen=new Set();let headerBytes=0;
|
||||
const connectionNamed=new Set();
|
||||
for(const [name,value] of Object.entries(request.headers))if(name.toLowerCase()==='connection'){
|
||||
if(typeof value!=='string')fail('invalid-forward-request');
|
||||
for(const token of value.split(',')){if(!/^[a-z0-9-]+$/i.test(token.trim()))fail('invalid-forward-request');connectionNamed.add(token.trim().toLowerCase());}
|
||||
}
|
||||
for(const [name,value] of Object.entries(request.headers)){
|
||||
const lower=name.toLowerCase();
|
||||
if(!/^[!#$%&'*+.^_`|~0-9a-z-]+$/i.test(name)||seen.has(lower)||typeof value!=='string'||[...value].some(character=>{const code=character.charCodeAt(0);return code!==9&&(code<32||code===127||code>255);}))fail('invalid-forward-request');
|
||||
seen.add(lower);headerBytes+=name.length+value.length;if(headerBytes>32768)fail('invalid-forward-request');
|
||||
if(!hop.has(lower)&&!connectionNamed.has(lower))headers[lower]=value;
|
||||
}
|
||||
headers['accept-encoding']='identity';headers.host=url.host;headers['content-length']=String(body.length);headers.connection='close';
|
||||
return {binding,path:url.pathname+url.search,method:request.method,headers};
|
||||
}
|
||||
/** Trusted route resolver MUST approve the original hostname/port and return
|
||||
* {approved:true,address:<numeric IP>,port:<same port>}. It must apply corporate
|
||||
* DNS/proxy policy; this transport never does system lookup or follows redirects.
|
||||
* ca is an optional trusted configuration/test CA, never renderer-supplied.
|
||||
*/
|
||||
function createHttpsForwarder({authority,current,resolveRoute,ca,deadlineMs=5000,maxRequestBytes=16777216,maxResponseBytes=1048576,maxPendingResolutions=8}={}){
|
||||
if(typeof authority!=='string'||!authority||typeof current!=='function'||typeof resolveRoute!=='function'||!Number.isSafeInteger(deadlineMs)||deadlineMs<1||deadlineMs>60000||![maxRequestBytes,maxResponseBytes].every(n=>Number.isSafeInteger(n)&&n>=0&&n<=16777216))fail('invalid-forward-configuration');
|
||||
if(ca!==undefined&&!Buffer.isBuffer(ca)&&typeof ca!=='string')fail('invalid-forward-configuration');
|
||||
if(!Number.isSafeInteger(maxPendingResolutions)||maxPendingResolutions<1||maxPendingResolutions>64)fail('invalid-forward-configuration');
|
||||
// Separate budget from outer forward admission: timed-out resolvers retain
|
||||
// their slot until actual settlement, avoiding nested single-slot deadlock.
|
||||
const resolutions=createWorkAdmission({maxJobs:maxPendingResolutions});
|
||||
const trustedCa=Buffer.isBuffer(ca)?Buffer.from(ca):ca;
|
||||
const isCurrent=()=>{try{return current(authority)===true;}catch{return false;}};
|
||||
return async function forward({request,body,signal,maxResponseBytes:requestedLimit=maxResponseBytes}){
|
||||
if(!Number.isSafeInteger(requestedLimit)||requestedLimit<0||requestedLimit>maxResponseBytes)fail('invalid-forward-request');
|
||||
const snapshot=requestSnapshot(request,body,maxRequestBytes),privateBody=Buffer.from(body),controller=new AbortController();
|
||||
let outgoing,timer,externalAbort,cancel;
|
||||
const cancellation=new Promise((_,reject)=>{cancel=()=>{outgoing?.destroy();reject(new GateError('upstream-cancelled'));};controller.signal.addEventListener('abort',cancel,{once:true});});
|
||||
void cancellation.catch(()=>{});
|
||||
try{
|
||||
externalAbort=()=>controller.abort();signal?.addEventListener('abort',externalAbort,{once:true});
|
||||
timer=setTimeout(()=>controller.abort(),deadlineMs);
|
||||
if(signal?.aborted)controller.abort();
|
||||
if(controller.signal.aborted)fail('upstream-cancelled');
|
||||
if(!isCurrent())fail('authority-expired');
|
||||
let resolution;
|
||||
try{resolution=resolutions.run(()=>resolveRoute(Object.freeze({hostname:snapshot.binding.hostname,port:snapshot.binding.port,signal:controller.signal})));}
|
||||
catch(error){if(error.code==='work_capacity_exhausted')fail('route-capacity-exhausted');throw error;}
|
||||
const route=await Promise.race([resolution,cancellation]);
|
||||
if(controller.signal.aborted)fail('upstream-cancelled');
|
||||
if(!isCurrent())fail('authority-expired');
|
||||
if(!route||route.approved!==true||!isIP(route.address)||route.port!==snapshot.binding.port)fail('route-unapproved');
|
||||
const response=await Promise.race([new Promise((resolve,reject)=>{
|
||||
// Last synchronous authorization check precedes request creation/dial.
|
||||
if(!isCurrent()){reject(new GateError('authority-expired'));return;}
|
||||
outgoing=https.request({host:route.address,port:route.port,path:snapshot.path,method:snapshot.method,headers:snapshot.headers,
|
||||
servername:isIP(snapshot.binding.hostname)?undefined:snapshot.binding.hostname,
|
||||
ca:trustedCa,rejectUnauthorized:true,checkServerIdentity:(_hostname,certificate)=>tls.checkServerIdentity(snapshot.binding.hostname,certificate),
|
||||
lookup:(_hostname,_options,callback)=>callback(new Error('unexpected-name-resolution')),agent:false,maxHeaderSize:32768,signal:controller.signal},incoming=>{
|
||||
const chunks=[];let bytes=0,headerBytes=0;
|
||||
for(const value of incoming.rawHeaders)headerBytes+=value.length;
|
||||
const wipe=()=>{for(const chunk of chunks)chunk.fill(0);chunks.length=0;};
|
||||
incoming.once('close',wipe);
|
||||
if(headerBytes>32768){reject(new GateError('invalid-upstream-response'));incoming.destroy();return;}
|
||||
incoming.on('data',chunk=>{bytes+=chunk.length;if(bytes>requestedLimit){reject(new GateError('upstream-response-too-large'));incoming.destroy();return;}chunks.push(Buffer.from(chunk));});
|
||||
incoming.once('aborted',()=>reject(new GateError('upstream-failed')));
|
||||
incoming.once('error',()=>reject(new GateError('upstream-failed')));
|
||||
incoming.once('end',()=>{
|
||||
try{if(!incoming.complete)fail('upstream-failed');resolve(validateResponse({statusCode:incoming.statusCode,headers:incoming.headers,body:Buffer.concat(chunks)},requestedLimit));}
|
||||
catch(error){reject(error);}finally{wipe();}
|
||||
});
|
||||
});
|
||||
outgoing.once('error',()=>reject(new GateError('upstream-failed')));
|
||||
// TLS setup is asynchronous. Reauthorize at the actual secure socket
|
||||
// before any HTTP headers/body are released, not merely before dial.
|
||||
outgoing.once('socket',socket=>socket.once('secureConnect',()=>{
|
||||
if(controller.signal.aborted){reject(new GateError('upstream-cancelled'));outgoing.destroy();return;}
|
||||
if(!isCurrent()){reject(new GateError('authority-expired'));outgoing.destroy();return;}
|
||||
outgoing.end(privateBody);
|
||||
}));
|
||||
}),cancellation]);
|
||||
if(!isCurrent())fail('authority-expired');
|
||||
return response;
|
||||
}catch(error){outgoing?.destroy();if(error instanceof GateError)throw error;throw new GateError('upstream-failed');}
|
||||
finally{clearTimeout(timer);signal?.removeEventListener('abort',externalAbort);controller.signal.removeEventListener('abort',cancel);privateBody.fill(0);}
|
||||
};
|
||||
}
|
||||
module.exports={createHttpsForwarder};
|
||||
@@ -0,0 +1,80 @@
|
||||
'use strict';
|
||||
const https=require('node:https');
|
||||
const {verifyInnerAuthority}=require('./tls-authority.cjs');
|
||||
const {gateRequest,GateError}=require('./http-request-gate.cjs');
|
||||
const {validateResponse}=require('./http-response-gate.cjs');
|
||||
const {createWorkAdmission}=require('./work-admission.cjs');
|
||||
const {recordLifecycle}=require('./bounded-audit.cjs');
|
||||
|
||||
/** Isolated decrypted HTTPS leg for one caller-authenticated CONNECT binding.
|
||||
* Trusted caller supplies certificate, authorization and HTTPS forwarder.
|
||||
* This is NOT a CONNECT proxy, certificate issuer or OS network boundary.
|
||||
*/
|
||||
function createInspectionServer({key,cert,binding,authority,current,inspect,forward,maxBytes=1048576,maxResponseBytes=1048576,deadlineMs=5000,maxClients=8,workAdmission,lifecycle,auditAdmission,connectionContext}) {
|
||||
if(typeof authority!=='string'||!authority||!Number.isSafeInteger(deadlineMs)||deadlineMs<1||deadlineMs>60000||!Number.isSafeInteger(maxBytes)||maxBytes<0||maxBytes>16777216||!Number.isSafeInteger(maxResponseBytes)||maxResponseBytes<0||maxResponseBytes>16777216||!Number.isSafeInteger(maxClients)||maxClients<1||maxClients>64||typeof current!=='function'||typeof inspect!=='function'||typeof forward!=='function')throw new GateError('invalid-configuration');
|
||||
const sockets=new Set();
|
||||
const admission=workAdmission||createWorkAdmission({maxJobs:maxClients});
|
||||
const controlAdmission=auditAdmission||createWorkAdmission({maxJobs:maxClients});
|
||||
const rejectAudit=(context,code,facts,scope='request')=>recordLifecycle({admission:controlAdmission,deadlineMs,context,code,facts,scope});
|
||||
let server;
|
||||
try {
|
||||
server=https.createServer({key,cert,minVersion:'TLSv1.2',handshakeTimeout:deadlineMs},async(req,res)=>{
|
||||
const controller=new AbortController();let phase='request',totalExpired=false,authorized=false;const expires=performance.now()+deadlineMs;
|
||||
const totalTimer=setTimeout(()=>{totalExpired=true;controller.abort();},deadlineMs);
|
||||
const auditContext=lifecycle?.begin(connectionContext?.requestId);
|
||||
let receivedBytes=0;
|
||||
const received=chunk=>{receivedBytes=Math.min(20*1024*1024,receivedBytes+chunk.length);};
|
||||
req.on('data',received);
|
||||
const abort=()=>controller.abort();
|
||||
res.once('close',abort);
|
||||
try {
|
||||
verifyInnerAuthority(binding,{rawHeaders:req.rawHeaders,url:req.url,servername:req.socket.servername||undefined});
|
||||
const target=req.url.startsWith('/')?`https://${binding.authority}${req.url}`:req.url;
|
||||
const parsed=new URL(target);
|
||||
const rawHeaders=req.rawHeaders.slice();
|
||||
for(let index=0;index<rawHeaders.length;index+=2)if(rawHeaders[index].toLowerCase()==='host')rawHeaders[index+1]=binding.authority;
|
||||
// Stream methods remain bound to the actual IncomingMessage. Only
|
||||
// immutable routing metadata is adapted for the reusable HTTP gate.
|
||||
const adapted=new Proxy(req,{get(object,name){if(name==='url')return `http://${binding.authority}${parsed.pathname}${parsed.search}`;if(name==='rawHeaders')return rawHeaders;const value=Reflect.get(object,name);return typeof value==='function'?value.bind(object):value;}});
|
||||
const secureSnapshot=value=>Object.freeze({...value,auditContext,markStage:value=>{if(value==='scan'||value==='forward')phase=value;},request:Object.freeze({...value.request,url:target})});
|
||||
const response=await gateRequest(adapted,{authority,current,maxBytes,deadlineMs:Math.max(1,Math.ceil(expires-performance.now())),signal:controller.signal,inspect:value=>{phase='scan';return admission.run(()=>inspect(secureSnapshot(value)),{payload:value.body});},forward:async value=>{
|
||||
phase='forward';authorized=true;
|
||||
let cancelled;
|
||||
let forwardExpired=false;
|
||||
const forwardTimer=setTimeout(()=>{forwardExpired=true;controller.abort();},Math.max(1,Math.ceil(expires-performance.now())));
|
||||
try {
|
||||
const cancellation=new Promise((_,reject)=>{cancelled=()=>reject(new GateError('upstream-failed'));controller.signal.addEventListener('abort',cancelled,{once:true});if(controller.signal.aborted)cancelled();});
|
||||
const result=await Promise.race([admission.run(()=>forward(Object.freeze({...secureSnapshot(value),signal:controller.signal,maxResponseBytes})),{payload:value.body}),cancellation]);
|
||||
return validateResponse(result,maxResponseBytes);
|
||||
} catch(error) {throw new GateError(totalExpired?(phase==='scan'?'inspection-deadline':'forward-deadline'):forwardExpired?'forward-deadline':controller.signal.aborted?'aborted':['invalid-upstream-response','unsupported-upstream-encoding','authority-expired','audit-unavailable','audit-persistence-failed'].includes(error?.code)?error.code:'upstream-failed');}
|
||||
finally {clearTimeout(forwardTimer);controller.signal.removeEventListener('abort',cancelled);}
|
||||
}});
|
||||
if(controller.signal.aborted||res.destroyed)return;
|
||||
res.writeHead(response.statusCode,response.headers);res.end(response.body);
|
||||
} catch(error) {
|
||||
const code=totalExpired?(phase==='scan'?'inspection-deadline':phase==='forward'?'forward-deadline':'deadline'):error instanceof GateError||error?.name==='AuthorityError'?error.code:'inspection-failed';
|
||||
try {await rejectAudit(auditContext,code,{bytes:receivedBytes,detectedType:'unknown',host:binding.hostname});}catch{}
|
||||
const upstream=code==='inspection-deadline'?authorized:['upstream-failed','forward-deadline','invalid-upstream-response','unsupported-upstream-encoding'].includes(code);
|
||||
if(!res.destroyed&&!res.headersSent){res.writeHead(upstream?502:403,{'connection':'close','content-type':'text/plain'});res.end(upstream?'upstream-failed':code);}
|
||||
} finally {clearTimeout(totalTimer);res.removeListener('close',abort);req.removeListener('data',received);}
|
||||
});
|
||||
} catch {throw new GateError('invalid-tls-configuration');}
|
||||
server.maxConnections=maxClients;
|
||||
server.headersTimeout=deadlineMs;
|
||||
server.requestTimeout=deadlineMs;
|
||||
server.keepAliveTimeout=1;
|
||||
// Idle fallback leaves room for a complete inspection phase followed by
|
||||
// its separately bounded forward phase. Neither wall clock is renewable.
|
||||
server.on('connection',socket=>{sockets.add(socket);socket.setTimeout(deadlineMs*3,()=>socket.destroy());socket.once('close',()=>sockets.delete(socket));});
|
||||
const malformedContexts=new WeakMap();
|
||||
server.on('clientError',(_error,socket)=>{if(!malformedContexts.has(socket))malformedContexts.set(socket,lifecycle?.begin(connectionContext?.requestId));const context=malformedContexts.get(socket);void rejectAudit(context,'invalid-headers',{bytes:0,detectedType:'unknown',host:binding.hostname}).catch(()=>{}).finally(()=>socket.destroy());});
|
||||
server.on('tlsClientError',(_error,socket)=>{void rejectAudit(connectionContext,'connect-denied',{bytes:0,detectedType:'unknown',host:binding.hostname},'connect').catch(()=>{}).finally(()=>socket.destroy());});
|
||||
return Object.freeze({
|
||||
// Trusted authenticated CONNECT acceptor only; no public IPC exposure.
|
||||
// HTTPS Server owns TLS negotiation on this accepted raw TCP socket.
|
||||
acceptAuthenticatedSocket(socket){server.emit('connection',socket);},
|
||||
async listen(){await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',()=>{server.removeListener('error',reject);resolve();});});return server.address().port;},
|
||||
async close(){if(!workAdmission)admission.close();if(!auditAdmission)controlAdmission.close();for(const socket of sockets)socket.destroy();await new Promise(resolve=>server.close(resolve));}
|
||||
});
|
||||
}
|
||||
module.exports={createInspectionServer};
|
||||
@@ -0,0 +1,56 @@
|
||||
'use strict';
|
||||
const {randomUUID}=require('node:crypto');
|
||||
const {GateError}=require('./http-request-gate.cjs');
|
||||
function reasonFor(code,scope){
|
||||
if(code==='inspection-deadline')return 'broker-scan-timeout';
|
||||
if(code==='authority-expired')return 'broker-authority-expired';
|
||||
if(code==='authentication-required')return scope==='connect'?'broker-auth-denied':'broker-framing-block';
|
||||
if(['deadline','certificate-deadline','forward-deadline'].includes(code))return 'broker-request-timeout';
|
||||
if(['aborted','incomplete-body','request-error'].includes(code))return 'broker-request-aborted';
|
||||
if(code==='body-too-large')return 'broker-size-block';
|
||||
if(['invalid-upstream-response','unsupported-upstream-encoding'].includes(code))return 'broker-response-invalid';
|
||||
if(code==='upstream-failed')return 'broker-upstream-failed';
|
||||
if(['invalid-headers','duplicate-header','unsupported-framing','unsupported-method','invalid-destination','authority-mismatch','invalid-length','unsupported-connect-framing','missing-host','duplicate-host','invalid-target','missing-sni','sni-mismatch'].includes(code))return 'broker-framing-block';
|
||||
return scope==='connect'?'broker-connect-denied':'broker-inspection-fault';
|
||||
}
|
||||
/** One bounded state object per request. UUIDs express audit causation, never
|
||||
* invented OpenTelemetry traces. A failed/uncertain commit is never retried.
|
||||
*/
|
||||
function createBrokerLifecycle(audit){
|
||||
if(!audit||typeof audit.record!=='function')throw new GateError('audit-configuration-invalid');
|
||||
return Object.freeze({begin(connectionId){
|
||||
if(connectionId!==undefined&&!/^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i.test(connectionId))throw new GateError('audit-configuration-invalid');
|
||||
const requestId=randomUUID();let decisionWork,decisionType,authorizedEventId,terminalWork,responseWork,responseDecision,responseCommitted=false;
|
||||
const broker=(phase,reason)=>({requestId,...(connectionId?{connectionId}:{}),phase,reason,...(phase==='forward.fault'||phase.startsWith('response.')?{authorizedEventId}: {})});
|
||||
return Object.freeze({requestId,
|
||||
decision(decision,facts){
|
||||
if(decisionWork)return decisionWork;if(terminalWork)throw new GateError('aborted');
|
||||
decisionType=decision;
|
||||
const reason=facts.reason||(decision==='allow'?'broker-approved':decision==='block'?'broker-policy-block':'broker-inspection-fault');
|
||||
const phase=decision==='allow'?'request.authorized':decision==='block'?'request.block':'request.fault';
|
||||
decisionWork=audit.record(decision,{...facts,reason,broker:broker(phase,reason)}).then(receipt=>{if(decision==='allow')authorizedEventId=receipt.eventId;return receipt;},error=>{if(decision==='allow'&&error?.committedEventId)authorizedEventId=error.committedEventId;throw error;});
|
||||
return decisionWork;
|
||||
},
|
||||
response(decision,facts){
|
||||
if(responseWork)return responseWork;if(terminalWork)throw new GateError('aborted');responseDecision=decision;
|
||||
responseWork=(async()=>{await decisionWork;if(!authorizedEventId)throw new GateError('authority-expired');const reason=decision==='allow'?'broker-download-approved':facts.reason||'broker-download-inspection-block';const safeFacts={...facts};delete safeFacts.reason;return audit.recordDownload(decision,{...safeFacts,broker:broker(decision==='allow'?'response.scanned':'response.block',reason)}).then(receipt=>{responseCommitted=true;return receipt;},error=>{responseCommitted=!!error?.committedEventId;throw error;});})();return responseWork;
|
||||
},
|
||||
reject(code,facts,scope='request'){
|
||||
if(terminalWork)return terminalWork;
|
||||
terminalWork=(async()=>{
|
||||
if(decisionWork){
|
||||
try {await decisionWork;} catch {if(!authorizedEventId)return {persisted:false,uncertain:true};}
|
||||
if(decisionType!=='allow')return {persisted:true,alreadyRecorded:true};
|
||||
}
|
||||
if(responseWork){try{await responseWork;}catch{if(!responseCommitted)return {persisted:false,uncertain:true};}if(responseDecision==='block')return {persisted:true,alreadyRecorded:true};}
|
||||
let reason=reasonFor(code,scope);
|
||||
const phase=authorizedEventId?'forward.fault':scope==='connect'?'connect.block':reason==='broker-inspection-fault'?'request.fault':'request.block';
|
||||
if(phase==='forward.fault'&&!['broker-upstream-failed','broker-response-invalid','broker-request-aborted','broker-request-timeout','broker-authority-expired','broker-scan-timeout'].includes(reason))reason='broker-upstream-failed';
|
||||
return audit.record(phase==='forward.fault'||phase==='request.fault'?'fault':'block',{...facts,reason,broker:broker(phase,reason)});
|
||||
})();
|
||||
return terminalWork;
|
||||
}
|
||||
});
|
||||
}});
|
||||
}
|
||||
module.exports={createBrokerLifecycle};
|
||||
@@ -0,0 +1,119 @@
|
||||
'use strict';
|
||||
const {createHash}=require('node:crypto');
|
||||
const {createConnectProxy}=require('./connect-inspection-proxy.cjs');
|
||||
const {GateError}=require('./http-request-gate.cjs');
|
||||
const {inspectFileTransfer,detectType}=require('../../packages/core/file-transfer.cjs');
|
||||
const {createBrokerAudit}=require('./audit-gate.cjs');
|
||||
const {createBrokerLifecycle}=require('./lifecycle-audit.cjs');
|
||||
const {icapRequired}=require('../browser/enterprise-inspection.cjs');
|
||||
const {inspectTextRepresentations}=require('../browser/inspect-text-representations.cjs');
|
||||
const {snapshotHeaders,inspectHeaders}=require('../browser/header-inspection.cjs');
|
||||
const {parseMultipart}=require('./multipart.cjs');
|
||||
const {extractEncodedDocuments}=require('../../packages/core/encoded-documents.cjs');
|
||||
const hash=bytes=>createHash('sha256').update(bytes).digest('hex');
|
||||
|
||||
/** Signed policy foundation for an isolated inspection broker. Trusted
|
||||
* callbacks supply scoped certificates, real local/enterprise scanners and
|
||||
* upstream transport. No classifier substitutes, enrollment or main wiring.
|
||||
* A policy update invalidates this snapshot: integration must replace the
|
||||
* listener with a newly authorized snapshot after protected publication.
|
||||
*/
|
||||
async function createManagedProxy({policyAuthority,snapshot=policyAuthority?.snapshot(),resolveCertificate,localInspector,enterpriseInspector,forward,persistAudit,auditCorrelation,deadlineMs=5000,maxClients=8,authMode='basic'}) {
|
||||
if(!snapshot||!policyAuthority||typeof policyAuthority.current!=='function'||!policyAuthority.current(snapshot.token)||policyAuthority.snapshot()!==snapshot||typeof resolveCertificate!=='function'||typeof forward!=='function')throw new GateError('managed-authority-invalid');
|
||||
if(typeof persistAudit!=='function')throw new GateError('managed-audit-unavailable');
|
||||
const core=await import('../../packages/core/policy.mjs');
|
||||
const p=snapshot.policy,current=token=>token===snapshot.token&&policyAuthority.current(token);
|
||||
const check=signal=>{if(signal?.aborted)throw new GateError('aborted');if(!current(snapshot.token))throw new GateError('authority-expired');};
|
||||
check();
|
||||
const registeredIds=[...p.dlp.rules.map(rule=>rule.id),...p.dlp.semantic.categories.map(category=>category.id),...p.security.yara.rules.map(rule=>rule.id)];
|
||||
const knownIds=new Set([...registeredIds,'private-key','aws-access-key','api-secret','content-blocklist','size-limit','uninspectable','normalization-limit','file-transfer-size-limit','file-transfer-type-blocked','file-transfer-type-not-allowed','file-transfer-unknown','file-transfer-extension-mismatch','file-transfer-mime-mismatch','inspection-incomplete']);
|
||||
const audit=createBrokerAudit({persist:persistAudit,current:()=>current(snapshot.token),policyVersion:p.version,allowedRuleIds:registeredIds,correlation:auditCorrelation});
|
||||
const lifecycle=createBrokerLifecycle(audit);
|
||||
const destinationAllowed=url=>core.destinationAllowed(url,p)&&core.inspect(Buffer.from(url),p).decision==='allow';
|
||||
const blocked=ruleId=>({decision:'block',ruleId});
|
||||
const scanner=async(fn,bytes,signal,enterprise=false)=>{
|
||||
const copy=Buffer.from(bytes),digest=hash(copy);
|
||||
try {
|
||||
check(signal);const result=await fn(copy,p,{signal});check(signal);
|
||||
if(hash(copy)!==digest)return blocked('inspection-incomplete');
|
||||
if(result?.decision==='block')return blocked(knownIds.has(result.ruleId)?result.ruleId:'inspection-incomplete');
|
||||
if(result?.decision!=='allow'||result.coverage?.complete!==true||enterprise&&(result.sha256!==digest||result.bytes!==copy.length))return blocked('inspection-incomplete');
|
||||
return result;
|
||||
} finally {copy.fill(0);}
|
||||
};
|
||||
const textScan=(bytes,signal)=>inspectTextRepresentations({bytes,policy:p,isCurrent:()=>current(snapshot.token),
|
||||
inspectLocal:typeof localInspector==='function'?value=>scanner(localInspector,value,signal):undefined,
|
||||
inspectEnterprise:icapRequired(p,'upload')&&typeof enterpriseInspector==='function'?value=>scanner(enterpriseInspector,value,signal,true):undefined});
|
||||
const textValue=async(value,signal,budget)=>{const bytes=Buffer.from(value);try{return await inspectContent(bytes,signal,undefined,budget);}finally{bytes.fill(0);}};
|
||||
const inspectContent=async(bytes,signal,metadata,budget,depth=0)=>{
|
||||
check(signal);const transfer=inspectFileTransfer(bytes,p,'upload',metadata);
|
||||
if(transfer.decision!=='allow')return blocked(transfer.ruleId);
|
||||
const verdict=core.inspect(bytes,p);
|
||||
if(verdict.decision!=='allow'&&verdict.ruleId!=='uninspectable')return verdict;
|
||||
if(transfer.detectedType==='text'){
|
||||
if(/^image\/svg\+xml(?:;|$)/i.test(metadata?.mime||'')||/<svg(?:\s|>)/i.test(bytes.toString('utf8')))return blocked('inspection-incomplete');
|
||||
if(!budget||budget.bytes<1||budget.nodes<1||budget.parts<1)return blocked('inspection-incomplete');
|
||||
const decoded=extractEncodedDocuments(bytes,{jsonRequired:/^(?:application\/(?:json|[a-z0-9.+-]+\+json))(?:;|$)/i.test(metadata?.mime||''),maxDecodedBytes:budget.bytes,maxNodes:budget.nodes,maxParts:budget.parts,maxDepth:32});
|
||||
try{
|
||||
budget.bytes-=decoded.decodedBytes;budget.nodes-=decoded.visitedNodes;budget.parts-=decoded.parts.length;
|
||||
if(decoded.complete!==true||budget.bytes<0||budget.nodes<0||budget.parts<0||depth>=4&&decoded.parts.length)return blocked('inspection-incomplete');
|
||||
for(const part of decoded.parts){const result=await inspectContent(part.bytes,signal,{mime:part.mime},budget,depth+1);if(result.decision!=='allow')return result;}
|
||||
check(signal);return textScan(bytes,signal);
|
||||
}finally{decoded.dispose();}
|
||||
}
|
||||
if(typeof localInspector!=='function')return blocked('inspector-unavailable');
|
||||
const local=await scanner(localInspector,bytes,signal);if(local.decision!=='allow')return local;
|
||||
if(icapRequired(p,'upload')){const enterprise=await scanner(enterpriseInspector,bytes,signal,true);if(enterprise.decision!=='allow')return enterprise;}
|
||||
check(signal);return {decision:'allow'};
|
||||
};
|
||||
return createConnectProxy({authority:snapshot.token,current,authMode,lifecycle,deadlineMs,maxClients,maxBytes:p.dlp.maxBytes,maxResponseBytes:Math.min(16777216,p.fileTransfers.download.maxBytes),
|
||||
resolveCertificate:async(binding,options)=>{
|
||||
check();if(!destinationAllowed(`https://${binding.authority}/`))throw new GateError('destination-denied');
|
||||
const result=await resolveCertificate(binding,options);check();return result;
|
||||
},
|
||||
inspect:async({request,body,signal,auditContext})=>{
|
||||
const classify=async()=>{
|
||||
const budget={bytes:Math.min(p.dlp.maxBytes,16777216),nodes:20000,parts:64};
|
||||
check();if(!destinationAllowed(request.url))return blocked('destination-denied');
|
||||
if(icapRequired(p,'upload')&&typeof enterpriseInspector!=='function')return blocked('enterprise-unavailable');
|
||||
const urlVerdict=await textValue(request.url,signal,budget);if(urlVerdict.decision!=='allow')return urlVerdict;
|
||||
const headers=snapshotHeaders(request.headers,p);
|
||||
const headerVerdict=await inspectHeaders(headers,{policy:p,inspect:core.inspect,isCurrent:()=>current(snapshot.token),
|
||||
inspectLocal:typeof localInspector==='function'?bytes=>scanner(localInspector,bytes,signal):undefined,
|
||||
inspectEnterprise:icapRequired(p,'upload')?bytes=>scanner(enterpriseInspector,bytes,signal,true):undefined});
|
||||
if(headerVerdict.decision!=='allow')return headerVerdict;
|
||||
for(const value of headers.values){const verdict=await textValue(value,signal,budget);if(verdict.decision!=='allow')return verdict;}
|
||||
if(/^multipart\//i.test(request.headers['content-type']||'')){
|
||||
const raw=core.inspect(body,p);if(raw.decision!=='allow'&&raw.ruleId!=='uninspectable')return raw;
|
||||
const parsed=parseMultipart(body,request.headers['content-type'],{maxBytes:p.dlp.maxBytes});
|
||||
try {
|
||||
if(parsed.coverage?.complete!==true)return blocked('multipart-incomplete');
|
||||
for(const part of parsed.parts){
|
||||
for(const value of [part.name,part.filename,part.contentType].filter(value=>value!==undefined)){
|
||||
const verdict=core.inspect(Buffer.from(value),p);if(verdict.decision!=='allow')return verdict;
|
||||
const representation=await textValue(value,signal,budget);if(representation.decision!=='allow')return representation;
|
||||
}
|
||||
const verdict=await inspectContent(part.body,signal,{filename:part.filename,mime:part.contentType},budget);if(verdict.decision!=='allow')return verdict;
|
||||
}
|
||||
check(signal);return {decision:'allow'};
|
||||
} finally {parsed.dispose();}
|
||||
}
|
||||
return inspectContent(body,signal,{mime:request.headers['content-type']},budget);
|
||||
};
|
||||
const facts={bytes:body.length,detectedType:detectType(body),host:new URL(request.url).hostname.replace(/^\[|\]$/g,'')};
|
||||
let verdict;
|
||||
try {verdict=await classify();}
|
||||
catch {
|
||||
if(signal.aborted)throw new GateError('aborted');
|
||||
await auditContext.decision(current(snapshot.token)?'fault':'block',{...facts,reason:current(snapshot.token)?'broker-inspection-fault':'broker-authority-expired'});
|
||||
throw new GateError('inspection-failed');
|
||||
}
|
||||
// This awaits a durable receipt while the original inspection job still
|
||||
// owns global admission. Outer cancellation cannot free pending audit.
|
||||
await auditContext.decision(verdict.decision==='allow'?'allow':'block',{...facts,...(verdict.decision!=='allow'&&knownIds.has(verdict.ruleId)?{matchedRuleId:verdict.ruleId}:{})});
|
||||
check(signal);return verdict;
|
||||
},
|
||||
forward:value=>{check();return forward(value);}
|
||||
});
|
||||
}
|
||||
module.exports={createManagedProxy};
|
||||
@@ -0,0 +1,49 @@
|
||||
'use strict';
|
||||
const {spawn}=require('node:child_process');
|
||||
const path=require('node:path');
|
||||
const {TextDecoder}=require('node:util');
|
||||
const {installationRoot}=require('./service-store-client.cjs');
|
||||
const {createWorkAdmission}=require('./work-admission.cjs');
|
||||
const refusal=()=>Error('broker-management-refused');
|
||||
const exact=(v,keys)=>v&&typeof v==='object'&&!Array.isArray(v)&&Object.keys(v).sort().join(',')===keys.sort().join(',');
|
||||
const keys=['deviceId','origin','policyVersion','policyKeySha256','policyDigest'];
|
||||
function validBinding(value){
|
||||
if(!exact(value,keys)||! /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(value.deviceId)||!Number.isSafeInteger(value.policyVersion)||value.policyVersion<1||![value.policyKeySha256,value.policyDigest].every(v=>typeof v==='string'&&/^[a-f0-9]{64}$/.test(v)))throw refusal();
|
||||
if(typeof value.origin!=='string'||value.origin.length>2048)throw refusal();const u=new URL(value.origin);if(u.protocol!=='https:'||u.origin!==value.origin||u.username||u.password||u.pathname!=='/'||u.search||u.hash)throw refusal();return Object.freeze({...value});
|
||||
}
|
||||
// Read-only contract for the fixed native protected store. Native command must
|
||||
// authenticate enabled SCM SID + retained signed Node parent and validate its
|
||||
// OS-minted user SID, signed deployment and exact accepted policy digest.
|
||||
// This module never creates a store or treats an environment SID as authority.
|
||||
function createNativeManagementCredentialLoader({expectedBinding,current,launch=spawn,deadlineMs=10000}={}){
|
||||
let expected;try{expected=validBinding(expectedBinding);if(typeof current!=='function'||typeof launch!=='function'||!Number.isSafeInteger(deadlineMs)||deadlineMs<1||deadlineMs>30000)throw refusal();}catch{throw refusal();}
|
||||
let closed=false,closing;const stops=new Set(),operations=new Set(),admission=createWorkAdmission({maxJobs:1}),active=()=>{try{return !closed&¤t()===true;}catch{return false;}};
|
||||
const load=async(binding,{signal}={})=>{
|
||||
try{
|
||||
const request=validBinding(binding);if(!active()||signal?.aborted||keys.some(k=>request[k]!==expected[k]))throw refusal();
|
||||
const operation=admission.run(()=>new Promise((resolve,reject)=>{
|
||||
let child,timer,total=0,failed=false;const output=Buffer.alloc(8196);
|
||||
const stop=()=>{failed=true;try{child?.kill();}catch{}};stops.add(stop);
|
||||
try{
|
||||
const sid=process.env.SAFETYNET_SERVICE_CLIENT_SID;if(typeof sid!=='string'||! /^(?:S-1-5-21|S-1-12-1)-(?:(?:0|[1-9][0-9]{0,9})-){3}(?:0|[1-9][0-9]{0,9})$/.test(sid)||sid.split('-').slice(4).some(n=>Number(n)>4294967295))throw refusal();
|
||||
child=launch(path.join(installationRoot,'safetynet-service-host.exe'),['--management-credential'],{cwd:installationRoot,shell:false,windowsHide:true,stdio:['pipe','pipe','ignore'],env:{SystemRoot:process.env.SystemRoot,WINDIR:process.env.WINDIR,SAFETYNET_SERVICE_CLIENT_SID:sid}});
|
||||
signal?.addEventListener('abort',stop,{once:true});timer=setTimeout(stop,deadlineMs);child.once('error',stop);child.stdin.on('error',stop);child.stdout.on('error',stop);
|
||||
child.stdout.on('data',part=>{if(failed)return;if(!Buffer.isBuffer(part)||total+part.length>output.length){stop();return;}part.copy(output,total);total+=part.length;if(total>=4&&(output.readUInt32LE(0)<2||output.readUInt32LE(0)>8192||total>output.readUInt32LE(0)+4))stop();});
|
||||
child.once('close',(code,endedSignal)=>{
|
||||
stops.delete(stop);clearTimeout(timer);signal?.removeEventListener('abort',stop);let token;
|
||||
try{
|
||||
if(failed||code!==0||endedSignal||!active()||signal?.aborted||total<6||output.readUInt32LE(0)!==total-4)throw refusal();
|
||||
const value=JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(output.subarray(4,total)));
|
||||
if(!exact(value,[...keys,'token'])||keys.some(k=>value[k]!==expected[k])||typeof value.token!=='string'||value.token.length<16||value.token.length>4096||! /^[A-Za-z0-9._~-]+$/.test(value.token))throw refusal();
|
||||
token=Buffer.from(value.token);resolve(Object.freeze({deviceId:expected.deviceId,origin:expected.origin,token}));
|
||||
}catch{token?.fill(0);reject(refusal());}finally{output.fill(0);}
|
||||
});
|
||||
const payload=Buffer.from(JSON.stringify(request));if(payload.length>4096){payload.fill(0);throw refusal();}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){stops.delete(stop);clearTimeout(timer);output.fill(0);reject(refusal());}}
|
||||
}));
|
||||
operations.add(operation);try{return await operation;}finally{operations.delete(operation);}
|
||||
}catch{throw refusal();}
|
||||
};
|
||||
return Object.freeze({load,close(){if(closing)return closing;closed=true;admission.close();for(const stop of stops)stop();closing=Promise.allSettled([...operations]).then(()=>undefined);return closing;}});
|
||||
}
|
||||
module.exports={createNativeManagementCredentialLoader};
|
||||
@@ -0,0 +1,69 @@
|
||||
'use strict';
|
||||
const {createHash,createPublicKey}=require('node:crypto');
|
||||
const {TextDecoder}=require('node:util');
|
||||
const {verifyServiceAcceptance}=require('./service-acceptance.cjs');
|
||||
const {createServiceStoreClient}=require('./service-store-client.cjs');
|
||||
const {createDnsPolicyRoute,assertSupportedDns}=require('./dns-policy-route.cjs');
|
||||
const {createHttpsForwarder}=require('./https-forwarder.cjs');
|
||||
const {createProxyForwarder}=require('./proxy-forwarder.cjs');
|
||||
const {deploymentRoots}=require('./production-factory.cjs');
|
||||
const {createWorkAdmission}=require('./work-admission.cjs');
|
||||
const channel=require('../../packages/core/policy-channel.cjs');
|
||||
const refused=()=>Error('broker-management-refused');
|
||||
// The fixed native loader supplies device credentials from protected storage;
|
||||
// injected loaders are explicit trusted test/service adapters, never IPC fields.
|
||||
// There is no URL, token, method, header, file path or general fetch IPC surface.
|
||||
// Enrollment is deliberately unavailable until its own authenticated bootstrap
|
||||
// contract exists. Only an already durably accepted policy may use this leg.
|
||||
async function createManagementIdentityOperation({store=createServiceStoreClient(),policyEnvelope,loadCredential,credentialLoader,current,network,deadlineMs=10000}={}){
|
||||
let authority,ownedLoader;
|
||||
try{
|
||||
if(credentialLoader){if(loadCredential!==undefined||typeof credentialLoader.load!=='function')throw refused();loadCredential=credentialLoader.load.bind(credentialLoader);}
|
||||
if(loadCredential!==undefined&&typeof loadCredential!=='function'||typeof current!=='function'||!Number.isSafeInteger(deadlineMs)||deadlineMs<1||deadlineMs>30000)throw refused();
|
||||
const snapshot=await store.snapshot(),files=await store.envelopes();
|
||||
const plan=await verifyServiceAcceptance({...files,state:snapshot.acceptanceState,releaseAnchor:snapshot.releaseAnchor,authenticatedSid:snapshot.authenticatedSid,policyEnvelope});
|
||||
if(plan.nextStateDigest!==plan.previousStateDigest)throw refused();
|
||||
const {verifiedManagementBinding}=require('../../packages/core/windows-trust.cjs'),binding=verifiedManagementBinding(plan.deployment);
|
||||
const {createPolicyAuthority}=require('./policy-authority.cjs');authority=await createPolicyAuthority(plan.bootstrap);const accepted=authority.accept(policyEnvelope),policy=accepted.policy;
|
||||
const active=()=>{try{return current()===true&&authority.current(accepted.token)&&Date.now()<plan.deployment.manifest.expiresAt;}catch{return false;}};
|
||||
if(!active()||!network||network.mode!==policy.proxy.mode||network.dns!=='system'||Object.keys(network).some(k=>!['mode','dns','ca','proxyCa','credentials'].includes(k)))throw refused();
|
||||
assertSupportedDns(policy,network,{numericOriginSupported:true});
|
||||
const security=await import('../../packages/core/security.mjs'),resolve=createDnsPolicyRoute({current:active,blocked:a=>security.dnsAddressBlocked(a,policy)});
|
||||
const transport={authority:accepted.token,current:active,ca:deploymentRoots(network.ca)?.join('\n'),deadlineMs,maxRequestBytes:16384,maxResponseBytes:5242880};
|
||||
const forward=network.mode==='fixed_servers'?createProxyForwarder({...transport,proxyPolicy:policy.proxy,proxyCa:deploymentRoots(network.proxyCa)?.join('\n'),credentials:network.credentials,resolveProxyRoute:resolve,resolveOriginRoute:policy.security?.dns?.enabled!==false&&policy.security?.dns?.blockedIPs?.length?resolve:undefined}):network.mode==='direct'?createHttpsForwarder({...transport,resolveRoute:resolve}):(()=>{throw refused();})();
|
||||
let closing;const originals=new Set(),jobs=createWorkAdmission({maxJobs:1}),controllers=new Set(),credentialBinding=Object.freeze({origin:binding.origin,deviceId:plan.bootstrap.deviceId,policyVersion:plan.policyVersion,policyDigest:plan.policyDigest,policyKeySha256:createHash('sha256').update(createPublicKey(plan.bootstrap.publicKey).export({type:'spki',format:'der'})).digest('hex')});
|
||||
if(loadCredential===undefined){const {createNativeManagementCredentialLoader}=require('./management-credential.cjs');ownedLoader=createNativeManagementCredentialLoader({expectedBinding:credentialBinding,current:active,deadlineMs});loadCredential=ownedLoader.load;}
|
||||
const close=()=>{if(closing)return closing;authority.revoke();jobs.close();for(const controller of controllers)controller.abort();closing=Promise.allSettled([...originals,ownedLoader?.close()]).then(()=>undefined);return closing;};
|
||||
const operation=async(kind,input)=>{
|
||||
if(input!==undefined||!active())throw refused();
|
||||
const controller=new AbortController();controllers.add(controller);let timer;
|
||||
const cancelled=new Promise((_,reject)=>{controller.signal.addEventListener('abort',()=>reject(refused()),{once:true});timer=setTimeout(()=>controller.abort(),deadlineMs);});
|
||||
const work=Promise.resolve().then(()=>jobs.run(async()=>{
|
||||
let credentialCopy,ownedToken,context;
|
||||
try{
|
||||
const credential=await loadCredential(credentialBinding,{signal:controller.signal});
|
||||
// Buffer credentials are private one-use leases from the protected loader,
|
||||
// transferred to this operation, never a reusable backing-store buffer.
|
||||
ownedToken=Buffer.isBuffer(credential?.token)?credential.token:undefined;
|
||||
if(!active()||controller.signal.aborted||!credential||Object.keys(credential).sort().join(',')!=='deviceId,origin,token'||credential.deviceId!==credentialBinding.deviceId||credential.origin!==binding.origin)throw refused();
|
||||
credentialCopy=Buffer.isBuffer(credential.token)?Buffer.from(credential.token):typeof credential.token==='string'?Buffer.from(credential.token):undefined;
|
||||
if(!credentialCopy||credentialCopy.length<16||credentialCopy.length>4096||! /^[A-Za-z0-9._~-]+$/.test(credentialCopy.toString('utf8')))throw refused();
|
||||
const request=async(route,method,body,limit)=>{if(!active()||controller.signal.aborted)throw refused();const bytes=body===undefined?Buffer.alloc(0):Buffer.from(JSON.stringify(body));let result;try{result=await forward({request:{url:binding.origin+route,method,headers:{authorization:'Bearer '+credentialCopy.toString('utf8'),...(body===undefined?{}:{'content-type':'application/json'})}},body:bytes,signal:controller.signal,maxResponseBytes:limit});if(result.statusCode!==200||!active()||controller.signal.aborted)throw refused();return JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(result.body));}finally{bytes.fill(0);result?.body.fill(0);}};
|
||||
if(kind==='identity'){const identity=await request('/api/device/channel/identity','GET',undefined,20000);channel.verifyIdentity(identity,plan.bootstrap.publicKey,binding.signatureKey);if(!active())throw refused();return Object.freeze(identity);}
|
||||
const challenge=await request('/api/device/channel/challenge','POST',{},20000);channel.verifyChallenge(challenge,plan.bootstrap.deviceId,plan.bootstrap.publicKey,binding.signatureKey);if(!active()||controller.signal.aborted)throw refused();context=channel.prepare(challenge);
|
||||
const response=await request('/api/device/channel/policy','POST',context.request,5242880),envelope=channel.finish(response,challenge,context);
|
||||
if(!envelope||Object.keys(envelope).sort().join(',')!=='payload,signature'||typeof envelope.payload!=='string'||typeof envelope.signature!=='string')throw refused();
|
||||
const core=await import('../../packages/core/policy.mjs'),next=core.verifyPolicy(envelope,plan.bootstrap.publicKey,plan.policyVersion);
|
||||
if(next.assignment?.deviceId!==plan.bootstrap.deviceId||next.version===plan.policyVersion&&createHash('sha256').update(envelope.payload).digest('hex')!==plan.policyDigest||!active()||controller.signal.aborted)throw refused();
|
||||
// Returned policy is verified but NOT activated or durably accepted here;
|
||||
// the privileged policy transition must commit its new floor separately.
|
||||
return Object.freeze({payload:envelope.payload,signature:envelope.signature});
|
||||
}catch{throw refused();}finally{credentialCopy?.fill(0);ownedToken?.fill(0);context?.responseKey.fill(0);}
|
||||
}));
|
||||
originals.add(work);work.finally(()=>originals.delete(work)).catch(()=>{});
|
||||
try{return await Promise.race([work,cancelled]);}catch{throw refused();}finally{clearTimeout(timer);controllers.delete(controller);}
|
||||
};
|
||||
return Object.freeze({close,identity:input=>operation('identity',input),policy:input=>operation('policy',input)});
|
||||
}catch{authority?.revoke();await ownedLoader?.close();throw refused();}
|
||||
}
|
||||
module.exports={createManagementIdentityOperation};
|
||||
@@ -0,0 +1,92 @@
|
||||
'use strict';
|
||||
class MultipartError extends Error{
|
||||
constructor(code){super(code);this.name='MultipartError';this.code=code;}
|
||||
}
|
||||
const fail=code=>{throw new MultipartError(code);};
|
||||
const token=/^[!#$%&'*+.^_`|~0-9a-z-]+$/i;
|
||||
function parameters(value){
|
||||
const main=/^([^;]+)(.*)$/.exec(value);if(!main)fail('invalid-multipart');
|
||||
const result=Object.create(null);let rest=main[2];
|
||||
while(rest){
|
||||
const key=/^;[ \t]*([!#$%&'*+.^_`|~0-9a-z-]+)[ \t]*=[ \t]*/i.exec(rest);
|
||||
if(!key)fail('invalid-multipart');rest=rest.slice(key[0].length);
|
||||
let parameter;
|
||||
if(rest.startsWith('"')){
|
||||
let index=1;parameter='';let closed=false;
|
||||
for(;index<rest.length;index++){
|
||||
const character=rest[index];
|
||||
if(character==='"'){closed=true;index++;break;}
|
||||
if(character==='\\'){
|
||||
index++;if(!['"','\\'].includes(rest[index]))fail('invalid-multipart');parameter+=rest[index];
|
||||
}else parameter+=character;
|
||||
}
|
||||
if(!closed)fail('invalid-multipart');rest=rest.slice(index);
|
||||
}else{
|
||||
const match=/^([^; \t]+)([ \t]*)(.*)$/.exec(rest);if(!match||!token.test(match[1]))fail('invalid-multipart');
|
||||
parameter=match[1];rest=match[3];
|
||||
}
|
||||
const name=key[1].toLowerCase();if(Object.hasOwn(result,name))fail('invalid-multipart');
|
||||
result[name]=parameter;rest=rest.replace(/^[ \t]+/,'');
|
||||
}
|
||||
return {main:main[1].trim().toLowerCase(),values:result};
|
||||
}
|
||||
function metadata(header){
|
||||
let text;try{text=new TextDecoder('utf-8',{fatal:true}).decode(header);}catch{fail('invalid-multipart');}
|
||||
const headers=Object.create(null);
|
||||
for(const line of text.split('\r\n')){
|
||||
const match=/^([^:]+):[ \t]*(.*)$/.exec(line);
|
||||
if(!match||!token.test(match[1])||[...match[2]].some(character=>{const code=character.charCodeAt(0);return code<32&&code!==9||code===127;}))fail('invalid-multipart');
|
||||
const name=match[1].toLowerCase();if(Object.hasOwn(headers,name))fail('invalid-multipart');headers[name]=match[2];
|
||||
}
|
||||
if(Object.keys(headers).some(name=>!['content-disposition','content-type'].includes(name)))fail('unsupported-multipart');
|
||||
if(!headers['content-disposition'])fail('invalid-multipart');
|
||||
const disposition=parameters(headers['content-disposition']),values=disposition.values;
|
||||
if(Object.keys(values).some(name=>name.endsWith('*')))fail('unsupported-multipart');
|
||||
if(disposition.main!=='form-data'||Object.keys(values).some(name=>!['name','filename'].includes(name))||typeof values.name!=='string'||!values.name||Buffer.byteLength(values.name)>256||[values.name,values.filename].some(value=>value!==undefined&&(Buffer.byteLength(value)>1024||[...value].some(character=>character.charCodeAt(0)<32||character.charCodeAt(0)===127))))fail('invalid-multipart');
|
||||
let contentType;
|
||||
if(headers['content-type']!==undefined){
|
||||
if(headers['content-type'].length>256)fail('multipart-limit');
|
||||
const type=parameters(headers['content-type']);
|
||||
if(!/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(type.main))fail('invalid-multipart');
|
||||
if(type.main.startsWith('multipart/'))fail('unsupported-multipart');
|
||||
contentType=headers['content-type'];
|
||||
}
|
||||
return {name:values.name,...values.filename!==undefined?{filename:values.filename}:{},...contentType!==undefined?{contentType}:{}};
|
||||
}
|
||||
/** Full-buffer parser. No filesystem access or content logging. Returned part
|
||||
* buffers are independent private copies; dispose only after scanners finish.
|
||||
* Strict unsupported forms fail closed instead of claiming partial coverage.
|
||||
*/
|
||||
function parseMultipart(body,contentType,{maxBytes=16777216,maxParts=64,maxHeaderBytes=8192}={}){
|
||||
if(!Buffer.isBuffer(body)||typeof contentType!=='string'||![maxBytes,maxParts,maxHeaderBytes].every(Number.isSafeInteger)||maxBytes<0||maxBytes>16777216||maxParts<1||maxParts>256||maxHeaderBytes<1||maxHeaderBytes>32768)fail('invalid-multipart');
|
||||
if(body.length>maxBytes||contentType.length>256)fail('multipart-limit');
|
||||
const type=parameters(contentType);
|
||||
if(type.main!=='multipart/form-data'||Object.keys(type.values).some(name=>name!=='boundary')||!/^[0-9a-z'()+_,./:=?-]{1,70}$/i.test(type.values.boundary||''))fail('invalid-multipart');
|
||||
const boundary=Buffer.from('--'+type.values.boundary),separator=Buffer.from('\r\n--'+type.values.boundary),parts=[];
|
||||
const dispose=()=>{for(const part of parts)part.body.fill(0);};
|
||||
try{
|
||||
if(!body.subarray(0,boundary.length).equals(boundary))fail('invalid-multipart');
|
||||
let position=boundary.length;
|
||||
while(true){
|
||||
if(body.subarray(position,position+2).equals(Buffer.from('--'))){
|
||||
position+=2;if(body.subarray(position).equals(Buffer.from('\r\n')))position+=2;
|
||||
if(position!==body.length)fail('invalid-multipart');break;
|
||||
}
|
||||
if(!body.subarray(position,position+2).equals(Buffer.from('\r\n')))fail('invalid-multipart');position+=2;
|
||||
if(parts.length>=maxParts)fail('multipart-limit');
|
||||
const headerEnd=body.indexOf('\r\n\r\n',position);
|
||||
if(headerEnd<0)fail('invalid-multipart');if(headerEnd-position>maxHeaderBytes)fail('multipart-limit');
|
||||
const record=metadata(body.subarray(position,headerEnd));position=headerEnd+4;
|
||||
let end=position;
|
||||
while(true){
|
||||
end=body.indexOf(separator,end);if(end<0)fail('invalid-multipart');
|
||||
const suffix=body.subarray(end+separator.length,end+separator.length+2);
|
||||
if(suffix.equals(Buffer.from('\r\n'))||suffix.equals(Buffer.from('--')))break;
|
||||
end++;
|
||||
}
|
||||
parts.push(Object.freeze({...record,body:Buffer.from(body.subarray(position,end))}));position=end+separator.length;
|
||||
}
|
||||
return Object.freeze({coverage:Object.freeze({complete:true}),parts:Object.freeze(parts),dispose});
|
||||
}catch(error){dispose();throw error;}
|
||||
}
|
||||
module.exports={parseMultipart,MultipartError};
|
||||
@@ -0,0 +1,147 @@
|
||||
# Native Windows service host
|
||||
|
||||
This component uses Windows SCM, named pipes and Job objects around the stock
|
||||
Node broker. It does not rebuild Electron/Chromium or modify installed Chrome.
|
||||
It has not been installed or proven as the production network boundary.
|
||||
|
||||
Run `node scripts/build-windows-service-host.mjs --self-test` on Windows with
|
||||
Visual Studio C++ Build Tools and SDK 10.0.26100.0. The builder uses explicit
|
||||
compiler/include/library paths, /W4 /WX, static runtime, CFG, ASLR and DEP. Output
|
||||
and fixed metadata evidence remain under ignored `artifacts/native-service-host`.
|
||||
It changes no execution policy, service, firewall, DNS setting or user profile.
|
||||
The builder accepts `--self-test` or `--compile-only`. Compile-only never launches
|
||||
the executable and writes separate evidence. It bakes a disposable public key, retains
|
||||
private test keys only in memory, and signs synthetic inventory fixtures after
|
||||
compilation. It never creates a production release key or registers a service.
|
||||
|
||||
The actual negative tests verify: missing SCM service identity, unauthorized
|
||||
client image, oversized frame, wrong file hash, second pipe instance denied by
|
||||
its DACL, and a child plus grandchild retained in a non-breakaway kill-on-close
|
||||
Job and both observed terminal after closing the Job. These are meaningful
|
||||
native component tests; they do not prove an installed SCM or WFP deployment.
|
||||
|
||||
The fixed bridge is `\\.\pipe\SafetyNet.Broker.v1`, local-only, one authenticated
|
||||
browser connection at a time. An enabled SCM service SID, protected code and
|
||||
independently pinned deployment material are required before broker launch. The
|
||||
client is the protected installed helper launched by the protected installed
|
||||
SafetyNet.exe. Both must have matching medium/high, non-AppContainer user
|
||||
tokens and sessions. Its process token and impersonated pipe token must agree
|
||||
on user SID and session. The process handle remains retained and monitored;
|
||||
client exit terminates the broker Job. The ACL excludes FILE_CREATE_PIPE_INSTANCE
|
||||
from employee rights. No trust arises from a pipe name or claimed PID in JSON.
|
||||
|
||||
Frames are uint32 little-endian length followed by UTF-8 JSON. The first
|
||||
parent-to-child frame is bounded to 2 MiB; subsequent frames and child responses
|
||||
are 64 KiB. The constant child receives only inherited stdin/stdout handles and
|
||||
`--service-stdio`; stderr goes to NUL. Seven Windows profile/runtime variables
|
||||
and one fixed OS-derived `SAFETYNET_SERVICE_CLIENT_SID` are passed. No caller
|
||||
environment supplies that SID. The Node adapter must validate the existing
|
||||
v1 generation, monotonic sequence, typed audit/certificate receipts and pinned
|
||||
service deployment authority. Framing does not authenticate policy or allow a
|
||||
caller to replace its public key, device identity or version floor.
|
||||
|
||||
The `--client-stdio` mode connects once and authenticates the actual connected
|
||||
handle's retained server process against signed release hashes, the exact
|
||||
installed path and an enabled SCM service SID. It never probes and reconnects.
|
||||
`--trust-snapshot` requires a service-token helper and a retained signed Node
|
||||
parent. It returns bounded framed public anchor/SID/registry-floor metadata,
|
||||
without accepting a registry path or trust key from its caller.
|
||||
|
||||
The fixed child entry is `resources/broker/apps/network-broker/service-entry.cjs`.
|
||||
The prepared `--topology-snapshot` helper uses the compiled broker port 43120,
|
||||
fixed installed browser/broker paths and the real SCM service SID. Service-token,
|
||||
signed Node-parent and signed user-assignment checks precede its read-only BFE
|
||||
enumeration. It verifies all eight owned filter keys, actions, conditions,
|
||||
weights and service descriptor bytes, checks equal-priority collisions and BFE
|
||||
state, and reports bounded metadata. It never accepts a plan, port or visibility
|
||||
flag from IPC. The new topology source compiled, but its latest self-test launch
|
||||
failed with `UNKNOWN:spawn`; compile-only evidence is separate from earlier
|
||||
successful native tests. This helper has no installed execution proof yet.
|
||||
|
||||
Windows enumerators omit unreadable objects and notifications are asynchronous
|
||||
and unreliable. Therefore the helper reports `allObjectsReadable:false` even at
|
||||
successful EOF. Production admission requires an independently reviewed,
|
||||
protected deployment prerequisite; token elevation or an ordinary JSON flag
|
||||
cannot establish it. Ordinary-user enforcement assumes trusted administrators;
|
||||
user-mode monitoring cannot promise zero packets during hostile privileged
|
||||
kernel/BFE changes. See Microsoft's [access control](https://learn.microsoft.com/en-us/windows/win32/fwp/access-control)
|
||||
and [notification contract](https://learn.microsoft.com/en-us/windows/win32/fwp/change-notifications).
|
||||
|
||||
The fixed 64-bit HKLM key `SOFTWARE\SafetyNet\Trust` contains one canonical
|
||||
`AcceptanceState` REG_BINARY value, including release/deployment digests and
|
||||
sorted per-user policy floors. Missing or corrupt state fails closed. The
|
||||
snapshot exposes `{version, authenticatedSid, releaseAnchor, acceptanceState}`.
|
||||
`--commit-acceptance` accepts only `{expectedPreviousSHA, candidateState}`,
|
||||
checks a full monotonic transition under an authenticated fixed mutex, writes
|
||||
one value, flushes and verifies readback before returning
|
||||
`{persisted:true,stateSha256}`. State bytes are capped at 2 MiB minus 1024 bytes;
|
||||
all stdio frames have a single bounded deadline. The trusted Node gate must
|
||||
verify the actual signed policy before requesting a commit. Policy digests hash
|
||||
the original base64url payload string; manifest digests hash canonical decoded
|
||||
JSON bytes.
|
||||
|
||||
The prepared `--management-credential` command accepts exactly device ID,
|
||||
canonical HTTPS origin, policy version, policy-key DER SHA256 and the accepted
|
||||
policy-payload digest. It binds them to the native service-minted user SID,
|
||||
signed v2 deployment and exact protected acceptance record. Its private reply
|
||||
contains only the same fields and the token, bounded to 8 KiB. The separate fixed
|
||||
key `SOFTWARE\SafetyNet\ManagementCredentials` requires a protected DACL owned
|
||||
by SYSTEM/administrators, granting access only to them and the exact service SID.
|
||||
Encrypted records use LocalMachine DPAPI with canonical metadata as entropy;
|
||||
this is Windows encryption, not a post-quantum storage claim.
|
||||
|
||||
Each read first validates the exact current accepted policy request. It then
|
||||
selects the unique highest stored version at or below that request for the same
|
||||
SID, device, origin and policy key. Same-version digest conflicts, ambiguous
|
||||
duplicates and future versions fail closed. Decryption uses the record's own
|
||||
original metadata; the reply echoes the verified current request. Advancing a
|
||||
policy therefore does not require re-encrypting an otherwise valid credential.
|
||||
Provisioning replaces all records for that stable identity and preserves other
|
||||
identities, preventing a new record for every policy revision.
|
||||
|
||||
Internal `--provision-management-credential` additionally requires the previous
|
||||
encrypted-store SHA256 and token. It uses the same authenticated service role,
|
||||
serialized mutex, bounded canonical store (256 records/1 MiB), CAS, flush and
|
||||
readback before returning a receipt. It accepts no registry path, user SID or
|
||||
key selector. Neither command creates keys or supplies an initial empty store.
|
||||
No renderer/enrollment provisioning endpoint is exposed. Initial protected
|
||||
provisioning and actual service-token DPAPI/registry execution remain unproven;
|
||||
the latest native source has compile-only evidence, not execution evidence.
|
||||
|
||||
The mutex uses an OWNER_RIGHTS read-control ACE rather than assuming the service
|
||||
SID can own objects. Actual native tests show owner WRITE_DAC denial while
|
||||
synchronization and modification remain allowed. Commit source is prepared,
|
||||
but no registry write, installed service positive or durable commit has been
|
||||
performed. Current uninstalled snapshot/commit/client tests require exit 5.
|
||||
|
||||
`signed-manifest.h` verifies canonical manifests using Windows CNG P-256/SHA256
|
||||
and fixed 64-byte IEEE-P1363 signatures. The baked vendor key verifies release
|
||||
inventory; its delegated customer key verifies deployment and user assignment.
|
||||
This is a classical release signature, not a post-quantum signature. Actual
|
||||
native tests verify positive delegation and reject tampering, the wrong customer
|
||||
key, expiry and equal-sequence digest mismatch. A larger signed inventory tests
|
||||
real envelope parsing. These fixture tests do not attest installed resource ACLs.
|
||||
|
||||
Before deployment, implement and independently verify these remaining gates:
|
||||
|
||||
- Actual vendor signing/provisioning and complete final inventory, including
|
||||
every broker, inspector, model and browser resource. The external signature
|
||||
avoids circular browser/host hashes; synthetic fixtures are not a release.
|
||||
- Installed positive client/server authentication and lifecycle tests; current
|
||||
native negatives cannot prove a correctly provisioned SCM service.
|
||||
- Protected initial acceptance-state provisioning and an actual service-token
|
||||
CAS/flush/readback proof before listen. Prepared source and uninstalled
|
||||
negative tests do not establish durable installed behavior.
|
||||
- Real SCM provisioning under a least-privileged service identity, protected
|
||||
resource/state ACLs, actual service-start/stop and bounded I/O timeout tests.
|
||||
- Per-user signed assignment binding and typed management/inspector/OTLP RPC.
|
||||
Interactive DPAPI audit persistence stays in the authenticated user parent.
|
||||
- Transactional WFP outbound filters and receiver service-descriptor soft-permit
|
||||
plus lower block in the same owned sublayer, preserving corporate veto.
|
||||
- Native readback and physical IPv4/IPv6/UDP/DNS/port-takeover tests, including
|
||||
another instance of the same stock Node executable and service crashes.
|
||||
- Effective WFP hard-permit topology validation and BFE change monitoring;
|
||||
a low-priority sublayer or a static rule inventory cannot prove enforcement.
|
||||
Browser inbound-first IPv4/IPv6 TCP/UDP denies need physical receiver tests.
|
||||
|
||||
Until those gates pass, retain existing browser quarantine and enforcement.
|
||||
@@ -0,0 +1,114 @@
|
||||
#pragma once
|
||||
#include "signed-manifest.h"
|
||||
// Canonical monotonic state contract. No registry operations in this header.
|
||||
namespace acceptance_contract {
|
||||
using release_contract::Json;
|
||||
static bool sha(const std::string &value) {
|
||||
if (value.size() != 64)
|
||||
return false;
|
||||
for (char c : value)
|
||||
if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
static bool userSid(const std::string &value) {
|
||||
if ((value.rfind("S-1-5-21-", 0) != 0 && value.rfind("S-1-12-1-", 0) != 0) ||
|
||||
std::count(value.begin(), value.end(), '-') != 7)
|
||||
return false;
|
||||
PSID sid = nullptr;
|
||||
if (!ConvertStringSidToSidA(value.c_str(), &sid))
|
||||
return false;
|
||||
LPSTR text = nullptr;
|
||||
bool valid = ConvertSidToStringSidA(sid, &text) && text && value == text;
|
||||
if (text)
|
||||
LocalFree(text);
|
||||
LocalFree(sid);
|
||||
return valid;
|
||||
}
|
||||
static std::string identity(const Json &record) {
|
||||
return release_contract::str(record.object.at("sid")) + "|" +
|
||||
release_contract::str(record.object.at("deviceId")) + "|" +
|
||||
release_contract::str(record.object.at("policyKeySha256"));
|
||||
}
|
||||
static void floor(const Json &value, bool deployment) {
|
||||
if (!release_contract::exact(
|
||||
value,
|
||||
deployment
|
||||
? std::initializer_list<const char *>{"minimumSequence",
|
||||
"acceptedDigest", "tenant"}
|
||||
: std::initializer_list<const char *>{"minimumSequence",
|
||||
"acceptedDigest"}) ||
|
||||
!sha(release_contract::str(value.object.at("acceptedDigest"))) ||
|
||||
!release_contract::number(value.object.at("minimumSequence")) ||
|
||||
(deployment && !release_contract::uuid(
|
||||
release_contract::str(value.object.at("tenant")))))
|
||||
throw std::runtime_error("fixed-state-invalid");
|
||||
}
|
||||
static Json validate(const std::string &text) {
|
||||
if (text.size() > 2096128)
|
||||
throw std::runtime_error("fixed-state-invalid");
|
||||
Json state = release_contract::json(text);
|
||||
if (!release_contract::exact(
|
||||
state, {"format", "release", "deployment", "policies"}) ||
|
||||
release_contract::str(state.object.at("format")) !=
|
||||
"safetynet-service-floors-v1")
|
||||
throw std::runtime_error("fixed-state-invalid");
|
||||
floor(state.object.at("release"), false);
|
||||
floor(state.object.at("deployment"), true);
|
||||
const auto &policies = state.object.at("policies");
|
||||
if (policies.kind != Json::Array || policies.array.size() > 8192)
|
||||
throw std::runtime_error("fixed-state-invalid");
|
||||
std::string previous;
|
||||
for (const auto &record : policies.array) {
|
||||
if (!release_contract::exact(record,
|
||||
{"sid", "deviceId", "policyKeySha256",
|
||||
"minimumVersion", "acceptedDigest"}) ||
|
||||
!userSid(release_contract::str(record.object.at("sid"))) ||
|
||||
!sha(release_contract::str(record.object.at("policyKeySha256"))) ||
|
||||
!sha(release_contract::str(record.object.at("acceptedDigest"))) ||
|
||||
!release_contract::number(record.object.at("minimumVersion")))
|
||||
throw std::runtime_error("fixed-state-invalid");
|
||||
auto device = release_contract::str(record.object.at("deviceId"));
|
||||
if (device.empty() || device.size() > 80 ||
|
||||
!isalnum(static_cast<unsigned char>(device[0])))
|
||||
throw std::runtime_error("fixed-state-invalid");
|
||||
for (unsigned char c : device)
|
||||
if (!isalnum(c) && c != '.' && c != '_' && c != '-')
|
||||
throw std::runtime_error("fixed-state-invalid");
|
||||
auto id = identity(record);
|
||||
if (!previous.empty() && id <= previous)
|
||||
throw std::runtime_error("fixed-state-invalid");
|
||||
previous = id;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
static bool nondecreasing(const Json &previous, const Json &candidate,
|
||||
const char *field) {
|
||||
auto old = release_contract::number(previous.object.at(field)),
|
||||
next = release_contract::number(candidate.object.at(field));
|
||||
return next >= old &&
|
||||
(next != old ||
|
||||
release_contract::str(previous.object.at("acceptedDigest")) ==
|
||||
release_contract::str(candidate.object.at("acceptedDigest")));
|
||||
}
|
||||
static bool transition(const Json &old, const Json &candidate) {
|
||||
if (!nondecreasing(old.object.at("release"), candidate.object.at("release"),
|
||||
"minimumSequence") ||
|
||||
!nondecreasing(old.object.at("deployment"),
|
||||
candidate.object.at("deployment"), "minimumSequence") ||
|
||||
release_contract::str(old.object.at("deployment").object.at("tenant")) !=
|
||||
release_contract::str(
|
||||
candidate.object.at("deployment").object.at("tenant")))
|
||||
return false;
|
||||
std::map<std::string, const Json *> records;
|
||||
for (const auto &record : candidate.object.at("policies").array)
|
||||
records.emplace(identity(record), &record);
|
||||
for (const auto &record : old.object.at("policies").array) {
|
||||
auto match = records.find(identity(record));
|
||||
if (match == records.end() ||
|
||||
!nondecreasing(record, *match->second, "minimumVersion"))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
} // namespace acceptance_contract
|
||||
@@ -0,0 +1,276 @@
|
||||
#pragma once
|
||||
#include "acceptance-state.h"
|
||||
#include <wincrypt.h>
|
||||
#pragma comment(lib, "crypt32.lib")
|
||||
namespace credential_contract {
|
||||
using release_contract::Json;
|
||||
static constexpr DWORD STORE_LIMIT = 1048576;
|
||||
struct Wipe {
|
||||
std::vector<BYTE> bytes;
|
||||
~Wipe() {
|
||||
if (!bytes.empty())
|
||||
SecureZeroMemory(bytes.data(), bytes.size());
|
||||
}
|
||||
};
|
||||
struct WipeText {
|
||||
std::string &text;
|
||||
~WipeText() {
|
||||
if (!text.empty())
|
||||
SecureZeroMemory(text.data(), text.size());
|
||||
}
|
||||
};
|
||||
static Json parse(const std::string &text) {
|
||||
release_contract::Parser parser{text};
|
||||
auto result = parser.parse();
|
||||
if (parser.offset != text.size())
|
||||
throw 1;
|
||||
return result;
|
||||
}
|
||||
static bool tokenValid(const std::string &token) {
|
||||
if (token.size() < 16 || token.size() > 4096)
|
||||
return false;
|
||||
for (char c : token)
|
||||
if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
|
||||
(c >= '0' && c <= '9') || c == '.' || c == '_' || c == '~' ||
|
||||
c == '-'))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
static Json binding(const Json &request,
|
||||
const release_contract::Assignment &assignment,
|
||||
const Json &state) {
|
||||
if (assignment.managementOrigin.empty() ||
|
||||
release_contract::str(request.object.at("deviceId")) !=
|
||||
assignment.deviceId ||
|
||||
release_contract::str(request.object.at("origin")) !=
|
||||
assignment.managementOrigin)
|
||||
throw 1;
|
||||
auto der = release_contract::pemDer(assignment.policyPem);
|
||||
const std::string keyBytes(der.begin(), der.end());
|
||||
if (release_contract::str(request.object.at("policyKeySha256")) !=
|
||||
release_contract::digest(keyBytes))
|
||||
throw 1;
|
||||
const auto version =
|
||||
release_contract::number(request.object.at("policyVersion"));
|
||||
const auto digest = release_contract::str(request.object.at("policyDigest"));
|
||||
if (!acceptance_contract::sha(digest) || version < assignment.minimumVersion)
|
||||
throw 1;
|
||||
bool matched = false;
|
||||
for (const auto &record : state.object.at("policies").array) {
|
||||
if (release_contract::str(record.object.at("sid")) == assignment.sid &&
|
||||
release_contract::str(record.object.at("deviceId")) ==
|
||||
assignment.deviceId &&
|
||||
release_contract::str(record.object.at("policyKeySha256")) ==
|
||||
release_contract::str(request.object.at("policyKeySha256"))) {
|
||||
matched =
|
||||
release_contract::number(record.object.at("minimumVersion")) ==
|
||||
version &&
|
||||
release_contract::str(record.object.at("acceptedDigest")) == digest;
|
||||
}
|
||||
}
|
||||
if (!matched)
|
||||
throw 1;
|
||||
Json result = request;
|
||||
result.object.erase("token");
|
||||
result.object.erase("expectedPreviousSHA");
|
||||
Json sid;
|
||||
sid.kind = Json::String;
|
||||
sid.string = assignment.sid;
|
||||
result.object["sid"] = sid;
|
||||
return result;
|
||||
}
|
||||
static Json validateStore(const std::string &text) {
|
||||
if (text.empty() || text.size() > STORE_LIMIT)
|
||||
throw 1;
|
||||
auto value = parse(text);
|
||||
if (release_contract::canonical(value) != text ||
|
||||
!release_contract::exact(value, {"format", "records"}) ||
|
||||
release_contract::str(value.object.at("format")) !=
|
||||
"safetynet-management-credentials-v1" ||
|
||||
value.object.at("records").kind != Json::Array ||
|
||||
value.object.at("records").array.size() > 256)
|
||||
throw 1;
|
||||
std::string previous;
|
||||
for (const auto &record : value.object.at("records").array) {
|
||||
if (!release_contract::exact(record, {"sid", "deviceId", "origin",
|
||||
"policyVersion", "policyKeySha256",
|
||||
"policyDigest", "protectedToken"}) ||
|
||||
!acceptance_contract::userSid(
|
||||
release_contract::str(record.object.at("sid"))) ||
|
||||
!release_contract::managementOrigin(
|
||||
release_contract::str(record.object.at("origin"))) ||
|
||||
!acceptance_contract::sha(
|
||||
release_contract::str(record.object.at("policyKeySha256"))) ||
|
||||
!acceptance_contract::sha(
|
||||
release_contract::str(record.object.at("policyDigest"))))
|
||||
throw 1;
|
||||
release_contract::number(record.object.at("policyVersion"));
|
||||
const auto device = release_contract::str(record.object.at("deviceId"));
|
||||
if (device.empty() || device.size() > 80 ||
|
||||
!isalnum(static_cast<unsigned char>(device[0])))
|
||||
throw 1;
|
||||
for (char c : device)
|
||||
if (!isalnum(static_cast<unsigned char>(c)) && c != '.' && c != '_' &&
|
||||
c != '-')
|
||||
throw 1;
|
||||
const auto cipher =
|
||||
release_contract::str(record.object.at("protectedToken"));
|
||||
if (cipher.empty() || cipher.size() > 16384)
|
||||
throw 1;
|
||||
release_contract::decode(cipher);
|
||||
Json identity = record;
|
||||
identity.object.erase("protectedToken");
|
||||
const auto key = release_contract::canonical(identity);
|
||||
if (!previous.empty() && key <= previous)
|
||||
throw 1;
|
||||
previous = key;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
static std::string stableIdentity(Json metadata) {
|
||||
metadata.object.erase("policyVersion");
|
||||
metadata.object.erase("policyDigest");
|
||||
metadata.object.erase("protectedToken");
|
||||
return release_contract::canonical(metadata);
|
||||
}
|
||||
static const Json *selectRecord(const Json &metadata,
|
||||
const std::vector<Json> &records) {
|
||||
const auto identity = stableIdentity(metadata);
|
||||
const auto current =
|
||||
release_contract::number(metadata.object.at("policyVersion"));
|
||||
const Json *selected = nullptr;
|
||||
ULONGLONG highest = 0;
|
||||
std::set<ULONGLONG> versions;
|
||||
for (const auto &record : records) {
|
||||
if (stableIdentity(record) != identity)
|
||||
continue;
|
||||
const auto version =
|
||||
release_contract::number(record.object.at("policyVersion"));
|
||||
if (!versions.insert(version).second)
|
||||
throw 1;
|
||||
if (version > current)
|
||||
throw 1;
|
||||
if (version == current &&
|
||||
release_contract::str(record.object.at("policyDigest")) !=
|
||||
release_contract::str(metadata.object.at("policyDigest")))
|
||||
throw 1;
|
||||
if (version > highest) {
|
||||
highest = version;
|
||||
selected = &record;
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
static void replaceRecord(std::vector<Json> &records, const Json &record) {
|
||||
const auto identity = stableIdentity(record);
|
||||
records.erase(std::remove_if(records.begin(), records.end(),
|
||||
[&](const Json &old) {
|
||||
return stableIdentity(old) == identity;
|
||||
}),
|
||||
records.end());
|
||||
records.push_back(record);
|
||||
std::sort(records.begin(), records.end(), [](Json a, Json b) {
|
||||
a.object.erase("protectedToken");
|
||||
b.object.erase("protectedToken");
|
||||
return release_contract::canonical(a) < release_contract::canonical(b);
|
||||
});
|
||||
}
|
||||
#ifdef SAFETYNET_NATIVE_SELF_TEST
|
||||
static bool selectionSelfTest() {
|
||||
auto current =
|
||||
parse("{\"deviceId\":\"synthetic\",\"origin\":\"https://"
|
||||
"management.example\",\"policyDigest\":\"" +
|
||||
std::string(64, 'b') + "\",\"policyKeySha256\":\"" +
|
||||
std::string(64, 'a') +
|
||||
"\",\"policyVersion\":8,\"sid\":\"S-1-5-21-1-2-3-1001\"}");
|
||||
auto old = current;
|
||||
old.object.at("policyVersion").integer = 7;
|
||||
old.object.at("policyDigest").string = std::string(64, 'c');
|
||||
auto oldest = old;
|
||||
oldest.object.at("policyVersion").integer = 6;
|
||||
std::vector<Json> records{oldest, old};
|
||||
if (selectRecord(current, records) != &records[1])
|
||||
return false;
|
||||
auto other = old;
|
||||
other.object.at("origin").string = "https://other.example";
|
||||
records.push_back(other);
|
||||
replaceRecord(records, current);
|
||||
if (records.size() != 2 || !selectRecord(current, records))
|
||||
return false;
|
||||
auto fails = [&](std::vector<Json> rows) {
|
||||
try {
|
||||
selectRecord(current, rows);
|
||||
return false;
|
||||
} catch (...) {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
auto conflicting = current;
|
||||
conflicting.object.at("policyDigest").string = std::string(64, 'd');
|
||||
auto future = current;
|
||||
future.object.at("policyVersion").integer = 9;
|
||||
if (!fails({old, old}) || !fails({conflicting}) || !fails({future}))
|
||||
return false;
|
||||
auto wrongKey = old;
|
||||
wrongKey.object.at("policyKeySha256").string = std::string(64, 'e');
|
||||
return !selectRecord(current, {other, wrongKey});
|
||||
}
|
||||
#endif
|
||||
static std::string load(HKEY key) {
|
||||
DWORD type = 0, size = 0;
|
||||
if (RegQueryValueExW(key, L"ManagementCredentials", nullptr, &type, nullptr,
|
||||
&size) ||
|
||||
type != REG_BINARY || !size || size > STORE_LIMIT)
|
||||
throw 1;
|
||||
std::string value(size, '\0');
|
||||
if (RegQueryValueExW(key, L"ManagementCredentials", nullptr, &type,
|
||||
reinterpret_cast<BYTE *>(value.data()), &size) ||
|
||||
type != REG_BINARY || size != value.size())
|
||||
throw 1;
|
||||
validateStore(value);
|
||||
return value;
|
||||
}
|
||||
static std::vector<BYTE> entropy(const Json &metadata) {
|
||||
const auto value =
|
||||
std::string("SafetyNet management credential v1\0",
|
||||
sizeof("SafetyNet management credential v1\0") - 1) +
|
||||
release_contract::canonical(metadata);
|
||||
auto digest = release_contract::unhex(release_contract::digest(value));
|
||||
return digest;
|
||||
}
|
||||
static std::string protect(const std::string &token, const Json &metadata) {
|
||||
if (!tokenValid(token))
|
||||
throw 1;
|
||||
auto bindingBytes = entropy(metadata);
|
||||
Wipe plain;
|
||||
plain.bytes.assign(token.begin(), token.end());
|
||||
DATA_BLOB input{static_cast<DWORD>(plain.bytes.size()), plain.bytes.data()},
|
||||
additional{static_cast<DWORD>(bindingBytes.size()), bindingBytes.data()},
|
||||
output{};
|
||||
if (!CryptProtectData(
|
||||
&input, L"SafetyNet credential", &additional, nullptr, nullptr,
|
||||
CRYPTPROTECT_LOCAL_MACHINE | CRYPTPROTECT_UI_FORBIDDEN, &output))
|
||||
throw 1;
|
||||
std::vector<BYTE> cipher(output.pbData, output.pbData + output.cbData);
|
||||
LocalFree(output.pbData);
|
||||
return release_contract::base64url(cipher);
|
||||
}
|
||||
static std::string unprotect(const std::string &cipher, const Json &metadata) {
|
||||
auto encrypted = release_contract::decode(cipher),
|
||||
bindingBytes = entropy(metadata);
|
||||
DATA_BLOB input{static_cast<DWORD>(encrypted.size()), encrypted.data()},
|
||||
additional{static_cast<DWORD>(bindingBytes.size()), bindingBytes.data()},
|
||||
output{};
|
||||
if (!CryptUnprotectData(&input, nullptr, &additional, nullptr, nullptr,
|
||||
CRYPTPROTECT_UI_FORBIDDEN, &output))
|
||||
throw 1;
|
||||
std::string token(reinterpret_cast<char *>(output.pbData), output.cbData);
|
||||
SecureZeroMemory(output.pbData, output.cbData);
|
||||
LocalFree(output.pbData);
|
||||
if (!tokenValid(token)) {
|
||||
SecureZeroMemory(token.data(), token.size());
|
||||
throw 1;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
} // namespace credential_contract
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,790 @@
|
||||
// Strict bounded native signed-release verification. No signer key in
|
||||
// envelopes.
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <stdexcept>
|
||||
#include <wincrypt.h>
|
||||
#include <ws2tcpip.h>
|
||||
#pragma comment(lib, "crypt32.lib")
|
||||
#pragma comment(lib, "ws2_32.lib")
|
||||
namespace release_contract {
|
||||
struct Json {
|
||||
enum Kind { String, Integer, Object, Array } kind;
|
||||
std::string string;
|
||||
ULONGLONG integer = 0;
|
||||
std::map<std::string, Json> object;
|
||||
std::vector<Json> array;
|
||||
};
|
||||
struct Parser {
|
||||
const std::string &text;
|
||||
size_t offset = 0, nodes = 0, stringCap = 16384;
|
||||
[[noreturn]] void fail() {
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
}
|
||||
char take() {
|
||||
if (offset >= text.size())
|
||||
fail();
|
||||
return text[offset++];
|
||||
}
|
||||
std::string string() {
|
||||
if (take() != '"')
|
||||
fail();
|
||||
std::string value;
|
||||
while (true) {
|
||||
char c = take();
|
||||
if (c == '"')
|
||||
break;
|
||||
if (static_cast<unsigned char>(c) < 32)
|
||||
fail();
|
||||
if (c == '\\') {
|
||||
char escaped = take();
|
||||
switch (escaped) {
|
||||
case '"':
|
||||
c = '"';
|
||||
break;
|
||||
case '\\':
|
||||
c = '\\';
|
||||
break;
|
||||
case 'b':
|
||||
c = '\b';
|
||||
break;
|
||||
case 'f':
|
||||
c = '\f';
|
||||
break;
|
||||
case 'n':
|
||||
c = '\n';
|
||||
break;
|
||||
case 'r':
|
||||
c = '\r';
|
||||
break;
|
||||
case 't':
|
||||
c = '\t';
|
||||
break;
|
||||
case 'u': {
|
||||
unsigned code = 0;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
char h = take();
|
||||
if (h >= '0' && h <= '9')
|
||||
code = code * 16 + unsigned(h - '0');
|
||||
else if (h >= 'a' && h <= 'f')
|
||||
code = code * 16 + unsigned(h - 'a' + 10);
|
||||
else
|
||||
fail();
|
||||
}
|
||||
if (code > 127)
|
||||
fail();
|
||||
c = char(code);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
fail();
|
||||
}
|
||||
}
|
||||
if (static_cast<unsigned char>(c) > 127 || value.size() >= stringCap)
|
||||
fail();
|
||||
value += c;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
Json parse(int depth = 0) {
|
||||
if (depth > 8 || ++nodes > 100000 || offset >= text.size())
|
||||
fail();
|
||||
Json result;
|
||||
char c = text[offset];
|
||||
if (c == '"') {
|
||||
result.kind = Json::String;
|
||||
result.string = string();
|
||||
return result;
|
||||
}
|
||||
if (c >= '0' && c <= '9') {
|
||||
result.kind = Json::Integer;
|
||||
size_t start = offset;
|
||||
while (offset < text.size() && text[offset] >= '0' &&
|
||||
text[offset] <= '9') {
|
||||
result.integer = result.integer * 10 + ULONGLONG(text[offset++] - '0');
|
||||
if (result.integer > 9007199254740991ULL)
|
||||
fail();
|
||||
}
|
||||
if (offset - start > 1 && text[start] == '0')
|
||||
fail();
|
||||
return result;
|
||||
}
|
||||
if (c == '{') {
|
||||
result.kind = Json::Object;
|
||||
offset++;
|
||||
if (offset < text.size() && text[offset] == '}') {
|
||||
offset++;
|
||||
return result;
|
||||
}
|
||||
while (true) {
|
||||
std::string key = string();
|
||||
if (take() != ':' || result.object.count(key))
|
||||
fail();
|
||||
result.object.emplace(key, parse(depth + 1));
|
||||
char delimiter = take();
|
||||
if (delimiter == '}')
|
||||
break;
|
||||
if (delimiter != ',')
|
||||
fail();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (c == '[') {
|
||||
result.kind = Json::Array;
|
||||
offset++;
|
||||
if (offset < text.size() && text[offset] == ']') {
|
||||
offset++;
|
||||
return result;
|
||||
}
|
||||
while (true) {
|
||||
result.array.push_back(parse(depth + 1));
|
||||
char delimiter = take();
|
||||
if (delimiter == ']')
|
||||
break;
|
||||
if (delimiter != ',')
|
||||
fail();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
fail();
|
||||
}
|
||||
};
|
||||
static std::string escaped(const std::string &input) {
|
||||
std::string output = "\"";
|
||||
for (unsigned char c : input) {
|
||||
switch (c) {
|
||||
case '"':
|
||||
output += "\\\"";
|
||||
break;
|
||||
case '\\':
|
||||
output += "\\\\";
|
||||
break;
|
||||
case '\b':
|
||||
output += "\\b";
|
||||
break;
|
||||
case '\f':
|
||||
output += "\\f";
|
||||
break;
|
||||
case '\n':
|
||||
output += "\\n";
|
||||
break;
|
||||
case '\r':
|
||||
output += "\\r";
|
||||
break;
|
||||
case '\t':
|
||||
output += "\\t";
|
||||
break;
|
||||
default:
|
||||
if (c < 32) {
|
||||
char bytes[7]{};
|
||||
sprintf_s(bytes, 7, "\\u%04x", unsigned(c));
|
||||
output += bytes;
|
||||
} else
|
||||
output += char(c);
|
||||
}
|
||||
}
|
||||
return output + '"';
|
||||
}
|
||||
static std::string canonical(const Json &value) {
|
||||
if (value.kind == Json::String)
|
||||
return escaped(value.string);
|
||||
if (value.kind == Json::Integer)
|
||||
return std::to_string(value.integer);
|
||||
std::string result;
|
||||
bool first = true;
|
||||
if (value.kind == Json::Object) {
|
||||
result = "{";
|
||||
for (const auto &pair : value.object) {
|
||||
if (!first)
|
||||
result += ',';
|
||||
first = false;
|
||||
result += escaped(pair.first) + ':' + canonical(pair.second);
|
||||
}
|
||||
return result + '}';
|
||||
}
|
||||
result = "[";
|
||||
for (const auto &item : value.array) {
|
||||
if (!first)
|
||||
result += ',';
|
||||
first = false;
|
||||
result += canonical(item);
|
||||
}
|
||||
return result + ']';
|
||||
}
|
||||
static Json json(const std::string &input, size_t maximum = 2097152,
|
||||
size_t stringCap = 16384) {
|
||||
if (input.empty() || input.size() > maximum)
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
Parser parser{input, 0, 0, stringCap};
|
||||
Json value = parser.parse();
|
||||
if (parser.offset != input.size() || canonical(value) != input)
|
||||
parser.fail();
|
||||
return value;
|
||||
}
|
||||
static bool exact(const Json &value,
|
||||
std::initializer_list<const char *> names) {
|
||||
if (value.kind != Json::Object || value.object.size() != names.size())
|
||||
return false;
|
||||
for (auto name : names)
|
||||
if (!value.object.count(name))
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
static const std::string &str(const Json &value) {
|
||||
if (value.kind != Json::String)
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
return value.string;
|
||||
}
|
||||
static ULONGLONG number(const Json &value) {
|
||||
if (value.kind != Json::Integer || !value.integer)
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
return value.integer;
|
||||
}
|
||||
static std::vector<BYTE> unhex(const std::string &input) {
|
||||
if (input.empty() || input.size() % 2)
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
std::vector<BYTE> result;
|
||||
auto digit = [](char c) {
|
||||
if (c >= '0' && c <= '9')
|
||||
return int(c - '0');
|
||||
if (c >= 'a' && c <= 'f')
|
||||
return int(c - 'a' + 10);
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
};
|
||||
for (size_t i = 0; i < input.size(); i += 2)
|
||||
result.push_back(BYTE(digit(input[i]) * 16 + digit(input[i + 1])));
|
||||
return result;
|
||||
}
|
||||
static std::string digest(const std::string &text) {
|
||||
BCRYPT_ALG_HANDLE alg = nullptr;
|
||||
BCRYPT_HASH_HANDLE hash = nullptr;
|
||||
std::array<BYTE, 32> bytes{};
|
||||
bool good = false;
|
||||
if (BCryptOpenAlgorithmProvider(&alg, BCRYPT_SHA256_ALGORITHM, nullptr, 0) >=
|
||||
0 &&
|
||||
BCryptCreateHash(alg, &hash, nullptr, 0, nullptr, 0, 0) >= 0)
|
||||
good = BCryptHashData(
|
||||
hash, reinterpret_cast<PUCHAR>(const_cast<char *>(text.data())),
|
||||
ULONG(text.size()), 0) >= 0 &&
|
||||
BCryptFinishHash(hash, bytes.data(), 32, 0) >= 0;
|
||||
if (hash)
|
||||
BCryptDestroyHash(hash);
|
||||
if (alg)
|
||||
BCryptCloseAlgorithmProvider(alg, 0);
|
||||
if (!good)
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
char output[65]{};
|
||||
for (size_t i = 0; i < 32; i++)
|
||||
sprintf_s(output + i * 2, 3, "%02x", bytes[i]);
|
||||
return output;
|
||||
}
|
||||
static std::string base64url(const std::vector<BYTE> &bytes) {
|
||||
DWORD size = 0;
|
||||
if (!CryptBinaryToStringA(bytes.data(), DWORD(bytes.size()),
|
||||
CRYPT_STRING_BASE64 | CRYPT_STRING_NOCRLF, nullptr,
|
||||
&size))
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
std::string text(size, '\0');
|
||||
if (!CryptBinaryToStringA(bytes.data(), DWORD(bytes.size()),
|
||||
CRYPT_STRING_BASE64 | CRYPT_STRING_NOCRLF,
|
||||
text.data(), &size))
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
text.resize(size);
|
||||
while (!text.empty() && (text.back() == '\0' || text.back() == '='))
|
||||
text.pop_back();
|
||||
std::replace(text.begin(), text.end(), '+', '-');
|
||||
std::replace(text.begin(), text.end(), '/', '_');
|
||||
return text;
|
||||
}
|
||||
static std::vector<BYTE> decode(const std::string &input) {
|
||||
if (input.empty() || input.size() > 3000000)
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
std::string text = input;
|
||||
for (char &c : text) {
|
||||
if (c == '-')
|
||||
c = '+';
|
||||
else if (c == '_')
|
||||
c = '/';
|
||||
else if (!(c >= 'a' && c <= 'z') && !(c >= 'A' && c <= 'Z') &&
|
||||
!(c >= '0' && c <= '9'))
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
}
|
||||
while (text.size() % 4)
|
||||
text += '=';
|
||||
DWORD size = 0;
|
||||
if (!CryptStringToBinaryA(text.c_str(), DWORD(text.size()),
|
||||
CRYPT_STRING_BASE64, nullptr, &size, nullptr,
|
||||
nullptr) ||
|
||||
size > 2097152)
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
std::vector<BYTE> result(size);
|
||||
if (!CryptStringToBinaryA(text.c_str(), DWORD(text.size()),
|
||||
CRYPT_STRING_BASE64, result.data(), &size, nullptr,
|
||||
nullptr) ||
|
||||
base64url(result) != input)
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
return result;
|
||||
}
|
||||
static bool verify(const std::vector<BYTE> &xy, const std::string &payload,
|
||||
const std::vector<BYTE> &signature,
|
||||
const std::string &domain) {
|
||||
if (xy.size() != 64 || signature.size() != 64)
|
||||
return false;
|
||||
BCRYPT_ALG_HANDLE algorithm = nullptr, sha = nullptr;
|
||||
BCRYPT_KEY_HANDLE key = nullptr;
|
||||
BCRYPT_HASH_HANDLE hash = nullptr;
|
||||
bool okay = false;
|
||||
if (BCryptOpenAlgorithmProvider(&algorithm, BCRYPT_ECDSA_P256_ALGORITHM,
|
||||
nullptr, 0) < 0)
|
||||
return false;
|
||||
BCRYPT_ECCKEY_BLOB header{BCRYPT_ECDSA_PUBLIC_P256_MAGIC, 32};
|
||||
std::vector<BYTE> blob(sizeof(header) + 64);
|
||||
memcpy(blob.data(), &header, sizeof(header));
|
||||
memcpy(blob.data() + sizeof(header), xy.data(), 64);
|
||||
if (BCryptImportKeyPair(algorithm, nullptr, BCRYPT_ECCPUBLIC_BLOB, &key,
|
||||
blob.data(), DWORD(blob.size()), 0) >= 0 &&
|
||||
BCryptOpenAlgorithmProvider(&sha, BCRYPT_SHA256_ALGORITHM, nullptr, 0) >=
|
||||
0 &&
|
||||
BCryptCreateHash(sha, &hash, nullptr, 0, nullptr, 0, 0) >= 0) {
|
||||
std::array<BYTE, 32> digest{};
|
||||
if (BCryptHashData(
|
||||
hash, reinterpret_cast<PUCHAR>(const_cast<char *>(domain.data())),
|
||||
ULONG(domain.size()), 0) >= 0 &&
|
||||
BCryptHashData(
|
||||
hash, reinterpret_cast<PUCHAR>(const_cast<char *>(payload.data())),
|
||||
ULONG(payload.size()), 0) >= 0 &&
|
||||
BCryptFinishHash(hash, digest.data(), 32, 0) >= 0)
|
||||
okay = BCryptVerifySignature(key, nullptr, digest.data(), 32,
|
||||
const_cast<PUCHAR>(signature.data()), 64,
|
||||
0) >= 0;
|
||||
}
|
||||
if (hash)
|
||||
BCryptDestroyHash(hash);
|
||||
if (sha)
|
||||
BCryptCloseAlgorithmProvider(sha, 0);
|
||||
if (key)
|
||||
BCryptDestroyKey(key);
|
||||
BCryptCloseAlgorithmProvider(algorithm, 0);
|
||||
return okay;
|
||||
}
|
||||
static std::string read(const std::wstring &file) {
|
||||
Handle input(CreateFileW(file.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr,
|
||||
OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT,
|
||||
nullptr));
|
||||
if (input.h == INVALID_HANDLE_VALUE)
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
FILE_ATTRIBUTE_TAG_INFO info{};
|
||||
LARGE_INTEGER size{};
|
||||
if (!GetFileInformationByHandleEx(input, FileAttributeTagInfo, &info,
|
||||
sizeof(info)) ||
|
||||
(info.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) ||
|
||||
!GetFileSizeEx(input, &size) || size.QuadPart < 1 ||
|
||||
size.QuadPart > 3000000)
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
std::string content(size_t(size.QuadPart), '\0');
|
||||
DWORD bytes = 0;
|
||||
if (!ReadFile(input, content.data(), DWORD(content.size()), &bytes,
|
||||
nullptr) ||
|
||||
bytes != content.size())
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
return content;
|
||||
}
|
||||
static Json signedPayload(const std::wstring &file, const std::vector<BYTE> &xy,
|
||||
const std::string &domain, size_t maximum = 2097152) {
|
||||
Json envelope = json(read(file), 3000000, 3000000);
|
||||
if (!exact(envelope, {"payload", "signature"}))
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
auto bytes = decode(str(envelope.object.at("payload"))),
|
||||
signature = decode(str(envelope.object.at("signature")));
|
||||
if (bytes.size() > maximum)
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
std::string payload(bytes.begin(), bytes.end());
|
||||
if (!verify(xy, payload, signature, domain))
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
return json(payload);
|
||||
}
|
||||
static bool uuid(const std::string &value) {
|
||||
if (value.size() != 36)
|
||||
return false;
|
||||
for (size_t i = 0; i < value.size(); i++) {
|
||||
if (i == 8 || i == 13 || i == 18 || i == 23) {
|
||||
if (value[i] != '-')
|
||||
return false;
|
||||
} else if (!((value[i] >= 'a' && value[i] <= 'f') ||
|
||||
(value[i] >= '0' && value[i] <= '9')))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
static bool relative(const std::string &value) {
|
||||
if (value.empty() || value.size() > 240 || value.front() == '/' ||
|
||||
value.back() == '/')
|
||||
return false;
|
||||
size_t start = 0;
|
||||
for (size_t i = 0; i <= value.size(); i++) {
|
||||
if (i < value.size() && value[i] != '/') {
|
||||
char c = value[i];
|
||||
if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
|
||||
(c >= '0' && c <= '9') || c == '_' || c == '.' || c == '-'))
|
||||
return false;
|
||||
continue;
|
||||
}
|
||||
std::string part = value.substr(start, i - start);
|
||||
if (part.empty() || part == "." || part == ".." || part.back() == '.')
|
||||
return false;
|
||||
std::transform(part.begin(), part.end(), part.begin(),
|
||||
[](unsigned char c) { return char(toupper(c)); });
|
||||
part = part.substr(0, part.find('.'));
|
||||
if (part == "CON" || part == "PRN" || part == "AUX" || part == "NUL" ||
|
||||
(part.size() == 4 &&
|
||||
(part.substr(0, 3) == "COM" || part.substr(0, 3) == "LPT") &&
|
||||
part[3] >= '1' && part[3] <= '9'))
|
||||
return false;
|
||||
start = i + 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
struct Release {
|
||||
std::string releaseId, tenant, customerPem;
|
||||
ULONGLONG sequence;
|
||||
std::map<std::string, std::string> hashes;
|
||||
};
|
||||
static Release release(const std::wstring &root,
|
||||
const std::vector<BYTE> &anchor, ULONGLONG floor,
|
||||
const std::string &acceptedDigest = "") {
|
||||
Json value =
|
||||
signedPayload(root + L"\\release-envelope.json", anchor,
|
||||
std::string("SafetyNet Windows release v1\0",
|
||||
sizeof("SafetyNet Windows release v1\0") - 1));
|
||||
if (!exact(value, {"format", "releaseId", "releaseSequence", "serviceName",
|
||||
"files", "deploymentTenant", "deploymentPublicKey"}))
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
if (str(value.object.at("format")) != "safetynet-windows-release-v1" ||
|
||||
str(value.object.at("serviceName")) != "SafetyNetNetworkBroker" ||
|
||||
!uuid(str(value.object.at("releaseId"))) ||
|
||||
!uuid(str(value.object.at("deploymentTenant"))))
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
Release result{str(value.object.at("releaseId")),
|
||||
str(value.object.at("deploymentTenant")),
|
||||
str(value.object.at("deploymentPublicKey")),
|
||||
number(value.object.at("releaseSequence")),
|
||||
{}};
|
||||
if (!floor || result.sequence < floor ||
|
||||
(result.sequence == floor && !acceptedDigest.empty() &&
|
||||
digest(canonical(value)) != acceptedDigest))
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
const Json &files = value.object.at("files");
|
||||
if (files.kind != Json::Array || files.array.empty() ||
|
||||
files.array.size() > 10000)
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
std::string previous;
|
||||
std::set<std::string> folded;
|
||||
for (const auto &item : files.array) {
|
||||
if (!exact(item, {"path", "bytes", "sha256"}))
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
auto name = str(item.object.at("path")),
|
||||
digest = str(item.object.at("sha256"));
|
||||
if (!relative(name) || (!previous.empty() && name <= previous) ||
|
||||
digest.size() != 64 || unhex(digest).size() != 32 ||
|
||||
item.object.at("bytes").kind != Json::Integer)
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
previous = name;
|
||||
std::string lower = name;
|
||||
std::transform(lower.begin(), lower.end(), lower.begin(),
|
||||
[](unsigned char c) { return char(tolower(c)); });
|
||||
if (!folded.insert(lower).second)
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
result.hashes.emplace(name, digest);
|
||||
}
|
||||
for (const char *required :
|
||||
{"SafetyNet.exe", "safetynet-service-host.exe", "resources/app.asar",
|
||||
"resources/broker/node.exe", "resources/python/python.exe",
|
||||
"resources/inspector/server.py"})
|
||||
if (!result.hashes.count(required))
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
bool semantic = false;
|
||||
for (const auto &item : result.hashes) {
|
||||
if (item.first.rfind("resources/semantic/", 0) == 0)
|
||||
semantic = true;
|
||||
if (item.first == "release-envelope.json" ||
|
||||
item.first == "deployment-envelope.json")
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
}
|
||||
if (!semantic)
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
return result;
|
||||
}
|
||||
static std::vector<BYTE> pemDer(const std::string &pem, size_t pemLimit = 4096,
|
||||
DWORD derLimit = 1024) {
|
||||
if (pem.size() > pemLimit)
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
DWORD size = 0;
|
||||
if (!CryptStringToBinaryA(pem.c_str(), DWORD(pem.size()),
|
||||
CRYPT_STRING_BASE64HEADER, nullptr, &size, nullptr,
|
||||
nullptr) ||
|
||||
size > derLimit)
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
std::vector<BYTE> bytes(size);
|
||||
if (!CryptStringToBinaryA(pem.c_str(), DWORD(pem.size()),
|
||||
CRYPT_STRING_BASE64HEADER, bytes.data(), &size,
|
||||
nullptr, nullptr))
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
std::string encoded = base64url(bytes);
|
||||
std::replace(encoded.begin(), encoded.end(), '-', '+');
|
||||
std::replace(encoded.begin(), encoded.end(), '_', '/');
|
||||
while (encoded.size() % 4)
|
||||
encoded += '=';
|
||||
std::string canonicalPem = "-----BEGIN PUBLIC KEY-----\n";
|
||||
for (size_t offset = 0; offset < encoded.size(); offset += 64)
|
||||
canonicalPem += encoded.substr(offset, 64) + '\n';
|
||||
canonicalPem += "-----END PUBLIC KEY-----\n";
|
||||
if (canonicalPem != pem)
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
return bytes;
|
||||
}
|
||||
static std::vector<BYTE> customerKey(const std::string &pem) {
|
||||
auto bytes = pemDer(pem),
|
||||
prefix = unhex("3059301306072a8648ce3d020106082a8648ce3d03010703420004");
|
||||
if (bytes.size() != 91 ||
|
||||
!std::equal(prefix.begin(), prefix.end(), bytes.begin()))
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
return std::vector<BYTE>(bytes.begin() + 27, bytes.end());
|
||||
}
|
||||
static bool policyKey(const std::string &pem) {
|
||||
auto bytes = pemDer(pem), prefix = unhex("302a300506032b6570032100");
|
||||
return bytes.size() == 44 &&
|
||||
std::equal(prefix.begin(), prefix.end(), bytes.begin());
|
||||
}
|
||||
// SPKI metadata binding only; actual ML-DSA signatures use the existing Node
|
||||
// policy channel. No unknown algorithm or opaque-key fallback is accepted.
|
||||
static bool managementKey(const std::string &pem) {
|
||||
auto bytes = pemDer(pem, 20000, 4096),
|
||||
prefix = unhex("30820a32300b060960864801650304031303820a2100");
|
||||
return bytes.size() == 2614 &&
|
||||
std::equal(prefix.begin(), prefix.end(), bytes.begin());
|
||||
}
|
||||
static std::string ipv6Canonical(const IN6_ADDR &address) {
|
||||
std::array<unsigned, 8> groups{};
|
||||
for (size_t i = 0; i < 8; ++i)
|
||||
groups[i] =
|
||||
unsigned(address.u.Byte[i * 2]) * 256 + address.u.Byte[i * 2 + 1];
|
||||
size_t best = 8, longest = 1;
|
||||
for (size_t i = 0; i < 8;) {
|
||||
if (groups[i]) {
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
size_t start = i;
|
||||
while (i < 8 && !groups[i])
|
||||
++i;
|
||||
if (i - start > longest) {
|
||||
best = start;
|
||||
longest = i - start;
|
||||
}
|
||||
}
|
||||
std::string output;
|
||||
for (size_t i = 0; i < 8;) {
|
||||
if (i == best) {
|
||||
output += "::";
|
||||
i += longest;
|
||||
continue;
|
||||
}
|
||||
if (!output.empty() && output.back() != ':')
|
||||
output += ':';
|
||||
char hex[5]{};
|
||||
sprintf_s(hex, "%x", groups[i]);
|
||||
output += hex;
|
||||
++i;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
static bool managementOrigin(const std::string &origin) {
|
||||
if (origin.size() > 2048 || origin.rfind("https://", 0) != 0)
|
||||
return false;
|
||||
std::string authority = origin.substr(8), host, port;
|
||||
for (unsigned char c : authority)
|
||||
if (c <= 32 || c >= 127 || c == '/' || c == '?' || c == '#' || c == '@' ||
|
||||
c == '\\' || c == '%')
|
||||
return false;
|
||||
if (authority.empty())
|
||||
return false;
|
||||
if (authority[0] == '[') {
|
||||
auto end = authority.find(']');
|
||||
if (end == std::string::npos)
|
||||
return false;
|
||||
host = authority.substr(1, end - 1);
|
||||
IN6_ADDR address{};
|
||||
if (InetPtonA(AF_INET6, host.c_str(), &address) != 1 ||
|
||||
ipv6Canonical(address) != host)
|
||||
return false;
|
||||
if (end + 1 < authority.size()) {
|
||||
if (authority[end + 1] != ':')
|
||||
return false;
|
||||
port = authority.substr(end + 2);
|
||||
if (port.empty())
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
auto colon = authority.find(':');
|
||||
host = authority.substr(0, colon);
|
||||
if (colon != std::string::npos) {
|
||||
port = authority.substr(colon + 1);
|
||||
if (port.empty())
|
||||
return false;
|
||||
}
|
||||
if (host.empty() || host.size() > 253)
|
||||
return false;
|
||||
IN_ADDR address{};
|
||||
if (InetPtonA(AF_INET, host.c_str(), &address) == 1) {
|
||||
const BYTE *bytes = reinterpret_cast<const BYTE *>(&address);
|
||||
std::string ip =
|
||||
std::to_string(bytes[0]) + "." + std::to_string(bytes[1]) + "." +
|
||||
std::to_string(bytes[2]) + "." + std::to_string(bytes[3]);
|
||||
if (ip != host)
|
||||
return false;
|
||||
} else {
|
||||
bool numeric = true;
|
||||
for (char c : host)
|
||||
if (!(c >= '0' && c <= '9') && c != '.')
|
||||
numeric = false;
|
||||
if (numeric)
|
||||
return false;
|
||||
size_t start = 0;
|
||||
while (start < host.size()) {
|
||||
auto end = host.find('.', start);
|
||||
if (end == std::string::npos)
|
||||
end = host.size();
|
||||
auto label = host.substr(start, end - start);
|
||||
if (label.empty() || label.size() > 63 || label.front() == '-' ||
|
||||
label.back() == '-')
|
||||
return false;
|
||||
for (char c : label)
|
||||
if (!(c >= 'a' && c <= 'z') && !(c >= '0' && c <= '9') && c != '-')
|
||||
return false;
|
||||
if (label.rfind("0x", 0) == 0 && label.size() > 2 &&
|
||||
std::all_of(label.begin() + 2, label.end(), [](char c) {
|
||||
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f');
|
||||
}))
|
||||
return false;
|
||||
if (end == host.size())
|
||||
break;
|
||||
start = end + 1;
|
||||
if (start == host.size())
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!port.empty()) {
|
||||
if (port.size() > 5 || port[0] == '0')
|
||||
return false;
|
||||
unsigned number = 0;
|
||||
for (char c : port) {
|
||||
if (c < '0' || c > '9')
|
||||
return false;
|
||||
number = number * 10 + unsigned(c - '0');
|
||||
}
|
||||
if (!number || number > 65535 || number == 443)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
struct Assignment {
|
||||
std::string sid, deviceId, policyPem;
|
||||
ULONGLONG minimumVersion, deploymentSequence;
|
||||
std::string managementOrigin;
|
||||
};
|
||||
static Assignment deployment(const std::wstring &root, const Release &release,
|
||||
const std::string &sid, ULONGLONG floor,
|
||||
const std::string &acceptedDigest = "") {
|
||||
Json value = signedPayload(
|
||||
root + L"\\deployment-envelope.json", customerKey(release.customerPem),
|
||||
std::string("SafetyNet Windows deployment v1\0",
|
||||
sizeof("SafetyNet Windows deployment v1\0") - 1),
|
||||
262144);
|
||||
const auto format = str(value.object.at("format"));
|
||||
const bool v2 = format == "safetynet-windows-deployment-v2";
|
||||
if ((!v2 && format != "safetynet-windows-deployment-v1") ||
|
||||
(v2 ? !exact(value, {"format", "releaseId", "deploymentTenant",
|
||||
"deploymentSequence", "policyPublicKey", "expiresAt",
|
||||
"users", "management"})
|
||||
: !exact(value, {"format", "releaseId", "deploymentTenant",
|
||||
"deploymentSequence", "policyPublicKey", "expiresAt",
|
||||
"users"})) ||
|
||||
str(value.object.at("releaseId")) != release.releaseId ||
|
||||
str(value.object.at("deploymentTenant")) != release.tenant || !floor ||
|
||||
number(value.object.at("deploymentSequence")) < floor)
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
if (number(value.object.at("deploymentSequence")) == floor &&
|
||||
!acceptedDigest.empty() && digest(canonical(value)) != acceptedDigest)
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
if (v2) {
|
||||
const auto &management = value.object.at("management");
|
||||
if (!exact(management, {"origin", "signatureKey", "protocol"}) ||
|
||||
!managementOrigin(str(management.object.at("origin"))) ||
|
||||
str(management.object.at("protocol")) !=
|
||||
"safetynet-policy-mlkem1024-mldsa87-v1" ||
|
||||
!managementKey(str(management.object.at("signatureKey"))))
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
}
|
||||
FILETIME now;
|
||||
GetSystemTimeAsFileTime(&now);
|
||||
ULARGE_INTEGER time{};
|
||||
time.LowPart = now.dwLowDateTime;
|
||||
time.HighPart = now.dwHighDateTime;
|
||||
ULONGLONG millis = time.QuadPart / 10000 - 11644473600000ULL;
|
||||
if (number(value.object.at("expiresAt")) <= millis)
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
const auto &users = value.object.at("users");
|
||||
if (users.kind != Json::Array || users.array.empty() ||
|
||||
users.array.size() > 1024)
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
Assignment result{};
|
||||
std::string previous;
|
||||
std::set<std::string> devices;
|
||||
for (const auto &user : users.array) {
|
||||
if (!exact(user, {"sid", "deviceId", "minimumPolicyVersion"}))
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
auto userSid = str(user.object.at("sid")),
|
||||
device = str(user.object.at("deviceId"));
|
||||
PSID parsed = nullptr;
|
||||
if (!ConvertStringSidToSidA(userSid.c_str(), &parsed))
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
LPSTR canonicalSid = nullptr;
|
||||
bool valid = ConvertSidToStringSidA(parsed, &canonicalSid) &&
|
||||
canonicalSid && userSid == canonicalSid;
|
||||
if (canonicalSid)
|
||||
LocalFree(canonicalSid);
|
||||
LocalFree(parsed);
|
||||
bool sidFamily = (userSid.rfind("S-1-5-21-", 0) == 0 ||
|
||||
userSid.rfind("S-1-12-1-", 0) == 0) &&
|
||||
std::count(userSid.begin(), userSid.end(), '-') == 7;
|
||||
if (!valid || !sidFamily || (!previous.empty() && userSid <= previous) ||
|
||||
device.empty() || device.size() > 80 ||
|
||||
!isalnum(static_cast<unsigned char>(device[0])) ||
|
||||
!devices.insert(device).second)
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
for (char c : device)
|
||||
if (!isalnum(static_cast<unsigned char>(c)) && c != '.' && c != '_' &&
|
||||
c != '-')
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
previous = userSid;
|
||||
ULONGLONG minimum = number(user.object.at("minimumPolicyVersion"));
|
||||
if (userSid == sid)
|
||||
result = {sid,
|
||||
device,
|
||||
str(value.object.at("policyPublicKey")),
|
||||
minimum,
|
||||
number(value.object.at("deploymentSequence")),
|
||||
v2 ? str(value.object.at("management").object.at("origin"))
|
||||
: ""};
|
||||
}
|
||||
if (result.sid.empty() || result.policyPem.empty() ||
|
||||
!policyKey(result.policyPem))
|
||||
throw std::runtime_error("fixed-manifest-invalid");
|
||||
return result;
|
||||
}
|
||||
} // namespace release_contract
|
||||
@@ -0,0 +1,378 @@
|
||||
#pragma once
|
||||
// clang-format off
|
||||
#include <windows.h>
|
||||
#include <sddl.h>
|
||||
#include <objbase.h>
|
||||
#include <fwpmu.h>
|
||||
// clang-format on
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <windows.h>
|
||||
#pragma comment(lib, "fwpuclnt.lib")
|
||||
#pragma comment(lib, "ole32.lib")
|
||||
namespace topology_contract {
|
||||
static constexpr UINT16 BROKER_PORT = 43120;
|
||||
static GUID guid(const wchar_t *text) {
|
||||
GUID value{};
|
||||
if (FAILED(CLSIDFromString(text, &value)))
|
||||
throw 1;
|
||||
return value;
|
||||
}
|
||||
struct Memory {
|
||||
void *p = nullptr;
|
||||
~Memory() {
|
||||
if (p)
|
||||
FwpmFreeMemory0(&p);
|
||||
}
|
||||
};
|
||||
struct Engine {
|
||||
HANDLE h = nullptr;
|
||||
~Engine() {
|
||||
if (h)
|
||||
FwpmEngineClose0(h);
|
||||
}
|
||||
};
|
||||
struct Enumeration {
|
||||
HANDLE engine, h = nullptr;
|
||||
explicit Enumeration(HANDLE e) : engine(e) {}
|
||||
~Enumeration() {
|
||||
if (h)
|
||||
FwpmFilterDestroyEnumHandle0(engine, h);
|
||||
}
|
||||
};
|
||||
struct Result {
|
||||
DWORD code = ERROR_ACCESS_DENIED;
|
||||
UINT32 accessibleFilters = 0;
|
||||
UINT32 ownedFilters = 0;
|
||||
bool ownedVerified = false;
|
||||
bool collision = false;
|
||||
bool bfeRunning = false;
|
||||
// Enumeration omits unreadable objects even at successful EOF. Never infer
|
||||
// this from token elevation, caller JSON or the number of returned rows.
|
||||
bool allObjectsReadable = false;
|
||||
};
|
||||
static bool blobEqual(const FWP_BYTE_BLOB *actual,
|
||||
const std::vector<BYTE> &expected) {
|
||||
return actual && actual->data && actual->size == expected.size() &&
|
||||
memcmp(actual->data, expected.data(), expected.size()) == 0;
|
||||
}
|
||||
struct ExpectedCondition {
|
||||
GUID field;
|
||||
FWP_MATCH_TYPE match;
|
||||
FWP_DATA_TYPE type;
|
||||
UINT32 value;
|
||||
std::vector<BYTE> bytes;
|
||||
};
|
||||
static bool marker(const FWP_BYTE_BLOB &value) {
|
||||
static constexpr char expected[] = "SafetyNet.NetworkBoundary.v1";
|
||||
return value.data && value.size == sizeof(expected) - 1 &&
|
||||
memcmp(value.data, expected, sizeof(expected) - 1) == 0;
|
||||
}
|
||||
using SecurityQuery = DWORD(WINAPI *)(HANDLE, const GUID *,
|
||||
SECURITY_INFORMATION, PSID *, PSID *,
|
||||
PACL *, PACL *, PSECURITY_DESCRIPTOR *);
|
||||
static bool protectedObject(HANDLE engine, const GUID &key,
|
||||
SecurityQuery query) {
|
||||
PSECURITY_DESCRIPTOR descriptor = nullptr;
|
||||
PACL dacl = nullptr;
|
||||
PSID owner = nullptr;
|
||||
DWORD code = query(engine, &key,
|
||||
OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION,
|
||||
&owner, nullptr, &dacl, nullptr, &descriptor);
|
||||
BYTE system[SECURITY_MAX_SID_SIZE], admins[SECURITY_MAX_SID_SIZE];
|
||||
DWORD ss = sizeof(system), as = sizeof(admins);
|
||||
CreateWellKnownSid(WinLocalSystemSid, nullptr, system, &ss);
|
||||
CreateWellKnownSid(WinBuiltinAdministratorsSid, nullptr, admins, &as);
|
||||
SECURITY_DESCRIPTOR_CONTROL control{};
|
||||
DWORD revision = 0;
|
||||
bool okay = !code && dacl && owner &&
|
||||
(EqualSid(owner, system) || EqualSid(owner, admins)) &&
|
||||
GetSecurityDescriptorControl(descriptor, &control, &revision) &&
|
||||
(control & SE_DACL_PROTECTED);
|
||||
const DWORD write = FWPM_ACTRL_ADD | FWPM_ACTRL_ADD_LINK |
|
||||
FWPM_ACTRL_BEGIN_WRITE_TXN | FWPM_ACTRL_WRITE | DELETE |
|
||||
WRITE_DAC | WRITE_OWNER | GENERIC_ALL | GENERIC_WRITE;
|
||||
if (okay)
|
||||
for (DWORD i = 0; i < dacl->AceCount; ++i) {
|
||||
void *raw = nullptr;
|
||||
if (!GetAce(dacl, i, &raw)) {
|
||||
okay = false;
|
||||
break;
|
||||
}
|
||||
auto header = static_cast<ACE_HEADER *>(raw);
|
||||
if (header->AceFlags & INHERIT_ONLY_ACE)
|
||||
continue;
|
||||
if (header->AceType == ACCESS_ALLOWED_ACE_TYPE) {
|
||||
auto ace = static_cast<ACCESS_ALLOWED_ACE *>(raw);
|
||||
if ((ace->Mask & write) && !EqualSid(&ace->SidStart, system) &&
|
||||
!EqualSid(&ace->SidStart, admins)) {
|
||||
okay = false;
|
||||
break;
|
||||
}
|
||||
} else if (header->AceType != ACCESS_DENIED_ACE_TYPE) {
|
||||
okay = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (descriptor)
|
||||
FwpmFreeMemory0(reinterpret_cast<void **>(&descriptor));
|
||||
return okay;
|
||||
}
|
||||
struct Rule {
|
||||
GUID key, layer;
|
||||
FWP_ACTION_TYPE action;
|
||||
UINT64 weight;
|
||||
std::vector<ExpectedCondition> conditions;
|
||||
};
|
||||
static bool matches(const FWPM_FILTER0 &actual, const Rule &rule,
|
||||
const GUID &provider, const GUID &sublayer) {
|
||||
if (!actual.providerKey || !IsEqualGUID(*actual.providerKey, provider) ||
|
||||
!IsEqualGUID(actual.subLayerKey, sublayer) ||
|
||||
!IsEqualGUID(actual.layerKey, rule.layer) ||
|
||||
actual.action.type != rule.action ||
|
||||
actual.flags != FWPM_FILTER_FLAG_PERSISTENT ||
|
||||
actual.numFilterConditions != rule.conditions.size() ||
|
||||
!actual.filterCondition)
|
||||
return false;
|
||||
if (rule.weight) {
|
||||
if (actual.weight.type != FWP_UINT64 || !actual.weight.uint64 ||
|
||||
*actual.weight.uint64 != rule.weight)
|
||||
return false;
|
||||
} else if (actual.weight.type != FWP_EMPTY)
|
||||
return false;
|
||||
for (size_t i = 0; i < rule.conditions.size(); ++i) {
|
||||
const auto &e = rule.conditions[i];
|
||||
const auto &a = actual.filterCondition[i];
|
||||
if (!IsEqualGUID(a.fieldKey, e.field) || a.matchType != e.match ||
|
||||
a.conditionValue.type != e.type)
|
||||
return false;
|
||||
switch (e.type) {
|
||||
case FWP_UINT8:
|
||||
if (a.conditionValue.uint8 != e.value)
|
||||
return false;
|
||||
break;
|
||||
case FWP_UINT16:
|
||||
if (a.conditionValue.uint16 != e.value)
|
||||
return false;
|
||||
break;
|
||||
case FWP_UINT32:
|
||||
if (a.conditionValue.uint32 != e.value)
|
||||
return false;
|
||||
break;
|
||||
case FWP_BYTE_BLOB_TYPE:
|
||||
if (!blobEqual(a.conditionValue.byteBlob, e.bytes))
|
||||
return false;
|
||||
break;
|
||||
case FWP_SECURITY_DESCRIPTOR_TYPE:
|
||||
if (!blobEqual(a.conditionValue.sd, e.bytes))
|
||||
return false;
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
static std::vector<BYTE> appId(const std::wstring &path) {
|
||||
Memory memory;
|
||||
FWP_BYTE_BLOB *blob = nullptr;
|
||||
if (FwpmGetAppIdFromFileName0(path.c_str(), &blob))
|
||||
throw 1;
|
||||
memory.p = blob;
|
||||
if (!blob || !blob->data || blob->size < 8 || blob->size > 65536)
|
||||
throw 1;
|
||||
return {blob->data, blob->data + blob->size};
|
||||
}
|
||||
static Result inspect(const std::wstring &root,
|
||||
const std::wstring &serviceSid) {
|
||||
Result result;
|
||||
try {
|
||||
SC_HANDLE manager = OpenSCManagerW(nullptr, nullptr, SC_MANAGER_CONNECT);
|
||||
if (!manager) {
|
||||
result.code = GetLastError();
|
||||
return result;
|
||||
}
|
||||
SC_HANDLE bfe = OpenServiceW(manager, L"BFE", SERVICE_QUERY_STATUS);
|
||||
SERVICE_STATUS_PROCESS status{};
|
||||
DWORD needed = 0;
|
||||
result.bfeRunning = bfe &&
|
||||
QueryServiceStatusEx(bfe, SC_STATUS_PROCESS_INFO,
|
||||
reinterpret_cast<BYTE *>(&status),
|
||||
sizeof(status), &needed) &&
|
||||
status.dwCurrentState == SERVICE_RUNNING;
|
||||
if (bfe)
|
||||
CloseServiceHandle(bfe);
|
||||
CloseServiceHandle(manager);
|
||||
if (!result.bfeRunning)
|
||||
return result;
|
||||
const GUID provider = guid(L"{2a7bcbf2-e4f5-4e94-bd33-d5442a3bef50}"),
|
||||
sub = guid(L"{403a69b8-fc7c-460a-a247-72e0d72e6bf8}");
|
||||
const GUID c4 = FWPM_LAYER_ALE_AUTH_CONNECT_V4,
|
||||
c6 = FWPM_LAYER_ALE_AUTH_CONNECT_V6,
|
||||
r4 = FWPM_LAYER_ALE_AUTH_RECV_ACCEPT_V4,
|
||||
r6 = FWPM_LAYER_ALE_AUTH_RECV_ACCEPT_V6;
|
||||
const auto browser = appId(root + L"\\SafetyNet.exe"),
|
||||
broker = appId(root + L"\\resources\\broker\\node.exe");
|
||||
PSECURITY_DESCRIPTOR descriptor = nullptr;
|
||||
ULONG size = 0;
|
||||
std::wstring sddl = L"D:(A;;CC;;;" + serviceSid + L")";
|
||||
if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||
sddl.c_str(), SDDL_REVISION_1, &descriptor, &size))
|
||||
throw 1;
|
||||
std::vector<BYTE> sd(reinterpret_cast<BYTE *>(descriptor),
|
||||
reinterpret_cast<BYTE *>(descriptor) + size);
|
||||
LocalFree(descriptor);
|
||||
auto scalar = [](GUID field, FWP_MATCH_TYPE match, FWP_DATA_TYPE type,
|
||||
UINT32 value) {
|
||||
return ExpectedCondition{field, match, type, value, {}};
|
||||
};
|
||||
auto bytes = [](GUID field, FWP_DATA_TYPE type,
|
||||
const std::vector<BYTE> &value) {
|
||||
return ExpectedCondition{field, FWP_MATCH_EQUAL, type, 0, value};
|
||||
};
|
||||
const auto app = bytes(FWPM_CONDITION_ALE_APP_ID, FWP_BYTE_BLOB_TYPE,
|
||||
browser),
|
||||
tcp = scalar(FWPM_CONDITION_IP_PROTOCOL, FWP_MATCH_EQUAL,
|
||||
FWP_UINT8, 6),
|
||||
local = scalar(FWPM_CONDITION_IP_REMOTE_ADDRESS, FWP_MATCH_EQUAL,
|
||||
FWP_UINT32, 0x7f000001);
|
||||
const std::vector<ExpectedCondition> tuple = {
|
||||
tcp,
|
||||
scalar(FWPM_CONDITION_IP_LOCAL_ADDRESS, FWP_MATCH_EQUAL, FWP_UINT32,
|
||||
0x7f000001),
|
||||
scalar(FWPM_CONDITION_IP_LOCAL_PORT, FWP_MATCH_EQUAL, FWP_UINT16,
|
||||
BROKER_PORT)};
|
||||
auto permit = tuple;
|
||||
permit.push_back(
|
||||
bytes(FWPM_CONDITION_ALE_APP_ID, FWP_BYTE_BLOB_TYPE, broker));
|
||||
permit.push_back(
|
||||
bytes(FWPM_CONDITION_ALE_USER_ID, FWP_SECURITY_DESCRIPTOR_TYPE, sd));
|
||||
const std::vector<Rule> rules = {
|
||||
{guid(L"{4ac4e1d1-f08b-50fe-83fe-1872672c42b9}"),
|
||||
c4,
|
||||
FWP_ACTION_BLOCK,
|
||||
0,
|
||||
{app, scalar(FWPM_CONDITION_IP_PROTOCOL, FWP_MATCH_NOT_EQUAL,
|
||||
FWP_UINT8, 6)}},
|
||||
{guid(L"{07130743-8828-58b5-9476-3a0c2a001796}"),
|
||||
c4,
|
||||
FWP_ACTION_BLOCK,
|
||||
0,
|
||||
{app, tcp,
|
||||
scalar(FWPM_CONDITION_IP_REMOTE_ADDRESS, FWP_MATCH_NOT_EQUAL,
|
||||
FWP_UINT32, 0x7f000001)}},
|
||||
{guid(L"{51401be2-30ce-5808-878f-7e181d0f6fde}"),
|
||||
c4,
|
||||
FWP_ACTION_BLOCK,
|
||||
0,
|
||||
{app, tcp, local,
|
||||
scalar(FWPM_CONDITION_IP_REMOTE_PORT, FWP_MATCH_NOT_EQUAL, FWP_UINT16,
|
||||
BROKER_PORT)}},
|
||||
{guid(L"{1ee3c54d-84f8-5461-bd50-b74f32335ff3}"),
|
||||
c6,
|
||||
FWP_ACTION_BLOCK,
|
||||
0,
|
||||
{app}},
|
||||
{guid(L"{a71f6123-ef64-5836-90ee-f624c6841f18}"),
|
||||
r4,
|
||||
FWP_ACTION_BLOCK,
|
||||
0,
|
||||
{app}},
|
||||
{guid(L"{e537682c-948d-5bbf-a231-5db3ba896eda}"),
|
||||
r6,
|
||||
FWP_ACTION_BLOCK,
|
||||
0,
|
||||
{app}},
|
||||
{guid(L"{dcbbb5f1-5936-5978-8349-21a2e791ffbf}"), r4, FWP_ACTION_PERMIT,
|
||||
2, permit},
|
||||
{guid(L"{7c4dba0f-904a-5685-a3ed-ae774a45136a}"), r4, FWP_ACTION_BLOCK,
|
||||
1, tuple}};
|
||||
Engine engine;
|
||||
result.code = FwpmEngineOpen0(nullptr, RPC_C_AUTHN_WINNT, nullptr, nullptr,
|
||||
&engine.h);
|
||||
if (result.code)
|
||||
return result;
|
||||
Enumeration enumeration(engine.h);
|
||||
Memory providerMemory;
|
||||
FWPM_PROVIDER0 *p = nullptr;
|
||||
result.code = FwpmProviderGetByKey0(engine.h, &provider, &p);
|
||||
providerMemory.p = p;
|
||||
if (result.code)
|
||||
return result;
|
||||
if (p->flags != FWPM_PROVIDER_FLAG_PERSISTENT || !p->serviceName ||
|
||||
wcscmp(p->serviceName, L"SafetyNetNetworkBroker") ||
|
||||
!marker(p->providerData) ||
|
||||
!protectedObject(engine.h, provider,
|
||||
FwpmProviderGetSecurityInfoByKey0)) {
|
||||
result.code = ERROR_INVALID_DATA;
|
||||
return result;
|
||||
}
|
||||
result.code =
|
||||
FwpmFilterCreateEnumHandle0(engine.h, nullptr, &enumeration.h);
|
||||
if (result.code)
|
||||
return result;
|
||||
std::vector<GUID> seen;
|
||||
for (;;) {
|
||||
Memory memory;
|
||||
FWPM_FILTER0 **entries = nullptr;
|
||||
UINT32 count = 0;
|
||||
result.code =
|
||||
FwpmFilterEnum0(engine.h, enumeration.h, 128, &entries, &count);
|
||||
memory.p = entries;
|
||||
if (result.code)
|
||||
return result;
|
||||
if (count > 128 || result.accessibleFilters + count > 65536) {
|
||||
result.code = ERROR_MORE_DATA;
|
||||
return result;
|
||||
}
|
||||
result.accessibleFilters += count;
|
||||
for (UINT32 i = 0; i < count; ++i) {
|
||||
const auto &f = *entries[i];
|
||||
if (IsEqualGUID(f.subLayerKey, sub) ||
|
||||
(f.providerKey && IsEqualGUID(*f.providerKey, provider))) {
|
||||
bool okay = false;
|
||||
for (const auto &rule : rules)
|
||||
if (IsEqualGUID(f.filterKey, rule.key) &&
|
||||
matches(f, rule, provider, sub))
|
||||
okay = true;
|
||||
if (!okay || !protectedObject(engine.h, f.filterKey,
|
||||
FwpmFilterGetSecurityInfoByKey0)) {
|
||||
result.code = ERROR_INVALID_DATA;
|
||||
return result;
|
||||
}
|
||||
seen.push_back(f.filterKey);
|
||||
++result.ownedFilters;
|
||||
} else if (IsEqualGUID(f.layerKey, c4) || IsEqualGUID(f.layerKey, c6) ||
|
||||
IsEqualGUID(f.layerKey, r4) || IsEqualGUID(f.layerKey, r6)) {
|
||||
Memory sm;
|
||||
FWPM_SUBLAYER0 *s = nullptr;
|
||||
result.code = FwpmSubLayerGetByKey0(engine.h, &f.subLayerKey, &s);
|
||||
sm.p = s;
|
||||
if (result.code)
|
||||
return result;
|
||||
if (s->weight == 65535)
|
||||
result.collision = true;
|
||||
}
|
||||
}
|
||||
if (count < 128)
|
||||
break;
|
||||
}
|
||||
Memory sm;
|
||||
FWPM_SUBLAYER0 *s = nullptr;
|
||||
result.code = FwpmSubLayerGetByKey0(engine.h, &sub, &s);
|
||||
sm.p = s;
|
||||
if (result.code)
|
||||
return result;
|
||||
result.ownedVerified =
|
||||
marker(s->providerData) &&
|
||||
protectedObject(engine.h, sub, FwpmSubLayerGetSecurityInfoByKey0) &&
|
||||
result.ownedFilters == 8 && !result.collision && s->weight == 65535 &&
|
||||
s->providerKey && IsEqualGUID(*s->providerKey, provider) &&
|
||||
s->flags == FWPM_SUBLAYER_FLAG_PERSISTENT;
|
||||
result.code = result.ownedVerified ? ERROR_SUCCESS : ERROR_INVALID_DATA;
|
||||
} catch (...) {
|
||||
result.code = ERROR_INVALID_DATA;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
} // namespace topology_contract
|
||||
@@ -0,0 +1,38 @@
|
||||
'use strict';
|
||||
const {createPublicKey,createHash,randomBytes}=require('node:crypto');
|
||||
const {assertPolicyAssignment}=require('../browser/policy-assignment.cjs');
|
||||
class PolicyAuthorityError extends Error {
|
||||
constructor(code){super(code);this.name='PolicyAuthorityError';this.code=code;}
|
||||
}
|
||||
function freeze(value){if(value&&typeof value==='object'){for(const item of Object.values(value))freeze(item);Object.freeze(value);}return value;}
|
||||
|
||||
/** Trusted bootstrap pins enrollment key/device/minimum version. No policy
|
||||
* enabling, network requests or fixture classifiers are performed here.
|
||||
* Persist the accepted minimum version in protected enrollment storage when
|
||||
* wiring production; this instance cannot supply durable anti-rollback alone.
|
||||
*/
|
||||
async function createPolicyAuthority({publicKey,deviceId,minimumVersion=0}) {
|
||||
let pinned;
|
||||
try {
|
||||
if(typeof publicKey!=='string'||publicKey.length>16384||typeof deviceId!=='string'||!/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(deviceId)||!Number.isSafeInteger(minimumVersion)||minimumVersion<0)throw Error('invalid');
|
||||
pinned=createPublicKey(publicKey).export({type:'spki',format:'pem'});
|
||||
} catch {throw new PolicyAuthorityError('policy-bootstrap-invalid');}
|
||||
const {verifyPolicy}=await import('../../packages/core/policy.mjs');
|
||||
const namespace=randomBytes(16).toString('hex');
|
||||
let generation=0,active=null,floor=minimumVersion,lastDigest=null,lastVersion=null;
|
||||
const revoke=()=>{generation++;active=null;};
|
||||
const current=token=>typeof token==='string'&&active!==null&&token===active.token&&Date.parse(active.policy.expiresAt)>Date.now();
|
||||
function accept(envelope) {
|
||||
try {
|
||||
if(!envelope||typeof envelope!=='object'||Array.isArray(envelope)||Object.keys(envelope).sort().join(',')!=='payload,signature'||typeof envelope.payload!=='string'||!envelope.payload||envelope.payload.length>2097152||!/^[A-Za-z0-9_-]+$/.test(envelope.payload)||typeof envelope.signature!=='string'||!envelope.signature||envelope.signature.length>16384||!/^[A-Za-z0-9_-]+$/.test(envelope.signature))throw Error('invalid-envelope');
|
||||
const policy=assertPolicyAssignment(verifyPolicy({payload:envelope.payload,signature:envelope.signature},pinned,floor),deviceId);
|
||||
const digest=createHash('sha256').update(envelope.payload).digest('hex');
|
||||
if(lastVersion===policy.version&&lastDigest!==digest)throw Error('version-equivocation');
|
||||
generation++;floor=policy.version;lastVersion=policy.version;lastDigest=digest;
|
||||
active=Object.freeze({token:`${namespace}:${generation}`,generation,version:policy.version,policy:freeze(policy)});
|
||||
return active;
|
||||
} catch {revoke();throw new PolicyAuthorityError('policy-rejected');}
|
||||
}
|
||||
return Object.freeze({accept,current,revoke,snapshot:()=>active&¤t(active.token)?active:null,status:()=>Object.freeze({generation,minimumVersion:floor,active:active!==null&¤t(active.token)})});
|
||||
}
|
||||
module.exports={createPolicyAuthority,PolicyAuthorityError};
|
||||
@@ -0,0 +1,38 @@
|
||||
'use strict';
|
||||
|
||||
/** Inherited private parent IPC only. The reviewed launcher supplies a fixed
|
||||
* factory in code; IPC cannot select modules, executable paths or commands.
|
||||
* This supplies lifecycle isolation, not a packaged/WFP-constrained broker.
|
||||
*/
|
||||
function runBrokerProcess(factory,{maxMessageBytes=65536}={}) {
|
||||
if(typeof factory!=='function'||typeof process.send!=='function'||!process.connected||!Number.isSafeInteger(maxMessageBytes)||maxMessageBytes<1||maxMessageBytes>65536)throw Error('broker-process-invalid');
|
||||
let state='waiting',broker,closing;
|
||||
const send=value=>{if(process.connected)process.send(value,()=>{});};
|
||||
const close=()=>{
|
||||
if(closing)return closing;
|
||||
state='closing';
|
||||
closing=Promise.resolve().then(()=>broker?.close()).catch(()=>{}).finally(()=>{if(process.connected)process.disconnect();});
|
||||
return closing;
|
||||
};
|
||||
process.once('disconnect',()=>{void close();});
|
||||
process.on('message',message=>{
|
||||
let serialized;
|
||||
try {serialized=JSON.stringify(message);} catch {serialized='';}
|
||||
if(!serialized||Buffer.byteLength(serialized)>maxMessageBytes||!message||typeof message!=='object'||Array.isArray(message)){send({type:'error',code:'invalid-message'});void close();return;}
|
||||
if(message.type==='stop'&&Object.keys(message).length===1){void close();return;}
|
||||
if(state!=='waiting'||message.type!=='start'||Object.keys(message).sort().join(',')!=='config,type'||!message.config||typeof message.config!=='object'||Array.isArray(message.config)){send({type:'error',code:'invalid-message'});void close();return;}
|
||||
state='starting';
|
||||
Promise.resolve().then(()=>factory(message.config)).then(async created=>{
|
||||
if(!created||typeof created.listen!=='function'||typeof created.close!=='function'||typeof created.capability!=='string'||!/^[a-zA-Z0-9_-]{43}$/.test(created.capability)||!['basic','bearer'].includes(created.authMode))throw Error('invalid-factory');
|
||||
if(created.authMode==='basic'&&(!created.credentials||!/^safetynet-[a-f0-9]{32}$/.test(created.credentials.username)||created.credentials.password!==created.capability))throw Error('invalid-factory');
|
||||
broker=created;
|
||||
if(state==='closing'){await broker.close();return;}
|
||||
const port=await broker.listen();
|
||||
if(state==='closing'){await broker.close();return;}
|
||||
if(!Number.isSafeInteger(port)||port<1||port>65535)throw Error('invalid-listener');
|
||||
state='ready';send({type:'ready',port,authMode:broker.authMode,capability:broker.capability,...(broker.authMode==='basic'?{credentials:{username:broker.credentials.username,password:broker.credentials.password}}:{})});
|
||||
}).catch(()=>{send({type:'error',code:'startup-failed'});void close();});
|
||||
});
|
||||
return Object.freeze({close});
|
||||
}
|
||||
module.exports={runBrokerProcess};
|
||||
@@ -0,0 +1,63 @@
|
||||
'use strict';
|
||||
const {randomUUID}=require('node:crypto');
|
||||
const {BrokerAuditEventSchema}=require('../../packages/core/broker-audit.cjs');
|
||||
const {createProductionBroker}=require('./production-factory.cjs');
|
||||
const UUID=/^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i;
|
||||
const exact=(value,keys)=>value&&typeof value==='object'&&!Array.isArray(value)&&Object.keys(value).sort().join(',')===keys.sort().join(',');
|
||||
/** Inherited IPC is the only bootstrap/control surface. No CLI, environment
|
||||
* credential, public management listener, dynamic module or private key IPC.
|
||||
*/
|
||||
function runProductionProcess(factory=createProductionBroker,channel=process){
|
||||
if(typeof factory!=='function'||typeof channel.send!=='function'||!channel.connected)throw Error('broker-process-invalid');
|
||||
let state='waiting',context,broker,construction,initialization,closing,cleanup,sequence=0;const pending=new Map();
|
||||
const closeBroker=created=>{if(!cleanup){cleanup=Promise.resolve().then(()=>created.close());cleanup.catch(()=>{});}return cleanup;};
|
||||
const close=()=>{
|
||||
if(closing)return closing;state='closing';for(const value of pending.values()){clearTimeout(value.timer);value.reject(Error('broker-parent-unavailable'));}pending.clear();
|
||||
closing=Promise.resolve().then(async()=>{
|
||||
// Construction and listen retain ownership until their ORIGINAL promises
|
||||
// settle. A late factory result cannot become ready or escape cleanup.
|
||||
let created=broker;try{created=created||await construction;}catch{}
|
||||
try{if(created)await closeBroker(created);}finally{await initialization?.catch(()=>{});}
|
||||
}).finally(()=>{if(channel.connected)channel.disconnect();});
|
||||
// Event-driven callers may discard the promise; explicit close callers still
|
||||
// receive the real sticky cleanup failure, never a fabricated confirmation.
|
||||
closing.catch(()=>{});return closing;
|
||||
};
|
||||
const send=message=>{if(!channel.connected||state==='closing')throw Error('broker-parent-unavailable');channel.send(message,error=>{if(error)void close();});};
|
||||
const rpc=(type,payload)=>new Promise((resolve,reject)=>{
|
||||
if(pending.size>=8||state==='closing'||!context){reject(Error('broker-parent-unavailable'));return;}
|
||||
if(sequence>=Number.MAX_SAFE_INTEGER){reject(Error('broker-parent-unavailable'));void close();return;}
|
||||
const nextSequence=++sequence,requestId=randomUUID(),timer=setTimeout(()=>{void close();},30000);
|
||||
pending.set(requestId,{type,sequence:nextSequence,eventId:payload.event?.eventId,resolve,reject,timer});
|
||||
try{send({type,...context,requestId,sequence:nextSequence,...payload});}catch{void close();}
|
||||
});
|
||||
channel.once('disconnect',()=>{void close();});
|
||||
channel.on('message',message=>{
|
||||
try{
|
||||
const bytes=Buffer.byteLength(JSON.stringify(message));if(!message||bytes>(state==='waiting'?2097152:65536))throw Error();
|
||||
if(state==='waiting'){
|
||||
if(!exact(message,['type','version','generation','policyVersion','config'])||message.type!=='start'||message.version!==1||!UUID.test(message.generation)||!Number.isSafeInteger(message.policyVersion)||message.policyVersion<1)throw Error();
|
||||
context={version:1,generation:message.generation,policyVersion:message.policyVersion};state='starting';
|
||||
construction=Promise.resolve().then(()=>factory(message.config,{expectedPolicyVersion:context.policyVersion,persistAudit:async event=>{
|
||||
BrokerAuditEventSchema.parse(event);if(event.policyVersion!==context.policyVersion)throw Error('broker-parent-unavailable');
|
||||
return rpc('audit',{event});
|
||||
},acknowledgeCertificate:async({authority,cert})=>rpc('certificate',{authority,cert})}));
|
||||
initialization=construction.then(async created=>{
|
||||
broker=created;if(state==='closing')return;
|
||||
const port=await broker.listen();if(state==='closing')return;
|
||||
if(created.authMode!=='basic'||port!==message.config.brokerPort||!/^safetynet-[a-f0-9]{32}$/.test(created.credentials?.username)||!/^[A-Za-z0-9_-]{43}$/.test(created.credentials?.password))throw Error();
|
||||
state='ready';send({type:'ready',...context,sequence:0,authMode:'basic',port,credentials:{...created.credentials}});
|
||||
}).catch(error=>{const codes=new Set(['broker-bootstrap-invalid','broker-route-unsupported','broker-startup-inspector','broker-startup-certificate','broker-startup-transport','broker-startup-managed']);const code=codes.has(error?.message)?error.message:'broker-startup-failed';try{send({type:'startup-fault',...context,code});}catch{}void close();});return;
|
||||
}
|
||||
if(message.version!==1||message.generation!==context.generation||message.policyVersion!==context.policyVersion)throw Error();
|
||||
if(message.type==='stop'&&exact(message,['type','version','generation','policyVersion'])){void close();return;}
|
||||
const value=pending.get(message.requestId);if(!value||message.sequence!==value.sequence)throw Error();
|
||||
if(value.type==='audit'&&exact(message,['type','version','generation','policyVersion','requestId','sequence','receipt'])&&message.type==='audit-receipt'&&exact(message.receipt,['persisted','eventId'])&&message.receipt.persisted===true&&message.receipt.eventId===value.eventId){clearTimeout(value.timer);pending.delete(message.requestId);value.resolve(Object.freeze({...message.receipt}));return;}
|
||||
if(value.type==='certificate'&&exact(message,['type','version','generation','policyVersion','requestId','sequence','accepted'])&&message.type==='certificate-receipt'&&message.accepted===true){clearTimeout(value.timer);pending.delete(message.requestId);value.resolve(true);return;}
|
||||
throw Error();
|
||||
}catch{void close();}
|
||||
});
|
||||
return Object.freeze({close});
|
||||
}
|
||||
if(require.main===module)runProductionProcess();
|
||||
module.exports={runProductionProcess};
|
||||
@@ -0,0 +1,62 @@
|
||||
'use strict';
|
||||
const path=require('node:path');
|
||||
const {createDnsPolicyRoute,assertSupportedDns}=require('./dns-policy-route.cjs');
|
||||
const tls=require('node:tls');
|
||||
const {X509Certificate}=require('node:crypto');
|
||||
const {z}=require('zod');
|
||||
const {createPolicyAuthority}=require('./policy-authority.cjs');
|
||||
const {createManagedProxy}=require('./managed-proxy.cjs');
|
||||
const {createSessionCertificates}=require('./tls-certificates.cjs');
|
||||
const {createProxyForwarder,parseFixedProxy}=require('./proxy-forwarder.cjs');
|
||||
const {createHttpsForwarder}=require('./https-forwarder.cjs');
|
||||
const {createDownloadResponseGate}=require('./download-response-gate.cjs');
|
||||
const {startInspector}=require('../browser/inspection.cjs');
|
||||
const {scanEnterprise}=require('../browser/enterprise-inspection.cjs');
|
||||
const networkBase={dns:z.literal('system'),ca:z.string().min(1).max(131072).optional()};
|
||||
const NetworkSchema=z.discriminatedUnion('mode',[z.object({mode:z.literal('direct'),...networkBase}).strict(),z.object({mode:z.literal('fixed_servers'),...networkBase,proxyCa:z.string().min(1).max(131072).optional(),credentials:z.object({username:z.string().min(1).max(1024).refine(value=>!value.includes(':')&&![...value].some(character=>character.charCodeAt(0)<32||character.charCodeAt(0)===127)),password:z.string().min(1).max(3072).refine(value=>![...value].some(character=>character.charCodeAt(0)<32||character.charCodeAt(0)===127))}).strict().optional()}).strict()]);
|
||||
const BootstrapSchema=z.object({publicKey:z.string().min(1).max(16384),deviceId:z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/),minimumVersion:z.number().int().min(1).max(Number.MAX_SAFE_INTEGER),envelope:z.object({payload:z.string().min(1).max(2097152),signature:z.string().min(1).max(16384)}).strict(),brokerPort:z.number().int().min(1).max(65535),network:NetworkSchema}).strict();
|
||||
function deploymentRoots(pem){
|
||||
if(pem===undefined)return undefined;
|
||||
const matches=pem.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g);
|
||||
if(!matches||matches.length>32||pem.replace(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g,'').trim())throw Error('broker-bootstrap-invalid');
|
||||
for(const item of matches){let certificate;try{certificate=new X509Certificate(item);}catch{throw Error('broker-bootstrap-invalid');}if(!certificate.ca||Date.parse(certificate.validFrom)>Date.now()||Date.parse(certificate.validTo)<=Date.now())throw Error('broker-bootstrap-invalid');}
|
||||
return Object.freeze([...tls.rootCertificates,...matches]);
|
||||
}
|
||||
/** Real packaged-resource factory. Parent verifies its signed package before
|
||||
* spawn; IPC cannot choose modules, resource paths, keys or scanner callbacks.
|
||||
* Direct or one signed fixed HTTP(S) proxy only; PAC/system/bypass fail closed.
|
||||
*/
|
||||
async function createProductionBroker(config,{persistAudit,acknowledgeCertificate,expectedPolicyVersion}={}){
|
||||
let c;try{c=BootstrapSchema.parse(config);}catch{throw Error('broker-bootstrap-invalid');}
|
||||
if(typeof persistAudit!=='function'||typeof acknowledgeCertificate!=='function')throw Error('broker-bootstrap-invalid');
|
||||
const ca=deploymentRoots(c.network.ca),proxyCa=deploymentRoots(c.network.proxyCa);
|
||||
const brokerRoot=path.resolve(__dirname,'../..'),scannerResourcesRoot=path.dirname(brokerRoot);
|
||||
const authority=await createPolicyAuthority({publicKey:c.publicKey,deviceId:c.deviceId,minimumVersion:c.minimumVersion});
|
||||
const snapshot=authority.accept(c.envelope),policy=snapshot.policy;
|
||||
if(expectedPolicyVersion!==policy.version)throw Error('broker-bootstrap-invalid');
|
||||
if(policy.proxy.mode!==c.network.mode)throw Error('broker-route-unsupported');
|
||||
assertSupportedDns(policy,c.network,{numericOriginSupported:true});
|
||||
if(c.network.mode==='fixed_servers'){try{parseFixedProxy(policy.proxy);}catch{throw Error('broker-route-unsupported');}}
|
||||
const core=await import('../../packages/core/policy.mjs'),securityCore=await import('../../packages/core/security.mjs');
|
||||
const current=token=>authority.current(token),active=()=>current(snapshot.token);
|
||||
const approved=binding=>active()&&core.destinationAllowed(`https://${binding.authority}/`,policy);
|
||||
let inspector,issuer,proxy,closed=false,stage='inspector';
|
||||
const close=async()=>{if(closed)return;closed=true;authority.revoke();try{await proxy?.close();}finally{issuer?.close();inspector?.stop();}};
|
||||
try{
|
||||
inspector=startInspector(scannerResourcesRoot,true);if(!inspector)throw Error('broker-inspector-unavailable');await inspector.waitReady();
|
||||
stage='certificate';issuer=await createSessionCertificates({current:active,authorized:approved});
|
||||
const maxResponseBytes=Math.min(policy.fileTransfers.download.maxBytes,16777216);
|
||||
stage='transport';
|
||||
const resolve=createDnsPolicyRoute({current:active,blocked:address=>securityCore.dnsAddressBlocked(address,policy)});
|
||||
const transport={authority:snapshot.token,current,ca:ca?.join('\n'),maxRequestBytes:policy.dlp.maxBytes,maxResponseBytes,deadlineMs:30000};
|
||||
const resolveOriginRoute=async route=>{if(!core.destinationAllowed(`https://${route.hostname.includes(':')?'['+route.hostname+']':route.hostname}:${route.port}/`,policy))throw Error('broker-route-denied');return resolve(route);};
|
||||
const rawForward=c.network.mode==='fixed_servers'?createProxyForwarder({...transport,proxyPolicy:policy.proxy,proxyCa:proxyCa?.join('\n'),credentials:c.network.credentials,resolveProxyRoute:resolve,resolveOriginRoute:policy.security?.dns?.enabled!==false&&policy.security?.dns?.blockedIPs?.length?resolveOriginRoute:undefined}):createHttpsForwarder({...transport,resolveRoute:resolveOriginRoute});
|
||||
const forward=createDownloadResponseGate({forward:rawForward,policy,current:active,inspector,maxBytes:maxResponseBytes});
|
||||
stage='managed';proxy=await createManagedProxy({deadlineMs:30000,policyAuthority:authority,snapshot,persistAudit,localInspector:(bytes,p)=>inspector.inspect(bytes,p),enterpriseInspector:(bytes,p)=>scanEnterprise(bytes,p,'upload'),forward,resolveCertificate:async binding=>{
|
||||
if(!approved(binding))throw Error('broker-certificate-denied');
|
||||
const leaf=issuer.resolve(binding);if(await acknowledgeCertificate({authority:leaf.authority,cert:leaf.cert})!==true||!active())throw Error('broker-certificate-denied');return leaf;
|
||||
}});
|
||||
return Object.freeze({authMode:proxy.authMode,credentials:proxy.credentials,capability:proxy.capability,listen:()=>proxy.listen(c.brokerPort),close});
|
||||
}catch{await close();throw Error('broker-startup-'+stage); }
|
||||
}
|
||||
module.exports={createProductionBroker,BootstrapSchema,deploymentRoots};
|
||||
@@ -0,0 +1,78 @@
|
||||
'use strict';
|
||||
const net=require('node:net'),tls=require('node:tls'),http=require('node:http');
|
||||
const {parseAuthority}=require('./tls-authority.cjs');
|
||||
const {validateResponse}=require('./http-response-gate.cjs');
|
||||
const {GateError}=require('./http-request-gate.cjs');
|
||||
const fail=code=>{throw new GateError(code);};
|
||||
// Deliberately narrow signed subset: one HTTP(S) proxy for all HTTPS origins.
|
||||
// PAC, system, SOCKS, fallback lists and bypass syntax are not interpreted.
|
||||
function parseFixedProxy(policy){
|
||||
if(!policy||policy.mode!=='fixed_servers'||typeof policy.rules!=='string'||policy.bypass!=='')fail('proxy-policy-unsupported');
|
||||
const match=/^(https?):\/\/([^/?#]+)$/.exec(policy.rules);if(!match)fail('proxy-policy-unsupported');
|
||||
let binding;try{binding=parseAuthority(match[2]);if(!/:[0-9]+$/.test(match[2])&&match[1]==='http')binding=parseAuthority(match[2]+':80');}catch{fail('proxy-policy-unsupported');}
|
||||
return Object.freeze({protocol:match[1],...binding});
|
||||
}
|
||||
function createProxyForwarder({authority,current,proxyPolicy,resolveProxyRoute,resolveOriginRoute,credentials,ca,proxyCa,deadlineMs=5000,maxRequestBytes=16777216,maxResponseBytes=1048576}={}){
|
||||
const proxy=parseFixedProxy(proxyPolicy);
|
||||
if(typeof authority!=='string'||typeof current!=='function'||typeof resolveProxyRoute!=='function'||!Number.isInteger(deadlineMs)||deadlineMs<1||deadlineMs>60000)fail('invalid-forward-configuration');
|
||||
if(resolveOriginRoute!==undefined&&typeof resolveOriginRoute!=='function')fail('invalid-forward-configuration');
|
||||
if(![maxRequestBytes,maxResponseBytes].every(n=>Number.isSafeInteger(n)&&n>=0&&n<=16777216))fail('invalid-forward-configuration');
|
||||
if([ca,proxyCa].some(value=>value!==undefined&&!Buffer.isBuffer(value)&&typeof value!=='string'))fail('invalid-forward-configuration');
|
||||
ca=Buffer.isBuffer(ca)?Buffer.from(ca):ca;proxyCa=Buffer.isBuffer(proxyCa)?Buffer.from(proxyCa):proxyCa;
|
||||
let authorization;
|
||||
if(credentials){if(Object.keys(credentials).sort().join(',')!=='password,username'||typeof credentials.username!=='string'||typeof credentials.password!=='string'||credentials.username.includes(':')||/[\r\n\0]/.test(credentials.username+credentials.password)||credentials.username.length+credentials.password.length>4096)fail('invalid-forward-configuration');authorization='Basic '+Buffer.from(credentials.username+':'+credentials.password).toString('base64');}
|
||||
const active=()=>{try{return current(authority)===true;}catch{return false;}};
|
||||
let pending=0;
|
||||
return async function forward({request,body,signal,maxResponseBytes:limit=maxResponseBytes}){
|
||||
if(pending>=8)fail('proxy-capacity-exhausted');pending++;
|
||||
let raw,secure,outgoing,agent,timer,abort,heldResolution=false;const controller=new AbortController();
|
||||
const privateBody=Buffer.isBuffer(body)&&body.length<=maxRequestBytes?Buffer.from(body):undefined;
|
||||
try{
|
||||
if(!privateBody||privateBody.length>maxRequestBytes||!Number.isSafeInteger(limit)||limit<0||limit>maxResponseBytes||!request||!['GET','HEAD','POST','PUT','PATCH','DELETE','OPTIONS'].includes(request.method))fail('invalid-forward-request');
|
||||
const url=new URL(request.url);if(url.protocol!=='https:'||url.username||url.password||url.hash||[...request.url].some(c=>c.charCodeAt(0)<=32||c.charCodeAt(0)===127||c==='\\'))fail('invalid-forward-request');
|
||||
const destination=parseAuthority(url.host),headers=Object.create(null);let headerBytes=0;
|
||||
for(const [name,value] of Object.entries(request.headers||{})){
|
||||
if(!/^[a-z0-9-]+$/i.test(name)||typeof value!=='string'||[...value].some(c=>{const code=c.charCodeAt(0);return code!==9&&(code<32||code===127||code>255);})||headers[name.toLowerCase()]!==undefined)fail('invalid-forward-request');
|
||||
if(name.toLowerCase()==='connection'&&!['close','keep-alive'].includes(value.toLowerCase()))fail('invalid-forward-request');
|
||||
headerBytes+=name.length+value.length;if(headerBytes>32768)fail('invalid-forward-request');
|
||||
if(!['host','content-length','connection','proxy-connection','proxy-authorization','proxy-authenticate','keep-alive','te','trailer','transfer-encoding','upgrade'].includes(name.toLowerCase()))headers[name.toLowerCase()]=value;
|
||||
}
|
||||
headers.host=url.host;headers.connection='close';headers['content-length']=String(privateBody.length);headers['accept-encoding']='identity';
|
||||
let rejectAbort;const cancelled=new Promise((_,reject)=>{rejectAbort=reject;});cancelled.catch(()=>{});
|
||||
abort=()=>{controller.abort();raw?.destroy();secure?.destroy();outgoing?.destroy();rejectAbort(new GateError('upstream-cancelled'));};
|
||||
signal?.addEventListener('abort',abort,{once:true});timer=setTimeout(abort,deadlineMs);
|
||||
const guarded=promise=>Promise.race([promise,cancelled]);
|
||||
if(signal?.aborted)fail('upstream-cancelled');if(!active())fail('authority-expired');
|
||||
// Resolve the origin first when IP policy is active. Numeric CONNECT binds
|
||||
// this exact approval to the proxy tunnel; original hostname remains TLS/SNI.
|
||||
// Each outstanding original resolver retains admission after outer timeout.
|
||||
const resolveBound=async(resolver,binding)=>{const work=Promise.resolve().then(()=>resolver(Object.freeze({hostname:binding.hostname,port:binding.port,signal:controller.signal})));let value;try{value=await guarded(work);}catch(error){heldResolution=true;work.finally(()=>{pending--;}).catch(()=>{});throw error;}if(controller.signal.aborted)fail('upstream-cancelled');if(!active())fail('authority-expired');return Object.freeze({approved:value?.approved,address:value?.address,port:value?.port});};
|
||||
let connectAuthority=destination.authority;
|
||||
if(resolveOriginRoute){const origin=await resolveBound(resolveOriginRoute,destination);if(origin.approved!==true||!net.isIP(origin.address)||origin.port!==destination.port)fail('proxy-route-unapproved');connectAuthority=parseAuthority(`${net.isIP(origin.address)===6?'['+origin.address+']':origin.address}:${origin.port}`).authority;}
|
||||
const route=await resolveBound(resolveProxyRoute,proxy);
|
||||
if(route.approved!==true||!net.isIP(route.address)||route.port!==proxy.port)fail('proxy-route-unapproved');if(!active())fail('authority-expired');
|
||||
raw=proxy.protocol==='https'?tls.connect({host:route.address,port:route.port,servername:net.isIP(proxy.hostname)?undefined:proxy.hostname,ca:proxyCa,rejectUnauthorized:true,checkServerIdentity:(_,cert)=>tls.checkServerIdentity(proxy.hostname,cert)}):net.connect({host:route.address,port:route.port});
|
||||
await guarded(new Promise((resolve,reject)=>{raw.once(proxy.protocol==='https'?'secureConnect':'connect',resolve);raw.once('error',()=>reject(new GateError('proxy-connect-failed')));}));
|
||||
if(!active())fail('authority-expired');
|
||||
await guarded(new Promise((resolve,reject)=>{
|
||||
let bytes=Buffer.alloc(0);const data=chunk=>{if(chunk.length>32768-bytes.length){cleanup();reject(new GateError('proxy-response-invalid'));return;}bytes=Buffer.concat([bytes,chunk]);const end=bytes.indexOf('\r\n\r\n');if(end<0)return;cleanup();if(end+4!==bytes.length||!/^HTTP\/1\.[01] 200(?: [^\r\n]*)?\r\n/.test(bytes.toString('latin1'))){reject(new GateError('proxy-response-invalid'));return;}resolve();};
|
||||
const error=()=>{cleanup();reject(new GateError('proxy-connect-failed'));};const cleanup=()=>{raw.off('data',data);raw.off('error',error);raw.off('end',error);};raw.on('data',data);raw.once('error',error);raw.once('end',error);
|
||||
raw.write(`CONNECT ${connectAuthority} HTTP/1.1\r\nHost: ${connectAuthority}\r\n${authorization?'Proxy-Authorization: '+authorization+'\r\n':''}Connection: keep-alive\r\n\r\n`);
|
||||
}));
|
||||
if(!active())fail('authority-expired');
|
||||
secure=tls.connect({socket:raw,servername:net.isIP(destination.hostname)?undefined:destination.hostname,ca,rejectUnauthorized:true,checkServerIdentity:(_,cert)=>tls.checkServerIdentity(destination.hostname,cert)});
|
||||
await guarded(new Promise((resolve,reject)=>{secure.once('secureConnect',resolve);secure.once('error',()=>reject(new GateError('upstream-failed')));}));
|
||||
if(!active())fail('authority-expired');
|
||||
agent=new http.Agent({keepAlive:false});agent.createConnection=()=>secure;
|
||||
const result=await guarded(new Promise((resolve,reject)=>{
|
||||
outgoing=http.request({host:destination.hostname,port:destination.port,path:url.pathname+url.search,method:request.method,headers,agent,maxHeaderSize:32768},incoming=>{
|
||||
const chunks=[];let bytes=0;const wipe=()=>{chunks.forEach(chunk=>chunk.fill(0));chunks.length=0;};incoming.once('close',wipe);incoming.on('data',chunk=>{bytes+=chunk.length;if(bytes>limit){incoming.destroy();reject(new GateError('upstream-response-too-large'));return;}chunks.push(Buffer.from(chunk));});
|
||||
incoming.once('error',()=>reject(new GateError('upstream-failed')));incoming.once('aborted',()=>reject(new GateError('upstream-failed')));
|
||||
incoming.once('end',()=>{try{if(!incoming.complete)fail('upstream-failed');resolve(validateResponse({statusCode:incoming.statusCode,headers:incoming.headers,body:Buffer.concat(chunks)},limit));}catch(error){reject(error);}finally{wipe();}});
|
||||
});outgoing.once('error',()=>reject(new GateError('upstream-failed')));if(!active()){reject(new GateError('authority-expired'));return;}outgoing.end(privateBody);
|
||||
}));if(!active())fail('authority-expired');return result;
|
||||
}catch(error){if(error instanceof GateError)throw error;throw new GateError('upstream-failed');}
|
||||
finally{clearTimeout(timer);signal?.removeEventListener('abort',abort);outgoing?.destroy();secure?.destroy();raw?.destroy();agent?.destroy();privateBody?.fill(0);if(!heldResolution)pending--;}
|
||||
};
|
||||
}
|
||||
module.exports={parseFixedProxy,createProxyForwarder};
|
||||
@@ -0,0 +1,48 @@
|
||||
'use strict';
|
||||
const {z}=require('zod');
|
||||
const {createHash,createPublicKey}=require('node:crypto');
|
||||
const {verifyReleaseEnvelope,verifyDeploymentEnvelope}=require('../../packages/core/windows-trust.cjs');
|
||||
const {createPolicyAuthority}=require('./policy-authority.cjs');
|
||||
const digest=z.string().regex(/^[a-f0-9]{64}$/),positive=z.number().int().min(1).max(Number.MAX_SAFE_INTEGER);
|
||||
const sid=z.string().regex(/^(?:S-1-5-21|S-1-12-1)-(?:(?:0|[1-9][0-9]{0,9})-){3}(?:0|[1-9][0-9]{0,9})$/).refine(value=>value.split('-').slice(4).every(n=>Number(n)<=4294967295));
|
||||
const device=z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/);
|
||||
const floor=z.object({minimumSequence:positive,acceptedDigest:digest}).strict();
|
||||
const policyFloor=z.object({sid,deviceId:device,policyKeySha256:digest,minimumVersion:positive,acceptedDigest:digest}).strict();
|
||||
const StateSchema=z.object({format:z.literal('safetynet-service-floors-v1'),release:floor,deployment:floor.extend({tenant:z.string().regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)}),policies:z.array(policyFloor).max(8192)}).strict();
|
||||
const identity=value=>`${value.sid}|${value.deviceId}|${value.policyKeySha256}`;
|
||||
// Reserve room for the fixed snapshot/CAS envelope inside its 2 MiB frame.
|
||||
const MAX_STATE_BYTES=2097152-1024;
|
||||
function freeze(value){if(value&&typeof value==='object'){for(const child of Object.values(value))freeze(child);Object.freeze(value);}return value;}
|
||||
function canonical(value){if(Array.isArray(value))return '['+value.map(canonical).join(',')+']';if(value&&typeof value==='object')return '{'+Object.keys(value).sort().map(key=>JSON.stringify(key)+':'+canonical(value[key])).join(',')+'}';return JSON.stringify(value);}
|
||||
function encodeAcceptanceState(value){
|
||||
const state=StateSchema.parse(value),ids=state.policies.map(identity);
|
||||
if(ids.some((id,index)=>index>0&&id<=ids[index-1]))throw Error('broker-bootstrap-invalid');
|
||||
const bytes=Buffer.from(canonical(state));if(bytes.length>MAX_STATE_BYTES)throw Error('broker-bootstrap-invalid');return bytes;
|
||||
}
|
||||
function acceptanceStateDigest(value){return createHash('sha256').update(encodeAcceptanceState(value)).digest('hex');}
|
||||
// Pure verification/transition. The fixed native store must supply this state
|
||||
// and commit nextState durably BEFORE listen. Returning a plan is not persistence.
|
||||
async function verifyServiceAcceptance(input){
|
||||
let authority,stage='state';
|
||||
try{
|
||||
const source=structuredClone(input),state=StateSchema.parse(source.state);
|
||||
const previousStateDigest=acceptanceStateDigest(state);
|
||||
stage='release';const release=verifyReleaseEnvelope(source.releaseEnvelope,{publicKey:source.releaseAnchor,floor:state.release});
|
||||
if(release.manifest.deploymentTenant!==state.deployment.tenant)throw Error();
|
||||
stage='deployment';const deployment=verifyDeploymentEnvelope(source.deploymentEnvelope,{release,floor:state.deployment,authenticatedSid:source.authenticatedSid});
|
||||
const binding=deployment.bootstrap,keySha=createHash('sha256').update(createPublicKey(binding.publicKey).export({type:'spki',format:'der'})).digest('hex');
|
||||
const recordId={sid:source.authenticatedSid,deviceId:binding.deviceId,policyKeySha256:keySha};
|
||||
const previous=state.policies.find(value=>identity(value)===identity(recordId));
|
||||
const minimumVersion=Math.max(binding.minimumVersion,previous?.minimumVersion||0);
|
||||
stage='policy';authority=await createPolicyAuthority({...binding,minimumVersion});const snapshot=authority.accept(source.policyEnvelope);
|
||||
if(snapshot.policy.assignment?.deviceId!==binding.deviceId)throw Error();
|
||||
const acceptedDigest=createHash('sha256').update(source.policyEnvelope.payload).digest('hex');
|
||||
if(previous?.minimumVersion===snapshot.version&&previous.acceptedDigest!==acceptedDigest)throw Error();
|
||||
stage='transition';const policies=state.policies.filter(value=>identity(value)!==identity(recordId));
|
||||
policies.push({...recordId,minimumVersion:snapshot.version,acceptedDigest});policies.sort((a,b)=>identity(a)<identity(b)?-1:identity(a)>identity(b)?1:0);
|
||||
const nextState=StateSchema.parse({format:state.format,release:{minimumSequence:release.manifest.releaseSequence,acceptedDigest:release.digest},deployment:{tenant:state.deployment.tenant,minimumSequence:deployment.manifest.deploymentSequence,acceptedDigest:deployment.digest},policies});
|
||||
const nextStateDigest=acceptanceStateDigest(nextState);
|
||||
return freeze({deployment,bootstrap:{...binding,minimumVersion:snapshot.version},policyVersion:snapshot.version,policyDigest:acceptedDigest,previousStateDigest,nextStateDigest,nextState});
|
||||
}catch{throw Object.assign(Error('broker-bootstrap-invalid'),{failureStage:stage});}finally{authority?.revoke();}
|
||||
}
|
||||
module.exports={verifyServiceAcceptance,StateSchema,encodeAcceptanceState,acceptanceStateDigest,MAX_STATE_BYTES};
|
||||
@@ -0,0 +1,37 @@
|
||||
'use strict';
|
||||
const {BootstrapSchema}=require('./production-factory.cjs');
|
||||
const {createHash}=require('node:crypto');
|
||||
// Matches the installed native service's fixed receiver/WFP contract. Caller
|
||||
// bootstrap JSON cannot choose a port outside that protected boundary.
|
||||
const SERVICE_BROKER_PORT=43120;
|
||||
function freeze(value){if(value&&typeof value==='object'){for(const child of Object.values(value))freeze(child);Object.freeze(value);}return value;}
|
||||
// loadTrust is fixed privileged code: verify protected release/deployment files,
|
||||
// rollback state and native-authenticated SID before returning its bootstrap.
|
||||
// No IPC field may select this callback, trust paths, keys or SID.
|
||||
function createServiceFactory({loadTrust,factory}={}){
|
||||
if(typeof loadTrust!=='function'||typeof factory!=='function')throw Error('broker-bootstrap-invalid');
|
||||
return async(config,callbacks)=>{
|
||||
let parsed,binding,expiresAt;
|
||||
try{
|
||||
parsed=freeze(BootstrapSchema.parse(config));
|
||||
if(parsed.brokerPort!==SERVICE_BROKER_PORT)throw Error();
|
||||
const verified=await loadTrust(parsed);binding=verified?.bootstrap;expiresAt=verified?.deployment?.manifest?.expiresAt;
|
||||
if(!Number.isSafeInteger(expiresAt)||expiresAt<=Date.now())throw Error();
|
||||
if(!Number.isSafeInteger(verified?.policyVersion)||verified.policyVersion<1||verified.policyVersion!==callbacks?.expectedPolicyVersion||typeof verified.policyDigest!=='string'||!/^[a-f0-9]{64}$/.test(verified.policyDigest)||verified.policyDigest!==createHash('sha256').update(parsed.envelope.payload).digest('hex'))throw Error();
|
||||
if(!binding||typeof binding.publicKey!=='string'||typeof binding.deviceId!=='string'||!Number.isSafeInteger(binding.minimumVersion)||binding.minimumVersion<1||
|
||||
parsed.publicKey!==binding.publicKey||parsed.deviceId!==binding.deviceId||parsed.minimumVersion<binding.minimumVersion)throw Error();
|
||||
// Reparse the trusted projection to prevent a malformed privileged loader
|
||||
// from weakening the production bootstrap contract.
|
||||
parsed=BootstrapSchema.parse({...parsed,publicKey:binding.publicKey,deviceId:binding.deviceId,minimumVersion:Math.max(parsed.minimumVersion,binding.minimumVersion)});
|
||||
}catch{throw Error('broker-bootstrap-invalid');}
|
||||
const created=await factory(parsed,callbacks);
|
||||
if(!created||typeof created.listen!=='function'||typeof created.close!=='function')throw Error('broker-bootstrap-invalid');
|
||||
let closed=false,timer,closeWork;
|
||||
const close=()=>{if(closeWork)return closeWork;closed=true;clearTimeout(timer);closeWork=Promise.resolve().then(()=>created.close());return closeWork;};
|
||||
const live=()=>!closed&&Date.now()<expiresAt;
|
||||
const expire=()=>{if(!live()){void close().catch(()=>{});return;}timer=setTimeout(expire,Math.min(expiresAt-Date.now(),2147483647));timer.unref?.();};
|
||||
expire();
|
||||
return Object.freeze({...created,async listen(){if(!live()){await close();throw Error('broker-bootstrap-invalid');}const result=await created.listen();if(!live()){await close();throw Error('broker-bootstrap-invalid');}return result;},close});
|
||||
};
|
||||
}
|
||||
module.exports={createServiceFactory,SERVICE_BROKER_PORT};
|
||||
@@ -0,0 +1,22 @@
|
||||
'use strict';
|
||||
const {verifyServiceAcceptance}=require('./service-acceptance.cjs');
|
||||
// Privileged, one-bootstrap gate. commitState must invoke the fixed protected
|
||||
// native compare-and-swap store and acknowledge only after RegFlushKey succeeds.
|
||||
// An in-memory callback in a test is not proof of Windows persistence.
|
||||
function createServiceCommitGate({current,commitState}={}){
|
||||
if(typeof current!=='function'||typeof commitState!=='function')throw Error('broker-bootstrap-invalid');
|
||||
let used=false,failed=false;
|
||||
const active=()=>{try{return !failed&¤t()===true;}catch{return false;}};
|
||||
return Object.freeze({async prepare(input){
|
||||
if(used||!active())throw Error('broker-bootstrap-invalid');used=true;
|
||||
try{
|
||||
const plan=await verifyServiceAcceptance(input);if(!active())throw Error();
|
||||
const request=Object.freeze({expectedPreviousSHA:plan.previousStateDigest,candidateState:plan.nextState});
|
||||
if(Buffer.byteLength(JSON.stringify(request))>2097152)throw Error();
|
||||
const receipt=await commitState(request);
|
||||
if(!receipt||typeof receipt!=='object'||Array.isArray(receipt)||Object.keys(receipt).sort().join(',')!=='persisted,stateSha256'||receipt.persisted!==true||receipt.stateSha256!==plan.nextStateDigest||!active()||Date.now()>=plan.deployment.manifest.expiresAt)throw Error();
|
||||
return plan;
|
||||
}catch{failed=true;throw Error('broker-bootstrap-invalid');}
|
||||
}});
|
||||
}
|
||||
module.exports={createServiceCommitGate};
|
||||
@@ -0,0 +1,33 @@
|
||||
'use strict';
|
||||
const {createFramedChannel}=require('./framed-channel.cjs');
|
||||
const {runProductionProcess}=require('./production-entry.cjs');
|
||||
const {createProductionBroker}=require('./production-factory.cjs');
|
||||
const {createServiceFactory}=require('./service-bootstrap.cjs');
|
||||
const {createServiceCommitGate}=require('./service-commit-gate.cjs');
|
||||
const {createServiceStoreClient}=require('./service-store-client.cjs');
|
||||
const {createWfpRevalidation}=require('./wfp-revalidation.cjs');
|
||||
// Fixed service entry only. Native pipe peer authentication and protected store
|
||||
// authorization precede policy verification; IPC cannot nominate files or keys.
|
||||
function runServiceProcess({channel,store=createServiceStoreClient(),factory=createProductionBroker,readTopology=async()=>{throw Error('native-topology-unavailable');}}={}){
|
||||
if(!channel?.connected||typeof store.snapshot!=='function'||typeof store.envelopes!=='function'||typeof store.commit!=='function')throw Error('broker-bootstrap-invalid');
|
||||
let live=true,handle;
|
||||
const topology=createWfpRevalidation({readTopology,quarantine:()=>{live=false;void handle?.close();}});
|
||||
channel.once('disconnect',()=>{live=false;topology.close();});
|
||||
const gate=createServiceCommitGate({current:()=>live&&channel.connected,commitState:request=>store.commit(request)});
|
||||
const wrapped=createServiceFactory({factory:async(...args)=>{
|
||||
if(!await topology.start()||!live||!channel.connected)throw Error('broker-bootstrap-invalid');
|
||||
return factory(...args);
|
||||
},loadTrust:async parsed=>{
|
||||
const snapshot=await store.snapshot();if(!live||!channel.connected)throw Error('broker-bootstrap-invalid');
|
||||
const envelopes=await store.envelopes();if(!live||!channel.connected)throw Error('broker-bootstrap-invalid');
|
||||
return gate.prepare({...envelopes,releaseAnchor:snapshot.releaseAnchor,authenticatedSid:snapshot.authenticatedSid,state:snapshot.acceptanceState,policyEnvelope:parsed.envelope});
|
||||
}});
|
||||
handle=runProductionProcess(wrapped,channel);
|
||||
const originalClose=handle.close;
|
||||
return {...handle,close:()=>{live=false;topology.close();return originalClose();}};
|
||||
}
|
||||
if(require.main===module){
|
||||
if(process.argv.length!==3||process.argv[2]!=='--service-stdio'){process.exitCode=5;}
|
||||
else{try{const channel=createFramedChannel({readable:process.stdin,writable:process.stdout,inboundFirstLimit:2097152,outboundFirstLimit:65536});runServiceProcess({channel});}catch{process.exitCode=5;}}
|
||||
}
|
||||
module.exports={runServiceProcess};
|
||||
@@ -0,0 +1,62 @@
|
||||
'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};
|
||||
@@ -0,0 +1,76 @@
|
||||
'use strict';
|
||||
const {isIP}=require('node:net');
|
||||
const bindings=new WeakSet();
|
||||
class AuthorityError extends Error {
|
||||
constructor(code){super(code);this.name='AuthorityError';this.code=code;}
|
||||
}
|
||||
const deny=code=>{throw new AuthorityError(code);};
|
||||
|
||||
function parseAuthority(value,{requirePort=false}={}) {
|
||||
if(typeof value!=='string'||!value||value.length>320||/[^\x21-\x7e]|[@/\\?#%]/.test(value))deny('invalid-authority');
|
||||
let hostname,portText;
|
||||
if(value.startsWith('[')) {
|
||||
const match=/^\[([^\]]+)\](?::([^:]+))?$/.exec(value);
|
||||
if(!match||isIP(match[1])!==6)deny('invalid-authority');
|
||||
hostname=new URL(`https://[${match[1]}]/`).hostname.slice(1,-1);
|
||||
portText=match[2];
|
||||
} else {
|
||||
const match=/^([^:]+)(?::([^:]+))?$/.exec(value);
|
||||
if(!match)deny('invalid-authority');
|
||||
hostname=match[1].toLowerCase();portText=match[2];
|
||||
if(isIP(hostname)!==4) {
|
||||
// Forbid URL-parser legacy numerical IPv4 aliases (127.1, octal,
|
||||
// decimal and hex). DNS labels remain explicit ASCII / punycode.
|
||||
if(/^[0-9.]+$/.test(hostname)||hostname.split('.').some(label=>/^0x[0-9a-f]+$/i.test(label)))deny('invalid-authority');
|
||||
if(hostname.length>253||hostname.split('.').some(label=>!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)))deny('invalid-authority');
|
||||
}
|
||||
}
|
||||
if(requirePort&&portText===undefined)deny('missing-port');
|
||||
if(portText!==undefined&&!/^[1-9][0-9]{0,4}$/.test(portText))deny('invalid-port');
|
||||
const port=portText===undefined?443:Number(portText);
|
||||
if(port>65535)deny('invalid-port');
|
||||
return Object.freeze({hostname,port,authority:`${isIP(hostname)===6?'['+hostname+']':hostname}:${port}`});
|
||||
}
|
||||
|
||||
/** Caller authenticates CONNECT before creating this immutable binding.
|
||||
* Parsing does not authenticate a user, authorize a destination, terminate
|
||||
* TLS, validate upstream certificates or constrain process/OS egress.
|
||||
*/
|
||||
function bindConnectAuthority(value) {
|
||||
const binding=parseAuthority(value,{requirePort:true});bindings.add(binding);return binding;
|
||||
}
|
||||
|
||||
function verifyInnerAuthority(binding,{rawHeaders,url,servername}) {
|
||||
if(!binding||!bindings.has(binding))deny('invalid-binding');
|
||||
const matches=value=>parseAuthority(value).authority===binding.authority;
|
||||
if(!Array.isArray(rawHeaders)||rawHeaders.length%2)deny('invalid-headers');
|
||||
let host;
|
||||
for(let index=0;index<rawHeaders.length;index+=2) {
|
||||
if(typeof rawHeaders[index]!=='string'||typeof rawHeaders[index+1]!=='string')deny('invalid-headers');
|
||||
if(rawHeaders[index].toLowerCase()==='host') {
|
||||
if(host!==undefined)deny('duplicate-host');
|
||||
host=rawHeaders[index+1];
|
||||
}
|
||||
}
|
||||
if(host===undefined)deny('missing-host');
|
||||
if(!matches(host))deny('authority-mismatch');
|
||||
if(typeof url!=='string'||!url||url.includes('\\')||[...url].some(character=>character.charCodeAt(0)<=32||character.charCodeAt(0)===127))deny('invalid-target');
|
||||
if(url.startsWith('/')) {
|
||||
if(url.startsWith('//')||url.includes('#'))deny('invalid-target');
|
||||
} else {
|
||||
const match=/^https:\/\/([^/;?#]+)(?:\/[^#]*)?$/.exec(url);
|
||||
if(!match)deny('invalid-target');
|
||||
if(!matches(match[1]))deny('authority-mismatch');
|
||||
}
|
||||
if(servername===undefined||servername==='') {
|
||||
if(!isIP(binding.hostname))deny('missing-sni');
|
||||
} else {
|
||||
// TLS SNI is a hostname, never an authority carrying a port.
|
||||
const sni=isIP(servername)===6?`[${servername}]`:servername;
|
||||
const parsed=parseAuthority(sni);
|
||||
if(sni.includes(':')&&isIP(servername)!==6)deny('invalid-sni');
|
||||
if(parsed.hostname!==binding.hostname)deny('sni-mismatch');
|
||||
}
|
||||
return binding;
|
||||
}
|
||||
module.exports={AuthorityError,parseAuthority,bindConnectAuthority,verifyInnerAuthority};
|
||||
@@ -0,0 +1,64 @@
|
||||
'use strict';
|
||||
// Session-local TLS leaves only. No CA installation, persistent key or browser
|
||||
// trust bypass. resolve() is broker-internal: its key must NEVER cross IPC.
|
||||
// Parent receives only publicMetadata(), bound separately to broker generation
|
||||
// and current policy. Destroy TLS sockets/contexts before close(); wiping this
|
||||
// JS key buffer does not erase copies already held by OpenSSL contexts.
|
||||
const crypto=require('node:crypto');
|
||||
const {isIP}=require('node:net');
|
||||
const {parseAuthority}=require('./tls-authority.cjs');
|
||||
const fail=()=>{throw Error('broker-certificate-unavailable');};
|
||||
function der(tag,bytes){
|
||||
const length=bytes.length;
|
||||
if(length>65535)fail();
|
||||
const size=length<128?Buffer.from([length]):length<256?Buffer.from([0x81,length]):Buffer.from([0x82,length>>8,length&255]);
|
||||
return Buffer.concat([Buffer.from([tag]),size,bytes]);
|
||||
}
|
||||
const seq=(...items)=>der(0x30,Buffer.concat(items));
|
||||
const oid=hex=>Buffer.from(hex,'hex');
|
||||
const algorithm=seq(oid('06082a8648ce3d040302')); // ECDSA with SHA-256; absent parameters.
|
||||
const extension=(id,value,critical=false)=>seq(oid(id),...(critical?[Buffer.from([1,1,255])]:[]),der(4,value));
|
||||
function ipBytes(host){
|
||||
if(isIP(host)===4)return Buffer.from(host.split('.').map(Number));
|
||||
let value=host;
|
||||
if(value.includes('.')){const split=value.lastIndexOf(':');const tail=ipBytes(value.slice(split+1));value=value.slice(0,split)+':'+tail.readUInt16BE(0).toString(16)+':'+tail.readUInt16BE(2).toString(16);}
|
||||
const halves=value.split('::');if(halves.length>2)fail();
|
||||
const left=halves[0]?halves[0].split(':'):[],right=halves[1]?halves[1].split(':'):[];
|
||||
const words=halves.length===2?[...left,...Array(8-left.length-right.length).fill('0'),...right]:left;
|
||||
if(words.length!==8)fail();const bytes=Buffer.alloc(16);words.forEach((word,index)=>bytes.writeUInt16BE(parseInt(word,16),index*2));return bytes;
|
||||
}
|
||||
function time(milliseconds){return der(0x18,Buffer.from(new Date(milliseconds).toISOString().replace(/[-:T]/g,'').replace(/\.\d{3}Z$/,'Z')));}
|
||||
function pem(bytes){return '-----BEGIN CERTIFICATE-----\n'+bytes.toString('base64').match(/.{1,64}/g).join('\n')+'\n-----END CERTIFICATE-----\n';}
|
||||
async function createSessionCertificates({maxAuthorities=128,lifetimeMs=3600000,current,authorized}={}){
|
||||
if(!Number.isInteger(maxAuthorities)||maxAuthorities<1||maxAuthorities>1024||!Number.isInteger(lifetimeMs)||lifetimeMs<60000||lifetimeMs>3600000||typeof current!=='function'||typeof authorized!=='function')fail();
|
||||
let keys=await new Promise((resolve,reject)=>crypto.generateKeyPair('ec',{namedCurve:'prime256v1'},(error,publicKey,privateKey)=>error?reject(Error('broker-certificate-unavailable')):resolve({publicKey,privateKey})));
|
||||
const keyBytes=Buffer.from(keys.privateKey.export({type:'pkcs8',format:'pem'}));
|
||||
const leaves=new Map();let closed=false;
|
||||
const active=()=>{try{return !closed&¤t()===true;}catch{return false;}};
|
||||
const close=()=>{closed=true;leaves.clear();keyBytes.fill(0);keys=null;};
|
||||
const issuer={close,
|
||||
resolve(binding){
|
||||
if(!active()||!binding||typeof binding.authority!=='string')fail();
|
||||
const authority=parseAuthority(binding.authority);if(authority.authority!==binding.authority)fail();
|
||||
try{if(authorized(authority)!==true)fail();}catch{fail();}
|
||||
const now=Date.now(),existing=leaves.get(authority.authority);
|
||||
if(existing&&existing.expiresAt>now+1000)return existing;
|
||||
for(const [name,leaf]of leaves)if(leaf.expiresAt<=now)leaves.delete(name);
|
||||
if(leaves.size>=maxAuthorities)fail();
|
||||
const serial=crypto.randomBytes(20);serial[0]&=127;serial[0]|=1;
|
||||
const name=seq(der(0x31,seq(oid('0603550403'),der(0x0c,Buffer.from('SafetyNet session leaf')))));
|
||||
const san=seq(der(isIP(authority.hostname)?0x87:0x82,isIP(authority.hostname)?ipBytes(authority.hostname):Buffer.from(authority.hostname,'ascii')));
|
||||
const expiresAt=now+lifetimeMs;
|
||||
const extensions=der(0xa3,seq(extension('0603551d13',seq(),true),extension('0603551d0f',der(3,Buffer.from([7,128])),true),extension('0603551d25',seq(oid('06082b06010505070301'))),extension('0603551d11',san)));
|
||||
const tbs=seq(der(0xa0,der(2,Buffer.from([2]))),der(2,serial),algorithm,name,seq(time(now-60000),time(expiresAt)),name,keys.publicKey.export({type:'spki',format:'der'}),extensions);
|
||||
const certificate=pem(seq(tbs,algorithm,der(3,Buffer.concat([Buffer.from([0]),crypto.sign('sha256',tbs,keys.privateKey)]))));
|
||||
const x509=new crypto.X509Certificate(certificate);
|
||||
if(x509.ca||!x509.verify(keys.publicKey)||!(isIP(authority.hostname)?x509.checkIP(authority.hostname):x509.checkHost(authority.hostname,{subject:'never',wildcards:false})))fail();
|
||||
const leaf=Object.freeze({authority:authority.authority,key:keyBytes,cert:certificate,fingerprint256:x509.fingerprint256,expiresAt});
|
||||
if(!active())fail();leaves.set(authority.authority,leaf);return leaf;
|
||||
},
|
||||
publicMetadata(binding){const leaf=issuer.resolve(binding);return Object.freeze({authority:leaf.authority,cert:leaf.cert,fingerprint256:leaf.fingerprint256,expiresAt:leaf.expiresAt});}
|
||||
};
|
||||
return Object.freeze(issuer);
|
||||
}
|
||||
module.exports={createSessionCertificates};
|
||||
@@ -0,0 +1,32 @@
|
||||
'use strict';
|
||||
const {validateTopology}=require('./wfp-topology.cjs');
|
||||
// Adapter must come from a protected native service authority, never IPC JSON.
|
||||
// Periodic reads supplement unreliable BFE notifications. Neither closes the
|
||||
// privileged-change packet window; no atomic kernel claim is made here.
|
||||
function createWfpRevalidation({readTopology,quarantine,intervalMs=500,deadlineMs=2000}={}){
|
||||
if(typeof readTopology!=='function'||typeof quarantine!=='function'||!Number.isInteger(intervalMs)||intervalMs<100||intervalMs>1000||!Number.isInteger(deadlineMs)||deadlineMs<10||deadlineMs>5000)throw TypeError('Invalid WFP monitor');
|
||||
let state='new',pending=false,timer,timeout;
|
||||
function fail(reason){if(state==='closed'||state==='quarantined')return;state='quarantined';clearInterval(timer);clearTimeout(timeout);quarantine(reason);}
|
||||
async function check(){
|
||||
if(pending||state==='closed'||state==='quarantined')return false;
|
||||
pending=true;
|
||||
timeout=setTimeout(()=>fail('wfp-revalidation-timeout'),deadlineMs);timeout.unref?.();
|
||||
try{
|
||||
const snapshot=await readTopology();
|
||||
if(state==='closed'||state==='quarantined')return false;
|
||||
if(snapshot?.bfeRunning!==true){fail('wfp-bfe-unavailable');return false;}
|
||||
const result=validateTopology(snapshot);
|
||||
if(!result.safe){fail(result.reason);return false;}
|
||||
return true;
|
||||
}catch{fail('wfp-revalidation-unavailable');return false;}
|
||||
finally{pending=false;clearTimeout(timeout);}
|
||||
}
|
||||
return Object.freeze({
|
||||
async start(){if(state!=='new')throw Error('WFP monitor already started');state='starting';if(!await check())return false;if(state!=='starting')return false;state='verified';timer=setInterval(()=>{void check();},intervalMs);timer.unref?.();return true;},
|
||||
changed(){fail('wfp-topology-changed');},
|
||||
current(){return state==='verified';},
|
||||
close(){state='closed';clearInterval(timer);clearTimeout(timeout);},
|
||||
status(){return Object.freeze({state,pending});}
|
||||
});
|
||||
}
|
||||
module.exports={createWfpRevalidation};
|
||||
@@ -0,0 +1,60 @@
|
||||
'use strict';
|
||||
// Contract only; not an installer and not proof of kernel enforcement.
|
||||
// https://learn.microsoft.com/en-us/windows/win32/fwp/filter-arbitration
|
||||
const {GUIDS,planBrowserNetworkBoundary}=require('./windows-wfp-plan.cjs');
|
||||
const OWNED_PRIORITY=65535;
|
||||
const RELEVANT_LAYERS=Object.freeze([GUIDS.connectV4,GUIDS.connectV6,GUIDS.recvV4,GUIDS.recvV6]);
|
||||
const USER_ID='af043a0a-b34d-4f86-979c-c90371af6e66';
|
||||
function planServiceReceiver({appId,brokerPort,serviceSid}={}){
|
||||
const base=planBrowserNetworkBoundary({appId,brokerPort});
|
||||
if(typeof serviceSid!=='string'||!/^S-1-5-80-(?:\d+-){4}\d+$/.test(serviceSid)||serviceSid.split('-').slice(4).some(x=>String(Number(x))!==x||Number(x)>4294967295))throw new TypeError('Actual SCM service SID required');
|
||||
const tuple=Object.freeze([
|
||||
{field:GUIDS.protocol,match:'equal',type:'FWP_UINT8',value:6},
|
||||
{field:GUIDS.localAddress,match:'equal',type:'FWP_UINT32',value:0x7f000001},
|
||||
{field:GUIDS.localPort,match:'equal',type:'FWP_UINT16',value:brokerPort}
|
||||
].map(Object.freeze));
|
||||
// Higher normal PERMIT skips the lower BLOCK in OUR sublayer. No clear-action-
|
||||
// right: corporate blocks in other sublayers retain their veto.
|
||||
const filters=Object.freeze([
|
||||
Object.freeze({id:'broker-service-receiver',layer:GUIDS.recvV4,weight:2,action:'FWP_ACTION_PERMIT',clearActionRight:false,conditions:Object.freeze([...tuple,Object.freeze({field:GUIDS.appId,match:'equal',type:'FWP_BYTE_BLOB_TYPE',value:base.applicationId}),Object.freeze({field:USER_ID,match:'equal',type:'FWP_SECURITY_DESCRIPTOR_TYPE',value:`D:(A;;CC;;;${serviceSid})`})])}),
|
||||
Object.freeze({id:'broker-receiver-deny',layer:GUIDS.recvV4,weight:1,action:'FWP_ACTION_BLOCK',clearActionRight:false,conditions:tuple})
|
||||
]);
|
||||
return Object.freeze({activation:'not-installed',sublayerPriority:OWNED_PRIORITY,filters,serviceSid,requiresActualServiceToken:true,completeBoundary:false});
|
||||
}
|
||||
function validateTopology({ownedSublayer,sublayers,filters,enumerationComplete,allObjectsReadable}={}){
|
||||
const fail=reason=>Object.freeze({safe:false,reason});
|
||||
if(enumerationComplete!==true||allObjectsReadable!==true)return fail('topology-incomplete');
|
||||
if(typeof ownedSublayer!=='string'||!Array.isArray(sublayers)||!Array.isArray(filters)||sublayers.length>4096||filters.length>65536)return fail('topology-invalid');
|
||||
const map=new Map();
|
||||
for(const s of sublayers){if(!s||typeof s.key!=='string'||map.has(s.key)||!Number.isInteger(s.weight)||s.weight<0||s.weight>65535)return fail('topology-invalid');map.set(s.key,s.weight);}
|
||||
if(map.get(ownedSublayer)!==OWNED_PRIORITY)return fail('owned-priority-invalid');
|
||||
let ownedCount=0;const ownedIds=new Set();
|
||||
for(const f of filters){
|
||||
if(!f||typeof f!=='object'||typeof f.layer!=='string'||typeof f.sublayer!=='string')return fail('snapshot-invalid');
|
||||
if(!RELEVANT_LAYERS.includes(f.layer))continue;
|
||||
if(!map.has(f.sublayer))return fail('unknown-sublayer');
|
||||
if(f.sublayer!==ownedSublayer&&map.get(f.sublayer)>=OWNED_PRIORITY)return fail('priority-collision');
|
||||
// Our complete filter readback must additionally match exact trusted plans.
|
||||
if(f.sublayer===ownedSublayer){
|
||||
if(f.ownedVerified!==true||typeof f.key!=='string'||ownedIds.has(f.key))return fail('owned-filter-unverified');
|
||||
ownedIds.add(f.key);ownedCount++;
|
||||
}
|
||||
}
|
||||
if(ownedCount!==8)return fail('owned-filter-count');
|
||||
return Object.freeze({safe:true,reason:'topology-compatible',zeroChangeWindowProven:false});
|
||||
}
|
||||
function createTopologyGuard({quarantine}={}){
|
||||
if(typeof quarantine!=='function')throw new TypeError('Quarantine callback required');
|
||||
let state='unverified';
|
||||
function invalidate(reason){if(state==='quarantined')return;state='quarantined';quarantine(reason);}
|
||||
return Object.freeze({
|
||||
admit(snapshot){if(state!=='unverified')return false;const result=validateTopology(snapshot);if(!result.safe){invalidate(result.reason);return false;}state='verified';return true;},
|
||||
// Subscribe ALL filter/sublayer/BFE state changes before snapshot, on a
|
||||
// separate read-only session. Invalidate synchronously on any notification;
|
||||
// never re-admit automatically after a change or failed subscription.
|
||||
changed(){invalidate('wfp-topology-changed');},
|
||||
monitoringFailed(){invalidate('wfp-monitor-unavailable');},
|
||||
current(){return state==='verified';},status(){return state;}
|
||||
});
|
||||
}
|
||||
module.exports={OWNED_PRIORITY,RELEVANT_LAYERS,planServiceReceiver,validateTopology,createTopologyGuard};
|
||||
@@ -0,0 +1,54 @@
|
||||
'use strict';
|
||||
// Planner only: it never opens BFE, installs filters, or changes Windows policy.
|
||||
// GUIDs checked against Windows SDK 10.0.26100.0 fwpmu.h.
|
||||
// https://learn.microsoft.com/en-us/windows/win32/fwp/filtering-condition-identifiers-
|
||||
// https://learn.microsoft.com/en-us/windows/win32/fwp/filtering-conditions-available-at-each-filtering-layer
|
||||
// https://learn.microsoft.com/en-us/windows/win32/fwp/filter-arbitration
|
||||
const GUIDS=Object.freeze({
|
||||
connectV4:'c38d57d1-05a7-4c33-904f-7fbceee60e82',connectV6:'4a72393b-319f-44bc-84c3-ba54dcb3b6b4',
|
||||
appId:'d78e1e87-8644-4ea5-9437-d809ecefc971',protocol:'3971ef2b-623e-4f9a-8cb1-6e79b806b9a7',
|
||||
remoteAddress:'b235ae9a-1d64-49b8-a44c-5ff3d9095045',remotePort:'c35a604d-d22b-4e1a-91b4-68f674ee674b',
|
||||
recvV4:'e1cd9fe7-f4b5-4273-96c0-592e487b8650',recvV6:'a3b42c97-9f04-4672-b87e-cee9c483257f',
|
||||
localAddress:'d9ee00de-c1ef-4617-bfe3-ffd8f5a08957',localPort:'0c1ba1af-5765-453f-af22-a8f791ac775b'
|
||||
});
|
||||
function condition(field,match,type,value){return Object.freeze({field:GUIDS[field],match,type,value});}
|
||||
function planBrowserNetworkBoundary({appId,brokerPort}={}){
|
||||
// App ID must come from FwpmGetAppIdFromFileName0 for the verified installed EXE.
|
||||
// Immutable hex prevents callers mutating a Buffer after planning.
|
||||
if(!Buffer.isBuffer(appId)||appId.length<8||appId.length>65536||appId.length%2||!Number.isInteger(brokerPort)||brokerPort<1||brokerPort>65535)throw new TypeError('Invalid browser network boundary');
|
||||
const devicePath=appId.toString('utf16le');
|
||||
if(!/^\\device\\harddiskvolume[0-9]+\\[^\0]+\.exe\0$/.test(devicePath)||devicePath!==devicePath.toLowerCase()||devicePath.slice(0,-1).split('\\').some(part=>part==='.'||part==='..')||devicePath.includes(':'))throw new TypeError('Native canonical application ID required');
|
||||
const applicationId=appId.toString('hex');
|
||||
const identity=condition('appId','equal','FWP_BYTE_BLOB_TYPE',applicationId);
|
||||
const tcp=condition('protocol','equal','FWP_UINT8',6);
|
||||
const localhost=condition('remoteAddress','equal','FWP_UINT32',0x7f000001);
|
||||
function rule(id,layer,conditions){return Object.freeze({id,layer,action:'FWP_ACTION_BLOCK',clearActionRight:false,conditions:Object.freeze([identity,...conditions])});}
|
||||
// Disjoint deny predicates cover the complement of the one approved tuple.
|
||||
// No PERMIT filter: existing corporate firewall blocks retain their veto.
|
||||
const filters=Object.freeze([
|
||||
rule('v4-non-tcp',GUIDS.connectV4,[condition('protocol','not-equal','FWP_UINT8',6)]),
|
||||
rule('v4-tcp-non-broker-address',GUIDS.connectV4,[tcp,condition('remoteAddress','not-equal','FWP_UINT32',0x7f000001)]),
|
||||
rule('v4-tcp-other-local-port',GUIDS.connectV4,[tcp,localhost,condition('remotePort','not-equal','FWP_UINT16',brokerPort)]),
|
||||
rule('v6-all',GUIDS.connectV6,[]),
|
||||
// ALE authorizes both directions of an inbound-first flow. Denying only
|
||||
// CONNECT would allow replies to an accepted hostile UDP/TCP peer.
|
||||
// Approved outbound broker responses already belong to their CONNECT flow.
|
||||
rule('browser-v4-incoming',GUIDS.recvV4,[]),
|
||||
rule('browser-v6-incoming',GUIDS.recvV6,[])
|
||||
]);
|
||||
return Object.freeze({version:2,activation:'not-installed',applicationId,devicePath:devicePath.slice(0,-1),broker:Object.freeze({family:4,address:'127.0.0.1',protocol:6,port:brokerPort}),filters,
|
||||
prerequisites:Object.freeze(['verified-native-app-id','signed-installer-transaction','persistent-owned-provider-and-sublayer','native-filter-enumeration','physical-ipv4-ipv6-and-loopback-tests','physical-inbound-first-tcp-udp-tests','browser-management-inspector-telemetry-and-agent-ipc-migration','browser-local-dns-denial','authenticated-http-only-broker'])});
|
||||
}
|
||||
// Separate supplemental plan; the existing six-filter native installer does
|
||||
// NOT install this plan. It prevents a different executable accepting the
|
||||
// approved TCP tuple, but does not distinguish instances of reusable node.exe.
|
||||
// A service-token or continuously protected listener remains mandatory.
|
||||
function planBrokerListenerProtection({appId,brokerPort}={}){
|
||||
const validated=planBrowserNetworkBoundary({appId,brokerPort});
|
||||
const filters=Object.freeze([Object.freeze({id:'broker-v4-other-executable',layer:GUIDS.recvV4,action:'FWP_ACTION_BLOCK',clearActionRight:false,conditions:Object.freeze([
|
||||
condition('protocol','equal','FWP_UINT8',6),condition('localAddress','equal','FWP_UINT32',0x7f000001),condition('localPort','equal','FWP_UINT16',brokerPort),condition('appId','not-equal','FWP_BYTE_BLOB_TYPE',validated.applicationId)
|
||||
])})]);
|
||||
return Object.freeze({version:1,activation:'not-installed',completeBoundary:false,applicationId:validated.applicationId,filters,
|
||||
prerequisites:Object.freeze(['native-recv-accept-filter-verification','same-executable-instance-protection','physical-port-takeover-tests','corporate-firewall-veto-preserved'])});
|
||||
}
|
||||
module.exports={GUIDS,planBrowserNetworkBoundary,planBrokerListenerProtection};
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Trusted signed-installer library. No CLI, automatic elevation or live apply.
|
||||
|
||||
The installer must authenticate its manifest, verify installation ACLs and service
|
||||
provenance, stop browser processes, and own rollback. This module does not prove
|
||||
those prerequisites or replace physical receiver tests. Filters never permit.
|
||||
"""
|
||||
import ctypes as C
|
||||
import copy
|
||||
import hashlib
|
||||
import os
|
||||
import uuid
|
||||
from contextlib import ExitStack
|
||||
from pathlib import Path
|
||||
from windows_wfp_probe import GUID,Blob,Display,Value,Condition,Filter,U32,ReadOnlyWfp,_blob,_decode,verify_observed
|
||||
PROVIDER='2a7bcbf2-e4f5-4e94-bd33-d5442a3bef50'
|
||||
SUBLAYER='403a69b8-fc7c-460a-a247-72e0d72e6bf8'
|
||||
SERVICE='SafetyNetNetworkBroker'
|
||||
MARKER=b'SafetyNet.NetworkBoundary.v1'
|
||||
OWNED_PRIORITY=65535
|
||||
L4='c38d57d1-05a7-4c33-904f-7fbceee60e82'
|
||||
L6='4a72393b-319f-44bc-84c3-ba54dcb3b6b4'
|
||||
R4='e1cd9fe7-f4b5-4273-96c0-592e487b8650'
|
||||
R6='a3b42c97-9f04-4672-b87e-cee9c483257f'
|
||||
APP='d78e1e87-8644-4ea5-9437-d809ecefc971'
|
||||
PROTO='3971ef2b-623e-4f9a-8cb1-6e79b806b9a7'
|
||||
ADDR='b235ae9a-1d64-49b8-a44c-5ff3d9095045'
|
||||
PORT='c35a604d-d22b-4e1a-91b4-68f674ee674b'
|
||||
RULE_IDS=['v4-non-tcp','v4-tcp-non-broker-address','v4-tcp-other-local-port','v6-all','browser-v4-incoming','browser-v6-incoming']
|
||||
_MANAGED_TOKEN=object()
|
||||
class InstallError(RuntimeError):
|
||||
def __init__(self,reason,code=0):super().__init__(reason);self.reason=reason;self.code=int(code)
|
||||
def _guid(text):return GUID.from_buffer_copy(uuid.UUID(text).bytes_le)
|
||||
def _key(rule):return str(uuid.uuid5(uuid.UUID(PROVIDER),rule))
|
||||
def _owned_metadata(value,kind):
|
||||
if not value:return False
|
||||
return value.get('flags')==1 and value.get('data')==MARKER.hex() and (value.get('service')==SERVICE if kind=='provider' else value.get('provider')==PROVIDER and value.get('weight')==OWNED_PRIORITY)
|
||||
def _validate(plan,identity,port):
|
||||
if not isinstance(port,int) or isinstance(port,bool) or not 1<=port<=65535:raise InstallError('invalid-broker-port')
|
||||
def c(field,match,type_,value):return {'field':field,'match':match,'type':type_,'value':value}
|
||||
app=c(APP,'equal','FWP_BYTE_BLOB_TYPE',identity)
|
||||
tcp=c(PROTO,'equal','FWP_UINT8',6)
|
||||
specifications=[(L4,[app,c(PROTO,'not-equal','FWP_UINT8',6)]),(L4,[app,tcp,c(ADDR,'not-equal','FWP_UINT32',0x7f000001)]),(L4,[app,tcp,c(ADDR,'equal','FWP_UINT32',0x7f000001),c(PORT,'not-equal','FWP_UINT16',port)]),(L6,[app]),(R4,[app]),(R6,[app])]
|
||||
managed=plan.get('version')==3
|
||||
if not isinstance(plan,dict) or plan.get('applicationId')!=identity or plan.get('version') not in (2,3) or len(plan.get('filters',[]))!=(8 if managed else 6):raise InstallError('plan-identity-mismatch')
|
||||
for rule,rule_id,(layer,conditions) in zip(plan['filters'],RULE_IDS,specifications):
|
||||
if rule.get('id')!=rule_id or rule.get('layer')!=layer or rule.get('action')!='FWP_ACTION_BLOCK' or rule.get('clearActionRight') is not False or rule.get('conditions')!=conditions:raise InstallError('unsafe-plan')
|
||||
if managed:
|
||||
from windows_wfp_receiver import validate_receiver_rule,service_sddl
|
||||
pair=plan['filters'][6:]
|
||||
if [rule.get('id') for rule in pair]!=['broker-service-receiver','broker-receiver-deny']:raise InstallError('unsafe-receiver-plan')
|
||||
for rule in pair:validate_receiver_rule(rule)
|
||||
if any(rule['conditions'][2]['value']!=port for rule in pair) or pair[0]['conditions'][3]['value']!=plan.get('brokerApplicationId') or pair[0]['conditions'][4]['value']!=service_sddl(plan.get('serviceSid')):raise InstallError('unsafe-receiver-plan')
|
||||
return plan
|
||||
|
||||
def elevated():
|
||||
if os.name!='nt':return False
|
||||
k=C.WinDLL('kernel32',use_last_error=True);a=C.WinDLL('advapi32',use_last_error=True)
|
||||
k.GetCurrentProcess.restype=C.c_void_p;k.CloseHandle.argtypes=[C.c_void_p]
|
||||
a.OpenProcessToken.argtypes=[C.c_void_p,U32,C.POINTER(C.c_void_p)]
|
||||
a.GetTokenInformation.argtypes=[C.c_void_p,U32,C.c_void_p,U32,C.POINTER(U32)]
|
||||
token=C.c_void_p();value=U32();size=U32()
|
||||
if not a.OpenProcessToken(k.GetCurrentProcess(),8,C.byref(token)):return False
|
||||
try:return bool(a.GetTokenInformation(token,20,C.byref(value),4,C.byref(size)) and value.value)
|
||||
finally:k.CloseHandle(token)
|
||||
|
||||
class Provider(C.Structure):
|
||||
_fields_=[('key',GUID),('display',Display),('flags',U32),('data',Blob),('service',C.c_wchar_p)]
|
||||
class Sublayer(C.Structure):
|
||||
_fields_=[('key',GUID),('display',Display),('flags',U32),('provider',C.POINTER(GUID)),('data',Blob),('weight',C.c_uint16)]
|
||||
|
||||
class NativeBackend:
|
||||
def __init__(self):
|
||||
self.reader=ReadOnlyWfp();self.api=self.reader.api;self.engine=C.c_void_p()
|
||||
for name,args in {
|
||||
'FwpmTransactionBegin0':[C.c_void_p,U32], 'FwpmTransactionCommit0':[C.c_void_p], 'FwpmTransactionAbort0':[C.c_void_p],
|
||||
'FwpmProviderGetByKey0':[C.c_void_p,C.POINTER(GUID),C.POINTER(C.POINTER(Provider))],
|
||||
'FwpmSubLayerGetByKey0':[C.c_void_p,C.POINTER(GUID),C.POINTER(C.POINTER(Sublayer))],
|
||||
'FwpmFilterGetByKey0':[C.c_void_p,C.POINTER(GUID),C.POINTER(C.POINTER(Filter))],
|
||||
'FwpmProviderAdd0':[C.c_void_p,C.POINTER(Provider),C.c_void_p],
|
||||
'FwpmSubLayerAdd0':[C.c_void_p,C.POINTER(Sublayer),C.c_void_p],
|
||||
'FwpmFilterAdd0':[C.c_void_p,C.POINTER(Filter),C.c_void_p,C.POINTER(C.c_uint64)],
|
||||
'FwpmFilterDeleteByKey0':[C.c_void_p,C.POINTER(GUID)],
|
||||
'FwpmSubLayerDeleteByKey0':[C.c_void_p,C.POINTER(GUID)],
|
||||
'FwpmProviderDeleteByKey0':[C.c_void_p,C.POINTER(GUID)]}.items():
|
||||
function=getattr(self.api,name);function.argtypes=args;function.restype=U32
|
||||
def _call(self,name,*args):
|
||||
code=getattr(self.api,name)(*args)
|
||||
if code:raise InstallError('native-operation-failed',code)
|
||||
def app_id(self,executable):
|
||||
result=self.reader.app_id(executable)
|
||||
if not result['ok']:raise InstallError(result['reason'],result['code'])
|
||||
return result['blobHex']
|
||||
def open(self):self._call('FwpmEngineOpen0',None,10,None,None,C.byref(self.engine))
|
||||
def prepare_topology(self):
|
||||
from windows_wfp_topology import TopologyObserver
|
||||
self.topology=TopologyObserver()
|
||||
result=self.topology.start()
|
||||
if not result.get('subscribed'):return False
|
||||
snapshot=self.topology.snapshot([L4,L6,R4,R6])
|
||||
# The current observer cannot attest READ visibility over every object.
|
||||
# Do not substitute successful enumeration for that deployment gate.
|
||||
return bool(snapshot.get('complete') and snapshot.get('allObjectsReadable'))
|
||||
def close(self):
|
||||
if getattr(self,'topology',None):self.topology.close();self.topology=None
|
||||
if self.engine:self.api.FwpmEngineClose0(self.engine);self.engine=C.c_void_p()
|
||||
def begin(self):self._call('FwpmTransactionBegin0',self.engine,0)
|
||||
def commit(self):self._call('FwpmTransactionCommit0',self.engine)
|
||||
def abort(self):self._call('FwpmTransactionAbort0',self.engine)
|
||||
def lookup(self,kind,key):
|
||||
cls,method,not_found={'provider':(Provider,'FwpmProviderGetByKey0',0x80320005),'sublayer':(Sublayer,'FwpmSubLayerGetByKey0',0x80320007),'filter':(Filter,'FwpmFilterGetByKey0',0x80320003)}[kind]
|
||||
pointer=C.POINTER(cls)();guid=_guid(key);code=getattr(self.api,method)(self.engine,C.byref(guid),C.byref(pointer))
|
||||
if code==not_found:return None
|
||||
if code:raise InstallError('native-lookup-failed',code)
|
||||
try:
|
||||
value=pointer.contents
|
||||
if kind=='filter':return _decode(value)
|
||||
result={'flags':value.flags,'data':_blob(value.data).hex()}
|
||||
if kind=='provider':result['service']=value.service
|
||||
else:result.update(provider=value.provider.contents.text() if value.provider else None,weight=value.weight)
|
||||
return result
|
||||
finally:
|
||||
memory=C.cast(pointer,C.c_void_p);self.api.FwpmFreeMemory0(C.byref(memory))
|
||||
def add_provider(self):
|
||||
data=(C.c_ubyte*len(MARKER)).from_buffer_copy(MARKER);value=Provider();value.key=_guid(PROVIDER);value.flags=1;value.display=Display('SafetyNet network boundary','Owned browser egress restrictions');value.data=Blob(len(MARKER),data);value.service=SERVICE
|
||||
self._call('FwpmProviderAdd0',self.engine,C.byref(value),None)
|
||||
def add_sublayer(self):
|
||||
data=(C.c_ubyte*len(MARKER)).from_buffer_copy(MARKER);provider=_guid(PROVIDER);value=Sublayer();value.key=_guid(SUBLAYER);value.flags=1;value.display=Display('SafetyNet network boundary','Owned priority; collisions rejected');value.provider=C.pointer(provider);value.data=Blob(len(MARKER),data);value.weight=OWNED_PRIORITY
|
||||
self._call('FwpmSubLayerAdd0',self.engine,C.byref(value),None)
|
||||
def add_filter(self,rule):
|
||||
if rule.get('id') in ('broker-service-receiver','broker-receiver-deny'):
|
||||
from windows_wfp_receiver import validate_receiver_rule
|
||||
validate_receiver_rule(rule)
|
||||
if any(item['type']=='FWP_SECURITY_DESCRIPTOR_TYPE' for item in rule['conditions']):
|
||||
with ExitStack() as stack:
|
||||
from windows_wfp_receiver import service_descriptor
|
||||
descriptors={}
|
||||
for index,item in enumerate(rule['conditions']):
|
||||
if item['type']=='FWP_SECURITY_DESCRIPTOR_TYPE':
|
||||
value=item['value']
|
||||
if not value.startswith('D:(A;;CC;;;') or not value.endswith(')'):raise InstallError('unsafe-service-descriptor')
|
||||
descriptors[index]=stack.enter_context(service_descriptor(value[len('D:(A;;CC;;;'):-1]))
|
||||
return self._add_filter(rule,descriptors)
|
||||
return self._add_filter(rule,{})
|
||||
def _add_filter(self,rule,descriptors):
|
||||
conditions=(Condition*len(rule['conditions']))();retained=[]
|
||||
for index,item in enumerate(rule['conditions']):
|
||||
target=conditions[index];target.field=_guid(item['field']);target.match={'equal':0,'not-equal':10}[item['match']]
|
||||
target.value.type={'FWP_UINT8':1,'FWP_UINT16':2,'FWP_UINT32':3,'FWP_BYTE_BLOB_TYPE':12,'FWP_SECURITY_DESCRIPTOR_TYPE':14}[item['type']]
|
||||
if target.value.type==14:
|
||||
blob=descriptors[index];retained.append(blob);target.value.pointer=C.cast(C.pointer(blob),C.c_void_p)
|
||||
continue
|
||||
if target.value.type==12:
|
||||
payload=bytes.fromhex(item['value']);data=(C.c_ubyte*len(payload)).from_buffer_copy(payload);blob=Blob(len(payload),data);retained.extend([data,blob]);target.value.pointer=C.cast(C.pointer(blob),C.c_void_p)
|
||||
else:setattr(target.value,{1:'u8',2:'u16',3:'u32'}[target.value.type],item['value'])
|
||||
provider=_guid(PROVIDER);value=Filter();value.key=_guid(_key(rule['id']));value.flags=1;value.display=Display('SafetyNet '+rule['id'],'Deny only');value.provider=C.pointer(provider);value.layer=_guid(rule['layer']);value.sublayer=_guid(SUBLAYER);value.weight.type=0;value.count=len(conditions);value.conditions=conditions;value.action.type=0x1001
|
||||
if rule.get('weight') is not None:
|
||||
if rule.get('id') not in ('broker-service-receiver','broker-receiver-deny') or rule['weight'] not in (1,2):raise InstallError('unsafe-receiver-weight')
|
||||
weight=C.c_uint64(rule['weight']);retained.append(weight);value.weight.type=4;value.weight.pointer=C.cast(C.pointer(weight),C.c_void_p)
|
||||
if rule['action']=='FWP_ACTION_PERMIT':
|
||||
if rule.get('id')!='broker-service-receiver' or rule.get('weight')!=2 or not descriptors:raise InstallError('unsafe-permit')
|
||||
value.action.type=0x1002
|
||||
elif rule['action']!='FWP_ACTION_BLOCK':raise InstallError('unsafe-action')
|
||||
filter_id=C.c_uint64();self._call('FwpmFilterAdd0',self.engine,C.byref(value),None,C.byref(filter_id))
|
||||
def verify(self,plan):return self.reader.inspect(plan,PROVIDER,SUBLAYER)
|
||||
def delete(self,kind,key):
|
||||
guid=_guid(key);method={'filter':'FwpmFilterDeleteByKey0','sublayer':'FwpmSubLayerDeleteByKey0','provider':'FwpmProviderDeleteByKey0'}[kind]
|
||||
self._call(method,self.engine,C.byref(guid))
|
||||
|
||||
class BoundaryInstaller:
|
||||
def __init__(self,backend=None,is_elevated=elevated):self.backend=backend;self.is_elevated=is_elevated
|
||||
def _prepare(self,installation_root,executable,expected_sha256,port,plan):
|
||||
if not self.is_elevated():raise InstallError('administrator-required')
|
||||
raw_root=Path(installation_root).absolute();raw_target=Path(executable).absolute()
|
||||
try:
|
||||
for component in {raw_root,raw_target,*raw_root.parents,*raw_target.parents}:
|
||||
if getattr(component.lstat(),'st_file_attributes',0)&0x400 or component.is_symlink():raise InstallError('installation-reparse-point')
|
||||
root=raw_root.resolve(strict=True);target=raw_target.resolve(strict=True)
|
||||
except OSError:raise InstallError('installation-path-invalid') from None
|
||||
if not root.is_dir() or not target.is_relative_to(root) or target==root or not target.is_file() or target.suffix.lower()!='.exe':raise InstallError('installation-path-invalid')
|
||||
if not isinstance(expected_sha256,str) or len(expected_sha256)!=64 or any(c not in '0123456789abcdef' for c in expected_sha256):raise InstallError('manifest-hash-invalid')
|
||||
digest=hashlib.sha256()
|
||||
with target.open('rb') as stream:
|
||||
for chunk in iter(lambda:stream.read(1024*1024),b''):digest.update(chunk)
|
||||
if digest.hexdigest()!=expected_sha256:raise InstallError('installation-hash-mismatch')
|
||||
backend=self.backend or NativeBackend();identity=backend.app_id(target);_validate(plan,identity,port)
|
||||
return backend
|
||||
def _existing(self,backend,plan):
|
||||
provider=backend.lookup('provider',PROVIDER);sublayer=backend.lookup('sublayer',SUBLAYER)
|
||||
filters=[backend.lookup('filter',_key(rule['id'])) for rule in plan['filters']]
|
||||
if provider is None and sublayer is None and all(item is None for item in filters):return False
|
||||
if not _owned_metadata(provider,'provider') or not _owned_metadata(sublayer,'sublayer') or any(item is None for item in filters):raise InstallError('existing-boundary-collision')
|
||||
result=verify_observed(plan,filters,PROVIDER,SUBLAYER)
|
||||
if not result['verified'] or not backend.verify(plan)['verified']:raise InstallError('existing-boundary-mismatch')
|
||||
return True
|
||||
def apply_managed(self,installation_root,executable,expected_sha256,broker_executable,broker_sha256,port,plan):
|
||||
plan=copy.deepcopy(plan)
|
||||
if plan.get('version')!=3:raise InstallError('managed-plan-required')
|
||||
# Independently bind the receiver app ID to an exact verified installed
|
||||
# broker executable, using the same containment/reparse/hash checks.
|
||||
broker_plan=copy.deepcopy(plan);broker_plan['version']=2;broker_plan['filters']=broker_plan['filters'][:6]
|
||||
broker_plan['applicationId']=plan.get('brokerApplicationId')
|
||||
for rule in broker_plan['filters']:rule['conditions'][0]['value']=broker_plan['applicationId']
|
||||
self._prepare(installation_root,broker_executable,broker_sha256,port,broker_plan)
|
||||
return self.apply(installation_root,executable,expected_sha256,port,plan,_managed=_MANAGED_TOKEN)
|
||||
def apply(self,installation_root,executable,expected_sha256,port,plan,_managed=None):
|
||||
plan=copy.deepcopy(plan)
|
||||
if plan.get('version')==3 and _managed is not _MANAGED_TOKEN:raise InstallError('receiver-install-requires-verified-broker')
|
||||
backend=self._prepare(installation_root,executable,expected_sha256,port,plan);active=False;backend.open()
|
||||
try:
|
||||
if not backend.prepare_topology():raise InstallError('topology-admission-unavailable')
|
||||
backend.begin();active=True
|
||||
existing=self._existing(backend,plan)
|
||||
if not existing:
|
||||
backend.add_provider();backend.add_sublayer()
|
||||
for rule in plan['filters']:backend.add_filter(rule)
|
||||
# Read back transaction-owned objects before committing.
|
||||
if not _owned_metadata(backend.lookup('provider',PROVIDER),'provider') or not _owned_metadata(backend.lookup('sublayer',SUBLAYER),'sublayer'):raise InstallError('added-metadata-mismatch')
|
||||
observed=[backend.lookup('filter',_key(rule['id'])) for rule in plan['filters']]
|
||||
if any(item is None for item in observed) or not verify_observed(plan,observed,PROVIDER,SUBLAYER)['verified']:raise InstallError('added-filter-mismatch')
|
||||
backend.commit();active=False
|
||||
result=backend.verify(plan)
|
||||
if not result['verified']:raise InstallError('postcommit-verification-failed')
|
||||
return {'installed':True,'existing':existing,'nativeVerified':True,'receiverTestsVerified':False}
|
||||
finally:
|
||||
try:
|
||||
if active:backend.abort()
|
||||
finally:backend.close()
|
||||
def rollback(self,installation_root,executable,expected_sha256,port,plan):
|
||||
plan=copy.deepcopy(plan)
|
||||
backend=self._prepare(installation_root,executable,expected_sha256,port,plan);active=False;backend.open()
|
||||
try:
|
||||
backend.begin();active=True
|
||||
if not self._existing(backend,plan):raise InstallError('owned-boundary-not-present')
|
||||
for rule in plan['filters']:backend.delete('filter',_key(rule['id']))
|
||||
backend.delete('sublayer',SUBLAYER);backend.delete('provider',PROVIDER)
|
||||
backend.commit();active=False
|
||||
return {'removedOwnedBoundary':True}
|
||||
finally:
|
||||
try:
|
||||
if active:backend.abort()
|
||||
finally:backend.close()
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Read-only native Windows WFP observations; never adds/deletes filters."""
|
||||
import ctypes as C
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
U32=C.c_uint32
|
||||
class GUID(C.Structure):
|
||||
_fields_=[('data1',U32),('data2',C.c_uint16),('data3',C.c_uint16),('data4',C.c_ubyte*8)]
|
||||
def text(self): return str(uuid.UUID(bytes_le=bytes(self)))
|
||||
class Blob(C.Structure):
|
||||
_fields_=[('size',U32),('data',C.POINTER(C.c_ubyte))]
|
||||
class Data(C.Union):
|
||||
_fields_=[('u8',C.c_uint8),('u16',C.c_uint16),('u32',U32),('pointer',C.c_void_p)]
|
||||
class Value(C.Structure):
|
||||
_anonymous_=['data']
|
||||
_fields_=[('type',U32),('data',Data)]
|
||||
class Condition(C.Structure):
|
||||
_fields_=[('field',GUID),('match',U32),('value',Value)]
|
||||
class Display(C.Structure):
|
||||
_fields_=[('name',C.c_wchar_p),('description',C.c_wchar_p)]
|
||||
class Action(C.Structure):
|
||||
_fields_=[('type',U32),('key',GUID)]
|
||||
class Context(C.Union):
|
||||
_fields_=[('raw',C.c_uint64),('provider',GUID)]
|
||||
class Filter(C.Structure):
|
||||
_fields_=[('key',GUID),('display',Display),('flags',U32),('provider',C.POINTER(GUID)),('provider_data',Blob),('layer',GUID),('sublayer',GUID),('weight',Value),('count',U32),('conditions',C.POINTER(Condition)),('action',Action),('context',Context),('reserved',C.POINTER(GUID)),('id',C.c_uint64),('effective_weight',Value)]
|
||||
|
||||
TYPES={1:'FWP_UINT8',2:'FWP_UINT16',3:'FWP_UINT32',12:'FWP_BYTE_BLOB_TYPE',14:'FWP_SECURITY_DESCRIPTOR_TYPE'}
|
||||
MATCHES={0:'equal',10:'not-equal'}
|
||||
def _blob(blob):
|
||||
if not blob.data or not 0<blob.size<=65536:raise ValueError('Invalid bounded blob')
|
||||
return C.string_at(blob.data,blob.size)
|
||||
def _decode(native):
|
||||
if native.count>16 or (native.count and not native.conditions):raise ValueError('Invalid bounded conditions')
|
||||
conditions=[]
|
||||
for index in range(native.count):
|
||||
condition=native.conditions[index];value=condition.value
|
||||
if value.type==1: parsed=value.u8
|
||||
elif value.type==2: parsed=value.u16
|
||||
elif value.type==3: parsed=value.u32
|
||||
elif value.type in (12,14):
|
||||
if not value.pointer:raise ValueError('Missing blob')
|
||||
parsed=_blob(C.cast(value.pointer,C.POINTER(Blob)).contents).hex()
|
||||
else:raise ValueError('Unsupported condition type')
|
||||
conditions.append({'field':condition.field.text(),'match':MATCHES.get(condition.match,'unsupported'),'type':TYPES[value.type],'value':parsed})
|
||||
weight=C.cast(native.weight.pointer,C.POINTER(C.c_uint64)).contents.value if native.weight.type==4 and native.weight.pointer else None
|
||||
return {'key':native.key.text(),'provider':native.provider.contents.text() if native.provider else None,'sublayer':native.sublayer.text(),'layer':native.layer.text(),'flags':native.flags,'action':native.action.type,'conditions':conditions,'weight':weight}
|
||||
|
||||
def verify_observed(plan,observed,provider,sublayer):
|
||||
"""Compare complete native observations to trusted deployment metadata."""
|
||||
provider=str(uuid.UUID(provider));sublayer=str(uuid.UUID(sublayer))
|
||||
selected=[item for item in observed if item.get('provider')==provider or item.get('sublayer')==sublayer]
|
||||
expected=plan.get('filters',[])
|
||||
if len(expected) not in (6,8) or len(selected)!=len(expected):return {'verified':False,'reason':'filter-count-mismatch'}
|
||||
def signature(conditions):return sorted((c['field'],c['match'],c['type'],str(c['value'])) for c in conditions)
|
||||
pending=list(expected)
|
||||
for item in selected:
|
||||
if item['provider']!=provider or item['sublayer']!=sublayer or not item['flags']&1 or item['flags']&(8|32) or (len(expected)==6 and item['action']!=0x1001):return {'verified':False,'reason':'filter-metadata-mismatch'}
|
||||
def expected_conditions(rule):
|
||||
result=[]
|
||||
for condition in rule['conditions']:
|
||||
condition=dict(condition)
|
||||
if condition['type']=='FWP_SECURITY_DESCRIPTOR_TYPE':
|
||||
from windows_wfp_receiver import service_descriptor
|
||||
with service_descriptor(condition['value'][len('D:(A;;CC;;;'):-1]) as blob:condition['value']=_blob(blob).hex()
|
||||
result.append(condition)
|
||||
return result
|
||||
matches=[rule for rule in pending if item['action']=={'FWP_ACTION_BLOCK':0x1001,'FWP_ACTION_PERMIT':0x1002}.get(rule['action']) and rule['clearActionRight'] is False and rule['layer']==item['layer'] and item.get('weight')==rule.get('weight') and signature(expected_conditions(rule))==signature(item['conditions'])]
|
||||
if len(matches)!=1:return {'verified':False,'reason':'filter-condition-mismatch'}
|
||||
pending.remove(matches[0])
|
||||
return {'verified':not pending,'reason':'verified' if not pending else 'filter-missing'}
|
||||
|
||||
class ReadOnlyWfp:
|
||||
def __init__(self):
|
||||
if os.name!='nt':raise RuntimeError('Windows required')
|
||||
self.api=C.WinDLL('fwpuclnt',use_last_error=True)
|
||||
definitions={
|
||||
'FwpmEngineOpen0':([C.c_wchar_p,U32,C.c_void_p,C.c_void_p,C.POINTER(C.c_void_p)],U32),
|
||||
'FwpmEngineClose0':([C.c_void_p],U32),
|
||||
'FwpmGetAppIdFromFileName0':([C.c_wchar_p,C.POINTER(C.POINTER(Blob))],U32),
|
||||
'FwpmFreeMemory0':([C.POINTER(C.c_void_p)],None),
|
||||
'FwpmFilterCreateEnumHandle0':([C.c_void_p,C.c_void_p,C.POINTER(C.c_void_p)],U32),
|
||||
'FwpmFilterDestroyEnumHandle0':([C.c_void_p,C.c_void_p],U32),
|
||||
'FwpmFilterEnum0':([C.c_void_p,C.c_void_p,U32,C.POINTER(C.POINTER(C.POINTER(Filter))),C.POINTER(U32)],U32)}
|
||||
for name,(arguments,result) in definitions.items():
|
||||
method=getattr(self.api,name);method.argtypes=arguments;method.restype=result
|
||||
def app_id(self,executable):
|
||||
target=Path(executable).resolve(strict=True)
|
||||
if not target.is_file() or target.suffix.lower()!='.exe':raise ValueError('Executable required')
|
||||
pointer=C.POINTER(Blob)();code=self.api.FwpmGetAppIdFromFileName0(str(target),C.byref(pointer))
|
||||
if code:return {'ok':False,'reason':'app-id-unavailable','code':int(code)}
|
||||
try:
|
||||
data=_blob(pointer.contents)
|
||||
return {'ok':True,'blobHex':data.hex()}
|
||||
finally:
|
||||
memory=C.cast(pointer,C.c_void_p);self.api.FwpmFreeMemory0(C.byref(memory))
|
||||
def inspect(self,plan,provider,sublayer,max_entries=4096):
|
||||
provider=str(uuid.UUID(provider));sublayer=str(uuid.UUID(sublayer))
|
||||
if not isinstance(max_entries,int) or not 6<=max_entries<=4096:raise ValueError('Invalid enumeration bound')
|
||||
engine=C.c_void_p();code=self.api.FwpmEngineOpen0(None,10,None,None,C.byref(engine))
|
||||
if code:return {'verified':False,'reason':'engine-unavailable','code':int(code)}
|
||||
enumeration=C.c_void_p()
|
||||
try:
|
||||
code=self.api.FwpmFilterCreateEnumHandle0(engine,None,C.byref(enumeration))
|
||||
if code:return {'verified':False,'reason':'enumeration-unavailable','code':int(code)}
|
||||
selected=[];total=0
|
||||
while True:
|
||||
entries=C.POINTER(C.POINTER(Filter))();count=U32()
|
||||
code=self.api.FwpmFilterEnum0(engine,enumeration,128,C.byref(entries),C.byref(count))
|
||||
if code:return {'verified':False,'reason':'enumeration-failed','code':int(code)}
|
||||
try:
|
||||
if count.value>128 or total+count.value>max_entries:return {'verified':False,'reason':'enumeration-limit'}
|
||||
total+=count.value
|
||||
for index in range(count.value):
|
||||
native=entries[index].contents
|
||||
if (native.provider and native.provider.contents.text()==provider) or native.sublayer.text()==sublayer:selected.append(_decode(native))
|
||||
if not count.value:break
|
||||
finally:
|
||||
if entries:
|
||||
memory=C.cast(entries,C.c_void_p);self.api.FwpmFreeMemory0(C.byref(memory))
|
||||
return verify_observed(plan,selected,provider,sublayer)
|
||||
except (ValueError,KeyError,TypeError):return {'verified':False,'reason':'native-contract-invalid'}
|
||||
finally:
|
||||
if enumeration:self.api.FwpmFilterDestroyEnumHandle0(engine,enumeration)
|
||||
self.api.FwpmEngineClose0(engine)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Exact service-SID receiver filter marshalling; installer integration pending.
|
||||
|
||||
No CLI or activation. Only FWP_ACTION_PERMIT without CLEAR_ACTION_RIGHT is used
|
||||
for the exact service identity, followed by a lower-weight tuple BLOCK.
|
||||
"""
|
||||
import ctypes as C
|
||||
import re
|
||||
from contextlib import contextmanager
|
||||
from windows_wfp_install import InstallError
|
||||
from windows_wfp_probe import Blob
|
||||
|
||||
def validate_receiver_rule(rule):
|
||||
expected = [('3971ef2b-623e-4f9a-8cb1-6e79b806b9a7','FWP_UINT8',6),
|
||||
('d9ee00de-c1ef-4617-bfe3-ffd8f5a08957','FWP_UINT32',0x7f000001)]
|
||||
conditions = rule.get('conditions', [])
|
||||
permit = rule.get('id') == 'broker-service-receiver'
|
||||
if rule.get('layer') != 'e1cd9fe7-f4b5-4273-96c0-592e487b8650' or rule.get('clearActionRight') is not False or len(conditions) != (5 if permit else 3):
|
||||
raise InstallError('unsafe-receiver-plan')
|
||||
for item, (field, type_, value) in zip(conditions, expected):
|
||||
if item != {'field':field,'match':'equal','type':type_,'value':value}:raise InstallError('unsafe-receiver-plan')
|
||||
port = conditions[2]
|
||||
if port.get('field') != '0c1ba1af-5765-453f-af22-a8f791ac775b' or port.get('match') != 'equal' or port.get('type') != 'FWP_UINT16' or not isinstance(port.get('value'),int) or not 1 <= port['value'] <= 65535:
|
||||
raise InstallError('unsafe-receiver-plan')
|
||||
if rule.get('action') != ('FWP_ACTION_PERMIT' if permit else 'FWP_ACTION_BLOCK') or rule.get('weight') != (2 if permit else 1):raise InstallError('unsafe-receiver-plan')
|
||||
if permit:
|
||||
app, sd = conditions[3:]
|
||||
if app.get('field') != 'd78e1e87-8644-4ea5-9437-d809ecefc971' or app.get('type') != 'FWP_BYTE_BLOB_TYPE' or app.get('match') != 'equal' or not isinstance(app.get('value'),str) or not re.fullmatch('[0-9a-f]{16,131072}',app['value']):raise InstallError('unsafe-receiver-plan')
|
||||
if sd.get('field') != 'af043a0a-b34d-4f86-979c-c90371af6e66' or sd.get('type') != 'FWP_SECURITY_DESCRIPTOR_TYPE' or sd.get('match') != 'equal':raise InstallError('unsafe-receiver-plan')
|
||||
|
||||
def service_sddl(sid):
|
||||
if not isinstance(sid, str) or not re.fullmatch(r'S-1-5-80-(?:\d+-){4}\d+', sid):
|
||||
raise InstallError('invalid-service-sid')
|
||||
if any(str(int(part)) != part or int(part) > 4294967295 for part in sid.split('-')[4:]):
|
||||
raise InstallError('invalid-service-sid')
|
||||
# FWP_ACTRL_MATCH_FILTER=1, represented by CC in SDDL. Not GENERIC_ALL.
|
||||
return 'D:(A;;CC;;;' + sid + ')'
|
||||
|
||||
@contextmanager
|
||||
def service_descriptor(sid):
|
||||
advapi = C.WinDLL('advapi32', use_last_error=True)
|
||||
kernel = C.WinDLL('kernel32', use_last_error=True)
|
||||
convert = advapi.ConvertStringSecurityDescriptorToSecurityDescriptorW
|
||||
convert.argtypes = [C.c_wchar_p, C.c_uint32, C.POINTER(C.c_void_p), C.POINTER(C.c_uint32)]
|
||||
convert.restype = C.c_int
|
||||
kernel.LocalFree.argtypes = [C.c_void_p]
|
||||
kernel.LocalFree.restype = C.c_void_p
|
||||
pointer, size = C.c_void_p(), C.c_uint32()
|
||||
if not convert(service_sddl(sid), 1, C.byref(pointer), C.byref(size)):
|
||||
raise InstallError('service-descriptor-invalid', C.get_last_error())
|
||||
try:
|
||||
if not 20 <= size.value <= 4096:
|
||||
raise InstallError('service-descriptor-size')
|
||||
yield Blob(size.value, C.cast(pointer, C.POINTER(C.c_ubyte)))
|
||||
finally:
|
||||
kernel.LocalFree(pointer)
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Read-only BFE topology/notifications. No installer or packet-enforcement claim.
|
||||
|
||||
Notifications exclude inaccessible objects and are asynchronous. Successful
|
||||
subscription does NOT establish atomic protection against privileged changes.
|
||||
"""
|
||||
import ctypes as C
|
||||
import threading
|
||||
from windows_wfp_probe import GUID, Display, Blob, Filter, U32, ReadOnlyWfp
|
||||
|
||||
class Sublayer(C.Structure):
|
||||
_fields_ = [('key', GUID), ('display', Display), ('flags', U32),
|
||||
('provider', C.POINTER(GUID)), ('data', Blob), ('weight', C.c_uint16)]
|
||||
|
||||
class Subscription(C.Structure):
|
||||
_fields_ = [('template', C.c_void_p), ('flags', U32), ('session', GUID)]
|
||||
|
||||
CALLBACK = getattr(C, 'WINFUNCTYPE', C.CFUNCTYPE)(None, C.c_void_p, C.c_void_p)
|
||||
|
||||
def bfe_status():
|
||||
"""SCM_QUERY_STATUS only; never start/stop/change service configuration."""
|
||||
api = C.WinDLL('advapi32', use_last_error=True)
|
||||
api.OpenSCManagerW.argtypes = [C.c_wchar_p, C.c_wchar_p, U32]
|
||||
api.OpenSCManagerW.restype = C.c_void_p
|
||||
api.OpenServiceW.argtypes = [C.c_void_p, C.c_wchar_p, U32]
|
||||
api.OpenServiceW.restype = C.c_void_p
|
||||
api.CloseServiceHandle.argtypes = [C.c_void_p]
|
||||
api.QueryServiceStatusEx.argtypes = [C.c_void_p, U32, C.c_void_p, U32, C.POINTER(U32)]
|
||||
manager = api.OpenSCManagerW(None, None, 1)
|
||||
if not manager:return {'running':False,'code':C.get_last_error()}
|
||||
service = None
|
||||
try:
|
||||
service = api.OpenServiceW(manager, 'BFE', 4)
|
||||
if not service:return {'running':False,'code':C.get_last_error()}
|
||||
status = (U32 * 9)()
|
||||
needed = U32()
|
||||
if not api.QueryServiceStatusEx(service, 0, status, C.sizeof(status), C.byref(needed)):
|
||||
return {'running':False,'code':C.get_last_error()}
|
||||
return {'running':status[1] == 4,'state':int(status[1])}
|
||||
finally:
|
||||
if service:api.CloseServiceHandle(service)
|
||||
api.CloseServiceHandle(manager)
|
||||
|
||||
class TopologyObserver:
|
||||
def __init__(self):
|
||||
self.reader = ReadOnlyWfp()
|
||||
self.api = self.reader.api
|
||||
self.engine = C.c_void_p()
|
||||
self.handles = []
|
||||
self.changed = threading.Event()
|
||||
self.callback = CALLBACK(lambda _context, _change: self.changed.set())
|
||||
for kind in ('Filter', 'SubLayer'):
|
||||
subscribe = getattr(self.api, 'Fwpm' + kind + 'SubscribeChanges0')
|
||||
subscribe.argtypes = [C.c_void_p, C.POINTER(Subscription), CALLBACK,
|
||||
C.c_void_p, C.POINTER(C.c_void_p)]
|
||||
subscribe.restype = U32
|
||||
unsubscribe = getattr(self.api, 'Fwpm' + kind + 'UnsubscribeChanges0')
|
||||
unsubscribe.argtypes = [C.c_void_p, C.c_void_p]
|
||||
unsubscribe.restype = U32
|
||||
self.api.FwpmSubLayerGetByKey0.argtypes = [C.c_void_p, C.POINTER(GUID), C.POINTER(C.POINTER(Sublayer))]
|
||||
self.api.FwpmSubLayerGetByKey0.restype = U32
|
||||
|
||||
def start(self):
|
||||
if self.engine.value:
|
||||
raise RuntimeError('Observer already started')
|
||||
code = self.api.FwpmEngineOpen0(None, 10, None, None, C.byref(self.engine))
|
||||
if code:
|
||||
self.changed.set()
|
||||
return {'subscribed': False, 'reason': 'engine-unavailable', 'code': int(code)}
|
||||
for kind in ('Filter', 'SubLayer'):
|
||||
handle = C.c_void_p()
|
||||
subscription = Subscription()
|
||||
# FWPM_SUBSCRIPTION_FLAG_NOTIFY_ON_ADD | NOTIFY_ON_DELETE.
|
||||
subscription.flags = 3
|
||||
code = getattr(self.api, 'Fwpm' + kind + 'SubscribeChanges0')(
|
||||
self.engine, C.byref(subscription), self.callback, None, C.byref(handle))
|
||||
if code:
|
||||
self.changed.set()
|
||||
self.close()
|
||||
return {'subscribed': False, 'reason': 'subscription-unavailable', 'code': int(code)}
|
||||
self.handles.append((kind, handle))
|
||||
return {'subscribed': True, 'allObjectsReadable': False,
|
||||
'zeroChangeWindowProven': False}
|
||||
|
||||
def snapshot(self, relevant_layers, limit=4096):
|
||||
if not self.engine.value or self.changed.is_set():
|
||||
return {'complete': False, 'reason': 'observer-unavailable'}
|
||||
if not isinstance(limit, int) or not 1 <= limit <= 65536:
|
||||
raise ValueError('Invalid snapshot bound')
|
||||
enum = C.c_void_p()
|
||||
layers = set(relevant_layers)
|
||||
filters, sublayers, total = [], {}, 0
|
||||
try:
|
||||
code = self.api.FwpmFilterCreateEnumHandle0(self.engine, None, C.byref(enum))
|
||||
if code:
|
||||
return {'complete': False, 'reason': 'enumeration-unavailable', 'code': int(code)}
|
||||
while True:
|
||||
entries = C.POINTER(C.POINTER(Filter))()
|
||||
count = U32()
|
||||
code = self.api.FwpmFilterEnum0(self.engine, enum, 128, C.byref(entries), C.byref(count))
|
||||
if code:
|
||||
return {'complete': False, 'reason': 'enumeration-failed', 'code': int(code)}
|
||||
try:
|
||||
total += count.value
|
||||
if count.value > 128 or total > limit:
|
||||
return {'complete': False, 'reason': 'enumeration-limit'}
|
||||
for index in range(count.value):
|
||||
value = entries[index].contents
|
||||
if value.layer.text() not in layers:
|
||||
continue
|
||||
key = value.sublayer.text()
|
||||
if key not in sublayers:
|
||||
pointer = C.POINTER(Sublayer)()
|
||||
code = self.api.FwpmSubLayerGetByKey0(self.engine, C.byref(value.sublayer), C.byref(pointer))
|
||||
if code:
|
||||
return {'complete': False, 'reason': 'sublayer-unavailable', 'code': int(code)}
|
||||
try:
|
||||
sublayers[key] = {'key': key, 'weight': int(pointer.contents.weight)}
|
||||
finally:
|
||||
memory = C.cast(pointer, C.c_void_p)
|
||||
self.api.FwpmFreeMemory0(C.byref(memory))
|
||||
filters.append({'key':value.key.text(),'provider':value.provider.contents.text() if value.provider else None,
|
||||
'layer': value.layer.text(), 'sublayer': key,
|
||||
'action': int(value.action.type), 'flags': int(value.flags)})
|
||||
if not count.value:
|
||||
break
|
||||
finally:
|
||||
if entries:
|
||||
memory = C.cast(entries, C.c_void_p)
|
||||
self.api.FwpmFreeMemory0(C.byref(memory))
|
||||
return {'complete': not self.changed.is_set(), 'allObjectsReadable': False,
|
||||
'filters': filters, 'sublayers': list(sublayers.values()),
|
||||
'zeroChangeWindowProven': False}
|
||||
finally:
|
||||
if enum:
|
||||
self.api.FwpmFilterDestroyEnumHandle0(self.engine, enum)
|
||||
|
||||
def revalidate(self, relevant_layers):
|
||||
# Notifications are unreliable. Every release gate must additionally
|
||||
# revalidate; a service caller can periodically invoke this method.
|
||||
# No result permits an automatic recovery after previous invalidation.
|
||||
if self.changed.is_set():return {'complete':False,'reason':'observer-invalidated'}
|
||||
if not bfe_status().get('running'):
|
||||
self.changed.set()
|
||||
return {'complete':False,'reason':'bfe-unavailable'}
|
||||
result = self.snapshot(relevant_layers)
|
||||
if not result.get('complete'):self.changed.set()
|
||||
return result
|
||||
|
||||
def authorized_snapshot(self, trusted_plan, provider, sublayer, relevant_layers):
|
||||
"""Exact protected-plan readback, never a caller-selected visibility flag.
|
||||
|
||||
Integration must derive trusted_plan from protected signed installation
|
||||
and deployment material. No public CLI or untrusted JSON plan loader.
|
||||
"""
|
||||
result = self.revalidate(relevant_layers)
|
||||
if not result.get('complete'):return result
|
||||
verified = self.reader.inspect(trusted_plan, provider, sublayer)
|
||||
if not verified.get('verified') or self.changed.is_set():
|
||||
self.changed.set()
|
||||
return {'complete':False,'reason':'owned-readback-unavailable'}
|
||||
selected=[row for row in result['filters'] if row['sublayer']==sublayer or row['provider']==provider]
|
||||
if len(selected)!=8 or any(row['sublayer']!=sublayer or row['provider']!=provider for row in selected):
|
||||
self.changed.set()
|
||||
return {'complete':False,'reason':'owned-readback-count'}
|
||||
for row in selected:row['ownedVerified']=True
|
||||
result.update(bfeRunning=True,ownedSublayer=sublayer,enumerationComplete=True)
|
||||
# Remains false until a separate, authoritative protected deployment
|
||||
# prerequisite proves visibility. EOF and token elevation cannot do so.
|
||||
return result
|
||||
|
||||
def close(self):
|
||||
self.changed.set()
|
||||
for kind, handle in reversed(self.handles):
|
||||
getattr(self.api, 'Fwpm' + kind + 'UnsubscribeChanges0')(self.engine, handle)
|
||||
self.handles.clear()
|
||||
if self.engine.value:
|
||||
self.api.FwpmEngineClose0(self.engine)
|
||||
self.engine = C.c_void_p()
|
||||
@@ -0,0 +1,30 @@
|
||||
'use strict';
|
||||
// A deadline race cannot stop an underlying worker. Keep its admission slot
|
||||
// until its real promise settles; unresponsive work consumes capacity and
|
||||
// subsequent work fails closed. Worker termination requires separate proof.
|
||||
class WorkAdmissionError extends Error{
|
||||
constructor(code){super(code);this.name='WorkAdmissionError';this.code=code;}
|
||||
}
|
||||
function createWorkAdmission({maxJobs=8}={}){
|
||||
if(!Number.isSafeInteger(maxJobs)||maxJobs<1||maxJobs>64)throw new WorkAdmissionError('work_configuration_invalid');
|
||||
let active=0,closed=false;
|
||||
return Object.freeze({
|
||||
run(fn,{payload}={}){
|
||||
if(typeof fn!=='function'||(payload!==undefined&&!Buffer.isBuffer(payload)))throw new WorkAdmissionError('work_configuration_invalid');
|
||||
if(closed){payload?.fill(0);throw new WorkAdmissionError('work_admission_closed');}
|
||||
if(active>=maxJobs){payload?.fill(0);throw new WorkAdmissionError('work_capacity_exhausted');}
|
||||
active++;
|
||||
let settled=false;
|
||||
const release=()=>{if(settled)return;settled=true;try{payload?.fill(0);}finally{active--;}};
|
||||
try{
|
||||
// The private payload is transferred to this admission job. Caller
|
||||
// must not reuse it; scanner may use it until underlying settlement.
|
||||
const result=fn();
|
||||
return Promise.resolve(result).then(value=>{release();return value;},error=>{release();throw error;});
|
||||
}catch(error){release();throw error;}
|
||||
},
|
||||
status:()=>Object.freeze({active,maxJobs,closed,available:closed?0:maxJobs-active}),
|
||||
close(){closed=true;}
|
||||
});
|
||||
}
|
||||
module.exports={createWorkAdmission,WorkAdmissionError};
|
||||
@@ -17,6 +17,12 @@ events. The standard `outcome` field reflects unknown authorization or failure;
|
||||
Microsoft documents its mapping to `EventOutcome` in
|
||||
[CommonSecurityLog](https://learn.microsoft.com/en-us/azure/sentinel/cef-name-mapping).
|
||||
|
||||
Typed download records use `download.scan` / `download.block` with the original
|
||||
request authorization UUID and real file size, magic-derived type and SHA-256.
|
||||
They never attach a generic request-body hash. A blocked download has a failure
|
||||
outcome and a denied event type. A successful scan reports inspection success;
|
||||
it does not prove that the user received or saved the file.
|
||||
|
||||
These format and exporter tests do not establish a customer's remote SIEM
|
||||
deployment, retention, key custody or incident-response configuration. Production
|
||||
browser-to-broker integration remains a separate acceptance gate.
|
||||
@@ -0,0 +1,443 @@
|
||||
# Windows DLP: aktuelle Abnahmegrenzen
|
||||
|
||||
Stand: 14. Oktober 2026. Diese Übersicht unterscheidet produktive Integration
|
||||
von isolierten Baustein- und Empfängertests. Das MVP ist nicht abgenommen.
|
||||
|
||||
| Bereich | Autoritativer aktueller Nachweis | Noch erforderlich |
|
||||
| --- | --- | --- |
|
||||
| Unveränderte Runtime | Offizielles Electron; eigener Chromium-Build abgebrochen | Ersatz der abstürzenden WebRTC-Header-Sperre erst nach vollständigem Netzwerk-Nachweis |
|
||||
| Broker-Transport | Gepackter Production-Child mit privater IPC, authentifiziertem CONNECT, TLS-Ausstellung und scoped Pin-Helper; öffentliche Originalbytes übertragen, Geheimnisse vor Zielverbindung gesperrt | Vollständige Anbindung an den Hauptbrowser und geschützten Windows-Dienst |
|
||||
| Signierte Regeln | Bestehende Core-Signaturprüfung, Device-Assignment, Ablauf, Widerruf, Generation und Rollback geprüft | Geschützte dauerhafte Mindestversion und vollständiger produktiver Bootstrap |
|
||||
| Transferprüfung | Header, URLs und Multipart-Verträge; tatsächlicher Production-Child blockiert geheime PDF-Teile in JSON und Prozent-Data-URLs mit lokalem Parser | Hauptbrowser-Abnahme einschließlich aller Clipboard-/Editor-Pfade; echte Unternehmens-Scanner-Endpunkte |
|
||||
| Downloads | Production-Child prüft Inline-PNG/PDF samt tatsächlichem Datei-Hash, sperrt Attachment bei deaktiviertem Speichern und geheimes PDF ohne PDF-Bytes; kausale Audit-Ereignisse persistiert | Hauptbrowser-SaveAs-, Blob- und Cache-Pfade |
|
||||
| PDF | Gepaarter nativer AppContainer-/PDFium-/OCR-Test besteht: Geheimnis erkannt, null Zielverbindungen; öffentliches statisches PDF exakt übertragen | Erweiterung und ausdrücklich dokumentierte Grenzen für weitere PDF-Funktionen; finale Browser-/Installer-Abnahme |
|
||||
| Office | Nativer AppContainer-/Broker-Test besteht für reale DOCX/XLSX: öffentliche Originalbytes übertragen; Geheimnisse in Metadaten/Hidden Sheet erkannt und ohne Zielverbindung blockiert | Weitere Office-Funktionen; komplexes PowerPoint und unbekannte Inhalte bleiben unvollständig und gesperrt |
|
||||
| Windows-Netzwerkgrenze | Resolver- und STUN-Teilnachweise; WFP-Plan und Installer-Fehlertests. Aktueller nicht erhöhter Token kann WFP öffnen, Filterenumeration scheitert mit Access Denied (5) | Geschützter SCM-Host und Dienst-SID, Receiver-/Portübernahmeschutz, erhöhte Installation und Filter-Readback; IPv4/IPv6/UDP/TCP/Loopback/DNS-Umgehungstests |
|
||||
| Broker-Runtime | Neunter geprüfter Payload: offizielles Node 24.21.0, 313 Dateien / 64 Quellnachweise, sechs tatsächliche Isolationsprüfungen einschließlich Management- und WFP-Module | Neuere Credential-/Policy-Änderungen erneut paketieren und prüfen; produktiven nativen Host und signierte Installation ergänzen |
|
||||
| Auslieferung | Standalone-Payload außerhalb des Repositorys getestet; historischer Node-24.20-Nachweis bleibt erhalten | Gemeinsamer finaler Installer mit Broker, Scanner und Modellen; Fresh-System-Test |
|
||||
| Unternehmens-Proxy | Achter gepackter Production-Child besteht HTTPS-Proxy und aktive IP-Policy mit sechs numerisch gebundenen CONNECT-Routen, ursprünglicher TLS-Hostname-Prüfung und gesperrten kodierten Geheimnissen | Hauptbrowser-/Dienst-Anbindung; PAC/system/SOCKS/NTLM/Kerberos noch nicht unterstützt |
|
||||
| Diagnose und Audit | Bestehende Browser-Diagnostik sowie tatsächliche isolierte Broker-OTLP-Tests | Broker-Diagnostik und dauerhafte Sicherheitsereignisse im produktiven Prozesspfad; zentrale Abschaltung beibehalten |
|
||||
|
||||
Die nächste Abnahme folgt dieser Reihenfolge: frisches gepacktes Proxy-/Download-
|
||||
Verhalten, geschützter Dienst samt authentifizierter Benutzeranbindung, vollständiger
|
||||
Hauptbrowserpfad, gemeinsamer Installer und native Netzwerk-/Clipboard-Umgehungstests.
|
||||
Bausteintests erlauben kein Entfernen der bestehenden WebRTC-Sperre. Management,
|
||||
Inspector und autorisierte OTLP-Exporte müssen vor einer Browser-EXE-Netzwerksperre
|
||||
über ausdrücklich erlaubte Dienstoperationen laufen; eine breite Ausnahme wäre
|
||||
kein Ersatz für diesen Pfad.
|
||||
|
||||
Konkrete Transport-Migration vor Aktivierung:
|
||||
|
||||
- Enrollment und Management benötigen einen im signierten Deployment gebundenen
|
||||
HTTPS-Origin und den bestehenden ML-DSA-87-Kanal-Pin. Das bisherige Deployment
|
||||
v1 enthält diese Bindung nicht; ein vom Renderer eingegebener Server darf keine
|
||||
allgemeine Dienst-Fetch-Berechtigung erzeugen. Versionierter Vertrag in Arbeit.
|
||||
- Device-Identität, Challenge, verschlüsselte Policy/Evidence, Heartbeat, Ereignisse,
|
||||
Bookmarks und Library brauchen begrenzte typisierte Operationen statt beliebiger
|
||||
URL-/Header-/Token-Weiterleitung. Pre-Enrollment bleibt gesondert nachzuweisen.
|
||||
- Browserseitige Clipboard-/AI-/manuelle Scans verwenden noch authentifiziertes
|
||||
Loopback-HTTP zum Inspector. Sie müssen über den Dienst laufen; bereits
|
||||
brokerseitig erfolgreiche Upload-Scans beweisen diese Browser-Pfade nicht.
|
||||
- Große Dokumente brauchen begrenzte Chunk-Transfers oder geschützte Handles;
|
||||
der 2-MiB-Bootstrap und spätere 64-KiB-Frames sind keine 16-MiB-Datei-RPC.
|
||||
- Autorisierte OTLP-Exporte brauchen ihren eigenen begrenzten Dienstpfad.
|
||||
Agentensteuerung muss vom Browser-Loopback-Listener auf authentifizierte lokale
|
||||
Pipes wechseln und vorhandene PQ-, Grant- und Replay-Prüfungen erhalten.
|
||||
|
||||
Der Dienstadapter ist unabhängig auf seine Paketauflösung geprüft: tatsächliches
|
||||
electron-builder File-Matching, ASAR-Erstellung/Extraktion und isolierter Modul-
|
||||
Load bestehen. Ausschließlich `apps/network-broker/framed-channel.cjs` wird als
|
||||
zusätzliche Browser-Abhängigkeit eingebettet, kein allgemeines Broker-Wildcard.
|
||||
Dies beweist keine fertig installierte Dienstverbindung.
|
||||
|
||||
Aktuelle Broker-Regression (ohne den separat zu prüfenden Payload-Staging-Test):
|
||||
188 Fälle, davon 181 bestanden und sieben ausdrücklich aktivierbare native
|
||||
Prüfungen übersprungen; keine Fehler. Die übersprungenen Tests werden nicht als
|
||||
native Abnahme gezählt. Die vier gezielten Diagnose-/Factory-/Download-/Proxy-
|
||||
Dateien bestanden zuvor mit 28 Fällen einschließlich tatsächlichem privatem
|
||||
OTLP-Empfänger. Der vorhandene geschützte Collector meldet null Commitfehler und
|
||||
null abgewiesene Schreibzugriffe; der bestehende Trace
|
||||
`e5ebf887a1ad8d90f065739a86cd2261` wurde mit 55 Spans abgerufen. Das beweist keine
|
||||
neuen produktiven Broker-Exporte. Lokale Evidenz:
|
||||
`artifacts/core-broker-development-check-2026-10-14.json`.
|
||||
|
||||
Der erste native Lauf des fünften Payloads mit tatsächlichem HTTPS-Unternehmens-
|
||||
Proxy ist fehlgeschlagen: öffentlicher Roh-Upload wurde übertragen, decodierte
|
||||
Dokumentprüfungen erreichten jedoch das feste fünfsekündige Request-Limit statt
|
||||
eines vollständigen Scannerurteils. Der Inspector erlaubt bis zu 29 Sekunden
|
||||
begrenzte Arbeit. Diese Zeitbudget-Inkompatibilität und die Unterdrückung später
|
||||
Urteile nach einem bereits protokollierten Timeout sind vor einer erneuten
|
||||
Proxy-Abnahme zu korrigieren. Ein funktionierender Roh-Upload beweist hier keine
|
||||
Dokumentabdeckung. Der Testprozess endete; ursprüngliche Fehlerevidenz bleibt.
|
||||
|
||||
Nach Korrektur auf ein absolutes 30-Sekunden-Gesamtbudget besteht der sechste
|
||||
gepackte native Lauf (1/1, 26,5 Sekunden): geheime Raw-/JSON-/Data-URL-Inhalte
|
||||
werden mit tatsächlicher Scanner-Regel vor zusätzlicher Zielverbindung gesperrt.
|
||||
Öffentliche äußere JSON-Bytes bleiben unverändert. PNG-Inline-Inhalt funktioniert
|
||||
trotz allowDownloads=false, Attachment-Speicherung wird gesperrt. Öffentliches
|
||||
Inline-PDF funktioniert, geheimes PDF gibt keine PDF-Bytes frei. 13 persistierte
|
||||
Ereignisse enthalten geprüfte echte Dateigrößen und SHA-256 sowie kausale UUIDs.
|
||||
Privater Testempfänger: 22 echte OTLP-Spans, 33.635 Bytes, null ungültige Exporte,
|
||||
null aktuelle/lebenslange Exportfehler; dauerhafte Trace-ID
|
||||
`2943810ad3499cd0d331a45206b2780d` wurde tatsächlich abgerufen, Audit-Span und
|
||||
Request-Elternspan verknüpft. Supervisor bestätigt geschlossen, ohne Quarantäne.
|
||||
Nachweis: `artifacts/broker-production-native/proof-2026-10-14T10-31-22.318Z.json`.
|
||||
Die Spans stammen vom privilegierten Test-Parent, nicht vom produktiven Child-
|
||||
Exporter. Hauptbrowser, Windows-Dienst und Installer bleiben separate Gates.
|
||||
|
||||
Der Transportbaustein für den nativen Dienst verwendet längenbegrenztes UTF-8-
|
||||
JSON (initial maximal 2 MiB, danach 64 KiB), höchstens acht ausstehende Writes
|
||||
und feste Fehlercodes; acht Tests einschließlich fragmentierter Frames,
|
||||
ungültigem UTF-8 und tatsächlichem Duplex-Transport des bestehenden Audit-RPC
|
||||
bestehen. Disconnect bestätigt ausstehende Writes nicht als erfolgreich;
|
||||
ein später Callback kann kein zweites Ergebnis liefern. Dieser Baustein
|
||||
authentifiziert keinen Peer und ist
|
||||
noch nicht aktiviert. Die native Dienst-/Client-Identitätsprüfung vor dem ersten
|
||||
Frame sowie die Produktionsanbindung bleiben separate Abnahmebedingungen.
|
||||
|
||||
Der eigene native Dienst-Host wurde mit MSVC 14.44 als Testvariante kompiliert
|
||||
(kein Chromium-Neubau). Ein tatsächlicher Windows-Selbsttest beweist die
|
||||
Ablehnung fehlender Dienst-SID, Job-Mitgliedschaft von Kind und Enkel, deren
|
||||
beendeten Zustand nach Job-Schließen und die Ablehnung eines falschen Hashes.
|
||||
`artifacts/native-service-host/proof.json` bindet diesen Nachweis an den damaligen
|
||||
Quell-/Binary-Hash und weist installed=false sowie wfpActivated=false aus.
|
||||
Die unabhängige Quellprüfung verlangt weitere Korrekturen vor Aktivierung:
|
||||
Pipe-DACL ohne FILE_CREATE_PIPE_INSTANCE für Benutzer, exakter geschützter
|
||||
Browserpfad, geschützter Deployment-Vertrauensanker, absolute Frame-Zeitgrenzen
|
||||
und abbrechbare Writes. Der Selbsttest beweist keine Dienstinstallation oder
|
||||
authentifizierte produktive Benutzerverbindung.
|
||||
|
||||
Nachfolgende native Testvariante: explizite Benutzer-Pipe-Rechte verweigern eine
|
||||
zweite Instanz auch ohne FIRST_PIPE_INSTANCE; unberechtigter Client und zu großer
|
||||
Frame werden tatsächlich abgewiesen. Job-Tests bleiben grün. Ein zusätzlicher
|
||||
anonymer Pipe-Test unterscheidet erfolgreich abgeschlossenen Write mit gesundem
|
||||
Idle von einem blockierten, abgebrochenen Write. Die Installation bleibt
|
||||
unbewiesen; das aktuell weiterentwickelte Quellfile muss jeweils erneut zum
|
||||
Hash des kompilierten Nachweises passen.
|
||||
|
||||
Ein noch nicht aktivierter Service-Bootstrap-Baustein verwirft einen vom Aufrufer
|
||||
gewählten Policy-Schlüssel, eine fremde Geräte-ID und eine Mindestversion unter
|
||||
der geschützten Zuordnung vor dem Factory-Aufruf. Sechs Tests bestehen, darunter
|
||||
eine echte Vendor-/Kundensignatur-Kette und die Ablehnung einer fremden in der
|
||||
Testfixture übergebenen SID. Das ist keine native Pipe-Identitätsprüfung. Sein
|
||||
privilegierter Trust-Loader muss zuvor Release-Signatur, delegierte Kundensignatur,
|
||||
geschützte dauerhafte Rollback-Grenzen und die tatsächlich vom nativen Pipe-Peer
|
||||
ermittelte SID prüfen; ein injizierter Test-Loader beweist diese Voraussetzungen
|
||||
nicht. Die Benutzer-SID darf ausschließlich aus der nativen Peer-Prüfung stammen,
|
||||
niemals aus dem START-JSON oder einer übernommenen Benutzer-Umgebungsvariable.
|
||||
Der Baustein sperrt zudem einen Start nach Deployment-Ablauf und schließt einen
|
||||
laufenden Broker bei Ablauf, auch wenn die Browser-Policy länger gültig wäre.
|
||||
Mehrere Close-Aufrufe warten auf denselben ursprünglichen Cleanup und erhalten
|
||||
dessen Fehler; ein bereits begonnener Cleanup gilt nicht als abgeschlossen.
|
||||
|
||||
Die neue Service-Acceptance-Prüfung verifiziert Release-, Kunden- und Policy-
|
||||
Signaturen zusammen, erzwingt die Gerätezuordnung und erhält gespeicherte
|
||||
Policy-Mindestversionen pro Nutzer-SID, Gerät und Policy-Schlüssel. Auch eine
|
||||
neuere Deployment-Freigabe oder eine Schlüsselrotation löscht alte Grenzen nicht.
|
||||
Der kanonische vollständige Zustand ist auf 2 MiB und 8.192 eindeutige Einträge
|
||||
begrenzt; 1.024 Bytes des Frame-Budgets bleiben für den festen Snapshot-/CAS-
|
||||
Umschlag reserviert. Der vollständige serialisierte Commit wird zusätzlich
|
||||
gegen das 2-MiB-Limit geprüft. Überfüllung wird gesperrt. Der Commit-Gate wartet auf die exakte
|
||||
native Persistenzbestätigung und akzeptiert weder unsichere Ergebnisse noch
|
||||
automatische Wiederholung nach einem ungewissen Commit.
|
||||
|
||||
Die unabhängige Prüfung reproduzierte außerdem einen Start-Binding-Fehler:
|
||||
eine andere gültig signierte Policy unter derselben Version konnte eine neue
|
||||
Factory erreichen, obwohl ein vorheriger Plan andere Inhalte bestätigt hatte.
|
||||
Der Start vergleicht jetzt zusätzlich den exakten Digest des bestätigten
|
||||
Policy-Payload-Feldes und die Version, bevor die Factory aufgerufen wird.
|
||||
Direkte Plan-/Commit-/Start-Komposition und gleichversionige Alternativ-Policy
|
||||
werden zusammen geprüft: acht Akzeptanz-/Commit-Tests und sechs Bootstrap-Tests
|
||||
bestanden. Ein weiterer Akzeptanztest beweist die Sperre von 8.000 einzeln gültigen
|
||||
Einträgen durch das gesamte serialisierte Byte-Budget (neun Akzeptanztests grün).
|
||||
Der Digest der Policy verwendet
|
||||
das Base64url-Payload-Feld als String; Release-/Deployment-Digests verwenden
|
||||
die decodierten kanonischen Bytes. Die native Implementierung muss diese
|
||||
unterschiedlichen Verträge einhalten.
|
||||
|
||||
Diese Regeln und Callback-Bestätigungen beweisen noch keinen Registry-Commit.
|
||||
Offen ist ein tatsächlich geschützter einzelner AcceptanceState-Wert mit
|
||||
Vergleich des vorherigen Zustands, vollständiger monotoner Transition und
|
||||
RegFlushKey vor Bestätigung. Fehlender Zustand darf nicht als Version null
|
||||
initialisiert werden. Installation und Freigabe dieses Stores bleiben offen.
|
||||
|
||||
Der native Store verwendet dafür feste Helper-Rollen `--trust-snapshot` und
|
||||
`--commit-acceptance`, einen einzelnen AcceptanceState-Registrywert und eine
|
||||
begrenzte Compare-and-swap-Anfrage. Der Quellpfad prüft Dienst-SID, tatsächlichen
|
||||
geschützten Node-Elternprozess, signierte Zuordnung, Flush und Rücklesen vor
|
||||
Bestätigung. Bisher wurde kein produktiver HKLM-Write ausgeführt; die
|
||||
uninstallierte Rollen-Ablehnung ersetzt keine positive Dienst-/Store-Prüfung.
|
||||
Die JavaScript-Anbindung wird separat auf genau eine gerahmte Antwort und
|
||||
bestätigtes Prozessende geprüft, ohne vom Renderer gewählte Pfade oder Schlüssel.
|
||||
|
||||
Offene native Ownership-Prüfung: Eine aktivierte Dienst-SID ist nicht automatisch
|
||||
als Eigentümer zulässig. Windows verlangt für einen Gruppen-Owner das
|
||||
SE_GROUP_OWNER-Attribut; siehe [Microsoft TOKEN_OWNER](https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntifs/ns-ntifs-_token_owner).
|
||||
Eine Alternative muss implizites WRITE_DAC des Kontoeigentümers ausdrücklich
|
||||
einschränken und tatsächlich testen; [Microsofts SID-Dokumentation](https://learn.microsoft.com/en-us/windows-server/identity/ad-ds/manage/understand-security-identifiers)
|
||||
beschreibt die OWNER-RIGHTS-SID. Ein Plan oder DACL-String allein ist kein
|
||||
Nachweis der tatsächlichen LocalService-/SCM-Berechtigungen.
|
||||
|
||||
Der native Test bestätigt inzwischen eine Alternative ohne SeRestore und ohne
|
||||
angenommene Service-SID-Owner-Eignung: Standardkontoeigentümer plus ausdrückliche
|
||||
OWNER-RIGHTS-ReadControl-Berechtigung. Im tatsächlichen Test wird WRITE_DAC mit
|
||||
Access Denied (5) verweigert, während Mutex-Synchronisation und Modify erlaubt
|
||||
sind. Das prüft die Owner-Regel, nicht einen installierten SCM-Commit.
|
||||
|
||||
Die JavaScript-Service-Anbindung besitzt einen eigenen festen Entry-Pfad und
|
||||
wartet auf die native Persistenzbestätigung vor Factory/Listen. 16 gezielte
|
||||
Transport-/Entry-/Akzeptanztests bestehen. Die unabhängige Quellprüfung fand noch
|
||||
Interoperabilitätskorrekturen vor einem nativen Lauf: Die vom Host ermittelte SID
|
||||
muss ausdrücklich an den Helper weitergegeben werden; der begrenzte rohe
|
||||
Release-Envelope muss den Base64-Overhead berücksichtigen; partielle Dateireads
|
||||
benötigen vollständige begrenzte Verarbeitung und Vorher-/Nachher-Prüfung.
|
||||
Die Fake-/Subprozess-Tests schließen diese tatsächlichen Dienst-Gates nicht.
|
||||
|
||||
Nach unabhängiger Korrektur bestehen 18 gezielte Service-Store-/Entry-/Akzeptanz-
|
||||
Tests. Ein tatsächlicher Subprozess erhält nur die validierte vom Host ermittelte
|
||||
SID und die beiden Windows-Systemvariablen. Ein echter signierter Release mit
|
||||
6.007 Dateien und mehr als 2 MiB rohem Envelope wurde über begrenzte partielle
|
||||
Reads vollständig gelesen und kryptografisch verifiziert. Dateiidentitätswechsel
|
||||
und ungültige Antworten werden gesperrt. Das ist weiterhin keine positive
|
||||
SCM-/HKLM-Abnahme.
|
||||
|
||||
Zusätzliche offene WFP-Abnahme: CONNECT-Filter allein reichen nicht aus.
|
||||
[Microsofts ALE-Dokumentation](https://learn.microsoft.com/en-us/windows/win32/fwp/ale-stateful-filtering)
|
||||
beschreibt, dass nach erster Freigabe eines eingehenden oder ausgehenden Flows
|
||||
beide Richtungen zustandsbehaftet erlaubt sind. Deshalb braucht die Browser-EXE
|
||||
auch RECV_ACCEPT-Sperren für neue eingehende IPv4-/IPv6-Flows und tatsächliche
|
||||
inbound-first-UDP-/TCP-Tests. Vor einer Aktivierung müssen bisherige lokale
|
||||
Agent-/Management-/Inspector-/OTLP-Listener bzw. Transporte über den Broker laufen.
|
||||
Der bisherige Vier-CONNECT-Filter-Plan und eine normale Unternehmens-Firewall
|
||||
beweisen diese Grenze nicht. Keine dieser neuen WFP-Regeln wurde aktiviert.
|
||||
|
||||
Der Plan und die Installer-/Probe-Quellen enthalten jetzt sechs Browser-Regeln:
|
||||
vier CONNECT- und zwei RECV_ACCEPT-Sperren. Sechs JavaScript-Planprüfungen und
|
||||
21 Python-Vertrags-/Fehlertests bestehen. Das sind keine aktivierten Windows-
|
||||
Filter oder physischen Egress-Tests. Zusätzlich muss die tatsächliche WFP-
|
||||
Arbitration geprüft werden: höher priorisierte Hard-PERMITS können gewöhnliche
|
||||
Sperren beeinflussen; siehe [Microsofts Filterarbitration](https://learn.microsoft.com/en-us/windows/win32/fwp/filter-arbitration).
|
||||
Die bisherige niedrige Sublayer-Priorität allein beweist keine wirksame Grenze.
|
||||
Eindeutige Priorität/Kollisionsregeln, native Topologieprüfung, relevante
|
||||
Änderungsüberwachung und tatsächliche TCP-/UDP-Tests bleiben vor Freigabe offen.
|
||||
|
||||
Die DNS-Migrationslücke ist im neuen Broker behoben: Alle aufgelösten Adressen
|
||||
werden gegen security.dns.blockedIPs geprüft; eine gesperrte Adresse verwirft
|
||||
die Route. Bei aktiver IP-Policy erhält der Unternehmens-Proxy ausschließlich
|
||||
die freigegebene numerische CONNECT-Adresse. SNI, Host und TLS-Zertifikatsprüfung
|
||||
bleiben an den ursprünglichen Namen gebunden. Der achte native Factory-Test
|
||||
beweist sechs tatsächliche numerische Tunnel und unveränderte DLP-Blockierungen.
|
||||
Der System-/VPN-Resolver bleibt der unterstützte Pfad. Konfiguriertes Secure DNS
|
||||
wird ausdrücklich als nicht unterstützt abgewiesen statt still heruntergestuft.
|
||||
Die Hauptbrowser-/Dienst-Migration und externe Kunden-Proxy-Abnahme bleiben offen.
|
||||
|
||||
Für diese Release-Verifikation wird standardisierte P-256/ECDSA mit Windows CNG
|
||||
und getrennt delegiertem Kundenschlüssel entwickelt. Das ist klassische
|
||||
Signaturkryptografie und kein Nachweis quantensicherer Artefakt-Signaturen.
|
||||
Produktionssignierung und die vollständige Windows-Verifikation bleiben offen.
|
||||
|
||||
Die unabhängige Prüfung des Release-Inventars fand einen Integrationsfehler:
|
||||
kanonisch signierte JSON-Objekte hatten eine andere Feldreihenfolge als das
|
||||
Dateiinventar und wurden deshalb fälschlich abgewiesen. Der Vergleich prüft jetzt
|
||||
explizit Pfad, Größe und SHA-256 bei unveränderter vollständiger Inventarabdeckung.
|
||||
Eine echte Signieren-/Verifizieren-/Dateisystem-Prüfung und anschließende
|
||||
gleichgroße Dateimanipulation prüfen beide Richtungen. Diese Testschlüssel liegen
|
||||
nur im Speicher; daraus entsteht kein produktiver Signaturnachweis.
|
||||
|
||||
## Reihenfolge der verbleibenden Core-Arbeit
|
||||
|
||||
Der erste echte Start des neuen gepackten Production-Childs war vor `READY`
|
||||
gesperrt. Der feste lokale Startcode `broker-startup-transport` identifizierte
|
||||
eine konkrete Typinkompatibilität: Die Factory lieferte ein CA-Array, während
|
||||
der geprüfte HTTPS-Forwarder ausschließlich PEM-String oder Buffer akzeptiert.
|
||||
Die Korrektur verbindet die freigegebenen öffentlichen PEM-Zertifikate zu einem
|
||||
String; TLS-Prüfung und Hostname-Prüfung bleiben aktiv. Der tatsächliche neu
|
||||
gestagte Prozess-/Transfernachweis bestand nach dieser Korrektur: tatsächlicher
|
||||
Production-Child mit offiziellem Node, enthaltenem Inspector und privater IPC,
|
||||
öffentlicher Upload exakt 16 Bytes / 201 / eine Zielverbindung, Geheimnis 403
|
||||
ohne zusätzliche Zielverbindung und tatsächliche erkannte Regel im fsync-Audit.
|
||||
Nachweis: `broker-production-native/proof-2026-10-14T09-10-08.981Z.json` unter
|
||||
`artifacts`; bestätigtes Prozessende. Dies ersetzt weder den Hauptbrowserpfad
|
||||
noch Download-, Unternehmens-Proxy- und WFP-Abnahmen.
|
||||
Ein später erfolgreicher direkter Inspector-Start ersetzt diesen Nachweis nicht.
|
||||
|
||||
Umgesetzt: bekannte Base64-verpackte binäre Dokumente in JSON/Data-URLs erhalten
|
||||
vor Freigabe Magic-Byte-, Fingerprint- und lokale Parserprüfung. Native Nachweise
|
||||
stehen unten. Download-Blockierungen besitzen typisierte Download-Fakten und
|
||||
registrierte Scanner-Regel-IDs. Dies ersetzt keine allgemeine Abdeckung beliebiger
|
||||
Kodierungen oder die noch offene Hauptbrowser-Anbindung.
|
||||
|
||||
Der neue begrenzte Carrier-Extractor ist implementiert und nach unabhängiger
|
||||
Prüfung um JSON-interne Prozentkodierung, verschachtelte Text-Envelopes und
|
||||
strikte JSON-Primitive erweitert. Acht Regressionstests und Lint bestehen.
|
||||
Die Integration erhält ein gemeinsames Byte-/Teil-/Knoten-/Rekursionsbudget über
|
||||
alle decodierten Teile und erzwingt tatsächliche Dokumentparser.
|
||||
Unbekannte rohe Binärkodierungen und Vektor-/SVG-Rendering sind damit nicht
|
||||
pauschal abgedeckt; ausdrücklich deklarierte unprüfbare Carrier müssen sperren.
|
||||
Die kombinierte native Production-Child-Prüfung besteht nach kontrolliert
|
||||
nachgewiesener Ergänzung von genau `USERPROFILE`, `LOCALAPPDATA` und `APPDATA` in
|
||||
der Windows-Umgebung. Geheimnisse im Hex-PDF werden in JSON und Prozent-Data-URL
|
||||
mit tatsächlicher registrierter Regel ohne zusätzliche Zielverbindung blockiert.
|
||||
Öffentliche äußere JSON-Bytes werden nach vollständiger Parserprüfung unverändert
|
||||
übertragen. Öffentlicher PDF-Download funktioniert; gesperrter PDF-Download gibt
|
||||
keine PDF-Bytes frei und erhält ein kausal verknüpftes `download.block`-Ereignis.
|
||||
Neun fsync-persistierte synthetische Ereignisse; bestätigtes Broker-Prozessende.
|
||||
Der ursprüngliche fehlgeschlagene kombinierte Nachweis bleibt erhalten. Die
|
||||
Umgebungskorrektur verändert weder ACLs noch Parser-, TLS- oder Policy-Prüfungen.
|
||||
Factory-Unternehmens-Proxy und Hauptbrowser-/Installer-Abnahmen bleiben offen.
|
||||
Nachweis: `broker-production-native/proof-2026-10-14T09-48-31.825Z.json` unter
|
||||
dem ignorierten lokalen `artifacts`-Verzeichnis.
|
||||
|
||||
### Verbindlicher Auslieferungsvertrag
|
||||
|
||||
Ein gemeinsamer Windows-Installer enthält Browser, separaten DLP-Broker,
|
||||
dessen private Runtime, lokale Scanner und Modelle. Auf dem Zielgerät ist keine
|
||||
Entwicklungsinstallation von Node oder Python und kein manueller Broker-Start
|
||||
erforderlich. Der Zielpfad startet und überwacht den enthaltenen Broker über einen
|
||||
geschützten Windows-Dienst; dessen Installation und produktive Anbindung sind
|
||||
noch nicht nachgewiesen. Fehlt
|
||||
dieser, ist er manipuliert oder fällt er aus, dürfen geschützte Transfers nicht
|
||||
auf einen ungeprüften direkten Netzwerkpfad ausweichen. Update und Deinstallation
|
||||
müssen auch die zugehörigen verwalteten Komponenten berücksichtigen.
|
||||
Ein entpacktes Entwicklungsverzeichnis allein erfüllt diesen Vertrag nicht.
|
||||
|
||||
### Neue lokale Integrationsnachweise
|
||||
|
||||
- Gemeinsamer Repository-Lauf: ESLint und sämtliche JavaScript-Syntaxprüfungen
|
||||
bestehen; 1.119 Node-Tests, davon 1.107 bestanden, null Fehler und zwölf explizite
|
||||
native Opt-in-Skips. Der Lauf ersetzt diese ausgelassenen nativen Abnahmen nicht.
|
||||
Tatsächlicher privater OTLP-Trace `4a4cbe86f72921e7e1d9a2b94abcb4b8` enthält
|
||||
fünf korrelierte Spans, null aktuelle/lebenslange Exportfehler. Zwei anschließend
|
||||
ergänzte Credential-Lifecycle-Consumer-Regressionen bestehen separat.
|
||||
- Credential-Lifecycle korrigiert: Der native Request muss zuerst exakt zur
|
||||
aktuellen angenommenen Policy passen. Für denselben SID, Device, Origin und
|
||||
Policy-Key kann der jüngste zulässige ältere Credential-Datensatz wiederverwendet
|
||||
werden; seine eigenen Metadaten bleiben an die DPAPI-Entropie gebunden. Ein
|
||||
gleiches Versionsdatum mit anderem Digest, zukünftige Versionen und Mehrdeutigkeit
|
||||
werden abgewiesen. Provisionierung ersetzt diese stabile Identität statt ihre
|
||||
Versionshistorie unbegrenzt anzuhäufen. Native Auswahl-/CAS-/Registry-Ausführung
|
||||
ist weiterhin nicht nachgewiesen; die zwei JS-Tests prüfen die Consumer-Grenze.
|
||||
- DPAPI-Capability-Helfer gehärtet: private Python-Runtime mit `-I -B` und
|
||||
ausschließlich erlaubten Windows-Umgebungsvariablen. Ausgabe ist vorallokiert
|
||||
und begrenzt; späte Daten nach einem Fehler werden ignoriert. Höchstens acht
|
||||
ursprüngliche Helper-Prozesse bleiben bis zu ihrem tatsächlichen Ende belegt;
|
||||
ein fehlgeschlagener Kill gibt keine Kapazität frei. Fehler bleiben bereinigt.
|
||||
Tatsächliche Windows-DPAPI-Roundtrips bestehen auch für 32.768 Eingabebytes und
|
||||
44.000 Ciphertext-Bytes sowie bei manipulierten Python-Umgebungsvariablen.
|
||||
Fünf Tests, null Skips. Die früher kleinere Entschlüsselungsgrenze wurde an
|
||||
den tatsächlich größeren Ciphertext angepasst, ohne Klartextgrenze zu erhöhen.
|
||||
`artifacts/dpapi-helper/proof.json` enthält vier tatsächliche private OTLP-Spans,
|
||||
Trace `a2f37753f5620155c4944b26ef1189bd`, null Exportfehler. Die beiden
|
||||
Credential-Stufen wurden mit tatsächlichem Parent und Zeiten abgerufen.
|
||||
Der Audit-Korrelationsanker ist synthetisch; kein Browser-Upload fand statt.
|
||||
- Begrenzte Management-Operationen prüfen jetzt Identität und verschlüsselte
|
||||
Policy über fest vorgegebene HTTPS-Routen. ML-KEM-1024, gepinnte ML-DSA-87-
|
||||
Signaturen, Device-Bindung, Mindestversion und gleicher-Version-Digest werden
|
||||
geprüft. Beide Policy-Aufrufe teilen ein Deadline-/Job-Budget; Widerruf nach
|
||||
Challenge verhindert den zweiten POST. 16 Tests bestehen. Das Policy-Ergebnis
|
||||
aktiviert keine Regeln und ersetzt keinen geschützten Floor-Commit.
|
||||
- Native Topologie-/Credential-Quellen sind erweitert und kompilieren. Ihre
|
||||
aktuelle Ausführung ist ausdrücklich nicht nachgewiesen: Beim Topologie-
|
||||
Selftest war die EXE vor dem Start vorhanden und gehasht; der Start scheiterte
|
||||
mit `UNKNOWN:spawn`, danach fehlte die Datei. Gezielte schreibgeschützte
|
||||
Defender-/CodeIntegrity-Abfragen belegen keine Ursache dieses Fehlers.
|
||||
`topology-compile-only-proof.json` und `current-compile-only-proof.json` unter
|
||||
`artifacts/native-service-host` unterscheiden Kompilierung von Ausführung.
|
||||
Frühere native v2-Erfolge gelten für den damaligen Hash. Kein Endpoint-Schutz
|
||||
wurde deaktiviert, kein neuer Registry-/SCM-/WFP-Zustand aktiviert.
|
||||
- Neunter Payload: `broker-prepared-service-proof-ninth.json` unter `artifacts`,
|
||||
313 Dateien / 64 Quellnachweise, sechs tatsächliche Paket-Isolationsprüfungen.
|
||||
Native Test-EXEs, Signing-Assets und damals unreviewte Credential-Helfer sind
|
||||
ausgeschlossen. Spätere Policy-/Credential-Quellen benötigen einen neuen
|
||||
expliziten Snapshot; dieser Komponenten-Nachweis ist keine Installer-Abnahme.
|
||||
- Dienst-Lifecycle weiter geprüft (`broker-service-lifecycle-proof-2026-10-14.json`
|
||||
unter `artifacts`): Paketprüfung, Startbereitschaft und Herunterfahren besitzen
|
||||
benannte tatsächliche OTLP-Spans. Trace `d0e4c73944250d8f96864c043e6a83d9`
|
||||
enthält fünf korrelierte Spans; aktuelle und lebenslange Exportfehler null.
|
||||
Instrumentierung kann Prüfungen nicht überspringen, ersetzen oder wiederholen.
|
||||
Der Prozesspfad wartet ursprüngliche Factory-/Listen-/Cleanup-Promises ab;
|
||||
verspätete Ergebnisse geben keine Bereitschaft frei. Cleanup-Fehler bleiben
|
||||
beim wiederholten Close erhalten statt als erfolgreicher Abschluss zu gelten.
|
||||
25 gezielte Prozess-/Dienst-/Framing-Tests bestehen. Ein parallel noch mit
|
||||
altem Testinhalt gestarteter Lauf hing an unterdrücktem Startup-Fault; der
|
||||
korrigierte Test wartet die tatsächliche Quarantäne-Disconnection ab.
|
||||
- Deployment v2 bindet HTTPS-Management-Origin und bestehenden ML-DSA-87-Pin.
|
||||
Native MSVC-Kompilierung besteht; sechs positive und 23 tatsächlich signierte
|
||||
negative native Manifestfälle geprüft. Der native Parser validiert PQ-Key-
|
||||
Metadaten; die tatsächliche PQ-Signaturprüfung bleibt im vorhandenen Node-
|
||||
Kanal. Die erste begrenzte Management-Operation prüft Identität über HTTPS mit
|
||||
Ed25519 und gepinntem ML-DSA-87. Sechs physische HTTPS-Tests bestehen. Geschützte
|
||||
Credentials, Enrollment und produktive IPC-Anbindung sind noch nicht umgesetzt.
|
||||
- Dienst-Bootstrap akzeptiert ausschließlich Receiver-Port 43120, passend zur
|
||||
festen nativen/WFP-Grenze. Andere Ports scheitern vor Trust-Load und Konstruktion;
|
||||
20 kombinierte Bootstrap-/Acceptance-/Service-Tests bestehen. Der separate
|
||||
isolierte Production-Child-Empfängertest darf weiterhin Testports verwenden.
|
||||
Neue Trust-/Lifecycle-/Topologiequellen erfordern vor dem nächsten Paketnachweis
|
||||
einen frischen explizit geprüften Payload; der achte bleibt historische Evidenz.
|
||||
- Achter Payload: `broker-prepared-service-proof-eighth.json` unter `artifacts`,
|
||||
309 Dateien / 60 Quellnachweise, sechs physische Paket-Isolationsprüfungen.
|
||||
Aktualisierter nativer Production-Child-Test besteht:
|
||||
`broker-production-native/proof-2026-10-14T11-28-12.697Z.json`, sechs numerische
|
||||
HTTPS-Proxy-Tunnel mit ursprünglichem SNI, 13 persistierte Ereignisse und
|
||||
22 tatsächliche private OTLP-Spans. Trace `40c28b579ce1adb8010503bae74b40f5`
|
||||
wurde mit seinem Audit-Kindspan abgerufen; aktuelle und lebenslange Exportfehler
|
||||
null. Die Exporte stammen vom privilegierten Test-Parent, nicht vom Dienst-SDK.
|
||||
- Neuer Browser-Dienstadapter startet ausschließlich den fest installierten
|
||||
nativen Host mit `--client-stdio`. Installation und Broker-Paket müssen vorher
|
||||
unabhängig verifiziert sein. Fünf Adaptertests mit tatsächlichem Node-Testchild
|
||||
plus zehn bestehende Supervisor-Tests bestehen. Das ist kein SCM-Positivnachweis.
|
||||
Der zusätzliche OTLP-Test persistiert ein synthetisches Audit-Ereignis mit fsync
|
||||
und ruft zwei tatsächliche, kausal verbundene Spans ab:
|
||||
`8be57a1790a8429140f5eeb71e0e219c`, null Exportfehler. Ein erster Test scheiterte
|
||||
an einer ungültigen Fixture-UUID; die strikte Validierung blieb unverändert.
|
||||
Hauptbrowser-Aktivierung und native Dienst-Authentifizierung stehen noch aus.
|
||||
- Office-Timings mit tatsächlichem AppContainer-Inspector und privatem OTLP-
|
||||
Testempfänger geprüft: `private-otlp-proof-2026-10-14T08-29-16.980Z.json`
|
||||
unter `artifacts/office-native-observability`. Vier benannte Office-Spans hängen
|
||||
am tatsächlichen Parse-Span; defektes Archiv erzeugt einen bereinigten Fehler-
|
||||
Span. Aktuelle und lebenslange Exportfehler: null. Dies ist kein Nachweis der
|
||||
produktiven Collector-Konfiguration; diese wurde nicht geändert.
|
||||
- Kurzlebige Broker-TLS-Zertifikate werden im Speicher erstellt, mit exakter SAN,
|
||||
ohne CA-Berechtigung und mit begrenztem Cache. Physischer Node-HTTPS-Test prüft
|
||||
die Zertifikatskette und verwirft einen falschen Hostnamen. Die Browser-
|
||||
Vertrauensbindung und die produktive Factory bleiben gesondert offen.
|
||||
- Sitzungsgebundene Zertifikat-Pins mit tatsächlichem Stock-Electron 44.7.0 /
|
||||
Chromium 152.0.7977.130 geprüft (`broker-certificate-pins/proof.json` unter
|
||||
`artifacts`): registriertes kurzlebiges Leaf liefert 200, unregistriertes Leaf
|
||||
wird ohne Anwendungsanfrage abgewiesen. Der Fingerprint wird aus Electrons
|
||||
tatsächlichem `Certificate.data` berechnet. Generation, SAN, Ablauf und Policy
|
||||
werden geprüft. Electrons Callback liefert keinen Port und cached Ergebnisse;
|
||||
CONNECT-/SNI-/Host-Bindung sowie Schließen alter Verbindungen bleiben zwingend.
|
||||
Der produktive Browser ist noch nicht auf diesen Helper umgestellt.
|
||||
- Der Parent-Audit-Adapter wurde mit tatsächlichem Windows-DPAPI und einer
|
||||
synchronen `fsync`-/Rename-Persistenz geprüft (`broker-parent-audit/proof-7IKfqn`
|
||||
unter `artifacts`). Die ursprüngliche Broker-Ereignis-ID wird erst nach Commit
|
||||
bestätigt; Klartext-ID fehlt in der geschützten Datei. Fehler, asynchrones
|
||||
Einreihen und uneindeutige boolesche Writer-Ergebnisse geben keine Bestätigung.
|
||||
Die produktive Private-IPC-/Browser-Anbindung dieses Adapters bleibt offen.
|
||||
- Der tatsächliche lokale SIEM-Dateiexporter erhält die Broker-Ereignis-UUID und
|
||||
die Verbindung zwischen Freigabe und anschließendem Übertragungsfehler.
|
||||
ECS/CEF-Felder und deren Grenzen stehen in [broker-siem.md](broker-siem.md).
|
||||
Eine Freigabe hat einen unbekannten Lieferstatus; Terminalfehler werden als
|
||||
Fehler ausgewiesen. Dieser Nachweis ersetzt keine externe SIEM-Abnahme.
|
||||
|
||||
1. Nativer PDF-/Office-Nachweis mit verpflichtender Audit-Persistenz bestanden:
|
||||
`office-proof-2026-10-14T08-23-30.852Z.json` unter dem ignorierten lokalen
|
||||
Evidenzverzeichnis `artifacts/broker-native-inspection`. Sieben mit `fsync`
|
||||
persistierte synthetische Ereignisse, drei tatsächliche erkannte Geheimnisregeln,
|
||||
keine Zielverbindung für gesperrte Dokumente und keine verbleibenden eigenen
|
||||
Inspector-Prozesse. Die Fixture beweist weder produktive Audit-Verschlüsselung
|
||||
noch SIEM-Auslieferung; Diagnostik war zentral deaktiviert.
|
||||
2. Sicherheitsereignisse für frühe CONNECT-/Framing-Blockierungen und Fehler nach
|
||||
Transferfreigabe ergänzen. Eine protokollierte Freigabe ist kein Nachweis einer
|
||||
erfolgreichen Übertragung. Erlaubte Dokumente müssen funktionieren; Geheimnisse
|
||||
dürfen beim kontrollierten Empfänger nicht ankommen.
|
||||
3. Zertifikats-/Trust-, Proxy-, Audit- und Management-Pfade vollständig integrieren.
|
||||
Kein ungeprüftes CONNECT-Piping oder DIRECT-Fallback als Ersatz.
|
||||
4. Windows-Regeln über den geprüften Installer-/Service-Pfad aktivieren und
|
||||
unabhängig nativ prüfen. Erst dann die bisherige Header-Sperre ersetzen.
|
||||
5. Gemeinsamen Installer bauen und ohne Entwicklungsruntime abnehmen, einschließlich
|
||||
Ausfall, Widerruf, Update und fehlender/manipulierter Broker-Dateien.
|
||||
|
||||
Die detaillierten Anforderungen bleiben in [windows-dlp-mvp.md](windows-dlp-mvp.md).
|
||||
Diese Übersicht ersetzt weder deren Kriterien noch die übrige Enterprise-Roadmap.
|
||||
@@ -1,11 +1,39 @@
|
||||
# Chromium 152 WebRTC binder backport
|
||||
|
||||
## Cancelled implementation path — stock runtime required
|
||||
|
||||
The user explicitly rejected rebuilding Chromium/Electron on2026-10-14. The
|
||||
owned native build was stopped and its terminal failure/cancellation evidence
|
||||
retained. No custom binary was activated or shipped. This document's backport,
|
||||
compiler and source-checkout records are historical evidence, not instructions
|
||||
to resume that implementation. **Do not restart this build or make a custom
|
||||
Chromium runtime a product dependency.**
|
||||
|
||||
The current direction is an unchanged official Electron/Chromium runtime plus
|
||||
an independently enforced Windows network boundary and separate controlled DLP
|
||||
broker. This alternative is not yet implemented or accepted. The existing relay
|
||||
allows opaque CONNECT forwarding and shares Electron's executable/process with
|
||||
upstream management traffic, so merely adding per-executable firewall blocking
|
||||
or removing the WebRTC header would not provide the required boundary. Retain
|
||||
the present protection until hostile native transport and DNS tests prove its
|
||||
replacement. A JavaScript constructor wrapper alone is insufficient.
|
||||
|
||||
Status: source patch prepared and checked; **not compiled or runtime verified**.
|
||||
Do not distribute this patch as a proven security fix until the acceptance gates
|
||||
below pass against the resulting binary.
|
||||
|
||||
## Latest stable candidate checked
|
||||
|
||||
On2026-10-14 the newer official Electron44.7.2 Windows x64 binary was also tested.
|
||||
Its ZIP matches the official SHA256 manifest:
|
||||
`10a89447ef64ac5cb9f782af48ffd83734a40ae91681259d1048ca3c9e6c5da4`.
|
||||
Both initialabout:blank andsrcdoc positive controls delivered exact synthetic
|
||||
RTC data and benign POSTs. Both corresponding managed-policy cases still
|
||||
crashed with missing MdnsResponder, bad IPC123 and renderer exit3. The earlier
|
||||
run's positive timeout remains preserved separately. Evidence:
|
||||
`artifacts/native-rtc-4472-second-proof.json`. No project dependency was upgraded.
|
||||
Thus this newly available binary does not replace the native backport either.
|
||||
|
||||
The official [Electron44.7.1 release](https://github.com/electron/electron/releases/tag/v44.7.1)
|
||||
uses Chromium152.0.7977.149. Its Windows x64 ZIP was downloaded into an isolated
|
||||
ignored directory, checked against the official SHA256 manifest and extracted
|
||||
@@ -254,6 +282,61 @@ is not identified as a compiler tool, but the official full sync remains failed.
|
||||
This read-only analysis authorizes neither skipping dependencies nor changing
|
||||
antivirus policy. ATL/MFC remains independently missing at the host check.
|
||||
|
||||
## Alternative dependency installation, 2026-10-14
|
||||
|
||||
The normal Microsoft installer subsequently completed. `vswhere` now finds
|
||||
`Microsoft.VisualStudio.Component.VC.ATLMFC` in the existing BuildTools instance
|
||||
and reports both `isComplete=true` and `isLaunchable=true`; the installer process
|
||||
has ended. Ignored evidence: `artifacts/native-build/atl-mfc-installed-proof.json`.
|
||||
Thus the earlier missing-component prerequisite is resolved. This does not by
|
||||
itself prove a generated GN graph or a working native compilation.
|
||||
|
||||
The user's explicit alternative-build request was implemented without changing
|
||||
Defender. A reviewed CIPD ensure manifest preserves all forty original requested
|
||||
package/subdirectory/version bindings from the existing `.gclient_entries`,
|
||||
except the single rejected Chrome-x86 updater fixture described above. The
|
||||
thirty-nine remaining bindings were processed by the official CIPD client with
|
||||
exit0 in119.7seconds. No new antivirus error occurred. Original version tags
|
||||
were preserved; this is not a claim that every tag was resolved offline to an
|
||||
immutable instance ID. The source entries and both manifests retain their
|
||||
verified hashes. Native Chromium/Electron HEADs and captured references remained
|
||||
unchanged before/after; no full Git sync or patch replay occurred.
|
||||
|
||||
Ignored evidence: `cipd-manifest-offline-proof.json`,
|
||||
`cipd-reviewed-ensure-journal.json` and `cipd-reviewed-ensure.log` under
|
||||
`artifacts/native-build`. The original failed full sync remains retained.
|
||||
Updater regression tests using the omitted fixture are not accepted. A generated
|
||||
Electron/native-test GN dependency graph must confirm that the omitted target is
|
||||
not needed before compiling. Required hooks, ATL/MFC, compilation and real native
|
||||
acceptance remain separate gates. The signed Microsoft installer was launched
|
||||
through normal Windows UAC; installation is not confirmed by starting it.
|
||||
|
||||
The selected original RC and four version-generation hooks subsequently exited0.
|
||||
RC matches its checked-in SHA1 and has recorded SHA256 provenance. Electron's
|
||||
Yarn4.18.0 immutable install also completed with unchanged lockfile and config.
|
||||
`enableScripts:false` stayed set, but the upstream workspace fixtures and explicit
|
||||
`dependenciesMeta.built:true` entries executed their permitted builds; this is
|
||||
not an all-scripts-disabled claim. All native HEAD/reference snapshots remained
|
||||
unchanged, with no Defender modification or new antivirus error. Evidence:
|
||||
`selected-hooks-journal.json` and per-action logs. The official local toolchain
|
||||
check also exited0, recognizing VS2022 and Windows SDK10.0.26100.0 without a Google
|
||||
toolchain download (`local-toolchain-proof.json`). GN generation, target-graph
|
||||
verification and product compilation remain separate steps.
|
||||
|
||||
GN generation subsequently succeeded in the fresh `out/SafetyNetTestingPython314`
|
||||
directory with the supported explicit Python interpreter switch; the original
|
||||
Windows-Store-alias failure remains retained. Actual recursive GN graphs for
|
||||
Electron and content_unittests contain no omitted updater-fixture dependency.
|
||||
Both NoOp test sources are present and `enable_mdns`/`is_p2p_enabled` are true.
|
||||
Evidence: `gn-generation-python314-proof.json` and `gn-target-graph-proof.json`.
|
||||
Physical Ninja aliases were also checked. The initial compile launch was
|
||||
controlled-stopped before observed compiler progress for insufficient memory;
|
||||
that failed attempt remains retained. A fresh continuation started only after
|
||||
the measured available commit memory exceeded4GiB, using one compiler job,
|
||||
bounded disk/memory guards and no remote execution. This running build is not
|
||||
yet a completed compilation or native acceptance. The shipped runtime remains
|
||||
unchanged until the required real tests pass.
|
||||
|
||||
### Patch readiness before compilation
|
||||
|
||||
ATL/MFC is required for the native toolchain, not for the Python patch import
|
||||
|
||||
@@ -7,7 +7,23 @@ Die breiteren Anforderungen und Erweiterungen unten bleiben Roadmap.
|
||||
|
||||
## Aktuelle nächste Abnahmen
|
||||
|
||||
**Aktualisierung des Windows-MVP:** 784 Tests bestehen mit begrenzter
|
||||
**Stand 14. Oktober 2026:** Das neue Windows-DLP-MVP ist nicht abgenommen.
|
||||
Maßgeblich ist die [aktuelle Abnahmematrix](core-dlp-current-status.md).
|
||||
Ein gepackter separater Broker ist mit privater Runtime und realen lokalen
|
||||
Scannern geprüft, aber noch nicht im Hauptbrowser aktiviert. Der eigene
|
||||
Chromium-Build wurde abgebrochen; stock Electron bleibt Voraussetzung. Der
|
||||
geschützte Windows-Dienst, native Netzwerkdurchsetzung, gemeinsame Installation
|
||||
und erneute vollständige Clipboard-/Upload-/Download-Abnahme sind offen.
|
||||
|
||||
Aktuelle Arbeit: Timeout-Korrektur für die reale Unternehmens-Proxy-Prüfung,
|
||||
authentifizierter Diensttransport und getrennte Release-/Kundendeployment-
|
||||
Signaturen. Klassische Release-Signaturen sind kein quantensicherer Nachweis.
|
||||
Baustein-/Fixture-Tests und frühere Browser-Builds ersetzen weder den aktuellen
|
||||
Hauptbrowserpfad noch einen final installierten gemeinsamen Broker-Installer.
|
||||
|
||||
### Historische Integrationsnachweise
|
||||
|
||||
**Vorangegangener Windows-MVP-Stand:** 784 Tests bestehen mit begrenzter
|
||||
Parallelität. Der aktuelle Windows-Build besteht Paket-, Fuse- und
|
||||
Manipulationsprüfungen, ist aber nicht signiert. Native Clipboard-/Upload-Tests,
|
||||
Prozessneustart bestehen auf dem finalen Quellstand. Die lokalen semantischen
|
||||
@@ -21,7 +37,7 @@ WebRTC-Absturz bleibt offen, bis ein korrigiertes Binary geprüft ist.
|
||||
Die folgenden älteren Integrationszahlen beschreiben historische Zwischenstände;
|
||||
die aktuelle verbindliche Abnahme steht im [Windows-MVP-Ziel](windows-dlp-mvp.md).
|
||||
|
||||
Aktueller Integrationsstand: 713 Tests bestehen
|
||||
Früherer Integrationsstand: 713 Tests bestehen
|
||||
(`artifacts/worker-integration-suite.log`). Der echte Admin-Workflow für typisierte
|
||||
Login-Regeln, unabhängige Veröffentlichung und Auditor-Leserechte ist geprüft:
|
||||
193 abgeschlossene Anfragen mit korrelierten exportierten Traces, eine abgebrochene
|
||||
|
||||
+195
-3
@@ -1,5 +1,192 @@
|
||||
# Verbindliches Ziel: Windows-DLP-MVP
|
||||
|
||||
## Verbindliche Runtime-Grenze
|
||||
|
||||
Nutzerpräzisierung vom14. Oktober2026: Chromium/Electron darf für dieses Produkt
|
||||
nicht selbst gepatcht oder neu gebaut werden. Offizielle, unveränderte Runtime-
|
||||
Updates bleiben Voraussetzung. Der bisherige native Backport-/Compilerpfad ist
|
||||
abgebrochen; kein Custom-Binary wurde aktiviert. Eine unabhängige Windows-
|
||||
Netzwerkgrenze mit separatem DLP-Broker muss die bisherige WebRTC-Sperre ersetzen,
|
||||
bevor der abstürzende Response-Header entfernt werden darf. Dieser Ersatz ist
|
||||
noch nicht implementiert oder nativ abgenommen. Das unveränderte Runtime-Ziel
|
||||
reduziert die erforderlichen DLP-/Umgehungs-/Stabilitätsnachweise nicht.
|
||||
|
||||
## Gemeinsame Auslieferung
|
||||
|
||||
Nutzerpräzisierung: Browser, DLP-Broker, lokale Scanner und benötigte Modelle
|
||||
werden gemeinsam in einer finalen Windows-Installer-EXE ausgeliefert. Der Broker
|
||||
darf ein eigener interner Prozess sein, aber kein separat zu installierendes
|
||||
Produkt oder manuell zu startendes Programm. Auf einem frischen Zielsystem dürfen
|
||||
kein Node-/Python-/Broker-Download und keine Entwicklungsumgebung erforderlich
|
||||
sein. Der Installer provisioniert die nötigen Schutzgrenzen und Dateirechte;
|
||||
Startup und Paketprüfung müssen fehlende/manipulierte Broker-Dateien, falsche
|
||||
Versionen sowie nicht aktive Schutzgrenzen erkennen und fail-closed reagieren.
|
||||
Ein grüner Paketnachweis des alten In-Process-Relays beweist die neue Broker-
|
||||
Auslieferung nicht; der neue vollständige Installer benötigt eigene Native-Tests.
|
||||
|
||||
## Aktueller Ersatzpfad: Nachweise und offene Grenzen
|
||||
|
||||
Die folgenden Einträge dokumentieren die schrittweise Entwicklung und enthalten
|
||||
auch frühere Zwischenstände. Die aktuelle Abnahmematrix und neuesten Paket-,
|
||||
Proxy-, Diagnose- und Dienstadapter-Nachweise stehen in
|
||||
[core-dlp-current-status.md](core-dlp-current-status.md). Frühere offene Punkte
|
||||
gelten nicht als aktuelle Lücke, wenn dort ein neuerer begrenzter Nachweis steht;
|
||||
die vollständige Browser-/Dienst-/Installer-Abnahme bleibt erforderlich.
|
||||
|
||||
Der isolierte Baustein `apps/network-broker/http-request-gate.cjs` puffert explizite
|
||||
HTTP-Requests vollständig vor der Freigabe. Er prüft Framing und Authority,
|
||||
begrenzt Größe und Laufzeit und prüft nach dem Scan den Policy-Widerruf erneut.
|
||||
Die Scan-Kopie kann die freigegebenen Originalbytes nicht verändern. Er ist noch
|
||||
nicht im produktiven Relay oder Installer eingebunden. TLS-Terminierung,
|
||||
Broker-Authentifizierung und WebSocket-/WebRTC-Prüfung sind dadurch nicht gelöst.
|
||||
Die neun gezielten Tests umfassen einen echten HTTP-Empfänger und zeigen null
|
||||
TCP-Verbindungen vor der Freigabe sowie keine Auslieferung blockierter Inhalte.
|
||||
|
||||
`scripts/broker-package-contract.mjs` erzeugt und prüft eine vollständige
|
||||
Broker-Dateiliste mit SHA-256, Version und Protokollkennung. Neun gezielte Tests
|
||||
prüfen fehlende, zusätzliche und manipulierte Dateien sowie unsichere Pfade und
|
||||
Junctions. Der erwartete Manifest-Inhalt muss aus der integritätsgeschützten
|
||||
Anwendung stammen. Das ist noch keine Installer-Integration und keine atomare
|
||||
Startgarantie; geschützte Installationsrechte bleiben Voraussetzung. Beide
|
||||
Testdateien und Bausteine bestehen den lokalen ESLint-Check. Die isolierten Tests
|
||||
erzeugen noch keine produktiven Broker-OTEL-Traces.
|
||||
|
||||
Weitere isolierte Vorarbeiten: `tls-authority.cjs` bindet CONNECT, inneren Host,
|
||||
HTTPS-Ziel und SNI an dieselbe unveränderliche Authority; fünf Tests bestanden.
|
||||
Es terminiert noch kein TLS. `windows-package-plan.mjs` trennt Unpacked-Build,
|
||||
Pflichtprüfungen und NSIS-Erstellung aus dem geprüften Verzeichnis; sechs Tests
|
||||
bestanden. Die Freigabe ist an Source-, Runtime- und Paketidentität gebunden.
|
||||
Der damalige Packaging-Aufruf verwendete nur `--dir`. Der aktuelle Aufruf
|
||||
bereitet den verpflichtenden Broker vor, verifiziert das entpackte Paket und
|
||||
erstellt standardmäßig anschließend NSIS; `--unpacked-only` bleibt ein expliziter
|
||||
Entwicklungsmodus. Ein finaler Installer mit aktiviertem geschütztem Broker-
|
||||
Dienst ist weiterhin nicht gebaut oder abgenommen.
|
||||
|
||||
`https-inspection-server.cjs` prüft jetzt einen echten entschlüsselten HTTPS-Leg
|
||||
mit vorab authentifizierter CONNECT-Bindung und vom vertrauenswürdigen Aufrufer
|
||||
bereitgestelltem Zertifikat. Fünf TLS-Tests prüfen erlaubte Originalbytes und
|
||||
null Ziel-TCP-Verbindungen bei Geheimnissen, Widerruf, Host-/SNI-Fehlern sowie
|
||||
vor verzögerter Freigabe. Client und Testziel validieren ihre TLS-Zertifikate.
|
||||
Der Baustein ist kein vollständiger CONNECT-Proxy: Zertifikatsausstellung,
|
||||
Browser-Vertrauensintegration, Zielantwort-Weiterleitung und produktive
|
||||
Einbindung fehlen. Die Gesamtsuite vor diesem zusätzlichen TLS-Testlauf bestand
|
||||
823 Tests; der anschließende separate TLS-Lauf bestand fünf Tests.
|
||||
|
||||
Inzwischen gibt der isolierte HTTPS-Leg die echte gepufferte Zielantwort zurück.
|
||||
Ein Response-Gate begrenzt Body und Header, berechnet Content-Length neu und
|
||||
entfernt Hop-by-hop-Header. Nicht unterstützte Kompression bleibt gesperrt.
|
||||
Der vertrauenswürdige Forwarder muss seine eigene Socket-/Response-Sammlung
|
||||
ebenfalls begrenzen. Die OTLP-Adaptertests prüfen echte Exporte mit Parent-/Child-
|
||||
Korrelation und Laufzeiten, unveränderte Fehler bei deaktivierter Diagnostik und
|
||||
den Ausschluss von Inhalt, Authority und Fehlermeldung. Der Adapter konfiguriert
|
||||
keinen Exporter selbst und benötigt die bereits autorisierte Diagnostik des
|
||||
Aufrufers. Das ist noch keine produktive Broker-Telemetrie-Integration.
|
||||
|
||||
Der Forwarding-Leg hat nun einen eigenen, nicht durch Netzwerkaktivität
|
||||
verlängerbaren Zeitrahmen und signalisiert Abbruch bei Client-Disconnect oder
|
||||
Fristüberschreitung. Neun HTTPS-Tests einschließlich realer Zielantwort,
|
||||
Upstream-Fehler, einmaligem Abbruch und Konstruktor-Limits bestehen. Die sechs
|
||||
Broker-Diagnosestufen besitzen getrennte feste `broker.*`-Operationsnamen.
|
||||
|
||||
`windows-wfp-plan.cjs` beschreibt vier komplementäre Deny-Filter für den nativen
|
||||
Application-ID-Blob: IPv4 Nicht-TCP, TCP zu anderen Adressen, Loopback-TCP zu
|
||||
anderen Ports und sämtliches IPv6. Es installiert keine Regeln und enthält
|
||||
keinen Permit-Override für Unternehmens-Firewalls. Vier Tests prüfen alle
|
||||
65.536 Portwerte, Protokoll-/Adressgrenzen und unveränderliche Identitäten.
|
||||
Die GUIDs stimmen mit dem installierten Windows SDK 26100 überein. Native
|
||||
BFE-Installation, Enumeration und physische Empfängertests sind noch offen;
|
||||
der Entwicklungsprozess besitzt nur ein Medium-Integrity-Token.
|
||||
|
||||
Der isolierte `connect-inspection-proxy.cjs` führt nun einen echten CONNECT-
|
||||
Durchlauf aus: zufällige Launch-Capability, konstante Tokenprüfung, autorisierte
|
||||
Zielbindung, verifiziertes TLS, Inhaltsprüfung und echte HTTPS-Zielantwort.
|
||||
Falsche Tokens werden vor Zertifikatsauflösung abgewiesen; gesperrte Inhalte
|
||||
erzeugen keine Zielverbindung. Es gibt kein opaques Upstream-Piping und keinen
|
||||
zusätzlichen ungeschützten lokalen Listener. Zertifikatsausstellung und produktive
|
||||
Browser-Vertrauensintegration fehlen weiterhin. Die unabhängige Prüfung fand
|
||||
eine noch offene Ressourcenlücke: Socket-Limits begrenzen nicht Scanner-/Forward-
|
||||
Jobs, die Abbruchsignale ignorieren. Vor Integration ist gemeinsame Job-Admission
|
||||
nötig, deren Plätze bis zum tatsächlichen Ende der zugrunde liegenden Arbeit
|
||||
belegt bleiben. Timeout allein darf keine belegte Kapazität freigeben.
|
||||
|
||||
Diese Lücke ist im isolierten Prototyp geschlossen: gemeinsame Work-Admission
|
||||
für Zertifikatsauflösung, Scan und Forwarding über alle CONNECT-Tunnel, Freigabe
|
||||
erst nach tatsächlichem Promise-Ende. Wiederholte Timeouts überschreiten das
|
||||
Job-Limit nicht; belegte Kapazität bleibt fail-closed verbraucht. Private Buffer
|
||||
werden nach tatsächlichem Arbeitsende gelöscht. 37 Broker-Tests und Lint bestehen.
|
||||
|
||||
Der native read-only WFP-Adapter besteht neun Python-Tests. Der tatsächliche
|
||||
Medium-Integrity-Prozess erhält einen nativen Application-ID-Blob, den der Plan
|
||||
akzeptiert. Filter-Enumeration endet mit Access Denied (5), korrekt als
|
||||
`verified: false`; native Ressourcen werden geschlossen. Der Adapter installiert
|
||||
keine Regeln. Installer-Aktivierung, Service-Provenienz und native Abnahme bleiben
|
||||
offen. Nachweis: ignorierter `artifacts/stock-wfp-readonly-proof.json`.
|
||||
|
||||
Der separate Broker-Prozess ist jetzt mit der offiziellen gebündelten Node-
|
||||
24.20.0-Runtime getestet: drei echte Child-Prozess-Tests bestehen, einschließlich
|
||||
CONNECT/TLS, blockiertem Geheimnis, exakten öffentlichen Bytes sowie geschlossenem
|
||||
Listener nach privatem IPC-Disconnect oder Stop. Der aktuelle Factory-Code nutzt
|
||||
nur eine explizite synthetische Testfixture; produktive Policy-/Scanner-Anbindung
|
||||
ist damit noch nicht implementiert. Node-Binary und Lizenz liegen ignoriert unter
|
||||
`.runtime/windows/broker`; offizielle Archiv- und Binary-SHA-256 stimmen,
|
||||
Authenticode ist gültig. Das Browser-Binary bleibt unverändert. Installer-
|
||||
Einbindung und Fresh-System-Abnahme fehlen weiterhin.
|
||||
|
||||
`policy-authority.cjs` verwendet die bestehende Core-Signatur-/Schema-Prüfung
|
||||
und dieselbe Device-Assignment-Prüfung wie der Browser. Enrollment-Key, Device-ID
|
||||
und Mindestversion stammen aus vertrauenswürdigem Bootstrap; unveränderliche
|
||||
Snapshots und Generationstokens verhindern Freigabe nach Policywechsel. Sechs
|
||||
Tests mit echten synthetischen Signaturen prüfen Ablauf, falsches Gerät, Änderung,
|
||||
Rollback und Widerruf. Mindestversionen müssen bei produktiver Integration in
|
||||
geschütztem Enrollment-Storage dauerhaft gespeichert werden. Die drei Child-
|
||||
Prozesstests verlangen jetzt ausdrücklich die verifizierte gebündelte Node-Runtime
|
||||
und besitzen keinen Entwicklungs-Node-Fallback. Fünf Runtime-Manifest-Tests prüfen
|
||||
Version, Hashes, zusätzliche Dateien und Junctions. Kombinierter Lauf: 14/14.
|
||||
|
||||
Der integrierte signierte Managed-Proxy prüft Header/Cookies und volle URLs mit
|
||||
dekodierten Darstellungen. Jeder nicht-textuelle Magic-Typ erfordert vollständige
|
||||
lokale Dokumentanalyse, auch bei UTF-8-Rohbytes. Der begrenzte Multipart-Parser
|
||||
prüft alle Teile: Metadaten, Dateityp und Inhalt müssen freigegeben sein, bevor
|
||||
die ursprünglichen Request-Bytes weitergehen. 13 echte TLS-/Empfängertests und
|
||||
neun Parser-Tests bestehen, ebenso Lint. Ihre Dokument-Scanner sind injizierte
|
||||
Verträge; ein echter AppContainer-/Modell-Nachweis über diesen Broker fehlt noch.
|
||||
Der Managed-Proxy ist weiterhin nicht produktiv im Browser eingebunden.
|
||||
|
||||
Stock-Resolver-Teilnachweis: `MAP * ^NOTFOUND, EXCLUDE 127.0.0.1` verhindert im
|
||||
isolierten offiziellen Electron-44.7.2-Test `Session.resolveHost(example.com)`
|
||||
mit `ERR_NAME_NOT_RESOLVED`, während der explizite Loopback-Proxy weiterhin
|
||||
einen harmlosen Hostname-HTTP-Request empfängt. Der positive Ausgangslauf löst
|
||||
den Namen auf. Das unveränderte Runtime-Binary bleibt hashgleich. Ohne exakte
|
||||
Loopback-Ausnahme scheitert auch der Proxy; diese Einschränkung bleibt sichtbar.
|
||||
Der bisherige RTC-UDP-Ausgangstest empfängt schon ohne Regeln null Pakete und
|
||||
beweist daher keine Sperre. Physische DNS-/mDNS-/LLMNR- und WFP-Umgehungsnachweise
|
||||
fehlen weiterhin. Nachweise: ignorierter `artifacts/stock-resolver-proof`-Ordner.
|
||||
|
||||
Ein zusätzlicher gepaarter nativer STUN-Test verwendet nun einen vorab nur auf
|
||||
die eigene nicht-loopback IPv4 aufgelösten Testhostnamen. Der Ausgangslauf
|
||||
empfängt fünf anhand der STUN-Magic validierte UDP-Pakete; mit derselben
|
||||
Resolver-Regel empfängt der eigene Receiver null Pakete und die Namensauflösung
|
||||
scheitert. Beide unveränderten Stock-Runtime-Läufe enden mit Exit 0 und bestehen
|
||||
den harmlosen HTTP-Proxy-Durchlauf. Das bestätigt den getesteten hostnamebasierten
|
||||
STUN-Pfad; Literal-IP, TURN/TLS, mDNS/LLMNR und physische DNS-Ausleitung sind
|
||||
weiterhin keine bestandenen Abnahmen. Eigene Nachweise bleiben getrennt als
|
||||
`baseline-stun-owned.log`, `mapped-stun-owned.log` und `stun-owned-summary.json`.
|
||||
|
||||
Die unveränderte offizielle Electron-44.7.2-Runtime startet im bisherigen
|
||||
AppContainer-Versuch mit null Capabilities nicht bis `READY`. Auch isolierte
|
||||
Profil-/Temp-Umgebungsvariablen ändern den Fehler nicht. Ein auf den synthetischen
|
||||
Testprozess begrenzter Microsoft-Debugger erfasst `0x80000003`, anschließend
|
||||
`0xC000001D`, bei Runtime-Offset `0x72ac2bb`. Ohne aufgelöste private Symbole
|
||||
beweisen benachbarte Exportnamen keine Fehlerursache. Dieser Versuch beweist
|
||||
weder Browser-Kompatibilität noch DNS-/Netzwerkisolation. Lokale Nachweise liegen
|
||||
ignoriert unter `artifacts/stock-appcontainer-feasibility`.
|
||||
|
||||
Nächste Abnahme: funktionsfähige Windows-Netzwerkgrenze einschließlich DNS,
|
||||
separater authentifizierter Broker mit tatsächlicher HTTPS-Inhaltsprüfung,
|
||||
Integration ohne ungeprüfte Ausweichwege und gemeinsamer Installer-Test auf
|
||||
einem System ohne Entwicklungsruntimes. Die bisherige Schutzmaßnahme bleibt
|
||||
bis zum erfolgreichen Ersatznachweis aktiv; das MVP ist noch nicht abgenommen.
|
||||
|
||||
Der Nutzer hat am 13. Oktober 2026 den bisherigen breiten Funktionsumfang
|
||||
ausdrücklich ersetzt. Dieses Ziel hat Vorrang vor älteren Aufgabenlisten:
|
||||
Ein stabiler, zentral verwalteter Windows-Browser verhindert, dass vertrauliche
|
||||
@@ -161,9 +348,14 @@ isolierten Build-Checkout als Commit
|
||||
`6f8b80fef13bfdc9d9c70df567ebcad722d70467` angewendet, mit genau16 Dateien und
|
||||
separatem Provenienzjournal. Er ist noch nicht kompiliert oder nativ abgenommen;
|
||||
das ausgelieferte Runtime-Binary bleibt unverändert. Aktuelle Build-Grenzen:
|
||||
ATL/MFC wird vom Visual-Studio-Komponentencheck nicht gefunden; der vollständige
|
||||
Windows-Dependency-Sync scheiterte an einer protokollierten Antivirus-Ablehnung
|
||||
des Chromium-Updater-Testpakets. Keine Antivirus-Ausnahme wurde vorgenommen.
|
||||
ATL/MFC fehlte ursprünglich; die normale Microsoft-Installation ist inzwischen
|
||||
abgeschlossen und `vswhere` bestätigt die Komponente in einer vollständigen,
|
||||
startfähigen BuildTools-Instanz. Der ursprüngliche vollständige Windows-Sync
|
||||
scheiterte an einer Antivirus-Ablehnung des Chromium-Updater-Testpakets. Ein
|
||||
separat geprüftes CIPD-Manifest verarbeitete danach39 unveränderte Bindings mit
|
||||
Exit0 und ließ ausschließlich die benannte Updater-Testfixture aus. Keine Antivirus-
|
||||
Ausnahme wurde vorgenommen. Build-Hooks und GN-Targetabhängigkeiten werden
|
||||
separat geprüft; ein vollständiger neuer Native-Build ist noch nicht belegt.
|
||||
Quellkompatibilität und Strukturprüfungen ersetzen weder Binder-Unittests noch
|
||||
die echten erlaubten und blockierten WebRTC-Übertragungen im neuen Binary.
|
||||
|
||||
|
||||
@@ -74,11 +74,23 @@
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": ".runtime/windows/broker-payload",
|
||||
"to": "broker",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
}
|
||||
],
|
||||
"files": [
|
||||
"apps/browser/**/*",
|
||||
"apps/network-broker/framed-channel.cjs",
|
||||
"packages/core/**/*",
|
||||
{
|
||||
"from": ".runtime/windows/broker-payload.manifest.json",
|
||||
"to": "apps/browser/broker-package-manifest.json"
|
||||
},
|
||||
"package.json"
|
||||
],
|
||||
"directories": {
|
||||
@@ -90,6 +102,13 @@
|
||||
],
|
||||
"icon": "docs/brand/safetynet.ico"
|
||||
},
|
||||
"nsis": {
|
||||
"perMachine": true,
|
||||
"oneClick": false,
|
||||
"allowElevation": true,
|
||||
"allowToChangeInstallationDirectory": false,
|
||||
"include": "scripts/install/windows-security.nsh"
|
||||
},
|
||||
"mac": {
|
||||
"target": [
|
||||
"dmg"
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
'use strict';
|
||||
const {z}=require('zod');
|
||||
const {FileFactsSchema}=require('./transfer-facts.cjs');
|
||||
const {RequestFactsSchema}=require('./request-transfer-facts.cjs');
|
||||
const BROKER_REASONS=Object.freeze(['broker-approved','broker-policy-block','broker-inspection-fault','broker-authority-expired','broker-audit-failed','broker-connect-denied','broker-auth-denied','broker-framing-block','broker-size-block','broker-request-aborted','broker-request-timeout','broker-upstream-failed','broker-response-invalid']);
|
||||
const BrokerFactsSchema=z.object({requestId:z.string().uuid(),connectionId:z.string().uuid().optional(),phase:z.enum(['connect.block','request.authorized','request.block','request.fault','forward.fault']),reason:z.enum(BROKER_REASONS),authorizedEventId:z.string().uuid().optional()}).strict();
|
||||
const BROKER_REASONS=Object.freeze(['broker-approved','broker-policy-block','broker-inspection-fault','broker-authority-expired','broker-audit-failed','broker-connect-denied','broker-auth-denied','broker-framing-block','broker-size-block','broker-request-aborted','broker-request-timeout','broker-upstream-failed','broker-response-invalid','broker-scan-timeout','broker-download-approved','broker-download-policy-block','broker-download-inspection-block']);
|
||||
const BrokerFactsSchema=z.object({requestId:z.string().uuid(),connectionId:z.string().uuid().optional(),phase:z.enum(['connect.block','request.authorized','request.block','request.fault','forward.fault','response.scanned','response.block']),reason:z.enum(BROKER_REASONS),authorizedEventId:z.string().uuid().optional()}).strict();
|
||||
const phaseReasons=Object.freeze({
|
||||
'connect.block':['broker-connect-denied','broker-auth-denied','broker-framing-block','broker-authority-expired','broker-audit-failed','broker-request-timeout','broker-size-block','broker-request-aborted'],
|
||||
'request.authorized':['broker-approved'],
|
||||
'request.block':['broker-policy-block','broker-authority-expired','broker-framing-block','broker-size-block','broker-request-aborted','broker-request-timeout'],
|
||||
'request.block':['broker-scan-timeout','broker-policy-block','broker-authority-expired','broker-framing-block','broker-size-block','broker-request-aborted','broker-request-timeout'],
|
||||
'request.fault':['broker-inspection-fault','broker-audit-failed'],
|
||||
'forward.fault':['broker-upstream-failed','broker-response-invalid','broker-request-aborted','broker-request-timeout','broker-authority-expired','broker-audit-failed']
|
||||
'response.scanned':['broker-download-approved'],
|
||||
'response.block':['broker-download-policy-block','broker-download-inspection-block'],
|
||||
'forward.fault':['broker-scan-timeout','broker-upstream-failed','broker-response-invalid','broker-request-aborted','broker-request-timeout','broker-authority-expired','broker-audit-failed']
|
||||
});
|
||||
function validateBrokerSemantics(event,context){
|
||||
if(!event.broker)return;
|
||||
const value=event.broker;
|
||||
if(!event.request||event.file||event.kind!==(value.phase==='request.authorized'?'upload.authorized':'upload.block')||!phaseReasons[value.phase].includes(value.reason)||value.phase==='forward.fault'&&!value.authorizedEventId||value.phase!=='forward.fault'&&value.authorizedEventId||value.authorizedEventId===event.eventId){
|
||||
const download=event.kind==='download.scan'||event.kind==='download.block',linked=value.phase==='forward.fault'||value.phase.startsWith('response.');
|
||||
const expectedKind=value.phase==='request.authorized'?'upload.authorized':value.phase==='response.scanned'?'download.scan':value.phase==='response.block'?'download.block':'upload.block';
|
||||
if(download?(!event.file||event.request||!value.phase.startsWith('response.')):(!event.request||event.file||value.phase.startsWith('response.')))context.addIssue({code:'custom',path:['broker'],message:'Broker transfer facts rejected'});
|
||||
if(event.kind!==expectedKind||!phaseReasons[value.phase].includes(value.reason)||linked&&!value.authorizedEventId||!linked&&value.authorizedEventId||value.authorizedEventId===event.eventId){
|
||||
context.addIssue({code:'custom',path:['broker'],message:'Broker lifecycle semantics rejected'});
|
||||
}
|
||||
}
|
||||
const BrokerAuditEventSchema=z.object({eventId:z.string().uuid(),kind:z.enum(['upload.authorized','upload.block']),ruleId:z.string().min(1).max(100),policyVersion:z.number().int().min(1).max(Number.MAX_SAFE_INTEGER),host:z.string().max(253).optional(),request:RequestFactsSchema,broker:BrokerFactsSchema.optional(),traceId:z.string().regex(/^[a-f0-9]{32}$/).optional(),spanId:z.string().regex(/^[a-f0-9]{16}$/).optional()}).strict().superRefine(validateBrokerSemantics);
|
||||
const BrokerAuditEventSchema=z.object({eventId:z.string().uuid(),kind:z.enum(['upload.authorized','upload.block','download.scan','download.block']),ruleId:z.string().min(1).max(100),policyVersion:z.number().int().min(1).max(Number.MAX_SAFE_INTEGER),host:z.string().max(253).optional(),request:RequestFactsSchema.optional(),file:FileFactsSchema.optional(),broker:BrokerFactsSchema.optional(),traceId:z.string().regex(/^[a-f0-9]{32}$/).optional(),spanId:z.string().regex(/^[a-f0-9]{16}$/).optional()}).strict().superRefine((event,context)=>{validateBrokerSemantics(event,context);const download=event.kind.startsWith('download.');if(download?(!event.file||event.request):(!event.request||event.file))context.addIssue({code:'custom',message:'Exclusive transfer facts required'});});
|
||||
module.exports={BrokerAuditEventSchema,BrokerFactsSchema,BROKER_REASONS,validateBrokerSemantics};
|
||||
@@ -0,0 +1,75 @@
|
||||
'use strict';
|
||||
const {createHash}=require('node:crypto');
|
||||
const {detectType}=require('./file-transfer.cjs');
|
||||
// Typed binary carriers supplement text normalization. No whole decoded file
|
||||
// may be released on the strength of an allow verdict for its outer JSON text.
|
||||
function extractEncodedDocuments(bytes,{maxDecodedBytes=16777216,maxParts=16,maxNodes=20000,maxDepth=32,jsonRequired=false}={}){
|
||||
if(!Buffer.isBuffer(bytes)||bytes.length>16777216||![maxDecodedBytes,maxParts,maxNodes,maxDepth].every(Number.isSafeInteger)||maxDecodedBytes<1||maxDecodedBytes>16777216||maxParts<1||maxParts>64||maxNodes<1||maxNodes>20000||maxDepth<1||maxDepth>32)throw Error('encoded-document-configuration-invalid');
|
||||
if(typeof jsonRequired!=='boolean')throw Error('encoded-document-configuration-invalid');
|
||||
const parts=[],seen=new Set(),encodings=new Set();let total=0,nodes=0,complete=true;
|
||||
const result=()=>Object.freeze({parts:Object.freeze(parts),complete,decodedBytes:total,visitedNodes:nodes,dispose});
|
||||
const dispose=()=>{for(const part of parts)part.bytes.fill(0);};
|
||||
function candidate(value){
|
||||
if(!complete||typeof value!=='string')return;
|
||||
let encoded=value.trim(),explicit=false,mime;
|
||||
if(/^data:/i.test(encoded)){
|
||||
const match=/^data:([a-z0-9.+/-]+)?(?:;[a-z0-9-]+=[a-z0-9._-]+)*;base64,([\s\S]*)$/i.exec(encoded);
|
||||
if(!match){complete=false;return;}mime=(match[1]||'application/octet-stream').toLowerCase();encoded=match[2];explicit=true;
|
||||
if(encoded.includes('%')){try{encoded=decodeURIComponent(encoded);}catch{complete=false;return;}}
|
||||
}
|
||||
if(!explicit&&encoded.length<16)return;
|
||||
if(!/^[A-Za-z0-9+/_-]*={0,2}$/.test(encoded)||encoded.length%4===1){if(explicit)complete=false;return;}
|
||||
const normalized=encoded.replace(/-/g,'+').replace(/_/g,'/').replace(/=+$/,'');
|
||||
const encodingKey=createHash('sha256').update(normalized).update('\0'+(mime||'')).digest('hex');
|
||||
if(encodings.has(encodingKey))return;
|
||||
const projected=Math.floor(normalized.length*3/4);
|
||||
if(total+projected>maxDecodedBytes){complete=false;return;}
|
||||
const decoded=Buffer.from(normalized,'base64');
|
||||
total+=decoded.length;encodings.add(encodingKey);
|
||||
const type=detectType(decoded);
|
||||
if(decoded.toString('base64').replace(/=+$/,'')!==normalized){decoded.fill(0);if(explicit||type!=='unknown')complete=false;return;}
|
||||
if(!explicit&&type==='unknown'){decoded.fill(0);return;}
|
||||
const digest=createHash('sha256').update(decoded).digest('hex');
|
||||
const identity=digest+'\0'+(mime||'');
|
||||
if(seen.has(identity)){decoded.fill(0);return;}
|
||||
if(parts.length>=maxParts){decoded.fill(0);complete=false;return;}
|
||||
seen.add(identity);parts.push(Object.freeze({bytes:decoded,...(mime?{mime}:{})}));
|
||||
}
|
||||
function scanText(value,percentDepth=0){
|
||||
candidate(value);
|
||||
const carriers=[];
|
||||
for(const match of value.matchAll(/data:[^\s,"'<>]+,[A-Za-z0-9+/_=%-]+/gi)){if(++nodes>maxNodes){complete=false;break;}carriers.push([match.index,match.index+match[0].length]);candidate(match[0]);}
|
||||
let carrier=0;
|
||||
for(const match of value.matchAll(/[A-Za-z0-9+/_-]{16,}={0,2}/g)){
|
||||
while(carrier<carriers.length&&carriers[carrier][1]<=match.index)carrier++;
|
||||
if(carrier<carriers.length&&match.index>=carriers[carrier][0]&&match.index<carriers[carrier][1])continue;
|
||||
if(++nodes>maxNodes){complete=false;break;}candidate(match[0]);
|
||||
}
|
||||
if(complete&&value.includes('%')){
|
||||
if(percentDepth>=4){complete=false;return;}
|
||||
try{const decoded=decodeURIComponent(value);if(decoded!==value)scanText(decoded,percentDepth+1);}catch{if(/data%3a/i.test(value))complete=false;}
|
||||
}
|
||||
}
|
||||
try{
|
||||
const text=new TextDecoder('utf-8',{fatal:true}).decode(bytes),trimmed=text.trim();
|
||||
if(jsonRequired||trimmed.startsWith('{')||trimmed.startsWith('[')){
|
||||
let root;try{root=JSON.parse(trimmed);}catch{if(jsonRequired)complete=false;else scanText(trimmed);return result();}
|
||||
const stack=[{value:root,depth:0}];
|
||||
while(stack.length&&complete){const {value,depth}=stack.pop();if(++nodes>maxNodes||depth>maxDepth){complete=false;break;}
|
||||
if(typeof value==='string')scanText(value);
|
||||
else if(Array.isArray(value)){
|
||||
if(value.length>maxNodes-nodes||stack.length+value.length>maxNodes){complete=false;break;}
|
||||
for(const child of value)stack.push({value:child,depth:depth+1});
|
||||
}else if(value&&typeof value==='object'){
|
||||
// Avoid Object.entries allocating an unbounded array of key/value pairs
|
||||
// before the traversal budget is checked on a hostile JSON collection.
|
||||
for(const key in value){if(!Object.hasOwn(value,key))continue;scanText(key);stack.push({value:value[key],depth:depth+1});if(stack.length>maxNodes||!complete){complete=false;break;}}
|
||||
}
|
||||
}
|
||||
}else{
|
||||
scanText(trimmed);
|
||||
}
|
||||
}catch{complete=false;}
|
||||
return result();
|
||||
}
|
||||
module.exports={extractEncodedDocuments};
|
||||
@@ -20,7 +20,7 @@ export function toECS(event,installationId='safetynet') {
|
||||
'@timestamp':event.time,ecs:{version:'8.17.0'},
|
||||
event:{id:`${installationId}:${event.id}`,kind:alert?'alert':'event',module:'safetynet',dataset:'safetynet.security',
|
||||
action:event.kind,category:[alert?'intrusion_detection':auth?'authentication':config?'configuration':clipboard?'process':'web'],
|
||||
type:[transportEvent||alert||interaction?.outcome==='observed'?'info':failure||brokerFailure?'error':auth?'start':config?'change':block?'denied':clipboard?'access':'allowed'],outcome:transportEvent||interaction?.outcome==='observed'||broker?.phase==='request.authorized'?'unknown':failure||brokerFailure?'failure':'success',...(alert?{severity:severity(m.level)}:{})},
|
||||
type:[transportEvent||alert||interaction?.outcome==='observed'?'info':failure||brokerFailure?'error':auth?'start':config?'change':block?'denied':clipboard?'access':'allowed'],outcome:transportEvent||interaction?.outcome==='observed'||broker?.phase==='request.authorized'?'unknown':failure||brokerFailure||broker?.phase==='response.block'?'failure':'success',...(alert?{severity:severity(m.level)}:{})},
|
||||
observer:{vendor:'SafetyNet',product:'Managed Enterprise Browser',type:'dlp',version:'0.1.0'},
|
||||
...(event.device_id?{host:{id:event.device_id}}:{}),
|
||||
...(m.actor?{user:{id:m.actor}}:{}),...(m.host&&(!transportEvent||transport)?{destination:{domain:m.host}}:{}),
|
||||
@@ -53,7 +53,7 @@ export function toCEF(event,installationId='safetynet') {
|
||||
...(event.kind.startsWith('download.')&&facts(m)?{cn2Label:'FileBytes',cn2:facts(m).bytes,cs6Label:'DetectedFileType',cs6:facts(m).detectedType,fileHash:facts(m).sha256}:{}),
|
||||
...(/^[a-f0-9]{32}$/.test(m.traceId||'')?{cs4Label:'TraceId',cs4:m.traceId}:{}),
|
||||
...(/^[a-f0-9]{16}$/.test(m.spanId||'')?{cs5Label:'SpanId',cs5:m.spanId}:{}),
|
||||
...(broker?{cs2Label:'BrokerLifecycle',cs2:JSON.stringify(broker),cs3Label:'BrokerEventId',cs3:broker.eventId,requestContext:broker.requestId,reason:broker.reason,outcome:broker.phase==='request.authorized'?'unknown':['request.fault','forward.fault'].includes(broker.phase)?'failure':'success'}:{}),
|
||||
...(broker?{cs2Label:'BrokerLifecycle',cs2:JSON.stringify(broker),cs3Label:'BrokerEventId',cs3:broker.eventId,requestContext:broker.requestId,reason:broker.reason,outcome:broker.phase==='request.authorized'?'unknown':['request.fault','forward.fault','response.block'].includes(broker.phase)?'failure':'success'}:{}),
|
||||
...(alert?{cs2Label:'SourceEventId',cs2:`${installationId}:${m.sourceEventId}`,cs3Label:'AlertLevel',cs3:m.level}:{})};
|
||||
return `CEF:0|SafetyNet|Enterprise Browser|0.1.0|${header(event.kind)}|${header(event.kind)}|${alert?severity(m.level):!transportEvent&&blocked?7:3}|`+Object.entries(fields).map(([k,v])=>`${k}=${ext(v)}`).join(' ');
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
'use strict';
|
||||
// Signed external manifests avoid embedding the final browser hash in its own
|
||||
// native helper. This classical P-256 contract is not a post-quantum signature.
|
||||
const {createPublicKey,createPrivateKey,createHash,sign,verify}=require('node:crypto');
|
||||
const {isIP}=require('node:net');
|
||||
const MAX_RELEASE_BYTES=2097152,MAX_DEPLOYMENT_BYTES=262144;
|
||||
const DOMAINS=Object.freeze({release:'SafetyNet Windows release v1\0',deployment:'SafetyNet Windows deployment v1\0'});
|
||||
const verifiedReleases=new WeakSet(),verifiedDeployments=new WeakSet();
|
||||
const MANAGEMENT_PROTOCOL=require('./policy-channel.cjs').VERSION;
|
||||
const UUID=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
function fail(code){throw Object.assign(new Error(code),{code});}
|
||||
function exact(value,keys){return value&&typeof value==='object'&&!Array.isArray(value)&&Object.getPrototypeOf(value)===Object.prototype&&Object.keys(value).sort().join(',')===keys.slice().sort().join(',');}
|
||||
function positive(value){return Number.isSafeInteger(value)&&value>0;}
|
||||
function relative(value){return typeof value==='string'&&value.length<=240&&value.split('/').every(part=>/^[A-Za-z0-9_.-]+$/.test(part)&&part!=='.'&&part!=='..'&&!/[. ]$/.test(part)&&!/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part));}
|
||||
function freeze(value){if(value&&typeof value==='object'){for(const item of Object.values(value))freeze(item);Object.freeze(value);}return value;}
|
||||
function canonical(value){if(Array.isArray(value))return '['+value.map(canonical).join(',')+']';if(value&&typeof value==='object')return '{'+Object.keys(value).sort().map(key=>JSON.stringify(key)+':'+canonical(value[key])).join(',')+'}';return JSON.stringify(value);}
|
||||
function publicKey(pem,type){
|
||||
if(typeof pem!=='string'||pem.length>(type==='ml-dsa-87'?20000:4096)||!pem.startsWith('-----BEGIN PUBLIC KEY-----\n'))fail('windows-trust-key-invalid');
|
||||
const key=createPublicKey(pem);if(key.export({format:'pem',type:'spki'})!==pem||key.asymmetricKeyType!==type||type==='ec'&&key.asymmetricKeyDetails?.namedCurve!=='prime256v1')fail('windows-trust-key-invalid');return key;
|
||||
}
|
||||
function releaseSchema(value){
|
||||
if(!exact(value,['format','releaseId','releaseSequence','serviceName','deploymentTenant','deploymentPublicKey','files'])||value.format!=='safetynet-windows-release-v1'||!UUID.test(value.releaseId)||!UUID.test(value.deploymentTenant)||!positive(value.releaseSequence)||value.serviceName!=='SafetyNetNetworkBroker'||!Array.isArray(value.files)||!value.files.length||value.files.length>10000)fail('windows-release-invalid');
|
||||
publicKey(value.deploymentPublicKey,'ec');let previous='';const seen=new Set();
|
||||
for(const file of value.files){if(!exact(file,['path','bytes','sha256'])||!relative(file.path)||!Number.isSafeInteger(file.bytes)||file.bytes<0||!/^[a-f0-9]{64}$/.test(file.sha256)||file.path<=previous||seen.has(file.path.toLowerCase()))fail('windows-release-invalid');previous=file.path;seen.add(file.path.toLowerCase());}
|
||||
for(const required of ['SafetyNet.exe','safetynet-service-host.exe','resources/app.asar','resources/broker/node.exe','resources/python/python.exe','resources/inspector/server.py'])if(!value.files.some(file=>file.path===required))fail('windows-release-invalid');
|
||||
if(!value.files.some(file=>file.path.startsWith('resources/semantic/'))||value.files.some(file=>['release-envelope.json','deployment-envelope.json'].includes(file.path)))fail('windows-release-invalid');
|
||||
return value;
|
||||
}
|
||||
function managementSchema(value){
|
||||
if(!exact(value,['origin','signatureKey','protocol'])||typeof value.origin!=='string'||value.origin.length>2048||[...value.origin].some(character=>character.charCodeAt(0)<=32||character.charCodeAt(0)>=127)||value.protocol!==MANAGEMENT_PROTOCOL)fail('windows-management-invalid');
|
||||
const url=new URL(value.origin);
|
||||
if(url.protocol!=='https:'||url.origin!==value.origin||url.username||url.password||url.search||url.hash||url.pathname!=='/'||url.hostname.endsWith('.'))fail('windows-management-invalid');
|
||||
const hostname=url.hostname.startsWith('[')?url.hostname.slice(1,-1):url.hostname;
|
||||
if(!isIP(hostname)&&(hostname.length>253||hostname.split('.').some(label=>!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)||/^0x[0-9a-f]+$/i.test(label))))fail('windows-management-invalid');
|
||||
publicKey(value.signatureKey,'ml-dsa-87');return value;
|
||||
}
|
||||
function deploymentSchema(value){
|
||||
const v2=value?.format==='safetynet-windows-deployment-v2';
|
||||
if(!exact(value,['format','releaseId','deploymentTenant','deploymentSequence','policyPublicKey','expiresAt','users',...(v2?['management']:[])])||!['safetynet-windows-deployment-v1','safetynet-windows-deployment-v2'].includes(value.format)||!UUID.test(value.releaseId)||!UUID.test(value.deploymentTenant)||!positive(value.deploymentSequence)||!positive(value.expiresAt)||!Array.isArray(value.users)||!value.users.length||value.users.length>1024)fail('windows-deployment-invalid');
|
||||
if(v2)managementSchema(value.management);
|
||||
publicKey(value.policyPublicKey,'ed25519');let previous='';const devices=new Set();
|
||||
for(const user of value.users){if(!exact(user,['sid','deviceId','minimumPolicyVersion'])||typeof user.sid!=='string'||!/^(?:S-1-5-21|S-1-12-1)-(?:(?:0|[1-9][0-9]{0,9})-){3}(?:0|[1-9][0-9]{0,9})$/.test(user.sid)||user.sid.split('-').slice(4).some(part=>Number(part)>4294967295)||user.sid<=previous||typeof user.deviceId!=='string'||!/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(user.deviceId)||devices.has(user.deviceId)||!positive(user.minimumPolicyVersion))fail('windows-deployment-invalid');previous=user.sid;devices.add(user.deviceId);}
|
||||
return value;
|
||||
}
|
||||
function decodeEnvelope(envelope,kind,key){
|
||||
const cap=kind==='release'?MAX_RELEASE_BYTES:MAX_DEPLOYMENT_BYTES;
|
||||
if(!exact(envelope,['payload','signature'])||typeof envelope.payload!=='string'||envelope.payload.length>Math.ceil(cap*4/3)||! /^[A-Za-z0-9_-]+$/.test(envelope.payload)||typeof envelope.signature!=='string'||! /^[A-Za-z0-9_-]{86}$/.test(envelope.signature))fail('windows-trust-envelope-invalid');
|
||||
const bytes=Buffer.from(envelope.payload,'base64url'),signature=Buffer.from(envelope.signature,'base64url');
|
||||
if(bytes.length>cap||bytes.toString('base64url')!==envelope.payload||signature.length!==64||signature.toString('base64url')!==envelope.signature||!verify('sha256',Buffer.concat([Buffer.from(DOMAINS[kind]),bytes]),{key,dsaEncoding:'ieee-p1363'},signature))fail('windows-trust-signature-invalid');
|
||||
const text=new TextDecoder('utf-8',{fatal:true}).decode(bytes),value=JSON.parse(text);if(canonical(value)!==text)fail('windows-trust-canonical-invalid');
|
||||
return {value,digest:createHash('sha256').update(bytes).digest('hex')};
|
||||
}
|
||||
function checkFloor(sequence,digest,{minimumSequence=0,acceptedDigest}={}){
|
||||
if(!Number.isSafeInteger(minimumSequence)||minimumSequence<0||acceptedDigest!==undefined&&!/^[a-f0-9]{64}$/.test(acceptedDigest))fail('windows-trust-floor-invalid');
|
||||
if(sequence<minimumSequence||sequence===minimumSequence&&acceptedDigest!==undefined&&digest!==acceptedDigest)fail('windows-trust-rollback');
|
||||
}
|
||||
function verifyReleaseEnvelope(envelope,{publicKey:anchor,floor}={}){
|
||||
try{const decoded=decodeEnvelope(envelope,'release',publicKey(anchor,'ec'));releaseSchema(decoded.value);checkFloor(decoded.value.releaseSequence,decoded.digest,floor);const result=freeze({manifest:decoded.value,digest:decoded.digest});verifiedReleases.add(result);return result;}catch(error){if(error.code?.startsWith('windows-'))throw error;fail('windows-release-invalid');}
|
||||
}
|
||||
function verifyDeploymentEnvelope(envelope,{release,floor,now=Date.now(),authenticatedSid}={}){
|
||||
try{if(!verifiedReleases.has(release)||!Number.isSafeInteger(now)||now<0)fail('windows-deployment-invalid');const decoded=decodeEnvelope(envelope,'deployment',publicKey(release.manifest.deploymentPublicKey,'ec'));deploymentSchema(decoded.value);const value=decoded.value;if(value.releaseId!==release.manifest.releaseId||value.deploymentTenant!==release.manifest.deploymentTenant||value.expiresAt<=now)fail('windows-deployment-binding-invalid');checkFloor(value.deploymentSequence,decoded.digest,floor);const user=value.users.find(item=>item.sid===authenticatedSid);if(!user)fail('windows-deployment-user-denied');const result=freeze({manifest:value,digest:decoded.digest,bootstrap:{publicKey:value.policyPublicKey,deviceId:user.deviceId,minimumVersion:user.minimumPolicyVersion}});verifiedDeployments.add(result);return result;}catch(error){if(error.code?.startsWith('windows-'))throw error;fail('windows-deployment-invalid');}
|
||||
}
|
||||
function verifiedManagementBinding(deployment){
|
||||
if(!verifiedDeployments.has(deployment)||deployment.manifest.format!=='safetynet-windows-deployment-v2'||deployment.manifest.expiresAt<=Date.now())fail('windows-management-denied');
|
||||
return deployment.manifest.management;
|
||||
}
|
||||
function signManifest(value,privatePem,kind){
|
||||
try{if(!Object.hasOwn(DOMAINS,kind))fail('windows-trust-kind-invalid');(kind==='release'?releaseSchema:deploymentSchema)(value);const payload=Buffer.from(canonical(value)),key=createPrivateKey(privatePem);if(key.asymmetricKeyType!=='ec'||key.asymmetricKeyDetails?.namedCurve!=='prime256v1')fail('windows-trust-key-invalid');if(payload.length>(kind==='release'?MAX_RELEASE_BYTES:MAX_DEPLOYMENT_BYTES))fail('windows-trust-envelope-invalid');return Object.freeze({payload:payload.toString('base64url'),signature:sign('sha256',Buffer.concat([Buffer.from(DOMAINS[kind]),payload]),{key,dsaEncoding:'ieee-p1363'}).toString('base64url')});}catch(error){if(error.code?.startsWith('windows-'))throw error;fail('windows-trust-signing-invalid');}
|
||||
}
|
||||
function publicKeyCoordinates(pem){try{const key=publicKey(pem,'ec'),jwk=key.export({format:'jwk'});return Buffer.concat([Buffer.from(jwk.x,'base64url'),Buffer.from(jwk.y,'base64url')]);}catch{fail('windows-trust-key-invalid');}}
|
||||
module.exports={verifyReleaseEnvelope,verifyDeploymentEnvelope,signManifest,publicKeyCoordinates,verifiedManagementBinding,DOMAINS};
|
||||
@@ -0,0 +1,95 @@
|
||||
// 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');}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Compile an owned native SCM helper; never installs services or Windows rules.
|
||||
import {spawn,execFileSync} from 'node:child_process';
|
||||
import {readFile,writeFile,mkdir,lstat,realpath} from 'node:fs/promises';
|
||||
import {createHash,generateKeyPairSync,sign} from 'node:crypto';
|
||||
import {createRequire} from 'node:module';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
|
||||
const args=process.argv.slice(2),compileOnly=args.length===1&&args[0]==='--compile-only',selfTest=args.length===1&&(args[0]==='--self-test'||compileOnly);
|
||||
const hash=bytes=>createHash('sha256').update(bytes).digest('hex');
|
||||
const fail=()=>{throw Error('native-service-build-invalid');};
|
||||
const {signManifest,publicKeyCoordinates}=createRequire(import.meta.url)('../packages/core/windows-trust.cjs');
|
||||
const {encodeAcceptanceState}=createRequire(import.meta.url)('../apps/network-broker/service-acceptance.cjs');
|
||||
async function safe(directory){const info=await lstat(directory);if(info.isSymbolicLink()||await realpath(directory)!==directory)fail();}
|
||||
async function run(executable,arguments_,expectedCode=0){const started=performance.now();return await new Promise((resolve,reject)=>{const child=spawn(executable,arguments_,{cwd:root,windowsHide:true,stdio:['ignore','pipe','pipe'],shell:false});let bytes=0;for(const stream of [child.stdout,child.stderr])stream.on('data',chunk=>{bytes+=chunk.length;if(bytes>65536){child.kill();reject(Error('native-build-output-limit'));}else process.stdout.write(chunk);});const timer=setTimeout(()=>{child.kill();reject(Error('native-build-timeout'));},60000);child.once('error',error=>{error.nativeStage=path.basename(executable);reject(error);});child.once('exit',code=>{clearTimeout(timer);(code===expectedCode)?resolve({code,milliseconds:performance.now()-started}):reject(Error('native-build-failed'));});});}
|
||||
async function main(){
|
||||
if(process.platform!=='win32'||!selfTest)fail();
|
||||
const output=path.join(root,'artifacts/native-service-host');await mkdir(output,{recursive:true});await safe(path.join(root,'artifacts'));await safe(output);
|
||||
const vswhere='C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe';
|
||||
const installation=execFileSync(vswhere,['-latest','-products','*','-requires','Microsoft.VisualStudio.Component.VC.Tools.x86.x64','-property','installationPath'],{encoding:'utf8',windowsHide:true,timeout:10000}).trim();if(!installation)fail();
|
||||
const version=(await readFile(path.join(installation,'VC/Auxiliary/Build/Microsoft.VCToolsVersion.default.txt'),'utf8')).trim();if(!/^\d+\.\d+\.\d+$/.test(version))fail();
|
||||
const tool=path.join(installation,'VC/Tools/MSVC',version),sdk='C:/Program Files (x86)/Windows Kits/10',sdkVersion='10.0.26100.0';
|
||||
const vendor=generateKeyPairSync('ec',{namedCurve:'prime256v1'}),customer=generateKeyPairSync('ec',{namedCurve:'prime256v1'}),policy=generateKeyPairSync('ed25519');
|
||||
const publicPem=key=>key.export({format:'pem',type:'spki'}),privatePem=key=>key.export({format:'pem',type:'pkcs8'});
|
||||
const header='static constexpr char RELEASE_ANCHOR_XY[]="'+publicKeyCoordinates(publicPem(vendor.publicKey)).toString('hex')+'";';
|
||||
await writeFile(path.join(output,'service-pins.h'),header);
|
||||
const source=path.join(root,'apps/network-broker/native/service-host.cpp'),nativeHeader=path.join(root,'apps/network-broker/native/signed-manifest.h'),stateHeader=path.join(root,'apps/network-broker/native/acceptance-state.h'),topologyHeader=path.join(root,'apps/network-broker/native/wfp-topology.h'),credentialHeader=path.join(root,'apps/network-broker/native/management-credential.h'),beforeCredentialHeader=hash(await readFile(credentialHeader)),before=hash(await readFile(source)),beforeHeader=hash(await readFile(nativeHeader)),beforeStateHeader=hash(await readFile(stateHeader)),beforeTopologyHeader=hash(await readFile(topologyHeader));
|
||||
const executable=path.join(output,'safetynet-service-host-test.exe');
|
||||
const compiler=path.join(tool,'bin/Hostx64/x64/cl.exe');
|
||||
const arguments_=['/nologo','/std:c++17','/W4','/WX','/EHsc','/O2','/MT','/guard:cf','/DUNICODE','/D_UNICODE','/DSAFETYNET_NATIVE_SELF_TEST',... [output,path.join(tool,'include'),...['ucrt','um','shared'].map(v=>path.join(sdk,'Include',sdkVersion,v))].map(v=>'/I'+v),'/Fo'+path.join(output,'service-host.obj'),'/Fe'+executable,source,'/link','/DYNAMICBASE','/NXCOMPAT','/HIGHENTROPYVA',... [path.join(tool,'lib/x64'),...['ucrt','um'].map(v=>path.join(sdk,'Lib',sdkVersion,v,'x64'))].map(v=>'/LIBPATH:'+v)];
|
||||
const stages={};stages.compile=await run(compiler,arguments_);process.stdout.write('native.compile.completed\n');const compiledStat=await lstat(executable);process.stdout.write('native.compile.output.bytes='+compiledStat.size+'\n');const compiledHash=hash(await readFile(executable));process.stdout.write('native.compile.output.sha256='+compiledHash+'\n');if(hash(await readFile(source))!==before||hash(await readFile(nativeHeader))!==beforeHeader||hash(await readFile(stateHeader))!==beforeStateHeader||hash(await readFile(topologyHeader))!==beforeTopologyHeader||hash(await readFile(credentialHeader))!==beforeCredentialHeader)fail();if(compileOnly){await writeFile(path.join(output,'current-compile-only-proof.json'),JSON.stringify({compiled:true,selfTestExecuted:false,installed:false,wfpActivated:false,helperSha256:compiledHash,sourceSha256:before,nativeHeaderSha256:beforeHeader,stateHeaderSha256:beforeStateHeader,topologyHeaderSha256:beforeTopologyHeader,credentialHeaderSha256:beforeCredentialHeader,stages},null,2));return;}process.stdout.write('native.selftest.starting\n');stages.pipeJob=await run(executable,['--self-test']);stages.credentialSelection=await run(executable,['--verify-credential-selection']);
|
||||
const helper=await readFile(executable),releaseId='11111111-1111-1111-1111-111111111111',deploymentTenant='22222222-2222-2222-2222-222222222222';
|
||||
const fixturePaths=['SafetyNet.exe','safetynet-service-host.exe','resources/app.asar','resources/broker/node.exe','resources/python/python.exe','resources/inspector/server.py','resources/semantic/synthetic.bin'];
|
||||
const files=[{path:'safetynet-service-host-test.exe',bytes:helper.length,sha256:hash(helper)}];
|
||||
for(const name of fixturePaths){const destination=path.join(output,name);await mkdir(path.dirname(destination),{recursive:true});const synthetic=Buffer.from('fixed native signed inventory fixture');await writeFile(destination,synthetic);files.push({path:name,bytes:synthetic.length,sha256:hash(synthetic)});}
|
||||
files.sort((a,b)=>a.path<b.path?-1:a.path>b.path?1:0);
|
||||
const release={format:'safetynet-windows-release-v1',releaseId,releaseSequence:2,serviceName:'SafetyNetNetworkBroker',deploymentTenant,deploymentPublicKey:publicPem(customer.publicKey),files};
|
||||
const deployment={format:'safetynet-windows-deployment-v1',releaseId,deploymentTenant,deploymentSequence:2,policyPublicKey:publicPem(policy.publicKey),expiresAt:Date.now()+60000,users:[{sid:'S-1-5-21-1-2-3-1001',deviceId:'synthetic-native',minimumPolicyVersion:3}]};
|
||||
const releaseEnvelope=signManifest(release,privatePem(vendor.privateKey),'release'),deploymentEnvelope=signManifest(deployment,privatePem(customer.privateKey),'deployment');
|
||||
await writeFile(path.join(output,'release-envelope.json'),JSON.stringify(releaseEnvelope));await writeFile(path.join(output,'deployment-envelope.json'),JSON.stringify(deploymentEnvelope));
|
||||
stages.trustPositive=await run(executable,['--verify-fixture']);
|
||||
// Native negatives are genuinely customer-signed, intentionally bypassing
|
||||
// JS schema validation so the independent C++ parser must reject them.
|
||||
const canonical=value=>Array.isArray(value)?'['+value.map(canonical).join(',')+']':value&&typeof value==='object'?'{'+Object.keys(value).sort().map(key=>JSON.stringify(key)+':'+canonical(value[key])).join(',')+'}':JSON.stringify(value);
|
||||
const rawDeployment=value=>{const bytes=Buffer.from(canonical(value));return {payload:bytes.toString('base64url'),signature:sign('sha256',Buffer.concat([Buffer.from('SafetyNet Windows deployment v1\0'),bytes]),{key:customer.privateKey,dsaEncoding:'ieee-p1363'}).toString('base64url')};};
|
||||
const pq=generateKeyPairSync('ml-dsa-87'),wrongPq=generateKeyPairSync('ml-dsa-65');
|
||||
const management={origin:'https://management.example',signatureKey:publicPem(pq.publicKey),protocol:'safetynet-policy-mlkem1024-mldsa87-v1'};
|
||||
const deploymentV2={...deployment,format:'safetynet-windows-deployment-v2',management};
|
||||
stages.managementV2={};
|
||||
for(const [index,origin] of ['https://management.example','https://management.example:8443','https://127.0.0.1','https://[::1]','https://[::ffff:c0a8:1]'].entries()){
|
||||
await writeFile(path.join(output,'deployment-envelope.json'),JSON.stringify(signManifest({...deploymentV2,management:{...management,origin}},privatePem(customer.privateKey),'deployment')));
|
||||
stages.managementV2['positive'+index]=await run(executable,['--verify-fixture']);
|
||||
}
|
||||
const invalidManagement=[...['http://management.example','https://management.example/','https://management.example/path','https://user:[email protected]','https://management.example?query=1','https://management.example#fragment','https://MANAGEMENT.example','https://management.example:443','https://management.example.','https://management.example:','https://[::1]:','https://[0:0:0:0:0:0:0:1]','https://[::ffff:192.168.0.1]','https://127.1','https://bad_host.example'].map(origin=>({...management,origin})),{...management,protocol:'safetynet-policy-mlkem768-mldsa65-v1'},{...management,signatureKey:publicPem(wrongPq.publicKey)},{...management,signatureKey:publicPem(policy.publicKey)},{...management,signatureKey:management.signatureKey.trim()},{...management,token:'synthetic-denied'}];
|
||||
for(const [index,value] of invalidManagement.entries()){
|
||||
await writeFile(path.join(output,'deployment-envelope.json'),JSON.stringify(rawDeployment({...deploymentV2,management:value})));
|
||||
stages.managementV2['invalid'+index]=await run(executable,['--verify-fixture'],86);
|
||||
}
|
||||
await writeFile(path.join(output,'deployment-envelope.json'),JSON.stringify(rawDeployment({...deploymentV2,format:'safetynet-windows-deployment-v3'})));stages.managementV2.unknownFormat=await run(executable,['--verify-fixture'],86);
|
||||
await writeFile(path.join(output,'deployment-envelope.json'),JSON.stringify(rawDeployment({...deploymentV2,format:'safetynet-windows-deployment-v1'})));stages.managementV2.v1ExtraCapability=await run(executable,['--verify-fixture'],86);
|
||||
const signedV2=signManifest(deploymentV2,privatePem(customer.privateKey),'deployment');
|
||||
await writeFile(path.join(output,'deployment-envelope.json'),JSON.stringify({...signedV2,payload:Buffer.from(canonical({...deploymentV2,management:{...management,origin:'https://attacker.example'}})).toString('base64url')}));stages.managementV2.signatureTamper=await run(executable,['--verify-fixture'],86);
|
||||
await writeFile(path.join(output,'deployment-envelope.json'),JSON.stringify(deploymentEnvelope));stages.managementV2.v1HistoricalPreserved=await run(executable,['--verify-fixture']);
|
||||
const changedPayload=JSON.parse(Buffer.from(releaseEnvelope.payload,'base64url'));changedPayload.releaseSequence=3;
|
||||
await writeFile(path.join(output,'release-envelope.json'),JSON.stringify({...releaseEnvelope,payload:Buffer.from(JSON.stringify(changedPayload)).toString('base64url')}));stages.signatureTamper=await run(executable,['--verify-fixture'],86);
|
||||
await writeFile(path.join(output,'release-envelope.json'),JSON.stringify(signManifest({...release,deploymentPublicKey:publicPem(vendor.publicKey)},privatePem(vendor.privateKey),'release')));stages.wrongDelegatedKey=await run(executable,['--verify-fixture'],86);
|
||||
await writeFile(path.join(output,'release-envelope.json'),JSON.stringify(releaseEnvelope));
|
||||
stages.uninstalledSnapshotRejected=await run(executable,['--trust-snapshot'],5);
|
||||
stages.uninstalledCommitRejected=await run(executable,['--commit-acceptance'],5);
|
||||
await writeFile(path.join(output,'deployment-envelope.json'),JSON.stringify(signManifest({...deployment,expiresAt:Date.now()-1},privatePem(customer.privateKey),'deployment')));stages.expiredDeployment=await run(executable,['--verify-fixture'],86);
|
||||
await writeFile(path.join(output,'deployment-envelope.json'),JSON.stringify(deploymentEnvelope));stages.uninstalledClientRejected=await run(executable,['--client-stdio'],5);
|
||||
stages.sameSequenceDigestRejected=await run(executable,['--verify-equivocation'],86);
|
||||
const manyFiles=[...files,...Array.from({length:500},(_,i)=>({path:`resources/semantic/synthetic-${String(i).padStart(4,'0')}.bin`,bytes:0,sha256:'0'.repeat(64)}))].sort((a,b)=>a.path<b.path?-1:a.path>b.path?1:0);
|
||||
await writeFile(path.join(output,'release-envelope.json'),JSON.stringify(signManifest({...release,files:manyFiles},privatePem(vendor.privateKey),'release')));stages.largeSignedInventory=await run(executable,['--verify-fixture']);
|
||||
await writeFile(path.join(output,'release-envelope.json'),JSON.stringify(releaseEnvelope));
|
||||
const oldState={format:'safetynet-service-floors-v1',release:{minimumSequence:2,acceptedDigest:hash(Buffer.from(releaseEnvelope.payload,'base64url'))},deployment:{tenant:deploymentTenant,minimumSequence:2,acceptedDigest:hash(Buffer.from(deploymentEnvelope.payload,'base64url'))},policies:[{sid:'S-1-5-21-1-2-3-1001',deviceId:'synthetic-native',policyKeySha256:hash(policy.publicKey.export({format:'der',type:'spki'})),minimumVersion:3,acceptedDigest:'a'.repeat(64)}]};
|
||||
await writeFile(path.join(output,'acceptance-old.json'),encodeAcceptanceState(oldState));
|
||||
const nextState=structuredClone(oldState);nextState.policies[0].minimumVersion=4;nextState.policies[0].acceptedDigest='b'.repeat(64);
|
||||
await writeFile(path.join(output,'acceptance-candidate.json'),encodeAcceptanceState(nextState));stages.monotonicState=await run(executable,['--verify-state-fixture']);
|
||||
await writeFile(path.join(output,'acceptance-candidate.json'),encodeAcceptanceState({...nextState,policies:[]}));stages.policyDropRejected=await run(executable,['--verify-state-fixture'],86);
|
||||
const lowered=structuredClone(nextState);lowered.policies[0].minimumVersion=2;await writeFile(path.join(output,'acceptance-candidate.json'),encodeAcceptanceState(lowered));stages.policyRollbackRejected=await run(executable,['--verify-state-fixture'],86);
|
||||
const equivocation=structuredClone(oldState);equivocation.policies[0].acceptedDigest='b'.repeat(64);await writeFile(path.join(output,'acceptance-candidate.json'),encodeAcceptanceState(equivocation));stages.policyEquivocationRejected=await run(executable,['--verify-state-fixture'],86);
|
||||
await writeFile(path.join(output,'proof.json'),JSON.stringify({sourceSha256:before,nativeHeaderSha256:beforeHeader,stateHeaderSha256:beforeStateHeader,topologyHeaderSha256:beforeTopologyHeader,credentialHeaderSha256:beforeCredentialHeader,helperSha256:hash(await readFile(executable)),selfTest:true,installed:false,wfpActivated:false,msvc:version,sdk:sdkVersion,stages},null,2));
|
||||
}
|
||||
main().catch(error=>{process.stderr.write('native-service-build-failed'+(typeof error?.nativeStage==='string'&&/^[a-zA-Z0-9.-]+$/.test(error.nativeStage)?':'+error.nativeStage:'')+(typeof error?.code==='string'&&/^[A-Z_]+$/.test(error.code)?':'+error.code:'')+(typeof error?.syscall==='string'&&/^[a-z_]+$/.test(error.syscall)?':'+error.syscall:'')+'\n');process.exitCode=1;});
|
||||
@@ -0,0 +1,14 @@
|
||||
; Called after resources are copied into the protected per-machine location.
|
||||
; nsExec hides the console and bounds captured output by NSIS_MAX_STRLEN.
|
||||
; Its timeout is an inactivity timeout; the trusted Python helper also owns
|
||||
; a hard wall-clock deadline and kill-on-close Job for its grant descendants.
|
||||
!macro customInstall
|
||||
nsExec::ExecToStack /TIMEOUT=245000 '"$INSTDIR\resources\python\python.exe" -I -B "$INSTDIR\resources\inspector\provision_runtime_acl.py" --provision'
|
||||
Pop $0
|
||||
Pop $1
|
||||
StrCpy $1 ""
|
||||
StrCmp $0 "0" safetynet_runtime_acl_ready
|
||||
SetErrorLevel 1
|
||||
Abort "SafetyNet could not establish the required scanner security permissions. Installation stopped."
|
||||
safetynet_runtime_acl_ready:
|
||||
!macroend
|
||||
@@ -0,0 +1,69 @@
|
||||
// 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;
|
||||
}
|
||||
+60
-17
@@ -1,26 +1,69 @@
|
||||
import {hardenedElectronFuses,verifyPackagedFuses} from './package-fuses.mjs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {readFileSync,readdirSync} from 'node:fs';
|
||||
import {createHash} from 'node:crypto';
|
||||
import {spawnSync} from 'node:child_process';
|
||||
import {readFileSync,readdirSync,writeFileSync,createReadStream} from 'node:fs';
|
||||
import {createHash,randomUUID} from 'node:crypto';
|
||||
import {createRequire} from 'node:module';
|
||||
import path from 'node:path';
|
||||
const platform=process.argv[2];if(platform!=='win'||process.platform!=='win32')throw Error('Windows is the active build target; other platforms are deferred');
|
||||
const runtime=spawnSync(process.env.SAFETYNET_BUILD_PYTHON||'python',['scripts/provision-windows-runtime.py'],{stdio:'inherit'});if(runtime.status!==0)process.exit(runtime.status??1);
|
||||
const semantic=spawnSync(process.env.SAFETYNET_BUILD_PYTHON||'python',['scripts/provision-semantic-runtime.py','--verify'],{stdio:'inherit'});if(semantic.status!==0)process.exit(semantic.status??1);
|
||||
import {provisionBrokerRuntime,BROKER_RUNTIME} from './provision-broker-runtime.mjs';
|
||||
import {prepareBrokerResources,verifyPackagedBroker,embeddedBrokerManifestReader,snapshotScannerResources,verifyScannerResources} from './package-broker-resources.mjs';
|
||||
import {createWindowsPackagePlan} from './windows-package-plan.mjs';
|
||||
import {buildVerifiedInstaller} from './verified-installer.mjs';
|
||||
const platform=process.argv[2];
|
||||
if(platform!=='win'||process.platform!=='win32'||process.argv.slice(3).some(value=>value!=='--unpacked-only'))throw Error('Windows is the active build target; only --unpacked-only is additionally supported');
|
||||
const workspace=path.resolve('.');
|
||||
for(const args of [['scripts/provision-windows-runtime.py'],['scripts/provision-semantic-runtime.py','--verify']]){
|
||||
const result=spawnSync(process.env.SAFETYNET_BUILD_PYTHON||'python',args,{stdio:'inherit'});if(result.status!==0)process.exit(result.status??1);
|
||||
}
|
||||
await provisionBrokerRuntime(workspace);
|
||||
const name=process.env.BRAND_NAME||'SafetyNet',id=process.env.BRAND_APP_ID||'enterprise.safetynet.browser';
|
||||
if(!/^[\w .-]{1,40}$/.test(name)||!/^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9]*){2,}$/.test(id))throw Error('Invalid white-label product name or app ID');
|
||||
const configuration=JSON.parse(readFileSync('package.json','utf8')),workspace=path.resolve('.'),output=path.resolve(configuration.build.directories.output);
|
||||
if(!output.startsWith(workspace+path.sep))throw Error('Build output must stay inside the workspace');
|
||||
const configuration=JSON.parse(readFileSync('package.json','utf8')),output=path.resolve(configuration.build.directories.output);
|
||||
// Reuse the pinned installed distribution already exercised by native tests.
|
||||
// This avoids a second archive extraction and Windows directory-rename locks.
|
||||
const electronDirectory=path.dirname(createRequire(import.meta.url)('electron'));
|
||||
if(readFileSync(path.join(electronDirectory,'version'),'utf8').trim().replace(/^v/,'')!==configuration.devDependencies.electron)throw Error('Installed Electron does not match the build pin');
|
||||
function sourceSnapshot(){const entries=[];function walk(directory){for(const item of readdirSync(directory,{withFileTypes:true})){const file=path.join(directory,item.name);if(item.isDirectory())walk(file);else if(item.isFile())entries.push([file,createHash('sha256').update(readFileSync(file)).digest('hex')]);}}for(const directory of ['apps/browser','packages/core'])walk(directory);entries.push(['package.json',createHash('sha256').update(readFileSync('package.json')).digest('hex')]);return JSON.stringify(entries.sort((a,b)=>a[0].localeCompare(b[0])));}
|
||||
const plan=createWindowsPackagePlan({workspace,outputDirectory:output,electronDirectory,productName:name,appId:id});
|
||||
const broker=await prepareBrokerResources({root:workspace,build:configuration.build});
|
||||
const scannerSnapshot=await snapshotScannerResources(workspace);
|
||||
const artifactName=`${name}-Setup-${configuration.version}.exe`;
|
||||
if(!/^[\w .-]+\.exe$/.test(artifactName))throw Error('Invalid installer artifact name');
|
||||
const buildConfiguration={...broker.configuration,artifactName,electronFuses:hardenedElectronFuses};
|
||||
const configPath=path.join(workspace,'.runtime/windows',`package-build-${randomUUID()}.json`);
|
||||
writeFileSync(configPath,JSON.stringify(buildConfiguration,null,2)+'\n',{flag:'wx'});
|
||||
const configArgs=[`--config=${configPath}`];
|
||||
function sourceSnapshot(){
|
||||
const entries=[];
|
||||
function walk(directory){for(const item of readdirSync(directory,{withFileTypes:true})){const file=path.join(directory,item.name);if(item.isSymbolicLink())throw Error('Build source reparse point rejected');if(item.isDirectory())walk(file);else if(item.isFile())entries.push([file,createHash('sha256').update(readFileSync(file)).digest('hex')]);}}
|
||||
for(const directory of ['apps/browser','apps/network-broker','apps/inspector','packages/core','scripts'])walk(directory);
|
||||
for(const file of ['package.json','package-lock.json',configuration.build.win.icon,configPath])entries.push([file,createHash('sha256').update(readFileSync(file)).digest('hex')]);
|
||||
return createHash('sha256').update(JSON.stringify(entries.sort((a,b)=>a[0].localeCompare(b[0])))).digest('hex');
|
||||
}
|
||||
const sourcesBefore=sourceSnapshot();
|
||||
const r=spawnSync(process.execPath,['node_modules/electron-builder/out/cli/cli.js',`--${platform}`,'--dir',`--config.electronDist=${electronDirectory}`,`--config.productName=${name}`,`--config.appId=${id}`,...Object.entries(hardenedElectronFuses).map(([key,value])=>`--config.electronFuses.${key}=${value}`)],{stdio:'inherit',env:process.env});
|
||||
if(r.status!==0)process.exit(r.status??1);
|
||||
if(sourceSnapshot()!==sourcesBefore)throw Error('Browser sources changed during packaging; discard this artifact and rebuild from stable sources');
|
||||
const resources=path.join(output,'win-unpacked/resources');
|
||||
await verifyPackagedFuses(path.join(output,'win-unpacked',`${name}.exe`),path.join(resources,'app.asar'));
|
||||
console.log('Packaged Electron security fuses and embedded ASAR integrity verified.');
|
||||
const acl=spawnSync(path.join(resources,'python/python.exe'),[path.join(resources,'inspector/provision_runtime_acl.py'),'--provision'],{stdio:'inherit',windowsHide:true});process.exit(acl.status??1);
|
||||
const run=args=>spawnSync(process.execPath,['node_modules/electron-builder/out/cli/cli.js',...args,...configArgs],{stdio:'inherit',env:process.env});
|
||||
const result=run(plan.unpacked.args);if(result.status!==0)process.exit(result.status??1);
|
||||
await broker.verifySources();if(sourceSnapshot()!==sourcesBefore)throw Error('Build sources changed; discard this package');
|
||||
const resources=path.join(plan.unpacked.directory,'resources'),archive=path.join(resources,'app.asar'),executable=path.join(plan.unpacked.directory,`${name}.exe`);
|
||||
async function verify(){
|
||||
await broker.verifySources();if(sourceSnapshot()!==sourcesBefore)throw Error('Build sources changed; discard this package');
|
||||
await verifyPackagedFuses(executable,archive);
|
||||
await verifyPackagedBroker({resources,expectedManifest:broker.manifest,readEmbeddedManifest:embeddedBrokerManifestReader(archive)});
|
||||
await verifyScannerResources(resources,scannerSnapshot);
|
||||
const acl=spawnSync(path.join(resources,'python/python.exe'),[path.join(resources,'inspector/provision_runtime_acl.py')],{stdio:'inherit',windowsHide:true});if(acl.status!==0)throw Error('Packaged scanner ACL verification failed');
|
||||
return {identity:await readIdentity(),fuses:true,resources:true,broker:true,runtimeAcl:true};
|
||||
}
|
||||
// This verifies/provisions the unpacked build location only. NSIS extraction
|
||||
// does not preserve these ACEs: a reviewed installer post-install ACL step and
|
||||
// actual standard-user Program Files acceptance remain required. Browser
|
||||
// startup must NEVER silently provision or broaden installation permissions.
|
||||
const acl=spawnSync(path.join(resources,'python/python.exe'),[path.join(resources,'inspector/provision_runtime_acl.py'),'--provision'],{stdio:'inherit',windowsHide:true});if(acl.status!==0)process.exit(acl.status??1);
|
||||
async function treeHash(directory){
|
||||
const hash=createHash('sha256');
|
||||
async function walk(current){for(const item of readdirSync(current,{withFileTypes:true}).sort((a,b)=>a.name.localeCompare(b.name))){const file=path.join(current,item.name);if(item.isSymbolicLink())throw Error('Packaged reparse point rejected');if(item.isDirectory())await walk(file);else if(item.isFile()){const content=createHash('sha256');let bytes=0;for await(const chunk of createReadStream(file)){content.update(chunk);bytes+=chunk.length;}hash.update(JSON.stringify([path.relative(directory,file).replaceAll('\\','/'),bytes,content.digest('hex')])+'\n');}}}
|
||||
await walk(directory);return hash.digest('hex');
|
||||
}
|
||||
async function readIdentity(){await broker.verifySources();const scanners=await snapshotScannerResources(workspace);if(JSON.stringify(scanners)!==JSON.stringify(scannerSnapshot))throw Error('Scanner sources changed; discard this package');return {sourceSha256:sourceSnapshot(),runtimeSha256:createHash('sha256').update(JSON.stringify({electron:configuration.devDependencies.electron,broker:BROKER_RUNTIME,scanners})).digest('hex'),packageSha256:await treeHash(plan.unpacked.directory)};}
|
||||
await verify();
|
||||
console.log('Unpacked browser, embedded broker manifest, complete broker payload, security fuses and scanner ACLs verified.');
|
||||
if(!process.argv.includes('--unpacked-only')){
|
||||
const installer=await buildVerifiedInstaller({plan,expectedIdentity:await readIdentity(),readIdentity,verify,run,installerPath:path.join(output,artifactName)});
|
||||
console.log(JSON.stringify({installer:installer.path,bytes:installer.bytes,sha256:installer.sha256,brokerIncluded:true,productionActivationVerified:false}));
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// 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}));
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Standalone payload preparation only. No browser wiring, installer or service activation.
|
||||
import {readFile,writeFile,lstat,mkdir,mkdtemp,rename} from 'node:fs/promises';
|
||||
import {createReadStream} from 'node:fs';
|
||||
import {createHash} from 'node:crypto';
|
||||
import {builtinModules} from 'node:module';
|
||||
import {gunzipSync} from 'node:zlib';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {BROKER_RUNTIME} from './provision-broker-runtime.mjs';
|
||||
import {buildBrokerPackageManifest,verifyBrokerPackage} from './broker-package-contract.mjs';
|
||||
export const BROKER_PAYLOAD=Object.freeze({version:'0.1.0',protocol:1,entrypoint:'node.exe'});
|
||||
const BROKER_FILES=Object.freeze(['audit-gate.cjs','bounded-audit.cjs','connect-inspection-proxy.cjs','diagnostics-adapter.cjs','dns-policy-route.cjs','download-response-gate.cjs','http-request-gate.cjs','http-response-gate.cjs','https-forwarder.cjs','https-inspection-server.cjs','lifecycle-audit.cjs','managed-proxy.cjs','management-identity-operation.cjs','multipart.cjs','policy-authority.cjs','process-entry.cjs','production-entry.cjs','production-factory.cjs','proxy-forwarder.cjs','service-acceptance.cjs','service-bootstrap.cjs','service-commit-gate.cjs','service-entry.cjs','service-store-client.cjs','framed-channel.cjs','tls-authority.cjs','tls-certificates.cjs','windows-wfp-plan.cjs','wfp-topology.cjs','wfp-revalidation.cjs','work-admission.cjs']);
|
||||
const BROWSER_HELPERS=new Set(['policy-assignment.cjs','enterprise-inspection.cjs','icap-client.cjs','inspect-text-representations.cjs','header-inspection.cjs','inspection.cjs','loopback-http.cjs']);
|
||||
const builtins=new Set(builtinModules.flatMap(name=>[name,name.replace(/^node:/,''),'node:'+name.replace(/^node:/,'')]));
|
||||
function fail(code){throw Object.assign(new Error(code),{code});}
|
||||
async function safe(file,directory=false){
|
||||
for(let current=path.resolve(file);;current=path.dirname(current)){
|
||||
const stat=await lstat(current);if(stat.isSymbolicLink())fail('broker_stage_path_unsafe');
|
||||
if(current===path.resolve(file)&&((directory&&!stat.isDirectory())||(!directory&&!stat.isFile())))fail('broker_stage_path_unsafe');
|
||||
if(path.dirname(current)===current)break;
|
||||
}
|
||||
}
|
||||
async function digest(file){await safe(file);const before=await lstat(file);if(before.size>100000000)fail('broker_stage_file_limit');const hash=createHash('sha256');for await(const chunk of createReadStream(file))hash.update(chunk);const after=await lstat(file);if(before.size!==after.size||before.mtimeMs!==after.mtimeMs||before.ctimeMs!==after.ctimeMs)fail('broker_stage_source_changed');return {bytes:after.size,sha256:hash.digest('hex')};}
|
||||
export async function snapshotSources(root,names){const records=[];for(const name of names){if(!/^[a-zA-Z0-9_./-]+$/.test(name)||name.split('/').some(p=>p==='..'||p==='.'||!p))fail('broker_stage_path_unsafe');records.push({path:name,...await digest(path.join(root,...name.split('/')))});}return records;}
|
||||
export async function verifySourceSnapshot(root,records){for(const record of records){const actual=await digest(path.join(root,...record.path.split('/')));if(actual.bytes!==record.bytes||actual.sha256!==record.sha256)fail('broker_stage_source_changed');}}
|
||||
function allowedSource(name){return /^packages\/core\/[a-z0-9-]+\.(?:cjs|mjs)$/.test(name)||(name.startsWith('apps/network-broker/')&&BROKER_FILES.includes(name.slice(20)))||(name.startsWith('apps/browser/')&&BROWSER_HELPERS.has(name.slice(13)));}
|
||||
export async function reviewedSourceClosure(root){
|
||||
const queue=BROKER_FILES.map(name=>'apps/network-broker/'+name);queue.push('packages/core/policy.mjs');const seen=new Set(),external=new Set();
|
||||
while(queue.length){const name=queue.shift();if(seen.has(name))continue;if(!allowedSource(name))fail('broker_stage_unreviewed_source');await safe(path.join(root,...name.split('/')));const source=await readFile(path.join(root,...name.split('/')),'utf8');if(Buffer.byteLength(source)>1048576)fail('broker_stage_source_limit');seen.add(name);
|
||||
for(const call of source.matchAll(/\b(?:require|import)\s*\(([^)]*)\)/g))if(!/^\s*['"][^'"]+['"]\s*$/.test(call[1]))fail('broker_stage_dynamic_dependency');
|
||||
const imports=[...source.matchAll(/(?:require\s*\(|import\s*\()\s*['"]([^'"]+)['"]/g),...source.matchAll(/\bfrom\s*['"]([^'"]+)['"]/g),...source.matchAll(/\bimport\s*['"]([^'"]+)['"]/g)].map(match=>match[1]);
|
||||
for(const specifier of imports){if(builtins.has(specifier))continue;if(specifier.startsWith('.')){const next=path.posix.normalize(path.posix.join(path.posix.dirname(name),specifier));if(!allowedSource(next))fail('broker_stage_unreviewed_source');queue.push(next);}else external.add(specifier);}
|
||||
}
|
||||
if([...external].some(name=>name!=='zod'))fail('broker_stage_unreviewed_dependency');return {files:[...seen].sort(),dependencies:[...external].sort()};
|
||||
}
|
||||
export function extractVerifiedZod(bytes,locked){
|
||||
if(!Buffer.isBuffer(bytes)||bytes.length>10485760||locked?.version!=='4.6.7'||locked.dev||locked.resolved!==`https://registry.npmjs.org/zod/-/zod-${locked.version}.tgz`||!/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(locked.integrity||'')||'sha512-'+createHash('sha512').update(bytes).digest('base64')!==locked.integrity)fail('broker_stage_dependency_integrity');
|
||||
let tar;try{tar=gunzipSync(bytes,{maxOutputLength:67108864});}catch{fail('broker_stage_dependency_archive');}
|
||||
const files=[];const names=new Set();for(let offset=0;offset+512<=tar.length;){const header=tar.subarray(offset,offset+512);if(header.every(byte=>byte===0))break;const text=buffer=>buffer.toString('utf8').replace(/\0.*$/s,'');const name=text(header.subarray(0,100)),prefix=text(header.subarray(345,500));const full=prefix?prefix+'/'+name:name;const size=parseInt(text(header.subarray(124,136)).trim(),8),type=header[156];const checksum=parseInt(text(header.subarray(148,156)).trim(),8);let sum=0;for(let i=0;i<512;i++)sum+=i>=148&&i<156?32:header[i];if(sum!==checksum||!Number.isSafeInteger(size)||size<0||offset+512+size>tar.length||![0,48,53].includes(type)||!full.startsWith('package/')||full.includes('\\')||full.split('/').filter(Boolean).some(part=>! /^[a-zA-Z0-9_.-]+$/.test(part)||part==='..'||part==='.'||/[. ]$/.test(part)||/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part))||names.has(full.toLowerCase()))fail('broker_stage_dependency_archive');names.add(full.toLowerCase());if(names.size>4096)fail('broker_stage_dependency_archive');
|
||||
const relative=full.slice(8);if(type!==53&&relative&&!relative.startsWith('src/')&&!/(^|\/)(tests?|fixtures?|benchmarks?)(\/|$)/i.test(relative)&&(relative==='package.json'||/^license(?:\.[a-z]+)?$/i.test(relative)||/\.(?:js|cjs|mjs)$/.test(relative)))files.push({path:'node_modules/zod/'+relative,bytes:Buffer.from(tar.subarray(offset+512,offset+512+size))});offset+=512+Math.ceil(size/512)*512;
|
||||
}
|
||||
if(files.length>2048||!files.some(file=>file.path==='node_modules/zod/package.json')||!files.some(file=>/\/license(?:\.[a-z]+)?$/i.test(file.path)))fail('broker_stage_dependency_archive');let metadata;try{metadata=JSON.parse(files.find(file=>file.path==='node_modules/zod/package.json').bytes.toString());}catch{fail('broker_stage_dependency_archive');}if(metadata.name!=='zod'||metadata.version!==locked.version)fail('broker_stage_dependency_integrity');return files;
|
||||
}
|
||||
async function archive(locked){const response=await fetch(locked.resolved,{redirect:'error',signal:AbortSignal.timeout(30000)});if(!response.ok)fail('broker_stage_dependency_download');const chunks=[];let count=0;for await(const chunk of response.body){count+=chunk.length;if(count>10485760)fail('broker_stage_dependency_limit');chunks.push(Buffer.from(chunk));}return Buffer.concat(chunks);}
|
||||
export async function stageBrokerPayload({root=fileURLToPath(new URL('../',import.meta.url)),destination,dependencyArchive}={}){
|
||||
root=path.resolve(root);const parent=path.join(root,'.runtime','windows');destination=path.resolve(destination||path.join(parent,'broker-payload'));if(path.dirname(destination)!==parent||!/^broker-payload(?:-[a-zA-Z0-9_-]+)?$/.test(path.basename(destination)))fail('broker_stage_destination_unsafe');await safe(root,true);await safe(parent,true);try{await lstat(destination);fail('broker_stage_destination_exists');}catch(error){if(error.code!=='ENOENT')throw error;}
|
||||
const closure=await reviewedSourceClosure(root);const runtimeNames=['node.exe','LICENSE'];const sourceNames=[...closure.files,'package-lock.json',...runtimeNames.map(name=>'.runtime/windows/broker/'+name)];const sources=await snapshotSources(root,sourceNames);for(const [name,expected] of [['node.exe',BROKER_RUNTIME.executableSha256],['LICENSE',BROKER_RUNTIME.licenseSha256]])if(sources.find(item=>item.path==='.runtime/windows/broker/'+name).sha256!==expected)fail('broker_stage_runtime_integrity');
|
||||
const lock=JSON.parse(await readFile(path.join(root,'package-lock.json'),'utf8'));const locked=lock.packages?.['node_modules/zod'];if(!locked)fail('broker_stage_dependency_missing');const dependencies=extractVerifiedZod(dependencyArchive||await archive(locked),locked);
|
||||
const temporary=await mkdtemp(path.join(parent,'broker-payload-stage-'));async function put(name,bytes){const target=path.join(temporary,...name.split('/'));await mkdir(path.dirname(target),{recursive:true});await writeFile(target,bytes,{flag:'wx'});}
|
||||
for(const name of runtimeNames)await put(name,await readFile(path.join(root,'.runtime/windows/broker',name)));for(const name of closure.files)await put(name,await readFile(path.join(root,...name.split('/'))));for(const file of dependencies)await put(file.path,file.bytes);
|
||||
await verifySourceSnapshot(root,sources);const manifest=await buildBrokerPackageManifest(temporary,BROKER_PAYLOAD);for(const source of sources.filter(item=>item.path!=='package-lock.json')){const target=source.path.startsWith('.runtime/windows/broker/')?source.path.slice('.runtime/windows/broker/'.length):source.path;const copied=manifest.files.find(item=>item.path===target);if(!copied||copied.bytes!==source.bytes||copied.sha256!==source.sha256)fail('broker_stage_source_changed');}await verifyBrokerPackage(temporary,manifest,BROKER_PAYLOAD);await verifySourceSnapshot(root,sources);await rename(temporary,destination);
|
||||
// Adjacent metadata is evidence; only an embedded/signed installer copy is trusted.
|
||||
await writeFile(destination+'.manifest.json',JSON.stringify(manifest,null,2)+'\n',{flag:'wx'});return {root:destination,manifest,sources,sourceFiles:closure.files.length,dependencies:closure.dependencies,productionReady:false};
|
||||
}
|
||||
if(process.argv[1]&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url)){try{const result=await stageBrokerPayload();console.log(JSON.stringify({files:result.manifest.files.length,dependencies:result.dependencies,productionReady:result.productionReady}));}catch(error){console.error(error.code?.startsWith('broker_')?error.code:'broker_stage_failed');process.exitCode=1;}}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {lstat,open} from 'node:fs/promises';
|
||||
import {createHash} from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
function fail(code){throw Object.assign(new Error(code),{code});}
|
||||
function same(expected,actual){return actual&&['sourceSha256','runtimeSha256','packageSha256'].every(key=>actual[key]===expected[key]);}
|
||||
async function stat(file){try{return await lstat(file);}catch(error){if(error.code==='ENOENT')return null;throw error;}}
|
||||
// Guard actual invocation and result. Caller owns/protects the build directory.
|
||||
export async function buildVerifiedInstaller({plan,expectedIdentity,readIdentity,verify,run,installerPath}){
|
||||
if(typeof run!=='function'||!plan||!path.isAbsolute(installerPath)||path.dirname(installerPath)!==path.dirname(plan.unpacked.directory)||!installerPath.endsWith('.exe'))fail('installer_configuration_invalid');
|
||||
const stage=await plan.installer({expectedIdentity,readIdentity,verify});
|
||||
if(!same(stage.identity,await readIdentity(stage.directory)))fail('installer_identity_changed');
|
||||
const previous=await stat(installerPath);if(previous?.isSymbolicLink())fail('installer_artifact_invalid');
|
||||
const result=await run(stage.args);
|
||||
if(result?.status!==0)fail('installer_build_failed');
|
||||
if(!same(stage.identity,await readIdentity(stage.directory)))fail('installer_identity_changed');
|
||||
const receipt=await verify(stage.directory,stage.identity);
|
||||
if(!receipt||!same(stage.identity,receipt.identity)||!['fuses','resources','broker','runtimeAcl'].every(key=>receipt[key]===true))fail('installer_verification_failed');
|
||||
if(!same(stage.identity,await readIdentity(stage.directory)))fail('installer_identity_changed');
|
||||
const produced=await stat(installerPath);
|
||||
if(!produced||!produced.isFile()||produced.isSymbolicLink()||produced.size<1024||produced.size>4294967295||previous&&previous.mtimeMs===produced.mtimeMs&&previous.ctimeMs===produced.ctimeMs&&previous.size===produced.size)fail('installer_artifact_invalid');
|
||||
const file=await open(installerPath,'r');
|
||||
try{
|
||||
const magic=Buffer.alloc(2);await file.read(magic,0,2,0);if(magic.toString('ascii')!=='MZ')fail('installer_artifact_invalid');
|
||||
const hash=createHash('sha256');for await(const chunk of file.createReadStream({start:0,autoClose:false}))hash.update(chunk);
|
||||
const after=await file.stat();if(after.size!==produced.size||after.mtimeMs!==produced.mtimeMs||after.ctimeMs!==produced.ctimeMs)fail('installer_artifact_changed');
|
||||
if(!same(stage.identity,await readIdentity(stage.directory)))fail('installer_identity_changed');
|
||||
const final=await file.stat();if(final.size!==after.size||final.mtimeMs!==after.mtimeMs||final.ctimeMs!==after.ctimeMs)fail('installer_artifact_changed');
|
||||
return Object.freeze({path:installerPath,bytes:after.size,sha256:hash.digest('hex'),identity:stage.identity});
|
||||
}finally{await file.close();}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Not wired into package.mjs until a real packaged broker is available.
|
||||
// Official CLI: https://www.electron.build/docs/cli/
|
||||
// --dir produces only an unpacked app; --win nsis --prepackaged distributes
|
||||
// that verified directory without silently rebuilding its application payload.
|
||||
import path from 'node:path';
|
||||
|
||||
function fail(code){throw Object.assign(new Error(code),{code});}
|
||||
function identity(value){
|
||||
if(!value||!['sourceSha256','runtimeSha256','packageSha256'].every(key=>/^[a-f0-9]{64}$/.test(value[key])))fail('package_identity_invalid');
|
||||
return Object.freeze(Object.fromEntries(['sourceSha256','runtimeSha256','packageSha256'].map(key=>[key,value[key]])));
|
||||
}
|
||||
function same(left,right){return Object.keys(left).every(key=>left[key]===right[key]);}
|
||||
export function createWindowsPackagePlan({workspace,outputDirectory,electronDirectory,productName='SafetyNet',appId='enterprise.safetynet.browser',platform=process.platform}){
|
||||
if(platform!=='win32')fail('package_platform_unsupported');
|
||||
if(![workspace,outputDirectory,electronDirectory].every(value=>typeof value==='string'&&path.isAbsolute(value)))fail('package_path_invalid');
|
||||
const root=path.resolve(workspace),output=path.resolve(outputDirectory),runtime=path.resolve(electronDirectory);
|
||||
const relative=path.relative(root,output);
|
||||
if(!relative||relative.startsWith('..'+path.sep)||relative==='..'||path.isAbsolute(relative))fail('package_output_unsafe');
|
||||
if(!/^[\w .-]{1,40}$/.test(productName)||!/^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9]*){2,}$/.test(appId))fail('package_brand_invalid');
|
||||
const directory=path.join(output,'win-unpacked');
|
||||
const common=[`--config.productName=${productName}`,`--config.appId=${appId}`,`--config.directories.output=${output}`,'--publish','never'];
|
||||
return Object.freeze({
|
||||
unpacked:Object.freeze({stage:'build-unpacked',args:Object.freeze(['--win','--dir',`--config.electronDist=${runtime}`,...common]),directory}),
|
||||
verification:Object.freeze({stage:'verify-unpacked',required:Object.freeze(['fuses','resources','broker','runtimeAcl']),directory}),
|
||||
async installer({expectedIdentity,readIdentity,verify}){
|
||||
if(typeof readIdentity!=='function'||typeof verify!=='function')fail('package_verifier_required');
|
||||
const expected=identity(expectedIdentity);
|
||||
if(!same(expected,identity(await readIdentity(directory))))fail('package_identity_changed');
|
||||
const receipt=await verify(directory,expected);
|
||||
if(!receipt||!same(expected,identity(receipt.identity))||!['fuses','resources','broker','runtimeAcl'].every(key=>receipt[key]===true))fail('package_verification_incomplete');
|
||||
if(!same(expected,identity(await readIdentity(directory))))fail('package_identity_changed');
|
||||
// This plans arguments only. Caller must protect the verified directory
|
||||
// against changes through invocation and inspect the real installer result.
|
||||
return Object.freeze({stage:'build-installer',directory,identity:expected,
|
||||
args:Object.freeze(['--win','nsis','--prepackaged',directory,...common])});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Build the inventory AFTER final binaries exist; signing is performed by an
|
||||
// external trusted release signer. No private key is generated or stored here.
|
||||
import {lstat,readdir,open} from 'node:fs/promises';
|
||||
import {createHash} from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
const OMIT=new Set(['release-envelope.json','deployment-envelope.json']);
|
||||
function fail(code){throw Object.assign(new Error(code),{code});}
|
||||
async function ancestors(root){for(let current=path.resolve(root);;current=path.dirname(current)){const stat=await lstat(current);if(!stat.isDirectory()||stat.isSymbolicLink())fail('windows-release-path-unsafe');if(path.dirname(current)===current)break;}}
|
||||
async function hash(file){const input=await open(file,'r');try{const before=await input.stat(),sha=createHash('sha256');let bytes=0;for await(const chunk of input.createReadStream({autoClose:false})){bytes+=chunk.length;sha.update(chunk);}const after=await input.stat();if(!before.isFile()||before.size!==bytes||after.size!==bytes||before.mtimeMs!==after.mtimeMs||before.ctimeMs!==after.ctimeMs||before.ino!==after.ino||before.dev!==after.dev)fail('windows-release-source-changed');return {bytes,sha256:sha.digest('hex')};}finally{await input.close();}}
|
||||
export async function buildReleaseInventory(root,{browserPath='SafetyNet.exe',nativeHostPath='safetynet-service-host.exe'}={}){
|
||||
await ancestors(root);const files=[],seen=new Set();let count=0;
|
||||
async function walk(directory,prefix=''){
|
||||
for(const entry of await readdir(directory,{withFileTypes:true})){
|
||||
const relative=prefix+entry.name;
|
||||
if(++count>15000||!relative.split('/').every(part=>/^[A-Za-z0-9_.-]+$/.test(part)&&part!=='.'&&part!=='..'&&!/[. ]$/.test(part)&&!/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part))||relative.length>240||entry.isSymbolicLink())fail('windows-release-path-unsafe');
|
||||
const target=path.join(directory,entry.name),stat=await lstat(target);if(stat.isSymbolicLink())fail('windows-release-path-unsafe');
|
||||
if(stat.isDirectory())await walk(target,relative+'/');else if(stat.isFile()){
|
||||
if(OMIT.has(relative))continue;
|
||||
if(seen.has(relative.toLowerCase())||files.length>=10000)fail('windows-release-inventory-invalid');seen.add(relative.toLowerCase());files.push({path:relative,...await hash(target)});
|
||||
}else fail('windows-release-path-unsafe');
|
||||
}
|
||||
}
|
||||
await walk(root);files.sort((a,b)=>a.path<b.path?-1:a.path>b.path?1:0);
|
||||
for(const required of [browserPath,nativeHostPath,'resources/app.asar','resources/broker/node.exe','resources/python/python.exe','resources/inspector/server.py'])if(!files.some(file=>file.path===required))fail('windows-release-component-missing');
|
||||
if(!files.some(file=>file.path.startsWith('resources/semantic/')))fail('windows-release-component-missing');
|
||||
return files;
|
||||
}
|
||||
export async function verifyReleaseInventory(root,verifiedRelease,options){
|
||||
// Cryptographic authenticity is established separately by windows-trust.cjs.
|
||||
// This API only compares an already trusted immutable inventory to disk.
|
||||
const actual=await buildReleaseInventory(root,options),expected=verifiedRelease?.manifest?.files;
|
||||
if(!Array.isArray(expected)||actual.length!==expected.length||actual.some((file,index)=>file.path!==expected[index]?.path||file.bytes!==expected[index]?.bytes||file.sha256!==expected[index]?.sha256))fail('windows-release-inventory-mismatch');return true;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
import {createServer,request as httpRequest} from 'node:http';
|
||||
import {once} from 'node:events';
|
||||
import {mkdtemp,open,readFile,rm} from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
const require=createRequire(import.meta.url),{createBrokerAudit,BrokerAuditEventSchema}=require('../apps/network-broker/audit-gate.cjs');
|
||||
const {gateRequest}=require('../apps/network-broker/http-request-gate.cjs');
|
||||
async function fixture(t,configure={}){
|
||||
const root=await mkdtemp(path.join(os.tmpdir(),'safetynet-broker-audit-')),journal=path.join(root,'synthetic-events.jsonl');
|
||||
const received=[];let connections=0;
|
||||
const receiver=createServer(async(req,res)=>{const chunks=[];for await(const chunk of req)chunks.push(chunk);received.push(Buffer.concat(chunks).toString());res.end('received');});receiver.on('connection',()=>connections++);receiver.listen(0,'127.0.0.1');await once(receiver,'listening');
|
||||
let valid=true,persistenceCalls=0;
|
||||
const durable=async event=>{
|
||||
persistenceCalls++;BrokerAuditEventSchema.parse(event);
|
||||
await configure.beforePersist?.(event);
|
||||
// Synthetic metadata only. Production requires the existing protected,
|
||||
// encrypted durable journal, not this test's plain fsync demonstration.
|
||||
const file=await open(journal,'a');try{await file.writeFile(JSON.stringify(event)+'\n');await file.sync();}finally{await file.close();}
|
||||
return {persisted:true,eventId:event.eventId};
|
||||
};
|
||||
const audit=createBrokerAudit({current:()=>valid,policyVersion:3,persist:configure.persist||durable,correlation:()=>({traceId:'a'.repeat(32),spanId:'b'.repeat(16)})});
|
||||
const destination=`http://127.0.0.1:${receiver.address().port}/upload`;
|
||||
const proxy=createServer(async(req,res)=>{
|
||||
try{
|
||||
await gateRequest(req,{authority:'policy-3',current:()=>valid,deadlineMs:1000,inspect:async({body})=>{
|
||||
const decision=body.toString().includes('PRIVATE_TEST_SECRET')?'block':'allow';
|
||||
await audit.record(decision,{bytes:body.length,detectedType:'text',host:'127.0.0.1'});return {decision};
|
||||
},forward:({body})=>new Promise((resolve,reject)=>{const outgoing=httpRequest(destination,{method:'POST',headers:{'content-length':body.length}},response=>{response.resume();response.once('end',resolve);});outgoing.once('error',reject);outgoing.end(body);})});
|
||||
res.end('allowed');
|
||||
}catch{res.writeHead(403);res.end('blocked');}
|
||||
});proxy.listen(0,'127.0.0.1');await once(proxy,'listening');
|
||||
t.after(async()=>{proxy.closeAllConnections();receiver.closeAllConnections();await Promise.all([new Promise(resolve=>proxy.close(resolve)),new Promise(resolve=>receiver.close(resolve))]);await rm(root,{force:true,recursive:true});});
|
||||
const send=body=>new Promise((resolve,reject)=>{const outgoing=httpRequest({host:'127.0.0.1',port:proxy.address().port,path:destination,method:'POST',headers:{host:new URL(destination).host,'content-length':Buffer.byteLength(body)}},response=>{response.resume();response.once('end',()=>resolve(response.statusCode));});outgoing.once('error',reject);outgoing.end(body);});
|
||||
return {send,audit,received,revoke:()=>{valid=false;},get connections(){return connections;},get persistenceCalls(){return persistenceCalls;},events:async()=>String(await readFile(journal)).trim().split('\n').map(line=>JSON.parse(line))};
|
||||
}
|
||||
test('pending durable allow audit releases no TCP connection and fsync receipt precedes release',async t=>{
|
||||
let entered,release;const started=new Promise(resolve=>entered=resolve),hold=new Promise(resolve=>release=resolve);
|
||||
const f=await fixture(t,{beforePersist:async()=>{entered();await hold;}});
|
||||
const pending=f.send('public payload');await started;assert.equal(f.connections,0);release();assert.equal(await pending,200);
|
||||
assert.deepEqual(f.received,['public payload']);const events=await f.events();assert.equal(events.length,1);assert.equal(events[0].kind,'upload.authorized');assert.equal(events[0].policyVersion,3);assert.equal(events[0].traceId,'a'.repeat(32));
|
||||
});
|
||||
test('durable block records safe facts without original secret or request fingerprint',async t=>{
|
||||
const f=await fixture(t);assert.equal(await f.send('PRIVATE_TEST_SECRET'),403);assert.equal(f.connections,0);
|
||||
const events=await f.events();assert.equal(events[0].kind,'upload.block');assert.equal(events[0].request.bytes,19);assert.equal(events[0].request.detectedType,'text');
|
||||
const text=JSON.stringify(events);assert.equal(text.includes('PRIVATE_TEST_SECRET'),false);assert.equal(text.includes('sha256'),false);
|
||||
});
|
||||
test('audit failure and uncertain receipt block without dial or automatic retry',async t=>{
|
||||
for(const persist of [async()=>{throw Error('private persistence details');},async()=>({persisted:true,eventId:'wrong-id'})]){
|
||||
let calls=0;const f=await fixture(t,{persist:async event=>{calls++;return persist(event);}});
|
||||
assert.equal(await f.send('public'),403);assert.equal(f.connections,0);assert.equal(calls,1);
|
||||
}
|
||||
});
|
||||
test('revocation while audit waits prevents release even after durable write succeeds',async t=>{
|
||||
let entered,release;const started=new Promise(resolve=>entered=resolve),hold=new Promise(resolve=>release=resolve);
|
||||
const f=await fixture(t,{beforePersist:async()=>{entered();await hold;}}),pending=f.send('public');await started;f.revoke();release();
|
||||
assert.equal(await pending,403);assert.equal(f.connections,0);assert.equal((await f.events()).length,1);assert.equal(f.persistenceCalls,1);
|
||||
});
|
||||
test('missing mandatory persistence and malformed security facts cannot authorize transfer',async()=>{
|
||||
const audit=createBrokerAudit({current:()=>true,policyVersion:1});
|
||||
await assert.rejects(audit.record('allow',{bytes:2,detectedType:'text'}),{code:'audit-unavailable'});
|
||||
for(const facts of [{bytes:-1,detectedType:'text'},{bytes:2,detectedType:'text',body:'private'},{bytes:2,detectedType:'text',sha256:'a'.repeat(64)},{bytes:2,detectedType:'text',host:'https://company.example/private?q=secret'},{bytes:2,detectedType:'text',reason:'broker-inspection-fault'}])await assert.rejects(audit.record('allow',facts),{code:'audit-facts-invalid'});
|
||||
});
|
||||
test('fault event uses existing schema fixed reason without error detail',async()=>{
|
||||
const events=[];const audit=createBrokerAudit({current:()=>false,policyVersion:7,persist:async event=>{events.push(BrokerAuditEventSchema.parse(event));return {persisted:true,eventId:event.eventId};}});
|
||||
await audit.record('fault',{bytes:0,detectedType:'unknown'});assert.equal(events[0].kind,'upload.block');assert.equal(events[0].ruleId,'broker-inspection-fault');
|
||||
});
|
||||
test('decision reason mismatch and accessors fail with fixed errors without reading secret getters',async()=>{
|
||||
let evaluated=false;const audit=createBrokerAudit({current:()=>true,policyVersion:1,persist:async event=>({persisted:true,eventId:event.eventId})});
|
||||
for(const [decision,reason] of [['allow','broker-policy-block'],['block','broker-approved'],['block','broker-audit-failed'],['fault','broker-authority-expired']])await assert.rejects(audit.record(decision,{bytes:1,detectedType:'text',reason}),{code:'audit-facts-invalid'});
|
||||
await assert.rejects(audit.record('allow',{get bytes(){evaluated=true;throw Error('private secret');},detectedType:'text'}),{message:'audit-facts-invalid'});assert.equal(evaluated,false);
|
||||
});
|
||||
test('only trusted policy and fixed built-in matched IDs enter security events',async()=>{
|
||||
const events=[],ids=['company-secret'];
|
||||
const audit=createBrokerAudit({current:()=>true,policyVersion:1,allowedRuleIds:ids,persist:async event=>{events.push(event);return {persisted:true,eventId:event.eventId};}});
|
||||
ids.push('RAW PRIVATE RULE TEXT');
|
||||
await audit.record('block',{bytes:4,detectedType:'text',matchedRuleId:'company-secret'});
|
||||
await audit.record('block',{bytes:4,detectedType:'text',matchedRuleId:'api-secret'});
|
||||
assert.deepEqual(events.map(event=>event.ruleId),['company-secret','api-secret']);
|
||||
for(const [decision,id] of [['allow','company-secret'],['block','RAW PRIVATE RULE TEXT'],['fault','unknown-rule']])await assert.rejects(audit.record(decision,{bytes:4,detectedType:'text',matchedRuleId:id}),{code:'audit-facts-invalid'});
|
||||
assert.equal(events.length,2);assert.equal(JSON.stringify(events).includes('RAW PRIVATE RULE TEXT'),false);
|
||||
});
|
||||
test('canonical bare IPv6 destination is valid security metadata without an URL or port',async()=>{
|
||||
const events=[],audit=createBrokerAudit({current:()=>true,policyVersion:1,persist:async event=>{events.push(event);return {persisted:true,eventId:event.eventId};}});
|
||||
await audit.record('allow',{bytes:1,detectedType:'text',host:'::1'});assert.equal(events[0].host,'::1');
|
||||
await assert.rejects(audit.record('allow',{bytes:1,detectedType:'text',host:'[::1]:443'}),{code:'audit-facts-invalid'});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import {spawnSync} from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
const require=createRequire(import.meta.url);
|
||||
const {createBrokerAuditPersistence}=require('../apps/browser/broker-audit-persistence.cjs');
|
||||
const enabled=process.platform==='win32'&&process.env.SAFETYNET_NATIVE_BROKER_AUDIT==='1';
|
||||
test('actual Windows DPAPI encrypted commit preserves broker identity before receipt',{skip:!enabled},()=>{
|
||||
const root=path.resolve('artifacts/broker-parent-audit');fs.mkdirSync(root,{recursive:true});
|
||||
const directory=fs.mkdtempSync(path.join(root,'proof-'));
|
||||
const python=path.resolve('.runtime/windows/python/python.exe'),helper=path.resolve('apps/inspector/dpapi.py');
|
||||
const convert=(action,input)=>{const result=spawnSync(python,[helper,action],{input,windowsHide:true,timeout:10000,maxBuffer:65536});assert.equal(result.status,0,'Private Windows DPAPI operation failed');return result.stdout;};
|
||||
const queue=[],file=path.join(directory,'queue.bin'),temporary=path.join(directory,'queue.tmp');let committed=false;
|
||||
const sink=createBrokerAuditPersistence({policyVersion:4,current:()=>true,getQueue:()=>queue,onFailure:()=>assert.fail('Parent audit was quarantined'),persistQueue:()=>{
|
||||
const protectedBytes=convert('protect',Buffer.from(JSON.stringify(queue)));
|
||||
const fd=fs.openSync(temporary,'wx',0o600);try{fs.writeFileSync(fd,protectedBytes);fs.fsyncSync(fd);}finally{fs.closeSync(fd);}
|
||||
fs.renameSync(temporary,file);committed=true;
|
||||
}});
|
||||
const input={eventId:randomUUID(),kind:'upload.authorized',ruleId:'broker-approved',policyVersion:4,request:{bytes:6,detectedType:'text'}};
|
||||
const receipt=sink.persist(input);assert.equal(committed,true);assert.equal(receipt.eventId,input.eventId);
|
||||
const encrypted=fs.readFileSync(file);assert.equal(encrypted.includes(Buffer.from(input.eventId)),false);
|
||||
const decrypted=convert('unprotect',encrypted);try{assert.deepEqual(JSON.parse(decrypted.toString()),[input]);}finally{decrypted.fill(0);}
|
||||
fs.writeFileSync(path.join(directory,'proof.json'),JSON.stringify({outcome:'passed',nativeProtection:'Windows current-user DPAPI',fsyncBeforeReceipt:true,identityPreserved:true,plaintextIdentityAbsent:true,productionMainWired:false},null,2)+'\n');
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
const require=createRequire(import.meta.url);
|
||||
const {createBrokerAuditPersistence}=require('../apps/browser/broker-audit-persistence.cjs');
|
||||
const event=()=>({eventId:randomUUID(),kind:'upload.authorized',ruleId:'broker-approved',policyVersion:4,request:{bytes:6,detectedType:'text'}});
|
||||
function fixture(overrides={}){const queue=[];let writes=0,failures=0;const sink=createBrokerAuditPersistence({policyVersion:4,current:()=>true,getQueue:()=>queue,persistQueue:()=>{writes++;},onFailure:()=>{failures++;},...overrides});return {sink,queue,writes:()=>writes,failures:()=>failures};}
|
||||
test('receipt preserves broker event identity only after synchronous parent commit',()=>{
|
||||
const f=fixture(),input=event(),receipt=f.sink.persist(input);
|
||||
assert.deepEqual(receipt,{persisted:true,eventId:input.eventId});assert.equal(f.writes(),1);
|
||||
assert.notEqual(f.queue[0],input);assert.ok(Object.isFrozen(f.queue[0]));assert.equal(f.queue[0].eventId,input.eventId);
|
||||
});
|
||||
test('write failure is sticky and never issues a receipt or retries uncertain commit',()=>{
|
||||
let writes=0;const f=fixture({persistQueue:()=>{writes++;throw Error('private filesystem detail');}});
|
||||
assert.throws(()=>f.sink.persist(event()),/^Error: broker-audit-parent-unavailable$/);
|
||||
assert.throws(()=>f.sink.persist(event()),/broker-audit-parent-unavailable/);assert.equal(writes,1);
|
||||
});
|
||||
test('invalid policy, duplicate ID and queue capacity fail before write',()=>{
|
||||
for(const mode of ['policy','duplicate','capacity']){const f=fixture(),input=event();if(mode==='policy')input.policyVersion=3;if(mode==='duplicate')f.queue.push({...input});if(mode==='capacity')f.queue.push(...Array.from({length:10000},()=>({eventId:randomUUID()})));
|
||||
assert.throws(()=>f.sink.persist(input),/broker-audit-parent-unavailable/);assert.equal(f.writes(),0);
|
||||
}
|
||||
});
|
||||
test('revocation during commit prevents acknowledgment and asynchronous enqueue is rejected',()=>{
|
||||
let active=true;const f=fixture({current:()=>active,persistQueue:()=>{active=false;}});
|
||||
assert.throws(()=>f.sink.persist(event()),/broker-audit-parent-unavailable/);
|
||||
const asyncFixture=fixture({persistQueue:()=>Promise.resolve()});assert.throws(()=>asyncFixture.sink.persist(event()),/broker-audit-parent-unavailable/);
|
||||
for(const result of [false,true,{queued:true}]){const uncertain=fixture({persistQueue:()=>result});assert.throws(()=>uncertain.sink.persist(event()),/broker-audit-parent-unavailable/);}
|
||||
});
|
||||
test('raw extra fields and top-level getters cannot become an audit channel',()=>{
|
||||
const f=fixture();let read=false;const input=event();Object.defineProperty(input,'secret',{enumerable:true,get(){read=true;return 'private';}});
|
||||
assert.throws(()=>f.sink.persist(input),/broker-audit-parent-unavailable/);assert.equal(read,false);assert.equal(f.writes(),0);
|
||||
const raw=fixture();assert.throws(()=>raw.sink.persist({...event(),body:'private'}),/broker-audit-parent-unavailable/);assert.equal(raw.writes(),0);
|
||||
const nested=fixture(),nestedInput=event();Object.defineProperty(nestedInput.request,'bytes',{enumerable:true,get(){read=true;return 6;}});
|
||||
assert.throws(()=>nested.sink.persist(nestedInput),/broker-audit-parent-unavailable/);assert.equal(read,false);assert.equal(nested.writes(),0);
|
||||
});
|
||||
test('real download facts are immutable and linked without a generic request hash',()=>{
|
||||
const input={eventId:randomUUID(),kind:'download.block',ruleId:'synthetic-malware',policyVersion:4,file:{bytes:128,detectedType:'pdf',sha256:'a'.repeat(64)},broker:{requestId:randomUUID(),phase:'response.block',reason:'broker-download-inspection-block',authorizedEventId:randomUUID()}};
|
||||
const f=fixture();assert.equal(f.sink.persist(input).eventId,input.eventId);assert.ok(Object.isFrozen(f.queue[0].file));assert.equal(f.queue[0].request,undefined);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
import {fork} from 'node:child_process';
|
||||
import {once} from 'node:events';
|
||||
import https from 'node:https';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
const require=createRequire(import.meta.url);
|
||||
const {createSessionCertificates}=require('../apps/network-broker/tls-certificates.cjs');
|
||||
const enabled=process.platform==='win32'&&process.env.SAFETYNET_NATIVE_CERTIFICATE_PINS==='1';
|
||||
test('actual stock Electron accepts exact session leaf and rejects unregistered TLS leaf',{skip:!enabled,timeout:40000},async()=>{
|
||||
const issuer=await createSessionCertificates({current:()=>true,authorized:()=>true});
|
||||
const servers=[],root=path.resolve('artifacts/broker-certificate-pins');fs.mkdirSync(root,{recursive:true});const profile=fs.mkdtempSync(path.join(root,'profile-'));let child;
|
||||
try{
|
||||
async function server(){const initial=issuer.resolve({authority:'localhost:443'});let requests=0;
|
||||
const site=https.createServer({key:initial.key,cert:initial.cert},(_request,response)=>{requests++;response.end('synthetic public page');});servers.push(site);site.listen(0,'127.0.0.1');await once(site,'listening');
|
||||
const authority='localhost:'+site.address().port,leaf=issuer.resolve({authority});site.setSecureContext({key:leaf.key,cert:leaf.cert});
|
||||
return {url:'https://'+authority+'/',leaf:issuer.publicMetadata({authority}),requests:()=>requests};
|
||||
}
|
||||
const publicSite=await server(),untrustedSite=await server();
|
||||
const env=Object.fromEntries(['SystemRoot','WINDIR','TEMP','TMP','USERPROFILE','APPDATA','LOCALAPPDATA','PATH'].filter(key=>process.env[key]).map(key=>[key,process.env[key]]));
|
||||
child=fork(path.resolve('tests/fixtures/broker-certificate-pins-electron.cjs'),[],{execPath:require('electron'),execArgv:[],env,windowsHide:true,stdio:['ignore','ignore','ignore','ipc']});
|
||||
const result=once(child,'message',{signal:AbortSignal.timeout(20000)});child.send({type:'start',profile,leaf:publicSite.leaf,publicURL:publicSite.url,untrustedURL:untrustedSite.url});
|
||||
const [proof]=await result;assert.equal(proof.type,'proof');assert.equal(proof.publicStatus,200);assert.equal(proof.publicBody,'synthetic public page');assert.equal(proof.untrustedRejected,true);assert.equal(publicSite.requests(),1);assert.equal(untrustedSite.requests(),0);
|
||||
fs.writeFileSync(path.join(root,'proof.json'),JSON.stringify({...proof,untrustedApplicationRequests:0,productionBrowserWired:false},null,2)+'\n');
|
||||
}finally{
|
||||
if(child&&child.exitCode===null&&child.signalCode===null){
|
||||
const exited=once(child,'exit',{signal:AbortSignal.timeout(6000)}),limit=setTimeout(()=>child.kill(),3000);
|
||||
try{if(child.connected)child.send({type:'stop'},error=>{if(error)child.kill();});else child.kill();await exited;}finally{clearTimeout(limit);}
|
||||
}
|
||||
for(const site of servers){site.closeAllConnections();await new Promise(resolve=>site.close(resolve));}issuer.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
const require=createRequire(import.meta.url);
|
||||
const {createBrokerCertificatePins,authorityFacts}=require('../apps/browser/broker-certificate-pins.cjs');
|
||||
const {createSessionCertificates}=require('../apps/network-broker/tls-certificates.cjs');
|
||||
test('exact session leaf accepted; other certificates use platform trust and revocation rejects known leaf',async()=>{
|
||||
let current=true;const issuer=await createSessionCertificates({current:()=>true,authorized:()=>true});
|
||||
const pins=createBrokerCertificatePins({generation:'synthetic-generation',policyVersion:4,current:()=>current,authorizedAuthority:()=>true});
|
||||
try{
|
||||
const leaf=issuer.publicMetadata({authority:'example.com:443'});
|
||||
assert.equal(pins.accept({...leaf,generation:'synthetic-generation',policyVersion:4}),true);
|
||||
const verify=(hostname,data)=>{let result;pins.verify({hostname,certificate:{data,fingerprint:leaf.fingerprint256}},code=>result=code);return result;};
|
||||
assert.equal(verify('example.com',leaf.cert),0);
|
||||
assert.equal(verify('attacker.test',leaf.cert),-3);
|
||||
assert.equal(verify('example.com','unrelated-public-certificate'),-3);
|
||||
current=false;assert.equal(verify('example.com',leaf.cert),-2);
|
||||
current=true;pins.revoke();assert.equal(verify('example.com',leaf.cert),-2);
|
||||
}finally{issuer.close();}
|
||||
});
|
||||
test('wrong generation, policy, SAN, fingerprint and unauthorized destination cannot publish pins',async()=>{
|
||||
const issuer=await createSessionCertificates({current:()=>true,authorized:()=>true});
|
||||
try{const leaf=issuer.publicMetadata({authority:'example.com:443'}),input={...leaf,generation:'one',policyVersion:4};
|
||||
for(const patch of [{generation:'two'},{policyVersion:3},{authority:'attacker.test:443'},{fingerprint256:'wrong'},{cert:leaf.cert+'private trailing payload'},{cert:leaf.cert+leaf.cert}]){
|
||||
const pins=createBrokerCertificatePins({generation:'one',policyVersion:4,current:()=>true,authorizedAuthority:()=>true});
|
||||
assert.throws(()=>pins.accept({...input,...patch}),/broker-certificate-pin-rejected/);assert.equal(pins.status().pins,0);
|
||||
}
|
||||
const denied=createBrokerCertificatePins({generation:'one',policyVersion:4,current:()=>true,authorizedAuthority:()=>false});assert.throws(()=>denied.accept(input));
|
||||
}finally{issuer.close();}
|
||||
});
|
||||
test('noncanonical authority and pin capacity cannot weaken trust',async()=>{
|
||||
for(const value of ['127.1:443','EXAMPLE.com:443','example.com:0443','example.com:65536','example.com:[email protected]','[0:0:0:0:0:0:0:1]:443'])assert.throws(()=>authorityFacts(value));
|
||||
assert.equal(authorityFacts('[::1]:443').hostname,'::1');
|
||||
const issuer=await createSessionCertificates({current:()=>true,authorized:()=>true});
|
||||
const pins=createBrokerCertificatePins({generation:'one',policyVersion:4,current:()=>true,authorizedAuthority:()=>true,maxPins:1});
|
||||
try{for(const [index,authority]of ['example.com:443','other.test:443'].entries()){
|
||||
const input={...issuer.publicMetadata({authority}),generation:'one',policyVersion:4};if(index===0)assert.equal(pins.accept(input),true);else assert.throws(()=>pins.accept(input));
|
||||
}}finally{issuer.close();}
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import tls from 'node:tls';
|
||||
import fs from 'node:fs';
|
||||
import {once} from 'node:events';
|
||||
import {createRequire} from 'node:module';
|
||||
const {createConnectProxy}=createRequire(import.meta.url)('../apps/network-broker/connect-inspection-proxy.cjs');
|
||||
const cert=fs.readFileSync(new URL('./fixtures/icap/cert.pem',import.meta.url)),key=fs.readFileSync(new URL('./fixtures/icap/key.pem',import.meta.url));
|
||||
|
||||
async function fixture(t,config={}) {
|
||||
let connections=0,resolutions=0;
|
||||
const received=[];
|
||||
const target=https.createServer({key,cert},async(req,res)=>{const chunks=[];for await(const chunk of req)chunks.push(chunk);received.push(Buffer.concat(chunks).toString());res.writeHead(201,{'content-type':'text/plain'});res.end('accepted');});
|
||||
target.on('connection',()=>connections++);target.listen(0,'127.0.0.1');await once(target,'listening');
|
||||
const destination=`localhost:${target.address().port}`;
|
||||
const proxy=createConnectProxy({authority:'policy-1',current:()=>true,resolveCertificate:async binding=>{resolutions++;if(binding.authority!==destination)return undefined;return {authority:destination,key,cert};},inspect:async({body})=>({decision:body.toString().includes('SECRET')?'block':'allow'}),forward:({request,body,signal})=>new Promise((resolve,reject)=>{
|
||||
const req=https.request({host:'127.0.0.1',port:target.address().port,path:new URL(request.url).pathname,method:request.method,servername:'localhost',ca:cert,rejectUnauthorized:true,signal,headers:{host:destination,'content-length':body.length}},res=>{const chunks=[];res.on('data',c=>chunks.push(c));res.on('end',()=>resolve({statusCode:res.statusCode,headers:res.headers,body:Buffer.concat(chunks)}));});req.on('error',reject);req.end(body);
|
||||
}),deadlineMs:1000,authMode:'bearer',...config});
|
||||
const port=await proxy.listen();
|
||||
t.after(async()=>{await proxy.close();target.closeAllConnections();await new Promise(r=>target.close(r));});
|
||||
async function send(body,{token=proxy.capability,authorization=`Bearer ${token}`,connectAuthority=destination,host=destination,servername='localhost'}={}) {
|
||||
const result=await new Promise((resolve,reject)=>{
|
||||
const req=http.request({host:'127.0.0.1',port,method:'CONNECT',path:connectAuthority,headers:{host:connectAuthority,'proxy-authorization':authorization}});
|
||||
req.setTimeout(2000,()=>req.destroy(new Error('connect-test-deadline')));req.on('error',reject);
|
||||
req.on('connect',(res,socket,head)=>{if(res.statusCode!==200){socket.destroy();resolve({status:res.statusCode,challenge:res.headers['proxy-authenticate']});return;}assert.equal(head.length,0);
|
||||
const secure=tls.connect({socket,servername,ca:cert,rejectUnauthorized:true});let text='';
|
||||
secure.setTimeout(2000,()=>secure.destroy(new Error('tls-test-deadline')));secure.on('error',reject);
|
||||
secure.on('secureConnect',()=>secure.write(`POST /submit HTTP/1.1\r\nHost: ${host}\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n${body}`));
|
||||
secure.on('data',c=>text+=c);secure.on('end',()=>{secure.destroy();resolve({status:Number(/^HTTP\/1.1 (\d+)/.exec(text)?.[1]),text});});
|
||||
});req.end();
|
||||
});return result;
|
||||
}
|
||||
return {send,received,credentials:proxy.credentials,get connections(){return connections;},get resolutions(){return resolutions;}};
|
||||
}
|
||||
test('real authenticated CONNECT wraps verified TLS; no opaque target dial for secret',async t=>{
|
||||
const f=await fixture(t);
|
||||
assert.equal((await f.send('SECRET')).status,403);assert.equal(f.connections,0);assert.deepEqual(f.received,[]);
|
||||
const allowed=await f.send('public');assert.equal(allowed.status,201);assert.match(allowed.text,/accepted$/);assert.equal(f.connections,1);assert.deepEqual(f.received,['public']);
|
||||
});
|
||||
test('incorrect token prevents certificate resolution, TLS establishment and target dial',async t=>{
|
||||
const f=await fixture(t);assert.equal((await f.send('public',{token:'incorrect'})).status,407);assert.equal(f.resolutions,0);assert.equal(f.connections,0);
|
||||
});
|
||||
test('unapproved CONNECT authority, inner mismatch and absent SNI never dial target',async t=>{
|
||||
const f=await fixture(t);
|
||||
assert.equal((await f.send('public',{connectAuthority:'evil.example:443'})).status,403);
|
||||
assert.equal((await f.send('public',{host:'evil.example:443'})).status,403);
|
||||
assert.equal((await f.send('public',{servername:''})).status,403);
|
||||
assert.equal(f.connections,0);assert.deepEqual(f.received,[]);
|
||||
});
|
||||
test('shared CONNECT work budget remains consumed when timed-out inspector ignores cancellation',async t=>{
|
||||
const retained=[];
|
||||
const f=await fixture(t,{maxClients:1,deadlineMs:100,inspect:({body})=>{retained.push(body);return new Promise(()=>{});}});
|
||||
assert.equal((await f.send('public')).status,403);
|
||||
for(let index=0;index<3;index++)assert.equal((await f.send('public')).status,403);
|
||||
assert.equal(retained.length,1);assert.equal(retained[0].toString(),'public');assert.equal(f.connections,0);
|
||||
});
|
||||
test('shared CONNECT work budget retains an ignored forward promise after timeout',async t=>{
|
||||
let calls=0;
|
||||
const f=await fixture(t,{maxClients:1,deadlineMs:100,forward:()=>{calls++;return new Promise(()=>{});}});
|
||||
assert.equal((await f.send('public')).status,502);
|
||||
for(let index=0;index<3;index++)assert.equal((await f.send('public')).status,403);
|
||||
assert.equal(calls,1);assert.equal(f.connections,0);
|
||||
});
|
||||
test('timed-out certificate resolver retains shared capacity across new CONNECT sockets',async t=>{
|
||||
let calls=0,aborted=false;
|
||||
const f=await fixture(t,{maxClients:1,deadlineMs:100,resolveCertificate:(_binding,{signal})=>{calls++;signal.addEventListener('abort',()=>aborted=true,{once:true});return new Promise(()=>{});}});
|
||||
for(let index=0;index<4;index++)assert.equal((await f.send('public')).status,403);
|
||||
assert.equal(calls,1);assert.equal(aborted,true);assert.equal(f.connections,0);
|
||||
});
|
||||
test('Basic mode challenges and rejects Bearer without downgrade then accepts exact random credentials',async t=>{
|
||||
const f=await fixture(t,{authMode:'basic'});
|
||||
const refused=await f.send('public');assert.equal(refused.status,407);assert.equal(refused.challenge,'Basic realm="SafetyNet private broker", charset="UTF-8"');assert.equal(f.connections,0);assert.equal(f.resolutions,0);
|
||||
const authorization=`Basic ${Buffer.from(`${f.credentials.username}:${f.credentials.password}`).toString('base64')}`;
|
||||
assert.equal((await f.send('public',{authorization})).status,201);assert.deepEqual(f.received,['public']);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
import {createServer} from 'node:http';
|
||||
import {DiagnosticsSchema} from '../packages/core/diagnostics.mjs';
|
||||
const require=createRequire(import.meta.url);
|
||||
const {createDiagnostics}=require('../apps/browser/diagnostics.cjs');
|
||||
const {createBrokerDiagnostics}=require('../apps/network-broker/diagnostics-adapter.cjs');
|
||||
async function fixture(t){
|
||||
const payloads=[];
|
||||
const server=createServer((request,response)=>{
|
||||
const chunks=[];let bytes=0;
|
||||
request.on('data',chunk=>{bytes+=chunk.length;if(bytes>1048576){request.destroy();return;}chunks.push(chunk);});
|
||||
request.on('end',()=>{payloads.push(JSON.parse(Buffer.concat(chunks).toString()));response.setHeader('content-type','application/json');response.end('{}');});
|
||||
}).listen(0,'127.0.0.1');
|
||||
await new Promise(resolve=>server.once('listening',resolve));
|
||||
const diagnostics=createDiagnostics();
|
||||
diagnostics.configure(DiagnosticsSchema.parse({enabled:true,mode:'debug',endpoint:`http://127.0.0.1:${server.address().port}`,sampleRate:1,debugSampleRate:1,flushIntervalMs:10000}));
|
||||
t.after(async()=>{await diagnostics.shutdown();server.closeAllConnections();await new Promise(resolve=>server.close(resolve));});
|
||||
return {diagnostics,adapter:createBrokerDiagnostics(diagnostics),payloads,spans:()=>payloads.flatMap(p=>p.resourceSpans||[]).flatMap(p=>p.scopeSpans||[]).flatMap(p=>p.spans||[])};
|
||||
}
|
||||
test('broker stages export actual correlated OTLP timing without payloads or authority',async t=>{
|
||||
const f=await fixture(t),secret='PRIVATE_BROKER_SECRET',attributes={bytes:12,policyVersion:4,url:'http://private-company.test/',authority:secret,token:secret,content:secret};
|
||||
await f.adapter.wrap('request',async()=>{
|
||||
for(const stage of ['framing','buffer','inspect','authorize','forward'])assert.equal(await f.adapter.wrap(stage,async()=>17,attributes),17);
|
||||
});
|
||||
await f.diagnostics.flush();
|
||||
const spans=f.spans();assert.equal(spans.length,6);
|
||||
const operations=spans.map(span=>span.attributes.find(attribute=>attribute.key==='safetynet.operation')?.value.stringValue).sort();
|
||||
assert.deepEqual(operations,['broker.authorize','broker.buffer','broker.forward','broker.framing','broker.inspect','broker.request'].sort());
|
||||
const parent=spans.find(span=>!span.parentSpanId);assert.ok(parent);
|
||||
for(const child of spans.filter(span=>span!==parent)){
|
||||
assert.equal(child.parentSpanId,parent.spanId);assert.equal(child.traceId,parent.traceId);
|
||||
assert.ok(BigInt(child.endTimeUnixNano)>=BigInt(child.startTimeUnixNano));
|
||||
assert.ok(child.attributes.some(attribute=>attribute.key==='safetynet.duration.ms'));
|
||||
}
|
||||
const text=JSON.stringify(f.payloads);assert.equal(text.includes(secret),false);assert.equal(text.includes('private-company'),false);
|
||||
assert.equal(f.diagnostics.status().lifetimeFailedExports,0);
|
||||
});
|
||||
test('disabled diagnostics preserve the exact enforcement failure with no exports',async t=>{
|
||||
const f=await fixture(t);f.diagnostics.configure({enabled:false});
|
||||
const failure=new Error('PRIVATE_ENFORCEMENT_FAILURE');
|
||||
await assert.rejects(f.adapter.wrap('inspect',async()=>{throw failure;}),error=>error===failure);
|
||||
assert.equal(await f.adapter.wrap('forward',async()=>23),23);
|
||||
await f.diagnostics.flush();assert.equal(f.spans().length,0);
|
||||
});
|
||||
test('enabled failure tracing preserves error identity without exporting error text',async t=>{
|
||||
const f=await fixture(t),failure=new Error('PRIVATE_ERROR_DETAIL');
|
||||
await assert.rejects(f.adapter.wrap('inspect',async()=>{throw failure;}),error=>error===failure);
|
||||
await f.diagnostics.flush();assert.equal(f.spans().length,1);
|
||||
assert.equal(JSON.stringify(f.payloads).includes('PRIVATE_ERROR_DETAIL'),false);
|
||||
assert.equal(f.spans()[0].status.code,2);
|
||||
});
|
||||
test('adapter rejects unknown stages and never reads telemetry accessors',()=>{
|
||||
let touched=false,seen;
|
||||
const adapter=createBrokerDiagnostics({span:(_name,attributes,fn)=>{seen=attributes;return fn();}});
|
||||
const attributes={get bytes(){touched=true;throw Error('private');},count:2,url:'private',operation:'private'};
|
||||
assert.equal(adapter.wrap('buffer',()=>true,attributes),true);
|
||||
assert.equal(touched,false);assert.deepEqual(seen,{operation:'broker.buffer',count:2});
|
||||
assert.throws(()=>adapter.wrap('private',()=>true),{message:'broker_diagnostics_stage_invalid'});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
import https from 'node:https';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import {dnsAddressBlocked} from '../packages/core/security.mjs';
|
||||
const require=createRequire(import.meta.url),{createDnsPolicyRoute,assertSupportedDns}=require('../apps/network-broker/dns-policy-route.cjs');
|
||||
const policy={security:{dns:{enabled:true,blockedIPs:['127.0.0.1','::1']}}};
|
||||
test('blocked literal including mapped IPv6 is denied before any DNS lookup',async()=>{
|
||||
let calls=0;const resolve=createDnsPolicyRoute({current:()=>true,blocked:a=>dnsAddressBlocked(a,policy),lookup:async()=>{calls++;throw Error();}});
|
||||
for(const hostname of ['127.0.0.1','[::1]','::ffff:127.0.0.1'])await assert.rejects(resolve({hostname,port:443}),{message:'broker-route-denied'});assert.equal(calls,0);
|
||||
});
|
||||
test('real OS DNS blocked route produces zero TCP at controlled HTTPS receiver',async()=>{
|
||||
const cert=await readFile(new URL('./fixtures/icap/cert.pem',import.meta.url)),key=await readFile(new URL('./fixtures/icap/key.pem',import.meta.url));let connections=0;
|
||||
const server=https.createServer({cert,key},(_q,r)=>r.end('public'));server.on('connection',()=>connections++);await new Promise(r=>server.listen(0,'::',r));const port=server.address().port;
|
||||
try{const {createHttpsForwarder}=require('../apps/network-broker/https-forwarder.cjs'),resolveRoute=createDnsPolicyRoute({current:()=>true,blocked:a=>dnsAddressBlocked(a,policy)}),forward=createHttpsForwarder({authority:'localhost:'+port,current:()=>true,resolveRoute,ca:cert});await assert.rejects(forward({request:{url:`https://localhost:${port}/`,method:'GET',headers:{}},body:Buffer.alloc(0),signal:new AbortController().signal}));assert.equal(connections,0);}finally{server.closeAllConnections();await new Promise(r=>server.close(r));}
|
||||
});
|
||||
test('system/VPN DNS all-address check refuses mixed blocked records and binds approved numeric route',async()=>{
|
||||
let response=[{address:'192.0.2.1',family:4},{address:'127.0.0.1',family:4}];const resolve=createDnsPolicyRoute({current:()=>true,blocked:a=>dnsAddressBlocked(a,policy),lookup:async(host,options)=>{assert.equal(host,'intranet.example');assert.deepEqual(options,{all:true,verbatim:true});return response;}});
|
||||
await assert.rejects(resolve({hostname:'intranet.example',port:443}),{message:'broker-route-denied'});response=[{address:'192.0.2.1',family:4}];const route=await resolve({hostname:'intranet.example',port:443});response[0].address='127.0.0.1';assert.equal(route.address,'192.0.2.1');assert.ok(Object.isFrozen(route));
|
||||
});
|
||||
test('revocation during original lookup and malformed results never produce approved route',async()=>{
|
||||
let active=true;const resolve=createDnsPolicyRoute({current:()=>active,blocked:()=>false,lookup:async()=>{active=false;return [{address:'192.0.2.1'}];}});await assert.rejects(resolve({hostname:'example.test',port:443}),{message:'broker-route-denied'});
|
||||
for(const response of [[],[{address:'hostname'}],null])await assert.rejects(createDnsPolicyRoute({current:()=>true,blocked:()=>false,lookup:async()=>response})({hostname:'example.test',port:443}),{message:'broker-route-denied'});
|
||||
});
|
||||
test('secure DNS and hostname proxy with active IP block policy refuse unsupported downgrade',()=>{
|
||||
assert.throws(()=>assertSupportedDns({enterprise:{dns:{mode:'secure'}}},{mode:'direct'}),/broker-route-unsupported/);assert.throws(()=>assertSupportedDns(policy,{mode:'fixed_servers'}),/broker-route-unsupported/);assert.doesNotThrow(()=>assertSupportedDns(policy,{mode:'direct'}));assert.doesNotThrow(()=>assertSupportedDns({security:{dns:{enabled:false,blockedIPs:['127.0.0.1']}}},{mode:'fixed_servers'}));
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
import {defaultPolicy} from '../packages/core/policy.mjs';
|
||||
const require=createRequire(import.meta.url),{createDownloadResponseGate}=require('../apps/network-broker/download-response-gate.cjs');
|
||||
const input={request:{url:'https://intranet.example/document.pdf'},body:Buffer.alloc(0)};
|
||||
const response=(body,headers={},statusCode=200)=>({statusCode,headers,body:Buffer.from(body)});
|
||||
test('incoming confidential HTML/API is not outbound DLP; magic document requires actual scanner',async()=>{
|
||||
const policy=defaultPolicy();policy.browser.allowDownloads=true;let calls=0;
|
||||
const page=createDownloadResponseGate({auditRequired:false,policy,current:()=>true,forward:async()=>response('company internal text',{'content-type':'text/html'}),inspector:{inspect:async()=>{calls++;assert.fail('incoming page not outgoingDLP');}}});
|
||||
assert.equal((await page(input)).body.toString(),'company internal text');assert.equal(calls,0);
|
||||
const document=createDownloadResponseGate({auditRequired:false,policy,current:()=>true,forward:async()=>response('%PDF-1.4 public',{'content-type':'application/pdf'})});await assert.rejects(document(input),{code:'download-inspection-blocked'});
|
||||
});
|
||||
test('signed explicit no-malware-scanner still requires complete document parsing and preserves disposition',async()=>{
|
||||
const policy=defaultPolicy();policy.browser.allowDownloads=true;policy.fileTransfers.download.allowWithoutMalwareScanner=true;
|
||||
let calls=0;const gate=createDownloadResponseGate({auditRequired:false,policy,current:()=>true,forward:async()=>response('%PDF-1.4 public',{'content-type':'application/pdf','content-disposition':'inline; filename="public.pdf"'}),inspector:{inspect:async()=>{calls++;return {decision:'allow',coverage:{complete:true}};}}});
|
||||
const value=await gate(input);assert.equal(calls,1);assert.equal(value.headers['content-disposition'],'inline; filename="public.pdf"');assert.equal(value.body.toString(),'%PDF-1.4 public');
|
||||
});
|
||||
test('malware/DLP block, incomplete coverage, scanner mutation and revocation release no response',async()=>{
|
||||
for(const inspect of [async()=>({decision:'block',ruleId:'registered'}),async()=>({decision:'allow',coverage:{complete:false}}),async bytes=>{bytes.fill(0);return {decision:'allow',coverage:{complete:true}};}]){
|
||||
const policy=defaultPolicy();policy.browser.allowDownloads=true;policy.fileTransfers.download.allowWithoutMalwareScanner=true;
|
||||
const gate=createDownloadResponseGate({auditRequired:false,policy,current:()=>true,forward:async()=>response('%PDF-1.4 public'),inspector:{inspect}});await assert.rejects(gate(input),{code:'download-inspection-blocked'});
|
||||
}
|
||||
const policy=defaultPolicy();policy.browser.allowDownloads=true;policy.fileTransfers.download.allowWithoutMalwareScanner=true;let current=true;
|
||||
const gate=createDownloadResponseGate({auditRequired:false,policy,current:()=>current,forward:async()=>response('%PDF-1.4 public'),inspector:{inspect:async()=>{current=false;return {decision:'allow',coverage:{complete:true}};}}});await assert.rejects(gate(input),{code:'authority-expired'});
|
||||
});
|
||||
test('magic mismatch, partial206 and compression cannot bypass full download inspection',async()=>{
|
||||
const policy=defaultPolicy();policy.browser.allowDownloads=true;policy.fileTransfers.download.blockMimeMismatch=true;
|
||||
for(const record of [response('%PDF-1.4 public',{'content-type':'text/plain'}),response('%PDF-1.4 public',{},206),response('%PDF-1.4 public',{'content-encoding':'gzip'})]){
|
||||
const gate=createDownloadResponseGate({auditRequired:false,policy,current:()=>true,forward:async()=>record,inspector:{inspect:async()=>assert.fail('invalidrepresentation must not scan')}});await assert.rejects(gate(input));
|
||||
}
|
||||
});
|
||||
import {randomUUID,createHash} from 'node:crypto';
|
||||
import {createSyntheticAudit} from './fixtures/broker-durable-audit.cjs';
|
||||
const {createBrokerAudit}=require('../apps/network-broker/audit-gate.cjs'),{createBrokerLifecycle}=require('../apps/network-broker/lifecycle-audit.cjs');
|
||||
test('download verdict commits typed immutable real-file facts and registered detector before release',async t=>{
|
||||
const policy=defaultPolicy();policy.browser.allowDownloads=true;policy.fileTransfers.download.allowWithoutMalwareScanner=true;policy.dlp.rules=[{id:'registered-secret',label:'Synthetic',kind:'literal',value:'synthetic',action:'block'}];
|
||||
const journal=createSyntheticAudit(t),audit=createBrokerAudit({persist:journal.persist,current:()=>true,policyVersion:1,allowedRuleIds:['registered-secret']}),context=createBrokerLifecycle(audit).begin();await context.decision('allow',{bytes:0,detectedType:'text'});
|
||||
const body=Buffer.from('%PDF-1.4 encodedsynthetic'),gate=createDownloadResponseGate({policy,current:()=>true,forward:async()=>response(body),inspector:{inspect:async()=>({decision:'block',ruleId:'registered-secret',coverage:{complete:true}})}});
|
||||
await assert.rejects(gate({...input,auditContext:context}),{code:'download-inspection-blocked'});await context.reject('upstream-failed',{bytes:0,detectedType:'text'});
|
||||
const events=journal.events();assert.equal(events.length,2);assert.equal(events[1].kind,'download.block');assert.equal(events[1].ruleId,'registered-secret');assert.equal(events[1].broker.phase,'response.block');assert.equal(events[1].broker.authorizedEventId,events[0].eventId);assert.equal(events[1].file.sha256,createHash('sha256').update(body).digest('hex'));assert.equal(events[1].request,undefined);
|
||||
});
|
||||
test('pending or failed mandatory download commit cannot return document bytes',async()=>{
|
||||
const policy=defaultPolicy();policy.browser.allowDownloads=true;policy.fileTransfers.download.allowWithoutMalwareScanner=true;let release,entered;const hold=new Promise(resolve=>release=resolve),started=new Promise(resolve=>entered=resolve);let completed=false;
|
||||
const audit=createBrokerAudit({current:()=>true,policyVersion:1,persist:async event=>{if(event.kind==='download.scan'){entered();await hold;}return {persisted:true,eventId:event.eventId};}}),context=createBrokerLifecycle(audit).begin();await context.decision('allow',{bytes:0,detectedType:'text'});
|
||||
const gate=createDownloadResponseGate({policy,current:()=>true,forward:async()=>response('%PDF-1.4 public'),inspector:{inspect:async()=>({decision:'allow',coverage:{complete:true}})}});
|
||||
const pending=gate({...input,auditContext:context}).then(()=>completed=true);await started;assert.equal(completed,false);release();await pending;assert.equal(completed,true);
|
||||
const fail=createBrokerAudit({current:()=>true,policyVersion:1,persist:async event=>{if(event.kind==='download.scan')throw Error('private journal detail');return {persisted:true,eventId:event.eventId};}}),denied=createBrokerLifecycle(fail).begin();await denied.decision('allow',{bytes:0,detectedType:'text'});await assert.rejects(gate({...input,auditContext:denied}),{code:'audit-persistence-failed'});
|
||||
const missing=createDownloadResponseGate({policy,current:()=>true,forward:async()=>assert.fail('no unaudited dial')});await assert.rejects(missing(input),{code:'audit-unavailable'});
|
||||
});
|
||||
test('download causation refuses missing authorization and unknown matched detector',async()=>{
|
||||
const audit=createBrokerAudit({current:()=>true,policyVersion:1,persist:async()=>assert.fail('invalid facts never persist')});
|
||||
for(const broker of [{requestId:randomUUID(),phase:'response.block',reason:'broker-download-inspection-block'},{requestId:randomUUID(),phase:'response.scanned',reason:'broker-download-approved',authorizedEventId:randomUUID()}])await assert.rejects(audit.recordDownload('block',{bytes:3,detectedType:'pdf',sha256:'a'.repeat(64),broker}),{code:'audit-facts-invalid'});
|
||||
});
|
||||
|
||||
test('OS save prohibition does not bypass or deny complete inline asset scans',async()=>{
|
||||
const policy=defaultPolicy();policy.fileTransfers.download.allowWithoutMalwareScanner=true;const bytes=Buffer.from('89504e470d0a1a0a','hex');let scans=0;
|
||||
const gate=createDownloadResponseGate({auditRequired:false,policy,current:()=>true,forward:async()=>response(bytes,{'content-type':'image/png'}),inspector:{inspect:async()=>{scans++;return {decision:'allow',coverage:{complete:true}};}}});
|
||||
assert.deepEqual((await gate({...input,request:{url:'https://intranet.example/icon.png'}})).body,bytes);assert.equal(scans,1);
|
||||
const attachment=createDownloadResponseGate({auditRequired:false,policy,current:()=>true,forward:async()=>response(bytes,{'content-type':'image/png','content-disposition':'attachment; filename=icon.png'}),inspector:{inspect:async()=>assert.fail('policy denies before scan')}});await assert.rejects(attachment(input),{code:'download-policy-blocked'});
|
||||
});
|
||||
|
||||
test('late original classifier cannot commit another decision after a terminal scan timeout',async t=>{
|
||||
const journal=createSyntheticAudit(t),audit=createBrokerAudit({persist:journal.persist,current:()=>true,policyVersion:1}),context=createBrokerLifecycle(audit).begin();
|
||||
await context.reject('inspection-deadline',{bytes:6,detectedType:'text'});assert.throws(()=>context.decision('allow',{bytes:6,detectedType:'text'}),{code:'aborted'});assert.throws(()=>context.response('allow',{bytes:6,detectedType:'pdf',sha256:'a'.repeat(64)}),{code:'aborted'});
|
||||
const events=journal.events();assert.equal(events.length,1);assert.equal(events[0].broker.reason,'broker-scan-timeout');
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {fork} from 'node:child_process';
|
||||
import {once} from 'node:events';
|
||||
import fs from 'node:fs';
|
||||
import {createRequire} from 'node:module';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import https from 'node:https';
|
||||
import {X509Certificate,generateKeyPairSync} from 'node:crypto';
|
||||
import {defaultPolicy,signPolicy} from '../packages/core/policy.mjs';
|
||||
const require=createRequire(import.meta.url),{createManagedProxy}=require('../apps/network-broker/managed-proxy.cjs'),{createPolicyAuthority}=require('../apps/network-broker/policy-authority.cjs'),{createHttpsForwarder}=require('../apps/network-broker/https-forwarder.cjs');
|
||||
const {createSyntheticAudit}=require('./fixtures/broker-durable-audit.cjs');
|
||||
const cert=fs.readFileSync(new URL('./fixtures/icap/cert.pem',import.meta.url)),key=fs.readFileSync(new URL('./fixtures/icap/key.pem',import.meta.url));
|
||||
function bounded(promise,ms=15000){let timer;return Promise.race([promise,new Promise((_,reject)=>timer=setTimeout(()=>reject(Error('interop-deadline')),ms))]).finally(()=>clearTimeout(timer));}
|
||||
test('stock Electron authenticates Basic proxy and sends only signed-policy approved browser content',{
|
||||
skip:process.platform!=='win32'||process.env.SAFETYNET_NATIVE_BROKER_ELECTRON!=='1',timeout:60000
|
||||
},async t=>{
|
||||
assert.equal(process.execPath.toLowerCase(),fileURLToPath(new URL('../.runtime/windows/broker/node.exe',import.meta.url)).toLowerCase());
|
||||
const received=[];let leakedProxyHeader=false,connections=0;
|
||||
const site=https.createServer({key,cert},async(req,res)=>{
|
||||
leakedProxyHeader ||= !!req.headers['proxy-authorization'];
|
||||
const chunks=[];for await(const chunk of req)chunks.push(chunk);
|
||||
if(req.method==='POST'){received.push(Buffer.concat(chunks).toString());res.writeHead(201,{'content-type':'text/plain'});res.end('accepted');}
|
||||
else {res.setHeader('content-type','text/html');res.end('<!doctype html><link rel="icon" href="data:,"><h1>Local interop fixture</h1>');}
|
||||
});site.on('connection',()=>connections++);site.listen(0,'127.0.0.1');await once(site,'listening');
|
||||
const port=site.address().port,destination=`localhost:${port}`,origin=`https://${destination}`;
|
||||
t.after(async()=>{site.closeAllConnections();await new Promise(resolve=>site.close(resolve));});
|
||||
const keys=generateKeyPairSync('ed25519'),publicKey=keys.publicKey.export({type:'spki',format:'pem'}),privateKey=keys.privateKey.export({type:'pkcs8',format:'pem'});
|
||||
const p=defaultPolicy();p.assignment={deviceId:'interop-device',groups:[]};p.dlp.rules=[{id:'company-secret',label:'Sensitive',kind:'literal',value:'PROJECT SECRET',action:'block'}];
|
||||
const authority=await createPolicyAuthority({publicKey,deviceId:'interop-device'}),snapshot=authority.accept(signPolicy(p,privateKey));
|
||||
const forward=createHttpsForwarder({authority:snapshot.token,current:authority.current,resolveRoute:()=>({approved:true,address:'127.0.0.1',port}),ca:cert,maxResponseBytes:16777216});
|
||||
const audit=createSyntheticAudit(t);
|
||||
const proxy=await createManagedProxy({policyAuthority:authority,persistAudit:audit.persist,resolveCertificate:binding=>binding.authority===destination?{authority:destination,key,cert}:undefined,forward});
|
||||
const proxyPort=await proxy.listen();t.after(()=>proxy.close());
|
||||
const artifactRoot=fileURLToPath(new URL('../artifacts/broker-electron-interop/',import.meta.url));fs.mkdirSync(artifactRoot,{recursive:true});
|
||||
const userData=fs.mkdtempSync(artifactRoot+'profile-');
|
||||
const cleanEnvironment=Object.fromEntries(['SystemRoot','WINDIR','TEMP','TMP','USERPROFILE','HOMEDRIVE','HOMEPATH','LOCALAPPDATA','APPDATA','PATH'].filter(name=>process.env[name]!==undefined).map(name=>[name,process.env[name]]));
|
||||
const child=fork(fileURLToPath(new URL('./fixtures/broker-electron-interop.cjs',import.meta.url)),[],{execPath:require('electron'),execArgv:[],env:cleanEnvironment,windowsHide:true,stdio:['ignore','ignore','ignore','ipc']});
|
||||
t.after(()=>{if(child.exitCode===null)child.kill();});
|
||||
const ready=bounded(once(child,'message'));child.send({type:'initialize',config:{proxyPort,origin,userData,credentials:proxy.credentials,fingerprint:new X509Certificate(cert).fingerprint256}});
|
||||
const [initialized]=await ready;assert.equal(initialized.type,'ready');assert.ok(initialized.loginCount>=1);assert.match(initialized.electron,/^44\./);
|
||||
const operation=async type=>{const result=bounded(once(child,'message'));child.send({type});const [message]=await result;assert.equal(message.type,'result');return message.result;};
|
||||
assert.deepEqual(await operation('public'),{status:201,body:'accepted'});assert.deepEqual(received,['public browser content']);
|
||||
const beforeSecret=connections;
|
||||
const blocked=await operation('secret');assert.ok(blocked.blocked||blocked.status===403);assert.deepEqual(received,['public browser content']);assert.equal(connections,beforeSecret);
|
||||
const next=structuredClone(p);next.version=2;authority.accept(signPolicy(next,privateKey));
|
||||
const revoked=await operation('revoked');assert.ok(revoked.blocked||revoked.status===403);assert.deepEqual(received,['public browser content']);assert.equal(leakedProxyHeader,false);assert.equal(connections,beforeSecret);
|
||||
const exit=bounded(once(child,'exit'));child.send({type:'stop'});await exit;
|
||||
fs.mkdirSync('artifacts/broker-electron-interop',{recursive:true});
|
||||
fs.writeFileSync(`artifacts/broker-electron-interop/proof-${new Date().toISOString().replace(/:/g,'-')}.json`,JSON.stringify({schemaVersion:1,electron:initialized.electron,chromium:initialized.chromium,brokerNode:process.version,proxyAuthentication:'Basic',loginEvents:initialized.loginCount,authorizedPosts:received.length,authorizedBytes:Buffer.byteLength(received[0]),secretPosts:0,revokedPosts:0,upstreamConnectionsBeforeSecret:beforeSecret,upstreamConnectionsAfterBlocks:connections,proxyCredentialsLeaked:leakedProxyHeader,isolatedFixture:true},null,2));
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {EventEmitter} from 'node:events';
|
||||
import {createRequire} from 'node:module';
|
||||
import {PassThrough} from 'node:stream';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
const {createFramedChannel}=createRequire(import.meta.url)('../apps/network-broker/framed-channel.cjs');
|
||||
function frame(value){const bytes=Buffer.from(typeof value==='string'?value:JSON.stringify(value)),result=Buffer.alloc(bytes.length+4);result.writeUInt32LE(bytes.length);bytes.copy(result,4);return result;}
|
||||
function fixture(options={}){
|
||||
const read=new EventEmitter(),write=new EventEmitter(),writes=[],callbacks=[];
|
||||
read.destroy=()=>{};write.destroy=()=>{};write.write=(bytes,callback)=>{writes.push(Buffer.from(bytes));callbacks.push(callback);};
|
||||
const channel=createFramedChannel({readable:read,writable:write,...options}),messages=[];let disconnected=0;
|
||||
channel.on('message',value=>messages.push(value));channel.on('disconnect',()=>disconnected++);
|
||||
return {read,write,channel,writes,callbacks,messages,disconnected:()=>disconnected};
|
||||
}
|
||||
test('fragmented headers and UTF8 plus coalesced frames preserve exact bounded objects',()=>{
|
||||
const f=fixture(),bytes=Buffer.concat([frame({text:'ä漢字'}),frame({type:'stop'})]);
|
||||
for(const byte of bytes)f.read.emit('data',Buffer.from([byte]));
|
||||
assert.deepEqual(f.messages,[{text:'ä漢字'},{type:'stop'}]);assert.equal(f.channel.connected,true);
|
||||
f.read.emit('data',Buffer.concat([frame({n:1}),frame({n:2})]));assert.equal(f.messages.length,4);
|
||||
f.channel.disconnect();assert.equal(f.disconnected(),1);
|
||||
});
|
||||
test('first bootstrap alone may exceed64KiB; later oversize rejected before allocation',()=>{
|
||||
const f=fixture({inboundFirstLimit:2097152});f.read.emit('data',frame({payload:'x'.repeat(70000)}));assert.equal(f.messages.length,1);
|
||||
const header=Buffer.alloc(4);header.writeUInt32LE(65537);f.read.emit('data',header);assert.equal(f.channel.connected,false);assert.equal(f.messages.length,1);
|
||||
for(const length of [0,2097153,0xffffffff]){const g=fixture({inboundFirstLimit:2097152}),h=Buffer.alloc(4);h.writeUInt32LE(length);g.read.emit('data',h);assert.equal(g.disconnected(),1);}
|
||||
});
|
||||
test('invalid UTF8, primitive JSON and malformed JSON disconnect without message or raw error',()=>{
|
||||
const invalid=Buffer.from([2,0,0,0,0xc0,0xaf]);
|
||||
for(const bytes of [invalid,frame('null'),frame('[]'),frame('"secret"'),frame('{broken')]){const f=fixture();f.read.emit('data',bytes);assert.equal(f.messages.length,0);assert.equal(f.disconnected(),1);}
|
||||
const f=fixture();f.read.emit('data',frame({valid:true}).subarray(0,6));f.read.emit('end');assert.equal(f.disconnected(),1);assert.equal(f.messages.length,0);
|
||||
});
|
||||
test('writes are framed with first-only bootstrap limit and at most8 unsettled writes',()=>{
|
||||
const f=fixture({outboundFirstLimit:2097152}),results=[];
|
||||
f.channel.send({payload:'x'.repeat(70000)},error=>results.push(error));assert.equal(f.writes[0].readUInt32LE(0),70014);f.callbacks.shift()();assert.equal(results[0],undefined);
|
||||
f.channel.send({payload:'x'.repeat(70000)},error=>results.push(error));assert.equal(f.disconnected(),1);assert.equal(results[1].message,'broker-channel-unavailable');assert.equal(f.writes.length,1);
|
||||
const g=fixture();for(let i=0;i<8;i++)g.channel.send({n:i});assert.equal(g.writes.length,8);let failure;
|
||||
g.channel.send({n:8},error=>{failure=error;});assert.equal(failure.message,'broker-channel-unavailable');assert.equal(g.disconnected(),1);assert.equal(g.writes.length,8);
|
||||
});
|
||||
test('stream failure yields fixed callback error and exactly one disconnect',()=>{
|
||||
const f=fixture();let failure;f.channel.send({type:'ready'},error=>{failure=error;});f.callbacks[0](Error('PRIVATE_FAILURE'));assert.equal(failure.message,'broker-channel-unavailable');
|
||||
f.read.emit('error',Error('PRIVATE_FAILURE'));f.write.emit('error',Error('PRIVATE_FAILURE'));assert.equal(f.disconnected(),1);
|
||||
});
|
||||
test('disconnect fails pending callbacks exactly once; late write success grants no success',()=>{
|
||||
const f=fixture(),results=[];f.channel.send({type:'audit'},error=>results.push(error?.message||'success'));
|
||||
f.channel.disconnect();assert.deepEqual(results,['broker-channel-unavailable']);
|
||||
f.callbacks[0]();f.callbacks[0](Error('late'));assert.deepEqual(results,['broker-channel-unavailable']);assert.equal(f.disconnected(),1);
|
||||
});
|
||||
test('throwing write callbacks cannot prevent fail-closed disconnection',()=>{
|
||||
const f=fixture();f.channel.send({type:'audit'},()=>{throw Error('private callback');});
|
||||
assert.doesNotThrow(()=>f.callbacks[0]());assert.equal(f.channel.connected,false);
|
||||
assert.doesNotThrow(()=>f.channel.send({type:'audit'},()=>{throw Error('private callback');}));assert.equal(f.disconnected(),1);
|
||||
});
|
||||
test('actual duplex framing carries production protocol and exact durable receipt',async t=>{
|
||||
const {runProductionProcess}=createRequire(import.meta.url)('../apps/network-broker/production-entry.cjs');
|
||||
const toChild=new PassThrough(),toParent=new PassThrough();
|
||||
const child=createFramedChannel({readable:toChild,writable:toParent,inboundFirstLimit:2097152});
|
||||
const parent=createFramedChannel({readable:toParent,writable:toChild,outboundFirstLimit:2097152});
|
||||
t.after(()=>parent.disconnect());
|
||||
let callbacks,closed=0;
|
||||
const processControl=runProductionProcess(async(_config,value)=>{callbacks=value;return {authMode:'basic',credentials:{username:'safetynet-'+'a'.repeat(32),password:'b'.repeat(43)},listen:async()=>43100,close:async()=>closed++};},child);
|
||||
const received=[];parent.on('message',message=>received.push(message));
|
||||
const context={version:1,generation:randomUUID(),policyVersion:1};
|
||||
parent.send({type:'start',...context,config:{brokerPort:43100}});
|
||||
await new Promise(resolve=>setImmediate(resolve));assert.equal(received[0].type,'ready');
|
||||
const event={eventId:randomUUID(),kind:'upload.authorized',ruleId:'broker-approved',policyVersion:1,request:{bytes:3,detectedType:'text'}};
|
||||
let committed=false;const pending=callbacks.persistAudit(event).then(receipt=>{committed=true;return receipt;});
|
||||
assert.equal(committed,false);assert.equal(received[1].type,'audit');
|
||||
parent.send({type:'audit-receipt',...context,requestId:received[1].requestId,sequence:received[1].sequence,receipt:{persisted:true,eventId:event.eventId}});
|
||||
assert.deepEqual(await pending,{persisted:true,eventId:event.eventId});
|
||||
// Receipt replay is invalid even though its frame, generation and UUID match.
|
||||
parent.send({type:'audit-receipt',...context,requestId:received[1].requestId,sequence:received[1].sequence,receipt:{persisted:true,eventId:event.eventId}});
|
||||
await processControl.close();assert.equal(child.connected,false);assert.equal(closed,1);
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
import {once} from 'node:events';
|
||||
import {PassThrough} from 'node:stream';
|
||||
import {createRequire} from 'node:module';
|
||||
const {gateRequest}=createRequire(import.meta.url)('../apps/network-broker/http-request-gate.cjs');
|
||||
|
||||
async function fixture(t,inspect,options={}) {
|
||||
const received=[];
|
||||
let connections=0;
|
||||
const receiver=http.createServer(async(req,res)=>{const chunks=[];for await(const chunk of req)chunks.push(chunk);received.push(Buffer.concat(chunks).toString());res.end('ok');});
|
||||
receiver.on('connection',()=>connections++);
|
||||
receiver.listen(0,'127.0.0.1');await once(receiver,'listening');
|
||||
const target=`http://127.0.0.1:${receiver.address().port}/submit`;
|
||||
const decisions=[];
|
||||
const broker=http.createServer((req,res)=>{
|
||||
gateRequest(req,{authority:'policy-1',current:()=>true,inspect,...options,forward:({request,body})=>new Promise((resolve,reject)=>{
|
||||
const outgoing=http.request(request.url,{method:request.method,headers:{'content-length':body.length}},response=>{response.resume();response.on('end',resolve);});
|
||||
outgoing.on('error',reject);outgoing.end(body);
|
||||
})}).then(()=>{decisions.push('allow');res.end('ok');},error=>{decisions.push(error.code);res.statusCode=403;res.end(error.code);});
|
||||
});
|
||||
broker.listen(0,'127.0.0.1');await once(broker,'listening');
|
||||
t.after(async()=>{broker.closeAllConnections();receiver.closeAllConnections();await Promise.all([new Promise(r=>broker.close(r)),new Promise(r=>receiver.close(r))]);});
|
||||
const send=body=>new Promise((resolve,reject)=>{const req=http.request({host:'127.0.0.1',port:broker.address().port,path:target,method:'POST',headers:{host:new URL(target).host,'content-length':Buffer.byteLength(body)}},res=>{res.resume();res.on('end',()=>resolve(res.statusCode));});req.on('error',reject);req.end(body);});
|
||||
return {received,decisions,send,target,broker,get connections(){return connections;}};
|
||||
}
|
||||
|
||||
async function rawRequest(t,broker,payload) {
|
||||
const socket=net.connect(broker.address().port,'127.0.0.1');
|
||||
t.after(()=>socket.destroy());
|
||||
return await new Promise((resolve,reject)=>{
|
||||
let text='',finished=false;
|
||||
const timer=setTimeout(()=>finish(new Error('raw-request-deadline')),2000);
|
||||
const finish=error=>{if(finished)return;finished=true;clearTimeout(timer);socket.destroy();if(error)reject(error);else resolve(text);};
|
||||
socket.on('connect',()=>socket.end(payload));
|
||||
socket.on('data',chunk=>text+=chunk);
|
||||
socket.on('error',finish);
|
||||
socket.on('end',()=>finish());
|
||||
socket.on('close',()=>finish());
|
||||
});
|
||||
}
|
||||
|
||||
test('controlled HTTP receiver sees benign original only; inspector mutation cannot alter release',async t=>{
|
||||
const f=await fixture(t,async({body})=>{const secret=body.toString().includes('SECRET');body.fill(120);return {decision:secret?'block':'allow'};});
|
||||
assert.equal(await f.send('public text'),200);assert.deepEqual(f.received,['public text']);
|
||||
assert.equal(await f.send('SECRET'),403);assert.deepEqual(f.received,['public text']);
|
||||
});
|
||||
test('no upstream bytes or dial before delayed allow',async t=>{
|
||||
let entered,release;const waiting=new Promise(r=>entered=r),hold=new Promise(r=>release=r);
|
||||
const f=await fixture(t,async()=>{entered();await hold;return {decision:'allow'};});
|
||||
const request=f.send('public');await waiting;assert.deepEqual(f.received,[]);assert.equal(f.connections,0);release();assert.equal(await request,200);assert.equal(f.connections,1);
|
||||
});
|
||||
test('policy revoked while inspection waits prevents release',async t=>{
|
||||
let valid=true;
|
||||
const f=await fixture(t,async()=>{valid=false;return {decision:'allow'};},{current:()=>valid});
|
||||
assert.equal(await f.send('public'),403);assert.deepEqual(f.received,[]);assert.deepEqual(f.decisions,['authority-expired']);
|
||||
});
|
||||
test('bounded body and inspection deadline fail closed',async t=>{
|
||||
const f=await fixture(t,()=>new Promise(()=>{}),{maxBytes:8,deadlineMs:30});
|
||||
assert.equal(await f.send('too long for this bound'),403);assert.equal(await f.send('short'),403);
|
||||
assert.deepEqual(f.received,[]);assert.deepEqual(f.decisions,['body-too-large','deadline']);
|
||||
});
|
||||
test('abort during inspection prevents release',async t=>{
|
||||
const controller=new AbortController();
|
||||
const f=await fixture(t,async()=>{controller.abort();return {decision:'allow'};},{signal:controller.signal});
|
||||
assert.equal(await f.send('public'),403);assert.deepEqual(f.received,[]);assert.deepEqual(f.decisions,['aborted']);
|
||||
});
|
||||
test('actual duplicate Host is denied, no upstream request',async t=>{
|
||||
const f=await fixture(t,async()=>({decision:'allow'}));
|
||||
const result=await rawRequest(t,f.broker,`POST ${f.target} HTTP/1.1\r\nHost: ${new URL(f.target).host}\r\nHost: attacker.invalid\r\nContent-Length: 0\r\nConnection: close\r\n\r\n`);
|
||||
assert.match(result,/403/);assert.deepEqual(f.received,[]);assert.equal(f.connections,0);assert.deepEqual(f.decisions,['duplicate-header']);
|
||||
});
|
||||
test('physical chunked and CL+TE requests never inspect or dial',async t=>{
|
||||
let inspected=0;
|
||||
const f=await fixture(t,async()=>{inspected++;return {decision:'allow'};});
|
||||
for(const length of ['', 'Content-Length: 6\r\n']) {
|
||||
const result=await rawRequest(t,f.broker,`POST ${f.target} HTTP/1.1\r\nHost: ${new URL(f.target).host}\r\n${length}Transfer-Encoding: chunked\r\nConnection: close\r\n\r\n6\r\nSECRET\r\n0\r\n\r\n`);
|
||||
assert.match(result,/HTTP\/1\.1 (400|403)/);
|
||||
}
|
||||
assert.equal(inspected,0);assert.equal(f.connections,0);assert.deepEqual(f.received,[]);
|
||||
});
|
||||
test('ambiguous framing and unsupported transports rejected before inspection',async()=>{
|
||||
for(const [headers,method,url] of [
|
||||
[['Host','example.test','Content-Length','1','Content-Length','1'],'POST','http://example.test/'],
|
||||
[['Host','example.test','Content-Length','1','Transfer-Encoding','chunked'],'POST','http://example.test/'],
|
||||
[['Host','example.test','Upgrade','websocket'],'GET','http://example.test/'],
|
||||
[['Host','example.test'],'CONNECT','example.test:443'],
|
||||
[['Host','example.test','Content-Encoding','gzip'],'POST','http://example.test/'],
|
||||
[['Host','evil.test'],'GET','http://example.test/']
|
||||
]) {
|
||||
const req=new PassThrough();Object.assign(req,{rawHeaders:headers,method,url,complete:true});
|
||||
await assert.rejects(gateRequest(req,{authority:'p',current:()=>true,inspect:()=>assert.fail('inspection'),forward:()=>assert.fail('forward')}));
|
||||
}
|
||||
});
|
||||
test('truncated body and stream error return fixed errors without release',async()=>{
|
||||
for(const kind of ['end','error','aborted']) {
|
||||
const req=new PassThrough();Object.assign(req,{rawHeaders:['Host','example.test','Content-Length','3'],method:'POST',url:'http://example.test/',complete:false});
|
||||
const promise=gateRequest(req,{authority:'p',current:()=>true,inspect:()=>assert.fail('inspection'),forward:()=>assert.fail('forward')});
|
||||
if(kind==='end')req.end('x');else req.emit(kind, new Error('confidential error message'));
|
||||
await assert.rejects(promise,error=>['incomplete-body','request-error','aborted'].includes(error.code)&&!error.message.includes('confidential'));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
const {validateResponse}=createRequire(import.meta.url)('../apps/network-broker/http-response-gate.cjs');
|
||||
test('private response preserves status/body and separate cookies; strips hop by hop',()=>{
|
||||
const body=Buffer.from('accepted'),cookies=['a=1; Secure','b=2; HttpOnly'];
|
||||
const result=validateResponse({statusCode:201,headers:{'Content-Type':'text/plain','Set-Cookie':cookies,Connection:'keep-alive','Transfer-Encoding':'chunked','Content-Length':'999','Proxy-Authenticate':'private'},body});
|
||||
body.fill(0);cookies[0]='changed';
|
||||
assert.equal(result.body.toString(),'accepted');assert.equal(result.headers['content-length'],'8');assert.deepEqual(result.headers['set-cookie'],['a=1; Secure','b=2; HttpOnly']);assert.equal(result.headers.connection,'close');assert.equal(result.headers['transfer-encoding'],undefined);assert.equal(result.headers['proxy-authenticate'],undefined);
|
||||
});
|
||||
test('invalid status, injected headers, overflow and unsupported encoding fail closed',()=>{
|
||||
const valid={statusCode:200,headers:{},body:Buffer.from('public')};
|
||||
for(const change of [{statusCode:101},{statusCode:999},{statusCode:204},{headers:{'set-cookie':['a=1\r\nInjected: yes']}},{headers:{'Content-Type':'text/plain','content-type':'other'}},{headers:{'content-encoding':'gzip'}},{headers:{location:['one','two']}}])assert.throws(()=>validateResponse({...valid,...change}));
|
||||
assert.throws(()=>validateResponse(valid,3));
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import https from 'node:https';
|
||||
import fs from 'node:fs';
|
||||
import {once} from 'node:events';
|
||||
import {createRequire} from 'node:module';
|
||||
const require=createRequire(import.meta.url),{createHttpsForwarder}=require('../apps/network-broker/https-forwarder.cjs');
|
||||
const cert=fs.readFileSync(new URL('./fixtures/icap/cert.pem',import.meta.url)),key=fs.readFileSync(new URL('./fixtures/icap/key.pem',import.meta.url));
|
||||
async function fixture(t,handler,configuration={}){
|
||||
const received=[];let connections=0;
|
||||
const server=https.createServer({key,cert},async(req,res)=>{const chunks=[];for await(const chunk of req)chunks.push(chunk);received.push({body:Buffer.concat(chunks).toString(),headers:req.headers,path:req.url});handler?.(req,res)||(!handler&&res.end('public response'));});
|
||||
server.on('connection',()=>connections++);server.on('tlsClientError',()=>{});
|
||||
server.listen(0,'127.0.0.1');await once(server,'listening');
|
||||
t.after(async()=>{server.closeAllConnections();await new Promise(resolve=>server.close(resolve));});
|
||||
const port=server.address().port;
|
||||
const forward=createHttpsForwarder({authority:'policy-1',current:()=>true,resolveRoute:async destination=>({approved:true,address:'127.0.0.1',port:destination.port}),ca:cert,deadlineMs:1000,...configuration});
|
||||
const request={method:'POST',url:`https://localhost:${port}/submit`,headers:{'content-type':'text/plain'}};
|
||||
return {request,forward,received,get connections(){return connections;}};
|
||||
}
|
||||
test('numeric approved route uses original TLS hostname and strips proxy credentials',async t=>{
|
||||
let release,entered;const wait=new Promise(resolve=>release=resolve),started=new Promise(resolve=>entered=resolve);
|
||||
const f=await fixture(t,undefined,{resolveRoute:async destination=>{entered();await wait;return {approved:true,address:'127.0.0.1',port:destination.port};}});
|
||||
const body=Buffer.from('public original');
|
||||
f.request.headers['proxy-authorization']='Bearer PRIVATE_PROXY_KEY';f.request.headers.connection='x-hop';f.request.headers['x-hop']='PRIVATE_HOP_VALUE';
|
||||
const pending=f.forward({request:f.request,body});await started;body.fill(120);release();
|
||||
const result=await pending;assert.equal(result.body.toString(),'public response');
|
||||
assert.equal(f.received[0].body,'public original');assert.equal(f.received[0].headers['proxy-authorization'],undefined);assert.equal(f.received[0].headers['x-hop'],undefined);
|
||||
assert.equal(f.connections,1);
|
||||
});
|
||||
test('revocation during route resolution prevents any upstream TCP dial',async t=>{
|
||||
let valid=true;const f=await fixture(t,undefined,{current:()=>valid,resolveRoute:async destination=>{valid=false;return {approved:true,address:'127.0.0.1',port:destination.port};}});
|
||||
await assert.rejects(f.forward({request:f.request,body:Buffer.from('public')}),{code:'authority-expired'});
|
||||
assert.equal(f.connections,0);
|
||||
});
|
||||
test('unapproved and nonnumeric routes cannot invoke system DNS or dial',async t=>{
|
||||
const f=await fixture(t,undefined,{resolveRoute:async destination=>({approved:true,address:'localhost',port:destination.port})});
|
||||
await assert.rejects(f.forward({request:f.request,body:Buffer.alloc(0)}),{code:'route-unapproved'});assert.equal(f.connections,0);
|
||||
});
|
||||
test('revocation after TLS establishment prevents HTTP headers and payload release',async t=>{
|
||||
let checks=0;const f=await fixture(t,undefined,{current:()=>++checks<4});
|
||||
await assert.rejects(f.forward({request:f.request,body:Buffer.from('must never transmit')}),{code:'authority-expired'});
|
||||
assert.equal(f.connections,1);assert.equal(f.received.length,0);
|
||||
});
|
||||
test('untrusted server certificate is rejected with fixed error and no retry',async t=>{
|
||||
const f=await fixture(t,undefined,{ca:undefined});
|
||||
await assert.rejects(f.forward({request:f.request,body:Buffer.from('public')}),{code:'upstream-failed'});
|
||||
assert.equal(f.connections,1);assert.equal(f.received.length,0);
|
||||
});
|
||||
test('approved numeric route does not substitute certificate hostname',async t=>{
|
||||
const f=await fixture(t);
|
||||
await assert.rejects(f.forward({request:{...f.request,url:f.request.url.replace('localhost','wrong-host.example')},body:Buffer.alloc(0)}),{code:'upstream-failed'});
|
||||
assert.equal(f.connections,1);assert.equal(f.received.length,0);
|
||||
});
|
||||
test('oversized streamed response is destroyed at configured limit',async t=>{
|
||||
const f=await fixture(t,(_req,res)=>{res.write('1234');res.end('56789');},{maxResponseBytes:8});
|
||||
await assert.rejects(f.forward({request:f.request,body:Buffer.alloc(0)}),{code:'upstream-response-too-large'});
|
||||
assert.equal(f.connections,1);
|
||||
});
|
||||
test('redirect response is returned without following location',async t=>{
|
||||
const f=await fixture(t,(_req,res)=>{res.writeHead(302,{location:'/private'});res.end('redirect');});
|
||||
const result=await f.forward({request:f.request,body:Buffer.alloc(0)});
|
||||
assert.equal(result.statusCode,302);assert.equal(result.headers.location,'/private');assert.equal(f.received.length,1);assert.equal(f.received[0].path,'/submit');
|
||||
});
|
||||
test('abort during route resolution prevents dial and does not retry',async t=>{
|
||||
let entered,release;const started=new Promise(resolve=>entered=resolve),wait=new Promise(resolve=>release=resolve);
|
||||
const f=await fixture(t,undefined,{resolveRoute:async destination=>{entered();await wait;return {approved:true,address:'127.0.0.1',port:destination.port};}});
|
||||
const controller=new AbortController(),pending=f.forward({request:f.request,body:Buffer.alloc(0),signal:controller.signal});
|
||||
await started;controller.abort();await assert.rejects(pending,{code:'upstream-cancelled'});release();assert.equal(f.connections,0);
|
||||
});
|
||||
test('wall clock timeout terminates a stalled upstream connection',async t=>{
|
||||
const f=await fixture(t,()=>{}, {deadlineMs:30});
|
||||
await assert.rejects(f.forward({request:f.request,body:Buffer.alloc(0)}),{code:'upstream-cancelled'});
|
||||
assert.ok(f.connections<=1);
|
||||
});
|
||||
test('timed out unresolved routes retain bounded underlying resolution admission',async t=>{
|
||||
let calls=0;const pending=[];
|
||||
const f=await fixture(t,undefined,{deadlineMs:15,maxPendingResolutions:2,resolveRoute:destination=>{
|
||||
calls++;return new Promise(resolve=>pending.push(()=>resolve({approved:true,address:'127.0.0.1',port:destination.port})));
|
||||
}});
|
||||
for(let index=0;index<2;index++)await assert.rejects(f.forward({request:f.request,body:Buffer.alloc(0)}),{code:'upstream-cancelled'});
|
||||
for(let index=0;index<3;index++)await assert.rejects(f.forward({request:f.request,body:Buffer.alloc(0)}),{code:'route-capacity-exhausted'});
|
||||
assert.equal(calls,2);assert.equal(f.connections,0);
|
||||
pending[0]();await new Promise(resolve=>setImmediate(resolve));
|
||||
await assert.rejects(f.forward({request:f.request,body:Buffer.alloc(0)}),{code:'upstream-cancelled'});
|
||||
assert.equal(calls,3);assert.equal(f.connections,0);
|
||||
for(const release of pending)release();
|
||||
});
|
||||
test('invalid destinations and methods reject before resolver invocation',async t=>{
|
||||
let resolutions=0;const f=await fixture(t,undefined,{resolveRoute:async()=>{resolutions++;}});
|
||||
for(const change of [{url:'http://localhost/'},{url:'https://user:password@localhost/'},{url:'https://localhost/#fragment'},{url:'https://127.1/'},{method:'CONNECT'}])await assert.rejects(f.forward({request:{...f.request,...change},body:Buffer.alloc(0)}),{code:'invalid-forward-request'});
|
||||
assert.equal(resolutions,0);assert.equal(f.connections,0);
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import https from 'node:https';
|
||||
import fs from 'node:fs';
|
||||
import {X509Certificate} from 'node:crypto';
|
||||
import {once} from 'node:events';
|
||||
import {createRequire} from 'node:module';
|
||||
const require=createRequire(import.meta.url);
|
||||
const {bindConnectAuthority}=require('../apps/network-broker/tls-authority.cjs');
|
||||
const {createInspectionServer}=require('../apps/network-broker/https-inspection-server.cjs');
|
||||
const cert=fs.readFileSync(new URL('./fixtures/icap/cert.pem',import.meta.url));
|
||||
const key=fs.readFileSync(new URL('./fixtures/icap/key.pem',import.meta.url));
|
||||
|
||||
async function fixture(t,inspect,current=()=>true,customForward,config={}) {
|
||||
const received=[];let connections=0;
|
||||
const receiver=https.createServer({key,cert},async(req,res)=>{const chunks=[];for await(const chunk of req)chunks.push(chunk);received.push(Buffer.concat(chunks).toString());res.writeHead(201,{'content-type':'text/plain','set-cookie':['session=synthetic; Secure; HttpOnly','locale=en; Secure']});res.end('accepted');});
|
||||
receiver.on('connection',()=>connections++);
|
||||
receiver.listen(0,'127.0.0.1');await once(receiver,'listening');
|
||||
const destination=`localhost:${receiver.address().port}`;
|
||||
const broker=createInspectionServer({key,cert,binding:bindConnectAuthority(destination),authority:'signed-policy-1',current,inspect,deadlineMs:1000,...config,forward:customForward||(({request,body,signal})=>new Promise((resolve,reject)=>{
|
||||
assert.equal(new URL(request.url).protocol,'https:');
|
||||
const outgoing=https.request({host:'127.0.0.1',port:receiver.address().port,path:new URL(request.url).pathname,method:request.method,servername:'localhost',ca:cert,rejectUnauthorized:true,signal,headers:{host:destination,'content-length':body.length}},res=>{const chunks=[];res.on('data',chunk=>chunks.push(chunk));res.on('end',()=>resolve({statusCode:res.statusCode,headers:res.headers,body:Buffer.concat(chunks)}));});outgoing.on('error',reject);outgoing.end(body);
|
||||
}))});
|
||||
const port=await broker.listen();
|
||||
t.after(async()=>{await broker.close();receiver.closeAllConnections();await new Promise(r=>receiver.close(r));});
|
||||
const send=(body,{host=destination,servername='localhost',signal}={})=>new Promise((resolve,reject)=>{
|
||||
const req=https.request({host:'127.0.0.1',port,method:'POST',path:'/submit',servername,ca:cert,rejectUnauthorized:true,agent:false,signal,headers:{host,'content-length':Buffer.byteLength(body)}},res=>{let result='';res.on('data',c=>result+=c);res.on('end',()=>resolve({status:res.statusCode,result}));});req.setTimeout(2000,()=>req.destroy(new Error('test-deadline')));req.on('error',reject);req.end(body);
|
||||
});
|
||||
return {send,received,destination,get connections(){return connections;}};
|
||||
}
|
||||
|
||||
test('synthetic TLS certificate explicitly covers localhost and loopback',()=>{
|
||||
const certificate=new X509Certificate(cert);
|
||||
assert.equal(certificate.checkHost('localhost'),'localhost');assert.equal(certificate.checkIP('127.0.0.1'),'127.0.0.1');
|
||||
});
|
||||
test('verified TLS delivers benign original and blocks secret before upstream TCP dial',async t=>{
|
||||
const f=await fixture(t,async({body,request})=>{assert.equal(new URL(request.url).protocol,'https:');const decision=body.toString().includes('SECRET')?'block':'allow';body.fill(120);return {decision};});
|
||||
assert.equal((await f.send('SECRET')).status,403);assert.equal(f.connections,0);assert.deepEqual(f.received,[]);
|
||||
assert.deepEqual(await f.send('public'),{status:201,result:'accepted'});assert.equal(f.connections,1);assert.deepEqual(f.received,['public']);
|
||||
});
|
||||
test('HTTPS inspection delay releases no TCP connection until complete approval',async t=>{
|
||||
let entered,release;const waiting=new Promise(r=>entered=r),hold=new Promise(r=>release=r);
|
||||
const f=await fixture(t,async()=>{entered();await hold;return {decision:'allow'};});
|
||||
const pending=f.send('public');await waiting;assert.equal(f.connections,0);release();assert.equal((await pending).status,201);assert.equal(f.connections,1);
|
||||
});
|
||||
test('revocation during HTTPS inspection blocks without dialing',async t=>{
|
||||
let valid=true;
|
||||
const f=await fixture(t,async()=>{valid=false;return {decision:'allow'};},()=>valid);
|
||||
assert.deepEqual(await f.send('public'),{status:403,result:'authority-expired'});assert.equal(f.connections,0);
|
||||
});
|
||||
test('inner Host mismatch and missing DNS SNI reject before inspection or dial',async t=>{
|
||||
let inspections=0;
|
||||
const f=await fixture(t,async()=>{inspections++;return {decision:'allow'};});
|
||||
assert.deepEqual(await f.send('public',{host:'evil.example:443'}),{status:403,result:'authority-mismatch'});
|
||||
assert.deepEqual(await f.send('public',{servername:''}),{status:403,result:'missing-sni'});
|
||||
assert.equal(inspections,0);assert.equal(f.connections,0);assert.deepEqual(f.received,[]);
|
||||
});
|
||||
test('upstream failure returns fixed 502 without retry or private errors',async t=>{
|
||||
let attempts=0;
|
||||
const f=await fixture(t,async()=>({decision:'allow'}),()=>true,async()=>{attempts++;throw new Error('private upstream credentials');});
|
||||
assert.deepEqual(await f.send('public'),{status:502,result:'upstream-failed'});assert.equal(attempts,1);assert.equal(f.connections,0);
|
||||
});
|
||||
test('client disconnect aborts in-flight forward once without retry',async t=>{
|
||||
let entered,aborted,attempts=0;
|
||||
const started=new Promise(r=>entered=r),cancelled=new Promise(r=>aborted=r);
|
||||
const f=await fixture(t,async()=>({decision:'allow'}),()=>true,({signal})=>new Promise((_,reject)=>{attempts++;signal.addEventListener('abort',()=>{aborted();reject(new Error('cancelled'));},{once:true});entered();}));
|
||||
const controller=new AbortController();
|
||||
const pending=f.send('public',{signal:controller.signal});const rejection=assert.rejects(pending);
|
||||
await started;controller.abort();await rejection;
|
||||
await Promise.race([cancelled,new Promise((_,reject)=>{const timer=setTimeout(()=>reject(new Error('abort-deadline')),1000);timer.unref();})]);
|
||||
assert.equal(attempts,1);
|
||||
});
|
||||
test('forwarding wall clock deadline cancels a continuously active upstream once',async t=>{
|
||||
let attempts=0,aborts=0;
|
||||
const f=await fixture(t,async()=>({decision:'allow'}),()=>true,({signal})=>new Promise((_,reject)=>{
|
||||
attempts++;
|
||||
// No completion, just work continuing independently of socket idle.
|
||||
const activity=setInterval(()=>{},2);
|
||||
signal.addEventListener('abort',()=>{aborts++;clearInterval(activity);reject(new Error('private deadline detail'));},{once:true});
|
||||
}),{deadlineMs:100});
|
||||
const result=await f.send('public');
|
||||
assert.deepEqual(result,{status:502,result:'upstream-failed'});assert.equal(attempts,1);assert.equal(aborts,1);
|
||||
});
|
||||
test('response bounds reject invalid construction immediately',()=>{
|
||||
for(const maxResponseBytes of [-1,1.5,16777217])assert.throws(()=>createInspectionServer({key,cert,authority:'p',current:()=>true,inspect:async()=>({decision:'allow'}),forward:async()=>{},maxResponseBytes}),{code:'invalid-configuration'});
|
||||
});
|
||||
test('standalone HTTPS job budget survives timed-out requests on new sockets',async t=>{
|
||||
let calls=0;
|
||||
const f=await fixture(t,()=>{calls++;return new Promise(()=>{});},()=>true,undefined,{maxClients:1,deadlineMs:100});
|
||||
for(let index=0;index<4;index++)assert.equal((await f.send('public')).status,403);
|
||||
assert.equal(calls,1);assert.equal(f.connections,0);
|
||||
});
|
||||
|
||||
test('one absolute deadline spans inspection and forwarding without resetting the budget',async t=>{
|
||||
let inspectEntered,releaseInspect,forwardEntered,releaseForward;const inspected=new Promise(resolve=>inspectEntered=resolve),first=new Promise(resolve=>releaseInspect=resolve),forwarded=new Promise(resolve=>forwardEntered=resolve),second=new Promise(resolve=>releaseForward=resolve);let signal;
|
||||
const f=await fixture(t,async()=>{inspectEntered();await first;return {decision:'allow'};},()=>true,async value=>{signal=value.signal;forwardEntered();await second;return {statusCode:201,headers:{},body:Buffer.from('must not release')};},{deadlineMs:100});
|
||||
const pending=f.send('public');await inspected;await new Promise(resolve=>setTimeout(resolve,55));releaseInspect();await forwarded;
|
||||
const result=await pending;assert.equal(result.status,502);assert.equal(result.result,'upstream-failed');assert.equal(signal.aborted,true);releaseForward();assert.equal(f.connections,0);
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {generateKeyPairSync} from 'node:crypto';
|
||||
import {createRequire} from 'node:module';
|
||||
import {once} from 'node:events';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import tls from 'node:tls';
|
||||
import {defaultPolicy,signPolicy,inspect} from '../packages/core/policy.mjs';
|
||||
const require=createRequire(import.meta.url),{createManagedProxy}=require('../apps/network-broker/managed-proxy.cjs'),{createPolicyAuthority}=require('../apps/network-broker/policy-authority.cjs');
|
||||
const {createSyntheticAudit}=require('./fixtures/broker-durable-audit.cjs');
|
||||
const cert=fs.readFileSync(new URL('./fixtures/icap/cert.pem',import.meta.url)),key=fs.readFileSync(new URL('./fixtures/icap/key.pem',import.meta.url));
|
||||
const keys=generateKeyPairSync('ed25519'),publicKey=keys.publicKey.export({type:'spki',format:'pem'}),privateKey=keys.privateKey.export({type:'pkcs8',format:'pem'});
|
||||
async function fixture(t,configure=()=>{},scanner,enterpriseInspector,options={}) {
|
||||
const received=[];let connections=0;
|
||||
const target=https.createServer({key,cert},async(req,res)=>{const chunks=[];for await(const c of req)chunks.push(c);received.push(Buffer.concat(chunks).toString());res.writeHead(201);res.end('accepted');});target.on('connection',()=>connections++);target.listen(0,'127.0.0.1');await once(target,'listening');
|
||||
const destination=`localhost:${target.address().port}`,p=defaultPolicy();p.assignment={deviceId:'device-1',groups:[]};p.dlp.rules=[{id:'company-secret',label:'Confidential',kind:'literal',value:'PROJECT SECRET',action:'block'}];configure(p);
|
||||
const policyAuthority=await createPolicyAuthority({publicKey,deviceId:'device-1',minimumVersion:1});policyAuthority.accept(signPolicy(p,privateKey));
|
||||
const audit=createSyntheticAudit(t);
|
||||
const proxy=await createManagedProxy({policyAuthority,persistAudit:audit.persist,localInspector:scanner,enterpriseInspector,resolveCertificate:binding=>binding.authority===destination?{authority:destination,key,cert}:undefined,forward:({body,signal,maxResponseBytes})=>new Promise((resolve,reject)=>{
|
||||
const request=https.request({host:'127.0.0.1',port:target.address().port,method:'POST',servername:'localhost',ca:cert,rejectUnauthorized:true,signal,headers:{'content-length':body.length}},res=>{const chunks=[];let bytes=0;res.on('data',chunk=>{bytes+=chunk.length;if(bytes>maxResponseBytes){request.destroy();reject(Error('bound'));}else chunks.push(chunk);});res.on('end',()=>resolve({statusCode:res.statusCode,headers:res.headers,body:Buffer.concat(chunks)}));});request.on('error',reject);request.end(body);
|
||||
}),deadlineMs:1000,authMode:'bearer',...options});const port=await proxy.listen();
|
||||
t.after(async()=>{await proxy.close();target.closeAllConnections();await new Promise(r=>target.close(r));});
|
||||
const send=(body,{path='/submit',headers={}}={})=>new Promise((resolve,reject)=>{
|
||||
const req=http.request({host:'127.0.0.1',port,method:'CONNECT',path:destination,headers:{host:destination,'proxy-authorization':`Bearer ${proxy.capability}`}});req.setTimeout(2000,()=>req.destroy(Error('deadline')));req.on('error',reject);req.on('connect',(response,socket)=>{if(response.statusCode!==200){socket.destroy();resolve(response.statusCode);return;}const secure=tls.connect({socket,servername:'localhost',ca:cert,rejectUnauthorized:true});let text='';secure.setTimeout(2000,()=>secure.destroy(Error('deadline')));secure.on('error',reject);secure.on('secureConnect',()=>{const extra=Object.entries(headers).map(([name,value])=>`${name}: ${value}\r\n`).join('');secure.write(`POST ${path} HTTP/1.1\r\nHost: ${destination}\r\nContent-Length: ${Buffer.byteLength(body)}\r\n${extra}Connection: close\r\n\r\n`);secure.write(body);});secure.on('data',chunk=>text+=chunk);secure.on('end',()=>{secure.destroy();resolve(Number(/^HTTP\/1.1 (\d+)/.exec(text)?.[1]));});});req.end();
|
||||
});return {send,received,policyAuthority,p,audit,get connections(){return connections;}};
|
||||
}
|
||||
test('signed company literal and credentials block before dial; public actualTLS delivery',async t=>{
|
||||
const f=await fixture(t);
|
||||
assert.equal(await f.send('PROJECT SECRET'),403);assert.equal(await f.send('sk-abcdefghijklmnopqrstuvwxyz123456'),403);assert.equal(f.connections,0);
|
||||
assert.equal(await f.send('public document'),201);assert.deepEqual(f.received,['public document']);
|
||||
});
|
||||
test('signed semantic or YARA requirements cannot omit local scanner',async t=>{
|
||||
const vector=Array(768).fill(0);vector[0]=1;
|
||||
for(const feature of ['semantic','yara']) {
|
||||
const f=await fixture(t,p=>{if(feature==='semantic'){p.dlp.semantic.enabled=true;p.dlp.semantic.revision='a'.repeat(40);p.dlp.semantic.categories=[{id:'confidential',label:'Confidential',threshold:.9,prototype:vector}];}else p.security.yara.enabled=true;});
|
||||
assert.equal(await f.send('public'),403);assert.equal(f.connections,0);
|
||||
}
|
||||
});
|
||||
test('binary upload requires complete local coverage and magic-byte policy applies first',async t=>{
|
||||
const f=await fixture(t);assert.equal(await f.send(Buffer.from([0,1,2,255])),403);assert.equal(f.connections,0);
|
||||
const blocked=await fixture(t,p=>p.fileTransfers.upload.blockedTypes=['pe'],async()=>assert.fail('scanner must not run'));assert.equal(await blocked.send(Buffer.from('MZPUBLIC')),403);assert.equal(blocked.connections,0);
|
||||
});
|
||||
test('genuine signed policy update during local scan invalidates release',async t=>{
|
||||
let entered,release;const started=new Promise(r=>entered=r),hold=new Promise(r=>release=r);
|
||||
const f=await fixture(t,p=>p.security.yara.enabled=true,async()=>{entered();await hold;return {decision:'allow',coverage:{complete:true}};});
|
||||
const pending=f.send('public');await started;const next=structuredClone(f.p);next.version=2;f.policyAuthority.accept(signPolicy(next,privateKey));release();assert.equal(await pending,403);assert.equal(f.connections,0);
|
||||
});
|
||||
test('wrong device and tampered signature cannot authorize listener creation',async()=>{
|
||||
const authority=await createPolicyAuthority({publicKey,deviceId:'device-1'}),p=defaultPolicy();p.assignment={deviceId:'device-2',groups:[]};
|
||||
assert.throws(()=>authority.accept(signPolicy(p,privateKey)));await assert.rejects(createManagedProxy({policyAuthority:authority}),{code:'managed-authority-invalid'});
|
||||
const envelope=signPolicy(defaultPolicy(),privateKey);envelope.signature='AAAA';assert.throws(()=>authority.accept(envelope));await assert.rejects(createManagedProxy({policyAuthority:authority}),{code:'managed-authority-invalid'});
|
||||
});
|
||||
test('mandatory ICAP cannot omit scanner or accept incomplete unbound evidence',async t=>{
|
||||
const configure=p=>{p.enterprise.icap.enabled=true;p.enterprise.icap.url='icaps://scanner.example/scan';};
|
||||
const missing=await fixture(t,configure);assert.equal(await missing.send('public'),403);assert.equal(missing.connections,0);
|
||||
const incomplete=await fixture(t,configure,undefined,async()=>({decision:'allow',coverage:{complete:true},sha256:'0'.repeat(64),bytes:6}));assert.equal(await incomplete.send('public'),403);assert.equal(incomplete.connections,0);
|
||||
});
|
||||
test('partial local coverage and scanner mutation fail closed; destination blacklist applies',async t=>{
|
||||
for(const inspector of [async()=>({decision:'allow',coverage:{complete:false}}),async bytes=>{bytes.fill(0);return {decision:'allow',coverage:{complete:true}};}]) {
|
||||
const f=await fixture(t,p=>p.security.yara.enabled=true,inspector);assert.equal(await f.send('public'),403);assert.equal(f.connections,0);
|
||||
}
|
||||
const f=await fixture(t,p=>p.browser.blockedHosts=['localhost']);assert.equal(await f.send('public'),403);assert.equal(f.connections,0);
|
||||
});
|
||||
test('decoded text representations receive independent local scans before release',async t=>{
|
||||
const seen=[];
|
||||
const f=await fixture(t,p=>p.security.yara.enabled=true,async bytes=>{
|
||||
const text=bytes.toString();seen.push(text);return {decision:text==='hidden private meaning'?'block':'allow',coverage:{complete:true}};
|
||||
});
|
||||
assert.equal(await f.send('hidden%20private%20meaning'),403);assert.ok(seen.includes('hidden%20private%20meaning'));assert.ok(seen.includes('hidden private meaning'));assert.equal(f.connections,0);
|
||||
});
|
||||
test('ASCII PDF magic requires local parsed coverage even when raw UTF8 inspection allows',async t=>{
|
||||
const document=Buffer.from('%PDF-1.4\n1 0 obj\n<< /Filter /ASCIIHexDecode >>\nstream\n50524F4A45435420534543524554>\nendstream\nendobj\n%%EOF');
|
||||
const missing=await fixture(t);assert.equal(inspect(document,missing.p).decision,'allow');assert.equal(await missing.send(document),403);assert.equal(missing.connections,0);
|
||||
let scanned=0;
|
||||
const rejected=await fixture(t,()=>{},async bytes=>{scanned++;assert.deepEqual(bytes,document);return {decision:'block',coverage:{complete:true}};});assert.equal(await rejected.send(document),403);assert.equal(scanned,1);assert.equal(rejected.connections,0);
|
||||
// Controlled scanner contract proof, not evidence of real PDF parsing.
|
||||
const publicDocument=Buffer.from('%PDF-1.4\npublic test document\n%%EOF');
|
||||
const allowed=await fixture(t,()=>{},async bytes=>{assert.deepEqual(bytes,publicDocument);return {decision:'allow',coverage:{complete:true}};});assert.equal(await allowed.send(publicDocument),201);assert.deepEqual(allowed.received,[publicDocument.toString()]);
|
||||
});
|
||||
test('custom header and Cookie corporate secrets block despite benign body',async t=>{
|
||||
const f=await fixture(t);
|
||||
assert.equal(await f.send('public',{headers:{'X-Note':'PROJECT SECRET'}}),403);
|
||||
assert.equal(await f.send('public',{headers:{Cookie:'note=PROJECT SECRET'}}),403);
|
||||
assert.equal(f.connections,0);assert.deepEqual(f.received,[]);
|
||||
});
|
||||
test('signed semantic query requires independent decoded URL inspection',async t=>{
|
||||
const seen=[];const vector=Array(768).fill(0);vector[0]=1;
|
||||
const f=await fixture(t,p=>{p.dlp.semantic.enabled=true;p.dlp.semantic.revision='a'.repeat(40);p.dlp.semantic.categories=[{id:'sensitive',label:'Sensitive',threshold:.9,prototype:vector}];},async bytes=>{const value=bytes.toString();seen.push(value);return {decision:value.includes('semantic query')?'block':'allow',coverage:{complete:true}};});
|
||||
assert.equal(await f.send('public',{path:'/submit?note=semantic%20query'}),403);assert.ok(seen.some(value=>value.includes('semantic query')));assert.equal(f.connections,0);
|
||||
});
|
||||
test('multipart files receive independent magic policy and complete parsed document gate',async t=>{
|
||||
const multipart=document=>Buffer.from(`--test-boundary\r\nContent-Disposition: form-data; name="note"\r\n\r\npublic text\r\n--test-boundary\r\nContent-Disposition: form-data; name="document"; filename="report.pdf"\r\nContent-Type: application/pdf\r\n\r\n${document}\r\n--test-boundary--\r\n`);
|
||||
const headers={'Content-Type':'multipart/form-data; boundary=test-boundary'};
|
||||
const document='%PDF-1.4\npublic sample\n%%EOF',body=multipart(document);
|
||||
const blocked=await fixture(t,p=>p.fileTransfers.upload.blockedTypes=['pdf']);assert.equal(await blocked.send(body,{headers}),403);assert.equal(blocked.connections,0);
|
||||
const missing=await fixture(t);assert.equal(await missing.send(body,{headers}),403);assert.equal(missing.connections,0);
|
||||
let parsed=0;
|
||||
const allowed=await fixture(t,()=>{},async bytes=>{parsed++;assert.equal(bytes.toString(),document);return {decision:'allow',coverage:{complete:true}};});assert.equal(await allowed.send(body,{headers}),201);assert.equal(parsed,1);assert.deepEqual(allowed.received,[body.toString()]);
|
||||
const rejected=await fixture(t,()=>{},async()=>({decision:'block',coverage:{complete:true}}));assert.equal(await rejected.send(multipart('%PDF-1.4\n50524F4A45435420534543524554>\n%%EOF'),{headers}),403);assert.equal(rejected.connections,0);
|
||||
});
|
||||
test('malformed multipart body cannot be released as ordinary printable text',async t=>{
|
||||
const f=await fixture(t);assert.equal(await f.send('--boundary\r\ntruncated',{headers:{'Content-Type':'multipart/form-data; boundary=boundary'}}),403);assert.equal(f.connections,0);
|
||||
});
|
||||
test('durable allow receipt is required before any upstream dial',async t=>{
|
||||
let entered,release,f;
|
||||
const started=new Promise(resolve=>entered=resolve),hold=new Promise(resolve=>release=resolve);
|
||||
f=await fixture(t,()=>{},undefined,undefined,{persistAudit:async event=>{entered();await hold;return f.audit.persist(event);}});
|
||||
const pending=f.send('public');await started;assert.equal(f.connections,0);assert.deepEqual(f.received,[]);
|
||||
release();assert.equal(await pending,201);assert.equal(f.audit.events().length,1);assert.equal(f.audit.events()[0].kind,'upload.authorized');
|
||||
});
|
||||
test('failed audit persistence blocks without replay and never logs protected input',async t=>{
|
||||
let calls=0;
|
||||
const f=await fixture(t,()=>{},undefined,undefined,{persistAudit:async()=>{calls++;throw Error('PRIVATE_AUDIT_FAILURE_CONTENT');}});
|
||||
assert.equal(await f.send('public'),403);assert.equal(calls,1);assert.equal(f.connections,0);
|
||||
const blocked=await fixture(t);assert.equal(await blocked.send('PROJECT SECRET'),403);
|
||||
const events=blocked.audit.events();assert.equal(events.length,1);assert.equal(events[0].kind,'upload.block');assert.equal(events[0].ruleId,'company-secret');assert.equal(events[0].request.detectedType,'text');assert.equal(events[0].request.bytes,14);assert.ok(!JSON.stringify(events).includes('PROJECT SECRET'));assert.ok(!JSON.stringify(events).includes('sha256'));
|
||||
});
|
||||
test('signed update during durable commit blocks release after receipt, without retry',async t=>{
|
||||
let entered,release,f,calls=0;
|
||||
const started=new Promise(resolve=>entered=resolve),hold=new Promise(resolve=>release=resolve);
|
||||
f=await fixture(t,()=>{},undefined,undefined,{persistAudit:async event=>{calls++;entered();await hold;return f.audit.persist(event);}});
|
||||
const pending=f.send('public');await started;const next=structuredClone(f.p);next.version=2;f.policyAuthority.accept(signPolicy(next,privateKey));release();assert.equal(await pending,403);assert.equal(f.connections,0);assert.equal(calls,2);
|
||||
const events=f.audit.events();assert.equal(events.length,2);assert.equal(events[1].broker.authorizedEventId,events[0].eventId);assert.equal(events[1].broker.requestId,events[0].broker.requestId);assert.notEqual(events[0].eventId,events[1].eventId);assert.equal(events[1].broker.reason,'broker-authority-expired');
|
||||
});
|
||||
test('nonsettling audit consumes shared capacity across timed-out CONNECT requests',async t=>{
|
||||
let calls=0;
|
||||
const f=await fixture(t,()=>{},undefined,undefined,{maxClients:1,deadlineMs:100,persistAudit:()=>{calls++;return new Promise(()=>{});}});
|
||||
for(let index=0;index<4;index++)assert.equal(await f.send('public'),403);
|
||||
assert.equal(calls,1);assert.equal(f.connections,0);
|
||||
});
|
||||
test('valid policy cannot create a managed listener without mandatory audit adapter',async()=>{
|
||||
const authority=await createPolicyAuthority({publicKey,deviceId:'device-1'});authority.accept(signPolicy(defaultPolicy(),privateKey));
|
||||
await assert.rejects(createManagedProxy({policyAuthority:authority,resolveCertificate:()=>{},forward:()=>{}}),{code:'managed-audit-unavailable'});
|
||||
});
|
||||
test('genuine local block retains signed detector ID without allowing incomplete coverage',async t=>{
|
||||
const document=Buffer.from('%PDF-1.4\npublic encoded fixture\n%%EOF');
|
||||
const f=await fixture(t,()=>{},async()=>({decision:'block',ruleId:'company-secret',coverage:{complete:false},privateDetails:'NEVER_SERIALIZE'}));
|
||||
assert.equal(await f.send(document),403);assert.equal(f.connections,0);
|
||||
const event=f.audit.events()[0];assert.equal(event.ruleId,'company-secret');assert.equal(event.kind,'upload.block');assert.ok(!JSON.stringify(event).includes('NEVER_SERIALIZE'));
|
||||
const unknown=await fixture(t,()=>{},async()=>({decision:'block',ruleId:'ARBITRARY_PRIVATE_RULE_TEXT',coverage:{complete:true}}));assert.equal(await unknown.send(document),403);assert.equal(unknown.audit.events()[0].ruleId,'inspection-incomplete');
|
||||
const mutated=await fixture(t,()=>{},async bytes=>{bytes.fill(0);return {decision:'block',ruleId:'company-secret'};});assert.equal(await mutated.send(document),403);assert.equal(mutated.audit.events()[0].ruleId,'inspection-incomplete');
|
||||
});
|
||||
|
||||
test('lifecycle upstream failure has one causal durable terminal event without retry',async t=>{
|
||||
let attempts=0;const f=await fixture(t,undefined,undefined,undefined,{forward:async()=>{attempts++;throw Error('private upstream details');}});
|
||||
assert.equal(await f.send('public'),502);assert.equal(attempts,1);assert.equal(f.connections,0);
|
||||
const events=f.audit.events();assert.equal(events.length,2);assert.equal(events[0].broker.phase,'request.authorized');assert.equal(events[1].broker.phase,'forward.fault');assert.equal(events[1].broker.reason,'broker-upstream-failed');assert.equal(events[1].broker.requestId,events[0].broker.requestId);assert.equal(events[1].broker.authorizedEventId,events[0].eventId);assert.equal(JSON.stringify(events).includes('private upstream'),false);
|
||||
});
|
||||
test('lifecycle CONNECT destination denial and ambiguous framing record before any target TCP',async t=>{
|
||||
const denied=await fixture(t,p=>p.browser.blockedHosts=['localhost']);assert.equal(await denied.send('public'),403);assert.equal(denied.connections,0);const event=denied.audit.events()[0];assert.equal(event.broker.phase,'connect.block');assert.equal(event.broker.reason,'broker-connect-denied');
|
||||
const ambiguous=await fixture(t);await ambiguous.send('public',{headers:{'Transfer-Encoding':'chunked'}}).catch(()=>{});assert.equal(ambiguous.connections,0);const records=ambiguous.audit.events();assert.equal(records.length,1);assert.equal(records[0].broker.reason,'broker-framing-block');assert.equal(records[0].broker.phase,'request.block');
|
||||
});
|
||||
|
||||
test('encoded document body/header/URL carriers hit actual signed fingerprint before target TCP',async t=>{
|
||||
const {createHash}=await import('node:crypto'),document=Buffer.from('%PDF-1.4 Registered document'),fingerprint=createHash('sha256').update(document).digest('hex');
|
||||
const f=await fixture(t,p=>p.dlp.rules=[{id:'encoded-fingerprint',label:'Synthetic',kind:'sha256',value:fingerprint,action:'block'}],async()=>assert.fail('registered fingerprint must block before local parser'));
|
||||
const encoded=document.toString('base64'),uri='data:application/pdf;base64,'+encoded;
|
||||
for(const body of [JSON.stringify({file:encoded}),JSON.stringify({file:encodeURIComponent(uri)}),JSON.stringify({file:Buffer.from(JSON.stringify({file:encoded})).toString('base64')})])assert.equal(await f.send(body,{headers:{'Content-Type':'application/json'}}),403);
|
||||
assert.equal(await f.send('public',{headers:{'X-Document':uri}}),403);assert.equal(await f.send('public',{path:'/upload?file='+encodeURIComponent(uri)}),403);assert.equal(f.connections,0);assert.equal(f.audit.events().length,5);assert.ok(f.audit.events().every(event=>event.ruleId==='encoded-fingerprint'));
|
||||
});
|
||||
test('public encoded document must receive full local coverage while original outer JSON remains exact',async t=>{
|
||||
const document=Buffer.from('%PDF-1.4 Public registered form'),seen=[];
|
||||
const f=await fixture(t,undefined,async bytes=>{seen.push(Buffer.from(bytes));return {decision:'allow',coverage:{complete:true}};});
|
||||
const outer=JSON.stringify({file:document.toString('base64')});assert.equal(await f.send(outer,{headers:{'Content-Type':'application/json'}}),201);assert.deepEqual(f.received,[outer]);assert.ok(seen.some(bytes=>bytes.equals(document)));
|
||||
});
|
||||
test('encoded vector SVG and nested carriers beyond the global depth budget are unscannable',async t=>{
|
||||
const f=await fixture(t,undefined,async()=>assert.fail('unsupported SVG must not be counted as plain text coverage'));
|
||||
assert.equal(await f.send(JSON.stringify({file:'data:image/svg+xml;base64,'+Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><path d="M0 0"/></svg>').toString('base64')}),{headers:{'Content-Type':'application/json'}}),403);
|
||||
let value='data:application/pdf;base64,'+Buffer.from('%PDF-1.4 public').toString('base64');for(let count=0;count<6;count++)value='data:application/json;base64,'+Buffer.from(JSON.stringify({file:value})).toString('base64');
|
||||
assert.equal(await f.send(JSON.stringify({file:value}),{headers:{'Content-Type':'application/json'}}),403);assert.equal(f.connections,0);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {spawn} from 'node:child_process';
|
||||
import {createRequire} from 'node:module';
|
||||
const require=createRequire(import.meta.url);
|
||||
const {createNativeManagementCredentialLoader}=require('../apps/network-broker/management-credential.cjs');
|
||||
const current={deviceId:'device',origin:'https://management.example',policyVersion:8,policyKeySha256:'a'.repeat(64),policyDigest:'b'.repeat(64)};
|
||||
// Synthetic stdio fixture exercises the real JS consumer only. Native selector,
|
||||
// registry and DPAPI execution are separate gates; this is not their proof.
|
||||
function client(stale=false){return createNativeManagementCredentialLoader({expectedBinding:current,current:()=>true,launch:()=>spawn(process.execPath,['-e',`let bytes=Buffer.alloc(0);process.stdin.on('data',part=>bytes=Buffer.concat([bytes,part]));process.stdin.on('end',()=>{const request=JSON.parse(bytes.subarray(4));const reply={...request,token:'synthetic_token_0123456789'};${stale?"reply.policyVersion=7;reply.policyDigest='c'.repeat(64);":""}const body=Buffer.from(JSON.stringify(reply)),header=Buffer.alloc(4);header.writeUInt32LE(body.length);process.stdout.write(Buffer.concat([header,body]));});`],{windowsHide:true,stdio:['pipe','pipe','ignore']})});}
|
||||
test('current request echo accepts a reused private credential without claiming stored old metadata is current',async()=>{
|
||||
const previous=process.env.SAFETYNET_SERVICE_CLIENT_SID;process.env.SAFETYNET_SERVICE_CLIENT_SID='S-1-5-21-1-2-3-1001';
|
||||
const loader=client();try{const value=await loader.load(current);assert.equal(value.token.toString(),'synthetic_token_0123456789');assert.equal(value.deviceId,current.deviceId);value.token.fill(0);}finally{await loader.close();if(previous===undefined)delete process.env.SAFETYNET_SERVICE_CLIENT_SID;else process.env.SAFETYNET_SERVICE_CLIENT_SID=previous;}
|
||||
});
|
||||
test('echoing the credential record older policy binding instead of current request is rejected',async()=>{
|
||||
const previous=process.env.SAFETYNET_SERVICE_CLIENT_SID;process.env.SAFETYNET_SERVICE_CLIENT_SID='S-1-5-21-1-2-3-1001';
|
||||
const loader=client(true);try{await assert.rejects(loader.load(current),{message:'broker-management-refused'});}finally{await loader.close();if(previous===undefined)delete process.env.SAFETYNET_SERVICE_CLIENT_SID;else process.env.SAFETYNET_SERVICE_CLIENT_SID=previous;}
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {spawn} from 'node:child_process';
|
||||
import {createRequire} from 'node:module';
|
||||
const require=createRequire(import.meta.url),{createNativeManagementCredentialLoader}=require('../apps/network-broker/management-credential.cjs');
|
||||
const binding={deviceId:'device',origin:'https://management.example',policyVersion:7,policyKeySha256:'a'.repeat(64),policyDigest:'b'.repeat(64)};
|
||||
const emit="let bytes=Buffer.alloc(0);process.stdin.on('data',part=>{bytes=Buffer.concat([bytes,part]);});process.stdin.on('end',()=>{const input=JSON.parse(bytes.subarray(4).toString());const value={...input,token:require('node:crypto').randomBytes(32).toString('base64url')};RESPONSE;});";
|
||||
const response="const b=Buffer.from(JSON.stringify(value)),h=Buffer.alloc(4);h.writeUInt32LE(b.length);process.stdout.write(Buffer.concat([h,b]));";
|
||||
function loader(code,options={}){return createNativeManagementCredentialLoader({expectedBinding:binding,current:()=>true,deadlineMs:1000,...options,launch:(file,args,config)=>{assert.ok(file.endsWith('safetynet-service-host.exe'));assert.deepEqual(args,['--management-credential']);assert.deepEqual(Object.keys(config.env).sort(),['SAFETYNET_SERVICE_CLIENT_SID','SystemRoot','WINDIR']);assert.equal(config.shell,false);return spawn(process.execPath,['-e',code],{stdio:['pipe','pipe','ignore'],env:config.env});}});}
|
||||
test.before(()=>{process.env.SAFETYNET_SERVICE_CLIENT_SID='S-1-12-1-1-2-3-4';});
|
||||
test.after(()=>{delete process.env.SAFETYNET_SERVICE_CLIENT_SID;});
|
||||
test('fixed native-helper contract yields private credential only after exact bound reply and close',async()=>{const client=loader(emit.replace('RESPONSE',response+'setTimeout(()=>{},100);'));let finished=false;const work=client.load(binding);work.then(()=>{finished=true;});await new Promise(r=>setTimeout(r,50));assert.equal(finished,false);const value=await work;assert.equal(value.deviceId,binding.deviceId);assert.ok(Buffer.isBuffer(value.token));assert.equal(value.token.length,43);value.token.fill(0);client.close();});
|
||||
test('wrong policy/device/origin or unsafe native reply cannot yield credential',async()=>{const client=loader(emit.replace('RESPONSE',"value.policyDigest='c'.repeat(64);"+response));await assert.rejects(client.load(binding),{message:'broker-management-refused'});for(const key of ['deviceId','origin','policyVersion','policyDigest','policyKeySha256'])await assert.rejects(client.load({...binding,[key]:key==='policyVersion'?8:'other'}),{message:'broker-management-refused'});client.close();});
|
||||
test('nonzero or duplicate native reply is refused without retries',async()=>{for(const suffix of ['process.exitCode=5;','process.stdout.write(Buffer.from([2,0,0,0,123,125]));']){const client=loader(emit.replace('RESPONSE',response+suffix));await assert.rejects(client.load(binding),{message:'broker-management-refused'});client.close();}});
|
||||
test('signal/revocation and native timeout wait actual owned helper termination',async()=>{const client=loader('setInterval(()=>{},1000);',{deadlineMs:40});await assert.rejects(client.load(binding),{message:'broker-management-refused'});client.close();let active=true;const revoked=loader(emit.replace('RESPONSE',response+'setTimeout(()=>{},150);'),{current:()=>active});const work=revoked.load(binding);active=false;await assert.rejects(work,{message:'broker-management-refused'});revoked.close();});
|
||||
test('actual Windows LocalMachine DPAPI synthetic roundtrip keeps plaintext out of files', {skip:process.platform!=='win32'},async()=>{
|
||||
const script=`$ErrorActionPreference='Stop'
|
||||
Add-Type -AssemblyName System.Security
|
||||
$stream=[Console]::OpenStandardInput()
|
||||
$header=New-Object byte[] 4
|
||||
$offset=0;while($offset -lt 4){$n=$stream.Read($header,$offset,4-$offset);if($n -eq 0){throw 'invalid'};$offset+=$n}
|
||||
$length=[BitConverter]::ToUInt32($header,0);if($length -gt 4096){throw 'invalid'}
|
||||
$bytes=New-Object byte[] $length;$offset=0;while($offset -lt $length){$n=$stream.Read($bytes,$offset,$length-$offset);if($n -eq 0){throw 'invalid'};$offset+=$n}
|
||||
$request=[Text.Encoding]::UTF8.GetString($bytes)|ConvertFrom-Json
|
||||
$entropy=[Security.Cryptography.SHA256]::Create().ComputeHash($bytes)
|
||||
$random=New-Object byte[] 32;[Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($random)
|
||||
$token=[Convert]::ToBase64String($random).TrimEnd('=').Replace('+','-').Replace('/','_')
|
||||
$plain=[Text.Encoding]::UTF8.GetBytes($token)
|
||||
$file=[IO.Path]::GetTempFileName()
|
||||
try{
|
||||
$encrypted=[Security.Cryptography.ProtectedData]::Protect($plain,$entropy,[Security.Cryptography.DataProtectionScope]::LocalMachine)
|
||||
[IO.File]::WriteAllBytes($file,$encrypted)
|
||||
$decoded=[Security.Cryptography.ProtectedData]::Unprotect([IO.File]::ReadAllBytes($file),$entropy,[Security.Cryptography.DataProtectionScope]::LocalMachine)
|
||||
if([Convert]::ToBase64String($plain) -ne [Convert]::ToBase64String($decoded)){throw 'invalid'}
|
||||
$value=[ordered]@{deviceId=$request.deviceId;origin=$request.origin;policyVersion=$request.policyVersion;policyKeySha256=$request.policyKeySha256;policyDigest=$request.policyDigest;token=[Text.Encoding]::UTF8.GetString($decoded)}
|
||||
$out=[Text.Encoding]::UTF8.GetBytes(($value|ConvertTo-Json -Compress));$stdout=[Console]::OpenStandardOutput();$h=[BitConverter]::GetBytes([uint32]$out.Length);$stdout.Write($h,0,4);$stdout.Write($out,0,$out.Length);$stdout.Flush()
|
||||
}finally{[Array]::Clear($plain,0,$plain.Length);if($decoded){[Array]::Clear($decoded,0,$decoded.Length)};[Array]::Clear($random,0,$random.Length);[IO.File]::Delete($file)}
|
||||
`;
|
||||
const client=createNativeManagementCredentialLoader({expectedBinding:binding,current:()=>true,launch:()=>spawn('powershell.exe',['-NoLogo','-NoProfile','-NonInteractive','-EncodedCommand',Buffer.from(script,'utf16le').toString('base64')],{windowsHide:true,stdio:['pipe','pipe','ignore']})});const value=await client.load(binding);assert.equal(value.token.length,43);value.token.fill(0);client.close();
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import https from 'node:https';
|
||||
import {once} from 'node:events';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import {generateKeyPairSync} from 'node:crypto';
|
||||
import {createRequire} from 'node:module';
|
||||
import {defaultPolicy,signPolicy,verifyPolicy} from '../packages/core/policy.mjs';
|
||||
const require=createRequire(import.meta.url),{createManagementIdentityOperation}=require('../apps/network-broker/management-identity-operation.cjs');
|
||||
const channel=require('../packages/core/policy-channel.cjs'),{verifyServiceAcceptance}=require('../apps/network-broker/service-acceptance.cjs');
|
||||
const {signManifest,verifyReleaseEnvelope,verifyDeploymentEnvelope}=require('../packages/core/windows-trust.cjs');
|
||||
const pub=k=>k.publicKey.export({type:'spki',format:'pem'}),priv=k=>k.privateKey.export({type:'pkcs8',format:'pem'});
|
||||
async function fixture(t){
|
||||
const cert=await readFile(new URL('./fixtures/icap/cert.pem',import.meta.url)),key=await readFile(new URL('./fixtures/icap/key.pem',import.meta.url)),ed=generateKeyPairSync('ed25519'),pq=generateKeyPairSync('ml-dsa-87'),vendor=generateKeyPairSync('ec',{namedCurve:'prime256v1'}),customer=generateKeyPairSync('ec',{namedCurve:'prime256v1'});let requests=0,tcp=0;
|
||||
let result=channel.identity(ed.privateKey,pq.privateKey,pub(pq)),status=200,handler;
|
||||
const server=https.createServer({key,cert},async(req,res)=>{requests++;assert.equal(req.headers.authorization,'Bearer synthetic-protected-token');if(handler){await handler(req,res);return;}assert.equal(req.method,'GET');assert.equal(req.url,'/api/device/channel/identity');res.writeHead(status,{'content-type':'application/json'});res.end(JSON.stringify(result));});server.on('connection',()=>tcp++);server.listen(0,'::');await once(server,'listening');t.after(async()=>{server.closeAllConnections();await new Promise(r=>server.close(r));});const origin=`https://localhost:${server.address().port}`,sid='S-1-12-1-1-2-3-4';
|
||||
const release={format:'safetynet-windows-release-v1',releaseId:'12345678-1234-1234-1234-123456789abc',releaseSequence:1,serviceName:'SafetyNetNetworkBroker',deploymentTenant:'22345678-1234-1234-1234-123456789abc',deploymentPublicKey:pub(customer),files:['SafetyNet.exe','resources/app.asar','resources/broker/node.exe','resources/inspector/server.py','resources/python/python.exe','resources/semantic/model.bin','safetynet-service-host.exe'].map(path=>({path,bytes:1,sha256:'a'.repeat(64)}))};const releaseEnvelope=signManifest(release,priv(vendor),'release'),r=verifyReleaseEnvelope(releaseEnvelope,{publicKey:pub(vendor)});
|
||||
const deployment={format:'safetynet-windows-deployment-v2',releaseId:release.releaseId,deploymentTenant:release.deploymentTenant,deploymentSequence:1,policyPublicKey:pub(ed),expiresAt:Date.now()+60000,users:[{sid,deviceId:'device',minimumPolicyVersion:7}],management:{origin,signatureKey:pub(pq),protocol:channel.VERSION}},deploymentEnvelope=signManifest(deployment,priv(customer),'deployment'),d=verifyDeploymentEnvelope(deploymentEnvelope,{release:r,authenticatedSid:sid});
|
||||
const policy=defaultPolicy();policy.version=7;policy.assignment={deviceId:'device',groups:[]};policy.proxy={mode:'direct'};const policyEnvelope=signPolicy(policy,priv(ed));
|
||||
const snapshot={version:1,authenticatedSid:sid,releaseAnchor:pub(vendor),acceptanceState:{format:'safetynet-service-floors-v1',release:{minimumSequence:1,acceptedDigest:r.digest},deployment:{tenant:release.deploymentTenant,minimumSequence:1,acceptedDigest:d.digest},policies:[]}},files={releaseEnvelope,deploymentEnvelope};const plan=await verifyServiceAcceptance({...files,releaseAnchor:snapshot.releaseAnchor,authenticatedSid:sid,state:snapshot.acceptanceState,policyEnvelope});snapshot.acceptanceState=plan.nextState;
|
||||
const store={async snapshot(){return snapshot;},async envelopes(){return files;}};const config={store,policyEnvelope,network:{mode:'direct',dns:'system',ca:cert.toString()},current:()=>true,loadCredential:async binding=>{assert.equal(binding.deviceId,'device');assert.equal(binding.origin,origin);return {deviceId:'device',origin,token:'synthetic-protected-token'};}};
|
||||
return {config,origin,pq,ed,snapshot,get requests(){return requests;},get tcp(){return tcp;},set result(v){result=v;},set status(v){status=v;},set handler(v){handler=v;}};
|
||||
}
|
||||
test('actual HTTPS identity verifies genuine Ed25519 plus deployment-pinned MLDSA87',async t=>{const f=await fixture(t),op=await createManagementIdentityOperation(f.config);t.after(()=>op.close());const value=await op.identity();assert.equal(value.signatureKey,pub(f.pq));assert.equal(f.requests,1);await assert.rejects(op.identity({url:'https://attacker.example',token:'override'}),{message:'broker-management-refused'});assert.equal(f.requests,1);});
|
||||
test('protected credentials missing or mismatched never start network',async t=>{let storeCalls=0;await assert.rejects(createManagementIdentityOperation({store:{snapshot(){storeCalls++;}}}),{message:'broker-management-refused'});assert.equal(storeCalls,0);const f=await fixture(t),op=await createManagementIdentityOperation({...f.config,loadCredential:async()=>({deviceId:'other',origin:f.origin,token:'synthetic-protected-token'})});t.after(()=>op.close());await assert.rejects(op.identity(),{message:'broker-management-refused'});assert.equal(f.tcp,0);});
|
||||
test('identity tampering and redirect fail without retry',async t=>{const f=await fixture(t),op=await createManagementIdentityOperation(f.config);t.after(()=>op.close());f.result={version:channel.VERSION,signatureKey:pub(f.pq),edSignature:'a',pqSignature:'b'};await assert.rejects(op.identity(),{message:'broker-management-refused'});assert.equal(f.requests,1);f.status=302;await assert.rejects(op.identity(),{message:'broker-management-refused'});assert.equal(f.requests,2);});
|
||||
test('timed-out protected credential retains admission until original settlement',async t=>{const f=await fixture(t);let settle,calls=0;const pending=new Promise(r=>{settle=r;}),op=await createManagementIdentityOperation({...f.config,deadlineMs:20,loadCredential:()=>{calls++;return pending;}});t.after(()=>op.close());await assert.rejects(op.identity(),{message:'broker-management-refused'});await assert.rejects(op.identity(),{message:'broker-management-refused'});assert.equal(calls,1);assert.equal(f.tcp,0);let closed=false;const closing=op.close().then(()=>{closed=true;});await new Promise(r=>setTimeout(r,10));assert.equal(closed,false);const lateToken=Buffer.from('synthetic-protected-token');settle({deviceId:'device',origin:f.origin,token:lateToken});await closing;assert.ok(lateToken.every(v=>v===0));assert.equal(f.tcp,0);});
|
||||
test('validly dual-signed identity with another PQ key is refused by deployed pin',async t=>{const f=await fixture(t),other=generateKeyPairSync('ml-dsa-87');f.result=channel.identity(f.ed.privateKey,other.privateKey,pub(other));const op=await createManagementIdentityOperation(f.config);t.after(()=>op.close());await assert.rejects(op.identity(),{message:'broker-management-refused'});assert.equal(f.requests,1);});
|
||||
test('pre-enrollment floors and revocation during credential load cannot dial',async t=>{const f=await fixture(t),saved=f.snapshot.acceptanceState;f.snapshot.acceptanceState={...saved,policies:[]};await assert.rejects(createManagementIdentityOperation(f.config),{message:'broker-management-refused'});f.snapshot.acceptanceState=saved;let active=true;const op=await createManagementIdentityOperation({...f.config,current:()=>active,loadCredential:async()=>{active=false;return {deviceId:'device',origin:f.origin,token:'synthetic-protected-token'};}});t.after(()=>op.close());await assert.rejects(op.identity(),{message:'broker-management-refused'});assert.equal(f.tcp,0);});
|
||||
test('private protected token lease is erased after actual HTTPS use',async t=>{const f=await fixture(t),token=Buffer.from('synthetic-protected-token');const op=await createManagementIdentityOperation({...f.config,loadCredential:async binding=>{assert.match(binding.policyDigest,/^[a-f0-9]{64}$/);return {deviceId:'device',origin:f.origin,token};}});t.after(()=>op.close());await op.identity();assert.ok(token.every(byte=>byte===0));assert.equal(f.requests,1);});
|
||||
function policyHandler(f,{version=8,deviceId='device',challengeDevice='device',tamper=false,onChallenge=()=>{}}={}){
|
||||
let pair,used=false;return async(req,res)=>{
|
||||
assert.equal(req.method,'POST');const chunks=[];for await(const part of req)chunks.push(part);const body=JSON.parse(Buffer.concat(chunks).toString());
|
||||
if(req.url==='/api/device/channel/challenge'){assert.deepEqual(body,{});pair=channel.challenge(challengeDevice,f.ed.privateKey,f.pq.privateKey);onChallenge();res.end(JSON.stringify(pair.bundle));return;}
|
||||
assert.equal(req.url,'/api/device/channel/policy');assert.equal(used,false);used=true;const context=channel.receive(body,pair.bundle,pair.privateKey);try{const p=defaultPolicy();p.version=version;p.assignment={deviceId,groups:[]};p.proxy={mode:'direct'};const envelope=signPolicy(p,priv(f.ed)),response=channel.response(envelope,pair.bundle,context);if(tamper)response.encrypted.tag='a'.repeat(22);res.end(JSON.stringify(response));}finally{context.responseKey.fill(0);}
|
||||
};
|
||||
}
|
||||
test('actual paired MLKEM1024/MLDSA87 HTTPS policy operation verifies new device-bound policy',async t=>{const f=await fixture(t);f.handler=policyHandler(f);const op=await createManagementIdentityOperation({...f.config,loadCredential:undefined,credentialLoader:{load:f.config.loadCredential}});t.after(()=>op.close());const result=await op.policy();assert.equal(verifyPolicy(result,pub(f.ed),7).version,8);assert.equal(f.requests,2);assert.equal(f.snapshot.acceptanceState.policies[0].minimumVersion,7);await assert.rejects(op.policy({url:'https://other.example'}),{message:'broker-management-refused'});assert.equal(f.requests,2);});
|
||||
test('policy channel wrong device, rollback, same-version equivocation and encrypted tampering fail',async t=>{for(const options of [{challengeDevice:'other'},{deviceId:'other'},{version:6},{version:7},{tamper:true}]){const f=await fixture(t);f.handler=policyHandler(f,options);const op=await createManagementIdentityOperation(f.config);t.after(()=>op.close());await assert.rejects(op.policy(),{message:'broker-management-refused'});assert.equal(f.requests,options.challengeDevice?1:2);}});
|
||||
test('revocation after signed challenge prevents policy POST and uses one total job budget',async t=>{const f=await fixture(t);let active=true;f.handler=policyHandler(f,{onChallenge:()=>{active=false;}});const op=await createManagementIdentityOperation({...f.config,current:()=>active});t.after(()=>op.close());await assert.rejects(op.policy(),{message:'broker-management-refused'});assert.equal(f.requests,1);});
|
||||
test('challenge and policy share one deadline rather than renewing per request',async t=>{const f=await fixture(t),handle=policyHandler(f);f.handler=async(req,res)=>{const end=res.end.bind(res);res.end=(...args)=>{setTimeout(()=>end(...args),180);return res;};await handle(req,res);};const token=Buffer.from('synthetic-protected-token'),op=await createManagementIdentityOperation({...f.config,deadlineMs:300,loadCredential:async()=>({deviceId:'device',origin:f.origin,token})});t.after(()=>op.close());await assert.rejects(op.policy(),{message:'broker-management-refused'});await new Promise(r=>setTimeout(r,250));assert.equal(f.requests,2);assert.ok(token.every(v=>v===0));});
|
||||
test('default fixed native loader refuses unavailable authenticated service context before network',async t=>{const f=await fixture(t),previous=process.env.SAFETYNET_SERVICE_CLIENT_SID;delete process.env.SAFETYNET_SERVICE_CLIENT_SID;try{const op=await createManagementIdentityOperation({...f.config,loadCredential:undefined});t.after(()=>op.close());await assert.rejects(op.identity(),{message:'broker-management-refused'});await assert.rejects(op.policy(),{message:'broker-management-refused'});assert.equal(f.tcp,0);}finally{if(previous!==undefined)process.env.SAFETYNET_SERVICE_CLIENT_SID=previous;}});
|
||||
@@ -0,0 +1,50 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
const require=createRequire(import.meta.url),{parseMultipart}=require('../apps/network-broker/multipart.cjs');
|
||||
const boundary='----WebKitFormBoundarySynthetic42',type=`multipart/form-data; boundary=${boundary}`;
|
||||
function encode(parts,ending=true){
|
||||
const output=[];
|
||||
for(const part of parts){output.push(Buffer.from(`--${boundary}\r\n${part.headers}\r\n\r\n`));output.push(Buffer.from(part.body));output.push(Buffer.from('\r\n'));}
|
||||
if(ending)output.push(Buffer.from(`--${boundary}--\r\n`));return Buffer.concat(output);
|
||||
}
|
||||
const text={headers:'Content-Disposition: form-data; name="note"',body:'public note'};
|
||||
const pdf={headers:'Content-Disposition: form-data; name="files"; filename="report.pdf"\r\nContent-Type: application/pdf',body:Buffer.from('%PDF-1.4\n\0\xffbinary','latin1')};
|
||||
const zip={headers:'Content-Disposition: form-data; name="files"; filename="archive.zip"\r\nContent-Type: application/zip',body:Buffer.from([0x50,0x4b,3,4,0,255,1])};
|
||||
test('Chromium form fields and PDF ZIP parts are preserved completely as independent bytes',()=>{
|
||||
const body=encode([text,pdf,zip]),parsed=parseMultipart(body,type);
|
||||
assert.equal(parsed.coverage.complete,true);assert.equal(parsed.parts.length,3);
|
||||
assert.equal(parsed.parts[0].body.toString(),'public note');
|
||||
assert.deepEqual(parsed.parts[1].body,pdf.body);assert.deepEqual(parsed.parts[2].body,zip.body);
|
||||
assert.equal(parsed.parts[1].filename,'report.pdf');assert.equal(parsed.parts[1].contentType,'application/pdf');
|
||||
body.fill(0);assert.deepEqual(parsed.parts[1].body,pdf.body);
|
||||
parsed.dispose();assert.ok(parsed.parts.every(part=>part.body.every(value=>value===0)));
|
||||
});
|
||||
test('boundary-like bytes without complete delimiter suffix remain inside file',()=>{
|
||||
const body=Buffer.from(`prefix\r\n--${boundary}XYZ\r\n--${boundary}-\r\nsuffix`);
|
||||
const parsed=parseMultipart(encode([{...pdf,body}]),type);assert.deepEqual(parsed.parts[0].body,body);parsed.dispose();
|
||||
});
|
||||
test('quoted boundary and escaped filename handle actual MIME syntax',()=>{
|
||||
const parsed=parseMultipart(encode([{...text,headers:'Content-Disposition: form-data; name="upload"; filename="quoted\\"name.txt"'}]),`multipart/form-data; boundary="${boundary}"`);
|
||||
assert.equal(parsed.parts[0].filename,'quoted"name.txt');parsed.dispose();
|
||||
});
|
||||
test('empty multipart form has complete zero-part coverage',()=>{
|
||||
assert.equal(parseMultipart(encode([]),type).parts.length,0);
|
||||
});
|
||||
test('truncation malformed CRLF preamble and epilogue never yield partial coverage',()=>{
|
||||
const valid=encode([text,pdf]);
|
||||
for(const body of [valid.subarray(0,-5),encode([text],false),Buffer.concat([Buffer.from('preamble'),valid]),Buffer.concat([valid,Buffer.from('epilogue')]),Buffer.from(valid.toString().replace('\r\n','\n'))])assert.throws(()=>parseMultipart(body,type),{code:'invalid-multipart'});
|
||||
});
|
||||
test('ambiguous disposition and duplicate headers are rejected',()=>{
|
||||
for(const headers of ['Content-Disposition: form-data; name="a"; name="b"','Content-Disposition: form-data; name="a"\r\ncontent-disposition: form-data; name="b"','Content-Disposition: form-data; name="a"; filename="one"; filename="two"','Content-Disposition: form-data; name="a"\r\n folded: value'])assert.throws(()=>parseMultipart(encode([{headers,body:'public'}]),type),{code:'invalid-multipart'});
|
||||
});
|
||||
test('unsupported extended filenames nested multipart and transfer encoding fail closed',()=>{
|
||||
for(const headers of ["Content-Disposition: form-data; name=upload; filename*=UTF-8''file.pdf",'Content-Disposition: form-data; name="a"\r\nContent-Type: multipart/mixed; boundary=inner','Content-Disposition: form-data; name="a"\r\nContent-Transfer-Encoding: base64'])assert.throws(()=>parseMultipart(encode([{headers,body:'public'}]),type),{code:'unsupported-multipart'});
|
||||
});
|
||||
test('whole payload part count and per-part headers are strictly bounded',()=>{
|
||||
const body=encode([text,pdf]);
|
||||
for(const options of [{maxBytes:body.length-1},{maxParts:1},{maxHeaderBytes:5}])assert.throws(()=>parseMultipart(body,type,options),{code:'multipart-limit'});
|
||||
});
|
||||
test('invalid boundary parameters and duplicate boundary are rejected',()=>{
|
||||
for(const value of ['multipart/form-data','multipart/form-data; boundary=""','multipart/form-data; boundary="bad\r\nvalue"',`multipart/form-data; boundary=${boundary}; boundary=other`,'multipart/form-data; boundary='+ 'x'.repeat(71)])assert.throws(()=>parseMultipart(encode([text]),value),{code:'invalid-multipart'});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import {defaultPolicy} from '../packages/core/policy.mjs';
|
||||
const require=createRequire(import.meta.url),{createFixtureDiagnostics}=require('./fixtures/broker-native-diagnostics.cjs'),{createSyntheticAudit}=require('./fixtures/broker-durable-audit.cjs');
|
||||
test('authorized private synthetic OTLP fixture retrieves real durable trace with audit child timings',async t=>{
|
||||
const audit=createSyntheticAudit(t),policy=defaultPolicy(),diagnostics=await createFixtureDiagnostics({enabled:true,policy,audit});
|
||||
await diagnostics.run({bytes:0},async()=>diagnostics.persist({eventId:randomUUID(),kind:'upload.authorized',ruleId:'broker-approved',policyVersion:1,request:{bytes:0,detectedType:'text'}}));
|
||||
const proof=await diagnostics.finish();assert.equal(proof.enabled,true);assert.ok(proof.spanCount>=2);assert.equal(proof.invalid,0);assert.equal(proof.failedExports,0);assert.equal(proof.lifetimeFailedExports,0);
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
// Explicit Windows integration: run with the verified bundled Node executable
|
||||
// and SAFETYNET_NATIVE_BROKER_INSPECTION=1. Default unit suites skip this gate.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {createRequire} from 'node:module';
|
||||
import {createHash,generateKeyPairSync} from 'node:crypto';
|
||||
import {execFileSync} from 'node:child_process';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import tls from 'node:tls';
|
||||
import {once} from 'node:events';
|
||||
import {defaultPolicy,signPolicy,inspect} from '../packages/core/policy.mjs';
|
||||
import {verifyBrokerRuntime} from '../scripts/provision-broker-runtime.mjs';
|
||||
const require=createRequire(import.meta.url),{startInspector}=require('../apps/browser/inspection.cjs');
|
||||
const {createManagedProxy}=require('../apps/network-broker/managed-proxy.cjs');
|
||||
const {createPolicyAuthority}=require('../apps/network-broker/policy-authority.cjs');
|
||||
const {createHttpsForwarder}=require('../apps/network-broker/https-forwarder.cjs');
|
||||
const {createSyntheticAudit}=require('./fixtures/broker-durable-audit.cjs');
|
||||
const hash=bytes=>createHash('sha256').update(bytes).digest('hex');
|
||||
function pdf(text){
|
||||
const stream=`BT /F1 12 Tf 72 720 Td <${Buffer.from(text).toString('hex')}> Tj ET`;
|
||||
const objects=['<< /Type /Catalog /Pages 2 0 R >>','<< /Type /Pages /Kids [3 0 R] /Count 1 >>','<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>','<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',`<< /Length ${Buffer.byteLength(stream)} >>\nstream\n${stream}\nendstream`];
|
||||
let output='%PDF-1.4\n';const offsets=[0];
|
||||
objects.forEach((object,index)=>{offsets.push(Buffer.byteLength(output));output+=`${index+1} 0 obj\n${object}\nendobj\n`;});
|
||||
const xref=Buffer.byteLength(output);output+=`xref\n0 6\n0000000000 65535 f \n${offsets.slice(1).map(offset=>String(offset).padStart(10,'0')+' 00000 n \n').join('')}trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
|
||||
return Buffer.from(output);
|
||||
}
|
||||
function inspectorChildren(){
|
||||
const script=`@(Get-CimInstance Win32_Process -Filter "ParentProcessId = ${process.pid}" | Where-Object { $_.Name -eq 'python.exe' -and $_.CommandLine -like '*apps*inspector*server.py*' } | Select-Object -ExpandProperty ProcessId) | ConvertTo-Json -Compress`;
|
||||
const output=execFileSync('powershell.exe',['-NoProfile','-NonInteractive','-Command',script],{encoding:'utf8',windowsHide:true,timeout:10000}).trim();
|
||||
return output?[].concat(JSON.parse(output)):[];
|
||||
}
|
||||
test('bundled Windows Node managed TLS broker uses real contained document inspection before dial',{
|
||||
skip:process.platform!=='win32'||process.env.SAFETYNET_NATIVE_BROKER_INSPECTION!=='1',timeout:120000
|
||||
},async t=>{
|
||||
const root=path.resolve('.'),proofDirectory=path.join(root,'artifacts/broker-native-inspection');await fs.mkdir(proofDirectory,{recursive:true});
|
||||
const proof={startedAt:new Date().toISOString(),outcome:'running',diagnosticsEnabled:false,scans:[],office:[]};
|
||||
let inspector,proxy,target;const started=performance.now();
|
||||
try{
|
||||
const runtime=await verifyBrokerRuntime(path.join(root,'.runtime/windows/broker'));
|
||||
assert.equal(path.resolve(process.execPath).toLowerCase(),path.join(root,'.runtime/windows/broker/node.exe').toLowerCase(),'Run this gate with the bundled broker Node executable');
|
||||
proof.runtime={version:runtime.version,executableSha256:runtime.files.find(file=>file.path==='node.exe').sha256};
|
||||
inspector=startInspector(root,false);assert.ok(inspector,'Contained inspector runtime unavailable');await inspector.waitReady();
|
||||
const children=inspectorChildren();assert.equal(children.length,1,'Expected one owned contained inspector parent');proof.inspectorOwnedPids=children;
|
||||
// startInspector only resolves readiness after validating Job Object plus
|
||||
// zero-network-capability AppContainer/private-pipe/read-only containment.
|
||||
proof.containmentStartupValidated=true;
|
||||
const key=await fs.readFile(new URL('./fixtures/icap/key.pem',import.meta.url)),cert=await fs.readFile(new URL('./fixtures/icap/cert.pem',import.meta.url));
|
||||
const received=[];let connections=0;
|
||||
target=https.createServer({key,cert},async(req,res)=>{const chunks=[];for await(const chunk of req)chunks.push(chunk);const body=Buffer.concat(chunks);received.push({sha256:hash(body),bytes:body.length});res.end('public receiver acknowledgment');});
|
||||
target.on('connection',()=>connections++);target.listen(0,'127.0.0.1');await once(target,'listening');
|
||||
const port=target.address().port,destination=`localhost:${port}`;
|
||||
const keys=generateKeyPairSync('ed25519'),policy=defaultPolicy();policy.assignment={deviceId:'native-broker-device',groups:[]};
|
||||
policy.dlp.rules=[{id:'synthetic-confidential-document',label:'Synthetic confidential',kind:'literal',value:'SYNTHETIC INTERNAL BLUEPRINT',action:'block'}];
|
||||
assert.equal(policy.diagnostics.enabled,false);
|
||||
const authority=await createPolicyAuthority({deviceId:'native-broker-device',publicKey:keys.publicKey.export({type:'spki',format:'pem'})});
|
||||
authority.accept(signPolicy(policy,keys.privateKey.export({type:'pkcs8',format:'pem'})));
|
||||
const snapshot=authority.snapshot();
|
||||
const forward=createHttpsForwarder({authority:snapshot.token,current:authority.current,resolveRoute:async value=>({approved:value.hostname==='localhost'&&value.port===port,address:'127.0.0.1',port:value.port}),ca:cert,deadlineMs:45000,maxResponseBytes:Math.min(16777216,policy.fileTransfers.download.maxBytes)});
|
||||
const durableAudit=createSyntheticAudit(t);
|
||||
proxy=await createManagedProxy({policyAuthority:authority,authMode:'bearer',persistAudit:durableAudit.persist,resolveCertificate:binding=>binding.authority===destination?{authority:destination,key,cert}:undefined,
|
||||
localInspector:async(bytes,signedPolicy)=>{const before=performance.now(),result=await inspector.inspect(bytes,signedPolicy);proof.scans.push({durationMs:performance.now()-before,decision:result.decision,complete:result.coverage.complete,contentType:result.contentType,matchedSyntheticRule:result.ruleId==='synthetic-confidential-document'});return result;},forward,deadlineMs:45000});
|
||||
const proxyPort=await proxy.listen();
|
||||
const send=(body,mime='application/pdf')=>new Promise((resolve,reject)=>{
|
||||
const request=http.request({host:'127.0.0.1',port:proxyPort,method:'CONNECT',path:destination,headers:{host:destination,'proxy-authorization':`Bearer ${proxy.capability}`}});
|
||||
request.setTimeout(55000,()=>request.destroy(new Error('native-test-deadline')));request.once('error',reject);
|
||||
request.once('connect',(response,socket)=>{
|
||||
if(response.statusCode!==200){socket.destroy();resolve(response.statusCode);return;}
|
||||
const secure=tls.connect({socket,servername:'localhost',ca:cert,rejectUnauthorized:true});let reply='';
|
||||
secure.setTimeout(55000,()=>secure.destroy(new Error('native-test-deadline')));secure.once('error',reject);
|
||||
secure.once('secureConnect',()=>secure.write(Buffer.concat([Buffer.from(`POST /upload HTTP/1.1\r\nHost: ${destination}\r\nContent-Type: ${mime}\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n`),body])));
|
||||
secure.on('data',chunk=>reply+=chunk.toString());secure.once('end',()=>{secure.destroy();resolve(Number(/^HTTP\/1.1 (\d+)/.exec(reply)?.[1]));});
|
||||
});request.end();
|
||||
});
|
||||
const secret=pdf(policy.dlp.rules[0].value),publicDocument=pdf('Public example report');
|
||||
assert.equal(inspect(secret,policy).decision,'allow','Encoded PDF must require genuine parsing, not raw literal detection');
|
||||
assert.equal(await send(secret),403);assert.equal(connections,0);assert.equal(received.length,0);
|
||||
proof.secret={status:403,upstreamConnections:connections,receivedBytes:0};
|
||||
assert.equal(await send(publicDocument),200);assert.deepEqual(received,[{sha256:hash(publicDocument),bytes:publicDocument.length}]);
|
||||
assert.ok(proof.scans.some(scan=>scan.decision==='block'&&scan.contentType==='pdf'&&scan.matchedSyntheticRule));
|
||||
assert.ok(proof.scans.some(scan=>scan.decision==='allow'&&scan.complete&&scan.contentType==='pdf'));
|
||||
proof.public={status:200,upstreamConnections:connections,...received[0]};
|
||||
const officeRoot=path.join(root,'artifacts/office-coverage-fixtures');
|
||||
// Only synthetic fixtures are modified by stdlib ZIP/XML; no classifier,
|
||||
// document parser or OCR result is substituted in the actual broker gate.
|
||||
const mutate=[
|
||||
'import sys,zipfile,pathlib,xml.etree.ElementTree as ET',
|
||||
'root=pathlib.Path(sys.argv[1]); marker=sys.argv[2]',
|
||||
"for source,destination,member in [('public-generated.docx','secret-metadata.docx','docProps/core.xml'),('public-generated.xlsx','secret-hidden.xlsx','xl/worksheets/sheet2.xml')]:",
|
||||
' with zipfile.ZipFile(root/source) as archive:',
|
||||
' records=[(entry,archive.read(entry)) for entry in archive.infolist()]',
|
||||
" if source.endswith('.xlsx'):",
|
||||
" workbook=ET.fromstring(archive.read('xl/workbook.xml'))",
|
||||
" sheets=[node for node in workbook.iter() if node.tag.endswith('}sheet')]",
|
||||
" assert len(sheets)==2 and sheets[1].attrib.get('state')=='hidden'",
|
||||
" with zipfile.ZipFile(root/destination,'w') as output:",
|
||||
' changed=False',
|
||||
' for entry,data in records:',
|
||||
' if entry.filename==member:',
|
||||
' document=ET.fromstring(data)',
|
||||
" if source.endswith('.docx'):",
|
||||
" title=document.find('{http://purl.org/dc/elements/1.1/}title')",
|
||||
" if title is None: title=ET.SubElement(document,'{http://purl.org/dc/elements/1.1/}title')",
|
||||
' title.text=marker',
|
||||
' else:',
|
||||
" namespace='{http://schemas.openxmlformats.org/spreadsheetml/2006/main}'",
|
||||
" sheet=document.find(namespace+'sheetData')",
|
||||
" if sheet is None: raise RuntimeError('Synthetic sheet data absent')",
|
||||
" row=ET.SubElement(sheet,namespace+'row',{'r':'1'})",
|
||||
" cell=ET.SubElement(row,namespace+'c',{'r':'A1','t':'inlineStr'})",
|
||||
" value=ET.SubElement(ET.SubElement(cell,namespace+'is'),namespace+'t'); value.text=marker",
|
||||
" data=ET.tostring(document,encoding='utf-8',xml_declaration=True);changed=True",
|
||||
' output.writestr(entry,data)',
|
||||
" if not changed: raise RuntimeError('Synthetic fixture target part absent')",
|
||||
''
|
||||
].join('\n');
|
||||
execFileSync(path.join(root,'.runtime/windows/python/python.exe'),['-c',mutate,officeRoot,policy.dlp.rules[0].value],{windowsHide:true,stdio:'pipe',timeout:10000});
|
||||
const expectedPublic=[received[0]];
|
||||
for(const [label,name,mime] of [
|
||||
['docx','public-generated.docx','application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
|
||||
['xlsx','public-generated.xlsx','application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']]){
|
||||
const bytes=await fs.readFile(path.join(officeRoot,name)),firstScan=proof.scans.length;
|
||||
assert.equal(await send(bytes,mime),200);
|
||||
const observed=proof.scans.slice(firstScan);assert.ok(observed.some(scan=>scan.contentType==='office'&&scan.decision==='allow'&&scan.complete));
|
||||
expectedPublic.push({sha256:hash(bytes),bytes:bytes.length});proof.office.push({case:label+'-public',status:200,complete:true,sha256:hash(bytes),bytes:bytes.length});
|
||||
}
|
||||
for(const [label,name,mime] of [
|
||||
['docx-metadata','secret-metadata.docx','application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
|
||||
['xlsx-hidden-sheet','secret-hidden.xlsx','application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']]){
|
||||
const before=connections,firstScan=proof.scans.length,bytes=await fs.readFile(path.join(officeRoot,name));
|
||||
assert.equal(await send(bytes,mime),403);assert.equal(connections,before);
|
||||
assert.ok(proof.scans.slice(firstScan).some(scan=>scan.contentType==='office'&&scan.decision==='block'&&scan.matchedSyntheticRule));
|
||||
proof.office.push({case:label,status:403,matchedSyntheticRule:true,newUpstreamConnections:0});
|
||||
}
|
||||
const complex=await fs.readFile(path.join(officeRoot,'public-generated.pptx')),beforeComplex=connections,firstComplexScan=proof.scans.length;
|
||||
assert.equal(await send(complex,'application/vnd.openxmlformats-officedocument.presentationml.presentation'),403);assert.equal(connections,beforeComplex);
|
||||
assert.ok(proof.scans.slice(firstComplexScan).some(scan=>scan.contentType==='office'&&scan.decision==='block'&&!scan.complete));
|
||||
proof.office.push({case:'unsupported-complex-pptx',status:403,complete:false,newUpstreamConnections:0});
|
||||
assert.deepEqual(received,expectedPublic);
|
||||
const auditEvents=durableAudit.events();
|
||||
assert.equal(auditEvents.filter(event=>event.kind==='upload.authorized').length,3);
|
||||
assert.equal(auditEvents.filter(event=>event.kind==='upload.block'&&event.ruleId==='synthetic-confidential-document').length,3);
|
||||
assert.equal(auditEvents.length,7);
|
||||
proof.audit={persistedEvents:auditEvents.length,authorizedEvents:3,matchedSecretBlocks:3,syntheticMetadataFsynced:true};
|
||||
proof.outcome='passed';
|
||||
}catch(error){proof.outcome='failed';proof.errorType=error.name;throw error;}
|
||||
finally{
|
||||
if(proxy)await proxy.close();if(target){target.closeAllConnections();await new Promise(resolve=>target.close(resolve));}
|
||||
if(inspector){inspector.stop();const deadline=Date.now()+10000;while(inspectorChildren().length&&Date.now()<deadline)await new Promise(resolve=>setTimeout(resolve,100));proof.ownedInspectorParentsRemaining=inspectorChildren().length;}
|
||||
proof.durationMs=performance.now()-started;await fs.writeFile(path.join(proofDirectory,`proof-${proof.startedAt.replaceAll(':','-')}.json`),JSON.stringify(proof,null,2)+'\n');
|
||||
await fs.writeFile(path.join(proofDirectory,`office-proof-${proof.startedAt.replaceAll(':','-')}.json`),JSON.stringify({startedAt:proof.startedAt,outcome:proof.outcome,diagnosticsEnabled:false,runtime:proof.runtime,office:proof.office,scans:proof.scans.filter(scan=>scan.contentType==='office'),audit:proof.audit,ownedInspectorParentsRemaining:proof.ownedInspectorParentsRemaining},null,2)+'\n');
|
||||
assert.equal(proof.ownedInspectorParentsRemaining??0,0,'Owned inspector parent did not terminate');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {mkdtemp,mkdir,writeFile,rm,symlink,open} from 'node:fs/promises';
|
||||
import {writeFileSync,utimesSync} from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {buildBrokerPackageManifest,verifyBrokerPackage} from '../scripts/broker-package-contract.mjs';
|
||||
|
||||
const release={version:'0.1.0',protocol:1,entrypoint:'broker.exe'};
|
||||
async function fixture(t){
|
||||
const root=await mkdtemp(path.join(os.tmpdir(),'safetynet-broker-contract-'));
|
||||
t.after(()=>rm(root,{recursive:true,force:true}));
|
||||
await mkdir(path.join(root,'runtime'));
|
||||
// Synthetic bytes deliberately are not a broker executable or launch proof.
|
||||
await writeFile(path.join(root,'broker.exe'),'synthetic packaged executable');
|
||||
await writeFile(path.join(root,'runtime','scanner.dll'),'synthetic scanner');
|
||||
const manifest=await buildBrokerPackageManifest(root,release);
|
||||
return {root,manifest};
|
||||
}
|
||||
test('complete bundled broker inventory hashes and verifies without external runtime',async t=>{
|
||||
const {root,manifest}=await fixture(t);
|
||||
assert.equal(manifest.files.length,2);
|
||||
assert.equal((await verifyBrokerPackage(root,manifest,release)).files,2);
|
||||
});
|
||||
for(const operation of ['missing','tampered','extra'])test(`rejects ${operation} broker payload`,async t=>{
|
||||
const {root,manifest}=await fixture(t);
|
||||
if(operation==='missing')await rm(path.join(root,'broker.exe'));
|
||||
if(operation==='tampered')await writeFile(path.join(root,'runtime','scanner.dll'),'replacement');
|
||||
if(operation==='extra')await writeFile(path.join(root,'runtime','injected.dll'),'injection');
|
||||
await assert.rejects(verifyBrokerPackage(root,manifest,release),{code:operation==='tampered'?'broker_package_integrity_mismatch':'broker_package_inventory_mismatch'});
|
||||
});
|
||||
test('runtime protocol and version must match integrity-protected expectations',async t=>{
|
||||
const {root,manifest}=await fixture(t);
|
||||
for(const expected of [{version:'0.2.0',protocol:1},{version:'0.1.0',protocol:2}])
|
||||
await assert.rejects(verifyBrokerPackage(root,manifest,expected),{code:'broker_package_incompatible'});
|
||||
});
|
||||
test('rejects ambiguous paths and manifests before package traversal',async t=>{
|
||||
const {root,manifest}=await fixture(t);
|
||||
for(const unsafe of ['../broker.exe','C:/broker.exe','runtime\\scanner.dll','CON.exe','broker.exe:stream','./broker.exe']){
|
||||
const changed=structuredClone(manifest);changed.files[0].path=unsafe;
|
||||
await assert.rejects(verifyBrokerPackage(root,changed,release),{code:'broker_manifest_invalid'});
|
||||
}
|
||||
const duplicate=structuredClone(manifest);duplicate.files.push({...duplicate.files[0],path:duplicate.files[0].path.toUpperCase()});
|
||||
await assert.rejects(verifyBrokerPackage(root,duplicate,release),{code:'broker_manifest_invalid'});
|
||||
});
|
||||
test('rejects absent entrypoint and malformed hashes',async t=>{
|
||||
const {root,manifest}=await fixture(t);
|
||||
await assert.rejects(verifyBrokerPackage(root,{...manifest,entrypoint:'missing.exe'},release),{code:'broker_manifest_invalid'});
|
||||
const changed=structuredClone(manifest);changed.files[0].sha256='invalid';
|
||||
await assert.rejects(verifyBrokerPackage(root,changed,release),{code:'broker_manifest_invalid'});
|
||||
});
|
||||
test('directory junctions cannot redirect broker payload outside installation',async t=>{
|
||||
const {root,manifest}=await fixture(t);
|
||||
const target=path.join(root,'runtime'),alias=path.join(root,'linked');
|
||||
await symlink(target,alias,process.platform==='win32'?'junction':'dir');
|
||||
await assert.rejects(verifyBrokerPackage(root,manifest,release),{code:'broker_path_unsafe'});
|
||||
});
|
||||
test('unavailable package errors contain no filesystem or raw exception content',async()=>{
|
||||
await assert.rejects(verifyBrokerPackage(path.join(os.tmpdir(),'absent-sensitive-company-path'),{...release,format:'safetynet-broker-package-v1',files:[{path:'broker.exe',bytes:0,sha256:'0'.repeat(64)}]},release),error=>error.code==='broker_package_unavailable'&&error.message==='broker_package_unavailable');
|
||||
});
|
||||
|
||||
test('same-size mutation during actual read rejects with bounded fixed invariant facts',async t=>{
|
||||
const {root,manifest}=await fixture(t),target=path.join(root,'broker.exe');
|
||||
const probe=await open(target,'r'),prototype=Object.getPrototypeOf(probe);
|
||||
const original=prototype.createReadStream;await probe.close();
|
||||
let mutated=false;
|
||||
t.mock.method(prototype,'createReadStream',function(options){
|
||||
const stream=original.call(this,options);
|
||||
stream.once('end',()=>{
|
||||
if(mutated)return;mutated=true;
|
||||
writeFileSync(target,'X'.repeat(manifest.files.find(file=>file.path==='broker.exe').bytes));
|
||||
const future=new Date(Date.now()+5000);utimesSync(target,future,future);
|
||||
});
|
||||
return stream;
|
||||
});
|
||||
await assert.rejects(verifyBrokerPackage(root,manifest,release),error=>{
|
||||
assert.equal(error.code,'broker_package_changed');assert.equal(error.message,error.code);
|
||||
assert.ok(error.failureFacts.changed.includes('modification-time'));
|
||||
assert.ok(error.failureFacts.changed.length<=5);
|
||||
assert.ok(error.failureFacts.changed.every(value=>['size-before-read','size-after-read','modification-time','change-time','file-identity'].includes(value)));
|
||||
assert.ok(Object.isFrozen(error.failureFacts)&&Object.isFrozen(error.failureFacts.changed));
|
||||
assert.ok(!JSON.stringify(error.failureFacts).includes(root));return true;
|
||||
});
|
||||
assert.equal(mutated,true);
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
import {mkdtemp,mkdir,writeFile,readFile,cp,rm} from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import {brokerResourceConfiguration,prepareBrokerResources,verifyPackagedBroker,embeddedBrokerManifestReader,snapshotScannerResources,verifyScannerResources} from '../scripts/package-broker-resources.mjs';
|
||||
import {buildBrokerPackageManifest} from '../scripts/broker-package-contract.mjs';
|
||||
import {BROKER_PAYLOAD} from '../scripts/stage-broker-payload.mjs';
|
||||
const require=createRequire(import.meta.url),asar=require('@electron/asar');
|
||||
const {getFileMatchers,copyFiles}=require('app-builder-lib/out/fileMatcher.js');
|
||||
const build={extraResources:[{from:'apps/inspector',to:'inspector'},{from:'.runtime/windows/broker-payload',to:'broker'}],files:['apps/browser/**/*',{from:'.runtime/windows/broker-payload.manifest.json',to:'apps/browser/broker-package-manifest.json'}]};
|
||||
async function fixture(t){const root=await mkdtemp(path.join(os.tmpdir(),'safetynet-package-resources-'));t.after(()=>rm(root,{recursive:true,force:true}));return root;}
|
||||
test('declared single broker and protected ASAR manifest mapping is mandatory',()=>{
|
||||
const payloadRoot=path.resolve('.runtime/windows/broker-payload-fixture'),manifestPath=payloadRoot+'.manifest.json';
|
||||
const configured=brokerResourceConfiguration(build,{payloadRoot,manifestPath});
|
||||
assert.equal(configured.extraResources[1].from,payloadRoot);assert.equal(configured.files[1].from,manifestPath);assert.equal(configured.files[1].to,'apps/browser/broker-package-manifest.json');
|
||||
for(const bad of [{...build,extraResources:build.extraResources.slice(0,1)},{...build,extraResources:[...build.extraResources,build.extraResources[1]]},{...build,extraResources:[...build.extraResources,{from:'unexpected',to:'BROKER'}]},{...build,extraResources:[...build.extraResources,{from:'unexpected',to:'broker/injected.dll'}]},{...build,files:[...build.files,build.files[1]]},{...build,files:build.files.slice(0,1)}])assert.throws(()=>brokerResourceConfiguration(bad,{payloadRoot,manifestPath}),{code:'broker_package_configuration_missing'});
|
||||
assert.equal(build.extraResources[1].from,'.runtime/windows/broker-payload');
|
||||
});
|
||||
test('actual ASAR reader verifies embedded manifest and rejects tampered external broker bytes',async t=>{
|
||||
const root=await fixture(t),resources=path.join(root,'resources'),broker=path.join(resources,'broker'),app=path.join(root,'app');
|
||||
await mkdir(broker,{recursive:true});await mkdir(path.join(app,'apps/browser'),{recursive:true});
|
||||
await writeFile(path.join(broker,'node.exe'),'synthetic payload, not executable proof');
|
||||
const manifest=await buildBrokerPackageManifest(broker,BROKER_PAYLOAD);
|
||||
const manifestPath=path.join(root,'broker.manifest.json');await writeFile(manifestPath,JSON.stringify(manifest));
|
||||
const configured=brokerResourceConfiguration(build,{payloadRoot:broker,manifestPath});
|
||||
const matchers=getFileMatchers(configured,'files',app,{defaultSrc:root,globalOutDir:path.join(root,'output'),macroExpander:value=>value,customBuildOptions:{}});
|
||||
const manifestMatcher=matchers.find(matcher=>matcher.from===manifestPath);
|
||||
assert.ok(manifestMatcher);assert.equal(manifestMatcher.to,path.join(app,'apps/browser/broker-package-manifest.json'));
|
||||
await copyFiles([manifestMatcher],null,false);
|
||||
const archive=path.join(resources,'app.asar');await asar.createPackage(app,archive);
|
||||
await verifyPackagedBroker({resources,expectedManifest:manifest,readEmbeddedManifest:embeddedBrokerManifestReader(archive)});
|
||||
await writeFile(path.join(broker,'node.exe'),'tampered');
|
||||
await assert.rejects(verifyPackagedBroker({resources,expectedManifest:manifest,readEmbeddedManifest:embeddedBrokerManifestReader(archive)}),{code:'broker_package_integrity_mismatch'});
|
||||
});
|
||||
test('adjacent modified metadata cannot replace expected protected manifest',async()=>{
|
||||
const expected={format:'reviewed'},changed={format:'attacker-adjacent'};
|
||||
await assert.rejects(verifyPackagedBroker({resources:path.resolve('resources'),expectedManifest:expected,readEmbeddedManifest:async()=>Buffer.from(JSON.stringify(changed))}),{code:'broker_package_manifest_mismatch'});
|
||||
await assert.rejects(verifyPackagedBroker({resources:path.resolve('resources'),expectedManifest:expected}),{code:'broker_package_manifest_reader_required'});
|
||||
});
|
||||
test('scanner resource inventory verifies copied bytes and rejects missing altered and extra files',async t=>{
|
||||
const root=await fixture(t),resources=path.join(root,'output');
|
||||
for(const directory of ['apps/inspector','.runtime/windows/python','.runtime/windows/semantic']){await mkdir(path.join(root,directory),{recursive:true});await writeFile(path.join(root,directory,'payload.dat'),'synthetic '+directory);}
|
||||
await mkdir(path.join(root,'apps/inspector/__pycache__'));await writeFile(path.join(root,'apps/inspector/__pycache__/ignored.pyc'),'not shipped');
|
||||
const expected=await snapshotScannerResources(root);assert.equal(expected.length,3);
|
||||
for(const [from,to] of [['apps/inspector','inspector'],['.runtime/windows/python','python'],['.runtime/windows/semantic','semantic']]){await mkdir(path.join(resources,to),{recursive:true});await cp(path.join(root,from,'payload.dat'),path.join(resources,to,'payload.dat'));}
|
||||
await verifyScannerResources(resources,expected);
|
||||
await writeFile(path.join(resources,'python/payload.dat'),'changed');await assert.rejects(verifyScannerResources(resources,expected),{code:'scanner_package_integrity_mismatch'});
|
||||
await cp(path.join(root,'.runtime/windows/python/payload.dat'),path.join(resources,'python/payload.dat'));
|
||||
await writeFile(path.join(resources,'inspector/injected.dll'),'extra');await assert.rejects(verifyScannerResources(resources,expected),{code:'scanner_package_integrity_mismatch'});
|
||||
await rm(path.join(resources,'inspector/injected.dll'));await rm(path.join(resources,'semantic/payload.dat'));await assert.rejects(verifyScannerResources(resources,expected),{code:'scanner_package_integrity_mismatch'});
|
||||
assert.equal((await readFile(path.join(root,'apps/inspector/payload.dat'))).toString(),'synthetic apps/inspector');
|
||||
});
|
||||
test('real prepared standalone payload keeps source identities pinned through subsequent packaging',{
|
||||
skip:process.platform!=='win32'||process.env.SAFETYNET_BROKER_PACKAGE_PREPARE!=='1',timeout:60000
|
||||
},async t=>{
|
||||
const root=await fixture(t),repository=path.resolve('.');
|
||||
// Explicit integration gate copies reviewed real sources and the pinned
|
||||
// official runtime. Zod is fetched and integrity checked against the lock.
|
||||
for(const directory of ['apps/network-broker','packages/core'])await cp(path.join(repository,directory),path.join(root,directory),{recursive:true});
|
||||
await mkdir(path.join(root,'apps/browser'),{recursive:true});
|
||||
for(const name of ['policy-assignment.cjs','enterprise-inspection.cjs','icap-client.cjs','inspect-text-representations.cjs','header-inspection.cjs','inspection.cjs','loopback-http.cjs'])await cp(path.join(repository,'apps/browser',name),path.join(root,'apps/browser',name));
|
||||
await cp(path.join(repository,'package-lock.json'),path.join(root,'package-lock.json'));
|
||||
await cp(path.join(repository,'.runtime/windows/broker'),path.join(root,'.runtime/windows/broker'),{recursive:true});
|
||||
const prepared=await prepareBrokerResources({root,build});
|
||||
assert.ok(prepared.manifest.files.some(file=>file.path==='apps/network-broker/audit-gate.cjs'));
|
||||
assert.ok(prepared.manifest.files.some(file=>file.path==='node_modules/zod/LICENSE'));
|
||||
assert.ok(prepared.sourceSnapshot.length>20);await prepared.verifySources();
|
||||
const changed=path.join(root,'apps/network-broker/policy-authority.cjs');await writeFile(changed,(await readFile(changed))+'\n// synthetic post-stage change\n');
|
||||
await assert.rejects(prepared.verifySources(),{code:'broker_stage_source_changed'});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {mkdtemp,writeFile,readFile,cp,rm} from 'node:fs/promises';
|
||||
import {tmpdir} from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {createHash} from 'node:crypto';
|
||||
import {gzipSync} from 'node:zlib';
|
||||
import {spawn} from 'node:child_process';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {BROKER_PAYLOAD,extractVerifiedZod,snapshotSources,verifySourceSnapshot,stageBrokerPayload} from '../scripts/stage-broker-payload.mjs';
|
||||
import {verifyBrokerPackage} from '../scripts/broker-package-contract.mjs';
|
||||
import {BROKER_RUNTIME} from '../scripts/provision-broker-runtime.mjs';
|
||||
const repo=fileURLToPath(new URL('../',import.meta.url));
|
||||
// Explicit native proof uses a prepared local stage. Production expected
|
||||
// state comes from the protected ASAR, never this adjacent test manifest.
|
||||
const nativeStage=process.env.SAFETYNET_TEST_BROKER_ROOT;
|
||||
async function currentStage(){
|
||||
assert.equal(typeof nativeStage,'string','explicit native stage required');
|
||||
const root=path.resolve(nativeStage),parent=path.resolve(repo,'.runtime/windows');
|
||||
assert.equal(path.dirname(root),parent,'stage must stay in the reviewed runtime directory');
|
||||
assert.match(path.basename(root),/^broker-payload-package-[a-z0-9-]+$/,'a unique current stage is required');
|
||||
const manifest=JSON.parse(await readFile(root+'.manifest.json','utf8'));
|
||||
await verifyBrokerPackage(root,manifest,BROKER_PAYLOAD);
|
||||
const executable=manifest.files.find(file=>file.path==='node.exe');
|
||||
assert.equal(executable?.sha256,BROKER_RUNTIME.executableSha256,'exact official runtime pin required');
|
||||
return {root,manifest};
|
||||
}
|
||||
async function temporary(fn){const root=await mkdtemp(path.join(tmpdir(),'safetynet-broker-stage-test-'));try{return await fn(root);}finally{const absolute=path.resolve(root);assert.equal(path.dirname(absolute),path.resolve(tmpdir()));assert.ok(path.basename(absolute).startsWith('safetynet-broker-stage-test-'));await rm(absolute,{recursive:true,force:true});}}
|
||||
function tar(entries){const chunks=[];for(const [name,content,type='0'] of entries){const data=Buffer.from(content),header=Buffer.alloc(512);header.write(name);header.write('0000644\0',100);header.write('0000000\0',108);header.write('0000000\0',116);header.write(data.length.toString(8).padStart(11,'0')+'\0',124);header.fill(32,148,156);header.write(type,156);header.write('ustar\0',257);header.write([...header].reduce((a,b)=>a+b,0).toString(8).padStart(6,'0')+'\0 ',148);chunks.push(header,data,Buffer.alloc((512-data.length%512)%512));}chunks.push(Buffer.alloc(1024));return gzipSync(Buffer.concat(chunks));}
|
||||
function lock(bytes){return {version:'4.6.7',resolved:'https://registry.npmjs.org/zod/-/zod-4.6.7.tgz',integrity:'sha512-'+createHash('sha512').update(bytes).digest('base64')};}
|
||||
const minimum=[['package/package.json',JSON.stringify({name:'zod',version:'4.6.7'})],['package/LICENSE','MIT synthetic fixture'],['package/index.js','export const z=1;']];
|
||||
test('verified dependency archive copies runtime and license, excludes development and secrets',()=>{const bytes=tar([...minimum,['package/src/dev.ts','dev'],['package/test/fixture.js','dev'],['package/.env','SECRET'],['package/key.pem','KEY']]);const files=extractVerifiedZod(bytes,lock(bytes));assert.deepEqual(files.map(f=>f.path),['node_modules/zod/package.json','node_modules/zod/LICENSE','node_modules/zod/index.js']);});
|
||||
test('archive rejects tampering, traversal, symlinks, duplicate-case paths and ADS before staging',()=>{const valid=tar(minimum);assert.throws(()=>extractVerifiedZod(Buffer.concat([valid,Buffer.from('tamper')]),lock(valid)),/integrity/);for(const entry of [['package/../../escape.js','x'],['package/link.js','x','2'],['package/INDEX.js','x'],['package/a.js:stream.js','x'],['package/CON.js','x']]){const bytes=tar([...minimum,entry]);assert.throws(()=>extractVerifiedZod(bytes,lock(bytes)),/archive/);}});
|
||||
test('source snapshot detects changes, including a same-size mutation',()=>temporary(async root=>{await writeFile(path.join(root,'source.cjs'),'AAA');const snapshot=await snapshotSources(root,['source.cjs']);await writeFile(path.join(root,'source.cjs'),'BBB');await assert.rejects(verifySourceSnapshot(root,snapshot),/source_changed/);await assert.rejects(snapshotSources(root,['../outside']),/path_unsafe/);}));
|
||||
test('stager refuses destination outside reviewed ignored root before copying',async()=>{await assert.rejects(stageBrokerPayload({root:repo,destination:path.join(repo,'dist','broker')}),/destination_unsafe/);});
|
||||
test('actual staged package excludes activation scripts and has complete trusted inventory',{skip:!nativeStage?'requires explicit SAFETYNET_TEST_BROKER_ROOT native stage':false},async()=>{const {root,manifest}=await currentStage();assert.ok(root);for(const file of manifest.files){assert.ok(!/(^|\/)(tests?|artifacts?|models?|__pycache__)(\/|$)|\.env$|\.(?:pem|py|pyc|exe)$/.test(file.path)||file.path==='node.exe');assert.ok(!file.path.startsWith('apps/browser/')||['policy-assignment.cjs','enterprise-inspection.cjs','icap-client.cjs','inspect-text-representations.cjs','header-inspection.cjs','inspection.cjs','loopback-http.cjs'].includes(file.path.slice(13)));}assert.ok(manifest.files.some(f=>f.path==='node_modules/zod/LICENSE'));});
|
||||
test('bundled official node loads real authority/policy outside repository with empty dependency environment',{skip:!nativeStage?'requires explicit SAFETYNET_TEST_BROKER_ROOT native stage':false},()=>temporary(async root=>{const {root:staged,manifest}=await currentStage();await cp(staged,root,{recursive:true});const script=`import {createRequire} from 'node:module';import {generateKeyPairSync} from 'node:crypto';const require=createRequire(process.cwd()+'/probe.cjs');const {createPolicyAuthority}=require('./apps/network-broker/policy-authority.cjs');const {defaultPolicy}=await import('./packages/core/policy.mjs');const service=require('./apps/network-broker/service-entry.cjs');const store=require('./apps/network-broker/service-store-client.cjs');const trust=require('./packages/core/windows-trust.cjs');const management=require('./apps/network-broker/management-identity-operation.cjs');const topology=require('./apps/network-broker/wfp-topology.cjs');const revalidation=require('./apps/network-broker/wfp-revalidation.cjs');const keys=generateKeyPairSync('ed25519');const authority=await createPolicyAuthority({publicKey:keys.publicKey.export({type:'spki',format:'pem'}),deviceId:'synthetic-staged-proof'});console.log(JSON.stringify({node:process.version,policyVersion:defaultPolicy().version,authorityActive:authority.status().active,dependency:require.resolve('zod'),nodePathPresent:!!process.env.NODE_PATH,serviceExportsLoaded:typeof service.runServiceProcess==='function'&&typeof store.createServiceStoreClient==='function'&&typeof trust.verifyDeploymentEnvelope==='function'&&typeof management.createManagementIdentityOperation==='function'&&typeof topology.validateTopology==='function'&&typeof revalidation.createWfpRevalidation==='function'}));`;const env={};for(const key of ['SystemRoot','WINDIR','TEMP','TMP'])if(process.env[key])env[key]=process.env[key];const output=await new Promise((resolve,reject)=>{const child=spawn(path.join(root,'node.exe'),['--input-type=module','-e',script],{cwd:root,env,windowsHide:true,timeout:15000});let stdout='',stderr='';child.stdout.on('data',b=>stdout+=b);child.stderr.on('data',b=>stderr+=b);child.on('error',reject);child.on('close',code=>code===0?resolve(stdout):reject(Error('bundled probe failed '+code+' '+stderr)));});const proof=JSON.parse(output);assert.equal(proof.node,'v'+BROKER_RUNTIME.version);assert.equal(proof.policyVersion,1);assert.equal(proof.authorityActive,false);assert.equal(proof.nodePathPresent,false);assert.equal(proof.serviceExportsLoaded,true);assert.ok(path.resolve(proof.dependency).startsWith(path.join(root,'node_modules','zod')+path.sep));await writeFile(path.join(root,'apps/network-broker/policy-authority.cjs'),'TAMPER');await assert.rejects(verifyBrokerPackage(root,manifest,BROKER_PAYLOAD),/integrity_mismatch/);}));
|
||||
@@ -0,0 +1,53 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {generateKeyPairSync,sign} from 'node:crypto';
|
||||
import {createRequire} from 'node:module';
|
||||
import {PassThrough} from 'node:stream';
|
||||
import {defaultPolicy,signPolicy,verifyPolicy} from '../packages/core/policy.mjs';
|
||||
const {createPolicyAuthority}=createRequire(import.meta.url)('../apps/network-broker/policy-authority.cjs');
|
||||
const {gateRequest}=createRequire(import.meta.url)('../apps/network-broker/http-request-gate.cjs');
|
||||
const keys=generateKeyPairSync('ed25519'),publicKey=keys.publicKey.export({type:'spki',format:'pem'}),privateKey=keys.privateKey.export({type:'pkcs8',format:'pem'});
|
||||
const policy=(version=1)=>({...defaultPolicy(),version,assignment:{deviceId:'device-1',groups:[]}});
|
||||
const create=()=>createPolicyAuthority({publicKey,deviceId:'device-1',minimumVersion:1});
|
||||
test('actual core signature/schema verification produces deeply immutable device authority',async()=>{
|
||||
const authority=await create(),envelope=signPolicy(policy(),privateKey),snapshot=authority.accept(envelope);
|
||||
assert.deepEqual(snapshot.policy,verifyPolicy(envelope,publicKey,1));assert.equal(authority.current(snapshot.token),true);
|
||||
assert.ok(Object.isFrozen(snapshot.policy.dlp.rules));assert.throws(()=>snapshot.policy.browser.allowedHosts.push('evil.example'));
|
||||
envelope.payload='mutated';assert.equal(authority.current(snapshot.token),true);
|
||||
});
|
||||
test('expired, wrong-device, modified, malformed schema and rollback invalidate old authority',async()=>{
|
||||
for(const invalid of ['expired','wrong-device','signature','schema','rollback']) {
|
||||
const authority=await create(),valid=authority.accept(signPolicy(policy(2),privateKey));let envelope;
|
||||
if(invalid==='expired'){const p=policy(3);p.expiresAt=new Date(0).toISOString();envelope=signPolicy(p,privateKey);}
|
||||
if(invalid==='wrong-device'){const p=policy(3);p.assignment.deviceId='device-2';envelope=signPolicy(p,privateKey);}
|
||||
if(invalid==='signature'){envelope=signPolicy(policy(3),privateKey);envelope.signature='AAAA';}
|
||||
if(invalid==='schema'){const p=policy(3);p.dlp.maxBytes=0;const payload=Buffer.from(JSON.stringify(p)).toString('base64url');envelope={payload,signature:sign(null,Buffer.from(payload),keys.privateKey).toString('base64url')};}
|
||||
if(invalid==='rollback')envelope=signPolicy(policy(1),privateKey);
|
||||
assert.throws(()=>authority.accept(envelope),{code:'policy-rejected'});assert.equal(authority.current(valid.token),false);assert.equal(authority.snapshot(),null);assert.equal(authority.status().minimumVersion,2);
|
||||
}
|
||||
});
|
||||
test('same-version changed policy rejected and revocation preserves rollback floor',async()=>{
|
||||
const authority=await create();const p=policy(2),first=authority.accept(signPolicy(p,privateKey));
|
||||
p.browser.blockedHosts=['example.com'];assert.throws(()=>authority.accept(signPolicy(p,privateKey)),{code:'policy-rejected'});assert.equal(authority.current(first.token),false);
|
||||
authority.revoke();assert.throws(()=>authority.accept(signPolicy(policy(1),privateKey)),{code:'policy-rejected'});
|
||||
});
|
||||
test('policy update during asynchronous scan prevents release using old token',async()=>{
|
||||
const authority=await create(),old=authority.accept(signPolicy(policy(),privateKey));let release,entered,dials=0;
|
||||
const scan=new Promise(r=>release=r),started=new Promise(r=>entered=r);
|
||||
const request=new PassThrough();Object.assign(request,{rawHeaders:['Host','example.com','Content-Length','6'],url:'http://example.com/',method:'POST',complete:true});
|
||||
const result=gateRequest(request,{authority:old.token,current:authority.current,inspect:async()=>{entered();await scan;return {decision:'allow'};},forward:()=>{dials++;}});
|
||||
const denied=assert.rejects(result,{code:'authority-expired'});request.end('public');await started;
|
||||
const next=authority.accept(signPolicy(policy(2),privateKey));release();await denied;assert.equal(dials,0);assert.equal(authority.current(next.token),true);assert.ok(next.generation>old.generation);
|
||||
authority.revoke();assert.equal(authority.current(next.token),false);
|
||||
});
|
||||
test('trusted bootstrap pins key and version; invalid bootstrap rejected',async()=>{
|
||||
for(const changed of [{publicKey:'invalid'},{deviceId:'bad/id'},{minimumVersion:-1},{minimumVersion:1.5}])await assert.rejects(createPolicyAuthority({publicKey,deviceId:'device-1',minimumVersion:1,...changed}),{code:'policy-bootstrap-invalid'});
|
||||
const authority=await createPolicyAuthority({publicKey,deviceId:'device-1',minimumVersion:5});assert.throws(()=>authority.accept(signPolicy(policy(4),privateKey)),{code:'policy-rejected'});
|
||||
});
|
||||
test('current authority expires without a policy refresh and rejects a different signing key',async()=>{
|
||||
const authority=await create(),p=policy();p.expiresAt=new Date(Date.now()+150).toISOString();
|
||||
const active=authority.accept(signPolicy(p,privateKey));assert.equal(authority.current(active.token),true);
|
||||
await new Promise(resolve=>setTimeout(resolve,170));assert.equal(authority.current(active.token),false);assert.equal(authority.snapshot(),null);
|
||||
const wrong=generateKeyPairSync('ed25519').privateKey.export({type:'pkcs8',format:'pem'});
|
||||
assert.throws(()=>authority.accept(signPolicy(policy(2),wrong)),{code:'policy-rejected'});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {fork} from 'node:child_process';
|
||||
import {once} from 'node:events';
|
||||
import fs from 'node:fs';
|
||||
import https from 'node:https';
|
||||
import http from 'node:http';
|
||||
import tls from 'node:tls';
|
||||
import net from 'node:net';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
const cert=fs.readFileSync(new URL('./fixtures/icap/cert.pem',import.meta.url)),key=fs.readFileSync(new URL('./fixtures/icap/key.pem',import.meta.url));
|
||||
const brokerNode=fileURLToPath(new URL('../.runtime/windows/broker/node.exe',import.meta.url));
|
||||
function child(t) {
|
||||
assert.equal(fs.existsSync(brokerNode),true,'Verified bundled broker Node prerequisite');
|
||||
const process=fork(fileURLToPath(new URL('./fixtures/broker-process-child.cjs',import.meta.url)),[],{execPath:brokerNode,execArgv:[],stdio:['ignore','ignore','ignore','ipc'],env:{SystemRoot:globalThis.process.env.SystemRoot}});
|
||||
t.after(()=>{if(process.exitCode===null)process.kill();});return process;
|
||||
}
|
||||
function bounded(event,ms=3000){let timer;return Promise.race([event,new Promise((_,reject)=>timer=setTimeout(()=>reject(Error('test-deadline')),ms))]).finally(()=>clearTimeout(timer));}
|
||||
async function request(ready,destination,body) {
|
||||
return await new Promise((resolve,reject)=>{
|
||||
const req=http.request({host:'127.0.0.1',port:ready.port,method:'CONNECT',path:destination,headers:{host:destination,'proxy-authorization':`Bearer ${ready.capability}`}});
|
||||
req.setTimeout(2000,()=>req.destroy(Error('test-deadline')));req.on('error',reject);
|
||||
req.on('connect',(res,socket)=>{
|
||||
if(res.statusCode!==200){socket.destroy();reject(Error('connect-rejected'));return;}
|
||||
const secure=tls.connect({socket,servername:'localhost',ca:cert,rejectUnauthorized:true});let text='';
|
||||
secure.setTimeout(2000,()=>secure.destroy(Error('test-deadline')));secure.on('error',reject);secure.on('secureConnect',()=>secure.write(`POST /submit HTTP/1.1\r\nHost: ${destination}\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n${body}`));secure.on('data',chunk=>text+=chunk);secure.on('end',()=>{secure.destroy();resolve(text);});
|
||||
});req.end();
|
||||
});
|
||||
}
|
||||
test('separate Node child handles inspected CONNECT and closes on private IPC disconnect',async t=>{
|
||||
const received=[];let connections=0;
|
||||
const target=https.createServer({key,cert},async(req,res)=>{const chunks=[];for await(const chunk of req)chunks.push(chunk);received.push(Buffer.concat(chunks).toString());res.writeHead(201);res.end('accepted');});
|
||||
target.on('connection',()=>connections++);target.listen(0,'127.0.0.1');await once(target,'listening');
|
||||
t.after(async()=>{target.closeAllConnections();await new Promise(r=>target.close(r));});
|
||||
const destination=`localhost:${target.address().port}`,worker=child(t);
|
||||
const readyEvent=bounded(once(worker,'message'));worker.send({type:'start',config:{destination}});
|
||||
const [ready]=await readyEvent;assert.equal(ready.type,'ready');assert.notEqual(worker.pid,process.pid);assert.match(ready.capability,/^[a-zA-Z0-9_-]{43}$/);
|
||||
assert.match(await request(ready,destination,'SECRET'),/^HTTP\/1.1 403/);assert.equal(connections,0);
|
||||
assert.match(await request(ready,destination,'public'),/^HTTP\/1.1 201/);assert.deepEqual(received,['public']);
|
||||
const exit=bounded(once(worker,'exit'));worker.disconnect();await exit;
|
||||
const failure=await new Promise(resolve=>{const socket=net.connect(ready.port,'127.0.0.1');socket.setTimeout(1000,()=>{socket.destroy();resolve('timeout');});socket.on('connect',()=>{socket.destroy();resolve('connected');});socket.on('error',error=>resolve(error.code));});
|
||||
assert.equal(failure,'ECONNREFUSED');
|
||||
});
|
||||
test('invalid and oversized private bootstrap reject without spawning another process',async t=>{
|
||||
for(const message of [{type:'command',command:'arbitrary'},{type:'start',config:{destination:'x'.repeat(70000)}}]) {
|
||||
const worker=child(t),reply=bounded(once(worker,'message')),exit=bounded(once(worker,'exit'));
|
||||
worker.send(message);assert.deepEqual((await reply)[0],{type:'error',code:'invalid-message'});await exit;
|
||||
}
|
||||
});
|
||||
test('explicit private stop closes the sole child without restart',async t=>{
|
||||
const worker=child(t),ready=bounded(once(worker,'message'));
|
||||
worker.send({type:'start',config:{destination:'localhost:443'}});assert.equal((await ready)[0].type,'ready');
|
||||
const exit=bounded(once(worker,'exit'));worker.send({type:'stop'});await exit;
|
||||
assert.equal(worker.exitCode,0);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {EventEmitter} from 'node:events';
|
||||
import {createRequire} from 'node:module';
|
||||
import {randomUUID,generateKeyPairSync} from 'node:crypto';
|
||||
import {defaultPolicy,signPolicy} from '../packages/core/policy.mjs';
|
||||
const require=createRequire(import.meta.url),{runProductionProcess}=require('../apps/network-broker/production-entry.cjs'),{createProductionBroker}=require('../apps/network-broker/production-factory.cjs'),{createBrokerAudit}=require('../apps/network-broker/audit-gate.cjs');
|
||||
class Channel extends EventEmitter{connected=true;sent=[];send(value,callback){this.sent.push(value);callback?.();this.emit('sent',value);}disconnect(){this.connected=false;this.emit('disconnect');}}
|
||||
const tick=()=>new Promise(resolve=>setImmediate(resolve));
|
||||
const start=()=>({type:'start',version:1,generation:randomUUID(),policyVersion:1,config:{brokerPort:43100}});
|
||||
|
||||
test('close retains late original factory ownership and cleans once before disconnect',async()=>{
|
||||
const channel=new Channel();let releaseFactory,releaseCleanup,closeCalls=0,listens=0;
|
||||
const created={close:()=>{closeCalls++;return new Promise(resolve=>{releaseCleanup=resolve;});},listen:()=>{listens++;return 43100;}};
|
||||
const handle=runProductionProcess(()=>new Promise(resolve=>{releaseFactory=resolve;}),channel);
|
||||
channel.emit('message',start());await tick();const closing=handle.close();assert.equal(handle.close(),closing);
|
||||
await tick();assert.equal(channel.connected,true);releaseFactory(created);await tick();
|
||||
assert.equal(closeCalls,1);assert.equal(listens,0);assert.equal(channel.connected,true);
|
||||
releaseCleanup();await closing;assert.equal(channel.connected,false);assert.equal(channel.sent.length,0);
|
||||
});
|
||||
test('real cleanup failure is sticky and cannot be reported as successful close',async()=>{
|
||||
const channel=new Channel();let calls=0;
|
||||
const handle=runProductionProcess(async()=>({authMode:'basic',credentials:{username:'safetynet-'+'a'.repeat(32),password:'b'.repeat(43)},listen:async()=>43100,close:async()=>{calls++;throw Error('fixed-cleanup-failure');}}),channel);
|
||||
channel.emit('message',start());await tick();const closing=handle.close();await assert.rejects(closing,/fixed-cleanup-failure/);
|
||||
assert.equal(handle.close(),closing);await assert.rejects(handle.close(),/fixed-cleanup-failure/);assert.equal(calls,1);assert.equal(channel.connected,false);
|
||||
});
|
||||
test('close waits original listen settlement and never sends late readiness',async()=>{
|
||||
const channel=new Channel();let releaseListen,closed=0,finished=false;
|
||||
const handle=runProductionProcess(async()=>({listen:()=>new Promise(resolve=>{releaseListen=resolve;}),close:async()=>closed++}),channel);
|
||||
channel.emit('message',start());await tick();const closing=handle.close().then(()=>{finished=true;});await tick();
|
||||
assert.equal(closed,1);assert.equal(finished,false);assert.equal(channel.connected,true);
|
||||
releaseListen(43100);await closing;assert.equal(channel.connected,false);assert.equal(channel.sent.length,0);
|
||||
});
|
||||
test('private v1 IPC requires matching durable receipt before factory continues',async()=>{
|
||||
const channel=new Channel(),message=start();let callbacks,closed=0;
|
||||
runProductionProcess(async(_config,value)=>{callbacks=value;return {authMode:'basic',credentials:{username:'safetynet-'+'a'.repeat(32),password:'b'.repeat(43)},listen:async()=>43100,close:async()=>closed++};},channel);
|
||||
channel.emit('message',message);await tick();assert.equal(channel.sent[0].type,'ready');
|
||||
const event={eventId:randomUUID(),kind:'upload.authorized',ruleId:'broker-approved',policyVersion:1,request:{bytes:3,detectedType:'text'}};
|
||||
let completed=false;const pending=callbacks.persistAudit(event).then(()=>completed=true);await tick();assert.equal(completed,false);const request=channel.sent[1];assert.equal(request.type,'audit');
|
||||
channel.emit('message',{type:'audit-receipt',version:1,generation:message.generation,policyVersion:1,requestId:request.requestId,sequence:request.sequence,receipt:{persisted:true,eventId:event.eventId}});await pending;
|
||||
channel.emit('message',{type:'stop',version:1,generation:message.generation,policyVersion:1});await tick();assert.equal(closed,1);
|
||||
});
|
||||
test('wrong generation or uncertain receipt closes child with no automatic retry',async()=>{
|
||||
const channel=new Channel(),message=start();let callbacks;
|
||||
runProductionProcess(async(_config,value)=>{callbacks=value;return {authMode:'basic',credentials:{username:'safetynet-'+'a'.repeat(32),password:'b'.repeat(43)},listen:async()=>43100,close:async()=>{}};},channel);
|
||||
channel.emit('message',message);await tick();const pending=callbacks.acknowledgeCertificate({authority:'example.com:443',cert:'public certificate'});const rejected=assert.rejects(pending,/broker-parent-unavailable/);const request=channel.sent[1];
|
||||
channel.emit('message',{type:'certificate-receipt',version:1,generation:randomUUID(),policyVersion:1,requestId:request.requestId,sequence:request.sequence,accepted:true});await rejected;await tick();assert.equal(channel.connected,false);assert.equal(channel.sent.length,2);
|
||||
});
|
||||
test('real factory rejects untrusted paths and unsupported signed proxy before inspector launch',async()=>{
|
||||
const keys=generateKeyPairSync('ed25519'),p=defaultPolicy();p.assignment={deviceId:'device',groups:[]};
|
||||
const config={publicKey:keys.publicKey.export({type:'spki',format:'pem'}),deviceId:'device',minimumVersion:1,envelope:signPolicy(p,keys.privateKey.export({type:'pkcs8',format:'pem'})),brokerPort:43100,network:{mode:'direct',dns:'system'}};
|
||||
const callbacks={persistAudit:async()=>{},acknowledgeCertificate:async()=>true,expectedPolicyVersion:1};
|
||||
await assert.rejects(createProductionBroker({...config,resourceRoot:'C:/arbitrary'},callbacks),/broker-bootstrap-invalid/);
|
||||
await assert.rejects(createProductionBroker(config,callbacks),/broker-route-unsupported/);
|
||||
await assert.rejects(createProductionBroker({...config,network:{mode:'system',dns:'system'}},callbacks),/broker-bootstrap-invalid/);
|
||||
});
|
||||
test('audit callback cannot mutate frozen request causation',async()=>{
|
||||
const eventId=randomUUID();let event;
|
||||
const audit=createBrokerAudit({current:()=>true,policyVersion:1,persist:async value=>{event=value;assert.throws(()=>value.broker.requestId=eventId,TypeError);return {persisted:true,eventId:value.eventId};}});
|
||||
const requestId=randomUUID();await audit.record('allow',{bytes:3,detectedType:'text',broker:{requestId,phase:'request.authorized',reason:'broker-approved'}});assert.equal(event.broker.requestId,requestId);
|
||||
});
|
||||
|
||||
test('protected deployment CA bundle rejects noncertificate payload and preserves default roots',()=>{
|
||||
const {deploymentRoots}=require('../apps/network-broker/production-factory.cjs'),fs=require('node:fs'),tls=require('node:tls');const cert=fs.readFileSync(new URL('./fixtures/icap/cert.pem',import.meta.url),'utf8');
|
||||
assert.equal(deploymentRoots(undefined),undefined);assert.equal(deploymentRoots(cert).length,tls.rootCertificates.length+1);
|
||||
for(const value of [cert+'\nPRIVATE PAYLOAD',cert+'\n-----BEGIN PRIVATE KEY-----\nsecret\n-----END PRIVATE KEY-----',Array(33).fill(cert).join('\n'),'-----BEGIN CERTIFICATE-----\nbroken\n-----END CERTIFICATE-----'])assert.throws(()=>deploymentRoots(value),/broker-bootstrap-invalid/);
|
||||
});
|
||||
|
||||
test('startup diagnosis is one bounded fixed stage without original exception detail',async()=>{
|
||||
const channel=new Channel();runProductionProcess(async()=>{throw Error('broker-startup-transport');},channel);channel.emit('message',start());await tick();assert.equal(channel.sent.length,1);assert.equal(channel.sent[0].type,'startup-fault');assert.equal(channel.sent[0].code,'broker-startup-transport');assert.equal(channel.connected,false);
|
||||
const unsafe=new Channel();runProductionProcess(async()=>{throw Error('private certificate secret');},unsafe);unsafe.emit('message',start());await tick();assert.equal(unsafe.sent[0].code,'broker-startup-failed');assert.equal(JSON.stringify(unsafe.sent).includes('private certificate'),false);
|
||||
});
|
||||
|
||||
test('additive deployment roots satisfy actual strict HTTPS transport construction',()=>{
|
||||
const {deploymentRoots}=require('../apps/network-broker/production-factory.cjs'),{createHttpsForwarder}=require('../apps/network-broker/https-forwarder.cjs'),fs=require('node:fs');
|
||||
const roots=deploymentRoots(fs.readFileSync(new URL('./fixtures/icap/cert.pem',import.meta.url),'utf8'));
|
||||
assert.equal(typeof createHttpsForwarder({authority:'current',current:()=>true,resolveRoute:async()=>({approved:true,address:'127.0.0.1',port:443}),ca:roots.join('\n')}),'function');
|
||||
});
|
||||
|
||||
test('signed proxy and protected network mode cannot silently diverge or bypass',async()=>{
|
||||
const keys=generateKeyPairSync('ed25519'),p=defaultPolicy();p.assignment={deviceId:'device',groups:[]};p.proxy={mode:'fixed_servers',rules:'http://proxy.example',bypass:'*'};
|
||||
const config={publicKey:keys.publicKey.export({type:'spki',format:'pem'}),deviceId:'device',minimumVersion:1,envelope:signPolicy(p,keys.privateKey.export({type:'pkcs8',format:'pem'})),brokerPort:43100,network:{mode:'fixed_servers',dns:'system',credentials:{username:'synthetic',password:'synthetic-password'}}},callbacks={persistAudit:async()=>{},acknowledgeCertificate:async()=>true,expectedPolicyVersion:1};
|
||||
await assert.rejects(createProductionBroker(config,callbacks),/broker-route-unsupported/);await assert.rejects(createProductionBroker({...config,network:{mode:'direct',dns:'system'}},callbacks),/broker-route-unsupported/);
|
||||
await assert.rejects(createProductionBroker({...config,network:{...config.network,credentials:{username:'bad:username',password:'synthetic'}}},callbacks),/broker-bootstrap-invalid/);
|
||||
});
|
||||
|
||||
test('protected additive corporate CA bundles satisfy both strict transport constructors',()=>{
|
||||
const {deploymentRoots}=require('../apps/network-broker/production-factory.cjs'),{createProxyForwarder}=require('../apps/network-broker/proxy-forwarder.cjs'),fs=require('node:fs');const ca=deploymentRoots(fs.readFileSync(new URL('./fixtures/icap/cert.pem',import.meta.url),'utf8')).join('\n');
|
||||
assert.equal(typeof createProxyForwarder({authority:'current',current:()=>true,proxyPolicy:{mode:'fixed_servers',rules:'https://proxy.example:443',bypass:''},resolveProxyRoute:async()=>({approved:true,address:'127.0.0.1',port:443}),ca,proxyCa:ca}),'function');
|
||||
});
|
||||
test('unsupported secure DNS fails before inspector startup',async()=>{
|
||||
const keys=generateKeyPairSync('ed25519');
|
||||
for(const proxy of [false]){const p=defaultPolicy();p.assignment={deviceId:'device',groups:[]};p.enterprise.dns={mode:'secure',templates:['https://resolver.example/dns-query']};
|
||||
const config={publicKey:keys.publicKey.export({type:'spki',format:'pem'}),deviceId:'device',minimumVersion:1,envelope:signPolicy(p,keys.privateKey.export({type:'pkcs8',format:'pem'})),brokerPort:43100,network:{mode:proxy?'fixed_servers':'direct',dns:'system'}};
|
||||
await assert.rejects(createProductionBroker(config,{persistAudit:async()=>{},acknowledgeCertificate:async()=>true,expectedPolicyVersion:1}),/broker-route-unsupported/);
|
||||
}
|
||||
});
|
||||
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user