/* 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/. */

/* global Components, Services, dump, AppsUtils, NetUtil, XPCOMUtils */

"use strict";

const Cu = Components.utils;
const Cc = Components.classes;
const Ci = Components.interfaces;
const Cr = Components.results;
const signatureFileExtension = ".sig";

this.EXPORTED_SYMBOLS = ["TrustedHostedAppsUtils"];

Cu.import("resource://gre/modules/AppsUtils.jsm");
Cu.import("resource://gre/modules/Promise.jsm");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");

XPCOMUtils.defineLazyModuleGetter(this, "NetUtil",
  "resource://gre/modules/NetUtil.jsm");

//@line 34 "/build/bluegriffon/src/mozilla-central-fec90cbfbaad/dom/apps/TrustedHostedAppsUtils.jsm"
// Elsewhere, report debug messages only if dom.mozApps.debug is set to true.
// The pref is only checked once, on startup, so restart after changing it.
let debug = Services.prefs.getBoolPref("dom.mozApps.debug") ?
  aMsg => dump("-*- TrustedHostedAppsUtils.jsm : " + aMsg + "\n") :
  () => {};
//@line 40 "/build/bluegriffon/src/mozilla-central-fec90cbfbaad/dom/apps/TrustedHostedAppsUtils.jsm"

/**
 * Verification functions for Trusted Hosted Apps.
 */
