import {z} from 'zod'; import {readFileSync,lstatSync} from 'node:fs'; import {isAbsolute,dirname} from 'node:path'; import {randomBytes,randomUUID,createCipheriv,createDecipheriv,createHash} from 'node:crypto'; import {bootstrapAllowed} from './admin-access.mjs'; import {installEvidenceChannel} from './evidence-channel.mjs'; import {assertPrivateEvidenceKey} from './evidence-key.mjs'; import {InteractionEvidenceSchema} from '../../packages/core/evidence.mjs'; const text=z.string().max(16000).refine(v=>!/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(v),'Control characters forbidden'); const url=z.string().max(4096).refine(v=>{try{const u=new URL(v);return ['https:','http:'].includes(u.protocol)&&!u.username&&!u.password&&!u.search&&!u.hash;}catch{return false;}},'Evidence URLs require HTTP(S), no credentials, query or fragment'); const digest=z.string().regex(/^[a-f0-9]{64}$/); // Typed allowlists prevent password, cookie, authorization and arbitrary fields. const payloads={ download:z.object({sourceUrl:url,name:z.string().min(1).max(255),sha256:digest,bytes:z.number().int().nonnegative(),mime:z.string().max(100),decision:z.enum(['allow','block']),ruleId:z.string().max(100).optional()}).strict(), interaction:z.object({pageUrl:url,action:z.enum(['click','copy','cut','paste','submit']),target:z.object({tag:z.string().max(32),role:z.string().max(64).optional(),label:text.optional(),x:z.number().finite().optional(),y:z.number().finite().optional()}).strict().optional(),content:text.optional(),contentSha256:digest.optional(),sensitiveField:z.literal(false)}).strict() }; const input=z.object({eventId:z.string().uuid(),type:z.enum(['download','interaction']),payload:z.unknown()}).strict(); // Conservatively reject recognizable credentials even in otherwise allowed // prose. This is a second boundary; native capture must still exclude secret // controls because arbitrary passwords cannot be inferred from their value. const credentialPatterns=[ /-----BEGIN (?:[A-Z0-9 ]*PRIVATE KEY|OPENSSH PRIVATE KEY)-----/i, /\b(?:bearer|basic)\s+[A-Za-z0-9+/_=-]{8,}/i, /\b(?:password|passwd|pwd|api[ _-]?key|access[ _-]?token|refresh[ _-]?token|client[ _-]?secret|authorization)\b["']?\s*[:=]\s*["']?[^\s"',;]{4,}/i, /\bsk-[A-Za-z0-9_-]{16,}\b/, /\bgh[pousr]_[A-Za-z0-9]{20,}\b/, /\bgithub_pat_[A-Za-z0-9_]{20,}\b/, /\bAKIA[A-Z0-9]{16}\b/, /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/ ]; function includesCredential(payload){ const strings=[];if(payload.content)strings.push(payload.content);if(payload.target?.label)strings.push(payload.target.label);if(payload.name)strings.push(payload.name); for(const field of ['sourceUrl','pageUrl'])if(payload[field]){strings.push(payload[field]);try{strings.push(decodeURIComponent(new URL(payload[field]).pathname));}catch{return true;}} return strings.some(value=>credentialPatterns.some(pattern=>pattern.test(value))); } function sourceMatches(source,payload,type){ let metadata;try{metadata=JSON.parse(source.metadata);}catch{return false;} if(type==='interaction'){ if(!payload.sessionId)return false; if(source.kind==='browser.interaction'){ if(payload.captureSource!=='human-observer'||payload.outcome!=='observed'||!['click','submit'].includes(payload.action)||metadata.interaction?.action!==payload.action||metadata.interaction?.outcome!=='observed'||metadata.interaction?.sessionId!==payload.sessionId)return false; }else if(source.kind==='agent.action'){ if(payload.outcome!=='completed')return false; if(payload.captureSource!=='agent-adapter'||metadata.agent?.action!=='click'||metadata.agent.outcome!=='completed'||payload.action!=='click'||metadata.agent.sessionId!==payload.sessionId)return false; }else{ if(payload.outcome!=='completed'||payload.captureSource!=='guarded-clipboard'||!['clipboard.copy','clipboard.cut','clipboard.paste'].includes(source.kind)||payload.action!==source.kind.split('.')[1]||metadata.interaction?.action!==payload.action||metadata.interaction?.outcome!=='completed'||metadata.interaction?.sessionId!==payload.sessionId)return false; } } if(type==='download'&&source.kind==='download.released'&&payload.decision!=='allow'||type==='download'&&source.kind==='download.block'&&payload.decision!=='block')return false; if(type==='download'&&metadata.file){for(const field of ['sha256','bytes'])if(metadata.file[field]!==undefined&&payload[field]!==metadata.file[field])return false;} if(metadata.host){try{if(new URL(payload.sourceUrl??payload.pageUrl).hostname.toLowerCase()!==String(metadata.host).toLowerCase())return false;}catch{return false;}} return true; } function aad(row){return Buffer.from(JSON.stringify([1,row.id,row.source_event_id,row.device_id,row.type,row.key_id,row.created_at,row.expires_at]));} function decryptStoredEvidence(row,key){const cipher=createDecipheriv('aes-256-gcm',key,row.nonce);cipher.setAAD(aad(row));cipher.setAuthTag(row.tag);let partial,final;try{partial=cipher.update(row.ciphertext);final=cipher.final();return Buffer.concat([partial,final]);}finally{partial?.fill(0);final?.fill(0);}} function transaction(db,fn){db.exec('SAVEPOINT protected_evidence');try{const result=fn();db.exec('RELEASE protected_evidence');return result;}catch(e){db.exec('ROLLBACK TO protected_evidence');db.exec('RELEASE protected_evidence');throw e;}} export function installEvidence(app,store,config,device,admin){ const db=store.db;let key=null,keyId=null; const days=config.evidenceRetentionDays??30;if(!Number.isInteger(days)||days<1||days>365)throw Error('Evidence retention must be 1..365 days'); if(config.evidenceKeyFile){ if(!isAbsolute(config.evidenceKeyFile))throw Error('Evidence key requires an absolute deployment file'); for(let path=config.evidenceKeyFile;;path=dirname(path)){if(lstatSync(path).isSymbolicLink())throw Error('Linked evidence key paths are forbidden');if(dirname(path)===path)break;} const stat=lstatSync(config.evidenceKeyFile);if(!stat.isFile()||stat.size!==32)throw Error('Evidence key must be a regular file containing exactly 32 random bytes'); if(process.platform!=='win32'&&(stat.mode&0o077))throw Error('Evidence key must be restricted to its service owner'); assertPrivateEvidenceKey(config.evidenceKeyFile); key=readFileSync(config.evidenceKeyFile);if(key.length!==32)throw Error('Evidence key changed during loading');keyId=createHash('sha256').update(key).digest('hex').slice(0,32); } db.exec(`CREATE TABLE IF NOT EXISTS protected_evidence(id TEXT PRIMARY KEY,source_event_id INTEGER NOT NULL REFERENCES events(id),device_id TEXT NOT NULL REFERENCES devices(id),type TEXT NOT NULL,key_id TEXT NOT NULL,created_at TEXT NOT NULL,expires_at TEXT NOT NULL,nonce BLOB NOT NULL,ciphertext BLOB NOT NULL,tag BLOB NOT NULL,UNIQUE(source_event_id,type)); CREATE INDEX IF NOT EXISTS evidence_source ON protected_evidence(source_event_id);`); const enabled=(_req,res,next)=>key?next():res.status(503).json({error:'Protected evidence storage is not configured'}); function recordEvidence(req,res){ let plain;try{ const b=input.parse(req.body),policy=store.effectivePolicy(req.device.id); if(Date.parse(policy.expiresAt)<=Date.now()||!policy.evidence.enabled||b.type==='download'&&!policy.evidence.captureDownloads||b.type==='interaction'&&!policy.evidence.captureInteractions)return res.status(403).json({error:'Evidence collection is disabled by current policy'}); const payload=(b.type==='interaction'?InteractionEvidenceSchema:payloads[b.type]).parse(b.payload);if(includesCredential(payload))return res.status(400).json({error:'Recognizable credentials are excluded from evidence'});plain=Buffer.from(JSON.stringify(payload));if(plain.length>65536)return res.status(413).json({error:'Evidence too large'}); const source=db.prepare('SELECT id,kind,metadata FROM events WHERE device_id=? AND json_extract(metadata,\'$.eventId\')=?').get(req.device.id,b.eventId);if(!source)return res.status(404).json({error:'Own source event not found'}); const matching=b.type==='download'?['download.scan','download.released','download.block']:['clipboard.copy','clipboard.cut','clipboard.paste','agent.action','browser.interaction'];if(!matching.includes(source.kind))return res.status(400).json({error:'Evidence type does not match source event'}); if(!sourceMatches(source,payload,b.type))return res.status(400).json({error:'Evidence facts contradict the source event'}); const prior=db.prepare('SELECT * FROM protected_evidence WHERE source_event_id=? AND type=?').get(source.id,b.type);if(prior){ let same=false;try{if(prior.key_id===keyId){const priorPlain=decryptStoredEvidence(prior,key);try{same=JSON.stringify(JSON.parse(priorPlain.toString('utf8')))===JSON.stringify(payload);}finally{priorPlain.fill(0);}}}catch{} plain.fill(0);if(same)return res.json({id:prior.id,sourceEventId:source.id,expiresAt:prior.expires_at,replayed:true});return res.status(409).json({error:'Evidence already recorded with different or unverifiable payload'}); } const row={id:randomUUID(),source_event_id:source.id,device_id:req.device.id,type:b.type,key_id:keyId,created_at:new Date().toISOString(),expires_at:new Date(Date.now()+days*86400000).toISOString()},nonce=randomBytes(12),cipher=createCipheriv('aes-256-gcm',key,nonce);cipher.setAAD(aad(row));const ciphertext=Buffer.concat([cipher.update(plain),cipher.final()]);plain.fill(0); transaction(db,()=>{db.prepare('INSERT INTO protected_evidence VALUES(?,?,?,?,?,?,?,?,?,?)').run(row.id,source.id,row.device_id,row.type,keyId,row.created_at,row.expires_at,nonce,ciphertext,cipher.getAuthTag());store.event(row.device_id,'evidence.recorded',{evidenceId:row.id,sourceEventId:source.id,evidenceType:row.type,keyId});});res.status(201).json({id:row.id,sourceEventId:source.id,expiresAt:row.expires_at}); }finally{plain?.fill(0);} } app.post('/api/device/evidence',device,enabled,(req,res)=>{if(config.requirePQManagement||config.secure||!bootstrapAllowed(config)||!['127.0.0.1','::1','::ffff:127.0.0.1'].includes(req.socket.remoteAddress))return res.status(403).json({error:'Protected evidence channel required'});return recordEvidence(req,res);}); installEvidenceChannel(app,store,device,enabled,recordEvidence); // Discovery exposes only typed metadata. It never decrypts evidence or // substitutes for the purpose-bound, auditable plaintext access endpoint. app.get('/api/admin/incidents/:id/evidence',admin,enabled,(req,res)=>{ const id=z.string().uuid().parse(req.params.id),incident=db.prepare('SELECT id,event_id,status FROM incident_cases WHERE id=?').get(id);if(!incident)return res.status(404).json({error:'Incident not found'}); const now=new Date().toISOString(),retentionHold=['open','investigating'].includes(incident.status); const rows=db.prepare("SELECT p.id,p.source_event_id,p.type,p.key_id,p.created_at,p.expires_at,json_extract(e.metadata,'$.eventId') event_uuid FROM protected_evidence p JOIN events e ON e.id=p.source_event_id WHERE p.source_event_id=? ORDER BY p.created_at DESC,p.id DESC LIMIT 100").all(incident.event_id); res.json({incidentId:incident.id,sourceEventId:incident.event_id,evidence:rows.map(row=>({id:row.id,sourceEventId:row.source_event_id,eventId:z.string().uuid().safeParse(row.event_uuid).success?row.event_uuid:null,type:row.type,createdAt:row.created_at,expiresAt:row.expires_at,retentionHold,availability:row.expires_at<=now&&!retentionHold?'expired':row.key_id!==keyId?'key-unavailable':'available'}))}); }); app.get('/api/admin/evidence/:id',admin,enabled,(req,res)=>{ const id=z.string().uuid().parse(req.params.id),reason=z.enum(['incident-response','investigation','compliance']).parse(req.query.reason),row=db.prepare('SELECT * FROM protected_evidence WHERE id=?').get(id);if(!row)return res.status(404).json({error:'Evidence not found'}); const incident=db.prepare("SELECT id FROM incident_cases WHERE event_id=? AND status IN ('open','investigating')").get(row.source_event_id);if(row.expires_at<=new Date().toISOString()&&!incident)return res.status(410).json({error:'Evidence access expired'});if(row.key_id!==keyId)return res.status(503).json({error:'Evidence key unavailable'}); let payload;try{const plain=decryptStoredEvidence(row,key);try{payload=(row.type==='interaction'?z.union([InteractionEvidenceSchema,payloads.interaction]):payloads[row.type]).parse(JSON.parse(plain.toString('utf8')));}finally{plain.fill(0);}}catch{return res.status(500).json({error:'Evidence integrity verification failed'});} // Access must be durably attributable before plaintext leaves the service. store.event(row.device_id,'evidence.accessed',{actor:req.principal.sub,evidenceId:row.id,sourceEventId:row.source_event_id,evidenceType:row.type,reason}); res.json({id:row.id,sourceEventId:row.source_event_id,type:row.type,createdAt:row.created_at,expiresAt:row.expires_at,incidentId:incident?.id??null,payload}); }); }