// -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
// Any copyright is dedicated to the Public Domain.
// http://creativecommons.org/publicdomain/zero/1.0/
"use strict";

// Tests various scenarios connecting to a server that requires client cert
// authentication. Also tests that nsIClientAuthDialogService.chooseCertificate
// is called at the appropriate times and with the correct arguments.

const { MockRegistrar } = ChromeUtils.importESModule(
  "resource://testing-common/MockRegistrar.sys.mjs"
);

const DialogState = {
  // Assert that chooseCertificate() is never called.
  ASSERT_NOT_CALLED: "ASSERT_NOT_CALLED",
  // Return that the user selected the first given cert.
  RETURN_CERT_SELECTED: "RETURN_CERT_SELECTED",
  // Return that the user canceled.
  RETURN_CERT_NOT_SELECTED: "RETURN_CERT_NOT_SELECTED",
};

var sdr = Cc["@mozilla.org/security/sdr;1"].getService(Ci.nsISecretDecoderRing);
let cars = Cc["@mozilla.org/security/clientAuthRememberService;1"].getService(
  Ci.nsIClientAuthRememberService
);

var gExpectedClientCertificateChoices;

// Mock implementation of nsIClientAuthDialogService.
const gClientAuthDialogService = {
  _state: DialogState.ASSERT_NOT_CALLED,
  _rememberClientAuthCertificate: false,
  _chooseCertificateCalled: false,

  set state(newState) {
    info(`old state: ${this._state}`);
    this._state = newState;
    info(`new state: ${this._state}`);
  },

  get state() {
    return this._state;
  },

  set rememberClientAuthCertificate(value) {
    this._rememberClientAuthCertificate = value;
  },

  get rememberClientAuthCertificate() {
    return this._rememberClientAuthCertificate;
  },

  get chooseCertificateCalled() {
    return this._chooseCertificateCalled;
  },

  set chooseCertificateCalled(value) {
    this._chooseCertificateCalled = value;
  },

  chooseCertificate(hostname, certArray, loadContext, callback) {
    this.chooseCertificateCalled = true;
    Assert.notEqual(
      this.state,
      DialogState.ASSERT_NOT_CALLED,
      "chooseCertificate() should be called only when expected"
    );
    Assert.equal(
      hostname,
      "requireclientcert.example.com",
      "Hostname should be 'requireclientcert.example.com'"
    );

    // For mochitests, the cert at build/pgo/certs/mochitest.client should be
    // selectable as well as one of the PGO certs we loaded in `setup`, so we do
    // some brief checks to confirm this.
    Assert.notEqual(certArray, null, "Cert list should not be null");
    Assert.equal(
      certArray.length,
      gExpectedClientCertificateChoices,
      `${gExpectedClientCertificateChoices} certificates should be available`
    );

    for (let cert of certArray) {
      Assert.notEqual(cert, null, "Cert list should contain nsIX509Certs");
      Assert.equal(
        cert.issuerCommonName,
        "Temporary Certificate Authority",
        "cert should have expected issuer CN"
      );
    }

    if (this.state == DialogState.RETURN_CERT_SELECTED) {
      callback.certificateChosen(
        certArray[0],
        this.rememberClientAuthCertificate
      );
    } else {
      callback.certificateChosen(null, this.rememberClientAuthCertificate);
    }
  },

  QueryInterface: ChromeUtils.generateQI(["nsIClientAuthDialogService"]),
};

add_setup(async function () {
  let clientAuthDialogServiceCID = MockRegistrar.register(
    "@mozilla.org/security/ClientAuthDialogService;1",
    gClientAuthDialogService
  );
  registerCleanupFunction(() => {
    MockRegistrar.unregister(clientAuthDialogServiceCID);
  });

  // This CA has the expected keyCertSign and cRLSign usages. It should not be
  // presented for use as a client certificate.
  await readCertificate("pgo-ca-regular-usages.pem", "CTu,CTu,CTu");
  // This CA has all keyUsages. For compatibility with preexisting behavior, it
  // will be presented for use as a client certificate.
  await readCertificate("pgo-ca-all-usages.pem", "CTu,CTu,CTu");
  // This client certificate was issued by an intermediate that was issued by
  // the test CA. The server only lists the test CA's subject distinguished name
  // as an acceptible issuer name for client certificates. If the implementation
  // can determine that the test CA is a root CA for the client certificate and
  // thus is acceptible to use, it should be included in the chooseCertificate
  // callback. At the beginning of this test (speaking of this file as a whole),
  // the client is not aware of the intermediate, and so it is not available in
  // the callback.
  await readCertificate("client-cert-via-intermediate.pem", ",,");
  // This certificate has an id-kp-OCSPSigning EKU. Client certificates
  // shouldn't have this EKU, but there is at least one private PKI where they
  // do. For interoperability, such certificates will be presented for use.
  await readCertificate("client-cert-with-ocsp-signing.pem", ",,");
  gExpectedClientCertificateChoices = 3;
});

