diff --git a/ph-security/pom.xml b/ph-security/pom.xml index 92abea437..2a97ff8d2 100644 --- a/ph-security/pom.xml +++ b/ph-security/pom.xml @@ -73,7 +73,7 @@ com.helger.commons ph-bc - true + test com.helger.commons diff --git a/ph-security/src/main/java/com/helger/security/crl/CRLDistributionPointParser.java b/ph-security/src/main/java/com/helger/security/crl/CRLDistributionPointParser.java new file mode 100644 index 000000000..8e70e2353 --- /dev/null +++ b/ph-security/src/main/java/com/helger/security/crl/CRLDistributionPointParser.java @@ -0,0 +1,216 @@ +/* + * Copyright (C) 2014-2026 Philip Helger (www.helger.com) + * philip[at]helger[dot]com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.helger.security.crl; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +import org.jspecify.annotations.NonNull; + +import com.helger.collection.commons.CommonsArrayList; +import com.helger.collection.commons.ICommonsList; + +/** + * Parser for the small DER subset used by the X.509 CRL Distribution Points extension. + * + * @author GT + */ +final class CRLDistributionPointParser +{ + private static final int TAG_OCTET_STRING = 0x04; + private static final int TAG_SEQUENCE = 0x30; + private static final int TAG_DISTRIBUTION_POINT_NAME = 0xa0; + private static final int TAG_FULL_NAME = 0xa0; + private static final int TAG_URI = 0x86; + + private CRLDistributionPointParser () + {} + + @NonNull + public static ICommonsList parse (final byte @NonNull [] aExtensionValue) + { + try + { + final DERReader aOuterReader = new DERReader (aExtensionValue); + final DERValue aExtensionOctets = aOuterReader.readExpected (TAG_OCTET_STRING); + aOuterReader.requireEnd (); + + final DERReader aExtensionReader = aExtensionOctets.createReader (); + final DERValue aDistributionPointsSequence = aExtensionReader.readExpected (TAG_SEQUENCE); + aExtensionReader.requireEnd (); + + final ICommonsList ret = new CommonsArrayList <> (); + final DERReader aDistributionPointsReader = aDistributionPointsSequence.createReader (); + while (aDistributionPointsReader.hasRemaining ()) + { + final DERValue aDistributionPoint = aDistributionPointsReader.readExpected (TAG_SEQUENCE); + _readDistributionPoint (aDistributionPoint, ret); + } + return ret; + } + catch (final IOException ex) + { + throw new UncheckedIOException ("Failed to decode the X.509 CRL Distribution Points extension", ex); + } + } + + private static void _readDistributionPoint (@NonNull final DERValue aDistributionPoint, + @NonNull final ICommonsList aTarget) throws IOException + { + final DERReader aFieldsReader = aDistributionPoint.createReader (); + while (aFieldsReader.hasRemaining ()) + { + final DERValue aField = aFieldsReader.read (); + if (aField.getTag () == TAG_DISTRIBUTION_POINT_NAME) + _readDistributionPointName (aField, aTarget); + } + } + + private static void _readDistributionPointName (@NonNull final DERValue aDistributionPointName, + @NonNull final ICommonsList aTarget) throws IOException + { + final DERReader aNameReader = aDistributionPointName.createReader (); + final DERValue aName = aNameReader.read (); + aNameReader.requireEnd (); + + if (aName.getTag () == TAG_FULL_NAME) + { + final DERReader aGeneralNamesReader = aName.createReader (); + while (aGeneralNamesReader.hasRemaining ()) + { + final DERValue aGeneralName = aGeneralNamesReader.read (); + if (aGeneralName.getTag () == TAG_URI) + aTarget.add (_readIA5String (aGeneralName.getValue ()).trim ()); + } + } + } + + @NonNull + private static String _readIA5String (final byte @NonNull [] aValue) throws IOException + { + for (final byte b : aValue) + if ((b & 0x80) != 0) + throw new IOException ("IA5String contains a non-ASCII byte"); + return new String (aValue, StandardCharsets.US_ASCII); + } + + private static final class DERReader + { + private final byte [] m_aData; + private int m_nPosition; + + DERReader (final byte @NonNull [] aData) + { + m_aData = aData; + } + + public boolean hasRemaining () + { + return m_nPosition < m_aData.length; + } + + public void requireEnd () throws IOException + { + if (hasRemaining ()) + throw new IOException ("Unexpected trailing DER data"); + } + + @NonNull + public DERValue readExpected (final int nExpectedTag) throws IOException + { + final DERValue ret = read (); + if (ret.getTag () != nExpectedTag) + throw new IOException ("Expected DER tag " + nExpectedTag + " but found " + ret.getTag ()); + return ret; + } + + @NonNull + public DERValue read () throws IOException + { + final int nTag = _readUnsignedByte (); + if ((nTag & 0x1f) == 0x1f) + throw new IOException ("High-tag-number DER values are not supported"); + + final int nLength = _readLength (); + if (nLength > m_aData.length - m_nPosition) + throw new IOException ("DER value length exceeds the available data"); + + final byte [] aValue = Arrays.copyOfRange (m_aData, m_nPosition, m_nPosition + nLength); + m_nPosition += nLength; + return new DERValue (nTag, aValue); + } + + private int _readLength () throws IOException + { + final int nFirst = _readUnsignedByte (); + if ((nFirst & 0x80) == 0) + return nFirst; + + final int nLengthBytes = nFirst & 0x7f; + if (nLengthBytes == 0) + throw new IOException ("Indefinite-length encoding is not valid DER"); + if (nLengthBytes > 4 || nLengthBytes > m_aData.length - m_nPosition) + throw new IOException ("Invalid DER length field"); + if ((m_aData[m_nPosition] & 0xff) == 0) + throw new IOException ("DER length has a redundant leading zero"); + + long nLength = 0; + for (int i = 0; i < nLengthBytes; ++i) + nLength = (nLength << 8) | _readUnsignedByte (); + if (nLength < 128 || nLength > Integer.MAX_VALUE) + throw new IOException ("Invalid DER length value"); + return (int) nLength; + } + + private int _readUnsignedByte () throws IOException + { + if (!hasRemaining ()) + throw new IOException ("Unexpected end of DER data"); + return m_aData[m_nPosition++] & 0xff; + } + } + + private static final class DERValue + { + private final int m_nTag; + private final byte [] m_aValue; + + DERValue (final int nTag, final byte @NonNull [] aValue) + { + m_nTag = nTag; + m_aValue = aValue; + } + + public int getTag () + { + return m_nTag; + } + + public byte @NonNull [] getValue () + { + return m_aValue; + } + + @NonNull + public DERReader createReader () + { + return new DERReader (m_aValue); + } + } +} diff --git a/ph-security/src/main/java/com/helger/security/crl/CRLHelper.java b/ph-security/src/main/java/com/helger/security/crl/CRLHelper.java index 59e7b91c1..c5ed268de 100644 --- a/ph-security/src/main/java/com/helger/security/crl/CRLHelper.java +++ b/ph-security/src/main/java/com/helger/security/crl/CRLHelper.java @@ -16,24 +16,12 @@ */ package com.helger.security.crl; -import java.io.IOException; -import java.io.UncheckedIOException; import java.security.cert.CRLException; import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; import java.security.cert.X509CRL; import java.security.cert.X509Certificate; -import org.bouncycastle.asn1.ASN1IA5String; -import org.bouncycastle.asn1.ASN1InputStream; -import org.bouncycastle.asn1.ASN1Primitive; -import org.bouncycastle.asn1.DEROctetString; -import org.bouncycastle.asn1.x509.CRLDistPoint; -import org.bouncycastle.asn1.x509.DistributionPoint; -import org.bouncycastle.asn1.x509.DistributionPointName; -import org.bouncycastle.asn1.x509.Extension; -import org.bouncycastle.asn1.x509.GeneralName; -import org.bouncycastle.asn1.x509.GeneralNames; import org.jspecify.annotations.NonNull; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -46,8 +34,7 @@ import com.helger.collection.commons.ICommonsList; /** - * Helper class to deal with CRLs. This class requires BouncyCastle to be in the - * classpath. + * Helper class to deal with CRLs. * * @author Philip Helger * @since 11.2.0 @@ -56,6 +43,7 @@ public final class CRLHelper { private static final Logger LOGGER = LoggerFactory.getLogger (CRLHelper.class); + private static final String CRL_DISTRIBUTION_POINTS_OID = "2.5.29.31"; private CRLHelper () {} @@ -104,67 +92,14 @@ public static X509CRL convertToCRL (final byte @NonNull @Nonempty [] aCRLBytes) public static ICommonsList getAllDistributionPoints (@NonNull final X509Certificate aCert) { ValueEnforcer.notNull (aCert, "Certificate"); - final ICommonsList ret = new CommonsArrayList <> (); + final byte [] aExtensionValue = aCert.getExtensionValue (CRL_DISTRIBUTION_POINTS_OID); + if (aExtensionValue == null) + return new CommonsArrayList <> (); - // Gets the DER-encoded OCTET string for the extension value for - // CRLDistributionPoints - final byte [] aExtensionValue = aCert.getExtensionValue (Extension.cRLDistributionPoints.getId ()); - if (aExtensionValue != null) - { - // crlDPExtensionValue is encoded in ASN.1 format. - try (final ASN1InputStream aAsn1IS = new ASN1InputStream (aExtensionValue)) - { - // DER (Distinguished Encoding Rules) is one of ASN.1 encoding rules - // defined in ITU-T X.690, 2002, specification. - // ASN.1 encoding rules can be used to encode any data object into a - // binary file. Read the object in octets. - final CRLDistPoint aDistPoint; - try - { - final DEROctetString aCrlDEROctetString = (DEROctetString) aAsn1IS.readObject (); - // Get Input stream in octets - try (final ASN1InputStream aAsn1InOctets = new ASN1InputStream (aCrlDEROctetString.getOctets ())) - { - final ASN1Primitive aCrlDERObject = aAsn1InOctets.readObject (); - aDistPoint = CRLDistPoint.getInstance (aCrlDERObject); - } - } - catch (final IOException e) - { - throw new UncheckedIOException (e); - } - - // Loop through ASN1Encodable DistributionPoints - for (final DistributionPoint aDP : aDistPoint.getDistributionPoints ()) - { - // get ASN1Encodable DistributionPointName - final DistributionPointName aDPName = aDP.getDistributionPoint (); - if (aDPName != null && aDPName.getType () == DistributionPointName.FULL_NAME) - { - // Create ASN1Encodable General Names - final GeneralName [] aGenNames = GeneralNames.getInstance (aDPName.getName ()).getNames (); - // Look for a URI - for (final GeneralName aGenName : aGenNames) - { - if (aGenName.getTagNo () == GeneralName.uniformResourceIdentifier) - { - // DERIA5String contains an ascii string. - // A IA5String is a restricted character string type in the - // ASN.1 notation - final String sURL = ASN1IA5String.getInstance (aGenName.getName ()).getString ().trim (); - if (LOGGER.isDebugEnabled ()) - LOGGER.debug ("Found CRL URL '" + sURL + "' in certificate"); - ret.add (sURL); - } - } - } - } - } - catch (final IOException ex) - { - throw new UncheckedIOException (ex); - } - } + final ICommonsList ret = CRLDistributionPointParser.parse (aExtensionValue); + if (LOGGER.isDebugEnabled ()) + for (final String sURL : ret) + LOGGER.debug ("Found CRL URL '" + sURL + "' in certificate"); return ret; } } diff --git a/ph-security/src/main/java/com/helger/security/oscp/EOCSPResponseStatus.java b/ph-security/src/main/java/com/helger/security/oscp/EOCSPResponseStatus.java index 8a86df24d..62c551be9 100644 --- a/ph-security/src/main/java/com/helger/security/oscp/EOCSPResponseStatus.java +++ b/ph-security/src/main/java/com/helger/security/oscp/EOCSPResponseStatus.java @@ -16,7 +16,6 @@ */ package com.helger.security.oscp; -import org.bouncycastle.asn1.ocsp.OCSPResponseStatus; import org.jspecify.annotations.Nullable; import com.helger.base.id.IHasIntID; @@ -30,12 +29,12 @@ */ public enum EOCSPResponseStatus implements ISuccessIndicator, IHasIntID { - SUCCESSFUL (OCSPResponseStatus.SUCCESSFUL), - MALFORMED_REQUEST (OCSPResponseStatus.MALFORMED_REQUEST), - INTERNAL_ERROR (OCSPResponseStatus.INTERNAL_ERROR), - TRY_LATER (OCSPResponseStatus.TRY_LATER), - SIG_REQUIRED (OCSPResponseStatus.SIG_REQUIRED), - UNAUTHORIZED (OCSPResponseStatus.UNAUTHORIZED); + SUCCESSFUL (0), + MALFORMED_REQUEST (1), + INTERNAL_ERROR (2), + TRY_LATER (3), + SIG_REQUIRED (5), + UNAUTHORIZED (6); private final int m_nValue; diff --git a/ph-security/src/test/java/com/helger/security/PhSecurityWithoutBCProbe.java b/ph-security/src/test/java/com/helger/security/PhSecurityWithoutBCProbe.java new file mode 100644 index 000000000..877c6652e --- /dev/null +++ b/ph-security/src/test/java/com/helger/security/PhSecurityWithoutBCProbe.java @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2014-2026 Philip Helger (www.helger.com) + * philip[at]helger[dot]com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.helger.security; + +import java.io.FileInputStream; +import java.security.KeyStore; +import java.security.cert.X509Certificate; + +import com.helger.collection.commons.ICommonsList; +import com.helger.security.certificate.CertificateHelper; +import com.helger.security.certificate.TrustedCACertificates; +import com.helger.security.crl.CRLHelper; +import com.helger.security.oscp.EOCSPResponseStatus; + +/** Invoked through an isolated class loader by {@link PhSecurityWithoutBCTest}. */ +public final class PhSecurityWithoutBCProbe +{ + private PhSecurityWithoutBCProbe () + {} + + public static String verify () throws Exception + { + final KeyStore aAPKeyStore = KeyStore.getInstance ("PKCS12"); + try (final FileInputStream aIS = new FileInputStream ("src/test/resources/keystores/keystore-pw-peppol-expired-2023.p12")) + { + aAPKeyStore.load (aIS, "peppol".toCharArray ()); + } + final X509Certificate aAPCert = (X509Certificate) aAPKeyStore.getCertificate (aAPKeyStore.aliases ().nextElement ()); + if (CertificateHelper.isCA (aAPCert)) + throw new IllegalStateException ("The AP certificate must not be treated as a CA"); + + final ICommonsList aCRLURLs = CRLHelper.getAllDistributionPoints (aAPCert); + if (aCRLURLs.size () != 1) + throw new IllegalStateException ("Expected one CRL distribution point but found " + aCRLURLs.size ()); + + final KeyStore aTrustStore = KeyStore.getInstance ("JKS"); + try (final FileInputStream aIS = new FileInputStream ("src/test/resources/keystores/truststore-peppol-prod.jks")) + { + aTrustStore.load (aIS, "peppol".toCharArray ()); + } + final X509Certificate aCACert = (X509Certificate) aTrustStore.getCertificate (aTrustStore.aliases ().nextElement ()); + final TrustedCACertificates aTrustedCAs = new TrustedCACertificates (); + aTrustedCAs.addTrustedCACertificate (aCACert); + + return aCRLURLs.get (0) + + '|' + + aTrustedCAs.getAllTrustedCACertificates ().size () + + '|' + + EOCSPResponseStatus.SUCCESSFUL.getID () + + ',' + + EOCSPResponseStatus.UNAUTHORIZED.getID (); + } +} diff --git a/ph-security/src/test/java/com/helger/security/PhSecurityWithoutBCTest.java b/ph-security/src/test/java/com/helger/security/PhSecurityWithoutBCTest.java new file mode 100644 index 000000000..b924cb28a --- /dev/null +++ b/ph-security/src/test/java/com/helger/security/PhSecurityWithoutBCTest.java @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2014-2026 Philip Helger (www.helger.com) + * philip[at]helger[dot]com + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.helger.security; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +import java.io.File; +import java.net.URL; +import java.net.URLClassLoader; + +import org.junit.Test; + +/** Verifies the standard X.509 helpers without Bouncy Castle or {@code ph-bc}. */ +public final class PhSecurityWithoutBCTest +{ + private static final String PROBE_CLASS = PhSecurityWithoutBCProbe.class.getName (); + + @Test + public void testStandardX509HelpersWithoutBC () throws Exception + { + final URL [] aURLs = { new File ("target/classes").toURI ().toURL (), + new File ("target/test-classes").toURI ().toURL () }; + try (final URLClassLoader aCL = new URLClassLoader (aURLs, getClass ().getClassLoader ()) + { + @Override + protected Class loadClass (final String sName, final boolean bResolve) throws ClassNotFoundException + { + if (sName.startsWith ("org.bouncycastle.") || sName.startsWith ("com.helger.bc.")) + throw new ClassNotFoundException ("Bouncy Castle deliberately hidden from test class loader"); + + if (sName.startsWith ("com.helger.security.")) + synchronized (getClassLoadingLock (sName)) + { + Class ret = findLoadedClass (sName); + if (ret == null) + ret = findClass (sName); + if (bResolve) + resolveClass (ret); + return ret; + } + + return super.loadClass (sName, bResolve); + } + }) + { + _assertClassIsHidden (aCL, "org.bouncycastle.asn1.ASN1Primitive"); + _assertClassIsHidden (aCL, "com.helger.bc.PBCProvider"); + + final Class aProbeClass = Class.forName (PROBE_CLASS, true, aCL); + final String sResult = (String) aProbeClass.getMethod ("verify").invoke (null); + assertEquals ("http://pki-crl.symauth.com/ca_6a937734a393a0805bf33cda8b331093/LatestCRL.crl|1|0,6", + sResult); + } + } + + private static void _assertClassIsHidden (final ClassLoader aClassLoader, final String sClassName) + { + try + { + aClassLoader.loadClass (sClassName); + fail (sClassName + " must not be visible to the isolated class loader"); + } + catch (final ClassNotFoundException ex) + { + // Expected + } + } +} diff --git a/ph-security/src/test/java/com/helger/security/crl/CRLHelperTest.java b/ph-security/src/test/java/com/helger/security/crl/CRLHelperTest.java index f5a8a8abc..49e14a330 100644 --- a/ph-security/src/test/java/com/helger/security/crl/CRLHelperTest.java +++ b/ph-security/src/test/java/com/helger/security/crl/CRLHelperTest.java @@ -18,12 +18,20 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; import java.io.File; +import java.io.UncheckedIOException; import java.security.KeyStore; import java.security.KeyStoreException; import java.security.cert.X509Certificate; +import org.bouncycastle.asn1.DEROctetString; +import org.bouncycastle.asn1.x509.CRLDistPoint; +import org.bouncycastle.asn1.x509.DistributionPoint; +import org.bouncycastle.asn1.x509.DistributionPointName; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; import org.junit.Test; import com.helger.collection.commons.ICommonsList; @@ -56,4 +64,37 @@ public void testGetAllDistributionPoints () throws KeyStoreException assertEquals (1, aList.size ()); assertEquals ("http://pki-crl.symauth.com/ca_6a937734a393a0805bf33cda8b331093/LatestCRL.crl", aList.get (0)); } + + @Test + public void testRejectsTruncatedDER () + { + assertThrows (UncheckedIOException.class, + () -> CRLDistributionPointParser.parse (new byte [] { 0x04, (byte) 0x82, 0x01 })); + } + + @Test + public void testMultipleDistributionPointsAndGeneralNames () throws Exception + { + final GeneralNames aFullNames = new GeneralNames (new GeneralName [] { new GeneralName (GeneralName.dNSName, + "crl.example.org"), + new GeneralName (GeneralName.uniformResourceIdentifier, + "https://crl.example.org/one.crl"), + new GeneralName (GeneralName.uniformResourceIdentifier, + "ldap://crl.example.org/two") }); + final DistributionPoint aNamedDistributionPoint = new DistributionPoint (new DistributionPointName (DistributionPointName.FULL_NAME, + aFullNames), + null, + null); + final DistributionPoint aIssuerOnlyDistributionPoint = new DistributionPoint (null, + null, + new GeneralNames (new GeneralName (GeneralName.uniformResourceIdentifier, + "https://issuer.example.org/not-a-distribution-point.crl"))); + final byte [] aEncodedExtension = new DEROctetString (new CRLDistPoint (new DistributionPoint [] { aNamedDistributionPoint, + aIssuerOnlyDistributionPoint }).getEncoded ()).getEncoded (); + + final ICommonsList aURLs = CRLDistributionPointParser.parse (aEncodedExtension); + assertEquals (2, aURLs.size ()); + assertEquals ("https://crl.example.org/one.crl", aURLs.get (0)); + assertEquals ("ldap://crl.example.org/two", aURLs.get (1)); + } }