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
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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.
*
* <p>
* DSS renders the digest through {@code BigInteger} ({@code Digest#getHexValue()}), so leading zero
* <em>bytes</em> 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.
* </p>
*
* @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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
}
}
Loading