Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions NEWS
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ Changes:

* Default `FidoMetadataDownloader` trust root changed from GlobalSign R3 to R46.
The default is used if using the builder method `useDefaultTrustRoot()`.
* If `FidoMetadataDownloader` fails to validate the MDS trust path, it will now
retry the validation with the trust path truncated one certificate at a time.
This enables successfully validating metadata BLOBs with a cross-signed trust
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


== Version 2.9.0 ==
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1271,7 +1271,6 @@ private MetadataBLOB verifyBlob(ParseResult parseResult, X509Certificate trustRo

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) {
Expand All @@ -1283,9 +1282,29 @@ private MetadataBLOB verifyBlob(ParseResult parseResult, X509Certificate trustRo
fetchCrlDistributionPoints(certChain, certFactory).ifPresent(pathParams::addCertStore);

pathParams.setDate(Date.from(clock.instant()));
cpv.validate(blobCertPath, pathParams);

return parseResult.blob;
// Try validating first the full cert path, and if that fails retry by omitting one cert at a
// time from the end.
// This enables "short-circuiting" the cert path if the trust anchor appears in the cert path,
// as was the case in August 2026 when the new trust anchor "R46" appeared last in the cert path
// signed by the previous trust anchor "R3".
CertPathValidatorException firstError = null;
for (int pathLen = certChain.size(); pathLen >= 1; --pathLen) {
final CertPath blobCertPath = certFactory.generateCertPath(certChain.subList(0, pathLen));
try {
cpv.validate(blobCertPath, pathParams);
return parseResult.blob;
} catch (CertPathValidatorException e) {
if (firstError == null) {
firstError = e;
}
if (pathLen == 1) {
throw firstError;
}
}
}
throw new IllegalStateException(
"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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import java.security.cert.CRL
import java.security.cert.CertPathValidatorException
import java.security.cert.CertPathValidatorException.BasicReason
import java.security.cert.CertificateExpiredException
import java.security.cert.PKIXReason
import java.security.cert.X509Certificate
import java.time.Clock
import java.time.Instant
Expand Down Expand Up @@ -113,8 +114,9 @@ class FidoMetadataDownloaderSpec
isCa: Boolean = false,
name: String =
"CN=Yubico java-webauthn-server unit tests blob cert, O=Yubico",
certKeypair: Option[KeyPair] = None,
): (X509Certificate, KeyPair, X500Name) = {
val keypair = TestAuthenticator.generateEcKeypair()
val keypair = certKeypair getOrElse TestAuthenticator.generateEcKeypair()
val x500Name = new X500Name(name)
(
TestAuthenticator.buildCertificate(
Expand Down Expand Up @@ -2111,6 +2113,81 @@ class FidoMetadataDownloaderSpec
blob should not be null
blob.getNo should equal(blobNo)
}

it("A cross-signed trust root cert appearing in the cert path validates successfully.") {
val (unrelatedRootCert, unrelatedRootKeypair, unrelatedRootName) =
makeTrustRootCert(distinguishedName =
"CN=Yubico java-webauthn-server unit tests UNRELATED CA, O=Yubico"
)
val (oldRootCert, oldRootKeypair, oldRootName) =
makeTrustRootCert(distinguishedName =
"CN=Yubico java-webauthn-server unit tests OLD CA, O=Yubico"
)
val (newRootCert, newCaKeypair, newCaName) =
makeTrustRootCert(distinguishedName =
"CN=Yubico java-webauthn-server unit tests NEW CA, O=Yubico"
)
val (crossCert, _, _) = makeCert(
oldRootKeypair,
oldRootName,
name = newCaName.toString,
certKeypair = Some(newCaKeypair),
isCa = true,
)
val (blobCert, blobKeypair, _) = makeCert(newCaKeypair, newCaName)
val crls = List(
(oldRootName, oldRootKeypair),
(newCaName, newCaKeypair),
(unrelatedRootName, unrelatedRootKeypair),
).map({
case (name, keypair) =>
TestAuthenticator.buildCrl(
name,
keypair.getPrivate,
"SHA256withECDSA",
CertValidFrom,
CertValidTo,
)
})

val blobJwt = makeBlob(
List(blobCert, crossCert),
blobKeypair,
LocalDate.parse("2022-01-19"),
)

for (trustRoot <- List(newRootCert, oldRootCert)) {
val blob = load(
FidoMetadataDownloader
.builder()
.expectLegalHeader(
"Kom ihåg att du aldrig får snyta dig i mattan!"
)
.useTrustRoot(trustRoot)
.useBlob(blobJwt)
.clock(Clock.fixed(CertValidFrom, ZoneOffset.UTC))
.useCrls(crls.asJava)
.build()
)
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()
)
}
thrown.getReason should be(PKIXReason.NO_TRUST_ANCHOR)
}
}

describe("7. Write the verified object to a local cache as required.") {
Expand Down
Loading