Skip to content

Commit 798ec03

Browse files
committed
Merge branch 'parse-sernum'
2 parents d33555e + 1be5ddd commit 798ec03

File tree

5 files changed

+265
-1
lines changed

5 files changed

+265
-1
lines changed

NEWS

+2
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ New features:
6868
* `FidoMetadataDownloader` now parses the CRLDistributionPoints extension on the
6969
application level, so the `com.sun.security.enableCRLDP=true` system property
7070
setting is no longer necessary.
71+
* Added helper function `CertificateUtil.parseFidoSernumExtension` for parsing
72+
serial number from enterprise attestation certificates.
7173

7274

7375
== Version 2.5.4 ==

webauthn-server-attestation/README.adoc

+27-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ An optional module which extends link:../[`webauthn-server-core`]
99
with a trust root source for verifying
1010
https://www.w3.org/TR/2021/REC-webauthn-2-20210408/#sctn-attestation[attestation statements],
1111
by interfacing with the https://fidoalliance.org/metadata/[FIDO Metadata Service].
12+
The module also provides helper functions for inspecting properties of attestation certificates.
1213

1314

1415
*Table of contents*
@@ -17,7 +18,7 @@ toc::[]
1718

1819
== Features
1920

20-
This module does four things:
21+
The FIDO MDS integration does four things:
2122

