security(pki): harden DER trust boundaries

Enforce canonical single-object DER validation across CSR, certificate,
SPKI, CRL and persisted credential boundaries.

Reject malformed, ambiguous and type-confused encodings while preserving
streaming aggregate processing and existing PKI semantics.
This commit is contained in:
2026-08-03 23:48:37 +02:00
parent d5d5bf7a96
commit 64af4519f0
19 changed files with 1484 additions and 150 deletions

View File

@@ -39,8 +39,6 @@ import java.util.Objects;
import org.bouncycastle.asn1.ASN1EncodableVector; import org.bouncycastle.asn1.ASN1EncodableVector;
import org.bouncycastle.asn1.ASN1Encoding; import org.bouncycastle.asn1.ASN1Encoding;
import org.bouncycastle.asn1.ASN1Integer; import org.bouncycastle.asn1.ASN1Integer;
import org.bouncycastle.asn1.ASN1Primitive;
import org.bouncycastle.asn1.ASN1Sequence;
import org.bouncycastle.asn1.DERSequence; import org.bouncycastle.asn1.DERSequence;
import zeroecho.core.spec.ContextSpec; import zeroecho.core.spec.ContextSpec;
@@ -84,7 +82,10 @@ import zeroecho.core.spec.ContextSpec;
* semantics, not as an optional hint.</li> * semantics, not as an optional hint.</li>
* </ul> * </ul>
*/ */
public final class SignatureInteropProfile { // NOPMD public final class SignatureInteropProfile {
private static final int DER_SEQUENCE_TAG = 0x30;
private static final int DER_INTEGER_TAG = 0x02;
private static final int DER_LONG_FORM_BIT = 0x80;
/** /**
* Signature byte representation bridging policy. * Signature byte representation bridging policy.
@@ -192,11 +193,10 @@ public final class SignatureInteropProfile { // NOPMD
* *
* @param signature external signature bytes * @param signature external signature bytes
* @return internal signature bytes suitable for ZeroEcho verification contexts * @return internal signature bytes suitable for ZeroEcho verification contexts
* @throws IOException if DER decoding fails
* @throws IllegalArgumentException if the supplied bytes do not match the * @throws IllegalArgumentException if the supplied bytes do not match the
* expected external representation * expected external representation
*/ */
public byte[] externalToInternalSignature(byte[] signature) throws IOException { public byte[] externalToInternalSignature(byte[] signature) {
Objects.requireNonNull(signature, "signature"); Objects.requireNonNull(signature, "signature");
if (signatureRepresentation == SignatureRepresentation.IDENTITY) { if (signatureRepresentation == SignatureRepresentation.IDENTITY) {
return signature.clone(); return signature.clone();
@@ -221,21 +221,86 @@ public final class SignatureInteropProfile { // NOPMD
return ecdsaP1363ToDer(signature, internalSignatureLength); return ecdsaP1363ToDer(signature, internalSignatureLength);
} }
private static byte[] ecdsaDerToP1363(byte[] der, int fixedLength) throws IOException { private static byte[] ecdsaDerToP1363(byte[] der, int fixedLength) {
ASN1Primitive primitive = ASN1Primitive.fromByteArray(der); if ((fixedLength & 1) != 0 || der.length > fixedLength + 8 || der.length < 8) {
ASN1Sequence sequence = ASN1Sequence.getInstance(primitive); throw new IllegalArgumentException("Invalid ECDSA DER signature length");
if (sequence.size() != 2) { // NOPMD
throw new IllegalArgumentException("ECDSA DER signature must contain exactly two integers");
} }
int coordinateLength = fixedLength / 2; int coordinateLength = fixedLength / 2;
int offset = 0;
if (Byte.toUnsignedInt(der[offset++]) != DER_SEQUENCE_TAG) {
throw new IllegalArgumentException("ECDSA DER signature must be a sequence");
}
long sequenceLength = readDerLength(der, offset);
offset = (int) sequenceLength;
int sequenceEnd = Math.addExact(offset, (int) (sequenceLength >>> Integer.SIZE));
if (sequenceEnd != der.length) {
throw new IllegalArgumentException("ECDSA DER signature contains trailing or truncated data");
}
byte[] out = new byte[fixedLength]; byte[] out = new byte[fixedLength];
byte[] r = ASN1Integer.getInstance(sequence.getObjectAt(0)).getPositiveValue().toByteArray(); offset = readPositiveInteger(der, offset, sequenceEnd, out, 0, coordinateLength);
byte[] s = ASN1Integer.getInstance(sequence.getObjectAt(1)).getPositiveValue().toByteArray(); offset = readPositiveInteger(der, offset, sequenceEnd, out, coordinateLength, coordinateLength);
copyUnsignedFixed(r, out, 0, coordinateLength); if (offset != sequenceEnd) {
copyUnsignedFixed(s, out, coordinateLength, coordinateLength); throw new IllegalArgumentException("ECDSA DER signature must contain exactly two integers");
}
return out; return out;
} }
/* High 32 bits carry the decoded length; low 32 bits carry the next offset. */
private static long readDerLength(byte[] der, int offset) {
int cursor = offset;
if (cursor >= der.length) {
throw new IllegalArgumentException("Truncated ECDSA DER length");
}
int first = Byte.toUnsignedInt(der[cursor++]);
int length;
if (first < DER_LONG_FORM_BIT) {
length = first;
} else {
int octets = first & 0x7f;
if (octets == 0 || octets > 2 || cursor + octets > der.length
|| Byte.toUnsignedInt(der[cursor]) == 0) {
throw new IllegalArgumentException("Non-canonical ECDSA DER length");
}
length = 0;
for (int index = 0; index < octets; index++) {
length = (length << Byte.SIZE) | Byte.toUnsignedInt(der[cursor++]);
}
if (length < DER_LONG_FORM_BIT) {
throw new IllegalArgumentException("Non-canonical ECDSA DER length");
}
}
return ((long) length << Integer.SIZE) | Integer.toUnsignedLong(cursor);
}
private static int readPositiveInteger(byte[] der, int offset, int limit, byte[] target, int targetOffset,
int width) {
int cursor = offset;
if (cursor >= limit || Byte.toUnsignedInt(der[cursor++]) != DER_INTEGER_TAG) {
throw new IllegalArgumentException("ECDSA DER signature must contain INTEGER values");
}
long encodedLength = readDerLength(der, cursor);
cursor = (int) encodedLength;
int length = (int) (encodedLength >>> Integer.SIZE);
if (length == 0 || length > width + 1 || cursor > limit - length) {
throw new IllegalArgumentException("Invalid ECDSA DER integer length");
}
int first = Byte.toUnsignedInt(der[cursor]);
if (first == 0) {
if (length == 1 || (der[cursor + 1] & DER_LONG_FORM_BIT) == 0) {
throw new IllegalArgumentException("ECDSA DER integer must be positive, nonzero, and minimal");
}
cursor++;
length--;
} else if ((first & DER_LONG_FORM_BIT) != 0) {
throw new IllegalArgumentException("ECDSA DER integer must be positive");
}
if (length > width) {
throw new IllegalArgumentException("ECDSA integer does not fit into fixed P1363 width");
}
System.arraycopy(der, cursor, target, targetOffset + width - length, length);
return cursor + length;
}
private static byte[] ecdsaP1363ToDer(byte[] p1363, int fixedLength) throws IOException { private static byte[] ecdsaP1363ToDer(byte[] p1363, int fixedLength) throws IOException {
if (p1363.length != fixedLength) { if (p1363.length != fixedLength) {
throw new IllegalArgumentException("Unexpected P1363 signature length: " + p1363.length); throw new IllegalArgumentException("Unexpected P1363 signature length: " + p1363.length);
@@ -252,18 +317,6 @@ public final class SignatureInteropProfile { // NOPMD
return new DERSequence(vector).getEncoded(ASN1Encoding.DER); return new DERSequence(vector).getEncoded(ASN1Encoding.DER);
} }
private static void copyUnsignedFixed(byte[] value, byte[] target, int offset, int width) {
int start = 0;
while (start < value.length - 1 && value[start] == 0) {
start++;
}
int len = value.length - start;
if (len > width) {
throw new IllegalArgumentException("ECDSA integer does not fit into fixed P1363 width");
}
System.arraycopy(value, start, target, offset + width - len, len);
}
private static String requireNonBlank(String value, String label) { private static String requireNonBlank(String value, String label) {
Objects.requireNonNull(value, label); Objects.requireNonNull(value, label);
if (value.isBlank()) { if (value.isBlank()) {

View File

@@ -36,6 +36,7 @@ package zeroecho.core.alg.common.sig;
import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.security.KeyPair; import java.security.KeyPair;
import java.security.KeyPairGenerator; import java.security.KeyPairGenerator;
@@ -117,6 +118,41 @@ public final class SignatureInteropProfilesTest {
System.out.println("resolveSha256WithEcdsa_roundTripsDerAndP1363...ok"); System.out.println("resolveSha256WithEcdsa_roundTripsDerAndP1363...ok");
} }
@Test
public void ecdsaDerConversionRejectsEveryNonCanonicalIntegerShape() throws Exception {
System.out.println("ecdsaDerConversionRejectsEveryNonCanonicalIntegerShape");
SignatureInteropProfile profile = SignatureInteropProfiles.resolve("SHA256withECDSA").orElseThrow();
byte[] canonical = { 0x30, 0x06, 0x02, 0x01, 0x01, 0x02, 0x01, 0x01 };
assertEquals(EcdsaCurveSpec.P256.signFixedLength(), profile.externalToInternalSignature(canonical).length);
assertThrows(IllegalArgumentException.class,
() -> profile.externalToInternalSignature(java.util.Arrays.copyOf(canonical, canonical.length + 1)));
assertThrows(IllegalArgumentException.class,
() -> profile.externalToInternalSignature(new byte[] { 0x30, (byte) 0x80, 0x02, 0x01, 0x01,
0x02, 0x01, 0x01, 0x00, 0x00 }));
assertThrows(IllegalArgumentException.class,
() -> profile.externalToInternalSignature(new byte[] { 0x30, 0x06, 0x02, 0x01, (byte) 0xff,
0x02, 0x01, 0x01 }));
assertThrows(IllegalArgumentException.class,
() -> profile.externalToInternalSignature(new byte[] { 0x30, 0x06, 0x02, 0x01, 0x00,
0x02, 0x01, 0x01 }));
assertThrows(IllegalArgumentException.class,
() -> profile.externalToInternalSignature(new byte[] { 0x30, 0x07, 0x02, 0x02, 0x00, 0x01,
0x02, 0x01, 0x01 }));
byte[] oversized = new byte[40];
oversized[0] = 0x30;
oversized[1] = 0x26;
oversized[2] = 0x02;
oversized[3] = 0x21;
oversized[4] = 0x01;
oversized[37] = 0x02;
oversized[38] = 0x01;
oversized[39] = 0x01;
assertThrows(IllegalArgumentException.class, () -> profile.externalToInternalSignature(oversized));
System.out.println("...rejected=trailing, BER, negative, zero, redundant, oversize");
System.out.println("ecdsaDerConversionRejectsEveryNonCanonicalIntegerShape...ok");
}
private static String hexPrefix(byte[] bytes) { private static String hexPrefix(byte[] bytes) {
int len = Math.min(bytes.length, 16); int len = Math.min(bytes.length, 16);
byte[] prefix = new byte[len]; byte[] prefix = new byte[len];

View File

@@ -66,6 +66,7 @@ import zeroecho.pki.api.audit.Purpose;
import zeroecho.pki.impl.core.async.PkiSigningBus; import zeroecho.pki.impl.core.async.PkiSigningBus;
import zeroecho.pki.impl.framework.x509.X509AlgorithmRole; import zeroecho.pki.impl.framework.x509.X509AlgorithmRole;
import zeroecho.pki.impl.framework.x509.X509ExecutionPlan; import zeroecho.pki.impl.framework.x509.X509ExecutionPlan;
import zeroecho.pki.impl.framework.x509.StreamingDerReader;
import zeroecho.pki.impl.framework.x509.bc.BcX509VerificationExecutor; import zeroecho.pki.impl.framework.x509.bc.BcX509VerificationExecutor;
import zeroecho.pki.impl.framework.x509.bc.BcX509AlgorithmAdapter; import zeroecho.pki.impl.framework.x509.bc.BcX509AlgorithmAdapter;
import zeroecho.pki.spi.audit.AuditSink; import zeroecho.pki.spi.audit.AuditSink;
@@ -85,6 +86,7 @@ final class CaProofGate {
private static final byte[] MANAGED_KEY_CHALLENGE_DOMAIN = "ZeroEcho/PKI/managed-key-possession/v1\0" private static final byte[] MANAGED_KEY_CHALLENGE_DOMAIN = "ZeroEcho/PKI/managed-key-possession/v1\0"
.getBytes(java.nio.charset.StandardCharsets.US_ASCII); .getBytes(java.nio.charset.StandardCharsets.US_ASCII);
private static final int CHALLENGE_NONCE_BYTES = 32; private static final int CHALLENGE_NONCE_BYTES = 32;
private static final int MAXIMUM_SPKI_BYTES = 1024 * 1024;
private static final SecureRandom CHALLENGE_RANDOM = new SecureRandom(); private static final SecureRandom CHALLENGE_RANDOM = new SecureRandom();
private final PublicKeyInfoResolver publicKeyResolver; private final PublicKeyInfoResolver publicKeyResolver;
@@ -114,11 +116,17 @@ final class CaProofGate {
} }
/* default */ SubjectPublicKeyInfo parseRootSpki(EncodedObject spki, FormatId formatId) { /* default */ SubjectPublicKeyInfo parseRootSpki(EncodedObject spki, FormatId formatId) {
try { if (spki.encoding() != Encoding.DER) {
return SubjectPublicKeyInfo.getInstance(spki.bytes());
} catch (RuntimeException ex) { // NOPMD - malformed resolver output fails closed
throw rejection("CREATE_ROOT_REJECTED", formatId, Optional.empty(), "ROOT_MANAGED_KEY_INVALID"); throw rejection("CREATE_ROOT_REJECTED", formatId, Optional.empty(), "ROOT_MANAGED_KEY_INVALID");
} }
byte[] encoded = spki.bytes();
try {
return parseValidatedSpki(encoded);
} catch (IOException | RuntimeException ex) { // NOPMD - malformed resolver output fails closed
throw rejection("CREATE_ROOT_REJECTED", formatId, Optional.empty(), "ROOT_MANAGED_KEY_INVALID");
} finally {
Arrays.fill(encoded, (byte) 0);
}
} }
/* default */ boolean rootProofIsValid(X509CertificateHolder certificate, EncodedObject expectedSpki) { /* default */ boolean rootProofIsValid(X509CertificateHolder certificate, EncodedObject expectedSpki) {
@@ -169,6 +177,7 @@ final class CaProofGate {
} }
return new ManagedKeyProof(keyRef, formatId, new EncodedObject(Encoding.DER, exactSpki.clone())); return new ManagedKeyProof(keyRef, formatId, new EncodedObject(Encoding.DER, exactSpki.clone()));
} finally { } finally {
Arrays.fill(exactSpki, (byte) 0);
Arrays.fill(challenge, (byte) 0); Arrays.fill(challenge, (byte) 0);
if (signature != null) { if (signature != null) {
Arrays.fill(signature, (byte) 0); Arrays.fill(signature, (byte) 0);
@@ -187,7 +196,15 @@ final class CaProofGate {
if (resolved == null || resolved.encoding() != Encoding.DER) { if (resolved == null || resolved.encoding() != Encoding.DER) {
throw rejection(auditAction, formatId, subjectCaId, "MANAGED_KEY_INVALID"); throw rejection(auditAction, formatId, subjectCaId, "MANAGED_KEY_INVALID");
} }
return new EncodedObject(Encoding.DER, resolved.bytes()); byte[] encoded = resolved.bytes();
try {
parseValidatedSpki(encoded);
return new EncodedObject(Encoding.DER, encoded);
} catch (IOException | RuntimeException exception) { // NOPMD - hostile resolver output is redacted
throw rejection(auditAction, formatId, subjectCaId, "MANAGED_KEY_INVALID");
} finally {
Arrays.fill(encoded, (byte) 0);
}
} }
/* default */ PkiException rejection(String action, FormatId formatId, Optional<PkiId> objectId, String code) { /* default */ PkiException rejection(String action, FormatId formatId, Optional<PkiId> objectId, String code) {
@@ -213,7 +230,7 @@ final class CaProofGate {
private boolean verifyChallenge(byte[] spkiDer, byte[] challenge, byte[] signature) { private boolean verifyChallenge(byte[] spkiDer, byte[] challenge, byte[] signature) {
try { try {
SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(spkiDer); SubjectPublicKeyInfo spki = parseValidatedSpki(spkiDer);
BcX509AlgorithmAdapter adapter = new BcX509AlgorithmAdapter(signingBus.authority().bindings()); BcX509AlgorithmAdapter adapter = new BcX509AlgorithmAdapter(signingBus.authority().bindings());
AlgorithmIdentity keyIdentity = adapter.decode(spki.getAlgorithm(), AlgorithmIdentity keyIdentity = adapter.decode(spki.getAlgorithm(),
X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM); X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM);
@@ -230,6 +247,14 @@ final class CaProofGate {
} }
} }
private SubjectPublicKeyInfo parseValidatedSpki(byte[] encoded) throws IOException {
new StreamingDerReader().validateSubjectPublicKeyInfo(encoded, MAXIMUM_SPKI_BYTES);
SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(encoded);
new BcX509AlgorithmAdapter(signingBus.authority().bindings()).decode(spki.getAlgorithm(),
X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM);
return spki;
}
/** /**
* Unforgeable package-local result of a completed managed-key challenge. * Unforgeable package-local result of a completed managed-key challenge.
*/ */

View File

@@ -33,6 +33,7 @@
******************************************************************************/ ******************************************************************************/
package zeroecho.pki.impl.core; package zeroecho.pki.impl.core;
import java.io.IOException;
import java.security.GeneralSecurityException; import java.security.GeneralSecurityException;
import java.security.KeyFactory; import java.security.KeyFactory;
import java.security.MessageDigest; import java.security.MessageDigest;
@@ -72,6 +73,7 @@ import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes;
import zeroecho.pki.impl.framework.x509.bc.BcX509AlgorithmAdapter; import zeroecho.pki.impl.framework.x509.bc.BcX509AlgorithmAdapter;
import zeroecho.pki.impl.framework.x509.X509AlgorithmRole; import zeroecho.pki.impl.framework.x509.X509AlgorithmRole;
import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot; import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot;
import zeroecho.pki.impl.framework.x509.StreamingDerReader;
import zeroecho.core.alg.BootstrapAlgorithmIdentities; import zeroecho.core.alg.BootstrapAlgorithmIdentities;
import zeroecho.core.spec.AlgorithmIdentity; import zeroecho.core.spec.AlgorithmIdentity;
import zeroecho.pki.impl.framework.x509.bc.BcX509ProfileSupport; import zeroecho.pki.impl.framework.x509.bc.BcX509ProfileSupport;
@@ -84,6 +86,7 @@ import zeroecho.pki.impl.framework.x509.bc.BcX509ProfileSupport;
final class CertificateProfileValidator { final class CertificateProfileValidator {
private static final String EC_FAMILY = "ec"; private static final String EC_FAMILY = "ec";
private static final int MAXIMUM_SPKI_BYTES = 1024 * 1024;
private CertificateProfileValidator() { private CertificateProfileValidator() {
} }
@@ -233,6 +236,7 @@ final class CertificateProfileValidator {
} }
byte[] encoded = exactPublicKey.bytes(); byte[] encoded = exactPublicKey.bytes();
try { try {
new StreamingDerReader().validateSubjectPublicKeyInfo(encoded, MAXIMUM_SPKI_BYTES);
SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(encoded); SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(encoded);
AlgorithmIdentity identity; AlgorithmIdentity identity;
try { try {
@@ -262,7 +266,7 @@ final class CertificateProfileValidator {
} }
} catch (PkiException exception) { } catch (PkiException exception) {
throw exception; throw exception;
} catch (GeneralSecurityException | IllegalArgumentException exception) { } catch (GeneralSecurityException | IllegalArgumentException | IOException exception) {
throw reject("SUBJECT_KEY_UNSUPPORTED"); throw reject("SUBJECT_KEY_UNSUPPORTED");
} finally { } finally {
java.util.Arrays.fill(encoded, (byte) 0); java.util.Arrays.fill(encoded, (byte) 0);

View File

@@ -53,10 +53,15 @@ import zeroecho.pki.spi.store.PkiStore;
* Internal individual-certificate adapter around store-owned content. * Internal individual-certificate adapter around store-owned content.
*/ */
final class CredentialContent { final class CredentialContent {
/* default */ static final int MAXIMUM_CERTIFICATE_BYTES = 1024 * 1024;
private CredentialContent() { private CredentialContent() {
} }
/* default */ static DurableContentReference stage(PkiStore store, byte[] encoded) { /* default */ static DurableContentReference stage(PkiStore store, byte[] encoded) {
if (encoded.length > MAXIMUM_CERTIFICATE_BYTES) {
throw new PkiException("Certificate exceeds artifact limit: code=CERTIFICATE_TOO_LARGE");
}
try (ContentSink sink = store.stagedContent().beginContent(Encoding.DER, DurableContentReference.Lifecycle.PERSISTED); try (ContentSink sink = store.stagedContent().beginContent(Encoding.DER, DurableContentReference.Lifecycle.PERSISTED);
OutputStream output = sink.outputStream()) { OutputStream output = sink.outputStream()) {
output.write(encoded); output.write(encoded);
@@ -67,10 +72,11 @@ final class CredentialContent {
} }
/* default */ static byte[] materializeForBc(PkiStore store, DurableContentReference reference) { /* default */ static byte[] materializeForBc(PkiStore store, DurableContentReference reference) {
if (reference.length() > Integer.MAX_VALUE) { long length = reference.length();
throw new PkiException("Credential exceeds BC adapter element domain: code=ADAPTER_ELEMENT_LIMIT_EXCEEDED"); if (reference.encoding() != Encoding.DER || length <= 0L || length > MAXIMUM_CERTIFICATE_BYTES) {
throw new PkiException("Certificate exceeds artifact limit: code=CERTIFICATE_TOO_LARGE");
} }
byte[] result = new byte[(int) reference.length()]; byte[] result = new byte[(int) length];
try { try {
readExact(store, reference, result); readExact(store, reference, result);
return result; return result;
@@ -83,6 +89,10 @@ final class CredentialContent {
/* default */ static BcX509SignedObjectValidator.CertificateBindings validateCertificate(PkiStore store, /* default */ static BcX509SignedObjectValidator.CertificateBindings validateCertificate(PkiStore store,
DurableContentReference reference, X509AuthoritySnapshot authority, DurableContentReference reference, X509AuthoritySnapshot authority,
Optional<AlgorithmIdentity> expectedSignature) { Optional<AlgorithmIdentity> expectedSignature) {
long length = reference.length();
if (reference.encoding() != Encoding.DER || length <= 0L || length > MAXIMUM_CERTIFICATE_BYTES) {
throw new PkiException("Certificate exceeds artifact limit: code=CERTIFICATE_TOO_LARGE");
}
try (RepeatableContent content = store.stagedContent().openContent(reference)) { try (RepeatableContent content = store.stagedContent().openContent(reference)) {
return new BcX509SignedObjectValidator(authority).validateCertificate(content, expectedSignature, return new BcX509SignedObjectValidator(authority).validateCertificate(content, expectedSignature,
CancellationSignal.NONE); CancellationSignal.NONE);

View File

@@ -222,8 +222,12 @@ public final class DefaultStatusObjectService implements StatusObjectService {
SimpleAttributeSet.Builder b = SimpleAttributeSet.builder(); SimpleAttributeSet.Builder b = SimpleAttributeSet.builder();
b.putAll(command.attributes()); b.putAll(command.attributes());
b.put(BcX509Attributes.ISSUER_CERT_DER, byte[] issuerDer = materializeValidatedCertificate(issuerCred);
new AttributeValue.BytesValue(CredentialContent.materializeForBc(store, issuerCred.content()))); try {
b.put(BcX509Attributes.ISSUER_CERT_DER, new AttributeValue.BytesValue(issuerDer.clone()));
} finally {
Arrays.fill(issuerDer, (byte) 0);
}
b.put(BcX509Attributes.ISSUER_KEYREF, new AttributeValue.StringValue(ca.issuerKeyRef().value())); b.put(BcX509Attributes.ISSUER_KEYREF, new AttributeValue.StringValue(ca.issuerKeyRef().value()));
StatusObjectGenerateCommand wired = new StatusObjectGenerateCommand(command.issuerCaId(), command.type(), StatusObjectGenerateCommand wired = new StatusObjectGenerateCommand(command.issuerCaId(), command.type(),
@@ -248,11 +252,12 @@ public final class DefaultStatusObjectService implements StatusObjectService {
requirePersistableContent(generated.content()); requirePersistableContent(generated.content());
boolean accepted = false; boolean accepted = false;
try { try {
byte[] issuerDer = CredentialContent.materializeForBc(store, issuer.content()); requireGeneratedMetadata(command, generated);
byte[] issuerDer = materializeValidatedCertificate(issuer);
try (RepeatableContent content = store.stagedContent().openContent(generated.content())) { try (RepeatableContent content = store.stagedContent().openContent(generated.content())) {
X509CertificateHolder holder = new X509CertificateHolder(issuerDer); X509CertificateHolder holder = new X509CertificateHolder(issuerDer);
new BcX509SignedObjectValidator(authority).validateGeneratedCrl(content, signingPlan, new BcX509SignedObjectValidator(authority).validateGeneratedCrl(content, signingPlan,
holder.getSubjectPublicKeyInfo(), CancellationSignal.NONE); holder, generated.thisUpdate(), generated.nextUpdate(), CancellationSignal.NONE);
} finally { } finally {
Arrays.fill(issuerDer, (byte) 0); Arrays.fill(issuerDer, (byte) 0);
} }
@@ -274,11 +279,20 @@ public final class DefaultStatusObjectService implements StatusObjectService {
private void requirePersistableContent(DurableContentReference content) { private void requirePersistableContent(DurableContentReference content) {
if (content.lifecycle() != DurableContentReference.Lifecycle.PERSISTED if (content.lifecycle() != DurableContentReference.Lifecycle.PERSISTED
|| !store.stagedContent().contentStoreId().equals(content.storeId())) { || !store.stagedContent().contentStoreId().equals(content.storeId())
|| content.encoding() != Encoding.DER) {
throw new PkiException("Status content lifecycle invalid: code=STAGED_CONTENT_FOREIGN_RUNTIME"); throw new PkiException("Status content lifecycle invalid: code=STAGED_CONTENT_FOREIGN_RUNTIME");
} }
} }
private static void requireGeneratedMetadata(StatusObjectGenerateCommand command, StatusObject generated) {
if (generated.type() != command.type() || !generated.formatId().equals(command.formatId())
|| !generated.issuerCaId().equals(command.issuerCaId())
|| generated.nextUpdate().filter(next -> !next.isAfter(generated.thisUpdate())).isPresent()) {
throw new PkiException("Status metadata binding invalid: code=STATUS_OBJECT_BINDING_MISMATCH");
}
}
private void releaseRejectedContent(DurableContentReference content) { private void releaseRejectedContent(DurableContentReference content) {
try { try {
store.stagedContent().retireUnownedContent(content); store.stagedContent().retireUnownedContent(content);
@@ -443,7 +457,7 @@ public final class DefaultStatusObjectService implements StatusObjectService {
// cause is intentionally removed at this public service boundary. // cause is intentionally removed at this public service boundary.
@SuppressWarnings("PMD.PreserveStackTrace") @SuppressWarnings("PMD.PreserveStackTrace")
private BigInteger certificateSerial(Credential credential) { private BigInteger certificateSerial(Credential credential) {
byte[] der = CredentialContent.materializeForBc(store, credential.content()); byte[] der = materializeValidatedCertificate(credential);
BigInteger serial; BigInteger serial;
try { try {
serial = new X509CertificateHolder(der).getSerialNumber(); serial = new X509CertificateHolder(der).getSerialNumber();
@@ -458,6 +472,16 @@ public final class DefaultStatusObjectService implements StatusObjectService {
return serial; return serial;
} }
@SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" })
private byte[] materializeValidatedCertificate(Credential credential) {
try {
CredentialContent.validateCertificate(store, credential.content(), authority, Optional.empty());
return CredentialContent.materializeForBc(store, credential.content());
} catch (RuntimeException exception) {
throw crlGenerationFailure();
}
}
private static PkiException crlGenerationFailure() { private static PkiException crlGenerationFailure() {
return new PkiException("CRL generation failed: code=" + CRL_GENERATION_FAILED); return new PkiException("CRL generation failed: code=" + CRL_GENERATION_FAILED);
} }

View File

@@ -33,9 +33,16 @@
******************************************************************************/ ******************************************************************************/
package zeroecho.pki.impl.framework.x509; package zeroecho.pki.impl.framework.x509;
import java.io.ByteArrayInputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.time.DateTimeException;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.Objects; import java.util.Objects;
import java.util.Optional;
import java.util.OptionalLong;
import zeroecho.core.io.CancellationSignal; import zeroecho.core.io.CancellationSignal;
import zeroecho.core.io.RepeatableContent; import zeroecho.core.io.RepeatableContent;
@@ -58,6 +65,9 @@ import zeroecho.core.io.RepeatableContent;
* fixed depth and comparison-buffer size. * fixed depth and comparison-buffer size.
* </p> * </p>
*/ */
// The aggregate branches implement one closed canonical-DER grammar.
@SuppressWarnings({ "PMD.CyclomaticComplexity", "PMD.NPathComplexity", "PMD.AvoidLiteralsInIfCondition",
"PMD.CommentDefaultAccessModifier" })
public final class StreamingDerReader { public final class StreamingDerReader {
private static final int BUFFER_BYTES = 16 * 1024; private static final int BUFFER_BYTES = 16 * 1024;
@@ -66,6 +76,76 @@ public final class StreamingDerReader {
private static final int CONTINUATION_BIT = 0x80; private static final int CONTINUATION_BIT = 0x80;
private static final long SHORT_LENGTH_LIMIT = 128L; private static final long SHORT_LENGTH_LIMIT = 128L;
/**
* Validates one complete canonical DER object held in an explicitly bounded
* byte array.
*
* <p>
* This convenience entry point is intended for individual adapter values such
* as PKCS#10 requests and subject-public-key information. The caller-selected
* {@code maximumBytes} is enforced before parsing. Repeatable stream sources,
* especially aggregate CRLs, should use
* {@link #validate(RepeatableContent, CancellationSignal)} instead.
* </p>
*
* @param encoded complete DER object
* @param maximumBytes positive caller-selected element limit in bytes
* @return exact encoded length
* @throws NullPointerException if {@code encoded} is {@code null}
* @throws IllegalArgumentException if {@code maximumBytes} is not positive
* @throws IOException if the input exceeds {@code maximumBytes}, is malformed,
* non-canonical, truncated, or contains trailing data
*/
public int validate(byte[] encoded, int maximumBytes) throws IOException {
Objects.requireNonNull(encoded, "encoded");
if (maximumBytes <= 0) {
throw new IllegalArgumentException("maximumBytes must be positive");
}
if (encoded.length > maximumBytes) {
throw new IOException("DER object exceeds adapter element limit: code=ADAPTER_ELEMENT_LIMIT_EXCEEDED");
}
long length = validate(new ByteArrayContent(encoded), CancellationSignal.NONE);
try {
return Math.toIntExact(length);
} catch (ArithmeticException exception) {
throw new IOException("DER length overflow: code=CONTENT_LENGTH_OVERFLOW", exception);
}
}
/**
* Validates one canonical DER {@code SubjectPublicKeyInfo} value, including
* the X.509 requirement that its public-key BIT STRING have zero unused bits.
*
* @param encoded complete DER subject-public-key information
* @param maximumBytes positive caller-selected element limit in bytes
* @return exact encoded length
* @throws NullPointerException if {@code encoded} is {@code null}
* @throws IllegalArgumentException if {@code maximumBytes} is not positive
* @throws IOException if the value exceeds the limit or is not one canonical
* {@code SubjectPublicKeyInfo}
*/
public int validateSubjectPublicKeyInfo(byte[] encoded, int maximumBytes) throws IOException {
int length = validate(encoded, maximumBytes);
try (CountedInput input = new CountedInput(new ByteArrayInputStream(encoded))) {
Header root = HeaderReader.read(input);
requireSequence(root);
long rootEnd = checkedAdd(input.position(), root.length());
Header algorithm = HeaderReader.read(input);
requireSequence(algorithm);
skip(input, algorithm.length(), CancellationSignal.NONE);
Header publicKey = HeaderReader.read(input);
if (!publicKey.universal() || publicKey.tagNumber() != 3 || publicKey.constructed()
|| publicKey.length() < 1L || input.readRequired() != 0) {
throw new IOException("Malformed SubjectPublicKeyInfo BIT STRING");
}
skip(input, publicKey.length() - 1L, CancellationSignal.NONE);
if (input.position() != rootEnd || input.position() != length) {
throw new IOException("Malformed SubjectPublicKeyInfo structure");
}
}
return length;
}
/** /**
* Validates one complete canonical DER object. * Validates one complete canonical DER object.
* *
@@ -108,7 +188,7 @@ public final class StreamingDerReader {
try (CountedInput input = new CountedInput(content.openStream())) { try (CountedInput input = new CountedInput(content.openStream())) {
Header outer = HeaderReader.read(input); Header outer = HeaderReader.read(input);
requireSequence(outer); requireSequence(outer);
long outerEnd = Math.addExact(input.position(), outer.length()); long outerEnd = checkedAdd(input.position(), outer.length());
Header tbs = HeaderReader.read(input); Header tbs = HeaderReader.read(input);
requireSequence(tbs); requireSequence(tbs);
long tbsTotalLength = tbs.encodedLength(); long tbsTotalLength = tbs.encodedLength();
@@ -133,7 +213,8 @@ public final class StreamingDerReader {
} }
return new SignedObjectLayout(tbs.start(), tbsTotalLength, inner.algorithmOffset(), return new SignedObjectLayout(tbs.start(), tbsTotalLength, inner.algorithmOffset(),
inner.algorithmLength(), outerAlgorithm.start(), outerAlgorithm.encodedLength(), inner.algorithmLength(), outerAlgorithm.start(), outerAlgorithm.encodedLength(),
inner.spkiOffset(), inner.spkiLength(), signatureOffset, signature.length() - 1L); inner.spkiOffset(), inner.spkiLength(), signatureOffset, signature.length() - 1L,
inner.issuerOffset(), inner.issuerLength(), inner.thisUpdate(), inner.nextUpdate());
} }
} }
@@ -150,16 +231,12 @@ public final class StreamingDerReader {
requireUniversal(child, 2); requireUniversal(child, 2);
skip(input, child.length(), cancellation); skip(input, child.length(), cancellation);
child = HeaderReader.read(input); child = HeaderReader.read(input);
} else if (child.universal() && child.tagNumber() == 2) { } else {
skip(input, child.length(), cancellation); return inspectCrlTbs(input, tbs, child, cancellation);
child = HeaderReader.read(input);
} }
requireSequence(child); requireSequence(child);
long algorithmOffset = child.start(); long algorithmOffset = child.start();
long algorithmLength = child.encodedLength(); long algorithmLength = child.encodedLength();
if (kind == SignedObjectKind.CRL) {
return new AlgorithmAndKey(algorithmOffset, algorithmLength, -1L, 0L);
}
skip(input, child.length(), cancellation); skip(input, child.length(), cancellation);
for (int index = 0; index < 3; index++) { for (int index = 0; index < 3; index++) {
Header field = HeaderReader.read(input); Header field = HeaderReader.read(input);
@@ -167,7 +244,213 @@ public final class StreamingDerReader {
} }
Header spki = HeaderReader.read(input); Header spki = HeaderReader.read(input);
requireSequence(spki); requireSequence(spki);
return new AlgorithmAndKey(algorithmOffset, algorithmLength, spki.start(), spki.encodedLength()); return new AlgorithmAndKey(algorithmOffset, algorithmLength, spki.start(), spki.encodedLength(),
-1L, 0L, Optional.empty(), Optional.empty());
}
}
private static AlgorithmAndKey inspectCrlTbs(CountedInput input, Header tbs, Header firstChild,
CancellationSignal cancellation) throws IOException {
long tbsEnd = checkedAdd(tbs.valueOffset(), tbs.length());
Header child = firstChild;
boolean v2 = false;
if (child.universal() && child.tagNumber() == 2) {
requireCrlVersion(input, child, cancellation);
v2 = true;
child = readWithin(input, tbsEnd);
}
requireAlgorithmIdentifier(input, child, cancellation);
long algorithmOffset = child.start();
long algorithmLength = child.encodedLength();
Header issuer = readWithin(input, tbsEnd);
requireSequence(issuer);
if (issuer.length() == 0L) {
throw new IOException("TBSCertList issuer Name must not be empty");
}
skip(input, issuer.length(), cancellation);
Header thisUpdateHeader = readWithin(input, tbsEnd);
Instant thisUpdate = readCrlTime(input, thisUpdateHeader);
Optional<Instant> nextUpdate = Optional.empty();
Header optional = input.position() < tbsEnd ? readWithin(input, tbsEnd) : null;
if (optional != null && isTime(optional)) {
Instant parsedNextUpdate = readCrlTime(input, optional);
if (!parsedNextUpdate.isAfter(thisUpdate)) {
throw new IOException("CRL nextUpdate must follow thisUpdate");
}
nextUpdate = Optional.of(parsedNextUpdate);
optional = input.position() < tbsEnd ? readWithin(input, tbsEnd) : null;
}
if (optional != null && optional.universal() && optional.tagNumber() == 16) {
readRevokedCertificates(input, optional, v2, cancellation);
optional = input.position() < tbsEnd ? readWithin(input, tbsEnd) : null;
}
if (optional != null && optional.contextSpecific(0)) {
if (!v2) {
throw new IOException("CRL extensions require TBSCertList v2");
}
readExplicitExtensions(input, optional, cancellation);
optional = input.position() < tbsEnd ? readWithin(input, tbsEnd) : null;
}
if (optional != null || input.position() != tbsEnd) {
throw new IOException("Malformed TBSCertList optional fields");
}
return new AlgorithmAndKey(algorithmOffset, algorithmLength, -1L, 0L, issuer.start(),
issuer.encodedLength(), Optional.of(thisUpdate), nextUpdate);
}
private static void requireCrlVersion(CountedInput input, Header version, CancellationSignal cancellation)
throws IOException {
requireUniversal(version, 2);
if (version.constructed() || version.length() != 1L || input.readRequired() != 1) {
throw new IOException("TBSCertList version must be canonical v2(1)");
}
skip(input, version.length() - 1L, cancellation);
}
private static void requireAlgorithmIdentifier(CountedInput input, Header algorithm,
CancellationSignal cancellation) throws IOException {
requireSequence(algorithm);
long end = checkedAdd(algorithm.valueOffset(), algorithm.length());
Header oid = readWithin(input, end);
requirePrimitiveUniversal(oid, 6);
PrimitiveReader.read(input, oid, cancellation);
if (input.position() < end) {
Header parameters = readWithin(input, end);
skip(input, parameters.length(), cancellation);
}
if (input.position() != end) {
throw new IOException("Malformed AlgorithmIdentifier");
}
}
private static Instant readCrlTime(CountedInput input, Header time) throws IOException {
if (!isTime(time) || time.constructed()) {
throw new IOException("Expected TBSCertList Time");
}
return PrimitiveReader.readTime(input, time.length(), time.tagNumber() == 24).toInstant(ZoneOffset.UTC);
}
private static boolean isTime(Header header) {
return header.universal() && (header.tagNumber() == 23 || header.tagNumber() == 24);
}
private static void readRevokedCertificates(CountedInput input, Header revokedCertificates, boolean v2,
CancellationSignal cancellation) throws IOException {
requireSequence(revokedCertificates);
long end = checkedAdd(revokedCertificates.valueOffset(), revokedCertificates.length());
while (input.position() < end) {
cancellation.throwIfCancelled();
Header entry = readWithin(input, end);
requireSequence(entry);
readRevokedCertificate(input, entry, v2, cancellation);
}
if (input.position() != end) {
throw new IOException("Malformed revokedCertificates");
}
}
private static void readRevokedCertificate(CountedInput input, Header entry, boolean v2,
CancellationSignal cancellation) throws IOException {
long end = checkedAdd(entry.valueOffset(), entry.length());
Header serial = readWithin(input, end);
readPositiveSerial(input, serial, cancellation);
Header revocationDate = readWithin(input, end);
readCrlTime(input, revocationDate);
if (input.position() < end) {
if (!v2) {
throw new IOException("CRL entry extensions require TBSCertList v2");
}
Header extensions = readWithin(input, end);
readExtensions(input, extensions, cancellation);
}
if (input.position() != end) {
throw new IOException("Malformed revoked certificate entry");
}
}
private static void readPositiveSerial(CountedInput input, Header serial, CancellationSignal cancellation)
throws IOException {
requirePrimitiveUniversal(serial, 2);
if (serial.length() == 0L || serial.length() > 20L) {
throw new IOException("CRL serial number must be positive");
}
int first = input.readRequired();
if ((first & CONTINUATION_BIT) != 0 || first == 0 && serial.length() == 1L) {
throw new IOException("CRL serial number must be positive");
}
skip(input, serial.length() - 1L, cancellation);
}
private static void readExplicitExtensions(CountedInput input, Header explicit,
CancellationSignal cancellation) throws IOException {
if (!explicit.constructed()) {
throw new IOException("CRL extensions must be [0] EXPLICIT");
}
long end = checkedAdd(explicit.valueOffset(), explicit.length());
Header extensions = readWithin(input, end);
readExtensions(input, extensions, cancellation);
if (input.position() != end) {
throw new IOException("Malformed explicit CRL extensions");
}
}
private static void readExtensions(CountedInput input, Header extensions, CancellationSignal cancellation)
throws IOException {
requireSequence(extensions);
long end = checkedAdd(extensions.valueOffset(), extensions.length());
if (input.position() == end) {
throw new IOException("Extensions must contain at least one Extension");
}
while (input.position() < end) {
cancellation.throwIfCancelled();
Header extension = readWithin(input, end);
requireSequence(extension);
readExtension(input, extension, cancellation);
}
if (input.position() != end) {
throw new IOException("Malformed Extensions");
}
}
private static void readExtension(CountedInput input, Header extension, CancellationSignal cancellation)
throws IOException {
long end = checkedAdd(extension.valueOffset(), extension.length());
Header oid = readWithin(input, end);
requirePrimitiveUniversal(oid, 6);
PrimitiveReader.read(input, oid, cancellation);
Header value = readWithin(input, end);
if (value.universal() && value.tagNumber() == 1) {
requirePrimitiveUniversal(value, 1);
if (value.length() != 1L || input.readRequired() != 0xff) {
throw new IOException("Extension critical DEFAULT FALSE must be omitted");
}
value = readWithin(input, end);
}
requirePrimitiveUniversal(value, 4);
skip(input, value.length(), cancellation);
if (input.position() != end) {
throw new IOException("Malformed Extension");
}
}
private static Header readWithin(CountedInput input, long end) throws IOException {
if (input.position() >= end) {
throw new IOException("Missing required DER field");
}
Header header = HeaderReader.read(input);
if (checkedAdd(input.position(), header.length()) > end) {
throw new IOException("DER field exceeds enclosing structure");
}
return header;
}
private static void requirePrimitiveUniversal(Header header, int tagNumber) throws IOException {
requireUniversal(header, tagNumber);
if (header.constructed()) {
throw new IOException("Expected primitive DER field");
} }
} }
@@ -189,7 +472,7 @@ public final class StreamingDerReader {
if (depth >= MAXIMUM_X509_STRUCTURE_DEPTH) { if (depth >= MAXIMUM_X509_STRUCTURE_DEPTH) {
throw new IOException("DER nesting exceeds X.509 adapter capability"); throw new IOException("DER nesting exceeds X.509 adapter capability");
} }
long end = Math.addExact(input.position(), header.length()); long end = checkedAdd(input.position(), header.length());
if (header.constructed()) { if (header.constructed()) {
requireConstructedForm(header); requireConstructedForm(header);
SetOrdering setOrdering = header.universal() && header.tagNumber() == 17 SetOrdering setOrdering = header.universal() && header.tagNumber() == 17
@@ -198,7 +481,7 @@ public final class StreamingDerReader {
while (input.position() < end) { while (input.position() < end) {
cancellation.throwIfCancelled(); cancellation.throwIfCancelled();
Header child = HeaderReader.read(input); Header child = HeaderReader.read(input);
long childEnd = Math.addExact(input.position(), child.length()); long childEnd = checkedAdd(input.position(), child.length());
if (childEnd > end) { if (childEnd > end) {
throw new IOException("DER child exceeds parent: code=MALFORMED_SIGNED_OBJECT"); throw new IOException("DER child exceeds parent: code=MALFORMED_SIGNED_OBJECT");
} }
@@ -232,8 +515,11 @@ public final class StreamingDerReader {
} }
private static void skip(CountedInput input, long length, CancellationSignal cancellation) throws IOException { private static void skip(CountedInput input, long length, CancellationSignal cancellation) throws IOException {
byte[] buffer = new byte[BUFFER_BYTES];
long remaining = length; long remaining = length;
if (remaining == 0L) {
return;
}
byte[] buffer = input.scratch();
while (remaining != 0L) { while (remaining != 0L) {
cancellation.throwIfCancelled(); cancellation.throwIfCancelled();
int read = input.read(buffer, 0, (int) Math.min(buffer.length, remaining)); int read = input.read(buffer, 0, (int) Math.min(buffer.length, remaining));
@@ -249,6 +535,14 @@ public final class StreamingDerReader {
} }
} }
private static long checkedAdd(long left, long right) throws IOException {
try {
return Math.addExact(left, right);
} catch (ArithmeticException exception) {
throw new IOException("DER length overflow: code=CONTENT_LENGTH_OVERFLOW", exception);
}
}
/** /**
* Supported signed-object grammar. * Supported signed-object grammar.
*/ */
@@ -272,14 +566,30 @@ public final class StreamingDerReader {
* @param subjectPublicKeyInfoLength SPKI TLV length, or zero for CRLs * @param subjectPublicKeyInfoLength SPKI TLV length, or zero for CRLs
* @param signatureOffset signature octets offset * @param signatureOffset signature octets offset
* @param signatureLength signature octets length * @param signatureLength signature octets length
* @param issuerOffset issuer Name TLV offset, or {@code -1} for certificates
* @param issuerLength issuer Name TLV length, or zero for certificates
* @param thisUpdate exact CRL {@code thisUpdate}, or empty for certificates
* @param nextUpdate exact optional CRL {@code nextUpdate}, or empty when absent
* and for certificates
*/ */
public record SignedObjectLayout(long tbsOffset, long tbsLength, long tbsAlgorithmOffset, public record SignedObjectLayout(long tbsOffset, long tbsLength, long tbsAlgorithmOffset,
long tbsAlgorithmLength, long outerAlgorithmOffset, long outerAlgorithmLength, long tbsAlgorithmLength, long outerAlgorithmOffset, long outerAlgorithmLength,
long subjectPublicKeyInfoOffset, long subjectPublicKeyInfoLength, long signatureOffset, long subjectPublicKeyInfoOffset, long subjectPublicKeyInfoLength, long signatureOffset,
long signatureLength) { long signatureLength, long issuerOffset, long issuerLength, Optional<Instant> thisUpdate,
Optional<Instant> nextUpdate) {
/**
* Creates an immutable layout for one validated signed object.
*
* @throws NullPointerException if either time container is {@code null}
*/
public SignedObjectLayout {
Objects.requireNonNull(thisUpdate, "thisUpdate");
Objects.requireNonNull(nextUpdate, "nextUpdate");
}
} }
private record AlgorithmAndKey(long algorithmOffset, long algorithmLength, long spkiOffset, long spkiLength) { private record AlgorithmAndKey(long algorithmOffset, long algorithmLength, long spkiOffset, long spkiLength,
long issuerOffset, long issuerLength, Optional<Instant> thisUpdate, Optional<Instant> nextUpdate) {
} }
private record Header(int firstTag, int tagNumber, long length, long start, long valueOffset) { private record Header(int firstTag, int tagNumber, long length, long start, long valueOffset) {
@@ -295,8 +605,8 @@ public final class StreamingDerReader {
return (firstTag & 0xc0) == 0x80 && tagNumber == expectedTag; return (firstTag & 0xc0) == 0x80 && tagNumber == expectedTag;
} }
private long encodedLength() { private long encodedLength() throws IOException {
return Math.addExact(valueOffset - start, length); return checkedAdd(valueOffset - start, length);
} }
} }
@@ -320,10 +630,20 @@ public final class StreamingDerReader {
if ((octet & 0x7f) == 0) { if ((octet & 0x7f) == 0) {
throw new IOException("Non-minimal DER high tag"); throw new IOException("Non-minimal DER high tag");
} }
while ((octet & CONTINUATION_BIT) != 0) { tagNumber = 0;
while (true) {
if (tagNumber > (Integer.MAX_VALUE >>> 7)) {
throw new IOException("DER tag number overflow: code=CONTENT_LENGTH_OVERFLOW");
}
tagNumber = (tagNumber << 7) | (octet & 0x7f);
if ((octet & CONTINUATION_BIT) == 0) {
break;
}
octet = input.readRequired(); octet = input.readRequired();
} }
tagNumber = -1; if (tagNumber < HIGH_TAG_NUMBER) {
throw new IOException("Non-minimal DER high tag");
}
} }
return tagNumber; return tagNumber;
} }
@@ -539,6 +859,7 @@ public final class StreamingDerReader {
private static final class CountedInput extends InputStream { private static final class CountedInput extends InputStream {
private final InputStream delegate; private final InputStream delegate;
private long position; private long position;
private byte[] scratch;
private CountedInput(InputStream delegate) { private CountedInput(InputStream delegate) {
super(); super();
@@ -549,7 +870,7 @@ public final class StreamingDerReader {
public int read() throws IOException { public int read() throws IOException {
int value = delegate.read(); int value = delegate.read();
if (value >= 0) { if (value >= 0) {
position = Math.addExact(position, 1L); position = checkedAdd(position, 1L);
} }
return value; return value;
} }
@@ -558,7 +879,7 @@ public final class StreamingDerReader {
public int read(byte[] bytes, int offset, int length) throws IOException { public int read(byte[] bytes, int offset, int length) throws IOException {
int count = delegate.read(bytes, offset, length); int count = delegate.read(bytes, offset, length);
if (count > 0) { if (count > 0) {
position = Math.addExact(position, count); position = checkedAdd(position, count);
} }
return count; return count;
} }
@@ -579,6 +900,13 @@ public final class StreamingDerReader {
private long position() { private long position() {
return position; return position;
} }
private byte[] scratch() {
if (scratch == null) {
scratch = new byte[BUFFER_BYTES];
}
return scratch;
}
} }
/** /**
@@ -596,11 +924,21 @@ public final class StreamingDerReader {
} }
switch (header.tagNumber()) { switch (header.tagNumber()) {
case 1 -> readBoolean(input, header.length()); case 1 -> readBoolean(input, header.length());
case 2 -> readInteger(input, header.length(), cancellation); case 2, 10 -> readInteger(input, header.length(), cancellation);
case 3 -> readBitString(input, header.length(), cancellation); case 3 -> readBitString(input, header.length(), cancellation);
case 4, 20 -> skip(input, header.length(), cancellation);
case 5 -> readNull(header.length()); case 5 -> readNull(header.length());
case 6 -> readOid(input, header.length(), cancellation); case 6 -> readOid(input, header.length(), cancellation);
default -> skip(input, header.length(), cancellation); case 12 -> readUtf8String(input, header.length(), cancellation);
case 18 -> readRestrictedString(input, header.length(), StringKind.NUMERIC, cancellation);
case 19 -> readRestrictedString(input, header.length(), StringKind.PRINTABLE, cancellation);
case 22 -> readRestrictedString(input, header.length(), StringKind.IA5, cancellation);
case 23 -> readTime(input, header.length(), false);
case 24 -> readTime(input, header.length(), true);
case 26 -> readRestrictedString(input, header.length(), StringKind.VISIBLE, cancellation);
case 28 -> readUniversalString(input, header.length(), cancellation);
case 30 -> readBmpString(input, header.length(), cancellation);
default -> throw new IOException("Unsupported universal DER primitive");
} }
} }
@@ -677,5 +1015,207 @@ public final class StreamingDerReader {
throw new IOException("Truncated DER OID"); throw new IOException("Truncated DER OID");
} }
} }
private static void readUtf8String(CountedInput input, long length, CancellationSignal cancellation)
throws IOException {
long remaining = length;
while (remaining > 0L) {
cancellation.throwIfCancelled();
int first = input.readRequired();
remaining--;
if (first <= 0x7f) {
continue;
}
int continuationCount;
int codePoint;
int minimum;
if (first >= 0xc2 && first <= 0xdf) {
continuationCount = 1;
codePoint = first & 0x1f;
minimum = 0x80;
} else if (first >= 0xe0 && first <= 0xef) {
continuationCount = 2;
codePoint = first & 0x0f;
minimum = 0x800;
} else if (first >= 0xf0 && first <= 0xf4) {
continuationCount = 3;
codePoint = first & 0x07;
minimum = 0x10000;
} else {
throw new IOException("Malformed DER UTF8String");
}
if (remaining < continuationCount) {
throw new IOException("Truncated DER UTF8String");
}
for (int index = 0; index < continuationCount; index++) {
int octet = input.readRequired();
if ((octet & 0xc0) != 0x80) {
throw new IOException("Malformed DER UTF8String");
}
codePoint = (codePoint << 6) | (octet & 0x3f);
}
remaining -= continuationCount;
if (codePoint < minimum || codePoint > 0x10ffff
|| codePoint >= 0xd800 && codePoint <= 0xdfff) {
throw new IOException("Non-canonical DER UTF8String");
}
}
}
private static void readRestrictedString(CountedInput input, long length, StringKind kind,
CancellationSignal cancellation) throws IOException {
for (long index = 0L; index < length; index++) {
cancellation.throwIfCancelled();
int octet = input.readRequired();
if (!kind.accepts(octet)) {
throw new IOException("Malformed DER " + kind.label());
}
}
}
private static LocalDateTime readTime(CountedInput input, long length, boolean generalized)
throws IOException {
int expectedLength = generalized ? 15 : 13;
if (length != expectedLength) {
throw new IOException("Non-canonical DER time");
}
byte[] value = new byte[expectedLength];
for (int index = 0; index < value.length; index++) {
value[index] = (byte) input.readRequired();
}
if (value[value.length - 1] != 'Z') {
throw new IOException("Non-canonical DER time");
}
int digitCount = value.length - 1;
for (int index = 0; index < digitCount; index++) {
if (value[index] < '0' || value[index] > '9') {
throw new IOException("Malformed DER time");
}
}
int offset;
int year;
if (generalized) {
year = decimal(value, 0, 4);
offset = 4;
} else {
int shortYear = decimal(value, 0, 2);
year = shortYear >= 50 ? 1900 + shortYear : 2000 + shortYear;
offset = 2;
}
try {
return LocalDateTime.of(year, decimal(value, offset, 2), decimal(value, offset + 2, 2),
decimal(value, offset + 4, 2), decimal(value, offset + 6, 2),
decimal(value, offset + 8, 2));
} catch (DateTimeException exception) {
throw new IOException("Malformed DER time", exception);
}
}
private static int decimal(byte[] value, int offset, int length) {
int result = 0;
for (int index = 0; index < length; index++) {
result = result * 10 + value[offset + index] - '0';
}
return result;
}
private static void readUniversalString(CountedInput input, long length, CancellationSignal cancellation)
throws IOException {
if ((length & 3L) != 0L) {
throw new IOException("Malformed DER UniversalString");
}
for (long index = 0L; index < length; index += 4L) {
cancellation.throwIfCancelled();
long codePoint = ((long) input.readRequired() << 24) | ((long) input.readRequired() << 16)
| ((long) input.readRequired() << 8) | input.readRequired();
if (codePoint > 0x10ffffL || codePoint >= 0xd800L && codePoint <= 0xdfffL) {
throw new IOException("Malformed DER UniversalString");
}
}
}
private static void readBmpString(CountedInput input, long length, CancellationSignal cancellation)
throws IOException {
if ((length & 1L) != 0L) {
throw new IOException("Malformed DER BMPString");
}
for (long index = 0L; index < length; index += 2L) {
cancellation.throwIfCancelled();
int codeUnit = (input.readRequired() << 8) | input.readRequired();
if (codeUnit >= 0xd800 && codeUnit <= 0xdfff) {
throw new IOException("Malformed DER BMPString");
}
}
}
/** Supported restricted X.509 character-string alphabets. */
private enum StringKind {
NUMERIC("NumericString") {
@Override
boolean accepts(int octet) {
return octet == ' ' || octet >= '0' && octet <= '9';
}
},
PRINTABLE("PrintableString") {
@Override
boolean accepts(int octet) {
return octet >= 'A' && octet <= 'Z' || octet >= 'a' && octet <= 'z'
|| octet >= '0' && octet <= '9' || " '()+,-./:=?".indexOf(octet) >= 0;
}
},
IA5("IA5String") {
@Override
boolean accepts(int octet) {
return octet <= 0x7f;
}
},
VISIBLE("VisibleString") {
@Override
boolean accepts(int octet) {
return octet >= 0x20 && octet <= 0x7e;
}
};
private final String label;
StringKind(String label) {
this.label = label;
}
abstract boolean accepts(int octet);
private String label() {
return label;
}
}
}
/** Non-copying adapter whose lifetime is confined to one synchronous call. */
private static final class ByteArrayContent implements RepeatableContent {
private final byte[] encoded;
private ByteArrayContent(byte[] encoded) {
this.encoded = encoded;
}
@Override
public InputStream openStream() {
return new ByteArrayInputStream(encoded);
}
@Override
public OptionalLong length() {
return OptionalLong.of(encoded.length);
}
@Override
public String contentId() {
return "bounded-byte-array";
}
@Override
public void close() {
// Caller retains ownership of the bounded array.
}
} }
} }

View File

@@ -35,7 +35,6 @@ package zeroecho.pki.impl.framework.x509.bc;
import java.io.IOException; import java.io.IOException;
import java.util.Objects; import java.util.Objects;
import java.util.Arrays;
import org.bouncycastle.asn1.ASN1Encoding; import org.bouncycastle.asn1.ASN1Encoding;
import org.bouncycastle.asn1.ASN1ObjectIdentifier; import org.bouncycastle.asn1.ASN1ObjectIdentifier;
@@ -140,29 +139,4 @@ public final class BcX509AlgorithmAdapter {
} }
} }
/**
* Requires one complete canonical DER object before a BC structure parser is
* invoked.
*
* @param encoded complete DER object
* @throws IllegalArgumentException for trailing data, BER forms, or
* non-canonical DER
*/
/* default */ static void requireCanonicalDer(byte[] encoded) {
Objects.requireNonNull(encoded, "encoded");
ASN1Primitive primitive;
try {
primitive = ASN1Primitive.fromByteArray(encoded);
} catch (IOException exception) {
throw new IllegalArgumentException("Invalid canonical DER object", exception);
}
try {
byte[] canonical = primitive.getEncoded(ASN1Encoding.DER);
if (!Arrays.equals(encoded, canonical)) {
throw new IllegalArgumentException("Input is not one canonical DER object");
}
} catch (IOException exception) {
throw new IllegalArgumentException("Invalid canonical DER object", exception);
}
}
} }

View File

@@ -33,11 +33,9 @@
******************************************************************************/ ******************************************************************************/
package zeroecho.pki.impl.framework.x509.bc; package zeroecho.pki.impl.framework.x509.bc;
import java.io.ByteArrayInputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Base64;
import java.util.HashSet; import java.util.HashSet;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.List; import java.util.List;
@@ -60,9 +58,6 @@ import org.bouncycastle.asn1.x509.Extensions;
import org.bouncycastle.asn1.x509.GeneralName; import org.bouncycastle.asn1.x509.GeneralName;
import org.bouncycastle.asn1.x509.GeneralNames; import org.bouncycastle.asn1.x509.GeneralNames;
import org.bouncycastle.pkcs.PKCS10CertificationRequest; import org.bouncycastle.pkcs.PKCS10CertificationRequest;
import org.bouncycastle.util.io.pem.PemObject;
import org.bouncycastle.util.io.pem.PemReader;
import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.Encoding; import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.PkiException; import zeroecho.pki.api.PkiException;
@@ -74,6 +69,7 @@ import zeroecho.pki.api.request.CertificationRequest;
import zeroecho.pki.api.request.ParsedCertificationRequest; import zeroecho.pki.api.request.ParsedCertificationRequest;
import zeroecho.pki.api.request.SubjectAlternativeName; import zeroecho.pki.api.request.SubjectAlternativeName;
import zeroecho.pki.api.request.SubjectRdn; import zeroecho.pki.api.request.SubjectRdn;
import zeroecho.pki.impl.framework.x509.StreamingDerReader;
import zeroecho.pki.spi.framework.CertificationRequestParser; import zeroecho.pki.spi.framework.CertificationRequestParser;
/** /**
@@ -133,8 +129,13 @@ import zeroecho.pki.spi.framework.CertificationRequestParser;
public final class BcX509CertificationRequestParser implements CertificationRequestParser { public final class BcX509CertificationRequestParser implements CertificationRequestParser {
private static final int MAXIMUM_SUBJECT_DER_BYTES = 16 * 1024; private static final int MAXIMUM_SUBJECT_DER_BYTES = 16 * 1024;
private static final int MAXIMUM_SAN_DER_BYTES = 32 * 1024; private static final int MAXIMUM_SAN_DER_BYTES = 32 * 1024;
/* default */ static final int MAXIMUM_CSR_DER_BYTES = 1024 * 1024;
private static final int MAXIMUM_CSR_ENCODED_BYTES = 2 * 1024 * 1024;
private static final int MAXIMUM_SUBJECT_RDNS = 32; private static final int MAXIMUM_SUBJECT_RDNS = 32;
private static final int SINGLE_ATTRIBUTE_VALUE = 1; private static final int SINGLE_ATTRIBUTE_VALUE = 1;
private static final String CERTIFICATE_REQUEST = "CERTIFICATE REQUEST";
private static final String NEW_CERTIFICATE_REQUEST = "NEW CERTIFICATE REQUEST";
private static final String CSR_PEM_INVALID = "Invalid PEM: code=CSR_PEM_INVALID";
/** /**
* Parses a PKCS#10 certification request into the normalized PKI request * Parses a PKCS#10 certification request into the normalized PKI request
@@ -179,12 +180,20 @@ public final class BcX509CertificationRequestParser implements CertificationRequ
} }
EncodedObject enc = request.encoded(); EncodedObject enc = request.encoded();
byte[] csrDer = toDer(enc); byte[] csrDer;
try {
csrDer = toDer(enc);
} catch (IllegalArgumentException exception) {
if (enc.encoding() != Encoding.DER && enc.encoding() != Encoding.PEM) {
throw exception;
}
throw new PkiException("Invalid PKCS#10 certification request: code=CSR_MALFORMED");
}
byte[] spki = null; byte[] spki = null;
try { try {
PKCS10CertificationRequest csr; PKCS10CertificationRequest csr;
try { try {
BcX509AlgorithmAdapter.requireCanonicalDer(csrDer); new StreamingDerReader().validate(csrDer, MAXIMUM_CSR_DER_BYTES);
csr = new PKCS10CertificationRequest(csrDer); csr = new PKCS10CertificationRequest(csrDer);
} catch (Exception ex) { } catch (Exception ex) {
throw new PkiException("Invalid PKCS#10 certification request: code=CSR_MALFORMED"); throw new PkiException("Invalid PKCS#10 certification request: code=CSR_MALFORMED");
@@ -364,20 +373,21 @@ public final class BcX509CertificationRequestParser implements CertificationRequ
*/ */
private static byte[] toDer(EncodedObject obj) { private static byte[] toDer(EncodedObject obj) {
Objects.requireNonNull(obj, "obj"); Objects.requireNonNull(obj, "obj");
byte[] encoded = obj.bytes();
if (obj.encoding() == Encoding.DER) {
return requireDecodedLimit(encoded);
}
try {
if (encoded.length > MAXIMUM_CSR_ENCODED_BYTES) {
throw new IllegalArgumentException("CSR encoding exceeds element limit");
}
return switch (obj.encoding()) { return switch (obj.encoding()) {
case DER -> obj.bytes(); case DER -> throw new IllegalStateException("DER handled before PEM conversion");
case PEM -> pemToDer(obj); case PEM -> readPemContentOrThrow(encoded);
default -> throw new IllegalArgumentException("Unsupported CSR encoding: " + obj.encoding()); default -> throw new IllegalArgumentException("Unsupported CSR encoding: " + obj.encoding());
}; };
}
private static byte[] pemToDer(EncodedObject obj) {
byte[] pemBytes = obj.bytes();
try {
return readPemContentOrThrow(pemBytes);
} finally { } finally {
java.util.Arrays.fill(pemBytes, (byte) 0); java.util.Arrays.fill(encoded, (byte) 0);
} }
} }
@@ -385,9 +395,8 @@ public final class BcX509CertificationRequestParser implements CertificationRequ
* Reads the binary content of a PEM object. * Reads the binary content of a PEM object.
* *
* <p> * <p>
* The input is interpreted as US-ASCII text and parsed using {@link PemReader}. * The input is interpreted as US-ASCII and must contain exactly one accepted
* If the PEM container is syntactically valid and contains an object, the raw * PKCS#10 PEM object with whitespace only outside the object.
* decoded content of that object is returned.
* </p> * </p>
* *
* <p> * <p>
@@ -401,20 +410,89 @@ public final class BcX509CertificationRequestParser implements CertificationRequ
* @throws IllegalArgumentException if the PEM input is empty or cannot be * @throws IllegalArgumentException if the PEM input is empty or cannot be
* parsed * parsed
*/ */
@SuppressWarnings("PMD.AvoidThrowingNewInstanceOfSameException")
private static byte[] readPemContentOrThrow(byte[] pemBytes) { private static byte[] readPemContentOrThrow(byte[] pemBytes) {
Objects.requireNonNull(pemBytes, "pemBytes"); Objects.requireNonNull(pemBytes, "pemBytes");
for (byte value : pemBytes) {
try (PemReader reader = new PemReader( if (value < 0) {
new InputStreamReader(new ByteArrayInputStream(pemBytes), StandardCharsets.US_ASCII))) { throw new IllegalArgumentException(CSR_PEM_INVALID);
PemObject pemObject = reader.readPemObject();
if (pemObject == null) {
throw new IllegalArgumentException("Empty PEM");
} }
return pemObject.getContent();
} catch (java.io.IOException ex) {
throw new IllegalArgumentException("Invalid PEM: code=CSR_PEM_INVALID");
} }
String text = new String(pemBytes, java.nio.charset.StandardCharsets.US_ASCII);
int begin = skipWhitespace(text, 0);
String label;
String certificateRequestBegin = "-----BEGIN " + CERTIFICATE_REQUEST + "-----";
String newCertificateRequestBegin = "-----BEGIN " + NEW_CERTIFICATE_REQUEST + "-----";
if (text.startsWith(certificateRequestBegin, begin)) {
label = CERTIFICATE_REQUEST;
} else if (text.startsWith(newCertificateRequestBegin, begin)) {
label = NEW_CERTIFICATE_REQUEST;
} else {
throw new IllegalArgumentException(CSR_PEM_INVALID);
}
int bodyStart = begin + "-----BEGIN ".length() + label.length() + "-----".length();
if (bodyStart >= text.length() || text.charAt(bodyStart) != '\n'
&& (text.charAt(bodyStart) != '\r' || bodyStart + 1 >= text.length()
|| text.charAt(bodyStart + 1) != '\n')) {
throw new IllegalArgumentException(CSR_PEM_INVALID);
}
String endMarker = "-----END " + label + "-----";
int end = text.indexOf(endMarker, bodyStart);
if (end <= bodyStart || text.charAt(end - 1) != '\n'
|| skipWhitespace(text, end + endMarker.length()) != text.length()) {
throw new IllegalArgumentException(CSR_PEM_INVALID);
}
StringBuilder compact = new StringBuilder(end - bodyStart);
for (int index = bodyStart; index < end; index++) {
char value = text.charAt(index);
if (isWhitespace(value)) {
continue;
}
if (isBase64(value) || value == '=') {
compact.append(value);
} else {
throw new IllegalArgumentException(CSR_PEM_INVALID);
}
}
if (compact.isEmpty() || (compact.length() + 3L) / 4L * 3L > MAXIMUM_CSR_DER_BYTES + 2L) {
throw new IllegalArgumentException(CSR_PEM_INVALID);
}
byte[] decoded;
try {
decoded = Base64.getDecoder().decode(compact.toString());
} catch (IllegalArgumentException exception) {
throw new IllegalArgumentException(CSR_PEM_INVALID);
}
if (!Base64.getEncoder().encodeToString(decoded).contentEquals(compact)) {
java.util.Arrays.fill(decoded, (byte) 0);
throw new IllegalArgumentException(CSR_PEM_INVALID);
}
return requireDecodedLimit(decoded);
}
private static byte[] requireDecodedLimit(byte[] decoded) {
if (decoded.length > MAXIMUM_CSR_DER_BYTES) {
java.util.Arrays.fill(decoded, (byte) 0);
throw new IllegalArgumentException("CSR DER exceeds element limit");
}
return decoded;
}
private static int skipWhitespace(String value, int offset) {
int index = offset;
while (index < value.length() && isWhitespace(value.charAt(index))) {
index++;
}
return index;
}
private static boolean isWhitespace(char value) {
return value == ' ' || value == '\t' || value == '\r' || value == '\n';
}
private static boolean isBase64(char value) {
return value >= 'A' && value <= 'Z' || value >= 'a' && value <= 'z'
|| value >= '0' && value <= '9' || value == '+' || value == '/';
} }
/** /**

View File

@@ -41,6 +41,7 @@ import java.security.MessageDigest;
import java.time.Duration; import java.time.Duration;
import java.util.Date; import java.util.Date;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.Optional;
import org.bouncycastle.asn1.ASN1ObjectIdentifier; import org.bouncycastle.asn1.ASN1ObjectIdentifier;
import org.bouncycastle.asn1.DEROctetString; import org.bouncycastle.asn1.DEROctetString;
@@ -57,6 +58,8 @@ import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.X509v3CertificateBuilder; import org.bouncycastle.cert.X509v3CertificateBuilder;
import zeroecho.core.spec.AlgorithmIdentity; import zeroecho.core.spec.AlgorithmIdentity;
import zeroecho.core.io.CancellationSignal;
import zeroecho.core.io.ImmutableByteContent;
import zeroecho.core.io.RepeatableContent; import zeroecho.core.io.RepeatableContent;
import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.Encoding; import zeroecho.pki.api.Encoding;
@@ -78,6 +81,8 @@ import zeroecho.pki.impl.core.ValidatedCaCertificateRequest;
import zeroecho.pki.impl.core.ValidatedCertificateRequest; import zeroecho.pki.impl.core.ValidatedCertificateRequest;
import zeroecho.pki.impl.core.async.PkiSigningBus; import zeroecho.pki.impl.core.async.PkiSigningBus;
import zeroecho.pki.impl.core.attr.SimpleAttributeSet; import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
import zeroecho.pki.impl.framework.x509.StreamingDerReader;
import zeroecho.pki.impl.framework.x509.X509AlgorithmRole;
import zeroecho.pki.spi.framework.CredentialIssuerBackend; import zeroecho.pki.spi.framework.CredentialIssuerBackend;
import zeroecho.pki.spi.store.ContentSink; import zeroecho.pki.spi.store.ContentSink;
@@ -124,9 +129,12 @@ import zeroecho.pki.spi.store.ContentSink;
* </p> * </p>
*/ */
// PMD cannot infer that retaining backend causes would violate the redaction contract. // PMD cannot infer that retaining backend causes would violate the redaction contract.
@SuppressWarnings("PMD.PreserveStackTrace") @SuppressWarnings({ "PMD.PreserveStackTrace", "PMD.CyclomaticComplexity" })
public final class BcX509CredentialIssuerBackend implements CredentialIssuerBackend { public final class BcX509CredentialIssuerBackend implements CredentialIssuerBackend {
private static final int MAXIMUM_CERTIFICATE_BYTES = 1024 * 1024;
private static final int MAXIMUM_SPKI_BYTES = 1024 * 1024;
private final PkiSigningBus signingBus; private final PkiSigningBus signingBus;
private final AlgorithmIdentity signatureIdentity; private final AlgorithmIdentity signatureIdentity;
private final Duration signingTtl; private final Duration signingTtl;
@@ -342,7 +350,7 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
SubjectPublicKeyInfo spki; SubjectPublicKeyInfo spki;
PkiId publicKeyId; PkiId publicKeyId;
try { try {
spki = SubjectPublicKeyInfo.getInstance(subjectSpki); spki = parseSubjectPublicKeyInfo(subjectSpki);
publicKeyId = new PkiId("spki:" + sha256Hex(subjectSpki)); publicKeyId = new PkiId("spki:" + sha256Hex(subjectSpki));
} finally { } finally {
java.util.Arrays.fill(subjectSpki, (byte) 0); java.util.Arrays.fill(subjectSpki, (byte) 0);
@@ -394,6 +402,9 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
} }
private DurableContentReference stageCertificate(byte[] certificate) { private DurableContentReference stageCertificate(byte[] certificate) {
if (certificate.length > MAXIMUM_CERTIFICATE_BYTES) {
throw new PkiException("Certificate exceeds artifact limit: code=CERTIFICATE_TOO_LARGE");
}
try (ContentSink sink = signingBus.beginContent(Encoding.DER, DurableContentReference.Lifecycle.PERSISTED); try (ContentSink sink = signingBus.beginContent(Encoding.DER, DurableContentReference.Lifecycle.PERSISTED);
OutputStream output = sink.outputStream()) { OutputStream output = sink.outputStream()) {
output.write(certificate); output.write(certificate);
@@ -406,7 +417,7 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
private void validateGeneratedCertificate(DurableContentReference reference, PkiBusContentSigner signer) { private void validateGeneratedCertificate(DurableContentReference reference, PkiBusContentSigner signer) {
try (RepeatableContent content = signingBus.openContent(reference)) { try (RepeatableContent content = signingBus.openContent(reference)) {
new BcX509SignedObjectValidator(signingBus.authority()).validateGeneratedCertificate(content, new BcX509SignedObjectValidator(signingBus.authority()).validateGeneratedCertificate(content,
signer.executionPlan(), zeroecho.core.io.CancellationSignal.NONE); signer.executionPlan(), CancellationSignal.NONE);
} catch (IOException | IllegalArgumentException exception) { } catch (IOException | IllegalArgumentException exception) {
signingBus.releaseContent(reference); signingBus.releaseContent(reference);
throw new PkiException("Certificate postcondition failed: code=BACKEND_RESULT_SUBSTITUTION", exception); throw new PkiException("Certificate postcondition failed: code=BACKEND_RESULT_SUBSTITUTION", exception);
@@ -414,10 +425,11 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
} }
private byte[] materialize(DurableContentReference reference) { private byte[] materialize(DurableContentReference reference) {
if (reference.length() > Integer.MAX_VALUE) { long length = reference.length();
throw new PkiException("Certificate exceeds BC adapter element domain: code=ADAPTER_ELEMENT_LIMIT_EXCEEDED"); if (reference.encoding() != Encoding.DER || length <= 0L || length > MAXIMUM_CERTIFICATE_BYTES) {
throw new PkiException("Certificate exceeds artifact limit: code=CERTIFICATE_TOO_LARGE");
} }
byte[] result = new byte[(int) reference.length()]; byte[] result = new byte[(int) length];
try { try {
readExact(reference, result); readExact(reference, result);
return result; return result;
@@ -442,10 +454,12 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
} }
} }
private static X509CertificateHolder parseIssuerCertificateOrThrow(byte[] issuerCertDer) { private X509CertificateHolder parseIssuerCertificateOrThrow(byte[] issuerCertDer) {
try { try {
new BcX509SignedObjectValidator(signingBus.authority()).validateCertificate(
new ImmutableByteContent(issuerCertDer), Optional.empty(), CancellationSignal.NONE);
return new X509CertificateHolder(issuerCertDer); return new X509CertificateHolder(issuerCertDer);
} catch (Exception ex) { } catch (IOException | IllegalArgumentException ex) {
throw new PkiException("Invalid issuer certificate: code=ISSUER_CERTIFICATE_INVALID"); throw new PkiException("Invalid issuer certificate: code=ISSUER_CERTIFICATE_INVALID");
} }
} }
@@ -473,15 +487,27 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
} }
} }
private static SubjectPublicKeyInfo parseSubjectPublicKeyInfo(EncodedObject encoded) { private SubjectPublicKeyInfo parseSubjectPublicKeyInfo(EncodedObject encoded) {
byte[] bytes = encoded.bytes(); byte[] bytes = encoded.bytes();
try { try {
return SubjectPublicKeyInfo.getInstance(bytes); return parseSubjectPublicKeyInfo(bytes);
} finally { } finally {
java.util.Arrays.fill(bytes, (byte) 0); java.util.Arrays.fill(bytes, (byte) 0);
} }
} }
private SubjectPublicKeyInfo parseSubjectPublicKeyInfo(byte[] bytes) {
try {
new StreamingDerReader().validateSubjectPublicKeyInfo(bytes, MAXIMUM_SPKI_BYTES);
SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(bytes);
new BcX509AlgorithmAdapter(signingBus.authority().bindings()).decode(spki.getAlgorithm(),
X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM);
return spki;
} catch (IOException | IllegalArgumentException exception) {
throw new PkiException("Invalid subject public key: code=SUBJECT_KEY_UNSUPPORTED");
}
}
private static String fingerprintEncoded(EncodedObject encoded) { private static String fingerprintEncoded(EncodedObject encoded) {
byte[] bytes = encoded.bytes(); byte[] bytes = encoded.bytes();
try { try {

View File

@@ -49,6 +49,7 @@ import zeroecho.pki.api.request.ProofOfPossessionStatus;
import zeroecho.pki.impl.framework.x509.X509AlgorithmRole; import zeroecho.pki.impl.framework.x509.X509AlgorithmRole;
import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot; import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot;
import zeroecho.pki.impl.framework.x509.X509ExecutionPlan; import zeroecho.pki.impl.framework.x509.X509ExecutionPlan;
import zeroecho.pki.impl.framework.x509.StreamingDerReader;
import zeroecho.pki.spi.framework.ProofOfPossessionVerifier; import zeroecho.pki.spi.framework.ProofOfPossessionVerifier;
/** /**
@@ -199,7 +200,8 @@ public final class BcX509ProofOfPossessionVerifier implements ProofOfPossessionV
try { try {
PKCS10CertificationRequest csr; PKCS10CertificationRequest csr;
try { try {
BcX509AlgorithmAdapter.requireCanonicalDer(csrDer); new StreamingDerReader().validate(csrDer,
BcX509CertificationRequestParser.MAXIMUM_CSR_DER_BYTES);
csr = new PKCS10CertificationRequest(csrDer); csr = new PKCS10CertificationRequest(csrDer);
} catch (Exception ex) { } catch (Exception ex) {
return new ProofOfPossessionResult(ProofOfPossessionStatus.FAILED, Optional.of("Invalid CSR")); return new ProofOfPossessionResult(ProofOfPossessionStatus.FAILED, Optional.of("Invalid CSR"));

View File

@@ -36,16 +36,19 @@ package zeroecho.pki.impl.framework.x509.bc;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.security.GeneralSecurityException; import java.security.GeneralSecurityException;
import java.time.Instant;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import org.bouncycastle.asn1.ASN1Primitive; import org.bouncycastle.asn1.ASN1Primitive;
import org.bouncycastle.asn1.x509.AlgorithmIdentifier; import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.operator.OperatorCreationException; import org.bouncycastle.operator.OperatorCreationException;
import zeroecho.core.io.CancellationSignal; import zeroecho.core.io.CancellationSignal;
import zeroecho.core.io.ContentSlice; import zeroecho.core.io.ContentSlice;
import zeroecho.core.io.ImmutableByteContent;
import zeroecho.core.io.RepeatableContent; import zeroecho.core.io.RepeatableContent;
import zeroecho.core.spec.AlgorithmIdentity; import zeroecho.core.spec.AlgorithmIdentity;
import zeroecho.core.spi.AlgorithmExecutionCapability; import zeroecho.core.spi.AlgorithmExecutionCapability;
@@ -71,6 +74,8 @@ import zeroecho.pki.spi.crypto.SignatureWorkflow;
public final class BcX509SignedObjectValidator { public final class BcX509SignedObjectValidator {
private static final int COMPARE_BUFFER_BYTES = 4096; private static final int COMPARE_BUFFER_BYTES = 4096;
private static final int MAXIMUM_CERTIFICATE_BYTES = 1024 * 1024;
private static final int MAXIMUM_ELEMENT_BYTES = 1024 * 1024;
private final X509AuthoritySnapshot authority; private final X509AuthoritySnapshot authority;
private final StreamingDerReader reader; private final StreamingDerReader reader;
@@ -100,6 +105,10 @@ public final class BcX509SignedObjectValidator {
Objects.requireNonNull(content, "content"); Objects.requireNonNull(content, "content");
Objects.requireNonNull(expectedSignature, "expectedSignature"); Objects.requireNonNull(expectedSignature, "expectedSignature");
Objects.requireNonNull(cancellation, "cancellation"); Objects.requireNonNull(cancellation, "cancellation");
long contentLength = content.length().orElse(-1L);
if (contentLength <= 0L || contentLength > MAXIMUM_CERTIFICATE_BYTES) {
throw new IOException("Certificate exceeds adapter domain: code=ADAPTER_ELEMENT_LIMIT_EXCEEDED");
}
StreamingDerReader.SignedObjectLayout layout = reader.inspectSignedObject(content, StreamingDerReader.SignedObjectLayout layout = reader.inspectSignedObject(content,
StreamingDerReader.SignedObjectKind.CERTIFICATE, cancellation); StreamingDerReader.SignedObjectKind.CERTIFICATE, cancellation);
ContentSlice tbsAlgorithm = new ContentSlice(content, layout.tbsAlgorithmOffset(), ContentSlice tbsAlgorithm = new ContentSlice(content, layout.tbsAlgorithmOffset(),
@@ -141,25 +150,43 @@ public final class BcX509SignedObjectValidator {
/** /**
* Validates a generated CRL against its exact live signing plan and issuer * Validates a generated CRL against its exact live signing plan and issuer
* public key. * certificate.
* *
* @param content original complete CRL DER * @param content original complete CRL DER
* @param plan exact non-forgeable SIGN plan used for generation * @param plan exact non-forgeable SIGN plan used for generation
* @param issuerPublicKey authorized issuer SPKI * @param issuerCertificate authorized issuer certificate whose exact subject
* Name and SPKI must bind the CRL
* @param expectedThisUpdate exact {@code thisUpdate} metadata to bind to the
* signed TBSCertList
* @param expectedNextUpdate exact optional {@code nextUpdate} metadata to bind
* to the signed TBSCertList
* @param cancellation operation cancellation signal * @param cancellation operation cancellation signal
* @throws IOException if canonical DER, binding, suite, or signature * @throws IOException if canonical DER, binding, suite, or signature
* validation fails * validation fails
* @throws IllegalArgumentException if the plan belongs to another runtime * @throws IllegalArgumentException if the plan belongs to another runtime
*/ */
public void validateGeneratedCrl(RepeatableContent content, X509ExecutionPlan<SignatureWorkflow> plan, public void validateGeneratedCrl(RepeatableContent content, X509ExecutionPlan<SignatureWorkflow> plan,
SubjectPublicKeyInfo issuerPublicKey, CancellationSignal cancellation) throws IOException { X509CertificateHolder issuerCertificate, Instant expectedThisUpdate,
Optional<Instant> expectedNextUpdate, CancellationSignal cancellation) throws IOException {
Objects.requireNonNull(content, "content"); Objects.requireNonNull(content, "content");
Objects.requireNonNull(plan, "plan"); Objects.requireNonNull(plan, "plan");
Objects.requireNonNull(issuerPublicKey, "issuerPublicKey"); Objects.requireNonNull(issuerCertificate, "issuerCertificate");
Objects.requireNonNull(expectedThisUpdate, "expectedThisUpdate");
Objects.requireNonNull(expectedNextUpdate, "expectedNextUpdate");
Objects.requireNonNull(cancellation, "cancellation"); Objects.requireNonNull(cancellation, "cancellation");
authority.authorize(plan, plan.executor(), AlgorithmExecutionCapability.Direction.SIGN); authority.authorize(plan, plan.executor(), AlgorithmExecutionCapability.Direction.SIGN);
StreamingDerReader.SignedObjectLayout layout = reader.inspectSignedObject(content, StreamingDerReader.SignedObjectLayout layout = reader.inspectSignedObject(content,
StreamingDerReader.SignedObjectKind.CRL, cancellation); StreamingDerReader.SignedObjectKind.CRL, cancellation);
if (!layout.thisUpdate().equals(Optional.of(expectedThisUpdate))
|| !layout.nextUpdate().equals(expectedNextUpdate)) {
throw new IOException("CRL update times differ from metadata: code=STATUS_OBJECT_BINDING_MISMATCH");
}
ContentSlice issuer = new ContentSlice(content, layout.issuerOffset(), layout.issuerLength());
try (RepeatableContent expectedIssuer = new ImmutableByteContent(issuerCertificate.getSubject().getEncoded())) {
if (!equalContent(issuer, expectedIssuer, cancellation)) {
throw new IOException("CRL issuer differs from issuer certificate: code=STATUS_OBJECT_BINDING_MISMATCH");
}
}
ContentSlice tbsAlgorithm = new ContentSlice(content, layout.tbsAlgorithmOffset(), ContentSlice tbsAlgorithm = new ContentSlice(content, layout.tbsAlgorithmOffset(),
layout.tbsAlgorithmLength()); layout.tbsAlgorithmLength());
ContentSlice outerAlgorithm = new ContentSlice(content, layout.outerAlgorithmOffset(), ContentSlice outerAlgorithm = new ContentSlice(content, layout.outerAlgorithmOffset(),
@@ -172,6 +199,7 @@ public final class BcX509SignedObjectValidator {
if (!plan.selection().requested().equals(signatureIdentity)) { if (!plan.selection().requested().equals(signatureIdentity)) {
throw new IOException("CRL signature differs from execution plan: code=STATUS_OBJECT_BINDING_MISMATCH"); throw new IOException("CRL signature differs from execution plan: code=STATUS_OBJECT_BINDING_MISMATCH");
} }
SubjectPublicKeyInfo issuerPublicKey = issuerCertificate.getSubjectPublicKeyInfo();
AlgorithmIdentity issuerKey = new BcX509AlgorithmAdapter(authority.bindings()).decode( AlgorithmIdentity issuerKey = new BcX509AlgorithmAdapter(authority.bindings()).decode(
issuerPublicKey.getAlgorithm(), X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM); issuerPublicKey.getAlgorithm(), X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM);
X509SuiteCompatibility.requireCompatible(signatureIdentity, issuerKey); X509SuiteCompatibility.requireCompatible(signatureIdentity, issuerKey);
@@ -231,8 +259,8 @@ public final class BcX509SignedObjectValidator {
} }
private static byte[] materializeElement(RepeatableContent content) throws IOException { private static byte[] materializeElement(RepeatableContent content) throws IOException {
long length = content.length().orElseThrow(); long length = content.length().orElse(-1L);
if (length > Integer.MAX_VALUE) { if (length <= 0L || length > MAXIMUM_ELEMENT_BYTES) {
throw new IOException("X.509 element exceeds adapter domain: code=ADAPTER_ELEMENT_LIMIT_EXCEEDED"); throw new IOException("X.509 element exceeds adapter domain: code=ADAPTER_ELEMENT_LIMIT_EXCEEDED");
} }
byte[] encoded = new byte[(int) length]; byte[] encoded = new byte[(int) length];

View File

@@ -57,6 +57,7 @@ import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils;
import zeroecho.core.io.CancellationSignal; import zeroecho.core.io.CancellationSignal;
import zeroecho.core.io.ContentSlice; import zeroecho.core.io.ContentSlice;
import zeroecho.core.io.ImmutableByteContent;
import zeroecho.core.io.RepeatableContent; import zeroecho.core.io.RepeatableContent;
import zeroecho.core.spec.AlgorithmIdentity; import zeroecho.core.spec.AlgorithmIdentity;
import zeroecho.core.spi.AlgorithmExecutionCapability; import zeroecho.core.spi.AlgorithmExecutionCapability;
@@ -161,6 +162,7 @@ import zeroecho.pki.spi.store.TemporaryUniqueIndex;
* </p> * </p>
*/ */
public final class BcX509StatusObjectGenerator implements StatusObjectGenerator { public final class BcX509StatusObjectGenerator implements StatusObjectGenerator {
private static final int MAXIMUM_CERTIFICATE_BYTES = 1024 * 1024;
private static final long EMPTY_CONTENT_LENGTH = 0L; private static final long EMPTY_CONTENT_LENGTH = 0L;
@@ -252,7 +254,7 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator
} }
IssuerMaterial issuerMaterial = extractIssuerMaterialOrThrow(command.attributes()); IssuerMaterial issuerMaterial = extractIssuerMaterialOrThrow(command.attributes());
Instant thisUpdate = Instant.now(); Instant thisUpdate = Instant.now().truncatedTo(ChronoUnit.SECONDS);
Instant nextUpdate = thisUpdate.plus(Duration.ofDays(7)); Instant nextUpdate = thisUpdate.plus(Duration.ofDays(7));
DurableContentReference entries = null; DurableContentReference entries = null;
DurableContentReference tbs = null; DurableContentReference tbs = null;
@@ -514,7 +516,8 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator
* unexpected type, or contain an invalid issuer * unexpected type, or contain an invalid issuer
* certificate * certificate
*/ */
private static IssuerMaterial extractIssuerMaterialOrThrow(AttributeSet attributes) { @SuppressWarnings("PMD.PreserveStackTrace")
private IssuerMaterial extractIssuerMaterialOrThrow(AttributeSet attributes) {
Optional<AttributeValue> issuerCertAttr = attributes.get(BcX509Attributes.ISSUER_CERT_DER); Optional<AttributeValue> issuerCertAttr = attributes.get(BcX509Attributes.ISSUER_CERT_DER);
Optional<AttributeValue> issuerKeyAttr = attributes.get(BcX509Attributes.ISSUER_KEYREF); Optional<AttributeValue> issuerKeyAttr = attributes.get(BcX509Attributes.ISSUER_KEYREF);
@@ -525,14 +528,21 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator
throw new PkiException("Missing issuer keyRef"); throw new PkiException("Missing issuer keyRef");
} }
byte[] issuerDer = ((AttributeValue.BytesValue) issuerCertAttr.get()).value(); byte[] issuerDer = ((AttributeValue.BytesValue) issuerCertAttr.get()).value().clone();
if (issuerDer.length > MAXIMUM_CERTIFICATE_BYTES) {
throw new PkiException("Invalid issuer certificate");
}
KeyRef keyRef = new KeyRef(((AttributeValue.StringValue) issuerKeyAttr.get()).value()); KeyRef keyRef = new KeyRef(((AttributeValue.StringValue) issuerKeyAttr.get()).value());
X509CertificateHolder issuerHolder; X509CertificateHolder issuerHolder;
try { try {
new BcX509SignedObjectValidator(signingBus.authority()).validateCertificate(
new ImmutableByteContent(issuerDer), Optional.empty(), CancellationSignal.NONE);
issuerHolder = new X509CertificateHolder(issuerDer); issuerHolder = new X509CertificateHolder(issuerDer);
} catch (Exception ex) { } catch (IOException | IllegalArgumentException ex) {
throw new PkiException("Invalid issuer certificate", ex); throw new PkiException("Invalid issuer certificate");
} finally {
Arrays.fill(issuerDer, (byte) 0);
} }
return new IssuerMaterial(issuerHolder, keyRef); return new IssuerMaterial(issuerHolder, keyRef);

View File

@@ -57,6 +57,7 @@ import zeroecho.pki.api.request.ProofOfPossessionStatus;
import zeroecho.pki.impl.framework.x509.X509AlgorithmRole; import zeroecho.pki.impl.framework.x509.X509AlgorithmRole;
import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot; import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot;
import zeroecho.pki.impl.framework.x509.X509ExecutionPlan; import zeroecho.pki.impl.framework.x509.X509ExecutionPlan;
import zeroecho.pki.impl.framework.x509.StreamingDerReader;
import zeroecho.pki.spi.crypto.SignatureWorkflow; import zeroecho.pki.spi.crypto.SignatureWorkflow;
import zeroecho.core.io.CancellationSignal; import zeroecho.core.io.CancellationSignal;
import zeroecho.core.io.ImmutableByteContent; import zeroecho.core.io.ImmutableByteContent;
@@ -267,7 +268,8 @@ public final class WorkflowProofOfPossessionVerifier implements ProofOfPossessio
*/ */
private static Optional<PKCS10CertificationRequest> parseCsr(byte[] csrDer) { private static Optional<PKCS10CertificationRequest> parseCsr(byte[] csrDer) {
try { try {
BcX509AlgorithmAdapter.requireCanonicalDer(csrDer); new StreamingDerReader().validate(csrDer,
BcX509CertificationRequestParser.MAXIMUM_CSR_DER_BYTES);
return Optional.of(new PKCS10CertificationRequest(csrDer)); return Optional.of(new PKCS10CertificationRequest(csrDer));
} catch (IOException | IllegalArgumentException ex) { } catch (IOException | IllegalArgumentException ex) {
return Optional.empty(); return Optional.empty();

View File

@@ -0,0 +1,84 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the conditions in the project license are met.
*******************************************************************************/
package zeroecho.pki.impl.core;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.content.DurableContentReference;
final class CredentialContentBoundaryTest {
@Test
void certificateLimitPrecedesStagingValidationAndMaterialization() {
System.out.println("certificateLimitPrecedesStagingValidationAndMaterialization");
byte[] oversized = new byte[CredentialContent.MAXIMUM_CERTIFICATE_BYTES + 1];
DurableContentReference oversizedReference = reference(oversized.length);
DurableContentReference negativeLengthReference = reference(-1L);
DurableContentReference zeroLengthReference = reference(0L);
DurableContentReference nonDerReference = reference(1L, Encoding.PEM);
assertThrows(PkiException.class, () -> CredentialContent.stage(null, oversized));
assertThrows(PkiException.class, () -> CredentialContent.materializeForBc(null, oversizedReference));
assertThrows(PkiException.class,
() -> CredentialContent.validateCertificate(null, oversizedReference, null, Optional.empty()));
assertThrows(PkiException.class, () -> CredentialContent.materializeForBc(null, negativeLengthReference));
assertThrows(PkiException.class,
() -> CredentialContent.validateCertificate(null, negativeLengthReference, null, Optional.empty()));
assertThrows(PkiException.class, () -> CredentialContent.materializeForBc(null, zeroLengthReference));
assertThrows(PkiException.class,
() -> CredentialContent.validateCertificate(null, zeroLengthReference, null, Optional.empty()));
assertThrows(PkiException.class, () -> CredentialContent.materializeForBc(null, nonDerReference));
assertThrows(PkiException.class,
() -> CredentialContent.validateCertificate(null, nonDerReference, null, Optional.empty()));
System.out.println("...rejected=staging, validation, materialization, invalid length and encoding");
System.out.println("certificateLimitPrecedesStagingValidationAndMaterialization...ok");
}
private static DurableContentReference reference(long length) {
return reference(length, Encoding.DER);
}
private static DurableContentReference reference(long length, Encoding encoding) {
return new DurableContentReference() {
@Override
public String storeId() {
return "test:store";
}
@Override
public String contentId() {
return "test:oversized";
}
@Override
public Encoding encoding() {
return encoding;
}
@Override
public long length() {
return length;
}
@Override
public String sha256() {
return "0".repeat(64);
}
@Override
public Lifecycle lifecycle() {
return Lifecycle.PERSISTED;
}
};
}
}

View File

@@ -57,6 +57,7 @@ import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import java.util.function.UnaryOperator;
import org.bouncycastle.asn1.x500.X500Name; import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x509.CRLReason; import org.bouncycastle.asn1.x509.CRLReason;
@@ -250,6 +251,23 @@ final class DefaultStatusObjectServiceCrlTest {
EVALUATION_TIME.minusSeconds(1), Optional.empty())), EVALUATION_TIME.minusSeconds(1), Optional.empty())),
Map.of(malformed.credentialId(), malformed), false); Map.of(malformed.credentialId(), malformed), false);
byte[] certificateWithTrailing = Arrays.copyOf(runtime.credentialBytes(template),
runtime.credentialBytes(template).length + 1);
Credential trailing = copy(template, "trailing", BcX509CredentialFramework.FORMAT_ID,
runtime.stageCredential(certificateWithTrailing));
assertCrlFailure(runtime, command,
List.of(record(trailing.credentialId(), RevocationState.HELD,
EVALUATION_TIME.minusSeconds(1), Optional.empty())),
Map.of(trailing.credentialId(), trailing), false);
byte[] csrInsteadOfCertificate = certificationRequest(generateRsa(), "Type Confusion").getEncoded();
Credential typeConfused = copy(template, "type-confused", BcX509CredentialFramework.FORMAT_ID,
runtime.stageCredential(csrInsteadOfCertificate));
assertCrlFailure(runtime, command,
List.of(record(typeConfused.credentialId(), RevocationState.HELD,
EVALUATION_TIME.minusSeconds(1), Optional.empty())),
Map.of(typeConfused.credentialId(), typeConfused), false);
Credential duplicateOne = copy(template, "duplicate-one", BcX509CredentialFramework.FORMAT_ID, Credential duplicateOne = copy(template, "duplicate-one", BcX509CredentialFramework.FORMAT_ID,
template.content()); template.content());
Credential duplicateTwo = copy(template, "duplicate-two", BcX509CredentialFramework.FORMAT_ID, Credential duplicateTwo = copy(template, "duplicate-two", BcX509CredentialFramework.FORMAT_ID,
@@ -318,6 +336,93 @@ final class DefaultStatusObjectServiceCrlTest {
} }
} }
@Test
void maliciousCompletionMetadataAbortsBeforePersistence(@TempDir Path root) throws Exception {
System.out.println("maliciousCompletionMetadataAbortsBeforePersistence");
KeyPair rootKey = generateRsa();
KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:crl-metadata");
try (PkiTestRuntime runtime = PkiTestRuntime.create(root, root.resolve("bus.log"),
Map.of(rootKeyRef, rootKey))) {
PkiId caId = createRoot(runtime, rootKeyRef, "CRL Metadata Root");
StatusObjectGenerateCommand command = new StatusObjectGenerateCommand(caId, StatusObjectType.CRL,
runtime.framework().formatId(), emptyAttributes());
List<UnaryOperator<StatusObject>> mutations = List.of(
status -> copyStatus(status, new FormatId("wrong-format"), status.issuerCaId(), status.type(),
status.thisUpdate(), status.nextUpdate()),
status -> copyStatus(status, status.formatId(), new PkiId("ca:wrong-issuer"), status.type(),
status.thisUpdate(), status.nextUpdate()),
status -> copyStatus(status, status.formatId(), status.issuerCaId(), StatusObjectType.DELTA_CRL,
status.thisUpdate(), status.nextUpdate()),
status -> copyStatus(status, status.formatId(), status.issuerCaId(), status.type(),
status.thisUpdate(), Optional.of(status.thisUpdate())),
status -> copyStatus(status, status.formatId(), status.issuerCaId(), status.type(),
status.thisUpdate().plusSeconds(1L), status.nextUpdate()),
status -> copyStatus(status, status.formatId(), status.issuerCaId(), status.type(),
status.thisUpdate(), Optional.empty()),
status -> copyStatus(status, status.formatId(), status.issuerCaId(), status.type(),
status.thisUpdate(), status.nextUpdate().map(next -> next.plusSeconds(1L))));
int signCount = runtime.submittedSignCount();
int statusCount = runtime.store().listStatusObjects(caId).size();
AtomicInteger persistenceCalls = new AtomicInteger();
for (UnaryOperator<StatusObject> mutation : mutations) {
StatusObjectGenerator delegate = runtime.framework().statusObjectGenerator();
StatusObjectGenerator malicious = (wired, entries) -> {
X509SignedObjectCompletion completion = delegate.generate(wired, entries);
StatusObject modified = mutation.apply(
runtime.signingBus().authority().requireStatusCompletion(completion));
return runtime.signingBus().authority().completeStatusObject(modified,
runtime.signingBus().authority().requireStatusSigningPlan(completion));
};
DefaultStatusObjectService service = new DefaultStatusObjectService(
failingPersistenceStore(runtime.store(), persistenceCalls),
frameworkView(runtime.framework(), malicious), runtime.auditSink(), usableResolver(),
runtime.signingBus().authority());
assertBoundaryFailure(assertThrows(PkiException.class, () -> service.generate(command)));
}
assertEquals(0, persistenceCalls.get());
assertEquals(statusCount, runtime.store().listStatusObjects(caId).size());
assertEquals(signCount + mutations.size(), runtime.submittedSignCount());
System.out.println("...rejected=type, format, issuer, time order, changed/omitted/later signed times");
}
System.out.println("maliciousCompletionMetadataAbortsBeforePersistence...ok");
}
@Test
void generatedCrlIssuerNameMustMatchSelectedIssuerCertificate(@TempDir Path root) throws Exception {
System.out.println("generatedCrlIssuerNameMustMatchSelectedIssuerCertificate");
KeyPair expectedKey = generateRsa();
KeyPair substitutedKey = generateRsa();
KeyRef expectedKeyRef = new KeyRef("kref:v1:keyring:test:crl-expected-issuer");
KeyRef substitutedKeyRef = new KeyRef("kref:v1:keyring:test:crl-substituted-issuer");
try (PkiTestRuntime runtime = PkiTestRuntime.create(root, root.resolve("bus.log"),
Map.of(expectedKeyRef, expectedKey, substitutedKeyRef, substitutedKey))) {
PkiId expectedCaId = createRoot(runtime, expectedKeyRef, "Expected CRL Issuer");
PkiId substitutedCaId = createRoot(runtime, substitutedKeyRef, "Substituted CRL Issuer");
Credential substitutedIssuer = runtime.caCredential(runtime.caService().getCa(substitutedCaId), 0);
StatusObjectGenerateCommand command = new StatusObjectGenerateCommand(expectedCaId,
StatusObjectType.CRL, runtime.framework().formatId(), emptyAttributes());
StatusObjectGenerateCommand substituted = crlCommand(runtime, expectedCaId, substitutedIssuer,
substitutedKeyRef);
StatusObjectGenerator delegate = runtime.framework().statusObjectGenerator();
StatusObjectGenerator malicious = (wired, entries) -> delegate.generate(
new StatusObjectGenerateCommand(wired.issuerCaId(), wired.type(), wired.formatId(),
substituted.attributes()), entries);
AtomicInteger persistenceCalls = new AtomicInteger();
DefaultStatusObjectService service = new DefaultStatusObjectService(
failingPersistenceStore(runtime.store(), persistenceCalls),
frameworkView(runtime.framework(), malicious), runtime.auditSink(), usableResolver(),
runtime.signingBus().authority());
assertBoundaryFailure(assertThrows(PkiException.class, () -> service.generate(command)));
assertEquals(0, persistenceCalls.get());
assertTrue(runtime.store().listStatusObjects(expectedCaId).isEmpty());
System.out.println("...rejected=exact issuer Name mismatch");
}
System.out.println("generatedCrlIssuerNameMustMatchSelectedIssuerCertificate...ok");
}
private static void assertCrlFailure(PkiTestRuntime runtime, StatusObjectGenerateCommand command, private static void assertCrlFailure(PkiTestRuntime runtime, StatusObjectGenerateCommand command,
List<RevocationRecord> records, Map<PkiId, Credential> credentials, boolean failListing) { List<RevocationRecord> records, Map<PkiId, Credential> credentials, boolean failListing) {
int signCount = runtime.submittedSignCount(); int signCount = runtime.submittedSignCount();
@@ -486,6 +591,12 @@ final class DefaultStatusObjectServiceCrlTest {
new CaProfileBinding(binding.reference()), CredentialStatus.ISSUED, content, template.attributes()); new CaProfileBinding(binding.reference()), CredentialStatus.ISSUED, content, template.attributes());
} }
private static StatusObject copyStatus(StatusObject source, FormatId formatId, PkiId issuerCaId,
StatusObjectType type, Instant thisUpdate, Optional<Instant> nextUpdate) {
return new StatusObject(source.statusObjectId(), formatId, issuerCaId, type, thisUpdate, nextUpdate,
source.content(), source.attributes());
}
private static StatusObjectGenerateCommand crlCommand(PkiTestRuntime runtime, PkiId caId, Credential issuer, private static StatusObjectGenerateCommand crlCommand(PkiTestRuntime runtime, PkiId caId, Credential issuer,
KeyRef keyRef) throws Exception { KeyRef keyRef) throws Exception {
AttributeSet attributes = SimpleAttributeSet.builder() AttributeSet attributes = SimpleAttributeSet.builder()

View File

@@ -60,6 +60,7 @@ import java.util.Set;
import org.bouncycastle.asn1.ASN1Encodable; import org.bouncycastle.asn1.ASN1Encodable;
import org.bouncycastle.asn1.ASN1ObjectIdentifier; import org.bouncycastle.asn1.ASN1ObjectIdentifier;
import org.bouncycastle.asn1.DERBMPString; import org.bouncycastle.asn1.DERBMPString;
import org.bouncycastle.asn1.DERBitString;
import org.bouncycastle.asn1.DERNull; import org.bouncycastle.asn1.DERNull;
import org.bouncycastle.asn1.DEROctetString; import org.bouncycastle.asn1.DEROctetString;
import org.bouncycastle.asn1.DERSequence; import org.bouncycastle.asn1.DERSequence;
@@ -250,6 +251,14 @@ final class H7ProfileEnforcementTest {
ed.getPublicKeyData().getBytes()); ed.getPublicKeyData().getBytes());
assertCode("SUBJECT_KEY_PARAMETERS_UNSUPPORTED", assertCode("SUBJECT_KEY_PARAMETERS_UNSUPPORTED",
() -> validate(edWithNull.getEncoded(), "Ed25519", policyWithFixedOrganization("Ed25519"))); () -> validate(edWithNull.getEncoded(), "Ed25519", policyWithFixedOrganization("Ed25519")));
byte[] trailing = java.util.Arrays.copyOf(original.getEncoded(), original.getEncoded().length + 1);
assertCode("SUBJECT_KEY_UNSUPPORTED",
() -> validate(trailing, "RSA", policyWithFixedOrganization("RSA")));
DERSequence padded = new DERSequence(new ASN1Encodable[] { original.getAlgorithm(),
new DERBitString(original.getPublicKeyData().getBytes(), 1) });
assertCode("SUBJECT_KEY_UNSUPPORTED",
() -> validate(padded.getEncoded(), "RSA", policyWithFixedOrganization("RSA")));
} }
@Test @Test

View File

@@ -33,6 +33,7 @@
******************************************************************************/ ******************************************************************************/
package zeroecho.pki.impl.framework.x509; package zeroecho.pki.impl.framework.x509;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -55,11 +56,11 @@ import zeroecho.pki.impl.framework.x509.StreamingDerReader.SignedObjectLayout;
final class StreamingDerReaderTest { final class StreamingDerReaderTest {
private static final byte[] CRL = { private static final byte[] CRL = {
0x30, 0x32,
0x30, 0x26, 0x30, 0x26,
0x30, 0x1a,
0x02, 0x01, 0x01, 0x02, 0x01, 0x01,
0x30, 0x04, 0x06, 0x02, 0x2a, 0x03, 0x30, 0x04, 0x06, 0x02, 0x2a, 0x03,
0x30, 0x00, 0x30, 0x0c, 0x31, 0x0a, 0x30, 0x08, 0x06, 0x03, 0x55, 0x04, 0x03, 0x0c, 0x01, 0x49,
0x17, 0x0d, 0x32, 0x36, 0x30, 0x31, 0x30, 0x31, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x5a, 0x17, 0x0d, 0x32, 0x36, 0x30, 0x31, 0x30, 0x31, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x5a,
0x30, 0x04, 0x06, 0x02, 0x2a, 0x03, 0x30, 0x04, 0x06, 0x02, 0x2a, 0x03,
0x03, 0x02, 0x00, (byte) 0xaa 0x03, 0x02, 0x00, (byte) 0xaa
@@ -74,16 +75,87 @@ final class StreamingDerReaderTest {
CancellationSignal.NONE); CancellationSignal.NONE);
System.out.println("...encodedLength=" + reader.validate(content, CancellationSignal.NONE)); System.out.println("...encodedLength=" + reader.validate(content, CancellationSignal.NONE));
assertEquals(2L, layout.tbsOffset()); assertEquals(2L, layout.tbsOffset());
assertEquals(28L, layout.tbsLength()); assertEquals(40L, layout.tbsLength());
assertEquals(7L, layout.tbsAlgorithmOffset()); assertEquals(7L, layout.tbsAlgorithmOffset());
assertEquals(6L, layout.tbsAlgorithmLength()); assertEquals(6L, layout.tbsAlgorithmLength());
assertEquals(30L, layout.outerAlgorithmOffset()); assertEquals(13L, layout.issuerOffset());
assertEquals(14L, layout.issuerLength());
assertEquals(42L, layout.outerAlgorithmOffset());
assertEquals(6L, layout.outerAlgorithmLength()); assertEquals(6L, layout.outerAlgorithmLength());
assertEquals(39L, layout.signatureOffset()); assertEquals(51L, layout.signatureOffset());
assertEquals(1L, layout.signatureLength()); assertEquals(1L, layout.signatureLength());
assertEquals(java.util.Optional.of(java.time.Instant.parse("2026-01-01T00:00:00Z")),
layout.thisUpdate());
assertEquals(java.util.Optional.empty(), layout.nextUpdate());
System.out.println("validatesCanonicalCrlAndLocatesSignedFields...ok"); System.out.println("validatesCanonicalCrlAndLocatesSignedFields...ok");
} }
@Test
void crlInspectionRejectsStructurallyValidV1Certificate() {
System.out.println("crlInspectionRejectsStructurallyValidV1Certificate");
StreamingDerReader reader = new StreamingDerReader();
byte[] validity = sequence(utcTime("250101000000Z"), utcTime("270101000000Z"));
byte[] subjectPublicKeyInfo = sequence(algorithm(), tlv(3, new byte[] { 0, 1 }));
byte[] certificate = signedObject(sequence(integer(1), algorithm(), issuer(), validity, issuer(),
subjectPublicKeyInfo));
ImmutableByteContent content = new ImmutableByteContent(certificate);
SignedObjectLayout certificateLayout = assertDoesNotThrow(() -> reader.inspectSignedObject(content,
SignedObjectKind.CERTIFICATE, CancellationSignal.NONE));
assertEquals(java.util.Optional.empty(), certificateLayout.thisUpdate());
assertEquals(java.util.Optional.empty(), certificateLayout.nextUpdate());
assertThrows(IOException.class, () -> reader.inspectSignedObject(content, SignedObjectKind.CRL,
CancellationSignal.NONE));
System.out.println("...rejected=certificate TBS is not TBSCertList");
System.out.println("crlInspectionRejectsStructurallyValidV1Certificate...ok");
}
@Test
void crlInspectionReturnsExactOptionalNextUpdate() throws Exception {
System.out.println("crlInspectionReturnsExactOptionalNextUpdate");
byte[] encoded = signedObject(sequence(integer(1), algorithm(), issuer(), utcTime("260803120000Z"),
utcTime("260810120000Z")));
SignedObjectLayout layout = new StreamingDerReader().inspectSignedObject(
new ImmutableByteContent(encoded), SignedObjectKind.CRL, CancellationSignal.NONE);
assertEquals(java.util.Optional.of(java.time.Instant.parse("2026-08-03T12:00:00Z")),
layout.thisUpdate());
assertEquals(java.util.Optional.of(java.time.Instant.parse("2026-08-10T12:00:00Z")),
layout.nextUpdate());
System.out.println("...thisUpdate=" + layout.thisUpdate().orElseThrow()
+ " nextUpdate=" + layout.nextUpdate().orElseThrow());
System.out.println("crlInspectionReturnsExactOptionalNextUpdate...ok");
}
@Test
void crlInspectionRejectsMalformedOptionalTimesEntriesAndExtensions() {
System.out.println("crlInspectionRejectsMalformedOptionalTimesEntriesAndExtensions");
StreamingDerReader reader = new StreamingDerReader();
byte[] thisUpdate = utcTime("260101000000Z");
byte[] earlier = utcTime("250101000000Z");
byte[] zeroSerialEntry = sequence(integer(0), thisUpdate);
byte[] oversizedSerial = new byte[21];
oversizedSerial[0] = 1;
byte[] oversizedSerialEntry = sequence(tlv(2, oversizedSerial), thisUpdate);
byte[] malformedEntry = sequence(integer(1), thisUpdate, tlv(4, new byte[] { 1 }));
byte[] falseCritical = explicitExtensions(sequence(sequence(oid(), tlv(1, new byte[] { 0 }),
tlv(4, new byte[] { 1 }))));
byte[] extensionOnV1 = explicitExtensions(sequence(sequence(oid(), tlv(4, new byte[] { 1 }))));
assertCrlRejected(reader, integer(0), algorithm(), issuer(), thisUpdate);
assertCrlRejected(reader, integer(1), algorithm(), sequence(), thisUpdate);
assertCrlRejected(reader, integer(1), algorithm(), issuer(), thisUpdate, earlier);
assertCrlRejected(reader, integer(1), algorithm(), issuer(), thisUpdate, thisUpdate);
assertCrlRejected(reader, integer(1), algorithm(), issuer(), thisUpdate, sequence(zeroSerialEntry));
assertCrlRejected(reader, integer(1), algorithm(), issuer(), thisUpdate, sequence(oversizedSerialEntry));
assertCrlRejected(reader, integer(1), algorithm(), issuer(), thisUpdate, sequence(malformedEntry));
assertCrlRejected(reader, integer(1), algorithm(), issuer(), thisUpdate, falseCritical);
assertCrlRejected(reader, algorithm(), issuer(), thisUpdate, extensionOnV1);
System.out.println("...rejected=version, issuer, time order/equality, serial, entry, DEFAULT, v1 extensions");
System.out.println("crlInspectionRejectsMalformedOptionalTimesEntriesAndExtensions...ok");
}
@Test @Test
void rejectsTrailingIndefiniteAndNonMinimalLength() { void rejectsTrailingIndefiniteAndNonMinimalLength() {
System.out.println("rejectsTrailingIndefiniteAndNonMinimalLength"); System.out.println("rejectsTrailingIndefiniteAndNonMinimalLength");
@@ -116,6 +188,53 @@ final class StreamingDerReaderTest {
System.out.println("rejectsNonCanonicalPrimitiveForms...ok"); System.out.println("rejectsNonCanonicalPrimitiveForms...ok");
} }
@Test
void boundedEntryRejectsOversizeAndHostileHighTags() throws Exception {
System.out.println("boundedEntryRejectsOversizeAndHostileHighTags");
StreamingDerReader reader = new StreamingDerReader();
byte[] highTag31 = { (byte) 0x9f, 0x1f, 0x00 };
assertEquals(highTag31.length, reader.validate(highTag31, highTag31.length));
assertThrows(IOException.class, () -> reader.validate(highTag31, highTag31.length - 1));
assertThrows(IOException.class, () -> reader.validate(new byte[] { 0x1f, 0x1e, 0x00 }, 3));
assertThrows(IOException.class, () -> reader.validate(new byte[] { 0x1f, 0x1f, 0x00 }, 3));
assertThrows(IOException.class,
() -> reader.validate(new byte[] { 0x1f, (byte) 0xff, (byte) 0xff, (byte) 0xff,
(byte) 0xff, 0x7f, 0x00 }, 7));
System.out.println("...rejected=limit, non-minimal tag, tag overflow");
System.out.println("boundedEntryRejectsOversizeAndHostileHighTags...ok");
}
@Test
void validatesReachableEnumeratedTimesAndX509Strings() throws Exception {
System.out.println("validatesReachableEnumeratedTimesAndX509Strings");
StreamingDerReader reader = new StreamingDerReader();
byte[] values = { 0x30, 0x1f, 0x0a, 0x01, 0x01, 0x0c, 0x02, (byte) 0xc3, (byte) 0xa9,
0x13, 0x02, 'C', 'N', 0x16, 0x03, 'a', '@', 'b', 0x17, 0x0d,
'2', '6', '0', '8', '0', '3', '1', '2', '0', '0', '0', '0', 'Z' };
assertEquals(values.length, reader.validate(values, values.length));
assertThrows(IOException.class, () -> reader.validate(new byte[] { 0x0a, 0x02, 0x00, 0x01 }, 4));
assertThrows(IOException.class,
() -> reader.validate(new byte[] { 0x17, 0x0d, '2', '6', '0', '2', '3', '0', '1', '2', '0',
'0', '0', '0', 'Z' }, 15));
assertThrows(IOException.class,
() -> reader.validate(new byte[] { 0x0c, 0x02, (byte) 0xc0, (byte) 0x80 }, 4));
assertThrows(IOException.class, () -> reader.validate(new byte[] { 0x13, 0x01, '@' }, 3));
System.out.println("...rejected=ENUMERATED, date, UTF-8, PrintableString");
System.out.println("validatesReachableEnumeratedTimesAndX509Strings...ok");
}
@Test
void subjectPublicKeyInfoRequiresZeroPadBits() throws Exception {
System.out.println("subjectPublicKeyInfoRequiresZeroPadBits");
StreamingDerReader reader = new StreamingDerReader();
byte[] valid = { 0x30, 0x08, 0x30, 0x03, 0x06, 0x01, 0x2a, 0x03, 0x01, 0x00 };
byte[] padded = { 0x30, 0x09, 0x30, 0x03, 0x06, 0x01, 0x2a, 0x03, 0x02, 0x01, 0x00 };
assertEquals(valid.length, reader.validateSubjectPublicKeyInfo(valid, valid.length));
assertThrows(IOException.class, () -> reader.validateSubjectPublicKeyInfo(padded, padded.length));
System.out.println("...rejected=nonzero pad bits");
System.out.println("subjectPublicKeyInfoRequiresZeroPadBits...ok");
}
@Test @Test
void enforcesCanonicalSetOrdering() throws Exception { void enforcesCanonicalSetOrdering() throws Exception {
System.out.println("enforcesCanonicalSetOrdering"); System.out.println("enforcesCanonicalSetOrdering");
@@ -144,6 +263,26 @@ final class StreamingDerReaderTest {
System.out.println("setOrderingUsesFixedSourcePasses...ok"); System.out.println("setOrderingUsesFixedSourcePasses...ok");
} }
@Test
void denseEmptyPrimitiveInputUsesOneStreamingPass() throws Exception {
System.out.println("denseEmptyPrimitiveInputUsesOneStreamingPass");
byte[] encoded = new byte[4 + 2 * 16_384];
encoded[0] = 0x30;
encoded[1] = (byte) 0x82;
encoded[2] = (byte) ((encoded.length - 4) >>> 8);
encoded[3] = (byte) (encoded.length - 4);
for (int index = 4; index < encoded.length; index += 2) {
encoded[index] = 0x04;
encoded[index + 1] = 0x00;
}
CountingContent content = new CountingContent(encoded);
assertEquals(encoded.length, new StreamingDerReader().validate(content, CancellationSignal.NONE));
assertEquals(1, content.openCount());
assertEquals(1, content.closeCount());
System.out.println("...emptyPrimitives=16384 sourcePasses=" + content.openCount());
System.out.println("denseEmptyPrimitiveInputUsesOneStreamingPass...ok");
}
private static byte[] repeatedIntegerSet(int count) { private static byte[] repeatedIntegerSet(int count) {
int valueLength = Math.multiplyExact(count, 3); int valueLength = Math.multiplyExact(count, 3);
byte[] encoded = new byte[Math.addExact(valueLength, 4)]; byte[] encoded = new byte[Math.addExact(valueLength, 4)];
@@ -159,6 +298,70 @@ final class StreamingDerReaderTest {
return encoded; return encoded;
} }
private static void assertCrlRejected(StreamingDerReader reader, byte[]... tbsFields) {
byte[] encoded = signedObject(sequence(tbsFields));
assertThrows(IOException.class, () -> reader.inspectSignedObject(new ImmutableByteContent(encoded),
SignedObjectKind.CRL, CancellationSignal.NONE));
}
private static byte[] signedObject(byte[] tbs) {
return sequence(tbs, algorithm(), tlv(3, new byte[] { 0, (byte) 0xaa }));
}
private static byte[] algorithm() {
return sequence(oid());
}
private static byte[] issuer() {
return sequence(tlv(0x31, sequence(tlv(6, new byte[] { 0x55, 0x04, 0x03 }),
tlv(12, new byte[] { 'I' }))));
}
private static byte[] oid() {
return tlv(6, new byte[] { 0x2a, 0x03 });
}
private static byte[] integer(int value) {
return tlv(2, new byte[] { (byte) value });
}
private static byte[] utcTime(String value) {
return tlv(23, value.getBytes(java.nio.charset.StandardCharsets.US_ASCII));
}
private static byte[] explicitExtensions(byte[] extensions) {
return tlv(0xa0, extensions);
}
private static byte[] sequence(byte[]... values) {
return tlv(0x30, concatenate(values));
}
private static byte[] tlv(int tag, byte[] value) {
if (value.length >= 128) {
throw new IllegalArgumentException("test DER value too long");
}
byte[] encoded = new byte[value.length + 2];
encoded[0] = (byte) tag;
encoded[1] = (byte) value.length;
System.arraycopy(value, 0, encoded, 2, value.length);
return encoded;
}
private static byte[] concatenate(byte[]... values) {
int length = 0;
for (byte[] value : values) {
length = Math.addExact(length, value.length);
}
byte[] result = new byte[length];
int offset = 0;
for (byte[] value : values) {
System.arraycopy(value, 0, result, offset, value.length);
offset += value.length;
}
return result;
}
/** Repeatable test content that accounts for every fixed comparison pass. */ /** Repeatable test content that accounts for every fixed comparison pass. */
private static final class CountingContent implements RepeatableContent { private static final class CountingContent implements RepeatableContent {
private final byte[] encoded; private final byte[] encoded;

View File

@@ -0,0 +1,115 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the conditions in the project license are met.
*******************************************************************************/
package zeroecho.pki.impl.framework.x509.bc;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.nio.charset.StandardCharsets;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.util.Base64;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.bouncycastle.pkcs.PKCS10CertificationRequest;
import org.bouncycastle.pkcs.PKCS10CertificationRequestBuilder;
import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequestBuilder;
import org.junit.jupiter.api.Test;
import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.request.CertificationRequest;
final class BcX509CertificationRequestParserDerTest {
@Test
void acceptsOnlyExactSingleCsrPemObject() throws Exception {
System.out.println("acceptsOnlyExactSingleCsrPemObject");
byte[] der = certificationRequest();
BcX509CertificationRequestParser parser = new BcX509CertificationRequestParser();
for (String label : new String[] { "CERTIFICATE REQUEST", "NEW CERTIFICATE REQUEST" }) {
byte[] pem = pem(label, der);
assertEquals(Encoding.DER, parser.parse(request(Encoding.PEM, pem)).publicKeyInfo().encoding());
}
assertThrows(PkiException.class,
() -> parser.parse(request(Encoding.PEM, pem("PUBLIC KEY", der))));
String missingLineBreak = "-----BEGIN CERTIFICATE REQUEST-----"
+ Base64.getEncoder().encodeToString(der) + "\n-----END CERTIFICATE REQUEST-----\n";
assertThrows(PkiException.class, () -> parser.parse(request(Encoding.PEM,
missingLineBreak.getBytes(StandardCharsets.US_ASCII))));
byte[] one = pem("CERTIFICATE REQUEST", der);
byte[] two = new byte[one.length * 2];
System.arraycopy(one, 0, two, 0, one.length);
System.arraycopy(one, 0, two, one.length, one.length);
assertThrows(PkiException.class, () -> parser.parse(request(Encoding.PEM, two)));
System.out.println("...labels=2 rejected=wrong-label, missing-line-break, second-object");
System.out.println("acceptsOnlyExactSingleCsrPemObject...ok");
}
@Test
void rejectsOversizeAndNonCanonicalDerBeforePkcs10Parsing() throws Exception {
System.out.println("rejectsOversizeAndNonCanonicalDerBeforePkcs10Parsing");
BcX509CertificationRequestParser parser = new BcX509CertificationRequestParser();
byte[] oversized = new byte[BcX509CertificationRequestParser.MAXIMUM_CSR_DER_BYTES + 1];
assertThrows(PkiException.class, () -> parser.parse(request(Encoding.DER, oversized)));
byte[] der = certificationRequest();
byte[] trailing = java.util.Arrays.copyOf(der, der.length + 1);
assertThrows(PkiException.class, () -> parser.parse(request(Encoding.DER, trailing)));
assertThrows(PkiException.class,
() -> parser.parse(request(Encoding.DER, new byte[] { 0x30, (byte) 0x80, 0x00, 0x00 })));
assertThrows(PkiException.class,
() -> parser.parse(request(Encoding.DER, new byte[] { 0x30, (byte) 0x81, 0x00 })));
assertThrows(PkiException.class,
() -> parser.parse(request(Encoding.DER, new byte[] { 0x05, 0x00 })));
assertThrows(PkiException.class, () -> parser.parse(request(Encoding.DER, excessivelyNestedDer())));
System.out.println("...rejected=limit, trailing, indefinite, nonminimal, type, depth");
System.out.println("rejectsOversizeAndNonCanonicalDerBeforePkcs10Parsing...ok");
}
private static CertificationRequest request(Encoding encoding, byte[] encoded) {
return new CertificationRequest(BcX509CredentialFramework.FORMAT_ID, new EncodedObject(encoding, encoded));
}
private static byte[] pem(String label, byte[] der) {
String encoded = "\n-----BEGIN " + label + "-----\n" + Base64.getEncoder().encodeToString(der)
+ "\n-----END " + label + "-----\n\t";
return encoded.getBytes(StandardCharsets.US_ASCII);
}
private static byte[] certificationRequest() throws Exception {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
KeyPair pair = generator.generateKeyPair();
PKCS10CertificationRequestBuilder builder = new JcaPKCS10CertificationRequestBuilder(
new X500Name("CN=Bounded CSR"), pair.getPublic());
ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(pair.getPrivate());
PKCS10CertificationRequest request = builder.build(signer);
return request.getEncoded();
}
private static byte[] excessivelyNestedDer() {
byte[] encoded = { 0x05, 0x00 };
for (int depth = 0; depth < 65; depth++) {
int headerLength = encoded.length < 128 ? 2 : 3;
byte[] wrapped = new byte[headerLength + encoded.length];
wrapped[0] = 0x30;
if (headerLength == 2) {
wrapped[1] = (byte) encoded.length;
} else {
wrapped[1] = (byte) 0x81;
wrapped[2] = (byte) encoded.length;
}
System.arraycopy(encoded, 0, wrapped, headerLength, encoded.length);
encoded = wrapped;
}
return encoded;
}
}