'use strict'; // Session-local TLS leaves only. No CA installation, persistent key or browser // trust bypass. resolve() is broker-internal: its key must NEVER cross IPC. // Parent receives only publicMetadata(), bound separately to broker generation // and current policy. Destroy TLS sockets/contexts before close(); wiping this // JS key buffer does not erase copies already held by OpenSSL contexts. const crypto=require('node:crypto'); const {isIP}=require('node:net'); const {parseAuthority}=require('./tls-authority.cjs'); const fail=()=>{throw Error('broker-certificate-unavailable');}; function der(tag,bytes){ const length=bytes.length; if(length>65535)fail(); const size=length<128?Buffer.from([length]):length<256?Buffer.from([0x81,length]):Buffer.from([0x82,length>>8,length&255]); return Buffer.concat([Buffer.from([tag]),size,bytes]); } const seq=(...items)=>der(0x30,Buffer.concat(items)); const oid=hex=>Buffer.from(hex,'hex'); const algorithm=seq(oid('06082a8648ce3d040302')); // ECDSA with SHA-256; absent parameters. const extension=(id,value,critical=false)=>seq(oid(id),...(critical?[Buffer.from([1,1,255])]:[]),der(4,value)); function ipBytes(host){ if(isIP(host)===4)return Buffer.from(host.split('.').map(Number)); let value=host; if(value.includes('.')){const split=value.lastIndexOf(':');const tail=ipBytes(value.slice(split+1));value=value.slice(0,split)+':'+tail.readUInt16BE(0).toString(16)+':'+tail.readUInt16BE(2).toString(16);} const halves=value.split('::');if(halves.length>2)fail(); const left=halves[0]?halves[0].split(':'):[],right=halves[1]?halves[1].split(':'):[]; const words=halves.length===2?[...left,...Array(8-left.length-right.length).fill('0'),...right]:left; if(words.length!==8)fail();const bytes=Buffer.alloc(16);words.forEach((word,index)=>bytes.writeUInt16BE(parseInt(word,16),index*2));return bytes; } function time(milliseconds){return der(0x18,Buffer.from(new Date(milliseconds).toISOString().replace(/[-:T]/g,'').replace(/\.\d{3}Z$/,'Z')));} function pem(bytes){return '-----BEGIN CERTIFICATE-----\n'+bytes.toString('base64').match(/.{1,64}/g).join('\n')+'\n-----END CERTIFICATE-----\n';} async function createSessionCertificates({maxAuthorities=128,lifetimeMs=3600000,current,authorized}={}){ if(!Number.isInteger(maxAuthorities)||maxAuthorities<1||maxAuthorities>1024||!Number.isInteger(lifetimeMs)||lifetimeMs<60000||lifetimeMs>3600000||typeof current!=='function'||typeof authorized!=='function')fail(); let keys=await new Promise((resolve,reject)=>crypto.generateKeyPair('ec',{namedCurve:'prime256v1'},(error,publicKey,privateKey)=>error?reject(Error('broker-certificate-unavailable')):resolve({publicKey,privateKey}))); const keyBytes=Buffer.from(keys.privateKey.export({type:'pkcs8',format:'pem'})); const leaves=new Map();let closed=false; const active=()=>{try{return !closed&¤t()===true;}catch{return false;}}; const close=()=>{closed=true;leaves.clear();keyBytes.fill(0);keys=null;}; const issuer={close, resolve(binding){ if(!active()||!binding||typeof binding.authority!=='string')fail(); const authority=parseAuthority(binding.authority);if(authority.authority!==binding.authority)fail(); try{if(authorized(authority)!==true)fail();}catch{fail();} const now=Date.now(),existing=leaves.get(authority.authority); if(existing&&existing.expiresAt>now+1000)return existing; for(const [name,leaf]of leaves)if(leaf.expiresAt<=now)leaves.delete(name); if(leaves.size>=maxAuthorities)fail(); const serial=crypto.randomBytes(20);serial[0]&=127;serial[0]|=1; const name=seq(der(0x31,seq(oid('0603550403'),der(0x0c,Buffer.from('SafetyNet session leaf'))))); const san=seq(der(isIP(authority.hostname)?0x87:0x82,isIP(authority.hostname)?ipBytes(authority.hostname):Buffer.from(authority.hostname,'ascii'))); const expiresAt=now+lifetimeMs; const extensions=der(0xa3,seq(extension('0603551d13',seq(),true),extension('0603551d0f',der(3,Buffer.from([7,128])),true),extension('0603551d25',seq(oid('06082b06010505070301'))),extension('0603551d11',san))); const tbs=seq(der(0xa0,der(2,Buffer.from([2]))),der(2,serial),algorithm,name,seq(time(now-60000),time(expiresAt)),name,keys.publicKey.export({type:'spki',format:'der'}),extensions); const certificate=pem(seq(tbs,algorithm,der(3,Buffer.concat([Buffer.from([0]),crypto.sign('sha256',tbs,keys.privateKey)])))); const x509=new crypto.X509Certificate(certificate); if(x509.ca||!x509.verify(keys.publicKey)||!(isIP(authority.hostname)?x509.checkIP(authority.hostname):x509.checkHost(authority.hostname,{subject:'never',wildcards:false})))fail(); const leaf=Object.freeze({authority:authority.authority,key:keyBytes,cert:certificate,fingerprint256:x509.fingerprint256,expiresAt}); if(!active())fail();leaves.set(authority.authority,leaf);return leaf; }, publicMetadata(binding){const leaf=issuer.resolve(binding);return Object.freeze({authority:leaf.authority,cert:leaf.cert,fingerprint256:leaf.fingerprint256,expiresAt:leaf.expiresAt});} }; return Object.freeze(issuer); } module.exports={createSessionCertificates};