41 lines
4.5 KiB
JavaScript
41 lines
4.5 KiB
JavaScript
'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};
|