tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit 003e779a3113e13b7cdf1b70368d2089a4ac98c0
parent 4d7d7f53ab6d78f27dd6b038ed0a7549378c5476
Author: Dana Keeler <dkeeler@mozilla.com>
Date:   Fri, 19 Dec 2025 20:12:19 +0000

Bug 1996724 - implement 2-QWACs r=jschanck

Differential Revision: https://phabricator.services.mozilla.com/D271162

Diffstat:
Asecurity/manager/ssl/QWACs.sys.mjs | 403+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msecurity/manager/ssl/moz.build | 1+
Msecurity/manager/ssl/nsIX509Cert.idl | 6++++++
Msecurity/manager/ssl/nsNSSCertificate.cpp | 21+++++++++++++++++++++
Msecurity/manager/ssl/tests/unit/head_psm.js | 4++--
Msecurity/manager/ssl/tests/unit/test_qwacs.js | 419+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asecurity/manager/ssl/tests/unit/test_qwacs/2-qwac-ec.pem | 15+++++++++++++++
Asecurity/manager/ssl/tests/unit/test_qwacs/2-qwac-ec.pem.certspec | 7+++++++
Asecurity/manager/ssl/tests/unit/test_qwacs/secp256r1.key | 5+++++
Asecurity/manager/ssl/tests/unit/test_qwacs/secp256r1.key.keyspec | 1+
10 files changed, 880 insertions(+), 2 deletions(-)