2223
- Download, verify and cache metadata BLOBs from the FIDO Metadata Service.
2324
- 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-
414415
class uses `CertPathValidator.getInstance("PKIX")` to retrieve a `CertPathValidator` instance.
415416
If you need to override any aspect of certificate path validation,
416417
such as CRL retrieval or OCSP, you may provide a custom `CertPathValidator` provider for the `"PKIX"` algorithm.
418+
419+
420+
== Using enterprise attestation
421+
422+
link:https://www.w3.org/TR/2021/REC-webauthn-2-20210408/#dom-attestationconveyancepreference-enterprise[Enterprise attestation]
423+
is the idea of having attestation statements contain a unique identifier such as a device serial number.
424+
For example, this identifier could be used by an employer provisioning security keys for their employees.
425+
By recording which employee has which security key serial numbers,
426+
the employer can automatically trust the employee upon successful WebAuthn registration
427+
without having to first authenticate the employee by other means.
428+
429+
Because enterprise attestation by design introduces powerful user tracking,
430+
it is only allowed in certain contexts and is otherwise blocked by the client.
431+
See the
432+
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]
433+
for guidance on how to enable enterprise attestation -
434+
this typically involves a special agreement with an authenticator or client vendor.
435+
436+
At time of writing, there is only one standardized way to convey an enterprise attestation identifer:
437+
438+
- An X.509 certificate extension with OID `1.3.6.1.4.1.45724.1.1.2 (id-fido-gen-ce-sernum)`
439+
MAY indicate a unique octet string such as a serial number
440+
see
441+
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].
442+
The `CertificateUtil` class provides `parseFidoSernumExtension` helper function for parsing this extension if present.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// Copyright (c) 2024, Yubico AB
2+
// All rights reserved.
3+
//
4+
// Redistribution and use in source and binary forms, with or without
5+
// modification, are permitted provided that the following conditions are met:
6+
//
7+
// 1. Redistributions of source code must retain the above copyright notice, this
8+
// list of conditions and the following disclaimer.
9+
//
10+
// 2. Redistributions in binary form must reproduce the above copyright notice,
11+
// this list of conditions and the following disclaimer in the documentation
12+
// and/or other materials provided with the distribution.
13+
//
14+
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
15+
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
16+
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
17+
// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
18+
// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
19+
// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
20+
// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
21+
// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
22+
// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
23+
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
24+
25+
package com.yubico.webauthn.attestation;
26+
27+
import com.yubico.internal.util.BinaryUtil;
28+
import com.yubico.webauthn.RegistrationResult;
29+
import com.yubico.webauthn.RelyingParty;
30+
import com.yubico.webauthn.data.ByteArray;
31+
import java.nio.ByteBuffer;
32+
import java.security.cert.X509Certificate;
33+
import java.util.Optional;
34+
import lombok.experimental.UtilityClass;
35+
36+
@UtilityClass
37+
public class CertificateUtil {
38+
public static final String ID_FIDO_GEN_CE_SERNUM = "1.3.6.1.4.1.45724.1.1.2";
39+
40+
private static byte[] parseSerNum(byte[] bytes) {
41+
try {
42+
byte[] extensionValueContents = BinaryUtil.parseDerOctetString(bytes, 0).result;
43+
byte[] sernumContents = BinaryUtil.parseDerOctetString(extensionValueContents, 0).result;
44+
return sernumContents;
45+
} catch (Exception e) {
46+
throw new IllegalArgumentException(
47+
"X.509 extension 1.3.6.1.4.1.45724.1.1.2 (id-fido-gen-ce-sernum) is not valid.", e);
48+
}
49+
}
50+
51+
/**
52+
* Attempt to parse the FIDO enterprise attestation serial number extension from the given
53+
* certificate.
54+
*
55+
* <p>NOTE: This function does NOT verify that the returned serial number is authentic and
56+
* trustworthy. See:
57+
*
58+
* <ul>
59+
* <li>{@link RelyingParty.RelyingPartyBuilder#attestationTrustSource(AttestationTrustSource)}
60+
* <li>{@link RegistrationResult#isAttestationTrusted()}
61+
* <li>{@link RelyingParty.RelyingPartyBuilder#allowUntrustedAttestation(boolean)}
62+
* </ul>
63+
*
64+
* <p>Note that the serial number is an opaque byte array with no defined structure in general.
65+
* For example, the byte array may or may not represent a big-endian integer depending on the
66+
* authenticator vendor.
67+
*
68+
* <p>The extension has OID <code>1.3.6.1.4.1.45724.1.1.2 (id-fido-gen-ce-sernum)</code>.
69+
*
70+
* @param cert the attestation certificate to parse the serial number from.
71+
* @return The serial number, if present and validly encoded. Empty if the extension is not
72+
* present in the certificate.
73+
* @throws IllegalArgumentException if the extension is present but not validly encoded.
74+
* @see RelyingParty.RelyingPartyBuilder#attestationTrustSource(AttestationTrustSource)
75+
* @see RegistrationResult#isAttestationTrusted()
76+
* @see RelyingParty.RelyingPartyBuilder#allowUntrustedAttestation(boolean)
77+
* @see <a
78+
* href="https://w3c.github.io/webauthn/#sctn-enterprise-packed-attestation-cert-requirements">WebAuthn
79+
* Level 3 §8.2.2. Certificate Requirements for Enterprise Packed Attestation Statements</a>
80+
* @see ByteBuffer#getLong()
81+
*/
82+
public static Optional<ByteArray> parseFidoSernumExtension(X509Certificate cert) {
83+
return Optional.ofNullable(cert.getExtensionValue(ID_FIDO_GEN_CE_SERNUM))
84+
.map(CertificateUtil::parseSerNum)
85+
.map(ByteArray::new);
86+
}
87+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// Copyright (c) 2024, Yubico AB
2+
// All rights reserved.
3+
//
4+
// Redistribution and use in source and binary forms, with or without
5+
// modification, are permitted provided that the following conditions are met:
6+
//
7+
// 1. Redistributions of source code must retain the above copyright notice, this
8+
// list of conditions and the following disclaimer.
9+
//
10+
// 2. Redistributions in binary form must reproduce the above copyright notice,
11+
// this list of conditions and the following disclaimer in the documentation
12+
// and/or other materials provided with the distribution.
13+
//
14+
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
15+
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
16+
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
17+
// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
18+
// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
19+
// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
20+
// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
21+
// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
22+
// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
23+
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
24+
25+
package com.yubico.webauthn.attestation
26+
27+
import com.yubico.internal.util.BinaryUtil
28+
import com.yubico.internal.util.CertificateParser
29+
import com.yubico.webauthn.TestAuthenticator
30+
import com.yubico.webauthn.data.ByteArray
31+
import com.yubico.webauthn.data.Generators.arbitraryByteArray
32+
import com.yubico.webauthn.data.Generators.shrinkByteArray
33+
import org.bouncycastle.asn1.DEROctetString
34+
import org.junit.runner.RunWith
35+
import org.scalatest.funspec.AnyFunSpec
36+
import org.scalatest.matchers.should.Matchers
37+
import org.scalatestplus.junit.JUnitRunner
38+
import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks
39+
40+
import java.security.cert.X509Certificate
41+
import scala.jdk.OptionConverters.RichOptional
42+
43+
@RunWith(classOf[JUnitRunner])
44+
class CertificateUtilSpec
45+
extends AnyFunSpec
46+
with Matchers
47+
with ScalaCheckDrivenPropertyChecks {
48+
describe("parseFidoSerNumExtension") {
49+
val idFidoGenCeSernum = "1.3.6.1.4.1.45724.1.1.2"
50+
51+
it("correctly parses the id-fido-gen-ce-sernum extension.") {
52+
forAll(
53+
// 500-byte long serial numbers are not realistic, but would be valid DER data.
54+
sizeRange(500)
55+
) {
56+
// Using Array[Byte] here causes an (almost) infinite loop in the shrinker in case of failure.
57+
// See: https://github.com/typelevel/scalacheck/issues/968#issuecomment-2594018791
58+
sernum: ByteArray =>
59+
val (cert, _): (X509Certificate, _) = TestAuthenticator
60+
.generateAttestationCertificate(
61+
extensions = List(
62+
(
63+
idFidoGenCeSernum,
64+
false,
65+
new DEROctetString(sernum.getBytes),
66+
)
67+
)
68+
)
69+
70+
val result =
71+
CertificateUtil
72+
.parseFidoSernumExtension(cert)
73+
.toScala
74+
result should equal(Some(sernum))
75+
}
76+
}
77+
78+
it("returns empty when cert has no id-fido-gen-ce-sernum extension.") {
79+
val (cert, _): (X509Certificate, _) =
80+
TestAuthenticator.generateAttestationCertificate(extensions = Nil)
81+
val result =
82+
CertificateUtil
83+
.parseFidoSernumExtension(cert)
84+
.toScala
85+
result should be(None)
86+
}
87+
88+
it("correctly parses the serial number from a real YubiKey enterprise attestation certificate.") {
89+
val cert = CertificateParser.parsePem("""-----BEGIN CERTIFICATE-----
90+
|MIIC8zCCAdugAwIBAgIJAKr/KiUzkKrgMA0GCSqGSIb3DQEBCwUAMC8xLTArBgNV
91+
|BAMMJFl1YmljbyBGSURPIFJvb3QgQ0EgU2VyaWFsIDQ1MDIwMzU1NjAgFw0yNDA1
92+
|MDEwMDAwMDBaGA8yMDYwMDQzMDAwMDAwMFowcDELMAkGA1UEBhMCU0UxEjAQBgNV
93+
|BAoMCVl1YmljbyBBQjEiMCAGA1UECwwZQXV0aGVudGljYXRvciBBdHRlc3RhdGlv
94+
|bjEpMCcGA1UEAwwgWXViaWNvIEZpZG8gRUUgKFNlcmlhbD0yODI5OTAwMykwWTAT
95+
|BgcqhkjOPQIBBggqhkjOPQMBBwNCAATImNkI1cwqkW5B3qNrY3pc8zBLhvGyfyfS
96+
|WCLrODSe8xaRPcZoXYGGwZ0Ua/Hp5nxyD+w1hjS9O9gx8mSDvp+zo4GZMIGWMBMG
97+
|CisGAQQBgsQKDQEEBQQDBQcBMBUGCysGAQQBguUcAQECBAYEBAGvzvswIgYJKwYB
98+
|BAGCxAoCBBUxLjMuNi4xLjQuMS40MTQ4Mi4xLjcwEwYLKwYBBAGC5RwCAQEEBAMC
99+
|AiQwIQYLKwYBBAGC5RwBAQQEEgQQuQ59wTFuT+6iWlamZqZw/jAMBgNVHRMBAf8E
100+
|AjAAMA0GCSqGSIb3DQEBCwUAA4IBAQAFEMXw1HUDC/TfMFxp2ZrmgQLa5fmzs2Jh
101+
|C22TUAuY26CYT5dmMUsS5aJd96MtC5gKS57h1auGr2Y4FMxQS9FJHzXAzAtYJfKh
102+
|j1uS2BSTXf9GULdFKcWvvv50kJ2VmXLge3UgHDBJ8LwrDlZFyISeMZ8jSbmrNu2c
103+
|8uNBBSfqdor+5H91L1brC9yYneHdxYk6YiEvDBxWjiMa9DQuySh/4a21nasgt0cB
104+
|prEbfFOLRDm7GDsRTPyefZjZ84yi4Ao+15x+7DM0UwudEVtjOWB2BJtJyxIkXXNF
105+
|iWFZaxezq0Xt2Kl2sYnMR97ynw/U4TzZDjgb56pN81oKz8Od9B/u
106+
|-----END CERTIFICATE-----""".stripMargin)
107+
108+
val result =
109+
CertificateUtil
110+
.parseFidoSernumExtension(cert)
111+
.toScala
112+
113+
result should equal(Some(ByteArray.fromHex("01AFCEFB")))
114+
115+
// For YubiKeys, the sernum octet string represents a big-endian integer
116+
BinaryUtil.getUint32(result.get.getBytes) should be(28299003)
117+
}
118+
}
119+
}

webauthn-server-core/src/test/scala/com/yubico/webauthn/data/Generators.scala

+30
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import com.yubico.webauthn.extension.uvm.Generators.userVerificationMethod
5252
import org.scalacheck.Arbitrary
5353
import org.scalacheck.Arbitrary.arbitrary
5454
import org.scalacheck.Gen
55+
import org.scalacheck.Shrink
5556

5657
import java.net.URL
5758
import java.security.interfaces.ECPublicKey
@@ -349,6 +350,35 @@ object Generators {
349350
implicit val arbitraryByteArray: Arbitrary[ByteArray] = Arbitrary(
350351
arbitrary[Array[Byte]].map(new ByteArray(_))
351352
)
353+
implicit val shrinkByteArray: Shrink[ByteArray] = Shrink({ b =>
354+
// 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.
355+
val prefixes = Stream.unfold(0) { len =>
356+
val nextLen = (len + b.size()) / 2
357+
if (nextLen == len || nextLen == b.size()) {
358+
None
359+
} else {
360+
Some((new ByteArray(b.getBytes.slice(0, nextLen)), nextLen))
361+
}
362+
}
363+
364+
// Same but removing from the front instead.
365+
val suffixes = Stream.unfold(0) { len =>
366+
val nextLen = (len + b.size()) / 2
367+
if (nextLen == len || nextLen == b.size()) {
368+
None
369+
} else {
370+
Some(
371+
(
372+
new ByteArray(b.getBytes.slice(b.size() - nextLen, b.size())),
373+
nextLen,
374+
)
375+
)
376+
}
377+
}
378+
379+
prefixes concat suffixes
380+
})
381+
352382
def byteArray(maxSize: Int): Gen[ByteArray] =
353383
Gen.listOfN(maxSize, arbitrary[Byte]).map(ba => new ByteArray(ba.toArray))
354384

0 commit comments

Comments
 (0)