![JAR search and dependency download from the Maven repository](/logo.png)
com.yubico.fido.metadata.FidoMetadataDownloader Maven / Gradle / Ivy
// Copyright (c) 2015-2021, 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.fido.metadata;
import com.fasterxml.jackson.core.Base64Variants;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yubico.fido.metadata.FidoMetadataDownloaderException.Reason;
import com.yubico.internal.util.BinaryUtil;
import com.yubico.internal.util.CertificateParser;
import com.yubico.webauthn.data.ByteArray;
import com.yubico.webauthn.data.exception.Base64UrlException;
import com.yubico.webauthn.data.exception.HexException;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLConnection;
import java.nio.charset.StandardCharsets;
import java.security.DigestException;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.KeyManagementException;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.Signature;
import java.security.SignatureException;
import java.security.cert.CRL;
import java.security.cert.CertPath;
import java.security.cert.CertPathValidator;
import java.security.cert.CertPathValidatorException;
import java.security.cert.CertStore;
import java.security.cert.CertStoreParameters;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.CollectionCertStoreParameters;
import java.security.cert.PKIXParameters;
import java.security.cert.TrustAnchor;
import java.security.cert.X509Certificate;
import java.time.Clock;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import java.util.Scanner;
import java.util.Set;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManagerFactory;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.Value;
import lombok.extern.slf4j.Slf4j;
/**
* Utility for downloading, caching and verifying Fido Metadata Service BLOBs and associated
* certificates.
*
* This class is NOT THREAD SAFE since it reads and writes caches. However, it has no internal
* mutable state, so instances MAY be reused in single-threaded or externally synchronized contexts.
* See also the {@link #loadCachedBlob()} and {@link #refreshBlob()} methods.
*
*
Use the {@link #builder() builder} to configure settings, then use the {@link
* #loadCachedBlob()} and {@link #refreshBlob()} methods to load the metadata BLOB.
*/
@Slf4j
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public final class FidoMetadataDownloader {
@NonNull private final Set expectedLegalHeaders;
private final X509Certificate trustRootCertificate;
private final URL trustRootUrl;
private final Set trustRootSha256;
private final File trustRootCacheFile;
private final Supplier> trustRootCacheSupplier;
private final Consumer trustRootCacheConsumer;
private final String blobJwt;
private final URL blobUrl;
private final File blobCacheFile;
private final Supplier> blobCacheSupplier;
private final Consumer blobCacheConsumer;
private final CertStore certStore;
@NonNull private final Clock clock;
private final KeyStore httpsTrustStore;
private final boolean verifyDownloadsOnly;
/**
* Begin configuring a {@link FidoMetadataDownloader} instance. See the {@link
* FidoMetadataDownloaderBuilder.Step1 Step1} type.
*
* @see FidoMetadataDownloaderBuilder.Step1
*/
public static FidoMetadataDownloaderBuilder.Step1 builder() {
return new FidoMetadataDownloaderBuilder.Step1();
}
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public static class FidoMetadataDownloaderBuilder {
@NonNull private final Set expectedLegalHeaders;
private final X509Certificate trustRootCertificate;
private final URL trustRootUrl;
private final Set trustRootSha256;
private final File trustRootCacheFile;
private final Supplier> trustRootCacheSupplier;
private final Consumer trustRootCacheConsumer;
private final String blobJwt;
private final URL blobUrl;
private final File blobCacheFile;
private final Supplier> blobCacheSupplier;
private final Consumer blobCacheConsumer;
private CertStore certStore = null;
@NonNull private Clock clock = Clock.systemUTC();
private KeyStore httpsTrustStore = null;
private boolean verifyDownloadsOnly = false;
public FidoMetadataDownloader build() {
return new FidoMetadataDownloader(
expectedLegalHeaders,
trustRootCertificate,
trustRootUrl,
trustRootSha256,
trustRootCacheFile,
trustRootCacheSupplier,
trustRootCacheConsumer,
blobJwt,
blobUrl,
blobCacheFile,
blobCacheSupplier,
blobCacheConsumer,
certStore,
clock,
httpsTrustStore,
verifyDownloadsOnly);
}
/**
* Step 1: Set the legal header to expect from the FIDO Metadata Service.
*
* By using the FIDO Metadata Service, you will be subject to its terms of service. This step
* serves two purposes:
*
*
* - To remind you and any code reviewers that you need to read those terms of service
* before using this feature.
*
- To help you detect if the legal header changes, so you can take appropriate action.
*
*
* See {@link Step1#expectLegalHeader(String...)}.
*
* @see Step1#expectLegalHeader(String...)
*/
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public static class Step1 {
/**
* Set legal headers expected in the metadata BLOB.
*
*
By using the FIDO Metadata Service, you will be subject to its terms of service. This
* builder step serves two purposes:
*
*
* - To remind you and any code reviewers that you need to read those terms of service
* before using this feature.
*
- To help you detect if the legal header changes, so you can take appropriate action.
*
*
* If the legal header in the downloaded BLOB does not equal any of the
* expectedLegalHeaders
, an {@link UnexpectedLegalHeader} exception will be thrown in
* the finalizing builder step.
*
*
Note that this library makes no guarantee that a change to the FIDO Metadata Service
* terms of service will also cause a change to the legal header in the BLOB.
*
*
At the time of this library release, the current legal header is
* "Retrieval and use of this BLOB indicates acceptance of the appropriate agreement located at https://fidoalliance.org/metadata/metadata-legal-terms/"
*
.
*
* @param expectedLegalHeaders the set of BLOB legal headers you expect in the metadata BLOB
* payload.
*/
public Step2 expectLegalHeader(@NonNull String... expectedLegalHeaders) {
return new Step2(Stream.of(expectedLegalHeaders).collect(Collectors.toSet()));
}
}
/**
* Step 2: Configure how to retrieve the FIDO Metadata Service trust root certificate when
* necessary.
*
*
This step offers three mutually exclusive options:
*
*
* - Use the default download URL and certificate hash. This is the main intended use case.
* See {@link #useDefaultTrustRoot()}.
*
- 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)}.
*
- 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)}.
*
*/
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public static class Step2 {
@NonNull private final Set expectedLegalHeaders;
/**
* Download the trust root certificate from a hard-coded URL and verify it against a
* hard-coded SHA-256 hash.
*
* This is an alias of:
*
*
* downloadTrustRoot(
* new URL("https://secure.globalsign.com/cacert/root-r3.crt"),
* Collections.singleton(ByteArray.fromHex("cbb522d7b7f127ad6a0113865bdf1cd4102e7d0759af635a7cf4720dc963c53b"))
* )
*
*
* This is the current FIDO Metadata Service trust root certificate at the time of this
* library release.
*
* @see #downloadTrustRoot(URL, Set)
*/
public Step3 useDefaultTrustRoot() {
try {
return downloadTrustRoot(
new URL("https://secure.globalsign.com/cacert/root-r3.crt"),
Collections.singleton(
ByteArray.fromHex(
"cbb522d7b7f127ad6a0113865bdf1cd4102e7d0759af635a7cf4720dc963c53b")));
} catch (MalformedURLException e) {
throw new RuntimeException(
"Bad hard-coded trust root certificate URL. Please file a bug report.", e);
} catch (HexException e) {
throw new RuntimeException(
"Bad hard-coded trust root certificate hash. Please file a bug report.", e);
}
}
/**
* 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 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.
*/
public Step3 downloadTrustRoot(@NonNull URL url, @NonNull Set acceptedCertSha256) {
if (!"https".equals(url.getProtocol())) {
throw new IllegalArgumentException("Trust certificate download URL must be a HTTPS URL.");
}
return new Step3(this, null, url, acceptedCertSha256);
}
/**
* Use the given trust root certificate. It is the caller's responsibility to perform any
* integrity checks and/or caching logic.
*
* @param trustRootCertificate the certificate to use as the FIDO Metadata Service trust root.
*/
public Step4 useTrustRoot(@NonNull X509Certificate trustRootCertificate) {
return new Step4(new Step3(this, trustRootCertificate, null, null), null, null, null);
}
}
/**
* Step 3: Configure how to cache the trust root certificate.
*
* This step offers two mutually exclusive options:
*
*
* - Cache the trust root certificate in a {@link File}. See {@link
* Step3#useTrustRootCacheFile(File)}.
*
- 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)}.
*
*/
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public static class Step3 {
@NonNull private final Step2 step2;
private final X509Certificate trustRootCertificate;
private final URL trustRootUrl;
private final Set trustRootSha256;
/**
* Cache the trust root certificate 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.
*
*
Otherwise, the trust root certificate will be downloaded and written to this file.
*/
public Step4 useTrustRootCacheFile(@NonNull File cacheFile) {
return new Step4(this, cacheFile, null, null);
}
/**
* 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.
*
*
Otherwise, the trust root certificate will be downloaded and written to
* writeCachedTrustRootCert
.
*
* @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
*
.
*/
public Step4 useTrustRootCache(
@NonNull Supplier> getCachedTrustRootCert,
@NonNull Consumer writeCachedTrustRootCert) {
return new Step4(this, null, getCachedTrustRootCert, writeCachedTrustRootCert);
}
}
/**
* Step 4: Configure how to fetch the FIDO Metadata Service metadata BLOB.
*
* This step offers three mutually exclusive options:
*
*
* - Use the default download URL. This is the main intended use case. See {@link
* #useDefaultBlob()}.
*
- Use a custom download URL. This is for future-proofing in case the BLOB download URL
* changes and there is no new release of this library. See {@link #downloadBlob(URL)}.
*
- Use a pre-retrieved BLOB. The signature will still be verified, but it is up to you to
* renew it when appropriate and perform any caching as desired. See {@link
* #useBlob(String)}.
*
*/
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public static class Step4 {
@NonNull private final Step3 step3;
private final File trustRootCacheFile;
private final Supplier> trustRootCacheSupplier;
private final Consumer trustRootCacheConsumer;
/**
* Download the metadata BLOB from a hard-coded URL.
*
* This is an alias of downloadBlob(new URL("https://mds.fidoalliance.org/"))
.
*
*
This is the current FIDO Metadata Service BLOB download URL at the time of this library
* release.
*
* @see #downloadBlob(URL)
*/
public Step5 useDefaultBlob() {
try {
return downloadBlob(new URL("https://mds.fidoalliance.org/"));
} catch (MalformedURLException e) {
throw new RuntimeException(
"Bad hard-coded trust root certificate URL. Please file a bug report.", e);
}
}
/**
* Download the metadata BLOB from the given HTTPS url
.
*
*
The BLOB will be downloaded if it does not exist in the cache, or if the
* nextUpdate
property of the cached BLOB is the current date or earlier.
*
*
If the BLOB is downloaded, it is also written to the cache {@link File} or {@link
* Consumer} configured in the next step.
*
* @param url the HTTP URL to download. It MUST use the https:
scheme.
*/
public Step5 downloadBlob(@NonNull URL url) {
return new Step5(this, null, url);
}
/**
* Use the given metadata BLOB; never download it.
*
*
The blob signature and trust chain will still be verified, but it is the caller's
* responsibility to renew the metadata BLOB according to the FIDO
* Metadata Service specification.
*
* @param blobJwt the Metadata BLOB in JWT format as defined in FIDO
* Metadata Service §3.1.7. Metadata BLOB. The byte array MUST NOT be Base64-decoded.
* @see FIDO
* Metadata Service §3.1.7. Metadata BLOB
* @see FIDO
* Metadata Service §3.2. Metadata BLOB object processing rules
*/
public FidoMetadataDownloaderBuilder useBlob(@NonNull String blobJwt) {
return finishRequiredSteps(new Step5(this, blobJwt, null), null, null, null);
}
}
/**
* Step 5: Configure how to cache the metadata BLOB.
*
*
This step offers two mutually exclusive options:
*
*
* - Cache the metadata BLOB in a {@link File}. See {@link Step5#useBlobCacheFile(File)}.
*
- Cache the metadata BLOB using a {@link Supplier} to read the cache and a {@link
* Consumer} to write the cache. See {@link Step5#useBlobCache(Supplier, Consumer)}.
*
*/
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public static class Step5 {
@NonNull private final Step4 step4;
private final String blobJwt;
private final URL blobUrl;
/**
* Cache metadata BLOB in the file cacheFile
.
*
* If cacheFile
exists, is a normal file, is readable, and is not out of date,
* then it will be used as the FIDO Metadata Service BLOB.
*
*
Otherwise, the metadata BLOB will be downloaded and written to this file.
*
* @param cacheFile a {@link File} which may or may not exist. If it exists, it MUST contain
* the metadata BLOB in JWS compact serialization format [RFC7515].
*/
public FidoMetadataDownloaderBuilder useBlobCacheFile(@NonNull File cacheFile) {
return finishRequiredSteps(this, cacheFile, null, null);
}
/**
* Cache the metadata BLOB using a {@link Supplier} to read the cache, and using a {@link
* Consumer} to write the cache.
*
*
If getCachedBlob
returns non-empty and the content is not out of date, then
* it will be used as the FIDO Metadata Service BLOB.
*
*
Otherwise, the metadata BLOB will be downloaded and written to writeCachedBlob
*
.
*
* @param getCachedBlob a {@link Supplier} that fetches the cached metadata BLOB if it exists.
* MUST NOT return null
. The returned value, if present, MUST be in JWS
* compact serialization format [RFC7515].
* @param writeCachedBlob a {@link Consumer} that accepts the metadata BLOB in JWS compact
* serialization format [RFC7515] and
* writes it to the cache. Its argument will never be null
.
*/
public FidoMetadataDownloaderBuilder useBlobCache(
@NonNull Supplier> getCachedBlob,
@NonNull Consumer writeCachedBlob) {
return finishRequiredSteps(this, null, getCachedBlob, writeCachedBlob);
}
}
private static FidoMetadataDownloaderBuilder finishRequiredSteps(
FidoMetadataDownloaderBuilder.Step5 step5,
File blobCacheFile,
Supplier> blobCacheSupplier,
Consumer blobCacheConsumer) {
return new FidoMetadataDownloaderBuilder(
step5.step4.step3.step2.expectedLegalHeaders,
step5.step4.step3.trustRootCertificate,
step5.step4.step3.trustRootUrl,
step5.step4.step3.trustRootSha256,
step5.step4.trustRootCacheFile,
step5.step4.trustRootCacheSupplier,
step5.step4.trustRootCacheConsumer,
step5.blobJwt,
step5.blobUrl,
blobCacheFile,
blobCacheSupplier,
blobCacheConsumer);
}
/**
* Use clock
as the source of the current time for some application-level logic.
*
* This is primarily intended for testing.
*
*
The default is {@link Clock#systemUTC()}.
*
* @param clock a {@link Clock} which the finished {@link FidoMetadataDownloader} will use to
* tell the time.
*/
public FidoMetadataDownloaderBuilder clock(@NonNull Clock clock) {
this.clock = clock;
return this;
}
/**
* Use the provided CRLs.
*
*
CRLs will also be downloaded from distribution points if the
* com.sun.security.enableCRLDP
system property is set to true
(assuming the
* use of the {@link CertPathValidator} implementation from the SUN provider).
*
* @throws InvalidAlgorithmParameterException if {@link CertStore#getInstance(String,
* CertStoreParameters)} does.
* @throws NoSuchAlgorithmException if a "Collection"
type {@link CertStore}
* provider is not available.
* @see #useCrls(CertStore)
*/
public FidoMetadataDownloaderBuilder useCrls(@NonNull Collection crls)
throws InvalidAlgorithmParameterException, NoSuchAlgorithmException {
return useCrls(CertStore.getInstance("Collection", new CollectionCertStoreParameters(crls)));
}
/**
* Use CRLs in the provided {@link CertStore}.
*
* CRLs will also be downloaded from distribution points if the
* com.sun.security.enableCRLDP
system property is set to true
(assuming the
* use of the {@link CertPathValidator} implementation from the SUN provider).
*
* @see #useCrls(Collection)
*/
public FidoMetadataDownloaderBuilder useCrls(CertStore certStore) {
this.certStore = certStore;
return this;
}
/**
* 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.
*
*
If provided, these will be used for downloading
*
*
* - the trust root certificate for the BLOB signature chain, and
*
- the metadata BLOB.
*
*
* If not set, the system default certificate store will be used.
*/
public FidoMetadataDownloaderBuilder trustHttpsCerts(@NonNull X509Certificate... certificates) {
final KeyStore trustStore;
try {
trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
trustStore.load(null);
} catch (KeyStoreException
| IOException
| NoSuchAlgorithmException
| CertificateException e) {
throw new RuntimeException(
"Failed to instantiate or initialize KeyStore. This should not be possible, please file a bug report.",
e);
}
for (X509Certificate cert : certificates) {
try {
trustStore.setCertificateEntry(UUID.randomUUID().toString(), cert);
} catch (KeyStoreException e) {
throw new RuntimeException(
"Failed to import HTTPS cert into KeyStore. This should not be possible, please file a bug report.",
e);
}
}
this.httpsTrustStore = trustStore;
return this;
}
/**
* If set to true
, the BLOB signature will not be verified when loading the BLOB
* from cache or when explicitly set via {@link Step4#useBlob(String)}. This means that if a
* BLOB was successfully verified once and written to cache, that cached value will be
* implicitly trusted when loaded in the future.
*
* If set to false
, the BLOB signature will always be verified no matter where
* the BLOB came from. This means that a cached BLOB may become invalid if the BLOB certificate
* expires, even if the BLOB was successfully verified at the time it was downloaded.
*
*
The default setting is false
.
*
* @param verifyDownloadsOnly true
if the BLOB signature should be ignored when
* loading the BLOB from cache or when explicitly set via {@link Step4#useBlob(String)}.
*/
public FidoMetadataDownloaderBuilder verifyDownloadsOnly(final boolean verifyDownloadsOnly) {
this.verifyDownloadsOnly = verifyDownloadsOnly;
return this;
}
}
/**
* Load the metadata BLOB from cache, or download a fresh one if necessary.
*
*
This method is NOT THREAD SAFE since it reads and writes caches.
*
*
On each execution this will, in order:
*
*
* - Download the trust root certificate, if necessary: if the cache is empty, the cache fails
* to load, or the cached cert is not valid at the current time (as determined by the {@link
* FidoMetadataDownloaderBuilder#clock(Clock) clock} setting).
*
- If downloaded, cache the trust root certificate using the configured {@link File} or
* {@link Consumer} (see {@link FidoMetadataDownloaderBuilder.Step3})
*
- Download the metadata BLOB, if necessary: if the cache is empty, the cache fails to load,
* or the
"nextUpdate"
property in the cached BLOB is the current date (as
* determined by the {@link FidoMetadataDownloaderBuilder#clock(Clock) clock} setting) or
* earlier.
* - Check the
"no"
property of the downloaded BLOB, if any, and compare it with
* the "no"
of the cached BLOB, if any. The one with a greater "no"
*
overrides the other, even if its "nextUpdate"
is in the past.
* - If a BLOB with a newer
"no"
was downloaded, verify that the value of its
* "legalHeader"
appears in the configured {@link
* FidoMetadataDownloaderBuilder.Step1#expectLegalHeader(String...) expectLegalHeader}
* setting. If not, throw an {@link UnexpectedLegalHeader} exception containing the cached
* BLOB, if any, and the downloaded BLOB.
* - If a BLOB with a newer
"no"
was downloaded and had an expected
* "legalHeader"
, cache the new BLOB using the configured {@link File} or {@link
* Consumer} (see {@link FidoMetadataDownloaderBuilder.Step5}).
*
*
* No internal mutable state is maintained between invocations of this method; each invocation
* will reload/rewrite caches, perform downloads and check the "legalHeader"
*
as necessary. You may therefore reuse a {@link FidoMetadataDownloader} instance and,
* for example, call this method periodically to refresh the BLOB when appropriate. Each call will
* return a new {@link MetadataBLOB} instance; ones already returned will not be updated by
* subsequent calls.
*
* @return the successfully retrieved and validated metadata BLOB.
* @throws Base64UrlException if the explicitly configured or newly downloaded BLOB is not a
* well-formed JWT in compact serialization.
* @throws CertPathValidatorException if the explicitly configured or newly downloaded BLOB fails
* certificate path validation.
* @throws CertificateException if the trust root certificate was downloaded and passed the
* SHA-256 integrity check, but does not contain a currently valid X.509 DER certificate; or
* if the BLOB signing certificate chain fails to parse.
* @throws DigestException if the trust root certificate was downloaded but failed the SHA-256
* integrity check.
* @throws FidoMetadataDownloaderException if the explicitly configured or newly downloaded BLOB
* (if any) has a bad signature and there is no cached BLOB to fall back to.
* @throws IOException if any of the following fails: downloading the trust root certificate,
* downloading the BLOB, reading or writing any cache file (if any), or parsing the BLOB
* contents.
* @throws InvalidAlgorithmParameterException if certificate path validation fails.
* @throws InvalidKeyException if signature verification fails.
* @throws NoSuchAlgorithmException if signature verification fails, or if the SHA-256 algorithm
* is not available.
* @throws SignatureException if signature verification fails.
* @throws UnexpectedLegalHeader if the downloaded BLOB (if any) contains a "legalHeader"
*
value not configured in {@link
* FidoMetadataDownloaderBuilder.Step1#expectLegalHeader(String...)
* expectLegalHeader(String...)} but is otherwise valid. The downloaded BLOB will not be
* written to cache in this case.
*/
public MetadataBLOB loadCachedBlob()
throws CertPathValidatorException,
InvalidAlgorithmParameterException,
Base64UrlException,
CertificateException,
IOException,
NoSuchAlgorithmException,
SignatureException,
InvalidKeyException,
UnexpectedLegalHeader,
DigestException,
FidoMetadataDownloaderException {
final X509Certificate trustRoot = retrieveTrustRootCert();
final Optional explicit = loadExplicitBlobOnly(trustRoot);
if (explicit.isPresent()) {
log.debug("Explicit BLOB is set - disregarding cache and download.");
return explicit.get();
}
final Optional cached = loadCachedBlobOnly(trustRoot);
if (cached.isPresent()) {
log.debug("Cached BLOB exists, checking expiry date...");
if (cached
.get()
.getPayload()
.getNextUpdate()
.atStartOfDay()
.atZone(clock.getZone())
.isAfter(clock.instant().atZone(clock.getZone()))) {
log.debug("Cached BLOB has not yet expired - using cached BLOB.");
return cached.get();
} else {
log.debug("Cached BLOB has expired.");
}
} else {
log.debug("Cached BLOB does not exist or is invalid.");
}
return refreshBlobInternal(trustRoot, cached).get();
}
/**
* Download and cache a fresh metadata BLOB, or read it from cache if the downloaded BLOB is not
* up to date.
*
* This method is NOT THREAD SAFE since it reads and writes caches.
*
*
On each execution this will, in order:
*
*
* - Download the trust root certificate, if necessary: if the cache is empty, the cache fails
* to load, or the cached cert is not valid at the current time (as determined by the {@link
* FidoMetadataDownloaderBuilder#clock(Clock) clock} setting).
*
- If downloaded, cache the trust root certificate using the configured {@link File} or
* {@link Consumer} (see {@link FidoMetadataDownloaderBuilder.Step3})
*
- Download the metadata BLOB.
*
- Check the
"no"
property of the downloaded BLOB and compare it with the
* "no"
of the cached BLOB, if any. The one with a greater "no"
*
overrides the other, even if its "nextUpdate"
is in the past.
* - If the downloaded BLOB has a newer
"no"
, or if no BLOB was cached, verify
* that the value of the downloaded BLOB's "legalHeader"
appears in the
* configured {@link FidoMetadataDownloaderBuilder.Step1#expectLegalHeader(String...)
* expectLegalHeader} setting. If not, throw an {@link UnexpectedLegalHeader} exception
* containing the cached BLOB, if any, and the downloaded BLOB.
* - If the downloaded BLOB has an expected
* "legalHeader"
, cache it using the configured {@link File} or {@link Consumer} (see
* {@link FidoMetadataDownloaderBuilder.Step5}).
*
*
* No internal mutable state is maintained between invocations of this method; each invocation
* will reload/rewrite caches, perform downloads and check the "legalHeader"
*
as necessary. You may therefore reuse a {@link FidoMetadataDownloader} instance and,
* for example, call this method periodically to refresh the BLOB. Each call will return a new
* {@link MetadataBLOB} instance; ones already returned will not be updated by subsequent calls.
*
* @return the successfully retrieved and validated metadata BLOB.
* @throws Base64UrlException if the explicitly configured or newly downloaded BLOB is not a
* well-formed JWT in compact serialization.
* @throws CertPathValidatorException if the explicitly configured or newly downloaded BLOB fails
* certificate path validation.
* @throws CertificateException if the trust root certificate was downloaded and passed the
* SHA-256 integrity check, but does not contain a currently valid X.509 DER certificate; or
* if the BLOB signing certificate chain fails to parse.
* @throws DigestException if the trust root certificate was downloaded but failed the SHA-256
* integrity check.
* @throws FidoMetadataDownloaderException if the explicitly configured or newly downloaded BLOB
* (if any) has a bad signature and there is no cached BLOB to fall back to.
* @throws IOException if any of the following fails: downloading the trust root certificate,
* downloading the BLOB, reading or writing any cache file (if any), or parsing the BLOB
* contents.
* @throws InvalidAlgorithmParameterException if certificate path validation fails.
* @throws InvalidKeyException if signature verification fails.
* @throws NoSuchAlgorithmException if signature verification fails, or if the SHA-256 algorithm
* is not available.
* @throws SignatureException if signature verification fails.
* @throws UnexpectedLegalHeader if the downloaded BLOB (if any) contains a "legalHeader"
*
value not configured in {@link
* FidoMetadataDownloaderBuilder.Step1#expectLegalHeader(String...)
* expectLegalHeader(String...)} but is otherwise valid. The downloaded BLOB will not be
* written to cache in this case.
*/
public MetadataBLOB refreshBlob()
throws CertPathValidatorException,
InvalidAlgorithmParameterException,
Base64UrlException,
CertificateException,
IOException,
NoSuchAlgorithmException,
SignatureException,
InvalidKeyException,
UnexpectedLegalHeader,
DigestException,
FidoMetadataDownloaderException {
final X509Certificate trustRoot = retrieveTrustRootCert();
final Optional explicit = loadExplicitBlobOnly(trustRoot);
if (explicit.isPresent()) {
log.debug("Explicit BLOB is set - disregarding cache and download.");
return explicit.get();
}
final Optional cached = loadCachedBlobOnly(trustRoot);
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();
}
private Optional refreshBlobInternal(
@NonNull X509Certificate trustRoot, @NonNull Optional cached)
throws CertPathValidatorException,
InvalidAlgorithmParameterException,
Base64UrlException,
CertificateException,
IOException,
NoSuchAlgorithmException,
SignatureException,
InvalidKeyException,
UnexpectedLegalHeader,
FidoMetadataDownloaderException {
try {
log.debug("Attempting to download new BLOB...");
final ByteArray downloadedBytes = download(blobUrl);
final MetadataBLOB downloadedBlob = parseAndVerifyBlob(downloadedBytes, trustRoot);
log.debug("New BLOB downloaded.");
if (cached.isPresent()) {
log.debug("Cached BLOB exists - checking if new BLOB has a higher \"no\"...");
if (downloadedBlob.getPayload().getNo() <= cached.get().getPayload().getNo()) {
log.debug("New BLOB does not have a higher \"no\" - using cached BLOB instead.");
return cached;
}
log.debug("New BLOB has a higher \"no\" - proceeding with new BLOB.");
}
log.debug("Checking legalHeader in new BLOB...");
if (!expectedLegalHeaders.contains(downloadedBlob.getPayload().getLegalHeader())) {
throw new UnexpectedLegalHeader(cached.orElse(null), downloadedBlob);
}
log.debug("Writing new BLOB to cache...");
if (blobCacheFile != null) {
try (FileOutputStream f = new FileOutputStream(blobCacheFile)) {
f.write(downloadedBytes.getBytes());
}
}
if (blobCacheConsumer != null) {
blobCacheConsumer.accept(downloadedBytes);
}
return Optional.of(downloadedBlob);
} catch (FidoMetadataDownloaderException e) {
if (e.getReason() == Reason.BAD_SIGNATURE && cached.isPresent()) {
log.warn("New BLOB has bad signature - falling back to cached BLOB.");
return cached;
} else {
throw e;
}
} catch (Exception e) {
if (cached.isPresent()) {
log.warn("Failed to download new BLOB - falling back to cached BLOB.", e);
return cached;
} else {
throw e;
}
}
}
/**
* @throws CertificateException if the trust root certificate was downloaded and passed the
* SHA-256 integrity check, but does not contain a currently valid X.509 DER certificate.
* @throws DigestException if the trust root certificate was downloaded but failed the SHA-256
* integrity check.
* @throws IOException if the trust root certificate download failed, or if reading or writing the
* cache file (if any) failed.
* @throws NoSuchAlgorithmException if the SHA-256 algorithm is not available.
*/
private X509Certificate retrieveTrustRootCert()
throws CertificateException, DigestException, IOException, NoSuchAlgorithmException {
if (trustRootCertificate != null) {
return trustRootCertificate;
} else {
final Optional cachedContents;
if (trustRootCacheFile != null) {
cachedContents = readCacheFile(trustRootCacheFile);
} else {
cachedContents = trustRootCacheSupplier.get();
}
X509Certificate cert = null;
if (cachedContents.isPresent()) {
final ByteArray verifiedCachedContents = verifyHash(cachedContents.get(), trustRootSha256);
if (verifiedCachedContents != null) {
try {
final X509Certificate cachedCert =
CertificateParser.parseDer(verifiedCachedContents.getBytes());
cachedCert.checkValidity(Date.from(clock.instant()));
cert = cachedCert;
} catch (CertificateException e) {
// Fall through
}
}
}
if (cert == null) {
final ByteArray 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.checkValidity(Date.from(clock.instant()));
if (trustRootCacheFile != null) {
try (FileOutputStream f = new FileOutputStream(trustRootCacheFile)) {
f.write(downloaded.getBytes());
}
}
if (trustRootCacheConsumer != null) {
trustRootCacheConsumer.accept(downloaded);
}
}
return cert;
}
}
/**
* @throws Base64UrlException if the metadata BLOB is not a well-formed JWT in compact
* serialization.
* @throws CertPathValidatorException if the explicitly configured BLOB fails certificate path
* validation.
* @throws CertificateException if the BLOB signing certificate chain fails to parse.
* @throws IOException on failure to parse the BLOB contents.
* @throws InvalidAlgorithmParameterException if certificate path validation fails.
* @throws InvalidKeyException if signature verification fails.
* @throws NoSuchAlgorithmException if signature verification fails.
* @throws SignatureException if signature verification fails.
* @throws FidoMetadataDownloaderException if the explicitly configured BLOB (if any) has a bad
* signature.
*/
private Optional loadExplicitBlobOnly(X509Certificate trustRootCertificate)
throws Base64UrlException,
CertPathValidatorException,
CertificateException,
IOException,
InvalidAlgorithmParameterException,
InvalidKeyException,
NoSuchAlgorithmException,
SignatureException,
FidoMetadataDownloaderException {
if (blobJwt != null) {
return Optional.of(
parseAndMaybeVerifyBlob(
new ByteArray(blobJwt.getBytes(StandardCharsets.UTF_8)), trustRootCertificate));
} else {
return Optional.empty();
}
}
private Optional loadCachedBlobOnly(X509Certificate trustRootCertificate) {
final Optional cachedContents;
if (blobCacheFile != null) {
log.debug("Attempting to read BLOB from cache file...");
try {
cachedContents = readCacheFile(blobCacheFile);
} catch (IOException e) {
return Optional.empty();
}
} else {
log.debug("Attempting to read BLOB from cache Supplier...");
cachedContents = blobCacheSupplier.get();
}
return cachedContents.map(
cached -> {
try {
return parseAndMaybeVerifyBlob(cached, trustRootCertificate);
} catch (Exception e) {
log.warn("Failed to read or parse cached BLOB.", e);
return null;
}
});
}
private Optional readCacheFile(File cacheFile) throws IOException {
if (cacheFile.exists() && cacheFile.canRead() && cacheFile.isFile()) {
try (FileInputStream f = new FileInputStream(cacheFile)) {
return Optional.of(readAll(f));
} catch (FileNotFoundException e) {
throw new RuntimeException(
"This exception should be impossible, please file a bug report.", e);
}
} else {
return Optional.empty();
}
}
private ByteArray download(URL url) throws IOException {
URLConnection conn = url.openConnection();
if (conn instanceof HttpsURLConnection) {
HttpsURLConnection httpsConn = (HttpsURLConnection) conn;
if (httpsTrustStore != null) {
try {
TrustManagerFactory trustMan =
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
trustMan.init(httpsTrustStore);
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, trustMan.getTrustManagers(), null);
httpsConn.setSSLSocketFactory(sslContext.getSocketFactory());
} catch (NoSuchAlgorithmException | KeyStoreException | KeyManagementException e) {
throw new RuntimeException(
"Failed to initialize HTTPS trust store. This should be impossible, please file a bug report.",
e);
}
}
httpsConn.setRequestMethod("GET");
}
return readAll(conn.getInputStream());
}
private MetadataBLOB parseAndVerifyBlob(ByteArray jwt, X509Certificate trustRootCertificate)
throws CertPathValidatorException,
InvalidAlgorithmParameterException,
CertificateException,
IOException,
NoSuchAlgorithmException,
SignatureException,
InvalidKeyException,
Base64UrlException,
FidoMetadataDownloaderException {
return verifyBlob(parseBlob(jwt), trustRootCertificate);
}
private MetadataBLOB parseAndMaybeVerifyBlob(ByteArray jwt, X509Certificate trustRootCertificate)
throws CertPathValidatorException,
InvalidAlgorithmParameterException,
CertificateException,
IOException,
NoSuchAlgorithmException,
SignatureException,
InvalidKeyException,
Base64UrlException,
FidoMetadataDownloaderException {
if (verifyDownloadsOnly) {
return parseBlob(jwt).blob;
} else {
return verifyBlob(parseBlob(jwt), trustRootCertificate);
}
}
private MetadataBLOB verifyBlob(ParseResult parseResult, X509Certificate trustRootCertificate)
throws IOException,
CertificateException,
NoSuchAlgorithmException,
InvalidKeyException,
SignatureException,
CertPathValidatorException,
InvalidAlgorithmParameterException,
FidoMetadataDownloaderException {
final MetadataBLOBHeader header = parseResult.blob.getHeader();
final List certChain;
if (header.getX5u().isPresent()) {
final URL x5u = header.getX5u().get();
if (blobUrl != null
&& (!(x5u.getHost().equals(blobUrl.getHost())
&& x5u.getProtocol().equals(blobUrl.getProtocol())
&& x5u.getPort() == blobUrl.getPort()))) {
throw new IllegalArgumentException(
String.format(
"x5u in BLOB header must have same origin as the URL the BLOB was downloaded from. Expected origin of: %s ; found: %s",
blobUrl, x5u));
}
List certs = new ArrayList<>();
for (String pem :
new String(download(x5u).getBytes(), StandardCharsets.UTF_8)
.trim()
.split("\\n+-----END CERTIFICATE-----\\n+-----BEGIN CERTIFICATE-----\\n+")) {
X509Certificate x509Certificate = CertificateParser.parsePem(pem);
certs.add(x509Certificate);
}
certChain = certs;
} else if (header.getX5c().isPresent()) {
certChain = header.getX5c().get();
} else {
certChain = Collections.singletonList(trustRootCertificate);
}
final X509Certificate leafCert = certChain.get(0);
final Signature signature;
switch (header.getAlg()) {
case "RS256":
signature = Signature.getInstance("SHA256withRSA");
break;
case "ES256":
signature = Signature.getInstance("SHA256withECDSA");
break;
default:
throw new UnsupportedOperationException(
"Unimplemented JWT verification algorithm: " + header.getAlg());
}
signature.initVerify(leafCert.getPublicKey());
signature.update(
(parseResult.jwtHeader.getBase64Url() + "." + parseResult.jwtPayload.getBase64Url())
.getBytes(StandardCharsets.UTF_8));
if (!signature.verify(parseResult.jwtSignature.getBytes())) {
throw new FidoMetadataDownloaderException(Reason.BAD_SIGNATURE);
}
final CertificateFactory certFactory = CertificateFactory.getInstance("X.509");
final CertPathValidator cpv = CertPathValidator.getInstance("PKIX");
final CertPath blobCertPath = certFactory.generateCertPath(certChain);
final PKIXParameters pathParams =
new PKIXParameters(Collections.singleton(new TrustAnchor(trustRootCertificate, null)));
if (certStore != null) {
pathParams.addCertStore(certStore);
}
pathParams.setDate(Date.from(clock.instant()));
cpv.validate(blobCertPath, pathParams);
return parseResult.blob;
}
private static ParseResult parseBlob(ByteArray jwt) throws IOException, Base64UrlException {
Scanner s = new Scanner(new ByteArrayInputStream(jwt.getBytes())).useDelimiter("\\.");
final ByteArray jwtHeader = ByteArray.fromBase64Url(s.next());
final ByteArray jwtPayload = ByteArray.fromBase64Url(s.next());
final ByteArray jwtSignature = ByteArray.fromBase64Url(s.next());
final ObjectMapper headerJsonMapper =
JacksonCodecs.json().setBase64Variant(Base64Variants.MIME_NO_LINEFEEDS);
return new ParseResult(
new MetadataBLOB(
headerJsonMapper.readValue(jwtHeader.getBytes(), MetadataBLOBHeader.class),
JacksonCodecs.jsonWithDefaultEnums()
.readValue(jwtPayload.getBytes(), MetadataBLOBPayload.class)),
jwtHeader,
jwtPayload,
jwtSignature);
}
private static ByteArray readAll(InputStream is) throws IOException {
return new ByteArray(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)
throws NoSuchAlgorithmException {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
final ByteArray hash = new ByteArray(digest.digest(contents.getBytes()));
if (acceptedCertSha256.stream().anyMatch(hash::equals)) {
return contents;
} else {
return null;
}
}
@Value
private static class ParseResult {
private MetadataBLOB blob;
private ByteArray jwtHeader;
private ByteArray jwtPayload;
private ByteArray jwtSignature;
}
}