From 7b5057901af3366d04092b0b51d809dd1ec3582a Mon Sep 17 00:00:00 2001 From: Karl Date: Wed, 14 Oct 2026 14:18:40 +0200 Subject: [PATCH] Add packaged DLP broker and protected Windows service foundation --- README.md | 34 +- apps/browser/broker-audit-persistence.cjs | 40 + apps/browser/broker-certificate-pins.cjs | 55 + apps/browser/broker-service-supervisor.cjs | 34 + apps/browser/broker-supervisor.cjs | 111 + apps/browser/diagnostics.cjs | 2 + apps/browser/dpapi.cjs | 48 +- apps/browser/main.cjs | 5 +- apps/inspector/dpapi.py | 11 +- apps/inspector/provision_runtime_acl.py | 20 +- apps/network-broker/audit-gate.cjs | 73 + apps/network-broker/bounded-audit.cjs | 13 + .../connect-inspection-proxy.cjs | 64 + apps/network-broker/diagnostics-adapter.cjs | 28 + apps/network-broker/dns-policy-route.cjs | 24 + .../network-broker/download-response-gate.cjs | 46 + apps/network-broker/framed-channel.cjs | 68 + apps/network-broker/http-request-gate.cjs | 109 + apps/network-broker/http-response-gate.cjs | 37 + apps/network-broker/https-forwarder.cjs | 102 + .../https-inspection-server.cjs | 80 + apps/network-broker/lifecycle-audit.cjs | 56 + apps/network-broker/managed-proxy.cjs | 119 ++ apps/network-broker/management-credential.cjs | 49 + .../management-identity-operation.cjs | 69 + apps/network-broker/multipart.cjs | 92 + apps/network-broker/native/README.md | 147 ++ apps/network-broker/native/acceptance-state.h | 114 ++ .../native/management-credential.h | 276 +++ apps/network-broker/native/service-host.cpp | 1802 +++++++++++++++++ apps/network-broker/native/signed-manifest.h | 790 ++++++++ apps/network-broker/native/wfp-topology.h | 378 ++++ apps/network-broker/policy-authority.cjs | 38 + apps/network-broker/process-entry.cjs | 38 + apps/network-broker/production-entry.cjs | 63 + apps/network-broker/production-factory.cjs | 62 + apps/network-broker/proxy-forwarder.cjs | 78 + apps/network-broker/service-acceptance.cjs | 48 + apps/network-broker/service-bootstrap.cjs | 37 + apps/network-broker/service-commit-gate.cjs | 22 + apps/network-broker/service-entry.cjs | 33 + apps/network-broker/service-store-client.cjs | 62 + apps/network-broker/tls-authority.cjs | 76 + apps/network-broker/tls-certificates.cjs | 64 + apps/network-broker/wfp-revalidation.cjs | 32 + apps/network-broker/wfp-topology.cjs | 60 + apps/network-broker/windows-wfp-plan.cjs | 54 + apps/network-broker/windows_wfp_install.py | 241 +++ apps/network-broker/windows_wfp_probe.py | 126 ++ apps/network-broker/windows_wfp_receiver.py | 55 + apps/network-broker/windows_wfp_topology.py | 178 ++ apps/network-broker/work-admission.cjs | 30 + docs/broker-siem.md | 6 + docs/core-dlp-current-status.md | 443 ++++ docs/native-runtime/README.md | 83 + docs/requirements-audit.md | 20 +- docs/windows-dlp-mvp.md | 198 +- package.json | 19 + packages/core/broker-audit.cjs | 18 +- packages/core/encoded-documents.cjs | 75 + packages/core/siem.mjs | 4 +- packages/core/windows-trust.cjs | 71 + scripts/broker-package-contract.mjs | 95 + scripts/build-windows-service-host.mjs | 85 + scripts/install/windows-security.nsh | 14 + scripts/package-broker-resources.mjs | 69 + scripts/package.mjs | 77 +- scripts/provision-broker-runtime.mjs | 96 + scripts/stage-broker-payload.mjs | 55 + scripts/verified-installer.mjs | 30 + scripts/windows-package-plan.mjs | 38 + scripts/windows-release-inventory.mjs | 33 + tests/broker-audit-gate.test.mjs | 89 + .../broker-audit-persistence-native.test.mjs | 27 + tests/broker-audit-persistence.test.mjs | 40 + tests/broker-certificate-pins-native.test.mjs | 34 + tests/broker-certificate-pins.test.mjs | 39 + tests/broker-connect-inspection.test.mjs | 77 + tests/broker-diagnostics.test.mjs | 61 + tests/broker-dns-policy-route.test.mjs | 28 + tests/broker-download-response.test.mjs | 67 + tests/broker-electron-interop.test.mjs | 51 + tests/broker-framed-channel.test.mjs | 74 + tests/broker-http-request-gate.test.mjs | 104 + tests/broker-http-response-gate.test.mjs | 15 + tests/broker-https-forwarder.test.mjs | 92 + tests/broker-https-inspection.test.mjs | 99 + tests/broker-managed-proxy.test.mjs | 173 ++ ...r-management-credential-lifecycle.test.mjs | 18 + tests/broker-management-credential.test.mjs | 40 + tests/broker-management-identity.test.mjs | 42 + tests/broker-multipart.test.mjs | 50 + tests/broker-native-diagnostics.test.mjs | 11 + tests/broker-native-inspection.test.mjs | 156 ++ tests/broker-package-contract.test.mjs | 85 + tests/broker-package-resources.test.mjs | 72 + tests/broker-payload-staging.test.mjs | 37 + tests/broker-policy-authority.test.mjs | 53 + tests/broker-process.test.mjs | 55 + tests/broker-production-entry.test.mjs | 97 + tests/broker-production-native.test.mjs | 59 + tests/broker-proxy-forwarder.test.mjs | 39 + tests/broker-runtime-manifest.test.mjs | 37 + tests/broker-service-acceptance.test.mjs | 75 + tests/broker-service-bootstrap.test.mjs | 70 + tests/broker-service-entry.test.mjs | 55 + tests/broker-service-package.test.mjs | 28 + tests/broker-service-store.test.mjs | 50 + tests/broker-service-supervisor.test.mjs | 62 + tests/broker-siem.test.mjs | 11 + tests/broker-supervisor-native.test.mjs | 29 + tests/broker-supervisor.test.mjs | 66 + tests/broker-tls-authority.test.mjs | 42 + tests/broker-tls-certificates.test.mjs | 40 + tests/broker-wfp-listener-plan.test.mjs | 15 + tests/broker-wfp-plan.test.mjs | 58 + tests/broker-wfp-revalidation.test.mjs | 9 + tests/broker-wfp-topology.test.mjs | 13 + tests/broker-work-admission.test.mjs | 49 + tests/dpapi-helper-native.test.mjs | 38 + tests/dpapi-helper.test.mjs | 36 + tests/encoded-documents.test.mjs | 52 + .../broker-certificate-pins-electron.cjs | 23 + tests/fixtures/broker-durable-audit.cjs | 17 + tests/fixtures/broker-electron-interop.cjs | 42 + .../fixtures/broker-inspector-environment.cjs | 19 + tests/fixtures/broker-native-diagnostics.cjs | 29 + tests/fixtures/broker-process-child.cjs | 19 + tests/fixtures/broker-service-stdio-child.cjs | 17 + tests/installer-acl-hook-native.test.mjs | 44 + tests/office-stage-otlp-native.test.mjs | 35 + tests/request-audit.test.mjs | 9 + tests/test_windows_wfp_install.py | 121 ++ tests/test_windows_wfp_probe.py | 75 + tests/test_windows_wfp_receiver.py | 59 + tests/test_windows_wfp_topology.py | 71 + tests/verified-installer.test.mjs | 38 + tests/windows-package-plan.test.mjs | 42 + tests/windows-release-inventory.test.mjs | 29 + tests/windows-trust.test.mjs | 45 + 140 files changed, 11215 insertions(+), 60 deletions(-) create mode 100644 apps/browser/broker-audit-persistence.cjs create mode 100644 apps/browser/broker-certificate-pins.cjs create mode 100644 apps/browser/broker-service-supervisor.cjs create mode 100644 apps/browser/broker-supervisor.cjs create mode 100644 apps/network-broker/audit-gate.cjs create mode 100644 apps/network-broker/bounded-audit.cjs create mode 100644 apps/network-broker/connect-inspection-proxy.cjs create mode 100644 apps/network-broker/diagnostics-adapter.cjs create mode 100644 apps/network-broker/dns-policy-route.cjs create mode 100644 apps/network-broker/download-response-gate.cjs create mode 100644 apps/network-broker/framed-channel.cjs create mode 100644 apps/network-broker/http-request-gate.cjs create mode 100644 apps/network-broker/http-response-gate.cjs create mode 100644 apps/network-broker/https-forwarder.cjs create mode 100644 apps/network-broker/https-inspection-server.cjs create mode 100644 apps/network-broker/lifecycle-audit.cjs create mode 100644 apps/network-broker/managed-proxy.cjs create mode 100644 apps/network-broker/management-credential.cjs create mode 100644 apps/network-broker/management-identity-operation.cjs create mode 100644 apps/network-broker/multipart.cjs create mode 100644 apps/network-broker/native/README.md create mode 100644 apps/network-broker/native/acceptance-state.h create mode 100644 apps/network-broker/native/management-credential.h create mode 100644 apps/network-broker/native/service-host.cpp create mode 100644 apps/network-broker/native/signed-manifest.h create mode 100644 apps/network-broker/native/wfp-topology.h create mode 100644 apps/network-broker/policy-authority.cjs create mode 100644 apps/network-broker/process-entry.cjs create mode 100644 apps/network-broker/production-entry.cjs create mode 100644 apps/network-broker/production-factory.cjs create mode 100644 apps/network-broker/proxy-forwarder.cjs create mode 100644 apps/network-broker/service-acceptance.cjs create mode 100644 apps/network-broker/service-bootstrap.cjs create mode 100644 apps/network-broker/service-commit-gate.cjs create mode 100644 apps/network-broker/service-entry.cjs create mode 100644 apps/network-broker/service-store-client.cjs create mode 100644 apps/network-broker/tls-authority.cjs create mode 100644 apps/network-broker/tls-certificates.cjs create mode 100644 apps/network-broker/wfp-revalidation.cjs create mode 100644 apps/network-broker/wfp-topology.cjs create mode 100644 apps/network-broker/windows-wfp-plan.cjs create mode 100644 apps/network-broker/windows_wfp_install.py create mode 100644 apps/network-broker/windows_wfp_probe.py create mode 100644 apps/network-broker/windows_wfp_receiver.py create mode 100644 apps/network-broker/windows_wfp_topology.py create mode 100644 apps/network-broker/work-admission.cjs create mode 100644 docs/core-dlp-current-status.md create mode 100644 packages/core/encoded-documents.cjs create mode 100644 packages/core/windows-trust.cjs create mode 100644 scripts/broker-package-contract.mjs create mode 100644 scripts/build-windows-service-host.mjs create mode 100644 scripts/install/windows-security.nsh create mode 100644 scripts/package-broker-resources.mjs create mode 100644 scripts/provision-broker-runtime.mjs create mode 100644 scripts/stage-broker-payload.mjs create mode 100644 scripts/verified-installer.mjs create mode 100644 scripts/windows-package-plan.mjs create mode 100644 scripts/windows-release-inventory.mjs create mode 100644 tests/broker-audit-gate.test.mjs create mode 100644 tests/broker-audit-persistence-native.test.mjs create mode 100644 tests/broker-audit-persistence.test.mjs create mode 100644 tests/broker-certificate-pins-native.test.mjs create mode 100644 tests/broker-certificate-pins.test.mjs create mode 100644 tests/broker-connect-inspection.test.mjs create mode 100644 tests/broker-diagnostics.test.mjs create mode 100644 tests/broker-dns-policy-route.test.mjs create mode 100644 tests/broker-download-response.test.mjs create mode 100644 tests/broker-electron-interop.test.mjs create mode 100644 tests/broker-framed-channel.test.mjs create mode 100644 tests/broker-http-request-gate.test.mjs create mode 100644 tests/broker-http-response-gate.test.mjs create mode 100644 tests/broker-https-forwarder.test.mjs create mode 100644 tests/broker-https-inspection.test.mjs create mode 100644 tests/broker-managed-proxy.test.mjs create mode 100644 tests/broker-management-credential-lifecycle.test.mjs create mode 100644 tests/broker-management-credential.test.mjs create mode 100644 tests/broker-management-identity.test.mjs create mode 100644 tests/broker-multipart.test.mjs create mode 100644 tests/broker-native-diagnostics.test.mjs create mode 100644 tests/broker-native-inspection.test.mjs create mode 100644 tests/broker-package-contract.test.mjs create mode 100644 tests/broker-package-resources.test.mjs create mode 100644 tests/broker-payload-staging.test.mjs create mode 100644 tests/broker-policy-authority.test.mjs create mode 100644 tests/broker-process.test.mjs create mode 100644 tests/broker-production-entry.test.mjs create mode 100644 tests/broker-production-native.test.mjs create mode 100644 tests/broker-proxy-forwarder.test.mjs create mode 100644 tests/broker-runtime-manifest.test.mjs create mode 100644 tests/broker-service-acceptance.test.mjs create mode 100644 tests/broker-service-bootstrap.test.mjs create mode 100644 tests/broker-service-entry.test.mjs create mode 100644 tests/broker-service-package.test.mjs create mode 100644 tests/broker-service-store.test.mjs create mode 100644 tests/broker-service-supervisor.test.mjs create mode 100644 tests/broker-supervisor-native.test.mjs create mode 100644 tests/broker-supervisor.test.mjs create mode 100644 tests/broker-tls-authority.test.mjs create mode 100644 tests/broker-tls-certificates.test.mjs create mode 100644 tests/broker-wfp-listener-plan.test.mjs create mode 100644 tests/broker-wfp-plan.test.mjs create mode 100644 tests/broker-wfp-revalidation.test.mjs create mode 100644 tests/broker-wfp-topology.test.mjs create mode 100644 tests/broker-work-admission.test.mjs create mode 100644 tests/dpapi-helper-native.test.mjs create mode 100644 tests/dpapi-helper.test.mjs create mode 100644 tests/encoded-documents.test.mjs create mode 100644 tests/fixtures/broker-certificate-pins-electron.cjs create mode 100644 tests/fixtures/broker-durable-audit.cjs create mode 100644 tests/fixtures/broker-electron-interop.cjs create mode 100644 tests/fixtures/broker-inspector-environment.cjs create mode 100644 tests/fixtures/broker-native-diagnostics.cjs create mode 100644 tests/fixtures/broker-process-child.cjs create mode 100644 tests/fixtures/broker-service-stdio-child.cjs create mode 100644 tests/installer-acl-hook-native.test.mjs create mode 100644 tests/office-stage-otlp-native.test.mjs create mode 100644 tests/test_windows_wfp_install.py create mode 100644 tests/test_windows_wfp_probe.py create mode 100644 tests/test_windows_wfp_receiver.py create mode 100644 tests/test_windows_wfp_topology.py create mode 100644 tests/verified-installer.test.mjs create mode 100644 tests/windows-package-plan.test.mjs create mode 100644 tests/windows-release-inventory.test.mjs create mode 100644 tests/windows-trust.test.mjs diff --git a/README.md b/README.md index 40848c2..5c661aa 100644 --- a/README.md +++ b/README.md @@ -11,20 +11,29 @@ SafetyNet combines an Electron/Chromium desktop browser, an administrator web co The current milestone is the [Windows DLP MVP](docs/windows-dlp-mvp.md): enforce confidential-content checks before browser clipboard release and outbound text or file transmission, with central policy and local semantic inspection. -Native public/blocked transfer and restart fixtures pass. Final acceptance is -still open: a reproducible Chromium WebRTC renderer crash in initial blank -iframes requires a verified native fix. Other platforms and broader features -remain outside this milestone. +S Browser uses official, unchanged Electron/Chromium releases. It does not require +a custom Chromium build or changes to installed Chrome. Browser, DLP broker, +private runtimes, scanners and models are intended to ship in one Windows +installer; the new broker component package is verified separately. + +Final acceptance is still open: the main browser must be connected to the +protected Windows service, with verified installation and network enforcement. +A reproducible WebRTC renderer crash remains; the existing restriction stays +until its replacement is tested. See the [current acceptance matrix](docs/core-dlp-current-status.md) +for completed component tests and remaining integration work. Other platforms +and broader features remain outside this milestone. [Native transport auditing](docs/native-transport-observability.md) records tested WebTransport lifecycle attempts without exposing raw browser debugging -APIs. Its observation channel preserves clipboard protection; unsupported -transport payloads remain blocked by the native relay boundary. +APIs. Its observation channel preserves clipboard protection. These tests do +not establish the new Windows network boundary or complete transport coverage. [Bounded diagnostics](docs/diagnostics-backpressure.md) preserve authorized exports through policy-version updates and cancel them when permissions change. -Current native clipboard, upload, restart, local Gemma and transport fixtures -pass with zero lifetime export failures; historical failures remain documented. +Controlled clipboard, upload, restart, local Gemma and transport fixtures have +export evidence; historical failures remain documented. Production broker +diagnostics, durable audit delivery and final installer acceptance remain separate +requirements. S Browser uses translucent materials, rounded controls, light/dark themes, and a content-first layout inspired by Safari. Website content is left unchanged. @@ -194,7 +203,14 @@ npm run package:win npm run verify:package ``` -The packaging workflow produces an unpacked evaluation build in `dist/win-unpacked`. Distribute the complete folder, not the executable alone. Code signing, a production installer and a trusted update service remain unfinished. +The existing evaluation build is an unpacked application in `dist/win-unpacked`. +The required release deliverable is one Windows installer containing the browser, +its separate DLP broker and private runtime, local scanners and semantic models. +It must require no separately installed Node/Python or manual broker startup. +That complete installation and automatic browser-to-broker integration are still +being implemented and verified. An unpacked folder or broker payload alone does +not prove this deliverable. Code signing and a trusted update service remain +unfinished. ## Current boundaries diff --git a/apps/browser/broker-audit-persistence.cjs b/apps/browser/broker-audit-persistence.cjs new file mode 100644 index 0000000..3d212b6 --- /dev/null +++ b/apps/browser/broker-audit-persistence.cjs @@ -0,0 +1,40 @@ +'use strict'; +const {BrokerAuditEventSchema}=require('../../packages/core/broker-audit.cjs'); +// Parent-only adapter. persistQueue must synchronously encrypt, write, fsync and +// commit the complete queue, return undefined on success and throw on failure. +// An OTLP send, boolean result or asynchronous enqueue is insufficient. +// Bind current() to the authenticated child and the accepted policy generation. +function createBrokerAuditPersistence({current,policyVersion,getQueue,persistQueue,onFailure}={}){ + if(![current,getQueue,persistQueue,onFailure].every(fn=>typeof fn==='function')||!Number.isSafeInteger(policyVersion)||policyVersion<1)throw Error('broker-audit-parent-invalid'); + let failed=false; + const fail=()=>{const first=!failed;failed=true;if(first)try{onFailure();}catch{}throw Error('broker-audit-parent-unavailable');}; + const active=()=>{try{return !failed&¤t()===true;}catch{return false;}}; + return Object.freeze({ + persist(input){ + if(!active())return fail(); + let event,queue; + try{ + let nodes=0; + const plain=(value,depth=0)=>{ + if(++nodes>128||depth>6)return false; + if(!value||typeof value!=='object')return true; + if(![Object.prototype,null].includes(Object.getPrototypeOf(value)))return false; + return Object.values(Object.getOwnPropertyDescriptors(value)).every(item=>'value'in item&&plain(item.value,depth+1)); + }; + if(!input||typeof input!=='object'||!plain(input))return fail(); + event=BrokerAuditEventSchema.parse(input);queue=getQueue(); + if(event.policyVersion!==policyVersion||!Array.isArray(queue)||queue.length>=10000||queue.some(item=>item.eventId===event.eventId))return fail(); + Object.freeze(event.request);if(event.file)Object.freeze(event.file);if(event.broker)Object.freeze(event.broker);Object.freeze(event); + queue.push(event); + const result=persistQueue(); + // Fail closed for an accidentally asynchronous writer; suppress its rejected + // promise without issuing a receipt or retrying uncertain persistence. + if(result&&typeof result.then==='function'){Promise.resolve(result).catch(()=>{});return fail();} + if(result!==undefined)return fail(); + if(!active())return fail(); + return Object.freeze({persisted:true,eventId:event.eventId}); + }catch{return fail();} + } + }); +} +module.exports={createBrokerAuditPersistence}; diff --git a/apps/browser/broker-certificate-pins.cjs b/apps/browser/broker-certificate-pins.cjs new file mode 100644 index 0000000..968f335 --- /dev/null +++ b/apps/browser/broker-certificate-pins.cjs @@ -0,0 +1,55 @@ +'use strict'; +const {X509Certificate}=require('node:crypto'); +const {isIP}=require('node:net'); +const fail=()=>{throw Error('broker-certificate-pin-rejected');}; +function authorityFacts(value){ + if(typeof value!=='string'||value.length>320||/[^\x21-\x7e]|[@/\\?#%]/.test(value))fail(); + const match=/^(\[[^\]]+\]|[^:]+):([1-9][0-9]{0,4})$/.exec(value);if(!match||Number(match[2])>65535)fail(); + let url;try{url=new URL('https://'+value+'/');}catch{fail();} + const hostname=url.hostname.startsWith('[')?url.hostname.slice(1,-1):url.hostname; + if(!isIP(hostname)&&hostname.split('.').some(label=>!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)))fail(); + if(`${isIP(hostname)===6?'['+hostname+']':hostname}:${Number(match[2])}`!==value)fail(); + return Object.freeze({hostname,authority:value}); +} +/** For one managed Electron session only. Electron's verifier has NO port and + * caches results: CONNECT/SNI/inner-Host gates must still bind the port. Revoke + * must also close existing connections/stop the old broker before replacement. + * Management sessions retain the platform verifier and receive no such pins. + */ +function createBrokerCertificatePins({generation,policyVersion,current,authorizedAuthority,maxPins=128}={}){ + if(typeof generation!=='string'||!generation||!Number.isSafeInteger(policyVersion)||policyVersion<1||typeof current!=='function'||typeof authorizedAuthority!=='function'||!Number.isInteger(maxPins)||maxPins<1||maxPins>1024)fail(); + let revoked=false;const pins=new Map(); + const active=()=>{try{return !revoked&¤t()===true;}catch{return false;}}; + const authorized=authority=>{try{return authorizedAuthority(authority)===true;}catch{return false;}}; + return Object.freeze({ + accept(input){ + if(!active()||!input||input.generation!==generation||input.policyVersion!==policyVersion||typeof input.cert!=='string'||Buffer.byteLength(input.cert)>16384||!/^-----BEGIN CERTIFICATE-----\r?\n[A-Za-z0-9+/=\r\n]+\r?\n-----END CERTIFICATE-----\s*$/.test(input.cert))fail(); + const facts=authorityFacts(input.authority);if(!authorized(facts.authority))fail(); + let certificate;try{certificate=new X509Certificate(input.cert);}catch{fail();} + const now=Date.now(),until=Date.parse(certificate.validTo),from=Date.parse(certificate.validFrom); + const matches=isIP(facts.hostname)?certificate.checkIP(facts.hostname):certificate.checkHost(facts.hostname,{subject:'never',wildcards:false}); + if(certificate.ca||!certificate.subjectAltName||certificate.subjectAltName.includes(',')||!matches||!certificate.verify(certificate.publicKey)||!Number.isFinite(until)||!Number.isFinite(from)||from>now||until<=now||until>now+3601000||input.fingerprint256!==certificate.fingerprint256)fail(); + for(const [key,pin]of pins)if(pin.expiresAt<=now)pins.delete(key); + const key=facts.authority; + if(!pins.has(key)&&pins.size>=maxPins)fail(); + if(!active()||!authorized(key))fail(); + pins.set(key,Object.freeze({...facts,fingerprint:certificate.fingerprint256,expiresAt:until}));return true; + }, + verify(request,callback){ + let result=-3; + try{ + // Electron exposes Certificate.data/fingerprint, not Node's fingerprint256. + // Derive our fingerprint from the actual presented PEM, never a claim. + const data=request?.certificate?.data,host=request?.hostname; + if(typeof data!=='string'||Buffer.byteLength(data)>16384)throw Error('certificate-data-unavailable'); + const fingerprint=new X509Certificate(data).fingerprint256; + const match=[...pins.values()].find(pin=>pin.hostname===host&&pin.fingerprint===fingerprint); + if(match)result=active()&&match.expiresAt>Date.now()&&authorized(match.authority)?0:-2; + }catch{result=-3;} + callback(result); + }, + revoke(){revoked=true;}, + status(){return Object.freeze({active:active(),pins:pins.size});} + }); +} +module.exports={createBrokerCertificatePins,authorityFacts}; diff --git a/apps/browser/broker-service-supervisor.cjs b/apps/browser/broker-service-supervisor.cjs new file mode 100644 index 0000000..002f9b5 --- /dev/null +++ b/apps/browser/broker-service-supervisor.cjs @@ -0,0 +1,34 @@ +'use strict'; +const path=require('node:path'); +const {spawn}=require('node:child_process'); +const {EventEmitter}=require('node:events'); +const {createBrokerSupervisor}=require('./broker-supervisor.cjs'); +const {createFramedChannel}=require('../network-broker/framed-channel.cjs'); +// Privileged browser construction only. Installation verification covers the +// fixed native client as well as the signed broker payload. No renderer-supplied +// command, path, pipe name, environment or identity enters this transport. +function createServiceBrokerSupervisor({installationRoot,verifyInstallation,verifyPackage,persistAudit,acceptCertificate,onQuarantine,launch=spawn,deadlineMs,shutdownMs,traceOperation}={}){ + if(!path.isAbsolute(installationRoot||'')||typeof verifyInstallation!=='function'||typeof launch!=='function'||typeof verifyPackage!=='function')throw Error('broker-supervisor-invalid'); + const root=path.resolve(installationRoot),resourceRoot=path.join(root,'resources','broker'); + function connect(){ + const env={};for(const name of ['SystemRoot','WINDIR','TEMP','TMP','USERPROFILE','LOCALAPPDATA','APPDATA'])if(process.env[name])env[name]=process.env[name]; + // --client-stdio authenticates the retained actual browser parent and the + // connected service pipe handle in native code, before forwarding frames. + const child=launch(path.join(root,'safetynet-service-host.exe'),['--client-stdio'],{cwd:root,env,windowsHide:true,shell:false,stdio:['pipe','pipe','ignore']}); + if(!child||!child.stdin||!child.stdout||typeof child.kill!=='function')throw Error('broker-supervisor-unavailable'); + const transport=new EventEmitter(); + const channel=createFramedChannel({readable:child.stdout,writable:child.stdin,inboundFirstLimit:65536,outboundFirstLimit:2097152}); + Object.defineProperties(transport,{exitCode:{get:()=>child.exitCode},signalCode:{get:()=>child.signalCode}}); + channel.on('message',message=>transport.emit('message',message)); + channel.once('disconnect',()=>transport.emit('disconnect')); + child.once('error',()=>{channel.disconnect();transport.emit('error',Error('broker-supervisor-unavailable'));}); + child.once('exit',(code,signal)=>{channel.disconnect();transport.emit('exit',code,signal);}); + transport.send=(message,callback)=>channel.send(message,callback); + // Only the retained child handle is terminated. The service owns the broker + // Job; losing this connection causes its generation to close independently. + transport.kill=signal=>{channel.disconnect();return child.kill(signal);}; + return transport; + } + return createBrokerSupervisor({resourceRoot,verifyPackage:async location=>await verifyInstallation(root)===true&&await verifyPackage(location)===true,persistAudit,acceptCertificate,onQuarantine,spawn:connect,deadlineMs,shutdownMs,traceOperation}); +} +module.exports={createServiceBrokerSupervisor}; diff --git a/apps/browser/broker-supervisor.cjs b/apps/browser/broker-supervisor.cjs new file mode 100644 index 0000000..5dd758a --- /dev/null +++ b/apps/browser/broker-supervisor.cjs @@ -0,0 +1,111 @@ +'use strict'; +const {fork}=require('node:child_process'); +const path=require('node:path'); +const {randomUUID,X509Certificate}=require('node:crypto'); +const UUID=/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/; +const ENTRY='apps/network-broker/production-entry.cjs'; +const STARTUP_CODES=new Set(['broker-bootstrap-invalid','broker-route-unsupported','broker-startup-inspector','broker-startup-certificate','broker-startup-transport','broker-startup-managed','broker-startup-failed']); +// Parent-only: resourceRoot and verifyPackage come from the trusted embedded +// installer contract. Never accept executable/module paths from renderer IPC. +function createBrokerSupervisor({resourceRoot,verifyPackage,persistAudit,acceptCertificate,onQuarantine,spawn=fork,deadlineMs=15000,shutdownMs=5000,traceOperation=(_name,_attributes,run)=>run()}={}){ + if(!path.isAbsolute(resourceRoot||'')||![verifyPackage,persistAudit,acceptCertificate,onQuarantine,spawn].every(f=>typeof f==='function')||!Number.isInteger(deadlineMs)||deadlineMs<1||deadlineMs>60000)throw Error('broker-supervisor-invalid'); + if(!Number.isInteger(shutdownMs)||shutdownMs<1||shutdownMs>30000)throw Error('broker-supervisor-invalid'); + if(typeof traceOperation!=='function')throw Error('broker-supervisor-invalid'); + // Instrumentation may neither skip a protected operation nor replace its + // result, retry it, or invoke it twice. Only fixed stage names are exported. + async function stage(name,operation){ + let original,called=false; + const once=()=>{if(called)throw Error('broker-diagnostics-invalid');called=true;try{original=Promise.resolve(operation());}catch(error){original=Promise.reject(error);}original.catch(()=>{});return original;}; + try{await traceOperation(name,{component:'network'},once);}catch(error){if(called)throw error;return once();} + if(!called)once();return original; + } + let child,current,failed=false,closed=false,starting=false,timer,readyResolve,readyReject,lastSequence=0,stopping,pendingCertificates=0,startupCode; + const stopChild=()=>{ + clearTimeout(timer);if(stopping)return stopping; + const previous=child;if(!previous)return Promise.resolve(); + previous.removeAllListeners('message'); + stopping=stage('browser.broker.shutdown',()=>new Promise((resolve,reject)=>{ + let done=false; + const finish=()=>{if(done)return;done=true;clearTimeout(limit);child=undefined;resolve();}; + const limit=setTimeout(()=>{if(done)return;done=true;failed=true;try{onQuarantine('broker-shutdown-unconfirmed');}catch{}reject(Error('broker-shutdown-unconfirmed'));},shutdownMs); + previous.once('exit',finish); + // ChildProcess retains ownership state; no numeric PID/taskkill operation + // is performed after exit, avoiding a reused-PID tree kill. Native job-based + // descendant containment remains an installer/launcher prerequisite. + if(previous.exitCode!==null&&previous.exitCode!==undefined||previous.signalCode)finish(); + else try{previous.kill('SIGKILL');}catch{} + })); + // Fault callers do not await shutdown, but close() receives its real outcome. + stopping.catch(()=>{});return stopping; + }; + const fault=()=>{if(failed||closed)return;failed=true;stopChild();try{onQuarantine('broker-supervisor-unavailable');}catch{}readyReject?.(Error('broker-supervisor-unavailable'));}; + const send=value=>{if(!child||failed||closed)throw Error('broker-supervisor-unavailable');child.send(value,error=>{if(error)fault();});}; + const context=()=>({version:1,generation:current.generation,policyVersion:current.policyVersion}); + const exact=(m,keys)=>Object.keys(m).sort().join(',')===keys.sort().join(','); + async function message(m,owner,generation){ + if(owner!==child||generation!==current.generation)return; + try{ + const bytes=Buffer.byteLength(JSON.stringify(m)); + if(bytes>65536||!m||typeof m!=='object'||Array.isArray(m)||m.version!==1||m.generation!==current.generation||m.policyVersion!==current.policyVersion)throw Error(); + const common=['type','version','generation','policyVersion']; + if(m.type==='startup-fault'){ + if(current.ready||!exact(m,[...common,'code'])||!STARTUP_CODES.has(m.code))throw Error(); + // Bounded local pre-enrollment state only; no raw errors or unauthorised + // telemetry exporter. This never grants readiness or permits a retry. + startupCode=m.code;fault();return; + } + if(m.type==='ready'){ + if(m.sequence!==0||m.authMode!=='basic'||current.ready||!exact(m,[...common,'port','credentials','authMode','sequence'])||!Number.isInteger(m.port)||m.port!==current.port||!m.credentials||!exact(m.credentials,['username','password'])||!/^safetynet-[a-f0-9]{32}$/.test(m.credentials.username)||!/^[A-Za-z0-9_-]{43}$/.test(m.credentials.password))throw Error(); + current.ready=true;clearTimeout(timer);readyResolve(Object.freeze({generation:current.generation,policyVersion:current.policyVersion,port:m.port,credentials:Object.freeze({...m.credentials})}));return; + } + if(!UUID.test(m.requestId)||!Number.isSafeInteger(m.sequence)||m.sequence!==lastSequence+1)throw Error();lastSequence=m.sequence; + if(m.type==='audit'&&exact(m,[...common,'requestId','sequence','event'])){ + const receipt=persistAudit(m.event); + if(!receipt||receipt.persisted!==true||receipt.eventId!==m.event.eventId||receipt.then)throw Error(); + if(owner!==child||generation!==current.generation)throw Error(); + send({type:'audit-receipt',...context(),requestId:m.requestId,sequence:m.sequence,receipt});return; + } + if(m.type==='certificate'&&exact(m,[...common,'requestId','sequence','authority','cert'])){ + if(pendingCertificates>=16)throw Error(); + if(typeof m.authority!=='string'||m.authority.length>300||typeof m.cert!=='string'||m.cert.length>16384||!/^-----BEGIN CERTIFICATE-----/.test(m.cert))throw Error(); + const certificate=new X509Certificate(m.cert);if(certificate.ca)throw Error(); + pendingCertificates++; + const certificateDeadline=setTimeout(fault,shutdownMs); + let accepted; + // Admission remains occupied until the ORIGINAL callback settles even if + // the deadline quarantines. A timed-out operation cannot free capacity. + try{accepted=await acceptCertificate(Object.freeze({...context(),authority:m.authority,cert:m.cert,fingerprint256:certificate.fingerprint256}));} + finally{clearTimeout(certificateDeadline);pendingCertificates--;} + if(accepted!==true||owner!==child||generation!==current.generation)throw Error(); + send({type:'certificate-receipt',...context(),requestId:m.requestId,sequence:m.sequence,accepted:true});return; + } + throw Error(); + }catch{fault();} + } + return Object.freeze({ + async start({policyVersion,port,config}={}){ + if(failed||closed||child||starting||!Number.isSafeInteger(policyVersion)||policyVersion<1||!Number.isInteger(port)||port<1||port>65535)throw Error('broker-supervisor-invalid'); + starting=true; + try{ + if(await stage('browser.broker.verify',()=>verifyPackage(resourceRoot))!==true)throw Error(); + if(failed||closed)throw Error(); + current={generation:randomUUID(),policyVersion,port,ready:false};lastSequence=0; + const envelope={type:'start',...context(),config};if(Buffer.byteLength(JSON.stringify(envelope))>2097152)throw Error(); + // Allowlisted Windows runtime essentials only; inherited Node injection, + // proxy credentials and developer environment never reach the child. + const env={};for(const key of ['SystemRoot','WINDIR','TEMP','TMP','USERPROFILE','LOCALAPPDATA','APPDATA'])if(process.env[key])env[key]=process.env[key]; + const pending=new Promise((resolve,reject)=>{readyResolve=resolve;readyReject=reject;}); + const owner=spawn(path.join(resourceRoot,ENTRY),[],{execPath:path.join(resourceRoot,'node.exe'),cwd:resourceRoot,env,execArgv:[],windowsHide:true,stdio:['ignore','ignore','ignore','ipc'],serialization:'json'}); + child=owner;const generation=current.generation; + owner.on('message',m=>{void message(m,owner,generation);}); + for(const event of ['error','exit','disconnect'])owner.once(event,()=>{if(owner===child)fault();}); + timer=setTimeout(fault,deadlineMs);send(envelope);return await stage('browser.broker.start',()=>pending); + }catch{fault();throw Error('broker-supervisor-unavailable');} + }, + async close(){closed=true;readyReject?.(Error('broker-supervisor-closed'));await stopChild();}, + // Replacement is deliberately stop-and-new-supervisor: no automatic retry + // and no stale ready state or credentials survive a policy generation. + status(){return Object.freeze({ready:!!current?.ready&&!failed&&!closed,failed,closed,generation:current?.generation,startupCode});} + }); +} +module.exports={createBrokerSupervisor}; diff --git a/apps/browser/diagnostics.cjs b/apps/browser/diagnostics.cjs index 34c1dd4..96973e4 100644 --- a/apps/browser/diagnostics.cjs +++ b/apps/browser/diagnostics.cjs @@ -10,6 +10,8 @@ const {trace,ROOT_CONTEXT,SpanStatusCode,TraceFlags}=require('@opentelemetry/api const names=new Set(['browser.window','browser.network','browser.ipc','browser.agent','browser.management','browser.navigation','browser.dns','browser.request','browser.request.headers','browser.inspect.local','browser.inspect.enterprise','browser.download','browser.clipboard','browser.visual','browser.password.fill','browser.ai','browser.policy','browser.startup','browser.parser','browser.semantic','browser.yara','browser.icap','browser.tab','browser.security','browser.evidence']); const operations=new Set(['audit.deliver','evidence.observer.install','evidence.observer.drain','evidence.observer.dispose','policy.verify','policy.bookmarks','policy.agent-reconcile','policy.page-configure','policy.audit','evidence.capture','evidence.authorize','evidence.challenge','evidence.encrypt','evidence.send','evidence.ack','agent.request','agent.challenge','agent.decrypt','agent.transport','agent.authorize','agent.audit','agent.dispatch','agent.response','agent.project','agent.encrypt','library:organize-plan','library:organize-apply','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','state','authorize-setting','settings-update','window-control','chrome-layout','share-url','bookmarks-mutate','enroll','sso','navigate','back','forward','reload','ai','inspect-file','agent-connection','ai-panel','vault-panel','vault-status','vault-create','vault-unlock','vault-lock','vault-list','vault-save','vault-delete','vault-fill','vault-generate','tab-create','tab-activate','tab-close','tab-group','sessions','read','inspect','scroll','click','type','select','check','screenshot','copy','cut','paste','scan','download','sync','create','activate','close','group','policy','request','dns','parse','embed','yara','icap']); for(const operation of ['visual.probe','visual.capture','visual.inspect.local','visual.inspect.enterprise','visual.audit','visual.validate','visual.project','visual.release'])operations.add(operation); +for(const operation of ['broker.request','broker.framing','broker.buffer','broker.inspect','broker.authorize','broker.forward'])operations.add(operation); +for(const name of ['browser.broker.verify','browser.broker.start','browser.broker.shutdown'])names.add(name); const components=new Set(['browser','ipc','agent','management','navigation','network','dns','dlp','inspector','parser','semantic','yara','icap','download','clipboard','visual','vault','ai','policy','tabs']); for(const operation of ['text.local','text.enterprise'])operations.add(operation); for(const operation of ['history:list','history:go','window:state','window:control'])operations.add(operation); diff --git a/apps/browser/dpapi.cjs b/apps/browser/dpapi.cjs index c5b2a53..71b8312 100644 --- a/apps/browser/dpapi.cjs +++ b/apps/browser/dpapi.cjs @@ -1,20 +1,40 @@ const {app}=require('electron'); const {spawn}=require('node:child_process'); const path=require('node:path'); -function capabilityCrypto(operation,input){ - const root=path.join(__dirname,'../..'); - const python=app.isPackaged?path.join(process.resourcesPath,'python/python.exe'):path.join(root,'.runtime/windows/python/python.exe'); - const script=app.isPackaged?path.join(process.resourcesPath,'inspector/dpapi.py'):path.join(root,'apps/inspector/dpapi.py'); - if(!['protect','unprotect'].includes(operation)||Buffer.byteLength(input)>32768)return Promise.reject(Error('Capability rejected')); +function createCapabilityCrypto({packaged=false,resourcesPath,root=path.join(__dirname,'../..'),launch=spawn,environment=process.env}={}){ + const python=packaged?path.join(resourcesPath,'python/python.exe'):path.join(root,'.runtime/windows/python/python.exe'); + const script=packaged?path.join(resourcesPath,'inspector/dpapi.py'):path.join(root,'apps/inspector/dpapi.py'); + const active=new Set(); + return function convert(operation,input){ + const inputLimit=operation==='protect'?32768:65536; + if(!['protect','unprotect'].includes(operation)||!(typeof input==='string'||Buffer.isBuffer(input))||!Buffer.byteLength(input)||Buffer.byteLength(input)>inputLimit)return Promise.reject(Error('Capability rejected')); + if(active.size>=8)return Promise.reject(Error('Windows capability protection failed')); return new Promise((resolve,reject)=>{ - const environment={...process.env};for(const key of ['ADMIN_TOKEN','OIDC_CLIENT_SECRET','AI_API_KEY','INSPECTOR_TOKEN','SIEM_HTTP_TOKEN','SAFETYNET_PROXY_PASSWORD'])delete environment[key]; - const child=spawn(python,[script,operation],{windowsHide:true,stdio:['pipe','pipe','pipe'],env:environment});let output='',settled=false; - const timer=setTimeout(()=>{child.kill();fail();},5000); - function fail(){if(!settled){settled=true;clearTimeout(timer);reject(Error('Windows capability protection failed'));}} - child.on('error',fail);child.stdin.on('error',fail);child.stderr.on('data',()=>{}); - child.stdout.on('data',data=>{output+=data;if(output.length>65536){child.kill();fail();}}); - child.on('close',code=>{if(code!==0||!output)return fail();if(!settled){settled=true;clearTimeout(timer);resolve(output);}}); - child.stdin.end(input); + // Python startup hooks and inherited secrets must not enter a capability + // helper. Isolated mode also disables user-site and Python environment paths. + const env={};for(const key of ['SystemRoot','WINDIR','TEMP','TMP','USERPROFILE','LOCALAPPDATA','APPDATA'])if(environment[key])env[key]=environment[key]; + let child,timer,settled=false,terminal=false,bytes=0; + const output=Buffer.alloc(operation==='protect'?65536:32768),slot={};active.add(slot); + function fail(){ + if(settled)return;settled=true;clearTimeout(timer);output.fill(0); + // A rejected operation does not free the slot until actual child close. + if(child&&!terminal){try{child.kill();}catch{}child.stdin?.destroy?.();child.stdout?.destroy?.();child.stderr?.destroy?.();} + reject(Error('Windows capability protection failed')); + } + try{ + child=launch(python,['-I','-B',script,operation],{windowsHide:true,shell:false,stdio:['pipe','pipe','pipe'],env}); + child.once('close',code=>{ + terminal=true;active.delete(slot); + if(settled)return;if(code!==0||!bytes){fail();return;} + settled=true;clearTimeout(timer);const result=output.subarray(0,bytes).toString('utf8');output.fill(0);resolve(result); + }); + child.on('error',fail);child.stdin.on('error',fail);child.stdout.on('error',fail);child.stderr.on('error',fail);child.stderr.on('data',()=>{}); + child.stdout.on('data',data=>{if(settled)return;if(!Buffer.isBuffer(data)||bytes+data.length>output.length){fail();return;}data.copy(output,bytes);bytes+=data.length;}); + timer=setTimeout(fail,5000);child.stdin.end(input); + }catch{if(!child)active.delete(slot);fail();} }); + }; } -module.exports={capabilityCrypto}; +let sharedConverter; +function capabilityCrypto(operation,input){sharedConverter??=createCapabilityCrypto({packaged:app.isPackaged,resourcesPath:process.resourcesPath});return sharedConverter(operation,input);} +module.exports={capabilityCrypto,createCapabilityCrypto}; diff --git a/apps/browser/main.cjs b/apps/browser/main.cjs index fd38778..81a904e 100644 --- a/apps/browser/main.cjs +++ b/apps/browser/main.cjs @@ -102,6 +102,7 @@ let message='Enroll this browser to receive company policy.',syncTimer,localInsp const recentRequests=new Map(); const agentFile=()=>path.join(app.getPath('userData'),'agent-connection.json'); const agentGrants=require('./agent-grants.cjs'); +function protectAgentCapability(value){return diagnosticSpan('browser.credential',{operation:'credential.issue',component:'agent',bytes:Buffer.byteLength(value)},()=>require('./dpapi.cjs').capabilityCrypto('protect',value));} const agentActorStorage=new (require('node:async_hooks').AsyncLocalStorage)(); let agentServiceMode; function effectiveAgentPolicy(){if(!policy)return policy;const {grants}=agentGrants.reconcileGenerationFloor(policy.agents.grants||[],device?.agentGenerationFloor||{});return {...policy,agents:{...policy.agents,grants}};} @@ -121,7 +122,7 @@ async function pairIndividualAgent(principalId){ const grant=approvedAgentGrants().find(g=>g.id===principalId),initial=policy,service=agentService; if(!grant||!service||agentServiceMode!=='individual')throw Error('Agent grant unavailable'); const connection=service.issuePrincipal(grant.id,grant.generation,grant.expiresAt); - const credentialPath=await agentGrants.persistConnection({filePath:agentGrants.descriptorPath(app.getPath('userData'),grant.id),connection,protect:value=>require('./dpapi.cjs').capabilityCrypto('protect',value),isCurrent:()=>ready&&policy===initial&&agentService===service&&featureAllowed(managedSettings(),'agents')&&actorGrantAllowed({principalId:grant.id,generation:grant.generation},grant.scopes[0])}); + const credentialPath=await agentGrants.persistConnection({filePath:agentGrants.descriptorPath(app.getPath('userData'),grant.id),connection,protect:protectAgentCapability,isCurrent:()=>ready&&policy===initial&&agentService===service&&featureAllowed(managedSettings(),'agents')&&actorGrantAllowed({principalId:grant.id,generation:grant.generation},grant.scopes[0])}); return {address:service.address,credentialPath,principalId:grant.id,generation:grant.generation}; } @@ -457,7 +458,7 @@ async function ensureAgentAPI(){ if(!checkURL(url))return {allowed:false}; return {allowed:true,role:'agent',scopes:[scope]}; },audit:auditAgent, - persistCredential:connection=>agentGrants.persistConnection({filePath:agentFile(),connection,protect:value=>require('./dpapi.cjs').capabilityCrypto('protect',value),isCurrent:()=>ready&&policy?.agents.identityMode==='legacy'&&policy.agents.enabled}) + persistCredential:connection=>agentGrants.persistConnection({filePath:agentFile(),connection,protect:protectAgentCapability,isCurrent:()=>ready&&policy?.agents.identityMode==='legacy'&&policy.agents.enabled}) });emit(); }finally{agentStarting=false;} } diff --git a/apps/inspector/dpapi.py b/apps/inspector/dpapi.py index 6b8da39..36578e3 100644 --- a/apps/inspector/dpapi.py +++ b/apps/inspector/dpapi.py @@ -24,10 +24,15 @@ def convert(data,protect): if __name__=='__main__': try: if sys.platform!='win32' or len(sys.argv)!=2 or sys.argv[1] not in ['protect','unprotect']: raise ValueError() - data=sys.stdin.buffer.read(32769) - if not data or len(data)>32768:raise ValueError() + limit=32768 if sys.argv[1]=='protect' else 65536 + data=sys.stdin.buffer.read(limit+1) + if not data or len(data)>limit:raise ValueError() if sys.argv[1]=='protect':output=base64.b64encode(convert(data,True)) - else:output=convert(base64.b64decode(data,validate=True),False) + else: + decoded=base64.b64decode(data,validate=True) + if base64.b64encode(decoded)!=data:raise ValueError() + output=convert(decoded,False) + if len(output)>(65536 if sys.argv[1]=='protect' else 32768):raise ValueError() sys.stdout.buffer.write(output) except Exception: sys.stderr.write('Windows capability protection failed.\n');sys.exit(1) diff --git a/apps/inspector/provision_runtime_acl.py b/apps/inspector/provision_runtime_acl.py index db3faf9..1201735 100644 --- a/apps/inspector/provision_runtime_acl.py +++ b/apps/inspector/provision_runtime_acl.py @@ -7,10 +7,13 @@ import argparse import json from pathlib import Path import sys +import os +import threading sys.path.insert(0, str(Path(__file__).resolve().parent)) sys.dont_write_bytecode=True from windows_appcontainer import AppContainer, SandboxError +from windows_containment import contain_inspector def main(): @@ -26,8 +29,21 @@ def main(): raise SandboxError('Use the private Python belonging to this installation') roots = [source, runtime] if semantic.is_dir(): roots.append(semantic) - sandbox = AppContainer(roots, provision=args.provision) - print(json.dumps({'verified': True, 'provisioned': args.provision, 'rootCount': len(roots), 'profile': sandbox.name})) + deadline = None + if args.provision: + # Installer-only: closing/killing this process terminates grant children. + # nsExec's own timeout renews on output, so enforce our own hard deadline. + containment = contain_inspector() + if containment.get('active') is not True or containment.get('killsDescendants') is not True: + raise SandboxError('Installer resource containment unavailable') + deadline = threading.Timer(240, lambda: os._exit(2)) + deadline.daemon = True + deadline.start() + try: + sandbox = AppContainer(roots, provision=args.provision) + print(json.dumps({'verified': True, 'provisioned': args.provision, 'rootCount': len(roots), 'profile': sandbox.name})) + finally: + if deadline is not None: deadline.cancel() if __name__ == '__main__': diff --git a/apps/network-broker/audit-gate.cjs b/apps/network-broker/audit-gate.cjs new file mode 100644 index 0000000..c40e0d2 --- /dev/null +++ b/apps/network-broker/audit-gate.cjs @@ -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}; diff --git a/apps/network-broker/bounded-audit.cjs b/apps/network-broker/bounded-audit.cjs new file mode 100644 index 0000000..6870cc7 --- /dev/null +++ b/apps/network-broker/bounded-audit.cjs @@ -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}; diff --git a/apps/network-broker/connect-inspection-proxy.cjs b/apps/network-broker/connect-inspection-proxy.cjs new file mode 100644 index 0000000..fd0aa23 --- /dev/null +++ b/apps/network-broker/connect-inspection-proxy.cjs @@ -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;indexresolverController.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}; diff --git a/apps/network-broker/diagnostics-adapter.cjs b/apps/network-broker/diagnostics-adapter.cjs new file mode 100644 index 0000000..d5f44f8 --- /dev/null +++ b/apps/network-broker/diagnostics-adapter.cjs @@ -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}; diff --git a/apps/network-broker/dns-policy-route.cjs b/apps/network-broker/dns-policy-route.cjs new file mode 100644 index 0000000..c028590 --- /dev/null +++ b/apps/network-broker/dns-policy-route.cjs @@ -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}; diff --git a/apps/network-broker/download-response-gate.cjs b/apps/network-broker/download-response-gate.cjs new file mode 100644 index 0000000..d1ccf2b --- /dev/null +++ b/apps/network-broker/download-response-gate.cjs @@ -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}; diff --git a/apps/network-broker/framed-channel.cjs b/apps/network-broker/framed-channel.cjs new file mode 100644 index 0000000..7ebd926 --- /dev/null +++ b/apps/network-broker/framed-channel.cjs @@ -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(offsetlimit)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}; diff --git a/apps/network-broker/http-request-gate.cjs b/apps/network-broker/http-request-gate.cjs new file mode 100644 index 0000000..9018a50 --- /dev/null +++ b/apps/network-broker/http-request-gate.cjs @@ -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;indexmaxBytes) 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}; diff --git a/apps/network-broker/http-response-gate.cjs b/apps/network-broker/http-response-gate.cjs new file mode 100644 index 0000000..dc110f6 --- /dev/null +++ b/apps/network-broker/http-response-gate.cjs @@ -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}; diff --git a/apps/network-broker/https-forwarder.cjs b/apps/network-broker/https-forwarder.cjs new file mode 100644 index 0000000..769bbf9 --- /dev/null +++ b/apps/network-broker/https-forwarder.cjs @@ -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:,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}; diff --git a/apps/network-broker/https-inspection-server.cjs b/apps/network-broker/https-inspection-server.cjs new file mode 100644 index 0000000..17835f5 --- /dev/null +++ b/apps/network-broker/https-inspection-server.cjs @@ -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;indexObject.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}; diff --git a/apps/network-broker/lifecycle-audit.cjs b/apps/network-broker/lifecycle-audit.cjs new file mode 100644 index 0000000..1dc7167 --- /dev/null +++ b/apps/network-broker/lifecycle-audit.cjs @@ -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}; diff --git a/apps/network-broker/managed-proxy.cjs b/apps/network-broker/managed-proxy.cjs new file mode 100644 index 0000000..5b0bbcc --- /dev/null +++ b/apps/network-broker/managed-proxy.cjs @@ -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||'')||/)/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}; diff --git a/apps/network-broker/management-credential.cjs b/apps/network-broker/management-credential.cjs new file mode 100644 index 0000000..ada612a --- /dev/null +++ b/apps/network-broker/management-credential.cjs @@ -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}; diff --git a/apps/network-broker/management-identity-operation.cjs b/apps/network-broker/management-identity-operation.cjs new file mode 100644 index 0000000..8e495e9 --- /dev/null +++ b/apps/network-broker/management-identity-operation.cjs @@ -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()!['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}; diff --git a/apps/network-broker/multipart.cjs b/apps/network-broker/multipart.cjs new file mode 100644 index 0000000..756434d --- /dev/null +++ b/apps/network-broker/multipart.cjs @@ -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{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}; diff --git a/apps/network-broker/native/README.md b/apps/network-broker/native/README.md new file mode 100644 index 0000000..d72d7b6 --- /dev/null +++ b/apps/network-broker/native/README.md @@ -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. diff --git a/apps/network-broker/native/acceptance-state.h b/apps/network-broker/native/acceptance-state.h new file mode 100644 index 0000000..237d66a --- /dev/null +++ b/apps/network-broker/native/acceptance-state.h @@ -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{"minimumSequence", + "acceptedDigest", "tenant"} + : std::initializer_list{"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(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 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 diff --git a/apps/network-broker/native/management-credential.h b/apps/network-broker/native/management-credential.h new file mode 100644 index 0000000..9c67d8e --- /dev/null +++ b/apps/network-broker/native/management-credential.h @@ -0,0 +1,276 @@ +#pragma once +#include "acceptance-state.h" +#include +#pragma comment(lib, "crypt32.lib") +namespace credential_contract { +using release_contract::Json; +static constexpr DWORD STORE_LIMIT = 1048576; +struct Wipe { + std::vector 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(device[0]))) + throw 1; + for (char c : device) + if (!isalnum(static_cast(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 &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 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 &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 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 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(value.data()), &size) || + type != REG_BINARY || size != value.size()) + throw 1; + validateStore(value); + return value; +} +static std::vector 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(plain.bytes.size()), plain.bytes.data()}, + additional{static_cast(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 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(encrypted.size()), encrypted.data()}, + additional{static_cast(bindingBytes.size()), bindingBytes.data()}, + output{}; + if (!CryptUnprotectData(&input, nullptr, &additional, nullptr, nullptr, + CRYPTPROTECT_UI_FORBIDDEN, &output)) + throw 1; + std::string token(reinterpret_cast(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 diff --git a/apps/network-broker/native/service-host.cpp b/apps/network-broker/native/service-host.cpp new file mode 100644 index 0000000..2e493c2 --- /dev/null +++ b/apps/network-broker/native/service-host.cpp @@ -0,0 +1,1802 @@ +// Native SCM boundary host. No Chromium/Electron changes; installation is +// separate. +#define WIN32_LEAN_AND_MEAN +#include "service-pins.h" +#include "wfp-topology.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#pragma comment(lib, "advapi32.lib") +#pragma comment(lib, "bcrypt.lib") +static constexpr wchar_t SERVICE_NAME[] = L"SafetyNetNetworkBroker"; +static constexpr wchar_t PIPE_NAME[] = L"\\\\.\\pipe\\SafetyNet.Broker.v1"; +struct Handle { + HANDLE h = nullptr; + explicit Handle(HANDLE v = nullptr) : h(v) {} + ~Handle() { + if (h && h != INVALID_HANDLE_VALUE) + CloseHandle(h); + } + Handle(const Handle &) = delete; + Handle &operator=(const Handle &) = delete; + operator HANDLE() const { return h; } +}; +static HANDLE stopEvent; +static SERVICE_STATUS_HANDLE statusHandle; +static bool hashFile(const std::wstring &file, const char *expected) { + 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) + return false; + FILE_ATTRIBUTE_TAG_INFO info{}; + if (!GetFileInformationByHandleEx(input, FileAttributeTagInfo, &info, + sizeof(info)) || + (info.FileAttributes & FILE_ATTRIBUTE_REPARSE_POINT)) + return false; + BCRYPT_ALG_HANDLE algorithm = nullptr; + BCRYPT_HASH_HANDLE hash = nullptr; + bool good = false; + if (BCryptOpenAlgorithmProvider(&algorithm, BCRYPT_SHA256_ALGORITHM, nullptr, + 0) < 0) + return false; + if (BCryptCreateHash(algorithm, &hash, nullptr, 0, nullptr, 0, 0) >= 0) { + std::array buffer; + DWORD read = 0; + bool okay = true; + while (true) { + if (!ReadFile(input, buffer.data(), DWORD(buffer.size()), &read, + nullptr)) { + okay = false; + break; + } + if (!read) + break; + if (BCryptHashData(hash, buffer.data(), read, 0) < 0) { + okay = false; + break; + } + } + std::array digest{}; + if (okay && BCryptFinishHash(hash, digest.data(), 32, 0) >= 0) { + char hex[65]{}; + for (size_t i = 0; i < 32; i++) + sprintf_s(hex + i * 2, 3, "%02x", digest[i]); + good = strcmp(hex, expected) == 0; + } + BCryptDestroyHash(hash); + } + BCryptCloseAlgorithmProvider(algorithm, 0); + return good; +} +static std::vector tokenInfo(HANDLE token, TOKEN_INFORMATION_CLASS kind) { + DWORD size = 0; + GetTokenInformation(token, kind, nullptr, 0, &size); + if (!size || size > 65536) + return {}; + std::vector value(size); + if (!GetTokenInformation(token, kind, value.data(), size, &size)) + return {}; + return value; +} +static bool serviceIdentity(HANDLE process = GetCurrentProcess()) { + Handle token; + if (!OpenProcessToken(process, TOKEN_QUERY, &token.h)) + return false; + auto groups = tokenInfo(token, TokenGroups); + if (groups.empty()) + return false; + DWORD sidBytes = 0, domainBytes = 0; + SID_NAME_USE use; + LookupAccountNameW(nullptr, L"NT SERVICE\\SafetyNetNetworkBroker", nullptr, + &sidBytes, nullptr, &domainBytes, &use); + if (!sidBytes || sidBytes > 65536) + return false; + std::vector sid(sidBytes); + std::vector domain(domainBytes); + if (!LookupAccountNameW(nullptr, L"NT SERVICE\\SafetyNetNetworkBroker", + sid.data(), &sidBytes, domain.data(), &domainBytes, + &use)) + return false; + auto list = reinterpret_cast(groups.data()); + for (DWORD i = 0; i < list->GroupCount; i++) + if (EqualSid(list->Groups[i].Sid, sid.data()) && + (list->Groups[i].Attributes & SE_GROUP_ENABLED) && + !(list->Groups[i].Attributes & SE_GROUP_USE_FOR_DENY_ONLY)) + return true; + return false; +} +static std::vector serviceSid() { + DWORD size = 0, domainSize = 0; + SID_NAME_USE use; + LookupAccountNameW(nullptr, L"NT SERVICE\\SafetyNetNetworkBroker", nullptr, + &size, nullptr, &domainSize, &use); + if (!size || size > 65536 || domainSize > 32768) + return {}; + std::vector sid(size); + std::vector domain(domainSize); + if (!LookupAccountNameW(nullptr, L"NT SERVICE\\SafetyNetNetworkBroker", + sid.data(), &size, domain.data(), &domainSize, &use)) + return {}; + return sid; +} +static bool protectedPath(const std::wstring &file) { + // Resources may be readable by employees, but only SYSTEM/Administrators may + // modify code. Unknown write trustees fail closed; inheritable-only ACEs are + // checked on their descendants rather than treated as effective on the + // parent. + PSECURITY_DESCRIPTOR descriptor = nullptr; + PACL dacl = nullptr; + PSID owner = nullptr; + DWORD code = GetNamedSecurityInfoW( + file.c_str(), SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | OWNER_SECURITY_INFORMATION, &owner, nullptr, + &dacl, nullptr, &descriptor); + if (code || !dacl) + return false; + 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); + bool good = owner && (EqualSid(owner, system) || EqualSid(owner, admins)); + for (DWORD i = 0; i < dacl->AceCount; i++) { + void *raw = nullptr; + if (!GetAce(dacl, i, &raw)) { + good = false; + break; + } + auto header = static_cast(raw); + if (header->AceFlags & INHERIT_ONLY_ACE) + continue; + if (header->AceType == ACCESS_ALLOWED_ACE_TYPE) { + auto ace = static_cast(raw); + DWORD write = FILE_WRITE_DATA | FILE_APPEND_DATA | FILE_WRITE_EA | + FILE_WRITE_ATTRIBUTES | DELETE | WRITE_DAC | WRITE_OWNER | + FILE_DELETE_CHILD | GENERIC_WRITE | GENERIC_ALL; + if ((ace->Mask & write) && !EqualSid(&ace->SidStart, system) && + !EqualSid(&ace->SidStart, admins)) { + good = false; + break; + } + } else if (header->AceType != ACCESS_DENIED_ACE_TYPE) { + good = false; + break; + } + } + LocalFree(descriptor); + return good; +} +#include "acceptance-state.h" +#include "management-credential.h" +#include "signed-manifest.h" +struct TrustFloor { + ULONGLONG release = 0, deployment = 0; + std::string releaseDigest, deploymentDigest, stateCanonical; +}; +static TrustFloor trustFloor() { + HKEY key = nullptr; + if (RegOpenKeyExW(HKEY_LOCAL_MACHINE, L"SOFTWARE\\SafetyNet\\Trust", 0, + KEY_READ | READ_CONTROL | KEY_WOW64_64KEY, + &key) != ERROR_SUCCESS) + throw std::runtime_error("fixed-trust-unavailable"); + // A writable counter store would turn signature checking into rollback. + PSECURITY_DESCRIPTOR descriptor = nullptr; + PACL dacl = nullptr; + PSID owner = nullptr; + DWORD status = + GetSecurityInfo(key, SE_REGISTRY_KEY, + DACL_SECURITY_INFORMATION | OWNER_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); + bool valid = !status && dacl && owner && + (EqualSid(owner, system) || EqualSid(owner, admins)); + auto service = serviceSid(); + if (valid) + for (DWORD i = 0; i < dacl->AceCount; i++) { + void *raw = nullptr; + if (!GetAce(dacl, i, &raw)) { + valid = false; + break; + } + auto header = static_cast(raw); + if (header->AceFlags & INHERIT_ONLY_ACE) + continue; + if (header->AceType == ACCESS_ALLOWED_ACE_TYPE) { + auto ace = static_cast(raw); + if ((ace->Mask & + (KEY_SET_VALUE | KEY_CREATE_SUB_KEY | DELETE | WRITE_DAC | + WRITE_OWNER | GENERIC_WRITE | GENERIC_ALL)) && + !EqualSid(&ace->SidStart, system) && + !EqualSid(&ace->SidStart, admins) && + !(service.size() && EqualSid(&ace->SidStart, service.data()) && + !(ace->Mask & + ~(KEY_QUERY_VALUE | KEY_SET_VALUE | READ_CONTROL | KEY_NOTIFY | + KEY_ENUMERATE_SUB_KEYS | SYNCHRONIZE)))) { + valid = false; + break; + } + } else if (header->AceType != ACCESS_DENIED_ACE_TYPE) { + valid = false; + break; + } + } + if (descriptor) + LocalFree(descriptor); + DWORD type = 0, size = 0; + bool okay = valid && + RegQueryValueExW(key, L"AcceptanceState", nullptr, &type, nullptr, + &size) == ERROR_SUCCESS && + type == REG_BINARY && size > 0 && size <= 2097152; + std::string canonical(okay ? size : 0, '\0'); + if (okay) + okay = RegQueryValueExW(key, L"AcceptanceState", nullptr, &type, + reinterpret_cast(canonical.data()), + &size) == ERROR_SUCCESS && + type == REG_BINARY && size == canonical.size(); + RegCloseKey(key); + if (!okay) + throw std::runtime_error("fixed-trust-unavailable"); + auto state = acceptance_contract::validate(canonical); + TrustFloor floor; + floor.release = release_contract::number( + state.object.at("release").object.at("minimumSequence")); + floor.deployment = release_contract::number( + state.object.at("deployment").object.at("minimumSequence")); + floor.releaseDigest = release_contract::str( + state.object.at("release").object.at("acceptedDigest")); + floor.deploymentDigest = release_contract::str( + state.object.at("deployment").object.at("acceptedDigest")); + floor.stateCanonical = canonical; + return floor; +} +static release_contract::Release trustedRelease(const std::wstring &root) { + auto floor = trustFloor(); + auto release = release_contract::release( + root, release_contract::unhex(RELEASE_ANCHOR_XY), floor.release, + floor.releaseDigest); + auto state = acceptance_contract::validate(floor.stateCanonical); + if (release.tenant != + release_contract::str(state.object.at("deployment").object.at("tenant"))) + throw std::runtime_error("fixed-trust-unavailable"); + return release; +} +static std::string currentSid(HANDLE process) { + Handle token; + if (!OpenProcessToken(process, TOKEN_QUERY, &token.h)) + throw std::runtime_error("fixed-peer-invalid"); + auto value = tokenInfo(token, TokenUser); + if (value.empty()) + throw std::runtime_error("fixed-peer-invalid"); + LPSTR text = nullptr; + if (!ConvertSidToStringSidA( + reinterpret_cast(value.data())->User.Sid, &text)) + throw std::runtime_error("fixed-peer-invalid"); + std::string result = text; + LocalFree(text); + return result; +} +static Handle *parentProcess(HANDLE process) { + // Called only with a retained actual process handle; no PID supplied by IPC. + DWORD pid = GetProcessId(process); + Handle snapshot(CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)); + PROCESSENTRY32W item{}; + item.dwSize = sizeof(item); + if (snapshot.h == INVALID_HANDLE_VALUE || !Process32FirstW(snapshot, &item)) + return nullptr; + do { + if (item.th32ProcessID == pid) { + HANDLE parent = + OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE, FALSE, + item.th32ParentProcessID); + if (!parent) + return nullptr; + FILETIME childCreated{}, parentCreated{}, exit{}, kernel{}, user{}; + if (!GetProcessTimes(process, &childCreated, &exit, &kernel, &user) || + !GetProcessTimes(parent, &parentCreated, &exit, &kernel, &user) || + CompareFileTime(&parentCreated, &childCreated) > 0) { + CloseHandle(parent); + return nullptr; + } + return new Handle(parent); + } + } while (Process32NextW(snapshot, &item)); + return nullptr; +} +static bool verifiedImage(HANDLE process, const std::wstring &root, + const char *relative, + const release_contract::Release &release) { + std::array image{}; + DWORD size = DWORD(image.size()); + std::wstring required = + root + L"\\" + std::wstring(relative, relative + strlen(relative)); + auto found = release.hashes.find(relative); + if (found == release.hashes.end() || + WaitForSingleObject(process, 0) != WAIT_TIMEOUT || + !QueryFullProcessImageNameW(process, 0, image.data(), &size) || + CompareStringOrdinal(image.data(), int(size), required.c_str(), -1, + TRUE) != CSTR_EQUAL || + !protectedPath(required) || !protectedPath(root)) + return false; + return hashFile(required, found->second.c_str()); +} +static bool sameUserSession(HANDLE left, HANDLE right, bool service = false) { + Handle lt, rt; + if (!OpenProcessToken(left, TOKEN_QUERY, <.h) || + !OpenProcessToken(right, TOKEN_QUERY, &rt.h)) + return false; + auto lu = tokenInfo(lt, TokenUser), ru = tokenInfo(rt, TokenUser), + ls = tokenInfo(lt, TokenSessionId), rs = tokenInfo(rt, TokenSessionId), + lc = tokenInfo(lt, TokenIsAppContainer), + rc = tokenInfo(rt, TokenIsAppContainer), + li = tokenInfo(lt, TokenIntegrityLevel), + ri = tokenInfo(rt, TokenIntegrityLevel); + if (lu.empty() || ru.empty() || ls.empty() || rs.empty() || lc.empty() || + rc.empty() || li.empty() || ri.empty() || + *reinterpret_cast(lc.data()) || + *reinterpret_cast(rc.data())) + return false; + for (const auto *item : {&li, &ri}) { + auto label = reinterpret_cast(item->data()); + DWORD level = *GetSidSubAuthority( + label->Label.Sid, *GetSidSubAuthorityCount(label->Label.Sid) - 1); + if (level < SECURITY_MANDATORY_MEDIUM_RID || + level > DWORD(service ? SECURITY_MANDATORY_SYSTEM_RID + : SECURITY_MANDATORY_HIGH_RID)) + return false; + } + return EqualSid(reinterpret_cast(lu.data())->User.Sid, + reinterpret_cast(ru.data())->User.Sid) && + *reinterpret_cast(ls.data()) == + *reinterpret_cast(rs.data()); +} +static bool pipeClient(HANDLE pipe, const std::wstring &root, + HANDLE *retained = nullptr) { + ULONG pid = 0; + if (!GetNamedPipeClientProcessId(pipe, &pid) || !pid) + return false; + Handle process( + OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE, FALSE, pid)); + if (!process.h || WaitForSingleObject(process, 0) != WAIT_TIMEOUT) + return false; + try { + auto release = trustedRelease(root); + std::unique_ptr parent(parentProcess(process)); + if (!parent || + !verifiedImage(process, root, "safetynet-service-host.exe", release) || + !verifiedImage(*parent, root, "SafetyNet.exe", release) || + !sameUserSession(process, *parent)) + return false; + } catch (...) { + return false; + } + Handle actual; + if (!OpenProcessToken(process, TOKEN_QUERY, &actual.h)) + return false; + auto user = tokenInfo(actual, TokenUser), + session = tokenInfo(actual, TokenSessionId), + container = tokenInfo(actual, TokenIsAppContainer), + integrity = tokenInfo(actual, TokenIntegrityLevel); + if (user.empty() || session.empty() || container.empty() || + integrity.empty() || *reinterpret_cast(container.data())) + return false; + auto label = reinterpret_cast(integrity.data()); + DWORD level = *GetSidSubAuthority( + label->Label.Sid, *GetSidSubAuthorityCount(label->Label.Sid) - 1); + if (level < SECURITY_MANDATORY_MEDIUM_RID || + level > SECURITY_MANDATORY_HIGH_RID) + return false; + if (!ImpersonateNamedPipeClient(pipe)) + return false; + Handle peer; + bool opened = + OpenThreadToken(GetCurrentThread(), TOKEN_QUERY, TRUE, &peer.h) != FALSE; + bool reverted = RevertToSelf() != FALSE; + if (!reverted) { + TerminateProcess(GetCurrentProcess(), ERROR_ACCESS_DENIED); + return false; + } + if (!opened) + return false; + auto peerUser = tokenInfo(peer, TokenUser), + peerSession = tokenInfo(peer, TokenSessionId); + if (peerUser.empty() || peerSession.empty()) + return false; + bool authenticated = + EqualSid(reinterpret_cast(user.data())->User.Sid, + reinterpret_cast(peerUser.data())->User.Sid) && + *reinterpret_cast(session.data()) == + *reinterpret_cast(peerSession.data()) && + WaitForSingleObject(process, 0) == WAIT_TIMEOUT; + if (authenticated && retained) { + *retained = process.h; + process.h = nullptr; + } + return authenticated; +} +static bool transfer(HANDLE pipe, bool write, void *bytes, DWORD length, + DWORD timeout, ULONGLONG *firstByteAt = nullptr) { + auto cursor = static_cast(bytes); + ULONGLONG started = GetTickCount64(); + while (length) { + Handle event(CreateEventW(nullptr, TRUE, FALSE, nullptr)); + OVERLAPPED ov{}; + ov.hEvent = event; + DWORD done = 0; + BOOL result = write ? WriteFile(pipe, cursor, length, &done, &ov) + : ReadFile(pipe, cursor, length, &done, &ov); + if (!result) { + if (GetLastError() != ERROR_IO_PENDING) + return false; + HANDLE waits[] = {event.h, stopEvent}; + ULONGLONG elapsed = GetTickCount64() - started; + DWORD remaining = + timeout == INFINITE + ? INFINITE + : (elapsed >= timeout ? 0 : timeout - DWORD(elapsed)); + DWORD status = WaitForMultipleObjects(2, waits, FALSE, remaining); + if (status != WAIT_OBJECT_0) { + CancelIoEx(pipe, &ov); + WaitForSingleObject(event, INFINITE); + return false; + } + if (!GetOverlappedResult(pipe, &ov, &done, FALSE)) + return false; + } + if (!done) + return false; + if (firstByteAt && !*firstByteAt) + *firstByteAt = GetTickCount64(); + if (timeout == INFINITE && done < length) { + timeout = 15000; + started = GetTickCount64(); + } + cursor += done; + length -= done; + } + return true; +} +static bool frame(HANDLE pipe, bool write, std::vector &value, + DWORD maximum, DWORD headerTimeout = 15000) { + ULONGLONG started = GetTickCount64(); + ULONGLONG firstByteAt = 0; + DWORD size = write ? DWORD(value.size()) : 0; + if (!transfer(pipe, write, &size, 4, headerTimeout, &firstByteAt) || !size || + size > maximum) + return false; + if (headerTimeout == INFINITE) + started = firstByteAt; + DWORD budget = headerTimeout == INFINITE ? 15000 : headerTimeout; + ULONGLONG elapsed = GetTickCount64() - started; + if (elapsed >= budget) + return false; + if (!write) + value.resize(size); + return transfer(pipe, write, value.data(), size, budget - DWORD(elapsed)); +} +static std::wstring rootPath() { + std::array image{}; + DWORD size = GetModuleFileNameW(nullptr, image.data(), DWORD(image.size())); + if (!size || size >= image.size()) + return {}; + std::wstring root(image.data(), size); + return root.substr(0, root.find_last_of(L'\\')); +} +static bool resources(const std::wstring &root) { + try { + if (root.empty() || !protectedPath(root) || + !protectedPath(root + L"\\release-envelope.json") || + !protectedPath(root + L"\\deployment-envelope.json")) + return false; + auto release = trustedRelease(root); + for (const auto &pin : release.hashes) { + std::wstring relative(pin.first.begin(), pin.first.end()); + std::replace(relative.begin(), relative.end(), L'/', L'\\'); + std::wstring file = root + std::wstring(1, wchar_t(92)) + relative, + ancestor = file; + while (ancestor.size() > root.size()) { + if (GetFileAttributesW(ancestor.c_str()) & + FILE_ATTRIBUTE_REPARSE_POINT || + !protectedPath(ancestor)) + return false; + ancestor = ancestor.substr(0, ancestor.find_last_of(L'\\')); + } + if (!hashFile(file, pin.second.c_str())) + return false; + } + return true; + } catch (...) { + return false; + } +} +static std::wstring environment(const std::string &sid) { + std::wstring result; + for (const wchar_t *name : {L"SystemRoot", L"WINDIR", L"TEMP", L"TMP", + L"USERPROFILE", L"LOCALAPPDATA", L"APPDATA"}) { + DWORD size = GetEnvironmentVariableW(name, nullptr, 0); + if (size && size < 32768) { + std::vector value(size); + GetEnvironmentVariableW(name, value.data(), size); + result += name; + result += L'='; + result += value.data(); + result += L'\0'; + } + } + result += L"SAFETYNET_SERVICE_CLIENT_SID="; + result += std::wstring(sid.begin(), sid.end()); + result += L'\0'; + result += L'\0'; + return result; +} +static bool writeChildFrame(HANDLE pipe, const std::vector &value, + std::atomic &deadline, + DWORD budget = 15000) { + deadline = GetTickCount64() + budget; + DWORD size = DWORD(value.size()); + auto exact = [&](const BYTE *bytes, DWORD length) { + while (length) { + DWORD done = 0; + if (!WriteFile(pipe, bytes, length, &done, nullptr) || !done) + return false; + bytes += done; + length -= done; + } + return true; + }; + if (!exact(reinterpret_cast(&size), 4) || + !exact(value.data(), size)) + return false; + deadline = 0; + return true; +} +static bool verifiedServer(HANDLE pipe, const std::wstring &root, + const release_contract::Release &release, + HANDLE *retained, + const char *role = "safetynet-service-host.exe") { + ULONG pid = 0; + if (!GetNamedPipeServerProcessId(pipe, &pid) || !pid) + return false; + Handle process( + OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE, FALSE, pid)); + if (!process.h || !verifiedImage(process, root, role, release) || + !serviceIdentity(process)) + return false; + *retained = process.h; + process.h = nullptr; + return true; +} +static bool boundedStdioWrite(const std::string &text); +static int topologySnapshot() { + try { + const auto root = rootPath(); + if (!serviceIdentity()) + return ERROR_ACCESS_DENIED; + auto release = trustedRelease(root); + std::unique_ptr parent(parentProcess(GetCurrentProcess())); + if (!parent || !serviceIdentity(*parent) || + !verifiedImage(GetCurrentProcess(), root, "safetynet-service-host.exe", + release) || + !verifiedImage(*parent, root, "resources/broker/node.exe", release) || + !sameUserSession(GetCurrentProcess(), *parent, true)) + return ERROR_ACCESS_DENIED; + DWORD userLength = + GetEnvironmentVariableA("SAFETYNET_SERVICE_CLIENT_SID", nullptr, 0); + if (!userLength || userLength > 256) + return ERROR_ACCESS_DENIED; + std::string userSid(userLength, '\0'); + if (!GetEnvironmentVariableA("SAFETYNET_SERVICE_CLIENT_SID", userSid.data(), + userLength)) + return ERROR_ACCESS_DENIED; + userSid.resize(userLength - 1); + auto assignmentFloor = trustFloor(); + release_contract::deployment(root, release, userSid, + assignmentFloor.deployment, + assignmentFloor.deploymentDigest); + auto sid = serviceSid(); + LPWSTR text = nullptr; + if (sid.empty() || !ConvertSidToStringSidW(sid.data(), &text)) + return ERROR_ACCESS_DENIED; + std::wstring serviceText = text; + LocalFree(text); + auto result = topology_contract::inspect(root, serviceText); + const std::string json = + "{\"version\":1,\"brokerPort\":43120,\"accessibleFilters\":" + + std::to_string(result.accessibleFilters) + + ",\"ownedFilters\":" + std::to_string(result.ownedFilters) + + ",\"ownedVerified\":" + (result.ownedVerified ? "true" : "false") + + ",\"collision\":" + (result.collision ? "true" : "false") + + ",\"bfeRunning\":" + (result.bfeRunning ? "true" : "false") + + ",\"allObjectsReadable\":false,\"code\":" + + std::to_string(result.code) + "}"; + return boundedStdioWrite(json) ? 0 : ERROR_ACCESS_DENIED; + } catch (...) { + return ERROR_ACCESS_DENIED; + } +} +static int trustSnapshot() { + try { + const auto root = rootPath(); + if (!serviceIdentity()) + return ERROR_ACCESS_DENIED; + auto release = trustedRelease(root); + std::unique_ptr parent(parentProcess(GetCurrentProcess())); + if (!parent || !serviceIdentity(*parent) || + !verifiedImage(GetCurrentProcess(), root, "safetynet-service-host.exe", + release) || + !verifiedImage(*parent, root, "resources/broker/node.exe", release) || + !sameUserSession(GetCurrentProcess(), *parent, true)) + return ERROR_ACCESS_DENIED; + DWORD length = + GetEnvironmentVariableA("SAFETYNET_SERVICE_CLIENT_SID", nullptr, 0); + if (!length || length > 256) + return ERROR_ACCESS_DENIED; + std::string sid(length, '\0'); + if (!GetEnvironmentVariableA("SAFETYNET_SERVICE_CLIENT_SID", sid.data(), + length)) + return ERROR_ACCESS_DENIED; + sid.resize(length - 1); + auto floor = trustFloor(); + release_contract::deployment(root, release, sid, floor.deployment, + floor.deploymentDigest); + auto xy = release_contract::unhex(RELEASE_ANCHOR_XY); + auto der = release_contract::unhex( + "3059301306072a8648ce3d020106082a8648ce3d03010703420004"); + der.insert(der.end(), xy.begin(), xy.end()); + std::string encoded = release_contract::base64url(der); + std::replace(encoded.begin(), encoded.end(), '-', '+'); + std::replace(encoded.begin(), encoded.end(), '_', '/'); + while (encoded.size() % 4) + encoded += '='; + std::string anchor = "-----BEGIN PUBLIC KEY-----\n"; + for (size_t offset = 0; offset < encoded.size(); offset += 64) + anchor += encoded.substr(offset, 64) + '\n'; + anchor += "-----END PUBLIC KEY-----\n"; + // All values originate in immutable code/protected Windows state. No + // registry selector, key, SID or path is accepted from command arguments. + std::string json = + "{\"acceptanceState\":" + floor.stateCanonical + + ",\"authenticatedSid\":" + release_contract::escaped(sid) + + ",\"releaseAnchor\":" + release_contract::escaped(anchor) + + ",\"version\":1}"; + return boundedStdioWrite(json) ? 0 : ERROR_ACCESS_DENIED; + } catch (...) { + return ERROR_ACCESS_DENIED; + } +} +static bool readStdioFrame(HANDLE input, std::vector &value, + DWORD maximum, std::atomic &deadline) { + DWORD size = 0, done = 0; + if (!ReadFile(input, &size, 4, &done, nullptr) || !done) + return false; + deadline = GetTickCount64() + 15000; + DWORD offset = done; + while (offset < 4) { + if (!ReadFile(input, reinterpret_cast(&size) + offset, 4 - offset, + &done, nullptr) || + !done) + return false; + offset += done; + } + if (!size || size > maximum) + return false; + value.resize(size); + offset = 0; + while (offset < size) { + if (!ReadFile(input, value.data() + offset, size - offset, &done, + nullptr) || + !done) + return false; + offset += done; + } + deadline = 0; + return true; +} +static int clientStdio() { + try { + const auto root = rootPath(); + auto release = trustedRelease(root); + std::unique_ptr parent(parentProcess(GetCurrentProcess())); + if (!parent || + !verifiedImage(GetCurrentProcess(), root, "safetynet-service-host.exe", + release) || + !verifiedImage(*parent, root, "SafetyNet.exe", release) || + !sameUserSession(GetCurrentProcess(), *parent)) + return ERROR_ACCESS_DENIED; + auto floor = trustFloor(); + release_contract::deployment(root, release, currentSid(GetCurrentProcess()), + floor.deployment, floor.deploymentDigest); + HANDLE input = GetStdHandle(STD_INPUT_HANDLE), + output = GetStdHandle(STD_OUTPUT_HANDLE); + if (GetFileType(input) != FILE_TYPE_PIPE || + GetFileType(output) != FILE_TYPE_PIPE) + return ERROR_ACCESS_DENIED; + // Connect exactly once, then authenticate THIS handle's server. No probe + // and reconnect, retry, alternate endpoint, service startup or command + // selector. + Handle pipe(CreateFileW(PIPE_NAME, GENERIC_READ | GENERIC_WRITE, 0, nullptr, + OPEN_EXISTING, FILE_FLAG_OVERLAPPED, nullptr)); + if (pipe.h == INVALID_HANDLE_VALUE) + return ERROR_ACCESS_DENIED; + Handle server; + if (!verifiedServer(pipe, root, release, &server.h)) + return ERROR_ACCESS_DENIED; + Handle stop(CreateEventW(nullptr, TRUE, FALSE, nullptr)); + stopEvent = stop; + Handle reader; + if (!DuplicateHandle(GetCurrentProcess(), GetCurrentThread(), + GetCurrentProcess(), &reader.h, THREAD_TERMINATE, + FALSE, 0)) + return ERROR_ACCESS_DENIED; + std::atomic good{true}; + std::atomic readDeadline{0}, writeDeadline{0}; + std::thread outputPump([&] { + while (good) { + std::vector value; + if (!frame(pipe, false, value, 65536, INFINITE) || + !writeChildFrame(output, value, writeDeadline)) { + good = false; + break; + } + } + CancelSynchronousIo(reader); + SetEvent(stopEvent); + }); + std::thread watcher([&] { + while (good) { + if (WaitForSingleObject(stopEvent, 50) == WAIT_OBJECT_0 || + WaitForSingleObject(*parent, 0) != WAIT_TIMEOUT || + WaitForSingleObject(server, 0) != WAIT_TIMEOUT || + (readDeadline.load() && GetTickCount64() >= readDeadline.load()) || + (writeDeadline.load() && + GetTickCount64() >= writeDeadline.load())) { + good = false; + SetEvent(stopEvent); + CancelSynchronousIo(reader); + CancelSynchronousIo(outputPump.native_handle()); + CancelIoEx(pipe, nullptr); + break; + } + } + }); + bool first = true; + while (good) { + std::vector value; + if (!readStdioFrame(input, value, first ? 2097152 : 65536, + readDeadline) || + !frame(pipe, true, value, first ? 2097152 : 65536)) + break; + first = false; + } + good = false; + SetEvent(stopEvent); + CancelIoEx(pipe, nullptr); + CancelSynchronousIo(outputPump.native_handle()); + outputPump.join(); + watcher.join(); + return 0; + } catch (...) { + return ERROR_ACCESS_DENIED; + } +} +static bool boundedStdioRead(HANDLE input, std::vector &value, + DWORD maximum) { + if (GetFileType(input) != FILE_TYPE_PIPE) + return false; + Handle ownThread; + if (!DuplicateHandle(GetCurrentProcess(), GetCurrentThread(), + GetCurrentProcess(), &ownThread.h, THREAD_TERMINATE, + FALSE, 0)) + return false; + std::atomic finished{false}; + std::atomic deadline{GetTickCount64() + 15000}; + const ULONGLONG expires = GetTickCount64() + 15000; + std::thread watchdog([&] { + while (!finished) { + if (GetTickCount64() >= expires) { + CancelSynchronousIo(ownThread); + break; + } + Sleep(10); + } + }); + bool okay = readStdioFrame(input, value, maximum, deadline); + finished = true; + watchdog.join(); + return okay; +} +static bool boundedStdioWrite(const std::string &text) { + HANDLE output = GetStdHandle(STD_OUTPUT_HANDLE); + if (GetFileType(output) != FILE_TYPE_PIPE || text.size() > 2097152) + return false; + Handle ownThread; + if (!DuplicateHandle(GetCurrentProcess(), GetCurrentThread(), + GetCurrentProcess(), &ownThread.h, THREAD_TERMINATE, + FALSE, 0)) + return false; + std::atomic finished{false}; + std::atomic deadline{0}; + const ULONGLONG expires = GetTickCount64() + 15000; + std::thread watchdog([&] { + while (!finished) { + if (GetTickCount64() >= expires) { + CancelSynchronousIo(ownThread); + break; + } + Sleep(10); + } + }); + std::vector bytes(text.begin(), text.end()); + bool okay = writeChildFrame(output, bytes, deadline); + finished = true; + watchdog.join(); + if (!bytes.empty()) + SecureZeroMemory(bytes.data(), bytes.size()); + return okay; +} +static bool ownedAcceptanceMutex(HANDLE mutex, + const std::vector &service) { + PSECURITY_DESCRIPTOR descriptor = nullptr; + PACL dacl = nullptr; + PSID owner = nullptr; + DWORD result = + GetSecurityInfo(mutex, SE_KERNEL_OBJECT, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + &owner, nullptr, &dacl, nullptr, &descriptor); + BYTE system[SECURITY_MAX_SID_SIZE], admins[SECURITY_MAX_SID_SIZE], + localService[SECURITY_MAX_SID_SIZE]; + DWORD ss = sizeof(system), as = sizeof(admins), ls = sizeof(localService); + CreateWellKnownSid(WinLocalSystemSid, nullptr, system, &ss); + CreateWellKnownSid(WinBuiltinAdministratorsSid, nullptr, admins, &as); + CreateWellKnownSid(WinLocalServiceSid, nullptr, localService, &ls); + PSID ownerRights = nullptr; + if (!ConvertStringSidToSidW(L"S-1-3-4", &ownerRights)) { + if (descriptor) + LocalFree(descriptor); + return false; + } + bool valid = result == ERROR_SUCCESS && owner && dacl && service.size() && + (EqualSid(owner, localService) || + EqualSid(owner, const_cast(service.data()))); + unsigned ownerRightsCount = 0; + if (valid) + for (DWORD index = 0; index < dacl->AceCount; index++) { + void *raw = nullptr; + if (!GetAce(dacl, index, &raw)) { + valid = false; + break; + } + auto header = static_cast(raw); + if (header->AceFlags & INHERIT_ONLY_ACE) + continue; + if (header->AceType == ACCESS_ALLOWED_ACE_TYPE) { + auto ace = static_cast(raw); + if (EqualSid(&ace->SidStart, ownerRights)) { + if (ace->Mask != READ_CONTROL) { + valid = false; + break; + } + ownerRightsCount++; + continue; + } + if (!EqualSid(&ace->SidStart, system) && + !EqualSid(&ace->SidStart, admins) && + !EqualSid(&ace->SidStart, const_cast(service.data()))) { + valid = false; + break; + } + } else if (header->AceType != ACCESS_DENIED_ACE_TYPE) { + valid = false; + break; + } + } + if (descriptor) + LocalFree(descriptor); + LocalFree(ownerRights); + return valid && ownerRightsCount == 1; +} +static bool protectedCredentialKey(HKEY key, const std::vector &service) { + PSECURITY_DESCRIPTOR descriptor = nullptr; + PACL dacl = nullptr; + PSID owner = nullptr; + DWORD status = + GetSecurityInfo(key, SE_REGISTRY_KEY, + DACL_SECURITY_INFORMATION | OWNER_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 = !status && dacl && owner && + (EqualSid(owner, system) || EqualSid(owner, admins)) && + GetSecurityDescriptorControl(descriptor, &control, &revision) && + (control & SE_DACL_PROTECTED); + 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(raw); + if (header->AceFlags & INHERIT_ONLY_ACE) + continue; + if (header->AceType == ACCESS_ALLOWED_ACE_TYPE) { + auto ace = static_cast(raw); + if (!EqualSid(&ace->SidStart, system) && + !EqualSid(&ace->SidStart, admins) && + (!EqualSid(&ace->SidStart, const_cast(service.data())) || + (ace->Mask & ~(KEY_QUERY_VALUE | KEY_SET_VALUE | READ_CONTROL)))) { + okay = false; + break; + } + } else if (header->AceType != ACCESS_DENIED_ACE_TYPE) { + okay = false; + break; + } + } + if (descriptor) + LocalFree(descriptor); + return okay; +} +static int managementCredential(bool provision) { + try { + const auto root = rootPath(); + if (!serviceIdentity()) + return ERROR_ACCESS_DENIED; + auto release = trustedRelease(root); + std::unique_ptr parent(parentProcess(GetCurrentProcess())); + if (!parent || !serviceIdentity(*parent) || + !verifiedImage(GetCurrentProcess(), root, "safetynet-service-host.exe", + release) || + !verifiedImage(*parent, root, "resources/broker/node.exe", release) || + !sameUserSession(GetCurrentProcess(), *parent, true)) + return ERROR_ACCESS_DENIED; + DWORD sidLength = + GetEnvironmentVariableA("SAFETYNET_SERVICE_CLIENT_SID", nullptr, 0); + if (!sidLength || sidLength > 256) + return ERROR_ACCESS_DENIED; + std::string sid(sidLength, '\0'); + if (!GetEnvironmentVariableA("SAFETYNET_SERVICE_CLIENT_SID", sid.data(), + sidLength)) + return ERROR_ACCESS_DENIED; + sid.resize(sidLength - 1); + credential_contract::Wipe frame; + if (!boundedStdioRead(GetStdHandle(STD_INPUT_HANDLE), frame.bytes, + provision ? 16384 : 4096)) + return ERROR_ACCESS_DENIED; + std::string requestText(frame.bytes.begin(), frame.bytes.end()); + credential_contract::WipeText wipeRequest{requestText}; + auto request = credential_contract::parse(requestText); + SecureZeroMemory(requestText.data(), requestText.size()); + struct SecretCleanup { + release_contract::Json &value; + ~SecretCleanup() { + auto found = value.object.find("token"); + if (found != value.object.end() && !found->second.string.empty()) + SecureZeroMemory(found->second.string.data(), + found->second.string.size()); + } + } secretCleanup{request}; + if (provision ? !release_contract::exact( + request, {"deviceId", "origin", "policyVersion", + "policyKeySha256", "policyDigest", + "expectedPreviousSHA", "token"}) + : !release_contract::exact( + request, {"deviceId", "origin", "policyVersion", + "policyKeySha256", "policyDigest"})) + return ERROR_ACCESS_DENIED; + auto service = serviceSid(); + LPWSTR serviceText = nullptr; + if (service.empty() || + !ConvertSidToStringSidW(service.data(), &serviceText)) + return ERROR_ACCESS_DENIED; + std::wstring security = + L"D:P(A;;RC;;;OW)(A;;GA;;;SY)(A;;GA;;;BA)(A;;GA;;;" + + std::wstring(serviceText) + L")"; + LocalFree(serviceText); + PSECURITY_DESCRIPTOR descriptor = nullptr; + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW( + security.c_str(), SDDL_REVISION_1, &descriptor, nullptr)) + return ERROR_ACCESS_DENIED; + SECURITY_ATTRIBUTES attributes{sizeof(attributes), descriptor, FALSE}; + Handle mutex( + CreateMutexW(&attributes, FALSE, L"Global\\SafetyNet.Acceptance.v1")); + LocalFree(descriptor); + if (!mutex.h || !ownedAcceptanceMutex(mutex, service) || + WaitForSingleObject(mutex, 15000) != WAIT_OBJECT_0) + return ERROR_ACCESS_DENIED; + struct Unlock { + HANDLE h; + ~Unlock() { ReleaseMutex(h); } + } unlock{mutex.h}; + auto floor = trustFloor(); + release = trustedRelease(root); + auto assignment = release_contract::deployment( + root, release, sid, floor.deployment, floor.deploymentDigest); + const auto metadata = credential_contract::binding( + request, assignment, + acceptance_contract::validate(floor.stateCanonical)); + HKEY key = nullptr; + if (RegOpenKeyExW(HKEY_LOCAL_MACHINE, + L"SOFTWARE\\SafetyNet\\ManagementCredentials", 0, + KEY_QUERY_VALUE | READ_CONTROL | KEY_WOW64_64KEY | + (provision ? KEY_SET_VALUE : 0), + &key)) + return ERROR_ACCESS_DENIED; + struct CloseKey { + HKEY h; + ~CloseKey() { RegCloseKey(h); } + } closeKey{key}; + if (!protectedCredentialKey(key, service)) + return ERROR_ACCESS_DENIED; + const auto previous = credential_contract::load(key); + auto store = credential_contract::validateStore(previous); + auto &records = store.object.at("records").array; + const auto selected = credential_contract::selectRecord(metadata, records); + if (provision) { + if (!acceptance_contract::sha(release_contract::str( + request.object.at("expectedPreviousSHA"))) || + release_contract::digest(previous) != + release_contract::str(request.object.at("expectedPreviousSHA"))) + return ERROR_ACCESS_DENIED; + auto record = metadata; + release_contract::Json cipher; + cipher.kind = release_contract::Json::String; + cipher.string = credential_contract::protect( + request.object.at("token").string, metadata); + record.object["protectedToken"] = cipher; + credential_contract::replaceRecord(records, record); + const auto candidate = release_contract::canonical(store); + credential_contract::validateStore(candidate); + if (RegSetValueExW(key, L"ManagementCredentials", 0, REG_BINARY, + reinterpret_cast(candidate.data()), + static_cast(candidate.size())) || + RegFlushKey(key) || credential_contract::load(key) != candidate) + return ERROR_ACCESS_DENIED; + return boundedStdioWrite("{\"persisted\":true,\"stateSha256\":" + + release_contract::escaped( + release_contract::digest(candidate)) + + "}") + ? 0 + : ERROR_ACCESS_DENIED; + } + if (selected) { + const auto &record = *selected; + auto bound = record; + bound.object.erase("protectedToken"); + std::string token = credential_contract::unprotect( + release_contract::str(record.object.at("protectedToken")), bound); + auto response = request; + release_contract::Json secret; + secret.kind = release_contract::Json::String; + secret.string = token; + response.object["token"] = secret; + std::string reply = release_contract::canonical(response); + SecureZeroMemory(token.data(), token.size()); + SecureZeroMemory(secret.string.data(), secret.string.size()); + SecureZeroMemory(response.object.at("token").string.data(), + response.object.at("token").string.size()); + bool okay = reply.size() <= 8192 && boundedStdioWrite(reply); + SecureZeroMemory(reply.data(), reply.size()); + return okay ? 0 : ERROR_ACCESS_DENIED; + } + return ERROR_ACCESS_DENIED; + } catch (...) { + return ERROR_ACCESS_DENIED; + } +} +static int commitAcceptance() { + try { + const auto root = rootPath(); + if (!serviceIdentity()) + return ERROR_ACCESS_DENIED; + auto release = trustedRelease(root); + std::unique_ptr parent(parentProcess(GetCurrentProcess())); + if (!parent || !serviceIdentity(*parent) || + !verifiedImage(GetCurrentProcess(), root, "safetynet-service-host.exe", + release) || + !verifiedImage(*parent, root, "resources/broker/node.exe", release) || + !sameUserSession(GetCurrentProcess(), *parent, true)) + return ERROR_ACCESS_DENIED; + DWORD size = + GetEnvironmentVariableA("SAFETYNET_SERVICE_CLIENT_SID", nullptr, 0); + if (!size || size > 256) + return ERROR_ACCESS_DENIED; + std::string sid(size, '\0'); + if (!GetEnvironmentVariableA("SAFETYNET_SERVICE_CLIENT_SID", sid.data(), + size)) + return ERROR_ACCESS_DENIED; + sid.resize(size - 1); + std::vector frameBytes; + if (!boundedStdioRead(GetStdHandle(STD_INPUT_HANDLE), frameBytes, 2097152)) + return ERROR_ACCESS_DENIED; + std::string requestText(frameBytes.begin(), frameBytes.end()); + release_contract::Parser parser{requestText}; + auto request = parser.parse(); + if (parser.offset != requestText.size() || + !release_contract::exact(request, + {"expectedPreviousSHA", "candidateState"}) || + !acceptance_contract::sha( + release_contract::str(request.object.at("expectedPreviousSHA")))) + return ERROR_ACCESS_DENIED; + auto candidateText = + release_contract::canonical(request.object.at("candidateState")); + auto candidate = acceptance_contract::validate(candidateText); + auto service = serviceSid(); + if (service.empty()) + return ERROR_ACCESS_DENIED; + LPWSTR serviceText = nullptr; + if (!ConvertSidToStringSidW(service.data(), &serviceText)) + return ERROR_ACCESS_DENIED; + std::wstring security = + L"D:P(A;;RC;;;OW)(A;;GA;;;SY)(A;;GA;;;BA)(A;;GA;;;" + + std::wstring(serviceText) + L")"; + LocalFree(serviceText); + PSECURITY_DESCRIPTOR descriptor = nullptr; + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW( + security.c_str(), SDDL_REVISION_1, &descriptor, nullptr)) + return ERROR_ACCESS_DENIED; + SECURITY_ATTRIBUTES attributes{sizeof(attributes), descriptor, FALSE}; + Handle mutex( + CreateMutexW(&attributes, FALSE, L"Global\\SafetyNet.Acceptance.v1")); + LocalFree(descriptor); + if (!mutex.h || !ownedAcceptanceMutex(mutex, service) || + WaitForSingleObject(mutex, 15000) != WAIT_OBJECT_0) + return ERROR_ACCESS_DENIED; + // No commit is attempted on an abandoned mutex: a prior writer may have + // completed its value write without returning a durable receipt. + struct Unlock { + HANDLE value; + ~Unlock() { ReleaseMutex(value); } + } unlock{mutex.h}; + auto floor = trustFloor(); + auto old = acceptance_contract::validate(floor.stateCanonical); + if (release_contract::digest(floor.stateCanonical) != + release_contract::str(request.object.at("expectedPreviousSHA")) || + !acceptance_contract::transition(old, candidate)) + return ERROR_ACCESS_DENIED; + release = trustedRelease(root); + auto assignment = release_contract::deployment( + root, release, sid, floor.deployment, floor.deploymentDigest); + auto releaseJson = release_contract::signedPayload( + root + L"\\release-envelope.json", + release_contract::unhex(RELEASE_ANCHOR_XY), + std::string("SafetyNet Windows release v1\0", + sizeof("SafetyNet Windows release v1\0") - 1)); + auto deploymentJson = release_contract::signedPayload( + root + L"\\deployment-envelope.json", + release_contract::customerKey(release.customerPem), + std::string("SafetyNet Windows deployment v1\0", + sizeof("SafetyNet Windows deployment v1\0") - 1), + 262144); + if (release_contract::number(candidate.object.at("release").object.at( + "minimumSequence")) != release.sequence || + release_contract::str( + candidate.object.at("release").object.at("acceptedDigest")) != + release_contract::digest( + release_contract::canonical(releaseJson)) || + release_contract::number( + candidate.object.at("deployment").object.at("minimumSequence")) != + assignment.deploymentSequence || + release_contract::str( + candidate.object.at("deployment").object.at("acceptedDigest")) != + release_contract::digest( + release_contract::canonical(deploymentJson))) + return ERROR_ACCESS_DENIED; + auto key = release_contract::pemDer(assignment.policyPem); + std::string policyKeyHash = + release_contract::digest(std::string(key.begin(), key.end())); + const std::string ownId = + sid + "|" + assignment.deviceId + "|" + policyKeyHash; + std::map previous; + for (const auto &record : old.object.at("policies").array) + previous.emplace(acceptance_contract::identity(record), &record); + bool hasOwn = false; + for (const auto &record : candidate.object.at("policies").array) { + auto id = acceptance_contract::identity(record); + if (id == ownId) { + hasOwn = true; + if (release_contract::number(record.object.at("minimumVersion")) < + assignment.minimumVersion) + return ERROR_ACCESS_DENIED; + } else { + auto found = previous.find(id); + if (found == previous.end() || + release_contract::canonical(*found->second) != + release_contract::canonical(record)) + return ERROR_ACCESS_DENIED; + } + } + if (!hasOwn) + return ERROR_ACCESS_DENIED; + HKEY registry = nullptr; + if (RegOpenKeyExW(HKEY_LOCAL_MACHINE, L"SOFTWARE\\SafetyNet\\Trust", 0, + KEY_SET_VALUE | KEY_QUERY_VALUE | KEY_WOW64_64KEY, + ®istry) != ERROR_SUCCESS) + return ERROR_ACCESS_DENIED; + // One canonical value, flushed before acknowledgment. These APIs are NOT + // exercised by the uninstalled tests. Caller must have verified the actual + // signed policy through service-acceptance before asking to commit. + DWORD result = + RegSetValueExW(registry, L"AcceptanceState", 0, REG_BINARY, + reinterpret_cast(candidateText.data()), + DWORD(candidateText.size())); + if (result == ERROR_SUCCESS) + result = RegFlushKey(registry); + RegCloseKey(registry); + if (result != ERROR_SUCCESS) + return ERROR_ACCESS_DENIED; + auto committed = trustFloor(); + if (committed.stateCanonical != candidateText) + return ERROR_ACCESS_DENIED; + return boundedStdioWrite("{\"persisted\":true,\"stateSha256\":" + + release_contract::escaped( + release_contract::digest(candidateText)) + + "}") + ? 0 + : ERROR_ACCESS_DENIED; + } catch (...) { + return ERROR_ACCESS_DENIED; + } +} +static bool serve(HANDLE pipe, const std::wstring &root) { + Handle clientProcess; + if (!pipeClient(pipe, root, &clientProcess.h) || !resources(root) || + !serviceIdentity()) + return false; + try { + auto release = trustedRelease(root); + auto floor = trustFloor(); + release_contract::deployment(root, release, currentSid(clientProcess), + floor.deployment, floor.deploymentDigest); + } catch (...) { + return false; + } + Handle job(CreateJobObjectW(nullptr, nullptr)); + JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{}; + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + if (!job.h || !SetInformationJobObject(job, JobObjectExtendedLimitInformation, + &limits, sizeof(limits))) + return false; + SECURITY_ATTRIBUTES sa{sizeof(sa), nullptr, TRUE}; + Handle inputRead, inputWrite, outputRead, outputWrite, + nul(CreateFileW(L"NUL", GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, + &sa, OPEN_EXISTING, 0, nullptr)); + if (!CreatePipe(&inputRead.h, &inputWrite.h, &sa, 0) || + !CreatePipe(&outputRead.h, &outputWrite.h, &sa, 0) || + nul.h == INVALID_HANDLE_VALUE) + return false; + SetHandleInformation(inputWrite, HANDLE_FLAG_INHERIT, 0); + SetHandleInformation(outputRead, HANDLE_FLAG_INHERIT, 0); + SIZE_T bytes = 0; + InitializeProcThreadAttributeList(nullptr, 1, 0, &bytes); + std::vector attributes(bytes); + STARTUPINFOEXW startup{}; + startup.StartupInfo.cb = sizeof(startup); + startup.lpAttributeList = + reinterpret_cast(attributes.data()); + if (!InitializeProcThreadAttributeList(startup.lpAttributeList, 1, 0, &bytes)) + return false; + HANDLE inherited[] = {inputRead.h, outputWrite.h, nul.h}; + bool configured = + UpdateProcThreadAttribute(startup.lpAttributeList, 0, + PROC_THREAD_ATTRIBUTE_HANDLE_LIST, inherited, + sizeof(inherited), nullptr, nullptr) != FALSE; + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + startup.StartupInfo.hStdInput = inputRead; + startup.StartupInfo.hStdOutput = outputWrite; + startup.StartupInfo.hStdError = nul; + std::wstring executable = root + L"\\resources\\broker\\node.exe", + command = L"\"" + executable + L"\" \"" + root + + L"\\resources\\broker\\apps\\network-" + L"broker\\service-entry.cjs\" --service-stdio", + env = environment(currentSid(clientProcess)); + PROCESS_INFORMATION information{}; + BOOL launched = + configured && + CreateProcessW( + executable.c_str(), command.data(), nullptr, nullptr, TRUE, + CREATE_SUSPENDED | CREATE_NO_WINDOW | CREATE_UNICODE_ENVIRONMENT | + EXTENDED_STARTUPINFO_PRESENT, + env.data(), root.c_str(), &startup.StartupInfo, &information); + DeleteProcThreadAttributeList(startup.lpAttributeList); + if (!launched) + return false; + Handle process(information.hProcess), thread(information.hThread); + if (!AssignProcessToJobObject(job, process)) { + TerminateProcess(process, 1); + WaitForSingleObject(process, 5000); + return false; + } + CloseHandle(inputRead.h); + inputRead.h = nullptr; + CloseHandle(outputWrite.h); + outputWrite.h = nullptr; + if (ResumeThread(thread) == DWORD(-1)) { + TerminateJobObject(job, 1); + WaitForSingleObject(process, 5000); + return false; + } + // Native relay carries framed data only. Typed START and v1 sequence/receipt + // validation belongs to the reviewed constant Node service-stdio adapter. + Handle serveThread; + if (!DuplicateHandle(GetCurrentProcess(), GetCurrentThread(), + GetCurrentProcess(), &serveThread.h, THREAD_TERMINATE, + FALSE, 0)) { + TerminateJobObject(job, 1); + return false; + } + std::atomic writeDeadline{0}, readDeadline{0}; + std::atomic good{true}; + std::thread watchdog([&] { + while (good) { + if (WaitForSingleObject(stopEvent, 50) == WAIT_OBJECT_0 || + WaitForSingleObject(clientProcess, 0) != WAIT_TIMEOUT || + (writeDeadline.load() != 0 && + GetTickCount64() >= writeDeadline.load()) || + (readDeadline.load() != 0 && + GetTickCount64() >= readDeadline.load())) { + good = false; + TerminateJobObject(job, 1); + CancelSynchronousIo(serveThread); + CancelIoEx(pipe, nullptr); + break; + } + } + }); + std::thread outgoing([&] { + while (good) { + DWORD size = 0, done = 0; + if (!ReadFile(outputRead, &size, 4, &done, nullptr) || !done) { + good = false; + break; + } + readDeadline = GetTickCount64() + 15000; + DWORD headerOffset = done; + while (headerOffset < 4) { + if (!ReadFile(outputRead, + reinterpret_cast(&size) + headerOffset, + 4 - headerOffset, &done, nullptr) || + !done) { + good = false; + break; + } + headerOffset += done; + } + if (!good || !size || size > 65536) { + good = false; + break; + } + std::vector value(size); + DWORD offset = 0; + while (offset < size) { + if (!ReadFile(outputRead, value.data() + offset, size - offset, &done, + nullptr) || + !done) { + good = false; + break; + } + offset += done; + } + if (!good || !frame(pipe, true, value, 65536)) { + good = false; + break; + } + readDeadline = 0; + } + CancelIoEx(pipe, nullptr); + }); + bool first = true; + while (good && WaitForSingleObject(stopEvent, 0) == WAIT_TIMEOUT) { + std::vector value; + if (!frame(pipe, false, value, first ? 2097152 : 65536, + first ? 15000 : INFINITE)) + break; + first = false; + if (!writeChildFrame(inputWrite, value, writeDeadline)) + break; + } + good = false; + TerminateJobObject(job, 1); + CancelSynchronousIo(outgoing.native_handle()); + CancelIoEx(pipe, nullptr); + outgoing.join(); + watchdog.join(); + return WaitForSingleObject(process, 5000) == WAIT_OBJECT_0; +} +static void report(DWORD state, DWORD error = 0) { + SERVICE_STATUS status{}; + status.dwServiceType = SERVICE_WIN32_OWN_PROCESS; + status.dwCurrentState = state; + status.dwWin32ExitCode = error; + status.dwControlsAccepted = + state == SERVICE_RUNNING ? SERVICE_ACCEPT_STOP | SERVICE_ACCEPT_SHUTDOWN + : 0; + status.dwWaitHint = state == SERVICE_STOP_PENDING ? 5000 : 0; + SetServiceStatus(statusHandle, &status); +} +static DWORD WINAPI control(DWORD code, DWORD, void *, void *) { + if (code == SERVICE_CONTROL_STOP || code == SERVICE_CONTROL_SHUTDOWN) { + report(SERVICE_STOP_PENDING); + SetEvent(stopEvent); + } + return NO_ERROR; +} +static void WINAPI serviceMain(DWORD, wchar_t **) { + statusHandle = RegisterServiceCtrlHandlerExW(SERVICE_NAME, control, nullptr); + if (!statusHandle) + return; + report(SERVICE_START_PENDING); + Handle stop(CreateEventW(nullptr, TRUE, FALSE, nullptr)); + stopEvent = stop; + std::wstring root = rootPath(); + if (!stop.h || !serviceIdentity() || !resources(root)) { + report(SERVICE_STOPPED, ERROR_ACCESS_DENIED); + return; + } + PSECURITY_DESCRIPTOR security = nullptr; + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW( + L"D:P(A;;GA;;;SY)(A;;0x0012019b;;;AU)", SDDL_REVISION_1, &security, + nullptr)) { + report(SERVICE_STOPPED, ERROR_ACCESS_DENIED); + return; + } + SECURITY_ATTRIBUTES sa{sizeof(sa), security, FALSE}; + Handle pipe(CreateNamedPipeW(PIPE_NAME, + PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED | + FILE_FLAG_FIRST_PIPE_INSTANCE, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | + PIPE_REJECT_REMOTE_CLIENTS, + 1, 65536, 65536, 15000, &sa)); + LocalFree(security); + if (pipe.h == INVALID_HANDLE_VALUE) { + report(SERVICE_STOPPED, ERROR_ACCESS_DENIED); + return; + } + report(SERVICE_RUNNING); + while (WaitForSingleObject(stopEvent, 0) == WAIT_TIMEOUT) { + Handle event(CreateEventW(nullptr, TRUE, FALSE, nullptr)); + OVERLAPPED ov{}; + ov.hEvent = event; + BOOL connected = ConnectNamedPipe(pipe, &ov); + DWORD code = connected ? ERROR_SUCCESS : GetLastError(); + if (code == ERROR_IO_PENDING) { + HANDLE events[] = {event.h, stopEvent}; + if (WaitForMultipleObjects(2, events, FALSE, INFINITE) != WAIT_OBJECT_0) { + CancelIoEx(pipe, &ov); + WaitForSingleObject(event, INFINITE); + break; + } + DWORD bytes = 0; + connected = GetOverlappedResult(pipe, &ov, &bytes, FALSE); + } else + connected = code == ERROR_PIPE_CONNECTED; + if (connected) + serve(pipe, root); + DisconnectNamedPipe(pipe); + } + report(SERVICE_STOPPED); +} +#ifdef SAFETYNET_NATIVE_SELF_TEST +static bool ownerRightsTest() { + auto user = currentSid(GetCurrentProcess()); + std::wstring sid(user.begin(), user.end()); + std::wstring security = L"D:P(A;;0x00120001;;;" + sid + L")(A;;RC;;;OW)"; + PSECURITY_DESCRIPTOR descriptor = nullptr; + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW( + security.c_str(), SDDL_REVISION_1, &descriptor, nullptr)) + return false; + SECURITY_ATTRIBUTES attributes{sizeof(attributes), descriptor, FALSE}; + auto name = L"Local\\SafetyNet.OwnerRights.Proof." + + std::to_wstring(GetCurrentProcessId()); + Handle mutex(CreateMutexW(&attributes, FALSE, name.c_str())); + LocalFree(descriptor); + if (!mutex.h) + return false; + Handle denied(OpenMutexW(WRITE_DAC, FALSE, name.c_str())); + DWORD deniedCode = GetLastError(); + if (denied.h || deniedCode != ERROR_ACCESS_DENIED) + return false; + Handle allowed(OpenMutexW(SYNCHRONIZE | MUTEX_MODIFY_STATE | READ_CONTROL, + FALSE, name.c_str())); + if (!allowed.h) + return false; + PSECURITY_DESCRIPTOR actual = nullptr; + PSID owner = nullptr; + PACL dacl = nullptr; + if (GetSecurityInfo(allowed, SE_KERNEL_OBJECT, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + &owner, nullptr, &dacl, nullptr, + &actual) != ERROR_SUCCESS) + return false; + PSID expected = nullptr; + ConvertStringSidToSidA(user.c_str(), &expected); + PSID ownerRights = nullptr; + ConvertStringSidToSidW(L"S-1-3-4", &ownerRights); + bool valid = owner && expected && EqualSid(owner, expected); + unsigned count = 0; + if (valid && dacl) + for (DWORD i = 0; i < dacl->AceCount; i++) { + void *raw = nullptr; + if (!GetAce(dacl, i, &raw)) { + valid = false; + break; + } + auto header = static_cast(raw); + if (header->AceType == ACCESS_ALLOWED_ACE_TYPE) { + auto ace = static_cast(raw); + if (EqualSid(&ace->SidStart, ownerRights)) { + count++; + if (ace->Mask != READ_CONTROL) + valid = false; + } + } + } + if (expected) + LocalFree(expected); + if (ownerRights) + LocalFree(ownerRights); + if (actual) + LocalFree(actual); + return valid && count == 1; +} +static bool writeDeadlineTest() { + Handle read, write; + if (!CreatePipe(&read.h, &write.h, nullptr, 4096)) + return false; + std::atomic deadline{0}; + std::vector samplePayload(16, 42); + if (!writeChildFrame(write, samplePayload, deadline, 30) || + deadline.load() != 0) + return false; + DWORD done = 0; + std::array consumed{}; + if (!ReadFile(read, consumed.data(), 20, &done, nullptr) || done != 20) + return false; + Sleep(60); + if (deadline.load() != 0) + return false; // healthy idle exceeds test budget + std::atomic result{true}; + std::vector stalledPayload(262144, 43); + std::thread blocked( + [&] { result = writeChildFrame(write, stalledPayload, deadline, 30); }); + for (int i = 0; i < 100 && deadline.load() == 0; i++) + Sleep(1); + Sleep(60); + if (deadline.load() == 0) { + CancelSynchronousIo(blocked.native_handle()); + blocked.join(); + return false; + } + if (!CancelSynchronousIo(blocked.native_handle())) { + CloseHandle(read.h); + read.h = nullptr; + } + blocked.join(); + return !result && deadline.load() != 0; +} +static bool pipeNegativeTest() { + std::wstring name = L"\\\\.\\pipe\\SafetyNet.NativeTest." + + std::to_wstring(GetCurrentProcessId()); + PSECURITY_DESCRIPTOR security = nullptr; + if (!ConvertStringSecurityDescriptorToSecurityDescriptorW( + L"D:P(A;;GA;;;SY)(A;;0x0012019b;;;AU)", SDDL_REVISION_1, &security, + nullptr)) + return false; + SECURITY_ATTRIBUTES sa{sizeof(sa), security, FALSE}; + Handle server(CreateNamedPipeW(name.c_str(), + PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED | + FILE_FLAG_FIRST_PIPE_INSTANCE, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | + PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS, + 2, 4096, 4096, 1000, &sa)); + LocalFree(security); + if (server.h == INVALID_HANDLE_VALUE) + return false; + // Attempt a second instance WITHOUT FIRST_PIPE_INSTANCE: DACL rather than + // creation-flag coincidence must deny FILE_CREATE_PIPE_INSTANCE to this user. + Handle attacker(CreateNamedPipeW(name.c_str(), PIPE_ACCESS_DUPLEX, + PIPE_TYPE_BYTE | PIPE_WAIT, 2, 4096, 4096, + 1000, nullptr)); + if (attacker.h != INVALID_HANDLE_VALUE || + GetLastError() != ERROR_ACCESS_DENIED) + return false; + Handle client(CreateFileW(name.c_str(), GENERIC_READ | GENERIC_WRITE, 0, + nullptr, OPEN_EXISTING, 0, nullptr)); + if (client.h == INVALID_HANDLE_VALUE) + return false; + ULONG actualServerPid = 0; + if (!GetNamedPipeServerProcessId(client, &actualServerPid) || + actualServerPid != GetCurrentProcessId()) + return false; + Handle actualServer(OpenProcess( + PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE, FALSE, actualServerPid)); + if (!actualServer.h || serviceIdentity(actualServer)) + return false; + Handle event(CreateEventW(nullptr, TRUE, FALSE, nullptr)); + OVERLAPPED ov{}; + ov.hEvent = event; + BOOL connected = ConnectNamedPipe(server, &ov); + if (!connected && GetLastError() != ERROR_PIPE_CONNECTED) + return false; + if (pipeClient(server, rootPath())) + return false; + DWORD invalid = 0xffffffff, done = 0; + if (!WriteFile(client, &invalid, 4, &done, nullptr) || done != 4) + return false; + std::vector frameBytes; + return !frame(server, false, frameBytes, 65536, 1000); +} +static int selfTest() { + Handle stop(CreateEventW(nullptr, TRUE, FALSE, nullptr)); + stopEvent = stop; + if (!ownerRightsTest()) + return 84; + if (!pipeNegativeTest()) + return 89; + if (!writeDeadlineTest()) + return 88; + if (serviceIdentity()) + return 90; + Handle job(CreateJobObjectW(nullptr, nullptr)); + JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{}; + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, &limits, + sizeof(limits))) + return 91; + auto root = rootPath(); + std::wstring command = + L"\"" + root + L"\\safetynet-service-host-test.exe\" --job-child"; + STARTUPINFOW startup{}; + startup.cb = sizeof(startup); + PROCESS_INFORMATION information{}; + if (!CreateProcessW(nullptr, command.data(), nullptr, nullptr, FALSE, + CREATE_SUSPENDED | CREATE_NO_WINDOW, nullptr, + root.c_str(), &startup, &information)) + return 92; + Handle process(information.hProcess), thread(information.hThread); + if (!AssignProcessToJobObject(job, process)) + return 93; + BOOL member = FALSE; + if (!IsProcessInJob(process, job, &member) || !member) + return 94; + ResumeThread(thread); + Handle descendant; + for (int attempt = 0; attempt < 100 && !descendant.h; attempt++) { + Handle snapshot(CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)); + PROCESSENTRY32W item{}; + item.dwSize = sizeof(item); + if (Process32FirstW(snapshot, &item)) + do { + if (item.th32ParentProcessID == information.dwProcessId) { + descendant.h = + OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, + FALSE, item.th32ProcessID); + break; + } + } while (Process32NextW(snapshot, &item)); + if (!descendant.h) + Sleep(20); + } + if (!descendant.h || !IsProcessInJob(descendant, job, &member) || !member) + return 98; + if (WaitForSingleObject(process, 50) != WAIT_TIMEOUT) + return 95; + CloseHandle(job.h); + job.h = nullptr; + if (WaitForSingleObject(process, 5000) != WAIT_OBJECT_0 || + WaitForSingleObject(descendant, 5000) != WAIT_OBJECT_0) + return 96; + if (hashFile( + root + L"\\safetynet-service-host-test.exe", + "0000000000000000000000000000000000000000000000000000000000000000")) + return 97; + puts("{\"ownerWriteDacDenied\":true,\"ownerReadSyncModifyAllowed\":true," + "\"healthyIdleWriteDeadlineCleared\":true,\"stalledWriteCancelled\":" + "true,\"sameConnectedHandleServerIdentified\":true," + "\"wrongServerServiceIdentityDenied\":true,\"pipeSecondInstanceDenied\":" + "true,\"unauthorizedClientDenied\":true,\"oversizedFrameDenied\":true," + "\"serviceIdentityRejected\":true,\"jobMembership\":true," + "\"descendantJobMembership\":true,\"killOnCloseTerminal\":true," + "\"wrongHashRejected\":true}"); + return 0; +} +#endif +int wmain(int argc, wchar_t **argv) { +#ifdef SAFETYNET_NATIVE_SELF_TEST + if (argc == 2 && !wcscmp(argv[1], L"--verify-credential-selection")) + return credential_contract::selectionSelfTest() ? 0 : 86; + if (argc == 2 && !wcscmp(argv[1], L"--verify-state-fixture")) { + try { + auto old = acceptance_contract::validate( + release_contract::read(rootPath() + L"\\acceptance-old.json")); + auto next = acceptance_contract::validate( + release_contract::read(rootPath() + L"\\acceptance-candidate.json")); + return acceptance_contract::transition(old, next) ? 0 : 86; + } catch (...) { + return 86; + } + } + if (argc == 2 && !wcscmp(argv[1], L"--verify-equivocation")) { + try { + release_contract::release(rootPath(), + release_contract::unhex(RELEASE_ANCHOR_XY), 2, + std::string(64, '0')); + return 85; + } catch (...) { + return 86; + } + } + if (argc == 2 && !wcscmp(argv[1], L"--verify-fixture")) { + try { + auto manifest = release_contract::release( + rootPath(), release_contract::unhex(RELEASE_ANCHOR_XY), 1); + auto assignment = release_contract::deployment(rootPath(), manifest, + "S-1-5-21-1-2-3-1001", 1); + if (assignment.minimumVersion != 3 || + !hashFile( + rootPath() + L"\\safetynet-service-host-test.exe", + manifest.hashes.at("safetynet-service-host-test.exe").c_str())) + return 87; + puts("{\"nativeVendorSignatureVerified\":true," + "\"nativeDelegatedCustomerSignatureVerified\":true," + "\"nativeSidAssignmentVerified\":true}"); + return 0; + } catch (...) { + return 86; + } + } + if (argc == 2 && !wcscmp(argv[1], L"--self-test")) + return selfTest(); + if (argc == 2 && !wcscmp(argv[1], L"--job-grandchild")) { + Sleep(INFINITE); + return 0; + } + if (argc == 2 && !wcscmp(argv[1], L"--job-child")) { + std::wstring command = + L"\"" + rootPath() + + L"\\safetynet-service-host-test.exe\" --job-grandchild"; + STARTUPINFOW startup{}; + startup.cb = sizeof(startup); + PROCESS_INFORMATION info{}; + if (!CreateProcessW(nullptr, command.data(), nullptr, nullptr, FALSE, + CREATE_NO_WINDOW, nullptr, nullptr, &startup, &info)) + return 99; + CloseHandle(info.hProcess); + CloseHandle(info.hThread); + Sleep(INFINITE); + return 0; + } +#endif + if (argc == 2 && !wcscmp(argv[1], L"--client-stdio")) + return clientStdio(); + if (argc == 2 && !wcscmp(argv[1], L"--trust-snapshot")) + return trustSnapshot(); + if (argc == 2 && !wcscmp(argv[1], L"--topology-snapshot")) + return topologySnapshot(); + if (argc == 2 && !wcscmp(argv[1], L"--commit-acceptance")) + return commitAcceptance(); + if (argc == 2 && !wcscmp(argv[1], L"--management-credential")) + return managementCredential(false); + if (argc == 2 && !wcscmp(argv[1], L"--provision-management-credential")) + return managementCredential(true); + if (argc != 1) + return ERROR_INVALID_PARAMETER; + SERVICE_TABLE_ENTRYW table[] = { + {const_cast(SERVICE_NAME), serviceMain}, {nullptr, nullptr}}; + return StartServiceCtrlDispatcherW(table) ? 0 : int(GetLastError()); +} diff --git a/apps/network-broker/native/signed-manifest.h b/apps/network-broker/native/signed-manifest.h new file mode 100644 index 0000000..16c7123 --- /dev/null +++ b/apps/network-broker/native/signed-manifest.h @@ -0,0 +1,790 @@ +// Strict bounded native signed-release verification. No signer key in +// envelopes. +#pragma once +#include +#include +#include +#include +#include +#include +#include +#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 object; + std::vector 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(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(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 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 unhex(const std::string &input) { + if (input.empty() || input.size() % 2) + throw std::runtime_error("fixed-manifest-invalid"); + std::vector 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 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(const_cast(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 &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 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 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 &xy, const std::string &payload, + const std::vector &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 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 digest{}; + if (BCryptHashData( + hash, reinterpret_cast(const_cast(domain.data())), + ULONG(domain.size()), 0) >= 0 && + BCryptHashData( + hash, reinterpret_cast(const_cast(payload.data())), + ULONG(payload.size()), 0) >= 0 && + BCryptFinishHash(hash, digest.data(), 32, 0) >= 0) + okay = BCryptVerifySignature(key, nullptr, digest.data(), 32, + const_cast(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 &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 hashes; +}; +static Release release(const std::wstring &root, + const std::vector &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 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 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 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 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(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 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(&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 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(device[0])) || + !devices.insert(device).second) + throw std::runtime_error("fixed-manifest-invalid"); + for (char c : device) + if (!isalnum(static_cast(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 diff --git a/apps/network-broker/native/wfp-topology.h b/apps/network-broker/native/wfp-topology.h new file mode 100644 index 0000000..9ffec07 --- /dev/null +++ b/apps/network-broker/native/wfp-topology.h @@ -0,0 +1,378 @@ +#pragma once +// clang-format off +#include +#include +#include +#include +// clang-format on +#include +#include +#include +#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 &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 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(raw); + if (header->AceFlags & INHERIT_ONLY_ACE) + continue; + if (header->AceType == ACCESS_ALLOWED_ACE_TYPE) { + auto ace = static_cast(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(&descriptor)); + return okay; +} +struct Rule { + GUID key, layer; + FWP_ACTION_TYPE action; + UINT64 weight; + std::vector 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 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(&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 sd(reinterpret_cast(descriptor), + reinterpret_cast(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 &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 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 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 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 diff --git a/apps/network-broker/policy-authority.cjs b/apps/network-broker/policy-authority.cjs new file mode 100644 index 0000000..7151809 --- /dev/null +++ b/apps/network-broker/policy-authority.cjs @@ -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}; diff --git a/apps/network-broker/process-entry.cjs b/apps/network-broker/process-entry.cjs new file mode 100644 index 0000000..36cdb4e --- /dev/null +++ b/apps/network-broker/process-entry.cjs @@ -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}; diff --git a/apps/network-broker/production-entry.cjs b/apps/network-broker/production-entry.cjs new file mode 100644 index 0000000..bafd26a --- /dev/null +++ b/apps/network-broker/production-entry.cjs @@ -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}; diff --git a/apps/network-broker/production-factory.cjs b/apps/network-broker/production-factory.cjs new file mode 100644 index 0000000..bdc8cc7 --- /dev/null +++ b/apps/network-broker/production-factory.cjs @@ -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}; diff --git a/apps/network-broker/proxy-forwarder.cjs b/apps/network-broker/proxy-forwarder.cjs new file mode 100644 index 0000000..b71ac5b --- /dev/null +++ b/apps/network-broker/proxy-forwarder.cjs @@ -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}; diff --git a/apps/network-broker/service-acceptance.cjs b/apps/network-broker/service-acceptance.cjs new file mode 100644 index 0000000..4cc2ee1 --- /dev/null +++ b/apps/network-broker/service-acceptance.cjs @@ -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: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}; diff --git a/apps/network-broker/service-bootstrap.cjs b/apps/network-broker/service-bootstrap.cjs new file mode 100644 index 0000000..38587d6 --- /dev/null +++ b/apps/network-broker/service-bootstrap.cjs @@ -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{if(closeWork)return closeWork;closed=true;clearTimeout(timer);closeWork=Promise.resolve().then(()=>created.close());return closeWork;}; + const live=()=>!closed&&Date.now(){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}; diff --git a/apps/network-broker/service-commit-gate.cjs b/apps/network-broker/service-commit-gate.cjs new file mode 100644 index 0000000..9af7f37 --- /dev/null +++ b/apps/network-broker/service-commit-gate.cjs @@ -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}; diff --git a/apps/network-broker/service-entry.cjs b/apps/network-broker/service-entry.cjs new file mode 100644 index 0000000..cd15c19 --- /dev/null +++ b/apps/network-broker/service-entry.cjs @@ -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}; diff --git a/apps/network-broker/service-store-client.cjs b/apps/network-broker/service-store-client.cjs new file mode 100644 index 0000000..79cd8cb --- /dev/null +++ b/apps/network-broker/service-store-client.cjs @@ -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(count30000)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}; diff --git a/apps/network-broker/tls-authority.cjs b/apps/network-broker/tls-authority.cjs new file mode 100644 index 0000000..e912cfe --- /dev/null +++ b/apps/network-broker/tls-authority.cjs @@ -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;indexcharacter.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}; diff --git a/apps/network-broker/tls-certificates.cjs b/apps/network-broker/tls-certificates.cjs new file mode 100644 index 0000000..f27f9da --- /dev/null +++ b/apps/network-broker/tls-certificates.cjs @@ -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}; diff --git a/apps/network-broker/wfp-revalidation.cjs b/apps/network-broker/wfp-revalidation.cjs new file mode 100644 index 0000000..3ed9760 --- /dev/null +++ b/apps/network-broker/wfp-revalidation.cjs @@ -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}; diff --git a/apps/network-broker/wfp-topology.cjs b/apps/network-broker/wfp-topology.cjs new file mode 100644 index 0000000..323e0af --- /dev/null +++ b/apps/network-broker/wfp-topology.cjs @@ -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}; diff --git a/apps/network-broker/windows-wfp-plan.cjs b/apps/network-broker/windows-wfp-plan.cjs new file mode 100644 index 0000000..ae4570e --- /dev/null +++ b/apps/network-broker/windows-wfp-plan.cjs @@ -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}; diff --git a/apps/network-broker/windows_wfp_install.py b/apps/network-broker/windows_wfp_install.py new file mode 100644 index 0000000..c01fd69 --- /dev/null +++ b/apps/network-broker/windows_wfp_install.py @@ -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() diff --git a/apps/network-broker/windows_wfp_probe.py b/apps/network-broker/windows_wfp_probe.py new file mode 100644 index 0000000..1578c88 --- /dev/null +++ b/apps/network-broker/windows_wfp_probe.py @@ -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 016 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) diff --git a/apps/network-broker/windows_wfp_receiver.py b/apps/network-broker/windows_wfp_receiver.py new file mode 100644 index 0000000..b3daf96 --- /dev/null +++ b/apps/network-broker/windows_wfp_receiver.py @@ -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) diff --git a/apps/network-broker/windows_wfp_topology.py b/apps/network-broker/windows_wfp_topology.py new file mode 100644 index 0000000..0b0bb78 --- /dev/null +++ b/apps/network-broker/windows_wfp_topology.py @@ -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() diff --git a/apps/network-broker/work-admission.cjs b/apps/network-broker/work-admission.cjs new file mode 100644 index 0000000..1d122c4 --- /dev/null +++ b/apps/network-broker/work-admission.cjs @@ -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}; diff --git a/docs/broker-siem.md b/docs/broker-siem.md index c8987ea..83d2d46 100644 --- a/docs/broker-siem.md +++ b/docs/broker-siem.md @@ -17,6 +17,12 @@ events. The standard `outcome` field reflects unknown authorization or failure; Microsoft documents its mapping to `EventOutcome` in [CommonSecurityLog](https://learn.microsoft.com/en-us/azure/sentinel/cef-name-mapping). +Typed download records use `download.scan` / `download.block` with the original +request authorization UUID and real file size, magic-derived type and SHA-256. +They never attach a generic request-body hash. A blocked download has a failure +outcome and a denied event type. A successful scan reports inspection success; +it does not prove that the user received or saved the file. + These format and exporter tests do not establish a customer's remote SIEM deployment, retention, key custody or incident-response configuration. Production browser-to-broker integration remains a separate acceptance gate. diff --git a/docs/core-dlp-current-status.md b/docs/core-dlp-current-status.md new file mode 100644 index 0000000..a1aa071 --- /dev/null +++ b/docs/core-dlp-current-status.md @@ -0,0 +1,443 @@ +# Windows DLP: aktuelle Abnahmegrenzen + +Stand: 14. Oktober 2026. Diese Übersicht unterscheidet produktive Integration +von isolierten Baustein- und Empfängertests. Das MVP ist nicht abgenommen. + +| Bereich | Autoritativer aktueller Nachweis | Noch erforderlich | +| --- | --- | --- | +| Unveränderte Runtime | Offizielles Electron; eigener Chromium-Build abgebrochen | Ersatz der abstürzenden WebRTC-Header-Sperre erst nach vollständigem Netzwerk-Nachweis | +| Broker-Transport | Gepackter Production-Child mit privater IPC, authentifiziertem CONNECT, TLS-Ausstellung und scoped Pin-Helper; öffentliche Originalbytes übertragen, Geheimnisse vor Zielverbindung gesperrt | Vollständige Anbindung an den Hauptbrowser und geschützten Windows-Dienst | +| Signierte Regeln | Bestehende Core-Signaturprüfung, Device-Assignment, Ablauf, Widerruf, Generation und Rollback geprüft | Geschützte dauerhafte Mindestversion und vollständiger produktiver Bootstrap | +| Transferprüfung | Header, URLs und Multipart-Verträge; tatsächlicher Production-Child blockiert geheime PDF-Teile in JSON und Prozent-Data-URLs mit lokalem Parser | Hauptbrowser-Abnahme einschließlich aller Clipboard-/Editor-Pfade; echte Unternehmens-Scanner-Endpunkte | +| Downloads | Production-Child prüft Inline-PNG/PDF samt tatsächlichem Datei-Hash, sperrt Attachment bei deaktiviertem Speichern und geheimes PDF ohne PDF-Bytes; kausale Audit-Ereignisse persistiert | Hauptbrowser-SaveAs-, Blob- und Cache-Pfade | +| PDF | Gepaarter nativer AppContainer-/PDFium-/OCR-Test besteht: Geheimnis erkannt, null Zielverbindungen; öffentliches statisches PDF exakt übertragen | Erweiterung und ausdrücklich dokumentierte Grenzen für weitere PDF-Funktionen; finale Browser-/Installer-Abnahme | +| Office | Nativer AppContainer-/Broker-Test besteht für reale DOCX/XLSX: öffentliche Originalbytes übertragen; Geheimnisse in Metadaten/Hidden Sheet erkannt und ohne Zielverbindung blockiert | Weitere Office-Funktionen; komplexes PowerPoint und unbekannte Inhalte bleiben unvollständig und gesperrt | +| Windows-Netzwerkgrenze | Resolver- und STUN-Teilnachweise; WFP-Plan und Installer-Fehlertests. Aktueller nicht erhöhter Token kann WFP öffnen, Filterenumeration scheitert mit Access Denied (5) | Geschützter SCM-Host und Dienst-SID, Receiver-/Portübernahmeschutz, erhöhte Installation und Filter-Readback; IPv4/IPv6/UDP/TCP/Loopback/DNS-Umgehungstests | +| Broker-Runtime | Neunter geprüfter Payload: offizielles Node 24.21.0, 313 Dateien / 64 Quellnachweise, sechs tatsächliche Isolationsprüfungen einschließlich Management- und WFP-Module | Neuere Credential-/Policy-Änderungen erneut paketieren und prüfen; produktiven nativen Host und signierte Installation ergänzen | +| Auslieferung | Standalone-Payload außerhalb des Repositorys getestet; historischer Node-24.20-Nachweis bleibt erhalten | Gemeinsamer finaler Installer mit Broker, Scanner und Modellen; Fresh-System-Test | +| Unternehmens-Proxy | Achter gepackter Production-Child besteht HTTPS-Proxy und aktive IP-Policy mit sechs numerisch gebundenen CONNECT-Routen, ursprünglicher TLS-Hostname-Prüfung und gesperrten kodierten Geheimnissen | Hauptbrowser-/Dienst-Anbindung; PAC/system/SOCKS/NTLM/Kerberos noch nicht unterstützt | +| Diagnose und Audit | Bestehende Browser-Diagnostik sowie tatsächliche isolierte Broker-OTLP-Tests | Broker-Diagnostik und dauerhafte Sicherheitsereignisse im produktiven Prozesspfad; zentrale Abschaltung beibehalten | + +Die nächste Abnahme folgt dieser Reihenfolge: frisches gepacktes Proxy-/Download- +Verhalten, geschützter Dienst samt authentifizierter Benutzeranbindung, vollständiger +Hauptbrowserpfad, gemeinsamer Installer und native Netzwerk-/Clipboard-Umgehungstests. +Bausteintests erlauben kein Entfernen der bestehenden WebRTC-Sperre. Management, +Inspector und autorisierte OTLP-Exporte müssen vor einer Browser-EXE-Netzwerksperre +über ausdrücklich erlaubte Dienstoperationen laufen; eine breite Ausnahme wäre +kein Ersatz für diesen Pfad. + +Konkrete Transport-Migration vor Aktivierung: + +- Enrollment und Management benötigen einen im signierten Deployment gebundenen + HTTPS-Origin und den bestehenden ML-DSA-87-Kanal-Pin. Das bisherige Deployment + v1 enthält diese Bindung nicht; ein vom Renderer eingegebener Server darf keine + allgemeine Dienst-Fetch-Berechtigung erzeugen. Versionierter Vertrag in Arbeit. +- Device-Identität, Challenge, verschlüsselte Policy/Evidence, Heartbeat, Ereignisse, + Bookmarks und Library brauchen begrenzte typisierte Operationen statt beliebiger + URL-/Header-/Token-Weiterleitung. Pre-Enrollment bleibt gesondert nachzuweisen. +- Browserseitige Clipboard-/AI-/manuelle Scans verwenden noch authentifiziertes + Loopback-HTTP zum Inspector. Sie müssen über den Dienst laufen; bereits + brokerseitig erfolgreiche Upload-Scans beweisen diese Browser-Pfade nicht. +- Große Dokumente brauchen begrenzte Chunk-Transfers oder geschützte Handles; + der 2-MiB-Bootstrap und spätere 64-KiB-Frames sind keine 16-MiB-Datei-RPC. +- Autorisierte OTLP-Exporte brauchen ihren eigenen begrenzten Dienstpfad. + Agentensteuerung muss vom Browser-Loopback-Listener auf authentifizierte lokale + Pipes wechseln und vorhandene PQ-, Grant- und Replay-Prüfungen erhalten. + +Der Dienstadapter ist unabhängig auf seine Paketauflösung geprüft: tatsächliches +electron-builder File-Matching, ASAR-Erstellung/Extraktion und isolierter Modul- +Load bestehen. Ausschließlich `apps/network-broker/framed-channel.cjs` wird als +zusätzliche Browser-Abhängigkeit eingebettet, kein allgemeines Broker-Wildcard. +Dies beweist keine fertig installierte Dienstverbindung. + +Aktuelle Broker-Regression (ohne den separat zu prüfenden Payload-Staging-Test): +188 Fälle, davon 181 bestanden und sieben ausdrücklich aktivierbare native +Prüfungen übersprungen; keine Fehler. Die übersprungenen Tests werden nicht als +native Abnahme gezählt. Die vier gezielten Diagnose-/Factory-/Download-/Proxy- +Dateien bestanden zuvor mit 28 Fällen einschließlich tatsächlichem privatem +OTLP-Empfänger. Der vorhandene geschützte Collector meldet null Commitfehler und +null abgewiesene Schreibzugriffe; der bestehende Trace +`e5ebf887a1ad8d90f065739a86cd2261` wurde mit 55 Spans abgerufen. Das beweist keine +neuen produktiven Broker-Exporte. Lokale Evidenz: +`artifacts/core-broker-development-check-2026-10-14.json`. + +Der erste native Lauf des fünften Payloads mit tatsächlichem HTTPS-Unternehmens- +Proxy ist fehlgeschlagen: öffentlicher Roh-Upload wurde übertragen, decodierte +Dokumentprüfungen erreichten jedoch das feste fünfsekündige Request-Limit statt +eines vollständigen Scannerurteils. Der Inspector erlaubt bis zu 29 Sekunden +begrenzte Arbeit. Diese Zeitbudget-Inkompatibilität und die Unterdrückung später +Urteile nach einem bereits protokollierten Timeout sind vor einer erneuten +Proxy-Abnahme zu korrigieren. Ein funktionierender Roh-Upload beweist hier keine +Dokumentabdeckung. Der Testprozess endete; ursprüngliche Fehlerevidenz bleibt. + +Nach Korrektur auf ein absolutes 30-Sekunden-Gesamtbudget besteht der sechste +gepackte native Lauf (1/1, 26,5 Sekunden): geheime Raw-/JSON-/Data-URL-Inhalte +werden mit tatsächlicher Scanner-Regel vor zusätzlicher Zielverbindung gesperrt. +Öffentliche äußere JSON-Bytes bleiben unverändert. PNG-Inline-Inhalt funktioniert +trotz allowDownloads=false, Attachment-Speicherung wird gesperrt. Öffentliches +Inline-PDF funktioniert, geheimes PDF gibt keine PDF-Bytes frei. 13 persistierte +Ereignisse enthalten geprüfte echte Dateigrößen und SHA-256 sowie kausale UUIDs. +Privater Testempfänger: 22 echte OTLP-Spans, 33.635 Bytes, null ungültige Exporte, +null aktuelle/lebenslange Exportfehler; dauerhafte Trace-ID +`2943810ad3499cd0d331a45206b2780d` wurde tatsächlich abgerufen, Audit-Span und +Request-Elternspan verknüpft. Supervisor bestätigt geschlossen, ohne Quarantäne. +Nachweis: `artifacts/broker-production-native/proof-2026-10-14T10-31-22.318Z.json`. +Die Spans stammen vom privilegierten Test-Parent, nicht vom produktiven Child- +Exporter. Hauptbrowser, Windows-Dienst und Installer bleiben separate Gates. + +Der Transportbaustein für den nativen Dienst verwendet längenbegrenztes UTF-8- +JSON (initial maximal 2 MiB, danach 64 KiB), höchstens acht ausstehende Writes +und feste Fehlercodes; acht Tests einschließlich fragmentierter Frames, +ungültigem UTF-8 und tatsächlichem Duplex-Transport des bestehenden Audit-RPC +bestehen. Disconnect bestätigt ausstehende Writes nicht als erfolgreich; +ein später Callback kann kein zweites Ergebnis liefern. Dieser Baustein +authentifiziert keinen Peer und ist +noch nicht aktiviert. Die native Dienst-/Client-Identitätsprüfung vor dem ersten +Frame sowie die Produktionsanbindung bleiben separate Abnahmebedingungen. + +Der eigene native Dienst-Host wurde mit MSVC 14.44 als Testvariante kompiliert +(kein Chromium-Neubau). Ein tatsächlicher Windows-Selbsttest beweist die +Ablehnung fehlender Dienst-SID, Job-Mitgliedschaft von Kind und Enkel, deren +beendeten Zustand nach Job-Schließen und die Ablehnung eines falschen Hashes. +`artifacts/native-service-host/proof.json` bindet diesen Nachweis an den damaligen +Quell-/Binary-Hash und weist installed=false sowie wfpActivated=false aus. +Die unabhängige Quellprüfung verlangt weitere Korrekturen vor Aktivierung: +Pipe-DACL ohne FILE_CREATE_PIPE_INSTANCE für Benutzer, exakter geschützter +Browserpfad, geschützter Deployment-Vertrauensanker, absolute Frame-Zeitgrenzen +und abbrechbare Writes. Der Selbsttest beweist keine Dienstinstallation oder +authentifizierte produktive Benutzerverbindung. + +Nachfolgende native Testvariante: explizite Benutzer-Pipe-Rechte verweigern eine +zweite Instanz auch ohne FIRST_PIPE_INSTANCE; unberechtigter Client und zu großer +Frame werden tatsächlich abgewiesen. Job-Tests bleiben grün. Ein zusätzlicher +anonymer Pipe-Test unterscheidet erfolgreich abgeschlossenen Write mit gesundem +Idle von einem blockierten, abgebrochenen Write. Die Installation bleibt +unbewiesen; das aktuell weiterentwickelte Quellfile muss jeweils erneut zum +Hash des kompilierten Nachweises passen. + +Ein noch nicht aktivierter Service-Bootstrap-Baustein verwirft einen vom Aufrufer +gewählten Policy-Schlüssel, eine fremde Geräte-ID und eine Mindestversion unter +der geschützten Zuordnung vor dem Factory-Aufruf. Sechs Tests bestehen, darunter +eine echte Vendor-/Kundensignatur-Kette und die Ablehnung einer fremden in der +Testfixture übergebenen SID. Das ist keine native Pipe-Identitätsprüfung. Sein +privilegierter Trust-Loader muss zuvor Release-Signatur, delegierte Kundensignatur, +geschützte dauerhafte Rollback-Grenzen und die tatsächlich vom nativen Pipe-Peer +ermittelte SID prüfen; ein injizierter Test-Loader beweist diese Voraussetzungen +nicht. Die Benutzer-SID darf ausschließlich aus der nativen Peer-Prüfung stammen, +niemals aus dem START-JSON oder einer übernommenen Benutzer-Umgebungsvariable. +Der Baustein sperrt zudem einen Start nach Deployment-Ablauf und schließt einen +laufenden Broker bei Ablauf, auch wenn die Browser-Policy länger gültig wäre. +Mehrere Close-Aufrufe warten auf denselben ursprünglichen Cleanup und erhalten +dessen Fehler; ein bereits begonnener Cleanup gilt nicht als abgeschlossen. + +Die neue Service-Acceptance-Prüfung verifiziert Release-, Kunden- und Policy- +Signaturen zusammen, erzwingt die Gerätezuordnung und erhält gespeicherte +Policy-Mindestversionen pro Nutzer-SID, Gerät und Policy-Schlüssel. Auch eine +neuere Deployment-Freigabe oder eine Schlüsselrotation löscht alte Grenzen nicht. +Der kanonische vollständige Zustand ist auf 2 MiB und 8.192 eindeutige Einträge +begrenzt; 1.024 Bytes des Frame-Budgets bleiben für den festen Snapshot-/CAS- +Umschlag reserviert. Der vollständige serialisierte Commit wird zusätzlich +gegen das 2-MiB-Limit geprüft. Überfüllung wird gesperrt. Der Commit-Gate wartet auf die exakte +native Persistenzbestätigung und akzeptiert weder unsichere Ergebnisse noch +automatische Wiederholung nach einem ungewissen Commit. + +Die unabhängige Prüfung reproduzierte außerdem einen Start-Binding-Fehler: +eine andere gültig signierte Policy unter derselben Version konnte eine neue +Factory erreichen, obwohl ein vorheriger Plan andere Inhalte bestätigt hatte. +Der Start vergleicht jetzt zusätzlich den exakten Digest des bestätigten +Policy-Payload-Feldes und die Version, bevor die Factory aufgerufen wird. +Direkte Plan-/Commit-/Start-Komposition und gleichversionige Alternativ-Policy +werden zusammen geprüft: acht Akzeptanz-/Commit-Tests und sechs Bootstrap-Tests +bestanden. Ein weiterer Akzeptanztest beweist die Sperre von 8.000 einzeln gültigen +Einträgen durch das gesamte serialisierte Byte-Budget (neun Akzeptanztests grün). +Der Digest der Policy verwendet +das Base64url-Payload-Feld als String; Release-/Deployment-Digests verwenden +die decodierten kanonischen Bytes. Die native Implementierung muss diese +unterschiedlichen Verträge einhalten. + +Diese Regeln und Callback-Bestätigungen beweisen noch keinen Registry-Commit. +Offen ist ein tatsächlich geschützter einzelner AcceptanceState-Wert mit +Vergleich des vorherigen Zustands, vollständiger monotoner Transition und +RegFlushKey vor Bestätigung. Fehlender Zustand darf nicht als Version null +initialisiert werden. Installation und Freigabe dieses Stores bleiben offen. + +Der native Store verwendet dafür feste Helper-Rollen `--trust-snapshot` und +`--commit-acceptance`, einen einzelnen AcceptanceState-Registrywert und eine +begrenzte Compare-and-swap-Anfrage. Der Quellpfad prüft Dienst-SID, tatsächlichen +geschützten Node-Elternprozess, signierte Zuordnung, Flush und Rücklesen vor +Bestätigung. Bisher wurde kein produktiver HKLM-Write ausgeführt; die +uninstallierte Rollen-Ablehnung ersetzt keine positive Dienst-/Store-Prüfung. +Die JavaScript-Anbindung wird separat auf genau eine gerahmte Antwort und +bestätigtes Prozessende geprüft, ohne vom Renderer gewählte Pfade oder Schlüssel. + +Offene native Ownership-Prüfung: Eine aktivierte Dienst-SID ist nicht automatisch +als Eigentümer zulässig. Windows verlangt für einen Gruppen-Owner das +SE_GROUP_OWNER-Attribut; siehe [Microsoft TOKEN_OWNER](https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntifs/ns-ntifs-_token_owner). +Eine Alternative muss implizites WRITE_DAC des Kontoeigentümers ausdrücklich +einschränken und tatsächlich testen; [Microsofts SID-Dokumentation](https://learn.microsoft.com/en-us/windows-server/identity/ad-ds/manage/understand-security-identifiers) +beschreibt die OWNER-RIGHTS-SID. Ein Plan oder DACL-String allein ist kein +Nachweis der tatsächlichen LocalService-/SCM-Berechtigungen. + +Der native Test bestätigt inzwischen eine Alternative ohne SeRestore und ohne +angenommene Service-SID-Owner-Eignung: Standardkontoeigentümer plus ausdrückliche +OWNER-RIGHTS-ReadControl-Berechtigung. Im tatsächlichen Test wird WRITE_DAC mit +Access Denied (5) verweigert, während Mutex-Synchronisation und Modify erlaubt +sind. Das prüft die Owner-Regel, nicht einen installierten SCM-Commit. + +Die JavaScript-Service-Anbindung besitzt einen eigenen festen Entry-Pfad und +wartet auf die native Persistenzbestätigung vor Factory/Listen. 16 gezielte +Transport-/Entry-/Akzeptanztests bestehen. Die unabhängige Quellprüfung fand noch +Interoperabilitätskorrekturen vor einem nativen Lauf: Die vom Host ermittelte SID +muss ausdrücklich an den Helper weitergegeben werden; der begrenzte rohe +Release-Envelope muss den Base64-Overhead berücksichtigen; partielle Dateireads +benötigen vollständige begrenzte Verarbeitung und Vorher-/Nachher-Prüfung. +Die Fake-/Subprozess-Tests schließen diese tatsächlichen Dienst-Gates nicht. + +Nach unabhängiger Korrektur bestehen 18 gezielte Service-Store-/Entry-/Akzeptanz- +Tests. Ein tatsächlicher Subprozess erhält nur die validierte vom Host ermittelte +SID und die beiden Windows-Systemvariablen. Ein echter signierter Release mit +6.007 Dateien und mehr als 2 MiB rohem Envelope wurde über begrenzte partielle +Reads vollständig gelesen und kryptografisch verifiziert. Dateiidentitätswechsel +und ungültige Antworten werden gesperrt. Das ist weiterhin keine positive +SCM-/HKLM-Abnahme. + +Zusätzliche offene WFP-Abnahme: CONNECT-Filter allein reichen nicht aus. +[Microsofts ALE-Dokumentation](https://learn.microsoft.com/en-us/windows/win32/fwp/ale-stateful-filtering) +beschreibt, dass nach erster Freigabe eines eingehenden oder ausgehenden Flows +beide Richtungen zustandsbehaftet erlaubt sind. Deshalb braucht die Browser-EXE +auch RECV_ACCEPT-Sperren für neue eingehende IPv4-/IPv6-Flows und tatsächliche +inbound-first-UDP-/TCP-Tests. Vor einer Aktivierung müssen bisherige lokale +Agent-/Management-/Inspector-/OTLP-Listener bzw. Transporte über den Broker laufen. +Der bisherige Vier-CONNECT-Filter-Plan und eine normale Unternehmens-Firewall +beweisen diese Grenze nicht. Keine dieser neuen WFP-Regeln wurde aktiviert. + +Der Plan und die Installer-/Probe-Quellen enthalten jetzt sechs Browser-Regeln: +vier CONNECT- und zwei RECV_ACCEPT-Sperren. Sechs JavaScript-Planprüfungen und +21 Python-Vertrags-/Fehlertests bestehen. Das sind keine aktivierten Windows- +Filter oder physischen Egress-Tests. Zusätzlich muss die tatsächliche WFP- +Arbitration geprüft werden: höher priorisierte Hard-PERMITS können gewöhnliche +Sperren beeinflussen; siehe [Microsofts Filterarbitration](https://learn.microsoft.com/en-us/windows/win32/fwp/filter-arbitration). +Die bisherige niedrige Sublayer-Priorität allein beweist keine wirksame Grenze. +Eindeutige Priorität/Kollisionsregeln, native Topologieprüfung, relevante +Änderungsüberwachung und tatsächliche TCP-/UDP-Tests bleiben vor Freigabe offen. + +Die DNS-Migrationslücke ist im neuen Broker behoben: Alle aufgelösten Adressen +werden gegen security.dns.blockedIPs geprüft; eine gesperrte Adresse verwirft +die Route. Bei aktiver IP-Policy erhält der Unternehmens-Proxy ausschließlich +die freigegebene numerische CONNECT-Adresse. SNI, Host und TLS-Zertifikatsprüfung +bleiben an den ursprünglichen Namen gebunden. Der achte native Factory-Test +beweist sechs tatsächliche numerische Tunnel und unveränderte DLP-Blockierungen. +Der System-/VPN-Resolver bleibt der unterstützte Pfad. Konfiguriertes Secure DNS +wird ausdrücklich als nicht unterstützt abgewiesen statt still heruntergestuft. +Die Hauptbrowser-/Dienst-Migration und externe Kunden-Proxy-Abnahme bleiben offen. + +Für diese Release-Verifikation wird standardisierte P-256/ECDSA mit Windows CNG +und getrennt delegiertem Kundenschlüssel entwickelt. Das ist klassische +Signaturkryptografie und kein Nachweis quantensicherer Artefakt-Signaturen. +Produktionssignierung und die vollständige Windows-Verifikation bleiben offen. + +Die unabhängige Prüfung des Release-Inventars fand einen Integrationsfehler: +kanonisch signierte JSON-Objekte hatten eine andere Feldreihenfolge als das +Dateiinventar und wurden deshalb fälschlich abgewiesen. Der Vergleich prüft jetzt +explizit Pfad, Größe und SHA-256 bei unveränderter vollständiger Inventarabdeckung. +Eine echte Signieren-/Verifizieren-/Dateisystem-Prüfung und anschließende +gleichgroße Dateimanipulation prüfen beide Richtungen. Diese Testschlüssel liegen +nur im Speicher; daraus entsteht kein produktiver Signaturnachweis. + +## Reihenfolge der verbleibenden Core-Arbeit + +Der erste echte Start des neuen gepackten Production-Childs war vor `READY` +gesperrt. Der feste lokale Startcode `broker-startup-transport` identifizierte +eine konkrete Typinkompatibilität: Die Factory lieferte ein CA-Array, während +der geprüfte HTTPS-Forwarder ausschließlich PEM-String oder Buffer akzeptiert. +Die Korrektur verbindet die freigegebenen öffentlichen PEM-Zertifikate zu einem +String; TLS-Prüfung und Hostname-Prüfung bleiben aktiv. Der tatsächliche neu +gestagte Prozess-/Transfernachweis bestand nach dieser Korrektur: tatsächlicher +Production-Child mit offiziellem Node, enthaltenem Inspector und privater IPC, +öffentlicher Upload exakt 16 Bytes / 201 / eine Zielverbindung, Geheimnis 403 +ohne zusätzliche Zielverbindung und tatsächliche erkannte Regel im fsync-Audit. +Nachweis: `broker-production-native/proof-2026-10-14T09-10-08.981Z.json` unter +`artifacts`; bestätigtes Prozessende. Dies ersetzt weder den Hauptbrowserpfad +noch Download-, Unternehmens-Proxy- und WFP-Abnahmen. +Ein später erfolgreicher direkter Inspector-Start ersetzt diesen Nachweis nicht. + +Umgesetzt: bekannte Base64-verpackte binäre Dokumente in JSON/Data-URLs erhalten +vor Freigabe Magic-Byte-, Fingerprint- und lokale Parserprüfung. Native Nachweise +stehen unten. Download-Blockierungen besitzen typisierte Download-Fakten und +registrierte Scanner-Regel-IDs. Dies ersetzt keine allgemeine Abdeckung beliebiger +Kodierungen oder die noch offene Hauptbrowser-Anbindung. + +Der neue begrenzte Carrier-Extractor ist implementiert und nach unabhängiger +Prüfung um JSON-interne Prozentkodierung, verschachtelte Text-Envelopes und +strikte JSON-Primitive erweitert. Acht Regressionstests und Lint bestehen. +Die Integration erhält ein gemeinsames Byte-/Teil-/Knoten-/Rekursionsbudget über +alle decodierten Teile und erzwingt tatsächliche Dokumentparser. +Unbekannte rohe Binärkodierungen und Vektor-/SVG-Rendering sind damit nicht +pauschal abgedeckt; ausdrücklich deklarierte unprüfbare Carrier müssen sperren. +Die kombinierte native Production-Child-Prüfung besteht nach kontrolliert +nachgewiesener Ergänzung von genau `USERPROFILE`, `LOCALAPPDATA` und `APPDATA` in +der Windows-Umgebung. Geheimnisse im Hex-PDF werden in JSON und Prozent-Data-URL +mit tatsächlicher registrierter Regel ohne zusätzliche Zielverbindung blockiert. +Öffentliche äußere JSON-Bytes werden nach vollständiger Parserprüfung unverändert +übertragen. Öffentlicher PDF-Download funktioniert; gesperrter PDF-Download gibt +keine PDF-Bytes frei und erhält ein kausal verknüpftes `download.block`-Ereignis. +Neun fsync-persistierte synthetische Ereignisse; bestätigtes Broker-Prozessende. +Der ursprüngliche fehlgeschlagene kombinierte Nachweis bleibt erhalten. Die +Umgebungskorrektur verändert weder ACLs noch Parser-, TLS- oder Policy-Prüfungen. +Factory-Unternehmens-Proxy und Hauptbrowser-/Installer-Abnahmen bleiben offen. +Nachweis: `broker-production-native/proof-2026-10-14T09-48-31.825Z.json` unter +dem ignorierten lokalen `artifacts`-Verzeichnis. + +### Verbindlicher Auslieferungsvertrag + +Ein gemeinsamer Windows-Installer enthält Browser, separaten DLP-Broker, +dessen private Runtime, lokale Scanner und Modelle. Auf dem Zielgerät ist keine +Entwicklungsinstallation von Node oder Python und kein manueller Broker-Start +erforderlich. Der Zielpfad startet und überwacht den enthaltenen Broker über einen +geschützten Windows-Dienst; dessen Installation und produktive Anbindung sind +noch nicht nachgewiesen. Fehlt +dieser, ist er manipuliert oder fällt er aus, dürfen geschützte Transfers nicht +auf einen ungeprüften direkten Netzwerkpfad ausweichen. Update und Deinstallation +müssen auch die zugehörigen verwalteten Komponenten berücksichtigen. +Ein entpacktes Entwicklungsverzeichnis allein erfüllt diesen Vertrag nicht. + +### Neue lokale Integrationsnachweise + +- Gemeinsamer Repository-Lauf: ESLint und sämtliche JavaScript-Syntaxprüfungen + bestehen; 1.119 Node-Tests, davon 1.107 bestanden, null Fehler und zwölf explizite + native Opt-in-Skips. Der Lauf ersetzt diese ausgelassenen nativen Abnahmen nicht. + Tatsächlicher privater OTLP-Trace `4a4cbe86f72921e7e1d9a2b94abcb4b8` enthält + fünf korrelierte Spans, null aktuelle/lebenslange Exportfehler. Zwei anschließend + ergänzte Credential-Lifecycle-Consumer-Regressionen bestehen separat. +- Credential-Lifecycle korrigiert: Der native Request muss zuerst exakt zur + aktuellen angenommenen Policy passen. Für denselben SID, Device, Origin und + Policy-Key kann der jüngste zulässige ältere Credential-Datensatz wiederverwendet + werden; seine eigenen Metadaten bleiben an die DPAPI-Entropie gebunden. Ein + gleiches Versionsdatum mit anderem Digest, zukünftige Versionen und Mehrdeutigkeit + werden abgewiesen. Provisionierung ersetzt diese stabile Identität statt ihre + Versionshistorie unbegrenzt anzuhäufen. Native Auswahl-/CAS-/Registry-Ausführung + ist weiterhin nicht nachgewiesen; die zwei JS-Tests prüfen die Consumer-Grenze. +- DPAPI-Capability-Helfer gehärtet: private Python-Runtime mit `-I -B` und + ausschließlich erlaubten Windows-Umgebungsvariablen. Ausgabe ist vorallokiert + und begrenzt; späte Daten nach einem Fehler werden ignoriert. Höchstens acht + ursprüngliche Helper-Prozesse bleiben bis zu ihrem tatsächlichen Ende belegt; + ein fehlgeschlagener Kill gibt keine Kapazität frei. Fehler bleiben bereinigt. + Tatsächliche Windows-DPAPI-Roundtrips bestehen auch für 32.768 Eingabebytes und + 44.000 Ciphertext-Bytes sowie bei manipulierten Python-Umgebungsvariablen. + Fünf Tests, null Skips. Die früher kleinere Entschlüsselungsgrenze wurde an + den tatsächlich größeren Ciphertext angepasst, ohne Klartextgrenze zu erhöhen. + `artifacts/dpapi-helper/proof.json` enthält vier tatsächliche private OTLP-Spans, + Trace `a2f37753f5620155c4944b26ef1189bd`, null Exportfehler. Die beiden + Credential-Stufen wurden mit tatsächlichem Parent und Zeiten abgerufen. + Der Audit-Korrelationsanker ist synthetisch; kein Browser-Upload fand statt. +- Begrenzte Management-Operationen prüfen jetzt Identität und verschlüsselte + Policy über fest vorgegebene HTTPS-Routen. ML-KEM-1024, gepinnte ML-DSA-87- + Signaturen, Device-Bindung, Mindestversion und gleicher-Version-Digest werden + geprüft. Beide Policy-Aufrufe teilen ein Deadline-/Job-Budget; Widerruf nach + Challenge verhindert den zweiten POST. 16 Tests bestehen. Das Policy-Ergebnis + aktiviert keine Regeln und ersetzt keinen geschützten Floor-Commit. +- Native Topologie-/Credential-Quellen sind erweitert und kompilieren. Ihre + aktuelle Ausführung ist ausdrücklich nicht nachgewiesen: Beim Topologie- + Selftest war die EXE vor dem Start vorhanden und gehasht; der Start scheiterte + mit `UNKNOWN:spawn`, danach fehlte die Datei. Gezielte schreibgeschützte + Defender-/CodeIntegrity-Abfragen belegen keine Ursache dieses Fehlers. + `topology-compile-only-proof.json` und `current-compile-only-proof.json` unter + `artifacts/native-service-host` unterscheiden Kompilierung von Ausführung. + Frühere native v2-Erfolge gelten für den damaligen Hash. Kein Endpoint-Schutz + wurde deaktiviert, kein neuer Registry-/SCM-/WFP-Zustand aktiviert. +- Neunter Payload: `broker-prepared-service-proof-ninth.json` unter `artifacts`, + 313 Dateien / 64 Quellnachweise, sechs tatsächliche Paket-Isolationsprüfungen. + Native Test-EXEs, Signing-Assets und damals unreviewte Credential-Helfer sind + ausgeschlossen. Spätere Policy-/Credential-Quellen benötigen einen neuen + expliziten Snapshot; dieser Komponenten-Nachweis ist keine Installer-Abnahme. +- Dienst-Lifecycle weiter geprüft (`broker-service-lifecycle-proof-2026-10-14.json` + unter `artifacts`): Paketprüfung, Startbereitschaft und Herunterfahren besitzen + benannte tatsächliche OTLP-Spans. Trace `d0e4c73944250d8f96864c043e6a83d9` + enthält fünf korrelierte Spans; aktuelle und lebenslange Exportfehler null. + Instrumentierung kann Prüfungen nicht überspringen, ersetzen oder wiederholen. + Der Prozesspfad wartet ursprüngliche Factory-/Listen-/Cleanup-Promises ab; + verspätete Ergebnisse geben keine Bereitschaft frei. Cleanup-Fehler bleiben + beim wiederholten Close erhalten statt als erfolgreicher Abschluss zu gelten. + 25 gezielte Prozess-/Dienst-/Framing-Tests bestehen. Ein parallel noch mit + altem Testinhalt gestarteter Lauf hing an unterdrücktem Startup-Fault; der + korrigierte Test wartet die tatsächliche Quarantäne-Disconnection ab. +- Deployment v2 bindet HTTPS-Management-Origin und bestehenden ML-DSA-87-Pin. + Native MSVC-Kompilierung besteht; sechs positive und 23 tatsächlich signierte + negative native Manifestfälle geprüft. Der native Parser validiert PQ-Key- + Metadaten; die tatsächliche PQ-Signaturprüfung bleibt im vorhandenen Node- + Kanal. Die erste begrenzte Management-Operation prüft Identität über HTTPS mit + Ed25519 und gepinntem ML-DSA-87. Sechs physische HTTPS-Tests bestehen. Geschützte + Credentials, Enrollment und produktive IPC-Anbindung sind noch nicht umgesetzt. +- Dienst-Bootstrap akzeptiert ausschließlich Receiver-Port 43120, passend zur + festen nativen/WFP-Grenze. Andere Ports scheitern vor Trust-Load und Konstruktion; + 20 kombinierte Bootstrap-/Acceptance-/Service-Tests bestehen. Der separate + isolierte Production-Child-Empfängertest darf weiterhin Testports verwenden. + Neue Trust-/Lifecycle-/Topologiequellen erfordern vor dem nächsten Paketnachweis + einen frischen explizit geprüften Payload; der achte bleibt historische Evidenz. +- Achter Payload: `broker-prepared-service-proof-eighth.json` unter `artifacts`, + 309 Dateien / 60 Quellnachweise, sechs physische Paket-Isolationsprüfungen. + Aktualisierter nativer Production-Child-Test besteht: + `broker-production-native/proof-2026-10-14T11-28-12.697Z.json`, sechs numerische + HTTPS-Proxy-Tunnel mit ursprünglichem SNI, 13 persistierte Ereignisse und + 22 tatsächliche private OTLP-Spans. Trace `40c28b579ce1adb8010503bae74b40f5` + wurde mit seinem Audit-Kindspan abgerufen; aktuelle und lebenslange Exportfehler + null. Die Exporte stammen vom privilegierten Test-Parent, nicht vom Dienst-SDK. +- Neuer Browser-Dienstadapter startet ausschließlich den fest installierten + nativen Host mit `--client-stdio`. Installation und Broker-Paket müssen vorher + unabhängig verifiziert sein. Fünf Adaptertests mit tatsächlichem Node-Testchild + plus zehn bestehende Supervisor-Tests bestehen. Das ist kein SCM-Positivnachweis. + Der zusätzliche OTLP-Test persistiert ein synthetisches Audit-Ereignis mit fsync + und ruft zwei tatsächliche, kausal verbundene Spans ab: + `8be57a1790a8429140f5eeb71e0e219c`, null Exportfehler. Ein erster Test scheiterte + an einer ungültigen Fixture-UUID; die strikte Validierung blieb unverändert. + Hauptbrowser-Aktivierung und native Dienst-Authentifizierung stehen noch aus. +- Office-Timings mit tatsächlichem AppContainer-Inspector und privatem OTLP- + Testempfänger geprüft: `private-otlp-proof-2026-10-14T08-29-16.980Z.json` + unter `artifacts/office-native-observability`. Vier benannte Office-Spans hängen + am tatsächlichen Parse-Span; defektes Archiv erzeugt einen bereinigten Fehler- + Span. Aktuelle und lebenslange Exportfehler: null. Dies ist kein Nachweis der + produktiven Collector-Konfiguration; diese wurde nicht geändert. +- Kurzlebige Broker-TLS-Zertifikate werden im Speicher erstellt, mit exakter SAN, + ohne CA-Berechtigung und mit begrenztem Cache. Physischer Node-HTTPS-Test prüft + die Zertifikatskette und verwirft einen falschen Hostnamen. Die Browser- + Vertrauensbindung und die produktive Factory bleiben gesondert offen. +- Sitzungsgebundene Zertifikat-Pins mit tatsächlichem Stock-Electron 44.7.0 / + Chromium 152.0.7977.130 geprüft (`broker-certificate-pins/proof.json` unter + `artifacts`): registriertes kurzlebiges Leaf liefert 200, unregistriertes Leaf + wird ohne Anwendungsanfrage abgewiesen. Der Fingerprint wird aus Electrons + tatsächlichem `Certificate.data` berechnet. Generation, SAN, Ablauf und Policy + werden geprüft. Electrons Callback liefert keinen Port und cached Ergebnisse; + CONNECT-/SNI-/Host-Bindung sowie Schließen alter Verbindungen bleiben zwingend. + Der produktive Browser ist noch nicht auf diesen Helper umgestellt. +- Der Parent-Audit-Adapter wurde mit tatsächlichem Windows-DPAPI und einer + synchronen `fsync`-/Rename-Persistenz geprüft (`broker-parent-audit/proof-7IKfqn` + unter `artifacts`). Die ursprüngliche Broker-Ereignis-ID wird erst nach Commit + bestätigt; Klartext-ID fehlt in der geschützten Datei. Fehler, asynchrones + Einreihen und uneindeutige boolesche Writer-Ergebnisse geben keine Bestätigung. + Die produktive Private-IPC-/Browser-Anbindung dieses Adapters bleibt offen. +- Der tatsächliche lokale SIEM-Dateiexporter erhält die Broker-Ereignis-UUID und + die Verbindung zwischen Freigabe und anschließendem Übertragungsfehler. + ECS/CEF-Felder und deren Grenzen stehen in [broker-siem.md](broker-siem.md). + Eine Freigabe hat einen unbekannten Lieferstatus; Terminalfehler werden als + Fehler ausgewiesen. Dieser Nachweis ersetzt keine externe SIEM-Abnahme. + +1. Nativer PDF-/Office-Nachweis mit verpflichtender Audit-Persistenz bestanden: + `office-proof-2026-10-14T08-23-30.852Z.json` unter dem ignorierten lokalen + Evidenzverzeichnis `artifacts/broker-native-inspection`. Sieben mit `fsync` + persistierte synthetische Ereignisse, drei tatsächliche erkannte Geheimnisregeln, + keine Zielverbindung für gesperrte Dokumente und keine verbleibenden eigenen + Inspector-Prozesse. Die Fixture beweist weder produktive Audit-Verschlüsselung + noch SIEM-Auslieferung; Diagnostik war zentral deaktiviert. +2. Sicherheitsereignisse für frühe CONNECT-/Framing-Blockierungen und Fehler nach + Transferfreigabe ergänzen. Eine protokollierte Freigabe ist kein Nachweis einer + erfolgreichen Übertragung. Erlaubte Dokumente müssen funktionieren; Geheimnisse + dürfen beim kontrollierten Empfänger nicht ankommen. +3. Zertifikats-/Trust-, Proxy-, Audit- und Management-Pfade vollständig integrieren. + Kein ungeprüftes CONNECT-Piping oder DIRECT-Fallback als Ersatz. +4. Windows-Regeln über den geprüften Installer-/Service-Pfad aktivieren und + unabhängig nativ prüfen. Erst dann die bisherige Header-Sperre ersetzen. +5. Gemeinsamen Installer bauen und ohne Entwicklungsruntime abnehmen, einschließlich + Ausfall, Widerruf, Update und fehlender/manipulierter Broker-Dateien. + +Die detaillierten Anforderungen bleiben in [windows-dlp-mvp.md](windows-dlp-mvp.md). +Diese Übersicht ersetzt weder deren Kriterien noch die übrige Enterprise-Roadmap. diff --git a/docs/native-runtime/README.md b/docs/native-runtime/README.md index 1bbfe30..2636bdb 100644 --- a/docs/native-runtime/README.md +++ b/docs/native-runtime/README.md @@ -1,11 +1,39 @@ # Chromium 152 WebRTC binder backport +## Cancelled implementation path — stock runtime required + +The user explicitly rejected rebuilding Chromium/Electron on2026-10-14. The +owned native build was stopped and its terminal failure/cancellation evidence +retained. No custom binary was activated or shipped. This document's backport, +compiler and source-checkout records are historical evidence, not instructions +to resume that implementation. **Do not restart this build or make a custom +Chromium runtime a product dependency.** + +The current direction is an unchanged official Electron/Chromium runtime plus +an independently enforced Windows network boundary and separate controlled DLP +broker. This alternative is not yet implemented or accepted. The existing relay +allows opaque CONNECT forwarding and shares Electron's executable/process with +upstream management traffic, so merely adding per-executable firewall blocking +or removing the WebRTC header would not provide the required boundary. Retain +the present protection until hostile native transport and DNS tests prove its +replacement. A JavaScript constructor wrapper alone is insufficient. + Status: source patch prepared and checked; **not compiled or runtime verified**. Do not distribute this patch as a proven security fix until the acceptance gates below pass against the resulting binary. ## Latest stable candidate checked +On2026-10-14 the newer official Electron44.7.2 Windows x64 binary was also tested. +Its ZIP matches the official SHA256 manifest: +`10a89447ef64ac5cb9f782af48ffd83734a40ae91681259d1048ca3c9e6c5da4`. +Both initialabout:blank andsrcdoc positive controls delivered exact synthetic +RTC data and benign POSTs. Both corresponding managed-policy cases still +crashed with missing MdnsResponder, bad IPC123 and renderer exit3. The earlier +run's positive timeout remains preserved separately. Evidence: +`artifacts/native-rtc-4472-second-proof.json`. No project dependency was upgraded. +Thus this newly available binary does not replace the native backport either. + The official [Electron44.7.1 release](https://github.com/electron/electron/releases/tag/v44.7.1) uses Chromium152.0.7977.149. Its Windows x64 ZIP was downloaded into an isolated ignored directory, checked against the official SHA256 manifest and extracted @@ -254,6 +282,61 @@ is not identified as a compiler tool, but the official full sync remains failed. This read-only analysis authorizes neither skipping dependencies nor changing antivirus policy. ATL/MFC remains independently missing at the host check. +## Alternative dependency installation, 2026-10-14 + +The normal Microsoft installer subsequently completed. `vswhere` now finds +`Microsoft.VisualStudio.Component.VC.ATLMFC` in the existing BuildTools instance +and reports both `isComplete=true` and `isLaunchable=true`; the installer process +has ended. Ignored evidence: `artifacts/native-build/atl-mfc-installed-proof.json`. +Thus the earlier missing-component prerequisite is resolved. This does not by +itself prove a generated GN graph or a working native compilation. + +The user's explicit alternative-build request was implemented without changing +Defender. A reviewed CIPD ensure manifest preserves all forty original requested +package/subdirectory/version bindings from the existing `.gclient_entries`, +except the single rejected Chrome-x86 updater fixture described above. The +thirty-nine remaining bindings were processed by the official CIPD client with +exit0 in119.7seconds. No new antivirus error occurred. Original version tags +were preserved; this is not a claim that every tag was resolved offline to an +immutable instance ID. The source entries and both manifests retain their +verified hashes. Native Chromium/Electron HEADs and captured references remained +unchanged before/after; no full Git sync or patch replay occurred. + +Ignored evidence: `cipd-manifest-offline-proof.json`, +`cipd-reviewed-ensure-journal.json` and `cipd-reviewed-ensure.log` under +`artifacts/native-build`. The original failed full sync remains retained. +Updater regression tests using the omitted fixture are not accepted. A generated +Electron/native-test GN dependency graph must confirm that the omitted target is +not needed before compiling. Required hooks, ATL/MFC, compilation and real native +acceptance remain separate gates. The signed Microsoft installer was launched +through normal Windows UAC; installation is not confirmed by starting it. + +The selected original RC and four version-generation hooks subsequently exited0. +RC matches its checked-in SHA1 and has recorded SHA256 provenance. Electron's +Yarn4.18.0 immutable install also completed with unchanged lockfile and config. +`enableScripts:false` stayed set, but the upstream workspace fixtures and explicit +`dependenciesMeta.built:true` entries executed their permitted builds; this is +not an all-scripts-disabled claim. All native HEAD/reference snapshots remained +unchanged, with no Defender modification or new antivirus error. Evidence: +`selected-hooks-journal.json` and per-action logs. The official local toolchain +check also exited0, recognizing VS2022 and Windows SDK10.0.26100.0 without a Google +toolchain download (`local-toolchain-proof.json`). GN generation, target-graph +verification and product compilation remain separate steps. + +GN generation subsequently succeeded in the fresh `out/SafetyNetTestingPython314` +directory with the supported explicit Python interpreter switch; the original +Windows-Store-alias failure remains retained. Actual recursive GN graphs for +Electron and content_unittests contain no omitted updater-fixture dependency. +Both NoOp test sources are present and `enable_mdns`/`is_p2p_enabled` are true. +Evidence: `gn-generation-python314-proof.json` and `gn-target-graph-proof.json`. +Physical Ninja aliases were also checked. The initial compile launch was +controlled-stopped before observed compiler progress for insufficient memory; +that failed attempt remains retained. A fresh continuation started only after +the measured available commit memory exceeded4GiB, using one compiler job, +bounded disk/memory guards and no remote execution. This running build is not +yet a completed compilation or native acceptance. The shipped runtime remains +unchanged until the required real tests pass. + ### Patch readiness before compilation ATL/MFC is required for the native toolchain, not for the Python patch import diff --git a/docs/requirements-audit.md b/docs/requirements-audit.md index 5837568..6684f7e 100644 --- a/docs/requirements-audit.md +++ b/docs/requirements-audit.md @@ -7,7 +7,23 @@ Die breiteren Anforderungen und Erweiterungen unten bleiben Roadmap. ## Aktuelle nächste Abnahmen -**Aktualisierung des Windows-MVP:** 784 Tests bestehen mit begrenzter +**Stand 14. Oktober 2026:** Das neue Windows-DLP-MVP ist nicht abgenommen. +Maßgeblich ist die [aktuelle Abnahmematrix](core-dlp-current-status.md). +Ein gepackter separater Broker ist mit privater Runtime und realen lokalen +Scannern geprüft, aber noch nicht im Hauptbrowser aktiviert. Der eigene +Chromium-Build wurde abgebrochen; stock Electron bleibt Voraussetzung. Der +geschützte Windows-Dienst, native Netzwerkdurchsetzung, gemeinsame Installation +und erneute vollständige Clipboard-/Upload-/Download-Abnahme sind offen. + +Aktuelle Arbeit: Timeout-Korrektur für die reale Unternehmens-Proxy-Prüfung, +authentifizierter Diensttransport und getrennte Release-/Kundendeployment- +Signaturen. Klassische Release-Signaturen sind kein quantensicherer Nachweis. +Baustein-/Fixture-Tests und frühere Browser-Builds ersetzen weder den aktuellen +Hauptbrowserpfad noch einen final installierten gemeinsamen Broker-Installer. + +### Historische Integrationsnachweise + +**Vorangegangener Windows-MVP-Stand:** 784 Tests bestehen mit begrenzter Parallelität. Der aktuelle Windows-Build besteht Paket-, Fuse- und Manipulationsprüfungen, ist aber nicht signiert. Native Clipboard-/Upload-Tests, Prozessneustart bestehen auf dem finalen Quellstand. Die lokalen semantischen @@ -21,7 +37,7 @@ WebRTC-Absturz bleibt offen, bis ein korrigiertes Binary geprüft ist. Die folgenden älteren Integrationszahlen beschreiben historische Zwischenstände; die aktuelle verbindliche Abnahme steht im [Windows-MVP-Ziel](windows-dlp-mvp.md). -Aktueller Integrationsstand: 713 Tests bestehen +Früherer Integrationsstand: 713 Tests bestehen (`artifacts/worker-integration-suite.log`). Der echte Admin-Workflow für typisierte Login-Regeln, unabhängige Veröffentlichung und Auditor-Leserechte ist geprüft: 193 abgeschlossene Anfragen mit korrelierten exportierten Traces, eine abgebrochene diff --git a/docs/windows-dlp-mvp.md b/docs/windows-dlp-mvp.md index 4d4c882..9f25ba9 100644 --- a/docs/windows-dlp-mvp.md +++ b/docs/windows-dlp-mvp.md @@ -1,5 +1,192 @@ # Verbindliches Ziel: Windows-DLP-MVP +## Verbindliche Runtime-Grenze + +Nutzerpräzisierung vom14. Oktober2026: Chromium/Electron darf für dieses Produkt +nicht selbst gepatcht oder neu gebaut werden. Offizielle, unveränderte Runtime- +Updates bleiben Voraussetzung. Der bisherige native Backport-/Compilerpfad ist +abgebrochen; kein Custom-Binary wurde aktiviert. Eine unabhängige Windows- +Netzwerkgrenze mit separatem DLP-Broker muss die bisherige WebRTC-Sperre ersetzen, +bevor der abstürzende Response-Header entfernt werden darf. Dieser Ersatz ist +noch nicht implementiert oder nativ abgenommen. Das unveränderte Runtime-Ziel +reduziert die erforderlichen DLP-/Umgehungs-/Stabilitätsnachweise nicht. + +## Gemeinsame Auslieferung + +Nutzerpräzisierung: Browser, DLP-Broker, lokale Scanner und benötigte Modelle +werden gemeinsam in einer finalen Windows-Installer-EXE ausgeliefert. Der Broker +darf ein eigener interner Prozess sein, aber kein separat zu installierendes +Produkt oder manuell zu startendes Programm. Auf einem frischen Zielsystem dürfen +kein Node-/Python-/Broker-Download und keine Entwicklungsumgebung erforderlich +sein. Der Installer provisioniert die nötigen Schutzgrenzen und Dateirechte; +Startup und Paketprüfung müssen fehlende/manipulierte Broker-Dateien, falsche +Versionen sowie nicht aktive Schutzgrenzen erkennen und fail-closed reagieren. +Ein grüner Paketnachweis des alten In-Process-Relays beweist die neue Broker- +Auslieferung nicht; der neue vollständige Installer benötigt eigene Native-Tests. + +## Aktueller Ersatzpfad: Nachweise und offene Grenzen + +Die folgenden Einträge dokumentieren die schrittweise Entwicklung und enthalten +auch frühere Zwischenstände. Die aktuelle Abnahmematrix und neuesten Paket-, +Proxy-, Diagnose- und Dienstadapter-Nachweise stehen in +[core-dlp-current-status.md](core-dlp-current-status.md). Frühere offene Punkte +gelten nicht als aktuelle Lücke, wenn dort ein neuerer begrenzter Nachweis steht; +die vollständige Browser-/Dienst-/Installer-Abnahme bleibt erforderlich. + +Der isolierte Baustein `apps/network-broker/http-request-gate.cjs` puffert explizite +HTTP-Requests vollständig vor der Freigabe. Er prüft Framing und Authority, +begrenzt Größe und Laufzeit und prüft nach dem Scan den Policy-Widerruf erneut. +Die Scan-Kopie kann die freigegebenen Originalbytes nicht verändern. Er ist noch +nicht im produktiven Relay oder Installer eingebunden. TLS-Terminierung, +Broker-Authentifizierung und WebSocket-/WebRTC-Prüfung sind dadurch nicht gelöst. +Die neun gezielten Tests umfassen einen echten HTTP-Empfänger und zeigen null +TCP-Verbindungen vor der Freigabe sowie keine Auslieferung blockierter Inhalte. + +`scripts/broker-package-contract.mjs` erzeugt und prüft eine vollständige +Broker-Dateiliste mit SHA-256, Version und Protokollkennung. Neun gezielte Tests +prüfen fehlende, zusätzliche und manipulierte Dateien sowie unsichere Pfade und +Junctions. Der erwartete Manifest-Inhalt muss aus der integritätsgeschützten +Anwendung stammen. Das ist noch keine Installer-Integration und keine atomare +Startgarantie; geschützte Installationsrechte bleiben Voraussetzung. Beide +Testdateien und Bausteine bestehen den lokalen ESLint-Check. Die isolierten Tests +erzeugen noch keine produktiven Broker-OTEL-Traces. + +Weitere isolierte Vorarbeiten: `tls-authority.cjs` bindet CONNECT, inneren Host, +HTTPS-Ziel und SNI an dieselbe unveränderliche Authority; fünf Tests bestanden. +Es terminiert noch kein TLS. `windows-package-plan.mjs` trennt Unpacked-Build, +Pflichtprüfungen und NSIS-Erstellung aus dem geprüften Verzeichnis; sechs Tests +bestanden. Die Freigabe ist an Source-, Runtime- und Paketidentität gebunden. +Der damalige Packaging-Aufruf verwendete nur `--dir`. Der aktuelle Aufruf +bereitet den verpflichtenden Broker vor, verifiziert das entpackte Paket und +erstellt standardmäßig anschließend NSIS; `--unpacked-only` bleibt ein expliziter +Entwicklungsmodus. Ein finaler Installer mit aktiviertem geschütztem Broker- +Dienst ist weiterhin nicht gebaut oder abgenommen. + +`https-inspection-server.cjs` prüft jetzt einen echten entschlüsselten HTTPS-Leg +mit vorab authentifizierter CONNECT-Bindung und vom vertrauenswürdigen Aufrufer +bereitgestelltem Zertifikat. Fünf TLS-Tests prüfen erlaubte Originalbytes und +null Ziel-TCP-Verbindungen bei Geheimnissen, Widerruf, Host-/SNI-Fehlern sowie +vor verzögerter Freigabe. Client und Testziel validieren ihre TLS-Zertifikate. +Der Baustein ist kein vollständiger CONNECT-Proxy: Zertifikatsausstellung, +Browser-Vertrauensintegration, Zielantwort-Weiterleitung und produktive +Einbindung fehlen. Die Gesamtsuite vor diesem zusätzlichen TLS-Testlauf bestand +823 Tests; der anschließende separate TLS-Lauf bestand fünf Tests. + +Inzwischen gibt der isolierte HTTPS-Leg die echte gepufferte Zielantwort zurück. +Ein Response-Gate begrenzt Body und Header, berechnet Content-Length neu und +entfernt Hop-by-hop-Header. Nicht unterstützte Kompression bleibt gesperrt. +Der vertrauenswürdige Forwarder muss seine eigene Socket-/Response-Sammlung +ebenfalls begrenzen. Die OTLP-Adaptertests prüfen echte Exporte mit Parent-/Child- +Korrelation und Laufzeiten, unveränderte Fehler bei deaktivierter Diagnostik und +den Ausschluss von Inhalt, Authority und Fehlermeldung. Der Adapter konfiguriert +keinen Exporter selbst und benötigt die bereits autorisierte Diagnostik des +Aufrufers. Das ist noch keine produktive Broker-Telemetrie-Integration. + +Der Forwarding-Leg hat nun einen eigenen, nicht durch Netzwerkaktivität +verlängerbaren Zeitrahmen und signalisiert Abbruch bei Client-Disconnect oder +Fristüberschreitung. Neun HTTPS-Tests einschließlich realer Zielantwort, +Upstream-Fehler, einmaligem Abbruch und Konstruktor-Limits bestehen. Die sechs +Broker-Diagnosestufen besitzen getrennte feste `broker.*`-Operationsnamen. + +`windows-wfp-plan.cjs` beschreibt vier komplementäre Deny-Filter für den nativen +Application-ID-Blob: IPv4 Nicht-TCP, TCP zu anderen Adressen, Loopback-TCP zu +anderen Ports und sämtliches IPv6. Es installiert keine Regeln und enthält +keinen Permit-Override für Unternehmens-Firewalls. Vier Tests prüfen alle +65.536 Portwerte, Protokoll-/Adressgrenzen und unveränderliche Identitäten. +Die GUIDs stimmen mit dem installierten Windows SDK 26100 überein. Native +BFE-Installation, Enumeration und physische Empfängertests sind noch offen; +der Entwicklungsprozess besitzt nur ein Medium-Integrity-Token. + +Der isolierte `connect-inspection-proxy.cjs` führt nun einen echten CONNECT- +Durchlauf aus: zufällige Launch-Capability, konstante Tokenprüfung, autorisierte +Zielbindung, verifiziertes TLS, Inhaltsprüfung und echte HTTPS-Zielantwort. +Falsche Tokens werden vor Zertifikatsauflösung abgewiesen; gesperrte Inhalte +erzeugen keine Zielverbindung. Es gibt kein opaques Upstream-Piping und keinen +zusätzlichen ungeschützten lokalen Listener. Zertifikatsausstellung und produktive +Browser-Vertrauensintegration fehlen weiterhin. Die unabhängige Prüfung fand +eine noch offene Ressourcenlücke: Socket-Limits begrenzen nicht Scanner-/Forward- +Jobs, die Abbruchsignale ignorieren. Vor Integration ist gemeinsame Job-Admission +nötig, deren Plätze bis zum tatsächlichen Ende der zugrunde liegenden Arbeit +belegt bleiben. Timeout allein darf keine belegte Kapazität freigeben. + +Diese Lücke ist im isolierten Prototyp geschlossen: gemeinsame Work-Admission +für Zertifikatsauflösung, Scan und Forwarding über alle CONNECT-Tunnel, Freigabe +erst nach tatsächlichem Promise-Ende. Wiederholte Timeouts überschreiten das +Job-Limit nicht; belegte Kapazität bleibt fail-closed verbraucht. Private Buffer +werden nach tatsächlichem Arbeitsende gelöscht. 37 Broker-Tests und Lint bestehen. + +Der native read-only WFP-Adapter besteht neun Python-Tests. Der tatsächliche +Medium-Integrity-Prozess erhält einen nativen Application-ID-Blob, den der Plan +akzeptiert. Filter-Enumeration endet mit Access Denied (5), korrekt als +`verified: false`; native Ressourcen werden geschlossen. Der Adapter installiert +keine Regeln. Installer-Aktivierung, Service-Provenienz und native Abnahme bleiben +offen. Nachweis: ignorierter `artifacts/stock-wfp-readonly-proof.json`. + +Der separate Broker-Prozess ist jetzt mit der offiziellen gebündelten Node- +24.20.0-Runtime getestet: drei echte Child-Prozess-Tests bestehen, einschließlich +CONNECT/TLS, blockiertem Geheimnis, exakten öffentlichen Bytes sowie geschlossenem +Listener nach privatem IPC-Disconnect oder Stop. Der aktuelle Factory-Code nutzt +nur eine explizite synthetische Testfixture; produktive Policy-/Scanner-Anbindung +ist damit noch nicht implementiert. Node-Binary und Lizenz liegen ignoriert unter +`.runtime/windows/broker`; offizielle Archiv- und Binary-SHA-256 stimmen, +Authenticode ist gültig. Das Browser-Binary bleibt unverändert. Installer- +Einbindung und Fresh-System-Abnahme fehlen weiterhin. + +`policy-authority.cjs` verwendet die bestehende Core-Signatur-/Schema-Prüfung +und dieselbe Device-Assignment-Prüfung wie der Browser. Enrollment-Key, Device-ID +und Mindestversion stammen aus vertrauenswürdigem Bootstrap; unveränderliche +Snapshots und Generationstokens verhindern Freigabe nach Policywechsel. Sechs +Tests mit echten synthetischen Signaturen prüfen Ablauf, falsches Gerät, Änderung, +Rollback und Widerruf. Mindestversionen müssen bei produktiver Integration in +geschütztem Enrollment-Storage dauerhaft gespeichert werden. Die drei Child- +Prozesstests verlangen jetzt ausdrücklich die verifizierte gebündelte Node-Runtime +und besitzen keinen Entwicklungs-Node-Fallback. Fünf Runtime-Manifest-Tests prüfen +Version, Hashes, zusätzliche Dateien und Junctions. Kombinierter Lauf: 14/14. + +Der integrierte signierte Managed-Proxy prüft Header/Cookies und volle URLs mit +dekodierten Darstellungen. Jeder nicht-textuelle Magic-Typ erfordert vollständige +lokale Dokumentanalyse, auch bei UTF-8-Rohbytes. Der begrenzte Multipart-Parser +prüft alle Teile: Metadaten, Dateityp und Inhalt müssen freigegeben sein, bevor +die ursprünglichen Request-Bytes weitergehen. 13 echte TLS-/Empfängertests und +neun Parser-Tests bestehen, ebenso Lint. Ihre Dokument-Scanner sind injizierte +Verträge; ein echter AppContainer-/Modell-Nachweis über diesen Broker fehlt noch. +Der Managed-Proxy ist weiterhin nicht produktiv im Browser eingebunden. + +Stock-Resolver-Teilnachweis: `MAP * ^NOTFOUND, EXCLUDE 127.0.0.1` verhindert im +isolierten offiziellen Electron-44.7.2-Test `Session.resolveHost(example.com)` +mit `ERR_NAME_NOT_RESOLVED`, während der explizite Loopback-Proxy weiterhin +einen harmlosen Hostname-HTTP-Request empfängt. Der positive Ausgangslauf löst +den Namen auf. Das unveränderte Runtime-Binary bleibt hashgleich. Ohne exakte +Loopback-Ausnahme scheitert auch der Proxy; diese Einschränkung bleibt sichtbar. +Der bisherige RTC-UDP-Ausgangstest empfängt schon ohne Regeln null Pakete und +beweist daher keine Sperre. Physische DNS-/mDNS-/LLMNR- und WFP-Umgehungsnachweise +fehlen weiterhin. Nachweise: ignorierter `artifacts/stock-resolver-proof`-Ordner. + +Ein zusätzlicher gepaarter nativer STUN-Test verwendet nun einen vorab nur auf +die eigene nicht-loopback IPv4 aufgelösten Testhostnamen. Der Ausgangslauf +empfängt fünf anhand der STUN-Magic validierte UDP-Pakete; mit derselben +Resolver-Regel empfängt der eigene Receiver null Pakete und die Namensauflösung +scheitert. Beide unveränderten Stock-Runtime-Läufe enden mit Exit 0 und bestehen +den harmlosen HTTP-Proxy-Durchlauf. Das bestätigt den getesteten hostnamebasierten +STUN-Pfad; Literal-IP, TURN/TLS, mDNS/LLMNR und physische DNS-Ausleitung sind +weiterhin keine bestandenen Abnahmen. Eigene Nachweise bleiben getrennt als +`baseline-stun-owned.log`, `mapped-stun-owned.log` und `stun-owned-summary.json`. + +Die unveränderte offizielle Electron-44.7.2-Runtime startet im bisherigen +AppContainer-Versuch mit null Capabilities nicht bis `READY`. Auch isolierte +Profil-/Temp-Umgebungsvariablen ändern den Fehler nicht. Ein auf den synthetischen +Testprozess begrenzter Microsoft-Debugger erfasst `0x80000003`, anschließend +`0xC000001D`, bei Runtime-Offset `0x72ac2bb`. Ohne aufgelöste private Symbole +beweisen benachbarte Exportnamen keine Fehlerursache. Dieser Versuch beweist +weder Browser-Kompatibilität noch DNS-/Netzwerkisolation. Lokale Nachweise liegen +ignoriert unter `artifacts/stock-appcontainer-feasibility`. + +Nächste Abnahme: funktionsfähige Windows-Netzwerkgrenze einschließlich DNS, +separater authentifizierter Broker mit tatsächlicher HTTPS-Inhaltsprüfung, +Integration ohne ungeprüfte Ausweichwege und gemeinsamer Installer-Test auf +einem System ohne Entwicklungsruntimes. Die bisherige Schutzmaßnahme bleibt +bis zum erfolgreichen Ersatznachweis aktiv; das MVP ist noch nicht abgenommen. + Der Nutzer hat am 13. Oktober 2026 den bisherigen breiten Funktionsumfang ausdrücklich ersetzt. Dieses Ziel hat Vorrang vor älteren Aufgabenlisten: Ein stabiler, zentral verwalteter Windows-Browser verhindert, dass vertrauliche @@ -161,9 +348,14 @@ isolierten Build-Checkout als Commit `6f8b80fef13bfdc9d9c70df567ebcad722d70467` angewendet, mit genau16 Dateien und separatem Provenienzjournal. Er ist noch nicht kompiliert oder nativ abgenommen; das ausgelieferte Runtime-Binary bleibt unverändert. Aktuelle Build-Grenzen: -ATL/MFC wird vom Visual-Studio-Komponentencheck nicht gefunden; der vollständige -Windows-Dependency-Sync scheiterte an einer protokollierten Antivirus-Ablehnung -des Chromium-Updater-Testpakets. Keine Antivirus-Ausnahme wurde vorgenommen. +ATL/MFC fehlte ursprünglich; die normale Microsoft-Installation ist inzwischen +abgeschlossen und `vswhere` bestätigt die Komponente in einer vollständigen, +startfähigen BuildTools-Instanz. Der ursprüngliche vollständige Windows-Sync +scheiterte an einer Antivirus-Ablehnung des Chromium-Updater-Testpakets. Ein +separat geprüftes CIPD-Manifest verarbeitete danach39 unveränderte Bindings mit +Exit0 und ließ ausschließlich die benannte Updater-Testfixture aus. Keine Antivirus- +Ausnahme wurde vorgenommen. Build-Hooks und GN-Targetabhängigkeiten werden +separat geprüft; ein vollständiger neuer Native-Build ist noch nicht belegt. Quellkompatibilität und Strukturprüfungen ersetzen weder Binder-Unittests noch die echten erlaubten und blockierten WebRTC-Übertragungen im neuen Binary. diff --git a/package.json b/package.json index 1433b83..1299c9a 100644 --- a/package.json +++ b/package.json @@ -74,11 +74,23 @@ "filter": [ "**/*" ] + }, + { + "from": ".runtime/windows/broker-payload", + "to": "broker", + "filter": [ + "**/*" + ] } ], "files": [ "apps/browser/**/*", + "apps/network-broker/framed-channel.cjs", "packages/core/**/*", + { + "from": ".runtime/windows/broker-payload.manifest.json", + "to": "apps/browser/broker-package-manifest.json" + }, "package.json" ], "directories": { @@ -90,6 +102,13 @@ ], "icon": "docs/brand/safetynet.ico" }, + "nsis": { + "perMachine": true, + "oneClick": false, + "allowElevation": true, + "allowToChangeInstallationDirectory": false, + "include": "scripts/install/windows-security.nsh" + }, "mac": { "target": [ "dmg" diff --git a/packages/core/broker-audit.cjs b/packages/core/broker-audit.cjs index 33ca067..299ffca 100644 --- a/packages/core/broker-audit.cjs +++ b/packages/core/broker-audit.cjs @@ -1,21 +1,27 @@ 'use strict'; const {z}=require('zod'); +const {FileFactsSchema}=require('./transfer-facts.cjs'); const {RequestFactsSchema}=require('./request-transfer-facts.cjs'); -const BROKER_REASONS=Object.freeze(['broker-approved','broker-policy-block','broker-inspection-fault','broker-authority-expired','broker-audit-failed','broker-connect-denied','broker-auth-denied','broker-framing-block','broker-size-block','broker-request-aborted','broker-request-timeout','broker-upstream-failed','broker-response-invalid']); -const BrokerFactsSchema=z.object({requestId:z.string().uuid(),connectionId:z.string().uuid().optional(),phase:z.enum(['connect.block','request.authorized','request.block','request.fault','forward.fault']),reason:z.enum(BROKER_REASONS),authorizedEventId:z.string().uuid().optional()}).strict(); +const BROKER_REASONS=Object.freeze(['broker-approved','broker-policy-block','broker-inspection-fault','broker-authority-expired','broker-audit-failed','broker-connect-denied','broker-auth-denied','broker-framing-block','broker-size-block','broker-request-aborted','broker-request-timeout','broker-upstream-failed','broker-response-invalid','broker-scan-timeout','broker-download-approved','broker-download-policy-block','broker-download-inspection-block']); +const BrokerFactsSchema=z.object({requestId:z.string().uuid(),connectionId:z.string().uuid().optional(),phase:z.enum(['connect.block','request.authorized','request.block','request.fault','forward.fault','response.scanned','response.block']),reason:z.enum(BROKER_REASONS),authorizedEventId:z.string().uuid().optional()}).strict(); const phaseReasons=Object.freeze({ 'connect.block':['broker-connect-denied','broker-auth-denied','broker-framing-block','broker-authority-expired','broker-audit-failed','broker-request-timeout','broker-size-block','broker-request-aborted'], 'request.authorized':['broker-approved'], - 'request.block':['broker-policy-block','broker-authority-expired','broker-framing-block','broker-size-block','broker-request-aborted','broker-request-timeout'], + 'request.block':['broker-scan-timeout','broker-policy-block','broker-authority-expired','broker-framing-block','broker-size-block','broker-request-aborted','broker-request-timeout'], 'request.fault':['broker-inspection-fault','broker-audit-failed'], - 'forward.fault':['broker-upstream-failed','broker-response-invalid','broker-request-aborted','broker-request-timeout','broker-authority-expired','broker-audit-failed'] + 'response.scanned':['broker-download-approved'], + 'response.block':['broker-download-policy-block','broker-download-inspection-block'], + 'forward.fault':['broker-scan-timeout','broker-upstream-failed','broker-response-invalid','broker-request-aborted','broker-request-timeout','broker-authority-expired','broker-audit-failed'] }); function validateBrokerSemantics(event,context){ if(!event.broker)return; const value=event.broker; - if(!event.request||event.file||event.kind!==(value.phase==='request.authorized'?'upload.authorized':'upload.block')||!phaseReasons[value.phase].includes(value.reason)||value.phase==='forward.fault'&&!value.authorizedEventId||value.phase!=='forward.fault'&&value.authorizedEventId||value.authorizedEventId===event.eventId){ + const download=event.kind==='download.scan'||event.kind==='download.block',linked=value.phase==='forward.fault'||value.phase.startsWith('response.'); + const expectedKind=value.phase==='request.authorized'?'upload.authorized':value.phase==='response.scanned'?'download.scan':value.phase==='response.block'?'download.block':'upload.block'; + if(download?(!event.file||event.request||!value.phase.startsWith('response.')):(!event.request||event.file||value.phase.startsWith('response.')))context.addIssue({code:'custom',path:['broker'],message:'Broker transfer facts rejected'}); + if(event.kind!==expectedKind||!phaseReasons[value.phase].includes(value.reason)||linked&&!value.authorizedEventId||!linked&&value.authorizedEventId||value.authorizedEventId===event.eventId){ context.addIssue({code:'custom',path:['broker'],message:'Broker lifecycle semantics rejected'}); } } -const BrokerAuditEventSchema=z.object({eventId:z.string().uuid(),kind:z.enum(['upload.authorized','upload.block']),ruleId:z.string().min(1).max(100),policyVersion:z.number().int().min(1).max(Number.MAX_SAFE_INTEGER),host:z.string().max(253).optional(),request:RequestFactsSchema,broker:BrokerFactsSchema.optional(),traceId:z.string().regex(/^[a-f0-9]{32}$/).optional(),spanId:z.string().regex(/^[a-f0-9]{16}$/).optional()}).strict().superRefine(validateBrokerSemantics); +const BrokerAuditEventSchema=z.object({eventId:z.string().uuid(),kind:z.enum(['upload.authorized','upload.block','download.scan','download.block']),ruleId:z.string().min(1).max(100),policyVersion:z.number().int().min(1).max(Number.MAX_SAFE_INTEGER),host:z.string().max(253).optional(),request:RequestFactsSchema.optional(),file:FileFactsSchema.optional(),broker:BrokerFactsSchema.optional(),traceId:z.string().regex(/^[a-f0-9]{32}$/).optional(),spanId:z.string().regex(/^[a-f0-9]{16}$/).optional()}).strict().superRefine((event,context)=>{validateBrokerSemantics(event,context);const download=event.kind.startsWith('download.');if(download?(!event.file||event.request):(!event.request||event.file))context.addIssue({code:'custom',message:'Exclusive transfer facts required'});}); module.exports={BrokerAuditEventSchema,BrokerFactsSchema,BROKER_REASONS,validateBrokerSemantics}; diff --git a/packages/core/encoded-documents.cjs b/packages/core/encoded-documents.cjs new file mode 100644 index 0000000..c3c7cbf --- /dev/null +++ b/packages/core/encoded-documents.cjs @@ -0,0 +1,75 @@ +'use strict'; +const {createHash}=require('node:crypto'); +const {detectType}=require('./file-transfer.cjs'); +// Typed binary carriers supplement text normalization. No whole decoded file +// may be released on the strength of an allow verdict for its outer JSON text. +function extractEncodedDocuments(bytes,{maxDecodedBytes=16777216,maxParts=16,maxNodes=20000,maxDepth=32,jsonRequired=false}={}){ + if(!Buffer.isBuffer(bytes)||bytes.length>16777216||![maxDecodedBytes,maxParts,maxNodes,maxDepth].every(Number.isSafeInteger)||maxDecodedBytes<1||maxDecodedBytes>16777216||maxParts<1||maxParts>64||maxNodes<1||maxNodes>20000||maxDepth<1||maxDepth>32)throw Error('encoded-document-configuration-invalid'); + if(typeof jsonRequired!=='boolean')throw Error('encoded-document-configuration-invalid'); + const parts=[],seen=new Set(),encodings=new Set();let total=0,nodes=0,complete=true; + const result=()=>Object.freeze({parts:Object.freeze(parts),complete,decodedBytes:total,visitedNodes:nodes,dispose}); + const dispose=()=>{for(const part of parts)part.bytes.fill(0);}; + function candidate(value){ + if(!complete||typeof value!=='string')return; + let encoded=value.trim(),explicit=false,mime; + if(/^data:/i.test(encoded)){ + const match=/^data:([a-z0-9.+/-]+)?(?:;[a-z0-9-]+=[a-z0-9._-]+)*;base64,([\s\S]*)$/i.exec(encoded); + if(!match){complete=false;return;}mime=(match[1]||'application/octet-stream').toLowerCase();encoded=match[2];explicit=true; + if(encoded.includes('%')){try{encoded=decodeURIComponent(encoded);}catch{complete=false;return;}} + } + if(!explicit&&encoded.length<16)return; + if(!/^[A-Za-z0-9+/_-]*={0,2}$/.test(encoded)||encoded.length%4===1){if(explicit)complete=false;return;} + const normalized=encoded.replace(/-/g,'+').replace(/_/g,'/').replace(/=+$/,''); + const encodingKey=createHash('sha256').update(normalized).update('\0'+(mime||'')).digest('hex'); + if(encodings.has(encodingKey))return; + const projected=Math.floor(normalized.length*3/4); + if(total+projected>maxDecodedBytes){complete=false;return;} + const decoded=Buffer.from(normalized,'base64'); + total+=decoded.length;encodings.add(encodingKey); + const type=detectType(decoded); + if(decoded.toString('base64').replace(/=+$/,'')!==normalized){decoded.fill(0);if(explicit||type!=='unknown')complete=false;return;} + if(!explicit&&type==='unknown'){decoded.fill(0);return;} + const digest=createHash('sha256').update(decoded).digest('hex'); + const identity=digest+'\0'+(mime||''); + if(seen.has(identity)){decoded.fill(0);return;} + if(parts.length>=maxParts){decoded.fill(0);complete=false;return;} + seen.add(identity);parts.push(Object.freeze({bytes:decoded,...(mime?{mime}:{})})); + } + function scanText(value,percentDepth=0){ + candidate(value); + const carriers=[]; + for(const match of value.matchAll(/data:[^\s,"'<>]+,[A-Za-z0-9+/_=%-]+/gi)){if(++nodes>maxNodes){complete=false;break;}carriers.push([match.index,match.index+match[0].length]);candidate(match[0]);} + let carrier=0; + for(const match of value.matchAll(/[A-Za-z0-9+/_-]{16,}={0,2}/g)){ + while(carrier=carriers[carrier][0]&&match.indexmaxNodes){complete=false;break;}candidate(match[0]); + } + if(complete&&value.includes('%')){ + if(percentDepth>=4){complete=false;return;} + try{const decoded=decodeURIComponent(value);if(decoded!==value)scanText(decoded,percentDepth+1);}catch{if(/data%3a/i.test(value))complete=false;} + } + } + try{ + const text=new TextDecoder('utf-8',{fatal:true}).decode(bytes),trimmed=text.trim(); + if(jsonRequired||trimmed.startsWith('{')||trimmed.startsWith('[')){ + let root;try{root=JSON.parse(trimmed);}catch{if(jsonRequired)complete=false;else scanText(trimmed);return result();} + const stack=[{value:root,depth:0}]; + while(stack.length&&complete){const {value,depth}=stack.pop();if(++nodes>maxNodes||depth>maxDepth){complete=false;break;} + if(typeof value==='string')scanText(value); + else if(Array.isArray(value)){ + if(value.length>maxNodes-nodes||stack.length+value.length>maxNodes){complete=false;break;} + for(const child of value)stack.push({value:child,depth:depth+1}); + }else if(value&&typeof value==='object'){ + // Avoid Object.entries allocating an unbounded array of key/value pairs + // before the traversal budget is checked on a hostile JSON collection. + for(const key in value){if(!Object.hasOwn(value,key))continue;scanText(key);stack.push({value:value[key],depth:depth+1});if(stack.length>maxNodes||!complete){complete=false;break;}} + } + } + }else{ + scanText(trimmed); + } + }catch{complete=false;} + return result(); +} +module.exports={extractEncodedDocuments}; diff --git a/packages/core/siem.mjs b/packages/core/siem.mjs index 6dc9a67..59ff29a 100644 --- a/packages/core/siem.mjs +++ b/packages/core/siem.mjs @@ -20,7 +20,7 @@ export function toECS(event,installationId='safetynet') { '@timestamp':event.time,ecs:{version:'8.17.0'}, event:{id:`${installationId}:${event.id}`,kind:alert?'alert':'event',module:'safetynet',dataset:'safetynet.security', action:event.kind,category:[alert?'intrusion_detection':auth?'authentication':config?'configuration':clipboard?'process':'web'], - type:[transportEvent||alert||interaction?.outcome==='observed'?'info':failure||brokerFailure?'error':auth?'start':config?'change':block?'denied':clipboard?'access':'allowed'],outcome:transportEvent||interaction?.outcome==='observed'||broker?.phase==='request.authorized'?'unknown':failure||brokerFailure?'failure':'success',...(alert?{severity:severity(m.level)}:{})}, + type:[transportEvent||alert||interaction?.outcome==='observed'?'info':failure||brokerFailure?'error':auth?'start':config?'change':block?'denied':clipboard?'access':'allowed'],outcome:transportEvent||interaction?.outcome==='observed'||broker?.phase==='request.authorized'?'unknown':failure||brokerFailure||broker?.phase==='response.block'?'failure':'success',...(alert?{severity:severity(m.level)}:{})}, observer:{vendor:'SafetyNet',product:'Managed Enterprise Browser',type:'dlp',version:'0.1.0'}, ...(event.device_id?{host:{id:event.device_id}}:{}), ...(m.actor?{user:{id:m.actor}}:{}),...(m.host&&(!transportEvent||transport)?{destination:{domain:m.host}}:{}), @@ -53,7 +53,7 @@ export function toCEF(event,installationId='safetynet') { ...(event.kind.startsWith('download.')&&facts(m)?{cn2Label:'FileBytes',cn2:facts(m).bytes,cs6Label:'DetectedFileType',cs6:facts(m).detectedType,fileHash:facts(m).sha256}:{}), ...(/^[a-f0-9]{32}$/.test(m.traceId||'')?{cs4Label:'TraceId',cs4:m.traceId}:{}), ...(/^[a-f0-9]{16}$/.test(m.spanId||'')?{cs5Label:'SpanId',cs5:m.spanId}:{}), - ...(broker?{cs2Label:'BrokerLifecycle',cs2:JSON.stringify(broker),cs3Label:'BrokerEventId',cs3:broker.eventId,requestContext:broker.requestId,reason:broker.reason,outcome:broker.phase==='request.authorized'?'unknown':['request.fault','forward.fault'].includes(broker.phase)?'failure':'success'}:{}), + ...(broker?{cs2Label:'BrokerLifecycle',cs2:JSON.stringify(broker),cs3Label:'BrokerEventId',cs3:broker.eventId,requestContext:broker.requestId,reason:broker.reason,outcome:broker.phase==='request.authorized'?'unknown':['request.fault','forward.fault','response.block'].includes(broker.phase)?'failure':'success'}:{}), ...(alert?{cs2Label:'SourceEventId',cs2:`${installationId}:${m.sourceEventId}`,cs3Label:'AlertLevel',cs3:m.level}:{})}; return `CEF:0|SafetyNet|Enterprise Browser|0.1.0|${header(event.kind)}|${header(event.kind)}|${alert?severity(m.level):!transportEvent&&blocked?7:3}|`+Object.entries(fields).map(([k,v])=>`${k}=${ext(v)}`).join(' '); } diff --git a/packages/core/windows-trust.cjs b/packages/core/windows-trust.cjs new file mode 100644 index 0000000..7289b2f --- /dev/null +++ b/packages/core/windows-trust.cjs @@ -0,0 +1,71 @@ +'use strict'; +// Signed external manifests avoid embedding the final browser hash in its own +// native helper. This classical P-256 contract is not a post-quantum signature. +const {createPublicKey,createPrivateKey,createHash,sign,verify}=require('node:crypto'); +const {isIP}=require('node:net'); +const MAX_RELEASE_BYTES=2097152,MAX_DEPLOYMENT_BYTES=262144; +const DOMAINS=Object.freeze({release:'SafetyNet Windows release v1\0',deployment:'SafetyNet Windows deployment v1\0'}); +const verifiedReleases=new WeakSet(),verifiedDeployments=new WeakSet(); +const MANAGEMENT_PROTOCOL=require('./policy-channel.cjs').VERSION; +const UUID=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +function fail(code){throw Object.assign(new Error(code),{code});} +function exact(value,keys){return value&&typeof value==='object'&&!Array.isArray(value)&&Object.getPrototypeOf(value)===Object.prototype&&Object.keys(value).sort().join(',')===keys.slice().sort().join(',');} +function positive(value){return Number.isSafeInteger(value)&&value>0;} +function relative(value){return typeof value==='string'&&value.length<=240&&value.split('/').every(part=>/^[A-Za-z0-9_.-]+$/.test(part)&&part!=='.'&&part!=='..'&&!/[. ]$/.test(part)&&!/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part));} +function freeze(value){if(value&&typeof value==='object'){for(const item of Object.values(value))freeze(item);Object.freeze(value);}return value;} +function canonical(value){if(Array.isArray(value))return '['+value.map(canonical).join(',')+']';if(value&&typeof value==='object')return '{'+Object.keys(value).sort().map(key=>JSON.stringify(key)+':'+canonical(value[key])).join(',')+'}';return JSON.stringify(value);} +function publicKey(pem,type){ + if(typeof pem!=='string'||pem.length>(type==='ml-dsa-87'?20000:4096)||!pem.startsWith('-----BEGIN PUBLIC KEY-----\n'))fail('windows-trust-key-invalid'); + const key=createPublicKey(pem);if(key.export({format:'pem',type:'spki'})!==pem||key.asymmetricKeyType!==type||type==='ec'&&key.asymmetricKeyDetails?.namedCurve!=='prime256v1')fail('windows-trust-key-invalid');return key; +} +function releaseSchema(value){ + if(!exact(value,['format','releaseId','releaseSequence','serviceName','deploymentTenant','deploymentPublicKey','files'])||value.format!=='safetynet-windows-release-v1'||!UUID.test(value.releaseId)||!UUID.test(value.deploymentTenant)||!positive(value.releaseSequence)||value.serviceName!=='SafetyNetNetworkBroker'||!Array.isArray(value.files)||!value.files.length||value.files.length>10000)fail('windows-release-invalid'); + publicKey(value.deploymentPublicKey,'ec');let previous='';const seen=new Set(); + for(const file of value.files){if(!exact(file,['path','bytes','sha256'])||!relative(file.path)||!Number.isSafeInteger(file.bytes)||file.bytes<0||!/^[a-f0-9]{64}$/.test(file.sha256)||file.path<=previous||seen.has(file.path.toLowerCase()))fail('windows-release-invalid');previous=file.path;seen.add(file.path.toLowerCase());} + for(const required of ['SafetyNet.exe','safetynet-service-host.exe','resources/app.asar','resources/broker/node.exe','resources/python/python.exe','resources/inspector/server.py'])if(!value.files.some(file=>file.path===required))fail('windows-release-invalid'); + if(!value.files.some(file=>file.path.startsWith('resources/semantic/'))||value.files.some(file=>['release-envelope.json','deployment-envelope.json'].includes(file.path)))fail('windows-release-invalid'); + return value; +} +function managementSchema(value){ + if(!exact(value,['origin','signatureKey','protocol'])||typeof value.origin!=='string'||value.origin.length>2048||[...value.origin].some(character=>character.charCodeAt(0)<=32||character.charCodeAt(0)>=127)||value.protocol!==MANAGEMENT_PROTOCOL)fail('windows-management-invalid'); + const url=new URL(value.origin); + if(url.protocol!=='https:'||url.origin!==value.origin||url.username||url.password||url.search||url.hash||url.pathname!=='/'||url.hostname.endsWith('.'))fail('windows-management-invalid'); + const hostname=url.hostname.startsWith('[')?url.hostname.slice(1,-1):url.hostname; + if(!isIP(hostname)&&(hostname.length>253||hostname.split('.').some(label=>!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)||/^0x[0-9a-f]+$/i.test(label))))fail('windows-management-invalid'); + publicKey(value.signatureKey,'ml-dsa-87');return value; +} +function deploymentSchema(value){ + const v2=value?.format==='safetynet-windows-deployment-v2'; + if(!exact(value,['format','releaseId','deploymentTenant','deploymentSequence','policyPublicKey','expiresAt','users',...(v2?['management']:[])])||!['safetynet-windows-deployment-v1','safetynet-windows-deployment-v2'].includes(value.format)||!UUID.test(value.releaseId)||!UUID.test(value.deploymentTenant)||!positive(value.deploymentSequence)||!positive(value.expiresAt)||!Array.isArray(value.users)||!value.users.length||value.users.length>1024)fail('windows-deployment-invalid'); + if(v2)managementSchema(value.management); + publicKey(value.policyPublicKey,'ed25519');let previous='';const devices=new Set(); + for(const user of value.users){if(!exact(user,['sid','deviceId','minimumPolicyVersion'])||typeof user.sid!=='string'||!/^(?:S-1-5-21|S-1-12-1)-(?:(?:0|[1-9][0-9]{0,9})-){3}(?:0|[1-9][0-9]{0,9})$/.test(user.sid)||user.sid.split('-').slice(4).some(part=>Number(part)>4294967295)||user.sid<=previous||typeof user.deviceId!=='string'||!/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/.test(user.deviceId)||devices.has(user.deviceId)||!positive(user.minimumPolicyVersion))fail('windows-deployment-invalid');previous=user.sid;devices.add(user.deviceId);} + return value; +} +function decodeEnvelope(envelope,kind,key){ + const cap=kind==='release'?MAX_RELEASE_BYTES:MAX_DEPLOYMENT_BYTES; + if(!exact(envelope,['payload','signature'])||typeof envelope.payload!=='string'||envelope.payload.length>Math.ceil(cap*4/3)||! /^[A-Za-z0-9_-]+$/.test(envelope.payload)||typeof envelope.signature!=='string'||! /^[A-Za-z0-9_-]{86}$/.test(envelope.signature))fail('windows-trust-envelope-invalid'); + const bytes=Buffer.from(envelope.payload,'base64url'),signature=Buffer.from(envelope.signature,'base64url'); + if(bytes.length>cap||bytes.toString('base64url')!==envelope.payload||signature.length!==64||signature.toString('base64url')!==envelope.signature||!verify('sha256',Buffer.concat([Buffer.from(DOMAINS[kind]),bytes]),{key,dsaEncoding:'ieee-p1363'},signature))fail('windows-trust-signature-invalid'); + const text=new TextDecoder('utf-8',{fatal:true}).decode(bytes),value=JSON.parse(text);if(canonical(value)!==text)fail('windows-trust-canonical-invalid'); + return {value,digest:createHash('sha256').update(bytes).digest('hex')}; +} +function checkFloor(sequence,digest,{minimumSequence=0,acceptedDigest}={}){ + if(!Number.isSafeInteger(minimumSequence)||minimumSequence<0||acceptedDigest!==undefined&&!/^[a-f0-9]{64}$/.test(acceptedDigest))fail('windows-trust-floor-invalid'); + if(sequenceitem.sid===authenticatedSid);if(!user)fail('windows-deployment-user-denied');const result=freeze({manifest:value,digest:decoded.digest,bootstrap:{publicKey:value.policyPublicKey,deviceId:user.deviceId,minimumVersion:user.minimumPolicyVersion}});verifiedDeployments.add(result);return result;}catch(error){if(error.code?.startsWith('windows-'))throw error;fail('windows-deployment-invalid');} +} +function verifiedManagementBinding(deployment){ + if(!verifiedDeployments.has(deployment)||deployment.manifest.format!=='safetynet-windows-deployment-v2'||deployment.manifest.expiresAt<=Date.now())fail('windows-management-denied'); + return deployment.manifest.management; +} +function signManifest(value,privatePem,kind){ + try{if(!Object.hasOwn(DOMAINS,kind))fail('windows-trust-kind-invalid');(kind==='release'?releaseSchema:deploymentSchema)(value);const payload=Buffer.from(canonical(value)),key=createPrivateKey(privatePem);if(key.asymmetricKeyType!=='ec'||key.asymmetricKeyDetails?.namedCurve!=='prime256v1')fail('windows-trust-key-invalid');if(payload.length>(kind==='release'?MAX_RELEASE_BYTES:MAX_DEPLOYMENT_BYTES))fail('windows-trust-envelope-invalid');return Object.freeze({payload:payload.toString('base64url'),signature:sign('sha256',Buffer.concat([Buffer.from(DOMAINS[kind]),payload]),{key,dsaEncoding:'ieee-p1363'}).toString('base64url')});}catch(error){if(error.code?.startsWith('windows-'))throw error;fail('windows-trust-signing-invalid');} +} +function publicKeyCoordinates(pem){try{const key=publicKey(pem,'ec'),jwk=key.export({format:'jwk'});return Buffer.concat([Buffer.from(jwk.x,'base64url'),Buffer.from(jwk.y,'base64url')]);}catch{fail('windows-trust-key-invalid');}} +module.exports={verifyReleaseEnvelope,verifyDeploymentEnvelope,signManifest,publicKeyCoordinates,verifiedManagementBinding,DOMAINS}; diff --git a/scripts/broker-package-contract.mjs b/scripts/broker-package-contract.mjs new file mode 100644 index 0000000..1e0a76e --- /dev/null +++ b/scripts/broker-package-contract.mjs @@ -0,0 +1,95 @@ +// Preparation for a separately packaged broker; NOT wired into production yet. +// The expected manifest must be embedded in the integrity-checked application, +// never accepted from the writable broker directory. Hashes alone do not replace +// installation ACLs, signed distribution, or a handle-bound process launch. +import {lstat, readdir, open} from 'node:fs/promises'; +import {createHash} from 'node:crypto'; +import path from 'node:path'; + +const MAX_FILES=4096; +function fail(code,failureFacts){throw Object.assign(new Error(code),{code,...(failureFacts?{failureFacts}:{})});} +function version(value){return typeof value==='string'&&/^\d{1,5}\.\d{1,5}\.\d{1,5}$/.test(value);} +function relative(value){ + return typeof value==='string'&&value.length<=240&&!value.includes('\\')&&value.split('/').every(segment=> + /^[a-zA-Z0-9_.-]+$/.test(segment)&&segment!=='.'&&segment!=='..'&&!/[. ]$/.test(segment)&& + !/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(segment)); +} +function validate(manifest,expected){ + if(!manifest||manifest.format!=='safetynet-broker-package-v1'||!version(manifest.version)|| + !Number.isSafeInteger(manifest.protocol)||manifest.protocol<1||manifest.protocol>65535|| + !relative(manifest.entrypoint)||!manifest.entrypoint.endsWith('.exe')|| + !Array.isArray(manifest.files)||!manifest.files.length||manifest.files.length>MAX_FILES)fail('broker_manifest_invalid'); + if(expected&&(manifest.version!==expected.version||manifest.protocol!==expected.protocol))fail('broker_package_incompatible'); + const names=new Set(); + for(const file of manifest.files){ + if(!file||!relative(file.path)||!Number.isSafeInteger(file.bytes)||file.bytes<0|| + !/^[a-f0-9]{64}$/.test(file.sha256)||names.has(file.path.toLowerCase()))fail('broker_manifest_invalid'); + names.add(file.path.toLowerCase()); + } + if(!names.has(manifest.entrypoint.toLowerCase()))fail('broker_manifest_invalid'); +} +async function inventory(root){ + const files=[];let entries=0; + // Reject symlinks/junctions at every ancestor, including the supplied root. + for(let current=path.resolve(root);;current=path.dirname(current)){ + const stat=await lstat(current); + if(stat.isSymbolicLink()||!stat.isDirectory())fail('broker_path_unsafe'); + if(path.dirname(current)===current)break; + } + async function walk(directory,prefix=''){ + for(const item of await readdir(directory,{withFileTypes:true})){ + if(++entries>MAX_FILES)fail('broker_package_too_large'); + const name=prefix+item.name; + if(!relative(name))fail('broker_path_unsafe'); + const stat=await lstat(path.join(root,...name.split('/'))); + if(stat.isSymbolicLink())fail('broker_path_unsafe'); + if(stat.isDirectory())await walk(path.join(directory,item.name),name+'/'); + else if(stat.isFile()){ + files.push(name);if(files.length>MAX_FILES)fail('broker_package_too_large'); + }else fail('broker_path_unsafe'); + } + } + await walk(root); + if(new Set(files.map(file=>file.toLowerCase())).size!==files.length)fail('broker_path_unsafe'); + return files.sort(); +} +async function digest(root,name){ + const handle=await open(path.join(root,...name.split('/')),'r'); + try{ + const before=await handle.stat();if(!before.isFile())fail('broker_path_unsafe'); + const hash=createHash('sha256');let bytes=0; + for await(const chunk of handle.createReadStream({autoClose:false})){hash.update(chunk);bytes+=chunk.length;} + const after=await handle.stat(); + const changed=[]; + if(before.size!==bytes)changed.push('size-before-read'); + if(after.size!==bytes)changed.push('size-after-read'); + if(before.mtimeMs!==after.mtimeMs)changed.push('modification-time'); + if(before.ctimeMs!==after.ctimeMs)changed.push('change-time'); + if(before.dev!==after.dev||before.ino!==after.ino)changed.push('file-identity'); + // Fixed bounded facts preserve the reason for a failed check without paths, + // file content, raw timestamps, OS error text or automatic retry. + if(changed.length)fail('broker_package_changed',Object.freeze({changed:Object.freeze(changed)})); + return {path:name,bytes,sha256:hash.digest('hex')}; + }finally{await handle.close();} +} +export async function buildBrokerPackageManifest(root,{version:release,protocol,entrypoint}){ + try{ + const manifest={format:'safetynet-broker-package-v1',version:release,protocol,entrypoint,files:[]}; + // Validate metadata before touching executable payloads. + validate({...manifest,files:[{path:entrypoint,bytes:0,sha256:'0'.repeat(64)}]}); + for(const name of await inventory(root))manifest.files.push(await digest(root,name)); + validate(manifest);return manifest; + }catch(error){if(error.code?.startsWith('broker_'))throw error;fail('broker_package_unavailable');} +} +export async function verifyBrokerPackage(root,trustedManifest,{version:release,protocol}){ + try{ + // Copy before await so a caller cannot mutate the trusted contract mid-check. + const manifest=structuredClone(trustedManifest);validate(manifest,{version:release,protocol}); + const actual=await inventory(root),expected=manifest.files.map(file=>file.path).sort(); + if(JSON.stringify(actual)!==JSON.stringify(expected))fail('broker_package_inventory_mismatch'); + for(const file of manifest.files){const observed=await digest(root,file.path); + if(observed.bytes!==file.bytes||observed.sha256!==file.sha256)fail('broker_package_integrity_mismatch');} + // Verification is an installation/startup check, not an atomic launch lease. + return Object.freeze({version:manifest.version,protocol:manifest.protocol,entrypoint:path.join(path.resolve(root),...manifest.entrypoint.split('/')),files:manifest.files.length}); + }catch(error){if(error.code?.startsWith('broker_'))throw error;fail('broker_package_unavailable');} +} diff --git a/scripts/build-windows-service-host.mjs b/scripts/build-windows-service-host.mjs new file mode 100644 index 0000000..162539a --- /dev/null +++ b/scripts/build-windows-service-host.mjs @@ -0,0 +1,85 @@ +// Compile an owned native SCM helper; never installs services or Windows rules. +import {spawn,execFileSync} from 'node:child_process'; +import {readFile,writeFile,mkdir,lstat,realpath} from 'node:fs/promises'; +import {createHash,generateKeyPairSync,sign} from 'node:crypto'; +import {createRequire} from 'node:module'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..'); +const args=process.argv.slice(2),compileOnly=args.length===1&&args[0]==='--compile-only',selfTest=args.length===1&&(args[0]==='--self-test'||compileOnly); +const hash=bytes=>createHash('sha256').update(bytes).digest('hex'); +const fail=()=>{throw Error('native-service-build-invalid');}; +const {signManifest,publicKeyCoordinates}=createRequire(import.meta.url)('../packages/core/windows-trust.cjs'); +const {encodeAcceptanceState}=createRequire(import.meta.url)('../apps/network-broker/service-acceptance.cjs'); +async function safe(directory){const info=await lstat(directory);if(info.isSymbolicLink()||await realpath(directory)!==directory)fail();} +async function run(executable,arguments_,expectedCode=0){const started=performance.now();return await new Promise((resolve,reject)=>{const child=spawn(executable,arguments_,{cwd:root,windowsHide:true,stdio:['ignore','pipe','pipe'],shell:false});let bytes=0;for(const stream of [child.stdout,child.stderr])stream.on('data',chunk=>{bytes+=chunk.length;if(bytes>65536){child.kill();reject(Error('native-build-output-limit'));}else process.stdout.write(chunk);});const timer=setTimeout(()=>{child.kill();reject(Error('native-build-timeout'));},60000);child.once('error',error=>{error.nativeStage=path.basename(executable);reject(error);});child.once('exit',code=>{clearTimeout(timer);(code===expectedCode)?resolve({code,milliseconds:performance.now()-started}):reject(Error('native-build-failed'));});});} +async function main(){ + if(process.platform!=='win32'||!selfTest)fail(); + const output=path.join(root,'artifacts/native-service-host');await mkdir(output,{recursive:true});await safe(path.join(root,'artifacts'));await safe(output); + const vswhere='C:/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe'; + const installation=execFileSync(vswhere,['-latest','-products','*','-requires','Microsoft.VisualStudio.Component.VC.Tools.x86.x64','-property','installationPath'],{encoding:'utf8',windowsHide:true,timeout:10000}).trim();if(!installation)fail(); + const version=(await readFile(path.join(installation,'VC/Auxiliary/Build/Microsoft.VCToolsVersion.default.txt'),'utf8')).trim();if(!/^\d+\.\d+\.\d+$/.test(version))fail(); + const tool=path.join(installation,'VC/Tools/MSVC',version),sdk='C:/Program Files (x86)/Windows Kits/10',sdkVersion='10.0.26100.0'; + const vendor=generateKeyPairSync('ec',{namedCurve:'prime256v1'}),customer=generateKeyPairSync('ec',{namedCurve:'prime256v1'}),policy=generateKeyPairSync('ed25519'); + const publicPem=key=>key.export({format:'pem',type:'spki'}),privatePem=key=>key.export({format:'pem',type:'pkcs8'}); + const header='static constexpr char RELEASE_ANCHOR_XY[]="'+publicKeyCoordinates(publicPem(vendor.publicKey)).toString('hex')+'";'; + await writeFile(path.join(output,'service-pins.h'),header); + const source=path.join(root,'apps/network-broker/native/service-host.cpp'),nativeHeader=path.join(root,'apps/network-broker/native/signed-manifest.h'),stateHeader=path.join(root,'apps/network-broker/native/acceptance-state.h'),topologyHeader=path.join(root,'apps/network-broker/native/wfp-topology.h'),credentialHeader=path.join(root,'apps/network-broker/native/management-credential.h'),beforeCredentialHeader=hash(await readFile(credentialHeader)),before=hash(await readFile(source)),beforeHeader=hash(await readFile(nativeHeader)),beforeStateHeader=hash(await readFile(stateHeader)),beforeTopologyHeader=hash(await readFile(topologyHeader)); + const executable=path.join(output,'safetynet-service-host-test.exe'); + const compiler=path.join(tool,'bin/Hostx64/x64/cl.exe'); + const arguments_=['/nologo','/std:c++17','/W4','/WX','/EHsc','/O2','/MT','/guard:cf','/DUNICODE','/D_UNICODE','/DSAFETYNET_NATIVE_SELF_TEST',... [output,path.join(tool,'include'),...['ucrt','um','shared'].map(v=>path.join(sdk,'Include',sdkVersion,v))].map(v=>'/I'+v),'/Fo'+path.join(output,'service-host.obj'),'/Fe'+executable,source,'/link','/DYNAMICBASE','/NXCOMPAT','/HIGHENTROPYVA',... [path.join(tool,'lib/x64'),...['ucrt','um'].map(v=>path.join(sdk,'Lib',sdkVersion,v,'x64'))].map(v=>'/LIBPATH:'+v)]; + const stages={};stages.compile=await run(compiler,arguments_);process.stdout.write('native.compile.completed\n');const compiledStat=await lstat(executable);process.stdout.write('native.compile.output.bytes='+compiledStat.size+'\n');const compiledHash=hash(await readFile(executable));process.stdout.write('native.compile.output.sha256='+compiledHash+'\n');if(hash(await readFile(source))!==before||hash(await readFile(nativeHeader))!==beforeHeader||hash(await readFile(stateHeader))!==beforeStateHeader||hash(await readFile(topologyHeader))!==beforeTopologyHeader||hash(await readFile(credentialHeader))!==beforeCredentialHeader)fail();if(compileOnly){await writeFile(path.join(output,'current-compile-only-proof.json'),JSON.stringify({compiled:true,selfTestExecuted:false,installed:false,wfpActivated:false,helperSha256:compiledHash,sourceSha256:before,nativeHeaderSha256:beforeHeader,stateHeaderSha256:beforeStateHeader,topologyHeaderSha256:beforeTopologyHeader,credentialHeaderSha256:beforeCredentialHeader,stages},null,2));return;}process.stdout.write('native.selftest.starting\n');stages.pipeJob=await run(executable,['--self-test']);stages.credentialSelection=await run(executable,['--verify-credential-selection']); + const helper=await readFile(executable),releaseId='11111111-1111-1111-1111-111111111111',deploymentTenant='22222222-2222-2222-2222-222222222222'; + const fixturePaths=['SafetyNet.exe','safetynet-service-host.exe','resources/app.asar','resources/broker/node.exe','resources/python/python.exe','resources/inspector/server.py','resources/semantic/synthetic.bin']; + const files=[{path:'safetynet-service-host-test.exe',bytes:helper.length,sha256:hash(helper)}]; + for(const name of fixturePaths){const destination=path.join(output,name);await mkdir(path.dirname(destination),{recursive:true});const synthetic=Buffer.from('fixed native signed inventory fixture');await writeFile(destination,synthetic);files.push({path:name,bytes:synthetic.length,sha256:hash(synthetic)});} + files.sort((a,b)=>a.pathb.path?1:0); + const release={format:'safetynet-windows-release-v1',releaseId,releaseSequence:2,serviceName:'SafetyNetNetworkBroker',deploymentTenant,deploymentPublicKey:publicPem(customer.publicKey),files}; + const deployment={format:'safetynet-windows-deployment-v1',releaseId,deploymentTenant,deploymentSequence:2,policyPublicKey:publicPem(policy.publicKey),expiresAt:Date.now()+60000,users:[{sid:'S-1-5-21-1-2-3-1001',deviceId:'synthetic-native',minimumPolicyVersion:3}]}; + const releaseEnvelope=signManifest(release,privatePem(vendor.privateKey),'release'),deploymentEnvelope=signManifest(deployment,privatePem(customer.privateKey),'deployment'); + await writeFile(path.join(output,'release-envelope.json'),JSON.stringify(releaseEnvelope));await writeFile(path.join(output,'deployment-envelope.json'),JSON.stringify(deploymentEnvelope)); + stages.trustPositive=await run(executable,['--verify-fixture']); + // Native negatives are genuinely customer-signed, intentionally bypassing + // JS schema validation so the independent C++ parser must reject them. + const canonical=value=>Array.isArray(value)?'['+value.map(canonical).join(',')+']':value&&typeof value==='object'?'{'+Object.keys(value).sort().map(key=>JSON.stringify(key)+':'+canonical(value[key])).join(',')+'}':JSON.stringify(value); + const rawDeployment=value=>{const bytes=Buffer.from(canonical(value));return {payload:bytes.toString('base64url'),signature:sign('sha256',Buffer.concat([Buffer.from('SafetyNet Windows deployment v1\0'),bytes]),{key:customer.privateKey,dsaEncoding:'ieee-p1363'}).toString('base64url')};}; + const pq=generateKeyPairSync('ml-dsa-87'),wrongPq=generateKeyPairSync('ml-dsa-65'); + const management={origin:'https://management.example',signatureKey:publicPem(pq.publicKey),protocol:'safetynet-policy-mlkem1024-mldsa87-v1'}; + const deploymentV2={...deployment,format:'safetynet-windows-deployment-v2',management}; + stages.managementV2={}; + for(const [index,origin] of ['https://management.example','https://management.example:8443','https://127.0.0.1','https://[::1]','https://[::ffff:c0a8:1]'].entries()){ + await writeFile(path.join(output,'deployment-envelope.json'),JSON.stringify(signManifest({...deploymentV2,management:{...management,origin}},privatePem(customer.privateKey),'deployment'))); + stages.managementV2['positive'+index]=await run(executable,['--verify-fixture']); + } + const invalidManagement=[...['http://management.example','https://management.example/','https://management.example/path','https://user:password@management.example','https://management.example?query=1','https://management.example#fragment','https://MANAGEMENT.example','https://management.example:443','https://management.example.','https://management.example:','https://[::1]:','https://[0:0:0:0:0:0:0:1]','https://[::ffff:192.168.0.1]','https://127.1','https://bad_host.example'].map(origin=>({...management,origin})),{...management,protocol:'safetynet-policy-mlkem768-mldsa65-v1'},{...management,signatureKey:publicPem(wrongPq.publicKey)},{...management,signatureKey:publicPem(policy.publicKey)},{...management,signatureKey:management.signatureKey.trim()},{...management,token:'synthetic-denied'}]; + for(const [index,value] of invalidManagement.entries()){ + await writeFile(path.join(output,'deployment-envelope.json'),JSON.stringify(rawDeployment({...deploymentV2,management:value}))); + stages.managementV2['invalid'+index]=await run(executable,['--verify-fixture'],86); + } + await writeFile(path.join(output,'deployment-envelope.json'),JSON.stringify(rawDeployment({...deploymentV2,format:'safetynet-windows-deployment-v3'})));stages.managementV2.unknownFormat=await run(executable,['--verify-fixture'],86); + await writeFile(path.join(output,'deployment-envelope.json'),JSON.stringify(rawDeployment({...deploymentV2,format:'safetynet-windows-deployment-v1'})));stages.managementV2.v1ExtraCapability=await run(executable,['--verify-fixture'],86); + const signedV2=signManifest(deploymentV2,privatePem(customer.privateKey),'deployment'); + await writeFile(path.join(output,'deployment-envelope.json'),JSON.stringify({...signedV2,payload:Buffer.from(canonical({...deploymentV2,management:{...management,origin:'https://attacker.example'}})).toString('base64url')}));stages.managementV2.signatureTamper=await run(executable,['--verify-fixture'],86); + await writeFile(path.join(output,'deployment-envelope.json'),JSON.stringify(deploymentEnvelope));stages.managementV2.v1HistoricalPreserved=await run(executable,['--verify-fixture']); + const changedPayload=JSON.parse(Buffer.from(releaseEnvelope.payload,'base64url'));changedPayload.releaseSequence=3; + await writeFile(path.join(output,'release-envelope.json'),JSON.stringify({...releaseEnvelope,payload:Buffer.from(JSON.stringify(changedPayload)).toString('base64url')}));stages.signatureTamper=await run(executable,['--verify-fixture'],86); + await writeFile(path.join(output,'release-envelope.json'),JSON.stringify(signManifest({...release,deploymentPublicKey:publicPem(vendor.publicKey)},privatePem(vendor.privateKey),'release')));stages.wrongDelegatedKey=await run(executable,['--verify-fixture'],86); + await writeFile(path.join(output,'release-envelope.json'),JSON.stringify(releaseEnvelope)); + stages.uninstalledSnapshotRejected=await run(executable,['--trust-snapshot'],5); + stages.uninstalledCommitRejected=await run(executable,['--commit-acceptance'],5); + await writeFile(path.join(output,'deployment-envelope.json'),JSON.stringify(signManifest({...deployment,expiresAt:Date.now()-1},privatePem(customer.privateKey),'deployment')));stages.expiredDeployment=await run(executable,['--verify-fixture'],86); + await writeFile(path.join(output,'deployment-envelope.json'),JSON.stringify(deploymentEnvelope));stages.uninstalledClientRejected=await run(executable,['--client-stdio'],5); + stages.sameSequenceDigestRejected=await run(executable,['--verify-equivocation'],86); + const manyFiles=[...files,...Array.from({length:500},(_,i)=>({path:`resources/semantic/synthetic-${String(i).padStart(4,'0')}.bin`,bytes:0,sha256:'0'.repeat(64)}))].sort((a,b)=>a.pathb.path?1:0); + await writeFile(path.join(output,'release-envelope.json'),JSON.stringify(signManifest({...release,files:manyFiles},privatePem(vendor.privateKey),'release')));stages.largeSignedInventory=await run(executable,['--verify-fixture']); + await writeFile(path.join(output,'release-envelope.json'),JSON.stringify(releaseEnvelope)); + const oldState={format:'safetynet-service-floors-v1',release:{minimumSequence:2,acceptedDigest:hash(Buffer.from(releaseEnvelope.payload,'base64url'))},deployment:{tenant:deploymentTenant,minimumSequence:2,acceptedDigest:hash(Buffer.from(deploymentEnvelope.payload,'base64url'))},policies:[{sid:'S-1-5-21-1-2-3-1001',deviceId:'synthetic-native',policyKeySha256:hash(policy.publicKey.export({format:'der',type:'spki'})),minimumVersion:3,acceptedDigest:'a'.repeat(64)}]}; + await writeFile(path.join(output,'acceptance-old.json'),encodeAcceptanceState(oldState)); + const nextState=structuredClone(oldState);nextState.policies[0].minimumVersion=4;nextState.policies[0].acceptedDigest='b'.repeat(64); + await writeFile(path.join(output,'acceptance-candidate.json'),encodeAcceptanceState(nextState));stages.monotonicState=await run(executable,['--verify-state-fixture']); + await writeFile(path.join(output,'acceptance-candidate.json'),encodeAcceptanceState({...nextState,policies:[]}));stages.policyDropRejected=await run(executable,['--verify-state-fixture'],86); + const lowered=structuredClone(nextState);lowered.policies[0].minimumVersion=2;await writeFile(path.join(output,'acceptance-candidate.json'),encodeAcceptanceState(lowered));stages.policyRollbackRejected=await run(executable,['--verify-state-fixture'],86); + const equivocation=structuredClone(oldState);equivocation.policies[0].acceptedDigest='b'.repeat(64);await writeFile(path.join(output,'acceptance-candidate.json'),encodeAcceptanceState(equivocation));stages.policyEquivocationRejected=await run(executable,['--verify-state-fixture'],86); + await writeFile(path.join(output,'proof.json'),JSON.stringify({sourceSha256:before,nativeHeaderSha256:beforeHeader,stateHeaderSha256:beforeStateHeader,topologyHeaderSha256:beforeTopologyHeader,credentialHeaderSha256:beforeCredentialHeader,helperSha256:hash(await readFile(executable)),selfTest:true,installed:false,wfpActivated:false,msvc:version,sdk:sdkVersion,stages},null,2)); +} +main().catch(error=>{process.stderr.write('native-service-build-failed'+(typeof error?.nativeStage==='string'&&/^[a-zA-Z0-9.-]+$/.test(error.nativeStage)?':'+error.nativeStage:'')+(typeof error?.code==='string'&&/^[A-Z_]+$/.test(error.code)?':'+error.code:'')+(typeof error?.syscall==='string'&&/^[a-z_]+$/.test(error.syscall)?':'+error.syscall:'')+'\n');process.exitCode=1;}); diff --git a/scripts/install/windows-security.nsh b/scripts/install/windows-security.nsh new file mode 100644 index 0000000..93315c3 --- /dev/null +++ b/scripts/install/windows-security.nsh @@ -0,0 +1,14 @@ +; Called after resources are copied into the protected per-machine location. +; nsExec hides the console and bounds captured output by NSIS_MAX_STRLEN. +; Its timeout is an inactivity timeout; the trusted Python helper also owns +; a hard wall-clock deadline and kill-on-close Job for its grant descendants. +!macro customInstall + nsExec::ExecToStack /TIMEOUT=245000 '"$INSTDIR\resources\python\python.exe" -I -B "$INSTDIR\resources\inspector\provision_runtime_acl.py" --provision' + Pop $0 + Pop $1 + StrCpy $1 "" + StrCmp $0 "0" safetynet_runtime_acl_ready + SetErrorLevel 1 + Abort "SafetyNet could not establish the required scanner security permissions. Installation stopped." + safetynet_runtime_acl_ready: +!macroend diff --git a/scripts/package-broker-resources.mjs b/scripts/package-broker-resources.mjs new file mode 100644 index 0000000..f99c2ff --- /dev/null +++ b/scripts/package-broker-resources.mjs @@ -0,0 +1,69 @@ +// Mandatory component packaging only; this does not activate a broker or +// install/attest its Windows network boundary. +import {createRequire} from 'node:module'; +import {randomUUID} from 'node:crypto'; +import {createHash} from 'node:crypto'; +import {createReadStream} from 'node:fs'; +import {readdir,lstat} from 'node:fs/promises'; +import path from 'node:path'; +import {stageBrokerPayload,BROKER_PAYLOAD,verifySourceSnapshot} from './stage-broker-payload.mjs'; +import {verifyBrokerRuntime} from './provision-broker-runtime.mjs'; +import {verifyBrokerPackage} from './broker-package-contract.mjs'; +const require=createRequire(import.meta.url); +const RESOURCE_FROM='.runtime/windows/broker-payload',MANIFEST_FROM=RESOURCE_FROM+'.manifest.json'; +const EMBEDDED_MANIFEST='apps/browser/broker-package-manifest.json'; +function fail(code){throw Object.assign(new Error(code),{code});} +export function brokerResourceConfiguration(build,{payloadRoot,manifestPath}){ + if(!build||!Array.isArray(build.extraResources)||!Array.isArray(build.files))fail('broker_package_configuration_missing'); + const destination=value=>typeof value==='string'?value.replaceAll('\\','/').replace(/\/+$/,'').toLowerCase():''; + const resources=build.extraResources.filter(item=>item&&typeof item==='object'&&(destination(item.to)==='broker'||destination(item.to).startsWith('broker/'))); + const manifests=build.files.filter(item=>item&&typeof item==='object'&&destination(item.to)===EMBEDDED_MANIFEST); + if(resources.length!==1||resources[0].from!==RESOURCE_FROM||manifests.length!==1||manifests[0].from!==MANIFEST_FROM)fail('broker_package_configuration_missing'); + if(!path.isAbsolute(payloadRoot)||!path.isAbsolute(manifestPath))fail('broker_package_configuration_invalid'); + return {...structuredClone(build),extraResources:build.extraResources.map(item=>item===resources[0]?{from:payloadRoot,to:'broker',filter:['**/*']}:structuredClone(item)),files:build.files.map(item=>item===manifests[0]?{from:manifestPath,to:EMBEDDED_MANIFEST}:structuredClone(item))}; +} +export async function prepareBrokerResources({root,build,dependencyArchive}){ + await verifyBrokerRuntime(path.join(root,'.runtime/windows/broker')); + const destination=path.join(root,'.runtime/windows','broker-payload-package-'+randomUUID()); + const staged=await stageBrokerPayload({root,destination,dependencyArchive}); + const configuration=brokerResourceConfiguration(build,{payloadRoot:staged.root,manifestPath:staged.root+'.manifest.json'}); + return {configuration,manifest:staged.manifest,sourceSnapshot:staged.sources, + verifySources:()=>verifySourceSnapshot(root,staged.sources)}; +} +export async function verifyPackagedBroker({resources,expectedManifest,readEmbeddedManifest}){ + if(typeof readEmbeddedManifest!=='function')fail('broker_package_manifest_reader_required'); + // Production supplies the ASAR extractor after embedded ASAR integrity/fuse + // verification. Never read expected state from resources/broker metadata. + let embedded;try{const bytes=await readEmbeddedManifest(EMBEDDED_MANIFEST);if(!Buffer.isBuffer(bytes)||bytes.length>1048576)fail('broker_package_manifest_invalid');embedded=JSON.parse(bytes.toString('utf8'));}catch{fail('broker_package_manifest_invalid');} + if(JSON.stringify(embedded)!==JSON.stringify(expectedManifest))fail('broker_package_manifest_mismatch'); + return verifyBrokerPackage(path.join(resources,'broker'),embedded,BROKER_PAYLOAD); +} +export function embeddedBrokerManifestReader(archive){ + const {extractFile}=require('@electron/asar'); + return relative=>extractFile(archive,path.normalize(relative)); +} +const scannerRoots=[['apps/inspector','inspector'],['.runtime/windows/python','python'],['.runtime/windows/semantic','semantic']]; +async function fileDigest(file){const stat=await lstat(file);if(stat.isSymbolicLink()||!stat.isFile())fail('scanner_package_path_unsafe');const hash=createHash('sha256');for await(const chunk of createReadStream(file))hash.update(chunk);const after=await lstat(file);if(stat.size!==after.size||stat.mtimeMs!==after.mtimeMs||stat.ctimeMs!==after.ctimeMs)fail('scanner_package_changed');return {bytes:stat.size,sha256:hash.digest('hex')};} +async function scannerInventory(base,component,filter){ + const records=[]; + async function walk(directory,prefix=''){ + for(const entry of await readdir(directory,{withFileTypes:true})){ + if(filter&&(entry.name==='__pycache__'||component==='inspector'&&(entry.name.startsWith('test')||entry.name==='appcontainer_probe.py')))continue; + const file=path.join(directory,entry.name),relative=prefix+entry.name; + if(entry.isSymbolicLink())fail('scanner_package_path_unsafe'); + if(entry.isDirectory())await walk(file,relative+'/');else if(entry.isFile())records.push({path:component+'/'+relative,...await fileDigest(file)}); + else fail('scanner_package_path_unsafe'); + if(records.length>20000)fail('scanner_package_too_large'); + } + } + const stat=await lstat(base);if(stat.isSymbolicLink()||!stat.isDirectory())fail('scanner_package_path_unsafe'); + await walk(base);return records.sort((a,b)=>a.path.localeCompare(b.path)); +} +export async function snapshotScannerResources(root){ + const records=[];for(const [from,to] of scannerRoots)records.push(...await scannerInventory(path.join(root,...from.split('/')),to,to!=='semantic')); + return records; +} +export async function verifyScannerResources(resources,expected){ + const actual=[];for(const [,component] of scannerRoots)actual.push(...await scannerInventory(path.join(resources,component),component,false)); + if(JSON.stringify(actual)!==JSON.stringify(expected))fail('scanner_package_integrity_mismatch');return true; +} diff --git a/scripts/package.mjs b/scripts/package.mjs index 2b8412f..c56bf5a 100644 --- a/scripts/package.mjs +++ b/scripts/package.mjs @@ -1,26 +1,69 @@ import {hardenedElectronFuses,verifyPackagedFuses} from './package-fuses.mjs'; -import { spawnSync } from 'node:child_process'; -import {readFileSync,readdirSync} from 'node:fs'; -import {createHash} from 'node:crypto'; +import {spawnSync} from 'node:child_process'; +import {readFileSync,readdirSync,writeFileSync,createReadStream} from 'node:fs'; +import {createHash,randomUUID} from 'node:crypto'; import {createRequire} from 'node:module'; import path from 'node:path'; -const platform=process.argv[2];if(platform!=='win'||process.platform!=='win32')throw Error('Windows is the active build target; other platforms are deferred'); -const runtime=spawnSync(process.env.SAFETYNET_BUILD_PYTHON||'python',['scripts/provision-windows-runtime.py'],{stdio:'inherit'});if(runtime.status!==0)process.exit(runtime.status??1); -const semantic=spawnSync(process.env.SAFETYNET_BUILD_PYTHON||'python',['scripts/provision-semantic-runtime.py','--verify'],{stdio:'inherit'});if(semantic.status!==0)process.exit(semantic.status??1); +import {provisionBrokerRuntime,BROKER_RUNTIME} from './provision-broker-runtime.mjs'; +import {prepareBrokerResources,verifyPackagedBroker,embeddedBrokerManifestReader,snapshotScannerResources,verifyScannerResources} from './package-broker-resources.mjs'; +import {createWindowsPackagePlan} from './windows-package-plan.mjs'; +import {buildVerifiedInstaller} from './verified-installer.mjs'; +const platform=process.argv[2]; +if(platform!=='win'||process.platform!=='win32'||process.argv.slice(3).some(value=>value!=='--unpacked-only'))throw Error('Windows is the active build target; only --unpacked-only is additionally supported'); +const workspace=path.resolve('.'); +for(const args of [['scripts/provision-windows-runtime.py'],['scripts/provision-semantic-runtime.py','--verify']]){ + const result=spawnSync(process.env.SAFETYNET_BUILD_PYTHON||'python',args,{stdio:'inherit'});if(result.status!==0)process.exit(result.status??1); +} +await provisionBrokerRuntime(workspace); const name=process.env.BRAND_NAME||'SafetyNet',id=process.env.BRAND_APP_ID||'enterprise.safetynet.browser'; -if(!/^[\w .-]{1,40}$/.test(name)||!/^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9]*){2,}$/.test(id))throw Error('Invalid white-label product name or app ID'); -const configuration=JSON.parse(readFileSync('package.json','utf8')),workspace=path.resolve('.'),output=path.resolve(configuration.build.directories.output); -if(!output.startsWith(workspace+path.sep))throw Error('Build output must stay inside the workspace'); +const configuration=JSON.parse(readFileSync('package.json','utf8')),output=path.resolve(configuration.build.directories.output); // Reuse the pinned installed distribution already exercised by native tests. // This avoids a second archive extraction and Windows directory-rename locks. const electronDirectory=path.dirname(createRequire(import.meta.url)('electron')); if(readFileSync(path.join(electronDirectory,'version'),'utf8').trim().replace(/^v/,'')!==configuration.devDependencies.electron)throw Error('Installed Electron does not match the build pin'); -function sourceSnapshot(){const entries=[];function walk(directory){for(const item of readdirSync(directory,{withFileTypes:true})){const file=path.join(directory,item.name);if(item.isDirectory())walk(file);else if(item.isFile())entries.push([file,createHash('sha256').update(readFileSync(file)).digest('hex')]);}}for(const directory of ['apps/browser','packages/core'])walk(directory);entries.push(['package.json',createHash('sha256').update(readFileSync('package.json')).digest('hex')]);return JSON.stringify(entries.sort((a,b)=>a[0].localeCompare(b[0])));} +const plan=createWindowsPackagePlan({workspace,outputDirectory:output,electronDirectory,productName:name,appId:id}); +const broker=await prepareBrokerResources({root:workspace,build:configuration.build}); +const scannerSnapshot=await snapshotScannerResources(workspace); +const artifactName=`${name}-Setup-${configuration.version}.exe`; +if(!/^[\w .-]+\.exe$/.test(artifactName))throw Error('Invalid installer artifact name'); +const buildConfiguration={...broker.configuration,artifactName,electronFuses:hardenedElectronFuses}; +const configPath=path.join(workspace,'.runtime/windows',`package-build-${randomUUID()}.json`); +writeFileSync(configPath,JSON.stringify(buildConfiguration,null,2)+'\n',{flag:'wx'}); +const configArgs=[`--config=${configPath}`]; +function sourceSnapshot(){ + const entries=[]; + function walk(directory){for(const item of readdirSync(directory,{withFileTypes:true})){const file=path.join(directory,item.name);if(item.isSymbolicLink())throw Error('Build source reparse point rejected');if(item.isDirectory())walk(file);else if(item.isFile())entries.push([file,createHash('sha256').update(readFileSync(file)).digest('hex')]);}} + for(const directory of ['apps/browser','apps/network-broker','apps/inspector','packages/core','scripts'])walk(directory); + for(const file of ['package.json','package-lock.json',configuration.build.win.icon,configPath])entries.push([file,createHash('sha256').update(readFileSync(file)).digest('hex')]); + return createHash('sha256').update(JSON.stringify(entries.sort((a,b)=>a[0].localeCompare(b[0])))).digest('hex'); +} const sourcesBefore=sourceSnapshot(); -const r=spawnSync(process.execPath,['node_modules/electron-builder/out/cli/cli.js',`--${platform}`,'--dir',`--config.electronDist=${electronDirectory}`,`--config.productName=${name}`,`--config.appId=${id}`,...Object.entries(hardenedElectronFuses).map(([key,value])=>`--config.electronFuses.${key}=${value}`)],{stdio:'inherit',env:process.env}); -if(r.status!==0)process.exit(r.status??1); -if(sourceSnapshot()!==sourcesBefore)throw Error('Browser sources changed during packaging; discard this artifact and rebuild from stable sources'); -const resources=path.join(output,'win-unpacked/resources'); -await verifyPackagedFuses(path.join(output,'win-unpacked',`${name}.exe`),path.join(resources,'app.asar')); -console.log('Packaged Electron security fuses and embedded ASAR integrity verified.'); -const acl=spawnSync(path.join(resources,'python/python.exe'),[path.join(resources,'inspector/provision_runtime_acl.py'),'--provision'],{stdio:'inherit',windowsHide:true});process.exit(acl.status??1); +const run=args=>spawnSync(process.execPath,['node_modules/electron-builder/out/cli/cli.js',...args,...configArgs],{stdio:'inherit',env:process.env}); +const result=run(plan.unpacked.args);if(result.status!==0)process.exit(result.status??1); +await broker.verifySources();if(sourceSnapshot()!==sourcesBefore)throw Error('Build sources changed; discard this package'); +const resources=path.join(plan.unpacked.directory,'resources'),archive=path.join(resources,'app.asar'),executable=path.join(plan.unpacked.directory,`${name}.exe`); +async function verify(){ + await broker.verifySources();if(sourceSnapshot()!==sourcesBefore)throw Error('Build sources changed; discard this package'); + await verifyPackagedFuses(executable,archive); + await verifyPackagedBroker({resources,expectedManifest:broker.manifest,readEmbeddedManifest:embeddedBrokerManifestReader(archive)}); + await verifyScannerResources(resources,scannerSnapshot); + const acl=spawnSync(path.join(resources,'python/python.exe'),[path.join(resources,'inspector/provision_runtime_acl.py')],{stdio:'inherit',windowsHide:true});if(acl.status!==0)throw Error('Packaged scanner ACL verification failed'); + return {identity:await readIdentity(),fuses:true,resources:true,broker:true,runtimeAcl:true}; +} +// This verifies/provisions the unpacked build location only. NSIS extraction +// does not preserve these ACEs: a reviewed installer post-install ACL step and +// actual standard-user Program Files acceptance remain required. Browser +// startup must NEVER silently provision or broaden installation permissions. +const acl=spawnSync(path.join(resources,'python/python.exe'),[path.join(resources,'inspector/provision_runtime_acl.py'),'--provision'],{stdio:'inherit',windowsHide:true});if(acl.status!==0)process.exit(acl.status??1); +async function treeHash(directory){ + const hash=createHash('sha256'); + async function walk(current){for(const item of readdirSync(current,{withFileTypes:true}).sort((a,b)=>a.name.localeCompare(b.name))){const file=path.join(current,item.name);if(item.isSymbolicLink())throw Error('Packaged reparse point rejected');if(item.isDirectory())await walk(file);else if(item.isFile()){const content=createHash('sha256');let bytes=0;for await(const chunk of createReadStream(file)){content.update(chunk);bytes+=chunk.length;}hash.update(JSON.stringify([path.relative(directory,file).replaceAll('\\','/'),bytes,content.digest('hex')])+'\n');}}} + await walk(directory);return hash.digest('hex'); +} +async function readIdentity(){await broker.verifySources();const scanners=await snapshotScannerResources(workspace);if(JSON.stringify(scanners)!==JSON.stringify(scannerSnapshot))throw Error('Scanner sources changed; discard this package');return {sourceSha256:sourceSnapshot(),runtimeSha256:createHash('sha256').update(JSON.stringify({electron:configuration.devDependencies.electron,broker:BROKER_RUNTIME,scanners})).digest('hex'),packageSha256:await treeHash(plan.unpacked.directory)};} +await verify(); +console.log('Unpacked browser, embedded broker manifest, complete broker payload, security fuses and scanner ACLs verified.'); +if(!process.argv.includes('--unpacked-only')){ + const installer=await buildVerifiedInstaller({plan,expectedIdentity:await readIdentity(),readIdentity,verify,run,installerPath:path.join(output,artifactName)}); + console.log(JSON.stringify({installer:installer.path,bytes:installer.bytes,sha256:installer.sha256,brokerIncluded:true,productionActivationVerified:false})); +} diff --git a/scripts/provision-broker-runtime.mjs b/scripts/provision-broker-runtime.mjs new file mode 100644 index 0000000..68487ee --- /dev/null +++ b/scripts/provision-broker-runtime.mjs @@ -0,0 +1,96 @@ +// Official standalone Node executable gives the broker a distinct Windows +// application identity; Electron utilityProcess shares the browser executable. +// This provisioning helper is not yet wired into production packaging. +import {createHash} from 'node:crypto'; +import {createReadStream,createWriteStream} from 'node:fs'; +import {lstat,mkdir,mkdtemp,readFile,rename,writeFile,readdir} from 'node:fs/promises'; +import {pipeline} from 'node:stream/promises'; +import {Readable,Transform} from 'node:stream'; +import {spawn} from 'node:child_process'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +export const BROKER_RUNTIME=Object.freeze({version:'24.21.0',platform:'win32',arch:'x64', + archive:'node-v24.21.0-win-x64.zip', + archiveSha256:'158f7685b44de51f6c0df1d153526cbcd3e1bc739a8dfc607721cef75de9e541', + executableSha256:'ba4e6d110e8c1592a1ecd390f6b05f3da124b13871a5be62b341a07a853c6c32', + licenseSha256:'ed34dd8e3f0a78dbaf00d0444ce8e285b015b765379c2e17880455f70370f8e9', + baseUrl:'https://nodejs.org/dist/v24.21.0/'}); +function fail(code){throw Object.assign(new Error(code),{code});} +async function sha256(file){const hash=createHash('sha256');for await(const chunk of createReadStream(file))hash.update(chunk);return hash.digest('hex');} +export function verifyOfficialChecksums(text){ + if(typeof text!=='string'||text.length>65536)fail('broker_runtime_checksums_invalid'); + for(const [name,expected] of [[BROKER_RUNTIME.archive,BROKER_RUNTIME.archiveSha256],['win-x64/node.exe',BROKER_RUNTIME.executableSha256]]){ + const rows=text.split(/\r?\n/).filter(row=>row.endsWith(' '+name)); + if(rows.length!==1||rows[0]!==expected+' '+name)fail('broker_runtime_checksums_mismatch'); + } +} +export async function downloadOfficialArchive(destination){ + const checksumResponse=await fetch(BROKER_RUNTIME.baseUrl+'SHASUMS256.txt',{redirect:'error',signal:AbortSignal.timeout(30000)}); + if(!checksumResponse.ok)fail('broker_runtime_download_failed'); + const chunks=[];let checksumBytes=0; + for await(const chunk of checksumResponse.body){checksumBytes+=chunk.length;if(checksumBytes>65536)fail('broker_runtime_checksums_invalid');chunks.push(Buffer.from(chunk));} + const checksumText=Buffer.concat(chunks).toString();verifyOfficialChecksums(checksumText); + const response=await fetch(BROKER_RUNTIME.baseUrl+BROKER_RUNTIME.archive,{redirect:'error',signal:AbortSignal.timeout(120000)}); + if(!response.ok)fail('broker_runtime_download_failed'); + let bytes=0;const hash=createHash('sha256'); + const bounded=new Transform({transform(chunk,_encoding,callback){bytes+=chunk.length;if(bytes>67108864){callback(new Error('broker_runtime_archive_too_large'));return;}hash.update(chunk);callback(null,chunk);}}); + await pipeline(Readable.fromWeb(response.body),bounded,createWriteStream(destination,{flags:'wx',mode:0o600})); + if(hash.digest('hex')!==BROKER_RUNTIME.archiveSha256)fail('broker_runtime_archive_mismatch'); + return {bytes,archiveSha256:BROKER_RUNTIME.archiveSha256,checksumText}; +} +function psQuote(value){return "'"+value.replaceAll("'","''")+"'";} +async function extractSelected(archive,destination){ + // Extract only exact reviewed members from the already hash-verified ZIP. + const script=`$ErrorActionPreference='Stop'; Add-Type -AssemblyName System.IO.Compression.FileSystem; $zip=[IO.Compression.ZipFile]::OpenRead(${psQuote(archive)}); try { foreach($name in @('node.exe','LICENSE')) { $member='node-v${BROKER_RUNTIME.version}-win-x64/'+$name; $entries=@($zip.Entries | Where-Object { $_.FullName -ceq $member }); if($entries.Count -ne 1 -or $entries[0].Length -gt 100000000) { throw 'broker_runtime_archive_members_invalid' }; $inputStream=$entries[0].Open(); $outputStream=[IO.File]::Open([IO.Path]::Combine(${psQuote(destination)},$name),[IO.FileMode]::CreateNew); try { $inputStream.CopyTo($outputStream) } finally { $outputStream.Dispose(); $inputStream.Dispose() } } } finally { $zip.Dispose() }`; + const child=spawn('powershell.exe',['-NoProfile','-NonInteractive','-EncodedCommand',Buffer.from(script,'utf16le').toString('base64')],{windowsHide:true,stdio:'ignore'}); + const code=await new Promise((resolve,reject)=>{child.once('error',reject);child.once('exit',resolve);}); + if(code!==0)fail('broker_runtime_extraction_failed'); +} +async function verifyDirectoryAncestors(directory){ + for(let current=path.resolve(directory);;current=path.dirname(current)){ + const stat=await lstat(current);if(stat.isSymbolicLink()||!stat.isDirectory())fail('broker_runtime_path_unsafe'); + if(path.dirname(current)===current)break; + } +} +export async function verifyBrokerRuntime(directory){ + await verifyDirectoryAncestors(directory); + const manifestPath=path.join(directory,'runtime-manifest.json'),manifestStat=await lstat(manifestPath); + if(manifestStat.isSymbolicLink()||!manifestStat.isFile()||manifestStat.size>16384)fail('broker_runtime_manifest_invalid'); + let manifest;try{manifest=JSON.parse(await readFile(manifestPath,'utf8'));}catch{fail('broker_runtime_manifest_invalid');} + if(!manifest||typeof manifest!=='object')fail('broker_runtime_manifest_invalid'); + if(manifest.format!=='safetynet-node-broker-runtime-v1'||manifest.version!==BROKER_RUNTIME.version||manifest.arch!=='x64'||manifest.platform!=='win32'||manifest.archiveSha256!==BROKER_RUNTIME.archiveSha256)fail('broker_runtime_manifest_invalid'); + if(!Array.isArray(manifest.files)||manifest.files.length!==2||manifest.files.some(file=>!file||typeof file.path!=='string')||new Set(manifest.files.map(file=>file.path)).size!==2)fail('broker_runtime_manifest_invalid'); + const files=await readdir(directory);if(JSON.stringify(files.sort())!==JSON.stringify(['LICENSE','node.exe','runtime-manifest.json'].sort()))fail('broker_runtime_inventory_mismatch'); + for(const name of ['node.exe','LICENSE']){ + const stat=await lstat(path.join(directory,name));if(stat.isSymbolicLink()||!stat.isFile())fail('broker_runtime_path_unsafe'); + const record=manifest.files?.find(item=>item.path===name); + if(!record||record.bytes!==stat.size||!/^[a-f0-9]{64}$/.test(record.sha256)||await sha256(path.join(directory,name))!==record.sha256)fail('broker_runtime_integrity_mismatch'); + if(record.sha256!==(name==='node.exe'?BROKER_RUNTIME.executableSha256:BROKER_RUNTIME.licenseSha256))fail('broker_runtime_integrity_mismatch'); + } + return manifest; +} +export async function provisionBrokerRuntime(workspace){ + if(process.platform!=='win32'||process.arch!=='x64')fail('broker_runtime_platform_unsupported'); + const parent=path.join(path.resolve(workspace),'.runtime/windows');await mkdir(parent,{recursive:true}); + await verifyDirectoryAncestors(parent); + const destination=path.join(parent,'broker'); + try{await lstat(destination);return await verifyBrokerRuntime(destination);}catch(error){if(error.code!=='ENOENT')throw error;} + const staging=await mkdtemp(path.join(parent,'broker-provision-')); + const archive=path.join(staging,BROKER_RUNTIME.archive),payload=path.join(staging,'payload');await mkdir(payload); + // Failed and verified archives/proof remain ignored for diagnosis; no + // unverified executable is started and no global Node installation changes. + const download=await downloadOfficialArchive(archive); + await writeFile(path.join(staging,'SHASUMS256.txt'),download.checksumText); + await extractSelected(archive,payload); + const manifest={format:'safetynet-node-broker-runtime-v1',version:BROKER_RUNTIME.version,platform:'win32',arch:'x64',archiveSha256:BROKER_RUNTIME.archiveSha256,files:[]}; + for(const name of ['node.exe','LICENSE'])manifest.files.push({path:name,bytes:(await lstat(path.join(payload,name))).size,sha256:await sha256(path.join(payload,name))}); + await writeFile(path.join(payload,'runtime-manifest.json'),JSON.stringify(manifest,null,2)+'\n'); + await verifyBrokerRuntime(payload);await rename(payload,destination); + return await verifyBrokerRuntime(destination); +} +if(process.argv[1]&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url)){ + const workspace=path.resolve(fileURLToPath(new URL('..',import.meta.url))); + const result=process.argv.includes('--verify')?await verifyBrokerRuntime(path.join(workspace,'.runtime/windows/broker')):await provisionBrokerRuntime(workspace); + console.log(JSON.stringify({version:result.version,files:result.files.length,verified:true})); +} diff --git a/scripts/stage-broker-payload.mjs b/scripts/stage-broker-payload.mjs new file mode 100644 index 0000000..9ba87f6 --- /dev/null +++ b/scripts/stage-broker-payload.mjs @@ -0,0 +1,55 @@ +// Standalone payload preparation only. No browser wiring, installer or service activation. +import {readFile,writeFile,lstat,mkdir,mkdtemp,rename} from 'node:fs/promises'; +import {createReadStream} from 'node:fs'; +import {createHash} from 'node:crypto'; +import {builtinModules} from 'node:module'; +import {gunzipSync} from 'node:zlib'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {BROKER_RUNTIME} from './provision-broker-runtime.mjs'; +import {buildBrokerPackageManifest,verifyBrokerPackage} from './broker-package-contract.mjs'; +export const BROKER_PAYLOAD=Object.freeze({version:'0.1.0',protocol:1,entrypoint:'node.exe'}); +const BROKER_FILES=Object.freeze(['audit-gate.cjs','bounded-audit.cjs','connect-inspection-proxy.cjs','diagnostics-adapter.cjs','dns-policy-route.cjs','download-response-gate.cjs','http-request-gate.cjs','http-response-gate.cjs','https-forwarder.cjs','https-inspection-server.cjs','lifecycle-audit.cjs','managed-proxy.cjs','management-identity-operation.cjs','multipart.cjs','policy-authority.cjs','process-entry.cjs','production-entry.cjs','production-factory.cjs','proxy-forwarder.cjs','service-acceptance.cjs','service-bootstrap.cjs','service-commit-gate.cjs','service-entry.cjs','service-store-client.cjs','framed-channel.cjs','tls-authority.cjs','tls-certificates.cjs','windows-wfp-plan.cjs','wfp-topology.cjs','wfp-revalidation.cjs','work-admission.cjs']); +const BROWSER_HELPERS=new Set(['policy-assignment.cjs','enterprise-inspection.cjs','icap-client.cjs','inspect-text-representations.cjs','header-inspection.cjs','inspection.cjs','loopback-http.cjs']); +const builtins=new Set(builtinModules.flatMap(name=>[name,name.replace(/^node:/,''),'node:'+name.replace(/^node:/,'')])); +function fail(code){throw Object.assign(new Error(code),{code});} +async function safe(file,directory=false){ + for(let current=path.resolve(file);;current=path.dirname(current)){ + const stat=await lstat(current);if(stat.isSymbolicLink())fail('broker_stage_path_unsafe'); + if(current===path.resolve(file)&&((directory&&!stat.isDirectory())||(!directory&&!stat.isFile())))fail('broker_stage_path_unsafe'); + if(path.dirname(current)===current)break; + } +} +async function digest(file){await safe(file);const before=await lstat(file);if(before.size>100000000)fail('broker_stage_file_limit');const hash=createHash('sha256');for await(const chunk of createReadStream(file))hash.update(chunk);const after=await lstat(file);if(before.size!==after.size||before.mtimeMs!==after.mtimeMs||before.ctimeMs!==after.ctimeMs)fail('broker_stage_source_changed');return {bytes:after.size,sha256:hash.digest('hex')};} +export async function snapshotSources(root,names){const records=[];for(const name of names){if(!/^[a-zA-Z0-9_./-]+$/.test(name)||name.split('/').some(p=>p==='..'||p==='.'||!p))fail('broker_stage_path_unsafe');records.push({path:name,...await digest(path.join(root,...name.split('/')))});}return records;} +export async function verifySourceSnapshot(root,records){for(const record of records){const actual=await digest(path.join(root,...record.path.split('/')));if(actual.bytes!==record.bytes||actual.sha256!==record.sha256)fail('broker_stage_source_changed');}} +function allowedSource(name){return /^packages\/core\/[a-z0-9-]+\.(?:cjs|mjs)$/.test(name)||(name.startsWith('apps/network-broker/')&&BROKER_FILES.includes(name.slice(20)))||(name.startsWith('apps/browser/')&&BROWSER_HELPERS.has(name.slice(13)));} +export async function reviewedSourceClosure(root){ + const queue=BROKER_FILES.map(name=>'apps/network-broker/'+name);queue.push('packages/core/policy.mjs');const seen=new Set(),external=new Set(); + while(queue.length){const name=queue.shift();if(seen.has(name))continue;if(!allowedSource(name))fail('broker_stage_unreviewed_source');await safe(path.join(root,...name.split('/')));const source=await readFile(path.join(root,...name.split('/')),'utf8');if(Buffer.byteLength(source)>1048576)fail('broker_stage_source_limit');seen.add(name); + for(const call of source.matchAll(/\b(?:require|import)\s*\(([^)]*)\)/g))if(!/^\s*['"][^'"]+['"]\s*$/.test(call[1]))fail('broker_stage_dynamic_dependency'); + const imports=[...source.matchAll(/(?:require\s*\(|import\s*\()\s*['"]([^'"]+)['"]/g),...source.matchAll(/\bfrom\s*['"]([^'"]+)['"]/g),...source.matchAll(/\bimport\s*['"]([^'"]+)['"]/g)].map(match=>match[1]); + for(const specifier of imports){if(builtins.has(specifier))continue;if(specifier.startsWith('.')){const next=path.posix.normalize(path.posix.join(path.posix.dirname(name),specifier));if(!allowedSource(next))fail('broker_stage_unreviewed_source');queue.push(next);}else external.add(specifier);} + } + if([...external].some(name=>name!=='zod'))fail('broker_stage_unreviewed_dependency');return {files:[...seen].sort(),dependencies:[...external].sort()}; +} +export function extractVerifiedZod(bytes,locked){ + if(!Buffer.isBuffer(bytes)||bytes.length>10485760||locked?.version!=='4.6.7'||locked.dev||locked.resolved!==`https://registry.npmjs.org/zod/-/zod-${locked.version}.tgz`||!/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(locked.integrity||'')||'sha512-'+createHash('sha512').update(bytes).digest('base64')!==locked.integrity)fail('broker_stage_dependency_integrity'); + let tar;try{tar=gunzipSync(bytes,{maxOutputLength:67108864});}catch{fail('broker_stage_dependency_archive');} + const files=[];const names=new Set();for(let offset=0;offset+512<=tar.length;){const header=tar.subarray(offset,offset+512);if(header.every(byte=>byte===0))break;const text=buffer=>buffer.toString('utf8').replace(/\0.*$/s,'');const name=text(header.subarray(0,100)),prefix=text(header.subarray(345,500));const full=prefix?prefix+'/'+name:name;const size=parseInt(text(header.subarray(124,136)).trim(),8),type=header[156];const checksum=parseInt(text(header.subarray(148,156)).trim(),8);let sum=0;for(let i=0;i<512;i++)sum+=i>=148&&i<156?32:header[i];if(sum!==checksum||!Number.isSafeInteger(size)||size<0||offset+512+size>tar.length||![0,48,53].includes(type)||!full.startsWith('package/')||full.includes('\\')||full.split('/').filter(Boolean).some(part=>! /^[a-zA-Z0-9_.-]+$/.test(part)||part==='..'||part==='.'||/[. ]$/.test(part)||/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part))||names.has(full.toLowerCase()))fail('broker_stage_dependency_archive');names.add(full.toLowerCase());if(names.size>4096)fail('broker_stage_dependency_archive'); + const relative=full.slice(8);if(type!==53&&relative&&!relative.startsWith('src/')&&!/(^|\/)(tests?|fixtures?|benchmarks?)(\/|$)/i.test(relative)&&(relative==='package.json'||/^license(?:\.[a-z]+)?$/i.test(relative)||/\.(?:js|cjs|mjs)$/.test(relative)))files.push({path:'node_modules/zod/'+relative,bytes:Buffer.from(tar.subarray(offset+512,offset+512+size))});offset+=512+Math.ceil(size/512)*512; + } + if(files.length>2048||!files.some(file=>file.path==='node_modules/zod/package.json')||!files.some(file=>/\/license(?:\.[a-z]+)?$/i.test(file.path)))fail('broker_stage_dependency_archive');let metadata;try{metadata=JSON.parse(files.find(file=>file.path==='node_modules/zod/package.json').bytes.toString());}catch{fail('broker_stage_dependency_archive');}if(metadata.name!=='zod'||metadata.version!==locked.version)fail('broker_stage_dependency_integrity');return files; +} +async function archive(locked){const response=await fetch(locked.resolved,{redirect:'error',signal:AbortSignal.timeout(30000)});if(!response.ok)fail('broker_stage_dependency_download');const chunks=[];let count=0;for await(const chunk of response.body){count+=chunk.length;if(count>10485760)fail('broker_stage_dependency_limit');chunks.push(Buffer.from(chunk));}return Buffer.concat(chunks);} +export async function stageBrokerPayload({root=fileURLToPath(new URL('../',import.meta.url)),destination,dependencyArchive}={}){ + root=path.resolve(root);const parent=path.join(root,'.runtime','windows');destination=path.resolve(destination||path.join(parent,'broker-payload'));if(path.dirname(destination)!==parent||!/^broker-payload(?:-[a-zA-Z0-9_-]+)?$/.test(path.basename(destination)))fail('broker_stage_destination_unsafe');await safe(root,true);await safe(parent,true);try{await lstat(destination);fail('broker_stage_destination_exists');}catch(error){if(error.code!=='ENOENT')throw error;} + const closure=await reviewedSourceClosure(root);const runtimeNames=['node.exe','LICENSE'];const sourceNames=[...closure.files,'package-lock.json',...runtimeNames.map(name=>'.runtime/windows/broker/'+name)];const sources=await snapshotSources(root,sourceNames);for(const [name,expected] of [['node.exe',BROKER_RUNTIME.executableSha256],['LICENSE',BROKER_RUNTIME.licenseSha256]])if(sources.find(item=>item.path==='.runtime/windows/broker/'+name).sha256!==expected)fail('broker_stage_runtime_integrity'); + const lock=JSON.parse(await readFile(path.join(root,'package-lock.json'),'utf8'));const locked=lock.packages?.['node_modules/zod'];if(!locked)fail('broker_stage_dependency_missing');const dependencies=extractVerifiedZod(dependencyArchive||await archive(locked),locked); + const temporary=await mkdtemp(path.join(parent,'broker-payload-stage-'));async function put(name,bytes){const target=path.join(temporary,...name.split('/'));await mkdir(path.dirname(target),{recursive:true});await writeFile(target,bytes,{flag:'wx'});} + for(const name of runtimeNames)await put(name,await readFile(path.join(root,'.runtime/windows/broker',name)));for(const name of closure.files)await put(name,await readFile(path.join(root,...name.split('/'))));for(const file of dependencies)await put(file.path,file.bytes); + await verifySourceSnapshot(root,sources);const manifest=await buildBrokerPackageManifest(temporary,BROKER_PAYLOAD);for(const source of sources.filter(item=>item.path!=='package-lock.json')){const target=source.path.startsWith('.runtime/windows/broker/')?source.path.slice('.runtime/windows/broker/'.length):source.path;const copied=manifest.files.find(item=>item.path===target);if(!copied||copied.bytes!==source.bytes||copied.sha256!==source.sha256)fail('broker_stage_source_changed');}await verifyBrokerPackage(temporary,manifest,BROKER_PAYLOAD);await verifySourceSnapshot(root,sources);await rename(temporary,destination); + // Adjacent metadata is evidence; only an embedded/signed installer copy is trusted. + await writeFile(destination+'.manifest.json',JSON.stringify(manifest,null,2)+'\n',{flag:'wx'});return {root:destination,manifest,sources,sourceFiles:closure.files.length,dependencies:closure.dependencies,productionReady:false}; +} +if(process.argv[1]&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url)){try{const result=await stageBrokerPayload();console.log(JSON.stringify({files:result.manifest.files.length,dependencies:result.dependencies,productionReady:result.productionReady}));}catch(error){console.error(error.code?.startsWith('broker_')?error.code:'broker_stage_failed');process.exitCode=1;}} diff --git a/scripts/verified-installer.mjs b/scripts/verified-installer.mjs new file mode 100644 index 0000000..511f2ce --- /dev/null +++ b/scripts/verified-installer.mjs @@ -0,0 +1,30 @@ +import {lstat,open} from 'node:fs/promises'; +import {createHash} from 'node:crypto'; +import path from 'node:path'; +function fail(code){throw Object.assign(new Error(code),{code});} +function same(expected,actual){return actual&&['sourceSha256','runtimeSha256','packageSha256'].every(key=>actual[key]===expected[key]);} +async function stat(file){try{return await lstat(file);}catch(error){if(error.code==='ENOENT')return null;throw error;}} +// Guard actual invocation and result. Caller owns/protects the build directory. +export async function buildVerifiedInstaller({plan,expectedIdentity,readIdentity,verify,run,installerPath}){ + if(typeof run!=='function'||!plan||!path.isAbsolute(installerPath)||path.dirname(installerPath)!==path.dirname(plan.unpacked.directory)||!installerPath.endsWith('.exe'))fail('installer_configuration_invalid'); + const stage=await plan.installer({expectedIdentity,readIdentity,verify}); + if(!same(stage.identity,await readIdentity(stage.directory)))fail('installer_identity_changed'); + const previous=await stat(installerPath);if(previous?.isSymbolicLink())fail('installer_artifact_invalid'); + const result=await run(stage.args); + if(result?.status!==0)fail('installer_build_failed'); + if(!same(stage.identity,await readIdentity(stage.directory)))fail('installer_identity_changed'); + const receipt=await verify(stage.directory,stage.identity); + if(!receipt||!same(stage.identity,receipt.identity)||!['fuses','resources','broker','runtimeAcl'].every(key=>receipt[key]===true))fail('installer_verification_failed'); + if(!same(stage.identity,await readIdentity(stage.directory)))fail('installer_identity_changed'); + const produced=await stat(installerPath); + if(!produced||!produced.isFile()||produced.isSymbolicLink()||produced.size<1024||produced.size>4294967295||previous&&previous.mtimeMs===produced.mtimeMs&&previous.ctimeMs===produced.ctimeMs&&previous.size===produced.size)fail('installer_artifact_invalid'); + const file=await open(installerPath,'r'); + try{ + const magic=Buffer.alloc(2);await file.read(magic,0,2,0);if(magic.toString('ascii')!=='MZ')fail('installer_artifact_invalid'); + const hash=createHash('sha256');for await(const chunk of file.createReadStream({start:0,autoClose:false}))hash.update(chunk); + const after=await file.stat();if(after.size!==produced.size||after.mtimeMs!==produced.mtimeMs||after.ctimeMs!==produced.ctimeMs)fail('installer_artifact_changed'); + if(!same(stage.identity,await readIdentity(stage.directory)))fail('installer_identity_changed'); + const final=await file.stat();if(final.size!==after.size||final.mtimeMs!==after.mtimeMs||final.ctimeMs!==after.ctimeMs)fail('installer_artifact_changed'); + return Object.freeze({path:installerPath,bytes:after.size,sha256:hash.digest('hex'),identity:stage.identity}); + }finally{await file.close();} +} diff --git a/scripts/windows-package-plan.mjs b/scripts/windows-package-plan.mjs new file mode 100644 index 0000000..333009a --- /dev/null +++ b/scripts/windows-package-plan.mjs @@ -0,0 +1,38 @@ +// Not wired into package.mjs until a real packaged broker is available. +// Official CLI: https://www.electron.build/docs/cli/ +// --dir produces only an unpacked app; --win nsis --prepackaged distributes +// that verified directory without silently rebuilding its application payload. +import path from 'node:path'; + +function fail(code){throw Object.assign(new Error(code),{code});} +function identity(value){ + if(!value||!['sourceSha256','runtimeSha256','packageSha256'].every(key=>/^[a-f0-9]{64}$/.test(value[key])))fail('package_identity_invalid'); + return Object.freeze(Object.fromEntries(['sourceSha256','runtimeSha256','packageSha256'].map(key=>[key,value[key]]))); +} +function same(left,right){return Object.keys(left).every(key=>left[key]===right[key]);} +export function createWindowsPackagePlan({workspace,outputDirectory,electronDirectory,productName='SafetyNet',appId='enterprise.safetynet.browser',platform=process.platform}){ + if(platform!=='win32')fail('package_platform_unsupported'); + if(![workspace,outputDirectory,electronDirectory].every(value=>typeof value==='string'&&path.isAbsolute(value)))fail('package_path_invalid'); + const root=path.resolve(workspace),output=path.resolve(outputDirectory),runtime=path.resolve(electronDirectory); + const relative=path.relative(root,output); + if(!relative||relative.startsWith('..'+path.sep)||relative==='..'||path.isAbsolute(relative))fail('package_output_unsafe'); + if(!/^[\w .-]{1,40}$/.test(productName)||!/^[a-z][a-z0-9]*(?:\.[a-z][a-z0-9]*){2,}$/.test(appId))fail('package_brand_invalid'); + const directory=path.join(output,'win-unpacked'); + const common=[`--config.productName=${productName}`,`--config.appId=${appId}`,`--config.directories.output=${output}`,'--publish','never']; + return Object.freeze({ + unpacked:Object.freeze({stage:'build-unpacked',args:Object.freeze(['--win','--dir',`--config.electronDist=${runtime}`,...common]),directory}), + verification:Object.freeze({stage:'verify-unpacked',required:Object.freeze(['fuses','resources','broker','runtimeAcl']),directory}), + async installer({expectedIdentity,readIdentity,verify}){ + if(typeof readIdentity!=='function'||typeof verify!=='function')fail('package_verifier_required'); + const expected=identity(expectedIdentity); + if(!same(expected,identity(await readIdentity(directory))))fail('package_identity_changed'); + const receipt=await verify(directory,expected); + if(!receipt||!same(expected,identity(receipt.identity))||!['fuses','resources','broker','runtimeAcl'].every(key=>receipt[key]===true))fail('package_verification_incomplete'); + if(!same(expected,identity(await readIdentity(directory))))fail('package_identity_changed'); + // This plans arguments only. Caller must protect the verified directory + // against changes through invocation and inspect the real installer result. + return Object.freeze({stage:'build-installer',directory,identity:expected, + args:Object.freeze(['--win','nsis','--prepackaged',directory,...common])}); + } + }); +} diff --git a/scripts/windows-release-inventory.mjs b/scripts/windows-release-inventory.mjs new file mode 100644 index 0000000..e15d349 --- /dev/null +++ b/scripts/windows-release-inventory.mjs @@ -0,0 +1,33 @@ +// Build the inventory AFTER final binaries exist; signing is performed by an +// external trusted release signer. No private key is generated or stored here. +import {lstat,readdir,open} from 'node:fs/promises'; +import {createHash} from 'node:crypto'; +import path from 'node:path'; +const OMIT=new Set(['release-envelope.json','deployment-envelope.json']); +function fail(code){throw Object.assign(new Error(code),{code});} +async function ancestors(root){for(let current=path.resolve(root);;current=path.dirname(current)){const stat=await lstat(current);if(!stat.isDirectory()||stat.isSymbolicLink())fail('windows-release-path-unsafe');if(path.dirname(current)===current)break;}} +async function hash(file){const input=await open(file,'r');try{const before=await input.stat(),sha=createHash('sha256');let bytes=0;for await(const chunk of input.createReadStream({autoClose:false})){bytes+=chunk.length;sha.update(chunk);}const after=await input.stat();if(!before.isFile()||before.size!==bytes||after.size!==bytes||before.mtimeMs!==after.mtimeMs||before.ctimeMs!==after.ctimeMs||before.ino!==after.ino||before.dev!==after.dev)fail('windows-release-source-changed');return {bytes,sha256:sha.digest('hex')};}finally{await input.close();}} +export async function buildReleaseInventory(root,{browserPath='SafetyNet.exe',nativeHostPath='safetynet-service-host.exe'}={}){ + await ancestors(root);const files=[],seen=new Set();let count=0; + async function walk(directory,prefix=''){ + for(const entry of await readdir(directory,{withFileTypes:true})){ + const relative=prefix+entry.name; + if(++count>15000||!relative.split('/').every(part=>/^[A-Za-z0-9_.-]+$/.test(part)&&part!=='.'&&part!=='..'&&!/[. ]$/.test(part)&&!/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part))||relative.length>240||entry.isSymbolicLink())fail('windows-release-path-unsafe'); + const target=path.join(directory,entry.name),stat=await lstat(target);if(stat.isSymbolicLink())fail('windows-release-path-unsafe'); + if(stat.isDirectory())await walk(target,relative+'/');else if(stat.isFile()){ + if(OMIT.has(relative))continue; + if(seen.has(relative.toLowerCase())||files.length>=10000)fail('windows-release-inventory-invalid');seen.add(relative.toLowerCase());files.push({path:relative,...await hash(target)}); + }else fail('windows-release-path-unsafe'); + } + } + await walk(root);files.sort((a,b)=>a.pathb.path?1:0); + for(const required of [browserPath,nativeHostPath,'resources/app.asar','resources/broker/node.exe','resources/python/python.exe','resources/inspector/server.py'])if(!files.some(file=>file.path===required))fail('windows-release-component-missing'); + if(!files.some(file=>file.path.startsWith('resources/semantic/')))fail('windows-release-component-missing'); + return files; +} +export async function verifyReleaseInventory(root,verifiedRelease,options){ + // Cryptographic authenticity is established separately by windows-trust.cjs. + // This API only compares an already trusted immutable inventory to disk. + const actual=await buildReleaseInventory(root,options),expected=verifiedRelease?.manifest?.files; + if(!Array.isArray(expected)||actual.length!==expected.length||actual.some((file,index)=>file.path!==expected[index]?.path||file.bytes!==expected[index]?.bytes||file.sha256!==expected[index]?.sha256))fail('windows-release-inventory-mismatch');return true; +} diff --git a/tests/broker-audit-gate.test.mjs b/tests/broker-audit-gate.test.mjs new file mode 100644 index 0000000..02059f0 --- /dev/null +++ b/tests/broker-audit-gate.test.mjs @@ -0,0 +1,89 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +import {createServer,request as httpRequest} from 'node:http'; +import {once} from 'node:events'; +import {mkdtemp,open,readFile,rm} from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +const require=createRequire(import.meta.url),{createBrokerAudit,BrokerAuditEventSchema}=require('../apps/network-broker/audit-gate.cjs'); +const {gateRequest}=require('../apps/network-broker/http-request-gate.cjs'); +async function fixture(t,configure={}){ + const root=await mkdtemp(path.join(os.tmpdir(),'safetynet-broker-audit-')),journal=path.join(root,'synthetic-events.jsonl'); + const received=[];let connections=0; + const receiver=createServer(async(req,res)=>{const chunks=[];for await(const chunk of req)chunks.push(chunk);received.push(Buffer.concat(chunks).toString());res.end('received');});receiver.on('connection',()=>connections++);receiver.listen(0,'127.0.0.1');await once(receiver,'listening'); + let valid=true,persistenceCalls=0; + const durable=async event=>{ + persistenceCalls++;BrokerAuditEventSchema.parse(event); + await configure.beforePersist?.(event); + // Synthetic metadata only. Production requires the existing protected, + // encrypted durable journal, not this test's plain fsync demonstration. + const file=await open(journal,'a');try{await file.writeFile(JSON.stringify(event)+'\n');await file.sync();}finally{await file.close();} + return {persisted:true,eventId:event.eventId}; + }; + const audit=createBrokerAudit({current:()=>valid,policyVersion:3,persist:configure.persist||durable,correlation:()=>({traceId:'a'.repeat(32),spanId:'b'.repeat(16)})}); + const destination=`http://127.0.0.1:${receiver.address().port}/upload`; + const proxy=createServer(async(req,res)=>{ + try{ + await gateRequest(req,{authority:'policy-3',current:()=>valid,deadlineMs:1000,inspect:async({body})=>{ + const decision=body.toString().includes('PRIVATE_TEST_SECRET')?'block':'allow'; + await audit.record(decision,{bytes:body.length,detectedType:'text',host:'127.0.0.1'});return {decision}; + },forward:({body})=>new Promise((resolve,reject)=>{const outgoing=httpRequest(destination,{method:'POST',headers:{'content-length':body.length}},response=>{response.resume();response.once('end',resolve);});outgoing.once('error',reject);outgoing.end(body);})}); + res.end('allowed'); + }catch{res.writeHead(403);res.end('blocked');} + });proxy.listen(0,'127.0.0.1');await once(proxy,'listening'); + t.after(async()=>{proxy.closeAllConnections();receiver.closeAllConnections();await Promise.all([new Promise(resolve=>proxy.close(resolve)),new Promise(resolve=>receiver.close(resolve))]);await rm(root,{force:true,recursive:true});}); + const send=body=>new Promise((resolve,reject)=>{const outgoing=httpRequest({host:'127.0.0.1',port:proxy.address().port,path:destination,method:'POST',headers:{host:new URL(destination).host,'content-length':Buffer.byteLength(body)}},response=>{response.resume();response.once('end',()=>resolve(response.statusCode));});outgoing.once('error',reject);outgoing.end(body);}); + return {send,audit,received,revoke:()=>{valid=false;},get connections(){return connections;},get persistenceCalls(){return persistenceCalls;},events:async()=>String(await readFile(journal)).trim().split('\n').map(line=>JSON.parse(line))}; +} +test('pending durable allow audit releases no TCP connection and fsync receipt precedes release',async t=>{ + let entered,release;const started=new Promise(resolve=>entered=resolve),hold=new Promise(resolve=>release=resolve); + const f=await fixture(t,{beforePersist:async()=>{entered();await hold;}}); + const pending=f.send('public payload');await started;assert.equal(f.connections,0);release();assert.equal(await pending,200); + assert.deepEqual(f.received,['public payload']);const events=await f.events();assert.equal(events.length,1);assert.equal(events[0].kind,'upload.authorized');assert.equal(events[0].policyVersion,3);assert.equal(events[0].traceId,'a'.repeat(32)); +}); +test('durable block records safe facts without original secret or request fingerprint',async t=>{ + const f=await fixture(t);assert.equal(await f.send('PRIVATE_TEST_SECRET'),403);assert.equal(f.connections,0); + const events=await f.events();assert.equal(events[0].kind,'upload.block');assert.equal(events[0].request.bytes,19);assert.equal(events[0].request.detectedType,'text'); + const text=JSON.stringify(events);assert.equal(text.includes('PRIVATE_TEST_SECRET'),false);assert.equal(text.includes('sha256'),false); +}); +test('audit failure and uncertain receipt block without dial or automatic retry',async t=>{ + for(const persist of [async()=>{throw Error('private persistence details');},async()=>({persisted:true,eventId:'wrong-id'})]){ + let calls=0;const f=await fixture(t,{persist:async event=>{calls++;return persist(event);}}); + assert.equal(await f.send('public'),403);assert.equal(f.connections,0);assert.equal(calls,1); + } +}); +test('revocation while audit waits prevents release even after durable write succeeds',async t=>{ + let entered,release;const started=new Promise(resolve=>entered=resolve),hold=new Promise(resolve=>release=resolve); + const f=await fixture(t,{beforePersist:async()=>{entered();await hold;}}),pending=f.send('public');await started;f.revoke();release(); + assert.equal(await pending,403);assert.equal(f.connections,0);assert.equal((await f.events()).length,1);assert.equal(f.persistenceCalls,1); +}); +test('missing mandatory persistence and malformed security facts cannot authorize transfer',async()=>{ + const audit=createBrokerAudit({current:()=>true,policyVersion:1}); + await assert.rejects(audit.record('allow',{bytes:2,detectedType:'text'}),{code:'audit-unavailable'}); + for(const facts of [{bytes:-1,detectedType:'text'},{bytes:2,detectedType:'text',body:'private'},{bytes:2,detectedType:'text',sha256:'a'.repeat(64)},{bytes:2,detectedType:'text',host:'https://company.example/private?q=secret'},{bytes:2,detectedType:'text',reason:'broker-inspection-fault'}])await assert.rejects(audit.record('allow',facts),{code:'audit-facts-invalid'}); +}); +test('fault event uses existing schema fixed reason without error detail',async()=>{ + const events=[];const audit=createBrokerAudit({current:()=>false,policyVersion:7,persist:async event=>{events.push(BrokerAuditEventSchema.parse(event));return {persisted:true,eventId:event.eventId};}}); + await audit.record('fault',{bytes:0,detectedType:'unknown'});assert.equal(events[0].kind,'upload.block');assert.equal(events[0].ruleId,'broker-inspection-fault'); +}); +test('decision reason mismatch and accessors fail with fixed errors without reading secret getters',async()=>{ + let evaluated=false;const audit=createBrokerAudit({current:()=>true,policyVersion:1,persist:async event=>({persisted:true,eventId:event.eventId})}); + for(const [decision,reason] of [['allow','broker-policy-block'],['block','broker-approved'],['block','broker-audit-failed'],['fault','broker-authority-expired']])await assert.rejects(audit.record(decision,{bytes:1,detectedType:'text',reason}),{code:'audit-facts-invalid'}); + await assert.rejects(audit.record('allow',{get bytes(){evaluated=true;throw Error('private secret');},detectedType:'text'}),{message:'audit-facts-invalid'});assert.equal(evaluated,false); +}); +test('only trusted policy and fixed built-in matched IDs enter security events',async()=>{ + const events=[],ids=['company-secret']; + const audit=createBrokerAudit({current:()=>true,policyVersion:1,allowedRuleIds:ids,persist:async event=>{events.push(event);return {persisted:true,eventId:event.eventId};}}); + ids.push('RAW PRIVATE RULE TEXT'); + await audit.record('block',{bytes:4,detectedType:'text',matchedRuleId:'company-secret'}); + await audit.record('block',{bytes:4,detectedType:'text',matchedRuleId:'api-secret'}); + assert.deepEqual(events.map(event=>event.ruleId),['company-secret','api-secret']); + for(const [decision,id] of [['allow','company-secret'],['block','RAW PRIVATE RULE TEXT'],['fault','unknown-rule']])await assert.rejects(audit.record(decision,{bytes:4,detectedType:'text',matchedRuleId:id}),{code:'audit-facts-invalid'}); + assert.equal(events.length,2);assert.equal(JSON.stringify(events).includes('RAW PRIVATE RULE TEXT'),false); +}); +test('canonical bare IPv6 destination is valid security metadata without an URL or port',async()=>{ + const events=[],audit=createBrokerAudit({current:()=>true,policyVersion:1,persist:async event=>{events.push(event);return {persisted:true,eventId:event.eventId};}}); + await audit.record('allow',{bytes:1,detectedType:'text',host:'::1'});assert.equal(events[0].host,'::1'); + await assert.rejects(audit.record('allow',{bytes:1,detectedType:'text',host:'[::1]:443'}),{code:'audit-facts-invalid'}); +}); diff --git a/tests/broker-audit-persistence-native.test.mjs b/tests/broker-audit-persistence-native.test.mjs new file mode 100644 index 0000000..3a0cc16 --- /dev/null +++ b/tests/broker-audit-persistence-native.test.mjs @@ -0,0 +1,27 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +import {randomUUID} from 'node:crypto'; +import {spawnSync} from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +const require=createRequire(import.meta.url); +const {createBrokerAuditPersistence}=require('../apps/browser/broker-audit-persistence.cjs'); +const enabled=process.platform==='win32'&&process.env.SAFETYNET_NATIVE_BROKER_AUDIT==='1'; +test('actual Windows DPAPI encrypted commit preserves broker identity before receipt',{skip:!enabled},()=>{ + const root=path.resolve('artifacts/broker-parent-audit');fs.mkdirSync(root,{recursive:true}); + const directory=fs.mkdtempSync(path.join(root,'proof-')); + const python=path.resolve('.runtime/windows/python/python.exe'),helper=path.resolve('apps/inspector/dpapi.py'); + const convert=(action,input)=>{const result=spawnSync(python,[helper,action],{input,windowsHide:true,timeout:10000,maxBuffer:65536});assert.equal(result.status,0,'Private Windows DPAPI operation failed');return result.stdout;}; + const queue=[],file=path.join(directory,'queue.bin'),temporary=path.join(directory,'queue.tmp');let committed=false; + const sink=createBrokerAuditPersistence({policyVersion:4,current:()=>true,getQueue:()=>queue,onFailure:()=>assert.fail('Parent audit was quarantined'),persistQueue:()=>{ + const protectedBytes=convert('protect',Buffer.from(JSON.stringify(queue))); + const fd=fs.openSync(temporary,'wx',0o600);try{fs.writeFileSync(fd,protectedBytes);fs.fsyncSync(fd);}finally{fs.closeSync(fd);} + fs.renameSync(temporary,file);committed=true; + }}); + const input={eventId:randomUUID(),kind:'upload.authorized',ruleId:'broker-approved',policyVersion:4,request:{bytes:6,detectedType:'text'}}; + const receipt=sink.persist(input);assert.equal(committed,true);assert.equal(receipt.eventId,input.eventId); + const encrypted=fs.readFileSync(file);assert.equal(encrypted.includes(Buffer.from(input.eventId)),false); + const decrypted=convert('unprotect',encrypted);try{assert.deepEqual(JSON.parse(decrypted.toString()),[input]);}finally{decrypted.fill(0);} + fs.writeFileSync(path.join(directory,'proof.json'),JSON.stringify({outcome:'passed',nativeProtection:'Windows current-user DPAPI',fsyncBeforeReceipt:true,identityPreserved:true,plaintextIdentityAbsent:true,productionMainWired:false},null,2)+'\n'); +}); diff --git a/tests/broker-audit-persistence.test.mjs b/tests/broker-audit-persistence.test.mjs new file mode 100644 index 0000000..1c28548 --- /dev/null +++ b/tests/broker-audit-persistence.test.mjs @@ -0,0 +1,40 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +import {randomUUID} from 'node:crypto'; +const require=createRequire(import.meta.url); +const {createBrokerAuditPersistence}=require('../apps/browser/broker-audit-persistence.cjs'); +const event=()=>({eventId:randomUUID(),kind:'upload.authorized',ruleId:'broker-approved',policyVersion:4,request:{bytes:6,detectedType:'text'}}); +function fixture(overrides={}){const queue=[];let writes=0,failures=0;const sink=createBrokerAuditPersistence({policyVersion:4,current:()=>true,getQueue:()=>queue,persistQueue:()=>{writes++;},onFailure:()=>{failures++;},...overrides});return {sink,queue,writes:()=>writes,failures:()=>failures};} +test('receipt preserves broker event identity only after synchronous parent commit',()=>{ + const f=fixture(),input=event(),receipt=f.sink.persist(input); + assert.deepEqual(receipt,{persisted:true,eventId:input.eventId});assert.equal(f.writes(),1); + assert.notEqual(f.queue[0],input);assert.ok(Object.isFrozen(f.queue[0]));assert.equal(f.queue[0].eventId,input.eventId); +}); +test('write failure is sticky and never issues a receipt or retries uncertain commit',()=>{ + let writes=0;const f=fixture({persistQueue:()=>{writes++;throw Error('private filesystem detail');}}); + assert.throws(()=>f.sink.persist(event()),/^Error: broker-audit-parent-unavailable$/); + assert.throws(()=>f.sink.persist(event()),/broker-audit-parent-unavailable/);assert.equal(writes,1); +}); +test('invalid policy, duplicate ID and queue capacity fail before write',()=>{ + for(const mode of ['policy','duplicate','capacity']){const f=fixture(),input=event();if(mode==='policy')input.policyVersion=3;if(mode==='duplicate')f.queue.push({...input});if(mode==='capacity')f.queue.push(...Array.from({length:10000},()=>({eventId:randomUUID()}))); + assert.throws(()=>f.sink.persist(input),/broker-audit-parent-unavailable/);assert.equal(f.writes(),0); + } +}); +test('revocation during commit prevents acknowledgment and asynchronous enqueue is rejected',()=>{ + let active=true;const f=fixture({current:()=>active,persistQueue:()=>{active=false;}}); + assert.throws(()=>f.sink.persist(event()),/broker-audit-parent-unavailable/); + const asyncFixture=fixture({persistQueue:()=>Promise.resolve()});assert.throws(()=>asyncFixture.sink.persist(event()),/broker-audit-parent-unavailable/); + for(const result of [false,true,{queued:true}]){const uncertain=fixture({persistQueue:()=>result});assert.throws(()=>uncertain.sink.persist(event()),/broker-audit-parent-unavailable/);} +}); +test('raw extra fields and top-level getters cannot become an audit channel',()=>{ + const f=fixture();let read=false;const input=event();Object.defineProperty(input,'secret',{enumerable:true,get(){read=true;return 'private';}}); + assert.throws(()=>f.sink.persist(input),/broker-audit-parent-unavailable/);assert.equal(read,false);assert.equal(f.writes(),0); + const raw=fixture();assert.throws(()=>raw.sink.persist({...event(),body:'private'}),/broker-audit-parent-unavailable/);assert.equal(raw.writes(),0); + const nested=fixture(),nestedInput=event();Object.defineProperty(nestedInput.request,'bytes',{enumerable:true,get(){read=true;return 6;}}); + assert.throws(()=>nested.sink.persist(nestedInput),/broker-audit-parent-unavailable/);assert.equal(read,false);assert.equal(nested.writes(),0); +}); +test('real download facts are immutable and linked without a generic request hash',()=>{ + const input={eventId:randomUUID(),kind:'download.block',ruleId:'synthetic-malware',policyVersion:4,file:{bytes:128,detectedType:'pdf',sha256:'a'.repeat(64)},broker:{requestId:randomUUID(),phase:'response.block',reason:'broker-download-inspection-block',authorizedEventId:randomUUID()}}; + const f=fixture();assert.equal(f.sink.persist(input).eventId,input.eventId);assert.ok(Object.isFrozen(f.queue[0].file));assert.equal(f.queue[0].request,undefined); +}); diff --git a/tests/broker-certificate-pins-native.test.mjs b/tests/broker-certificate-pins-native.test.mjs new file mode 100644 index 0000000..28b15ba --- /dev/null +++ b/tests/broker-certificate-pins-native.test.mjs @@ -0,0 +1,34 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +import {fork} from 'node:child_process'; +import {once} from 'node:events'; +import https from 'node:https'; +import fs from 'node:fs'; +import path from 'node:path'; +const require=createRequire(import.meta.url); +const {createSessionCertificates}=require('../apps/network-broker/tls-certificates.cjs'); +const enabled=process.platform==='win32'&&process.env.SAFETYNET_NATIVE_CERTIFICATE_PINS==='1'; +test('actual stock Electron accepts exact session leaf and rejects unregistered TLS leaf',{skip:!enabled,timeout:40000},async()=>{ + const issuer=await createSessionCertificates({current:()=>true,authorized:()=>true}); + const servers=[],root=path.resolve('artifacts/broker-certificate-pins');fs.mkdirSync(root,{recursive:true});const profile=fs.mkdtempSync(path.join(root,'profile-'));let child; + try{ + async function server(){const initial=issuer.resolve({authority:'localhost:443'});let requests=0; + const site=https.createServer({key:initial.key,cert:initial.cert},(_request,response)=>{requests++;response.end('synthetic public page');});servers.push(site);site.listen(0,'127.0.0.1');await once(site,'listening'); + const authority='localhost:'+site.address().port,leaf=issuer.resolve({authority});site.setSecureContext({key:leaf.key,cert:leaf.cert}); + return {url:'https://'+authority+'/',leaf:issuer.publicMetadata({authority}),requests:()=>requests}; + } + const publicSite=await server(),untrustedSite=await server(); + const env=Object.fromEntries(['SystemRoot','WINDIR','TEMP','TMP','USERPROFILE','APPDATA','LOCALAPPDATA','PATH'].filter(key=>process.env[key]).map(key=>[key,process.env[key]])); + child=fork(path.resolve('tests/fixtures/broker-certificate-pins-electron.cjs'),[],{execPath:require('electron'),execArgv:[],env,windowsHide:true,stdio:['ignore','ignore','ignore','ipc']}); + const result=once(child,'message',{signal:AbortSignal.timeout(20000)});child.send({type:'start',profile,leaf:publicSite.leaf,publicURL:publicSite.url,untrustedURL:untrustedSite.url}); + const [proof]=await result;assert.equal(proof.type,'proof');assert.equal(proof.publicStatus,200);assert.equal(proof.publicBody,'synthetic public page');assert.equal(proof.untrustedRejected,true);assert.equal(publicSite.requests(),1);assert.equal(untrustedSite.requests(),0); + fs.writeFileSync(path.join(root,'proof.json'),JSON.stringify({...proof,untrustedApplicationRequests:0,productionBrowserWired:false},null,2)+'\n'); + }finally{ + if(child&&child.exitCode===null&&child.signalCode===null){ + const exited=once(child,'exit',{signal:AbortSignal.timeout(6000)}),limit=setTimeout(()=>child.kill(),3000); + try{if(child.connected)child.send({type:'stop'},error=>{if(error)child.kill();});else child.kill();await exited;}finally{clearTimeout(limit);} + } + for(const site of servers){site.closeAllConnections();await new Promise(resolve=>site.close(resolve));}issuer.close(); + } +}); diff --git a/tests/broker-certificate-pins.test.mjs b/tests/broker-certificate-pins.test.mjs new file mode 100644 index 0000000..e053a47 --- /dev/null +++ b/tests/broker-certificate-pins.test.mjs @@ -0,0 +1,39 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +const require=createRequire(import.meta.url); +const {createBrokerCertificatePins,authorityFacts}=require('../apps/browser/broker-certificate-pins.cjs'); +const {createSessionCertificates}=require('../apps/network-broker/tls-certificates.cjs'); +test('exact session leaf accepted; other certificates use platform trust and revocation rejects known leaf',async()=>{ + let current=true;const issuer=await createSessionCertificates({current:()=>true,authorized:()=>true}); + const pins=createBrokerCertificatePins({generation:'synthetic-generation',policyVersion:4,current:()=>current,authorizedAuthority:()=>true}); + try{ + const leaf=issuer.publicMetadata({authority:'example.com:443'}); + assert.equal(pins.accept({...leaf,generation:'synthetic-generation',policyVersion:4}),true); + const verify=(hostname,data)=>{let result;pins.verify({hostname,certificate:{data,fingerprint:leaf.fingerprint256}},code=>result=code);return result;}; + assert.equal(verify('example.com',leaf.cert),0); + assert.equal(verify('attacker.test',leaf.cert),-3); + assert.equal(verify('example.com','unrelated-public-certificate'),-3); + current=false;assert.equal(verify('example.com',leaf.cert),-2); + current=true;pins.revoke();assert.equal(verify('example.com',leaf.cert),-2); + }finally{issuer.close();} +}); +test('wrong generation, policy, SAN, fingerprint and unauthorized destination cannot publish pins',async()=>{ + const issuer=await createSessionCertificates({current:()=>true,authorized:()=>true}); + try{const leaf=issuer.publicMetadata({authority:'example.com:443'}),input={...leaf,generation:'one',policyVersion:4}; + for(const patch of [{generation:'two'},{policyVersion:3},{authority:'attacker.test:443'},{fingerprint256:'wrong'},{cert:leaf.cert+'private trailing payload'},{cert:leaf.cert+leaf.cert}]){ + const pins=createBrokerCertificatePins({generation:'one',policyVersion:4,current:()=>true,authorizedAuthority:()=>true}); + assert.throws(()=>pins.accept({...input,...patch}),/broker-certificate-pin-rejected/);assert.equal(pins.status().pins,0); + } + const denied=createBrokerCertificatePins({generation:'one',policyVersion:4,current:()=>true,authorizedAuthority:()=>false});assert.throws(()=>denied.accept(input)); + }finally{issuer.close();} +}); +test('noncanonical authority and pin capacity cannot weaken trust',async()=>{ + for(const value of ['127.1:443','EXAMPLE.com:443','example.com:0443','example.com:65536','example.com:443@attacker.test','[0:0:0:0:0:0:0:1]:443'])assert.throws(()=>authorityFacts(value)); + assert.equal(authorityFacts('[::1]:443').hostname,'::1'); + const issuer=await createSessionCertificates({current:()=>true,authorized:()=>true}); + const pins=createBrokerCertificatePins({generation:'one',policyVersion:4,current:()=>true,authorizedAuthority:()=>true,maxPins:1}); + try{for(const [index,authority]of ['example.com:443','other.test:443'].entries()){ + const input={...issuer.publicMetadata({authority}),generation:'one',policyVersion:4};if(index===0)assert.equal(pins.accept(input),true);else assert.throws(()=>pins.accept(input)); + }}finally{issuer.close();} +}); diff --git a/tests/broker-connect-inspection.test.mjs b/tests/broker-connect-inspection.test.mjs new file mode 100644 index 0000000..47a47b8 --- /dev/null +++ b/tests/broker-connect-inspection.test.mjs @@ -0,0 +1,77 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import https from 'node:https'; +import tls from 'node:tls'; +import fs from 'node:fs'; +import {once} from 'node:events'; +import {createRequire} from 'node:module'; +const {createConnectProxy}=createRequire(import.meta.url)('../apps/network-broker/connect-inspection-proxy.cjs'); +const cert=fs.readFileSync(new URL('./fixtures/icap/cert.pem',import.meta.url)),key=fs.readFileSync(new URL('./fixtures/icap/key.pem',import.meta.url)); + +async function fixture(t,config={}) { + let connections=0,resolutions=0; + const received=[]; + const target=https.createServer({key,cert},async(req,res)=>{const chunks=[];for await(const chunk of req)chunks.push(chunk);received.push(Buffer.concat(chunks).toString());res.writeHead(201,{'content-type':'text/plain'});res.end('accepted');}); + target.on('connection',()=>connections++);target.listen(0,'127.0.0.1');await once(target,'listening'); + const destination=`localhost:${target.address().port}`; + const proxy=createConnectProxy({authority:'policy-1',current:()=>true,resolveCertificate:async binding=>{resolutions++;if(binding.authority!==destination)return undefined;return {authority:destination,key,cert};},inspect:async({body})=>({decision:body.toString().includes('SECRET')?'block':'allow'}),forward:({request,body,signal})=>new Promise((resolve,reject)=>{ + const req=https.request({host:'127.0.0.1',port:target.address().port,path:new URL(request.url).pathname,method:request.method,servername:'localhost',ca:cert,rejectUnauthorized:true,signal,headers:{host:destination,'content-length':body.length}},res=>{const chunks=[];res.on('data',c=>chunks.push(c));res.on('end',()=>resolve({statusCode:res.statusCode,headers:res.headers,body:Buffer.concat(chunks)}));});req.on('error',reject);req.end(body); + }),deadlineMs:1000,authMode:'bearer',...config}); + const port=await proxy.listen(); + t.after(async()=>{await proxy.close();target.closeAllConnections();await new Promise(r=>target.close(r));}); + async function send(body,{token=proxy.capability,authorization=`Bearer ${token}`,connectAuthority=destination,host=destination,servername='localhost'}={}) { + const result=await new Promise((resolve,reject)=>{ + const req=http.request({host:'127.0.0.1',port,method:'CONNECT',path:connectAuthority,headers:{host:connectAuthority,'proxy-authorization':authorization}}); + req.setTimeout(2000,()=>req.destroy(new Error('connect-test-deadline')));req.on('error',reject); + req.on('connect',(res,socket,head)=>{if(res.statusCode!==200){socket.destroy();resolve({status:res.statusCode,challenge:res.headers['proxy-authenticate']});return;}assert.equal(head.length,0); + const secure=tls.connect({socket,servername,ca:cert,rejectUnauthorized:true});let text=''; + secure.setTimeout(2000,()=>secure.destroy(new Error('tls-test-deadline')));secure.on('error',reject); + secure.on('secureConnect',()=>secure.write(`POST /submit HTTP/1.1\r\nHost: ${host}\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n${body}`)); + secure.on('data',c=>text+=c);secure.on('end',()=>{secure.destroy();resolve({status:Number(/^HTTP\/1.1 (\d+)/.exec(text)?.[1]),text});}); + });req.end(); + });return result; + } + return {send,received,credentials:proxy.credentials,get connections(){return connections;},get resolutions(){return resolutions;}}; +} +test('real authenticated CONNECT wraps verified TLS; no opaque target dial for secret',async t=>{ + const f=await fixture(t); + assert.equal((await f.send('SECRET')).status,403);assert.equal(f.connections,0);assert.deepEqual(f.received,[]); + const allowed=await f.send('public');assert.equal(allowed.status,201);assert.match(allowed.text,/accepted$/);assert.equal(f.connections,1);assert.deepEqual(f.received,['public']); +}); +test('incorrect token prevents certificate resolution, TLS establishment and target dial',async t=>{ + const f=await fixture(t);assert.equal((await f.send('public',{token:'incorrect'})).status,407);assert.equal(f.resolutions,0);assert.equal(f.connections,0); +}); +test('unapproved CONNECT authority, inner mismatch and absent SNI never dial target',async t=>{ + const f=await fixture(t); + assert.equal((await f.send('public',{connectAuthority:'evil.example:443'})).status,403); + assert.equal((await f.send('public',{host:'evil.example:443'})).status,403); + assert.equal((await f.send('public',{servername:''})).status,403); + assert.equal(f.connections,0);assert.deepEqual(f.received,[]); +}); +test('shared CONNECT work budget remains consumed when timed-out inspector ignores cancellation',async t=>{ + const retained=[]; + const f=await fixture(t,{maxClients:1,deadlineMs:100,inspect:({body})=>{retained.push(body);return new Promise(()=>{});}}); + assert.equal((await f.send('public')).status,403); + for(let index=0;index<3;index++)assert.equal((await f.send('public')).status,403); + assert.equal(retained.length,1);assert.equal(retained[0].toString(),'public');assert.equal(f.connections,0); +}); +test('shared CONNECT work budget retains an ignored forward promise after timeout',async t=>{ + let calls=0; + const f=await fixture(t,{maxClients:1,deadlineMs:100,forward:()=>{calls++;return new Promise(()=>{});}}); + assert.equal((await f.send('public')).status,502); + for(let index=0;index<3;index++)assert.equal((await f.send('public')).status,403); + assert.equal(calls,1);assert.equal(f.connections,0); +}); +test('timed-out certificate resolver retains shared capacity across new CONNECT sockets',async t=>{ + let calls=0,aborted=false; + const f=await fixture(t,{maxClients:1,deadlineMs:100,resolveCertificate:(_binding,{signal})=>{calls++;signal.addEventListener('abort',()=>aborted=true,{once:true});return new Promise(()=>{});}}); + for(let index=0;index<4;index++)assert.equal((await f.send('public')).status,403); + assert.equal(calls,1);assert.equal(aborted,true);assert.equal(f.connections,0); +}); +test('Basic mode challenges and rejects Bearer without downgrade then accepts exact random credentials',async t=>{ + const f=await fixture(t,{authMode:'basic'}); + const refused=await f.send('public');assert.equal(refused.status,407);assert.equal(refused.challenge,'Basic realm="SafetyNet private broker", charset="UTF-8"');assert.equal(f.connections,0);assert.equal(f.resolutions,0); + const authorization=`Basic ${Buffer.from(`${f.credentials.username}:${f.credentials.password}`).toString('base64')}`; + assert.equal((await f.send('public',{authorization})).status,201);assert.deepEqual(f.received,['public']); +}); diff --git a/tests/broker-diagnostics.test.mjs b/tests/broker-diagnostics.test.mjs new file mode 100644 index 0000000..be5a29f --- /dev/null +++ b/tests/broker-diagnostics.test.mjs @@ -0,0 +1,61 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +import {createServer} from 'node:http'; +import {DiagnosticsSchema} from '../packages/core/diagnostics.mjs'; +const require=createRequire(import.meta.url); +const {createDiagnostics}=require('../apps/browser/diagnostics.cjs'); +const {createBrokerDiagnostics}=require('../apps/network-broker/diagnostics-adapter.cjs'); +async function fixture(t){ + const payloads=[]; + const server=createServer((request,response)=>{ + const chunks=[];let bytes=0; + request.on('data',chunk=>{bytes+=chunk.length;if(bytes>1048576){request.destroy();return;}chunks.push(chunk);}); + request.on('end',()=>{payloads.push(JSON.parse(Buffer.concat(chunks).toString()));response.setHeader('content-type','application/json');response.end('{}');}); + }).listen(0,'127.0.0.1'); + await new Promise(resolve=>server.once('listening',resolve)); + const diagnostics=createDiagnostics(); + diagnostics.configure(DiagnosticsSchema.parse({enabled:true,mode:'debug',endpoint:`http://127.0.0.1:${server.address().port}`,sampleRate:1,debugSampleRate:1,flushIntervalMs:10000})); + t.after(async()=>{await diagnostics.shutdown();server.closeAllConnections();await new Promise(resolve=>server.close(resolve));}); + return {diagnostics,adapter:createBrokerDiagnostics(diagnostics),payloads,spans:()=>payloads.flatMap(p=>p.resourceSpans||[]).flatMap(p=>p.scopeSpans||[]).flatMap(p=>p.spans||[])}; +} +test('broker stages export actual correlated OTLP timing without payloads or authority',async t=>{ + const f=await fixture(t),secret='PRIVATE_BROKER_SECRET',attributes={bytes:12,policyVersion:4,url:'http://private-company.test/',authority:secret,token:secret,content:secret}; + await f.adapter.wrap('request',async()=>{ + for(const stage of ['framing','buffer','inspect','authorize','forward'])assert.equal(await f.adapter.wrap(stage,async()=>17,attributes),17); + }); + await f.diagnostics.flush(); + const spans=f.spans();assert.equal(spans.length,6); + const operations=spans.map(span=>span.attributes.find(attribute=>attribute.key==='safetynet.operation')?.value.stringValue).sort(); + assert.deepEqual(operations,['broker.authorize','broker.buffer','broker.forward','broker.framing','broker.inspect','broker.request'].sort()); + const parent=spans.find(span=>!span.parentSpanId);assert.ok(parent); + for(const child of spans.filter(span=>span!==parent)){ + assert.equal(child.parentSpanId,parent.spanId);assert.equal(child.traceId,parent.traceId); + assert.ok(BigInt(child.endTimeUnixNano)>=BigInt(child.startTimeUnixNano)); + assert.ok(child.attributes.some(attribute=>attribute.key==='safetynet.duration.ms')); + } + const text=JSON.stringify(f.payloads);assert.equal(text.includes(secret),false);assert.equal(text.includes('private-company'),false); + assert.equal(f.diagnostics.status().lifetimeFailedExports,0); +}); +test('disabled diagnostics preserve the exact enforcement failure with no exports',async t=>{ + const f=await fixture(t);f.diagnostics.configure({enabled:false}); + const failure=new Error('PRIVATE_ENFORCEMENT_FAILURE'); + await assert.rejects(f.adapter.wrap('inspect',async()=>{throw failure;}),error=>error===failure); + assert.equal(await f.adapter.wrap('forward',async()=>23),23); + await f.diagnostics.flush();assert.equal(f.spans().length,0); +}); +test('enabled failure tracing preserves error identity without exporting error text',async t=>{ + const f=await fixture(t),failure=new Error('PRIVATE_ERROR_DETAIL'); + await assert.rejects(f.adapter.wrap('inspect',async()=>{throw failure;}),error=>error===failure); + await f.diagnostics.flush();assert.equal(f.spans().length,1); + assert.equal(JSON.stringify(f.payloads).includes('PRIVATE_ERROR_DETAIL'),false); + assert.equal(f.spans()[0].status.code,2); +}); +test('adapter rejects unknown stages and never reads telemetry accessors',()=>{ + let touched=false,seen; + const adapter=createBrokerDiagnostics({span:(_name,attributes,fn)=>{seen=attributes;return fn();}}); + const attributes={get bytes(){touched=true;throw Error('private');},count:2,url:'private',operation:'private'}; + assert.equal(adapter.wrap('buffer',()=>true,attributes),true); + assert.equal(touched,false);assert.deepEqual(seen,{operation:'broker.buffer',count:2}); + assert.throws(()=>adapter.wrap('private',()=>true),{message:'broker_diagnostics_stage_invalid'}); +}); diff --git a/tests/broker-dns-policy-route.test.mjs b/tests/broker-dns-policy-route.test.mjs new file mode 100644 index 0000000..e42d78d --- /dev/null +++ b/tests/broker-dns-policy-route.test.mjs @@ -0,0 +1,28 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +import https from 'node:https'; +import {readFile} from 'node:fs/promises'; +import {dnsAddressBlocked} from '../packages/core/security.mjs'; +const require=createRequire(import.meta.url),{createDnsPolicyRoute,assertSupportedDns}=require('../apps/network-broker/dns-policy-route.cjs'); +const policy={security:{dns:{enabled:true,blockedIPs:['127.0.0.1','::1']}}}; +test('blocked literal including mapped IPv6 is denied before any DNS lookup',async()=>{ + let calls=0;const resolve=createDnsPolicyRoute({current:()=>true,blocked:a=>dnsAddressBlocked(a,policy),lookup:async()=>{calls++;throw Error();}}); + for(const hostname of ['127.0.0.1','[::1]','::ffff:127.0.0.1'])await assert.rejects(resolve({hostname,port:443}),{message:'broker-route-denied'});assert.equal(calls,0); +}); +test('real OS DNS blocked route produces zero TCP at controlled HTTPS receiver',async()=>{ + const cert=await readFile(new URL('./fixtures/icap/cert.pem',import.meta.url)),key=await readFile(new URL('./fixtures/icap/key.pem',import.meta.url));let connections=0; + const server=https.createServer({cert,key},(_q,r)=>r.end('public'));server.on('connection',()=>connections++);await new Promise(r=>server.listen(0,'::',r));const port=server.address().port; + try{const {createHttpsForwarder}=require('../apps/network-broker/https-forwarder.cjs'),resolveRoute=createDnsPolicyRoute({current:()=>true,blocked:a=>dnsAddressBlocked(a,policy)}),forward=createHttpsForwarder({authority:'localhost:'+port,current:()=>true,resolveRoute,ca:cert});await assert.rejects(forward({request:{url:`https://localhost:${port}/`,method:'GET',headers:{}},body:Buffer.alloc(0),signal:new AbortController().signal}));assert.equal(connections,0);}finally{server.closeAllConnections();await new Promise(r=>server.close(r));} +}); +test('system/VPN DNS all-address check refuses mixed blocked records and binds approved numeric route',async()=>{ + let response=[{address:'192.0.2.1',family:4},{address:'127.0.0.1',family:4}];const resolve=createDnsPolicyRoute({current:()=>true,blocked:a=>dnsAddressBlocked(a,policy),lookup:async(host,options)=>{assert.equal(host,'intranet.example');assert.deepEqual(options,{all:true,verbatim:true});return response;}}); + await assert.rejects(resolve({hostname:'intranet.example',port:443}),{message:'broker-route-denied'});response=[{address:'192.0.2.1',family:4}];const route=await resolve({hostname:'intranet.example',port:443});response[0].address='127.0.0.1';assert.equal(route.address,'192.0.2.1');assert.ok(Object.isFrozen(route)); +}); +test('revocation during original lookup and malformed results never produce approved route',async()=>{ + let active=true;const resolve=createDnsPolicyRoute({current:()=>active,blocked:()=>false,lookup:async()=>{active=false;return [{address:'192.0.2.1'}];}});await assert.rejects(resolve({hostname:'example.test',port:443}),{message:'broker-route-denied'}); + for(const response of [[],[{address:'hostname'}],null])await assert.rejects(createDnsPolicyRoute({current:()=>true,blocked:()=>false,lookup:async()=>response})({hostname:'example.test',port:443}),{message:'broker-route-denied'}); +}); +test('secure DNS and hostname proxy with active IP block policy refuse unsupported downgrade',()=>{ + assert.throws(()=>assertSupportedDns({enterprise:{dns:{mode:'secure'}}},{mode:'direct'}),/broker-route-unsupported/);assert.throws(()=>assertSupportedDns(policy,{mode:'fixed_servers'}),/broker-route-unsupported/);assert.doesNotThrow(()=>assertSupportedDns(policy,{mode:'direct'}));assert.doesNotThrow(()=>assertSupportedDns({security:{dns:{enabled:false,blockedIPs:['127.0.0.1']}}},{mode:'fixed_servers'})); +}); diff --git a/tests/broker-download-response.test.mjs b/tests/broker-download-response.test.mjs new file mode 100644 index 0000000..7cae2d5 --- /dev/null +++ b/tests/broker-download-response.test.mjs @@ -0,0 +1,67 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +import {defaultPolicy} from '../packages/core/policy.mjs'; +const require=createRequire(import.meta.url),{createDownloadResponseGate}=require('../apps/network-broker/download-response-gate.cjs'); +const input={request:{url:'https://intranet.example/document.pdf'},body:Buffer.alloc(0)}; +const response=(body,headers={},statusCode=200)=>({statusCode,headers,body:Buffer.from(body)}); +test('incoming confidential HTML/API is not outbound DLP; magic document requires actual scanner',async()=>{ + const policy=defaultPolicy();policy.browser.allowDownloads=true;let calls=0; + const page=createDownloadResponseGate({auditRequired:false,policy,current:()=>true,forward:async()=>response('company internal text',{'content-type':'text/html'}),inspector:{inspect:async()=>{calls++;assert.fail('incoming page not outgoingDLP');}}}); + assert.equal((await page(input)).body.toString(),'company internal text');assert.equal(calls,0); + const document=createDownloadResponseGate({auditRequired:false,policy,current:()=>true,forward:async()=>response('%PDF-1.4 public',{'content-type':'application/pdf'})});await assert.rejects(document(input),{code:'download-inspection-blocked'}); +}); +test('signed explicit no-malware-scanner still requires complete document parsing and preserves disposition',async()=>{ + const policy=defaultPolicy();policy.browser.allowDownloads=true;policy.fileTransfers.download.allowWithoutMalwareScanner=true; + let calls=0;const gate=createDownloadResponseGate({auditRequired:false,policy,current:()=>true,forward:async()=>response('%PDF-1.4 public',{'content-type':'application/pdf','content-disposition':'inline; filename="public.pdf"'}),inspector:{inspect:async()=>{calls++;return {decision:'allow',coverage:{complete:true}};}}}); + const value=await gate(input);assert.equal(calls,1);assert.equal(value.headers['content-disposition'],'inline; filename="public.pdf"');assert.equal(value.body.toString(),'%PDF-1.4 public'); +}); +test('malware/DLP block, incomplete coverage, scanner mutation and revocation release no response',async()=>{ + for(const inspect of [async()=>({decision:'block',ruleId:'registered'}),async()=>({decision:'allow',coverage:{complete:false}}),async bytes=>{bytes.fill(0);return {decision:'allow',coverage:{complete:true}};}]){ + const policy=defaultPolicy();policy.browser.allowDownloads=true;policy.fileTransfers.download.allowWithoutMalwareScanner=true; + const gate=createDownloadResponseGate({auditRequired:false,policy,current:()=>true,forward:async()=>response('%PDF-1.4 public'),inspector:{inspect}});await assert.rejects(gate(input),{code:'download-inspection-blocked'}); + } + const policy=defaultPolicy();policy.browser.allowDownloads=true;policy.fileTransfers.download.allowWithoutMalwareScanner=true;let current=true; + const gate=createDownloadResponseGate({auditRequired:false,policy,current:()=>current,forward:async()=>response('%PDF-1.4 public'),inspector:{inspect:async()=>{current=false;return {decision:'allow',coverage:{complete:true}};}}});await assert.rejects(gate(input),{code:'authority-expired'}); +}); +test('magic mismatch, partial206 and compression cannot bypass full download inspection',async()=>{ + const policy=defaultPolicy();policy.browser.allowDownloads=true;policy.fileTransfers.download.blockMimeMismatch=true; + for(const record of [response('%PDF-1.4 public',{'content-type':'text/plain'}),response('%PDF-1.4 public',{},206),response('%PDF-1.4 public',{'content-encoding':'gzip'})]){ + const gate=createDownloadResponseGate({auditRequired:false,policy,current:()=>true,forward:async()=>record,inspector:{inspect:async()=>assert.fail('invalidrepresentation must not scan')}});await assert.rejects(gate(input)); + } +}); +import {randomUUID,createHash} from 'node:crypto'; +import {createSyntheticAudit} from './fixtures/broker-durable-audit.cjs'; +const {createBrokerAudit}=require('../apps/network-broker/audit-gate.cjs'),{createBrokerLifecycle}=require('../apps/network-broker/lifecycle-audit.cjs'); +test('download verdict commits typed immutable real-file facts and registered detector before release',async t=>{ + const policy=defaultPolicy();policy.browser.allowDownloads=true;policy.fileTransfers.download.allowWithoutMalwareScanner=true;policy.dlp.rules=[{id:'registered-secret',label:'Synthetic',kind:'literal',value:'synthetic',action:'block'}]; + const journal=createSyntheticAudit(t),audit=createBrokerAudit({persist:journal.persist,current:()=>true,policyVersion:1,allowedRuleIds:['registered-secret']}),context=createBrokerLifecycle(audit).begin();await context.decision('allow',{bytes:0,detectedType:'text'}); + const body=Buffer.from('%PDF-1.4 encodedsynthetic'),gate=createDownloadResponseGate({policy,current:()=>true,forward:async()=>response(body),inspector:{inspect:async()=>({decision:'block',ruleId:'registered-secret',coverage:{complete:true}})}}); + await assert.rejects(gate({...input,auditContext:context}),{code:'download-inspection-blocked'});await context.reject('upstream-failed',{bytes:0,detectedType:'text'}); + const events=journal.events();assert.equal(events.length,2);assert.equal(events[1].kind,'download.block');assert.equal(events[1].ruleId,'registered-secret');assert.equal(events[1].broker.phase,'response.block');assert.equal(events[1].broker.authorizedEventId,events[0].eventId);assert.equal(events[1].file.sha256,createHash('sha256').update(body).digest('hex'));assert.equal(events[1].request,undefined); +}); +test('pending or failed mandatory download commit cannot return document bytes',async()=>{ + const policy=defaultPolicy();policy.browser.allowDownloads=true;policy.fileTransfers.download.allowWithoutMalwareScanner=true;let release,entered;const hold=new Promise(resolve=>release=resolve),started=new Promise(resolve=>entered=resolve);let completed=false; + const audit=createBrokerAudit({current:()=>true,policyVersion:1,persist:async event=>{if(event.kind==='download.scan'){entered();await hold;}return {persisted:true,eventId:event.eventId};}}),context=createBrokerLifecycle(audit).begin();await context.decision('allow',{bytes:0,detectedType:'text'}); + const gate=createDownloadResponseGate({policy,current:()=>true,forward:async()=>response('%PDF-1.4 public'),inspector:{inspect:async()=>({decision:'allow',coverage:{complete:true}})}}); + const pending=gate({...input,auditContext:context}).then(()=>completed=true);await started;assert.equal(completed,false);release();await pending;assert.equal(completed,true); + const fail=createBrokerAudit({current:()=>true,policyVersion:1,persist:async event=>{if(event.kind==='download.scan')throw Error('private journal detail');return {persisted:true,eventId:event.eventId};}}),denied=createBrokerLifecycle(fail).begin();await denied.decision('allow',{bytes:0,detectedType:'text'});await assert.rejects(gate({...input,auditContext:denied}),{code:'audit-persistence-failed'}); + const missing=createDownloadResponseGate({policy,current:()=>true,forward:async()=>assert.fail('no unaudited dial')});await assert.rejects(missing(input),{code:'audit-unavailable'}); +}); +test('download causation refuses missing authorization and unknown matched detector',async()=>{ + const audit=createBrokerAudit({current:()=>true,policyVersion:1,persist:async()=>assert.fail('invalid facts never persist')}); + for(const broker of [{requestId:randomUUID(),phase:'response.block',reason:'broker-download-inspection-block'},{requestId:randomUUID(),phase:'response.scanned',reason:'broker-download-approved',authorizedEventId:randomUUID()}])await assert.rejects(audit.recordDownload('block',{bytes:3,detectedType:'pdf',sha256:'a'.repeat(64),broker}),{code:'audit-facts-invalid'}); +}); + +test('OS save prohibition does not bypass or deny complete inline asset scans',async()=>{ + const policy=defaultPolicy();policy.fileTransfers.download.allowWithoutMalwareScanner=true;const bytes=Buffer.from('89504e470d0a1a0a','hex');let scans=0; + const gate=createDownloadResponseGate({auditRequired:false,policy,current:()=>true,forward:async()=>response(bytes,{'content-type':'image/png'}),inspector:{inspect:async()=>{scans++;return {decision:'allow',coverage:{complete:true}};}}}); + assert.deepEqual((await gate({...input,request:{url:'https://intranet.example/icon.png'}})).body,bytes);assert.equal(scans,1); + const attachment=createDownloadResponseGate({auditRequired:false,policy,current:()=>true,forward:async()=>response(bytes,{'content-type':'image/png','content-disposition':'attachment; filename=icon.png'}),inspector:{inspect:async()=>assert.fail('policy denies before scan')}});await assert.rejects(attachment(input),{code:'download-policy-blocked'}); +}); + +test('late original classifier cannot commit another decision after a terminal scan timeout',async t=>{ + const journal=createSyntheticAudit(t),audit=createBrokerAudit({persist:journal.persist,current:()=>true,policyVersion:1}),context=createBrokerLifecycle(audit).begin(); + await context.reject('inspection-deadline',{bytes:6,detectedType:'text'});assert.throws(()=>context.decision('allow',{bytes:6,detectedType:'text'}),{code:'aborted'});assert.throws(()=>context.response('allow',{bytes:6,detectedType:'pdf',sha256:'a'.repeat(64)}),{code:'aborted'}); + const events=journal.events();assert.equal(events.length,1);assert.equal(events[0].broker.reason,'broker-scan-timeout'); +}); diff --git a/tests/broker-electron-interop.test.mjs b/tests/broker-electron-interop.test.mjs new file mode 100644 index 0000000..6f5c193 --- /dev/null +++ b/tests/broker-electron-interop.test.mjs @@ -0,0 +1,51 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {fork} from 'node:child_process'; +import {once} from 'node:events'; +import fs from 'node:fs'; +import {createRequire} from 'node:module'; +import {fileURLToPath} from 'node:url'; +import https from 'node:https'; +import {X509Certificate,generateKeyPairSync} from 'node:crypto'; +import {defaultPolicy,signPolicy} from '../packages/core/policy.mjs'; +const require=createRequire(import.meta.url),{createManagedProxy}=require('../apps/network-broker/managed-proxy.cjs'),{createPolicyAuthority}=require('../apps/network-broker/policy-authority.cjs'),{createHttpsForwarder}=require('../apps/network-broker/https-forwarder.cjs'); +const {createSyntheticAudit}=require('./fixtures/broker-durable-audit.cjs'); +const cert=fs.readFileSync(new URL('./fixtures/icap/cert.pem',import.meta.url)),key=fs.readFileSync(new URL('./fixtures/icap/key.pem',import.meta.url)); +function bounded(promise,ms=15000){let timer;return Promise.race([promise,new Promise((_,reject)=>timer=setTimeout(()=>reject(Error('interop-deadline')),ms))]).finally(()=>clearTimeout(timer));} +test('stock Electron authenticates Basic proxy and sends only signed-policy approved browser content',{ + skip:process.platform!=='win32'||process.env.SAFETYNET_NATIVE_BROKER_ELECTRON!=='1',timeout:60000 +},async t=>{ + assert.equal(process.execPath.toLowerCase(),fileURLToPath(new URL('../.runtime/windows/broker/node.exe',import.meta.url)).toLowerCase()); + const received=[];let leakedProxyHeader=false,connections=0; + const site=https.createServer({key,cert},async(req,res)=>{ + leakedProxyHeader ||= !!req.headers['proxy-authorization']; + const chunks=[];for await(const chunk of req)chunks.push(chunk); + if(req.method==='POST'){received.push(Buffer.concat(chunks).toString());res.writeHead(201,{'content-type':'text/plain'});res.end('accepted');} + else {res.setHeader('content-type','text/html');res.end('

Local interop fixture

');} + });site.on('connection',()=>connections++);site.listen(0,'127.0.0.1');await once(site,'listening'); + const port=site.address().port,destination=`localhost:${port}`,origin=`https://${destination}`; + t.after(async()=>{site.closeAllConnections();await new Promise(resolve=>site.close(resolve));}); + const keys=generateKeyPairSync('ed25519'),publicKey=keys.publicKey.export({type:'spki',format:'pem'}),privateKey=keys.privateKey.export({type:'pkcs8',format:'pem'}); + const p=defaultPolicy();p.assignment={deviceId:'interop-device',groups:[]};p.dlp.rules=[{id:'company-secret',label:'Sensitive',kind:'literal',value:'PROJECT SECRET',action:'block'}]; + const authority=await createPolicyAuthority({publicKey,deviceId:'interop-device'}),snapshot=authority.accept(signPolicy(p,privateKey)); + const forward=createHttpsForwarder({authority:snapshot.token,current:authority.current,resolveRoute:()=>({approved:true,address:'127.0.0.1',port}),ca:cert,maxResponseBytes:16777216}); + const audit=createSyntheticAudit(t); + const proxy=await createManagedProxy({policyAuthority:authority,persistAudit:audit.persist,resolveCertificate:binding=>binding.authority===destination?{authority:destination,key,cert}:undefined,forward}); + const proxyPort=await proxy.listen();t.after(()=>proxy.close()); + const artifactRoot=fileURLToPath(new URL('../artifacts/broker-electron-interop/',import.meta.url));fs.mkdirSync(artifactRoot,{recursive:true}); + const userData=fs.mkdtempSync(artifactRoot+'profile-'); + const cleanEnvironment=Object.fromEntries(['SystemRoot','WINDIR','TEMP','TMP','USERPROFILE','HOMEDRIVE','HOMEPATH','LOCALAPPDATA','APPDATA','PATH'].filter(name=>process.env[name]!==undefined).map(name=>[name,process.env[name]])); + const child=fork(fileURLToPath(new URL('./fixtures/broker-electron-interop.cjs',import.meta.url)),[],{execPath:require('electron'),execArgv:[],env:cleanEnvironment,windowsHide:true,stdio:['ignore','ignore','ignore','ipc']}); + t.after(()=>{if(child.exitCode===null)child.kill();}); + const ready=bounded(once(child,'message'));child.send({type:'initialize',config:{proxyPort,origin,userData,credentials:proxy.credentials,fingerprint:new X509Certificate(cert).fingerprint256}}); + const [initialized]=await ready;assert.equal(initialized.type,'ready');assert.ok(initialized.loginCount>=1);assert.match(initialized.electron,/^44\./); + const operation=async type=>{const result=bounded(once(child,'message'));child.send({type});const [message]=await result;assert.equal(message.type,'result');return message.result;}; + assert.deepEqual(await operation('public'),{status:201,body:'accepted'});assert.deepEqual(received,['public browser content']); + const beforeSecret=connections; + const blocked=await operation('secret');assert.ok(blocked.blocked||blocked.status===403);assert.deepEqual(received,['public browser content']);assert.equal(connections,beforeSecret); + const next=structuredClone(p);next.version=2;authority.accept(signPolicy(next,privateKey)); + const revoked=await operation('revoked');assert.ok(revoked.blocked||revoked.status===403);assert.deepEqual(received,['public browser content']);assert.equal(leakedProxyHeader,false);assert.equal(connections,beforeSecret); + const exit=bounded(once(child,'exit'));child.send({type:'stop'});await exit; + fs.mkdirSync('artifacts/broker-electron-interop',{recursive:true}); + fs.writeFileSync(`artifacts/broker-electron-interop/proof-${new Date().toISOString().replace(/:/g,'-')}.json`,JSON.stringify({schemaVersion:1,electron:initialized.electron,chromium:initialized.chromium,brokerNode:process.version,proxyAuthentication:'Basic',loginEvents:initialized.loginCount,authorizedPosts:received.length,authorizedBytes:Buffer.byteLength(received[0]),secretPosts:0,revokedPosts:0,upstreamConnectionsBeforeSecret:beforeSecret,upstreamConnectionsAfterBlocks:connections,proxyCredentialsLeaked:leakedProxyHeader,isolatedFixture:true},null,2)); +}); diff --git a/tests/broker-framed-channel.test.mjs b/tests/broker-framed-channel.test.mjs new file mode 100644 index 0000000..cbff6c5 --- /dev/null +++ b/tests/broker-framed-channel.test.mjs @@ -0,0 +1,74 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {EventEmitter} from 'node:events'; +import {createRequire} from 'node:module'; +import {PassThrough} from 'node:stream'; +import {randomUUID} from 'node:crypto'; +const {createFramedChannel}=createRequire(import.meta.url)('../apps/network-broker/framed-channel.cjs'); +function frame(value){const bytes=Buffer.from(typeof value==='string'?value:JSON.stringify(value)),result=Buffer.alloc(bytes.length+4);result.writeUInt32LE(bytes.length);bytes.copy(result,4);return result;} +function fixture(options={}){ + const read=new EventEmitter(),write=new EventEmitter(),writes=[],callbacks=[]; + read.destroy=()=>{};write.destroy=()=>{};write.write=(bytes,callback)=>{writes.push(Buffer.from(bytes));callbacks.push(callback);}; + const channel=createFramedChannel({readable:read,writable:write,...options}),messages=[];let disconnected=0; + channel.on('message',value=>messages.push(value));channel.on('disconnect',()=>disconnected++); + return {read,write,channel,writes,callbacks,messages,disconnected:()=>disconnected}; +} +test('fragmented headers and UTF8 plus coalesced frames preserve exact bounded objects',()=>{ + const f=fixture(),bytes=Buffer.concat([frame({text:'ä漢字'}),frame({type:'stop'})]); + for(const byte of bytes)f.read.emit('data',Buffer.from([byte])); + assert.deepEqual(f.messages,[{text:'ä漢字'},{type:'stop'}]);assert.equal(f.channel.connected,true); + f.read.emit('data',Buffer.concat([frame({n:1}),frame({n:2})]));assert.equal(f.messages.length,4); + f.channel.disconnect();assert.equal(f.disconnected(),1); +}); +test('first bootstrap alone may exceed64KiB; later oversize rejected before allocation',()=>{ + const f=fixture({inboundFirstLimit:2097152});f.read.emit('data',frame({payload:'x'.repeat(70000)}));assert.equal(f.messages.length,1); + const header=Buffer.alloc(4);header.writeUInt32LE(65537);f.read.emit('data',header);assert.equal(f.channel.connected,false);assert.equal(f.messages.length,1); + for(const length of [0,2097153,0xffffffff]){const g=fixture({inboundFirstLimit:2097152}),h=Buffer.alloc(4);h.writeUInt32LE(length);g.read.emit('data',h);assert.equal(g.disconnected(),1);} +}); +test('invalid UTF8, primitive JSON and malformed JSON disconnect without message or raw error',()=>{ + const invalid=Buffer.from([2,0,0,0,0xc0,0xaf]); + for(const bytes of [invalid,frame('null'),frame('[]'),frame('"secret"'),frame('{broken')]){const f=fixture();f.read.emit('data',bytes);assert.equal(f.messages.length,0);assert.equal(f.disconnected(),1);} + const f=fixture();f.read.emit('data',frame({valid:true}).subarray(0,6));f.read.emit('end');assert.equal(f.disconnected(),1);assert.equal(f.messages.length,0); +}); +test('writes are framed with first-only bootstrap limit and at most8 unsettled writes',()=>{ + const f=fixture({outboundFirstLimit:2097152}),results=[]; + f.channel.send({payload:'x'.repeat(70000)},error=>results.push(error));assert.equal(f.writes[0].readUInt32LE(0),70014);f.callbacks.shift()();assert.equal(results[0],undefined); + f.channel.send({payload:'x'.repeat(70000)},error=>results.push(error));assert.equal(f.disconnected(),1);assert.equal(results[1].message,'broker-channel-unavailable');assert.equal(f.writes.length,1); + const g=fixture();for(let i=0;i<8;i++)g.channel.send({n:i});assert.equal(g.writes.length,8);let failure; + g.channel.send({n:8},error=>{failure=error;});assert.equal(failure.message,'broker-channel-unavailable');assert.equal(g.disconnected(),1);assert.equal(g.writes.length,8); +}); +test('stream failure yields fixed callback error and exactly one disconnect',()=>{ + const f=fixture();let failure;f.channel.send({type:'ready'},error=>{failure=error;});f.callbacks[0](Error('PRIVATE_FAILURE'));assert.equal(failure.message,'broker-channel-unavailable'); + f.read.emit('error',Error('PRIVATE_FAILURE'));f.write.emit('error',Error('PRIVATE_FAILURE'));assert.equal(f.disconnected(),1); +}); +test('disconnect fails pending callbacks exactly once; late write success grants no success',()=>{ + const f=fixture(),results=[];f.channel.send({type:'audit'},error=>results.push(error?.message||'success')); + f.channel.disconnect();assert.deepEqual(results,['broker-channel-unavailable']); + f.callbacks[0]();f.callbacks[0](Error('late'));assert.deepEqual(results,['broker-channel-unavailable']);assert.equal(f.disconnected(),1); +}); +test('throwing write callbacks cannot prevent fail-closed disconnection',()=>{ + const f=fixture();f.channel.send({type:'audit'},()=>{throw Error('private callback');}); + assert.doesNotThrow(()=>f.callbacks[0]());assert.equal(f.channel.connected,false); + assert.doesNotThrow(()=>f.channel.send({type:'audit'},()=>{throw Error('private callback');}));assert.equal(f.disconnected(),1); +}); +test('actual duplex framing carries production protocol and exact durable receipt',async t=>{ + const {runProductionProcess}=createRequire(import.meta.url)('../apps/network-broker/production-entry.cjs'); + const toChild=new PassThrough(),toParent=new PassThrough(); + const child=createFramedChannel({readable:toChild,writable:toParent,inboundFirstLimit:2097152}); + const parent=createFramedChannel({readable:toParent,writable:toChild,outboundFirstLimit:2097152}); + t.after(()=>parent.disconnect()); + let callbacks,closed=0; + const processControl=runProductionProcess(async(_config,value)=>{callbacks=value;return {authMode:'basic',credentials:{username:'safetynet-'+'a'.repeat(32),password:'b'.repeat(43)},listen:async()=>43100,close:async()=>closed++};},child); + const received=[];parent.on('message',message=>received.push(message)); + const context={version:1,generation:randomUUID(),policyVersion:1}; + parent.send({type:'start',...context,config:{brokerPort:43100}}); + await new Promise(resolve=>setImmediate(resolve));assert.equal(received[0].type,'ready'); + const event={eventId:randomUUID(),kind:'upload.authorized',ruleId:'broker-approved',policyVersion:1,request:{bytes:3,detectedType:'text'}}; + let committed=false;const pending=callbacks.persistAudit(event).then(receipt=>{committed=true;return receipt;}); + assert.equal(committed,false);assert.equal(received[1].type,'audit'); + parent.send({type:'audit-receipt',...context,requestId:received[1].requestId,sequence:received[1].sequence,receipt:{persisted:true,eventId:event.eventId}}); + assert.deepEqual(await pending,{persisted:true,eventId:event.eventId}); + // Receipt replay is invalid even though its frame, generation and UUID match. + parent.send({type:'audit-receipt',...context,requestId:received[1].requestId,sequence:received[1].sequence,receipt:{persisted:true,eventId:event.eventId}}); + await processControl.close();assert.equal(child.connected,false);assert.equal(closed,1); +}); diff --git a/tests/broker-http-request-gate.test.mjs b/tests/broker-http-request-gate.test.mjs new file mode 100644 index 0000000..84fe8c4 --- /dev/null +++ b/tests/broker-http-request-gate.test.mjs @@ -0,0 +1,104 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import net from 'node:net'; +import {once} from 'node:events'; +import {PassThrough} from 'node:stream'; +import {createRequire} from 'node:module'; +const {gateRequest}=createRequire(import.meta.url)('../apps/network-broker/http-request-gate.cjs'); + +async function fixture(t,inspect,options={}) { + const received=[]; + let connections=0; + const receiver=http.createServer(async(req,res)=>{const chunks=[];for await(const chunk of req)chunks.push(chunk);received.push(Buffer.concat(chunks).toString());res.end('ok');}); + receiver.on('connection',()=>connections++); + receiver.listen(0,'127.0.0.1');await once(receiver,'listening'); + const target=`http://127.0.0.1:${receiver.address().port}/submit`; + const decisions=[]; + const broker=http.createServer((req,res)=>{ + gateRequest(req,{authority:'policy-1',current:()=>true,inspect,...options,forward:({request,body})=>new Promise((resolve,reject)=>{ + const outgoing=http.request(request.url,{method:request.method,headers:{'content-length':body.length}},response=>{response.resume();response.on('end',resolve);}); + outgoing.on('error',reject);outgoing.end(body); + })}).then(()=>{decisions.push('allow');res.end('ok');},error=>{decisions.push(error.code);res.statusCode=403;res.end(error.code);}); + }); + broker.listen(0,'127.0.0.1');await once(broker,'listening'); + t.after(async()=>{broker.closeAllConnections();receiver.closeAllConnections();await Promise.all([new Promise(r=>broker.close(r)),new Promise(r=>receiver.close(r))]);}); + const send=body=>new Promise((resolve,reject)=>{const req=http.request({host:'127.0.0.1',port:broker.address().port,path:target,method:'POST',headers:{host:new URL(target).host,'content-length':Buffer.byteLength(body)}},res=>{res.resume();res.on('end',()=>resolve(res.statusCode));});req.on('error',reject);req.end(body);}); + return {received,decisions,send,target,broker,get connections(){return connections;}}; +} + +async function rawRequest(t,broker,payload) { + const socket=net.connect(broker.address().port,'127.0.0.1'); + t.after(()=>socket.destroy()); + return await new Promise((resolve,reject)=>{ + let text='',finished=false; + const timer=setTimeout(()=>finish(new Error('raw-request-deadline')),2000); + const finish=error=>{if(finished)return;finished=true;clearTimeout(timer);socket.destroy();if(error)reject(error);else resolve(text);}; + socket.on('connect',()=>socket.end(payload)); + socket.on('data',chunk=>text+=chunk); + socket.on('error',finish); + socket.on('end',()=>finish()); + socket.on('close',()=>finish()); + }); +} + +test('controlled HTTP receiver sees benign original only; inspector mutation cannot alter release',async t=>{ + const f=await fixture(t,async({body})=>{const secret=body.toString().includes('SECRET');body.fill(120);return {decision:secret?'block':'allow'};}); + assert.equal(await f.send('public text'),200);assert.deepEqual(f.received,['public text']); + assert.equal(await f.send('SECRET'),403);assert.deepEqual(f.received,['public text']); +}); +test('no upstream bytes or dial before delayed allow',async t=>{ + let entered,release;const waiting=new Promise(r=>entered=r),hold=new Promise(r=>release=r); + const f=await fixture(t,async()=>{entered();await hold;return {decision:'allow'};}); + const request=f.send('public');await waiting;assert.deepEqual(f.received,[]);assert.equal(f.connections,0);release();assert.equal(await request,200);assert.equal(f.connections,1); +}); +test('policy revoked while inspection waits prevents release',async t=>{ + let valid=true; + const f=await fixture(t,async()=>{valid=false;return {decision:'allow'};},{current:()=>valid}); + assert.equal(await f.send('public'),403);assert.deepEqual(f.received,[]);assert.deepEqual(f.decisions,['authority-expired']); +}); +test('bounded body and inspection deadline fail closed',async t=>{ + const f=await fixture(t,()=>new Promise(()=>{}),{maxBytes:8,deadlineMs:30}); + assert.equal(await f.send('too long for this bound'),403);assert.equal(await f.send('short'),403); + assert.deepEqual(f.received,[]);assert.deepEqual(f.decisions,['body-too-large','deadline']); +}); +test('abort during inspection prevents release',async t=>{ + const controller=new AbortController(); + const f=await fixture(t,async()=>{controller.abort();return {decision:'allow'};},{signal:controller.signal}); + assert.equal(await f.send('public'),403);assert.deepEqual(f.received,[]);assert.deepEqual(f.decisions,['aborted']); +}); +test('actual duplicate Host is denied, no upstream request',async t=>{ + const f=await fixture(t,async()=>({decision:'allow'})); + const result=await rawRequest(t,f.broker,`POST ${f.target} HTTP/1.1\r\nHost: ${new URL(f.target).host}\r\nHost: attacker.invalid\r\nContent-Length: 0\r\nConnection: close\r\n\r\n`); + assert.match(result,/403/);assert.deepEqual(f.received,[]);assert.equal(f.connections,0);assert.deepEqual(f.decisions,['duplicate-header']); +}); +test('physical chunked and CL+TE requests never inspect or dial',async t=>{ + let inspected=0; + const f=await fixture(t,async()=>{inspected++;return {decision:'allow'};}); + for(const length of ['', 'Content-Length: 6\r\n']) { + const result=await rawRequest(t,f.broker,`POST ${f.target} HTTP/1.1\r\nHost: ${new URL(f.target).host}\r\n${length}Transfer-Encoding: chunked\r\nConnection: close\r\n\r\n6\r\nSECRET\r\n0\r\n\r\n`); + assert.match(result,/HTTP\/1\.1 (400|403)/); + } + assert.equal(inspected,0);assert.equal(f.connections,0);assert.deepEqual(f.received,[]); +}); +test('ambiguous framing and unsupported transports rejected before inspection',async()=>{ + for(const [headers,method,url] of [ + [['Host','example.test','Content-Length','1','Content-Length','1'],'POST','http://example.test/'], + [['Host','example.test','Content-Length','1','Transfer-Encoding','chunked'],'POST','http://example.test/'], + [['Host','example.test','Upgrade','websocket'],'GET','http://example.test/'], + [['Host','example.test'],'CONNECT','example.test:443'], + [['Host','example.test','Content-Encoding','gzip'],'POST','http://example.test/'], + [['Host','evil.test'],'GET','http://example.test/'] + ]) { + const req=new PassThrough();Object.assign(req,{rawHeaders:headers,method,url,complete:true}); + await assert.rejects(gateRequest(req,{authority:'p',current:()=>true,inspect:()=>assert.fail('inspection'),forward:()=>assert.fail('forward')})); + } +}); +test('truncated body and stream error return fixed errors without release',async()=>{ + for(const kind of ['end','error','aborted']) { + const req=new PassThrough();Object.assign(req,{rawHeaders:['Host','example.test','Content-Length','3'],method:'POST',url:'http://example.test/',complete:false}); + const promise=gateRequest(req,{authority:'p',current:()=>true,inspect:()=>assert.fail('inspection'),forward:()=>assert.fail('forward')}); + if(kind==='end')req.end('x');else req.emit(kind, new Error('confidential error message')); + await assert.rejects(promise,error=>['incomplete-body','request-error','aborted'].includes(error.code)&&!error.message.includes('confidential')); + } +}); diff --git a/tests/broker-http-response-gate.test.mjs b/tests/broker-http-response-gate.test.mjs new file mode 100644 index 0000000..d380637 --- /dev/null +++ b/tests/broker-http-response-gate.test.mjs @@ -0,0 +1,15 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +const {validateResponse}=createRequire(import.meta.url)('../apps/network-broker/http-response-gate.cjs'); +test('private response preserves status/body and separate cookies; strips hop by hop',()=>{ + const body=Buffer.from('accepted'),cookies=['a=1; Secure','b=2; HttpOnly']; + const result=validateResponse({statusCode:201,headers:{'Content-Type':'text/plain','Set-Cookie':cookies,Connection:'keep-alive','Transfer-Encoding':'chunked','Content-Length':'999','Proxy-Authenticate':'private'},body}); + body.fill(0);cookies[0]='changed'; + assert.equal(result.body.toString(),'accepted');assert.equal(result.headers['content-length'],'8');assert.deepEqual(result.headers['set-cookie'],['a=1; Secure','b=2; HttpOnly']);assert.equal(result.headers.connection,'close');assert.equal(result.headers['transfer-encoding'],undefined);assert.equal(result.headers['proxy-authenticate'],undefined); +}); +test('invalid status, injected headers, overflow and unsupported encoding fail closed',()=>{ + const valid={statusCode:200,headers:{},body:Buffer.from('public')}; + for(const change of [{statusCode:101},{statusCode:999},{statusCode:204},{headers:{'set-cookie':['a=1\r\nInjected: yes']}},{headers:{'Content-Type':'text/plain','content-type':'other'}},{headers:{'content-encoding':'gzip'}},{headers:{location:['one','two']}}])assert.throws(()=>validateResponse({...valid,...change})); + assert.throws(()=>validateResponse(valid,3)); +}); diff --git a/tests/broker-https-forwarder.test.mjs b/tests/broker-https-forwarder.test.mjs new file mode 100644 index 0000000..a4a5eb3 --- /dev/null +++ b/tests/broker-https-forwarder.test.mjs @@ -0,0 +1,92 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import https from 'node:https'; +import fs from 'node:fs'; +import {once} from 'node:events'; +import {createRequire} from 'node:module'; +const require=createRequire(import.meta.url),{createHttpsForwarder}=require('../apps/network-broker/https-forwarder.cjs'); +const cert=fs.readFileSync(new URL('./fixtures/icap/cert.pem',import.meta.url)),key=fs.readFileSync(new URL('./fixtures/icap/key.pem',import.meta.url)); +async function fixture(t,handler,configuration={}){ + const received=[];let connections=0; + const server=https.createServer({key,cert},async(req,res)=>{const chunks=[];for await(const chunk of req)chunks.push(chunk);received.push({body:Buffer.concat(chunks).toString(),headers:req.headers,path:req.url});handler?.(req,res)||(!handler&&res.end('public response'));}); + server.on('connection',()=>connections++);server.on('tlsClientError',()=>{}); + server.listen(0,'127.0.0.1');await once(server,'listening'); + t.after(async()=>{server.closeAllConnections();await new Promise(resolve=>server.close(resolve));}); + const port=server.address().port; + const forward=createHttpsForwarder({authority:'policy-1',current:()=>true,resolveRoute:async destination=>({approved:true,address:'127.0.0.1',port:destination.port}),ca:cert,deadlineMs:1000,...configuration}); + const request={method:'POST',url:`https://localhost:${port}/submit`,headers:{'content-type':'text/plain'}}; + return {request,forward,received,get connections(){return connections;}}; +} +test('numeric approved route uses original TLS hostname and strips proxy credentials',async t=>{ + let release,entered;const wait=new Promise(resolve=>release=resolve),started=new Promise(resolve=>entered=resolve); + const f=await fixture(t,undefined,{resolveRoute:async destination=>{entered();await wait;return {approved:true,address:'127.0.0.1',port:destination.port};}}); + const body=Buffer.from('public original'); + f.request.headers['proxy-authorization']='Bearer PRIVATE_PROXY_KEY';f.request.headers.connection='x-hop';f.request.headers['x-hop']='PRIVATE_HOP_VALUE'; + const pending=f.forward({request:f.request,body});await started;body.fill(120);release(); + const result=await pending;assert.equal(result.body.toString(),'public response'); + assert.equal(f.received[0].body,'public original');assert.equal(f.received[0].headers['proxy-authorization'],undefined);assert.equal(f.received[0].headers['x-hop'],undefined); + assert.equal(f.connections,1); +}); +test('revocation during route resolution prevents any upstream TCP dial',async t=>{ + let valid=true;const f=await fixture(t,undefined,{current:()=>valid,resolveRoute:async destination=>{valid=false;return {approved:true,address:'127.0.0.1',port:destination.port};}}); + await assert.rejects(f.forward({request:f.request,body:Buffer.from('public')}),{code:'authority-expired'}); + assert.equal(f.connections,0); +}); +test('unapproved and nonnumeric routes cannot invoke system DNS or dial',async t=>{ + const f=await fixture(t,undefined,{resolveRoute:async destination=>({approved:true,address:'localhost',port:destination.port})}); + await assert.rejects(f.forward({request:f.request,body:Buffer.alloc(0)}),{code:'route-unapproved'});assert.equal(f.connections,0); +}); +test('revocation after TLS establishment prevents HTTP headers and payload release',async t=>{ + let checks=0;const f=await fixture(t,undefined,{current:()=>++checks<4}); + await assert.rejects(f.forward({request:f.request,body:Buffer.from('must never transmit')}),{code:'authority-expired'}); + assert.equal(f.connections,1);assert.equal(f.received.length,0); +}); +test('untrusted server certificate is rejected with fixed error and no retry',async t=>{ + const f=await fixture(t,undefined,{ca:undefined}); + await assert.rejects(f.forward({request:f.request,body:Buffer.from('public')}),{code:'upstream-failed'}); + assert.equal(f.connections,1);assert.equal(f.received.length,0); +}); +test('approved numeric route does not substitute certificate hostname',async t=>{ + const f=await fixture(t); + await assert.rejects(f.forward({request:{...f.request,url:f.request.url.replace('localhost','wrong-host.example')},body:Buffer.alloc(0)}),{code:'upstream-failed'}); + assert.equal(f.connections,1);assert.equal(f.received.length,0); +}); +test('oversized streamed response is destroyed at configured limit',async t=>{ + const f=await fixture(t,(_req,res)=>{res.write('1234');res.end('56789');},{maxResponseBytes:8}); + await assert.rejects(f.forward({request:f.request,body:Buffer.alloc(0)}),{code:'upstream-response-too-large'}); + assert.equal(f.connections,1); +}); +test('redirect response is returned without following location',async t=>{ + const f=await fixture(t,(_req,res)=>{res.writeHead(302,{location:'/private'});res.end('redirect');}); + const result=await f.forward({request:f.request,body:Buffer.alloc(0)}); + assert.equal(result.statusCode,302);assert.equal(result.headers.location,'/private');assert.equal(f.received.length,1);assert.equal(f.received[0].path,'/submit'); +}); +test('abort during route resolution prevents dial and does not retry',async t=>{ + let entered,release;const started=new Promise(resolve=>entered=resolve),wait=new Promise(resolve=>release=resolve); + const f=await fixture(t,undefined,{resolveRoute:async destination=>{entered();await wait;return {approved:true,address:'127.0.0.1',port:destination.port};}}); + const controller=new AbortController(),pending=f.forward({request:f.request,body:Buffer.alloc(0),signal:controller.signal}); + await started;controller.abort();await assert.rejects(pending,{code:'upstream-cancelled'});release();assert.equal(f.connections,0); +}); +test('wall clock timeout terminates a stalled upstream connection',async t=>{ + const f=await fixture(t,()=>{}, {deadlineMs:30}); + await assert.rejects(f.forward({request:f.request,body:Buffer.alloc(0)}),{code:'upstream-cancelled'}); + assert.ok(f.connections<=1); +}); +test('timed out unresolved routes retain bounded underlying resolution admission',async t=>{ + let calls=0;const pending=[]; + const f=await fixture(t,undefined,{deadlineMs:15,maxPendingResolutions:2,resolveRoute:destination=>{ + calls++;return new Promise(resolve=>pending.push(()=>resolve({approved:true,address:'127.0.0.1',port:destination.port}))); + }}); + for(let index=0;index<2;index++)await assert.rejects(f.forward({request:f.request,body:Buffer.alloc(0)}),{code:'upstream-cancelled'}); + for(let index=0;index<3;index++)await assert.rejects(f.forward({request:f.request,body:Buffer.alloc(0)}),{code:'route-capacity-exhausted'}); + assert.equal(calls,2);assert.equal(f.connections,0); + pending[0]();await new Promise(resolve=>setImmediate(resolve)); + await assert.rejects(f.forward({request:f.request,body:Buffer.alloc(0)}),{code:'upstream-cancelled'}); + assert.equal(calls,3);assert.equal(f.connections,0); + for(const release of pending)release(); +}); +test('invalid destinations and methods reject before resolver invocation',async t=>{ + let resolutions=0;const f=await fixture(t,undefined,{resolveRoute:async()=>{resolutions++;}}); + for(const change of [{url:'http://localhost/'},{url:'https://user:password@localhost/'},{url:'https://localhost/#fragment'},{url:'https://127.1/'},{method:'CONNECT'}])await assert.rejects(f.forward({request:{...f.request,...change},body:Buffer.alloc(0)}),{code:'invalid-forward-request'}); + assert.equal(resolutions,0);assert.equal(f.connections,0); +}); diff --git a/tests/broker-https-inspection.test.mjs b/tests/broker-https-inspection.test.mjs new file mode 100644 index 0000000..aa8283f --- /dev/null +++ b/tests/broker-https-inspection.test.mjs @@ -0,0 +1,99 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import https from 'node:https'; +import fs from 'node:fs'; +import {X509Certificate} from 'node:crypto'; +import {once} from 'node:events'; +import {createRequire} from 'node:module'; +const require=createRequire(import.meta.url); +const {bindConnectAuthority}=require('../apps/network-broker/tls-authority.cjs'); +const {createInspectionServer}=require('../apps/network-broker/https-inspection-server.cjs'); +const cert=fs.readFileSync(new URL('./fixtures/icap/cert.pem',import.meta.url)); +const key=fs.readFileSync(new URL('./fixtures/icap/key.pem',import.meta.url)); + +async function fixture(t,inspect,current=()=>true,customForward,config={}) { + const received=[];let connections=0; + const receiver=https.createServer({key,cert},async(req,res)=>{const chunks=[];for await(const chunk of req)chunks.push(chunk);received.push(Buffer.concat(chunks).toString());res.writeHead(201,{'content-type':'text/plain','set-cookie':['session=synthetic; Secure; HttpOnly','locale=en; Secure']});res.end('accepted');}); + receiver.on('connection',()=>connections++); + receiver.listen(0,'127.0.0.1');await once(receiver,'listening'); + const destination=`localhost:${receiver.address().port}`; + const broker=createInspectionServer({key,cert,binding:bindConnectAuthority(destination),authority:'signed-policy-1',current,inspect,deadlineMs:1000,...config,forward:customForward||(({request,body,signal})=>new Promise((resolve,reject)=>{ + assert.equal(new URL(request.url).protocol,'https:'); + const outgoing=https.request({host:'127.0.0.1',port:receiver.address().port,path:new URL(request.url).pathname,method:request.method,servername:'localhost',ca:cert,rejectUnauthorized:true,signal,headers:{host:destination,'content-length':body.length}},res=>{const chunks=[];res.on('data',chunk=>chunks.push(chunk));res.on('end',()=>resolve({statusCode:res.statusCode,headers:res.headers,body:Buffer.concat(chunks)}));});outgoing.on('error',reject);outgoing.end(body); + }))}); + const port=await broker.listen(); + t.after(async()=>{await broker.close();receiver.closeAllConnections();await new Promise(r=>receiver.close(r));}); + const send=(body,{host=destination,servername='localhost',signal}={})=>new Promise((resolve,reject)=>{ + const req=https.request({host:'127.0.0.1',port,method:'POST',path:'/submit',servername,ca:cert,rejectUnauthorized:true,agent:false,signal,headers:{host,'content-length':Buffer.byteLength(body)}},res=>{let result='';res.on('data',c=>result+=c);res.on('end',()=>resolve({status:res.statusCode,result}));});req.setTimeout(2000,()=>req.destroy(new Error('test-deadline')));req.on('error',reject);req.end(body); + }); + return {send,received,destination,get connections(){return connections;}}; +} + +test('synthetic TLS certificate explicitly covers localhost and loopback',()=>{ + const certificate=new X509Certificate(cert); + assert.equal(certificate.checkHost('localhost'),'localhost');assert.equal(certificate.checkIP('127.0.0.1'),'127.0.0.1'); +}); +test('verified TLS delivers benign original and blocks secret before upstream TCP dial',async t=>{ + const f=await fixture(t,async({body,request})=>{assert.equal(new URL(request.url).protocol,'https:');const decision=body.toString().includes('SECRET')?'block':'allow';body.fill(120);return {decision};}); + assert.equal((await f.send('SECRET')).status,403);assert.equal(f.connections,0);assert.deepEqual(f.received,[]); + assert.deepEqual(await f.send('public'),{status:201,result:'accepted'});assert.equal(f.connections,1);assert.deepEqual(f.received,['public']); +}); +test('HTTPS inspection delay releases no TCP connection until complete approval',async t=>{ + let entered,release;const waiting=new Promise(r=>entered=r),hold=new Promise(r=>release=r); + const f=await fixture(t,async()=>{entered();await hold;return {decision:'allow'};}); + const pending=f.send('public');await waiting;assert.equal(f.connections,0);release();assert.equal((await pending).status,201);assert.equal(f.connections,1); +}); +test('revocation during HTTPS inspection blocks without dialing',async t=>{ + let valid=true; + const f=await fixture(t,async()=>{valid=false;return {decision:'allow'};},()=>valid); + assert.deepEqual(await f.send('public'),{status:403,result:'authority-expired'});assert.equal(f.connections,0); +}); +test('inner Host mismatch and missing DNS SNI reject before inspection or dial',async t=>{ + let inspections=0; + const f=await fixture(t,async()=>{inspections++;return {decision:'allow'};}); + assert.deepEqual(await f.send('public',{host:'evil.example:443'}),{status:403,result:'authority-mismatch'}); + assert.deepEqual(await f.send('public',{servername:''}),{status:403,result:'missing-sni'}); + assert.equal(inspections,0);assert.equal(f.connections,0);assert.deepEqual(f.received,[]); +}); +test('upstream failure returns fixed 502 without retry or private errors',async t=>{ + let attempts=0; + const f=await fixture(t,async()=>({decision:'allow'}),()=>true,async()=>{attempts++;throw new Error('private upstream credentials');}); + assert.deepEqual(await f.send('public'),{status:502,result:'upstream-failed'});assert.equal(attempts,1);assert.equal(f.connections,0); +}); +test('client disconnect aborts in-flight forward once without retry',async t=>{ + let entered,aborted,attempts=0; + const started=new Promise(r=>entered=r),cancelled=new Promise(r=>aborted=r); + const f=await fixture(t,async()=>({decision:'allow'}),()=>true,({signal})=>new Promise((_,reject)=>{attempts++;signal.addEventListener('abort',()=>{aborted();reject(new Error('cancelled'));},{once:true});entered();})); + const controller=new AbortController(); + const pending=f.send('public',{signal:controller.signal});const rejection=assert.rejects(pending); + await started;controller.abort();await rejection; + await Promise.race([cancelled,new Promise((_,reject)=>{const timer=setTimeout(()=>reject(new Error('abort-deadline')),1000);timer.unref();})]); + assert.equal(attempts,1); +}); +test('forwarding wall clock deadline cancels a continuously active upstream once',async t=>{ + let attempts=0,aborts=0; + const f=await fixture(t,async()=>({decision:'allow'}),()=>true,({signal})=>new Promise((_,reject)=>{ + attempts++; + // No completion, just work continuing independently of socket idle. + const activity=setInterval(()=>{},2); + signal.addEventListener('abort',()=>{aborts++;clearInterval(activity);reject(new Error('private deadline detail'));},{once:true}); + }),{deadlineMs:100}); + const result=await f.send('public'); + assert.deepEqual(result,{status:502,result:'upstream-failed'});assert.equal(attempts,1);assert.equal(aborts,1); +}); +test('response bounds reject invalid construction immediately',()=>{ + for(const maxResponseBytes of [-1,1.5,16777217])assert.throws(()=>createInspectionServer({key,cert,authority:'p',current:()=>true,inspect:async()=>({decision:'allow'}),forward:async()=>{},maxResponseBytes}),{code:'invalid-configuration'}); +}); +test('standalone HTTPS job budget survives timed-out requests on new sockets',async t=>{ + let calls=0; + const f=await fixture(t,()=>{calls++;return new Promise(()=>{});},()=>true,undefined,{maxClients:1,deadlineMs:100}); + for(let index=0;index<4;index++)assert.equal((await f.send('public')).status,403); + assert.equal(calls,1);assert.equal(f.connections,0); +}); + +test('one absolute deadline spans inspection and forwarding without resetting the budget',async t=>{ + let inspectEntered,releaseInspect,forwardEntered,releaseForward;const inspected=new Promise(resolve=>inspectEntered=resolve),first=new Promise(resolve=>releaseInspect=resolve),forwarded=new Promise(resolve=>forwardEntered=resolve),second=new Promise(resolve=>releaseForward=resolve);let signal; + const f=await fixture(t,async()=>{inspectEntered();await first;return {decision:'allow'};},()=>true,async value=>{signal=value.signal;forwardEntered();await second;return {statusCode:201,headers:{},body:Buffer.from('must not release')};},{deadlineMs:100}); + const pending=f.send('public');await inspected;await new Promise(resolve=>setTimeout(resolve,55));releaseInspect();await forwarded; + const result=await pending;assert.equal(result.status,502);assert.equal(result.result,'upstream-failed');assert.equal(signal.aborted,true);releaseForward();assert.equal(f.connections,0); +}); diff --git a/tests/broker-managed-proxy.test.mjs b/tests/broker-managed-proxy.test.mjs new file mode 100644 index 0000000..1bc7be8 --- /dev/null +++ b/tests/broker-managed-proxy.test.mjs @@ -0,0 +1,173 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {generateKeyPairSync} from 'node:crypto'; +import {createRequire} from 'node:module'; +import {once} from 'node:events'; +import fs from 'node:fs'; +import http from 'node:http'; +import https from 'node:https'; +import tls from 'node:tls'; +import {defaultPolicy,signPolicy,inspect} from '../packages/core/policy.mjs'; +const require=createRequire(import.meta.url),{createManagedProxy}=require('../apps/network-broker/managed-proxy.cjs'),{createPolicyAuthority}=require('../apps/network-broker/policy-authority.cjs'); +const {createSyntheticAudit}=require('./fixtures/broker-durable-audit.cjs'); +const cert=fs.readFileSync(new URL('./fixtures/icap/cert.pem',import.meta.url)),key=fs.readFileSync(new URL('./fixtures/icap/key.pem',import.meta.url)); +const keys=generateKeyPairSync('ed25519'),publicKey=keys.publicKey.export({type:'spki',format:'pem'}),privateKey=keys.privateKey.export({type:'pkcs8',format:'pem'}); +async function fixture(t,configure=()=>{},scanner,enterpriseInspector,options={}) { + const received=[];let connections=0; + const target=https.createServer({key,cert},async(req,res)=>{const chunks=[];for await(const c of req)chunks.push(c);received.push(Buffer.concat(chunks).toString());res.writeHead(201);res.end('accepted');});target.on('connection',()=>connections++);target.listen(0,'127.0.0.1');await once(target,'listening'); + const destination=`localhost:${target.address().port}`,p=defaultPolicy();p.assignment={deviceId:'device-1',groups:[]};p.dlp.rules=[{id:'company-secret',label:'Confidential',kind:'literal',value:'PROJECT SECRET',action:'block'}];configure(p); + const policyAuthority=await createPolicyAuthority({publicKey,deviceId:'device-1',minimumVersion:1});policyAuthority.accept(signPolicy(p,privateKey)); + const audit=createSyntheticAudit(t); + const proxy=await createManagedProxy({policyAuthority,persistAudit:audit.persist,localInspector:scanner,enterpriseInspector,resolveCertificate:binding=>binding.authority===destination?{authority:destination,key,cert}:undefined,forward:({body,signal,maxResponseBytes})=>new Promise((resolve,reject)=>{ + const request=https.request({host:'127.0.0.1',port:target.address().port,method:'POST',servername:'localhost',ca:cert,rejectUnauthorized:true,signal,headers:{'content-length':body.length}},res=>{const chunks=[];let bytes=0;res.on('data',chunk=>{bytes+=chunk.length;if(bytes>maxResponseBytes){request.destroy();reject(Error('bound'));}else chunks.push(chunk);});res.on('end',()=>resolve({statusCode:res.statusCode,headers:res.headers,body:Buffer.concat(chunks)}));});request.on('error',reject);request.end(body); + }),deadlineMs:1000,authMode:'bearer',...options});const port=await proxy.listen(); + t.after(async()=>{await proxy.close();target.closeAllConnections();await new Promise(r=>target.close(r));}); + const send=(body,{path='/submit',headers={}}={})=>new Promise((resolve,reject)=>{ + const req=http.request({host:'127.0.0.1',port,method:'CONNECT',path:destination,headers:{host:destination,'proxy-authorization':`Bearer ${proxy.capability}`}});req.setTimeout(2000,()=>req.destroy(Error('deadline')));req.on('error',reject);req.on('connect',(response,socket)=>{if(response.statusCode!==200){socket.destroy();resolve(response.statusCode);return;}const secure=tls.connect({socket,servername:'localhost',ca:cert,rejectUnauthorized:true});let text='';secure.setTimeout(2000,()=>secure.destroy(Error('deadline')));secure.on('error',reject);secure.on('secureConnect',()=>{const extra=Object.entries(headers).map(([name,value])=>`${name}: ${value}\r\n`).join('');secure.write(`POST ${path} HTTP/1.1\r\nHost: ${destination}\r\nContent-Length: ${Buffer.byteLength(body)}\r\n${extra}Connection: close\r\n\r\n`);secure.write(body);});secure.on('data',chunk=>text+=chunk);secure.on('end',()=>{secure.destroy();resolve(Number(/^HTTP\/1.1 (\d+)/.exec(text)?.[1]));});});req.end(); + });return {send,received,policyAuthority,p,audit,get connections(){return connections;}}; +} +test('signed company literal and credentials block before dial; public actualTLS delivery',async t=>{ + const f=await fixture(t); + assert.equal(await f.send('PROJECT SECRET'),403);assert.equal(await f.send('sk-abcdefghijklmnopqrstuvwxyz123456'),403);assert.equal(f.connections,0); + assert.equal(await f.send('public document'),201);assert.deepEqual(f.received,['public document']); +}); +test('signed semantic or YARA requirements cannot omit local scanner',async t=>{ + const vector=Array(768).fill(0);vector[0]=1; + for(const feature of ['semantic','yara']) { + const f=await fixture(t,p=>{if(feature==='semantic'){p.dlp.semantic.enabled=true;p.dlp.semantic.revision='a'.repeat(40);p.dlp.semantic.categories=[{id:'confidential',label:'Confidential',threshold:.9,prototype:vector}];}else p.security.yara.enabled=true;}); + assert.equal(await f.send('public'),403);assert.equal(f.connections,0); + } +}); +test('binary upload requires complete local coverage and magic-byte policy applies first',async t=>{ + const f=await fixture(t);assert.equal(await f.send(Buffer.from([0,1,2,255])),403);assert.equal(f.connections,0); + const blocked=await fixture(t,p=>p.fileTransfers.upload.blockedTypes=['pe'],async()=>assert.fail('scanner must not run'));assert.equal(await blocked.send(Buffer.from('MZPUBLIC')),403);assert.equal(blocked.connections,0); +}); +test('genuine signed policy update during local scan invalidates release',async t=>{ + let entered,release;const started=new Promise(r=>entered=r),hold=new Promise(r=>release=r); + const f=await fixture(t,p=>p.security.yara.enabled=true,async()=>{entered();await hold;return {decision:'allow',coverage:{complete:true}};}); + const pending=f.send('public');await started;const next=structuredClone(f.p);next.version=2;f.policyAuthority.accept(signPolicy(next,privateKey));release();assert.equal(await pending,403);assert.equal(f.connections,0); +}); +test('wrong device and tampered signature cannot authorize listener creation',async()=>{ + const authority=await createPolicyAuthority({publicKey,deviceId:'device-1'}),p=defaultPolicy();p.assignment={deviceId:'device-2',groups:[]}; + assert.throws(()=>authority.accept(signPolicy(p,privateKey)));await assert.rejects(createManagedProxy({policyAuthority:authority}),{code:'managed-authority-invalid'}); + const envelope=signPolicy(defaultPolicy(),privateKey);envelope.signature='AAAA';assert.throws(()=>authority.accept(envelope));await assert.rejects(createManagedProxy({policyAuthority:authority}),{code:'managed-authority-invalid'}); +}); +test('mandatory ICAP cannot omit scanner or accept incomplete unbound evidence',async t=>{ + const configure=p=>{p.enterprise.icap.enabled=true;p.enterprise.icap.url='icaps://scanner.example/scan';}; + const missing=await fixture(t,configure);assert.equal(await missing.send('public'),403);assert.equal(missing.connections,0); + const incomplete=await fixture(t,configure,undefined,async()=>({decision:'allow',coverage:{complete:true},sha256:'0'.repeat(64),bytes:6}));assert.equal(await incomplete.send('public'),403);assert.equal(incomplete.connections,0); +}); +test('partial local coverage and scanner mutation fail closed; destination blacklist applies',async t=>{ + for(const inspector of [async()=>({decision:'allow',coverage:{complete:false}}),async bytes=>{bytes.fill(0);return {decision:'allow',coverage:{complete:true}};}]) { + const f=await fixture(t,p=>p.security.yara.enabled=true,inspector);assert.equal(await f.send('public'),403);assert.equal(f.connections,0); + } + const f=await fixture(t,p=>p.browser.blockedHosts=['localhost']);assert.equal(await f.send('public'),403);assert.equal(f.connections,0); +}); +test('decoded text representations receive independent local scans before release',async t=>{ + const seen=[]; + const f=await fixture(t,p=>p.security.yara.enabled=true,async bytes=>{ + const text=bytes.toString();seen.push(text);return {decision:text==='hidden private meaning'?'block':'allow',coverage:{complete:true}}; + }); + assert.equal(await f.send('hidden%20private%20meaning'),403);assert.ok(seen.includes('hidden%20private%20meaning'));assert.ok(seen.includes('hidden private meaning'));assert.equal(f.connections,0); +}); +test('ASCII PDF magic requires local parsed coverage even when raw UTF8 inspection allows',async t=>{ + const document=Buffer.from('%PDF-1.4\n1 0 obj\n<< /Filter /ASCIIHexDecode >>\nstream\n50524F4A45435420534543524554>\nendstream\nendobj\n%%EOF'); + const missing=await fixture(t);assert.equal(inspect(document,missing.p).decision,'allow');assert.equal(await missing.send(document),403);assert.equal(missing.connections,0); + let scanned=0; + const rejected=await fixture(t,()=>{},async bytes=>{scanned++;assert.deepEqual(bytes,document);return {decision:'block',coverage:{complete:true}};});assert.equal(await rejected.send(document),403);assert.equal(scanned,1);assert.equal(rejected.connections,0); + // Controlled scanner contract proof, not evidence of real PDF parsing. + const publicDocument=Buffer.from('%PDF-1.4\npublic test document\n%%EOF'); + const allowed=await fixture(t,()=>{},async bytes=>{assert.deepEqual(bytes,publicDocument);return {decision:'allow',coverage:{complete:true}};});assert.equal(await allowed.send(publicDocument),201);assert.deepEqual(allowed.received,[publicDocument.toString()]); +}); +test('custom header and Cookie corporate secrets block despite benign body',async t=>{ + const f=await fixture(t); + assert.equal(await f.send('public',{headers:{'X-Note':'PROJECT SECRET'}}),403); + assert.equal(await f.send('public',{headers:{Cookie:'note=PROJECT SECRET'}}),403); + assert.equal(f.connections,0);assert.deepEqual(f.received,[]); +}); +test('signed semantic query requires independent decoded URL inspection',async t=>{ + const seen=[];const vector=Array(768).fill(0);vector[0]=1; + const f=await fixture(t,p=>{p.dlp.semantic.enabled=true;p.dlp.semantic.revision='a'.repeat(40);p.dlp.semantic.categories=[{id:'sensitive',label:'Sensitive',threshold:.9,prototype:vector}];},async bytes=>{const value=bytes.toString();seen.push(value);return {decision:value.includes('semantic query')?'block':'allow',coverage:{complete:true}};}); + assert.equal(await f.send('public',{path:'/submit?note=semantic%20query'}),403);assert.ok(seen.some(value=>value.includes('semantic query')));assert.equal(f.connections,0); +}); +test('multipart files receive independent magic policy and complete parsed document gate',async t=>{ + const multipart=document=>Buffer.from(`--test-boundary\r\nContent-Disposition: form-data; name="note"\r\n\r\npublic text\r\n--test-boundary\r\nContent-Disposition: form-data; name="document"; filename="report.pdf"\r\nContent-Type: application/pdf\r\n\r\n${document}\r\n--test-boundary--\r\n`); + const headers={'Content-Type':'multipart/form-data; boundary=test-boundary'}; + const document='%PDF-1.4\npublic sample\n%%EOF',body=multipart(document); + const blocked=await fixture(t,p=>p.fileTransfers.upload.blockedTypes=['pdf']);assert.equal(await blocked.send(body,{headers}),403);assert.equal(blocked.connections,0); + const missing=await fixture(t);assert.equal(await missing.send(body,{headers}),403);assert.equal(missing.connections,0); + let parsed=0; + const allowed=await fixture(t,()=>{},async bytes=>{parsed++;assert.equal(bytes.toString(),document);return {decision:'allow',coverage:{complete:true}};});assert.equal(await allowed.send(body,{headers}),201);assert.equal(parsed,1);assert.deepEqual(allowed.received,[body.toString()]); + const rejected=await fixture(t,()=>{},async()=>({decision:'block',coverage:{complete:true}}));assert.equal(await rejected.send(multipart('%PDF-1.4\n50524F4A45435420534543524554>\n%%EOF'),{headers}),403);assert.equal(rejected.connections,0); +}); +test('malformed multipart body cannot be released as ordinary printable text',async t=>{ + const f=await fixture(t);assert.equal(await f.send('--boundary\r\ntruncated',{headers:{'Content-Type':'multipart/form-data; boundary=boundary'}}),403);assert.equal(f.connections,0); +}); +test('durable allow receipt is required before any upstream dial',async t=>{ + let entered,release,f; + const started=new Promise(resolve=>entered=resolve),hold=new Promise(resolve=>release=resolve); + f=await fixture(t,()=>{},undefined,undefined,{persistAudit:async event=>{entered();await hold;return f.audit.persist(event);}}); + const pending=f.send('public');await started;assert.equal(f.connections,0);assert.deepEqual(f.received,[]); + release();assert.equal(await pending,201);assert.equal(f.audit.events().length,1);assert.equal(f.audit.events()[0].kind,'upload.authorized'); +}); +test('failed audit persistence blocks without replay and never logs protected input',async t=>{ + let calls=0; + const f=await fixture(t,()=>{},undefined,undefined,{persistAudit:async()=>{calls++;throw Error('PRIVATE_AUDIT_FAILURE_CONTENT');}}); + assert.equal(await f.send('public'),403);assert.equal(calls,1);assert.equal(f.connections,0); + const blocked=await fixture(t);assert.equal(await blocked.send('PROJECT SECRET'),403); + const events=blocked.audit.events();assert.equal(events.length,1);assert.equal(events[0].kind,'upload.block');assert.equal(events[0].ruleId,'company-secret');assert.equal(events[0].request.detectedType,'text');assert.equal(events[0].request.bytes,14);assert.ok(!JSON.stringify(events).includes('PROJECT SECRET'));assert.ok(!JSON.stringify(events).includes('sha256')); +}); +test('signed update during durable commit blocks release after receipt, without retry',async t=>{ + let entered,release,f,calls=0; + const started=new Promise(resolve=>entered=resolve),hold=new Promise(resolve=>release=resolve); + f=await fixture(t,()=>{},undefined,undefined,{persistAudit:async event=>{calls++;entered();await hold;return f.audit.persist(event);}}); + const pending=f.send('public');await started;const next=structuredClone(f.p);next.version=2;f.policyAuthority.accept(signPolicy(next,privateKey));release();assert.equal(await pending,403);assert.equal(f.connections,0);assert.equal(calls,2); + const events=f.audit.events();assert.equal(events.length,2);assert.equal(events[1].broker.authorizedEventId,events[0].eventId);assert.equal(events[1].broker.requestId,events[0].broker.requestId);assert.notEqual(events[0].eventId,events[1].eventId);assert.equal(events[1].broker.reason,'broker-authority-expired'); +}); +test('nonsettling audit consumes shared capacity across timed-out CONNECT requests',async t=>{ + let calls=0; + const f=await fixture(t,()=>{},undefined,undefined,{maxClients:1,deadlineMs:100,persistAudit:()=>{calls++;return new Promise(()=>{});}}); + for(let index=0;index<4;index++)assert.equal(await f.send('public'),403); + assert.equal(calls,1);assert.equal(f.connections,0); +}); +test('valid policy cannot create a managed listener without mandatory audit adapter',async()=>{ + const authority=await createPolicyAuthority({publicKey,deviceId:'device-1'});authority.accept(signPolicy(defaultPolicy(),privateKey)); + await assert.rejects(createManagedProxy({policyAuthority:authority,resolveCertificate:()=>{},forward:()=>{}}),{code:'managed-audit-unavailable'}); +}); +test('genuine local block retains signed detector ID without allowing incomplete coverage',async t=>{ + const document=Buffer.from('%PDF-1.4\npublic encoded fixture\n%%EOF'); + const f=await fixture(t,()=>{},async()=>({decision:'block',ruleId:'company-secret',coverage:{complete:false},privateDetails:'NEVER_SERIALIZE'})); + assert.equal(await f.send(document),403);assert.equal(f.connections,0); + const event=f.audit.events()[0];assert.equal(event.ruleId,'company-secret');assert.equal(event.kind,'upload.block');assert.ok(!JSON.stringify(event).includes('NEVER_SERIALIZE')); + const unknown=await fixture(t,()=>{},async()=>({decision:'block',ruleId:'ARBITRARY_PRIVATE_RULE_TEXT',coverage:{complete:true}}));assert.equal(await unknown.send(document),403);assert.equal(unknown.audit.events()[0].ruleId,'inspection-incomplete'); + const mutated=await fixture(t,()=>{},async bytes=>{bytes.fill(0);return {decision:'block',ruleId:'company-secret'};});assert.equal(await mutated.send(document),403);assert.equal(mutated.audit.events()[0].ruleId,'inspection-incomplete'); +}); + +test('lifecycle upstream failure has one causal durable terminal event without retry',async t=>{ + let attempts=0;const f=await fixture(t,undefined,undefined,undefined,{forward:async()=>{attempts++;throw Error('private upstream details');}}); + assert.equal(await f.send('public'),502);assert.equal(attempts,1);assert.equal(f.connections,0); + const events=f.audit.events();assert.equal(events.length,2);assert.equal(events[0].broker.phase,'request.authorized');assert.equal(events[1].broker.phase,'forward.fault');assert.equal(events[1].broker.reason,'broker-upstream-failed');assert.equal(events[1].broker.requestId,events[0].broker.requestId);assert.equal(events[1].broker.authorizedEventId,events[0].eventId);assert.equal(JSON.stringify(events).includes('private upstream'),false); +}); +test('lifecycle CONNECT destination denial and ambiguous framing record before any target TCP',async t=>{ + const denied=await fixture(t,p=>p.browser.blockedHosts=['localhost']);assert.equal(await denied.send('public'),403);assert.equal(denied.connections,0);const event=denied.audit.events()[0];assert.equal(event.broker.phase,'connect.block');assert.equal(event.broker.reason,'broker-connect-denied'); + const ambiguous=await fixture(t);await ambiguous.send('public',{headers:{'Transfer-Encoding':'chunked'}}).catch(()=>{});assert.equal(ambiguous.connections,0);const records=ambiguous.audit.events();assert.equal(records.length,1);assert.equal(records[0].broker.reason,'broker-framing-block');assert.equal(records[0].broker.phase,'request.block'); +}); + +test('encoded document body/header/URL carriers hit actual signed fingerprint before target TCP',async t=>{ + const {createHash}=await import('node:crypto'),document=Buffer.from('%PDF-1.4 Registered document'),fingerprint=createHash('sha256').update(document).digest('hex'); + const f=await fixture(t,p=>p.dlp.rules=[{id:'encoded-fingerprint',label:'Synthetic',kind:'sha256',value:fingerprint,action:'block'}],async()=>assert.fail('registered fingerprint must block before local parser')); + const encoded=document.toString('base64'),uri='data:application/pdf;base64,'+encoded; + for(const body of [JSON.stringify({file:encoded}),JSON.stringify({file:encodeURIComponent(uri)}),JSON.stringify({file:Buffer.from(JSON.stringify({file:encoded})).toString('base64')})])assert.equal(await f.send(body,{headers:{'Content-Type':'application/json'}}),403); + assert.equal(await f.send('public',{headers:{'X-Document':uri}}),403);assert.equal(await f.send('public',{path:'/upload?file='+encodeURIComponent(uri)}),403);assert.equal(f.connections,0);assert.equal(f.audit.events().length,5);assert.ok(f.audit.events().every(event=>event.ruleId==='encoded-fingerprint')); +}); +test('public encoded document must receive full local coverage while original outer JSON remains exact',async t=>{ + const document=Buffer.from('%PDF-1.4 Public registered form'),seen=[]; + const f=await fixture(t,undefined,async bytes=>{seen.push(Buffer.from(bytes));return {decision:'allow',coverage:{complete:true}};}); + const outer=JSON.stringify({file:document.toString('base64')});assert.equal(await f.send(outer,{headers:{'Content-Type':'application/json'}}),201);assert.deepEqual(f.received,[outer]);assert.ok(seen.some(bytes=>bytes.equals(document))); +}); +test('encoded vector SVG and nested carriers beyond the global depth budget are unscannable',async t=>{ + const f=await fixture(t,undefined,async()=>assert.fail('unsupported SVG must not be counted as plain text coverage')); + assert.equal(await f.send(JSON.stringify({file:'data:image/svg+xml;base64,'+Buffer.from('').toString('base64')}),{headers:{'Content-Type':'application/json'}}),403); + let value='data:application/pdf;base64,'+Buffer.from('%PDF-1.4 public').toString('base64');for(let count=0;count<6;count++)value='data:application/json;base64,'+Buffer.from(JSON.stringify({file:value})).toString('base64'); + assert.equal(await f.send(JSON.stringify({file:value}),{headers:{'Content-Type':'application/json'}}),403);assert.equal(f.connections,0); +}); diff --git a/tests/broker-management-credential-lifecycle.test.mjs b/tests/broker-management-credential-lifecycle.test.mjs new file mode 100644 index 0000000..9f9dc35 --- /dev/null +++ b/tests/broker-management-credential-lifecycle.test.mjs @@ -0,0 +1,18 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawn} from 'node:child_process'; +import {createRequire} from 'node:module'; +const require=createRequire(import.meta.url); +const {createNativeManagementCredentialLoader}=require('../apps/network-broker/management-credential.cjs'); +const current={deviceId:'device',origin:'https://management.example',policyVersion:8,policyKeySha256:'a'.repeat(64),policyDigest:'b'.repeat(64)}; +// Synthetic stdio fixture exercises the real JS consumer only. Native selector, +// registry and DPAPI execution are separate gates; this is not their proof. +function client(stale=false){return createNativeManagementCredentialLoader({expectedBinding:current,current:()=>true,launch:()=>spawn(process.execPath,['-e',`let bytes=Buffer.alloc(0);process.stdin.on('data',part=>bytes=Buffer.concat([bytes,part]));process.stdin.on('end',()=>{const request=JSON.parse(bytes.subarray(4));const reply={...request,token:'synthetic_token_0123456789'};${stale?"reply.policyVersion=7;reply.policyDigest='c'.repeat(64);":""}const body=Buffer.from(JSON.stringify(reply)),header=Buffer.alloc(4);header.writeUInt32LE(body.length);process.stdout.write(Buffer.concat([header,body]));});`],{windowsHide:true,stdio:['pipe','pipe','ignore']})});} +test('current request echo accepts a reused private credential without claiming stored old metadata is current',async()=>{ + const previous=process.env.SAFETYNET_SERVICE_CLIENT_SID;process.env.SAFETYNET_SERVICE_CLIENT_SID='S-1-5-21-1-2-3-1001'; + const loader=client();try{const value=await loader.load(current);assert.equal(value.token.toString(),'synthetic_token_0123456789');assert.equal(value.deviceId,current.deviceId);value.token.fill(0);}finally{await loader.close();if(previous===undefined)delete process.env.SAFETYNET_SERVICE_CLIENT_SID;else process.env.SAFETYNET_SERVICE_CLIENT_SID=previous;} +}); +test('echoing the credential record older policy binding instead of current request is rejected',async()=>{ + const previous=process.env.SAFETYNET_SERVICE_CLIENT_SID;process.env.SAFETYNET_SERVICE_CLIENT_SID='S-1-5-21-1-2-3-1001'; + const loader=client(true);try{await assert.rejects(loader.load(current),{message:'broker-management-refused'});}finally{await loader.close();if(previous===undefined)delete process.env.SAFETYNET_SERVICE_CLIENT_SID;else process.env.SAFETYNET_SERVICE_CLIENT_SID=previous;} +}); diff --git a/tests/broker-management-credential.test.mjs b/tests/broker-management-credential.test.mjs new file mode 100644 index 0000000..afa3834 --- /dev/null +++ b/tests/broker-management-credential.test.mjs @@ -0,0 +1,40 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawn} from 'node:child_process'; +import {createRequire} from 'node:module'; +const require=createRequire(import.meta.url),{createNativeManagementCredentialLoader}=require('../apps/network-broker/management-credential.cjs'); +const binding={deviceId:'device',origin:'https://management.example',policyVersion:7,policyKeySha256:'a'.repeat(64),policyDigest:'b'.repeat(64)}; +const emit="let bytes=Buffer.alloc(0);process.stdin.on('data',part=>{bytes=Buffer.concat([bytes,part]);});process.stdin.on('end',()=>{const input=JSON.parse(bytes.subarray(4).toString());const value={...input,token:require('node:crypto').randomBytes(32).toString('base64url')};RESPONSE;});"; +const response="const b=Buffer.from(JSON.stringify(value)),h=Buffer.alloc(4);h.writeUInt32LE(b.length);process.stdout.write(Buffer.concat([h,b]));"; +function loader(code,options={}){return createNativeManagementCredentialLoader({expectedBinding:binding,current:()=>true,deadlineMs:1000,...options,launch:(file,args,config)=>{assert.ok(file.endsWith('safetynet-service-host.exe'));assert.deepEqual(args,['--management-credential']);assert.deepEqual(Object.keys(config.env).sort(),['SAFETYNET_SERVICE_CLIENT_SID','SystemRoot','WINDIR']);assert.equal(config.shell,false);return spawn(process.execPath,['-e',code],{stdio:['pipe','pipe','ignore'],env:config.env});}});} +test.before(()=>{process.env.SAFETYNET_SERVICE_CLIENT_SID='S-1-12-1-1-2-3-4';}); +test.after(()=>{delete process.env.SAFETYNET_SERVICE_CLIENT_SID;}); +test('fixed native-helper contract yields private credential only after exact bound reply and close',async()=>{const client=loader(emit.replace('RESPONSE',response+'setTimeout(()=>{},100);'));let finished=false;const work=client.load(binding);work.then(()=>{finished=true;});await new Promise(r=>setTimeout(r,50));assert.equal(finished,false);const value=await work;assert.equal(value.deviceId,binding.deviceId);assert.ok(Buffer.isBuffer(value.token));assert.equal(value.token.length,43);value.token.fill(0);client.close();}); +test('wrong policy/device/origin or unsafe native reply cannot yield credential',async()=>{const client=loader(emit.replace('RESPONSE',"value.policyDigest='c'.repeat(64);"+response));await assert.rejects(client.load(binding),{message:'broker-management-refused'});for(const key of ['deviceId','origin','policyVersion','policyDigest','policyKeySha256'])await assert.rejects(client.load({...binding,[key]:key==='policyVersion'?8:'other'}),{message:'broker-management-refused'});client.close();}); +test('nonzero or duplicate native reply is refused without retries',async()=>{for(const suffix of ['process.exitCode=5;','process.stdout.write(Buffer.from([2,0,0,0,123,125]));']){const client=loader(emit.replace('RESPONSE',response+suffix));await assert.rejects(client.load(binding),{message:'broker-management-refused'});client.close();}}); +test('signal/revocation and native timeout wait actual owned helper termination',async()=>{const client=loader('setInterval(()=>{},1000);',{deadlineMs:40});await assert.rejects(client.load(binding),{message:'broker-management-refused'});client.close();let active=true;const revoked=loader(emit.replace('RESPONSE',response+'setTimeout(()=>{},150);'),{current:()=>active});const work=revoked.load(binding);active=false;await assert.rejects(work,{message:'broker-management-refused'});revoked.close();}); +test('actual Windows LocalMachine DPAPI synthetic roundtrip keeps plaintext out of files', {skip:process.platform!=='win32'},async()=>{ + const script=`$ErrorActionPreference='Stop' +Add-Type -AssemblyName System.Security +$stream=[Console]::OpenStandardInput() +$header=New-Object byte[] 4 +$offset=0;while($offset -lt 4){$n=$stream.Read($header,$offset,4-$offset);if($n -eq 0){throw 'invalid'};$offset+=$n} +$length=[BitConverter]::ToUInt32($header,0);if($length -gt 4096){throw 'invalid'} +$bytes=New-Object byte[] $length;$offset=0;while($offset -lt $length){$n=$stream.Read($bytes,$offset,$length-$offset);if($n -eq 0){throw 'invalid'};$offset+=$n} +$request=[Text.Encoding]::UTF8.GetString($bytes)|ConvertFrom-Json +$entropy=[Security.Cryptography.SHA256]::Create().ComputeHash($bytes) +$random=New-Object byte[] 32;[Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($random) +$token=[Convert]::ToBase64String($random).TrimEnd('=').Replace('+','-').Replace('/','_') +$plain=[Text.Encoding]::UTF8.GetBytes($token) +$file=[IO.Path]::GetTempFileName() +try{ + $encrypted=[Security.Cryptography.ProtectedData]::Protect($plain,$entropy,[Security.Cryptography.DataProtectionScope]::LocalMachine) + [IO.File]::WriteAllBytes($file,$encrypted) + $decoded=[Security.Cryptography.ProtectedData]::Unprotect([IO.File]::ReadAllBytes($file),$entropy,[Security.Cryptography.DataProtectionScope]::LocalMachine) + if([Convert]::ToBase64String($plain) -ne [Convert]::ToBase64String($decoded)){throw 'invalid'} + $value=[ordered]@{deviceId=$request.deviceId;origin=$request.origin;policyVersion=$request.policyVersion;policyKeySha256=$request.policyKeySha256;policyDigest=$request.policyDigest;token=[Text.Encoding]::UTF8.GetString($decoded)} + $out=[Text.Encoding]::UTF8.GetBytes(($value|ConvertTo-Json -Compress));$stdout=[Console]::OpenStandardOutput();$h=[BitConverter]::GetBytes([uint32]$out.Length);$stdout.Write($h,0,4);$stdout.Write($out,0,$out.Length);$stdout.Flush() +}finally{[Array]::Clear($plain,0,$plain.Length);if($decoded){[Array]::Clear($decoded,0,$decoded.Length)};[Array]::Clear($random,0,$random.Length);[IO.File]::Delete($file)} +`; + const client=createNativeManagementCredentialLoader({expectedBinding:binding,current:()=>true,launch:()=>spawn('powershell.exe',['-NoLogo','-NoProfile','-NonInteractive','-EncodedCommand',Buffer.from(script,'utf16le').toString('base64')],{windowsHide:true,stdio:['pipe','pipe','ignore']})});const value=await client.load(binding);assert.equal(value.token.length,43);value.token.fill(0);client.close(); +}); diff --git a/tests/broker-management-identity.test.mjs b/tests/broker-management-identity.test.mjs new file mode 100644 index 0000000..1b48915 --- /dev/null +++ b/tests/broker-management-identity.test.mjs @@ -0,0 +1,42 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import https from 'node:https'; +import {once} from 'node:events'; +import {readFile} from 'node:fs/promises'; +import {generateKeyPairSync} from 'node:crypto'; +import {createRequire} from 'node:module'; +import {defaultPolicy,signPolicy,verifyPolicy} from '../packages/core/policy.mjs'; +const require=createRequire(import.meta.url),{createManagementIdentityOperation}=require('../apps/network-broker/management-identity-operation.cjs'); +const channel=require('../packages/core/policy-channel.cjs'),{verifyServiceAcceptance}=require('../apps/network-broker/service-acceptance.cjs'); +const {signManifest,verifyReleaseEnvelope,verifyDeploymentEnvelope}=require('../packages/core/windows-trust.cjs'); +const pub=k=>k.publicKey.export({type:'spki',format:'pem'}),priv=k=>k.privateKey.export({type:'pkcs8',format:'pem'}); +async function fixture(t){ + const cert=await readFile(new URL('./fixtures/icap/cert.pem',import.meta.url)),key=await readFile(new URL('./fixtures/icap/key.pem',import.meta.url)),ed=generateKeyPairSync('ed25519'),pq=generateKeyPairSync('ml-dsa-87'),vendor=generateKeyPairSync('ec',{namedCurve:'prime256v1'}),customer=generateKeyPairSync('ec',{namedCurve:'prime256v1'});let requests=0,tcp=0; + let result=channel.identity(ed.privateKey,pq.privateKey,pub(pq)),status=200,handler; + const server=https.createServer({key,cert},async(req,res)=>{requests++;assert.equal(req.headers.authorization,'Bearer synthetic-protected-token');if(handler){await handler(req,res);return;}assert.equal(req.method,'GET');assert.equal(req.url,'/api/device/channel/identity');res.writeHead(status,{'content-type':'application/json'});res.end(JSON.stringify(result));});server.on('connection',()=>tcp++);server.listen(0,'::');await once(server,'listening');t.after(async()=>{server.closeAllConnections();await new Promise(r=>server.close(r));});const origin=`https://localhost:${server.address().port}`,sid='S-1-12-1-1-2-3-4'; + const release={format:'safetynet-windows-release-v1',releaseId:'12345678-1234-1234-1234-123456789abc',releaseSequence:1,serviceName:'SafetyNetNetworkBroker',deploymentTenant:'22345678-1234-1234-1234-123456789abc',deploymentPublicKey:pub(customer),files:['SafetyNet.exe','resources/app.asar','resources/broker/node.exe','resources/inspector/server.py','resources/python/python.exe','resources/semantic/model.bin','safetynet-service-host.exe'].map(path=>({path,bytes:1,sha256:'a'.repeat(64)}))};const releaseEnvelope=signManifest(release,priv(vendor),'release'),r=verifyReleaseEnvelope(releaseEnvelope,{publicKey:pub(vendor)}); + const deployment={format:'safetynet-windows-deployment-v2',releaseId:release.releaseId,deploymentTenant:release.deploymentTenant,deploymentSequence:1,policyPublicKey:pub(ed),expiresAt:Date.now()+60000,users:[{sid,deviceId:'device',minimumPolicyVersion:7}],management:{origin,signatureKey:pub(pq),protocol:channel.VERSION}},deploymentEnvelope=signManifest(deployment,priv(customer),'deployment'),d=verifyDeploymentEnvelope(deploymentEnvelope,{release:r,authenticatedSid:sid}); + const policy=defaultPolicy();policy.version=7;policy.assignment={deviceId:'device',groups:[]};policy.proxy={mode:'direct'};const policyEnvelope=signPolicy(policy,priv(ed)); + const snapshot={version:1,authenticatedSid:sid,releaseAnchor:pub(vendor),acceptanceState:{format:'safetynet-service-floors-v1',release:{minimumSequence:1,acceptedDigest:r.digest},deployment:{tenant:release.deploymentTenant,minimumSequence:1,acceptedDigest:d.digest},policies:[]}},files={releaseEnvelope,deploymentEnvelope};const plan=await verifyServiceAcceptance({...files,releaseAnchor:snapshot.releaseAnchor,authenticatedSid:sid,state:snapshot.acceptanceState,policyEnvelope});snapshot.acceptanceState=plan.nextState; + const store={async snapshot(){return snapshot;},async envelopes(){return files;}};const config={store,policyEnvelope,network:{mode:'direct',dns:'system',ca:cert.toString()},current:()=>true,loadCredential:async binding=>{assert.equal(binding.deviceId,'device');assert.equal(binding.origin,origin);return {deviceId:'device',origin,token:'synthetic-protected-token'};}}; + return {config,origin,pq,ed,snapshot,get requests(){return requests;},get tcp(){return tcp;},set result(v){result=v;},set status(v){status=v;},set handler(v){handler=v;}}; +} +test('actual HTTPS identity verifies genuine Ed25519 plus deployment-pinned MLDSA87',async t=>{const f=await fixture(t),op=await createManagementIdentityOperation(f.config);t.after(()=>op.close());const value=await op.identity();assert.equal(value.signatureKey,pub(f.pq));assert.equal(f.requests,1);await assert.rejects(op.identity({url:'https://attacker.example',token:'override'}),{message:'broker-management-refused'});assert.equal(f.requests,1);}); +test('protected credentials missing or mismatched never start network',async t=>{let storeCalls=0;await assert.rejects(createManagementIdentityOperation({store:{snapshot(){storeCalls++;}}}),{message:'broker-management-refused'});assert.equal(storeCalls,0);const f=await fixture(t),op=await createManagementIdentityOperation({...f.config,loadCredential:async()=>({deviceId:'other',origin:f.origin,token:'synthetic-protected-token'})});t.after(()=>op.close());await assert.rejects(op.identity(),{message:'broker-management-refused'});assert.equal(f.tcp,0);}); +test('identity tampering and redirect fail without retry',async t=>{const f=await fixture(t),op=await createManagementIdentityOperation(f.config);t.after(()=>op.close());f.result={version:channel.VERSION,signatureKey:pub(f.pq),edSignature:'a',pqSignature:'b'};await assert.rejects(op.identity(),{message:'broker-management-refused'});assert.equal(f.requests,1);f.status=302;await assert.rejects(op.identity(),{message:'broker-management-refused'});assert.equal(f.requests,2);}); +test('timed-out protected credential retains admission until original settlement',async t=>{const f=await fixture(t);let settle,calls=0;const pending=new Promise(r=>{settle=r;}),op=await createManagementIdentityOperation({...f.config,deadlineMs:20,loadCredential:()=>{calls++;return pending;}});t.after(()=>op.close());await assert.rejects(op.identity(),{message:'broker-management-refused'});await assert.rejects(op.identity(),{message:'broker-management-refused'});assert.equal(calls,1);assert.equal(f.tcp,0);let closed=false;const closing=op.close().then(()=>{closed=true;});await new Promise(r=>setTimeout(r,10));assert.equal(closed,false);const lateToken=Buffer.from('synthetic-protected-token');settle({deviceId:'device',origin:f.origin,token:lateToken});await closing;assert.ok(lateToken.every(v=>v===0));assert.equal(f.tcp,0);}); +test('validly dual-signed identity with another PQ key is refused by deployed pin',async t=>{const f=await fixture(t),other=generateKeyPairSync('ml-dsa-87');f.result=channel.identity(f.ed.privateKey,other.privateKey,pub(other));const op=await createManagementIdentityOperation(f.config);t.after(()=>op.close());await assert.rejects(op.identity(),{message:'broker-management-refused'});assert.equal(f.requests,1);}); +test('pre-enrollment floors and revocation during credential load cannot dial',async t=>{const f=await fixture(t),saved=f.snapshot.acceptanceState;f.snapshot.acceptanceState={...saved,policies:[]};await assert.rejects(createManagementIdentityOperation(f.config),{message:'broker-management-refused'});f.snapshot.acceptanceState=saved;let active=true;const op=await createManagementIdentityOperation({...f.config,current:()=>active,loadCredential:async()=>{active=false;return {deviceId:'device',origin:f.origin,token:'synthetic-protected-token'};}});t.after(()=>op.close());await assert.rejects(op.identity(),{message:'broker-management-refused'});assert.equal(f.tcp,0);}); +test('private protected token lease is erased after actual HTTPS use',async t=>{const f=await fixture(t),token=Buffer.from('synthetic-protected-token');const op=await createManagementIdentityOperation({...f.config,loadCredential:async binding=>{assert.match(binding.policyDigest,/^[a-f0-9]{64}$/);return {deviceId:'device',origin:f.origin,token};}});t.after(()=>op.close());await op.identity();assert.ok(token.every(byte=>byte===0));assert.equal(f.requests,1);}); +function policyHandler(f,{version=8,deviceId='device',challengeDevice='device',tamper=false,onChallenge=()=>{}}={}){ + let pair,used=false;return async(req,res)=>{ + assert.equal(req.method,'POST');const chunks=[];for await(const part of req)chunks.push(part);const body=JSON.parse(Buffer.concat(chunks).toString()); + if(req.url==='/api/device/channel/challenge'){assert.deepEqual(body,{});pair=channel.challenge(challengeDevice,f.ed.privateKey,f.pq.privateKey);onChallenge();res.end(JSON.stringify(pair.bundle));return;} + assert.equal(req.url,'/api/device/channel/policy');assert.equal(used,false);used=true;const context=channel.receive(body,pair.bundle,pair.privateKey);try{const p=defaultPolicy();p.version=version;p.assignment={deviceId,groups:[]};p.proxy={mode:'direct'};const envelope=signPolicy(p,priv(f.ed)),response=channel.response(envelope,pair.bundle,context);if(tamper)response.encrypted.tag='a'.repeat(22);res.end(JSON.stringify(response));}finally{context.responseKey.fill(0);} + }; +} +test('actual paired MLKEM1024/MLDSA87 HTTPS policy operation verifies new device-bound policy',async t=>{const f=await fixture(t);f.handler=policyHandler(f);const op=await createManagementIdentityOperation({...f.config,loadCredential:undefined,credentialLoader:{load:f.config.loadCredential}});t.after(()=>op.close());const result=await op.policy();assert.equal(verifyPolicy(result,pub(f.ed),7).version,8);assert.equal(f.requests,2);assert.equal(f.snapshot.acceptanceState.policies[0].minimumVersion,7);await assert.rejects(op.policy({url:'https://other.example'}),{message:'broker-management-refused'});assert.equal(f.requests,2);}); +test('policy channel wrong device, rollback, same-version equivocation and encrypted tampering fail',async t=>{for(const options of [{challengeDevice:'other'},{deviceId:'other'},{version:6},{version:7},{tamper:true}]){const f=await fixture(t);f.handler=policyHandler(f,options);const op=await createManagementIdentityOperation(f.config);t.after(()=>op.close());await assert.rejects(op.policy(),{message:'broker-management-refused'});assert.equal(f.requests,options.challengeDevice?1:2);}}); +test('revocation after signed challenge prevents policy POST and uses one total job budget',async t=>{const f=await fixture(t);let active=true;f.handler=policyHandler(f,{onChallenge:()=>{active=false;}});const op=await createManagementIdentityOperation({...f.config,current:()=>active});t.after(()=>op.close());await assert.rejects(op.policy(),{message:'broker-management-refused'});assert.equal(f.requests,1);}); +test('challenge and policy share one deadline rather than renewing per request',async t=>{const f=await fixture(t),handle=policyHandler(f);f.handler=async(req,res)=>{const end=res.end.bind(res);res.end=(...args)=>{setTimeout(()=>end(...args),180);return res;};await handle(req,res);};const token=Buffer.from('synthetic-protected-token'),op=await createManagementIdentityOperation({...f.config,deadlineMs:300,loadCredential:async()=>({deviceId:'device',origin:f.origin,token})});t.after(()=>op.close());await assert.rejects(op.policy(),{message:'broker-management-refused'});await new Promise(r=>setTimeout(r,250));assert.equal(f.requests,2);assert.ok(token.every(v=>v===0));}); +test('default fixed native loader refuses unavailable authenticated service context before network',async t=>{const f=await fixture(t),previous=process.env.SAFETYNET_SERVICE_CLIENT_SID;delete process.env.SAFETYNET_SERVICE_CLIENT_SID;try{const op=await createManagementIdentityOperation({...f.config,loadCredential:undefined});t.after(()=>op.close());await assert.rejects(op.identity(),{message:'broker-management-refused'});await assert.rejects(op.policy(),{message:'broker-management-refused'});assert.equal(f.tcp,0);}finally{if(previous!==undefined)process.env.SAFETYNET_SERVICE_CLIENT_SID=previous;}}); diff --git a/tests/broker-multipart.test.mjs b/tests/broker-multipart.test.mjs new file mode 100644 index 0000000..f057c89 --- /dev/null +++ b/tests/broker-multipart.test.mjs @@ -0,0 +1,50 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +const require=createRequire(import.meta.url),{parseMultipart}=require('../apps/network-broker/multipart.cjs'); +const boundary='----WebKitFormBoundarySynthetic42',type=`multipart/form-data; boundary=${boundary}`; +function encode(parts,ending=true){ + const output=[]; + for(const part of parts){output.push(Buffer.from(`--${boundary}\r\n${part.headers}\r\n\r\n`));output.push(Buffer.from(part.body));output.push(Buffer.from('\r\n'));} + if(ending)output.push(Buffer.from(`--${boundary}--\r\n`));return Buffer.concat(output); +} +const text={headers:'Content-Disposition: form-data; name="note"',body:'public note'}; +const pdf={headers:'Content-Disposition: form-data; name="files"; filename="report.pdf"\r\nContent-Type: application/pdf',body:Buffer.from('%PDF-1.4\n\0\xffbinary','latin1')}; +const zip={headers:'Content-Disposition: form-data; name="files"; filename="archive.zip"\r\nContent-Type: application/zip',body:Buffer.from([0x50,0x4b,3,4,0,255,1])}; +test('Chromium form fields and PDF ZIP parts are preserved completely as independent bytes',()=>{ + const body=encode([text,pdf,zip]),parsed=parseMultipart(body,type); + assert.equal(parsed.coverage.complete,true);assert.equal(parsed.parts.length,3); + assert.equal(parsed.parts[0].body.toString(),'public note'); + assert.deepEqual(parsed.parts[1].body,pdf.body);assert.deepEqual(parsed.parts[2].body,zip.body); + assert.equal(parsed.parts[1].filename,'report.pdf');assert.equal(parsed.parts[1].contentType,'application/pdf'); + body.fill(0);assert.deepEqual(parsed.parts[1].body,pdf.body); + parsed.dispose();assert.ok(parsed.parts.every(part=>part.body.every(value=>value===0))); +}); +test('boundary-like bytes without complete delimiter suffix remain inside file',()=>{ + const body=Buffer.from(`prefix\r\n--${boundary}XYZ\r\n--${boundary}-\r\nsuffix`); + const parsed=parseMultipart(encode([{...pdf,body}]),type);assert.deepEqual(parsed.parts[0].body,body);parsed.dispose(); +}); +test('quoted boundary and escaped filename handle actual MIME syntax',()=>{ + const parsed=parseMultipart(encode([{...text,headers:'Content-Disposition: form-data; name="upload"; filename="quoted\\"name.txt"'}]),`multipart/form-data; boundary="${boundary}"`); + assert.equal(parsed.parts[0].filename,'quoted"name.txt');parsed.dispose(); +}); +test('empty multipart form has complete zero-part coverage',()=>{ + assert.equal(parseMultipart(encode([]),type).parts.length,0); +}); +test('truncation malformed CRLF preamble and epilogue never yield partial coverage',()=>{ + const valid=encode([text,pdf]); + for(const body of [valid.subarray(0,-5),encode([text],false),Buffer.concat([Buffer.from('preamble'),valid]),Buffer.concat([valid,Buffer.from('epilogue')]),Buffer.from(valid.toString().replace('\r\n','\n'))])assert.throws(()=>parseMultipart(body,type),{code:'invalid-multipart'}); +}); +test('ambiguous disposition and duplicate headers are rejected',()=>{ + for(const headers of ['Content-Disposition: form-data; name="a"; name="b"','Content-Disposition: form-data; name="a"\r\ncontent-disposition: form-data; name="b"','Content-Disposition: form-data; name="a"; filename="one"; filename="two"','Content-Disposition: form-data; name="a"\r\n folded: value'])assert.throws(()=>parseMultipart(encode([{headers,body:'public'}]),type),{code:'invalid-multipart'}); +}); +test('unsupported extended filenames nested multipart and transfer encoding fail closed',()=>{ + for(const headers of ["Content-Disposition: form-data; name=upload; filename*=UTF-8''file.pdf",'Content-Disposition: form-data; name="a"\r\nContent-Type: multipart/mixed; boundary=inner','Content-Disposition: form-data; name="a"\r\nContent-Transfer-Encoding: base64'])assert.throws(()=>parseMultipart(encode([{headers,body:'public'}]),type),{code:'unsupported-multipart'}); +}); +test('whole payload part count and per-part headers are strictly bounded',()=>{ + const body=encode([text,pdf]); + for(const options of [{maxBytes:body.length-1},{maxParts:1},{maxHeaderBytes:5}])assert.throws(()=>parseMultipart(body,type,options),{code:'multipart-limit'}); +}); +test('invalid boundary parameters and duplicate boundary are rejected',()=>{ + for(const value of ['multipart/form-data','multipart/form-data; boundary=""','multipart/form-data; boundary="bad\r\nvalue"',`multipart/form-data; boundary=${boundary}; boundary=other`,'multipart/form-data; boundary='+ 'x'.repeat(71)])assert.throws(()=>parseMultipart(encode([text]),value),{code:'invalid-multipart'}); +}); diff --git a/tests/broker-native-diagnostics.test.mjs b/tests/broker-native-diagnostics.test.mjs new file mode 100644 index 0000000..d024a45 --- /dev/null +++ b/tests/broker-native-diagnostics.test.mjs @@ -0,0 +1,11 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +import {randomUUID} from 'node:crypto'; +import {defaultPolicy} from '../packages/core/policy.mjs'; +const require=createRequire(import.meta.url),{createFixtureDiagnostics}=require('./fixtures/broker-native-diagnostics.cjs'),{createSyntheticAudit}=require('./fixtures/broker-durable-audit.cjs'); +test('authorized private synthetic OTLP fixture retrieves real durable trace with audit child timings',async t=>{ + const audit=createSyntheticAudit(t),policy=defaultPolicy(),diagnostics=await createFixtureDiagnostics({enabled:true,policy,audit}); + await diagnostics.run({bytes:0},async()=>diagnostics.persist({eventId:randomUUID(),kind:'upload.authorized',ruleId:'broker-approved',policyVersion:1,request:{bytes:0,detectedType:'text'}})); + const proof=await diagnostics.finish();assert.equal(proof.enabled,true);assert.ok(proof.spanCount>=2);assert.equal(proof.invalid,0);assert.equal(proof.failedExports,0);assert.equal(proof.lifetimeFailedExports,0); +}); diff --git a/tests/broker-native-inspection.test.mjs b/tests/broker-native-inspection.test.mjs new file mode 100644 index 0000000..962440b --- /dev/null +++ b/tests/broker-native-inspection.test.mjs @@ -0,0 +1,156 @@ +// Explicit Windows integration: run with the verified bundled Node executable +// and SAFETYNET_NATIVE_BROKER_INSPECTION=1. Default unit suites skip this gate. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import {createRequire} from 'node:module'; +import {createHash,generateKeyPairSync} from 'node:crypto'; +import {execFileSync} from 'node:child_process'; +import http from 'node:http'; +import https from 'node:https'; +import tls from 'node:tls'; +import {once} from 'node:events'; +import {defaultPolicy,signPolicy,inspect} from '../packages/core/policy.mjs'; +import {verifyBrokerRuntime} from '../scripts/provision-broker-runtime.mjs'; +const require=createRequire(import.meta.url),{startInspector}=require('../apps/browser/inspection.cjs'); +const {createManagedProxy}=require('../apps/network-broker/managed-proxy.cjs'); +const {createPolicyAuthority}=require('../apps/network-broker/policy-authority.cjs'); +const {createHttpsForwarder}=require('../apps/network-broker/https-forwarder.cjs'); +const {createSyntheticAudit}=require('./fixtures/broker-durable-audit.cjs'); +const hash=bytes=>createHash('sha256').update(bytes).digest('hex'); +function pdf(text){ + const stream=`BT /F1 12 Tf 72 720 Td <${Buffer.from(text).toString('hex')}> Tj ET`; + const objects=['<< /Type /Catalog /Pages 2 0 R >>','<< /Type /Pages /Kids [3 0 R] /Count 1 >>','<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>','<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',`<< /Length ${Buffer.byteLength(stream)} >>\nstream\n${stream}\nendstream`]; + let output='%PDF-1.4\n';const offsets=[0]; + objects.forEach((object,index)=>{offsets.push(Buffer.byteLength(output));output+=`${index+1} 0 obj\n${object}\nendobj\n`;}); + const xref=Buffer.byteLength(output);output+=`xref\n0 6\n0000000000 65535 f \n${offsets.slice(1).map(offset=>String(offset).padStart(10,'0')+' 00000 n \n').join('')}trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`; + return Buffer.from(output); +} +function inspectorChildren(){ + const script=`@(Get-CimInstance Win32_Process -Filter "ParentProcessId = ${process.pid}" | Where-Object { $_.Name -eq 'python.exe' -and $_.CommandLine -like '*apps*inspector*server.py*' } | Select-Object -ExpandProperty ProcessId) | ConvertTo-Json -Compress`; + const output=execFileSync('powershell.exe',['-NoProfile','-NonInteractive','-Command',script],{encoding:'utf8',windowsHide:true,timeout:10000}).trim(); + return output?[].concat(JSON.parse(output)):[]; +} +test('bundled Windows Node managed TLS broker uses real contained document inspection before dial',{ + skip:process.platform!=='win32'||process.env.SAFETYNET_NATIVE_BROKER_INSPECTION!=='1',timeout:120000 +},async t=>{ + const root=path.resolve('.'),proofDirectory=path.join(root,'artifacts/broker-native-inspection');await fs.mkdir(proofDirectory,{recursive:true}); + const proof={startedAt:new Date().toISOString(),outcome:'running',diagnosticsEnabled:false,scans:[],office:[]}; + let inspector,proxy,target;const started=performance.now(); + try{ + const runtime=await verifyBrokerRuntime(path.join(root,'.runtime/windows/broker')); + assert.equal(path.resolve(process.execPath).toLowerCase(),path.join(root,'.runtime/windows/broker/node.exe').toLowerCase(),'Run this gate with the bundled broker Node executable'); + proof.runtime={version:runtime.version,executableSha256:runtime.files.find(file=>file.path==='node.exe').sha256}; + inspector=startInspector(root,false);assert.ok(inspector,'Contained inspector runtime unavailable');await inspector.waitReady(); + const children=inspectorChildren();assert.equal(children.length,1,'Expected one owned contained inspector parent');proof.inspectorOwnedPids=children; + // startInspector only resolves readiness after validating Job Object plus + // zero-network-capability AppContainer/private-pipe/read-only containment. + proof.containmentStartupValidated=true; + const key=await fs.readFile(new URL('./fixtures/icap/key.pem',import.meta.url)),cert=await fs.readFile(new URL('./fixtures/icap/cert.pem',import.meta.url)); + const received=[];let connections=0; + target=https.createServer({key,cert},async(req,res)=>{const chunks=[];for await(const chunk of req)chunks.push(chunk);const body=Buffer.concat(chunks);received.push({sha256:hash(body),bytes:body.length});res.end('public receiver acknowledgment');}); + target.on('connection',()=>connections++);target.listen(0,'127.0.0.1');await once(target,'listening'); + const port=target.address().port,destination=`localhost:${port}`; + const keys=generateKeyPairSync('ed25519'),policy=defaultPolicy();policy.assignment={deviceId:'native-broker-device',groups:[]}; + policy.dlp.rules=[{id:'synthetic-confidential-document',label:'Synthetic confidential',kind:'literal',value:'SYNTHETIC INTERNAL BLUEPRINT',action:'block'}]; + assert.equal(policy.diagnostics.enabled,false); + const authority=await createPolicyAuthority({deviceId:'native-broker-device',publicKey:keys.publicKey.export({type:'spki',format:'pem'})}); + authority.accept(signPolicy(policy,keys.privateKey.export({type:'pkcs8',format:'pem'}))); + const snapshot=authority.snapshot(); + const forward=createHttpsForwarder({authority:snapshot.token,current:authority.current,resolveRoute:async value=>({approved:value.hostname==='localhost'&&value.port===port,address:'127.0.0.1',port:value.port}),ca:cert,deadlineMs:45000,maxResponseBytes:Math.min(16777216,policy.fileTransfers.download.maxBytes)}); + const durableAudit=createSyntheticAudit(t); + proxy=await createManagedProxy({policyAuthority:authority,authMode:'bearer',persistAudit:durableAudit.persist,resolveCertificate:binding=>binding.authority===destination?{authority:destination,key,cert}:undefined, + localInspector:async(bytes,signedPolicy)=>{const before=performance.now(),result=await inspector.inspect(bytes,signedPolicy);proof.scans.push({durationMs:performance.now()-before,decision:result.decision,complete:result.coverage.complete,contentType:result.contentType,matchedSyntheticRule:result.ruleId==='synthetic-confidential-document'});return result;},forward,deadlineMs:45000}); + const proxyPort=await proxy.listen(); + const send=(body,mime='application/pdf')=>new Promise((resolve,reject)=>{ + const request=http.request({host:'127.0.0.1',port:proxyPort,method:'CONNECT',path:destination,headers:{host:destination,'proxy-authorization':`Bearer ${proxy.capability}`}}); + request.setTimeout(55000,()=>request.destroy(new Error('native-test-deadline')));request.once('error',reject); + request.once('connect',(response,socket)=>{ + if(response.statusCode!==200){socket.destroy();resolve(response.statusCode);return;} + const secure=tls.connect({socket,servername:'localhost',ca:cert,rejectUnauthorized:true});let reply=''; + secure.setTimeout(55000,()=>secure.destroy(new Error('native-test-deadline')));secure.once('error',reject); + secure.once('secureConnect',()=>secure.write(Buffer.concat([Buffer.from(`POST /upload HTTP/1.1\r\nHost: ${destination}\r\nContent-Type: ${mime}\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n`),body]))); + secure.on('data',chunk=>reply+=chunk.toString());secure.once('end',()=>{secure.destroy();resolve(Number(/^HTTP\/1.1 (\d+)/.exec(reply)?.[1]));}); + });request.end(); + }); + const secret=pdf(policy.dlp.rules[0].value),publicDocument=pdf('Public example report'); + assert.equal(inspect(secret,policy).decision,'allow','Encoded PDF must require genuine parsing, not raw literal detection'); + assert.equal(await send(secret),403);assert.equal(connections,0);assert.equal(received.length,0); + proof.secret={status:403,upstreamConnections:connections,receivedBytes:0}; + assert.equal(await send(publicDocument),200);assert.deepEqual(received,[{sha256:hash(publicDocument),bytes:publicDocument.length}]); + assert.ok(proof.scans.some(scan=>scan.decision==='block'&&scan.contentType==='pdf'&&scan.matchedSyntheticRule)); + assert.ok(proof.scans.some(scan=>scan.decision==='allow'&&scan.complete&&scan.contentType==='pdf')); + proof.public={status:200,upstreamConnections:connections,...received[0]}; + const officeRoot=path.join(root,'artifacts/office-coverage-fixtures'); + // Only synthetic fixtures are modified by stdlib ZIP/XML; no classifier, + // document parser or OCR result is substituted in the actual broker gate. + const mutate=[ + 'import sys,zipfile,pathlib,xml.etree.ElementTree as ET', + 'root=pathlib.Path(sys.argv[1]); marker=sys.argv[2]', + "for source,destination,member in [('public-generated.docx','secret-metadata.docx','docProps/core.xml'),('public-generated.xlsx','secret-hidden.xlsx','xl/worksheets/sheet2.xml')]:", + ' with zipfile.ZipFile(root/source) as archive:', + ' records=[(entry,archive.read(entry)) for entry in archive.infolist()]', + " if source.endswith('.xlsx'):", + " workbook=ET.fromstring(archive.read('xl/workbook.xml'))", + " sheets=[node for node in workbook.iter() if node.tag.endswith('}sheet')]", + " assert len(sheets)==2 and sheets[1].attrib.get('state')=='hidden'", + " with zipfile.ZipFile(root/destination,'w') as output:", + ' changed=False', + ' for entry,data in records:', + ' if entry.filename==member:', + ' document=ET.fromstring(data)', + " if source.endswith('.docx'):", + " title=document.find('{http://purl.org/dc/elements/1.1/}title')", + " if title is None: title=ET.SubElement(document,'{http://purl.org/dc/elements/1.1/}title')", + ' title.text=marker', + ' else:', + " namespace='{http://schemas.openxmlformats.org/spreadsheetml/2006/main}'", + " sheet=document.find(namespace+'sheetData')", + " if sheet is None: raise RuntimeError('Synthetic sheet data absent')", + " row=ET.SubElement(sheet,namespace+'row',{'r':'1'})", + " cell=ET.SubElement(row,namespace+'c',{'r':'A1','t':'inlineStr'})", + " value=ET.SubElement(ET.SubElement(cell,namespace+'is'),namespace+'t'); value.text=marker", + " data=ET.tostring(document,encoding='utf-8',xml_declaration=True);changed=True", + ' output.writestr(entry,data)', + " if not changed: raise RuntimeError('Synthetic fixture target part absent')", + '' + ].join('\n'); + execFileSync(path.join(root,'.runtime/windows/python/python.exe'),['-c',mutate,officeRoot,policy.dlp.rules[0].value],{windowsHide:true,stdio:'pipe',timeout:10000}); + const expectedPublic=[received[0]]; + for(const [label,name,mime] of [ + ['docx','public-generated.docx','application/vnd.openxmlformats-officedocument.wordprocessingml.document'], + ['xlsx','public-generated.xlsx','application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']]){ + const bytes=await fs.readFile(path.join(officeRoot,name)),firstScan=proof.scans.length; + assert.equal(await send(bytes,mime),200); + const observed=proof.scans.slice(firstScan);assert.ok(observed.some(scan=>scan.contentType==='office'&&scan.decision==='allow'&&scan.complete)); + expectedPublic.push({sha256:hash(bytes),bytes:bytes.length});proof.office.push({case:label+'-public',status:200,complete:true,sha256:hash(bytes),bytes:bytes.length}); + } + for(const [label,name,mime] of [ + ['docx-metadata','secret-metadata.docx','application/vnd.openxmlformats-officedocument.wordprocessingml.document'], + ['xlsx-hidden-sheet','secret-hidden.xlsx','application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']]){ + const before=connections,firstScan=proof.scans.length,bytes=await fs.readFile(path.join(officeRoot,name)); + assert.equal(await send(bytes,mime),403);assert.equal(connections,before); + assert.ok(proof.scans.slice(firstScan).some(scan=>scan.contentType==='office'&&scan.decision==='block'&&scan.matchedSyntheticRule)); + proof.office.push({case:label,status:403,matchedSyntheticRule:true,newUpstreamConnections:0}); + } + const complex=await fs.readFile(path.join(officeRoot,'public-generated.pptx')),beforeComplex=connections,firstComplexScan=proof.scans.length; + assert.equal(await send(complex,'application/vnd.openxmlformats-officedocument.presentationml.presentation'),403);assert.equal(connections,beforeComplex); + assert.ok(proof.scans.slice(firstComplexScan).some(scan=>scan.contentType==='office'&&scan.decision==='block'&&!scan.complete)); + proof.office.push({case:'unsupported-complex-pptx',status:403,complete:false,newUpstreamConnections:0}); + assert.deepEqual(received,expectedPublic); + const auditEvents=durableAudit.events(); + assert.equal(auditEvents.filter(event=>event.kind==='upload.authorized').length,3); + assert.equal(auditEvents.filter(event=>event.kind==='upload.block'&&event.ruleId==='synthetic-confidential-document').length,3); + assert.equal(auditEvents.length,7); + proof.audit={persistedEvents:auditEvents.length,authorizedEvents:3,matchedSecretBlocks:3,syntheticMetadataFsynced:true}; + proof.outcome='passed'; + }catch(error){proof.outcome='failed';proof.errorType=error.name;throw error;} + finally{ + if(proxy)await proxy.close();if(target){target.closeAllConnections();await new Promise(resolve=>target.close(resolve));} + if(inspector){inspector.stop();const deadline=Date.now()+10000;while(inspectorChildren().length&&Date.now()setTimeout(resolve,100));proof.ownedInspectorParentsRemaining=inspectorChildren().length;} + proof.durationMs=performance.now()-started;await fs.writeFile(path.join(proofDirectory,`proof-${proof.startedAt.replaceAll(':','-')}.json`),JSON.stringify(proof,null,2)+'\n'); + await fs.writeFile(path.join(proofDirectory,`office-proof-${proof.startedAt.replaceAll(':','-')}.json`),JSON.stringify({startedAt:proof.startedAt,outcome:proof.outcome,diagnosticsEnabled:false,runtime:proof.runtime,office:proof.office,scans:proof.scans.filter(scan=>scan.contentType==='office'),audit:proof.audit,ownedInspectorParentsRemaining:proof.ownedInspectorParentsRemaining},null,2)+'\n'); + assert.equal(proof.ownedInspectorParentsRemaining??0,0,'Owned inspector parent did not terminate'); + } +}); diff --git a/tests/broker-package-contract.test.mjs b/tests/broker-package-contract.test.mjs new file mode 100644 index 0000000..d7323b1 --- /dev/null +++ b/tests/broker-package-contract.test.mjs @@ -0,0 +1,85 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtemp,mkdir,writeFile,rm,symlink,open} from 'node:fs/promises'; +import {writeFileSync,utimesSync} from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import {buildBrokerPackageManifest,verifyBrokerPackage} from '../scripts/broker-package-contract.mjs'; + +const release={version:'0.1.0',protocol:1,entrypoint:'broker.exe'}; +async function fixture(t){ + const root=await mkdtemp(path.join(os.tmpdir(),'safetynet-broker-contract-')); + t.after(()=>rm(root,{recursive:true,force:true})); + await mkdir(path.join(root,'runtime')); + // Synthetic bytes deliberately are not a broker executable or launch proof. + await writeFile(path.join(root,'broker.exe'),'synthetic packaged executable'); + await writeFile(path.join(root,'runtime','scanner.dll'),'synthetic scanner'); + const manifest=await buildBrokerPackageManifest(root,release); + return {root,manifest}; +} +test('complete bundled broker inventory hashes and verifies without external runtime',async t=>{ + const {root,manifest}=await fixture(t); + assert.equal(manifest.files.length,2); + assert.equal((await verifyBrokerPackage(root,manifest,release)).files,2); +}); +for(const operation of ['missing','tampered','extra'])test(`rejects ${operation} broker payload`,async t=>{ + const {root,manifest}=await fixture(t); + if(operation==='missing')await rm(path.join(root,'broker.exe')); + if(operation==='tampered')await writeFile(path.join(root,'runtime','scanner.dll'),'replacement'); + if(operation==='extra')await writeFile(path.join(root,'runtime','injected.dll'),'injection'); + await assert.rejects(verifyBrokerPackage(root,manifest,release),{code:operation==='tampered'?'broker_package_integrity_mismatch':'broker_package_inventory_mismatch'}); +}); +test('runtime protocol and version must match integrity-protected expectations',async t=>{ + const {root,manifest}=await fixture(t); + for(const expected of [{version:'0.2.0',protocol:1},{version:'0.1.0',protocol:2}]) + await assert.rejects(verifyBrokerPackage(root,manifest,expected),{code:'broker_package_incompatible'}); +}); +test('rejects ambiguous paths and manifests before package traversal',async t=>{ + const {root,manifest}=await fixture(t); + for(const unsafe of ['../broker.exe','C:/broker.exe','runtime\\scanner.dll','CON.exe','broker.exe:stream','./broker.exe']){ + const changed=structuredClone(manifest);changed.files[0].path=unsafe; + await assert.rejects(verifyBrokerPackage(root,changed,release),{code:'broker_manifest_invalid'}); + } + const duplicate=structuredClone(manifest);duplicate.files.push({...duplicate.files[0],path:duplicate.files[0].path.toUpperCase()}); + await assert.rejects(verifyBrokerPackage(root,duplicate,release),{code:'broker_manifest_invalid'}); +}); +test('rejects absent entrypoint and malformed hashes',async t=>{ + const {root,manifest}=await fixture(t); + await assert.rejects(verifyBrokerPackage(root,{...manifest,entrypoint:'missing.exe'},release),{code:'broker_manifest_invalid'}); + const changed=structuredClone(manifest);changed.files[0].sha256='invalid'; + await assert.rejects(verifyBrokerPackage(root,changed,release),{code:'broker_manifest_invalid'}); +}); +test('directory junctions cannot redirect broker payload outside installation',async t=>{ + const {root,manifest}=await fixture(t); + const target=path.join(root,'runtime'),alias=path.join(root,'linked'); + await symlink(target,alias,process.platform==='win32'?'junction':'dir'); + await assert.rejects(verifyBrokerPackage(root,manifest,release),{code:'broker_path_unsafe'}); +}); +test('unavailable package errors contain no filesystem or raw exception content',async()=>{ + await assert.rejects(verifyBrokerPackage(path.join(os.tmpdir(),'absent-sensitive-company-path'),{...release,format:'safetynet-broker-package-v1',files:[{path:'broker.exe',bytes:0,sha256:'0'.repeat(64)}]},release),error=>error.code==='broker_package_unavailable'&&error.message==='broker_package_unavailable'); +}); + +test('same-size mutation during actual read rejects with bounded fixed invariant facts',async t=>{ + const {root,manifest}=await fixture(t),target=path.join(root,'broker.exe'); + const probe=await open(target,'r'),prototype=Object.getPrototypeOf(probe); + const original=prototype.createReadStream;await probe.close(); + let mutated=false; + t.mock.method(prototype,'createReadStream',function(options){ + const stream=original.call(this,options); + stream.once('end',()=>{ + if(mutated)return;mutated=true; + writeFileSync(target,'X'.repeat(manifest.files.find(file=>file.path==='broker.exe').bytes)); + const future=new Date(Date.now()+5000);utimesSync(target,future,future); + }); + return stream; + }); + await assert.rejects(verifyBrokerPackage(root,manifest,release),error=>{ + assert.equal(error.code,'broker_package_changed');assert.equal(error.message,error.code); + assert.ok(error.failureFacts.changed.includes('modification-time')); + assert.ok(error.failureFacts.changed.length<=5); + assert.ok(error.failureFacts.changed.every(value=>['size-before-read','size-after-read','modification-time','change-time','file-identity'].includes(value))); + assert.ok(Object.isFrozen(error.failureFacts)&&Object.isFrozen(error.failureFacts.changed)); + assert.ok(!JSON.stringify(error.failureFacts).includes(root));return true; + }); + assert.equal(mutated,true); +}); diff --git a/tests/broker-package-resources.test.mjs b/tests/broker-package-resources.test.mjs new file mode 100644 index 0000000..4e19a97 --- /dev/null +++ b/tests/broker-package-resources.test.mjs @@ -0,0 +1,72 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +import {mkdtemp,mkdir,writeFile,readFile,cp,rm} from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import {brokerResourceConfiguration,prepareBrokerResources,verifyPackagedBroker,embeddedBrokerManifestReader,snapshotScannerResources,verifyScannerResources} from '../scripts/package-broker-resources.mjs'; +import {buildBrokerPackageManifest} from '../scripts/broker-package-contract.mjs'; +import {BROKER_PAYLOAD} from '../scripts/stage-broker-payload.mjs'; +const require=createRequire(import.meta.url),asar=require('@electron/asar'); +const {getFileMatchers,copyFiles}=require('app-builder-lib/out/fileMatcher.js'); +const build={extraResources:[{from:'apps/inspector',to:'inspector'},{from:'.runtime/windows/broker-payload',to:'broker'}],files:['apps/browser/**/*',{from:'.runtime/windows/broker-payload.manifest.json',to:'apps/browser/broker-package-manifest.json'}]}; +async function fixture(t){const root=await mkdtemp(path.join(os.tmpdir(),'safetynet-package-resources-'));t.after(()=>rm(root,{recursive:true,force:true}));return root;} +test('declared single broker and protected ASAR manifest mapping is mandatory',()=>{ + const payloadRoot=path.resolve('.runtime/windows/broker-payload-fixture'),manifestPath=payloadRoot+'.manifest.json'; + const configured=brokerResourceConfiguration(build,{payloadRoot,manifestPath}); + assert.equal(configured.extraResources[1].from,payloadRoot);assert.equal(configured.files[1].from,manifestPath);assert.equal(configured.files[1].to,'apps/browser/broker-package-manifest.json'); + for(const bad of [{...build,extraResources:build.extraResources.slice(0,1)},{...build,extraResources:[...build.extraResources,build.extraResources[1]]},{...build,extraResources:[...build.extraResources,{from:'unexpected',to:'BROKER'}]},{...build,extraResources:[...build.extraResources,{from:'unexpected',to:'broker/injected.dll'}]},{...build,files:[...build.files,build.files[1]]},{...build,files:build.files.slice(0,1)}])assert.throws(()=>brokerResourceConfiguration(bad,{payloadRoot,manifestPath}),{code:'broker_package_configuration_missing'}); + assert.equal(build.extraResources[1].from,'.runtime/windows/broker-payload'); +}); +test('actual ASAR reader verifies embedded manifest and rejects tampered external broker bytes',async t=>{ + const root=await fixture(t),resources=path.join(root,'resources'),broker=path.join(resources,'broker'),app=path.join(root,'app'); + await mkdir(broker,{recursive:true});await mkdir(path.join(app,'apps/browser'),{recursive:true}); + await writeFile(path.join(broker,'node.exe'),'synthetic payload, not executable proof'); + const manifest=await buildBrokerPackageManifest(broker,BROKER_PAYLOAD); + const manifestPath=path.join(root,'broker.manifest.json');await writeFile(manifestPath,JSON.stringify(manifest)); + const configured=brokerResourceConfiguration(build,{payloadRoot:broker,manifestPath}); + const matchers=getFileMatchers(configured,'files',app,{defaultSrc:root,globalOutDir:path.join(root,'output'),macroExpander:value=>value,customBuildOptions:{}}); + const manifestMatcher=matchers.find(matcher=>matcher.from===manifestPath); + assert.ok(manifestMatcher);assert.equal(manifestMatcher.to,path.join(app,'apps/browser/broker-package-manifest.json')); + await copyFiles([manifestMatcher],null,false); + const archive=path.join(resources,'app.asar');await asar.createPackage(app,archive); + await verifyPackagedBroker({resources,expectedManifest:manifest,readEmbeddedManifest:embeddedBrokerManifestReader(archive)}); + await writeFile(path.join(broker,'node.exe'),'tampered'); + await assert.rejects(verifyPackagedBroker({resources,expectedManifest:manifest,readEmbeddedManifest:embeddedBrokerManifestReader(archive)}),{code:'broker_package_integrity_mismatch'}); +}); +test('adjacent modified metadata cannot replace expected protected manifest',async()=>{ + const expected={format:'reviewed'},changed={format:'attacker-adjacent'}; + await assert.rejects(verifyPackagedBroker({resources:path.resolve('resources'),expectedManifest:expected,readEmbeddedManifest:async()=>Buffer.from(JSON.stringify(changed))}),{code:'broker_package_manifest_mismatch'}); + await assert.rejects(verifyPackagedBroker({resources:path.resolve('resources'),expectedManifest:expected}),{code:'broker_package_manifest_reader_required'}); +}); +test('scanner resource inventory verifies copied bytes and rejects missing altered and extra files',async t=>{ + const root=await fixture(t),resources=path.join(root,'output'); + for(const directory of ['apps/inspector','.runtime/windows/python','.runtime/windows/semantic']){await mkdir(path.join(root,directory),{recursive:true});await writeFile(path.join(root,directory,'payload.dat'),'synthetic '+directory);} + await mkdir(path.join(root,'apps/inspector/__pycache__'));await writeFile(path.join(root,'apps/inspector/__pycache__/ignored.pyc'),'not shipped'); + const expected=await snapshotScannerResources(root);assert.equal(expected.length,3); + for(const [from,to] of [['apps/inspector','inspector'],['.runtime/windows/python','python'],['.runtime/windows/semantic','semantic']]){await mkdir(path.join(resources,to),{recursive:true});await cp(path.join(root,from,'payload.dat'),path.join(resources,to,'payload.dat'));} + await verifyScannerResources(resources,expected); + await writeFile(path.join(resources,'python/payload.dat'),'changed');await assert.rejects(verifyScannerResources(resources,expected),{code:'scanner_package_integrity_mismatch'}); + await cp(path.join(root,'.runtime/windows/python/payload.dat'),path.join(resources,'python/payload.dat')); + await writeFile(path.join(resources,'inspector/injected.dll'),'extra');await assert.rejects(verifyScannerResources(resources,expected),{code:'scanner_package_integrity_mismatch'}); + await rm(path.join(resources,'inspector/injected.dll'));await rm(path.join(resources,'semantic/payload.dat'));await assert.rejects(verifyScannerResources(resources,expected),{code:'scanner_package_integrity_mismatch'}); + assert.equal((await readFile(path.join(root,'apps/inspector/payload.dat'))).toString(),'synthetic apps/inspector'); +}); +test('real prepared standalone payload keeps source identities pinned through subsequent packaging',{ + skip:process.platform!=='win32'||process.env.SAFETYNET_BROKER_PACKAGE_PREPARE!=='1',timeout:60000 +},async t=>{ + const root=await fixture(t),repository=path.resolve('.'); + // Explicit integration gate copies reviewed real sources and the pinned + // official runtime. Zod is fetched and integrity checked against the lock. + for(const directory of ['apps/network-broker','packages/core'])await cp(path.join(repository,directory),path.join(root,directory),{recursive:true}); + await mkdir(path.join(root,'apps/browser'),{recursive:true}); + for(const name of ['policy-assignment.cjs','enterprise-inspection.cjs','icap-client.cjs','inspect-text-representations.cjs','header-inspection.cjs','inspection.cjs','loopback-http.cjs'])await cp(path.join(repository,'apps/browser',name),path.join(root,'apps/browser',name)); + await cp(path.join(repository,'package-lock.json'),path.join(root,'package-lock.json')); + await cp(path.join(repository,'.runtime/windows/broker'),path.join(root,'.runtime/windows/broker'),{recursive:true}); + const prepared=await prepareBrokerResources({root,build}); + assert.ok(prepared.manifest.files.some(file=>file.path==='apps/network-broker/audit-gate.cjs')); + assert.ok(prepared.manifest.files.some(file=>file.path==='node_modules/zod/LICENSE')); + assert.ok(prepared.sourceSnapshot.length>20);await prepared.verifySources(); + const changed=path.join(root,'apps/network-broker/policy-authority.cjs');await writeFile(changed,(await readFile(changed))+'\n// synthetic post-stage change\n'); + await assert.rejects(prepared.verifySources(),{code:'broker_stage_source_changed'}); +}); diff --git a/tests/broker-payload-staging.test.mjs b/tests/broker-payload-staging.test.mjs new file mode 100644 index 0000000..215c73f --- /dev/null +++ b/tests/broker-payload-staging.test.mjs @@ -0,0 +1,37 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtemp,writeFile,readFile,cp,rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import {createHash} from 'node:crypto'; +import {gzipSync} from 'node:zlib'; +import {spawn} from 'node:child_process'; +import {fileURLToPath} from 'node:url'; +import {BROKER_PAYLOAD,extractVerifiedZod,snapshotSources,verifySourceSnapshot,stageBrokerPayload} from '../scripts/stage-broker-payload.mjs'; +import {verifyBrokerPackage} from '../scripts/broker-package-contract.mjs'; +import {BROKER_RUNTIME} from '../scripts/provision-broker-runtime.mjs'; +const repo=fileURLToPath(new URL('../',import.meta.url)); +// Explicit native proof uses a prepared local stage. Production expected +// state comes from the protected ASAR, never this adjacent test manifest. +const nativeStage=process.env.SAFETYNET_TEST_BROKER_ROOT; +async function currentStage(){ + assert.equal(typeof nativeStage,'string','explicit native stage required'); + const root=path.resolve(nativeStage),parent=path.resolve(repo,'.runtime/windows'); + assert.equal(path.dirname(root),parent,'stage must stay in the reviewed runtime directory'); + assert.match(path.basename(root),/^broker-payload-package-[a-z0-9-]+$/,'a unique current stage is required'); + const manifest=JSON.parse(await readFile(root+'.manifest.json','utf8')); + await verifyBrokerPackage(root,manifest,BROKER_PAYLOAD); + const executable=manifest.files.find(file=>file.path==='node.exe'); + assert.equal(executable?.sha256,BROKER_RUNTIME.executableSha256,'exact official runtime pin required'); + return {root,manifest}; +} +async function temporary(fn){const root=await mkdtemp(path.join(tmpdir(),'safetynet-broker-stage-test-'));try{return await fn(root);}finally{const absolute=path.resolve(root);assert.equal(path.dirname(absolute),path.resolve(tmpdir()));assert.ok(path.basename(absolute).startsWith('safetynet-broker-stage-test-'));await rm(absolute,{recursive:true,force:true});}} +function tar(entries){const chunks=[];for(const [name,content,type='0'] of entries){const data=Buffer.from(content),header=Buffer.alloc(512);header.write(name);header.write('0000644\0',100);header.write('0000000\0',108);header.write('0000000\0',116);header.write(data.length.toString(8).padStart(11,'0')+'\0',124);header.fill(32,148,156);header.write(type,156);header.write('ustar\0',257);header.write([...header].reduce((a,b)=>a+b,0).toString(8).padStart(6,'0')+'\0 ',148);chunks.push(header,data,Buffer.alloc((512-data.length%512)%512));}chunks.push(Buffer.alloc(1024));return gzipSync(Buffer.concat(chunks));} +function lock(bytes){return {version:'4.6.7',resolved:'https://registry.npmjs.org/zod/-/zod-4.6.7.tgz',integrity:'sha512-'+createHash('sha512').update(bytes).digest('base64')};} +const minimum=[['package/package.json',JSON.stringify({name:'zod',version:'4.6.7'})],['package/LICENSE','MIT synthetic fixture'],['package/index.js','export const z=1;']]; +test('verified dependency archive copies runtime and license, excludes development and secrets',()=>{const bytes=tar([...minimum,['package/src/dev.ts','dev'],['package/test/fixture.js','dev'],['package/.env','SECRET'],['package/key.pem','KEY']]);const files=extractVerifiedZod(bytes,lock(bytes));assert.deepEqual(files.map(f=>f.path),['node_modules/zod/package.json','node_modules/zod/LICENSE','node_modules/zod/index.js']);}); +test('archive rejects tampering, traversal, symlinks, duplicate-case paths and ADS before staging',()=>{const valid=tar(minimum);assert.throws(()=>extractVerifiedZod(Buffer.concat([valid,Buffer.from('tamper')]),lock(valid)),/integrity/);for(const entry of [['package/../../escape.js','x'],['package/link.js','x','2'],['package/INDEX.js','x'],['package/a.js:stream.js','x'],['package/CON.js','x']]){const bytes=tar([...minimum,entry]);assert.throws(()=>extractVerifiedZod(bytes,lock(bytes)),/archive/);}}); +test('source snapshot detects changes, including a same-size mutation',()=>temporary(async root=>{await writeFile(path.join(root,'source.cjs'),'AAA');const snapshot=await snapshotSources(root,['source.cjs']);await writeFile(path.join(root,'source.cjs'),'BBB');await assert.rejects(verifySourceSnapshot(root,snapshot),/source_changed/);await assert.rejects(snapshotSources(root,['../outside']),/path_unsafe/);})); +test('stager refuses destination outside reviewed ignored root before copying',async()=>{await assert.rejects(stageBrokerPayload({root:repo,destination:path.join(repo,'dist','broker')}),/destination_unsafe/);}); +test('actual staged package excludes activation scripts and has complete trusted inventory',{skip:!nativeStage?'requires explicit SAFETYNET_TEST_BROKER_ROOT native stage':false},async()=>{const {root,manifest}=await currentStage();assert.ok(root);for(const file of manifest.files){assert.ok(!/(^|\/)(tests?|artifacts?|models?|__pycache__)(\/|$)|\.env$|\.(?:pem|py|pyc|exe)$/.test(file.path)||file.path==='node.exe');assert.ok(!file.path.startsWith('apps/browser/')||['policy-assignment.cjs','enterprise-inspection.cjs','icap-client.cjs','inspect-text-representations.cjs','header-inspection.cjs','inspection.cjs','loopback-http.cjs'].includes(file.path.slice(13)));}assert.ok(manifest.files.some(f=>f.path==='node_modules/zod/LICENSE'));}); +test('bundled official node loads real authority/policy outside repository with empty dependency environment',{skip:!nativeStage?'requires explicit SAFETYNET_TEST_BROKER_ROOT native stage':false},()=>temporary(async root=>{const {root:staged,manifest}=await currentStage();await cp(staged,root,{recursive:true});const script=`import {createRequire} from 'node:module';import {generateKeyPairSync} from 'node:crypto';const require=createRequire(process.cwd()+'/probe.cjs');const {createPolicyAuthority}=require('./apps/network-broker/policy-authority.cjs');const {defaultPolicy}=await import('./packages/core/policy.mjs');const service=require('./apps/network-broker/service-entry.cjs');const store=require('./apps/network-broker/service-store-client.cjs');const trust=require('./packages/core/windows-trust.cjs');const management=require('./apps/network-broker/management-identity-operation.cjs');const topology=require('./apps/network-broker/wfp-topology.cjs');const revalidation=require('./apps/network-broker/wfp-revalidation.cjs');const keys=generateKeyPairSync('ed25519');const authority=await createPolicyAuthority({publicKey:keys.publicKey.export({type:'spki',format:'pem'}),deviceId:'synthetic-staged-proof'});console.log(JSON.stringify({node:process.version,policyVersion:defaultPolicy().version,authorityActive:authority.status().active,dependency:require.resolve('zod'),nodePathPresent:!!process.env.NODE_PATH,serviceExportsLoaded:typeof service.runServiceProcess==='function'&&typeof store.createServiceStoreClient==='function'&&typeof trust.verifyDeploymentEnvelope==='function'&&typeof management.createManagementIdentityOperation==='function'&&typeof topology.validateTopology==='function'&&typeof revalidation.createWfpRevalidation==='function'}));`;const env={};for(const key of ['SystemRoot','WINDIR','TEMP','TMP'])if(process.env[key])env[key]=process.env[key];const output=await new Promise((resolve,reject)=>{const child=spawn(path.join(root,'node.exe'),['--input-type=module','-e',script],{cwd:root,env,windowsHide:true,timeout:15000});let stdout='',stderr='';child.stdout.on('data',b=>stdout+=b);child.stderr.on('data',b=>stderr+=b);child.on('error',reject);child.on('close',code=>code===0?resolve(stdout):reject(Error('bundled probe failed '+code+' '+stderr)));});const proof=JSON.parse(output);assert.equal(proof.node,'v'+BROKER_RUNTIME.version);assert.equal(proof.policyVersion,1);assert.equal(proof.authorityActive,false);assert.equal(proof.nodePathPresent,false);assert.equal(proof.serviceExportsLoaded,true);assert.ok(path.resolve(proof.dependency).startsWith(path.join(root,'node_modules','zod')+path.sep));await writeFile(path.join(root,'apps/network-broker/policy-authority.cjs'),'TAMPER');await assert.rejects(verifyBrokerPackage(root,manifest,BROKER_PAYLOAD),/integrity_mismatch/);})); diff --git a/tests/broker-policy-authority.test.mjs b/tests/broker-policy-authority.test.mjs new file mode 100644 index 0000000..1d4c74d --- /dev/null +++ b/tests/broker-policy-authority.test.mjs @@ -0,0 +1,53 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {generateKeyPairSync,sign} from 'node:crypto'; +import {createRequire} from 'node:module'; +import {PassThrough} from 'node:stream'; +import {defaultPolicy,signPolicy,verifyPolicy} from '../packages/core/policy.mjs'; +const {createPolicyAuthority}=createRequire(import.meta.url)('../apps/network-broker/policy-authority.cjs'); +const {gateRequest}=createRequire(import.meta.url)('../apps/network-broker/http-request-gate.cjs'); +const keys=generateKeyPairSync('ed25519'),publicKey=keys.publicKey.export({type:'spki',format:'pem'}),privateKey=keys.privateKey.export({type:'pkcs8',format:'pem'}); +const policy=(version=1)=>({...defaultPolicy(),version,assignment:{deviceId:'device-1',groups:[]}}); +const create=()=>createPolicyAuthority({publicKey,deviceId:'device-1',minimumVersion:1}); +test('actual core signature/schema verification produces deeply immutable device authority',async()=>{ + const authority=await create(),envelope=signPolicy(policy(),privateKey),snapshot=authority.accept(envelope); + assert.deepEqual(snapshot.policy,verifyPolicy(envelope,publicKey,1));assert.equal(authority.current(snapshot.token),true); + assert.ok(Object.isFrozen(snapshot.policy.dlp.rules));assert.throws(()=>snapshot.policy.browser.allowedHosts.push('evil.example')); + envelope.payload='mutated';assert.equal(authority.current(snapshot.token),true); +}); +test('expired, wrong-device, modified, malformed schema and rollback invalidate old authority',async()=>{ + for(const invalid of ['expired','wrong-device','signature','schema','rollback']) { + const authority=await create(),valid=authority.accept(signPolicy(policy(2),privateKey));let envelope; + if(invalid==='expired'){const p=policy(3);p.expiresAt=new Date(0).toISOString();envelope=signPolicy(p,privateKey);} + if(invalid==='wrong-device'){const p=policy(3);p.assignment.deviceId='device-2';envelope=signPolicy(p,privateKey);} + if(invalid==='signature'){envelope=signPolicy(policy(3),privateKey);envelope.signature='AAAA';} + if(invalid==='schema'){const p=policy(3);p.dlp.maxBytes=0;const payload=Buffer.from(JSON.stringify(p)).toString('base64url');envelope={payload,signature:sign(null,Buffer.from(payload),keys.privateKey).toString('base64url')};} + if(invalid==='rollback')envelope=signPolicy(policy(1),privateKey); + assert.throws(()=>authority.accept(envelope),{code:'policy-rejected'});assert.equal(authority.current(valid.token),false);assert.equal(authority.snapshot(),null);assert.equal(authority.status().minimumVersion,2); + } +}); +test('same-version changed policy rejected and revocation preserves rollback floor',async()=>{ + const authority=await create();const p=policy(2),first=authority.accept(signPolicy(p,privateKey)); + p.browser.blockedHosts=['example.com'];assert.throws(()=>authority.accept(signPolicy(p,privateKey)),{code:'policy-rejected'});assert.equal(authority.current(first.token),false); + authority.revoke();assert.throws(()=>authority.accept(signPolicy(policy(1),privateKey)),{code:'policy-rejected'}); +}); +test('policy update during asynchronous scan prevents release using old token',async()=>{ + const authority=await create(),old=authority.accept(signPolicy(policy(),privateKey));let release,entered,dials=0; + const scan=new Promise(r=>release=r),started=new Promise(r=>entered=r); + const request=new PassThrough();Object.assign(request,{rawHeaders:['Host','example.com','Content-Length','6'],url:'http://example.com/',method:'POST',complete:true}); + const result=gateRequest(request,{authority:old.token,current:authority.current,inspect:async()=>{entered();await scan;return {decision:'allow'};},forward:()=>{dials++;}}); + const denied=assert.rejects(result,{code:'authority-expired'});request.end('public');await started; + const next=authority.accept(signPolicy(policy(2),privateKey));release();await denied;assert.equal(dials,0);assert.equal(authority.current(next.token),true);assert.ok(next.generation>old.generation); + authority.revoke();assert.equal(authority.current(next.token),false); +}); +test('trusted bootstrap pins key and version; invalid bootstrap rejected',async()=>{ + for(const changed of [{publicKey:'invalid'},{deviceId:'bad/id'},{minimumVersion:-1},{minimumVersion:1.5}])await assert.rejects(createPolicyAuthority({publicKey,deviceId:'device-1',minimumVersion:1,...changed}),{code:'policy-bootstrap-invalid'}); + const authority=await createPolicyAuthority({publicKey,deviceId:'device-1',minimumVersion:5});assert.throws(()=>authority.accept(signPolicy(policy(4),privateKey)),{code:'policy-rejected'}); +}); +test('current authority expires without a policy refresh and rejects a different signing key',async()=>{ + const authority=await create(),p=policy();p.expiresAt=new Date(Date.now()+150).toISOString(); + const active=authority.accept(signPolicy(p,privateKey));assert.equal(authority.current(active.token),true); + await new Promise(resolve=>setTimeout(resolve,170));assert.equal(authority.current(active.token),false);assert.equal(authority.snapshot(),null); + const wrong=generateKeyPairSync('ed25519').privateKey.export({type:'pkcs8',format:'pem'}); + assert.throws(()=>authority.accept(signPolicy(policy(2),wrong)),{code:'policy-rejected'}); +}); diff --git a/tests/broker-process.test.mjs b/tests/broker-process.test.mjs new file mode 100644 index 0000000..a9b892a --- /dev/null +++ b/tests/broker-process.test.mjs @@ -0,0 +1,55 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {fork} from 'node:child_process'; +import {once} from 'node:events'; +import fs from 'node:fs'; +import https from 'node:https'; +import http from 'node:http'; +import tls from 'node:tls'; +import net from 'node:net'; +import {fileURLToPath} from 'node:url'; +const cert=fs.readFileSync(new URL('./fixtures/icap/cert.pem',import.meta.url)),key=fs.readFileSync(new URL('./fixtures/icap/key.pem',import.meta.url)); +const brokerNode=fileURLToPath(new URL('../.runtime/windows/broker/node.exe',import.meta.url)); +function child(t) { + assert.equal(fs.existsSync(brokerNode),true,'Verified bundled broker Node prerequisite'); + const process=fork(fileURLToPath(new URL('./fixtures/broker-process-child.cjs',import.meta.url)),[],{execPath:brokerNode,execArgv:[],stdio:['ignore','ignore','ignore','ipc'],env:{SystemRoot:globalThis.process.env.SystemRoot}}); + t.after(()=>{if(process.exitCode===null)process.kill();});return process; +} +function bounded(event,ms=3000){let timer;return Promise.race([event,new Promise((_,reject)=>timer=setTimeout(()=>reject(Error('test-deadline')),ms))]).finally(()=>clearTimeout(timer));} +async function request(ready,destination,body) { + return await new Promise((resolve,reject)=>{ + const req=http.request({host:'127.0.0.1',port:ready.port,method:'CONNECT',path:destination,headers:{host:destination,'proxy-authorization':`Bearer ${ready.capability}`}}); + req.setTimeout(2000,()=>req.destroy(Error('test-deadline')));req.on('error',reject); + req.on('connect',(res,socket)=>{ + if(res.statusCode!==200){socket.destroy();reject(Error('connect-rejected'));return;} + const secure=tls.connect({socket,servername:'localhost',ca:cert,rejectUnauthorized:true});let text=''; + secure.setTimeout(2000,()=>secure.destroy(Error('test-deadline')));secure.on('error',reject);secure.on('secureConnect',()=>secure.write(`POST /submit HTTP/1.1\r\nHost: ${destination}\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n${body}`));secure.on('data',chunk=>text+=chunk);secure.on('end',()=>{secure.destroy();resolve(text);}); + });req.end(); + }); +} +test('separate Node child handles inspected CONNECT and closes on private IPC disconnect',async t=>{ + const received=[];let connections=0; + const target=https.createServer({key,cert},async(req,res)=>{const chunks=[];for await(const chunk of req)chunks.push(chunk);received.push(Buffer.concat(chunks).toString());res.writeHead(201);res.end('accepted');}); + target.on('connection',()=>connections++);target.listen(0,'127.0.0.1');await once(target,'listening'); + t.after(async()=>{target.closeAllConnections();await new Promise(r=>target.close(r));}); + const destination=`localhost:${target.address().port}`,worker=child(t); + const readyEvent=bounded(once(worker,'message'));worker.send({type:'start',config:{destination}}); + const [ready]=await readyEvent;assert.equal(ready.type,'ready');assert.notEqual(worker.pid,process.pid);assert.match(ready.capability,/^[a-zA-Z0-9_-]{43}$/); + assert.match(await request(ready,destination,'SECRET'),/^HTTP\/1.1 403/);assert.equal(connections,0); + assert.match(await request(ready,destination,'public'),/^HTTP\/1.1 201/);assert.deepEqual(received,['public']); + const exit=bounded(once(worker,'exit'));worker.disconnect();await exit; + const failure=await new Promise(resolve=>{const socket=net.connect(ready.port,'127.0.0.1');socket.setTimeout(1000,()=>{socket.destroy();resolve('timeout');});socket.on('connect',()=>{socket.destroy();resolve('connected');});socket.on('error',error=>resolve(error.code));}); + assert.equal(failure,'ECONNREFUSED'); +}); +test('invalid and oversized private bootstrap reject without spawning another process',async t=>{ + for(const message of [{type:'command',command:'arbitrary'},{type:'start',config:{destination:'x'.repeat(70000)}}]) { + const worker=child(t),reply=bounded(once(worker,'message')),exit=bounded(once(worker,'exit')); + worker.send(message);assert.deepEqual((await reply)[0],{type:'error',code:'invalid-message'});await exit; + } +}); +test('explicit private stop closes the sole child without restart',async t=>{ + const worker=child(t),ready=bounded(once(worker,'message')); + worker.send({type:'start',config:{destination:'localhost:443'}});assert.equal((await ready)[0].type,'ready'); + const exit=bounded(once(worker,'exit'));worker.send({type:'stop'});await exit; + assert.equal(worker.exitCode,0); +}); diff --git a/tests/broker-production-entry.test.mjs b/tests/broker-production-entry.test.mjs new file mode 100644 index 0000000..80ca855 --- /dev/null +++ b/tests/broker-production-entry.test.mjs @@ -0,0 +1,97 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {EventEmitter} from 'node:events'; +import {createRequire} from 'node:module'; +import {randomUUID,generateKeyPairSync} from 'node:crypto'; +import {defaultPolicy,signPolicy} from '../packages/core/policy.mjs'; +const require=createRequire(import.meta.url),{runProductionProcess}=require('../apps/network-broker/production-entry.cjs'),{createProductionBroker}=require('../apps/network-broker/production-factory.cjs'),{createBrokerAudit}=require('../apps/network-broker/audit-gate.cjs'); +class Channel extends EventEmitter{connected=true;sent=[];send(value,callback){this.sent.push(value);callback?.();this.emit('sent',value);}disconnect(){this.connected=false;this.emit('disconnect');}} +const tick=()=>new Promise(resolve=>setImmediate(resolve)); +const start=()=>({type:'start',version:1,generation:randomUUID(),policyVersion:1,config:{brokerPort:43100}}); + +test('close retains late original factory ownership and cleans once before disconnect',async()=>{ + const channel=new Channel();let releaseFactory,releaseCleanup,closeCalls=0,listens=0; + const created={close:()=>{closeCalls++;return new Promise(resolve=>{releaseCleanup=resolve;});},listen:()=>{listens++;return 43100;}}; + const handle=runProductionProcess(()=>new Promise(resolve=>{releaseFactory=resolve;}),channel); + channel.emit('message',start());await tick();const closing=handle.close();assert.equal(handle.close(),closing); + await tick();assert.equal(channel.connected,true);releaseFactory(created);await tick(); + assert.equal(closeCalls,1);assert.equal(listens,0);assert.equal(channel.connected,true); + releaseCleanup();await closing;assert.equal(channel.connected,false);assert.equal(channel.sent.length,0); +}); +test('real cleanup failure is sticky and cannot be reported as successful close',async()=>{ + const channel=new Channel();let calls=0; + const handle=runProductionProcess(async()=>({authMode:'basic',credentials:{username:'safetynet-'+'a'.repeat(32),password:'b'.repeat(43)},listen:async()=>43100,close:async()=>{calls++;throw Error('fixed-cleanup-failure');}}),channel); + channel.emit('message',start());await tick();const closing=handle.close();await assert.rejects(closing,/fixed-cleanup-failure/); + assert.equal(handle.close(),closing);await assert.rejects(handle.close(),/fixed-cleanup-failure/);assert.equal(calls,1);assert.equal(channel.connected,false); +}); +test('close waits original listen settlement and never sends late readiness',async()=>{ + const channel=new Channel();let releaseListen,closed=0,finished=false; + const handle=runProductionProcess(async()=>({listen:()=>new Promise(resolve=>{releaseListen=resolve;}),close:async()=>closed++}),channel); + channel.emit('message',start());await tick();const closing=handle.close().then(()=>{finished=true;});await tick(); + assert.equal(closed,1);assert.equal(finished,false);assert.equal(channel.connected,true); + releaseListen(43100);await closing;assert.equal(channel.connected,false);assert.equal(channel.sent.length,0); +}); +test('private v1 IPC requires matching durable receipt before factory continues',async()=>{ + const channel=new Channel(),message=start();let callbacks,closed=0; + runProductionProcess(async(_config,value)=>{callbacks=value;return {authMode:'basic',credentials:{username:'safetynet-'+'a'.repeat(32),password:'b'.repeat(43)},listen:async()=>43100,close:async()=>closed++};},channel); + channel.emit('message',message);await tick();assert.equal(channel.sent[0].type,'ready'); + const event={eventId:randomUUID(),kind:'upload.authorized',ruleId:'broker-approved',policyVersion:1,request:{bytes:3,detectedType:'text'}}; + let completed=false;const pending=callbacks.persistAudit(event).then(()=>completed=true);await tick();assert.equal(completed,false);const request=channel.sent[1];assert.equal(request.type,'audit'); + channel.emit('message',{type:'audit-receipt',version:1,generation:message.generation,policyVersion:1,requestId:request.requestId,sequence:request.sequence,receipt:{persisted:true,eventId:event.eventId}});await pending; + channel.emit('message',{type:'stop',version:1,generation:message.generation,policyVersion:1});await tick();assert.equal(closed,1); +}); +test('wrong generation or uncertain receipt closes child with no automatic retry',async()=>{ + const channel=new Channel(),message=start();let callbacks; + runProductionProcess(async(_config,value)=>{callbacks=value;return {authMode:'basic',credentials:{username:'safetynet-'+'a'.repeat(32),password:'b'.repeat(43)},listen:async()=>43100,close:async()=>{}};},channel); + channel.emit('message',message);await tick();const pending=callbacks.acknowledgeCertificate({authority:'example.com:443',cert:'public certificate'});const rejected=assert.rejects(pending,/broker-parent-unavailable/);const request=channel.sent[1]; + channel.emit('message',{type:'certificate-receipt',version:1,generation:randomUUID(),policyVersion:1,requestId:request.requestId,sequence:request.sequence,accepted:true});await rejected;await tick();assert.equal(channel.connected,false);assert.equal(channel.sent.length,2); +}); +test('real factory rejects untrusted paths and unsupported signed proxy before inspector launch',async()=>{ + const keys=generateKeyPairSync('ed25519'),p=defaultPolicy();p.assignment={deviceId:'device',groups:[]}; + const config={publicKey:keys.publicKey.export({type:'spki',format:'pem'}),deviceId:'device',minimumVersion:1,envelope:signPolicy(p,keys.privateKey.export({type:'pkcs8',format:'pem'})),brokerPort:43100,network:{mode:'direct',dns:'system'}}; + const callbacks={persistAudit:async()=>{},acknowledgeCertificate:async()=>true,expectedPolicyVersion:1}; + await assert.rejects(createProductionBroker({...config,resourceRoot:'C:/arbitrary'},callbacks),/broker-bootstrap-invalid/); + await assert.rejects(createProductionBroker(config,callbacks),/broker-route-unsupported/); + await assert.rejects(createProductionBroker({...config,network:{mode:'system',dns:'system'}},callbacks),/broker-bootstrap-invalid/); +}); +test('audit callback cannot mutate frozen request causation',async()=>{ + const eventId=randomUUID();let event; + const audit=createBrokerAudit({current:()=>true,policyVersion:1,persist:async value=>{event=value;assert.throws(()=>value.broker.requestId=eventId,TypeError);return {persisted:true,eventId:value.eventId};}}); + const requestId=randomUUID();await audit.record('allow',{bytes:3,detectedType:'text',broker:{requestId,phase:'request.authorized',reason:'broker-approved'}});assert.equal(event.broker.requestId,requestId); +}); + +test('protected deployment CA bundle rejects noncertificate payload and preserves default roots',()=>{ + const {deploymentRoots}=require('../apps/network-broker/production-factory.cjs'),fs=require('node:fs'),tls=require('node:tls');const cert=fs.readFileSync(new URL('./fixtures/icap/cert.pem',import.meta.url),'utf8'); + assert.equal(deploymentRoots(undefined),undefined);assert.equal(deploymentRoots(cert).length,tls.rootCertificates.length+1); + for(const value of [cert+'\nPRIVATE PAYLOAD',cert+'\n-----BEGIN PRIVATE KEY-----\nsecret\n-----END PRIVATE KEY-----',Array(33).fill(cert).join('\n'),'-----BEGIN CERTIFICATE-----\nbroken\n-----END CERTIFICATE-----'])assert.throws(()=>deploymentRoots(value),/broker-bootstrap-invalid/); +}); + +test('startup diagnosis is one bounded fixed stage without original exception detail',async()=>{ + const channel=new Channel();runProductionProcess(async()=>{throw Error('broker-startup-transport');},channel);channel.emit('message',start());await tick();assert.equal(channel.sent.length,1);assert.equal(channel.sent[0].type,'startup-fault');assert.equal(channel.sent[0].code,'broker-startup-transport');assert.equal(channel.connected,false); + const unsafe=new Channel();runProductionProcess(async()=>{throw Error('private certificate secret');},unsafe);unsafe.emit('message',start());await tick();assert.equal(unsafe.sent[0].code,'broker-startup-failed');assert.equal(JSON.stringify(unsafe.sent).includes('private certificate'),false); +}); + +test('additive deployment roots satisfy actual strict HTTPS transport construction',()=>{ + const {deploymentRoots}=require('../apps/network-broker/production-factory.cjs'),{createHttpsForwarder}=require('../apps/network-broker/https-forwarder.cjs'),fs=require('node:fs'); + const roots=deploymentRoots(fs.readFileSync(new URL('./fixtures/icap/cert.pem',import.meta.url),'utf8')); + assert.equal(typeof createHttpsForwarder({authority:'current',current:()=>true,resolveRoute:async()=>({approved:true,address:'127.0.0.1',port:443}),ca:roots.join('\n')}),'function'); +}); + +test('signed proxy and protected network mode cannot silently diverge or bypass',async()=>{ + const keys=generateKeyPairSync('ed25519'),p=defaultPolicy();p.assignment={deviceId:'device',groups:[]};p.proxy={mode:'fixed_servers',rules:'http://proxy.example',bypass:'*'}; + const config={publicKey:keys.publicKey.export({type:'spki',format:'pem'}),deviceId:'device',minimumVersion:1,envelope:signPolicy(p,keys.privateKey.export({type:'pkcs8',format:'pem'})),brokerPort:43100,network:{mode:'fixed_servers',dns:'system',credentials:{username:'synthetic',password:'synthetic-password'}}},callbacks={persistAudit:async()=>{},acknowledgeCertificate:async()=>true,expectedPolicyVersion:1}; + await assert.rejects(createProductionBroker(config,callbacks),/broker-route-unsupported/);await assert.rejects(createProductionBroker({...config,network:{mode:'direct',dns:'system'}},callbacks),/broker-route-unsupported/); + await assert.rejects(createProductionBroker({...config,network:{...config.network,credentials:{username:'bad:username',password:'synthetic'}}},callbacks),/broker-bootstrap-invalid/); +}); + +test('protected additive corporate CA bundles satisfy both strict transport constructors',()=>{ + const {deploymentRoots}=require('../apps/network-broker/production-factory.cjs'),{createProxyForwarder}=require('../apps/network-broker/proxy-forwarder.cjs'),fs=require('node:fs');const ca=deploymentRoots(fs.readFileSync(new URL('./fixtures/icap/cert.pem',import.meta.url),'utf8')).join('\n'); + assert.equal(typeof createProxyForwarder({authority:'current',current:()=>true,proxyPolicy:{mode:'fixed_servers',rules:'https://proxy.example:443',bypass:''},resolveProxyRoute:async()=>({approved:true,address:'127.0.0.1',port:443}),ca,proxyCa:ca}),'function'); +}); +test('unsupported secure DNS fails before inspector startup',async()=>{ + const keys=generateKeyPairSync('ed25519'); + for(const proxy of [false]){const p=defaultPolicy();p.assignment={deviceId:'device',groups:[]};p.enterprise.dns={mode:'secure',templates:['https://resolver.example/dns-query']}; + const config={publicKey:keys.publicKey.export({type:'spki',format:'pem'}),deviceId:'device',minimumVersion:1,envelope:signPolicy(p,keys.privateKey.export({type:'pkcs8',format:'pem'})),brokerPort:43100,network:{mode:proxy?'fixed_servers':'direct',dns:'system'}}; + await assert.rejects(createProductionBroker(config,{persistAudit:async()=>{},acknowledgeCertificate:async()=>true,expectedPolicyVersion:1}),/broker-route-unsupported/); + } +}); diff --git a/tests/broker-production-native.test.mjs b/tests/broker-production-native.test.mjs new file mode 100644 index 0000000..1bc1464 --- /dev/null +++ b/tests/broker-production-native.test.mjs @@ -0,0 +1,59 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import net from 'node:net'; +import http from 'node:http'; +import https from 'node:https'; +import tls from 'node:tls'; +import {once} from 'node:events'; +import {generateKeyPairSync,createHash} from 'node:crypto'; +import {createRequire} from 'node:module'; +import {defaultPolicy,signPolicy} from '../packages/core/policy.mjs'; +import {verifyBrokerPackage} from '../scripts/broker-package-contract.mjs'; +const require=createRequire(import.meta.url),{createBrokerSupervisor}=require('../apps/browser/broker-supervisor.cjs'); +const {createBrokerCertificatePins}=require('../apps/browser/broker-certificate-pins.cjs'); +const {createFixtureDiagnostics}=require('./fixtures/broker-native-diagnostics.cjs'); +const {createSyntheticAudit}=require('./fixtures/broker-durable-audit.cjs'); +function pdf(text){ + const stream=`BT /F1 12 Tf 72 720 Td <${Buffer.from(text).toString('hex')}> Tj ET`; + const objects=['<< /Type /Catalog /Pages 2 0 R >>','<< /Type /Pages /Kids [3 0 R] /Count 1 >>','<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>','<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',`<< /Length ${Buffer.byteLength(stream)} >>\nstream\n${stream}\nendstream`]; + let output='%PDF-1.4\n';const offsets=[0];objects.forEach((object,index)=>{offsets.push(Buffer.byteLength(output));output+=`${index+1} 0 obj\n${object}\nendobj\n`;}); + const xref=Buffer.byteLength(output);output+=`xref\n0 6\n0000000000 65535 f \n${offsets.slice(1).map(offset=>String(offset).padStart(10,'0')+' 00000 n \n').join('')}trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;return Buffer.from(output); +} +test('real verified production child private IPC delivers public bytes and blocks signed secret before TCP',{skip:process.env.SAFETYNET_NATIVE_PRODUCTION_BROKER!=='1'},async t=>{ + const root=path.resolve(process.env.SAFETYNET_TEST_BROKER_ROOT||'.runtime/windows/broker-payload'); + const manifest=JSON.parse(fs.readFileSync(root+'.manifest.json','utf8')); + const cert=fs.readFileSync(new URL('./fixtures/icap/cert.pem',import.meta.url)),key=fs.readFileSync(new URL('./fixtures/icap/key.pem',import.meta.url)); + const proxyMode=process.env.SAFETYNET_NATIVE_PRODUCTION_PROXY==='1',png=Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAfElEQVR4nNXOQREAIADDsFL/nocIHlyjIGcbZRIncRIncRIncRIncRIncRIncRIncRIncRIncRIncRIncRIncRIncRIncRIncRIncRIncRIncRIncRIncRIncRIncRIncRIncRIncRIncRIncRIncRIncRIncRIncRIncRIncRIncRIncRIncRIncRIncRIncRIncRIncf4OvLpyqgN9ZSiDcwAAAABJRU5ErkJggg==','base64'); + const received=[],clientResponses=[],clientBodies=[];const proxyTargets=[];let proxyConnects=0;const proxySockets=new Set();let connections=0,quarantines=0,pins;const leaves=new Map(); + const receiver=https.createServer({key,cert},async(req,res)=>{const chunks=[];for await(const chunk of req)chunks.push(chunk);assert.equal(req.socket.servername,'localhost');assert.equal(req.headers['proxy-authorization'],undefined);assert.equal(req.headers['accept-encoding'],'identity');received.push(Buffer.concat(chunks));if(req.url==='/asset.png'){res.writeHead(200,{'content-type':'image/png'});res.end(png);}else if(req.url.startsWith('/download-')){res.writeHead(200,{'content-type':'application/pdf','content-disposition':(req.url==='/download-attachment'?'attachment':'inline')+'; filename=report.pdf'});res.end(pdf(req.url==='/download-secret'?'SYNTHETIC_PRIVATE':'Public document'));}else{res.writeHead(201);res.end('accepted');}});receiver.on('connection',()=>connections++);receiver.listen(0,'::');await once(receiver,'listening'); + const corporate=https.createServer({key,cert});corporate.on('connection',socket=>{proxySockets.add(socket);socket.once('close',()=>proxySockets.delete(socket));});corporate.on('connect',(request,socket,head)=>{proxyConnects++;proxyTargets.push(request.url);const target=require('../apps/network-broker/tls-authority.cjs').parseAuthority(request.url);if(!['127.0.0.1','::1'].includes(target.hostname)||target.port!==receiver.address().port||request.headers['proxy-authorization']!=='Basic '+Buffer.from('proxy-user:proxy-password').toString('base64')){socket.end('HTTP/1.1 407 Proxy Authentication Required\r\nContent-Length: 0\r\n\r\n');return;}const origin=net.connect(target.port,target.hostname);proxySockets.add(origin);origin.once('close',()=>proxySockets.delete(origin));origin.on('error',()=>socket.destroy());socket.on('error',()=>origin.destroy());socket.once('close',()=>origin.destroy());origin.once('connect',()=>{socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');if(head.length)origin.write(head);socket.pipe(origin);origin.pipe(socket);});});if(proxyMode){corporate.listen(0,'::');await once(corporate,'listening');} + const reserved=net.createServer();reserved.listen(0,'127.0.0.1');await once(reserved,'listening');const port=reserved.address().port;await new Promise(resolve=>reserved.close(resolve)); + const destination=`localhost:${receiver.address().port}`,audit=createSyntheticAudit(t),keys=generateKeyPairSync('ed25519'),policy=defaultPolicy();policy.assignment={deviceId:'native-production',groups:[]};policy.proxy=proxyMode?{mode:'fixed_servers',rules:`https://localhost:${corporate.address().port}`,bypass:''}:{mode:'direct'};policy.security.dns.blockedIPs=['192.0.2.254'];policy.browser.allowDownloads=false;policy.fileTransfers.download.allowWithoutMalwareScanner=true;policy.dlp.rules=[{id:'production-secret',label:'Synthetic',kind:'literal',value:'SYNTHETIC_PRIVATE',action:'block'}]; + const diagnostics=await createFixtureDiagnostics({enabled:process.env.SAFETYNET_NATIVE_FIXTURE_DIAGNOSTICS==='1',policy,audit});let diagnosticProof; + const supervisor=createBrokerSupervisor({resourceRoot:root,verifyPackage:async value=>{await verifyBrokerPackage(value,manifest,{version:manifest.version,protocol:manifest.protocol});return true;},persistAudit:diagnostics.persist,acceptCertificate:async value=>{assert.equal(value.authority,destination);assert.equal(pins.accept(value),true);leaves.set(value.authority,value.cert);return true;},onQuarantine:()=>{quarantines++;t.diagnostic('fixed-startup-code:'+String(supervisor.status().startupCode||'unclassified'));},deadlineMs:45000}); + t.after(async()=>{try{await supervisor.close();}finally{receiver.closeAllConnections();for(const socket of proxySockets)socket.destroy();if(proxyMode)await new Promise(resolve=>corporate.close(resolve));await new Promise(resolve=>receiver.close(resolve));diagnosticProof=await diagnostics.finish();const directory=path.resolve('artifacts/broker-production-native');fs.mkdirSync(directory,{recursive:true});fs.writeFileSync(path.join(directory,'proof-'+new Date().toISOString().replace(/[:]/g,'-')+'.json'),JSON.stringify({kind:'actual-production-child',stage:path.basename(root),networkMode:policy.proxy.mode,proxyConnects,proxyTargets,diagnostics:diagnosticProof,status:supervisor.status(),connections,receivedBytes:received.map(value=>value.length),quarantines,events:audit.events().map(value=>({eventId:value.eventId,kind:value.kind,ruleId:value.ruleId,broker:value.broker,...(value.traceId?{traceId:value.traceId,spanId:value.spanId}:{}),...(value.file?{file:value.file}:{})}))},null,2));}}); + const ready=await supervisor.start({policyVersion:policy.version,port,config:{publicKey:keys.publicKey.export({type:'spki',format:'pem'}),deviceId:'native-production',minimumVersion:1,envelope:signPolicy(policy,keys.privateKey.export({type:'pkcs8',format:'pem'})),brokerPort:port,network:{mode:policy.proxy.mode,dns:'system',ca:cert.toString(),...(proxyMode?{proxyCa:cert.toString(),credentials:{username:'proxy-user',password:'proxy-password'}}:{})}}}); + pins=createBrokerCertificatePins({generation:ready.generation,policyVersion:ready.policyVersion,current:()=>supervisor.status().ready&&supervisor.status().generation===ready.generation,authorizedAuthority:value=>value===destination}); + // Production uses one30s total request budget (inspector worker29s);35s + // client limit leaves only a bounded transport/audit margin. Prior5s failures + // remain preserved rather than being reported as scanner verdicts. + const sendRaw=(body,route='/upload',headers={})=>new Promise((resolve,reject)=>{ + const auth=Buffer.from(`${ready.credentials.username}:${ready.credentials.password}`).toString('base64'); + const request=http.request({host:'127.0.0.1',port,method:'CONNECT',path:destination,headers:{host:destination,'proxy-authorization':'Basic '+auth}});request.setTimeout(35000,()=>request.destroy(Error('deadline')));request.on('error',reject); + request.on('connect',(response,socket)=>{if(response.statusCode!==200){socket.destroy();resolve(response.statusCode);return;}assert.ok(leaves.has(destination));const secure=tls.connect({socket,servername:'localhost',ca:leaves.get(destination),rejectUnauthorized:true});let output='';const chunks=[];secure.setTimeout(35000,()=>secure.destroy(Error('deadline')));secure.on('error',reject);secure.on('secureConnect',()=>secure.write(`POST ${route} HTTP/1.1\r\nHost: ${destination}\r\nContent-Length: ${Buffer.byteLength(body)}\r\n${Object.entries(headers).map(([name,value])=>name+': '+value+'\r\n').join('')}Connection: close\r\n\r\n${body}`));secure.on('data',chunk=>{output+=chunk;chunks.push(chunk);});secure.on('end',()=>{clientResponses.push(output);const bytes=Buffer.concat(chunks);clientBodies.push(bytes.subarray(bytes.indexOf('\r\n\r\n')+4));resolve(Number(/^HTTP\/1.1 (\d+)/.exec(output)?.[1]));});});request.end(); + }); + const send=(body,route='/upload',headers={})=>diagnostics.run({bytes:Buffer.byteLength(body),policyVersion:policy.version},()=>sendRaw(body,route,headers)); + assert.equal(await send('public synthetic'),201);assert.equal(connections,1);assert.deepEqual(received.map(value=>value.toString()),['public synthetic']); + assert.equal(await send('SYNTHETIC_PRIVATE'),403);assert.equal(connections,1);assert.equal(received.length,1);assert.equal(quarantines,0); + const secretDocument=pdf('SYNTHETIC_PRIVATE'),encodedSecret=JSON.stringify({file:secretDocument.toString('base64')}),secretURI=JSON.stringify({file:encodeURIComponent('data:application/pdf;base64,'+secretDocument.toString('base64'))}); + assert.equal(await send(encodedSecret,'/upload',{'Content-Type':'application/json'}),403);assert.equal(await send(secretURI,'/upload',{'Content-Type':'application/json'}),403);assert.equal(connections,1); + const publicOuter=JSON.stringify({file:pdf('Public encoded document').toString('base64')});assert.equal(await send(publicOuter,'/upload',{'Content-Type':'application/json'}),201);assert.equal(received[1].toString(),publicOuter); + assert.equal(await send('','/asset.png',{'Accept-Encoding':'gzip, br'}),200);assert.deepEqual(clientBodies.at(-1),png); + assert.equal(await send('','/download-attachment'),502);assert.equal(clientBodies.at(-1).includes(Buffer.from('%PDF-')),false); + assert.equal(await send('','/download-public'),200);assert.equal(clientResponses.at(-1).split('\r\n\r\n')[1],pdf('Public document').toString()); + assert.equal(await send('','/download-secret'),502);assert.equal(clientResponses.at(-1).includes('%PDF-'),false);assert.equal(clientResponses.at(-1).includes(Buffer.from('SYNTHETIC_PRIVATE').toString('hex')),false); + const events=audit.events();assert.equal(events.length,13);assert.equal(events[10].kind,'download.scan');assert.equal(events[12].kind,'download.block');assert.equal(events[12].ruleId,'production-secret');assert.equal(events[12].file.bytes,pdf('SYNTHETIC_PRIVATE').length);assert.equal(events[12].file.sha256,createHash('sha256').update(pdf('SYNTHETIC_PRIVATE')).digest('hex'));assert.equal(events[10].file.sha256,createHash('sha256').update(pdf('Public document')).digest('hex'));assert.equal(events[12].broker.authorizedEventId,events[11].eventId);assert.equal(events[8].broker.reason,'broker-download-policy-block');assert.equal(events[6].file.sha256,createHash('sha256').update(png).digest('hex'));assert.equal(events[2].ruleId,'production-secret');assert.equal(events[3].ruleId,'production-secret');assert.equal(events[1].ruleId,'production-secret');assert.equal(events[1].broker.phase,'request.block');assert.equal(JSON.stringify(events).includes('SYNTHETIC_PRIVATE'),false);if(proxyMode)assert.equal(proxyConnects,connections); + await supervisor.close();assert.equal(supervisor.status().closed,true); +}); diff --git a/tests/broker-proxy-forwarder.test.mjs b/tests/broker-proxy-forwarder.test.mjs new file mode 100644 index 0000000..b4436da --- /dev/null +++ b/tests/broker-proxy-forwarder.test.mjs @@ -0,0 +1,39 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http';import https from 'node:https';import net from 'node:net';import fs from 'node:fs';import {once} from 'node:events';import {createRequire} from 'node:module'; +const {createProxyForwarder,parseFixedProxy}=createRequire(import.meta.url)('../apps/network-broker/proxy-forwarder.cjs'); +const cert=fs.readFileSync(new URL('./fixtures/icap/cert.pem',import.meta.url)),key=fs.readFileSync(new URL('./fixtures/icap/key.pem',import.meta.url)); +async function fixture(t,{rejectAuth=false,ca=cert,current=()=>true,tlsProxy=false,resolveOriginRoute}={}){ + let originRequests=0,connects=0,proxyAuth,originAuth,connectTarget,originSni,proxyTcp=0;const sockets=new Set(); + const origin=https.createServer({cert,key},(req,res)=>{originRequests++;originAuth=req.headers['proxy-authorization'];res.end('public');});origin.on('tlsClientError',()=>{});origin.listen(0,'127.0.0.1');await once(origin,'listening'); + origin.on('secureConnection',s=>{originSni=s.servername;}); + const proxy=tlsProxy?https.createServer({cert,key}):http.createServer();proxy.on('connect',(req,socket,head)=>{connects++;connectTarget=req.url;proxyAuth=req.headers['proxy-authorization'];if(rejectAuth){socket.end('HTTP/1.1 407 Proxy Authentication Required\r\nContent-Length: 0\r\n\r\n');return;}const upstream=net.connect(origin.address().port,'127.0.0.1',()=>{socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');if(head.length)upstream.write(head);socket.pipe(upstream);upstream.pipe(socket);});sockets.add(upstream);upstream.on('error',()=>socket.destroy());});proxy.on('connection',s=>{proxyTcp++;sockets.add(s);});proxy.listen(0,'127.0.0.1');await once(proxy,'listening'); + t.after(async()=>{for(const socket of sockets)socket.destroy();origin.closeAllConnections();await Promise.all([new Promise(r=>proxy.close(r)),new Promise(r=>origin.close(r))]);}); + const forward=createProxyForwarder({authority:'p',current,proxyPolicy:{mode:'fixed_servers',rules:`${tlsProxy?"https":"http"}://localhost:${proxy.address().port}`,bypass:''},resolveProxyRoute:async d=>({approved:true,address:'127.0.0.1',port:d.port}),resolveOriginRoute,credentials:{username:'corp',password:'synthetic'},ca:ca===null?undefined:ca,proxyCa:cert,deadlineMs:1000}); + return {forward,request:{method:'POST',url:`https://localhost:${origin.address().port}/`,headers:{'proxy-authorization':'must-not-reach-origin'}},get originRequests(){return originRequests;},get connects(){return connects;},get proxyAuth(){return proxyAuth;},get originAuth(){return originAuth;},get connectTarget(){return connectTarget;},get originSni(){return originSni;},get proxyTcp(){return proxyTcp;}}; +} +test('real CONNECT proxy carries verified HTTPS and credentials stay proxy-only',async t=>{const f=await fixture(t);const result=await f.forward({request:f.request,body:Buffer.from('public')});assert.equal(result.body.toString(),'public');assert.equal(f.originRequests,1);assert.equal(f.connects,1);assert.equal(f.proxyAuth,'Basic '+Buffer.from('corp:synthetic').toString('base64'));assert.equal(f.originAuth,undefined);}); +test('proxy authentication rejection never falls back direct',async t=>{const f=await fixture(t,{rejectAuth:true});await assert.rejects(f.forward({request:f.request,body:Buffer.from('secret')}));assert.equal(f.originRequests,0);assert.equal(f.connects,1);}); +test('untrusted origin TLS prevents HTTP release',async t=>{const f=await fixture(t,{ca:null});await assert.rejects(f.forward({request:f.request,body:Buffer.from('secret')}));assert.equal(f.originRequests,0);}); +test('revoked authority never dials proxy or origin',async t=>{const f=await fixture(t,{current:()=>false});await assert.rejects(f.forward({request:f.request,body:Buffer.from('secret')}));assert.equal(f.connects,0);assert.equal(f.originRequests,0);}); +test('unsupported PAC fallback and bypass fail closed',()=>{for(const rules of ['socks://a:1080','http://a:80;http://b:80','a:80'])assert.throws(()=>parseFixedProxy({mode:'fixed_servers',rules,bypass:''}));assert.throws(()=>parseFixedProxy({mode:'fixed_servers',rules:'http://a:80',bypass:'*'}));}); + + +test('real TLS proxy verifies outer endpoint and origin independently',async t=>{const f=await fixture(t,{tlsProxy:true});const result=await f.forward({request:f.request,body:Buffer.from('public')});assert.equal(result.body.toString(),'public');assert.equal(f.originRequests,1);}); +test('timed out resolver retains admission until actual settlement',async()=>{ + let settle;const pending=new Promise(resolve=>{settle=resolve;}); + const forward=createProxyForwarder({authority:'p',current:()=>true,proxyPolicy:{mode:'fixed_servers',rules:'http://localhost:1234',bypass:''},resolveProxyRoute:()=>pending,deadlineMs:5}); + const arguments_={request:{method:'POST',url:'https://localhost:443/',headers:{}},body:Buffer.from('public')}; + const requests=Array.from({length:8},()=>assert.rejects(forward(arguments_),{code:'upstream-cancelled'}));await Promise.all(requests); + await assert.rejects(forward(arguments_),{code:'proxy-capacity-exhausted'});settle({approved:false});await new Promise(resolve=>setImmediate(resolve)); + await assert.rejects(forward(arguments_),{code:'proxy-route-unapproved'}); +}); + +test('proxy URI defaults follow scheme and CA inputs are typed',()=>{ + assert.equal(parseFixedProxy({mode:'fixed_servers',rules:'http://proxy.example',bypass:''}).port,80); + assert.equal(parseFixedProxy({mode:'fixed_servers',rules:'https://proxy.example',bypass:''}).port,443); + assert.throws(()=>createProxyForwarder({authority:'p',current:()=>true,proxyPolicy:{mode:'fixed_servers',rules:'http://localhost:1234',bypass:''},resolveProxyRoute:()=>{},ca:{untrusted:true}})); +}); +test('approved numeric origin CONNECT preserves original TLS hostname and exact port',async t=>{const f=await fixture(t,{tlsProxy:true,resolveOriginRoute:async r=>({approved:true,address:'127.0.0.1',port:r.port})});const result=await f.forward({request:f.request,body:Buffer.from('public')});assert.equal(result.body.toString(),'public');assert.equal(f.connectTarget,'127.0.0.1:'+new URL(f.request.url).port);assert.equal(f.originSni,'localhost');}); +test('blocked resolved origin prevents even proxy TCP and never falls back to hostname',async t=>{const {createDnsPolicyRoute}=createRequire(import.meta.url)('../apps/network-broker/dns-policy-route.cjs');const f=await fixture(t,{resolveOriginRoute:createDnsPolicyRoute({current:()=>true,blocked:a=>a==='127.0.0.1',lookup:async()=>[{address:'127.0.0.1',family:4}]})});await assert.rejects(f.forward({request:f.request,body:Buffer.from('secret')}));assert.equal(f.proxyTcp,0);assert.equal(f.connects,0);assert.equal(f.originRequests,0);}); +test('origin resolver abort retains original slot and revocation prevents proxy dial',async()=>{let settle,active=true;const pending=new Promise(r=>{settle=r;});let proxyLookups=0;const forward=createProxyForwarder({authority:'p',current:()=>active,proxyPolicy:{mode:'fixed_servers',rules:'http://localhost:1234',bypass:''},resolveOriginRoute:()=>pending,resolveProxyRoute:async()=>{proxyLookups++;return {approved:false};},deadlineMs:5});const args={request:{method:'POST',url:'https://localhost:443/',headers:{}},body:Buffer.from('public')};await Promise.all(Array.from({length:8},()=>assert.rejects(forward(args),{code:'upstream-cancelled'})));active=false;await assert.rejects(forward(args),{code:'proxy-capacity-exhausted'});settle({approved:true,address:'127.0.0.1',port:443});await new Promise(r=>setImmediate(r));assert.equal(proxyLookups,0);await assert.rejects(forward(args),{code:'authority-expired'});}); diff --git a/tests/broker-runtime-manifest.test.mjs b/tests/broker-runtime-manifest.test.mjs new file mode 100644 index 0000000..31c9a0e --- /dev/null +++ b/tests/broker-runtime-manifest.test.mjs @@ -0,0 +1,37 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtemp,writeFile,rm,symlink} from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import {BROKER_RUNTIME,verifyOfficialChecksums,verifyBrokerRuntime} from '../scripts/provision-broker-runtime.mjs'; +const checksums=`${BROKER_RUNTIME.archiveSha256} ${BROKER_RUNTIME.archive}\n${BROKER_RUNTIME.executableSha256} win-x64/node.exe\n`; +async function fixture(t){ + const root=await mkdtemp(path.join(os.tmpdir(),'safetynet-node-runtime-'));t.after(()=>rm(root,{force:true,recursive:true})); + const manifest={format:'safetynet-node-broker-runtime-v1',version:BROKER_RUNTIME.version,platform:'win32',arch:'x64',archiveSha256:BROKER_RUNTIME.archiveSha256,files:[{path:'node.exe',bytes:9,sha256:BROKER_RUNTIME.executableSha256},{path:'LICENSE',bytes:7,sha256:BROKER_RUNTIME.licenseSha256}]}; + await writeFile(path.join(root,'runtime-manifest.json'),JSON.stringify(manifest)); + await writeFile(path.join(root,'node.exe'),'synthetic');await writeFile(path.join(root,'LICENSE'),'license'); + return {root,manifest}; +} +test('official checksums must match reviewed archive and standalone executable pins',()=>{ + assert.doesNotThrow(()=>verifyOfficialChecksums(checksums)); + for(const text of [checksums.replace(BROKER_RUNTIME.archiveSha256,'0'.repeat(64)),checksums+checksums,checksums.replace('win-x64/node.exe','win-arm64/node.exe'),'x'.repeat(65537)])assert.throws(()=>verifyOfficialChecksums(text)); +}); +test('edited manifest cannot relabel wrong runtime version architecture or archive',async t=>{ + const {root,manifest}=await fixture(t); + for(const change of [{version:'24.22.0'},{arch:'arm64'},{archiveSha256:'0'.repeat(64)},{files:[manifest.files[0],manifest.files[0]]}]){ + await writeFile(path.join(root,'runtime-manifest.json'),JSON.stringify({...manifest,...change})); + await assert.rejects(verifyBrokerRuntime(root),{code:'broker_runtime_manifest_invalid'}); + } +}); +test('synthetic executable cannot be accepted as official runtime even with shaped manifest',async t=>{ + const {root}=await fixture(t);await assert.rejects(verifyBrokerRuntime(root),{code:'broker_runtime_integrity_mismatch'}); +}); +test('unlisted runtime injection is rejected before payload verification',async t=>{ + const {root}=await fixture(t);await writeFile(path.join(root,'injection.dll'),'private'); + await assert.rejects(verifyBrokerRuntime(root),{code:'broker_runtime_inventory_mismatch'}); +}); +test('runtime directory junction is not trusted as an installation path',async t=>{ + const {root}=await fixture(t),alias=root+'-alias';t.after(()=>rm(alias,{force:true,recursive:true})); + await symlink(root,alias,process.platform==='win32'?'junction':'dir'); + await assert.rejects(verifyBrokerRuntime(alias),{code:'broker_runtime_path_unsafe'}); +}); diff --git a/tests/broker-service-acceptance.test.mjs b/tests/broker-service-acceptance.test.mjs new file mode 100644 index 0000000..aeab254 --- /dev/null +++ b/tests/broker-service-acceptance.test.mjs @@ -0,0 +1,75 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {generateKeyPairSync} from 'node:crypto'; +import {createRequire} from 'node:module'; +import {defaultPolicy,signPolicy} from '../packages/core/policy.mjs'; +const require=createRequire(import.meta.url),{verifyServiceAcceptance}=require('../apps/network-broker/service-acceptance.cjs'); +const {acceptanceStateDigest,StateSchema}=require('../apps/network-broker/service-acceptance.cjs'),{createServiceCommitGate}=require('../apps/network-broker/service-commit-gate.cjs'); +const {verifyReleaseEnvelope,verifyDeploymentEnvelope,signManifest}=require('../packages/core/windows-trust.cjs'); +const pub=key=>key.publicKey.export({type:'spki',format:'pem'}),priv=key=>key.privateKey.export({type:'pkcs8',format:'pem'}); +function fixture(){ + const vendor=generateKeyPairSync('ec',{namedCurve:'prime256v1'}),customer=generateKeyPairSync('ec',{namedCurve:'prime256v1'}),key=generateKeyPairSync('ed25519'),sid='S-1-12-1-1-2-3-4'; + const release={format:'safetynet-windows-release-v1',releaseId:'12345678-1234-1234-1234-123456789abc',releaseSequence:1,serviceName:'SafetyNetNetworkBroker',deploymentTenant:'22345678-1234-1234-1234-123456789abc',deploymentPublicKey:pub(customer),files:['SafetyNet.exe','resources/app.asar','resources/broker/node.exe','resources/inspector/server.py','resources/python/python.exe','resources/semantic/model.bin','safetynet-service-host.exe'].map(path=>({path,bytes:1,sha256:'a'.repeat(64)}))}; + const releaseEnvelope=signManifest(release,priv(vendor),'release'),verified=verifyReleaseEnvelope(releaseEnvelope,{publicKey:pub(vendor)}); + const deployment={format:'safetynet-windows-deployment-v1',releaseId:release.releaseId,deploymentTenant:release.deploymentTenant,deploymentSequence:1,policyPublicKey:pub(key),expiresAt:Date.now()+60000,users:[{sid,deviceId:'device',minimumPolicyVersion:7}]}; + const deploymentEnvelope=signManifest(deployment,priv(customer),'deployment'),d=verifyDeploymentEnvelope(deploymentEnvelope,{release:verified,authenticatedSid:sid}); + const policy=defaultPolicy();policy.version=7;policy.assignment={deviceId:'device',groups:[]}; + const input={state:{format:'safetynet-service-floors-v1',release:{minimumSequence:1,acceptedDigest:verified.digest},deployment:{tenant:release.deploymentTenant,minimumSequence:1,acceptedDigest:d.digest},policies:[]},releaseEnvelope,deploymentEnvelope,releaseAnchor:pub(vendor),authenticatedSid:sid,policyEnvelope:signPolicy(policy,priv(key))}; + return {input,deployment,customer,key,policy}; +} +test('genuine signed acceptance raises all in-memory floors and preserves the protected input',async()=>{ + const f=fixture(),result=await verifyServiceAcceptance(f.input);assert.equal(result.bootstrap.minimumVersion,7);assert.equal(result.nextState.policies.length,1);assert.equal(result.nextState.policies[0].minimumVersion,7);assert.equal(f.input.state.policies.length,0);assert.ok(Object.isFrozen(result.nextState.policies)); + const replay=await verifyServiceAcceptance({...f.input,state:result.nextState});assert.deepEqual(replay.nextState,result.nextState); +}); +test('newer deployment cannot lower an accepted policy floor; same policy version cannot equivocate',async()=>{ + const f=fixture(),accepted=await verifyServiceAcceptance(f.input);f.deployment.deploymentSequence=2;f.deployment.users[0].minimumPolicyVersion=1; + const input={...f.input,state:accepted.nextState,deploymentEnvelope:signManifest(f.deployment,priv(f.customer),'deployment')};f.policy.version=6; + await assert.rejects(verifyServiceAcceptance({...input,policyEnvelope:signPolicy(f.policy,priv(f.key))}),{message:'broker-bootstrap-invalid'}); + f.policy.version=7;f.policy.brand.name='Changed same version';await assert.rejects(verifyServiceAcceptance({...input,policyEnvelope:signPolicy(f.policy,priv(f.key))}),{message:'broker-bootstrap-invalid'}); + f.policy.version=8;const next=await verifyServiceAcceptance({...input,policyEnvelope:signPolicy(f.policy,priv(f.key))});assert.equal(next.bootstrap.minimumVersion,8);assert.equal(next.nextState.deployment.minimumSequence,2); +}); +test('missing state, duplicate floor identities, unknown peer and absent device assignment fail closed',async()=>{ + const f=fixture(),accepted=await verifyServiceAcceptance(f.input); + for(const input of [{...f.input,state:undefined},{...f.input,state:{...accepted.nextState,policies:[...accepted.nextState.policies,...accepted.nextState.policies]}},{...f.input,authenticatedSid:'S-1-12-1-5-6-7-8'}])await assert.rejects(verifyServiceAcceptance(input),{message:'broker-bootstrap-invalid'}); + delete f.policy.assignment;await assert.rejects(verifyServiceAcceptance({...f.input,policyEnvelope:signPolicy(f.policy,priv(f.key))}),{message:'broker-bootstrap-invalid'}); +}); +test('approved key rotation retains old-key floors when that key is selected again',async()=>{ + const f=fixture(),first=await verifyServiceAcceptance(f.input),replacement=generateKeyPairSync('ed25519'); + f.deployment.deploymentSequence=2;f.deployment.policyPublicKey=pub(replacement);f.deployment.users[0].minimumPolicyVersion=1;f.policy.version=1; + const rotated=await verifyServiceAcceptance({...f.input,state:first.nextState,deploymentEnvelope:signManifest(f.deployment,priv(f.customer),'deployment'),policyEnvelope:signPolicy(f.policy,priv(replacement))});assert.equal(rotated.nextState.policies.length,2); + f.deployment.deploymentSequence=3;f.deployment.policyPublicKey=pub(f.key);f.policy.version=6; + await assert.rejects(verifyServiceAcceptance({...f.input,state:rotated.nextState,deploymentEnvelope:signManifest(f.deployment,priv(f.customer),'deployment'),policyEnvelope:signPolicy(f.policy,priv(f.key))}),error=>error.message==='broker-bootstrap-invalid'&&error.failureStage==='policy'); +}); +test('bootstrap gate releases no plan before exact protected commit receipt',async()=>{ + const f=fixture();let enter,finish,completed=false;const entered=new Promise(resolve=>enter=resolve),hold=new Promise(resolve=>finish=resolve); + const gate=createServiceCommitGate({current:()=>true,commitState:async request=>{assert.equal(request.expectedPreviousSHA,acceptanceStateDigest(f.input.state));assert.ok(Object.isFrozen(request.candidateState));enter();await hold;return {persisted:true,stateSha256:acceptanceStateDigest(request.candidateState)};}}); + const pending=gate.prepare(f.input).then(value=>{completed=true;return value;});await entered;assert.equal(completed,false);finish();const plan=await pending;assert.equal(plan.policyVersion,7);await assert.rejects(gate.prepare(f.input),{message:'broker-bootstrap-invalid'}); +}); +test('failed or uncertain native commit is sticky without automatic retry',async()=>{ + const f=fixture(); + for(const answer of [undefined,true,{persisted:true,stateSha256:'0'.repeat(64)},{persisted:false,stateSha256:'0'.repeat(64)}]){ + let commits=0;const gate=createServiceCommitGate({current:()=>true,commitState:async()=>{commits++;return answer;}}); + await assert.rejects(gate.prepare(f.input),{message:'broker-bootstrap-invalid'});await assert.rejects(gate.prepare(f.input),{message:'broker-bootstrap-invalid'});assert.equal(commits,1); + } +}); +test('revocation during original commit blocks even a correct persisted receipt',async()=>{ + const f=fixture();let active=true; + const gate=createServiceCommitGate({current:()=>active,commitState:async request=>{active=false;return {persisted:true,stateSha256:acceptanceStateDigest(request.candidateState)};}}); + await assert.rejects(gate.prepare(f.input),{message:'broker-bootstrap-invalid'}); +}); +test('commit plan composes with service startup and rejects another signed policy at the same version',async()=>{ + const {createServiceFactory}=require('../apps/network-broker/service-bootstrap.cjs'),f=fixture(); + const gate=createServiceCommitGate({current:()=>true,commitState:async request=>({persisted:true,stateSha256:acceptanceStateDigest(request.candidateState)})}); + const plan=await gate.prepare(f.input);let launches=0; + const factory=createServiceFactory({loadTrust:async parsed=>{assert.ok(Object.isFrozen(parsed.envelope));return plan;},factory:async()=>{launches++;return {listen:async()=>43120,close:async()=>{}};}}); + const config={publicKey:pub(f.key),deviceId:'device',minimumVersion:7,envelope:f.input.policyEnvelope,brokerPort:43120,network:{mode:'direct',dns:'system'}},callbacks={expectedPolicyVersion:7}; + f.policy.brand.name='Differently signed same version';const other=signPolicy(f.policy,priv(f.key)); + await assert.rejects(factory({...config,envelope:other},callbacks),{message:'broker-bootstrap-invalid'});assert.equal(launches,0); + const accepted=await factory(config,callbacks);assert.equal(await accepted.listen(),43120);await accepted.close();assert.equal(launches,1); +}); +test('valid record count cannot bypass total serialized state and CAS frame budget',async()=>{ + const f=fixture();f.input.state.policies=Array.from({length:8000},(_,index)=>({sid:'S-1-12-1-4294967295-4294967295-4294967295-4294967295',deviceId:'device'+String(index).padStart(74,'0'),policyKeySha256:'a'.repeat(64),minimumVersion:Number.MAX_SAFE_INTEGER,acceptedDigest:'b'.repeat(64)})); + assert.doesNotThrow(()=>StateSchema.parse(f.input.state)); + let commits=0;const gate=createServiceCommitGate({current:()=>true,commitState:async()=>{commits++;}}); + await assert.rejects(gate.prepare(f.input),{message:'broker-bootstrap-invalid'});assert.equal(commits,0); +}); diff --git a/tests/broker-service-bootstrap.test.mjs b/tests/broker-service-bootstrap.test.mjs new file mode 100644 index 0000000..40dccb9 --- /dev/null +++ b/tests/broker-service-bootstrap.test.mjs @@ -0,0 +1,70 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +import {generateKeyPairSync,createHash} from 'node:crypto'; +import {defaultPolicy,signPolicy} from '../packages/core/policy.mjs'; +const {createServiceFactory}=createRequire(import.meta.url)('../apps/network-broker/service-bootstrap.cjs'); +function fixture(){ + const keys=generateKeyPairSync('ed25519'),publicKey=keys.publicKey.export({type:'spki',format:'pem'}),policy=defaultPolicy();policy.version=4;policy.assignment={deviceId:'managed-device',groups:[]};policy.proxy={mode:'direct'}; + const config={publicKey,deviceId:'managed-device',minimumVersion:4,envelope:signPolicy(policy,keys.privateKey.export({type:'pkcs8',format:'pem'})),brokerPort:43120,network:{mode:'direct',dns:'system'}}; + return {config,binding:{publicKey,deviceId:'managed-device',minimumVersion:4}}; +} +const trusted=f=>({bootstrap:f.binding,deployment:{manifest:{expiresAt:Date.now()+60000}},policyVersion:4,policyDigest:createHash('sha256').update(f.config.envelope.payload).digest('hex')}); +const broker=()=>({listen:async()=>43120,close:async()=>{}}); + +test('caller-selected receiver port fails before trust loading or construction',async()=>{ + const f=fixture();let loads=0,launches=0; + const factory=createServiceFactory({loadTrust:async()=>{loads++;return trusted(f);},factory:async()=>{launches++;return broker();}}); + for(const port of [1,43100,43121,65535])await assert.rejects(factory({...f.config,brokerPort:port},{expectedPolicyVersion:4}),{message:'broker-bootstrap-invalid'}); + assert.equal(loads,0);assert.equal(launches,0); +}); +test('service bootstrap rejects caller-selected key/device or lower protected floor before factory',async()=>{ + const f=fixture();let launches=0;const factory=createServiceFactory({loadTrust:async()=>trusted(f),factory:async()=>{launches++;return broker();}}); + const forged=generateKeyPairSync('ed25519').publicKey.export({type:'spki',format:'pem'}); + for(const changed of [{publicKey:forged},{deviceId:'other-user-device'},{minimumVersion:3},{minimumVersion:0},{resourceRoot:'C:/caller-selected'}])await assert.rejects(factory({...f.config,...changed},{expectedPolicyVersion:4}),{message:'broker-bootstrap-invalid'}); + assert.equal(launches,0); +}); +test('trusted SID-derived mapping keeps stricter caller floor and exact callbacks without mutation',async()=>{ + const f=fixture(),callbacks={expectedPolicyVersion:4};let observed; + const factory=createServiceFactory({loadTrust:async()=>trusted(f),factory:async(config,value)=>{observed={config,value};return broker();}}); + const result=await factory({...f.config,minimumVersion:5},callbacks);assert.equal(await result.listen(),43120);await result.close();assert.equal(observed.config.minimumVersion,5);assert.equal(observed.config.publicKey,f.binding.publicKey);assert.equal(observed.value,callbacks);assert.equal(f.config.minimumVersion,4); +}); +test('missing assignment, invalid trusted floor and failed trust load expose only fixed error',async()=>{ + const f=fixture();let launches=0; + for(const loadTrust of [async()=>({}),async()=>({...trusted(f),bootstrap:{...f.binding,minimumVersion:0}}),async()=>{throw Error('PRIVATE_PATH_OR_KEY');}]){ + const factory=createServiceFactory({loadTrust,factory:async()=>{launches++;}}); + await assert.rejects(factory(f.config,{}),error=>error.message==='broker-bootstrap-invalid'&&!String(error).includes('PRIVATE')); + } + assert.equal(launches,0); +}); +test('real vendor/customer signature chain binds bootstrap to actual supplied peer SID',async()=>{ + const {verifyReleaseEnvelope,verifyDeploymentEnvelope,signManifest}=createRequire(import.meta.url)('../packages/core/windows-trust.cjs'); + const f=fixture(),vendor=generateKeyPairSync('ec',{namedCurve:'prime256v1'}),customer=generateKeyPairSync('ec',{namedCurve:'prime256v1'}); + const pub=key=>key.publicKey.export({type:'spki',format:'pem'}),priv=key=>key.privateKey.export({type:'pkcs8',format:'pem'}); + const release={format:'safetynet-windows-release-v1',releaseId:'12345678-1234-1234-1234-123456789abc',releaseSequence:1,serviceName:'SafetyNetNetworkBroker',deploymentTenant:'22345678-1234-1234-1234-123456789abc',deploymentPublicKey:pub(customer),files:['SafetyNet.exe','resources/app.asar','resources/broker/node.exe','resources/inspector/server.py','resources/python/python.exe','resources/semantic/model.bin','safetynet-service-host.exe'].map(path=>({path,bytes:1,sha256:'a'.repeat(64)}))}; + const signedRelease=signManifest(release,priv(vendor),'release'),verifiedRelease=verifyReleaseEnvelope(signedRelease,{publicKey:pub(vendor),floor:{minimumSequence:1}}); + const sid='S-1-12-1-1-2-3-4',deployment={format:'safetynet-windows-deployment-v1',releaseId:release.releaseId,deploymentTenant:release.deploymentTenant,deploymentSequence:1,policyPublicKey:f.binding.publicKey,expiresAt:Date.now()+60000,users:[{sid,deviceId:f.binding.deviceId,minimumPolicyVersion:4}]}; + let envelope=signManifest(deployment,priv(customer),'deployment'),peer=sid,launches=0; + const factory=createServiceFactory({loadTrust:async()=>{const deployment=verifyDeploymentEnvelope(envelope,{release:verifiedRelease,authenticatedSid:peer,floor:{minimumSequence:1}});return {...trusted(f),bootstrap:deployment.bootstrap,deployment};},factory:async()=>{launches++;return broker();}}); + const result=await factory(f.config,{expectedPolicyVersion:4});assert.equal(await result.listen(),43120);await result.close(); + peer='S-1-12-1-5-6-7-8';await assert.rejects(factory(f.config,{}),{message:'broker-bootstrap-invalid'}); + peer=sid;envelope=signManifest(deployment,priv(vendor),'deployment');await assert.rejects(factory(f.config,{}),{message:'broker-bootstrap-invalid'}); + assert.equal(launches,1); +}); +test('deployment expiration rejects startup and closes an otherwise valid running broker',async()=>{ + const f=fixture();let closed=0,expired; + const stopped=new Promise(resolve=>expired=resolve); + const factory=createServiceFactory({loadTrust:async()=>({...trusted(f),deployment:{manifest:{expiresAt:Date.now()+60}}}),factory:async()=>({listen:async()=>43120,close:async()=>{closed++;expired();}})}); + const result=await factory(f.config,{expectedPolicyVersion:4});assert.equal(await result.listen(),43120); + await Promise.race([stopped,new Promise((_,reject)=>{const limit=setTimeout(()=>reject(Error('expiry not enforced')),1000);limit.unref();})]); + assert.equal(closed,1);await assert.rejects(result.listen(),{message:'broker-bootstrap-invalid'});await result.close();assert.equal(closed,1); + let launches=0;const rejected=createServiceFactory({loadTrust:async()=>({...trusted(f),deployment:{manifest:{expiresAt:Date.now()-1}}}),factory:async()=>{launches++;return broker();}}); + await assert.rejects(rejected(f.config,{}),{message:'broker-bootstrap-invalid'});assert.equal(launches,0); +}); +test('repeated close waits the original cleanup and preserves its failure',async()=>{ + const f=fixture();let finish,closes=0; + const cleanup=new Promise((_,reject)=>finish=reject); + const factory=createServiceFactory({loadTrust:async()=>trusted(f),factory:async()=>({listen:async()=>43120,close:()=>{closes++;return cleanup;}})}); + const result=await factory(f.config,{expectedPolicyVersion:4}),first=result.close(),second=result.close();assert.equal(first,second); + const failure=Error('synthetic cleanup failure'),rejected=assert.rejects(first,error=>error===failure);await new Promise(resolve=>setImmediate(resolve));assert.equal(closes,1);finish(failure);await rejected;await assert.rejects(result.close(),error=>error===failure); +}); diff --git a/tests/broker-service-entry.test.mjs b/tests/broker-service-entry.test.mjs new file mode 100644 index 0000000..a728238 --- /dev/null +++ b/tests/broker-service-entry.test.mjs @@ -0,0 +1,55 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {EventEmitter} from 'node:events'; +import {generateKeyPairSync,randomUUID} from 'node:crypto'; +import {createRequire} from 'node:module'; +import {defaultPolicy,signPolicy} from '../packages/core/policy.mjs'; +const require=createRequire(import.meta.url),{runServiceProcess}=require('../apps/network-broker/service-entry.cjs'); +const {signManifest,verifyReleaseEnvelope,verifyDeploymentEnvelope}=require('../packages/core/windows-trust.cjs'); +const {acceptanceStateDigest}=require('../apps/network-broker/service-acceptance.cjs'); +const pub=k=>k.publicKey.export({type:'spki',format:'pem'}),priv=k=>k.privateKey.export({type:'pkcs8',format:'pem'}); +function fixture(){ + const vendor=generateKeyPairSync('ec',{namedCurve:'prime256v1'}),customer=generateKeyPairSync('ec',{namedCurve:'prime256v1'}),key=generateKeyPairSync('ed25519'),sid='S-1-12-1-1-2-3-4'; + const release={format:'safetynet-windows-release-v1',releaseId:'12345678-1234-1234-1234-123456789abc',releaseSequence:1,serviceName:'SafetyNetNetworkBroker',deploymentTenant:'22345678-1234-1234-1234-123456789abc',deploymentPublicKey:pub(customer),files:['SafetyNet.exe','resources/app.asar','resources/broker/node.exe','resources/inspector/server.py','resources/python/python.exe','resources/semantic/model.bin','safetynet-service-host.exe'].map(path=>({path,bytes:1,sha256:'a'.repeat(64)}))}; + const releaseEnvelope=signManifest(release,priv(vendor),'release'),r=verifyReleaseEnvelope(releaseEnvelope,{publicKey:pub(vendor)}); + const deployment={format:'safetynet-windows-deployment-v1',releaseId:release.releaseId,deploymentTenant:release.deploymentTenant,deploymentSequence:1,policyPublicKey:pub(key),expiresAt:Date.now()+60000,users:[{sid,deviceId:'device',minimumPolicyVersion:7}]}; + const deploymentEnvelope=signManifest(deployment,priv(customer),'deployment'),d=verifyDeploymentEnvelope(deploymentEnvelope,{release:r,authenticatedSid:sid}); + const policy=defaultPolicy();policy.version=7;policy.assignment={deviceId:'device',groups:[]}; + const snapshot={version:1,authenticatedSid:sid,releaseAnchor:pub(vendor),acceptanceState:{format:'safetynet-service-floors-v1',release:{minimumSequence:1,acceptedDigest:r.digest},deployment:{tenant:release.deploymentTenant,minimumSequence:1,acceptedDigest:d.digest},policies:[]}}; + return {snapshot,envelopes:{releaseEnvelope,deploymentEnvelope},config:{publicKey:pub(key),deviceId:'device',minimumVersion:7,envelope:signPolicy(policy,priv(key)),brokerPort:43120,network:{mode:'direct',dns:'system'}}}; +} +function channel(){const c=new EventEmitter();c.connected=true;c.send=(v,callback)=>{c.emit('sent',v);callback?.();};c.disconnect=()=>{if(c.connected){c.connected=false;c.emit('disconnect');}};return c;} +const verifiedTopology=async()=>({bfeRunning:true,ownedSublayer:'ours',enumerationComplete:true,allObjectsReadable:true,sublayers:[{key:'ours',weight:65535}],filters:Array.from({length:8},(_,i)=>({key:String(i),sublayer:'ours',layer:'c38d57d1-05a7-4c33-904f-7fbceee60e82',ownedVerified:true}))}); +test('default unavailable native topology constructs no factory and opens no listener',async()=>{ + const f=fixture(),c=channel();let factories=0; + const store={async snapshot(){return f.snapshot;},async envelopes(){return f.envelopes;},async commit(value){return {persisted:true,stateSha256:acceptanceStateDigest(value.candidateState)};}}; + const sent=new Promise(resolve=>{c.once('sent',resolve);c.once('disconnect',()=>resolve({type:'quarantined'}));}); + const handle=runServiceProcess({channel:c,store,factory:async()=>{factories++;throw Error('must not construct');}}); + c.emit('message',{type:'start',version:1,generation:randomUUID(),policyVersion:7,config:f.config}); + assert.ok(['startup-fault','quarantined'].includes((await sent).type));assert.equal(factories,0);await handle.close();assert.equal(c.connected,false); +}); +test('runtime topology change waits original broker cleanup before disconnect',async()=>{ + const f=fixture(),c=channel();let checks=0,closeCalls=0,releaseClose,closeStarted; + const started=new Promise(resolve=>{closeStarted=resolve;}); + const cleanup=new Promise(resolve=>{releaseClose=resolve;}); + const store={async snapshot(){return f.snapshot;},async envelopes(){return f.envelopes;},async commit(value){return {persisted:true,stateSha256:acceptanceStateDigest(value.candidateState)};}}; + const ready=new Promise(resolve=>c.once('sent',resolve)); + const handle=runServiceProcess({channel:c,store,readTopology:async()=>({...await verifiedTopology(),bfeRunning:++checks===1}),factory:async()=>({authMode:'basic',credentials:{username:'safetynet-'+'a'.repeat(32),password:'a'.repeat(43)},async listen(){return 43120;},close(){closeCalls++;closeStarted();return cleanup;}})}); + c.emit('message',{type:'start',version:1,generation:randomUUID(),policyVersion:7,config:f.config}); + assert.equal((await ready).type,'ready'); + await started;assert.equal(closeCalls,1);assert.equal(c.connected,true); + const original=handle.close();assert.equal(handle.close(),original); + releaseClose();await original;assert.equal(c.connected,false);assert.equal(closeCalls,1); +}); +test('signed service entry commits exact verified state before factory or listener',async()=>{ + const f=fixture(),c=channel(),order=[];let request; + const store={async snapshot(){order.push('snapshot');return f.snapshot;},async envelopes(){order.push('files');return f.envelopes;},async commit(value){order.push('commit');request=value;return {persisted:true,stateSha256:acceptanceStateDigest(value.candidateState)};}}; + const finished=new Promise(resolve=>c.once('sent',resolve));const handle=runServiceProcess({channel:c,store,readTopology:verifiedTopology,factory:async(config)=>{order.push('factory');assert.equal(config.minimumVersion,7);return {authMode:'basic',credentials:{username:'safetynet-'+'a'.repeat(32),password:'a'.repeat(43)},async listen(){order.push('listen');return 43120;},async close(){order.push('close');}};}}); + c.emit('message',{type:'start',version:1,generation:randomUUID(),policyVersion:7,config:f.config});assert.equal((await finished).type,'ready');assert.deepEqual(order,['snapshot','files','commit','factory','listen']);assert.equal(request.expectedPreviousSHA,acceptanceStateDigest(f.snapshot.acceptanceState));await handle.close();assert.equal(c.connected,false); +}); +test('failed durable commit and modified policy cannot construct the real factory',async()=>{ + for(const tamper of [false,true]){const f=fixture(),c=channel();let called=0,commits=0;if(tamper)f.config.envelope.payload+=' '; + const sent=new Promise(resolve=>c.once('sent',resolve));const handle=runServiceProcess({channel:c,store:{async snapshot(){return f.snapshot;},async envelopes(){return f.envelopes;},async commit(){commits++;throw Error('sensitive native error');}},factory:async()=>{called++;}}); + c.emit('message',{type:'start',version:1,generation:randomUUID(),policyVersion:7,config:f.config});const result=await sent;assert.equal(result.type,'startup-fault');assert.equal(result.code,'broker-bootstrap-invalid');assert.equal(called,0);assert.equal(commits,tamper?0:1);await handle.close(); + } +}); diff --git a/tests/broker-service-package.test.mjs b/tests/broker-service-package.test.mjs new file mode 100644 index 0000000..547eb67 --- /dev/null +++ b/tests/broker-service-package.test.mjs @@ -0,0 +1,28 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +import {readFile,writeFile,mkdir,mkdtemp,rm} from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import {spawn} from 'node:child_process'; +const require=createRequire(import.meta.url),asar=require('@electron/asar'); +const {getFileMatchers,copyFiles}=require('app-builder-lib/out/fileMatcher.js'); +test('actual builder file matching puts only fixed framing dependency in ASAR and isolated adapter loads',async t=>{ + const repo=process.cwd(),root=await mkdtemp(path.join(os.tmpdir(),'safetynet-service-package-'));t.after(()=>rm(root,{recursive:true,force:true})); + const source=path.join(root,'source'),app=path.join(root,'app'),extracted=path.join(root,'isolated'); + for(const file of ['apps/browser/broker-service-supervisor.cjs','apps/browser/broker-supervisor.cjs','apps/network-broker/framed-channel.cjs']){const target=path.join(source,file);await mkdir(path.dirname(target),{recursive:true});await writeFile(target,await readFile(path.join(repo,file)));} + await writeFile(path.join(source,'apps/network-broker/unreviewed-extra.cjs'),'throw Error("must not ship inside browser");'); + const pkg=JSON.parse(await readFile(path.join(repo,'package.json'),'utf8')); + assert.equal(pkg.build.files.filter(value=>value==='apps/network-broker/framed-channel.cjs').length,1); + assert.ok(!pkg.build.files.some(value=>typeof value==='string'&&value.startsWith('apps/network-broker/')&&value.includes('*'))); + const matchers=getFileMatchers(pkg.build,'files',app,{defaultSrc:source,globalOutDir:path.join(root,'out'),macroExpander:value=>value,customBuildOptions:{}}); + const normal=matchers.filter(matcher=>matcher.from===source);assert.equal(normal.length,1);await copyFiles(normal,null,false); + const archive=path.join(root,'app.asar');await asar.createPackage(app,archive); + assert.deepEqual(asar.extractFile(archive,path.normalize('apps/network-broker/framed-channel.cjs')),await readFile(path.join(repo,'apps/network-broker/framed-channel.cjs'))); + assert.ok(!asar.listPackage(archive).some(value=>value.endsWith('unreviewed-extra.cjs'))); + asar.extractAll(archive,extracted); + const script="const value=require('./apps/browser/broker-service-supervisor.cjs');process.stdout.write(JSON.stringify({loaded:typeof value.createServiceBrokerSupervisor==='function'}));"; + const env={};for(const key of ['SystemRoot','WINDIR','TEMP','TMP'])if(process.env[key])env[key]=process.env[key]; + const output=await new Promise((resolve,reject)=>{const child=spawn(process.execPath,['-e',script],{cwd:extracted,env,windowsHide:true,timeout:5000});let bytes=0,stdout='';child.stdout.on('data',chunk=>{bytes+=chunk.length;if(bytes>4096){child.kill();reject(Error('package-probe-output-limit'));}else stdout+=chunk;});child.stderr.on('data',()=>{});child.once('error',reject);child.once('close',code=>code===0?resolve(stdout):reject(Error('package-probe-failed')));}); + assert.deepEqual(JSON.parse(output),{loaded:true}); +}); diff --git a/tests/broker-service-store.test.mjs b/tests/broker-service-store.test.mjs new file mode 100644 index 0000000..79eafd5 --- /dev/null +++ b/tests/broker-service-store.test.mjs @@ -0,0 +1,50 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawn} from 'node:child_process'; +import fs,{mkdtemp,writeFile,rm,symlink,mkdir} from 'node:fs/promises'; +import {generateKeyPairSync} from 'node:crypto'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import {createRequire} from 'node:module'; +const require=createRequire(import.meta.url),{invokeHelper,createServiceStoreClient,readEnvelope}=require('../apps/network-broker/service-store-client.cjs'); +const {signManifest,verifyReleaseEnvelope}=require('../packages/core/windows-trust.cjs'); +function launchScript(code,observe=()=>{}){return (file,args,options)=>{observe(file,args,options);return spawn(process.execPath,['-e',code],{stdio:['pipe','pipe','ignore']});};} +const emit="const b=Buffer.from(JSON.stringify({persisted:true,stateSha256:'a'.repeat(64)}));const h=Buffer.alloc(4);h.writeUInt32LE(b.length);process.stdout.write(Buffer.concat([h,b]));"; +test('actual owned subprocess one framed reply is accepted only after terminal close',async()=>{ + let settled=false;const work=invokeHelper('--commit-acceptance',{expectedPreviousSHA:'b'.repeat(64),candidateState:{}},{launch:launchScript(emit+'setTimeout(()=>process.exit(0),150);',(_,args,options)=>{assert.deepEqual(args,['--commit-acceptance']);assert.equal(options.shell,false);assert.equal(options.windowsHide,true);assert.deepEqual(Object.keys(options.env).sort(),['SystemRoot','WINDIR']);})});work.then(()=>{settled=true;});await new Promise(r=>setTimeout(r,75));assert.equal(settled,false);assert.equal((await work).persisted,true); +}); +test('native-minted canonical SID alone is preserved to actual helper subprocess',async()=>{ + const old=process.env.SAFETYNET_SERVICE_CLIENT_SID;process.env.SAFETYNET_SERVICE_CLIENT_SID='S-1-12-1-1-2-3-4'; + try{const value=await invokeHelper('--trust-snapshot',undefined,{launch:(file,args,options)=>{assert.deepEqual(Object.keys(options.env).sort(),['SAFETYNET_SERVICE_CLIENT_SID','SystemRoot','WINDIR']);return spawn(process.execPath,['-e',"const b=Buffer.from(JSON.stringify({sid:process.env.SAFETYNET_SERVICE_CLIENT_SID}));const h=Buffer.alloc(4);h.writeUInt32LE(b.length);process.stdout.write(Buffer.concat([h,b]));"],{stdio:['pipe','pipe','ignore'],env:options.env});}});assert.equal(value.sid,'S-1-12-1-1-2-3-4');process.env.SAFETYNET_SERVICE_CLIENT_SID='S-1-12-1-01-2-3-4';await assert.rejects(invokeHelper('--trust-snapshot',undefined),{message:'broker-bootstrap-invalid'});}finally{if(old===undefined)delete process.env.SAFETYNET_SERVICE_CLIENT_SID;else process.env.SAFETYNET_SERVICE_CLIENT_SID=old;} +}); +test('large genuinely signed release survives bounded partial reads and verifies cryptographically',async()=>{ + const root=await mkdtemp(path.join(tmpdir(),'safetynet-large-release-')),keys=generateKeyPairSync('ec',{namedCurve:'prime256v1'}); + try{ + const paths=['SafetyNet.exe','resources/app.asar','resources/broker/node.exe','resources/inspector/server.py','resources/python/python.exe','resources/semantic/model.bin','safetynet-service-host.exe',...Array.from({length:6000},(_,i)=>'payload/'+String(i).padStart(5,'0')+'x'.repeat(205)+'.bin')].sort(); + const manifest={format:'safetynet-windows-release-v1',releaseId:'12345678-1234-1234-1234-123456789abc',releaseSequence:1,serviceName:'SafetyNetNetworkBroker',deploymentTenant:'22345678-1234-1234-1234-123456789abc',deploymentPublicKey:keys.publicKey.export({type:'spki',format:'pem'}),files:paths.map(path=>({path,bytes:1,sha256:'a'.repeat(64)}))}; + const envelope=signManifest(manifest,keys.privateKey.export({type:'pkcs8',format:'pem'}),'release');assert.ok(Buffer.byteLength(JSON.stringify(envelope))>2097152);await writeFile(path.join(root,'release-envelope.json'),JSON.stringify(envelope)); + const partial={...fs,async open(...args){const h=await fs.open(...args);return {stat:()=>h.stat(),close:()=>h.close(),read:(b,o,n,p)=>h.read(b,o,Math.min(n,4096),p)};}}; + const actual=await readEnvelope(root,'release-envelope.json',partial);assert.equal(verifyReleaseEnvelope(actual,{publicKey:keys.publicKey.export({type:'spki',format:'pem'})}).manifest.files.length,6007); + const changed={...fs,async open(...args){const h=await fs.open(...args);let stats=0;return {close:()=>h.close(),read:(...a)=>h.read(...a),async stat(){const s=await h.stat();if(++stats>1)s.mtimeMs++;return s;}};}};await assert.rejects(readEnvelope(root,'release-envelope.json',changed),{message:'broker-bootstrap-invalid'}); + }finally{await rm(root,{recursive:true,force:true});} +}); +test('physical framing rejects duplicate, trailing, invalid UTF8 and nonzero exit',async()=>{ + for(const suffix of ['process.stdout.write(Buffer.from([0]));','process.stdout.write(Buffer.from([2,0,0,0,123,125]));','process.exitCode=5;'])await assert.rejects(invokeHelper('--trust-snapshot',undefined,{launch:launchScript(emit+suffix)}),{message:'broker-bootstrap-invalid'}); + await assert.rejects(invokeHelper('--trust-snapshot',undefined,{launch:launchScript("process.stdout.write(Buffer.from([2,0,0,0,255,255]));")}),{message:'broker-bootstrap-invalid'}); +}); +test('physical hanging helper deadline kills its owned process, no retry or acceptance',async()=>{ + let calls=0;await assert.rejects(invokeHelper('--trust-snapshot',undefined,{deadlineMs:50,launch:launchScript('setInterval(()=>{},1000);',()=>calls++)}),{message:'broker-bootstrap-invalid'});assert.equal(calls,1); +}); +test('commit has smaller reply limit and invalid snapshot cannot become trust',async()=>{ + await assert.rejects(invokeHelper('--commit-acceptance',{}, {launch:launchScript('process.stdout.write(Buffer.from([1,0,1,0]));setInterval(()=>{},1000);')}),{message:'broker-bootstrap-invalid'}); + const client=createServiceStoreClient({launch:launchScript(emit)});await assert.rejects(client.snapshot(),{message:'broker-bootstrap-invalid'}); +}); +test('fixed envelope files are bounded and junction ancestors cannot select another trust store',async()=>{ + const root=await mkdtemp(path.join(tmpdir(),'safetynet-store-')); + try{ + const envelope={payload:'synthetic signed payload',signature:'synthetic signature'};await writeFile(path.join(root,'release-envelope.json'),JSON.stringify(envelope));await writeFile(path.join(root,'deployment-envelope.json'),JSON.stringify(envelope)); + assert.deepEqual((await createServiceStoreClient({root}).envelopes()).releaseEnvelope,envelope); + await writeFile(path.join(root,'deployment-envelope.json'),'x'.repeat(2097153));await assert.rejects(createServiceStoreClient({root}).envelopes(),{message:'broker-bootstrap-invalid'}); + const target=path.join(root,'target'),alias=path.join(root,'alias');await mkdir(target);await writeFile(path.join(target,'release-envelope.json'),JSON.stringify(envelope));await writeFile(path.join(target,'deployment-envelope.json'),JSON.stringify(envelope));await symlink(target,alias,'junction');await assert.rejects(createServiceStoreClient({root:alias}).envelopes(),{message:'broker-bootstrap-invalid'}); + }finally{await rm(root,{recursive:true,force:true});} +}); diff --git a/tests/broker-service-supervisor.test.mjs b/tests/broker-service-supervisor.test.mjs new file mode 100644 index 0000000..b22011a --- /dev/null +++ b/tests/broker-service-supervisor.test.mjs @@ -0,0 +1,62 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import {spawn} from 'node:child_process'; +import {createRequire} from 'node:module'; +import {fileURLToPath} from 'node:url'; +import {defaultPolicy} from '../packages/core/policy.mjs'; +const {createServiceBrokerSupervisor}=createRequire(import.meta.url)('../apps/browser/broker-service-supervisor.cjs'); +const {createFixtureDiagnostics}=createRequire(import.meta.url)('./fixtures/broker-native-diagnostics.cjs'); +const {createSyntheticAudit}=createRequire(import.meta.url)('./fixtures/broker-durable-audit.cjs'); +const root=path.resolve(fileURLToPath(new URL('../',import.meta.url))); +function setup(extra={}){ + let launched,quarantines=0;const events=[]; + const supervisor=createServiceBrokerSupervisor({installationRoot:root,verifyInstallation:()=>true,verifyPackage:()=>true,persistAudit:event=>{events.push(event);return {persisted:true,eventId:event.eventId};},acceptCertificate:()=>true,onQuarantine:()=>quarantines++,deadlineMs:5000,shutdownMs:5000, + launch:(executable,args,options)=>{launched={executable,args,options};return spawn(process.execPath,[fileURLToPath(new URL('./fixtures/broker-service-stdio-child.cjs',import.meta.url))],options);},...extra}); + return {supervisor,events,get launched(){return launched;},get quarantines(){return quarantines;}}; +} +test('service transport uses fixed native command and real bounded stdio protocol',async()=>{ + const f=setup();try{ + const ready=await f.supervisor.start({policyVersion:3,port:43871,config:{port:43871}}); + assert.equal(ready.port,43871);assert.equal(f.supervisor.status().ready,true); + assert.equal(f.launched.executable,path.join(root,'safetynet-service-host.exe'));assert.deepEqual(f.launched.args,['--client-stdio']); + assert.equal(f.launched.options.shell,false);assert.equal(f.launched.options.windowsHide,true);assert.deepEqual(f.launched.options.stdio,['pipe','pipe','ignore']); + assert.ok(Object.keys(f.launched.options.env).every(key=>['SystemRoot','WINDIR','TEMP','TMP','USERPROFILE','LOCALAPPDATA','APPDATA'].includes(key))); + assert.equal(f.launched.options.env.SAFETYNET_SERVICE_CLIENT_SID,undefined);assert.equal(f.launched.options.env.NODE_OPTIONS,undefined); + }finally{await f.supervisor.close();} + assert.equal(f.quarantines,0);assert.equal(f.supervisor.status().closed,true); +}); +test('real stdio audit roundtrip retains event identity and durable receipt',async()=>{ + const f=setup();try{ + await f.supervisor.start({policyVersion:3,port:43871,config:{port:43871,audit:true}}); + const until=Date.now()+3000;while(!f.events.length&&Date.now()setTimeout(resolve,10)); + assert.equal(f.events.length,1);assert.equal(f.events[0].eventId,'12345678-1234-4234-8234-123456789abc'); + assert.equal(f.supervisor.status().ready,true); + }finally{await f.supervisor.close();} +}); +test('malformed native stream quarantines and cannot restart',async()=>{ + const f=setup();await assert.rejects(f.supervisor.start({policyVersion:3,port:43871,config:{malformed:true}}),/unavailable/); + assert.equal(f.quarantines,1);assert.equal(f.supervisor.status().ready,false); + await assert.rejects(f.supervisor.start({policyVersion:3,port:43871}),/invalid/);await f.supervisor.close(); +}); +test('unverified installed native client or broker payload never launches',async()=>{ + for(const extra of [{verifyInstallation:()=>false},{verifyPackage:()=>false}]){ + const f=setup(extra);await assert.rejects(f.supervisor.start({policyVersion:3,port:43871,config:{}}),/unavailable/);assert.equal(f.launched,undefined);await f.supervisor.close(); + } +}); +test('real service-adapter subprocess exports audit-correlated OTLP timings',async t=>{ + const audit=createSyntheticAudit(t),diagnostics=await createFixtureDiagnostics({enabled:true,policy:defaultPolicy(),audit}); + const f=setup({persistAudit:diagnostics.persist,traceOperation:diagnostics.stage});let proof; + try{ + await diagnostics.run({bytes:0},async()=>{ + await f.supervisor.start({policyVersion:3,port:43871,config:{port:43871,audit:true}}); + const until=Date.now()+3000;while(!audit.events().length&&Date.now()setTimeout(resolve,10)); + assert.equal(audit.events().length,1);assert.equal(f.supervisor.status().ready,true); + await f.supervisor.close(); + }); + }finally{try{await f.supervisor.close();}finally{proof=await diagnostics.finish();}} + assert.ok(proof.spanCount>=2);assert.equal(proof.invalid,0);assert.equal(proof.failedExports,0);assert.equal(proof.lifetimeFailedExports,0); + assert.deepEqual(proof.stages.map(value=>value.name).sort(),['browser.broker.shutdown','browser.broker.start','browser.broker.verify']); + assert.ok(proof.stages.every(value=>value.parentSpanId===proof.parentSpanId&&value.durationMs>0)); + t.diagnostic(JSON.stringify({kind:'actual-service-adapter-test-child',...proof})); +}); diff --git a/tests/broker-siem.test.mjs b/tests/broker-siem.test.mjs index fb1be5e..c6f0785 100644 --- a/tests/broker-siem.test.mjs +++ b/tests/broker-siem.test.mjs @@ -22,6 +22,17 @@ test('ECS retains actual causal UUIDs and marks terminal forwarding failure as e test('a durable authorization is not reported as completed delivery',()=>{ const output=toECS(event('request.authorized'));assert.equal(output.event.outcome,'unknown');assert.deepEqual(output.event.type,['allowed']); }); +test('typed download records retain actual file evidence and causal authorization',()=>{ + for(const blocked of [false,true]){ + const input=event();input.kind=blocked?'download.block':'download.scan';delete input.metadata.request; + input.metadata.file={bytes:617,detectedType:'pdf',sha256:'a'.repeat(64)}; + input.metadata.broker.phase=blocked?'response.block':'response.scanned';input.metadata.broker.reason=blocked?'broker-download-inspection-block':'broker-download-approved'; + const output=toECS(input),cef=toCEF(input); + assert.equal(output.file.size,617);assert.equal(output.file.hash.sha256,'a'.repeat(64));assert.equal(output.event.outcome,blocked?'failure':'success'); + assert.equal(output.safetynet.broker.authorized_event_id,input.metadata.broker.authorizedEventId); + assert.ok(cef.includes('fileHash='+'a'.repeat(64)));assert.ok(cef.includes('outcome='+(blocked?'failure':'success'))); + } +}); test('CEF standard custom string preserves all causation without overwriting integrity or type',()=>{ const input=event();input.audit={hash:'synthetic-hash',sequence:3}; const output=toCEF(input);assert.ok(output.includes('cs2Label=BrokerLifecycle')); diff --git a/tests/broker-supervisor-native.test.mjs b/tests/broker-supervisor-native.test.mjs new file mode 100644 index 0000000..4e974cd --- /dev/null +++ b/tests/broker-supervisor-native.test.mjs @@ -0,0 +1,29 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtemp,mkdir,copyFile,readFile,writeFile,rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import {createHash} from 'node:crypto'; +import {fork} from 'node:child_process'; +import {createRequire} from 'node:module'; +import {BROKER_RUNTIME} from '../scripts/provision-broker-runtime.mjs'; +const {createBrokerSupervisor}=createRequire(import.meta.url)('../apps/browser/broker-supervisor.cjs'); +test('actual pinned bundled Node private IPC startup and confirmed terminal shutdown',{skip:process.platform!=='win32'||process.env.SAFETYNET_BROKER_SUPERVISOR_NATIVE!=='1'},async()=>{ + const runtime=path.resolve('.runtime/windows/broker/node.exe'); + const hash=bytes=>createHash('sha256').update(bytes).digest('hex'); + assert.equal(hash(await readFile(runtime)),BROKER_RUNTIME.executableSha256); + const root=await mkdtemp(path.join(tmpdir(),'safetynet-supervisor-')); + let child,exited=false,quarantines=0; const savedOptions=process.env.NODE_OPTIONS; process.env.NODE_OPTIONS="--require C:/safetynet-nonexistent-injection.cjs"; + try{ + await mkdir(path.join(root,'apps/network-broker'),{recursive:true});await copyFile(runtime,path.join(root,'node.exe')); + const fixture=`'use strict';if(process.version!=='v${BROKER_RUNTIME.version}'||process.env.NODE_OPTIONS||process.env.NODE_PATH||process.env.ELECTRON_RUN_AS_NODE)process.exit(91);process.on('message',m=>{if(m.type==='start')process.send({type:'ready',version:1,generation:m.generation,policyVersion:m.policyVersion,authMode:'basic',sequence:0,port:m.config.brokerPort,credentials:{username:'safetynet-'+ 'a'.repeat(32),password:'b'.repeat(43)}});});setInterval(()=>{},1000);`; + await writeFile(path.join(root,'apps/network-broker/production-entry.cjs'),fixture); + const supervisor=createBrokerSupervisor({resourceRoot:root,verifyPackage:async target=>target===root&&hash(await readFile(path.join(target,'node.exe')))===BROKER_RUNTIME.executableSha256&&await readFile(path.join(target,'apps/network-broker/production-entry.cjs'),'utf8')===fixture,persistAudit:()=>{throw Error();},acceptCertificate:()=>false,onQuarantine:()=>quarantines++,spawn:(...args)=>{assert.equal(args[2].env.NODE_OPTIONS,undefined);child=fork(...args);child.once('exit',()=>{exited=true;});return child;}}); + const ready=await supervisor.start({policyVersion:1,port:43871,config:{brokerPort:43871}});assert.equal(ready.port,43871); + await supervisor.close();assert.equal(exited,true);assert.notEqual(child.exitCode===null&&child.signalCode===null,true);assert.equal(quarantines,0); + }finally{ + if(savedOptions===undefined)delete process.env.NODE_OPTIONS;else process.env.NODE_OPTIONS=savedOptions; + if(child&&!exited){child.kill('SIGKILL');await new Promise(resolve=>child.once('exit',resolve));} + await rm(root,{recursive:true,force:true}); + } +}); diff --git a/tests/broker-supervisor.test.mjs b/tests/broker-supervisor.test.mjs new file mode 100644 index 0000000..bf51e67 --- /dev/null +++ b/tests/broker-supervisor.test.mjs @@ -0,0 +1,66 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {EventEmitter} from 'node:events'; +import {createRequire} from 'node:module'; +const {createBrokerSupervisor}=createRequire(import.meta.url)('../apps/browser/broker-supervisor.cjs'); +function fixture(extra={}){const child=new EventEmitter();child.sent=[];child.send=(m,cb)=>{child.sent.push(m);cb();};child.kill=()=>{child.killed=true;child.exitCode=0;child.emit('exit',0);};let options,quarantines=0;const supervisor=createBrokerSupervisor({resourceRoot:'C:/trusted/broker',verifyPackage:()=>true,persistAudit:event=>({persisted:true,eventId:event.eventId}),acceptCertificate:()=>true,onQuarantine:()=>quarantines++,spawn:(entry,args,opts)=>{options={entry,args,opts};return child;},deadlineMs:100,...extra});return {child,supervisor,get options(){return options;},get quarantines(){return quarantines;}};} +async function starting(f){const pending=f.supervisor.start({policyVersion:3,port:43871,config:{synthetic:true}});await new Promise(resolve=>setImmediate(resolve));return {pending,context:(({version,generation,policyVersion})=>({version,generation,policyVersion}))(f.child.sent[0])};} +function ready(f,context){f.child.emit('message',{type:'ready',...context,authMode:'basic',sequence:0,port:43871,credentials:{username:'safetynet-'+'a'.repeat(32),password:'b'.repeat(43)}});} +test('fixed verified launch, sanitized environment and private authenticated generation',async()=>{const f=fixture();const {pending,context}=await starting(f);ready(f,context);assert.equal((await pending).port,43871);assert.deepEqual(f.options.opts.execArgv,[]);assert.equal(f.options.opts.env.NODE_OPTIONS,undefined);assert.equal(f.options.opts.env.NODE_PATH,undefined);assert.ok(Object.keys(f.options.opts.env).every(key=>['SystemRoot','WINDIR','TEMP','TMP','USERPROFILE','LOCALAPPDATA','APPDATA'].includes(key)));for(const key of ['USERPROFILE','LOCALAPPDATA','APPDATA'])assert.equal(f.options.opts.env[key],process.env[key]);assert.ok(f.options.entry.endsWith('production-entry.cjs'));assert.equal(f.supervisor.status().ready,true);await f.supervisor.close();assert.equal(f.child.killed,true);}); +test('audit receipt preserves authenticated event ID',async()=>{const f=fixture();const {pending,context}=await starting(f);ready(f,context);await pending;const eventId='12345678-1234-1234-1234-123456789abc';f.child.emit('message',{type:'audit',...context,requestId:eventId,sequence:1,event:{eventId}});await new Promise(resolve=>setImmediate(resolve));assert.equal(f.child.sent[1].receipt.eventId,eventId);await f.supervisor.close();}); +test('wrong generation quarantines with no retry',async()=>{const f=fixture();const {pending,context}=await starting(f);const rejected=assert.rejects(pending,/unavailable/);ready(f,{...context,generation:'wrong'});await rejected;assert.equal(f.quarantines,1);assert.equal(f.child.killed,true);await assert.rejects(f.supervisor.start({policyVersion:3,port:43871}),/invalid/);}); +test('deadline and child crash fail closed',async()=>{const f=fixture({deadlineMs:5});const {pending}=await starting(f);await assert.rejects(pending,/unavailable/);assert.equal(f.quarantines,1);const g=fixture();const {pending:p,context}=await starting(g);ready(g,context);await p;g.child.emit('exit',1);assert.equal(g.supervisor.status().ready,false);assert.equal(g.quarantines,1);}); +test('unverified package never spawns',async()=>{const f=fixture({verifyPackage:()=>false});await assert.rejects(f.supervisor.start({policyVersion:3,port:43871,config:{}}),/unavailable/);assert.equal(f.options,undefined);}); + +test('close waits terminal exit and rejects unconfirmed shutdown',async()=>{ + const f=fixture({shutdownMs:10});const {pending,context}=await starting(f);ready(f,context);await pending; + f.child.kill=()=>{f.child.killed=true;}; + await assert.rejects(f.supervisor.close(),/shutdown-unconfirmed/); + assert.equal(f.supervisor.status().failed,true);assert.equal(f.quarantines,1); +}); +test('concurrent start reserves before asynchronous package verification',async()=>{ + let release;const f=fixture({verifyPackage:()=>new Promise(resolve=>{release=resolve;})}); + const first=f.supervisor.start({policyVersion:3,port:43871,config:{}}); + await assert.rejects(f.supervisor.start({policyVersion:3,port:43871,config:{}}),/invalid/); + release(true);await new Promise(resolve=>setImmediate(resolve)); + const context=(({version,generation,policyVersion})=>({version,generation,policyVersion}))(f.child.sent[0]);ready(f,context);await first;await f.supervisor.close(); +}); + +test('monotonic stream rejects replay, gaps and out-of-order requests',async()=>{ + for(const sequences of [[1,1],[2],[1,3],[1,0]]){ + const f=fixture();const {pending,context}=await starting(f);ready(f,context);await pending; + for(const sequence of sequences)f.child.emit('message',{type:'audit',...context,sequence,requestId:'12345678-1234-1234-1234-123456789abc',event:{eventId:'12345678-1234-1234-1234-123456789abc'}}); + assert.equal(f.supervisor.status().failed,true);assert.equal(f.quarantines,1);await f.supervisor.close(); + } +}); +test('more than 10000 legitimate requests retain constant replay state',async()=>{ + const f=fixture();const {pending,context}=await starting(f);ready(f,context);await pending; + for(let sequence=1;sequence<=10001;sequence++){ + const requestId=`12345678-1234-1234-1234-${sequence.toString(16).padStart(12,'0')}`; + f.child.emit('message',{type:'audit',...context,sequence,requestId,event:{eventId:requestId}}); + assert.equal(f.child.sent.at(-1).sequence,sequence); + } + assert.equal(f.supervisor.status().ready,true);assert.equal(f.quarantines,0);await f.supervisor.close(); +}); +test('fixed startup diagnostic is local, bounded and never grants readiness',async()=>{ + const f=fixture();const {pending,context}=await starting(f);const rejection=assert.rejects(pending,/unavailable/); + f.child.emit('message',{type:'startup-fault',...context,code:'broker-startup-inspector'});await rejection; + assert.equal(f.supervisor.status().startupCode,'broker-startup-inspector');assert.equal(f.supervisor.status().ready,false);await f.supervisor.close(); + const g=fixture();const started=await starting(g);const rejected=assert.rejects(started.pending,/unavailable/); + g.child.emit('message',{type:'startup-fault',...started.context,code:'raw arbitrary exception'});await rejected;assert.equal(g.supervisor.status().startupCode,undefined);await g.supervisor.close(); +}); + +test('diagnostics cannot skip or replace verification and cannot repeat protected work',async()=>{ + for(const tracer of [()=>true,()=>{throw Error('fixed-diagnostics-failure');},async(_name,_attributes,run)=>{await run();try{await run();}catch{}return true;}]){ + let calls=0;const f=fixture({verifyPackage:()=>{calls++;return false;},traceOperation:tracer}); + await assert.rejects(f.supervisor.start({policyVersion:3,port:43871,config:{}}),/unavailable/); + assert.equal(calls,1);assert.equal(f.options,undefined);await f.supervisor.close(); + } +}); +test('diagnostic stages retain fixed names and original ready and shutdown outcomes',async()=>{ + const stages=[];const f=fixture({traceOperation:async(name,attributes,run)=>{stages.push({name,attributes});await run();return {ready:false};}}); + const {pending,context}=await starting(f);ready(f,context); + assert.equal((await pending).port,43871);await f.supervisor.close(); + assert.deepEqual(stages.map(value=>value.name),['browser.broker.verify','browser.broker.start','browser.broker.shutdown']); + assert.ok(stages.every(value=>JSON.stringify(value.attributes)==='{"component":"network"}')); +}); diff --git a/tests/broker-tls-authority.test.mjs b/tests/broker-tls-authority.test.mjs new file mode 100644 index 0000000..a16031a --- /dev/null +++ b/tests/broker-tls-authority.test.mjs @@ -0,0 +1,42 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +const {parseAuthority,bindConnectAuthority,verifyInnerAuthority}=createRequire(import.meta.url)('../apps/network-broker/tls-authority.cjs'); + +test('canonical DNS, IPv4 and bracketed IPv6 ports and default 443',()=>{ + assert.equal(parseAuthority('Example.COM').authority,'example.com:443'); + assert.equal(parseAuthority('example.com:443').authority,'example.com:443'); + assert.equal(parseAuthority('127.0.0.1:8443').authority,'127.0.0.1:8443'); + assert.equal(parseAuthority('[2001:0DB8:0:0::1]').authority,'[2001:db8::1]:443'); + assert.equal(parseAuthority('xn--bcher-kva.example:65535').port,65535); + assert.throws(()=>bindConnectAuthority('example.com'),{code:'missing-port'}); +}); +test('reject legacy IP aliases, controls, userinfo, paths and ambiguous ports',()=>{ + for(const value of ['127.1:443','0177.0.0.1:443','2130706433:443','0x7f000001:443','0x7f.0.0.1:443','example.com:0','example.com:0443','example.com:65536','example.com:+443','example.com:','example.com:443:4','user@example.com:443','example.com/path','example.com?x',' example.com','example.com\r\n','example.com.','-bad.example','bad_.example','[fe80::1%25eth0]:443','::1:443','https://example.com:443']) { + assert.throws(()=>parseAuthority(value),error=>error.name==='AuthorityError'&&!error.message.includes(value)); + } +}); +test('inner Host, absolute URL and SNI bind to authenticated CONNECT',()=>{ + const binding=bindConnectAuthority('EXAMPLE.com:443'); + assert.equal(verifyInnerAuthority(binding,{rawHeaders:['Host','example.com'],url:'/submit?x=1',servername:'example.com'}),binding); + assert.equal(verifyInnerAuthority(binding,{rawHeaders:['host','EXAMPLE.com:443'],url:'https://example.com/submit',servername:'EXAMPLE.COM'}),binding); + assert.ok(Object.isFrozen(binding)); +}); +test('cross authority reuse and missing/duplicate Host or mismatched SNI fail closed',()=>{ + const binding=bindConnectAuthority('example.com:443'); + const valid={rawHeaders:['Host','example.com'],url:'/',servername:'example.com'}; + for(const changed of [ + {rawHeaders:[]},{rawHeaders:['Host','example.com','host','example.com']}, + {rawHeaders:['Host','evil.com']},{rawHeaders:['Host','example.com:8443']}, + {url:'https://evil.com/'},{url:'https://example.com:8443/'}, + {url:'https://user@example.com/'},{url:'http://example.com/'}, + {url:'//evil.com/'},{url:'/x#fragment'},{url:'/x\r\n'}, + {servername:'evil.com'},{servername:'example.com:443'},{servername:undefined} + ])assert.throws(()=>verifyInnerAuthority(binding,{...valid,...changed})); + assert.throws(()=>verifyInnerAuthority({...binding},valid),{code:'invalid-binding'}); +}); +test('IP CONNECT binding accepts absent SNI but does not accept a different IP',()=>{ + const binding=bindConnectAuthority('[::1]:443'); + assert.equal(verifyInnerAuthority(binding,{rawHeaders:['Host','[0:0:0:0:0:0:0:1]'],url:'https://[::1]/'}),binding); + assert.throws(()=>verifyInnerAuthority(binding,{rawHeaders:['Host','[::1]'],url:'/',servername:'::2'}),{code:'sni-mismatch'}); +}); diff --git a/tests/broker-tls-certificates.test.mjs b/tests/broker-tls-certificates.test.mjs new file mode 100644 index 0000000..ed7595e --- /dev/null +++ b/tests/broker-tls-certificates.test.mjs @@ -0,0 +1,40 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +import {X509Certificate} from 'node:crypto'; +import https from 'node:https'; +const require=createRequire(import.meta.url); +const {createSessionCertificates}=require('../apps/network-broker/tls-certificates.cjs'); +test('session leaf has exact SAN, bounded lifetime, valid signature and no CA authority',async()=>{ + const issuer=await createSessionCertificates({current:()=>true,authorized:()=>true}); + try{for(const authority of ['example.com:443','127.0.0.1:443','[::1]:443','[::ffff:c000:201]:443']){ + const leaf=issuer.resolve({authority}),certificate=new X509Certificate(leaf.cert); + assert.equal(certificate.ca,false);assert.equal(certificate.verify(certificate.publicKey),true); + assert.equal(certificate.checkHost('attacker.test',{subject:'never'}),undefined); + assert.ok(Date.parse(certificate.validTo)<=Date.now()+3600000); + assert.equal(issuer.resolve({authority}),leaf); + const metadata=issuer.publicMetadata({authority}); + assert.deepEqual(Object.keys(metadata).sort(),['authority','cert','expiresAt','fingerprint256']); + assert.equal(metadata.fingerprint256,certificate.fingerprint256); + }}finally{issuer.close();} + assert.throws(()=>issuer.resolve({authority:'example.com:443'}),/broker-certificate-unavailable/); +}); +test('deny, revocation and cache exhaustion do not issue or reuse leaves',async()=>{ + let active=true,allowed=true; + const issuer=await createSessionCertificates({maxAuthorities:1,current:()=>active,authorized:()=>allowed}); + try{issuer.resolve({authority:'example.com:443'}); + assert.throws(()=>issuer.resolve({authority:'other.test:443'}),/broker-certificate-unavailable/); + allowed=false;assert.throws(()=>issuer.resolve({authority:'example.com:443'}),/broker-certificate-unavailable/); + allowed=true;active=false;assert.throws(()=>issuer.resolve({authority:'example.com:443'}),/broker-certificate-unavailable/); + }finally{issuer.close();} +}); +test('generated certificate works with verified physical HTTPS and rejects a different hostname',async()=>{ + const issuer=await createSessionCertificates({current:()=>true,authorized:()=>true}); + const leaf=issuer.resolve({authority:'localhost:443'}); + const server=https.createServer({key:leaf.key,cert:leaf.cert},(_request,response)=>response.end('public')); + try{await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve)); + const request=servername=>new Promise((resolve,reject)=>{https.get({host:'127.0.0.1',port:server.address().port,servername,ca:leaf.cert,agent:false},response=>{let body='';response.on('data',part=>body+=part);response.on('end',()=>resolve(body));}).on('error',reject);}); + assert.equal(await request('localhost'),'public'); + await assert.rejects(request('attacker.test'),{code:'ERR_TLS_CERT_ALTNAME_INVALID'}); + }finally{await new Promise(resolve=>server.close(resolve));issuer.close();} +}); diff --git a/tests/broker-wfp-listener-plan.test.mjs b/tests/broker-wfp-listener-plan.test.mjs new file mode 100644 index 0000000..b81ee88 --- /dev/null +++ b/tests/broker-wfp-listener-plan.test.mjs @@ -0,0 +1,15 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +const {GUIDS,planBrokerListenerProtection}=createRequire(import.meta.url)('../apps/network-broker/windows-wfp-plan.cjs'); +const appId=Buffer.from('\\device\\harddiskvolume3\\safetynet\\node.exe\0','utf16le'); +test('supplemental receiver deny protects exact tuple and preserves same-exe limitation',()=>{ + const p=planBrokerListenerProtection({appId,brokerPort:43871}); + assert.equal(p.completeBoundary,false);assert.equal(p.activation,'not-installed'); + const matches=(packet)=>p.filters[0].conditions.every(c=>c.match==='equal'?packet[c.field]===c.value:packet[c.field]!==c.value); + const tuple={[GUIDS.protocol]:6,[GUIDS.localAddress]:0x7f000001,[GUIDS.localPort]:43871,[GUIDS.appId]:'other'}; + assert.equal(matches(tuple),true); + for(const [field,value] of [[GUIDS.protocol,17],[GUIDS.localAddress,0x7f000002],[GUIDS.localPort,43872],[GUIDS.appId,p.applicationId]])assert.equal(matches({...tuple,[field]:value}),false); + assert.equal(p.filters[0].action,'FWP_ACTION_BLOCK');assert.equal(p.filters[0].clearActionRight,false); + assert.ok(p.prerequisites.includes('same-executable-instance-protection')); +}); diff --git a/tests/broker-wfp-plan.test.mjs b/tests/broker-wfp-plan.test.mjs new file mode 100644 index 0000000..ced642f --- /dev/null +++ b/tests/broker-wfp-plan.test.mjs @@ -0,0 +1,58 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +const require=createRequire(import.meta.url); +const {GUIDS,planBrowserNetworkBoundary}=require('../apps/network-broker/windows-wfp-plan.cjs'); +const identity=Buffer.from('\\device\\harddiskvolume3\\program files\\safetynet\\safetynet.exe\0'.replaceAll('\\\\','\\').replace('\\0','\0'),'utf16le'); +function plan(port=43871){return planBrowserNetworkBoundary({appId:identity,brokerPort:port});} +// Independent interpreter of a serialized WFP conjunction: no production matcher. +function matchingFilters(p,packet){return p.filters.filter(rule=>rule.layer===(packet.family===4?GUIDS.connectV4:GUIDS.connectV6)&&rule.conditions.every(c=>{ + const values={[GUIDS.appId]:packet.appId,[GUIDS.protocol]:packet.protocol,[GUIDS.remoteAddress]:packet.address,[GUIDS.remotePort]:packet.port}; + if(c.match==='equal')return values[c.field]===c.value; + if(c.match==='not-equal')return values[c.field]!==c.value; + throw Error('Unsupported match'); +}));} +test('only approved IPv4 TCP broker tuple survives exhaustive port/protocol edges',()=>{ + const p=plan(); + for(const address of [0,0x7f000001,0x7f000002,0x7fffffff,0x0a000001,0xffffffff]){ + for(const protocol of [0,1,6,17,58,255]){ + for(const port of [0,1,53,80,443,3478,43870,43871,43872,65535]){ + const matches=matchingFilters(p,{family:4,address,protocol,port,appId:p.applicationId}); + const approved=address===0x7f000001&&protocol===6&&port===43871; + assert.equal(matches.length,approved?0:1,JSON.stringify({address,protocol,port})); + } + } + } + for(let port=0;port<=65535;port++)assert.equal(matchingFilters(p,{family:4,address:0x7f000001,protocol:6,port,appId:p.applicationId}).length,port===43871?0:1); +}); +test('all IPv6 including mapped loopback is denied, unrelated applications untouched',()=>{ + const p=plan(); + for(const address of ['::1','::ffff:127.0.0.1','fe80::1','2001:db8::1'])for(const protocol of [6,17,58]){ + assert.equal(matchingFilters(p,{family:6,address,protocol,port:43871,appId:p.applicationId}).length,1); + assert.equal(matchingFilters(p,{family:6,address,protocol,port:43871,appId:'other-app'}).length,0); + } + assert.equal(matchingFilters(p,{family:4,address:0x08080808,protocol:17,port:53,appId:'other-app'}).length,0); +}); +test('plan contains only deny rules and never overrides enterprise veto or activates BFE',()=>{ + const p=plan();assert.equal(p.activation,'not-installed');assert.ok(p.filters.every(f=>f.action==='FWP_ACTION_BLOCK'&&f.clearActionRight===false)); + assert.ok(Object.isFrozen(p)&&Object.isFrozen(p.filters)&&p.filters.every(f=>Object.isFrozen(f.conditions))); + const copy=Buffer.from(identity);const q=planBrowserNetworkBoundary({appId:copy,brokerPort:43871});copy.fill(0);assert.equal(q.applicationId,identity.toString('hex')); +}); +test('rejects invalid ports and noncanonical or arbitrary executable identities',()=>{ + for(const port of [0,65536,1.5,'443',NaN])assert.throws(()=>plan(port)); + for(const path of ['C:\\safetynet.exe\0','\\device\\harddiskvolume3\\..\\a.exe\0','\\device\\harddiskvolume3\\A.exe\0','\\device\\harddiskvolume3\\a.exe','\\device\\harddiskvolume3\\a.exe:stream\0'])assert.throws(()=>planBrowserNetworkBoundary({appId:Buffer.from(path,'utf16le'),brokerPort:123})); +}); +test('inbound-first flows are denied for the browser in both families',()=>{ + const p=plan();assert.equal(p.version,2);assert.equal(p.filters.length,6); + for(const layer of [GUIDS.recvV4,GUIDS.recvV6])for(const protocol of [1,6,17,58,255])for(const port of [0,53,43871,65535]){ + const packet={[GUIDS.appId]:p.applicationId,[GUIDS.protocol]:protocol,[GUIDS.localPort]:port}; + const matches=p.filters.filter(rule=>rule.layer===layer&&rule.conditions.every(c=>c.match==='equal'?packet[c.field]===c.value:packet[c.field]!==c.value)); + assert.equal(matches.length,1);assert.equal(matches[0].action,'FWP_ACTION_BLOCK'); + assert.equal(matches[0].conditions.length,1); + } + // Model the documented ALE stateful direction: an approved outbound CONNECT + // creates its flow; its reply is not a NEW incoming accept authorization. + assert.equal(matchingFilters(p,{family:4,address:0x7f000001,protocol:6,port:43871,appId:p.applicationId}).length,0); + assert.ok(p.prerequisites.includes('physical-inbound-first-tcp-udp-tests')); + assert.ok(p.prerequisites.includes('browser-management-inspector-telemetry-and-agent-ipc-migration')); +}); diff --git a/tests/broker-wfp-revalidation.test.mjs b/tests/broker-wfp-revalidation.test.mjs new file mode 100644 index 0000000..0b14f60 --- /dev/null +++ b/tests/broker-wfp-revalidation.test.mjs @@ -0,0 +1,9 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +const require=createRequire(import.meta.url); +const {createWfpRevalidation}=require('../apps/network-broker/wfp-revalidation.cjs'); +const valid=()=>({bfeRunning:true,ownedSublayer:'ours',enumerationComplete:true,allObjectsReadable:true,sublayers:[{key:'ours',weight:65535}],filters:Array.from({length:8},(_,i)=>({key:String(i),sublayer:'ours',layer:'c38d57d1-05a7-4c33-904f-7fbceee60e82',ownedVerified:true}))}); +test('unreadable native snapshot never admits',async()=>{const reasons=[];const m=createWfpRevalidation({readTopology:async()=>({...valid(),allObjectsReadable:false}),quarantine:r=>reasons.push(r)});assert.equal(await m.start(),false);assert.equal(m.current(),false);assert.deepEqual(reasons,['topology-incomplete']);m.close();}); +test('slow initial original callback remains admitted only once and stale success cannot recover',async()=>{let resolve,calls=0;const reasons=[];const m=createWfpRevalidation({readTopology:()=>{calls++;return new Promise(r=>{resolve=r;});},quarantine:r=>reasons.push(r),deadlineMs:10});const starting=m.start();await new Promise(r=>setTimeout(r,30));assert.equal(m.status().pending,true);assert.equal(m.current(),false);resolve(valid());assert.equal(await starting,false);assert.equal(calls,1);assert.deepEqual(reasons,['wfp-revalidation-timeout']);m.close();}); +test('periodic failed BFE status quarantines and never auto recovers',async()=>{let calls=0;const reasons=[];const m=createWfpRevalidation({readTopology:async()=>({...valid(),bfeRunning:++calls===1}),quarantine:r=>reasons.push(r),intervalMs:100});assert.equal(await m.start(),true);await new Promise(r=>setTimeout(r,150));assert.equal(m.current(),false);assert.deepEqual(reasons,['wfp-bfe-unavailable']);m.close();}); diff --git a/tests/broker-wfp-topology.test.mjs b/tests/broker-wfp-topology.test.mjs new file mode 100644 index 0000000..73c89c1 --- /dev/null +++ b/tests/broker-wfp-topology.test.mjs @@ -0,0 +1,13 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +const require=createRequire(import.meta.url); +const {GUIDS}=require('../apps/network-broker/windows-wfp-plan.cjs'); +const {validateTopology,createTopologyGuard,planServiceReceiver}=require('../apps/network-broker/wfp-topology.cjs'); +const snapshot=()=>({ownedSublayer:'ours',enumerationComplete:true,allObjectsReadable:true,sublayers:[{key:'ours',weight:65535},{key:'corp',weight:100}],filters:[...Array.from({length:8},(_,i)=>({key:String(i),sublayer:'ours',layer:GUIDS.connectV4,ownedVerified:true})),{sublayer:'corp',layer:GUIDS.recvV4,action:'BLOCK'}]}); +test('complete highest owned topology admits while corporate block stays present',()=>{assert.equal(validateTopology(snapshot()).safe,true);assert.equal(validateTopology(snapshot()).zeroChangeWindowProven,false);}); +test('equal priority hard permit/callout ambiguity cannot be admitted',()=>{for(const action of ['PERMIT','CALLOUT','BLOCK']){const s=snapshot();s.sublayers[1].weight=65535;s.filters.at(-1).action=action;assert.equal(validateTopology(s).reason,'priority-collision');}}); +test('partial visibility and unverified own filters fail closed',()=>{const s=snapshot();s.allObjectsReadable=false;assert.equal(validateTopology(s).safe,false);s.allObjectsReadable=true;s.filters[0].ownedVerified=false;assert.equal(validateTopology(s).safe,false);}); +test('malformed filter rows cannot silently vanish from a snapshot',()=>{for(const row of [null,{}, {layer:3,sublayer:'corp'}, {layer:GUIDS.recvV4}]){const s=snapshot();s.filters.push(row);assert.equal(validateTopology(s).reason,'snapshot-invalid');}}); +test('change during initial enumeration cannot be undone by later successful snapshot',()=>{const reasons=[];const g=createTopologyGuard({quarantine:r=>reasons.push(r)});g.changed();assert.equal(g.admit(snapshot()),false);assert.equal(g.current(),false);g.changed();assert.deepEqual(reasons,['wfp-topology-changed']);}); +test('service receiver soft permit and lower block match only exact service tuple',()=>{const p=planServiceReceiver({appId:Buffer.from('\\device\\harddiskvolume3\\safetynet\\node.exe\0','utf16le'),brokerPort:43120,serviceSid:'S-1-5-80-1-2-3-4-5'});assert.equal(p.filters[0].action,'FWP_ACTION_PERMIT');assert.equal(p.filters[0].clearActionRight,false);assert.ok(p.filters[0].weight>p.filters[1].weight);assert.equal(p.filters[0].conditions.at(-1).type,'FWP_SECURITY_DESCRIPTOR_TYPE');assert.equal(p.completeBoundary,false);assert.throws(()=>planServiceReceiver({appId:Buffer.alloc(8),brokerPort:3,serviceSid:'S-1-5-19'}));}); diff --git a/tests/broker-work-admission.test.mjs b/tests/broker-work-admission.test.mjs new file mode 100644 index 0000000..f3181bd --- /dev/null +++ b/tests/broker-work-admission.test.mjs @@ -0,0 +1,49 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +const require=createRequire(import.meta.url),{createWorkAdmission}=require('../apps/network-broker/work-admission.cjs'); +function deferred(){let resolve,reject;const promise=new Promise((yes,no)=>{resolve=yes;reject=no;});return {promise,resolve,reject};} +test('outer deadline and abort races cannot free never-settled worker admission',async()=>{ + const admission=createWorkAdmission({maxJobs:2}),first=deferred(),second=deferred(); + const bytes=Buffer.from('synthetic private body'); + const work=admission.run(()=>first.promise,{payload:bytes}); + assert.equal(await Promise.race([work,Promise.resolve('outer aborted')]),'outer aborted'); + assert.equal(bytes.toString(),'synthetic private body'); + const other=admission.run(()=>second.promise); + assert.equal(await Promise.race([other,Promise.resolve('outer deadline')]),'outer deadline'); + assert.throws(()=>admission.run(()=>{throw Error('must not execute');}),{code:'work_capacity_exhausted'}); + assert.equal(admission.status().active,2); + first.resolve(42);assert.equal(await work,42);assert.ok(bytes.every(value=>value===0)); + assert.equal(admission.status().active,1); + second.resolve(17);assert.equal(await other,17);assert.equal(admission.status().active,0); +}); +test('reservation is synchronous and shared across concurrent callbacks',async()=>{ + const admission=createWorkAdmission({maxJobs:1}),pending=deferred();let calls=0; + const work=admission.run(()=>{calls++;return pending.promise;}); + assert.throws(()=>admission.run(()=>calls++),{code:'work_capacity_exhausted'});assert.equal(calls,1); + pending.resolve();await work; + assert.equal(await admission.run(()=>++calls),2); +}); +test('sync and async failure preserve identity release slot and wipe private payload',async()=>{ + const admission=createWorkAdmission({maxJobs:1}),failure=new Error('synthetic worker failure'),bytes=Buffer.from('private'); + assert.throws(()=>admission.run(()=>{throw failure;},{payload:bytes}),error=>error===failure); + assert.ok(bytes.every(value=>value===0));assert.equal(admission.status().active,0); + const other=Buffer.from('private'); + await assert.rejects(admission.run(()=>Promise.reject(failure),{payload:other}),error=>error===failure); + assert.ok(other.every(value=>value===0));assert.equal(admission.status().active,0); +}); +test('close rejects new work without claiming pending workers terminated',async()=>{ + const admission=createWorkAdmission({maxJobs:1}),pending=deferred(),bytes=Buffer.from('still used'); + const work=admission.run(()=>pending.promise,{payload:bytes});admission.close(); + assert.deepEqual(admission.status(),{active:1,maxJobs:1,closed:true,available:0}); + assert.equal(bytes.toString(),'still used');assert.throws(()=>admission.run(()=>true),{code:'work_admission_closed'}); + pending.resolve();await work;assert.equal(admission.status().active,0);assert.ok(bytes.every(value=>value===0)); +}); +test('configuration and admission failures use fixed public errors and immutable status',()=>{ + assert.throws(()=>createWorkAdmission({maxJobs:0}),{message:'work_configuration_invalid'}); + const admission=createWorkAdmission();assert.equal(Object.isFrozen(admission.status()),true); + assert.throws(()=>admission.run(()=>{}, {payload:'private content'}),{message:'work_configuration_invalid'}); + admission.close();const bytes=Buffer.from('not admitted'); + assert.throws(()=>admission.run(()=>{}, {payload:bytes}),{code:'work_admission_closed'}); + assert.ok(bytes.every(value=>value===0)); +}); diff --git a/tests/dpapi-helper-native.test.mjs b/tests/dpapi-helper-native.test.mjs new file mode 100644 index 0000000..22fef6e --- /dev/null +++ b/tests/dpapi-helper-native.test.mjs @@ -0,0 +1,38 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +import {mkdtemp,writeFile,access,rm,mkdir} from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import {randomUUID} from 'node:crypto'; +import {defaultPolicy} from '../packages/core/policy.mjs'; +const {createCapabilityCrypto}=createRequire(import.meta.url)('../apps/browser/dpapi.cjs'); +const {createFixtureDiagnostics}=createRequire(import.meta.url)('./fixtures/broker-native-diagnostics.cjs'); +const {createSyntheticAudit}=createRequire(import.meta.url)('./fixtures/broker-durable-audit.cjs'); +const enabled=process.platform==='win32'&&process.env.SAFETYNET_NATIVE_DPAPI_HELPER==='1'; +test('actual isolated private Python performs Windows DPAPI despite poisoned Python environment',{skip:!enabled},async t=>{ + const root=process.cwd(),temporary=await mkdtemp(path.join(os.tmpdir(),'safetynet-dpapi-injection-')); + t.after(()=>rm(temporary,{recursive:true,force:true}));const marker=path.join(temporary,'startup-hook-ran'); + await writeFile(path.join(temporary,'sitecustomize.py'),`from pathlib import Path\nPath(${JSON.stringify(marker)}).write_text('synthetic startup hook')\n`); + const convert=createCapabilityCrypto({root,environment:{...process.env,PYTHONPATH:temporary,PYTHONHOME:path.join(temporary,'missing-runtime'),PYTHONINSPECT:'1',UNLISTED_SECRET:'synthetic-inherited-secret'}}); + const payload=Buffer.from('synthetic private capability'); + const audit=createSyntheticAudit(t),diagnostics=await createFixtureDiagnostics({enabled:true,policy:defaultPolicy(),audit});let diagnosticProof,timings; + t.after(async()=>{if(!diagnosticProof)await diagnostics.finish();}); + const started=performance.now();let encrypted,decrypted; + await diagnostics.run({bytes:0},async()=>{ + encrypted=await diagnostics.stage('browser.credential',{operation:'credential.issue',component:'agent'},()=>convert('protect',payload)); + decrypted=await diagnostics.stage('browser.credential',{operation:'credential.claim',component:'agent'},()=>convert('unprotect',encrypted)); + // Synthetic fsync event supplies correlation only; no browser upload occurs. + diagnostics.persist({eventId:randomUUID(),kind:'upload.authorized',ruleId:'broker-approved',policyVersion:1,request:{bytes:0,detectedType:'text'}}); + }); + const traceId=audit.events()[0].traceId,actual=await diagnostics.trace(traceId),parent=actual.spans.find(value=>value.name==='browser.request'); + timings=actual.spans.filter(value=>value.name==='browser.credential').map(value=>({name:value.name,parentSpanId:value.parentSpanId,durationMs:Number(BigInt(value.endTimeUnixNano)-BigInt(value.startTimeUnixNano))/1e6})); + assert.equal(timings.length,2);assert.ok(timings.every(value=>value.parentSpanId===parent.spanId&&value.durationMs>0)); + diagnosticProof=await diagnostics.finish();assert.equal(diagnosticProof.failedExports,0);assert.equal(diagnosticProof.lifetimeFailedExports,0); + assert.equal(decrypted,payload.toString());assert.notEqual(encrypted,decrypted);assert.equal(Buffer.from(encrypted,'base64').includes(payload),false); + await assert.rejects(access(marker),{code:'ENOENT'});payload.fill(0); + const maximum=Buffer.alloc(32768,97),maximumEncrypted=await convert('protect',maximum); + assert.ok(Buffer.byteLength(maximumEncrypted)>32768);assert.equal(await convert('unprotect',maximumEncrypted),maximum.toString());maximum.fill(0); + const directory=path.join(root,'artifacts/dpapi-helper');await mkdir(directory,{recursive:true}); + await writeFile(path.join(directory,'proof.json'),JSON.stringify({kind:'actual-windows-current-user-dpapi',passed:true,isolatedPrivatePython:true,poisonedPythonEnvironmentIgnored:true,startupHookAbsent:true,plaintextStored:false,maximumInputBytes:32768,maximumCiphertextBytes:Buffer.byteLength(maximumEncrypted),maximumRoundtripPassed:true,roundtripMs:performance.now()-started,diagnostics:{...diagnosticProof,timings,syntheticCorrelationEvent:true},serviceCredentialStoreProven:false},null,2)); +}); diff --git a/tests/dpapi-helper.test.mjs b/tests/dpapi-helper.test.mjs new file mode 100644 index 0000000..c9aa952 --- /dev/null +++ b/tests/dpapi-helper.test.mjs @@ -0,0 +1,36 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +import {EventEmitter} from 'node:events'; +const {createCapabilityCrypto}=createRequire(import.meta.url)('../apps/browser/dpapi.cjs'); +test('capability helper launches fixed isolated Python without startup injection or inherited secrets',async()=>{ + let observed;const child=new EventEmitter();child.stdout=new EventEmitter();child.stderr=new EventEmitter();child.stdin=new EventEmitter();child.stdin.end=()=>{queueMicrotask(()=>{child.stdout.emit('data',Buffer.from('synthetic-ciphertext'));child.emit('close',0);});};child.kill=()=>{}; + const convert=createCapabilityCrypto({root:'C:/trusted/safetynet',environment:{SystemRoot:'C:/Windows',TEMP:'C:/temporary',PYTHONPATH:'injected',PYTHONHOME:'injected',PYTHONINSPECT:'1',NODE_OPTIONS:'injected',UNLISTED_SECRET:'synthetic-secret'},launch:(file,args,options)=>{observed={file,args,options};return child;}}); + assert.equal(await convert('protect','synthetic-input'),'synthetic-ciphertext'); + assert.ok(observed.file.endsWith('python.exe'));assert.deepEqual(observed.args.slice(0,2),['-I','-B']);assert.equal(observed.args.at(-1),'protect');assert.ok(observed.args[2].endsWith('dpapi.py')); + assert.deepEqual(observed.options.env,{SystemRoot:'C:/Windows',TEMP:'C:/temporary'});assert.equal(observed.options.shell,false);assert.equal(observed.options.windowsHide,true); +}); +test('invalid capability input never spawns a helper',async()=>{ + let calls=0;const convert=createCapabilityCrypto({root:'C:/trusted',launch:()=>{calls++;throw Error('must not launch');}}); + for(const [operation,input] of [['arbitrary','x'],['protect',''],['protect',{}],['protect',Buffer.alloc(32769)],['unprotect',Buffer.alloc(65537)]])await assert.rejects(convert(operation,input),/Capability rejected/); + assert.equal(calls,0); +}); +function fakeChild(){const child=new EventEmitter();child.stdout=new EventEmitter();child.stderr=new EventEmitter();child.stdin=new EventEmitter();child.stdin.end=()=>{};child.kills=0;child.kill=()=>{child.kills++;};return child;} +test('failed output stays bounded and original unclosed helpers retain eight slots',async()=>{ + const children=[];const convert=createCapabilityCrypto({root:'C:/trusted',launch:()=>{const child=fakeChild();children.push(child);return child;}}); + for(let index=0;index<8;index++){ + const pending=assert.rejects(convert('protect','synthetic'),/Windows capability protection failed/),child=children[index]; + child.stdout.emit('data',Buffer.alloc(65537));for(let late=0;late<10;late++)child.stdout.emit('data',Buffer.alloc(65536));await pending;assert.equal(child.kills,1); + } + await assert.rejects(convert('protect','synthetic'),/Windows capability protection failed/);assert.equal(children.length,8); + children[0].emit('close',1);const next=convert('protect','synthetic');assert.equal(children.length,9); + children[8].stdout.emit('data',Buffer.from('ciphertext'));children[8].emit('close',0);assert.equal(await next,'ciphertext'); + for(const child of children.slice(1,8))child.emit('close',1); +}); +test('synchronous launch and pipe faults expose fixed errors with owned cleanup',async()=>{ + const convert=createCapabilityCrypto({root:'C:/trusted',launch:()=>{throw Error('PRIVATE_PATH_TOKEN');}}); + for(let attempt=0;attempt<2;attempt++)await assert.rejects(convert('protect','synthetic'),{message:'Windows capability protection failed'}); + const child=fakeChild(),pipe=createCapabilityCrypto({root:'C:/trusted',launch:()=>child}); + const pending=assert.rejects(pipe('protect','synthetic'),{message:'Windows capability protection failed'}); + child.stdout.emit('error',Error('PRIVATE_PATH_TOKEN'));await pending;assert.equal(child.kills,1);child.emit('close',1); +}); diff --git a/tests/encoded-documents.test.mjs b/tests/encoded-documents.test.mjs new file mode 100644 index 0000000..1155f54 --- /dev/null +++ b/tests/encoded-documents.test.mjs @@ -0,0 +1,52 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +const require=createRequire(import.meta.url); +const {extractEncodedDocuments}=require('../packages/core/encoded-documents.cjs'); +const pdf=Buffer.from('%PDF-1.4\nsynthetic bounded document'); +test('JSON nested values and keys yield private original PDF bytes including base64url',()=>{ + for(const value of [{nested:[{file:pdf.toString('base64')}]},{[pdf.toString('base64url')]:true}]){ + const result=extractEncodedDocuments(Buffer.from(JSON.stringify(value)));assert.equal(result.complete,true);assert.equal(result.parts.length,1);assert.deepEqual(result.parts[0].bytes,pdf); + result.dispose();assert.ok(result.parts[0].bytes.every(byte=>byte===0));assert.notEqual(pdf[0],0); + } +}); +test('data URLs preserve declared MIME, percent decoding and exact decoded content',()=>{ + const result=extractEncodedDocuments(Buffer.from('data:application/pdf;base64,'+encodeURIComponent(pdf.toString('base64')))); + assert.equal(result.complete,true);assert.equal(result.parts[0].mime,'application/pdf');assert.deepEqual(result.parts[0].bytes,pdf);result.dispose(); +}); +test('invalid carriers and decoding budgets cannot become complete coverage',()=>{ + for(const value of ['data:application/pdf;base64,%%%','data:application/pdf;base64,abcde','{"file":'])assert.equal(extractEncodedDocuments(Buffer.from(value),{jsonRequired:true}).complete,false); + assert.equal(extractEncodedDocuments(Buffer.from(pdf.toString('base64')),{maxDecodedBytes:1}).complete,false); + const result=extractEncodedDocuments(Buffer.from(JSON.stringify([pdf.toString('base64'),Buffer.from('%PDF-1.4\nother').toString('base64')])) ,{maxParts:1});assert.equal(result.complete,false);result.dispose(); +}); +test('ordinary text is unchanged and duplicate encoded documents use one private part',()=>{ + assert.equal(extractEncodedDocuments(Buffer.from('ordinary public text')).parts.length,0); + const result=extractEncodedDocuments(Buffer.from(JSON.stringify([pdf.toString('base64'),pdf.toString('base64')])));assert.equal(result.complete,true);assert.equal(result.parts.length,1);result.dispose(); +}); +test('embedded and URL-encoded data URLs receive typed binary coverage without rejecting plain source code',()=>{ + const data='data:application/pdf;base64,'+pdf.toString('base64'); + for(const value of ['file='+encodeURIComponent(data),'','document='+pdf.toString('base64')]){ + const result=extractEncodedDocuments(Buffer.from(value));assert.equal(result.complete,true);assert.ok(result.parts.some(part=>part.bytes.equals(pdf)));result.dispose(); + } + assert.equal(extractEncodedDocuments(Buffer.from('{ let publicValue = 1; }')).complete,true); +}); +test('duplicate encoding does not consume part capacity; different declared MIME is independently checked',()=>{ + const value=pdf.toString('base64'),duplicate=extractEncodedDocuments(Buffer.from(JSON.stringify([value,value])),{maxParts:1});assert.equal(duplicate.complete,true);duplicate.dispose(); + const distinct=extractEncodedDocuments(Buffer.from(JSON.stringify(['data:application/pdf;base64,'+value,'data:image/jpeg;base64,'+value])));assert.equal(distinct.parts.length,2);distinct.dispose(); + const one=extractEncodedDocuments(Buffer.from('data:application/pdf;base64,'+value),{maxParts:1});assert.equal(one.complete,true);assert.equal(one.parts.length,1);one.dispose(); +}); +test('large collections and deep JSON exceed bounded traversal without decoded release',()=>{ + const array=extractEncodedDocuments(Buffer.from(JSON.stringify(Array.from({length:100},()=>0))),{maxNodes:10});assert.equal(array.complete,false);assert.equal(array.parts.length,0); + let value=pdf.toString('base64');for(let index=0;index<10;index++)value={next:value};const deep=extractEncodedDocuments(Buffer.from(JSON.stringify(value)),{maxDepth:4});assert.equal(deep.complete,false);deep.dispose(); +}); +test('review regressions cover JSON percent carriers, textual envelopes and strict scalar JSON',()=>{ + const uri='data:application/pdf;base64,'+pdf.toString('base64'); + for(const body of [{file:encodeURIComponent(uri)},{[encodeURIComponent(uri)]:true}]){ + const result=extractEncodedDocuments(Buffer.from(JSON.stringify(body)),{jsonRequired:true});assert.equal(result.complete,true);assert.ok(result.parts.some(part=>part.bytes.equals(pdf)));result.dispose(); + } + const inner=JSON.stringify({file:pdf.toString('base64')}),outer=extractEncodedDocuments(Buffer.from(JSON.stringify({file:Buffer.from(inner).toString('base64')}))); + assert.equal(outer.complete,true);assert.ok(outer.parts.some(part=>part.bytes.toString()===inner));assert.ok(outer.decodedBytes>=inner.length); + const nested=extractEncodedDocuments(outer.parts.find(part=>part.bytes.toString()===inner).bytes,{maxDecodedBytes:16777216-outer.decodedBytes});assert.ok(nested.parts.some(part=>part.bytes.equals(pdf)));nested.dispose();outer.dispose(); + assert.equal(extractEncodedDocuments(Buffer.from('not JSON'),{jsonRequired:true}).complete,false); + const scalar=extractEncodedDocuments(Buffer.from(JSON.stringify(pdf.toString('base64'))),{jsonRequired:true});assert.equal(scalar.complete,true);assert.deepEqual(scalar.parts[0].bytes,pdf);scalar.dispose(); +}); diff --git a/tests/fixtures/broker-certificate-pins-electron.cjs b/tests/fixtures/broker-certificate-pins-electron.cjs new file mode 100644 index 0000000..a8571cf --- /dev/null +++ b/tests/fixtures/broker-certificate-pins-electron.cjs @@ -0,0 +1,23 @@ +'use strict'; +const {app,session}=require('electron'); +const {createBrokerCertificatePins}=require('../../apps/browser/broker-certificate-pins.cjs'); +app.disableHardwareAcceleration(); +let initialized=false; +process.once('disconnect',()=>app.exit(0)); +process.on('message',async input=>{ + if(input?.type==='stop'){app.exit(0);return;} + if(initialized||input?.type!=='start')return;initialized=true; + try{ + app.setPath('userData',input.profile);await app.whenReady(); + const partition=session.fromPartition('certificate-pin-native',{cache:false}); + const pins=createBrokerCertificatePins({generation:'synthetic-generation',policyVersion:4,current:()=>true,authorizedAuthority:authority=>authority===input.leaf.authority}); + pins.accept({...input.leaf,generation:'synthetic-generation',policyVersion:4}); + partition.setCertificateVerifyProc((request,callback)=>pins.verify(request,callback)); + await partition.setProxy({mode:'direct'}); + const allowed=await partition.fetch(input.publicURL,{signal:AbortSignal.timeout(10000)}); + const publicBody=await allowed.text();let denied=false; + try{await partition.fetch(input.untrustedURL,{signal:AbortSignal.timeout(10000)});}catch{denied=true;} + await partition.closeAllConnections();pins.revoke(); + process.send({type:'proof',publicStatus:allowed.status,publicBody,untrustedRejected:denied,electron:process.versions.electron,chromium:process.versions.chrome}); + }catch{process.send({type:'fault',reason:'certificate-native-proof-failed'});} +}); diff --git a/tests/fixtures/broker-durable-audit.cjs b/tests/fixtures/broker-durable-audit.cjs new file mode 100644 index 0000000..bcc3d74 --- /dev/null +++ b/tests/fixtures/broker-durable-audit.cjs @@ -0,0 +1,17 @@ +'use strict'; +// Test-only, actual fsync of synthetic metadata. Production uses the protected +// encrypted audit store; this fixture never stores request contents or tokens. +const fs=require('node:fs'); +const path=require('node:path'); +const {randomUUID}=require('node:crypto'); +const {BrokerAuditEventSchema}=require('../../apps/network-broker/audit-gate.cjs'); +function createSyntheticAudit(test){ + const root=path.resolve(__dirname,'../../artifacts/broker-audit-fixtures');fs.mkdirSync(root,{recursive:true}); + const filename=path.join(root,`${randomUUID()}.jsonl`),fd=fs.openSync(filename,'wx'); + test.after(()=>fs.closeSync(fd)); + return Object.freeze({ + persist:event=>{BrokerAuditEventSchema.parse(event);fs.writeFileSync(fd,JSON.stringify(event)+'\n');fs.fsyncSync(fd);return {persisted:true,eventId:event.eventId};}, + events:()=>fs.readFileSync(filename,'utf8').trim().split('\n').filter(Boolean).map(line=>JSON.parse(line)) + }); +} +module.exports={createSyntheticAudit}; diff --git a/tests/fixtures/broker-electron-interop.cjs b/tests/fixtures/broker-electron-interop.cjs new file mode 100644 index 0000000..62342c8 --- /dev/null +++ b/tests/fixtures/broker-electron-interop.cjs @@ -0,0 +1,42 @@ +'use strict'; +// Test-only stock Electron client. No production browser APIs or policy edits. +const {app,BrowserWindow,session}=require('electron'); +const {X509Certificate}=require('node:crypto'); +app.disableHardwareAcceleration(); +let window,config,loginCount=0,initialized=false; +const send=value=>{if(process.connected)process.send(value);}; +process.on('disconnect',()=>app.exit(0)); +app.on('login',(event,contents,_details,info,callback)=>{ + event.preventDefault(); + if(config&&contents===window?.webContents&&info.isProxy&&info.host==='127.0.0.1'&&info.port===config.proxyPort&&info.scheme.toLowerCase()==='basic'&&info.realm==='SafetyNet private broker'){ + loginCount++;callback(config.credentials.username,config.credentials.password); + }else callback(); +}); +process.on('message',async message=>{ + try { + if(message?.type==='initialize'&&!initialized){ + initialized=true;config=message.config; + app.setPath('userData',config.userData); + await app.whenReady(); + const partition=session.fromPartition('broker-interop',{cache:false}); + partition.setCertificateVerifyProc((request,callback)=>{ + try { + const certificate=new X509Certificate(request.certificate.data); + const now=Date.now(); + if(request.hostname==='localhost'&&certificate.checkHost('localhost')==='localhost'&&certificate.fingerprint256===config.fingerprint&&Date.parse(certificate.validFrom)<=now&&now'}); + window=new BrowserWindow({show:false,webPreferences:{session:partition,nodeIntegration:false,contextIsolation:true,sandbox:true,webSecurity:true}}); + window.webContents.on('render-process-gone',()=>send({type:'error',code:'renderer-gone'})); + await window.loadURL(config.origin+'/'); + send({type:'ready',loginCount,electron:process.versions.electron,chromium:process.versions.chrome});return; + } + if(message?.type==='stop'){window?.destroy();app.exit(0);return;} + const bodies={public:'public browser content',secret:'PROJECT SECRET',revoked:'public revoked content'}; + if(!window||!Object.hasOwn(bodies,message?.type))throw Error('invalid-message'); + const result=await window.webContents.executeJavaScript(`(async()=>{try{const response=await fetch('/submit',{method:'POST',body:${JSON.stringify(bodies[message.type])}});return {status:response.status,body:await response.text()};}catch{return {blocked:true};}})()`); + send({type:'result',operation:message.type,result,loginCount}); + } catch {send({type:'error',code:'interop-failed'});} +}); diff --git a/tests/fixtures/broker-inspector-environment.cjs b/tests/fixtures/broker-inspector-environment.cjs new file mode 100644 index 0000000..bd12513 --- /dev/null +++ b/tests/fixtures/broker-inspector-environment.cjs @@ -0,0 +1,19 @@ +'use strict'; +// Diagnostic fixture only: compare explicitly allowlisted environment profiles +// against a real packaged contained inspector. No private content or values. +const path=require('node:path'); +const {startInspector}=require('../../apps/browser/inspection.cjs'); +const {execFileSync}=require('node:child_process'); +function children(){const text=execFileSync('C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe',['-NoProfile','-NonInteractive','-Command',`@(Get-CimInstance Win32_Process -Filter "ParentProcessId = ${process.pid}" | Where-Object Name -eq 'python.exe' | Select-Object -ExpandProperty ProcessId) | ConvertTo-Json -Compress`],{encoding:'utf8',windowsHide:true,timeout:10000}).trim();return text?[].concat(JSON.parse(text)):[];} +async function main(){ + const {defaultPolicy}=await import('../../packages/core/policy.mjs');const baseline={...process.env},root=path.resolve(__dirname,'../../.runtime/windows'); + for(const variant of ['stripped','profile']){ + const allowed=['SystemRoot','WINDIR','TEMP','TMP',...(variant==='profile'?['USERPROFILE','LOCALAPPDATA','APPDATA']:[])];for(const key of Object.keys(process.env))delete process.env[key];for(const key of allowed)if(baseline[key])process.env[key]=baseline[key]; + let inspector,stage='startup',result; + try{inspector=startInspector(root,true);if(!inspector)throw Error('fixed-path-fault');await inspector.waitReady();stage='scan';const answer=await inspector.inspect(Buffer.from('public synthetic text'),defaultPolicy());result={variant,stage,decision:answer.decision,complete:answer.coverage.complete};} + catch{result={variant,stage,failed:true};} + finally{inspector?.stop();for(let count=0;count<20&&children().length;count++)await new Promise(resolve=>setTimeout(resolve,100));result.ownedParents=children().length;} + process.stdout.write(JSON.stringify(result)+'\n');if(result.ownedParents)throw Error('fixed-cleanup-fault'); + } +} +main().catch(()=>{process.stderr.write('fixed-environment-proof-fault\n');process.exitCode=1;}); diff --git a/tests/fixtures/broker-native-diagnostics.cjs b/tests/fixtures/broker-native-diagnostics.cjs new file mode 100644 index 0000000..9ef5dd8 --- /dev/null +++ b/tests/fixtures/broker-native-diagnostics.cjs @@ -0,0 +1,29 @@ +'use strict'; +// Explicitly authorized synthetic fixture only. Private bounded in-memory OTLP +// receiver; no real management policy, content, token or production exporter. +const http=require('node:http'); +const {createDiagnostics}=require('../../apps/browser/diagnostics.cjs'); +async function createFixtureDiagnostics({enabled,policy,audit}){ + if(!enabled)return {persist:audit.persist,run:(_attributes,fn)=>fn(),stage:(_name,_attributes,fn)=>fn(),finish:async()=>({enabled:false})}; + const batches=[];let storedBytes=0,invalid=0;const spans=()=>batches.flatMap(batch=>(batch.resourceSpans||[]).flatMap(resource=>(resource.scopeSpans||[]).flatMap(scope=>scope.spans||[]))); + const server=http.createServer({maxHeaderSize:8192},async(request,response)=>{ + if(request.method==='GET'&&/^\/api\/trace\/[a-f0-9]{32}$/.test(request.url)){const id=request.url.split('/').pop();response.setHeader('content-type','application/json');response.end(JSON.stringify({spans:spans().filter(span=>span.traceId===id)}));return;} + if(request.method!=='POST'||!['/v1/traces','/v1/logs'].includes(request.url)){response.writeHead(404);response.end();return;} + const chunks=[];let size=0; + try{for await(const chunk of request){size+=chunk.length;if(size>1048576||storedBytes+size>4194304)throw Error('fixed-budget');chunks.push(chunk);}const text=Buffer.concat(chunks).toString();if(text.includes('SYNTHETIC_PRIVATE')||text.includes('proxy-password')||text.includes('%PDF-'))throw Error('fixed-content');const value=JSON.parse(text);if(request.url==='/v1/traces')batches.push(value);storedBytes+=size;response.setHeader('content-type','application/json');response.end('{}');}catch{invalid++;response.writeHead(400);response.end('{}');} + });server.maxConnections=8;server.listen(0,'127.0.0.1');await new Promise(resolve=>server.once('listening',resolve)); + const endpoint=`http://127.0.0.1:${server.address().port}`,diagnostics=createDiagnostics();let active; + policy.diagnostics={...policy.diagnostics,enabled:true,mode:'debug',endpoint,sampleRate:1,debugSampleRate:1,maxRecordAgeMs:120000}; + if(!diagnostics.configure(policy.diagnostics,{policyVersion:policy.version,deviceId:'native-production-fixture'}))throw Error('fixture-diagnostics-unavailable'); + return { + stage:(name,attributes,fn)=>diagnostics.span(name,attributes,fn), + async trace(traceId){if(!/^[a-f0-9]{32}$/.test(traceId))throw Error('fixture-trace-invalid');await diagnostics.flush();const response=await fetch(endpoint+'/api/trace/'+traceId);if(!response.ok)throw Error('fixture-trace-unavailable');return response.json();}, + persist:event=>diagnostics.withCorrelation(active,()=>diagnostics.spanSync('browser.management',{operation:'audit.deliver',component:'management'},()=>audit.persist({...event,...diagnostics.correlation()}))), + run:(attributes,fn)=>diagnostics.span('browser.request',{operation:'broker.request',component:'network',...attributes},async()=>{active=diagnostics.correlation();try{return await fn();}finally{active=undefined;}}), + async finish(){ + try{await diagnostics.flush();const status=diagnostics.status();if(status.failedExports||status.lifetimeFailedExports||invalid)throw Error('fixture-diagnostics-export-failed');const event=audit.events().find(value=>value.traceId);if(!event)throw Error('fixture-diagnostics-correlation-missing');const response=await fetch(endpoint+'/api/trace/'+event.traceId),trace=await response.json();const child=trace.spans.find(value=>value.spanId===event.spanId),parent=trace.spans.find(value=>value.spanId===child?.parentSpanId);if(!child||!parent||parent.name!=='browser.request'||BigInt(parent.endTimeUnixNano)<=BigInt(parent.startTimeUnixNano))throw Error('fixture-diagnostics-parent-missing');const stages=trace.spans.filter(value=>value.name.startsWith('browser.broker.')).map(value=>({name:value.name,parentSpanId:value.parentSpanId,durationMs:Number(BigInt(value.endTimeUnixNano)-BigInt(value.startTimeUnixNano))/1e6}));return {enabled:true,traceId:event.traceId,auditSpanId:event.spanId,parentSpanId:parent.spanId,spanCount:spans().length,storedBytes,stages,invalid,failedExports:status.failedExports,lifetimeFailedExports:status.lifetimeFailedExports};} + finally{await diagnostics.shutdown();server.closeAllConnections();await new Promise(resolve=>server.close(resolve));} + } + }; +} +module.exports={createFixtureDiagnostics}; diff --git a/tests/fixtures/broker-process-child.cjs b/tests/fixtures/broker-process-child.cjs new file mode 100644 index 0000000..f11159b --- /dev/null +++ b/tests/fixtures/broker-process-child.cjs @@ -0,0 +1,19 @@ +'use strict'; +// Synthetic classification belongs only in this explicitly test-only factory. +const fs=require('node:fs'); +const path=require('node:path'); +const https=require('node:https'); +const {runBrokerProcess}=require('../../apps/network-broker/process-entry.cjs'); +const {createConnectProxy}=require('../../apps/network-broker/connect-inspection-proxy.cjs'); +const cert=fs.readFileSync(path.join(__dirname,'icap/cert.pem')); +const key=fs.readFileSync(path.join(__dirname,'icap/key.pem')); +runBrokerProcess(config=>{ + if(Object.keys(config).join(',')!=='destination'||typeof config.destination!=='string'||!/^localhost:[1-9][0-9]{0,4}$/.test(config.destination))throw Error('invalid-test-config'); + const port=Number(config.destination.split(':')[1]);if(port>65535)throw Error('invalid-test-config'); + return createConnectProxy({authority:'synthetic-test-policy',current:()=>true,resolveCertificate:binding=>binding.authority===config.destination?{authority:config.destination,key,cert}:undefined,inspect:({body})=>({decision:body.toString().includes('SECRET')?'block':'allow'}),forward:({request,body,signal,maxResponseBytes})=>new Promise((resolve,reject)=>{ + const outgoing=https.request({host:'127.0.0.1',port,path:new URL(request.url).pathname,method:request.method,servername:'localhost',ca:cert,rejectUnauthorized:true,signal,headers:{host:config.destination,'content-length':body.length}},res=>{ + const chunks=[];let bytes=0; + res.on('data',chunk=>{bytes+=chunk.length;if(bytes>maxResponseBytes){outgoing.destroy();reject(Error('response-bound'));return;}chunks.push(Buffer.from(chunk));});res.on('end',()=>resolve({statusCode:res.statusCode,headers:res.headers,body:Buffer.concat(chunks)}));res.on('error',reject); + });outgoing.on('error',reject);outgoing.end(body); + }),deadlineMs:1000,authMode:'bearer'}); +}); diff --git a/tests/fixtures/broker-service-stdio-child.cjs b/tests/fixtures/broker-service-stdio-child.cjs new file mode 100644 index 0000000..25242d1 --- /dev/null +++ b/tests/fixtures/broker-service-stdio-child.cjs @@ -0,0 +1,17 @@ +'use strict'; +const {createFramedChannel}=require('../../apps/network-broker/framed-channel.cjs'); +const channel=createFramedChannel({readable:process.stdin,writable:process.stdout,inboundFirstLimit:2097152}); +channel.once('disconnect',()=>process.exit(0)); +let context; +channel.on('message',message=>{ + if(message.type==='start'){ + context={version:1,generation:message.generation,policyVersion:message.policyVersion}; + if(message.config.malformed){process.stdout.write(Buffer.from([1,0,0,0,0xff]));return;} + channel.send({type:'ready',...context,authMode:'basic',sequence:0,port:message.config.port,credentials:{username:'safetynet-'+'a'.repeat(32),password:'b'.repeat(43)}},error=>{ + if(error)process.exit(2); + if(message.config.audit){const id='12345678-1234-4234-8234-123456789abc';channel.send({type:'audit',...context,sequence:1,requestId:id,event:{eventId:id,kind:'upload.authorized',ruleId:'broker-approved',policyVersion:context.policyVersion,request:{bytes:0,detectedType:'text'}}});} + }); + }else if(message.type==='audit-receipt'){ + if(message.receipt?.persisted!==true||message.receipt.eventId!==message.requestId)process.exit(3); + }else process.exit(4); +}); diff --git a/tests/installer-acl-hook-native.test.mjs b/tests/installer-acl-hook-native.test.mjs new file mode 100644 index 0000000..15e82b5 --- /dev/null +++ b/tests/installer-acl-hook-native.test.mjs @@ -0,0 +1,44 @@ +// Explicit native installer dispatch proof; does not claim NSIS execution or +// an elevated-to-different-standard-user Program Files installation proof. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFile,mkdir,readdir,copyFile,mkdtemp,rm,writeFile} from 'node:fs/promises'; +import {spawn} from 'node:child_process'; +import path from 'node:path'; +async function run(executable,args){ + const child=spawn(executable,args,{windowsHide:true,stdio:['ignore','pipe','pipe']});let output='',bytes=0; + for(const stream of [child.stdout,child.stderr])stream.on('data',chunk=>{bytes+=chunk.length;if(bytes>4096)child.kill();else output+=chunk.toString();}); + const timer=setTimeout(()=>child.kill(),60000); + try{return await new Promise((resolve,reject)=>{child.once('error',reject);child.once('exit',(code,signal)=>resolve({code,signal,output}));});}finally{clearTimeout(timer);} +} +test('actual fixed installer hook dispatch provisions copied runtime then verifies deterministic native package SID',{ + skip:process.platform!=='win32'||process.env.SAFETYNET_NATIVE_INSTALLER_ACL!=='1',timeout:90000 +},async()=>{ + const repository=path.resolve('.'),artifacts=path.join(repository,'artifacts'); + await mkdir(artifacts,{recursive:true});const root=await mkdtemp(path.join(artifacts,'installer ACL native-')); + const resources=path.join(root,'resources'),python=path.join(resources,'python'),inspector=path.join(resources,'inspector'); + const proof={startedAt:new Date().toISOString(),outcome:'running',nsisPluginExecuted:false,crossUserProgramFilesProven:false}; + try{ + await Promise.all([mkdir(python,{recursive:true}),mkdir(inspector,{recursive:true}),mkdir(path.join(resources,'semantic'),{recursive:true})]); + for(const item of await readdir(path.join(repository,'.runtime/windows/python'),{withFileTypes:true}))if(item.isFile())await copyFile(path.join(repository,'.runtime/windows/python',item.name),path.join(python,item.name)); + for(const name of ['provision_runtime_acl.py','windows_appcontainer.py','windows_containment.py'])await copyFile(path.join(repository,'apps/inspector',name),path.join(inspector,name)); + await writeFile(path.join(resources,'semantic/public-marker.txt'),'Synthetic installer ACL role marker'); + const hook=await readFile(path.join(repository,'scripts/install/windows-security.nsh'),'utf8'); + const command=/nsExec::ExecToStack \/TIMEOUT=245000 '(.*)'/.exec(hook)?.[1];assert.ok(command); + const match=/^"\$INSTDIR\\resources\\python\\python.exe" -I -B "\$INSTDIR\\resources\\inspector\\provision_runtime_acl.py" --provision$/.exec(command);assert.ok(match,'Installer command must use the exact copied private interpreter and helper'); + const executable=path.join(python,'python.exe'),helper=path.join(inspector,'provision_runtime_acl.py'); + const provision=await run(executable,['-I','-B',helper,'--provision']);assert.equal(provision.code,0,provision.output);assert.equal(provision.signal,null); + const verified=JSON.parse(provision.output.trim());assert.equal(verified.verified,true);assert.equal(verified.rootCount,3);proof.profile=verified.profile; + const readonly=await run(executable,['-I','-B',helper]);assert.equal(readonly.code,0,readonly.output);assert.equal(JSON.parse(readonly.output).profile,verified.profile); + const script=`import sys,ctypes,json\nfrom pathlib import Path\nsys.path.insert(0,sys.argv[1])\nfrom windows_appcontainer import AppContainer,_api\nfrom ctypes import wintypes as W\ncontainer=AppContainer([sys.argv[1],sys.argv[2],sys.argv[3]])\nk,u,a=_api(); sid=ctypes.c_void_p(); assert u.DeriveAppContainerSidFromAppContainerName(container.name,ctypes.byref(sid))==0\ntext=W.LPWSTR(); assert a.ConvertSidToStringSidW(sid,ctypes.byref(text)); derived=text.value; k.LocalFree(text); a.FreeSid(sid)\nassert derived==container.sid_string\nprint(json.dumps({'matches':True,'profile':container.name,'packageAuthority':derived.startswith('S-1-15-2-')}))\n`; + const sid=await run(executable,['-I','-B','-c',script,inspector,python,path.join(resources,'semantic')]);assert.equal(sid.code,0,sid.output); + const identity=JSON.parse(sid.output);assert.equal(identity.matches,true);assert.equal(identity.packageAuthority,true);assert.equal(identity.profile,verified.profile); + proof.deterministicNativePackageSid=true;proof.provisionExit=0;proof.readOnlyVerifyExit=0; + const invalid=await run(executable,['-I','-B',helper,'--provision','--root',repository]);assert.notEqual(invalid.code,0);proof.callerChosenRootRejected=true; + proof.outcome='passed'; + }catch(error){proof.outcome='failed';proof.errorType=error.name;throw error;} + finally{ + await writeFile(path.join(artifacts,'installer-acl-native-proof-'+proof.startedAt.replaceAll(':','-')+'.json'),JSON.stringify(proof,null,2)+'\n'); + const absolute=path.resolve(root);assert.equal(path.dirname(absolute),artifacts);assert.ok(path.basename(absolute).startsWith('installer ACL native-'));await rm(absolute,{recursive:true,force:true}); + } +}); diff --git a/tests/office-stage-otlp-native.test.mjs b/tests/office-stage-otlp-native.test.mjs new file mode 100644 index 0000000..fcbf851 --- /dev/null +++ b/tests/office-stage-otlp-native.test.mjs @@ -0,0 +1,35 @@ +// Explicit native proof: private synthetic signed-policy OTLP receiver only. +// Never publishes/enables diagnostics on the user's browser or production tenant. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +import {createServer} from 'node:http'; +import {generateKeyPairSync,createHash} from 'node:crypto'; +import {readFile,writeFile,mkdir} from 'node:fs/promises'; +import {once} from 'node:events'; +import path from 'node:path'; +import {execFileSync} from 'node:child_process'; +import {defaultPolicy,signPolicy} from '../packages/core/policy.mjs'; +const require=createRequire(import.meta.url),{createDiagnostics}=require('../apps/browser/diagnostics.cjs'),{startInspector}=require('../apps/browser/inspection.cjs'),{createPolicyAuthority}=require('../apps/network-broker/policy-authority.cjs'); +function children(){const text=execFileSync('powershell.exe',['-NoProfile','-NonInteractive','-Command',`@(Get-CimInstance Win32_Process -Filter "ParentProcessId = ${process.pid}" | Where-Object {$_.Name -eq 'python.exe' -and $_.CommandLine -like '*apps*inspector*server.py*'} | Select-Object -ExpandProperty ProcessId) | ConvertTo-Json -Compress`],{encoding:'utf8',windowsHide:true,timeout:10000}).trim();return text?[].concat(JSON.parse(text)):[];} +test('real contained Office stages export parent-correlated OTLP with sanitized native errors',{ + skip:process.platform!=='win32'||process.env.SAFETYNET_OFFICE_OTLP_NATIVE!=='1',timeout:90000 +},async()=>{ + const root=path.resolve('.'),payloads=[],diagnostics=createDiagnostics();let inspector; + const server=createServer(async(req,res)=>{const chunks=[];let size=0;for await(const chunk of req){size+=chunk.length;assert.ok(size<1048576);chunks.push(chunk);}payloads.push({path:req.url,body:JSON.parse(Buffer.concat(chunks).toString())});res.setHeader('content-type','application/json');res.end('{}');});server.listen(0,'127.0.0.1');await once(server,'listening'); + try{ + const keys=generateKeyPairSync('ed25519'),policy=defaultPolicy();policy.assignment={deviceId:'synthetic-office-otel',groups:[]};policy.diagnostics={...policy.diagnostics,enabled:true,mode:'debug',endpoint:`http://127.0.0.1:${server.address().port}`,sampleRate:1,debugSampleRate:1,flushIntervalMs:10000}; + const authority=await createPolicyAuthority({deviceId:'synthetic-office-otel',publicKey:keys.publicKey.export({type:'spki',format:'pem'})});authority.accept(signPolicy(policy,keys.privateKey.export({type:'pkcs8',format:'pem'})));const trusted=authority.snapshot();diagnostics.configure(trusted.policy.diagnostics,{deviceId:'synthetic-office-otel',policyVersion:trusted.version}); + inspector=startInspector(root,false,{onStages:envelope=>diagnostics.importStages(envelope)});assert.ok(inspector);await inspector.waitReady();assert.equal(children().length,1); + const bytes=await readFile(path.join(root,'artifacts/office-coverage-fixtures/public-generated.xlsx'));const verdict=await diagnostics.span('browser.inspect.local',{operation:'office.native-proof'},()=>inspector.inspect(bytes,trusted.policy));assert.equal(verdict.decision,'allow');assert.equal(verdict.coverage.complete,true); + const malformed=await diagnostics.span('browser.inspect.local',{operation:'office.native-error-proof'},()=>inspector.inspect(Buffer.from('PK\x03\x04SYNTHETIC PRIVATE ERROR BODY'),trusted.policy));assert.equal(malformed.decision,'block'); + await diagnostics.flush();assert.equal(diagnostics.status().failedExports,0);assert.equal(diagnostics.status().lifetimeFailedExports,0); + const spans=payloads.flatMap(item=>item.body.resourceSpans||[]).flatMap(resource=>resource.scopeSpans||[]).flatMap(scope=>scope.spans||[]),names=['inspector.office.archive','inspector.office.xml','inspector.office.relationships','inspector.office.ocr']; + const all=spans.find(item=>item.name==='inspector.office.relationships'),parse=spans.find(item=>item.name==='inspector.parse'&&item.traceId===all?.traceId);assert.ok(all&&parse); + const stages=names.map(name=>{const span=spans.find(item=>item.name===name&&item.traceId===all.traceId);assert.ok(span,name);assert.equal(span.parentSpanId,parse.spanId);assert.ok(BigInt(span.endTimeUnixNano)>=BigInt(span.startTimeUnixNano));return {stage:name,spanId:span.spanId,parentSpanId:span.parentSpanId,durationMs:Number(BigInt(span.endTimeUnixNano)-BigInt(span.startTimeUnixNano))/1e6};}); + assert.ok(spans.some(item=>item.name==='inspector.office.archive'&&item.status?.code===2));assert.equal(JSON.stringify(payloads).includes('SYNTHETIC PRIVATE ERROR BODY'),false);assert.equal(JSON.stringify(payloads).includes('Public revenue'),false); + const directory=path.join(root,'artifacts/office-native-observability');await mkdir(directory,{recursive:true});await writeFile(path.join(directory,'private-otlp-proof-'+new Date().toISOString().replaceAll(':','-')+'.json'),JSON.stringify({receiver:'private-synthetic-fixture',productionCollector:false,decision:'allow',coverageComplete:true,inputSha256:createHash('sha256').update(bytes).digest('hex'),traceId:all.traceId,parseSpanId:parse.spanId,stages,sanitizedErrorStage:true,failedExports:diagnostics.status().failedExports,lifetimeFailedExports:diagnostics.status().lifetimeFailedExports},null,2)); + }finally{ + inspector?.stop();await diagnostics.shutdown();server.closeAllConnections();await new Promise(resolve=>server.close(resolve));for(let attempt=0;attempt<20&&children().length;attempt++)await new Promise(resolve=>setTimeout(resolve,100));assert.equal(children().length,0); + } +}); diff --git a/tests/request-audit.test.mjs b/tests/request-audit.test.mjs index 2fc249b..b7bdc0f 100644 --- a/tests/request-audit.test.mjs +++ b/tests/request-audit.test.mjs @@ -41,3 +41,12 @@ test('central API accepts typed broker causation and rejects invalid lifecycle s for(const broker of [{...fault.broker,authorizedEventId:undefined},{...fault.broker,authorizedEventId:fault.eventId},{...allow.broker,phase:'connect.block'},{...allow.broker,reason:'arbitrary secret details'},{...allow.broker,body:'private'}])assert.equal((await send({...fault,eventId:broker.authorizedEventId===fault.eventId?fault.eventId:randomUUID(),broker})).status,400); const rows=store.db.prepare('SELECT metadata FROM events ORDER BY id').all();assert.equal(rows.length,2);assert.deepEqual(JSON.parse(rows[1].metadata).broker,fault.broker);assert.equal(store.auditIntegrity.verify().valid,true); }); + +test('central typed download audit retains genuine file hash and requires causal request authorization',async t=>{ + const dir=mkdtempSync(join(tmpdir(),'safetynet-request-audit-')),store=openStore(dir),credential='synthetic-device-download-broker-token';store.db.prepare('INSERT INTO devices(id,name,token_hash) VALUES(?,?,?)').run('download-broker','Synthetic',digest(credential)); + const server=createApp(store,{publicUrl:'http://127.0.0.1',adminToken:'synthetic-administrator-token-123456789',secure:false}).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));t.after(async()=>{server.closeAllConnections();await new Promise(r=>server.close(r));store.db.close();rmSync(dir,{recursive:true,force:true});}); + const send=event=>fetch(`http://127.0.0.1:${server.address().port}/api/device/events`,{method:'POST',headers:{authorization:`Bearer ${credential}`,'content-type':'application/json'},body:JSON.stringify(event)}); + const event={eventId:randomUUID(),policyVersion:1,kind:'download.block',ruleId:'registered-document',file:{bytes:613,detectedType:'pdf',sha256:digest('synthetic real PDF bytes')},broker:{requestId:randomUUID(),authorizedEventId:randomUUID(),phase:'response.block',reason:'broker-download-inspection-block'}};assert.equal((await send(event)).status,200); + for(const value of [{...event,eventId:randomUUID(),request:{bytes:613,detectedType:'pdf'}},{...event,eventId:randomUUID(),file:undefined},{...event,eventId:randomUUID(),broker:{...event.broker,authorizedEventId:undefined}},{...event,eventId:randomUUID(),broker:{...event.broker,phase:'request.block'}}])assert.equal((await send(value)).status,400); + const stored=JSON.parse(store.db.prepare('SELECT metadata FROM events').get().metadata);assert.deepEqual(stored.file,event.file);assert.deepEqual(stored.broker,event.broker);assert.equal(store.auditIntegrity.verify().valid,true); +}); diff --git a/tests/test_windows_wfp_install.py b/tests/test_windows_wfp_install.py new file mode 100644 index 0000000..6537b2d --- /dev/null +++ b/tests/test_windows_wfp_install.py @@ -0,0 +1,121 @@ +"""Installer library tests use an in-memory BFE model, never Windows mutation.""" +import copy +import hashlib +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +sys.path.insert(0,str(Path(__file__).resolve().parent.parent/'apps/network-broker')) +from windows_wfp_install import BoundaryInstaller,InstallError,PROVIDER,SUBLAYER,MARKER,SERVICE,_key +from windows_wfp_probe import verify_observed +IDENTITY='\\device\\harddiskvolume3\\program files\\safetynet\\safetynet.exe\0'.encode('utf-16le').hex() +class Backend: + def __init__(self):self.objects={};self.calls=[];self.failure=None;self.post_failure=False;self.snapshot=None;self.tamper=False + def event(self,event): + self.calls.append(event) + if self.failure==event:raise InstallError('injected-native-failure',5) + def app_id(self,_path):self.event('app-id');return IDENTITY + def open(self):self.event('open') + def close(self):self.event('close') + def prepare_topology(self):return True + def begin(self):self.event('begin');self.snapshot=copy.deepcopy(self.objects) + def commit(self):self.event('commit');self.snapshot=None + def abort(self):self.event('abort');self.objects=self.snapshot;self.snapshot=None + def lookup(self,kind,key):self.event('lookup-'+kind);return copy.deepcopy(self.objects.get((kind,key))) + def add_provider(self):self.event('add-provider');self.objects[('provider',PROVIDER)]={'flags':1,'data':MARKER.hex(),'service':SERVICE} + def add_sublayer(self):self.event('add-sublayer');self.objects[('sublayer',SUBLAYER)]={'flags':1,'data':MARKER.hex(),'provider':PROVIDER,'weight':65535} + def add_filter(self,rule): + self.event('add-'+rule['id']);value={'provider':PROVIDER,'sublayer':SUBLAYER,'layer':rule['layer'],'flags':1,'action':0x1002 if rule['action']=='FWP_ACTION_PERMIT' else 0x1001,'conditions':copy.deepcopy(rule['conditions']),'weight':rule.get('weight')} + for condition in value['conditions']: + if condition['type']=='FWP_SECURITY_DESCRIPTOR_TYPE': + from windows_wfp_receiver import service_descriptor + import ctypes as C + with service_descriptor(condition['value'][len('D:(A;;CC;;;'):-1]) as blob:condition['value']=C.string_at(blob.data,blob.size).hex() + if self.tamper:value['action']=0x1002 + self.objects[('filter',_key(rule['id']))]=value + def verify(self,plan): + self.event('verify') + if self.post_failure:return {'verified':False} + return verify_observed(plan,[v for (kind,_key_),v in self.objects.items() if kind=='filter'],PROVIDER,SUBLAYER) + def delete(self,kind,key):self.event('delete-'+kind);del self.objects[(kind,key)] +class InstallTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + root=Path(__file__).resolve().parent.parent + script="const {planBrowserNetworkBoundary}=require('./apps/network-broker/windows-wfp-plan.cjs');console.log(JSON.stringify(planBrowserNetworkBoundary({appId:Buffer.from(process.argv[1],'hex'),brokerPort:43871})));" + result=subprocess.run(['node','-e',script,IDENTITY],cwd=root,capture_output=True,text=True,timeout=10,check=True);cls.plan=json.loads(result.stdout) + def setUp(self): + self.temp=tempfile.TemporaryDirectory();self.root=Path(self.temp.name);self.exe=self.root/'safetynet.exe';self.exe.write_bytes(b'SYNTHETIC EXE FIXTURE NEVER EXECUTED');self.sha=hashlib.sha256(self.exe.read_bytes()).hexdigest();self.backend=Backend();self.installer=BoundaryInstaller(self.backend,is_elevated=lambda:True) + def tearDown(self):self.temp.cleanup() + def apply(self,plan=None):return self.installer.apply(self.root,self.exe,self.sha,43871,plan or self.plan) + def rollback(self):return self.installer.rollback(self.root,self.exe,self.sha,43871,self.plan) + def test_atomic_order_and_exact_six_deny_filters(self): + result=self.apply();self.assertTrue(result['nativeVerified']);self.assertFalse(result['receiverTestsVerified']);self.assertEqual(len(self.backend.objects),8) + calls=self.backend.calls;self.assertLess(calls.index('begin'),calls.index('add-provider'));self.assertLess(calls.index('add-sublayer'),calls.index('add-v4-non-tcp'));self.assertLess(calls.index('add-v6-all'),calls.index('commit'));self.assertEqual(calls[-2:],['verify','close']) + def test_incomplete_topology_prevents_transaction_or_mutation(self): + self.backend.prepare_topology=lambda:False + with self.assertRaisesRegex(InstallError,'topology-admission-unavailable'):self.apply() + self.assertNotIn('begin',self.backend.calls) + self.assertFalse(any(call.startswith('add-') for call in self.backend.calls)) + self.assertEqual(self.backend.calls[-1],'close') + def test_exact_eight_filter_atomic_receiver_install_and_rollback(self): + script="const {planServiceReceiver}=require('./apps/network-broker/wfp-topology.cjs');console.log(JSON.stringify(planServiceReceiver({appId:Buffer.from(process.argv[1],'hex'),brokerPort:43871,serviceSid:'S-1-5-80-1-2-3-4-5'})));" + result=subprocess.run(['node','-e',script,IDENTITY],cwd=Path(__file__).resolve().parents[1],capture_output=True,text=True,check=True,timeout=10) + receiver=json.loads(result.stdout) + plan=copy.deepcopy(self.plan);plan.update(version=3,brokerApplicationId=IDENTITY,serviceSid=receiver['serviceSid']);plan['filters']+=receiver['filters'] + with self.assertRaisesRegex(InstallError,'receiver-install-requires-verified-broker'):self.apply(plan) + result=self.installer.apply_managed(self.root,self.exe,self.sha,self.exe,self.sha,43871,plan) + self.assertTrue(result['nativeVerified']);self.assertEqual(len(self.backend.objects),10) + permit=self.backend.objects[('filter',_key('broker-service-receiver'))] + self.assertEqual(permit['action'],0x1002);self.assertEqual(permit['weight'],2) + self.installer.rollback(self.root,self.exe,self.sha,43871,plan) + self.assertEqual(self.backend.objects,{}) + def test_repeat_install_does_not_recreate_or_delete_existing_filters(self): + self.apply();self.backend.calls=[];self.assertTrue(self.apply()['existing']);self.assertFalse(any(c.startswith(('add-','delete-')) for c in self.backend.calls)) + def test_administrator_required_before_any_backend_operation(self): + self.installer.is_elevated=lambda:False + with self.assertRaisesRegex(InstallError,'administrator-required'):self.apply() + self.assertEqual(self.backend.calls,[]) + def test_manifest_hash_and_containment_enforced(self): + with self.assertRaisesRegex(InstallError,'installation-hash-mismatch'):self.installer.apply(self.root,self.exe,'0'*64,43871,self.plan) + with self.assertRaisesRegex(InstallError,'installation-path-invalid'):self.installer.apply(self.root/'missing',self.exe,self.sha,43871,self.plan) + def test_changed_app_id_or_permit_filter_refused_before_open(self): + for mutate in ['identity','permit','port','extra']: + plan=copy.deepcopy(self.plan) + if mutate=='identity':plan['applicationId']='00' + if mutate=='permit':plan['filters'][0]['action']='FWP_ACTION_PERMIT' + if mutate=='port':plan['filters'][2]['conditions'][-1]['value']=443 + if mutate=='extra':plan['filters'].append(plan['filters'][0]) + with self.assertRaises(InstallError):self.apply(plan) + self.assertNotIn('open',self.backend.calls) + def test_existing_provider_collision_does_not_add_delete(self): + self.backend.objects[('provider',PROVIDER)]={'flags':1,'data':'foreign','service':SERVICE} + with self.assertRaisesRegex(InstallError,'existing-boundary-collision'):self.apply() + self.assertEqual(self.backend.calls[-2:],['abort','close']);self.assertFalse(any(c.startswith(('add-','delete-')) for c in self.backend.calls)) + def test_every_mutation_or_commit_error_aborts_and_closes(self): + for failure in ['add-provider','add-sublayer','add-v4-non-tcp','add-v4-tcp-non-broker-address','add-v4-tcp-other-local-port','add-v6-all','commit']: + self.backend=Backend();self.installer=BoundaryInstaller(self.backend,is_elevated=lambda:True);self.backend.failure=failure + with self.assertRaises(InstallError):self.apply() + self.assertEqual(self.backend.calls[-2:],['abort','close']);self.assertEqual(self.backend.objects,{}) + def test_readback_tampering_aborts_before_commit(self): + self.backend.tamper=True + with self.assertRaisesRegex(InstallError,'added-filter-mismatch'):self.apply() + self.assertNotIn('commit',self.backend.calls);self.assertEqual(self.backend.objects,{}) + def test_postcommit_verification_failure_keeps_denies_and_reports_failure(self): + self.backend.post_failure=True + with self.assertRaisesRegex(InstallError,'postcommit-verification-failed'):self.apply() + self.assertEqual(len(self.backend.objects),8);self.assertNotIn('abort',self.backend.calls);self.assertEqual(self.backend.calls[-1],'close') + def test_rollback_removes_only_exact_owned_six_filters_and_metadata(self): + self.apply();self.backend.calls=[];self.assertTrue(self.rollback()['removedOwnedBoundary']);self.assertEqual(self.backend.objects,{}) + self.assertEqual([c for c in self.backend.calls if c.startswith('delete-')],['delete-filter']*6+['delete-sublayer','delete-provider']) + def test_rollback_metadata_collision_prevents_any_delete(self): + self.apply();self.backend.objects[('sublayer',SUBLAYER)]['data']='foreign';self.backend.calls=[] + with self.assertRaises(InstallError):self.rollback() + self.assertFalse(any(c.startswith('delete-') for c in self.backend.calls));self.assertEqual(len(self.backend.objects),8) + def test_rollback_error_restores_ownership_objects(self): + self.apply();self.backend.failure='delete-sublayer' + with self.assertRaises(InstallError):self.rollback() + self.assertEqual(len(self.backend.objects),8);self.assertEqual(self.backend.calls[-2:],['abort','close']) +if __name__=='__main__':unittest.main() diff --git a/tests/test_windows_wfp_probe.py b/tests/test_windows_wfp_probe.py new file mode 100644 index 0000000..13bb8c7 --- /dev/null +++ b/tests/test_windows_wfp_probe.py @@ -0,0 +1,75 @@ +"""Read-only WFP contract and cleanup tests; no filter mutations.""" +import copy +import ctypes as C +import sys +import unittest +from pathlib import Path +from unittest.mock import Mock +sys.path.insert(0,str(Path(__file__).resolve().parent.parent/'apps/network-broker')) +from windows_wfp_probe import ReadOnlyWfp,verify_observed,Filter +PROVIDER='2a7bcbf2-e4f5-4e94-bd33-d5442a3bef50' +SUBLAYER='403a69b8-fc7c-460a-a247-72e0d72e6bf8' +def contract(): + expected=[];observed=[] + for index in range(6): + conditions=[{'field':'d78e1e87-8644-4ea5-9437-d809ecefc971','match':'equal','type':'FWP_BYTE_BLOB_TYPE','value':'01020304'}, {'field':'3971ef2b-623e-4f9a-8cb1-6e79b806b9a7','match':'equal','type':'FWP_UINT8','value':index}] + expected.append({'layer':'layer-'+str(index),'action':'FWP_ACTION_BLOCK','clearActionRight':False,'conditions':conditions}) + observed.append({'provider':PROVIDER,'sublayer':SUBLAYER,'layer':'layer-'+str(index),'flags':1,'action':0x1001,'conditions':copy.deepcopy(conditions)}) + return {'filters':expected},observed +class WfpProbeTests(unittest.TestCase): + def test_exact_contract_and_reordered_conditions(self): + plan,items=contract();items.reverse() + for item in items:item['conditions'].reverse() + self.assertTrue(verify_observed(plan,items,PROVIDER,SUBLAYER)['verified']) + def test_missing_duplicate_or_extra_filters(self): + plan,items=contract() + for bad in [items[:-1],items+items[:1],items[:3]+items[:1]]: + self.assertFalse(verify_observed(plan,bad,PROVIDER,SUBLAYER)['verified']) + def test_blob_type_match_layer_and_numeric_mismatch(self): + plan,items=contract() + for key,value in [('type','FWP_UINT32'),('value','deadbeef'),('match','not-equal')]: + bad=copy.deepcopy(items);bad[0]['conditions'][0][key]=value + self.assertEqual(verify_observed(plan,bad,PROVIDER,SUBLAYER)['reason'],'filter-condition-mismatch') + bad=copy.deepcopy(items);bad[0]['layer']='other-layer';self.assertFalse(verify_observed(plan,bad,PROVIDER,SUBLAYER)['verified']) + def test_disabled_nonpersistent_permit_softblock_or_wrong_owner(self): + plan,items=contract() + for key,value in [('flags',0),('flags',33),('flags',9),('action',0x1002),('provider','11111111-1111-1111-1111-111111111111'),('sublayer','22222222-2222-2222-2222-222222222222')]: + bad=copy.deepcopy(items);bad[0][key]=value + self.assertEqual(verify_observed(plan,bad,PROVIDER,SUBLAYER)['reason'],'filter-metadata-mismatch') + def test_engine_denial_is_unverified_and_not_closed_or_enumerated(self): + probe=ReadOnlyWfp.__new__(ReadOnlyWfp);probe.api=Mock();probe.api.FwpmEngineOpen0.return_value=5 + self.assertEqual(probe.inspect(contract()[0],PROVIDER,SUBLAYER),{'verified':False,'reason':'engine-unavailable','code':5}) + probe.api.FwpmFilterCreateEnumHandle0.assert_not_called();probe.api.FwpmEngineClose0.assert_not_called() + def test_enum_handle_denial_closes_opened_engine(self): + probe=ReadOnlyWfp.__new__(ReadOnlyWfp);probe.api=Mock() + def opened(_name,_auth,_identity,_session,handle):handle._obj.value=123;return 0 + probe.api.FwpmEngineOpen0.side_effect=opened;probe.api.FwpmFilterCreateEnumHandle0.return_value=5 + self.assertEqual(probe.inspect(contract()[0],PROVIDER,SUBLAYER)['code'],5) + probe.api.FwpmEngineClose0.assert_called_once();probe.api.FwpmFilterDestroyEnumHandle0.assert_not_called() + def test_empty_enumeration_cleans_handle_and_engine(self): + probe=ReadOnlyWfp.__new__(ReadOnlyWfp);probe.api=Mock() + def opened(_name,_auth,_identity,_session,handle):handle._obj.value=123;return 0 + def enumeration(_engine,_template,handle):handle._obj.value=456;return 0 + def empty(_engine,_handle,_limit,_entries,count):count._obj.value=0;return 0 + probe.api.FwpmEngineOpen0.side_effect=opened;probe.api.FwpmFilterCreateEnumHandle0.side_effect=enumeration;probe.api.FwpmFilterEnum0.side_effect=empty + self.assertEqual(probe.inspect(contract()[0],PROVIDER,SUBLAYER)['reason'],'filter-count-mismatch') + probe.api.FwpmFilterDestroyEnumHandle0.assert_called_once();probe.api.FwpmEngineClose0.assert_called_once() + def test_oversized_native_page_frees_memory_and_closes_handles(self): + probe=ReadOnlyWfp.__new__(ReadOnlyWfp);probe.api=Mock() + probe.api.FwpmEngineOpen0.side_effect=lambda *args:(setattr(args[-1]._obj,'value',123) or 0) + probe.api.FwpmFilterCreateEnumHandle0.side_effect=lambda *args:(setattr(args[-1]._obj,'value',456) or 0) + storage=(C.POINTER(Filter)*129)() + def oversized(_engine,_handle,_limit,entries,count): + C.cast(entries,C.POINTER(C.POINTER(C.POINTER(Filter))))[0]=C.cast(storage,C.POINTER(C.POINTER(Filter))) + count._obj.value=129;return 0 + probe.api.FwpmFilterEnum0.side_effect=oversized + self.assertEqual(probe.inspect(contract()[0],PROVIDER,SUBLAYER)['reason'],'enumeration-limit') + probe.api.FwpmFreeMemory0.assert_called_once();probe.api.FwpmFilterDestroyEnumHandle0.assert_called_once();probe.api.FwpmEngineClose0.assert_called_once() + def test_enumeration_error_cleans_handles(self): + probe=ReadOnlyWfp.__new__(ReadOnlyWfp);probe.api=Mock() + probe.api.FwpmEngineOpen0.side_effect=lambda *args:(setattr(args[-1]._obj,'value',123) or 0) + probe.api.FwpmFilterCreateEnumHandle0.side_effect=lambda *args:(setattr(args[-1]._obj,'value',456) or 0) + probe.api.FwpmFilterEnum0.return_value=5 + self.assertEqual(probe.inspect(contract()[0],PROVIDER,SUBLAYER)['reason'],'enumeration-failed') + probe.api.FwpmFilterDestroyEnumHandle0.assert_called_once();probe.api.FwpmEngineClose0.assert_called_once() +if __name__=='__main__':unittest.main() diff --git a/tests/test_windows_wfp_receiver.py b/tests/test_windows_wfp_receiver.py new file mode 100644 index 0000000..3b85f21 --- /dev/null +++ b/tests/test_windows_wfp_receiver.py @@ -0,0 +1,59 @@ +import ctypes as C +import os +import json +import subprocess +import sys +import unittest +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'apps/network-broker')) +from windows_wfp_receiver import service_sddl, service_descriptor +from windows_wfp_install import NativeBackend +from windows_wfp_probe import Filter, Blob + +class ReceiverTests(unittest.TestCase): + def test_service_sid_only_and_minimum_match_right(self): + self.assertEqual(service_sddl('S-1-5-80-1-2-3-4-5'), 'D:(A;;CC;;;S-1-5-80-1-2-3-4-5)') + for sid in ['S-1-5-19', 'S-1-5-80-01-2-3-4-5', 'S-1-5-80-4294967296-2-3-4-5']: + with self.assertRaises(Exception): service_sddl(sid) + + @unittest.skipUnless(os.name == 'nt', 'Native Windows SD validation') + def test_actual_native_self_relative_sd_and_exact_ace_mask(self): + api = C.WinDLL('advapi32') + api.IsValidSecurityDescriptor.argtypes = [C.c_void_p] + api.IsValidSecurityDescriptor.restype = C.c_int + with service_descriptor('S-1-5-80-1-2-3-4-5') as blob: + self.assertTrue(api.IsValidSecurityDescriptor(blob.data)) + raw = C.string_at(blob.data, blob.size) + self.assertTrue(int.from_bytes(raw[2:4], 'little') & 0x8000) + dacl = int.from_bytes(raw[16:20], 'little') + self.assertEqual(int.from_bytes(raw[dacl+4:dacl+6], 'little'), 1) + self.assertEqual(int.from_bytes(raw[dacl+12:dacl+16], 'little'), 1) + + @unittest.skipUnless(os.name == 'nt', 'Native Windows SD marshalling') + def test_actual_sd_lifetime_and_uint64_soft_permit_marshalling_without_add(self): + backend = NativeBackend.__new__(NativeBackend) + backend.engine = C.c_void_p(1) + captured = [] + def capture(name, _engine, pointer, _security, _id): + self.assertEqual(name, 'FwpmFilterAdd0') + value = C.cast(pointer, C.POINTER(Filter)).contents + self.assertEqual(value.flags, 1) # PERSISTENT only; no clear-action. + self.assertEqual(value.action.type, 0x1002) + self.assertEqual(value.weight.type, 4) + self.assertEqual(C.cast(value.weight.pointer, C.POINTER(C.c_uint64)).contents.value, 2) + self.assertEqual(value.count, 5) + condition = value.conditions[4] + self.assertEqual(condition.value.type, 14) + blob = C.cast(condition.value.pointer, C.POINTER(Blob)).contents + captured.append(C.string_at(blob.data, blob.size)) + backend._call = capture + script = "const {planServiceReceiver}=require('./apps/network-broker/wfp-topology.cjs');console.log(JSON.stringify(planServiceReceiver({appId:Buffer.from(process.argv[1],'hex'),brokerPort:43120,serviceSid:'S-1-5-80-1-2-3-4-5'}).filters[0]));" + identity = '\\device\\harddiskvolume3\\safetynet\\node.exe\0'.encode('utf-16le').hex() + result = subprocess.run(['node','-e',script,identity],cwd=Path(__file__).resolve().parents[1],capture_output=True,text=True,check=True,timeout=10) + rule = json.loads(result.stdout) + backend.add_filter(rule) + rule['conditions'] = rule['conditions'][-1:] + with self.assertRaises(Exception): backend.add_filter(rule) + self.assertEqual(len(captured), 1) + +if __name__ == '__main__': unittest.main() diff --git a/tests/test_windows_wfp_topology.py b/tests/test_windows_wfp_topology.py new file mode 100644 index 0000000..f80a951 --- /dev/null +++ b/tests/test_windows_wfp_topology.py @@ -0,0 +1,71 @@ +import ctypes as C +import sys +import threading +import unittest +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'apps/network-broker')) +from windows_wfp_topology import TopologyObserver, CALLBACK + +class ObserverTests(unittest.TestCase): + def observer(self, denied=False): + calls = [] + class Api: + def FwpmEngineOpen0(self, *_args): + _args[-1]._obj.value = 9 + return 0 + def FwpmFilterSubscribeChanges0(self, _engine, _subscription, _callback, _context, handle): + calls.append('filter-subscribe') + handle._obj.value = 10 + return 0 + def FwpmSubLayerSubscribeChanges0(self, _engine, _subscription, _callback, _context, handle): + calls.append('sublayer-subscribe') + if denied: + return 5 + handle._obj.value = 11 + return 0 + def FwpmFilterUnsubscribeChanges0(self, *_args): calls.append('filter-unsubscribe') + def FwpmSubLayerUnsubscribeChanges0(self, *_args): calls.append('sublayer-unsubscribe') + def FwpmEngineClose0(self, *_args): calls.append('close') + observer = TopologyObserver.__new__(TopologyObserver) + observer.api = Api() + observer.engine = C.c_void_p() + observer.handles = [] + observer.changed = threading.Event() + observer.callback = CALLBACK(lambda *_args: observer.changed.set()) + return observer, calls + + def test_second_subscription_failure_releases_first_and_engine(self): + observer, calls = self.observer(True) + self.assertEqual(observer.start()['code'], 5) + self.assertTrue(observer.changed.is_set()) + self.assertEqual(calls, ['filter-subscribe', 'sublayer-subscribe', 'filter-unsubscribe', 'close']) + self.assertEqual(observer.handles, []) + + def test_actual_callback_invalidates_and_never_claims_full_visibility(self): + observer, calls = self.observer() + result = observer.start() + self.assertFalse(result['allObjectsReadable']) + observer.callback(None, None) + self.assertEqual(observer.snapshot([])['reason'], 'observer-unavailable') + observer.close() + self.assertEqual(calls[-3:], ['sublayer-unsubscribe', 'filter-unsubscribe', 'close']) + observer.close() + self.assertEqual(calls.count('close'), 1) + + def test_authorized_readback_cannot_invent_visibility_and_change_is_sticky(self): + from types import SimpleNamespace + observer, _calls = self.observer() + rows=[{'key':str(index),'sublayer':'owned','provider':'provider'} for index in range(8)] + observer.revalidate=lambda _layers:{'complete':True,'allObjectsReadable':False,'filters':rows} + observer.reader=SimpleNamespace(inspect=lambda *_args:{'verified':True}) + result=observer.authorized_snapshot({},'provider','owned',[]) + self.assertFalse(result['allObjectsReadable']) + self.assertTrue(all(row['ownedVerified'] for row in result['filters'])) + def raced(*_args): + observer.changed.set() + return {'verified':True} + observer.reader.inspect=raced + self.assertEqual(observer.authorized_snapshot({},'provider','owned',[])['reason'],'owned-readback-unavailable') + +if __name__ == '__main__': + unittest.main() diff --git a/tests/verified-installer.test.mjs b/tests/verified-installer.test.mjs new file mode 100644 index 0000000..9436c68 --- /dev/null +++ b/tests/verified-installer.test.mjs @@ -0,0 +1,38 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtemp,mkdir,writeFile,rm} from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import {createWindowsPackagePlan} from '../scripts/windows-package-plan.mjs'; +import {buildVerifiedInstaller} from '../scripts/verified-installer.mjs'; +const identity={sourceSha256:'a'.repeat(64),runtimeSha256:'b'.repeat(64),packageSha256:'c'.repeat(64)}; +async function fixture(t){ + const root=await mkdtemp(path.join(os.tmpdir(),'safetynet-installer-verifier-'));t.after(()=>rm(root,{force:true,recursive:true})); + const output=path.join(root,'dist');await mkdir(output);const plan=createWindowsPackagePlan({workspace:root,outputDirectory:output,electronDirectory:path.join(root,'electron'),platform:'win32'}); + return {plan,expectedIdentity:identity,readIdentity:async()=>identity,verify:async()=>({identity,fuses:true,resources:true,broker:true,runtimeAcl:true}),installerPath:path.join(output,'fixture.exe')}; +} +test('actual invoked installer arguments require before and after package verification',async t=>{ + const f=await fixture(t);let calls=0,verifications=0; + const result=await buildVerifiedInstaller({...f,verify:async()=>{verifications++;return f.verify();},run:async args=>{calls++;assert.ok(args.includes('--prepackaged'));const fake=Buffer.alloc(1024);fake.write('MZ');await writeFile(f.installerPath,fake);return {status:0};}}); + assert.equal(calls,1);assert.equal(verifications,2);assert.equal(result.bytes,1024);assert.match(result.sha256,/^[a-f0-9]{64}$/); + // Synthetic PE prefix exercises orchestration only, never installer validity. +}); +test('source mutation during successful CLI run invalidates the installer result',async t=>{ + const f=await fixture(t);let changed=false; + await assert.rejects(buildVerifiedInstaller({...f,readIdentity:async()=>changed?{...identity,sourceSha256:'d'.repeat(64)}:identity,run:async()=>{changed=true;return {status:0};}}),{code:'installer_identity_changed'}); +}); +test('failed CLI and missing or stale artifact cannot pass based on a good plan',async t=>{ + const f=await fixture(t); + await assert.rejects(buildVerifiedInstaller({...f,run:async()=>({status:1})}),{code:'installer_build_failed'}); + await assert.rejects(buildVerifiedInstaller({...f,run:async()=>({status:0})}),{code:'installer_artifact_invalid'}); + const fake=Buffer.alloc(1024);fake.write('MZ');await writeFile(f.installerPath,fake); + await assert.rejects(buildVerifiedInstaller({...f,run:async()=>({status:0})}),{code:'installer_artifact_invalid'}); +}); +test('post-invocation mandatory security verification cannot be skipped',async t=>{ + const f=await fixture(t);let calls=0; + await assert.rejects(buildVerifiedInstaller({...f,verify:async()=>({...await f.verify(),broker:++calls===1}),run:async()=>({status:0})}),{code:'installer_verification_failed'}); +}); +test('identity stays pinned through final artifact hashing and result delivery',async t=>{ + const f=await fixture(t);let reads=0; + await assert.rejects(buildVerifiedInstaller({...f,readIdentity:async()=>++reads>=6?{...identity,sourceSha256:'d'.repeat(64)}:identity,run:async()=>{const fake=Buffer.alloc(1024);fake.write('MZ');await writeFile(f.installerPath,fake);return {status:0};}}),{code:'installer_identity_changed'}); +}); diff --git a/tests/windows-package-plan.test.mjs b/tests/windows-package-plan.test.mjs new file mode 100644 index 0000000..07aa2ee --- /dev/null +++ b/tests/windows-package-plan.test.mjs @@ -0,0 +1,42 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import {createWindowsPackagePlan} from '../scripts/windows-package-plan.mjs'; + +const root=path.resolve('.'),options={workspace:root,outputDirectory:path.join(root,'dist'),electronDirectory:path.join(root,'node_modules/electron/dist'),platform:'win32'}; +const expected={sourceSha256:'a'.repeat(64),runtimeSha256:'b'.repeat(64),packageSha256:'c'.repeat(64)}; +const receipt={identity:expected,fuses:true,resources:true,broker:true,runtimeAcl:true}; +test('unpacked and verified NSIS stages use distinct official CLI commands',async()=>{ + const plan=createWindowsPackagePlan(options); + assert.ok(plan.unpacked.args.includes('--dir')); + assert.deepEqual(plan.verification.required,['fuses','resources','broker','runtimeAcl']); + const installer=await plan.installer({expectedIdentity:expected,readIdentity:async()=>expected,verify:async()=>receipt}); + assert.deepEqual(installer.args.slice(0,4),['--win','nsis','--prepackaged',plan.unpacked.directory]); + assert.equal(installer.args.includes('--dir'),false); + assert.deepEqual(installer.args.slice(-2),['--publish','never']); + assert.equal(Object.isFrozen(installer.args),true); +}); +test('every security verification gate is mandatory before installer planning',async()=>{ + const plan=createWindowsPackagePlan(options); + for(const key of ['fuses','resources','broker','runtimeAcl'])await assert.rejects(plan.installer({expectedIdentity:expected,readIdentity:async()=>expected,verify:async()=>({...receipt,[key]:false})}),{code:'package_verification_incomplete'}); +}); +test('missing verification callbacks cannot authorize an installer',async()=>{ + await assert.rejects(createWindowsPackagePlan(options).installer({expectedIdentity:expected}),{code:'package_verifier_required'}); +}); +test('source runtime and package identity changes each prevent reuse',async()=>{ + const plan=createWindowsPackagePlan(options); + for(const key of Object.keys(expected)){ + let reads=0; + await assert.rejects(plan.installer({expectedIdentity:expected,readIdentity:async()=>++reads===1?expected:{...expected,[key]:'d'.repeat(64)},verify:async()=>receipt}),{code:'package_identity_changed'}); + await assert.rejects(plan.installer({expectedIdentity:expected,readIdentity:async()=>expected,verify:async()=>({...receipt,identity:{...expected,[key]:'d'.repeat(64)}})}),{code:'package_verification_incomplete'}); + } +}); +test('outside output malformed branding and unsupported platform are rejected',()=>{ + for(const override of [{outputDirectory:path.dirname(root)},{outputDirectory:root},{productName:'bad\nname'},{appId:'bad'},{platform:'linux'}]){ + assert.throws(()=>createWindowsPackagePlan({...options,...override})); + } +}); +test('failed verifier remains a failure rather than a distributable result',async()=>{ + const failure=new Error('synthetic verifier failure'); + await assert.rejects(createWindowsPackagePlan(options).installer({expectedIdentity:expected,readIdentity:async()=>expected,verify:async()=>{throw failure;}}),error=>error===failure); +}); diff --git a/tests/windows-release-inventory.test.mjs b/tests/windows-release-inventory.test.mjs new file mode 100644 index 0000000..95e6a40 --- /dev/null +++ b/tests/windows-release-inventory.test.mjs @@ -0,0 +1,29 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtemp,mkdir,writeFile,rm,symlink} from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import {generateKeyPairSync} from 'node:crypto'; +import {createRequire} from 'node:module'; +const {signManifest,verifyReleaseEnvelope}=createRequire(import.meta.url)('../packages/core/windows-trust.cjs'); +import {buildReleaseInventory,verifyReleaseInventory} from '../scripts/windows-release-inventory.mjs'; +async function fixture(t){const root=await mkdtemp(path.join(os.tmpdir(),'safetynet-release-inventory-'));t.after(()=>rm(root,{recursive:true,force:true}));for(const file of ['SafetyNet.exe','safetynet-service-host.exe','resources/app.asar','resources/broker/node.exe','resources/python/python.exe','resources/inspector/server.py','resources/semantic/model.bin']){const name=path.join(root,file);await mkdir(path.dirname(name),{recursive:true});await writeFile(name,'synthetic-public-'+file);}return root;} +test('complete actual staged inventory binds all components and excludes only external envelopes',async t=>{ + const root=await fixture(t);await writeFile(path.join(root,'release-envelope.json'),'synthetic-public-signature');await writeFile(path.join(root,'deployment-envelope.json'),'synthetic-public-deployment');const files=await buildReleaseInventory(root);assert.equal(files.length,7);assert.deepEqual(files.map(file=>file.path),files.map(file=>file.path).sort());assert.ok(files.every(file=>/^[a-f0-9]{64}$/.test(file.sha256)));assert.equal(await verifyReleaseInventory(root,{manifest:{files}}),true); +}); +test('missing scanner, same-size tampering and extra broker module cannot pass trusted inventory',async t=>{ + const root=await fixture(t),files=await buildReleaseInventory(root);const target=path.join(root,'resources/broker/node.exe');const original=Buffer.from('synthetic-public-resources/broker/node.exe');await writeFile(target,Buffer.alloc(original.length,65));await assert.rejects(verifyReleaseInventory(root,{manifest:{files}}),{code:'windows-release-inventory-mismatch'});await writeFile(target,original);await writeFile(path.join(root,'resources/broker/extra.cjs'),'synthetic-public-extra');await assert.rejects(verifyReleaseInventory(root,{manifest:{files}}),{code:'windows-release-inventory-mismatch'});await rm(path.join(root,'resources/inspector/server.py'));await assert.rejects(buildReleaseInventory(root),{code:'windows-release-component-missing'}); +}); +test('actual junction cannot smuggle unsigned external files into release inventory',async t=>{ + const root=await fixture(t);await symlink(path.join(root,'resources/python'),path.join(root,'resources/linked'),process.platform==='win32'?'junction':'dir');await assert.rejects(buildReleaseInventory(root),{code:'windows-release-path-unsafe'}); +}); + +test('genuine canonical signed release verifies actual inventory despite JSON field order, then rejects tamper',async t=>{ + const root=await fixture(t),vendor=generateKeyPairSync('ec',{namedCurve:'prime256v1'}),customer=generateKeyPairSync('ec',{namedCurve:'prime256v1'}); + const manifest={format:'safetynet-windows-release-v1',releaseId:'12345678-1234-1234-1234-123456789abc',releaseSequence:1,serviceName:'SafetyNetNetworkBroker',deploymentTenant:'22345678-1234-1234-1234-123456789abc',deploymentPublicKey:customer.publicKey.export({type:'spki',format:'pem'}),files:await buildReleaseInventory(root)}; + const signed=signManifest(manifest,vendor.privateKey.export({type:'pkcs8',format:'pem'}),'release'),verified=verifyReleaseEnvelope(signed,{publicKey:vendor.publicKey.export({type:'spki',format:'pem'})}); + assert.notDeepEqual(Object.keys(manifest.files[0]),Object.keys(verified.manifest.files[0])); + assert.equal(await verifyReleaseInventory(root,verified),true); + const browser=verified.manifest.files.find(file=>file.path==='SafetyNet.exe');await writeFile(path.join(root,'SafetyNet.exe'),Buffer.alloc(browser.bytes,66)); + await assert.rejects(verifyReleaseInventory(root,verified),{code:'windows-release-inventory-mismatch'}); +}); diff --git a/tests/windows-trust.test.mjs b/tests/windows-trust.test.mjs new file mode 100644 index 0000000..641d52e --- /dev/null +++ b/tests/windows-trust.test.mjs @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +import {generateKeyPairSync,sign} from 'node:crypto'; +const require=createRequire(import.meta.url); +const {verifyReleaseEnvelope,verifyDeploymentEnvelope,signManifest,publicKeyCoordinates,verifiedManagementBinding,DOMAINS}=require('../packages/core/windows-trust.cjs'); +function key(type='ec'){const value=generateKeyPairSync(type,type==='ec'?{namedCurve:'prime256v1'}:{});return {publicKey:value.publicKey.export({type:'spki',format:'pem'}),privateKey:value.privateKey.export({type:'pkcs8',format:'pem'})};} +function fixture(){ + const vendor=key(),customer=key(),policy=key('ed25519'); + const release={format:'safetynet-windows-release-v1',releaseId:'12345678-1234-1234-1234-123456789abc',releaseSequence:2,serviceName:'SafetyNetNetworkBroker',deploymentTenant:'22345678-1234-1234-1234-123456789abc',deploymentPublicKey:customer.publicKey,files:['SafetyNet.exe','resources/app.asar','resources/broker/node.exe','resources/inspector/server.py','resources/python/python.exe','resources/semantic/model.bin','safetynet-service-host.exe'].map(path=>({path,bytes:4,sha256:'a'.repeat(64)}))}; + const deployment={format:'safetynet-windows-deployment-v1',releaseId:release.releaseId,deploymentTenant:release.deploymentTenant,deploymentSequence:3,policyPublicKey:policy.publicKey,expiresAt:Date.now()+60000,users:[{sid:'S-1-5-21-1-2-3-1001',deviceId:'synthetic-workstation',minimumPolicyVersion:7}]}; + const verified=verifyReleaseEnvelope(signManifest(release,vendor.privateKey,'release'),{publicKey:vendor.publicKey}); + return {vendor,customer,policy,release,deployment,verified}; +} +function deploy(f,value=f.deployment,options={}){return verifyDeploymentEnvelope(signManifest(value,f.customer.privateKey,'deployment'),{release:f.verified,authenticatedSid:f.deployment.users[0].sid,...options});} +test('vendor release delegates independent customer signer and actual SID produces exact pinned bootstrap',()=>{const f=fixture(),verified=deploy(f);assert.deepEqual(verified.bootstrap,{publicKey:f.policy.publicKey,deviceId:'synthetic-workstation',minimumVersion:7});assert.ok(Object.isFrozen(verified)&&Object.isFrozen(verified.bootstrap)&&Object.isFrozen(f.verified.manifest.files));assert.equal(publicKeyCoordinates(f.vendor.publicKey).length,64);}); +test('vendor key cannot sign delegated customer deployment and fake verified releases are rejected',()=>{const f=fixture();assert.throws(()=>verifyDeploymentEnvelope(signManifest(f.deployment,f.vendor.privateKey,'deployment'),{release:f.verified,authenticatedSid:f.deployment.users[0].sid}),{code:'windows-trust-signature-invalid'});assert.throws(()=>verifyDeploymentEnvelope(signManifest(f.deployment,f.customer.privateKey,'deployment'),{release:structuredClone(f.verified),authenticatedSid:f.deployment.users[0].sid}),{code:'windows-deployment-invalid'});}); +test('release signature tamper, unpinned signer and invalid public key types fail with fixed errors',()=>{const f=fixture(),envelope=signManifest(f.release,f.vendor.privateKey,'release');const tampered={...envelope,payload:Buffer.from(JSON.stringify({...f.release,releaseSequence:99})).toString('base64url')};assert.throws(()=>verifyReleaseEnvelope(tampered,{publicKey:f.vendor.publicKey}),{code:'windows-trust-signature-invalid'});assert.throws(()=>verifyReleaseEnvelope(envelope,{publicKey:f.customer.publicKey}),{code:'windows-trust-signature-invalid'});for(const pem of [f.vendor.privateKey,f.policy.publicKey,'secret invalid key'])assert.throws(()=>verifyReleaseEnvelope(envelope,{publicKey:pem}),error=>error.message===error.code&&!error.message.includes('secret'));}); +test('strict canonical signed bytes reject duplicate fields, envelope extras and cross-domain signatures',()=>{ + const f=fixture(),text=JSON.stringify({...f.release,extra:'secret'}),payload=Buffer.from(text).toString('base64url'); + const signed=text=>sign('sha256',Buffer.concat([Buffer.from(DOMAINS.release),Buffer.from(text)]),{key:f.vendor.privateKey,dsaEncoding:'ieee-p1363'}).toString('base64url'); + assert.throws(()=>verifyReleaseEnvelope({payload,signature:signed(text)},{publicKey:f.vendor.publicKey}),{code:'windows-trust-canonical-invalid'}); + const good=signManifest(f.release,f.vendor.privateKey,'release'),canonical=Buffer.from(good.payload,'base64url').toString(),duplicate=canonical.replace('"releaseSequence":2','"releaseSequence":2,"releaseSequence":2');assert.throws(()=>verifyReleaseEnvelope({payload:Buffer.from(duplicate).toString('base64url'),signature:signed(duplicate)},{publicKey:f.vendor.publicKey}),{code:'windows-trust-canonical-invalid'}); + assert.throws(()=>verifyReleaseEnvelope({...good,key:f.vendor.publicKey},{publicKey:f.vendor.publicKey}),{code:'windows-trust-envelope-invalid'}); + const deploymentSignature=sign('sha256',Buffer.concat([Buffer.from(DOMAINS.deployment),Buffer.from(good.payload,'base64url')]),{key:f.vendor.privateKey,dsaEncoding:'ieee-p1363'}).toString('base64url');assert.throws(()=>verifyReleaseEnvelope({...good,signature:deploymentSignature},{publicKey:f.vendor.publicKey}),{code:'windows-trust-signature-invalid'}); +}); +test('protected release/deployment floors reject rollback and same sequence equivocation',()=>{const f=fixture(),envelope=signManifest(f.release,f.vendor.privateKey,'release');assert.throws(()=>verifyReleaseEnvelope(envelope,{publicKey:f.vendor.publicKey,floor:{minimumSequence:3}}),{code:'windows-trust-rollback'});assert.throws(()=>verifyReleaseEnvelope(envelope,{publicKey:f.vendor.publicKey,floor:{minimumSequence:2,acceptedDigest:'0'.repeat(64)}}),{code:'windows-trust-rollback'});assert.ok(verifyReleaseEnvelope(envelope,{publicKey:f.vendor.publicKey,floor:{minimumSequence:2,acceptedDigest:f.verified.digest}}));const d=deploy(f);assert.throws(()=>deploy(f,f.deployment,{floor:{minimumSequence:4}}),{code:'windows-trust-rollback'});assert.throws(()=>deploy(f,{...f.deployment,expiresAt:f.deployment.expiresAt+1},{floor:{minimumSequence:3,acceptedDigest:d.digest}}),{code:'windows-trust-rollback'});}); +test('release/tenant/expiry/SID binding rejects caller-selected authority or unknown peer',()=>{const f=fixture();for(const change of [{releaseId:'32345678-1234-1234-1234-123456789abc'},{deploymentTenant:'32345678-1234-1234-1234-123456789abc'},{expiresAt:1}])assert.throws(()=>deploy(f,{...f.deployment,...change}),{code:'windows-deployment-binding-invalid'});assert.throws(()=>deploy(f,f.deployment,{authenticatedSid:'S-1-5-21-1-2-3-1002'}),{code:'windows-deployment-user-denied'});assert.throws(()=>signManifest({...f.deployment,publicKey:f.policy.publicKey},f.customer.privateKey,'deployment'),{code:'windows-deployment-invalid'});}); +test('strict inventory and user schemas reject traversal, case duplicates, unsafe bounds and ambiguous accounts',()=>{const f=fixture();for(const files of [[{path:'../SafetyNet.exe',bytes:1,sha256:'a'.repeat(64)}],[...f.release.files,{path:'safetynet.exe',bytes:4,sha256:'a'.repeat(64)}]])assert.throws(()=>signManifest({...f.release,files},f.vendor.privateKey,'release'),{code:'windows-release-invalid'});for(const sid of ['S-1-5-18','S-1-5-21-1-2-3-01001','S-1-5-21-1-2-3-4294967296'])assert.throws(()=>signManifest({...f.deployment,users:[{...f.deployment.users[0],sid}]},f.customer.privateKey,'deployment'),{code:'windows-deployment-invalid'});assert.throws(()=>deploy(f,f.deployment,{floor:{minimumSequence:-1}}),{code:'windows-trust-floor-invalid'});}); +test('canonical Entra user SID binds actual peer while service aliases remain rejected',()=>{const f=fixture(),sid='S-1-12-1-123-456-789-1000',deployment={...f.deployment,users:[{...f.deployment.users[0],sid}]};assert.equal(deploy(f,deployment,{authenticatedSid:sid}).bootstrap.deviceId,'synthetic-workstation');for(const invalid of ['S-1-12-1-0123-456-789-1000','S-1-12-1-4294967296-456-789-1000','S-1-5-80-1-2-3-4-5','S-1-5-32-544'])assert.throws(()=>signManifest({...deployment,users:[{...deployment.users[0],sid:invalid}]},f.customer.privateKey,'deployment'),{code:'windows-deployment-invalid'});}); +function v2(f){const pq=key('ml-dsa-87'),channel=require('../packages/core/policy-channel.cjs');return {pq,channel,deployment:{...f.deployment,format:'safetynet-windows-deployment-v2',management:{origin:'https://management.example',signatureKey:pq.publicKey,protocol:channel.VERSION}}};} +test('signed v2 returns only provenance-verified frozen management binding compatible with actual PQ channel',()=>{const f=fixture(),{pq,channel,deployment}=v2(f),verified=deploy(f,deployment),binding=verifiedManagementBinding(verified);assert.deepEqual(binding,deployment.management);assert.ok(Object.isFrozen(binding));const identity=channel.identity(f.policy.privateKey,pq.privateKey,pq.publicKey);assert.equal(channel.verifyIdentity(identity,verified.bootstrap.publicKey,binding.signatureKey),pq.publicKey);assert.throws(()=>verifiedManagementBinding(structuredClone(verified)),{code:'windows-management-denied'});assert.throws(()=>verifiedManagementBinding(deploy(f)),{code:'windows-management-denied'});}); +test('v2 management rejects noncanonical origins, credential fields and channel algorithm downgrade',()=>{const f=fixture(),{pq,deployment}=v2(f),management=deployment.management;for(const origin of ['http://management.example','https://management.example/','https://management.example/path','https://user:password@management.example','https://management.example?query=1','https://management.example#fragment','https://MANAGEMENT.example','https://management.example:443','https://management.example.','https://\u7ba1\u7406.example'])assert.throws(()=>signManifest({...deployment,management:{...management,origin}},f.customer.privateKey,'deployment'));for(const changed of [{...management,token:'synthetic-disallowed'},{...management,protocol:'safetynet-policy-mlkem768-mldsa65-v1'},{...management,signatureKey:key('ml-dsa-65').publicKey},{...management,signatureKey:f.policy.publicKey},{...management,signatureKey:pq.privateKey},{...management,signatureKey:pq.publicKey.trim()}])assert.throws(()=>signManifest({...deployment,management:changed},f.customer.privateKey,'deployment'));}); +test('management format/tampering cannot bypass delegated signatures or v1 historical capability boundary',()=>{const f=fixture(),{deployment}=v2(f),envelope=signManifest(deployment,f.customer.privateKey,'deployment'),tampered={...envelope,payload:Buffer.from(JSON.stringify({...deployment,management:{...deployment.management,origin:'https://attacker.example'}})).toString('base64url')};assert.throws(()=>verifyDeploymentEnvelope(tampered,{release:f.verified,authenticatedSid:f.deployment.users[0].sid}),{code:'windows-trust-signature-invalid'});for(const value of [{...deployment,format:'safetynet-windows-deployment-v3'},{...deployment,format:'safetynet-windows-deployment-v1'},{...deployment,extra:'unknown'},Object.fromEntries(Object.entries(deployment).filter(([name])=>name!=='management'))])assert.throws(()=>signManifest(value,f.customer.privateKey,'deployment'),{code:'windows-deployment-invalid'});assert.throws(()=>verifyDeploymentEnvelope(signManifest(deployment,f.vendor.privateKey,'deployment'),{release:f.verified,authenticatedSid:f.deployment.users[0].sid}),{code:'windows-trust-signature-invalid'});assert.equal(verifiedManagementBinding(deploy(f,deployment)).origin,'https://management.example');}); + +test('verified management projection cannot grant an expired deployment capability',t=>{ + const f=fixture(),{deployment}=v2(f),verified=deploy(f,deployment); + t.mock.method(Date,'now',()=>deployment.expiresAt); + assert.throws(()=>verifiedManagementBinding(verified),{code:'windows-management-denied'}); +}); +test('management origin host subset matches routed DNS and canonical IPv4/IPv6',()=>{ + const f=fixture(),{deployment}=v2(f); + for(const origin of ['https://localhost:8443','https://127.0.0.1','https://[::1]','https://[::ffff:c0a8:1]'])assert.equal(verifiedManagementBinding(deploy(f,{...deployment,management:{...deployment.management,origin}})).origin,origin); + for(const origin of ['https://bad_host.example','https://0xabc.example','https://[0:0:0:0:0:0:0:1]','https://[::ffff:192.168.0.1]','https://127.1','https://management.example:','https://[::1]:'])assert.throws(()=>signManifest({...deployment,management:{...deployment.management,origin}},f.customer.privateKey,'deployment')); +});