browser_clientAuth_connection.js (13143B)
1 // -*- indent-tabs-mode: nil; js-indent-level: 2 -*- 2 // Any copyright is dedicated to the Public Domain. 3 // http://creativecommons.org/publicdomain/zero/1.0/ 4 "use strict"; 5 6 // Tests various scenarios connecting to a server that requires client cert 7 // authentication. Also tests that nsIClientAuthDialogService.chooseCertificate 8 // is called at the appropriate times and with the correct arguments. 9 10 const { MockRegistrar } = ChromeUtils.importESModule( 11 "resource://testing-common/MockRegistrar.sys.mjs" 12 ); 13 14 const DialogState = { 15 // Assert that chooseCertificate() is never called. 16 ASSERT_NOT_CALLED: "ASSERT_NOT_CALLED", 17 // Return that the user selected the first given cert. 18 RETURN_CERT_SELECTED: "RETURN_CERT_SELECTED", 19 // Return that the user canceled. 20 RETURN_CERT_NOT_SELECTED: "RETURN_CERT_NOT_SELECTED", 21 }; 22 23 var sdr = Cc["@mozilla.org/security/sdr;1"].getService(Ci.nsISecretDecoderRing); 24 let cars = Cc["@mozilla.org/security/clientAuthRememberService;1"].getService( 25 Ci.nsIClientAuthRememberService 26 ); 27 28 var gExpectedClientCertificateChoices; 29 30 // Mock implementation of nsIClientAuthDialogService. 31 const gClientAuthDialogService = { 32 _state: DialogState.ASSERT_NOT_CALLED, 33 _rememberClientAuthCertificate: false, 34 _chooseCertificateCalled: false, 35 36 set state(newState) { 37 info(`old state: ${this._state}`); 38 this._state = newState; 39 info(`new state: ${this._state}`); 40 }, 41 42 get state() { 43 return this._state; 44 }, 45 46 set rememberClientAuthCertificate(value) { 47 this._rememberClientAuthCertificate = value; 48 }, 49 50 get rememberClientAuthCertificate() { 51 return this._rememberClientAuthCertificate; 52 }, 53 54 get chooseCertificateCalled() { 55 return this._chooseCertificateCalled; 56 }, 57 58 set chooseCertificateCalled(value) { 59 this._chooseCertificateCalled = value; 60 }, 61 62 chooseCertificate(hostname, certArray, loadContext, caNames, callback) { 63 this.chooseCertificateCalled = true; 64 Assert.notEqual( 65 this.state, 66 DialogState.ASSERT_NOT_CALLED, 67 "chooseCertificate() should be called only when expected" 68 ); 69 Assert.equal( 70 hostname, 71 "requireclientcert.example.com", 72 "Hostname should be 'requireclientcert.example.com'" 73 ); 74 75 // For mochitests, the cert at build/pgo/certs/mochitest.client should be 76 // selectable as well as one of the PGO certs we loaded in `setup`, so we do 77 // some brief checks to confirm this. 78 Assert.notEqual(certArray, null, "Cert list should not be null"); 79 Assert.equal( 80 certArray.length, 81 gExpectedClientCertificateChoices, 82 `${gExpectedClientCertificateChoices} certificates should be available` 83 ); 84 85 for (let cert of certArray) { 86 Assert.notEqual(cert, null, "Cert list should contain nsIX509Certs"); 87 Assert.equal( 88 cert.issuerCommonName, 89 "Temporary Certificate Authority", 90 "cert should have expected issuer CN" 91 ); 92 } 93 94 if (this.state == DialogState.RETURN_CERT_SELECTED) { 95 callback.certificateChosen( 96 certArray[0], 97 this.rememberClientAuthCertificate 98 ); 99 } else { 100 callback.certificateChosen(null, this.rememberClientAuthCertificate); 101 } 102 }, 103 104 QueryInterface: ChromeUtils.generateQI(["nsIClientAuthDialogService"]), 105 }; 106 107 add_setup(async function () { 108 let clientAuthDialogServiceCID = MockRegistrar.register( 109 "@mozilla.org/security/ClientAuthDialogService;1", 110 gClientAuthDialogService 111 ); 112 registerCleanupFunction(() => { 113 MockRegistrar.unregister(clientAuthDialogServiceCID); 114 }); 115 116 // This CA has the expected keyCertSign and cRLSign usages. It should not be 117 // presented for use as a client certificate. 118 await readCertificate("pgo-ca-regular-usages.pem", "CTu,CTu,CTu"); 119 // This CA has all keyUsages. For compatibility with preexisting behavior, it 120 // will be presented for use as a client certificate. 121 await readCertificate("pgo-ca-all-usages.pem", "CTu,CTu,CTu"); 122 // This client certificate was issued by an intermediate that was issued by 123 // the test CA. The server only lists the test CA's subject distinguished name 124 // as an acceptible issuer name for client certificates. If the implementation 125 // can determine that the test CA is a root CA for the client certificate and 126 // thus is acceptible to use, it should be included in the chooseCertificate 127 // callback. At the beginning of this test (speaking of this file as a whole), 128 // the client is not aware of the intermediate, and so it is not available in 129 // the callback. 130 await readCertificate("client-cert-via-intermediate.pem", ",,"); 131 // This certificate has an id-kp-OCSPSigning EKU. Client certificates 132 // shouldn't have this EKU, but there is at least one private PKI where they 133 // do. For interoperability, such certificates will be presented for use. 134 await readCertificate("client-cert-with-ocsp-signing.pem", ",,"); 135 gExpectedClientCertificateChoices = 3; 136 }); 137 138 /** 139 * Test helper for the tests below. 140 * 141 * @param {string} prefValue 142 * Value to set the "security.default_personal_cert" pref to. 143 * @param {string} urlToNavigate 144 * The URL to navigate to. 145 * @param {string} expectedURL 146 * If the connection is expected to load successfully, the URL that 147 * should load. If the connection is expected to fail and result in an 148 * error page, |undefined|. 149 * @param {boolean} expectCallingChooseCertificate 150 * Determines whether we expect chooseCertificate to be called. 151 * @param {object} options 152 * Optional options object to pass on to the window that gets opened. 153 * @param {string} expectStringInPage 154 * Optional string that is expected to be in the content of the page 155 * once it loads. 156 */ 157 async function testHelper( 158 prefValue, 159 urlToNavigate, 160 expectedURL, 161 expectCallingChooseCertificate, 162 options = undefined, 163 expectStringInPage = undefined 164 ) { 165 gClientAuthDialogService.chooseCertificateCalled = false; 166 await SpecialPowers.pushPrefEnv({ 167 set: [["security.default_personal_cert", prefValue]], 168 }); 169 170 let win = await BrowserTestUtils.openNewBrowserWindow(options); 171 172 BrowserTestUtils.startLoadingURIString( 173 win.gBrowser.selectedBrowser, 174 urlToNavigate 175 ); 176 if (expectedURL) { 177 await BrowserTestUtils.browserLoaded( 178 win.gBrowser.selectedBrowser, 179 false, 180 "https://requireclientcert.example.com/", 181 true 182 ); 183 let loadedURL = win.gBrowser.selectedBrowser.documentURI.spec; 184 Assert.ok( 185 loadedURL.startsWith(expectedURL), 186 `Expected and actual URLs should match (got '${loadedURL}', expected '${expectedURL}')` 187 ); 188 } else { 189 await new Promise(resolve => { 190 let removeEventListener = BrowserTestUtils.addContentEventListener( 191 win.gBrowser.selectedBrowser, 192 "AboutNetErrorLoad", 193 () => { 194 removeEventListener(); 195 resolve(); 196 }, 197 { capture: false, wantUntrusted: true } 198 ); 199 }); 200 } 201 202 Assert.equal( 203 gClientAuthDialogService.chooseCertificateCalled, 204 expectCallingChooseCertificate, 205 "chooseCertificate should have been called if we were expecting it to be called" 206 ); 207 208 if (expectStringInPage) { 209 let pageContent = await SpecialPowers.spawn( 210 win.gBrowser.selectedBrowser, 211 [], 212 async function () { 213 return content.document.body.textContent; 214 } 215 ); 216 Assert.ok( 217 pageContent.includes(expectStringInPage), 218 `page should contain the string '${expectStringInPage}' (was '${pageContent}')` 219 ); 220 } 221 222 await win.close(); 223 224 // This clears the TLS session cache so we don't use a previously-established 225 // ticket to connect and bypass selecting a client auth certificate in 226 // subsequent tests. 227 sdr.logout(); 228 } 229 230 // Test that if a certificate is chosen automatically the connection succeeds, 231 // and that nsIClientAuthDialogService.chooseCertificate() is never called. 232 add_task(async function testCertChosenAutomatically() { 233 gClientAuthDialogService.state = DialogState.ASSERT_NOT_CALLED; 234 await testHelper( 235 "Select Automatically", 236 "https://requireclientcert.example.com/", 237 "https://requireclientcert.example.com/", 238 false 239 ); 240 // This clears all saved client auth certificate state so we don't influence 241 // subsequent tests. 242 cars.clearRememberedDecisions(); 243 }); 244 245 // Test that if the user doesn't choose a certificate, the connection fails and 246 // an error page is displayed. 247 add_task(async function testCertNotChosenByUser() { 248 gClientAuthDialogService.state = DialogState.RETURN_CERT_NOT_SELECTED; 249 await testHelper( 250 "Ask Every Time", 251 "https://requireclientcert.example.com/", 252 undefined, 253 true, 254 undefined, 255 // bug 1818556: ssltunnel doesn't behave as expected here on Windows 256 AppConstants.platform != "win" 257 ? "SSL_ERROR_RX_CERTIFICATE_REQUIRED_ALERT" 258 : undefined 259 ); 260 cars.clearRememberedDecisions(); 261 }); 262 263 // Test that if the user chooses a certificate the connection suceeeds. 264 add_task(async function testCertChosenByUser() { 265 gClientAuthDialogService.state = DialogState.RETURN_CERT_SELECTED; 266 await testHelper( 267 "Ask Every Time", 268 "https://requireclientcert.example.com/", 269 "https://requireclientcert.example.com/", 270 true 271 ); 272 cars.clearRememberedDecisions(); 273 }); 274 275 // Test that the cancel decision is remembered correctly 276 add_task(async function testEmptyCertChosenByUser() { 277 gClientAuthDialogService.state = DialogState.RETURN_CERT_NOT_SELECTED; 278 gClientAuthDialogService.rememberClientAuthCertificate = true; 279 await testHelper( 280 "Ask Every Time", 281 "https://requireclientcert.example.com/", 282 undefined, 283 true 284 ); 285 await testHelper( 286 "Ask Every Time", 287 "https://requireclientcert.example.com/", 288 undefined, 289 false 290 ); 291 cars.clearRememberedDecisions(); 292 }); 293 294 // Test that if the user chooses a certificate in a private browsing window, 295 // configures Firefox to remember this certificate for the duration of the 296 // session, closes that window (and thus all private windows), reopens a private 297 // window, and visits that site again, they are re-asked for a certificate (i.e. 298 // any state from the previous private session should be gone). Similarly, after 299 // closing that private window, if the user opens a non-private window, they 300 // again should be asked to choose a certificate (i.e. private state should not 301 // be remembered/used in non-private contexts). 302 add_task(async function testClearPrivateBrowsingState() { 303 gClientAuthDialogService.rememberClientAuthCertificate = true; 304 gClientAuthDialogService.state = DialogState.RETURN_CERT_SELECTED; 305 await testHelper( 306 "Ask Every Time", 307 "https://requireclientcert.example.com/", 308 "https://requireclientcert.example.com/", 309 true, 310 { 311 private: true, 312 } 313 ); 314 await testHelper( 315 "Ask Every Time", 316 "https://requireclientcert.example.com/", 317 "https://requireclientcert.example.com/", 318 true, 319 { 320 private: true, 321 } 322 ); 323 await testHelper( 324 "Ask Every Time", 325 "https://requireclientcert.example.com/", 326 "https://requireclientcert.example.com/", 327 true 328 ); 329 // NB: we don't `cars.clearRememberedDecisions()` in between the two calls to 330 // `testHelper` because that would clear all client auth certificate state and 331 // obscure what we're testing (that Firefox properly clears the relevant state 332 // when the last private window closes). 333 cars.clearRememberedDecisions(); 334 }); 335 336 // Test that 3rd party certificates are taken into account when filtering client 337 // certificates based on the acceptible CA list sent by the server. 338 add_task(async function testCertFilteringWithIntermediate() { 339 let intermediateBytes = await IOUtils.readUTF8( 340 getTestFilePath("intermediate.pem") 341 ).then( 342 pem => { 343 let base64 = pemToBase64(pem); 344 let bin = atob(base64); 345 let bytes = []; 346 for (let i = 0; i < bin.length; i++) { 347 bytes.push(bin.charCodeAt(i)); 348 } 349 return bytes; 350 }, 351 error => { 352 throw error; 353 } 354 ); 355 let nssComponent = Cc["@mozilla.org/psm;1"].getService(Ci.nsINSSComponent); 356 nssComponent.addEnterpriseIntermediate(intermediateBytes); 357 gExpectedClientCertificateChoices = 4; 358 gClientAuthDialogService.state = DialogState.RETURN_CERT_SELECTED; 359 await testHelper( 360 "Ask Every Time", 361 "https://requireclientcert.example.com/", 362 "https://requireclientcert.example.com/", 363 true 364 ); 365 cars.clearRememberedDecisions(); 366 // This will reset the added intermediate. 367 await SpecialPowers.pushPrefEnv({ 368 set: [["security.enterprise_roots.enabled", true]], 369 }); 370 }); 371 372 // Test that if the server certificate does not validate successfully, 373 // nsIClientAuthDialogService.chooseCertificate() is never called. 374 add_task(async function testNoDialogForUntrustedServerCertificate() { 375 gClientAuthDialogService.state = DialogState.ASSERT_NOT_CALLED; 376 await testHelper( 377 "Ask Every Time", 378 "https://requireclientcert-untrusted.example.com/", 379 undefined, 380 false 381 ); 382 // This clears all saved client auth certificate state so we don't influence 383 // subsequent tests. 384 cars.clearRememberedDecisions(); 385 });