this.TrustedHostedAppsUtils = {

  /**
   * Check if the given host is pinned in the CA pinning database.
   */
  isHostPinned: function (aUrl) {
    let uri;
    try {
      uri = Services.io.newURI(aUrl, null, null);
    } catch(e) {
      debug("Host parsing failed: " + e);
      return false;
    }

    // TODO: use nsSiteSecurityService.isSecureURI()
    if (!uri.host || "https" != uri.scheme) {
      return false;
    }

    // Check certificate pinning
    let siteSecurityService;
    try {
      siteSecurityService = Cc["@mozilla.org/ssservice;1"]
        .getService(Ci.nsISiteSecurityService);
    } catch (e) {
      debug("nsISiteSecurityService error: " + e);
      // unrecoverable error, don't bug the user
      throw "CERTDB_ERROR";
    }

    if (siteSecurityService.isSecureHost(Ci.nsISiteSecurityService.HEADER_HPKP,
                                         uri.host, 0)) {
      debug("\tvalid certificate pinning for host: " + uri.host + "\n");
      return true;
    }

    debug("\tHost NOT pinned: " + uri.host + "\n");
    return false;
  },

  /**
   * Take a CSP policy string as input and ensure that it contains at
   * least the directives that are required ('script-src' and
   * 'style-src').  If the CSP policy string is 'undefined' or does
   * not contain some of the required csp directives the function will
   * return empty list with status set to false.  Otherwise a parsed
   * list of the unique sources listed from the required csp
   * directives is returned.
   */
  getCSPWhiteList: function(aCsp) {
    let isValid = false;
    let whiteList = [];
    let requiredDirectives = [ "script-src", "style-src" ];

    if (aCsp) {
      let validDirectives = [];
      let directives = aCsp.split(";");
      // TODO: Use nsIContentSecurityPolicy
      directives
        .map(aDirective => aDirective.trim().split(" "))
        .filter(aList => aList.length > 1)
        // we only restrict on requiredDirectives
        .filter(aList => (requiredDirectives.indexOf(aList[0]) != -1))
        .forEach(aList => {
          // aList[0] contains the directive name.
          // aList[1..n] contains sources.
          let directiveName = aList.shift();
          let sources = aList;

          if ((-1 == validDirectives.indexOf(directiveName))) {
            validDirectives.push(directiveName);
          }
          whiteList.push(...sources.filter(
             // 'self' is checked separately during manifest check
            aSource => (aSource !="'self'" && whiteList.indexOf(aSource) == -1)
          ));
        });

      // Check if all required directives are present.
      isValid = requiredDirectives.length === validDirectives.length;

      if (!isValid) {
        debug("White list doesn't contain all required directives!");
        whiteList = [];
      }
    }

    debug("White list contains " + whiteList.length + " hosts");
    return { list: whiteList, valid: isValid };
  },

  /**
   * Verify that the given csp is valid:
   *  1. contains required directives "script-src" and "style-src"
   *  2. required directives contain only "https" URLs
   *  3. domains of the restricted sources exist in the CA pinning database
   */
  verifyCSPWhiteList: function(aCsp) {
    let domainWhitelist = this.getCSPWhiteList(aCsp);
    if (!domainWhitelist.valid) {
      debug("TRUSTED_APPLICATION_WHITELIST_PARSING_FAILED");
      return false;
    }

    if (!domainWhitelist.list.every(aUrl => this.isHostPinned(aUrl))) {
      debug("TRUSTED_APPLICATION_WHITELIST_VALIDATION_FAILED");
      return false;
    }

    return true;
  },

  _verifySignedFile: function(aManifestStream, aSignatureStream, aCertDb) {
    let deferred = Promise.defer();

    let root = Ci.nsIX509CertDB.TrustedHostedAppPublicRoot;
    try {
      // Check if we should use the test certificates.
      // Please note that this should be changed if we ever allow chages to the
      // prefs since that would create a way for an attacker to use the test
      // root for real apps.
      let useTrustedAppTestCerts = Services.prefs
        .getBoolPref("dom.mozApps.use_trustedapp_test_certs");
      if (useTrustedAppTestCerts) {
        root = Ci.nsIX509CertDB.TrustedHostedAppTestRoot;
      }
    } catch (ex) { }

    aCertDb.verifySignedManifestAsync(
      root, aManifestStream, aSignatureStream,
      function(aRv, aCert) {
        debug("Signature verification returned code, cert & root: " + aRv + " " + aCert + " " + root);
        if (Components.isSuccessCode(aRv)) {
          deferred.resolve(aCert);
        } else if (aRv == Cr.NS_ERROR_FILE_CORRUPTED ||
                   aRv == Cr.NS_ERROR_SIGNED_MANIFEST_FILE_INVALID) {
          deferred.reject("MANIFEST_SIGNATURE_FILE_INVALID");
        } else {
          deferred.reject("MANIFEST_SIGNATURE_VERIFICATION_ERROR");
        }
      }
    );

    return deferred.promise;
  },

  verifySignedManifest: function(aApp, aAppId) {
    let deferred = Promise.defer();

    let certDb;
    try {
      certDb = Cc["@mozilla.org/security/x509certdb;1"]
                 .getService(Ci.nsIX509CertDB);
    } catch (e) {
      debug("nsIX509CertDB error: " + e);
      // unrecoverable error, don't bug the user
      throw "CERTDB_ERROR";
    }

    let principal = Services.scriptSecurityManager.getAppCodebasePrincipal(
                      aApp.origin, aApp.localId, false);

    let mRequestChannel = NetUtil.newChannel2(aApp.manifestURL,
                                              null,
                                              null,
                                              null,      // aLoadingNode
                                              principal,
                                              null,      // aTriggeringPrincipal
                                              Ci.nsILoadInfo.SEC_NORMAL,
                                              Ci.nsIContentPolicy.TYPE_OTHER)
                                 .QueryInterface(Ci.nsIHttpChannel);
    mRequestChannel.loadFlags |= Ci.nsIRequest.INHIBIT_CACHING;
    mRequestChannel.notificationCallbacks =
      AppsUtils.createLoadContext(aAppId, false);

    // The manifest signature must be located at the same path as the
    // manifest and have the same file name, only the file extension
    // should differ. Any fragment or query parameter will be ignored.
    let signatureURL;
    try {
      let mURL = Cc["@mozilla.org/network/io-service;1"]
        .getService(Ci.nsIIOService)
        .newURI(aApp.manifestURL, null, null)
        .QueryInterface(Ci.nsIURL);
      signatureURL = mURL.prePath +
        mURL.directory + mURL.fileBaseName + signatureFileExtension;
    } catch(e) {
      deferred.reject("SIGNATURE_PATH_INVALID");
      return;
    }

    let sRequestChannel = NetUtil.newChannel2(signatureURL,
                                              null,
                                              null,
                                              null,      // aLoadingNode
                                              principal,
                                              null,      // aTriggeringPrincipal
                                              Ci.nsILoadInfo.SEC_NORMAL,
                                              Ci.nsIContentPolicy.TYPE_OTHER)
      .QueryInterface(Ci.nsIHttpChannel);
    sRequestChannel.loadFlags |= Ci.nsIRequest.INHIBIT_CACHING;
    sRequestChannel.notificationCallbacks =
      AppsUtils.createLoadContext(aAppId, false);
    let getAsyncFetchCallback = (resolve, reject) =>
        (aInputStream, aResult) => {
          if (!Components.isSuccessCode(aResult)) {
            debug("Failed to download file");
            reject("MANIFEST_FILE_UNAVAILABLE");
            return;
          }
          resolve(aInputStream);
        };

    Promise.all([
      new Promise((resolve, reject) => {
        NetUtil.asyncFetch2(mRequestChannel,
                            getAsyncFetchCallback(resolve, reject));
      }),
      new Promise((resolve, reject) => {
        NetUtil.asyncFetch2(sRequestChannel,
                            getAsyncFetchCallback(resolve, reject));
      })
    ]).then(([aManifestStream, aSignatureStream]) => {
      this._verifySignedFile(aManifestStream, aSignatureStream, certDb)
        .then(deferred.resolve, deferred.reject);
    }, deferred.reject);

    return deferred.promise;
  },

  verifyManifest: function(aApp, aAppId, aManifest) {
    return new Promise((resolve, reject) => {
      // sanity check on manifest host's CA (proper CA check with
      // pinning is done by regular networking code)
      if (!this.isHostPinned(aApp.manifestURL)) {
        reject("TRUSTED_APPLICATION_HOST_CERTIFICATE_INVALID");
        return;
      }
      if (!this.verifyCSPWhiteList(aManifest.csp)) {
        reject("TRUSTED_APPLICATION_WHITELIST_VALIDATION_FAILED");
        return;
      }
      this.verifySignedManifest(aApp, aAppId).then(resolve, reject);
    });
  }
};
