Add scoped human-unlocked vault agent autofill

This commit is contained in:
Karl committed 2026-10-13 19:14:32 +02:00
1 parent 61fcd8336f
commit e8a654fb38
23 files changed
+383 -29

No files matched your search

+1 -1
View File
@@ -153,7 +153,7 @@ function createAgentAdapters({getContext,getSessions,contextCurrent,navigate,his
await onInteraction({action:'click',sessionId:initial.sessionId,pageUrl:initial.url,outcome:'completed',sensitiveField:false,...(target?{target}:{})});check(initial,args,signal);}
return {completed:result?.completed===true};
};
const appActions=['window:state','window:control','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply'];
const appActions=['vault:status','vault:list','vault:fill','window:state','window:control','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply'];
// Native history owns target resolution and pre-release destination checks. A
// refreshed token permits intentional navigation but never a different tab.
const historyAdapters=Object.fromEntries(['back','forward','reload'].map(action=>[action,async(args={}, {signal}={})=>{
+15 -2
View File
@@ -3,7 +3,7 @@ const http = require('node:http');
const crypto = require('node:crypto');
const channel=require('../../packages/core/policy-channel.cjs');
const APP_ACTIONS=Object.freeze(['history:list','history:go','window:state','window:control','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply']);
const APP_ACTIONS=Object.freeze(['vault:status','vault:list','vault:fill','history:list','history:go','window:state','window:control','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','library:organize-plan','library:organize-apply']);
const SCOPES = Object.freeze({ sessions: 'sessions:list', navigate: 'browser:navigate', back:'browser:navigate',forward:'browser:navigate',reload:'browser:navigate', read: 'browser:read', inspect:'browser:read', scroll:'browser:interact', click: 'browser:interact', type: 'browser:interact', select:'browser:interact', check:'browser:interact', ai: 'ai:request',...Object.fromEntries(APP_ACTIONS.map(action=>[action,action])) });
const fail = (status, message) => Object.assign(new Error(message), { status });
const bounded = (value, max, name) => {
@@ -16,9 +16,13 @@ function validate(body) {
if (!Object.hasOwn(SCOPES, action)) throw fail(400, 'Unsupported action');
const args = body.args ?? {};
if (!args || typeof args !== 'object' || Array.isArray(args)) throw fail(400, 'Invalid arguments');
const keys = { 'history:list':['sessionId'],'history:go':['sessionId','snapshotId','entryId'],'window:state':[],'window:control':['command'],sessions: [], navigate: ['sessionId', 'url'],back:['sessionId'],forward:['sessionId'],reload:['sessionId'], read: ['sessionId'], inspect:['sessionId'], scroll:['sessionId','x','y'], click: ['sessionId', 'selector'], type: ['sessionId', 'selector', 'text'], select:['sessionId','selector','value'], check:['sessionId','selector','checked'], ai: ['sessionId', 'prompt'],'application:state':[],'settings:get':[],'settings:update':['appearance'],'bookmarks:list':[],'bookmarks:mutate':['action','bookmark','id','expectedRevision','policyVersion'],'tabs:create':['group','url'],'tabs:activate':['sessionId'],'tabs:close':['sessionId'],'tabs:group':['sessionId','group'],'library:organize-plan':[],'library:organize-apply':['proposalId','confirm','autoApply'] }[action];
const keys = { 'vault:status':['sessionId'],'vault:list':['sessionId'],'vault:fill':['sessionId','credentialRef'],'history:list':['sessionId'],'history:go':['sessionId','snapshotId','entryId'],'window:state':[],'window:control':['command'],sessions: [], navigate: ['sessionId', 'url'],back:['sessionId'],forward:['sessionId'],reload:['sessionId'], read: ['sessionId'], inspect:['sessionId'], scroll:['sessionId','x','y'], click: ['sessionId', 'selector'], type: ['sessionId', 'selector', 'text'], select:['sessionId','selector','value'], check:['sessionId','selector','checked'], ai: ['sessionId', 'prompt'],'application:state':[],'settings:get':[],'settings:update':['appearance'],'bookmarks:list':[],'bookmarks:mutate':['action','bookmark','id','expectedRevision','policyVersion'],'tabs:create':['group','url'],'tabs:activate':['sessionId'],'tabs:close':['sessionId'],'tabs:group':['sessionId','group'],'library:organize-plan':[],'library:organize-apply':['proposalId','confirm','autoApply'] }[action];
if (Object.keys(args).some(k => !keys.includes(k))) throw fail(400, 'Unsupported argument');
const clean = {};
if(action.startsWith('vault:')){
if(args.sessionId!==undefined||action==='vault:fill')clean.sessionId=bounded(args.sessionId,128,'sessionId');
if(action==='vault:fill'){clean.credentialRef=bounded(args.credentialRef,36,'credentialRef');if(!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(clean.credentialRef))throw fail(400,'Invalid credential reference');}
}
if(action.startsWith('history:')){
clean.sessionId=bounded(args.sessionId,128,'sessionId');
if(action==='history:go')for(const key of ['snapshotId','entryId']){clean[key]=bounded(args[key],36,key);if(!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(clean[key]))throw fail(400,'Invalid history handle');}
@@ -55,6 +59,15 @@ function validate(body) {
}
// Never return adapter objects wholesale: session handles and sanitized text only.
function project(action, result) {
if(action==='vault:status'){
if(!result||!['exists','locked','enabled','allowAutofill'].every(key=>typeof result[key]==='boolean'))throw fail(502,'Invalid vault response');
return Object.fromEntries(['exists','locked','enabled','allowAutofill'].map(key=>[key,result[key]]));
}
if(action==='vault:list'){
if(!Array.isArray(result)||result.length>32)throw fail(502,'Invalid vault response');
return result.map(row=>{let origin;try{origin=new URL(row.origin);}catch{throw fail(502,'Invalid vault response');}if(typeof row.credentialRef!=='string'||!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(row.credentialRef)||typeof row.origin!=='string'||row.origin.length>2048||origin.protocol!=='https:'||origin.username||origin.password||origin.origin!==row.origin)throw fail(502,'Invalid vault response');return {credentialRef:row.credentialRef,origin:row.origin};});
}
if(action==='vault:fill')return {completed:result?.completed===true};
if(action==='history:list'){
const uuid=value=>{if(typeof value!=='string'||!/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(value))throw fail(502,'Invalid history response');return value;};
if(!Array.isArray(result?.entries)||result.entries.length>100)throw fail(502,'Invalid history response');
+2
View File
@@ -14,6 +14,8 @@ const names=new Set(['browser.window','browser.network','browser.ipc','browser.a
const operations=new Set(['audit.deliver','evidence.observer.install','evidence.observer.drain','evidence.observer.dispose','policy.verify','policy.bookmarks','policy.agent-reconcile','policy.page-configure','policy.audit','evidence.capture','evidence.authorize','evidence.challenge','evidence.encrypt','evidence.send','evidence.ack','agent.request','agent.challenge','agent.decrypt','agent.transport','agent.authorize','agent.audit','agent.dispatch','agent.response','agent.project','agent.encrypt','library:organize-plan','library:organize-apply','application:state','settings:get','settings:update','bookmarks:list','bookmarks:mutate','tabs:create','tabs:activate','tabs:close','tabs:group','state','authorize-setting','settings-update','window-control','chrome-layout','share-url','bookmarks-mutate','enroll','sso','navigate','back','forward','reload','ai','inspect-file','agent-connection','ai-panel','vault-panel','vault-status','vault-create','vault-unlock','vault-lock','vault-list','vault-save','vault-delete','vault-fill','vault-generate','tab-create','tab-activate','tab-close','tab-group','sessions','read','inspect','scroll','click','type','select','check','screenshot','copy','cut','paste','scan','download','sync','create','activate','close','group','policy','request','dns','parse','embed','yara','icap']);
const components=new Set(['browser','ipc','agent','management','navigation','network','dns','dlp','inspector','parser','semantic','yara','icap','download','clipboard','visual','vault','ai','policy','tabs']);
for(const operation of ['history:list','history:go','window:state','window:control'])operations.add(operation);
names.add('browser.vault');
for(const operation of ['vault:status','vault:list','vault:fill','vault.inventory.inspect','vault.fill.visual','vault.fill.native'])operations.add(operation);
const decisions=new Set(['allow','block','deny','unknown']),statuses=new Set(['ok','error','cancelled','timeout']);
const requestTypes=new Set(['mainFrame','subFrame','stylesheet','script','image','font','object','xhr','ping','cspReport','media','webSocket','other']);
for(const name of ['browser.upload.lease','browser.upload.part','browser.upload.release','browser.upload.fault'])names.add(name);
+21 -9
View File
@@ -395,25 +395,29 @@ async function applyLibraryOrganization(input,options){
const result=await getLibraryOrganizer().apply(input,options);
await sync();return result;
}
async function inspectManagedAgentText(text,context){
const aiContext=context.action==='ai';
let verdict=aiContext?core.inspectForAI(Buffer.from(text),context.policy):core.inspect(Buffer.from(text),context.policy);if(verdict.decision==='block')return verdict;
if(context.policy.dlp.semantic.enabled||context.policy.security.yara.enabled){
if(!localInspector)return {decision:'block',ruleId:'inspection-unavailable',coverage:{complete:false}};
const result=await diagnosticSpan('browser.inspect.local',{bytes:Buffer.byteLength(text)},()=>localInspector.inspect(Buffer.from(text),context.policy));
verdict=aiContext&&core.allowsConfidentialLocal(result,context.policy)?{...result,decision:'allow',classification:'local-only'}:result;
}
if(verdict.decision!=='allow')return verdict;
const external=await diagnosticSpan('browser.inspect.enterprise',{bytes:Buffer.byteLength(text)},()=>scanEnterprise(Buffer.from(text),context.policy,aiContext?'ai':'upload'));return external.decision==='allow'?verdict:external;
}
async function ensureAgentAPI(){
if(!ready||!policy.agents.enabled||!featureAllowed(managedSettings(),'agents')||agentService||agentStarting)return;agentStarting=true;
try{
const getContext=agentContext;
const actions=createAgentAdapters({getContext,appControls:agentAppControls(),getSessions:()=>tabs.all().filter(tab=>!agentActorStorage.getStore()?.principalId||actorDestinationAllowed(tab.view.webContents.getURL())).map(tab=>({id:tab.id,title:tab.title,foreground:tab===activeTab()})),contextCurrent:currentTabContext,navigate,history:agentHistory,getHistory:getAgentHistory,goHistory:goAgentHistory,ai:managedAI,
inspect:async(text,context)=>{
const aiContext=context.action==='ai';
let verdict=aiContext?core.inspectForAI(Buffer.from(text),context.policy):core.inspect(Buffer.from(text),context.policy);if(verdict.decision==='block')return verdict;
if(context.policy.dlp.semantic.enabled||context.policy.security.yara.enabled){
if(!localInspector)return {decision:'block'};const result=await diagnosticSpan('browser.inspect.local',{bytes:Buffer.byteLength(text)},()=>localInspector.inspect(Buffer.from(text),context.policy));
verdict=aiContext&&core.allowsConfidentialLocal(result,context.policy)?{...result,decision:'allow',classification:'local-only'}:result;
}if(verdict.decision!=='allow')return verdict;const external=await diagnosticSpan('browser.inspect.enterprise',{bytes:Buffer.byteLength(text)},()=>scanEnterprise(Buffer.from(text),context.policy,aiContext?'ai':'upload'));return external.decision==='allow'?verdict:external;
},audit:auditAgent,onInteraction:async metadata=>{
inspect:inspectManagedAgentText,audit:auditAgent,onInteraction:async metadata=>{
const payload=await diagnosticSpan('browser.evidence',{operation:'evidence.capture',sessionId:metadata.sessionId},()=>require('./evidence-capture.cjs').buildInteractionEvidence({...metadata,url:metadata.pageUrl,captureSource:'agent-adapter'},policy));if(!payload)return;
const actor=agentActorStorage.getStore();await auditAgent({action:'click',outcome:'completed',sessionId:metadata.sessionId,...(actor?.principalId?{principalId:actor.principalId,generation:actor.generation}:{}),protectedInteraction:payload});
}});
for(const action of ['click','type','select','check']){const original=actions[action];actions[action]=async(...args)=>{const tab=tabs.get(args[0]?.sessionId||activeTab().id),context=tabContext(tab.id);const checked=await checkVisual(tab);if(!currentTabContext(context)||checked.decision!=='allow')throw Error('Visual login protection has not approved this page');return original(...args);};}
for(const action of Object.keys(actions)){const original=actions[action];actions[action]=(args,context={})=>agentActorStorage.run({...context,action},()=>diagnosticSpan('browser.agent',{operation:action},async()=>{
if(context.principalId&&['navigate','back','forward','reload','history:go','click','type','select','check'].includes(action)){const tab=tabs.get(args.sessionId||activeTab().id);tab.security.agentAuthority={principalId:context.principalId,generation:context.generation,action};}
if(context.principalId&&['navigate','back','forward','reload','history:go','vault:fill','click','type','select','check'].includes(action)){const tab=tabs.get(args.sessionId||activeTab().id);tab.security.agentAuthority={principalId:context.principalId,generation:context.generation,action};}
return original(args,context);
}));}
agentServiceMode=policy.agents.identityMode;agentService=await startAgentControl({actions,principalMode:agentServiceMode,principals:agentServiceMode==='individual'?effectiveAgentPolicy().agents.grants:[],
@@ -641,11 +645,19 @@ function groupNativeTab(input){requireManagedSection('library');if(libraryOrgani
function agentAppControls(){
const actorIdentity=()=>{const actor=agentActorStorage.getStore();return actor?.principalId?`${actor.principalId}:${actor.generation}`:'legacy';};
const windowControls=require('./window-controls.cjs').createWindowControls({getWindow:()=>win,getContext:()=>({ready:ready&&featureAllowed(managedSettings(),'agents'),policy,policyVersion:policy?.version,deviceId:device?.id,actor:actorIdentity()}),isCurrent:context=>ready&&policy===context.policy&&device?.id===context.deviceId&&actorIdentity()===context.actor,authorize:action=>actorGrantAllowed(agentActorStorage.getStore(),action),traceOperation:(operation,fn)=>diagnosticSpan('browser.window',{operation},fn)});
const vaultControls=require('./vault-agent-controls.cjs').createVaultAgentControls({
getContext:id=>{const context=agentContext(id);return {...context,ready:context.ready&&featureAllowed(managedSettings(),'passwords'),actor:context.actor||Object.freeze({agentId:'legacy-paired-agent',generation:1})};},
contextCurrent:currentTabContext,getVault:requireVault,checkVisual:context=>checkVisual(tabs.get(context.sessionId)),inspect:inspectManagedAgentText,
audit:event=>audit(event.action),traceOperation:(operation,fn)=>diagnosticSpan('browser.vault',{operation},fn)
});
const checked=(operation)=>async(args,{signal,policyVersion}={})=>{
if(signal?.aborted||!ready||policy?.version!==policyVersion||!featureAllowed(managedSettings(),'agents'))throw Error('Managed application changed');
return operation(args,{signal});
};
return {
'vault:status':checked((args,options)=>vaultControls.status(args,options)),
'vault:list':checked((args,options)=>vaultControls.list(args,options)),
'vault:fill':checked((args,options)=>vaultControls.fill(args,options)),
'window:state':checked((args,options)=>windowControls.state(args,options)),
'window:control':checked((args,options)=>windowControls.control(args,options)),
'application:state':checked(()=>({ready,policyVersion:policy.version,activeTabId:activeTab()?.id,tabCount:tabs.all().length})),
+70
View File
@@ -0,0 +1,70 @@
'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};
+11 -3
View File
@@ -49,11 +49,19 @@ function generatePassword(length = 32) {
}
function createVault({ filePath, protect, unprotect, idleSeconds = 300 }) {
if (typeof filePath !== 'string' || !path.isAbsolute(filePath) || typeof protect !== 'function' || typeof unprotect !== 'function' || !Number.isInteger(idleSeconds) || idleSeconds < 60 || idleSeconds > 900) fail();
let key = null, entries = [], envelope = null, timer = null;
let key = null, entries = [], envelope = null, timer = null, accessGeneration = 0;
// Process-local capability epoch. Never persisted or included in public metadata.
// create/unlock begin with lock, so even reopening the same vault invalidates refs.
function invalidateAccess() {
if (accessGeneration >= Number.MAX_SAFE_INTEGER) { lock(); fail(); }
accessGeneration += 1;
}
function lock() {
clearTimeout(timer); timer = null; wipe(key); key = null;
for (const entry of entries) { entry.password = ''; entry.username = ''; entry.label = ''; entry.origin = ''; }
entries = []; envelope = null;
if (accessGeneration >= Number.MAX_SAFE_INTEGER) fail();
accessGeneration += 1;
}
function touch() { clearTimeout(timer); timer = setTimeout(lock, idleSeconds * 1000); timer.unref(); }
function requireUnlocked(refreshIdle = true) { if (!key) fail(); if (refreshIdle) touch(); }
@@ -136,7 +144,7 @@ function createVault({ filePath, protect, unprotect, idleSeconds = 300 }) {
function status() { return { exists: fs.existsSync(filePath), locked: !key, idleSeconds, algorithm: 'AES-256-GCM / ML-KEM-1024 / scrypt / Windows DPAPI' }; }
function list() { requireUnlocked(false); return entries.map(({ id, origin, username, label }) => ({ id, origin, username, label })); }
function mutate(action) {
requireUnlocked(); const previous = entries.map(entry => ({ ...entry }));
requireUnlocked(); invalidateAccess(); const previous = entries.map(entry => ({ ...entry }));
try { const result = action(); persist(); return result; } catch { entries = previous; fail(); }
}
function upsert(entry) {
@@ -156,6 +164,6 @@ function createVault({ filePath, protect, unprotect, idleSeconds = 300 }) {
if (!entry || entry.origin !== expected) fail();
return { username: entry.username, password: entry.password };
}
return { create, unlock, lock, status, list, upsert, delete: remove, credential, generatePassword };
return { create, unlock, lock, status, list, upsert, delete: remove, credential, generatePassword, accessGeneration: () => accessGeneration };
}
module.exports = { createVault, generatePassword };