diff --git a/NEWS b/NEWS index 2de34f101..df1d21692 100644 --- a/NEWS +++ b/NEWS @@ -68,6 +68,8 @@ New features: * `FidoMetadataDownloader` now parses the CRLDistributionPoints extension on the application level, so the `com.sun.security.enableCRLDP=true` system property setting is no longer necessary. +* Added helper function `CertificateUtil.parseFidoSernumExtension` for parsing + serial number from enterprise attestation certificates. == Version 2.5.4 == diff --git a/webauthn-server-attestation/README.adoc b/webauthn-server-attestation/README.adoc index 0307e645f..fd3c34ca3 100644 --- a/webauthn-server-attestation/README.adoc +++ b/webauthn-server-attestation/README.adoc @@ -9,6 +9,7 @@ An optional module which extends link:../[`webauthn-server-core`] with a trust root source for verifying https://www.w3.org/TR/2021/REC-webauthn-2-20210408/#sctn-attestation[attestation statements], by interfacing with the https://fidoalliance.org/metadata/[FIDO Metadata Service]. +The module also provides helper functions for inspecting properties of attestation certificates. *Table of contents* @@ -17,7 +18,7 @@ toc::[] == Features -This module does four things: +The FIDO MDS integration does four things: - Download, verify and cache metadata BLOBs from the FIDO Metadata Service. - Re-download the metadata BLOB when out of date or invalid. @@ -414,3 +415,28 @@ link:https://developers.yubico.com/java-webauthn-server/JavaDoc/webauthn-server- class uses `CertPathValidator.getInstance("PKIX")` to retrieve a `CertPathValidator` instance. If you need to override any aspect of certificate path validation, such as CRL retrieval or OCSP, you may provide a custom `CertPathValidator` provider for the `"PKIX"` algorithm. + + +== Using enterprise attestation + +link:https://www.w3.org/TR/2021/REC-webauthn-2-20210408/#dom-attestationconveyancepreference-enterprise[Enterprise attestation] +is the idea of having attestation statements contain a unique identifier such as a device serial number. +For example, this identifier could be used by an employer provisioning security keys for their employees. +By recording which employee has which security key serial numbers, +the employer can automatically trust the employee upon successful WebAuthn registration +without having to first authenticate the employee by other means. + +Because enterprise attestation by design introduces powerful user tracking, +it is only allowed in certain contexts and is otherwise blocked by the client. +See the +link:https://fidoalliance.org/specs/fido-v2.2-rd-20230321/fido-client-to-authenticator-protocol-v2.2-rd-20230321.html#sctn-feature-descriptions-enterp-attstn[CTAP2 section on Enterprise Attestation] +for guidance on how to enable enterprise attestation - +this typically involves a special agreement with an authenticator or client vendor. + +At time of writing, there is only one standardized way to convey an enterprise attestation identifer: + +- An X.509 certificate extension with OID `1.3.6.1.4.1.45724.1.1.2 (id-fido-gen-ce-sernum)` + MAY indicate a unique octet string such as a serial number + see + https://w3c.github.io/webauthn/#sctn-enterprise-packed-attestation-cert-requirements[Web Authentication Level 3 §8.2.2. Certificate Requirements for Enterprise Packed Attestation Statements]. + The `CertificateUtil` class provides `parseFidoSernumExtension` helper function for parsing this extension if present. diff --git a/webauthn-server-attestation/src/main/java/com/yubico/webauthn/attestation/CertificateUtil.java b/webauthn-server-attestation/src/main/java/com/yubico/webauthn/attestation/CertificateUtil.java new file mode 100644 index 000000000..a91216f53 --- /dev/null +++ b/webauthn-server-attestation/src/main/java/com/yubico/webauthn/attestation/CertificateUtil.java @@ -0,0 +1,87 @@ +// Copyright (c) 2024, Yubico AB +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation +// and/or other materials provided with the distribution. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package com.yubico.webauthn.attestation; + +import com.yubico.internal.util.BinaryUtil; +import com.yubico.webauthn.RegistrationResult; +import com.yubico.webauthn.RelyingParty; +import com.yubico.webauthn.data.ByteArray; +import java.nio.ByteBuffer; +import java.security.cert.X509Certificate; +import java.util.Optional; +import lombok.experimental.UtilityClass; + +@UtilityClass +public class CertificateUtil { + public static final String ID_FIDO_GEN_CE_SERNUM = "1.3.6.1.4.1.45724.1.1.2"; + + private static byte[] parseSerNum(byte[] bytes) { + try { + byte[] extensionValueContents = BinaryUtil.parseDerOctetString(bytes, 0).result; + byte[] sernumContents = BinaryUtil.parseDerOctetString(extensionValueContents, 0).result; + return sernumContents; + } catch (Exception e) { + throw new IllegalArgumentException( + "X.509 extension 1.3.6.1.4.1.45724.1.1.2 (id-fido-gen-ce-sernum) is not valid.", e); + } + } + + /** + * Attempt to parse the FIDO enterprise attestation serial number extension from the given + * certificate. + * + *

