commit 4e5864a4ee8e7ab48f55d3495836f508809a3657 parent 003e779a3113e13b7cdf1b70368d2089a4ac98c0 Author: Dana Keeler <dkeeler@mozilla.com> Date: Fri, 19 Dec 2025 20:12:19 +0000 Bug 2003902 - QWACs desktop ui r=jschanck,Gijs,fluent-reviewers,desktop-theme-reviewers,webidl,smaug,bolsson Differential Revision: https://phabricator.services.mozilla.com/D274988 Diffstat:
27 files changed, 679 insertions(+), 56 deletions(-)
diff --git a/browser/actors/TLSCertificateBindingChild.sys.mjs b/browser/actors/TLSCertificateBindingChild.sys.mjs @@ -0,0 +1,45 @@ +/* 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/. */ + +export class TLSCertificateBindingChild extends JSWindowActorChild { + #tlsCertificateBindingPromise = undefined; + + constructor() { + super(); + } + + async receiveMessage(message) { + if (message.name == "TLSCertificateBinding::Get") { + this.maybeFetchTLSCertificateBinding(); + return this.#tlsCertificateBindingPromise; + } + return undefined; + } + + maybeFetchTLSCertificateBinding() { + // `#tlsCertificateBindingPromise` will be undefined if we haven't yet + // attempted fetching for this document. + if (this.#tlsCertificateBindingPromise === undefined) { + this.#tlsCertificateBindingPromise = this.#fetchTLSCertificateBinding(); + } + } + + async #fetchTLSCertificateBinding() { + if (this.document.tlsCertificateBindingURI) { + try { + let response = await this.contentWindow.fetch( + this.document.tlsCertificateBindingURI.spec + ); + if (response.ok) { + return response.text(); + } + } catch (e) { + console.error("Fetching TLS certificate binding failed:", e); + } + } + // If there is no TLS certificate binding URI, or if fetching it failed, + // return null to indicate that an attempt to fetch it was made. + return null; + } +} diff --git a/browser/actors/moz.build b/browser/actors/moz.build @@ -80,6 +80,7 @@ FINAL_TARGET_FILES.actors += [ "SpeechDispatcherChild.sys.mjs", "SpeechDispatcherParent.sys.mjs", "SwitchDocumentDirectionChild.sys.mjs", + "TLSCertificateBindingChild.sys.mjs", "WebRTCChild.sys.mjs", "WebRTCParent.sys.mjs", ] diff --git a/browser/base/content/browser-siteIdentity.js b/browser/base/content/browser-siteIdentity.js @@ -4,6 +4,7 @@ ChromeUtils.defineESModuleGetters(this, { ExtensionUtils: "resource://gre/modules/ExtensionUtils.sys.mjs", + QWACs: "resource://gre/modules/psm/QWACs.sys.mjs", }); /** @@ -51,6 +52,18 @@ var gIdentityHandler = { _secInfo: null, /** + * If the document is using a QWAC, this may eventually be an nsIX509Cert + * corresponding to it. + */ + _qwac: null, + + /** + * Promise that will resolve when determining if the document is using a QWAC + * has resolved. + */ + _qwacStatusPromise: null, + + /** * Bitmask provided by nsIWebProgressListener.onSecurityChange. */ _state: 0, @@ -629,12 +642,11 @@ var gIdentityHandler = { }, /** - * Helper to parse out the important parts of _secInfo (of the SSL cert in - * particular) for use in constructing identity UI strings + * Helper to parse out the important parts of the given certificate for use + * in constructing identity UI strings. */ - getIdentityData() { + getIdentityData(cert = this._secInfo.serverCert) { var result = {}; - var cert = this._secInfo.serverCert; // Human readable name of Subject result.subjectOrg = cert.organization; @@ -699,7 +711,7 @@ var gIdentityHandler = { * processed by createExposableURI. */ updateIdentity(state, uri) { - let shouldHidePopup = this._uri && this._uri.spec != uri.spec; + let locationChanged = this._uri && this._uri.spec != uri.spec; this._state = state; // Firstly, populate the state properties required to display the UI. See @@ -707,12 +719,15 @@ var gIdentityHandler = { this.setURI(uri); this._secInfo = gBrowser.securityUI.secInfo; this._isSecureContext = this._getIsSecureContext(); - + if (locationChanged) { + this._qwac = null; + this._qwacStatusPromise = null; + } // Then, update the user interface with the available data. this.refreshIdentityBlock(); // Handle a location change while the Control Center is focused // by closing the popup (bug 1207542) - if (shouldHidePopup) { + if (locationChanged) { this.hidePopup(); gPermissionPanel.hidePopup(); } @@ -960,6 +975,43 @@ var gIdentityHandler = { }, /** + * Determines the string used to describe the connection security + * information. + */ + getConnectionSecurityInformation() { + if (this._isSecureInternalUI) { + return "chrome"; + } else if (this._pageExtensionPolicy) { + return "extension"; + } else if (this._isURILoadedFromFile) { + return "file"; + } else if (this._qwac) { + return "secure-etsi"; + } else if (this._isEV) { + return "secure-ev"; + } else if (this._isCertUserOverridden) { + return "secure-cert-user-overridden"; + } else if (this._isSecureConnection) { + return "secure"; + } else if (this._isCertErrorPage) { + return "cert-error-page"; + } else if (this._isAboutHttpsOnlyErrorPage) { + return "https-only-error-page"; + } else if (this._isAboutBlockedPage) { + return "not-secure"; + } else if (this._isSecurelyConnectedAboutNetErrorPage) { + return "secure"; + } else if (this._isAboutNetErrorPage) { + return "net-error-page"; + } else if (this._isAssociatedIdentity) { + return "associated"; + } else if (this._isPotentiallyTrustworthy) { + return "file"; + } + return "not-secure"; + }, + + /** * Set up the title and content messages for the identity message popup, * based on the specified mode, and the details of the SSL cert, where * applicable @@ -988,34 +1040,9 @@ var gIdentityHandler = { let customRoot = false; // Determine connection security information. - let connection = "not-secure"; - if (this._isSecureInternalUI) { - connection = "chrome"; - } else if (this._pageExtensionPolicy) { - connection = "extension"; - } else if (this._isURILoadedFromFile) { - connection = "file"; - } else if (this._isEV) { - connection = "secure-ev"; - } else if (this._isCertUserOverridden) { - connection = "secure-cert-user-overridden"; - } else if (this._isSecureConnection) { - connection = "secure"; + let connection = this.getConnectionSecurityInformation(); + if (this._isSecureConnection) { customRoot = this._hasCustomRoot(); - } else if (this._isCertErrorPage) { - connection = "cert-error-page"; - } else if (this._isAboutHttpsOnlyErrorPage) { - connection = "https-only-error-page"; - } else if (this._isAboutBlockedPage) { - connection = "not-secure"; - } else if (this._isSecurelyConnectedAboutNetErrorPage) { - connection = "secure"; - } else if (this._isAboutNetErrorPage) { - connection = "net-error-page"; - } else if (this._isAssociatedIdentity) { - connection = "associated"; - } else if (this._isPotentiallyTrustworthy) { - connection = "file"; } let securityButtonNode = document.getElementById( @@ -1025,6 +1052,7 @@ var gIdentityHandler = { let disableSecurityButton = ![ "not-secure", "secure", + "secure-etsi", "secure-ev", "secure-cert-user-overridden", "cert-error-page", @@ -1137,9 +1165,10 @@ var gIdentityHandler = { verifier = this._identityIconLabel.tooltipText; } - // Fill in organization information if we have a valid EV certificate. - if (this._isEV) { - let iData = this.getIdentityData(); + // Fill in organization information if we have a valid EV certificate or + // QWAC. + if (this._isEV || this._qwac) { + let iData = this.getIdentityData(this._qwac || this._secInfo.serverCert); owner = iData.subjectOrg; verifier = this._identityIconLabel.tooltipText; @@ -1253,6 +1282,23 @@ var gIdentityHandler = { // Make the popup available. this._initializePopup(); + // Kick off background determination of QWAC status. + if (this._isSecureContext && !this._qwacStatusPromise) { + let qwacStatusPromise = QWACs.determineQWACStatus( + this._secInfo, + this._uri, + gBrowser.selectedBrowser.browsingContext + ).then(result => { + // Check that when this promise resolves, we're still on the same + // document as when it was created. + if (qwacStatusPromise == this._qwacStatusPromise && result) { + this._qwac = result; + this.refreshIdentityPopup(); + } + }); + this._qwacStatusPromise = qwacStatusPromise; + } + // Update the popup strings this.refreshIdentityPopup(); diff --git a/browser/base/content/test/siteIdentity/2-qwac.binding b/browser/base/content/test/siteIdentity/2-qwac.binding @@ -0,0 +1 @@ +eyJhbGciOiAiUlMyNTYiLCAiY3R5IjogIlRMUy1DZXJ0aWZpY2F0ZS1CaW5kaW5nLXYxIiwgIng1YyI6IFsiTUlJRGlUQ0NBbkdnQXdJQkFnSVVOZDNGQlErNGpJMUpCNW54VDdEM2RXdWZ4dTR3RFFZSktvWklodmNOQVFFTEJRQXdFakVRTUE0R0ExVUVBd3dIVkdWemRDQkRRVEFpR0E4eU1ESXpNVEV5T0RBd01EQXdNRm9ZRHpJd01qWXdNakExTURBd01EQXdXakJnTVE4d0RRWURWUVFERXdZeUxWRlhRVU14SVRBZkJnTlZCQW9UR0ZSbGMzUWdNaTFSVjBGRElFOXlaMkZ1YVhwaGRHbHZiakVkTUJzR0ExVUVCeE1VTWkxUlYwRkRJRlJsYzNRZ1RHOWpZV3hwZEhreEN6QUpCZ05WQkFZVEFrVllNSUlCSWpBTkJna3Foa2lHOXcwQkFRRUZBQU9DQVE4QU1JSUJDZ0tDQVFFQXVvaFJxRVNPRnRaQi9XNjJpQVkyRUQwOEU5bnE1RFZLdE96MWFGZHNKSHZCeHlXbzROZ2Z2YkdjQnB0dUdvYnlhK0t2V25WcmFtUnhDSHFsV3FkRmgvY2MxU1NjQW43TlEvd2VhZEE0SUNtVHF5RERTZVRidVV6Q2Eyd083UldDRC9GK3JXa2FzZE1DT29zcVFlNm5jT0FQRFkzOVpnc3JzQ1NTcEgyNWlHRjVrTEZYa0QzU084WGd1RWdmcURmVGlFUHZKeGJZVmJkbVdxcCtBcEF2T25zUWdBWWt6QnhzbDYyV1lWdTM0cFlTd0hVeG93eVIzYlRLOS95dEhTWFRDZSs1Rnc2bmFPR3pleThpYjJuanRJcVZZUjN1SnRZbG5hdVJDRTQyeXh3a0JDeS9Gb3N2NWZHUG1SY3h1TFArU1NQNmNsSEVNZFVEck5vWUNqWHRqUUlEQVFBQm80R0VNSUdCTUMwR0NDc0dBUVVGQndFREJDRXdIekFJQmdZRUFJNUdBUUV3RXdZR0JBQ09SZ0VHTUFrR0J3UUFqa1lCQmdNd0ZBWURWUjBnQkEwd0N6QUpCZ2NFQUl2c1FBRUdNQklHQTFVZEpRUUxNQWtHQndRQWkreERBUUF3SmdZRFZSMFJCQjh3SFlJYlltOTFibVF0WW5rdE1pMXhkMkZqTG1WNFlXMXdiR1V1WTI5dE1BMEdDU3FHU0liM0RRRUJDd1VBQTRJQkFRQ1VvSmJDT1daR2hJdlVnSG9IdzR0U2l1dEZmME9FUHFpQ0xTS2ZaeCtHclV0RUtSaUxzb2JLcnVKdUt2ellIem1ialV0eHNzWExHWHZZc2htTzYwelNSdldUc0JaNk1Cd2x1UlJTWk93Ui81c2RRMk9mU3NLd2tYVlNRQXphSkdJRTB6RUlBSlpNeVo4amw4OHZ1NW1DU2xvbitHblIrcmNHbTBYQS9JTTlZdjczNDV5dWMvelViZjB1RjRSUzMyZWdhNWdVZkx1ck1jQlc3eExtM3ptajJuZ2x2ekFFVUZSbzlyQWV2SFdYUjlSOFVGN1pBa1QxTUdxZ0Y5SlpGa2hKOEdYWFE1Sys0NjJkcG9yRXBUUjVXSGFCcVRQZGw2NVpzNHd5cjg0VkxIMkFVZTkvbTQvRThmQ2hyU0w3MjhxN1RrUE0zNEt6VDRjYjF5bnFIWS9GIl0sICJzaWdEIjogeyJtSWQiOiAiaHR0cDovL3VyaS5ldHNpLm9yZy8xOTE4Mi9PYmplY3RJZEJ5VVJJSGFzaCIsICJwYXJzIjogWyIiXSwgImhhc2hNIjogIlMyNTYiLCAiaGFzaFYiOiBbIlB0LVRpUWdRcGM1WmF3SEtEbzNFOHV2Z0Z4VVl5bUozVEVqZEkyTkRiT1kiXX19..iFp_7dWGzj65p3WSbeLJMdvX4kYJQsPQ1ShsnHFxu0OXFihoQsRAWLXsW4ra3EqK1JAmlhWYp-QV3MxS0lAC2C2LQbf7-2c4U6f-5e_JxOv_mJmtDE2aNjMLLYRvH0FpJNyu0prOd7IH9xJiYE0t_zmG_Icr5Je8DKKLLTIA4DWVmXUwNu8SJooF3CWjsMg0J7Sil-UWrrbko9g8yCaHSRmldcjvEfB86DXIQrRGkdYRMP763eF8VSWwECF9PCRO5zrOT1rrSsskjRNqRadhXrdk7PieVr2q4Dn2cOpu_pwjFGoNAxvoq87AyqDbINiKuaPwR8kuuBtq1GvPlSccHw +\ No newline at end of file diff --git a/browser/base/content/test/siteIdentity/2-qwac.binding.bindingspec b/browser/base/content/test/siteIdentity/2-qwac.binding.bindingspec @@ -0,0 +1,13 @@ +signingCertificate: +issuer:Test CA +subject:printableString/CN=2-QWAC/O=Test 2-QWAC Organization/L=2-QWAC Test Locality/C=EX +extension:qcStatements:0.4.0.1862.1.1,0.4.0.1862.1.6:0.4.0.1862.1.6.3 +extension:certificatePolicies:0.4.0.194112.1.6 +extension:extKeyUsage:tlsBinding +extension:subjectAlternativeName:bound-by-2-qwac.example.com +:end +certificateToBind: +subject:Bound By 2-QWAC +issuer:printableString/CN=Temporary Certificate Authority/O=Mozilla Testing/OU=Profile Guided Optimization +extension:subjectAlternativeName:bound-by-2-qwac.example.com +:end diff --git a/browser/base/content/test/siteIdentity/2-qwac.html b/browser/base/content/test/siteIdentity/2-qwac.html @@ -0,0 +1,10 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8"> + <title>2-QWACs</title> + </head> + <body> + <h1>This page sends a TLS certificate binding in a link header. It should verify as a 2-QWAC.</h1> + </body> +</html> diff --git a/browser/base/content/test/siteIdentity/2-qwac.html^headers^ b/browser/base/content/test/siteIdentity/2-qwac.html^headers^ @@ -0,0 +1 @@ +Link: <2-qwac.binding>; rel=tls-certificate-binding diff --git a/browser/base/content/test/siteIdentity/browser.toml b/browser/base/content/test/siteIdentity/browser.toml @@ -1,5 +1,8 @@ [DEFAULT] support-files = [ + "2-qwac.binding", + "2-qwac.html", + "2-qwac.html^headers^", "head.js", "dummy_page.html", "!/image/test/mochitest/blue.png", @@ -74,6 +77,8 @@ skip-if = [ "os == 'linux' && os_version == '24.04' && arch == 'x86_64' && display == 'x11' && asan", # Bug 1723899 ] +["browser_identityPopup_qwacs.js"] + ["browser_identity_UI.js"] https_first_disabled = true diff --git a/browser/base/content/test/siteIdentity/browser_identityPopup_qwacs.js b/browser/base/content/test/siteIdentity/browser_identityPopup_qwacs.js @@ -0,0 +1,205 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* Test that the QWACs indicator appears as appropriate in the site identity + * drop-down. */ + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["security.qwacs.enable_test_trust_anchors", true]], + }); +}); + +add_task(async function test_1_qwac() { + await BrowserTestUtils.withNewTab( + "https://1-qwac.example.com", + async function () { + let promisePanelOpen = BrowserTestUtils.waitForEvent( + window, + "popupshown", + true, + event => event.target == gIdentityHandler._identityPopup + ); + + gIdentityHandler._identityIconBox.click(); + await promisePanelOpen; + + // Wait for the QWAC status to be determined. + await gIdentityHandler._qwacStatusPromise; + + let securityView = document.getElementById("identity-popup-securityView"); + let shown = BrowserTestUtils.waitForEvent(securityView, "ViewShown"); + document.getElementById("identity-popup-security-button").click(); + await shown; + + let qualifiedText = document.getElementById( + "identity-popup-content-etsi" + ); + ok( + BrowserTestUtils.isVisible(qualifiedText), + "etsi qualified text visible" + ); + + let issuedToLabel = document.getElementById( + "identity-popup-content-owner-label" + ); + ok( + BrowserTestUtils.isVisible(issuedToLabel), + "'Certificate issued to:' label visible" + ); + + let qwacOrganization = document.getElementById( + "identity-popup-content-owner" + ); + ok( + BrowserTestUtils.isVisible(qwacOrganization), + "QWAC organization text visible" + ); + is( + qwacOrganization.textContent, + "Test 1-QWAC Organization", + "QWAC organization text as expected" + ); + + let qwacLocation = document.getElementById( + "identity-popup-content-supplemental" + ); + ok( + BrowserTestUtils.isVisible(qwacLocation), + "QWAC location text visible" + ); + is( + qwacLocation.textContent, + "1-QWAC Test Locality\nEX", + "QWAC location text as expected" + ); + } + ); +}); + +add_task(async function test_2_qwac() { + let boundBy2QwacUri = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://bound-by-2-qwac.example.com" + ) + "2-qwac.html"; + + await BrowserTestUtils.withNewTab(boundBy2QwacUri, async function () { + let promisePanelOpen = BrowserTestUtils.waitForEvent( + window, + "popupshown", + true, + event => event.target == gIdentityHandler._identityPopup + ); + + gIdentityHandler._identityIconBox.click(); + await promisePanelOpen; + + // Wait for the QWAC status to be determined. + await gIdentityHandler._qwacStatusPromise; + + let securityView = document.getElementById("identity-popup-securityView"); + let shown = BrowserTestUtils.waitForEvent(securityView, "ViewShown"); + document.getElementById("identity-popup-security-button").click(); + await shown; + + let qualifiedText = document.getElementById("identity-popup-content-etsi"); + ok( + BrowserTestUtils.isVisible(qualifiedText), + "etsi qualified text visible" + ); + + let issuedToLabel = document.getElementById( + "identity-popup-content-owner-label" + ); + ok( + BrowserTestUtils.isVisible(issuedToLabel), + "'Certificate issued to:' label visible" + ); + + let qwacOrganization = document.getElementById( + "identity-popup-content-owner" + ); + ok( + BrowserTestUtils.isVisible(qwacOrganization), + "QWAC organization text visible" + ); + is( + qwacOrganization.textContent, + "Test 2-QWAC Organization", + "QWAC organization text as expected" + ); + + let qwacLocation = document.getElementById( + "identity-popup-content-supplemental" + ); + ok(BrowserTestUtils.isVisible(qwacLocation), "QWAC location text visible"); + is( + qwacLocation.textContent, + "2-QWAC Test Locality\nEX", + "QWAC location text as expected" + ); + }); +}); + +// Also check that there are conditions where this isn't shown. +add_task(async function test_non_qwac() { + let uris = [ + "https://example.com", + "https://example.com", + "data:,Hello%2C World!", + ]; + for (let uri of uris) { + await BrowserTestUtils.withNewTab(uri, async function () { + let promisePanelOpen = BrowserTestUtils.waitForEvent( + window, + "popupshown", + true, + event => event.target == gIdentityHandler._identityPopup + ); + + gIdentityHandler._identityIconBox.click(); + await promisePanelOpen; + + // Wait for the QWAC status to be determined. + await gIdentityHandler._qwacStatusPromise; + + let securityView = document.getElementById("identity-popup-securityView"); + let shown = BrowserTestUtils.waitForEvent(securityView, "ViewShown"); + document.getElementById("identity-popup-security-button").click(); + await shown; + + let qualifiedText = document.getElementById( + "identity-popup-content-etsi" + ); + ok( + !BrowserTestUtils.isVisible(qualifiedText), + "etsi qualified text not visible" + ); + + let issuedToLabel = document.getElementById( + "identity-popup-content-owner-label" + ); + ok( + !BrowserTestUtils.isVisible(issuedToLabel), + "'Certificate issued to:' label not visible" + ); + + let qwacOrganization = document.getElementById( + "identity-popup-content-owner" + ); + ok( + !BrowserTestUtils.isVisible(qwacOrganization), + "QWAC organization text not visible" + ); + + let qwacLocation = document.getElementById( + "identity-popup-content-supplemental" + ); + ok( + !BrowserTestUtils.isVisible(qwacLocation), + "QWAC location text not visible" + ); + }); + } +}); diff --git a/browser/components/DesktopActorRegistry.sys.mjs b/browser/components/DesktopActorRegistry.sys.mjs @@ -773,6 +773,14 @@ let JSWINDOWACTORS = { allFrames: true, }, + TLSCertificateBinding: { + child: { + esModuleURI: "resource:///actors/TLSCertificateBindingChild.sys.mjs", + }, + + messageManagerGroups: ["browsers"], + }, + UITour: { parent: { esModuleURI: "moz-src:///browser/components/uitour/UITourParent.sys.mjs", diff --git a/browser/components/controlcenter/content/identityPanel.inc.xhtml b/browser/components/controlcenter/content/identityPanel.inc.xhtml @@ -34,7 +34,7 @@ <label class="identity-popup-connection-not-secure" when-connection="not-secure secure-cert-user-overridden secure-custom-root cert-error-page https-only-error-page" data-l10n-id="identity-connection-not-secure"></label> <label class="identity-popup-connection-secure" - when-connection="secure secure-ev" data-l10n-id="identity-connection-secure"></label> + when-connection="secure secure-ev secure-etsi" data-l10n-id="identity-connection-secure"></label> <label class="identity-popup-connection-failure" when-connection="net-error-page" data-l10n-id="identity-connection-failure"></label> <label when-connection="chrome" data-l10n-id="identity-connection-internal"></label> diff --git a/browser/components/controlcenter/content/securityInformation.inc.xhtml b/browser/components/controlcenter/content/securityInformation.inc.xhtml @@ -8,7 +8,7 @@ <description class="identity-popup-connection-not-secure security-view" when-connection="not-secure secure-cert-user-overridden cert-error-page net-error-page https-only-error-page" data-l10n-id="identity-connection-not-secure-security-view"></description> <description class="identity-popup-connection-secure security-view" - when-connection="secure secure-ev" data-l10n-id="identity-connection-verified"></description> + when-connection="secure secure-ev secure-etsi" data-l10n-id="identity-connection-verified"></description> <!-- These descriptions are shown on a seperate subview when trustpanel is disabled and are only visible here under the trustpanel --> <box class="only-trustpanel"> @@ -25,14 +25,17 @@ <vbox id="identity-popup-securityView-extended-info" class="identity-popup-section"> <!-- (EV) Certificate Information --> <description id="identity-popup-content-owner-label" - when-connection="secure-ev" data-l10n-id="identity-ev-owner-label"></description> + when-connection="secure-ev secure-etsi" data-l10n-id="identity-ev-owner-label"></description> <description id="identity-popup-content-owner" - when-connection="secure-ev" + when-connection="secure-ev secure-etsi" class="header"/> <description id="identity-popup-content-supplemental" - when-connection="secure-ev"/> + when-connection="secure-ev secure-etsi"/> <description id="identity-popup-content-verifier" - when-connection="secure secure-ev secure-cert-user-overridden"/> + when-connection="secure secure-ev secure-etsi secure-cert-user-overridden"/> + <description id="identity-popup-content-etsi" + when-connection="secure-etsi" + data-l10n-id="identity-etsi"/> <description id="identity-popup-content-verifier-unknown" class="identity-popup-warning-box identity-popup-warning-gray" when-customroot="true"> diff --git a/browser/locales/en-US/browser/browser.ftl b/browser/locales/en-US/browser/browser.ftl @@ -433,6 +433,8 @@ identity-clear-site-data = identity-connection-not-secure-security-view = You are not securely connected to this site. identity-connection-verified = You are securely connected to this site. identity-ev-owner-label = Certificate issued to: +# "qualified" here refers to the qualified website authentication certificate presented by the site. +identity-etsi = Qualified as specified in Regulation (EU) 2024/1183. identity-description-custom-root2 = Mozilla does not recognize this certificate issuer. It may have been added from your operating system or by an administrator. identity-remove-cert-exception = .label = Remove Exception diff --git a/browser/themes/shared/controlcenter/panel.css b/browser/themes/shared/controlcenter/panel.css @@ -49,6 +49,7 @@ .site-information-popup[connection=not-secure] [when-connection~=not-secure], .site-information-popup[connection=secure-cert-user-overridden] [when-connection~=secure-cert-user-overridden], .site-information-popup[connection=secure-ev] [when-connection~=secure-ev], +.site-information-popup[connection=secure-etsi] [when-connection~=secure-etsi], .site-information-popup[connection=secure] [when-connection~=secure], .site-information-popup[connection=chrome] [when-connection~=chrome], .site-information-popup[connection=file] [when-connection~=file], diff --git a/build/pgo/certs/1-qwac.certspec b/build/pgo/certs/1-qwac.certspec @@ -0,0 +1,5 @@ +issuer:printableString/CN=Temporary Certificate Authority/O=Mozilla Testing/OU=Profile Guided Optimization +subject:printableString/CN=1-QWAC/O=Test 1-QWAC Organization/L=1-QWAC Test Locality/C=EX +extension:qcStatements:0.4.0.1862.1.1,0.4.0.1862.1.6:0.4.0.1862.1.6.3 +extension:certificatePolicies:0.4.0.194112.1.5 +extension:subjectAlternativeName:1-qwac.example.com diff --git a/build/pgo/certs/bound-by-2-qwac.certspec b/build/pgo/certs/bound-by-2-qwac.certspec @@ -0,0 +1,3 @@ +subject:Bound By 2-QWAC +issuer:printableString/CN=Temporary Certificate Authority/O=Mozilla Testing/OU=Profile Guided Optimization +extension:subjectAlternativeName:bound-by-2-qwac.example.com diff --git a/build/pgo/certs/cert9.db b/build/pgo/certs/cert9.db Binary files differ. diff --git a/build/pgo/certs/key4.db b/build/pgo/certs/key4.db Binary files differ. diff --git a/build/pgo/certs/mochitest.client b/build/pgo/certs/mochitest.client Binary files differ. diff --git a/build/pgo/server-locations.txt b/build/pgo/server-locations.txt @@ -418,3 +418,6 @@ https://api.profiler.firefox.com:443 # FirefoxRelay test country and PSL-specific deny- and allow-list matching http://accounts.example.com.ar:80 privileged https://accounts.example.com.ar:443 privileged + +https://1-qwac.example.com:443 privileged,cert=1-qwac +https://bound-by-2-qwac.example.com:443 privileged,cert=bound-by-2-qwac diff --git a/dom/base/Document.cpp b/dom/base/Document.cpp @@ -3743,6 +3743,9 @@ nsresult Document::StartDocumentLoad(const char* aCommand, nsIChannel* aChannel, rv = InitFeaturePolicy(aChannel); NS_ENSURE_SUCCESS(rv, rv); + rv = InitTLSCertificateBinding(aChannel); + NS_ENSURE_SUCCESS(rv, rv); + rv = loadInfo->GetCookieJarSettings(getter_AddRefs(mCookieJarSettings)); NS_ENSURE_SUCCESS(rv, rv); @@ -4082,6 +4085,46 @@ nsresult Document::InitIntegrityPolicy(nsIChannel* aChannel) { return NS_OK; } +nsresult Document::InitTLSCertificateBinding(nsIChannel* aChannel) { + mTLSCertificateBindingURI = nullptr; + nsCOMPtr<nsIHttpChannel> httpChannel; + nsresult rv = GetHttpChannelHelper(aChannel, getter_AddRefs(httpChannel)); + if (NS_WARN_IF(NS_FAILED(rv))) { + return rv; + } + + if (!httpChannel) { + return NS_OK; + } + + nsAutoCString linkHeader; + rv = httpChannel->GetResponseHeader("link"_ns, linkHeader); + if (NS_FAILED(rv) || linkHeader.IsEmpty()) { + return NS_OK; + } + nsTArray<LinkHeader> linkHeaders( + ParseLinkHeader(NS_ConvertUTF8toUTF16(linkHeader))); + for (const auto& linkHeader : linkHeaders) { + // According to ETSI TS 119 411-5 V2.1.1 Section 5.2, "When using a 2-QWAC, + // website operators shall... Configure their website to serve... an HTTP + // 'Link' response header (as defined in IETF RFC 8288 [6]) with a relative + // reference to the TLS Certificate Binding, and a rel value of + // tls-certificate-binding". + if (linkHeader.mRel.EqualsIgnoreCase("tls-certificate-binding") && + !net_IsAbsoluteURL(NS_ConvertUTF16toUTF8(linkHeader.mHref)) && + !net_IsAbsoluteURL(NS_ConvertUTF16toUTF8(linkHeader.mAnchor))) { + if (NS_SUCCEEDED(linkHeader.NewResolveHref( + getter_AddRefs(mTLSCertificateBindingURI), mDocumentURI))) { + break; + } else { + mTLSCertificateBindingURI = nullptr; + } + } + } + + return NS_OK; +} + static FeaturePolicy* GetFeaturePolicyFromElement(Element* aElement) { if (auto* iframe = HTMLIFrameElement::FromNodeOrNull(aElement)) { return iframe->FeaturePolicy(); diff --git a/dom/base/Document.h b/dom/base/Document.h @@ -1610,6 +1610,7 @@ class Document : public nsINode, nsresult InitIntegrityPolicy(nsIChannel* aChannel); nsresult InitCOEP(nsIChannel* aChannel); nsresult InitDocPolicy(nsIChannel* aChannel); + nsresult InitTLSCertificateBinding(nsIChannel* aChannel); nsresult InitReferrerInfo(nsIChannel* aChannel); @@ -5769,6 +5770,8 @@ class Document : public nsINode, RefPtr<class FragmentDirective> mFragmentDirective; UniquePtr<RadioGroupContainer> mRadioGroupContainer; + nsCOMPtr<nsIURI> mTLSCertificateBindingURI; + public: // Needs to be public because the bindings code pokes at it. JS::ExpandoAndGeneration mExpandoAndGeneration; @@ -5791,6 +5794,10 @@ class Document : public nsINode, const nsAString& aHTML, const SetHTMLOptions& aOptions, ErrorResult& aError); + + nsIURI* GetTlsCertificateBindingURI() const { + return mTLSCertificateBindingURI; + } }; enum class SyncOperationBehavior { eSuspendInput, eAllowInput }; diff --git a/dom/webidl/Document.webidl b/dom/webidl/Document.webidl @@ -596,6 +596,10 @@ partial interface Document { }; partial interface Document { + [ChromeOnly] readonly attribute URI? tlsCertificateBindingURI; +}; + +partial interface Document { [Func="Document::DocumentSupportsL10n"] readonly attribute DocumentL10n? l10n; [Func="Document::DocumentSupportsL10n"] readonly attribute boolean hasPendingL10nMutations; }; diff --git a/security/manager/ssl/QWACs.sys.mjs b/security/manager/ssl/QWACs.sys.mjs @@ -290,6 +290,7 @@ export var QWACs = { // given hostname that chains to a QWAC trust anchor, verifies the signature // on the binding, and finally verifies that the binding covers the server // certificate. + // Returns the QWAC upon success, and null otherwise. async verifyTLSCertificateBinding( tlsCertificateBinding, serverCertificate, @@ -305,22 +306,22 @@ export var QWACs = { let parts = tlsCertificateBinding.split("."); if (parts.length != 3) { console.error("invalid TLS certificate binding"); - return false; + return null; } if (parts[1] != "") { console.error("TLS certificate binding must have empty payload"); - return false; + return null; } let header; try { header = JSON.parse(QWACs.fromBase64URLEncoding(parts[0])); } catch (e) { console.error("header is not base64(JSON)"); - return false; + return null; } let params = QWACs.validateTLSCertificateBindingHeader(header); if (!params) { - return false; + return null; } // The 0th certificate signed the binding. It must be a 2-QWAC that is @@ -337,7 +338,7 @@ export var QWACs = { )) ) { console.error("signing certificate not 2-QWAC"); - return false; + return null; } let spki = signingCertificate.subjectPublicKeyInfo; @@ -352,7 +353,7 @@ export var QWACs = { ); } catch (e) { console.error("invalid signing key (algorithm mismatch?)"); - return false; + return null; } let signature; @@ -360,7 +361,7 @@ export var QWACs = { signature = QWACs.fromBase64URLEncoding(parts[2]); } catch (e) { console.error("signature is not base64"); - return false; + return null; } // Validate the signature (Step 5). @@ -374,11 +375,11 @@ export var QWACs = { ); } catch (e) { console.error("failed to verify signature"); - return false; + return null; } if (!signatureValid) { console.error("invalid signature"); - return false; + return null; } // The binding must list the server certificate's hash (Step 6). @@ -396,8 +397,73 @@ export var QWACs = { ) ) { console.error("TLS binding does not cover server certificate"); - return false; + return null; } - return true; + return signingCertificate; + }, + + /** + * Asynchronously determines the QWAC status of a document. + * + * @param secInfo {nsITransportSecurityInfo} + * The security information for the connection of the document. + * @param uri {nsIURI} + * The URI of the document. + * @param browsingContext {BrowsingContext} + * The browsing context of the load of the document. + * @returns {Promise} + * A promise that will resolve to an nsIX509Cert representing the QWAC in + * use, if any, and null otherwise. + */ + async determineQWACStatus(secInfo, uri, browsingContext) { + if (!secInfo || !secInfo.serverCert) { + return null; + } + + // For some URIs, getting `host` will throw. ETSI TS 119 411-5 V2.1.1 only + // mentions domain names, so the assumed intention in such cases is to + // determine that the document is not using a QWAC. + let hostname; + try { + hostname = uri.host; + } catch { + return null; + } + + let windowGlobal = browsingContext.currentWindowGlobal; + let actor = windowGlobal.getActor("TLSCertificateBinding"); + let tlsCertificateBinding = null; + try { + tlsCertificateBinding = await actor.sendQuery( + "TLSCertificateBinding::Get" + ); + } catch { + // If the page is closed before the query resolves, the actor will be + // destroyed, which causes a JS exception. We can safely ignore it, + // because the page is going away. + return null; + } + if (tlsCertificateBinding) { + let twoQwac = await QWACs.verifyTLSCertificateBinding( + tlsCertificateBinding, + secInfo.serverCert, + hostname + ); + if (twoQwac) { + return twoQwac; + } + } + + let is1qwac = await lazy.CertDB.asyncVerifyQWAC( + Ci.nsIX509CertDB.OneQWAC, + secInfo.serverCert, + hostname, + secInfo.handshakeCertificates.concat(secInfo.succeededCertChain) + ); + if (is1qwac) { + return secInfo.serverCert; + } + + return null; }, }; diff --git a/security/manager/tools/mach_commands.py b/security/manager/tools/mach_commands.py @@ -53,6 +53,13 @@ def is_sctspec_file(filename): return filename.endswith(".sctspec") +def is_bindingspec_file(filename): + """Returns True if the given filename is a TLS certificate + binding specification file (.bindingspec) and False + otherwise.""" + return filename.endswith(".bindingspec") + + def is_specification_file(filename): """Returns True if the given filename is a specification file supported by this script, and False otherewise.""" @@ -61,6 +68,7 @@ def is_specification_file(filename): or is_keyspec_file(filename) or is_pkcs12spec_file(filename) or is_sctspec_file(filename) + or is_bindingspec_file(filename) ) @@ -80,6 +88,7 @@ def generate_test_certs(command_context, specifications): import pyct import pykey import pypkcs12 + import pytlsbinding if not specifications: specifications = find_all_specifications(command_context) @@ -96,9 +105,11 @@ def generate_test_certs(command_context, specifications): elif is_sctspec_file(specification): module = pyct output_is_binary = True + elif is_bindingspec_file(specification): + module = pytlsbinding else: raise UserError( - f"'{specification}' is not a .certspec, .keyspec, or .pkcs12spec file" + f"'{specification}' is not a .certspec, .keyspec, .pkcs12spec, or .bindingspec file" ) run_module_main_on(module, os.path.abspath(specification), output_is_binary) return 0 @@ -109,6 +120,7 @@ def find_all_specifications(command_context): and returns them as a list.""" specifications = [] inclusions = [ + "browser/base/content/test/siteIdentity/", "netwerk/test/unit", "security/manager/ssl/tests", "services/settings/test/unit/test_remote_settings_signatures", diff --git a/security/manager/tools/pytlsbinding.py b/security/manager/tools/pytlsbinding.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python +# +# 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/. + +""" +Helper library for creating a 2-QWACs TLS certificate binding given the details +of a signing certificate and a certificate to bind. See ETSI TS 119 411-5 +V2.1.1 Annex B. + +When run with an output file-like object and a path to a file containing +a specification, creates a TLS certificate binding from the given information +and writes it to the output object. The specification is as follows: + +signingCertificate: +<certificate specification> +:end +certificateToBind: +<certificate specification> +:end + +Where: + <> indicates a required component of a field + ":end" indicates the end of a multi-line specification + +Currently only the algorithms RS256 (RSA PKCS#1v1.5 with SHA-256) and S256 +(SHA-256) are supported. +""" + + +import base64 +import hashlib +import json +from io import StringIO + +import pycert +import pykey + + +def urlsafebase64(b): + """Helper function that takes a bytes-like object and returns the + urlsafebase64-encoded bytes without any trailing '='.""" + return base64.urlsafe_b64encode(b).decode().replace("=", "").encode("utf-8") + + +class Header: + """Class representing a 2-QWACs TLS certificate binding header.""" + + def __init__(self, signingCertificate, certificateToBind): + self.signingCertificate = signingCertificate + self.certificateToBind = certificateToBind + + def __str__(self): + signingCertificateBase64 = base64.standard_b64encode( + self.signingCertificate.toDER() + ).decode() + certificateToBindDER = self.certificateToBind.toDER() + certificateToBindBase64Urlsafe = urlsafebase64(certificateToBindDER) + certificateToBindHash = urlsafebase64( + hashlib.sha256(certificateToBindBase64Urlsafe).digest() + ).decode() + header = { + "alg": "RS256", + "cty": "TLS-Certificate-Binding-v1", + "x5c": [signingCertificateBase64], + "sigD": { + "mId": "http://uri.etsi.org/19182/ObjectIdByURIHash", + "pars": [""], + "hashM": "S256", + "hashV": [certificateToBindHash], + }, + } + return json.dumps(header) + + +class TLSBinding: + """Class representing a 2-QWACs TLS certificate binding.""" + + def __init__(self, signingCertificate, certificateToBind): + self.signingCertificate = signingCertificate + self.certificateToBind = certificateToBind + + @staticmethod + def fromSpecification(specStream): + """Constructs a TLS certificate binding from a specification.""" + signingCertificateSpecification = StringIO() + readingSigningCertificateSpecification = False + certificateToBindSpecification = StringIO() + readingCertificateToBindSpecification = False + for line in specStream.readlines(): + lineStripped = line.strip() + if readingSigningCertificateSpecification: + if lineStripped == ":end": + readingSigningCertificateSpecification = False + else: + print(lineStripped, file=signingCertificateSpecification) + elif readingCertificateToBindSpecification: + if lineStripped == ":end": + readingCertificateToBindSpecification = False + else: + print(lineStripped, file=certificateToBindSpecification) + elif lineStripped == "certificateToBind:": + readingCertificateToBindSpecification = True + elif lineStripped == "signingCertificate:": + readingSigningCertificateSpecification = True + else: + raise pycert.UnknownParameterTypeError(lineStripped) + signingCertificateSpecification.seek(0) + signingCertificate = pycert.Certificate(signingCertificateSpecification) + certificateToBindSpecification.seek(0) + certificateToBind = pycert.Certificate(certificateToBindSpecification) + return TLSBinding(signingCertificate, certificateToBind) + + def signAndEncode(self): + """Returns a signed and encoded representation of the TLS certificate + binding as bytes.""" + header = urlsafebase64( + str(Header(self.signingCertificate, self.certificateToBind)).encode("utf-8") + ) + signature = self.signingCertificate.subjectKey.sign( + header + b".", pykey.HASH_SHA256 + ) + # signature will be of the form "'AABBCC...'H" + return ( + header.decode() + + ".." + + urlsafebase64(bytes.fromhex(signature[1:-2])).decode() + ) + + +# The build harness will call this function with an output +# file-like object and a path to a file containing an SCT +# specification. This will read the specification and output +# the SCT as bytes. +def main(output, inputPath): + with open(inputPath) as configStream: + output.write(TLSBinding.fromSpecification(configStream).signAndEncode()) diff --git a/testing/mochitest/runtests.py b/testing/mochitest/runtests.py @@ -716,7 +716,7 @@ class SSLTunnel: self.webServer = options.webServer self.webSocketPort = options.webSocketPort - self.customCertRE = re.compile("^cert=(?P<nickname>[0-9a-zA-Z_ ]+)") + self.customCertRE = re.compile("^cert=(?P<nickname>[0-9a-zA-Z_ -]+)") self.clientAuthRE = re.compile("^clientauth=(?P<clientauth>[a-z]+)") self.redirRE = re.compile("^redir=(?P<redirhost>[0-9a-zA-Z_ .]+)")