Files
safetynet/tests/broker-management-credential.test.mjs
T

41 lines
5.8 KiB
JavaScript

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();
});