65 lines
5.1 KiB
JavaScript
65 lines
5.1 KiB
JavaScript
'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};
|