I've been using Badssl to test certificate verification for a tiny Java client. It used to work well in January. But as of today, there's something failing : the certificate for Badssl base domain (badssl.com) is not verified anymore by the following piece of code :
public static void main(String[] args) throws KeyStoreException, IOException, CertificateException, NoSuchAlgorithmException {
KeyStore keystore=KeyStore.getInstance("JKS");
FileInputStream stream = new FileInputStream(getTrustStore());
keystore.load(stream, getTrustStorePassword());
X509TrustManager secureTrustManager = new X509TrustManager() {
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
}
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType) {
PKIXParameters params = null;
try {
CertPath certPath = CertificateFactory.getInstance("X.509").generateCertPath(Arrays.asList(chain));
params = new PKIXParameters(keystore);
params.setRevocationEnabled(true);
CertPathValidator.getInstance("PKIX").validate(certPath, params);
} catch (CertPathValidatorException e) {
if (e.getMessage().startsWith("Certificate has been revoked")) {
System.out.println("One certificate of the trust chain has been revoked : " + e.getMessage());
} else {
System.out.println(chain[2]); // Displays the root CA - ISRG X1
System.out.println("============TRUSTANCHORS==============");
// Displays all trusted certs, including ISRG X1
for (TrustAnchor t : params.getTrustAnchors()) {
System.out.println(t.toString());
}
// This is where the code unexpectedly exits
System.out.println("Unexpected error during certificate revocation validation : " + e.getMessage());
}
} catch (InvalidAlgorithmParameterException | NoSuchAlgorithmException | CertificateException | KeyStoreException e) {
System.out.println("Unexpected error during certificate revocation validation : " + e.getMessage());
}
}
@Override
public X509Certificate[] getAcceptedIssuers() {
return null;
}
};
}
The error message that comes along with the exit is :
Path does not chain with any of the trust anchors
I've been digging around, (hence the System.out in the middle), and found out that there is a slight difference between the certificate my webclient gets, and the certificate in Java's truststore :
Version: V3
Subject: CN=ISRG Root X1, O=Internet Security Research Group, C=US
Signature Algorithm: SHA256withRSA, OID = 1.2.840.113549.1.1.11
Key: Sun RSA public key, 4096 bits
params: null
modulus: 709477870415445373015359016562426660610553770685944520893298396600226760899977879191004898543350831842119174188613678136510262472550532722234131754439181090009824131001234702144200501816519311599904090606194984753842587622398776018408050245574116028550608708896478977104703101364577377554823893350339376892984086676842821506637376561471221178677513035811884589888230947855482554780924844280661412982827405878164907670403886160896655313460186264922042760067692235383478494519985672059698752915965998412445946254227413232257276525240006651483130792248112417425846451951438781260632137645358927568158361961710185115502577127010922344394993078948994750404287047493247048147066090211292167313905862438457453781042040498702821432013765502024105065778257759178356925494156447570322373310256999609083201778278588599854706241788119448943034477370959349516873162063461521707809689839710972753590949570167489887658749686740890549110678989462474318310617765270337415238713770800711236563610171101328052424145478220993016515262478543813796899677215192789612682845145008993144513547444131126029557147570005369943143213525671105288817016183804256755470528641042403865830064493168693765438364296560479053823886598989258655438933191724193029337334607
public exponent: 65537
Validity: [From: Wed Jan 20 20:14:03 CET 2021,
To: Mon Sep 30 20:14:03 CEST 2024]
Issuer: CN=DST Root CA X3, O=Digital Signature Trust Co.
SerialNumber: [ 40017721 37d4e942 b8ee76aa 3c640ab7]
Version: V3
Subject: CN=ISRG Root X1, O=Internet Security Research Group, C=US
Signature Algorithm: SHA256withRSA, OID = 1.2.840.113549.1.1.11
Key: Sun RSA public key, 4096 bits
params: null
modulus: 709477870415445373015359016562426660610553770685944520893298396600226760899977879191004898543350831842119174188613678136510262472550532722234131754439181090009824131001234702144200501816519311599904090606194984753842587622398776018408050245574116028550608708896478977104703101364577377554823893350339376892984086676842821506637376561471221178677513035811884589888230947855482554780924844280661412982827405878164907670403886160896655313460186264922042760067692235383478494519985672059698752915965998412445946254227413232257276525240006651483130792248112417425846451951438781260632137645358927568158361961710185115502577127010922344394993078948994750404287047493247048147066090211292167313905862438457453781042040498702821432013765502024105065778257759178356925494156447570322373310256999609083201778278588599854706241788119448943034477370959349516873162063461521707809689839710972753590949570167489887658749686740890549110678989462474318310617765270337415238713770800711236563610171101328052424145478220993016515262478543813796899677215192789612682845145008993144513547444131126029557147570005369943143213525671105288817016183804256755470528641042403865830064493168693765438364296560479053823886598989258655438933191724193029337334607
public exponent: 65537
Validity: [From: Thu Jun 04 13:04:38 CEST 2015,
To: Mon Jun 04 13:04:38 CEST 2035]
Issuer: CN=ISRG Root X1, O=Internet Security Research Group, C=US
SerialNumber: [ 8210cfb0 d240e359 4463e0bb 63828b00]
So it appears what I have in my truststore doesn't match what badssl.com sends me.
Still, when I browse badssl.com (Tried with multiple browsers), it works properly (the certificate chain send contains the 2035 expiry date certificate contained in my OS (and in the Java truststore).
I performed another test via openssl command line (get badssl.com certificate via openssl, then just format it in human-readable fashion) :
openssl s_client -showcerts -connect badssl.com:443 </dev/null 2>/dev/null| sed -n '/BEGIN CERTIFICATE/,/END CERTIFICATE/p' | tac | sed '/BEGIN CERT/q' | tac | openssl x509 -text -noout'
And I also get the 2024 expiry date.
So it seems to me badssl.com is sending me a different certificate chain when I call him :
Am I missing something, or is this some weird behavior on badssl side ? Plus, should Java TrustManager validate this cert, despite the strange certificate chain send by badssl ? TL;DR : Should I correct my Java code or should some things change on badssl side to make it work ?
Also, if something has an explanation of how that odd behavior is possible (side effect of a specific configuration, load balancer not updated, ...), I would be grateful to understand how that can happen !
Thanks!
This is common/standard for LetsEncrypt certs, see https://letsencrypt.org/docs/dst-root-ca-x3-expiration-september-2021 and as linked there https://letsencrypt.org/2020/12/21/extending-android-compatibility.html (and also dozens of Qs on this and several other Stacks last Sept-Oct when the DST X3 expiration occurred, essentially all of them due to OpenSSL 1.0.2 which mishandled it). From the transparency logs (per the Sectigo viewer) it appears badssl didn't have any LetsEncrypt cert before 2022-02, and after then might or might not have used it for the base domain you specify.
The server always sends the 'temporary' (2024) cross cert -- with issuer DST X3 shown in your display -- but a non-obsolete client can ignore it and use (substitute) the root -- with issuer ISRG and expiration 2035 -- which contains the same subject name and subject key so it verifies the lower-level cert(s). A browser like Firefox or Chrome/Edge shows you the cert chain found (and substituted) from the local truststore not necessarily the one received. Similarly openssl s_client -showcerts
shows in PEM the certs received, and that's what your command looks at, but the callback trace at the beginning, on stderr which your command discarded, shows the cert chain that was actually validated, followed by on stdout the cert chain received, and (given 1.1.0 up with a decent truststore i.e. cacert.pem file or similar) the validation will succeed like this:
$ openssl s_client -connect badssl.com:443 -servername badssl.com
depth=2 C = US, O = Internet Security Research Group, CN = ISRG Root X1
verify return:1
depth=1 C = US, O = Let's Encrypt, CN = R3
verify return:1
depth=0 CN = *.badssl.com
verify return:1
CONNECTED(00000003)
---
Certificate chain
0 s:CN = *.badssl.com
i:C = US, O = Let's Encrypt, CN = R3
1 s:C = US, O = Let's Encrypt, CN = R3
i:C = US, O = Internet Security Research Group, CN = ISRG Root X1
2 s:C = US, O = Internet Security Research Group, CN = ISRG Root X1
i:O = Digital Signature Trust Co., CN = DST Root CA X3
... (much) more
The cert sent at depth=2 was ISRG-cross-DST but the one validated, substituted from the truststore, was ISRG-root; we can tell by the absence of a verify error which would be printed if nonzero.
Java CertPathValidator only checks the chain you give it, and thus may fail on recent versions since the expired DST X3 root has been removed from the default truststore at least in Oracle builds (OpenJDK may vary, especially where it is built to use a platform-provided truststore, as is typical on Linux). But Java does not check anchor expiration, so if you use an older truststore that had DST X3, or manually add it back, this succeeds.
However the JSSE default TrustManager is not limited to validating the chain received; it tries that first, but if that fails it tries to build an alternate and valid chain (for the same leaf) with CertPathBuilder, which succeeds for this case using the ISRG root. (AFAICT this re-built path isn't available on any API, which can be a nuisance.) That's why when you connect to a server with a bad cert the exception thrown and usually displayed/logged says "path building failed" -- it first tried validating as received, which failed, then it tried building (and validating), which also failed, and then it threw.
That behavior -- trying to find an alternate chain if needed -- is generally considered best practice, and probably what you should do it you want to write your own TrustManager for SSL/TLS, though it is not required by any applicable standard I know of. Browsers can also fill in chain/intermediate certs if the server fails to send them as required, if the available cert(s) has(have) AIA with caIssuer usable; Java CertPathBuilder can do this but not by default.
Incidentally you can also show in PEM the cert chain (sent and) received -- not validated at all -- with keytool -printcert -sslserver badssl.com -rfc
. If you leave off -rfc
it decodes each cert in a form similar to openssl x509 -text
, which is convenient if you just want to look at one or a few field(s) as here, but doesn't give you the definitive cert encoding usable for additional checking or processing later.