NOTE: This function does NOT verify that the returned serial number is authentic and + * trustworthy. See: + * + *

+ * + *

Note that the serial number is an opaque byte array with no defined structure in general. + * For example, the byte array may or may not represent a big-endian integer depending on the + * authenticator vendor. + * + *

The extension has OID 1.3.6.1.4.1.45724.1.1.2 (id-fido-gen-ce-sernum). + * + * @param cert the attestation certificate to parse the serial number from. + * @return The serial number, if present and validly encoded. Empty if the extension is not + * present in the certificate. + * @throws IllegalArgumentException if the extension is present but not validly encoded. + * @see RelyingParty.RelyingPartyBuilder#attestationTrustSource(AttestationTrustSource) + * @see RegistrationResult#isAttestationTrusted() + * @see RelyingParty.RelyingPartyBuilder#allowUntrustedAttestation(boolean) + * @see WebAuthn + * Level 3 §8.2.2. Certificate Requirements for Enterprise Packed Attestation Statements + * @see ByteBuffer#getLong() + */ + public static Optional parseFidoSernumExtension(X509Certificate cert) { + return Optional.ofNullable(cert.getExtensionValue(ID_FIDO_GEN_CE_SERNUM)) + .map(CertificateUtil::parseSerNum) + .map(ByteArray::new); + } +} diff --git a/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala b/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala new file mode 100644 index 000000000..099d5bc83 --- /dev/null +++ b/webauthn-server-attestation/src/test/scala/com/yubico/webauthn/attestation/CertificateUtilSpec.scala @@ -0,0 +1,119 @@ +// Copyright (c) 2024, Yubico AB +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation +// and/or other materials provided with the distribution. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package com.yubico.webauthn.attestation + +import com.yubico.internal.util.BinaryUtil +import com.yubico.internal.util.CertificateParser +import com.yubico.webauthn.TestAuthenticator +import com.yubico.webauthn.data.ByteArray +import com.yubico.webauthn.data.Generators.arbitraryByteArray +import com.yubico.webauthn.data.Generators.shrinkByteArray +import org.bouncycastle.asn1.DEROctetString +import org.junit.runner.RunWith +import org.scalatest.funspec.AnyFunSpec +import org.scalatest.matchers.should.Matchers +import org.scalatestplus.junit.JUnitRunner +import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks + +import java.security.cert.X509Certificate +import scala.jdk.OptionConverters.RichOptional + +@RunWith(classOf[JUnitRunner]) +class CertificateUtilSpec + extends AnyFunSpec + with Matchers + with ScalaCheckDrivenPropertyChecks { + describe("parseFidoSerNumExtension") { + val idFidoGenCeSernum = "1.3.6.1.4.1.45724.1.1.2" + + it("correctly parses the id-fido-gen-ce-sernum extension.") { + forAll( + // 500-byte long serial numbers are not realistic, but would be valid DER data. + sizeRange(500) + ) { + // Using Array[Byte] here causes an (almost) infinite loop in the shrinker in case of failure. + // See: https://github.com/typelevel/scalacheck/issues/968#issuecomment-2594018791 + sernum: ByteArray => + val (cert, _): (X509Certificate, _) = TestAuthenticator + .generateAttestationCertificate( + extensions = List( + ( + idFidoGenCeSernum, + false, + new DEROctetString(sernum.getBytes), + ) + ) + ) + + val result = + CertificateUtil + .parseFidoSernumExtension(cert) + .toScala + result should equal(Some(sernum)) + } + } + + it("returns empty when cert has no id-fido-gen-ce-sernum extension.") { + val (cert, _): (X509Certificate, _) = + TestAuthenticator.generateAttestationCertificate(extensions = Nil) + val result = + CertificateUtil + .parseFidoSernumExtension(cert) + .toScala + result should be(None) + } + + it("correctly parses the serial number from a real YubiKey enterprise attestation certificate.") { + val cert = CertificateParser.parsePem("""-----BEGIN CERTIFICATE----- + |MIIC8zCCAdugAwIBAgIJAKr/KiUzkKrgMA0GCSqGSIb3DQEBCwUAMC8xLTArBgNV + |BAMMJFl1YmljbyBGSURPIFJvb3QgQ0EgU2VyaWFsIDQ1MDIwMzU1NjAgFw0yNDA1 + |MDEwMDAwMDBaGA8yMDYwMDQzMDAwMDAwMFowcDELMAkGA1UEBhMCU0UxEjAQBgNV + |BAoMCVl1YmljbyBBQjEiMCAGA1UECwwZQXV0aGVudGljYXRvciBBdHRlc3RhdGlv + |bjEpMCcGA1UEAwwgWXViaWNvIEZpZG8gRUUgKFNlcmlhbD0yODI5OTAwMykwWTAT + |BgcqhkjOPQIBBggqhkjOPQMBBwNCAATImNkI1cwqkW5B3qNrY3pc8zBLhvGyfyfS + |WCLrODSe8xaRPcZoXYGGwZ0Ua/Hp5nxyD+w1hjS9O9gx8mSDvp+zo4GZMIGWMBMG + |CisGAQQBgsQKDQEEBQQDBQcBMBUGCysGAQQBguUcAQECBAYEBAGvzvswIgYJKwYB + |BAGCxAoCBBUxLjMuNi4xLjQuMS40MTQ4Mi4xLjcwEwYLKwYBBAGC5RwCAQEEBAMC + |AiQwIQYLKwYBBAGC5RwBAQQEEgQQuQ59wTFuT+6iWlamZqZw/jAMBgNVHRMBAf8E + |AjAAMA0GCSqGSIb3DQEBCwUAA4IBAQAFEMXw1HUDC/TfMFxp2ZrmgQLa5fmzs2Jh + |C22TUAuY26CYT5dmMUsS5aJd96MtC5gKS57h1auGr2Y4FMxQS9FJHzXAzAtYJfKh + |j1uS2BSTXf9GULdFKcWvvv50kJ2VmXLge3UgHDBJ8LwrDlZFyISeMZ8jSbmrNu2c + |8uNBBSfqdor+5H91L1brC9yYneHdxYk6YiEvDBxWjiMa9DQuySh/4a21nasgt0cB + |prEbfFOLRDm7GDsRTPyefZjZ84yi4Ao+15x+7DM0UwudEVtjOWB2BJtJyxIkXXNF + |iWFZaxezq0Xt2Kl2sYnMR97ynw/U4TzZDjgb56pN81oKz8Od9B/u + |-----END CERTIFICATE-----""".stripMargin) + + val result = + CertificateUtil + .parseFidoSernumExtension(cert) + .toScala + + result should equal(Some(ByteArray.fromHex("01AFCEFB"))) + + // For YubiKeys, the sernum octet string represents a big-endian integer + BinaryUtil.getUint32(result.get.getBytes) should be(28299003) + } + } +} diff --git a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/Generators.scala b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/Generators.scala index 8a98c17b4..787d90159 100644 --- a/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/Generators.scala +++ b/webauthn-server-core/src/test/scala/com/yubico/webauthn/data/Generators.scala @@ -52,6 +52,7 @@ import com.yubico.webauthn.extension.uvm.Generators.userVerificationMethod import org.scalacheck.Arbitrary import org.scalacheck.Arbitrary.arbitrary import org.scalacheck.Gen +import org.scalacheck.Shrink import java.net.URL import java.security.interfaces.ECPublicKey @@ -349,6 +350,35 @@ object Generators { implicit val arbitraryByteArray: Arbitrary[ByteArray] = Arbitrary( arbitrary[Array[Byte]].map(new ByteArray(_)) ) + implicit val shrinkByteArray: Shrink[ByteArray] = Shrink({ b => + // Attempt to remove as much as possible at a time: first the back half, then the back 1/4, then the back 1/8, etc. + val prefixes = Stream.unfold(0) { len => + val nextLen = (len + b.size()) / 2 + if (nextLen == len || nextLen == b.size()) { + None + } else { + Some((new ByteArray(b.getBytes.slice(0, nextLen)), nextLen)) + } + } + + // Same but removing from the front instead. + val suffixes = Stream.unfold(0) { len => + val nextLen = (len + b.size()) / 2 + if (nextLen == len || nextLen == b.size()) { + None + } else { + Some( + ( + new ByteArray(b.getBytes.slice(b.size() - nextLen, b.size())), + nextLen, + ) + ) + } + } + + prefixes concat suffixes + }) + def byteArray(maxSize: Int): Gen[ByteArray] = Gen.listOfN(maxSize, arbitrary[Byte]).map(ba => new ByteArray(ba.toArray))