48 lines
4.7 KiB
JavaScript
48 lines
4.7 KiB
JavaScript
'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};
|