/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";

// How to run this file:
// 1. [obtain CNNIC-issued certificates to be whitelisted]
// 2. [obtain firefox source code]
// 3. [build/obtain firefox binaries]
// 4. run `[path to]/run-mozilla.sh [path to]/xpcshell makeCNNICHashes.js \
//                                  [path to]/intermediatesFile
//                                  [path to]/certlist'
//    Where |intermediatesFile| is a file containing PEM encoded intermediate
//    certificates that the certificates in |certlist| may be issued by.
//    where certlist is a file containing a list of paths to certificates to
//    be included in the whitelist

var Cc = Components.classes;
var Ci = Components.interfaces;
var Cu = Components.utils;

var gCertDB = Cc["@mozilla.org/security/x509certdb;1"]
                .getService(Ci.nsIX509CertDB);

var { NetUtil } = Cu.import("resource://gre/modules/NetUtil.jsm", {});
var { Services } = Cu.import("resource://gre/modules/Services.jsm", {});

const HEADER = "// This Source Code Form is subject to the terms of the Mozilla Public\n" +
"// License, v. 2.0. If a copy of the MPL was not distributed with this\n" +
"// file, You can obtain one at http://mozilla.org/MPL/2.0/.\n" +
"//\n" +
"//***************************************************************************\n" +
"// This file was automatically generated by makeCNNICHashes.js. It shouldn't\n" +
"// need to be manually edited.\n" +
"//***************************************************************************\n" +
"\n";

const PREAMBLE = "#define CNNIC_WHITELIST_HASH_LEN 32\n\n" +
"struct WhitelistedCNNICHash {\n" +
" const uint8_t hash[CNNIC_WHITELIST_HASH_LEN];\n" +
"};\n\n" +
"static const struct WhitelistedCNNICHash WhitelistedCNNICHashes[] = {\n";

const POSTAMBLE = "};\n";

function writeString(fos, string) {
  fos.write(string, string.length);
}

// fingerprint is in the form "00:11:22:..."
function hexSlice(fingerprint, start, end) {
  let hexBytes = fingerprint.split(":");
  let ret = "";
  for (let i = start; i < end; i++) {
    let hex = hexBytes[i];
    ret += "0x" + hex;
    if (i < end - 1) {
      ret += ", ";
    }
  }
  return ret;
}

// Write the C++ header file
function writeHashes(certs, lastValidTime, fos) {
  writeString(fos, HEADER);
  writeString(fos, `// This file may be removed after ${new Date(lastValidTime)}\n\n`);
  writeString(fos, PREAMBLE);

  certs.forEach(function(cert) {
    writeString(fos, "  {\n");
    writeString(fos, "    { " + hexSlice(cert.sha256Fingerprint, 0, 16) + ",\n");
    writeString(fos, "      " + hexSlice(cert.sha256Fingerprint, 16, 32) + " },\n");

    writeString(fos, "  },\n");
  });
  writeString(fos, POSTAMBLE);
}

function readFileContents(file) {
  let fstream = Cc["@mozilla.org/network/file-input-stream;1"]
                  .createInstance(Ci.nsIFileInputStream);
  fstream.init(file, -1, 0, 0);
  let data = NetUtil.readInputStreamToString(fstream, fstream.available());
  fstream.close();
  return data;
}

function relativePathToFile(path) {
  let currentDirectory = Cc["@mozilla.org/file/directory_service;1"]
                           .getService(Ci.nsIProperties)
                           .get("CurWorkD", Ci.nsILocalFile);
  let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsILocalFile);
  file.initWithPath(currentDirectory.path + "/" + path);
  return file;
}

function pathToFile(path) {
  let file = relativePathToFile(path);
  if (!file.exists()) {
    // Fall back to trying absolute path
    file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsILocalFile);
    file.initWithPath(path);
  }
  return file;
}

// punt on dealing with leap-years
const sixYearsInMilliseconds = 6 * 366 * 24 * 60 * 60 * 1000;

