Skip to content
Open
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
1 change: 1 addition & 0 deletions docs/releasenotes.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ Date: 2026, TBD

- Neither copy of PKIXCertPathReviewer (org.bouncycastle.pkix.jcajce and the legacy org.bouncycastle.x509) applied rfc822Name name constraints to emailAddress attributes in the subject DN, which RFC 5280 sec. 4.2.1.10 requires where the certificate carries no subject alternative name. A certificate whose subject DN e-mail address lay outside its issuing CA's permitted rfc822Name subtrees, or inside its excluded ones, was reported by isValidCertPath() as valid with an empty error list, where CertPathValidator("PKIX") rejects the same chain against the same trust anchor. Both copies now check every subject DN e-mail address, including those in multi-valued RDNs, against the accumulated permitted and excluded subtrees, reporting CertPathReviewer.notPermittedEmail or CertPathReviewer.excludedEmail. As with the CertPathValidator, the check is made whether or not a subject alternative name is present. This resolves the outstanding TODO in the org.bouncycastle.pkix.jcajce copy noting that the validator's subject emailAddress check was missing from the reviewer.
- ML-KEM matrix generation (FIPS 203 Algorithm 7, SampleNTT) squeezed two SHAKE128 blocks for each additional block it needed after the first three, but sampled from only the first of them, so a matrix entry needing more than one additional block was sampled from the wrong part of the XOF stream and differed from the one FIPS 203 specifies. The chance of an entry needing that is about 10^-32, so no key, ciphertext or test vector is known to be affected; each additional block is now squeezed singly, as the standard and the reference implementation do.
- ECDSAEncoder.toX962, which every IEEE 1609.2 (ITS) verification path uses to recover r and s - ETSISignedData.signatureValid and ITSCertificate.isSignatureValid, through both the Bc and Jca ITSContentVerifierProvider - read the r value without checking which EccP256CurvePoint or EccP384CurvePoint choice carried it. IEEE 1609.2 sec. 6.3.29 and sec. 6.3.30 represent r as the x-only choice, but compressed-y-0 and compressed-y-1 hold the same x octets, so a signature re-encoded under either of them produced an identical r and verified: one signature had three interchangeable encodings, and a certificate re-encoded that way kept verifying while its HashedId8, the identity SignerIdentifier and ETSI TS 102 941 CRLs use, changed with it. The remaining choices, fill and uncompressed, carry no octet string to read and escaped both entry points as an unchecked IllegalArgumentException from getInstance. r is now required to be of form x-only; nothing BC produces is affected, as ECDSAEncoder.toITS only ever emits that choice.

### 2.1.3 Additional Features and Functionality

Expand Down
26 changes: 24 additions & 2 deletions pkix/src/main/java/org/bouncycastle/its/operator/ECDSAEncoder.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,42 @@

