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:
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