diff --git a/engines/api/src/main/java/net/sf/jsignpdf/utils/CertificateInfo.java b/engines/api/src/main/java/net/sf/jsignpdf/utils/CertificateInfo.java index b59cce34..f091cbd3 100644 --- a/engines/api/src/main/java/net/sf/jsignpdf/utils/CertificateInfo.java +++ b/engines/api/src/main/java/net/sf/jsignpdf/utils/CertificateInfo.java @@ -1,5 +1,6 @@ package net.sf.jsignpdf.utils; +import java.math.BigInteger; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.cert.Certificate; @@ -11,8 +12,8 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Date; -import java.util.HexFormat; import java.util.List; +import java.util.Locale; import java.util.logging.Level; import javax.naming.ldap.LdapName; @@ -152,13 +153,20 @@ public static String describe(X509Certificate cert, String prefix) { * The DSS certificate token id ({@code C-} + uppercase SHA-256 hex of the DER encoding) — the * identifier DSS uses when it reports a missing trust anchor or missing revocation data. * + *
+ * DSS renders the digest through {@code BigInteger} ({@code Digest#getHexValue()}), so leading zero + * bytes are dropped and only an odd nibble count is padded back. A plain zero-padded 64-char hex + * would therefore differ from the id DSS prints for roughly one certificate in 256. + *
+ * * @param cert the certificate * @return the token id, or {@code null} when the certificate cannot be re-encoded */ public static String tokenId(X509Certificate cert) { try { final MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); - return "C-" + HexFormat.of().withUpperCase().formatHex(sha256.digest(cert.getEncoded())); + final String hex = new BigInteger(1, sha256.digest(cert.getEncoded())).toString(16); + return "C-" + (hex.length() % 2 == 0 ? hex : "0" + hex).toUpperCase(Locale.ENGLISH); } catch (NoSuchAlgorithmException | CertificateEncodingException e) { return null; } diff --git a/engines/api/src/test/java/net/sf/jsignpdf/utils/CertificateInfoTest.java b/engines/api/src/test/java/net/sf/jsignpdf/utils/CertificateInfoTest.java index 434463ac..9d8891ec 100644 --- a/engines/api/src/test/java/net/sf/jsignpdf/utils/CertificateInfoTest.java +++ b/engines/api/src/test/java/net/sf/jsignpdf/utils/CertificateInfoTest.java @@ -1,6 +1,7 @@ package net.sf.jsignpdf.utils; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; @@ -13,7 +14,6 @@ import java.security.cert.X509Certificate; import java.util.ArrayList; import java.util.Date; -import java.util.HexFormat; import java.util.List; import java.util.logging.Handler; import java.util.logging.Level; @@ -63,9 +63,23 @@ public static void addProvider() { @Test public void tokenIdIsDssCertificateTokenId() throws Exception { final X509Certificate cert = selfSigned("CN=Token Id Test", true); - final String expected = "C-" + HexFormat.of().withUpperCase() - .formatHex(MessageDigest.getInstance("SHA-256").digest(cert.getEncoded())); - assertEquals(expected, CertificateInfo.tokenId(cert)); + assertEquals(dssTokenId(cert), CertificateInfo.tokenId(cert)); + } + + /** + * DSS prints the digest through {@code BigInteger}, so a certificate whose SHA-256 starts with a zero byte + * gets a token id shorter than the usual 64 hex characters. Zero-padding it (as {@code HexFormat} does) + * produced an id that never matched the one in the DSS alert for roughly one certificate in 256, which + * silently disabled the issue #448 enrichment. + */ + @Test + public void tokenIdDropsLeadingZeroBytesLikeDss() throws Exception { + final X509Certificate cert = withLeadingZeroDigestByte(); + final String tokenId = CertificateInfo.tokenId(cert); + + assertEquals(dssTokenId(cert), tokenId); + assertEquals("the leading zero byte must be dropped, not padded", 62, tokenId.length() - 2); + assertFalse("id must not keep the zero-padded prefix", tokenId.startsWith("C-00")); } @Test @@ -171,6 +185,39 @@ public void close() { return records; } + /** The token id as DSS builds it ({@code Digest#getHexValue()}), the value the reporter has to match. */ + private static String dssTokenId(X509Certificate cert) throws Exception { + final String hex = new BigInteger(1, MessageDigest.getInstance("SHA-256").digest(cert.getEncoded())) + .toString(16); + return "C-" + (hex.length() % 2 == 0 ? hex : "0" + hex).toUpperCase(java.util.Locale.ENGLISH); + } + + /** + * Mines a certificate whose DER SHA-256 starts with a {@code 0x00} byte (about one attempt in 256) by + * re-signing with a single key pair and only varying the serial number, which keeps this cheap. + */ + private static X509Certificate withLeadingZeroDigestByte() throws Exception { + final KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + final KeyPair keyPair = kpg.generateKeyPair(); + final X500Name name = new X500Name("CN=Leading Zero Digest"); + final Date notBefore = new Date(System.currentTimeMillis() - 24L * 60 * 60 * 1000); + final Date notAfter = new Date(System.currentTimeMillis() + 365L * 24 * 60 * 60 * 1000); + final ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA") + .setProvider(BouncyCastleProvider.PROVIDER_NAME).build(keyPair.getPrivate()); + final MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); + + for (int serial = 1; serial < 20000; serial++) { + final X509CertificateHolder holder = new JcaX509v3CertificateBuilder(name, BigInteger.valueOf(serial), + notBefore, notAfter, name, keyPair.getPublic()).build(signer); + if (sha256.digest(holder.getEncoded())[0] == 0) { + return new JcaX509CertificateConverter().setProvider(BouncyCastleProvider.PROVIDER_NAME) + .getCertificate(holder); + } + } + throw new AssertionError("no certificate with a leading zero digest byte found"); + } + private static X509Certificate selfSigned(String dn, boolean withExtensions) throws Exception { final Date notBefore = new Date(System.currentTimeMillis() - 24L * 60 * 60 * 1000); final Date notAfter = new Date(System.currentTimeMillis() + 365L * 24 * 60 * 60 * 1000); diff --git a/engines/dss/src/main/java/net/sf/jsignpdf/engine/dss/DssUntrustedChainReporter.java b/engines/dss/src/main/java/net/sf/jsignpdf/engine/dss/DssUntrustedChainReporter.java index 18100f57..691e9632 100644 --- a/engines/dss/src/main/java/net/sf/jsignpdf/engine/dss/DssUntrustedChainReporter.java +++ b/engines/dss/src/main/java/net/sf/jsignpdf/engine/dss/DssUntrustedChainReporter.java @@ -34,8 +34,11 @@ */ final class DssUntrustedChainReporter { - /** DSS certificate token id as it appears in the alert message: {@code C-} + uppercase SHA-256 hex (64 chars). */ - private static final Pattern TOKEN_ID = Pattern.compile("C-[0-9A-Fa-f]{64}"); + /** + * DSS certificate token id as it appears in the alert message: {@code C-} + uppercase SHA-256 hex. DSS + * strips leading zero bytes from the digest, so the id is at most 64 characters but can be shorter. + */ + private static final Pattern TOKEN_ID = Pattern.compile("C-[0-9A-Fa-f]{2,64}"); private DssUntrustedChainReporter() { } diff --git a/engines/dss/src/test/java/net/sf/jsignpdf/engine/dss/DssUntrustedChainReporterTest.java b/engines/dss/src/test/java/net/sf/jsignpdf/engine/dss/DssUntrustedChainReporterTest.java index 2154bd6f..e0b864eb 100644 --- a/engines/dss/src/test/java/net/sf/jsignpdf/engine/dss/DssUntrustedChainReporterTest.java +++ b/engines/dss/src/test/java/net/sf/jsignpdf/engine/dss/DssUntrustedChainReporterTest.java @@ -4,13 +4,24 @@ import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; import java.security.KeyStore; +import java.security.MessageDigest; import java.security.Security; import java.security.cert.Certificate; import java.security.cert.X509Certificate; +import java.util.Date; import java.util.List; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; import org.junit.AfterClass; import org.junit.BeforeClass; import org.junit.Test; @@ -118,4 +129,52 @@ public void readsTokenIdFromCauseChain() throws Exception { assertFalse("must find the token id nested in the cause chain", details.isEmpty()); assertTrue(details.contains("JSignPdf Test Signer")); } + + /** + * DSS renders the token id through {@code BigInteger}, so a certificate whose DER SHA-256 starts with a + * zero byte is reported with a 62-character id. Both the id pattern and our own fingerprinting assumed a + * fixed 64, which dropped the enrichment for about one certificate in 256 -- reproducible only as a rare + * CI flake until this test pinned it. + */ + @Test + public void namesCertificateWhoseTokenIdHasALeadingZeroByte() throws Exception { + X509Certificate cert = withLeadingZeroDigestByte(); + String id = tokenId(cert); + assertEquals("DSS must shorten the id for this certificate", 62, id.length() - 2); + + Throwable error = new RuntimeException("Revocation data is missing for one or more certificate(s). [" + + id + ": Revocation data is skipped for untrusted certificate chain!]"); + + String details = DssUntrustedChainReporter.describe(error, new Certificate[] { cert }, null); + + assertTrue("must name the signer role", details.contains("Signer chain certificate")); + assertTrue("must include the subject CN", details.contains("Leading Zero Digest")); + assertTrue("must keep the fingerprint as a secondary identifier", details.contains(id)); + } + + /** + * Mines a certificate whose DER SHA-256 starts with a {@code 0x00} byte (about one attempt in 256) by + * re-signing with a single key pair and only varying the serial number, which keeps this cheap. + */ + private static X509Certificate withLeadingZeroDigestByte() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + KeyPair keyPair = kpg.generateKeyPair(); + X500Name name = new X500Name("CN=Leading Zero Digest"); + Date notBefore = new Date(System.currentTimeMillis() - 24L * 60 * 60 * 1000); + Date notAfter = new Date(System.currentTimeMillis() + 365L * 24 * 60 * 60 * 1000); + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA") + .setProvider(BouncyCastleProvider.PROVIDER_NAME).build(keyPair.getPrivate()); + MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); + + for (int serial = 1; serial < 20000; serial++) { + X509CertificateHolder holder = new JcaX509v3CertificateBuilder(name, BigInteger.valueOf(serial), + notBefore, notAfter, name, keyPair.getPublic()).build(signer); + if (sha256.digest(holder.getEncoded())[0] == 0) { + return new JcaX509CertificateConverter().setProvider(BouncyCastleProvider.PROVIDER_NAME) + .getCertificate(holder); + } + } + throw new AssertionError("no certificate with a leading zero digest byte found"); + } }