public class ECDSAEncoder
{
/**
* Return the X9.62 DER encoding of the r and s values in the passed in IEEE 1609.2 signature.
* <p>
* IEEE 1609.2 sec. 6.3.29 and sec. 6.3.30 represent r as an EccP256CurvePoint (EccP384CurvePoint)
* "indicating the selection x-only", so any other choice is refused: compressed-y-0 and
* compressed-y-1 carry the same x octets, which would otherwise give one signature several
* interchangeable encodings, and fill and uncompressed carry no octet string to read at all.
* </p>
*
* @param signature the signature to convert.
* @return the DER encoded SEQUENCE of r and s.
*/
public static byte[] toX962(Signature signature)
{
byte[] r;
byte[] s;
if (signature.getChoice() == Signature.ecdsaNistP256Signature || signature.getChoice() == Signature.ecdsaBrainpoolP256r1Signature)
{
EcdsaP256Signature sig = EcdsaP256Signature.getInstance(signature.getSignature());
r = ASN1OctetString.getInstance(sig.getRSig().getEccp256CurvePoint()).getOctets();
EccP256CurvePoint rSig = sig.getRSig();
if (rSig.getChoice() != EccP256CurvePoint.xonly)
{
throw new IllegalArgumentException("rSig must be of form x-only");
}
r = ASN1OctetString.getInstance(rSig.getEccp256CurvePoint()).getOctets();
s = sig.getSSig().getOctets();
}
else
{
EcdsaP384Signature sig = EcdsaP384Signature.getInstance(signature.getSignature());
r = ASN1OctetString.getInstance(sig.getRSig().getEccP384CurvePoint()).getOctets();
EccP384CurvePoint rSig = sig.getRSig();
if (rSig.getChoice() != EccP384CurvePoint.xonly)
{
throw new IllegalArgumentException("rSig must be of form x-only");
}
r = ASN1OctetString.getInstance(rSig.getEccP384CurvePoint()).getOctets();
s = sig.getSSig().getOctets();
}

Expand Down
105 changes: 105 additions & 0 deletions pkix/src/test/java/org/bouncycastle/its/test/ETSIDataSignerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import java.util.Date;

import junit.framework.TestCase;
import org.bouncycastle.asn1.ASN1OctetString;
import org.bouncycastle.asn1.DEROctetString;
import org.bouncycastle.asn1.nist.NISTNamedCurves;
import org.bouncycastle.asn1.sec.SECObjectIdentifiers;
Expand Down Expand Up @@ -39,18 +40,24 @@
import org.bouncycastle.oer.its.ieee1609dot2.CertificateId;
import org.bouncycastle.oer.its.ieee1609dot2.EndEntityType;
import org.bouncycastle.oer.its.ieee1609dot2.PsidGroupPermissions;
import org.bouncycastle.oer.its.ieee1609dot2.CertificateBase;
import org.bouncycastle.oer.its.ieee1609dot2.SignedData;
import org.bouncycastle.oer.its.ieee1609dot2.SequenceOfPsidGroupPermissions;
import org.bouncycastle.oer.its.ieee1609dot2.SubjectPermissions;
import org.bouncycastle.oer.its.ieee1609dot2.ToBeSignedCertificate;
import org.bouncycastle.oer.its.ieee1609dot2.basetypes.BitmapSsp;
import org.bouncycastle.oer.its.ieee1609dot2.basetypes.CrlSeries;
import org.bouncycastle.oer.its.ieee1609dot2.basetypes.EccP256CurvePoint;
import org.bouncycastle.oer.its.ieee1609dot2.basetypes.EcdsaP256Signature;
import org.bouncycastle.oer.its.ieee1609dot2.basetypes.Hostname;
import org.bouncycastle.oer.its.ieee1609dot2.basetypes.Point256;
import org.bouncycastle.oer.its.ieee1609dot2.basetypes.Psid;
import org.bouncycastle.oer.its.ieee1609dot2.basetypes.PsidSsp;
import org.bouncycastle.oer.its.ieee1609dot2.basetypes.PsidSspRange;
import org.bouncycastle.oer.its.ieee1609dot2.basetypes.SequenceOfPsidSsp;
import org.bouncycastle.oer.its.ieee1609dot2.basetypes.SequenceOfPsidSspRange;
import org.bouncycastle.oer.its.ieee1609dot2.basetypes.ServiceSpecificPermissions;
import org.bouncycastle.oer.its.ieee1609dot2.basetypes.Signature;
import org.bouncycastle.oer.its.ieee1609dot2.basetypes.SubjectAssurance;
import org.bouncycastle.util.encoders.Hex;

Expand Down Expand Up @@ -266,4 +273,102 @@ public void testJca()
}



/**
* IEEE 1609.2 sec. 6.3.29 carries the ECDSA r value as the x-only choice of EccP256CurvePoint.
* The compressed-y-0 and compressed-y-1 choices hold the same x octets, so a signature re-encoded
* under either of them used to produce an identical r and verify, giving one signature three
* interchangeable encodings - and a certificate re-encoded that way keeps verifying while its
* HashedId8, the identity SignerIdentifier and the ETSI TS 102 941 CRLs use, changes with it.
* The fill and uncompressed choices carry no octet string to read and used to throw out of
* signatureValid()/isSignatureValid() unchecked.
*/
public void testRSigMustBeXOnly()
throws Exception
{
ECKeyPairGenerator generator = new ECKeyPairGenerator();
X9ECParameters parameters = NISTNamedCurves.getByOID(SECObjectIdentifiers.secp256r1);
generator.init(new ECKeyGenerationParameters(
new ECNamedDomainParameters(SECObjectIdentifiers.secp256r1, parameters), new SecureRandom()));
AsymmetricCipherKeyPair kp = generator.generateKeyPair();

ECPublicKeyParameters publicVerificationKey = (ECPublicKeyParameters)kp.getPublic();
ECPrivateKeyParameters keyParameters = (ECPrivateKeyParameters)kp.getPrivate();
ECPrivateKeyParameters privateKeyParameters = new ECPrivateKeyParameters(keyParameters.getD(),
new ECNamedDomainParameters(SECObjectIdentifiers.secp256r1, keyParameters.getParameters()));

ToBeSignedCertificate.Builder tbsBuilder = new ToBeSignedCertificate.Builder();
tbsBuilder.setAssuranceLevel(new SubjectAssurance(new byte[]{(byte)0xC0}));
tbsBuilder.setCrlSeries(new CrlSeries(1));

ITSContentSigner itsContentSigner = new BcITSContentSigner(privateKeyParameters);
BcITSExplicitCertificateBuilder itsCertificateBuilder = new BcITSExplicitCertificateBuilder(itsContentSigner, tbsBuilder);

itsCertificateBuilder.setValidityPeriod(ITSValidityPeriod.from(new Date()).plusYears(1));

ITSCertificate newCert = itsCertificateBuilder.build(
CertificateId.name(new Hostname("Legion of the BouncyCastle CA")),
publicVerificationKey);

ETSISignedData signedData = ETSISignedDataBuilder.builder(new Psid(10))
.setUnsecuredData("The cat sat on the mat".getBytes())
.build(new BcITSContentSigner(privateKeyParameters, newCert));

SignedData sd = signedData.getSignedData();
EcdsaP256Signature sig = EcdsaP256Signature.getInstance(sd.getSignature().getSignature());

// the conformant encoding the signer produced
assertEquals(EccP256CurvePoint.xonly, sig.getRSig().getChoice());
assertTrue(signedData.signatureValid(new BcITSContentVerifierProvider(newCert)));

byte[] rx = ASN1OctetString.getInstance(sig.getRSig().getEccp256CurvePoint()).getOctets();

EccP256CurvePoint[] rejected = new EccP256CurvePoint[]
{
new EccP256CurvePoint(EccP256CurvePoint.compressedY0, new DEROctetString(rx)),
new EccP256CurvePoint(EccP256CurvePoint.compressedY1, new DEROctetString(rx)),
EccP256CurvePoint.fill(),
new EccP256CurvePoint(EccP256CurvePoint.uncompressedP256,
new Point256(new DEROctetString(rx), new DEROctetString(rx)))
};

for (int i = 0; i != rejected.length; i++)
{
ETSISignedData reEncoded = new ETSISignedData(new SignedData(sd.getHashId(), sd.getTbsData(), sd.getSigner(),
Signature.ecdsaNistP256Signature(new EcdsaP256Signature(rejected[i], sig.getSSig()))));

try
{
reEncoded.signatureValid(new BcITSContentVerifierProvider(newCert));
fail("signature with rSig choice " + rejected[i].getChoice() + " accepted");
}
catch (IllegalArgumentException e)
{
assertEquals("rSig must be of form x-only", e.getMessage());
}
}

// the same re-encoding of a certificate's own signature, which leaves its HashedId8 free to change
CertificateBase certBase = newCert.toASN1Structure();
EcdsaP256Signature certSig = EcdsaP256Signature.getInstance(certBase.getSignature().getSignature());
byte[] certRx = ASN1OctetString.getInstance(certSig.getRSig().getEccp256CurvePoint()).getOctets();

ITSCertificate reEncodedCert = new ITSCertificate(new CertificateBase(certBase.getVersion(), certBase.getType(),
certBase.getIssuer(), certBase.getToBeSigned(),
Signature.ecdsaNistP256Signature(new EcdsaP256Signature(
new EccP256CurvePoint(EccP256CurvePoint.compressedY0, new DEROctetString(certRx)), certSig.getSSig()))));

assertTrue(newCert.isSignatureValid(new BcITSContentVerifierProvider(newCert)));

try
{
reEncodedCert.isSignatureValid(new BcITSContentVerifierProvider(reEncodedCert));
fail("certificate signature with rSig choice compressed-y-0 accepted");
}
catch (IllegalArgumentException e)
{
assertEquals("rSig must be of form x-only", e.getMessage());
}
}

}