function loadCertificates(certFile, currentWhitelist) {
  let nowInMilliseconds = (new Date()).getTime();
  // months are 0-indexed, so April is month 3 :(
  let april1InMilliseconds = (new Date(2015, 3, 1)).getTime();
  let latestNotAfter = nowInMilliseconds;
  let certs = [];
  let certMap = {};
  let invalidCerts = [];
  let paths = readFileContents(certFile).split("\n");
  for (let path of paths) {
    if (!path) {
      continue;
    }
    let certData = readFileContents(pathToFile(path));
    let cert = null;
    try {
      cert = gCertDB.constructX509FromBase64(certData);
    } catch (e) {}
    if (!cert) {
      cert = gCertDB.constructX509(certData, certData.length);
    }
    // Don't add multiple copies of any particular certificate.
    if (cert.sha256Fingerprint in certMap) {
      continue;
    }
    certMap[cert.sha256Fingerprint] = true;
    // If we can't verify the certificate, don't include it. Unfortunately, if
    // a CNNIC-issued certificate wasn't previously on the whitelist but it
    // otherwise verifies successfully, verifyCertNow will return
    // SEC_ERROR_REVOKED_CERTIFICATE, so we count that as verifying
    // successfully. If the certificate is later revoked by CNNIC, the user
    // will see that when they attempt to connect to a site using it and we do
    // normal revocation checking.
    let errorCode = gCertDB.verifyCertNow(cert, 2 /* SSL Server */,
                                          Ci.nsIX509CertDB.LOCAL_ONLY, null,
                                          {}, {});
    if (errorCode != 0 &&
        errorCode != -8180 /* SEC_ERROR_REVOKED_CERTIFICATE */) {
      continue;
    }
    let durationMilliseconds = (cert.validity.notAfter - cert.validity.notBefore) / 1000;
    let notBeforeMilliseconds = cert.validity.notBefore / 1000;
    let notAfterMilliseconds = cert.validity.notAfter / 1000;
    // Only consider certificates that were issued before 1 April 2015, haven't
    // expired, and have a validity period shorter than 6 years (there is a
    // delegated OCSP responder certificate with a validity period of 6 years
    // that should be on the whitelist).
    // Also only consider certificates that were already on the whitelist.
    if (notBeforeMilliseconds < april1InMilliseconds &&
        notAfterMilliseconds > nowInMilliseconds &&
        durationMilliseconds < sixYearsInMilliseconds &&
        currentWhitelist[cert.sha256Fingerprint]) {
      certs.push(cert);
      if (notAfterMilliseconds > latestNotAfter) {
        latestNotAfter = notAfterMilliseconds;
      }
    }
    if (durationMilliseconds >= sixYearsInMilliseconds) {
      invalidCerts.push(cert);
    }
  }
  return { certs: certs,
           lastValidTime: latestNotAfter,
           invalidCerts: invalidCerts };
}

// Expects something like "00:11:22:...", returns a string of bytes.
function hexToBinaryString(hexString) {
  let hexBytes = hexString.split(":");
  let result = "";
  for (let hexByte of hexBytes) {
    result += String.fromCharCode(parseInt(hexByte, 16));
  }
  return result;
}

function compareCertificatesByHash(certA, certB) {
  let aBin = hexToBinaryString(certA.sha256Fingerprint);
  let bBin = hexToBinaryString(certB.sha256Fingerprint);

  if (aBin < bBin) {
     return -1;
  }
  if (aBin > bBin) {
     return 1;
  }
 return 0;
}

function certToPEM(cert) {
  let der = cert.getRawDER({});
  let derString = '';
  for (let i = 0; i < der.length; i++) {
    derString += String.fromCharCode(der[i]);
  }
  let base64Lines = btoa(derString).replace(/(.{64})/g, "$1\n");
  let output = "-----BEGIN CERTIFICATE-----\n";
  for (let line of base64Lines.split("\n")) {
    if (line.length > 0) {
      output += line + "\n";
    }
  }
  output += "-----END CERTIFICATE-----";
  return output;
}

function loadIntermediates(intermediatesFile) {
  let pem = readFileContents(intermediatesFile);
  let intermediates = [];
  let currentPEM = "";
  for (let line of pem.split("\r\n")) {
    if (line == "-----END CERTIFICATE-----") {
      if (currentPEM) {
        intermediates.push(gCertDB.constructX509FromBase64(currentPEM));
      }
      currentPEM = "";
      continue;
    }
    if (line != "-----BEGIN CERTIFICATE-----") {
      currentPEM += line;
    }
  }
  return intermediates;
}

function readCurrentWhitelist(currentWhitelistFile) {
  let contents = readFileContents(currentWhitelistFile).replace(/[\r\n ]/g, "");
  let split = contents.split(/((?:0x[0-9A-F][0-9A-F],){31}0x[0-9A-F][0-9A-F])/);
  // The hashes will be every odd-indexed element of the array.
  let currentWhitelist = {};
  for (let i = 1; i < split.length && i < split.length - 1; i += 2) {
    let hash = split[i].replace(/0x/g, "").replace(/,/g, ":");
    currentWhitelist[hash] = true;
  }
  return currentWhitelist;
}

///////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////

if (arguments.length != 3) {
  throw new Error("Usage: makeCNNICHashes.js <PEM intermediates file> " +
                  "<path to list of certificates> <path to current whitelist file>");
}

Services.prefs.setIntPref("security.OCSP.enabled", 0);
var intermediatesFile = pathToFile(arguments[0]);
var intermediates = loadIntermediates(intermediatesFile);
var certFile = pathToFile(arguments[1]);
var currentWhitelistFile = pathToFile(arguments[2]);
var currentWhitelist = readCurrentWhitelist(currentWhitelistFile);
var { certs, lastValidTime, invalidCerts } = loadCertificates(certFile, currentWhitelist);

dump("The following certificates were not included due to overlong validity periods:\n");
for (let cert of invalidCerts) {
  dump(certToPEM(cert) + "\n");
}

// Sort the key hashes to allow for binary search.
certs.sort(compareCertificatesByHash);

// Write the output file.
var outFile = relativePathToFile("CNNICHashWhitelist.inc");
if (!outFile.exists()) {
  outFile.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0o644);
}
var outStream = Cc["@mozilla.org/network/file-output-stream;1"]
                  .createInstance(Ci.nsIFileOutputStream);
outStream.init(outFile, -1, 0, 0);
writeHashes(certs, lastValidTime, outStream);
outStream.close();