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:
+ *
+ *
+ * - {@link RelyingParty.RelyingPartyBuilder#attestationTrustSource(AttestationTrustSource)}
+ *
- {@link RegistrationResult#isAttestationTrusted()}
+ *
- {@link RelyingParty.RelyingPartyBuilder#allowUntrustedAttestation(boolean)}
+ *
+ *
+ * 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))