diff --git a/security/manager/ssl/QWACs.sys.mjs b/security/manager/ssl/QWACs.sys.mjs @@ -0,0 +1,403 @@ +/* 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/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetters(lazy, { + CertDB: ["@mozilla.org/security/x509certdb;1", Ci.nsIX509CertDB], +}); + +function arrayToString(a) { + let s = ""; + for (let b of a) { + s += String.fromCharCode(b); + } + return s; +} + +function stringToArrayBuffer(str) { + let bytes = new Uint8Array(str.length); + for (let i = 0; i < str.length; i++) { + bytes[i] = str.charCodeAt(i); + } + return bytes; +} + +export var QWACs = { + fromBase64URLEncoding(base64URLEncoded) { + return atob(base64URLEncoded.replaceAll("-", "+").replaceAll("_", "/")); + }, + + toBase64URLEncoding(str) { + return btoa(str) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replaceAll("=", ""); + }, + + // Validates and returns the decoded parameters of a TLS certificate binding + // header as specified by ETSI TS 119 411-5 V2.1.1 Annex B, ETSI TS 119 182-1 + // V1.2.1, and RFC 7515. + // If the header contains invalid values or otherwise fails to validate, + // returns false. + // This should probably not be called directly outside of tests - + // verifyTLSCertificateBinding is the main entrypoint of this implementation. + validateTLSCertificateBindingHeader(header) { + // ETSI TS 119 411-5 V2.1.1 Annex B specifies the TLS Certificate Binding + // Profile and states that "Only header parameters specified in this + // profile may be present in the header of the generated JAdES signature." + const allowedHeaderKeys = new Set([ + "alg", + "kid", + "cty", + "x5t#S256", + "x5c", + "iat", + "exp", + "sigD", + ]); + let headerKeys = new Set(Object.keys(header)); + if (!headerKeys.isSubsetOf(allowedHeaderKeys)) { + console.error("header contains invalid parameter"); + return false; + } + + // ETSI TS 119 182-1 V1.2.1 Section 5.1.2 specifies that "alg" shall be as + // described in RFC 7515 Section 4.1.1, which references RFC 7518. None of + // these specifications require support for a particular signature + // algorithm, but RFC 7518 recommends supporting "RS256" (RSASSA-PKCS1-v1_5 + // with SHA-256) and "ES256" (ECDSA with P-256 and SHA-256), so those are + // supported. Additionally, "PS256" (RSASSA-PSS with SHA-256) is supported + // for compatibility and as better alternative to RSASSA-PKCS1-v1_5. + // The specification says "alg" can't conflict with signing certificate + // key. This is enforced when the signature is verified. + if (!("alg" in header)) { + console.error("header missing 'alg' field"); + return false; + } + let algorithm; + switch (header.alg) { + case "RS256": + algorithm = { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }; + break; + case "PS256": + algorithm = { name: "RSA-PSS", saltLength: 32, hash: "SHA-256" }; + break; + case "ES256": + algorithm = { name: "ECDSA", namedCurve: "P-256", hash: "SHA-256" }; + break; + default: + console.error("unsupported alg:", header.alg); + return false; + } + + // RFC 7515 defines "kid" as an optional hint. It is unnecessary. + + // ETSI TS 119 411-5 V2.1.1 Annex B says that "cty" will have the value + // "TLS-Certificate-Binding-v1". However, ETSI TS 119 182-1 V1.2.1 Section + // 5.1.3 states that "The cty header parameter should not be present if the + // sigD header parameter, specified in clause 5.2.8 of the present + // document, is present within the JAdES signature." + // ETSI TS 119 411-5 V2.1.1 Annex B also requires sigD to be present, so + // either this is a mistake, or ETSI TS 119 411-5 Annex B deliberately + // disregards ETSI TS 119 182-1. + // Chrome's implementation requires "cty", so for compatibility, this + // matches that behavior. + if (!("cty" in header)) { + console.error("header missing field 'cty'"); + return false; + } + if (header.cty != "TLS-Certificate-Binding-v1") { + console.error("invalid value for cty:", header.cty); + return false; + } + + // RFC 7515 defines "x5t#S256" as "base64url-encoded SHA-256 thumbprint + // (a.k.a. digest) of the DER encoding of the X.509 certificate [RFC5280] + // corresponding to the key used to digitally sign the JWS". + // It is optional. If present, it must match the digest of the 0th element + // of "x5c" (this is checked after processing x5c, below). + let x5tS256; + if ("x5t#S256" in header) { + x5tS256 = header["x5t#S256"]; + } + + // RFC 7515: + // The "x5c" (X.509 certificate chain) Header Parameter contains the + // X.509 public key certificate or certificate chain [RFC5280] + // corresponding to the key used to digitally sign the JWS. The + // certificate or certificate chain is represented as a JSON array of + // certificate value strings. Each string in the array is a + // base64-encoded (Section 4 of [RFC4648] -- not base64url-encoded) DER + // [ITU.X690.2008] PKIX certificate value. + if (!("x5c" in header)) { + console.error("header missing field 'x5c'"); + return false; + } + let certificates = []; + for (let base64 of header.x5c) { + try { + certificates.push(lazy.CertDB.constructX509FromBase64(base64)); + } catch (e) { + console.error("couldn't decode certificate"); + return false; + } + } + // ETSI TS 119 411-5 V2.1.1 Annex B states that "x5c" consists of the full + // certificate chain, including the trust anchor. However, only the signing + // certificate and any intermediates relevant to path-building are strictly + // necessary. + if (certificates.length < 1) { + console.error("header must specify certificate chain"); + return false; + } + if (x5tS256) { + // signingCertificateHashHex will be of the form "AA:BB:..." + let signingCertificateHashHex = certificates[0].sha256Fingerprint; + let signingCertificateHashBytes = signingCertificateHashHex + .split(":") + .map(hexStr => parseInt(hexStr, 16)); + if ( + x5tS256 != + QWACs.toBase64URLEncoding(arrayToString(signingCertificateHashBytes)) + ) { + console.error("x5t#S256 does not match signing certificate"); + return false; + } + } + + // ETSI TS 119 411-5 V2.1.1 Annex B's definition of "iat" reads "This field + // contains the claimed signing time. The value shall be encoded as + // specified in IETF RFC 7519 [9]." However, in RFC 7519, "iat" is a claim + // that can be made by a JWT, not used as a header field in a JWS. In any + // case, ETSI TS 119 411-5 offers no guidance on how this header affects + // validation. Consequently, as it is optional, it is ignored. + + // Similarly, the definition of "exp" reads "This field contains the expiry + // date of the binding. The maximum effective expiry time is whichever is + // soonest of this field, the longest-lived TLS certificate identified in + // the sigD member payload (below), or the notAfter time of the signing + // certificate. The value shall be encoded as specified in IETF RFC 7519 + // [9]," again referencing a JWT claim and not a JWS header. + // We interpret this to be an optional mechanism to expire bindings earlier + // than the earliest "notAfter" value amongst the certificates specified in + // "x5c". + // RFC 7519 says this will be a NumericDate, which is a "JSON numeric value + // representing the number of seconds from 1970-01-01T00:00:00Z UTC". + if ("exp" in header) { + let expirationSeconds = parseInt(header.exp); + if (isNaN(expirationSeconds)) { + console.error("invalid expiration time"); + return false; + } + let expiration = new Date(expirationSeconds * 1000); + if (expiration < new Date()) { + console.error("header has expired"); + return false; + } + } + + // "sigD" lists the TLS server certificates being bound, and must be + // present. + if (!("sigD" in header)) { + console.error("header missing field 'sigD'"); + return false; + } + let sigD = header.sigD; + const allowedSigDKeys = new Set(["mId", "pars", "hashM", "hashV"]); + let sigDKeys = new Set(Object.keys(sigD)); + if (!sigDKeys.isSubsetOf(allowedSigDKeys)) { + console.error("sigD contains invalid parameter"); + return false; + } + // ETSI TS 119 411-5 V2.1.1 Annex B requires that "sigD.mId" be + // "http://uri.etsi.org/19182/ObjectIdByURIHash". + if (!("mId" in sigD)) { + console.error("header missing field 'sigD.mId'"); + return false; + } + if (sigD.mId != "http://uri.etsi.org/19182/ObjectIdByURIHash") { + console.error("invalid value for sigD.mId:", sigD.mId); + return false; + } + + // ETSI TS 119 411-5 V2.1.1 Annex B defines "sigD.pars" as "A comma-separated + // list of TLS certificate file names." The only thing to validate here is + // that pars has as many elements as "hashV", later. + if (!("pars" in sigD)) { + console.error("header missing field 'sigD.pars'"); + return false; + } + let pars = sigD.pars; + + // ETSI TS 119 411-5 V2.1.1 Annex B defines "sigD.hashM" as 'The string + // identifying one of the approved hashing algorithms identified by ETSI TS + // 119 312 [8] for JAdES. This hashing algorithm is used to calculate the + // hashes described in the "hashV" member below.' It further requires that + // "SHA-256, SHA-384, and SHA-512 are supported, and it is assumed that + // strings identifying them are S256, S384, and S512 respectively". + if (!("hashM" in sigD)) { + console.error("header missing field 'sigD.hashM'"); + return false; + } + let hashAlg; + switch (sigD.hashM) { + case "S256": + hashAlg = "SHA-256"; + break; + case "S384": + hashAlg = "SHA-384"; + break; + case "S512": + hashAlg = "SHA-512"; + break; + default: + console.error("unsupported hashM:", sigD.hashM); + return false; + } + + // ETSI TS 119 411-5 V2.1.1 Annex B defines "sigD.hashV" as 'A + // comma-separated list of TLS certificate file hashes. Each hash is + // produced by taking the corresponding X.509 certificate, computing its + // base64url encoding, and calculating its hash using the algorithm + // identified in the "hashM" member above.' + // This array must be the same length as the "sigD.pars" array. + if (!("hashV" in sigD)) { + console.error("header missing field 'sigD.hashV'"); + return false; + } + let hashes = sigD.hashV; + if (hashes.length != pars.length) { + console.error("header sigD.pars/hashV mismatch"); + return false; + } + for (let hash of hashes) { + if (typeof hash != "string") { + console.error("invalid hash:", hash); + return false; + } + } + + return { algorithm, certificates, hashAlg, hashes }; + }, + + // Given a TLS certificate binding, a TLS server certificate, and a hostname, + // this function validates the binding, extracts its parameters, verifies + // that the binding signing certificate is a 2-QWAC certificate valid for the + // 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. + async verifyTLSCertificateBinding( + tlsCertificateBinding, + serverCertificate, + hostname + ) { + // tlsCertificateBinding is a JAdES signature, which is a JWS. Because ETSI + // TS 119 411-5 V2.1.1 Annex B requires sigD be present, and because ETSI + // TS 119 182-1 V1.2.1 states "The sigD header parameter shall not appear + // in JAdES signatures whose JWS Payload is attached", + // tlsCertificateBinding must have a detached payload. + // In other words, tlsCertificateBinding is a consists of: + // "<base64url-encoded header>..<base64url-encoded signature>" + let parts = tlsCertificateBinding.split("."); + if (parts.length != 3) { + console.error("invalid TLS certificate binding"); + return false; + } + if (parts[1] != "") { + console.error("TLS certificate binding must have empty payload"); + return false; + } + let header; + try { + header = JSON.parse(QWACs.fromBase64URLEncoding(parts[0])); + } catch (e) { + console.error("header is not base64(JSON)"); + return false; + } + let params = QWACs.validateTLSCertificateBindingHeader(header); + if (!params) { + return false; + } + + // The 0th certificate signed the binding. It must be a 2-QWAC that is + // valid for the given hostname (ETSI TS 119 411-5 V2.1.1 Section 6.2.2 + // Step 4). + let signingCertificate = params.certificates[0]; + let chain = params.certificates.slice(1); + if ( + !(await lazy.CertDB.asyncVerifyQWAC( + Ci.nsIX509CertDB.TwoQWAC, + signingCertificate, + hostname, + chain + )) + ) { + console.error("signing certificate not 2-QWAC"); + return false; + } + + let spki = signingCertificate.subjectPublicKeyInfo; + let signingKey; + try { + signingKey = await crypto.subtle.importKey( + "spki", + new Uint8Array(spki), + params.algorithm, + true, + ["verify"] + ); + } catch (e) { + console.error("invalid signing key (algorithm mismatch?)"); + return false; + } + + let signature; + try { + signature = QWACs.fromBase64URLEncoding(parts[2]); + } catch (e) { + console.error("signature is not base64"); + return false; + } + + // Validate the signature (Step 5). + let signatureValid; + try { + signatureValid = await crypto.subtle.verify( + params.algorithm, + signingKey, + stringToArrayBuffer(signature), + stringToArrayBuffer(parts[0] + ".") + ); + } catch (e) { + console.error("failed to verify signature"); + return false; + } + if (!signatureValid) { + console.error("invalid signature"); + return false; + } + + // The binding must list the server certificate's hash (Step 6). + let serverCertificateHash = await crypto.subtle.digest( + params.hashAlg, + stringToArrayBuffer( + QWACs.toBase64URLEncoding(arrayToString(serverCertificate.getRawDER())) + ) + ); + if ( + !params.hashes.includes( + QWACs.toBase64URLEncoding( + arrayToString(new Uint8Array(serverCertificateHash)) + ) + ) + ) { + console.error("TLS binding does not cover server certificate"); + return false; + } + return true; + }, +}; diff --git a/security/manager/ssl/moz.build b/security/manager/ssl/moz.build @@ -49,6 +49,7 @@ XPCOM_MANIFESTS += [ EXTRA_JS_MODULES.psm += [ "ClientAuthDialogService.sys.mjs", "DER.sys.mjs", + "QWACs.sys.mjs", "RemoteSecuritySettings.sys.mjs", "X509.sys.mjs", ] diff --git a/security/manager/ssl/nsIX509Cert.idl b/security/manager/ssl/nsIX509Cert.idl @@ -180,6 +180,12 @@ interface nsIX509Cert : nsISupports { ACString getBase64DERString(); /** + * The bytes of the certificate's DER encoded subject public key info. + */ + [must_use] + readonly attribute Array<octet> subjectPublicKeyInfo; + + /** * The base64 encoding of the DER encoded public key info using the specified * digest. */ diff --git a/security/manager/ssl/nsNSSCertificate.cpp b/security/manager/ssl/nsNSSCertificate.cpp @@ -485,6 +485,27 @@ nsNSSCertificate::GetTokenName(nsAString& aTokenName) { } NS_IMETHODIMP +nsNSSCertificate::GetSubjectPublicKeyInfo(nsTArray<uint8_t>& aSPKI) { + aSPKI.Clear(); + + pkix::Input certInput; + pkix::Result result = certInput.Init(mDER.Elements(), mDER.Length()); + if (result != pkix::Result::Success) { + return NS_ERROR_INVALID_ARG; + } + // NB: since we're not building a trust path, the endEntityOrCA parameter is + // irrelevant. + pkix::BackCert cert(certInput, pkix::EndEntityOrCA::MustBeEndEntity, nullptr); + result = cert.Init(); + if (result != pkix::Result::Success) { + return NS_ERROR_INVALID_ARG; + } + pkix::Input spki = cert.GetSubjectPublicKeyInfo(); + aSPKI.AppendElements(spki.UnsafeGetData(), spki.GetLength()); + return NS_OK; +} + +NS_IMETHODIMP nsNSSCertificate::GetSha256SubjectPublicKeyInfoDigest( nsACString& aSha256SPKIDigest) { aSha256SPKIDigest.Truncate(); diff --git a/security/manager/ssl/tests/unit/head_psm.js b/security/manager/ssl/tests/unit/head_psm.js @@ -165,8 +165,8 @@ function arrayToString(a) { // PEM to the format that nsIX509CertDB requires. function pemToBase64(pem) { return pem - .replace(/-----BEGIN CERTIFICATE-----/, "") - .replace(/-----END CERTIFICATE-----/, "") + .replace(/-----BEGIN (CERTIFICATE|(EC )?PRIVATE KEY)-----/, "") + .replace(/-----END (CERTIFICATE|(EC )?PRIVATE KEY)-----/, "") .replace(/[\r\n]/g, ""); } diff --git a/security/manager/ssl/tests/unit/test_qwacs.js b/security/manager/ssl/tests/unit/test_qwacs.js @@ -10,6 +10,10 @@ const certdb = Cc["@mozilla.org/security/x509certdb;1"].getService( Ci.nsIX509CertDB ); +const { QWACs } = ChromeUtils.importESModule( + "resource://gre/modules/psm/QWACs.sys.mjs" +); + async function verify_1_qwacs(filename, expectSuccess, extraCertNames = []) { let cert = constructCertFromFile(filename); let result = await certdb.asyncVerifyQWAC( @@ -87,3 +91,418 @@ add_task(async function test_verify_2_qwacs() { await verify_2_qwacs("test_qwacs/2-qwac-multiple-key-purpose-eku.pem", false); await verify_2_qwacs("test_qwacs/2-qwac.pem", false, "example.org"); }); + +// Produces base64url(hash(base64url(certificate DER))) +async function certificateHash(certificate, hashAlg) { + let hash = await crypto.subtle.digest( + hashAlg.replace("S", "SHA-"), + new Uint8Array( + stringToArray( + QWACs.toBase64URLEncoding(arrayToString(certificate.getRawDER())) + ) + ) + ); + return QWACs.toBase64URLEncoding(arrayToString(new Uint8Array(hash))); +} + +const kTLSCertificateBindingEE = constructCertFromFile("test_qwacs/2-qwac.pem"); + +const kTLSCertificateBindingHeader = { + alg: "", + cty: "TLS-Certificate-Binding-v1", + // RFC 7515 Section 4.1.6: "Each string in the array is a base64-encoded + // (Section 4 of [RFC4648] -- not base64url-encoded) DER [ITU.X690.2008] PKIX + // certificate value." + x5c: [], + sigD: { + mId: "http://uri.etsi.org/19182/ObjectIdByURIHash", + pars: [], + hashM: "", + hashV: [], + }, +}; + +async function makeBindingHeader( + certificateChain, + certificatesToBind, + signingAlg, + hashAlg +) { + let header = structuredClone(kTLSCertificateBindingHeader); + header.alg = signingAlg; + header.x5c = certificateChain.map(c => btoa(arrayToString(c.getRawDER()))); + header.sigD.hashM = hashAlg; + for (let toBind of certificatesToBind) { + header.sigD.pars.push(""); + header.sigD.hashV.push(await certificateHash(toBind, hashAlg)); + } + return header; +} + +add_task(async function test_validate_tls_certificate_binding_header() { + let serverCertificate = constructCertFromFile("bad_certs/default-ee.pem"); + let testHeader = await makeBindingHeader( + [kTLSCertificateBindingEE], + [serverCertificate], + "RS256", + "S256" + ); + let validatedHeader = QWACs.validateTLSCertificateBindingHeader(testHeader); + ok(validatedHeader, "header should validate successfully"); + deepEqual(validatedHeader.algorithm, { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + }); + equal(validatedHeader.certificates.length, 1); + equal(validatedHeader.hashAlg, "SHA-256"); + equal(validatedHeader.hashes.length, 1); + + let headerWithExtraKey = structuredClone(testHeader); + headerWithExtraKey.extra = "foo"; + ok( + !QWACs.validateTLSCertificateBindingHeader(headerWithExtraKey), + "header with extra key should not validate" + ); + + let headerWithExtraSigDKey = structuredClone(testHeader); + headerWithExtraSigDKey.sigD.additional = "bar"; + ok( + !QWACs.validateTLSCertificateBindingHeader(headerWithExtraSigDKey), + "header with extra key in sigD should not parse" + ); + + let headerWithWrongCty = structuredClone(testHeader); + headerWithWrongCty.cty = "TLS-Certificate-Binding-v2"; + ok( + !QWACs.validateTLSCertificateBindingHeader(headerWithWrongCty), + "header with wrong cty should not parse" + ); + + let headerWithWrongMId = structuredClone(testHeader); + headerWithWrongMId.sigD.mId = "http://example.org"; + ok( + !QWACs.validateTLSCertificateBindingHeader(headerWithWrongMId), + "header with wrong sigD.mId should not parse" + ); + + let headerWithTooManyPars = structuredClone(testHeader); + headerWithTooManyPars.sigD.pars.push(""); + ok( + !QWACs.validateTLSCertificateBindingHeader(headerWithTooManyPars), + "header with too many sigD.pars elements should not parse" + ); + + let headerWithInvalidHashV = structuredClone(testHeader); + headerWithInvalidHashV.sigD.hashV[0] = 1234; + ok( + !QWACs.validateTLSCertificateBindingHeader(headerWithInvalidHashV), + "header with invalid sigD.hashV should not parse" + ); + + let headerWithTooManyHashV = structuredClone(testHeader); + headerWithTooManyHashV.sigD.hashV.push(headerWithTooManyHashV.sigD.hashV[0]); + ok( + !QWACs.validateTLSCertificateBindingHeader(headerWithTooManyHashV), + "header with too many sigD.hashV elements should not parse" + ); + + let headerWithEmptyX5c = structuredClone(testHeader); + headerWithEmptyX5c.x5c = []; + ok( + !QWACs.validateTLSCertificateBindingHeader(headerWithEmptyX5c), + "header with empty x5c should not parse" + ); + + let headerWithUnsupportedAlg = structuredClone(testHeader); + headerWithUnsupportedAlg.alg = "RS384"; + ok( + !QWACs.validateTLSCertificateBindingHeader(headerWithUnsupportedAlg), + "header with unsupported alg should not parse" + ); + + let headerWithUnsupportedHashM = structuredClone(testHeader); + headerWithUnsupportedHashM.sigD.hashM = "S224"; + ok( + !QWACs.validateTLSCertificateBindingHeader(headerWithUnsupportedHashM), + "header with unsupported sigD.hashM should not parse" + ); + + let headerWithOptionalHeaders = structuredClone(testHeader); + headerWithOptionalHeaders.kid = "optional kid"; + headerWithOptionalHeaders.iat = "optional iat"; + ok( + QWACs.validateTLSCertificateBindingHeader(headerWithOptionalHeaders), + "header with optional headers should parse" + ); + + let headerWithX5tS256Match = structuredClone(testHeader); + let signingCertificateHash = await crypto.subtle.digest( + "SHA-256", + new Uint8Array(kTLSCertificateBindingEE.getRawDER()) + ); + headerWithX5tS256Match["x5t#S256"] = QWACs.toBase64URLEncoding( + arrayToString(new Uint8Array(signingCertificateHash)) + ); + ok( + QWACs.validateTLSCertificateBindingHeader(headerWithX5tS256Match), + "header with matching x5t#S256 should parse" + ); + + let headerWithX5tS256Mismatch = structuredClone(testHeader); + headerWithX5tS256Mismatch["x5t#S256"] = + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + ok( + !QWACs.validateTLSCertificateBindingHeader(headerWithX5tS256Mismatch), + "header with x5t#S256 mismatch should not parse" + ); + + let headerWithBadExp = structuredClone(testHeader); + headerWithBadExp.exp = "not a number"; + ok( + !QWACs.validateTLSCertificateBindingHeader(headerWithBadExp), + "header with bad expiration time should not parse" + ); + + let expiredHeader = structuredClone(testHeader); + expiredHeader.exp = "946684800"; + ok( + !QWACs.validateTLSCertificateBindingHeader(expiredHeader), + "expired header should not parse" + ); + + let unexpiredHeader = structuredClone(testHeader); + unexpiredHeader.exp = "4102444800"; + ok( + QWACs.validateTLSCertificateBindingHeader(unexpiredHeader), + "unexpired header should parse" + ); +}); + +async function validate_tls_certificate_binding_header_with_algorithms( + signatureAlg, + hashAlg, + expectedSignatureAlg, + expectedHashAlg +) { + let serverCertificate = constructCertFromFile("bad_certs/default-ee.pem"); + let testHeader = await makeBindingHeader( + [kTLSCertificateBindingEE], + [serverCertificate], + signatureAlg, + hashAlg + ); + let validatedHeader = QWACs.validateTLSCertificateBindingHeader(testHeader); + ok(validatedHeader, "header should validate successfully"); + deepEqual(validatedHeader.algorithm, expectedSignatureAlg); + equal(validatedHeader.certificates.length, 1); + equal(validatedHeader.hashAlg, expectedHashAlg); + equal(validatedHeader.hashes.length, 1); +} + +add_task( + async function test_validate_tls_certificate_binding_header_with_algorithms() { + let options = [ + { + signatureAlg: "RS256", + hashAlg: "S256", + expectedSignatureAlg: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + expectedHashAlg: "SHA-256", + }, + { + signatureAlg: "PS256", + hashAlg: "S384", + expectedSignatureAlg: { + name: "RSA-PSS", + saltLength: 32, + hash: "SHA-256", + }, + expectedHashAlg: "SHA-384", + }, + { + signatureAlg: "ES256", + hashAlg: "S512", + expectedSignatureAlg: { + name: "ECDSA", + namedCurve: "P-256", + hash: "SHA-256", + }, + expectedHashAlg: "SHA-512", + }, + ]; + for (let option of options) { + await validate_tls_certificate_binding_header_with_algorithms( + option.signatureAlg, + option.hashAlg, + option.expectedSignatureAlg, + option.expectedHashAlg + ); + } + } +); + +async function sign(privateKeyInfo, algorithm, header) { + let toSign = new Uint8Array(stringToArray(header + ".")); + let key = await crypto.subtle.importKey( + "pkcs8", + privateKeyInfo, + algorithm, + true, + ["sign"] + ); + let signature = await crypto.subtle.sign(algorithm, key, toSign); + return ( + header + + ".." + + QWACs.toBase64URLEncoding(arrayToString(new Uint8Array(signature))) + ); +} + +async function signTLSCertificateBinding(header, key, algorithm) { + return sign( + key, + algorithm, + QWACs.toBase64URLEncoding(JSON.stringify(header)) + ); +} + +async function verify_tls_certificate_binding_signature( + headerSignatureAlgorithm, + headerHashAlgorithm, + signatureAlgorithm, + signingKeyFilename, + bindingCertificateFilename, + serverCertificateFilename, + presentedServerCertificateFilename, + expectSuccess, + expectedCertificateSubject +) { + let signingKey = new Uint8Array( + stringToArray( + QWACs.fromBase64URLEncoding( + pemToBase64(readFile(do_get_file(signingKeyFilename, false))) + ) + ) + ); + let bindingCertificate = constructCertFromFile(bindingCertificateFilename); + let serverCertificate = constructCertFromFile(serverCertificateFilename); + let testHeader = await makeBindingHeader( + [bindingCertificate], + [serverCertificate], + headerSignatureAlgorithm, + headerHashAlgorithm + ); + let binding = await signTLSCertificateBinding( + testHeader, + signingKey, + signatureAlgorithm + ); + let presentedServerCertificate = constructCertFromFile( + presentedServerCertificateFilename + ); + let qwac = await QWACs.verifyTLSCertificateBinding( + binding, + presentedServerCertificate, + "example.com" + ); + equal( + !!qwac, + expectSuccess, + `TLS certificate binding ${expectSuccess ? "should" : "should not"} verify correctly` + ); + if (expectSuccess) { + equal( + qwac.commonName, + expectedCertificateSubject, + "Verification should return the expected certificate" + ); + } +} + +add_task(async function test_verify_tls_certificate_binding_signatures() { + let options = [ + { + headerSignatureAlgorithm: "RS256", + headerHashAlgorithm: "S512", + signatureAlgorithm: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + signingKeyFilename: "bad_certs/default-ee.key", + bindingCertificateFilename: "test_qwacs/2-qwac.pem", + serverCertificateFilename: "bad_certs/default-ee.pem", + presentedServerCertificateFilename: "bad_certs/default-ee.pem", + expectSuccess: true, + expectedCertificateSubject: "2-QWAC", + }, + // presented server certificate / bound server certificate mismatch + { + headerSignatureAlgorithm: "RS256", + headerHashAlgorithm: "S512", + signatureAlgorithm: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + signingKeyFilename: "bad_certs/default-ee.key", + bindingCertificateFilename: "test_qwacs/2-qwac.pem", + serverCertificateFilename: "bad_certs/default-ee.pem", + presentedServerCertificateFilename: + "bad_certs/ee-from-missing-intermediate.pem", + expectSuccess: false, + expectedCertificateSubject: null, + }, + { + headerSignatureAlgorithm: "PS256", + headerHashAlgorithm: "S256", + signatureAlgorithm: { + name: "RSA-PSS", + hash: "SHA-256", + saltLength: 32, + }, + signingKeyFilename: "bad_certs/default-ee.key", + bindingCertificateFilename: "test_qwacs/2-qwac.pem", + serverCertificateFilename: "bad_certs/default-ee.pem", + presentedServerCertificateFilename: "bad_certs/default-ee.pem", + expectSuccess: true, + expectedCertificateSubject: "2-QWAC", + }, + { + headerSignatureAlgorithm: "ES256", + headerHashAlgorithm: "S384", + signatureAlgorithm: { + name: "ECDSA", + namedCurve: "P-256", + hash: "SHA-256", + }, + signingKeyFilename: "test_qwacs/secp256r1.key", + bindingCertificateFilename: "test_qwacs/2-qwac-ec.pem", + serverCertificateFilename: "bad_certs/default-ee.pem", + presentedServerCertificateFilename: "bad_certs/default-ee.pem", + expectSuccess: true, + expectedCertificateSubject: "2-QWAC with EC key", + }, + // header signature algorithm / actual signature algorithm mismatch + { + headerSignatureAlgorithm: "RS256", + headerHashAlgorithm: "S384", + signatureAlgorithm: { + name: "ECDSA", + namedCurve: "P-256", + hash: "SHA-256", + }, + signingKeyFilename: "test_qwacs/secp256r1.key", + bindingCertificateFilename: "test_qwacs/2-qwac-ec.pem", + serverCertificateFilename: "bad_certs/default-ee.pem", + presentedServerCertificateFilename: "bad_certs/default-ee.pem", + expectSuccess: false, + expectedCertificateSubject: null, + }, + ]; + + for (let option of options) { + await verify_tls_certificate_binding_signature( + option.headerSignatureAlgorithm, + option.headerHashAlgorithm, + option.signatureAlgorithm, + option.signingKeyFilename, + option.bindingCertificateFilename, + option.serverCertificateFilename, + option.presentedServerCertificateFilename, + option.expectSuccess, + option.expectedCertificateSubject + ); + } +}); diff --git a/security/manager/ssl/tests/unit/test_qwacs/2-qwac-ec.pem b/security/manager/ssl/tests/unit/test_qwacs/2-qwac-ec.pem @@ -0,0 +1,15 @@ +-----BEGIN CERTIFICATE----- +MIICaTCCAVGgAwIBAgIUI7puI/7AoO5YJyA1Ur7jnUMlPWswDQYJKoZIhvcNAQEL +BQAwEjEQMA4GA1UEAwwHVGVzdCBDQTAiGA8yMDIzMTEyODAwMDAwMFoYDzIwMjYw +MjA1MDAwMDAwWjAdMRswGQYDVQQDDBIyLVFXQUMgd2l0aCBFQyBrZXkwWTATBgcq +hkjOPQIBBggqhkjOPQMBBwNCAARPv7u7YeD4+bGmClmshwTi7AULQj489y6SPyxP +eUtFXCpp0jNFbDbEEZ0HBuAO7cjRk5DXmRt7LQejBOqgSqbAo3MwcTAtBggrBgEF +BQcBAwQhMB8wCAYGBACORgEBMBMGBgQAjkYBBjAJBgcEAI5GAQYDMBQGA1UdIAQN +MAswCQYHBACL7EABBjASBgNVHSUECzAJBgcEAIvsQwEAMBYGA1UdEQQPMA2CC2V4 +YW1wbGUuY29tMA0GCSqGSIb3DQEBCwUAA4IBAQCLH2MoMgd+32MGtyxv12FaQk9l ++vctL03M6i1lUmv9kKUVVpvU8R+489gKLaJ1/Qbu1874yrpS9TyqN8IGq9bHZNpY +TY9As2sv4z9Uyh4tkysrYB+ecI9o6Al3fIbsxYgfBwF+rre9e6yTSx8vrikfQc5p +Ni8xYd7nULEW+Hbo5XjbT5Yt0UcFBTYthH65Rjck8qAJwyKDleXDXgaJ2TpA4nQ2 +2C3PK0SZWWJ8Ua9hYkGWZEvNIDIiyZ99REdXEghEarT1R+qSBW0+d1MX7UO3QdIN +xNhERu68/2rnu/RN17LaG3jbR20IASQJQfDv1uXVc1PulYnLzK2J9t08Me6U +-----END CERTIFICATE----- diff --git a/security/manager/ssl/tests/unit/test_qwacs/2-qwac-ec.pem.certspec b/security/manager/ssl/tests/unit/test_qwacs/2-qwac-ec.pem.certspec @@ -0,0 +1,7 @@ +issuer:Test CA +subject:2-QWAC with EC key +subjectKey:secp256r1 +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:example.com diff --git a/security/manager/ssl/tests/unit/test_qwacs/secp256r1.key b/security/manager/ssl/tests/unit/test_qwacs/secp256r1.key @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgIZFAPVcQvxWiZYGM +1C7W/t8JrdkteLGOeh6f65VSRwKhRANCAARPv7u7YeD4+bGmClmshwTi7AULQj48 +9y6SPyxPeUtFXCpp0jNFbDbEEZ0HBuAO7cjRk5DXmRt7LQejBOqgSqbA +-----END EC PRIVATE KEY----- diff --git a/security/manager/ssl/tests/unit/test_qwacs/secp256r1.key.keyspec b/security/manager/ssl/tests/unit/test_qwacs/secp256r1.key.keyspec @@ -0,0 +1 @@ +secp256r1