Files
safetynet/apps/browser/broker-certificate-pins.cjs
T

56 lines
3.8 KiB
JavaScript

'use strict';
const {X509Certificate}=require('node:crypto');
const {isIP}=require('node:net');
const fail=()=>{throw Error('broker-certificate-pin-rejected');};
function authorityFacts(value){
if(typeof value!=='string'||value.length>320||/[^\x21-\x7e]|[@/\\?#%]/.test(value))fail();
const match=/^(\[[^\]]+\]|[^:]+):([1-9][0-9]{0,4})$/.exec(value);if(!match||Number(match[2])>65535)fail();
let url;try{url=new URL('https://'+value+'/');}catch{fail();}
const hostname=url.hostname.startsWith('[')?url.hostname.slice(1,-1):url.hostname;
if(!isIP(hostname)&&hostname.split('.').some(label=>!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)))fail();
if(`${isIP(hostname)===6?'['+hostname+']':hostname}:${Number(match[2])}`!==value)fail();
return Object.freeze({hostname,authority:value});
}
/** For one managed Electron session only. Electron's verifier has NO port and
* caches results: CONNECT/SNI/inner-Host gates must still bind the port. Revoke
* must also close existing connections/stop the old broker before replacement.
* Management sessions retain the platform verifier and receive no such pins.
*/
function createBrokerCertificatePins({generation,policyVersion,current,authorizedAuthority,maxPins=128}={}){
if(typeof generation!=='string'||!generation||!Number.isSafeInteger(policyVersion)||policyVersion<1||typeof current!=='function'||typeof authorizedAuthority!=='function'||!Number.isInteger(maxPins)||maxPins<1||maxPins>1024)fail();
let revoked=false;const pins=new Map();
const active=()=>{try{return !revoked&&current()===true;}catch{return false;}};
const authorized=authority=>{try{return authorizedAuthority(authority)===true;}catch{return false;}};
return Object.freeze({
accept(input){
if(!active()||!input||input.generation!==generation||input.policyVersion!==policyVersion||typeof input.cert!=='string'||Buffer.byteLength(input.cert)>16384||!/^-----BEGIN CERTIFICATE-----\r?\n[A-Za-z0-9+/=\r\n]+\r?\n-----END CERTIFICATE-----\s*$/.test(input.cert))fail();
const facts=authorityFacts(input.authority);if(!authorized(facts.authority))fail();
let certificate;try{certificate=new X509Certificate(input.cert);}catch{fail();}
const now=Date.now(),until=Date.parse(certificate.validTo),from=Date.parse(certificate.validFrom);
const matches=isIP(facts.hostname)?certificate.checkIP(facts.hostname):certificate.checkHost(facts.hostname,{subject:'never',wildcards:false});
if(certificate.ca||!certificate.subjectAltName||certificate.subjectAltName.includes(',')||!matches||!certificate.verify(certificate.publicKey)||!Number.isFinite(until)||!Number.isFinite(from)||from>now||until<=now||until>now+3601000||input.fingerprint256!==certificate.fingerprint256)fail();
for(const [key,pin]of pins)if(pin.expiresAt<=now)pins.delete(key);
const key=facts.authority;
if(!pins.has(key)&&pins.size>=maxPins)fail();
if(!active()||!authorized(key))fail();
pins.set(key,Object.freeze({...facts,fingerprint:certificate.fingerprint256,expiresAt:until}));return true;
},
verify(request,callback){
let result=-3;
try{
// Electron exposes Certificate.data/fingerprint, not Node's fingerprint256.
// Derive our fingerprint from the actual presented PEM, never a claim.
const data=request?.certificate?.data,host=request?.hostname;
if(typeof data!=='string'||Buffer.byteLength(data)>16384)throw Error('certificate-data-unavailable');
const fingerprint=new X509Certificate(data).fingerprint256;
const match=[...pins.values()].find(pin=>pin.hostname===host&&pin.fingerprint===fingerprint);
if(match)result=active()&&match.expiresAt>Date.now()&&authorized(match.authority)?0:-2;
}catch{result=-3;}
callback(result);
},
revoke(){revoked=true;},
status(){return Object.freeze({active:active(),pins:pins.size});}
});
}
module.exports={createBrokerCertificatePins,authorityFacts};