/**
 * Test helper for the tests below.
 *
 * @param {string} prefValue
 *        Value to set the "security.default_personal_cert" pref to.
 * @param {string} urlToNavigate
 *        The URL to navigate to.
 * @param {string} expectedURL
 *        If the connection is expected to load successfully, the URL that
 *        should load. If the connection is expected to fail and result in an
 *        error page, |undefined|.
 * @param {boolean} expectCallingChooseCertificate
 *        Determines whether we expect chooseCertificate to be called.
 * @param {object} options
 *        Optional options object to pass on to the window that gets opened.
 * @param {string} expectStringInPage
 *        Optional string that is expected to be in the content of the page
 *        once it loads.
 */
async function testHelper(
  prefValue,
  urlToNavigate,
  expectedURL,
  expectCallingChooseCertificate,
  options = undefined,
  expectStringInPage = undefined
) {
  gClientAuthDialogService.chooseCertificateCalled = false;
  await SpecialPowers.pushPrefEnv({
    set: [["security.default_personal_cert", prefValue]],
  });

  let win = await BrowserTestUtils.openNewBrowserWindow(options);

  BrowserTestUtils.startLoadingURIString(
    win.gBrowser.selectedBrowser,
    urlToNavigate
  );
  if (expectedURL) {
    await BrowserTestUtils.browserLoaded(
      win.gBrowser.selectedBrowser,
      false,
      "https://requireclientcert.example.com/",
      true
    );
    let loadedURL = win.gBrowser.selectedBrowser.documentURI.spec;
    Assert.ok(
      loadedURL.startsWith(expectedURL),
      `Expected and actual URLs should match (got '${loadedURL}', expected '${expectedURL}')`
    );
  } else {
    await new Promise(resolve => {
      let removeEventListener = BrowserTestUtils.addContentEventListener(
        win.gBrowser.selectedBrowser,
        "AboutNetErrorLoad",
        () => {
          removeEventListener();
          resolve();
        },
        { capture: false, wantUntrusted: true }
      );
    });
  }

  Assert.equal(
    gClientAuthDialogService.chooseCertificateCalled,
    expectCallingChooseCertificate,
    "chooseCertificate should have been called if we were expecting it to be called"
  );

  if (expectStringInPage) {
    let pageContent = await SpecialPowers.spawn(
      win.gBrowser.selectedBrowser,
      [],
      async function () {
        return content.document.body.textContent;
      }
    );
    Assert.ok(
      pageContent.includes(expectStringInPage),
      `page should contain the string '${expectStringInPage}' (was '${pageContent}')`
    );
  }

  await win.close();

  // This clears the TLS session cache so we don't use a previously-established
  // ticket to connect and bypass selecting a client auth certificate in
  // subsequent tests.
  sdr.logout();
}

// Test that if a certificate is chosen automatically the connection succeeds,
// and that nsIClientAuthDialogService.chooseCertificate() is never called.
add_task(async function testCertChosenAutomatically() {
  gClientAuthDialogService.state = DialogState.ASSERT_NOT_CALLED;
  await testHelper(
    "Select Automatically",
    "https://requireclientcert.example.com/",
    "https://requireclientcert.example.com/",
    false
  );
  // This clears all saved client auth certificate state so we don't influence
  // subsequent tests.
  cars.clearRememberedDecisions();
});

// Test that if the user doesn't choose a certificate, the connection fails and
// an error page is displayed.
add_task(async function testCertNotChosenByUser() {
  gClientAuthDialogService.state = DialogState.RETURN_CERT_NOT_SELECTED;
  await testHelper(
    "Ask Every Time",
    "https://requireclientcert.example.com/",
    undefined,
    true,
    undefined,
    // bug 1818556: ssltunnel doesn't behave as expected here on Windows
    AppConstants.platform != "win"
      ? "SSL_ERROR_RX_CERTIFICATE_REQUIRED_ALERT"
      : undefined
  );
  cars.clearRememberedDecisions();
});

