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
@@ -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};
|
||||
Reference in new issue
Block a user