genHPKPStaticPins.js (20991B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 // How to run this file: 6 // 1. [obtain firefox source code] 7 // 2. [build/obtain firefox binaries] 8 // 3. run `[path to]/firefox -xpcshell [path to]/genHPKPStaticpins.js \ 9 // [absolute path to]/PreloadedHPKPins.json \ 10 // [absolute path to]/StaticHPKPins.h 11 "use strict"; 12 13 if (arguments.length != 2) { 14 throw new Error( 15 "Usage: genHPKPStaticPins.js " + 16 "<absolute path to PreloadedHPKPins.json> " + 17 "<absolute path to StaticHPKPins.h>" 18 ); 19 } 20 21 Services.prefs.setBoolPref("security.osclientcerts.autoload", false); 22 Services.prefs.setBoolPref("network.xhr.block_sync_system_requests", false); 23 24 var { NetUtil } = ChromeUtils.importESModule( 25 "resource://gre/modules/NetUtil.sys.mjs" 26 ); 27 var { FileUtils } = ChromeUtils.importESModule( 28 "resource://gre/modules/FileUtils.sys.mjs" 29 ); 30 31 var gCertDB = Cc["@mozilla.org/security/x509certdb;1"].getService( 32 Ci.nsIX509CertDB 33 ); 34 35 const SHA256_PREFIX = "sha256/"; 36 const GOOGLE_PIN_PREFIX = "GOOGLE_PIN_"; 37 38 // Pins expire in 14 weeks (6 weeks on Beta + 8 weeks on stable) 39 const PINNING_MINIMUM_REQUIRED_MAX_AGE = 60 * 60 * 24 * 7 * 14; 40 41 const FILE_HEADER = 42 "/* This Source Code Form is subject to the terms of the Mozilla Public\n" + 43 " * License, v. 2.0. If a copy of the MPL was not distributed with this\n" + 44 " * file, You can obtain one at http://mozilla.org/MPL/2.0/. */\n" + 45 "\n" + 46 "/*****************************************************************************/\n" + 47 "/* This is an automatically generated file. If you're not */\n" + 48 "/* PublicKeyPinningService.cpp, you shouldn't be #including it. */\n" + 49 "/*****************************************************************************/\n" + 50 "#include <stdint.h>" + 51 "\n"; 52 53 const DOMAINHEADER = 54 "/* Domainlist */\n" + 55 "struct TransportSecurityPreload {\n" + 56 " // See bug 1338873 about making these fields const.\n" + 57 " const char* mHost;\n" + 58 " bool mIncludeSubdomains;\n" + 59 " bool mTestMode;\n" + 60 " bool mIsMoz;\n" + 61 " int32_t mId;\n" + 62 " const StaticFingerprints* pinset;\n" + 63 "};\n\n"; 64 65 const PINSETDEF = 66 "/* Pinsets are each an ordered list by the actual value of the fingerprint */\n" + 67 "struct StaticFingerprints {\n" + 68 " // See bug 1338873 about making these fields const.\n" + 69 " size_t size;\n" + 70 " const char* const* data;\n" + 71 "};\n\n"; 72 73 // Command-line arguments 74 var gStaticPins = parseJson(arguments[0]); 75 76 // Open the output file. 77 var file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); 78 file.initWithPath(arguments[1]); 79 var gFileOutputStream = FileUtils.openSafeFileOutputStream(file); 80 81 function writeString(string) { 82 gFileOutputStream.write(string, string.length); 83 } 84 85 function readFileToString(filename) { 86 let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); 87 file.initWithPath(filename); 88 let stream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance( 89 Ci.nsIFileInputStream 90 ); 91 stream.init(file, -1, 0, 0); 92 let buf = NetUtil.readInputStreamToString(stream, stream.available()); 93 return buf; 94 } 95 96 function stripComments(buf) { 97 let lines = buf.split("\n"); 98 let entryRegex = /^\s*\/\//; 99 let data = ""; 100 for (let i = 0; i < lines.length; ++i) { 101 let match = entryRegex.exec(lines[i]); 102 if (!match) { 103 data = data + lines[i]; 104 } 105 } 106 return data; 107 } 108 109 function download(filename) { 110 let req = new XMLHttpRequest(); 111 req.open("GET", filename, false); // doing the request synchronously 112 try { 113 req.send(); 114 } catch (e) { 115 throw new Error(`ERROR: problem downloading '${filename}': ${e}`); 116 } 117 118 if (req.status != 200) { 119 throw new Error( 120 "ERROR: problem downloading '" + filename + "': status " + req.status 121 ); 122 } 123 124 let resultDecoded; 125 try { 126 resultDecoded = atob(req.responseText); 127 } catch (e) { 128 throw new Error( 129 "ERROR: could not decode data as base64 from '" + filename + "': " + e 130 ); 131 } 132 return resultDecoded; 133 } 134 135 function downloadAsJson(filename) { 136 // we have to filter out '//' comments, while not mangling the json 137 let result = download(filename).replace(/^(\s*)?\/\/[^\n]*\n/gm, ""); 138 let data = null; 139 try { 140 data = JSON.parse(result); 141 } catch (e) { 142 throw new Error( 143 "ERROR: could not parse data from '" + filename + "': " + e 144 ); 145 } 146 return data; 147 } 148 149 // Returns a Subject Public Key Digest from the given pem, if it exists. 150 function getSKDFromPem(pem) { 151 let cert = gCertDB.constructX509FromBase64(pem, pem.length); 152 return cert.sha256SubjectPublicKeyInfoDigest; 153 } 154 155 /** 156 * Hashes |input| using the SHA-256 algorithm in the following manner: 157 * btoa(sha256(atob(input))) 158 * 159 * @param {string} input Base64 string to decode and return the hash of. 160 * @returns {string} Base64 encoded SHA-256 hash. 161 */ 162 function sha256Base64(input) { 163 let decodedValue; 164 try { 165 decodedValue = atob(input); 166 } catch (e) { 167 throw new Error(`ERROR: could not decode as base64: '${input}': ${e}`); 168 } 169 170 // Convert |decodedValue| to an array so that it can be hashed by the 171 // nsICryptoHash instance below. 172 // In most cases across the code base, convertToByteArray() of 173 // nsIScriptableUnicodeConverter is used to do this, but the method doesn't 174 // seem to work here. 175 let data = []; 176 for (let i = 0; i < decodedValue.length; i++) { 177 data[i] = decodedValue.charCodeAt(i); 178 } 179 180 let hasher = Cc["@mozilla.org/security/hash;1"].createInstance( 181 Ci.nsICryptoHash 182 ); 183 hasher.init(hasher.SHA256); 184 hasher.update(data, data.length); 185 186 // true is passed so that the hasher returns a Base64 encoded string. 187 return hasher.finish(true); 188 } 189 190 // Downloads the static certs file and tries to map Google Chrome nicknames 191 // to Mozilla nicknames, as well as storing any hashes for pins for which we 192 // don't have root PEMs. Each entry consists of a line containing the name of 193 // the pin followed either by a hash in the format "sha256/" + base64(hash), 194 // a PEM encoded public key, or a PEM encoded certificate. 195 // For certificates that we have in our database, 196 // return a map of Google's nickname to ours. For ones that aren't return a 197 // map of Google's nickname to SHA-256 values. This code is modeled after agl's 198 // https://github.com/agl/transport-security-state-generate, which doesn't 199 // live in the Chromium repo because go is not an official language in 200 // Chromium. 201 // For all of the entries in this file: 202 // - If the entry has a hash format, find the Mozilla pin name (cert nickname) 203 // and stick the hash into certSKDToName 204 // - If the entry has a PEM format, parse the PEM, find the Mozilla pin name 205 // and stick the hash in certSKDToName 206 // We MUST be able to find a corresponding cert nickname for the Chrome names, 207 // otherwise we skip all pinsets referring to that Chrome name. 208 function downloadAndParseChromeCerts(filename, certNameToSKD, certSKDToName) { 209 // Prefixes that we care about. 210 const BEGIN_CERT = "-----BEGIN CERTIFICATE-----"; 211 const END_CERT = "-----END CERTIFICATE-----"; 212 const BEGIN_PUB_KEY = "-----BEGIN PUBLIC KEY-----"; 213 const END_PUB_KEY = "-----END PUBLIC KEY-----"; 214 215 // Parsing states. 216 const PRE_NAME = 0; 217 const POST_NAME = 1; 218 const IN_CERT = 2; 219 const IN_PUB_KEY = 3; 220 let state = PRE_NAME; 221 222 let lines = download(filename).split("\n"); 223 let pemCert = ""; 224 let pemPubKey = ""; 225 let hash = ""; 226 let chromeNameToHash = {}; 227 let chromeNameToMozName = {}; 228 let chromeName; 229 for (let line of lines) { 230 // Skip comments and newlines. 231 if (!line.length || line[0] == "#") { 232 continue; 233 } 234 switch (state) { 235 case PRE_NAME: 236 chromeName = line; 237 state = POST_NAME; 238 break; 239 case POST_NAME: 240 if (line.startsWith(SHA256_PREFIX)) { 241 hash = line.substring(SHA256_PREFIX.length); 242 chromeNameToHash[chromeName] = hash; 243 certNameToSKD[chromeName] = hash; 244 certSKDToName[hash] = chromeName; 245 state = PRE_NAME; 246 } else if (line.startsWith(BEGIN_CERT)) { 247 state = IN_CERT; 248 } else if (line.startsWith(BEGIN_PUB_KEY)) { 249 state = IN_PUB_KEY; 250 } else if ( 251 chromeName == "PinsListTimestamp" && 252 line.match(/^[0-9]+$/) 253 ) { 254 // If the name of this entry is "PinsListTimestamp", this line should 255 // be the pins list timestamp. It should consist solely of digits. 256 // Ignore it and expect other entries to come. 257 state = PRE_NAME; 258 } else { 259 throw new Error( 260 "ERROR: couldn't parse Chrome certificate file line: " + line 261 ); 262 } 263 break; 264 case IN_CERT: 265 if (line.startsWith(END_CERT)) { 266 state = PRE_NAME; 267 hash = getSKDFromPem(pemCert); 268 pemCert = ""; 269 let mozName; 270 if (hash in certSKDToName) { 271 mozName = certSKDToName[hash]; 272 } else { 273 // Not one of our built-in certs. Prefix the name with 274 // GOOGLE_PIN_. 275 mozName = GOOGLE_PIN_PREFIX + chromeName; 276 dump( 277 "Can't find hash in builtin certs for Chrome nickname " + 278 chromeName + 279 ", inserting " + 280 mozName + 281 "\n" 282 ); 283 certSKDToName[hash] = mozName; 284 certNameToSKD[mozName] = hash; 285 } 286 chromeNameToMozName[chromeName] = mozName; 287 } else { 288 pemCert += line; 289 } 290 break; 291 case IN_PUB_KEY: 292 if (line.startsWith(END_PUB_KEY)) { 293 state = PRE_NAME; 294 hash = sha256Base64(pemPubKey); 295 pemPubKey = ""; 296 chromeNameToHash[chromeName] = hash; 297 certNameToSKD[chromeName] = hash; 298 certSKDToName[hash] = chromeName; 299 } else { 300 pemPubKey += line; 301 } 302 break; 303 default: 304 throw new Error( 305 "ERROR: couldn't parse Chrome certificate file " + line 306 ); 307 } 308 } 309 return [chromeNameToHash, chromeNameToMozName]; 310 } 311 312 // We can only import pinsets from chrome if for every name in the pinset: 313 // - We have a hash from Chrome's static certificate file 314 // - We have a builtin cert 315 // If the pinset meets these requirements, we store a map array of pinset 316 // objects: 317 // { 318 // pinset_name : { 319 // // Array of names with entries in certNameToSKD 320 // sha256_hashes: [] 321 // } 322 // } 323 // and an array of imported pinset entries: 324 // { name: string, include_subdomains: boolean, test_mode: boolean, 325 // pins: pinset_name } 326 function downloadAndParseChromePins( 327 filename, 328 chromeNameToHash, 329 chromeNameToMozName, 330 certNameToSKD, 331 certSKDToName 332 ) { 333 let chromePreloads = downloadAsJson(filename); 334 let chromePins = chromePreloads.pinsets; 335 let chromeImportedPinsets = {}; 336 let chromeImportedEntries = []; 337 338 chromePins.forEach(function (pin) { 339 let valid = true; 340 let pinset = { name: pin.name, sha256_hashes: [] }; 341 // Translate the Chrome pinset format to ours 342 pin.static_spki_hashes.forEach(function (name) { 343 if (name in chromeNameToHash) { 344 let hash = chromeNameToHash[name]; 345 pinset.sha256_hashes.push(certSKDToName[hash]); 346 347 // We should have already added hashes for all of these when we 348 // imported the certificate file. 349 if (!certNameToSKD[name]) { 350 throw new Error("ERROR: No hash for name: " + name); 351 } 352 } else if (name in chromeNameToMozName) { 353 pinset.sha256_hashes.push(chromeNameToMozName[name]); 354 } else { 355 dump( 356 "Skipping Chrome pinset " + 357 pinset.name + 358 ", couldn't find " + 359 "builtin " + 360 name + 361 " from cert file\n" 362 ); 363 valid = false; 364 } 365 }); 366 if (valid) { 367 chromeImportedPinsets[pinset.name] = pinset; 368 } 369 }); 370 371 // Grab the domain entry lists. Chrome's entry format is similar to 372 // ours, except theirs includes a HSTS mode. 373 const cData = gStaticPins.chromium_data; 374 let entries = chromePreloads.entries; 375 entries.forEach(function (entry) { 376 // HSTS entry only 377 if (!entry.pins) { 378 return; 379 } 380 let pinsetName = cData.substitute_pinsets[entry.pins]; 381 if (!pinsetName) { 382 pinsetName = entry.pins; 383 } 384 385 // We trim the entry name here to avoid breaking hostname comparisons in the 386 // HPKP implementation. 387 entry.name = entry.name.trim(); 388 389 let isProductionDomain = cData.production_domains.includes(entry.name); 390 let isProductionPinset = cData.production_pinsets.includes(pinsetName); 391 let excludeDomain = cData.exclude_domains.includes(entry.name); 392 let isTestMode = !isProductionPinset && !isProductionDomain; 393 if (entry.pins && !excludeDomain && chromeImportedPinsets[entry.pins]) { 394 chromeImportedEntries.push({ 395 name: entry.name, 396 include_subdomains: entry.include_subdomains, 397 test_mode: isTestMode, 398 is_moz: false, 399 pins: pinsetName, 400 }); 401 } 402 }); 403 return [chromeImportedPinsets, chromeImportedEntries]; 404 } 405 406 // Returns a pair of maps [certNameToSKD, certSKDToName] between cert 407 // nicknames and digests of the SPKInfo for the mozilla trust store 408 function loadNSSCertinfo(extraCertificates) { 409 let allCerts = gCertDB.getCerts(); 410 let certNameToSKD = {}; 411 let certSKDToName = {}; 412 for (let cert of allCerts) { 413 let name = cert.displayName; 414 let SKD = cert.sha256SubjectPublicKeyInfoDigest; 415 certNameToSKD[name] = SKD; 416 certSKDToName[SKD] = name; 417 } 418 419 for (let cert of extraCertificates) { 420 let name = cert.commonName; 421 let SKD = cert.sha256SubjectPublicKeyInfoDigest; 422 certNameToSKD[name] = SKD; 423 certSKDToName[SKD] = name; 424 } 425 426 { 427 // This is the pinning test certificate. The key hash identifies the 428 // default RSA key from pykey. 429 let name = "End Entity Test Cert"; 430 let SKD = "VCIlmPM9NkgFQtrs4Oa5TeFcDu6MWRTKSNdePEhOgD8="; 431 certNameToSKD[name] = SKD; 432 certSKDToName[SKD] = name; 433 } 434 return [certNameToSKD, certSKDToName]; 435 } 436 437 function parseJson(filename) { 438 let json = stripComments(readFileToString(filename)); 439 return JSON.parse(json); 440 } 441 442 function nameToAlias(certName) { 443 // change the name to a string valid as a c identifier 444 // remove non-ascii characters 445 certName = certName.replace(/[^[:ascii:]]/g, "_"); 446 // replace non word characters 447 certName = certName.replace(/[^A-Za-z0-9]/g, "_"); 448 449 return "k" + certName + "Fingerprint"; 450 } 451 452 function compareByName(a, b) { 453 return a.name.localeCompare(b.name); 454 } 455 456 function genExpirationTime() { 457 let now = new Date(); 458 let nowMillis = now.getTime(); 459 let expirationMillis = nowMillis + PINNING_MINIMUM_REQUIRED_MAX_AGE * 1000; 460 let expirationMicros = expirationMillis * 1000; 461 return ( 462 "static const PRTime kPreloadPKPinsExpirationTime = INT64_C(" + 463 expirationMicros + 464 ");\n" 465 ); 466 } 467 468 function writeFullPinset(certNameToSKD, certSKDToName, pinset) { 469 if (!pinset.sha256_hashes || !pinset.sha256_hashes.length) { 470 throw new Error(`ERROR: Pinset ${pinset.name} does not contain any hashes`); 471 } 472 writeFingerprints( 473 certNameToSKD, 474 certSKDToName, 475 pinset.name, 476 pinset.sha256_hashes 477 ); 478 } 479 480 function writeFingerprints(certNameToSKD, certSKDToName, name, hashes) { 481 let varPrefix = "kPinset_" + name; 482 writeString("static const char* const " + varPrefix + "_Data[] = {\n"); 483 let SKDList = []; 484 for (let certName of hashes) { 485 if (!(certName in certNameToSKD)) { 486 throw new Error(`ERROR: Can't find '${certName}' in certNameToSKD`); 487 } 488 SKDList.push(certNameToSKD[certName]); 489 } 490 for (let skd of SKDList.sort()) { 491 writeString(" " + nameToAlias(certSKDToName[skd]) + ",\n"); 492 } 493 if (!hashes.length) { 494 // ANSI C requires that an initialiser list be non-empty. 495 writeString(" 0\n"); 496 } 497 writeString("};\n"); 498 writeString( 499 "static const StaticFingerprints " + 500 varPrefix + 501 " = {\n " + 502 "sizeof(" + 503 varPrefix + 504 "_Data) / sizeof(const char*),\n " + 505 varPrefix + 506 "_Data\n};\n\n" 507 ); 508 } 509 510 function writeEntry(entry) { 511 let printVal = ` { "${entry.name}", `; 512 if (entry.include_subdomains) { 513 printVal += "true, "; 514 } else { 515 printVal += "false, "; 516 } 517 // Default to test mode if not specified. 518 let testMode = true; 519 if (entry.hasOwnProperty("test_mode")) { 520 testMode = entry.test_mode; 521 } 522 if (testMode) { 523 printVal += "true, "; 524 } else { 525 printVal += "false, "; 526 } 527 if ( 528 entry.is_moz || 529 (entry.pins.includes("mozilla") && entry.pins != "mozilla_test") 530 ) { 531 printVal += "true, "; 532 } else { 533 printVal += "false, "; 534 } 535 if ("id" in entry) { 536 if (entry.id >= 256) { 537 throw new Error("ERROR: Not enough buckets in histogram"); 538 } 539 if (entry.id >= 0) { 540 printVal += entry.id + ", "; 541 } 542 } else { 543 printVal += "-1, "; 544 } 545 printVal += "&kPinset_" + entry.pins; 546 printVal += " },\n"; 547 writeString(printVal); 548 } 549 550 function writeDomainList(chromeImportedEntries) { 551 writeString("/* Sort hostnames for binary search. */\n"); 552 writeString( 553 "static const TransportSecurityPreload " + 554 "kPublicKeyPinningPreloadList[] = {\n" 555 ); 556 let count = 0; 557 let mozillaDomains = {}; 558 gStaticPins.entries.forEach(function (entry) { 559 mozillaDomains[entry.name] = true; 560 }); 561 // For any domain for which we have set pins, exclude them from 562 // chromeImportedEntries. 563 for (let i = chromeImportedEntries.length - 1; i >= 0; i--) { 564 if (mozillaDomains[chromeImportedEntries[i].name]) { 565 dump( 566 "Skipping duplicate pinset for domain " + 567 JSON.stringify(chromeImportedEntries[i], undefined, 2) + 568 "\n" 569 ); 570 chromeImportedEntries.splice(i, 1); 571 } 572 } 573 let sortedEntries = gStaticPins.entries; 574 sortedEntries.push.apply(sortedEntries, chromeImportedEntries); 575 for (let entry of sortedEntries.sort(compareByName)) { 576 count++; 577 writeEntry(entry); 578 } 579 writeString("};\n"); 580 581 writeString("\n// Pinning Preload List Length = " + count + ";\n"); 582 writeString("\nstatic const int32_t kUnknownId = -1;\n"); 583 } 584 585 function writeFile( 586 certNameToSKD, 587 certSKDToName, 588 chromeImportedPinsets, 589 chromeImportedEntries 590 ) { 591 // Compute used pins from both Chrome's and our pinsets, so we can output 592 // them later. 593 let usedFingerprints = {}; 594 let mozillaPins = {}; 595 gStaticPins.pinsets.forEach(function (pinset) { 596 mozillaPins[pinset.name] = true; 597 pinset.sha256_hashes.forEach(function (name) { 598 usedFingerprints[name] = true; 599 }); 600 }); 601 for (let key in chromeImportedPinsets) { 602 let pinset = chromeImportedPinsets[key]; 603 pinset.sha256_hashes.forEach(function (name) { 604 usedFingerprints[name] = true; 605 }); 606 } 607 608 writeString(FILE_HEADER); 609 610 // Write actual fingerprints. 611 Object.keys(usedFingerprints) 612 .sort() 613 .forEach(function (certName) { 614 if (certName) { 615 writeString("/* " + certName + " */\n"); 616 writeString("static const char " + nameToAlias(certName) + "[] =\n"); 617 writeString(' "' + certNameToSKD[certName] + '";\n'); 618 writeString("\n"); 619 } 620 }); 621 622 // Write the pinsets 623 writeString(PINSETDEF); 624 writeString("/* PreloadedHPKPins.json pinsets */\n"); 625 gStaticPins.pinsets.sort(compareByName).forEach(function (pinset) { 626 writeFullPinset(certNameToSKD, certSKDToName, pinset); 627 }); 628 writeString("/* Chrome static pinsets */\n"); 629 for (let key in chromeImportedPinsets) { 630 if (mozillaPins[key]) { 631 dump("Skipping duplicate pinset " + key + "\n"); 632 } else { 633 dump("Writing pinset " + key + "\n"); 634 writeFullPinset(certNameToSKD, certSKDToName, chromeImportedPinsets[key]); 635 } 636 } 637 638 // Write the domainlist entries. 639 writeString(DOMAINHEADER); 640 writeDomainList(chromeImportedEntries); 641 writeString("\n"); 642 writeString(genExpirationTime()); 643 } 644 645 function loadExtraCertificates(certStringList) { 646 let constructedCerts = []; 647 for (let certString of certStringList) { 648 constructedCerts.push(gCertDB.constructX509FromBase64(certString)); 649 } 650 return constructedCerts; 651 } 652 653 var extraCertificates = loadExtraCertificates(gStaticPins.extra_certificates); 654 var [certNameToSKD, certSKDToName] = loadNSSCertinfo(extraCertificates); 655 var [chromeNameToHash, chromeNameToMozName] = downloadAndParseChromeCerts( 656 gStaticPins.chromium_data.cert_file_url, 657 certNameToSKD, 658 certSKDToName 659 ); 660 var [chromeImportedPinsets, chromeImportedEntries] = downloadAndParseChromePins( 661 gStaticPins.chromium_data.json_file_url, 662 chromeNameToHash, 663 chromeNameToMozName, 664 certNameToSKD, 665 certSKDToName 666 ); 667 668 writeFile( 669 certNameToSKD, 670 certSKDToName, 671 chromeImportedPinsets, 672 chromeImportedEntries 673 ); 674 675 FileUtils.closeSafeFileOutputStream(gFileOutputStream);