// Test that if the user chooses a certificate the connection suceeeds.
add_task(async function testCertChosenByUser() {
  gClientAuthDialogService.state = DialogState.RETURN_CERT_SELECTED;
  await testHelper(
    "Ask Every Time",
    "https://requireclientcert.example.com/",
    "https://requireclientcert.example.com/",
    true
  );
  cars.clearRememberedDecisions();
});

// Test that the cancel decision is remembered correctly
add_task(async function testEmptyCertChosenByUser() {
  gClientAuthDialogService.state = DialogState.RETURN_CERT_NOT_SELECTED;
  gClientAuthDialogService.rememberClientAuthCertificate = true;
  await testHelper(
    "Ask Every Time",
    "https://requireclientcert.example.com/",
    undefined,
    true
  );
  await testHelper(
    "Ask Every Time",
    "https://requireclientcert.example.com/",
    undefined,
    false
  );
  cars.clearRememberedDecisions();
});

// Test that if the user chooses a certificate in a private browsing window,
// configures Firefox to remember this certificate for the duration of the
// session, closes that window (and thus all private windows), reopens a private
// window, and visits that site again, they are re-asked for a certificate (i.e.
// any state from the previous private session should be gone). Similarly, after
// closing that private window, if the user opens a non-private window, they
// again should be asked to choose a certificate (i.e. private state should not
// be remembered/used in non-private contexts).
add_task(async function testClearPrivateBrowsingState() {
  gClientAuthDialogService.rememberClientAuthCertificate = true;
  gClientAuthDialogService.state = DialogState.RETURN_CERT_SELECTED;
  await testHelper(
    "Ask Every Time",
    "https://requireclientcert.example.com/",
    "https://requireclientcert.example.com/",
    true,
    {
      private: true,
    }
  );
  await testHelper(
    "Ask Every Time",
    "https://requireclientcert.example.com/",
    "https://requireclientcert.example.com/",
    true,
    {
      private: true,
    }
  );
  await testHelper(
    "Ask Every Time",
    "https://requireclientcert.example.com/",
    "https://requireclientcert.example.com/",
    true
  );
  // NB: we don't `cars.clearRememberedDecisions()` in between the two calls to
  // `testHelper` because that would clear all client auth certificate state and
  // obscure what we're testing (that Firefox properly clears the relevant state
  // when the last private window closes).
  cars.clearRememberedDecisions();
});

// Test that 3rd party certificates are taken into account when filtering client
// certificates based on the acceptible CA list sent by the server.
add_task(async function testCertFilteringWithIntermediate() {
  let intermediateBytes = await IOUtils.readUTF8(
    getTestFilePath("intermediate.pem")
  ).then(
    pem => {
      let base64 = pemToBase64(pem);
      let bin = atob(base64);
      let bytes = [];
      for (let i = 0; i < bin.length; i++) {
        bytes.push(bin.charCodeAt(i));
      }
      return bytes;
    },
    error => {
      throw error;
    }
  );
  let nssComponent = Cc["@mozilla.org/psm;1"].getService(Ci.nsINSSComponent);
  nssComponent.addEnterpriseIntermediate(intermediateBytes);
  gExpectedClientCertificateChoices = 4;
  gClientAuthDialogService.state = DialogState.RETURN_CERT_SELECTED;
  await testHelper(
    "Ask Every Time",
    "https://requireclientcert.example.com/",
    "https://requireclientcert.example.com/",
    true
  );
  cars.clearRememberedDecisions();
  // This will reset the added intermediate.
  await SpecialPowers.pushPrefEnv({
    set: [["security.enterprise_roots.enabled", true]],
  });
});

// Test that if the server certificate does not validate successfully,
// nsIClientAuthDialogService.chooseCertificate() is never called.
add_task(async function testNoDialogForUntrustedServerCertificate() {
  gClientAuthDialogService.state = DialogState.ASSERT_NOT_CALLED;
  await testHelper(
    "Ask Every Time",
    "https://requireclientcert-untrusted.example.com/",
    undefined,
    false
  );
  // This clears all saved client auth certificate state so we don't influence
  // subsequent tests.
  cars.clearRememberedDecisions();
});
