From b461d6839f260c88b3587ef1ceb0633597439778 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Wed, 2 Sep 2026 12:17:36 +0200 Subject: [PATCH 1/2] Remove unnecessary internal ByteArray wrapping in FidoMetadataDownloader --- ...idoMetadataDownloaderIntegrationTest.scala | 2 +- .../fido/metadata/FidoMetadataDownloader.java | 56 +++++++++---------- 2 files changed, 28 insertions(+), 30 deletions(-) diff --git a/webauthn-server-attestation/src/integrationTest/scala/com/yubico/fido/metadata/FidoMetadataDownloaderIntegrationTest.scala b/webauthn-server-attestation/src/integrationTest/scala/com/yubico/fido/metadata/FidoMetadataDownloaderIntegrationTest.scala index 709efe57d..1c5d6aea5 100644 --- a/webauthn-server-attestation/src/integrationTest/scala/com/yubico/fido/metadata/FidoMetadataDownloaderIntegrationTest.scala +++ b/webauthn-server-attestation/src/integrationTest/scala/com/yubico/fido/metadata/FidoMetadataDownloaderIntegrationTest.scala @@ -46,7 +46,7 @@ class FidoMetadataDownloaderIntegrationTest .fetchHeaderCertChain( trustRootCert, downloader - .parseBlob(TestCaches.blobCache.get) + .parseBlob(TestCaches.blobCache.get.getBytes) .getBlob .getHeader, ) diff --git a/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/FidoMetadataDownloader.java b/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/FidoMetadataDownloader.java index f69ccee2b..74aa9c5d8 100644 --- a/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/FidoMetadataDownloader.java +++ b/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/FidoMetadataDownloader.java @@ -935,7 +935,7 @@ private Optional refreshBlobInternal( return cached; } else { - ByteArray downloadedBytes = downloadResult.getContent(); + byte[] downloadedBytes = downloadResult.getContent(); final MetadataBLOB downloadedBlob = parseAndVerifyBlob(downloadedBytes, trustRoot); log.debug("New BLOB downloaded."); @@ -956,12 +956,12 @@ private Optional refreshBlobInternal( log.debug("Writing new BLOB to cache..."); if (blobCacheFile != null) { try (FileOutputStream f = new FileOutputStream(blobCacheFile)) { - f.write(downloadedBytes.getBytes()); + f.write(downloadedBytes); } } if (blobCacheConsumer != null) { - blobCacheConsumer.accept(downloadedBytes); + blobCacheConsumer.accept(new ByteArray(downloadedBytes)); } return Optional.of(downloadedBlob); @@ -1018,11 +1018,11 @@ private X509Certificate retrieveTrustRootCert() X509Certificate cert = null; if (cachedContents.isPresent()) { - final ByteArray verifiedCachedContents = verifyHash(cachedContents.get(), trustRootSha256); + final byte[] verifiedCachedContents = + verifyHash(cachedContents.get().getBytes(), trustRootSha256); if (verifiedCachedContents != null) { try { - final X509Certificate cachedCert = - CertificateParser.parseDer(verifiedCachedContents.getBytes()); + final X509Certificate cachedCert = CertificateParser.parseDer(verifiedCachedContents); cachedCert.checkValidity(Date.from(clock.instant())); cert = cachedCert; } catch (CertificateException e) { @@ -1032,23 +1032,23 @@ private X509Certificate retrieveTrustRootCert() } if (cert == null) { - final ByteArray downloaded = verifyHash(download(trustRootUrl), trustRootSha256); + final byte[] downloaded = verifyHash(download(trustRootUrl), trustRootSha256); if (downloaded == null) { throw new DigestException( "Downloaded trust root certificate matches none of the acceptable hashes."); } - cert = CertificateParser.parseDer(downloaded.getBytes()); + cert = CertificateParser.parseDer(downloaded); cert.checkValidity(Date.from(clock.instant())); if (trustRootCacheFile != null) { try (FileOutputStream f = new FileOutputStream(trustRootCacheFile)) { - f.write(downloaded.getBytes()); + f.write(downloaded); } } if (trustRootCacheConsumer != null) { - trustRootCacheConsumer.accept(downloaded); + trustRootCacheConsumer.accept(new ByteArray(downloaded)); } } @@ -1083,8 +1083,7 @@ private Optional loadExplicitBlobOnly(X509Certificate trustRootCer FidoMetadataDownloaderException { if (blobJwt != null) { return Optional.of( - parseAndMaybeVerifyBlob( - new ByteArray(blobJwt.getBytes(StandardCharsets.UTF_8)), trustRootCertificate)); + parseAndMaybeVerifyBlob(blobJwt.getBytes(StandardCharsets.UTF_8), trustRootCertificate)); } else { return Optional.empty(); @@ -1110,7 +1109,7 @@ private Optional loadCachedBlobOnly(X509Certificate trustRootCerti return cachedContents.map( cached -> { try { - return parseAndMaybeVerifyBlob(cached, trustRootCertificate); + return parseAndMaybeVerifyBlob(cached.getBytes(), trustRootCertificate); } catch (Exception e) { log.warn("Failed to read or parse cached BLOB.", e); return null; @@ -1121,7 +1120,7 @@ private Optional loadCachedBlobOnly(X509Certificate trustRootCerti Optional readCacheFile(File cacheFile) throws IOException { if (cacheFile.exists() && cacheFile.canRead() && cacheFile.isFile()) { try (FileInputStream f = new FileInputStream(cacheFile)) { - return Optional.of(readAll(f)); + return Optional.of(new ByteArray(readAll(f))); } catch (FileNotFoundException e) { throw new RuntimeException( "This exception should be impossible, please file a bug report.", e); @@ -1131,7 +1130,7 @@ Optional readCacheFile(File cacheFile) throws IOException { } } - private ByteArray download(URL url) throws IOException { + private byte[] download(URL url) throws IOException { final DownloadResult downloadResult = download(url, Optional.empty()); if (downloadResult.isOk()) { return downloadResult.getContent(); @@ -1203,7 +1202,7 @@ private DownloadResult download(URL url, Optional etag) throws IOExcepti return DownloadResult.ok(readAll(conn.getInputStream())); } - private MetadataBLOB parseAndVerifyBlob(ByteArray jwt, X509Certificate trustRootCertificate) + private MetadataBLOB parseAndVerifyBlob(byte[] jwt, X509Certificate trustRootCertificate) throws CertPathValidatorException, InvalidAlgorithmParameterException, CertificateException, @@ -1216,7 +1215,7 @@ private MetadataBLOB parseAndVerifyBlob(ByteArray jwt, X509Certificate trustRoot return verifyBlob(parseBlob(jwt), trustRootCertificate); } - private MetadataBLOB parseAndMaybeVerifyBlob(ByteArray jwt, X509Certificate trustRootCertificate) + private MetadataBLOB parseAndMaybeVerifyBlob(byte[] jwt, X509Certificate trustRootCertificate) throws CertPathValidatorException, InvalidAlgorithmParameterException, CertificateException, @@ -1307,8 +1306,8 @@ private MetadataBLOB verifyBlob(ParseResult parseResult, X509Certificate trustRo "Exited without finding a certification path or failing to validate any certification path. This should be impossible, please file a bug report."); } - ParseResult parseBlob(ByteArray jwt) throws IOException, Base64UrlException { - Scanner s = new Scanner(new ByteArrayInputStream(jwt.getBytes())).useDelimiter("\\."); + ParseResult parseBlob(byte[] jwt) throws IOException, Base64UrlException { + Scanner s = new Scanner(new ByteArrayInputStream(jwt)).useDelimiter("\\."); final ByteArray jwtHeader = ByteArray.fromBase64Url(s.next()); final ByteArray jwtPayload = ByteArray.fromBase64Url(s.next()); final ByteArray jwtSignature = ByteArray.fromBase64Url(s.next()); @@ -1335,18 +1334,18 @@ static ObjectMapper defaultPayloadJsonMapper() { return JacksonCodecs.jsonWithDefaultEnums(); } - private static ByteArray readAll(InputStream is) throws IOException { - return new ByteArray(BinaryUtil.readAll(is)); + private static byte[] readAll(InputStream is) throws IOException { + return BinaryUtil.readAll(is); } /** * @return contents if its SHA-256 hash matches any element of * acceptedCertSha256, otherwise null. */ - private static ByteArray verifyHash(ByteArray contents, Set acceptedCertSha256) + private static byte[] verifyHash(byte[] contents, Set acceptedCertSha256) throws NoSuchAlgorithmException { MessageDigest digest = MessageDigest.getInstance("SHA-256"); - final ByteArray hash = new ByteArray(digest.digest(contents.getBytes())); + final ByteArray hash = new ByteArray(digest.digest(contents)); if (acceptedCertSha256.stream().anyMatch(hash::equals)) { return contents; } else { @@ -1379,7 +1378,7 @@ List fetchHeaderCertChain( } List certs = new ArrayList<>(); for (String pem : - new String(download(x5u).getBytes(), StandardCharsets.UTF_8) + new String(download(x5u), StandardCharsets.UTF_8) .trim() .split("\\n+-----END CERTIFICATE-----\\n+-----BEGIN CERTIFICATE-----\\n+")) { X509Certificate x509Certificate = CertificateParser.parsePem(pem); @@ -1439,8 +1438,7 @@ private Optional fetchCrlDistributionPoints( log.debug("Attempting to download CRL distribution point: {}", crldpUrl); try { return Optional.of( - certFactory.generateCRL( - new ByteArrayInputStream(download(crldpUrl).getBytes()))); + certFactory.generateCRL(new ByteArrayInputStream(download(crldpUrl)))); } catch (CRLException e) { log.warn("Failed to import CRL from distribution point: {}", crldpUrl, e); return Optional.empty(); @@ -1461,17 +1459,17 @@ private Optional fetchCrlDistributionPoints( @AllArgsConstructor(access = AccessLevel.PRIVATE) private static class DownloadResult { private boolean notModified; - private Optional content; + private Optional content; static DownloadResult notModified() { return new DownloadResult(true, Optional.empty()); } - static DownloadResult ok(@NonNull ByteArray content) { + static DownloadResult ok(@NonNull byte[] content) { return new DownloadResult(false, Optional.of(content)); } - ByteArray getContent() { + byte[] getContent() { return content.get(); } From 4a244081a1b99c92345355d6fe160a5b4435bc14 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 3 Sep 2026 18:03:14 +0200 Subject: [PATCH 2/2] Support multiple FIDO MDS trust roots --- NEWS | 21 + ...idoMetadataDownloaderIntegrationTest.scala | 14 +- .../fido/metadata/FidoMetadataDownloader.java | 313 ++++++++++----- .../metadata/FidoMetadataDownloaderSpec.scala | 366 ++++++++++++++++-- 4 files changed, 576 insertions(+), 138 deletions(-) diff --git a/NEWS b/NEWS index 3a1cd8e1a..639ae4f57 100644 --- a/NEWS +++ b/NEWS @@ -22,6 +22,27 @@ Changes: root cert in the trust path during a transition grace period before MDS migrates to a new trust root. See: https://github.com/Yubico/java-webauthn-server/issues/498 +* The internal format of the `FidoMetadataDownloader` trust root cache has + changed and is now opaque. This will invalidate the trust root cache and cause + a re-download of the trust root certificate. +* The `FidoMetadataDownloader` trust root cache will now be invalidated and + cause a re-download if the set of trust root download URLs changes. Changing + the set of acceptable SHA-256 hashes does not directly invalidate the cache, + but may indirectly invalidate the cache unless each cached certificate matches + some of the given SHA-256 hashes. +* Parameters `getCachedTrustRootCert` and `writeCachedTrustRootCert` in + `FidoMetadataDownloader` builder method `useTrustRootCache` renamed to + `getCachedTrustRootCerts` and `writeCachedTrustRootCerts` in JavaDoc. + +New features: + +* New `FidoMetadataDownloader` builder methods. + These enable trusting multiple trust roots, for example to ensure a smooth + transition when MDS migrates to a new trust root: + ** `downloadTrustRoots(List, Set)` as alternative to + `downloadTrustRoot(URL, Set)` + ** `useTrustRoots(Set)` as alternative to + `useTrustRoot(X509Certificate)` == Version 2.9.0 == diff --git a/webauthn-server-attestation/src/integrationTest/scala/com/yubico/fido/metadata/FidoMetadataDownloaderIntegrationTest.scala b/webauthn-server-attestation/src/integrationTest/scala/com/yubico/fido/metadata/FidoMetadataDownloaderIntegrationTest.scala index 1c5d6aea5..7e67524b2 100644 --- a/webauthn-server-attestation/src/integrationTest/scala/com/yubico/fido/metadata/FidoMetadataDownloaderIntegrationTest.scala +++ b/webauthn-server-attestation/src/integrationTest/scala/com/yubico/fido/metadata/FidoMetadataDownloaderIntegrationTest.scala @@ -12,6 +12,7 @@ import org.scalatest.tags.Network import org.scalatest.tags.Slow import org.scalatestplus.junit.JUnitRunner +import java.util.Collections import scala.jdk.CollectionConverters.ListHasAsScala @Slow @@ -37,14 +38,23 @@ class FidoMetadataDownloaderIntegrationTest blob should not be null val trustRootCert = CertificateParser.parseDer( - TestCaches.trustRootCache.get.getBytes + com.yubico.internal.util.JacksonCodecs + .cbor() + .readValue( + TestCaches.trustRootCache.get.getBytes, + classOf[FidoMetadataDownloader.TrustRootsCacheValue], + ) + .getCertsDer + .get(0) ) val certChain = TestCaches .cacheSynchronized( downloader .fetchHeaderCertChain( - trustRootCert, + Collections.singleton( + FidoMetadataDownloader.importTrustAnchor(trustRootCert) + ), downloader .parseBlob(TestCaches.blobCache.get.getBytes) .getBlob diff --git a/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/FidoMetadataDownloader.java b/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/FidoMetadataDownloader.java index 74aa9c5d8..85c93b27e 100644 --- a/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/FidoMetadataDownloader.java +++ b/webauthn-server-attestation/src/main/java/com/yubico/fido/metadata/FidoMetadataDownloader.java @@ -29,6 +29,7 @@ import com.yubico.fido.metadata.FidoMetadataDownloaderException.Reason; import com.yubico.internal.util.BinaryUtil; import com.yubico.internal.util.CertificateParser; +import com.yubico.internal.util.CollectionUtil; import com.yubico.internal.util.OptionalUtil; import com.yubico.webauthn.data.ByteArray; import com.yubico.webauthn.data.exception.Base64UrlException; @@ -72,6 +73,7 @@ import java.util.Collection; import java.util.Collections; import java.util.Date; +import java.util.HashSet; import java.util.List; import java.util.Optional; import java.util.Scanner; @@ -87,9 +89,11 @@ import javax.net.ssl.TrustManagerFactory; import lombok.AccessLevel; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.NonNull; import lombok.RequiredArgsConstructor; import lombok.Value; +import lombok.extern.jackson.Jacksonized; import lombok.extern.slf4j.Slf4j; /** @@ -108,8 +112,8 @@ public final class FidoMetadataDownloader { @NonNull private final Set expectedLegalHeaders; - private final X509Certificate trustRootCertificate; - private final URL trustRootUrl; + private final Set trustAnchors; + private final List trustRootUrls; private final Set trustRootSha256; private final File trustRootCacheFile; private final Supplier> trustRootCacheSupplier; @@ -144,8 +148,8 @@ public static FidoMetadataDownloaderBuilder.Step1 builder() { @RequiredArgsConstructor(access = AccessLevel.PRIVATE) public static class FidoMetadataDownloaderBuilder { @NonNull private final Set expectedLegalHeaders; - private final X509Certificate trustRootCertificate; - private final URL trustRootUrl; + private final Set trustAnchors; + private final List trustRootUrls; private final Set trustRootSha256; private final File trustRootCacheFile; private final Supplier> trustRootCacheSupplier; @@ -171,8 +175,8 @@ public static class FidoMetadataDownloaderBuilder { public FidoMetadataDownloader build() { return new FidoMetadataDownloader( expectedLegalHeaders, - trustRootCertificate, - trustRootUrl, + trustAnchors, + trustRootUrls, trustRootSha256, trustRootCacheFile, trustRootCacheSupplier, @@ -250,11 +254,12 @@ public Step2 expectLegalHeader(@NonNull String... expectedLegalHeaders) { *
    *
  1. Use the default download URL and certificate hash. This is the main intended use case. * See {@link #useDefaultTrustRoot()}. - *
  2. Use a custom download URL and certificate hash. This is for future-proofing in case the - * trust root certificate changes and there is no new release of this library. See {@link - * #downloadTrustRoot(URL, Set)}. - *
  3. Use a pre-retrieved trust root certificate. It is up to you to perform any integrity - * checks and cache it as desired. See {@link #useTrustRoot(X509Certificate)}. + *
  4. Use custom download URLs and certificate hashes. This is for future-proofing in case + * the upstream trust roots change and there is no new release of this library. See {@link + * #downloadTrustRoot(URL, Set)} and {@link #downloadTrustRoots(List, Set)}. + *
  5. Use a pre-retrieved trust root certificate or set of trust anchors. It is up to you to + * perform any integrity checks and caching as desired. See {@link + * #useTrustRoot(X509Certificate)} and {@link #useTrustRoots(Set)}. *
*/ @AllArgsConstructor(access = AccessLevel.PRIVATE) @@ -271,7 +276,7 @@ public static class Step2 { *
        * downloadTrustRoot(
        *   new URL("https://secure.globalsign.com/cacert/rootr46.crt"),
-       *   Collections.singleton(ByteArray.fromHex("4fa3126d8d3a11d1c4855a4f807cbad6cf919d3a5a88b03bea2c6372d93c40c9"))
+       *   Collections.singletonList(ByteArray.fromHex("4fa3126d8d3a11d1c4855a4f807cbad6cf919d3a5a88b03bea2c6372d93c40c9"))
        * )
        * 
* @@ -279,6 +284,7 @@ public static class Step2 { * library release. * * @see #downloadTrustRoot(URL, Set) + * @see #downloadTrustRoots(List, Set) */ public Step3 useDefaultTrustRoot() { try { @@ -306,26 +312,79 @@ public Step3 useDefaultTrustRoot() { *

If the cert is downloaded, it is also written to the cache {@link File} or {@link * Consumer} configured in the {@link Step3 next step}. * + *

This is an alias of + * downloadTrustRoots(Collections.singletonList(url), acceptedCertSha256). See {@link + * #downloadTrustRoots(List, Set)}. + * * @param url the HTTP URL to download. It MUST use the https: scheme. * @param acceptedCertSha256 a set of SHA-256 hashes to verify the downloaded certificate * against. The downloaded certificate MUST match at least one of these hashes. * @throws IllegalArgumentException if url is not a HTTPS URL. + * @see #downloadTrustRoots(List, Set) */ public Step3 downloadTrustRoot(@NonNull URL url, @NonNull Set acceptedCertSha256) { - if (!"https".equals(url.getProtocol())) { + return downloadTrustRoots(Collections.singletonList(url), acceptedCertSha256); + } + + /** + * Download the trust root certificate from the given HTTPS url and verify its + * SHA-256 hash against acceptedCertSha256. + * + *

The certificate will be downloaded if it does not exist in the cache, or if the cached + * certificate is not currently valid. + * + *

If the cert is downloaded, it is also written to the cache {@link File} or {@link + * Consumer} configured in the {@link Step3 next step}. + * + * @param urls a non-empty list of HTTPS URLs to download. Each URL MUST use the https: + * scheme. + * @param acceptedCertSha256 a set of SHA-256 hashes to verify downloaded certificates + * against. Each downloaded certificate MUST match at least one of these hashes. + * @throws IllegalArgumentException if urls is empty or if any element of + * urls is not a HTTPS URL. + * @see #downloadTrustRoot(URL, Set) + */ + public Step3 downloadTrustRoots( + @NonNull List urls, @NonNull Set acceptedCertSha256) { + if (urls.isEmpty()) { + throw new IllegalArgumentException( + "List of trust certificate download URLs must not be empty."); + } + if (!urls.stream().allMatch(u -> "https".equals(u.getProtocol()))) { throw new IllegalArgumentException("Trust certificate download URL must be a HTTPS URL."); } - return new Step3(this, null, url, acceptedCertSha256); + return new Step3(this, null, CollectionUtil.immutableList(urls), acceptedCertSha256); } /** * Use the given trust root certificate. It is the caller's responsibility to perform any * integrity checks and/or caching logic. * + *

This is a shortcut for {@link #useTrustRoots(Set)} with trustRootCertificate + * imported into a singleton set. + * * @param trustRootCertificate the certificate to use as the FIDO Metadata Service trust root. + * @see #useTrustRoots(Set) */ public Step4 useTrustRoot(@NonNull X509Certificate trustRootCertificate) { - return new Step4(new Step3(this, trustRootCertificate, null, null), null, null, null); + return useTrustRoots(Collections.singleton(importTrustAnchor(trustRootCertificate))); + } + + /** + * Use the given set of trust anchors. It is the caller's responsibility to perform any + * integrity checks and/or caching logic. + * + * @param trustAnchors the trust anchors to use as the FIDO Metadata Service trust root. The + * set will be copied, so subsequent modifications to trustAnchors will not + * affect the FidoMetadataDownloader instance. + * @see #useTrustRoot(X509Certificate) + */ + public Step4 useTrustRoots(@NonNull Set trustAnchors) { + return new Step4( + new Step3(this, CollectionUtil.immutableSet(trustAnchors), null, null), + null, + null, + null); } } @@ -335,28 +394,32 @@ public Step4 useTrustRoot(@NonNull X509Certificate trustRootCertificate) { *

This step offers two mutually exclusive options: * *

    - *
  1. Cache the trust root certificate in a {@link File}. See {@link + *
  2. Cache trust root certificates in a {@link File}. See {@link * Step3#useTrustRootCacheFile(File)}. - *
  3. Cache the trust root certificate using a {@link Supplier} to read the cache and a - * {@link Consumer} to write the cache. See {@link Step3#useTrustRootCache(Supplier, - * Consumer)}. + *
  4. Cache trust root certificates using a {@link Supplier} to read the cache and a {@link + * Consumer} to write the cache. See {@link Step3#useTrustRootCache(Supplier, Consumer)}. *
*/ @AllArgsConstructor(access = AccessLevel.PRIVATE) public static class Step3 { @NonNull private final Step2 step2; - private final X509Certificate trustRootCertificate; - private final URL trustRootUrl; + private final Set trustAnchors; + private final List trustRootUrls; private final Set trustRootSha256; /** - * Cache the trust root certificate in the file cacheFile. + * Cache trust root certificates in the file cacheFile. * - *

If cacheFile exists, is a normal file, is readable, matches one of the - * SHA-256 hashes configured in the previous step, and contains a currently valid X.509 - * certificate, then it will be used as the trust root for the FIDO Metadata Service blob. + *

If cacheFile exists, is a normal file and is readable, then trust root + * certificates will be attempted to be read from this file. The internal format of the file + * is opaque and subject to change without a major version release of the library. * - *

Otherwise, the trust root certificate will be downloaded and written to this file. + *

If reading from the cache fails, then trust root certificates will instead be downloaded + * and written to this file. + * + *

The cache is invalidated whenever the configured list of trust root download URLs + * changes or differs in length from the number of cached certificates, or whenever any cached + * certificate matches none of the configured SHA-256 hashes. */ public Step4 useTrustRootCacheFile(@NonNull File cacheFile) { return new Step4(this, cacheFile, null, null); @@ -366,24 +429,30 @@ public Step4 useTrustRootCacheFile(@NonNull File cacheFile) { * Cache the trust root certificate using a {@link Supplier} to read the cache, and using a * {@link Consumer} to write the cache. * - *

If getCachedTrustRootCert returns non-empty, the value matches one of the - * SHA-256 hashes configured in the previous step, and is a currently valid X.509 certificate, - * then it will be used as the trust root for the FIDO Metadata Service blob. + *

If getCachedTrustRootCerts returns non-empty, then trust root certificates + * will be attempted to be read from the contained {@link ByteArray}. The internal format of + * the byte array is opaque and subject to change without a major version release of the + * library. + * + *

If the supplier returns empty or reading from the contained byte array fails, then trust + * root certificates will be downloaded and written to + * writeCachedTrustRootCerts. * - *

Otherwise, the trust root certificate will be downloaded and written to - * writeCachedTrustRootCert. + *

The cache is invalidated whenever the configured list of trust root download URLs + * changes or differs in length from the number of cached certificates, or whenever any cached + * certificate matches none of the configured SHA-256 hashes. * - * @param getCachedTrustRootCert a {@link Supplier} that fetches the cached trust root - * certificate if it exists. MUST NOT return null. The returned value, if - * present, MUST be the trust root certificate in X.509 DER format. - * @param writeCachedTrustRootCert a {@link Consumer} that accepts the trust root certificate - * in X.509 DER format and writes it to the cache. Its argument will never be null - * . + * @param getCachedTrustRootCerts a {@link Supplier} that fetches cached trust root + * certificates if they exist. MUST NOT return null. The format of the + * returned value, if present, is opaque to the supplier. + * @param writeCachedTrustRootCerts a {@link Consumer} that accepts trust root certificates in + * an unspecified opaque format and writes it to the cache. Its argument will never be + * null. */ public Step4 useTrustRootCache( - @NonNull Supplier> getCachedTrustRootCert, - @NonNull Consumer writeCachedTrustRootCert) { - return new Step4(this, null, getCachedTrustRootCert, writeCachedTrustRootCert); + @NonNull Supplier> getCachedTrustRootCerts, + @NonNull Consumer writeCachedTrustRootCerts) { + return new Step4(this, null, getCachedTrustRootCerts, writeCachedTrustRootCerts); } } @@ -539,8 +608,8 @@ private static FidoMetadataDownloaderBuilder finishRequiredSteps( Consumer blobCacheConsumer) { return new FidoMetadataDownloaderBuilder( step5.step4.step3.step2.expectedLegalHeaders, - step5.step4.step3.trustRootCertificate, - step5.step4.step3.trustRootUrl, + step5.step4.step3.trustAnchors, + step5.step4.step3.trustRootUrls, step5.step4.step3.trustRootSha256, step5.step4.trustRootCacheFile, step5.step4.trustRootCacheSupplier, @@ -603,8 +672,9 @@ public FidoMetadataDownloaderBuilder useCrls(CertStore certStore) { * Use the provided {@link X509Certificate}s as trust roots for HTTPS downloads. * *

This is primarily useful when setting {@link Step2#downloadTrustRoot(URL, Set) - * downloadTrustRoot} and/or {@link Step4#downloadBlob(URL) downloadBlob} to download from - * custom servers instead of the defaults. + * downloadTrustRoot} or {@link Step2#downloadTrustRoots(List, Set) downloadTrustRoots} and/or + * {@link Step4#downloadBlob(URL) downloadBlob} to download from custom servers instead of the + * defaults. * *

If provided, these will be used for downloading * @@ -786,15 +856,15 @@ public MetadataBLOB loadCachedBlob() UnexpectedLegalHeader, DigestException, FidoMetadataDownloaderException { - final X509Certificate trustRoot = retrieveTrustRootCert(); + final Set trustAnchors = retrieveTrustAnchors(); - final Optional explicit = loadExplicitBlobOnly(trustRoot); + final Optional explicit = loadExplicitBlobOnly(trustAnchors); if (explicit.isPresent()) { log.debug("Explicit BLOB is set - disregarding cache and download."); return explicit.get(); } - final Optional cached = loadCachedBlobOnly(trustRoot); + final Optional cached = loadCachedBlobOnly(trustAnchors); if (cached.isPresent()) { log.debug("Cached BLOB exists, checking expiry date..."); if (cached @@ -814,7 +884,7 @@ public MetadataBLOB loadCachedBlob() log.debug("Cached BLOB does not exist or is invalid."); } - return refreshBlobInternal(trustRoot, cached).get(); + return refreshBlobInternal(trustAnchors, cached).get(); } /** @@ -889,26 +959,26 @@ public MetadataBLOB refreshBlob() UnexpectedLegalHeader, DigestException, FidoMetadataDownloaderException { - final X509Certificate trustRoot = retrieveTrustRootCert(); + final Set trustAnchors = retrieveTrustAnchors(); - final Optional explicit = loadExplicitBlobOnly(trustRoot); + final Optional explicit = loadExplicitBlobOnly(trustAnchors); if (explicit.isPresent()) { log.debug("Explicit BLOB is set - disregarding cache and download."); return explicit.get(); } - final Optional cached = loadCachedBlobOnly(trustRoot); + final Optional cached = loadCachedBlobOnly(trustAnchors); if (cached.isPresent()) { log.debug("Cached BLOB exists, proceeding to compare against fresh BLOB..."); } else { log.debug("Cached BLOB does not exist or is invalid."); } - return refreshBlobInternal(trustRoot, cached).get(); + return refreshBlobInternal(trustAnchors, cached).get(); } private Optional refreshBlobInternal( - @NonNull X509Certificate trustRoot, @NonNull Optional cached) + @NonNull Set trustAnchors, @NonNull Optional cached) throws CertPathValidatorException, InvalidAlgorithmParameterException, Base64UrlException, @@ -936,7 +1006,7 @@ private Optional refreshBlobInternal( } else { byte[] downloadedBytes = downloadResult.getContent(); - final MetadataBLOB downloadedBlob = parseAndVerifyBlob(downloadedBytes, trustRoot); + final MetadataBLOB downloadedBlob = parseAndVerifyBlob(downloadedBytes, trustAnchors); log.debug("New BLOB downloaded."); if (cached.isPresent()) { @@ -1002,11 +1072,11 @@ private Optional refreshBlobInternal( * cache file (if any) failed. * @throws NoSuchAlgorithmException if the SHA-256 algorithm is not available. */ - private X509Certificate retrieveTrustRootCert() + private Set retrieveTrustAnchors() throws CertificateException, DigestException, IOException, NoSuchAlgorithmException { - if (trustRootCertificate != null) { - return trustRootCertificate; + if (trustAnchors != null) { + return trustAnchors; } else { final Optional cachedContents; @@ -1016,46 +1086,53 @@ private X509Certificate retrieveTrustRootCert() cachedContents = trustRootCacheSupplier.get(); } - X509Certificate cert = null; - if (cachedContents.isPresent()) { - final byte[] verifiedCachedContents = - verifyHash(cachedContents.get().getBytes(), trustRootSha256); - if (verifiedCachedContents != null) { - try { - final X509Certificate cachedCert = CertificateParser.parseDer(verifiedCachedContents); - cachedCert.checkValidity(Date.from(clock.instant())); - cert = cachedCert; - } catch (CertificateException e) { - // Fall through + Set certs = + cachedContents + .flatMap(cc -> readTrustAnchorsCache(new ByteArrayInputStream(cc.getBytes()))) + .orElseGet(HashSet::new); + + if (certs.isEmpty()) { + List downloadedChunks = new ArrayList<>(); + for (URL trustRootUrl : trustRootUrls) { + final byte[] downloaded = verifyHash(download(trustRootUrl), trustRootSha256); + if (downloaded == null) { + throw new DigestException( + "Downloaded trust root certificate matches none of the acceptable hashes."); } - } - } - if (cert == null) { - final byte[] downloaded = verifyHash(download(trustRootUrl), trustRootSha256); - if (downloaded == null) { - throw new DigestException( - "Downloaded trust root certificate matches none of the acceptable hashes."); + final X509Certificate cert = CertificateParser.parseDer(downloaded); + cert.checkValidity(Date.from(clock.instant())); + certs.add(cert); + downloadedChunks.add(downloaded); } - cert = CertificateParser.parseDer(downloaded); - cert.checkValidity(Date.from(clock.instant())); - + final TrustRootsCacheValue cacheValue = + new TrustRootsCacheValue( + trustRootUrls.stream().map(URL::toString).collect(Collectors.toList()), + downloadedChunks); if (trustRootCacheFile != null) { try (FileOutputStream f = new FileOutputStream(trustRootCacheFile)) { - f.write(downloaded); + com.yubico.internal.util.JacksonCodecs.cbor().writeValue(f, cacheValue); } } if (trustRootCacheConsumer != null) { - trustRootCacheConsumer.accept(new ByteArray(downloaded)); + trustRootCacheConsumer.accept( + new ByteArray( + com.yubico.internal.util.JacksonCodecs.cbor().writeValueAsBytes(cacheValue))); } } - return cert; + return certs.stream() + .map(FidoMetadataDownloader::importTrustAnchor) + .collect(Collectors.toSet()); } } + static TrustAnchor importTrustAnchor(X509Certificate trustRootCertificate) { + return new TrustAnchor(trustRootCertificate, null); + } + /** * @throws Base64UrlException if the metadata BLOB is not a well-formed JWT in compact * serialization. @@ -1071,7 +1148,7 @@ private X509Certificate retrieveTrustRootCert() * @throws FidoMetadataDownloaderException if the explicitly configured BLOB (if any) has a bad * signature. */ - private Optional loadExplicitBlobOnly(X509Certificate trustRootCertificate) + private Optional loadExplicitBlobOnly(Set trustAnchors) throws Base64UrlException, CertPathValidatorException, CertificateException, @@ -1083,14 +1160,14 @@ private Optional loadExplicitBlobOnly(X509Certificate trustRootCer FidoMetadataDownloaderException { if (blobJwt != null) { return Optional.of( - parseAndMaybeVerifyBlob(blobJwt.getBytes(StandardCharsets.UTF_8), trustRootCertificate)); + parseAndMaybeVerifyBlob(blobJwt.getBytes(StandardCharsets.UTF_8), trustAnchors)); } else { return Optional.empty(); } } - private Optional loadCachedBlobOnly(X509Certificate trustRootCertificate) { + private Optional loadCachedBlobOnly(Set trustAnchors) { final Optional cachedContents; if (blobCacheFile != null) { @@ -1109,7 +1186,7 @@ private Optional loadCachedBlobOnly(X509Certificate trustRootCerti return cachedContents.map( cached -> { try { - return parseAndMaybeVerifyBlob(cached.getBytes(), trustRootCertificate); + return parseAndMaybeVerifyBlob(cached.getBytes(), trustAnchors); } catch (Exception e) { log.warn("Failed to read or parse cached BLOB.", e); return null; @@ -1130,6 +1207,38 @@ Optional readCacheFile(File cacheFile) throws IOException { } } + Optional> readTrustAnchorsCache(InputStream is) { + try { + TrustRootsCacheValue cache = + com.yubico.internal.util.JacksonCodecs.cbor().readValue(is, TrustRootsCacheValue.class); + if (cache.urls.equals(trustRootUrls.stream().map(URL::toString).collect(Collectors.toList())) + && cache.urls.size() == cache.certsDer.size()) { + Set cachedCerts = new HashSet<>(); + for (byte[] der : cache.certsDer) { + X509Certificate cachedCert = CertificateParser.parseDer(der); + final byte[] verifiedCachedContents = + verifyHash(cachedCert.getEncoded(), trustRootSha256); + if (verifiedCachedContents != null) { + cachedCert.checkValidity(Date.from(clock.instant())); + } else { + log.debug( + "Cached trust root certificate does not match any acceptable trust root SHA-256 hash."); + return Optional.empty(); + } + cachedCerts.add(cachedCert); + } + return Optional.of(cachedCerts); + } else { + log.debug( + "Cached trust root certificate URLs differ from current configuration, or number of URLs does not equal number of cached certificates - ignoring cache."); + return Optional.empty(); + } + } catch (IOException | CertificateException | NoSuchAlgorithmException e) { + log.debug("Failed to read trust root certificates from cache", e); + return Optional.empty(); + } + } + private byte[] download(URL url) throws IOException { final DownloadResult downloadResult = download(url, Optional.empty()); if (downloadResult.isOk()) { @@ -1202,7 +1311,7 @@ private DownloadResult download(URL url, Optional etag) throws IOExcepti return DownloadResult.ok(readAll(conn.getInputStream())); } - private MetadataBLOB parseAndVerifyBlob(byte[] jwt, X509Certificate trustRootCertificate) + private MetadataBLOB parseAndVerifyBlob(byte[] jwt, Set trustAnchors) throws CertPathValidatorException, InvalidAlgorithmParameterException, CertificateException, @@ -1212,10 +1321,10 @@ private MetadataBLOB parseAndVerifyBlob(byte[] jwt, X509Certificate trustRootCer InvalidKeyException, Base64UrlException, FidoMetadataDownloaderException { - return verifyBlob(parseBlob(jwt), trustRootCertificate); + return verifyBlob(parseBlob(jwt), trustAnchors); } - private MetadataBLOB parseAndMaybeVerifyBlob(byte[] jwt, X509Certificate trustRootCertificate) + private MetadataBLOB parseAndMaybeVerifyBlob(byte[] jwt, Set trustAnchors) throws CertPathValidatorException, InvalidAlgorithmParameterException, CertificateException, @@ -1228,11 +1337,11 @@ private MetadataBLOB parseAndMaybeVerifyBlob(byte[] jwt, X509Certificate trustRo if (verifyDownloadsOnly) { return parseBlob(jwt).blob; } else { - return verifyBlob(parseBlob(jwt), trustRootCertificate); + return verifyBlob(parseBlob(jwt), trustAnchors); } } - private MetadataBLOB verifyBlob(ParseResult parseResult, X509Certificate trustRootCertificate) + private MetadataBLOB verifyBlob(ParseResult parseResult, Set trustAnchors) throws IOException, CertificateException, NoSuchAlgorithmException, @@ -1242,7 +1351,7 @@ private MetadataBLOB verifyBlob(ParseResult parseResult, X509Certificate trustRo InvalidAlgorithmParameterException, FidoMetadataDownloaderException { final MetadataBLOBHeader header = parseResult.blob.getHeader(); - final List certChain = fetchHeaderCertChain(trustRootCertificate, header); + final List certChain = fetchHeaderCertChain(trustAnchors, header); final X509Certificate leafCert = certChain.get(0); final Signature signature; @@ -1270,8 +1379,7 @@ private MetadataBLOB verifyBlob(ParseResult parseResult, X509Certificate trustRo final CertificateFactory certFactory = CertificateFactory.getInstance("X.509"); final CertPathValidator cpv = CertPathValidator.getInstance("PKIX"); - final PKIXParameters pathParams = - new PKIXParameters(Collections.singleton(new TrustAnchor(trustRootCertificate, null))); + final PKIXParameters pathParams = new PKIXParameters(trustAnchors); if (certStore != null) { pathParams.addCertStore(certStore); } @@ -1363,7 +1471,7 @@ static class ParseResult { /** Parse the header cert chain and download any certificates as necessary. */ List fetchHeaderCertChain( - X509Certificate trustRootCertificate, MetadataBLOBHeader header) + Set trustAnchors, MetadataBLOBHeader header) throws IOException, CertificateException { if (header.getX5u().isPresent()) { final URL x5u = header.getX5u().get(); @@ -1388,7 +1496,14 @@ List fetchHeaderCertChain( } else if (header.getX5c().isPresent()) { return header.getX5c().get(); } else { - return Collections.singletonList(trustRootCertificate); + return trustAnchors.stream() + .map(TrustAnchor::getTrustedCert) + .findFirst() + .map(Collections::singletonList) + .orElseThrow( + () -> + new IllegalArgumentException( + "x5u and x5c both missing from BLOB header, and no given trust anchor could be interpreted as an X509Certificate.")); } } @@ -1490,4 +1605,12 @@ public enum CachePolicyDecision { /** Propagate the failure by re-throwing the exception. */ THROW; } + + @Value + @Builder + @Jacksonized + static class TrustRootsCacheValue { + List urls; + List certsDer; + } } diff --git a/webauthn-server-attestation/src/test/scala/com/yubico/fido/metadata/FidoMetadataDownloaderSpec.scala b/webauthn-server-attestation/src/test/scala/com/yubico/fido/metadata/FidoMetadataDownloaderSpec.scala index 7056ddec9..436a43701 100644 --- a/webauthn-server-attestation/src/test/scala/com/yubico/fido/metadata/FidoMetadataDownloaderSpec.scala +++ b/webauthn-server-attestation/src/test/scala/com/yubico/fido/metadata/FidoMetadataDownloaderSpec.scala @@ -4,6 +4,7 @@ import com.fasterxml.jackson.databind.node.IntNode import com.fasterxml.jackson.databind.node.ObjectNode import com.yubico.fido.metadata.FidoMetadataDownloader.CachePolicyDecision import com.yubico.fido.metadata.FidoMetadataDownloader.FidoMetadataDownloaderBuilder +import com.yubico.fido.metadata.FidoMetadataDownloader.TrustRootsCacheValue import com.yubico.fido.metadata.FidoMetadataDownloaderException.Reason import com.yubico.internal.util.BinaryUtil import com.yubico.internal.util.JacksonCodecs @@ -165,6 +166,16 @@ class FidoMetadataDownloaderSpec certs } + private def serializeTrustRootCache( + urls: List[String], + certs: List[X509Certificate], + ): Array[Byte] = + JacksonCodecs + .cbor() + .writeValueAsBytes( + new TrustRootsCacheValue(urls.asJava, certs.map(_.getEncoded).asJava) + ) + private def formatJwtTbs(header: String, body: String): String = new ByteArray( header.getBytes(StandardCharsets.UTF_8) @@ -311,7 +322,7 @@ class FidoMetadataDownloaderSpec withEachLoadMethod { load => describe("1. Download and cache the root signing trust anchor from the respective MDS root location e.g. More information can be found at https://fidoalliance.org/metadata/") { it( - "The trust root is downloaded and cached if there isn't a supplier-cached one." + "Trust roots are downloaded and cached if there aren't any in supplier cache." ) { val random = new SecureRandom() val trustRootDistinguishedName = @@ -337,6 +348,7 @@ class FidoMetadataDownloaderSpec makeHttpServer("/trust-root.der", trustRootCert.getEncoded) startServer(server) + val trustRootUrl = s"${serverUrl}/trust-root.der" val blob = load( FidoMetadataDownloader .builder() @@ -344,7 +356,7 @@ class FidoMetadataDownloaderSpec "Kom ihåg att du aldrig får snyta dig i mattan!" ) .downloadTrustRoot( - new URL(s"${serverUrl}/trust-root.der"), + new URL(trustRootUrl), Set( TestAuthenticator.sha256( new ByteArray(trustRootCert.getEncoded) @@ -369,11 +381,110 @@ class FidoMetadataDownloaderSpec trustRootDistinguishedName ) writtenCache should equal( - Some(new ByteArray(trustRootCert.getEncoded)) + Some( + new ByteArray( + serializeTrustRootCache(List(trustRootUrl), List(trustRootCert)) + ) + ) + ) + } + + it( + "Trust roots are downloaded and cached if the URLs don't match those in supplier cache." + ) { + val random = new SecureRandom() + + val oldTrustRootDistinguishedName = + s"CN=Test trust root ${random.nextInt(10000)}" + val newTrustRootDistinguishedName = + s"CN=Test trust root ${random.nextInt(10000) + 10000}" + val (oldTrustRootCert, _, _) = + makeTrustRootCert(distinguishedName = oldTrustRootDistinguishedName) + val (newTrustRootCert, caKeypair, caName) = + makeTrustRootCert(distinguishedName = newTrustRootDistinguishedName) + + val (blobCert, blobKeypair, _) = makeCert(caKeypair, caName) + val blobJwt = + makeBlob(List(blobCert), blobKeypair, LocalDate.now()) + val crls = List[CRL]( + TestAuthenticator.buildCrl( + caName, + caKeypair.getPrivate, + "SHA256withECDSA", + CertValidFrom, + CertValidTo, + ) + ) + + var writtenCache: Option[ByteArray] = None + + val oldTrustRootPath = "/old-trust-root.der" + val newTrustRootPath = "/new-trust-root.der" + val (server, serverUrl, httpsCert) = + makeHttpServer( + Map( + oldTrustRootPath -> (_ => (200, oldTrustRootCert.getEncoded)), + newTrustRootPath -> (_ => (200, newTrustRootCert.getEncoded)), + ) + ) + startServer(server) + val oldTrustRootUrl = s"${serverUrl}${oldTrustRootPath}" + val newTrustRootUrl = s"${serverUrl}${newTrustRootPath}" + + val blob = load( + FidoMetadataDownloader + .builder() + .expectLegalHeader( + "Kom ihåg att du aldrig får snyta dig i mattan!" + ) + .downloadTrustRoots( + List(new URL(oldTrustRootUrl), new URL(newTrustRootUrl)).asJava, + Set( + TestAuthenticator.sha256( + new ByteArray(oldTrustRootCert.getEncoded) + ), + TestAuthenticator.sha256( + new ByteArray(newTrustRootCert.getEncoded) + ), + ).asJava, + ) + .useTrustRootCache( + () => + Optional.of( + new ByteArray( + serializeTrustRootCache( + List(oldTrustRootUrl), + List(oldTrustRootCert), + ) + ) + ), + newCache => { + writtenCache = Some(newCache) + }, + ) + .useBlob(blobJwt) + .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) + .useCrls(crls.asJava) + .trustHttpsCerts(httpsCert) + .build() + ) + blob should not be null + blob.getHeader.getX5c.get.asScala.last.getIssuerX500Principal.getName should equal( + newTrustRootDistinguishedName + ) + writtenCache should equal( + Some( + new ByteArray( + serializeTrustRootCache( + List(oldTrustRootUrl, newTrustRootUrl), + List(oldTrustRootCert, newTrustRootCert), + ) + ) + ) ) } - it("The trust root is downloaded and cached if there's an expired one in supplier-cache.") { + it("Trust roots are downloaded and cached if there's an expired one in supplier cache.") { val random = new SecureRandom() val oldTrustRootDistinguishedName = @@ -408,6 +519,7 @@ class FidoMetadataDownloaderSpec makeHttpServer("/trust-root.der", newTrustRootCert.getEncoded) startServer(server) + val trustRootUrl = s"${serverUrl}/trust-root.der" val blob = load( FidoMetadataDownloader .builder() @@ -415,7 +527,7 @@ class FidoMetadataDownloaderSpec "Kom ihåg att du aldrig får snyta dig i mattan!" ) .downloadTrustRoot( - new URL(s"${serverUrl}/trust-root.der"), + new URL(trustRootUrl), Set( TestAuthenticator.sha256( new ByteArray(newTrustRootCert.getEncoded) @@ -439,12 +551,19 @@ class FidoMetadataDownloaderSpec newTrustRootDistinguishedName ) writtenCache should equal( - Some(new ByteArray(newTrustRootCert.getEncoded)) + Some( + new ByteArray( + serializeTrustRootCache( + List(trustRootUrl), + List(newTrustRootCert), + ) + ) + ) ) } it( - "The trust root is not downloaded and not written to cache if there's a valid one in file cache." + "Trust roots are not downloaded and not written to cache if there are valid ones in file cache." ) { val random = new SecureRandom() val trustRootDistinguishedName = @@ -463,13 +582,19 @@ class FidoMetadataDownloaderSpec CertValidTo, ) ) + val trustRootUrl = "https://localhost:12345/nonexistent.dev.null" val cacheFile = File.createTempFile( s"${getClass.getCanonicalName}_test_cache_", ".tmp", ) val f = new FileOutputStream(cacheFile) - f.write(trustRootCert.getEncoded) + f.write( + serializeTrustRootCache( + List(trustRootUrl), + List(trustRootCert), + ) + ) f.close() cacheFile.deleteOnExit() cacheFile.setLastModified( @@ -484,7 +609,7 @@ class FidoMetadataDownloaderSpec "Kom ihåg att du aldrig får snyta dig i mattan!" ) .downloadTrustRoot( - new URL("https://localhost:12345/nonexistent.dev.null"), + new URL(trustRootUrl), Set( TestAuthenticator.sha256( new ByteArray(trustRootCert.getEncoded) @@ -505,7 +630,7 @@ class FidoMetadataDownloaderSpec } it( - "The trust root is downloaded and cached if there isn't a file-cached one." + "Trust roots are downloaded and cached if there aren't any in file cache." ) { val random = new SecureRandom() val trustRootDistinguishedName = @@ -536,6 +661,7 @@ class FidoMetadataDownloaderSpec cacheFile.delete() cacheFile.deleteOnExit() + val trustRootUrl = s"${serverUrl}/trust-root.der" val blob = load( FidoMetadataDownloader .builder() @@ -543,7 +669,7 @@ class FidoMetadataDownloaderSpec "Kom ihåg att du aldrig får snyta dig i mattan!" ) .downloadTrustRoot( - new URL(s"${serverUrl}/trust-root.der"), + new URL(trustRootUrl), Set( TestAuthenticator.sha256( new ByteArray(trustRootCert.getEncoded) @@ -563,11 +689,108 @@ class FidoMetadataDownloaderSpec ) cacheFile.exists() should be(true) BinaryUtil.readAll(new FileInputStream(cacheFile)) should equal( - trustRootCert.getEncoded + serializeTrustRootCache( + List(trustRootUrl), + List(trustRootCert), + ) + ) + } + + it("Trust roots are downloaded and cached if the URLs don't match those in file cache.") { + val random = new SecureRandom() + + val oldTrustRootDistinguishedName = + s"CN=Test trust root ${random.nextInt(10000)}" + val newTrustRootDistinguishedName = + s"CN=Test trust root ${random.nextInt(10000) + 10000}" + val (oldTrustRootCert, _, _) = + makeTrustRootCert(distinguishedName = oldTrustRootDistinguishedName) + val (newTrustRootCert, caKeypair, caName) = + makeTrustRootCert(distinguishedName = newTrustRootDistinguishedName) + + val (blobCert, blobKeypair, _) = makeCert(caKeypair, caName) + val blobJwt = + makeBlob(List(blobCert), blobKeypair, LocalDate.now()) + val crls = List[CRL]( + TestAuthenticator.buildCrl( + caName, + caKeypair.getPrivate, + "SHA256withECDSA", + CertValidFrom, + CertValidTo, + ) + ) + + val oldTrustRootPath = "/old-trust-root.der" + val newTrustRootPath = "/new-trust-root.der" + val (server, serverUrl, httpsCert) = + makeHttpServer( + Map( + oldTrustRootPath -> (_ => (200, oldTrustRootCert.getEncoded)), + newTrustRootPath -> (_ => (200, newTrustRootCert.getEncoded)), + ) + ) + startServer(server) + val oldTrustRootUrl = s"${serverUrl}${oldTrustRootPath}" + val newTrustRootUrl = s"${serverUrl}${newTrustRootPath}" + + val cacheFile = File.createTempFile( + s"${getClass.getCanonicalName}_test_cache_", + ".tmp", + ) + val f = new FileOutputStream(cacheFile) + f.write( + serializeTrustRootCache( + List(oldTrustRootUrl), + List(oldTrustRootCert), + ) + ) + f.close() + cacheFile.deleteOnExit() + cacheFile.setLastModified( + cacheFile.lastModified() - 10000 + ) // Set mtime in the past to ensure any write will change it + val initialModTime = cacheFile.lastModified + + val blob = load( + FidoMetadataDownloader + .builder() + .expectLegalHeader( + "Kom ihåg att du aldrig får snyta dig i mattan!" + ) + .downloadTrustRoots( + List(new URL(oldTrustRootUrl), new URL(newTrustRootUrl)).asJava, + Set( + TestAuthenticator.sha256( + new ByteArray(oldTrustRootCert.getEncoded) + ), + TestAuthenticator.sha256( + new ByteArray(newTrustRootCert.getEncoded) + ), + ).asJava, + ) + .useTrustRootCacheFile(cacheFile) + .useBlob(blobJwt) + .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) + .useCrls(crls.asJava) + .trustHttpsCerts(httpsCert) + .build() + ) + blob should not be null + blob.getHeader.getX5c.get.asScala.last.getIssuerX500Principal.getName should equal( + newTrustRootDistinguishedName + ) + cacheFile.exists() should be(true) + cacheFile.lastModified should not equal initialModTime + BinaryUtil.readAll(new FileInputStream(cacheFile)) should equal( + serializeTrustRootCache( + List(oldTrustRootUrl, newTrustRootUrl), + List(oldTrustRootCert, newTrustRootCert), + ) ) } - it("The trust root is downloaded and cached if there's an expired one in file cache.") { + it("Trust roots are downloaded and cached if there's an expired one in file cache.") { val random = new SecureRandom() val oldTrustRootDistinguishedName = @@ -609,6 +832,7 @@ class FidoMetadataDownloaderSpec f.close() cacheFile.deleteOnExit() + val trustRootUrl = s"${serverUrl}/trust-root.der" val blob = load( FidoMetadataDownloader .builder() @@ -616,7 +840,7 @@ class FidoMetadataDownloaderSpec "Kom ihåg att du aldrig får snyta dig i mattan!" ) .downloadTrustRoot( - new URL(s"${serverUrl}/trust-root.der"), + new URL(trustRootUrl), Set( TestAuthenticator.sha256( new ByteArray(newTrustRootCert.getEncoded) @@ -636,11 +860,14 @@ class FidoMetadataDownloaderSpec ) cacheFile.exists() should be(true) BinaryUtil.readAll(new FileInputStream(cacheFile)) should equal( - newTrustRootCert.getEncoded + serializeTrustRootCache( + List(trustRootUrl), + List(newTrustRootCert), + ) ) } - it("The trust root is not downloaded if there's a valid one in supplier-cache.") { + it("Trust roots are not downloaded if there are valid ones in in supplier cache.") { val random = new SecureRandom() val trustRootDistinguishedName = s"CN=Test trust root ${random.nextInt(10000)}" @@ -661,6 +888,7 @@ class FidoMetadataDownloaderSpec var writtenCache: Option[ByteArray] = None + val trustRootUrl = "https://localhost:12345/nonexistent.dev.null" val blob = load( FidoMetadataDownloader .builder() @@ -668,7 +896,7 @@ class FidoMetadataDownloaderSpec "Kom ihåg att du aldrig får snyta dig i mattan!" ) .downloadTrustRoot( - new URL("https://localhost:12345/nonexistent.dev.null"), + new URL(trustRootUrl), Set( TestAuthenticator.sha256( new ByteArray(trustRootCert.getEncoded) @@ -676,7 +904,15 @@ class FidoMetadataDownloaderSpec ).asJava, ) .useTrustRootCache( - () => Optional.of(new ByteArray(trustRootCert.getEncoded)), + () => + Optional.of( + new ByteArray( + serializeTrustRootCache( + List(trustRootUrl), + List(trustRootCert), + ) + ) + ), newCache => { writtenCache = Some(newCache) }, @@ -693,7 +929,7 @@ class FidoMetadataDownloaderSpec writtenCache should equal(None) } - it("The downloaded trust root cert must match one of the expected SHA256 hashes.") { + it("Each downloaded trust root cert must match one of the expected SHA256 hashes.") { val (trustRootCert, caKeypair, caName) = makeTrustRootCert() val (blobCert, blobKeypair, _) = makeCert(caKeypair, caName) val blobJwt = makeBlob(List(blobCert), blobKeypair, LocalDate.now()) @@ -742,7 +978,7 @@ class FidoMetadataDownloaderSpec testWithHashes(Set(badHash, goodHash)) should not be null } - it("The cached trust root cert must match one of the expected SHA256 hashes.") { + it("Each cached trust root cert must match one of the expected SHA256 hashes.") { val (cachedTrustRootCert, cachedCaKeypair, cachedCaName) = makeTrustRootCert() val (cachedRootBlobCert, cachedRootBlobKeypair, _) = @@ -787,6 +1023,7 @@ class FidoMetadataDownloaderSpec downloadedTrustRootCert.getEncoded, ) startServer(server) + val trustRootUrl = s"${serverUrl}/trust-root.der" def testWithHashes( hashes: Set[ByteArray], @@ -802,12 +1039,19 @@ class FidoMetadataDownloaderSpec "Kom ihåg att du aldrig får snyta dig i mattan!" ) .downloadTrustRoot( - new URL(s"${serverUrl}/trust-root.der"), + new URL(trustRootUrl), hashes.asJava, ) .useTrustRootCache( () => - Optional.of(new ByteArray(cachedTrustRootCert.getEncoded)), + Optional.of( + new ByteArray( + serializeTrustRootCache( + List(trustRootUrl), + List(cachedTrustRootCert), + ) + ) + ), downloaded => { writtenCache = Some(downloaded) }, ) .useBlob(blobJwt) @@ -846,10 +1090,31 @@ class FidoMetadataDownloaderSpec ) blob should not be null writtenCache should be( - Some(new ByteArray(downloadedTrustRootCert.getEncoded)) + Some( + new ByteArray( + serializeTrustRootCache( + List(trustRootUrl), + List(downloadedTrustRootCert), + ) + ) + ) ) } } + + it("An empty set of trust roots is invalid.") { + an[IllegalArgumentException] should be thrownBy { + FidoMetadataDownloader + .builder() + .expectLegalHeader( + "Kom ihåg att du aldrig får snyta dig i mattan!" + ) + .downloadTrustRoots( + List.empty[URL].asJava, + Set.empty[ByteArray].asJava, + ) + } + } } describe("2. To validate the digital certificates used in the digital signature, the certificate revocation information MUST be available in the form of CRLs at the respective MDS CRL location e.g. More information can be found at https://fidoalliance.org/metadata/") { @@ -2114,7 +2379,7 @@ class FidoMetadataDownloaderSpec blob.getNo should equal(blobNo) } - it("A cross-signed trust root cert appearing in the cert path validates successfully.") { + describe("A cross-signed root CA cert appearing in the cert path") { val (unrelatedRootCert, unrelatedRootKeypair, unrelatedRootName) = makeTrustRootCert(distinguishedName = "CN=Yubico java-webauthn-server unit tests UNRELATED CA, O=Yubico" @@ -2156,37 +2421,56 @@ class FidoMetadataDownloaderSpec LocalDate.parse("2022-01-19"), ) - for (trustRoot <- List(newRootCert, oldRootCert)) { - val blob = load( + def loadWithTrustRoots( + trustRoots: Set[X509Certificate] + ): MetadataBLOB = { + load( FidoMetadataDownloader .builder() .expectLegalHeader( "Kom ihåg att du aldrig får snyta dig i mattan!" ) - .useTrustRoot(trustRoot) + .useTrustRoots( + trustRoots + .map(FidoMetadataDownloader.importTrustAnchor) + .asJava + ) .useBlob(blobJwt) .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) .useCrls(crls.asJava) .build() ) + } + + def checkSuccess(trustRoots: Set[X509Certificate]): Unit = { + val blob = loadWithTrustRoots(trustRoots) blob should not be null } - val thrown = the[CertPathValidatorException] thrownBy { - load( - FidoMetadataDownloader - .builder() - .expectLegalHeader( - "Kom ihåg att du aldrig får snyta dig i mattan!" - ) - .useTrustRoot(unrelatedRootCert) - .useBlob(blobJwt) - .clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC)) - .useCrls(crls.asJava) - .build() - ) + def checkFailure(trustRoots: Set[X509Certificate]): Unit = { + val thrown = the[CertPathValidatorException] thrownBy { + loadWithTrustRoots(trustRoots) + } + thrown.getReason should be(PKIXReason.NO_TRUST_ANCHOR) + } + + it("validates successfully if both root certs are trusted.") { + checkSuccess(Set(oldRootCert, newRootCert)) + } + + it("validates successfully if only the cross-signing root cert is trusted.") { + checkSuccess(Set(oldRootCert)) + } + + it( + "validates successfully if only the cross-signed root cert is trusted." + ) { + checkSuccess(Set(newRootCert)) + } + + it("fails validation if neither root cert is trusted.") { + checkFailure(Set(unrelatedRootCert)) } - thrown.getReason should be(PKIXReason.NO_TRUST_ANCHOR) } }