Files
safetynet/apps/browser/vault-agent-controls.cjs
T

71 lines
6.5 KiB
JavaScript

'use strict';
const {randomUUID}=require('node:crypto');
const {fillScript}=require('./password-fill.cjs');
const refused=()=>{throw Error('Managed vault operation refused');};
const idPattern=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const hostMatch=(name,pattern)=>pattern.startsWith('*.')?name.endsWith(pattern.slice(1))&&name!==pattern.slice(2):name===pattern;
function createVaultAgentControls({getContext,contextCurrent,getVault,checkVisual,inspect,audit,traceOperation=async(_name,fn)=>fn(),now=Date.now}={}) {
if([getContext,contextCurrent,getVault,checkVisual,inspect,audit,traceOperation,now].some(fn=>typeof fn!=='function'))throw TypeError('Vault boundary callbacks required');
const references=new Map();
const allowed=(origin,c)=>{
let u;try{u=new URL(origin);}catch{return false;}
const hosts=c.policy.agents.allowedHosts;
return u.protocol==='https:'&&!u.username&&!u.password&&u.origin===origin&&Array.isArray(hosts)&&(!hosts.length||hosts.some(p=>typeof p==='string'&&hostMatch(u.hostname.toLowerCase(),p.toLowerCase())))&&c.destinationAllowed(origin,c.policy)===true;
};
function snapshot(args,signal,action){
const fields=action==='vault:fill'?['sessionId','credentialRef']:['sessionId'];
if(!args||typeof args!=='object'||Array.isArray(args)||Object.keys(args).some(key=>!fields.includes(key))||(args.sessionId!==undefined&&!idPattern.test(args.sessionId)))refused();
const c=getContext(args.sessionId);
if(signal?.aborted||!c?.ready||!c.token||(!c.actor||typeof c.actor.agentId!=='string'||!c.actor.agentId||!Number.isSafeInteger(c.actor.generation))||!c.policy?.agents?.enabled||!c.policy.agents.scopes?.includes(action)||!c.policy.passwordManager?.enabled||!Number.isFinite(Date.parse(c.policy.expiresAt))||Date.parse(c.policy.expiresAt)<=now()||typeof c.destinationAllowed!=='function')refused();
return {...c,action};
}
function check(c,args,signal){
const next=snapshot(args,signal,c.action);
if(next.policy!==c.policy||next.actor.agentId!==c.actor.agentId||next.actor.generation!==c.actor.generation||!contextCurrent(c.token)||next.sessionId!==c.sessionId||next.webContents!==c.webContents)refused();
return next;
}
function unlocked(c){const vault=getVault();if(!vault||typeof vault.accessGeneration!=='function'||vault.status().locked)refused();return vault;}
function currentVault(c,args,signal,vault){check(c,args,signal);if(getVault()!==vault||vault.status().locked)refused();}
const status=async(args={}, {signal}={})=>{
const c=snapshot(args,signal,'vault:status'),s=getVault().status();check(c,args,signal);
return {exists:s.exists===true,locked:s.locked!==false,enabled:true,allowAutofill:c.policy.passwordManager.allowAutofill===true};
};
const list=async(args={}, {signal}={})=>{
const c=snapshot(args,signal,'vault:list'),vault=unlocked(c),generation=vault.accessGeneration(),rows=vault.list();
if(!Array.isArray(rows)||rows.length>1000)refused();
const safe=rows.filter(row=>row&&idPattern.test(row.id)&&allowed(row.origin,c)).map(row=>({id:row.id,origin:row.origin}));
const verdict=await traceOperation('vault.inventory.inspect',()=>inspect(safe.map(row=>row.origin).join('\n'),c));
currentVault(c,args,signal,vault);
if(vault.accessGeneration()!==generation)refused();
const currentRows=vault.list();
if(!Array.isArray(currentRows)||JSON.stringify(currentRows.map(row=>({id:row.id,origin:row.origin})))!==JSON.stringify(rows.map(row=>({id:row.id,origin:row.origin}))))refused();
if(verdict?.decision!=='allow'||verdict.coverage?.complete===false||/^(?:uninspectable|size-limit|inspection-incomplete|unavailable|timeout)$/.test(verdict.ruleId||''))refused();
// A denied export creates no usable references. References expire and cannot cross actors/policies.
for(const [key,row]of references)if(row.expires<=now()||!contextCurrent(row.token))references.delete(key);
if(references.size+Math.min(safe.length,32)>32)refused();
return safe.slice(0,32).map(row=>{const credentialRef=randomUUID();references.set(credentialRef,{...row,sessionId:c.sessionId,webContents:c.webContents,actor:{...c.actor},generation:vault.accessGeneration(),policy:c.policy,token:c.token,vault,expires:now()+60000});return {credentialRef,origin:row.origin};});
};
const fill=async(args={}, {signal}={})=>{
let credential;
try{
const c=snapshot(args,signal,'vault:fill'),vault=unlocked(c),ref=references.get(args.credentialRef);
if(!idPattern.test(args.credentialRef||'')||!ref||ref.sessionId!==c.sessionId||ref.webContents!==c.webContents||ref.expires<=now()||(ref.actor.agentId!==c.actor.agentId||ref.actor.generation!==c.actor.generation)||ref.generation!==vault.accessGeneration()||ref.policy!==c.policy||ref.vault!==vault||!contextCurrent(ref.token)||c.foreground!==true||c.interactive===false||!c.policy.passwordManager.allowAutofill||!c.webContents||c.webContents.isDestroyed?.())refused();
const url=c.webContents.getURL(),u=new URL(url);
if(u.username||u.password||u.protocol!=='https:'||u.origin!==ref.origin||!allowed(ref.origin,c))refused();
const recheck=()=>{const next=check(c,args,signal);currentVault(c,args,signal,vault);if(vault.accessGeneration()!==ref.generation||next.foreground!==true||next.interactive===false||!next.policy.passwordManager.allowAutofill||c.webContents.isDestroyed?.()||c.webContents.getURL()!==url||ref.expires<=now())refused();};
if((await traceOperation('vault.fill.visual',()=>checkVisual(c)))?.decision!=='allow')refused();recheck();
await audit({action:'vault.fill-requested',outcome:'requested',sessionId:c.sessionId});recheck();
// Consume once before the native effect: failures never authorize an automatic retry.
references.delete(args.credentialRef);
credential={...vault.credential(ref.id,ref.origin),origin:ref.origin};recheck();
c.webContents.focus();recheck();
const result=await traceOperation('vault.fill.native',()=>c.webContents.executeJavaScriptInIsolatedWorld(999,[{code:fillScript(url,credential,Date.parse(c.policy.expiresAt))}],false));
recheck();if(result?.completed!==true)refused();
await audit({action:'vault.filled',outcome:'completed',sessionId:c.sessionId});recheck();return {completed:true};
}catch{refused();}finally{if(credential){credential.username='';credential.password='';credential.origin='';}}
};
const safe=fn=>async(...args)=>{try{return await fn(...args);}catch{refused();}};
return {status:safe(status),list:safe(list),fill:safe(fill),clear:()=>references.clear()};
}
module.exports={createVaultAgentControls};