diff --git a/src/db/host_cityStateCountry.json b/src/db/host_cityStateCountry.json index 3f9792a..14591ff 100644 --- a/src/db/host_cityStateCountry.json +++ b/src/db/host_cityStateCountry.json @@ -1,30 +1,30 @@ { - "camerfirma.com": "Madrid, Madrid, ES", - "certum.eu": "Szczecin, West Pomeranian, PL", - "amazontrust.com": "North Seattle, Washington, US", - "atos.net": "Meppen, Lower Saxony, DE", - "firmaprofesional.com": "Barcelona, Catalonia, ES", - "aoc.cat": "Barcelona, Catalonia, ES", - "digicert.com": "Lehi, Utah, US", - "emudhra.com": "Bangalore, Karnataka, IN", - "entrust.net": "Ottawa, Ontario, CA", - "globalsign.com": "Leuven, Flemish Brabant, BE", - "godaddy.com": "Scottsdale, Arizona, US", - "goog": "Mountain View, California, US", - "identrust.com": "Salt Lake City, Utah, US", - "letsencrypt.org": "San Francisco, California, US", - "izenpe.com": "Vitoria-Gasteiz, Alava, ES", - "microsoft.com": "Redmond, Washington, US", - "wisekey.com": "Geneva, Geneva, CH", - "quovadisglobal.com": "Hamilton, Pembroke, BM", - "secomtrust.net": "Mitaka-Shi, Tokyo, JP", - "sectigo.com": "Salford, Greater Manchester, GB", - "trustwave.com": "Chicago, Ilinois, US", - "sheca.com": "Hongkou, Shanghai, CN", - "ssl.com": "Houston, Texas, US", - "swisssign.com": "Glattbrugg, Opfikon, CH", - "teliasonera.com": "Helsinki, Uusimaa, FI", - "trustcorsystems.com": "Toronto, Ontario, CA", - "trustis.com": "Thatcham, Berkshire, GB", - "networksolutions.com": "Herndon, Virginia, US" + "camerfirma.com": ["Madrid", "Madrid", "ES"], + "certum.eu": ["Szczecin", "West Pomeranian", "PL"], + "amazontrust.com": ["North Seattle", "Washington", "US"], + "atos.net": ["Meppen", "Lower Saxony", "DE"], + "firmaprofesional.com": ["Barcelona", "Catalonia", "ES"], + "aoc.cat": ["Barcelona", "Catalonia", "ES"], + "digicert.com": ["Lehi", "Utah", "US"], + "emudhra.com": ["Bangalore", "Karnataka", "IN"], + "entrust.net": ["Ottawa", "Ontario", "CA"], + "globalsign.com": ["Leuven", "Flemish Brabant", "BE"], + "godaddy.com": ["Scottsdale", "Arizona", "US"], + "goog": ["Mountain View", "California", "US"], + "identrust.com": ["Salt Lake City", "Utah", "US"], + "letsencrypt.org": ["San Francisco", "California", "US"], + "izenpe.com": ["Vitoria-Gasteiz", "Alava", "ES"], + "microsoft.com": ["Redmond", "Washington", "US"], + "wisekey.com": ["Geneva", "Geneva", "CH"], + "quovadisglobal.com": ["Hamilton", "Pembroke", "BM"], + "secomtrust.net": ["Mitaka-Shi", "Tokyo", "JP"], + "sectigo.com": ["Salford", "Greater Manchester", "GB"], + "trustwave.com": ["Chicago", "Ilinois", "US"], + "sheca.com": ["Hongkou", "Shanghai", "CN"], + "ssl.com": ["Houston", "Texas", "US"], + "swisssign.com": ["Glattbrugg", "Opfikon", "CH"], + "teliasonera.com": ["Helsinki", "Uusimaa", "FI"], + "trustcorsystems.com": ["Toronto", "Ontario", "CA"], + "trustis.com": ["Thatcham", "Berkshire", "GB"], + "networksolutions.com": ["Herndon", "Virginia", "US"] } diff --git a/src/js/constants.js b/src/js/constants.js index 2ecede3..679251a 100644 --- a/src/js/constants.js +++ b/src/js/constants.js @@ -3,6 +3,7 @@ const secTypes={ MitM: 1, aRootKnown: 2, aRootUnknown: 3, + selfSigned: 4, unknown: 254, insecure: 255 }; @@ -17,41 +18,36 @@ sha256fp_host_alt['07:ED:BD:82:4A:49:88:CF:EF:42:15:DA:20:D4:8C:2B:41:D7:15:29:D sha256fp_host_alt['89:4E:BC:0B:23:DA:2A:50:C0:18:6B:7F:8F:25:EF:1F:6B:29:35:AF:32:A9:45:84:EF:80:AA:F8:77:A3:A0:6E']='us';//fpki.gov=us Object.freeze(sha256fp_host_alt); -{ - let data=JSON.parse(getAsset("db/IncludedCACertificateReport.json")); - data.forEach(ca=>{ - let reducedHostname; - let sha256fp=ca["SHA-256 Fingerprint"] - .replaceAll(/(\w{2})(?=\w)/g,'$1:'); //perforate the fingerprints with : every 2 characters - - let hostParts=(new URL(ca["Company Website"])).hostname.split('.') - - //www isn't a real subdomain - if(hostParts[0]=='www') hostParts.splice(0,1); - - //neither is pki - if(hostParts[0]=='pki') hostParts.splice(0,1); - - //. is from that country - if(hostParts[hostParts.length-1].length==2){ - //gov is also a non-semantic subdomain - if(hostParts[hostParts.length-2]=='gov') hostParts.splice(0,hostParts.length-1); - reducedHostname=hostParts.join('.'); - host_country[reducedHostname]=hostParts[hostParts.length-1].toUpperCase(); - } else { - reducedHostname=hostParts.join('.'); - } - - sha256fp_host[sha256fp]=reducedHostname; - }); -} +//Record the SHA-256 Fingerprint -> (reduced) hostname mappings +//As well as non-idiopathic SHA-256 Fingerprint -> country mappings +getAsset("db/IncludedCACertificateReport.json","json") +.forEach(ca => { + const sha256fp = ca["SHA-256 Fingerprint"].replaceAll(/(\w{2})(?=\w)/g,'$1:'); + //perforate the fingerprints with : every 2 characters + //(i.e. convert from CCADB-format to Firefox-format) + + const host = reduceHostname(new URL(ca["Company Website"]).hostname); + + sha256fp_host[sha256fp] = host; + + let country = identifyCountry(host); + if( country ) host_country[host] = country; +}); Object.freeze(sha256fp_host); -{ - let data=JSON.parse(getAsset("db/host_cityStateCountry.json")); - for(let host in data){ - host_country[host]=data[host].split(', ')[2]; +//Apply idiopathic country mappings +Object.entries(getAsset("db/host_cityStateCountry.json","json")) +.forEach(([host,[city,state,country]]) => { + if( host in host_country ) { + console.warn("Overwriting country! Database may need to be checked.\nIf you're seeing this, please open an issue on GitHub; include the following:", + { + host: host, + currently: host_country[host], + csc: [city,state,country] + } + ); } -} + host_country[host] = country.toLowerCase(); +}); Object.freeze(host_country); diff --git a/src/js/main.js b/src/js/main.js index 1e39989..b16b950 100644 --- a/src/js/main.js +++ b/src/js/main.js @@ -1,106 +1,47 @@ -//Global cache of securityInfo objects: -const cachedSecurityInfosByTabIdAndURL=new Object(); - -function identifySecType(securityInfo){ - //Takes in a browser.webRequest.getSecurityInfo object - //and returns an integer from secTypes corresponding to the - try { - switch(securityInfo.state) { - case 'insecure': - - //genuinely not HTTPS - return secTypes.insecure; - - case 'secure': - let certChain=securityInfo.certificates; - - if(certChain.length==0) { - //TODO/FIXME: Mozilla doesn't provide - //any access whatsoever to self-signed - //or otherwise nominally-invalid certs - // https://discourse.mozilla.org/t/webrequest-getsecurityinfo-cant-get-self-signed-tofu-exception-certificates/67135 - return secTypes.unknown; - } - - let rootCert=certChain[certChain.length-1]; - - //Now, this connection is... - if(rootCert.isBuiltInRoot){ - //...Mozilla-supported - return secTypes.Mozilla; - } - - if(!securityInfo.isUntrusted){//why didn't they use .isTrusted lol - //...supported by a Non-Mozilla cert,... - if(isItMitM(rootCert)){ //TODO - //...a TLS MITM proxy - return secTypes.MitM; - } else { - //...an alternative Root CA - if(certChain[certChain.length-1].fingerprint.sha256 in sha256fp_host_alt) { - return secTypes.aRootKnown; - } else { - return secTypes.aRootUnknown; - } - } - } - default: - throw {status:'thisShouldNeverHappen',securityInfo:securityInfo}; - } - } catch(e) { - switch(e.status){ - default: - console.error(e.status||e,{securityInfo:securityInfo}); - return secTypes.unknown; - } - } -} +//Global cache of SecurityDetails objects: +const cachedSecurityDetails=new Object(); browser.tabs.onUpdated.addListener( function onTabUpdatedStatusListener(tabId,changeInfo,tabInfo){ - let securityInfo,secType,certChain,browserActionSpec,extraCmds=[]; + let browserActionSpec = genBrowserActionSpec(secTypes.unknown); + let extraCmds = {}; try { - let url=removeFragment(tabInfo.url); - securityInfo=cachedSecurityInfosByTabIdAndURL[tabId][url]; - if(changeInfo.status=='complete' && securityInfo) extraCmds={enable:tabId}; - secType=identifySecType(securityInfo); - certChain=securityInfo.certificates; - browserActionSpec=genBrowserActionSpec(secType,certChain); - } catch(e) { - secType=secTypes.unknown; + const url = removeFragment(tabInfo.url); + const securityDetails = cachedSecurityDetails[tabId][url]; + if( changeInfo.status == 'complete' && securityDetails ) extraCmds.enable = tabId; + browserActionSpec = genBrowserActionSpec(securityDetails.secType,securityDetails.caId); } finally { applyBrowserActionSpec({tabId:tabId},browserActionSpec,extraCmds); } }, { - properties: ["status"] + properties: ["status"]//TODO: include "url"? -will require some refactoring. } ); browser.tabs.onRemoved.addListener( async function onTabRemovedListener(tabId,removeInfo){ - delete cachedSecurityInfosByTabIdAndURL[tabId]; + delete cachedSecurityDetails[tabId]; } ); +//this is the only point we can getSecurityInfo. +//this is a design flaw IMO, since it allows attackers +//to intercept at least one outbound request (no matter +//how well we code) before detection. +//TODO: pester Mozilla about this browser.webRequest.onHeadersReceived.addListener( - //this is the only point we can getSecurityInfo. - //this is a design flaw IMO, since it allows attackers - //to intercept at least one outbound request (no matter - //how well we code) before detection. - //TODO: pester Mozilla about this async function onHeadersReceivedListener(details){ - let tabId=details.tabId; - let type=details.type; - let requestId=details.requestId; - let requestUrl=details.url; - let securityInfo = await browser.webRequest.getSecurityInfo(requestId,{certificateChain:true,rawDER:true}); + const tabId = details.tabId; + const type = details.type; + const requestId = details.requestId; + const requestUrl = removeFragment(details.url); + const securityInfo = await browser.webRequest.getSecurityInfo(requestId,{certificateChain:true,rawDER:true}); switch(type){ case 'main_frame': - if(!(tabId in cachedSecurityInfosByTabIdAndURL)) cachedSecurityInfosByTabIdAndURL[tabId]=new Object(); - cachedSecurityInfosByTabIdAndURL[tabId][requestUrl]=securityInfo; - return; - break; + if(!( tabId in cachedSecurityDetails )) cachedSecurityDetails[tabId] = new Object(); + const secDetails = new SecurityDetails(details, securityInfo); + return cachedSecurityDetails[tabId][requestUrl] = secDetails; default: //TODO return; diff --git a/src/js/util.js b/src/js/util.js index 61cbee3..f5a11ea 100644 --- a/src/js/util.js +++ b/src/js/util.js @@ -1,37 +1,157 @@ -function getAsset(path){ +browser.browserAction.disable();//This should be greyed-out by default + +class SecurityDetails { + constructor(requestDetails,securityInfo) { + this.securityInfo = securityInfo; + this.requestDetails = requestDetails; + + Object.freeze(this.securityInfo); + Object.freeze(this.requestDetails); + + //lazily-evaluated: + this._secType=undefined; + this._caId=undefined; + } + + get secType() { + if( this._secType === undefined ) { + this._secType = SecurityDetails.identifySecType(this.securityInfo); + } + return this._secType; + } + + get caId() { + if( this._caId === undefined ) { + this._caId = SecurityDetails.identifyCaId(this.securityInfo); + } + return this._caId; + } + + static identifySecType(securityInfo){ + //Takes in a browser.webRequest.getSecurityInfo object + //and returns an integer from secTypes corresponding to the + try { + switch(securityInfo.state) { + case 'insecure': + //genuinely not HTTPS + + return secTypes.insecure; + + case 'secure': + // ANY HTTPS-secured connection // + + const certChain=securityInfo.certificates; + + //If it's length-0 -OR- somehow undefined/null/etc + if(!( certChain.length >= 1 )) { + //TODO/FIXME: Mozilla doesn't provide + //any access whatsoever to self-signed + //or otherwise nominally-invalid certs + // https://discourse.mozilla.org/t/webrequest-getsecurityinfo-cant-get-self-signed-tofu-exception-certificates/67135 + return secTypes.unknown; + } else if( certChain.length == 1 ) { + + return secTypes.selfSigned; + } + + const rootCert = certChain[certChain.length-1]; + + //Now, this connection is... + if(rootCert.isBuiltInRoot){ + //...Mozilla-supported + return secTypes.Mozilla; + } + + if(!securityInfo.isUntrusted){//why didn't they use .isTrusted lol + //...supported by a Non-Mozilla cert,... + if(isItMitM(rootCert)){ //TODO + //...a TLS MITM proxy + return secTypes.MitM; + } else { + //...an alternative Root CA + if(certChain[certChain.length-1].fingerprint.sha256 in sha256fp_host_alt) { + return secTypes.aRootKnown; + } else { + return secTypes.aRootUnknown; + } + } + } + default: + throw {status:'thisShouldNeverHappen',securityInfo:securityInfo}; + } + } catch(e) { + console.error(e.status||e,{securityInfo:securityInfo}); + return secTypes.unknown; + } + } + + static identifyCaId(securityInfo) { + try { + const certChain = securityInfo.certificates; + if( ! certChain.length ) { + return null; + } else if( certChain.length == 1 ) { + return 'self'; + } else { + const rootCert = certChain[certChain.length-1]; + const sha256fp = rootCert.fingerprint.sha256; + if( sha256fp in sha256fp_host){ + return sha256fp_host[sha256fp]; + } else if( sha256fp in sha256fp_host_alt ){ + return sha256fp_host_alt[sha256fp]; + } else { + console.warn('Unknown CA',certChain); + return sha256fp; + } + } + throw {status:'thisShouldNeverHappen',securityInfo:securityInfo}; + } catch(e) { + console.error(e.status||e,{securityInfo:securityInfo}); + return null; + } + } +} + +function getAsset(path,type=null){ const assetURL = browser.runtime.getURL(path); const xhr = new XMLHttpRequest(); xhr.open('GET',assetURL,false); xhr.send(); if(intDiv(xhr.status,100) != 2) throw xhr; - return xhr.response; + switch(type){ + case null: + return xhr.response; + case 'json': + return JSON.parse(xhr.response); + default: + throw {error:'unsupported "type"',type:type}; + } } function removeFragment(url){ //Removes the fragment from a URL + //TODO? iff profiler indicts this function: + // return url.match(/^[^#]*/)[0]; let u=new URL(url); - u.hash=''; + u.hash=""; return u.toString(); } -function intDiv(a,b=100){ +function intDiv(a,b){ //Functions as e.g. Python's integer division //Divides then casts directly to integer return a/b>>0; } -function genBrowserActionSpec(secType,certChain){ - let rootHost,iconPath; +function genBrowserActionSpec(secType=null,caId=null){ switch(secType) { case secTypes.Mozilla: - rootHost=sha256fp_host[certChain[certChain.length-1].fingerprint.sha256]; return { - Icon: {path: `images/root_icons/${rootHost}.ico`}, - Title: {title: `${rootHost}\n(Mozilla-trusted Root CA)`}, + Icon: {path: `images/root_icons/${caId}.ico`}, + Title: {title: `${caId}\n(Mozilla-trusted Root CA)`}, BadgeText: {text: '\uD83E\uDD8A'}, BadgeBackgroundColor: {color: 'LimeGreen'} }; - break; case secTypes.MitM: return { Icon: {path: `images/Twemoji_1f441.svg`}, @@ -39,25 +159,21 @@ function genBrowserActionSpec(secType,certChain){ BadgeText: {text: '\u2013'}, BadgeBackgroundColor: {color: 'Fuchsia'} }; - break; case secTypes.aRootKnown: - rootHost=sha256fp_host_alt[certChain[certChain.length-1].fingerprint.sha256]; return { - Icon: {path: `images/alt_root_icons/${rootHost}.ico`}, - Title: {title: `${rootHost}\n(Alternative Root CA)`}, + Icon: {path: `images/alt_root_icons/${caId}.ico`}, + Title: {title: `${caId}\n(Alternative Root CA)`}, BadgeText: {text: rootHost[0].toUpperCase()}, //TODO BadgeBackgroundColor: {color: 'Teal'} }; - break; case secTypes.aRootUnknown: return { //TODO: better support for these? Icon: {path: 'images/Twemoji_1f50f.svg'}, - Title: {title: `I've\u2026never heard of this Root CA before.\n\nIts fingerprint is:\n${certChain[certChain.length-1].fingerprint.sha256}`}, + Title: {title: `I've\u2026never heard of this Root CA before.\n\nWe seem to be identifying it as:\n${caId}`}, BadgeText: {text: '\u24D8\uFE0F'}, BadgeBackgroundColor: {color: 'Cyan'} }; - break; case secTypes.insecure: return { Icon: {path: 'images/Twemoji_26a0.svg'}, @@ -65,7 +181,6 @@ function genBrowserActionSpec(secType,certChain){ BadgeText: {text: '\u2013'}, BadgeBackgroundColor: {color: 'Grey'} } - break; default: return { Icon: {path: 'images/Twemoji_2753.svg'}, @@ -92,29 +207,42 @@ function isItMitM(cert){ function applyBrowserActionSpec(propCmdDefaults={},browserActionSpec,extraCmds={}){ //TODO: why does[?] Firefox not give us an atomic version of this function?? - for(let prop in browserActionSpec) { - let cmd = Object.assign(new Object(), + for(const prop in browserActionSpec) { + const cmd = Object.assign(new Object(), propCmdDefaults, browserActionSpec[prop]); browser.browserAction['set'+prop](cmd); } - for(let cmd in extraCmds){ + for(const cmd in extraCmds){ browser.browserAction[cmd](extraCmds[cmd]); } } +function resetBrowserAction(propCmdDefaults={}){ + return applyBrowserActionSpec(propCmdDefaults, + { + Title: null, + Icon: null, + Popup: null, + BadgeText: null, + BadgeBackgroundColor: null, + BadgeTextColor: null, + } + ); +} + browser.runtime.onInstalled.addListener( function onInstalledListener(details){ // Only pester the user if this is a fresh installation [1], // or at least a minor version bump [2]. - let openPathInTab = path=>browser.tabs.create({url:browser.runtime.getURL(path)}); - let curVersion = browser.runtime.getManifest().version; + const openPathInTab = path=>browser.tabs.create({url:browser.runtime.getURL(path)}); + const curVersion = browser.runtime.getManifest().version; if( details.reason == "install" ) { //[1] openPathInTab('db/welcome/install.htm'); } else { - let curMinorVersion = curVersion.split('.').splice(0,2).join('.'); - let prevMinorVersion = details.previousVersion.split('.').splice(0,2).join('.'); + const curMinorVersion = curVersion.split('.').splice(0,2).join('.'); + const prevMinorVersion = details.previousVersion.split('.').splice(0,2).join('.'); if( curMinorVersion != prevMinorVersion ) { //[2] //openPathInTab('db/welcome/update.htm');//TODO @@ -123,5 +251,75 @@ browser.runtime.onInstalled.addListener( } ); -browser.browserAction.disable();//This should be greyed-out by default +function match0(s,r){ + //Returns the match of r on s + //Otherwise, null + //https://stackoverflow.com/a/64083302 + const m = s.match(r); + if(m) { + return m[0]; + } else { + return null; + } +} + +function identifyCountry(hostname,only_gov=false){ + const exceptionRe = /^(?:uk|ac|eu)$/ ; //https://en.wikipedia.org/wiki/Country_code_top-level_domain#ASCII_ccTLDs_not_in_ISO_3166-1 + const h = hostname.split ? hostname.split('.') : hostname; + const len = h.length; + const tld = len >= 1 ? h[len-1] : null; + const sld = len >= 2 ? h[len-2] : null; + + if( tld.length == 2 ) { + if( only_gov && sld != 'gov' ) return null; + switch( match0(tld,exceptionRe) ) { + case 'uk': + //Britain owns+uses this one + return 'gb'; + case 'ac': + //Ascension Island is part of the British Overseas territory + //"Saint Helena, Ascension and Tristan da Cunha" + return 'sh'; + case null: + //2-letter TLD *not* in the exception list; + //it's a valid ccTLD corresponding to its country + return tld; + default: + //2-letter TLD *in* the exception list (e.g.: .eu); + //it's not a valid ccTLD and we don't know the country + return null; + } + } else if( tld == 'gov' ) { + //AMERICAAA + return 'us'; + } else { + return null; + } +} + +function reduceHostname(hostname){ + //Takes in a hostname (e.g. "www.pki.gov.tw") and reduces it to + //just the "bare minimum" pure-interest/semantic value (e.g. "tw") + //i.e. it does the following (in order): + // 1. Strips off the "www" subdomain + // 2. Strips off the "pki" subdomain + // 3. If the host is a government domain, return just the country + // (otherwise, it returns the hostname as-modified per 1 and 2) + + let h = hostname.split('.'); + const tld = h[h.length-1]; + const sld = h.length >= 2 ? h[h.length-2] : null; + + // 1. + if( h[0] == 'www') h.splice(0,1); + + // 2. + if( h[0] == 'pki') h.splice(0,1); + + // 3. + const govCountry = identifyCountry(h,true); + if( govCountry ) return govCountry; + + return h.join('.'); +} diff --git a/src/manifest.json b/src/manifest.json index 96ff4d6..b0967bf 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 2, "name": "cerdicator", - "version": "0.0.10", + "version": "0.0.11", "description": "Enhanced TLS indicator with an emphasis on information about the Root Certificate Authority from which the connection's authenticity is derived.",