Files
safetynet/apps/network-broker/service-acceptance.cjs
T

48 lines
4.7 KiB
JavaScript
Raw Normal View History

'use strict';
const {z}=require('zod');
const {createHash,createPublicKey}=require('node:crypto');
const {verifyReleaseEnvelope,verifyDeploymentEnvelope}=require('../../packages/core/windows-trust.cjs');
const {createPolicyAuthority}=require('./policy-authority.cjs');
const digest=z.string().regex(/^[a-f0-9]{64}$/),positive=z.number().int().min(1).max(Number.MAX_SAFE_INTEGER);
const sid=z.string().regex(/^(?:S-1-5-21|S-1-12-1)-(?:(?:0|[1-9][0-9]{0,9})-){3}(?:0|[1-9][0-9]{0,9})$/).refine(value=>value.split('-').slice(4).every(n=>Number(n)<=4294967295));
const device=z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/);
const floor=z.object({minimumSequence:positive,acceptedDigest:digest}).strict();
const policyFloor=z.object({sid,deviceId:device,policyKeySha256:digest,minimumVersion:positive,acceptedDigest:digest}).strict();
const StateSchema=z.object({format:z.literal('safetynet-service-floors-v1'),release:floor,deployment:floor.extend({tenant:z.string().regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)}),policies:z.array(policyFloor).max(8192)}).strict();
const identity=value=>`${value.sid}|${value.deviceId}|${value.policyKeySha256}`;
// Reserve room for the fixed snapshot/CAS envelope inside its 2 MiB frame.
const MAX_STATE_BYTES=2097152-1024;
function freeze(value){if(value&&typeof value==='object'){for(const child of Object.values(value))freeze(child);Object.freeze(value);}return value;}
function canonical(value){if(Array.isArray(value))return '['+value.map(canonical).join(',')+']';if(value&&typeof value==='object')return '{'+Object.keys(value).sort().map(key=>JSON.stringify(key)+':'+canonical(value[key])).join(',')+'}';return JSON.stringify(value);}
function encodeAcceptanceState(value){
const state=StateSchema.parse(value),ids=state.policies.map(identity);
if(ids.some((id,index)=>index>0&&id<=ids[index-1]))throw Error('broker-bootstrap-invalid');
const bytes=Buffer.from(canonical(state));if(bytes.length>MAX_STATE_BYTES)throw Error('broker-bootstrap-invalid');return bytes;
}
function acceptanceStateDigest(value){return createHash('sha256').update(encodeAcceptanceState(value)).digest('hex');}
// Pure verification/transition. The fixed native store must supply this state
// and commit nextState durably BEFORE listen. Returning a plan is not persistence.
async function verifyServiceAcceptance(input){
let authority,stage='state';
try{
const source=structuredClone(input),state=StateSchema.parse(source.state);
const previousStateDigest=acceptanceStateDigest(state);
stage='release';const release=verifyReleaseEnvelope(source.releaseEnvelope,{publicKey:source.releaseAnchor,floor:state.release});
if(release.manifest.deploymentTenant!==state.deployment.tenant)throw Error();
stage='deployment';const deployment=verifyDeploymentEnvelope(source.deploymentEnvelope,{release,floor:state.deployment,authenticatedSid:source.authenticatedSid});
const binding=deployment.bootstrap,keySha=createHash('sha256').update(createPublicKey(binding.publicKey).export({type:'spki',format:'der'})).digest('hex');
const recordId={sid:source.authenticatedSid,deviceId:binding.deviceId,policyKeySha256:keySha};
const previous=state.policies.find(value=>identity(value)===identity(recordId));
const minimumVersion=Math.max(binding.minimumVersion,previous?.minimumVersion||0);
stage='policy';authority=await createPolicyAuthority({...binding,minimumVersion});const snapshot=authority.accept(source.policyEnvelope);
if(snapshot.policy.assignment?.deviceId!==binding.deviceId)throw Error();
const acceptedDigest=createHash('sha256').update(source.policyEnvelope.payload).digest('hex');
if(previous?.minimumVersion===snapshot.version&&previous.acceptedDigest!==acceptedDigest)throw Error();
stage='transition';const policies=state.policies.filter(value=>identity(value)!==identity(recordId));
policies.push({...recordId,minimumVersion:snapshot.version,acceptedDigest});policies.sort((a,b)=>identity(a)<identity(b)?-1:identity(a)>identity(b)?1:0);
const nextState=StateSchema.parse({format:state.format,release:{minimumSequence:release.manifest.releaseSequence,acceptedDigest:release.digest},deployment:{tenant:state.deployment.tenant,minimumSequence:deployment.manifest.deploymentSequence,acceptedDigest:deployment.digest},policies});
const nextStateDigest=acceptanceStateDigest(nextState);
return freeze({deployment,bootstrap:{...binding,minimumVersion:snapshot.version},policyVersion:snapshot.version,policyDigest:acceptedDigest,previousStateDigest,nextStateDigest,nextState});
}catch{throw Object.assign(Error('broker-bootstrap-invalid'),{failureStage:stage});}finally{authority?.revoke();}
}
module.exports={verifyServiceAcceptance,StateSchema,encodeAcceptanceState,acceptanceStateDigest,MAX_STATE_BYTES};