Files
safetynet/apps/browser/agent-transport-client.cjs
T

40 lines
4.5 KiB
JavaScript
Raw Normal View History

'use strict';
const channel=require('../../packages/core/policy-channel.cjs');
const crypto=require('node:crypto');
function authorization(token,route,body='{}',principal){const time=Date.now(),nonce=crypto.randomBytes(32).toString('base64url');const mac=crypto.createHmac('sha384',token).update(principal?JSON.stringify(['safetynet-agent-auth-v1',route,principal.principalId,principal.generation,time,nonce,crypto.createHash('sha384').update(body).digest('base64url')]):`${route}:${time}:${nonce}:${crypto.createHash('sha384').update(body).digest('base64url')}`).digest('base64url');return principal?`SafetyNet-Agent-PQ ${principal.principalId}.${principal.generation}.${time}.${nonce}.${mac}`:`SafetyNet-PQ ${time}.${nonce}.${mac}`;}
async function boundedJson(response,max){const reader=response.body.getReader();const chunks=[];let size=0;try{for(;;){const {done,value}=await reader.read();if(done)break;size+=value.length;if(size>max)throw Error('Protected agent response too large');chunks.push(Buffer.from(value));}return JSON.parse(Buffer.concat(chunks).toString('utf8'));}finally{await reader.cancel();}}
async function requestAttempt(credential, action, options={}) {
const url=new URL(credential.address);
if(url.protocol!=='http:'||url.hostname!=='127.0.0.1'||url.username||url.password||url.pathname!=='/'||url.search||url.hash||credential.protocol!==channel.VERSION)throw Error('Protected agent credential required');
const individual=credential.principalId!==undefined;if(individual&&(!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/.test(credential.principalId)||!Number.isSafeInteger(credential.generation)||credential.generation<1||!credential.principalBinding?.startsWith(`agent:${credential.principalId}:${credential.generation}:`)||!/^.{1,120}:[0-9a-f-]{36}$/.test(credential.principalBinding)||!Number.isFinite(Date.parse(credential.expiresAt))||Date.parse(credential.expiresAt)<=Date.now()))throw Error('Invalid principal credential');
const headers=(route,body='{}')=>({'Content-Type':'application/json',Authorization:authorization(credential.token,route,body,individual?credential:undefined),...options.headers});
const challenge=await fetch(`${url.origin}/v1/challenge`,{method:'POST',headers:headers('/v1/challenge'),body:'{}',redirect:'error',signal:AbortSignal.timeout(10000)});
if(!challenge.ok){await boundedJson(challenge,32768);return {ok:false,status:challenge.status,json:async()=>({error:'Protected agent challenge refused'})};}
const bundle=await boundedJson(challenge,32768);
channel.verifyChallenge(bundle,individual?credential.principalBinding:'local-agent',credential.edPublic,credential.pqPublic);
const context=channel.prepare(bundle);
try{
const payload=channel.response({kind:'agent-request',action:action.action,args:action.args??{}},bundle,context);
const body=JSON.stringify({handshake:context.request,payload});if(Buffer.byteLength(body)>131072)throw Error('Protected agent request too large');
const response=await fetch(`${url.origin}/v1/actions`,{method:'POST',headers:headers('/v1/actions',body),body,redirect:'error',signal:AbortSignal.timeout(65000)});
const value=await boundedJson(response,4*1024*1024+65536);
if(!value.encrypted&&response.ok)throw Error('Protected agent response required');
const data=value.encrypted?channel.finish(value,bundle,context):value;
if(value.encrypted&&(data.kind!=='agent-response'||!Number.isInteger(data.status)||data.status<200||data.status>599))throw Error('Protected agent response refused');
const status=value.encrypted?data.status:response.status,ok=value.encrypted&&status>=200&&status<300;
const busy=value.encrypted&&status===409&&action.action==='bookmarks:mutate'&&data.code==='browser-management-busy'&&data.error==='Management update in progress. Try again.';
const clean=data.error?{error:data.error,...(busy?{code:data.code}:{})}:{result:data.result};
return {ok,status,json:async()=>clean,...(busy?{retryManagementBusy:true}:{})};
}finally{context.responseKey.fill(0);}
}
async function requestAgent(credential, action, options={}) {
// Snapshot validated JSON arguments: retries cannot update CAS or signed-policy inputs.
const fixed=JSON.parse(JSON.stringify(action));
for(let retry=0;;retry++){
const response=await requestAttempt(credential,fixed,options);
if(!response.retryManagementBusy||retry>=3){delete response.retryManagementBusy;return response;}
// Each attempt creates a fresh authenticated proof and consumes a new signed challenge.
await new Promise(resolve=>setTimeout(resolve,200*(retry+1)));
}
}
module.exports={requestAgent,authorization};