// -*- 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 nsIClientAuthDialogs.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 nsIClientAuthDialogs. const gClientAuthDialogs = { _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, port, organization, issuerOrg, certList, selectedIndex, rememberClientAuthCertificate ) { this.chooseCertificateCalled = true; Assert.notEqual( this.state, DialogState.ASSERT_NOT_CALLED, "chooseCertificate() should be called only when expected" ); rememberClientAuthCertificate.value = this.rememberClientAuthCertificate; Assert.equal( hostname, "requireclientcert.example.com", "Hostname should be 'requireclientcert.example.com'" ); Assert.equal(port, 443, "Port should be 443"); Assert.equal( organization, "", "Server cert Organization should be empty/not present" ); Assert.equal( issuerOrg, "Mozilla Testing", "Server cert issuer Organization should be 'Mozilla Testing'" ); // 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(certList, null, "Cert list should not be null"); Assert.equal( certList.length, gExpectedClientCertificateChoices, `${gExpectedClientCertificateChoices} certificates should be available` ); for (let cert of certList.enumerate(Ci.nsIX509Cert)) { 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) { selectedIndex.value = 0; return true; } return false; }, QueryInterface: ChromeUtils.generateQI(["nsIClientAuthDialogs"]), }; add_setup(async function () { let clientAuthDialogsCID = MockRegistrar.register( "@mozilla.org/nsClientAuthDialogs;1", gClientAuthDialogs ); registerCleanupFunction(() => { MockRegistrar.unregister(clientAuthDialogsCID); }); // 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 ) { gClientAuthDialogs.chooseCertificateCalled = false; await SpecialPowers.pushPrefEnv({ set: [["security.default_personal_cert", prefValue]], }); let win = await BrowserTestUtils.openNewBrowserWindow(options); BrowserTestUtils.loadURIString(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( gClientAuthDialogs.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 nsIClientAuthDialogs.chooseCertificate() is never called. add_task(async function testCertChosenAutomatically() { gClientAuthDialogs.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() { gClientAuthDialogs.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() { gClientAuthDialogs.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() { gClientAuthDialogs.state = DialogState.RETURN_CERT_NOT_SELECTED; gClientAuthDialogs.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() { gClientAuthDialogs.rememberClientAuthCertificate = true; gClientAuthDialogs.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; gClientAuthDialogs.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, // nsIClientAuthDialogs.chooseCertificate() is never called. add_task(async function testNoDialogForUntrustedServerCertificate() { gClientAuthDialogs.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(); });