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.ASN1Encoding;
import org.bouncycastle.asn1.ASN1Integer;
import org.bouncycastle.asn1.ASN1Primitive;
import org.bouncycastle.asn1.ASN1Sequence;
import org.bouncycastle.asn1.DERSequence;
import zeroecho.core.spec.ContextSpec;
@@ -84,7 +82,10 @@ import zeroecho.core.spec.ContextSpec;
* semantics, not as an optional hint.</li>
* </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.
@@ -192,11 +193,10 @@ public final class SignatureInteropProfile { // NOPMD
*
* @param signature external signature bytes
* @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
* expected external representation
*/
public byte[] externalToInternalSignature(byte[] signature) throws IOException {
public byte[] externalToInternalSignature(byte[] signature) {
Objects.requireNonNull(signature, "signature");
if (signatureRepresentation == SignatureRepresentation.IDENTITY) {
return signature.clone();
@@ -221,21 +221,86 @@ public final class SignatureInteropProfile { // NOPMD
return ecdsaP1363ToDer(signature, internalSignatureLength);
}
private static byte[] ecdsaDerToP1363(byte[] der, int fixedLength) throws IOException {
ASN1Primitive primitive = ASN1Primitive.fromByteArray(der);
ASN1Sequence sequence = ASN1Sequence.getInstance(primitive);
if (sequence.size() != 2) { // NOPMD
throw new IllegalArgumentException("ECDSA DER signature must contain exactly two integers");
private static byte[] ecdsaDerToP1363(byte[] der, int fixedLength) {
if ((fixedLength & 1) != 0 || der.length > fixedLength + 8 || der.length < 8) {
throw new IllegalArgumentException("Invalid ECDSA DER signature length");
}
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[] r = ASN1Integer.getInstance(sequence.getObjectAt(0)).getPositiveValue().toByteArray();
byte[] s = ASN1Integer.getInstance(sequence.getObjectAt(1)).getPositiveValue().toByteArray();
copyUnsignedFixed(r, out, 0, coordinateLength);
copyUnsignedFixed(s, out, coordinateLength, coordinateLength);
offset = readPositiveInteger(der, offset, sequenceEnd, out, 0, coordinateLength);
offset = readPositiveInteger(der, offset, sequenceEnd, out, coordinateLength, coordinateLength);
if (offset != sequenceEnd) {
throw new IllegalArgumentException("ECDSA DER signature must contain exactly two integers");
}
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 {
if (p1363.length != fixedLength) {
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);
}
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) {
Objects.requireNonNull(value, label);
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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
@@ -117,6 +118,41 @@ public final class SignatureInteropProfilesTest {
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) {
int len = Math.min(bytes.length, 16);
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.framework.x509.X509AlgorithmRole;
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.BcX509AlgorithmAdapter;
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"
.getBytes(java.nio.charset.StandardCharsets.US_ASCII);
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 final PublicKeyInfoResolver publicKeyResolver;
@@ -114,11 +116,17 @@ final class CaProofGate {
}
/* default */ SubjectPublicKeyInfo parseRootSpki(EncodedObject spki, FormatId formatId) {
try {
return SubjectPublicKeyInfo.getInstance(spki.bytes());
} catch (RuntimeException ex) { // NOPMD - malformed resolver output fails closed
if (spki.encoding() != Encoding.DER) {
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) {
@@ -169,6 +177,7 @@ final class CaProofGate {
}
return new ManagedKeyProof(keyRef, formatId, new EncodedObject(Encoding.DER, exactSpki.clone()));
} finally {
Arrays.fill(exactSpki, (byte) 0);
Arrays.fill(challenge, (byte) 0);
if (signature != null) {
Arrays.fill(signature, (byte) 0);
@@ -187,7 +196,15 @@ final class CaProofGate {
if (resolved == null || resolved.encoding() != Encoding.DER) {
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) {
@@ -213,7 +230,7 @@ final class CaProofGate {
private boolean verifyChallenge(byte[] spkiDer, byte[] challenge, byte[] signature) {
try {
SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(spkiDer);
SubjectPublicKeyInfo spki = parseValidatedSpki(spkiDer);
BcX509AlgorithmAdapter adapter = new BcX509AlgorithmAdapter(signingBus.authority().bindings());
AlgorithmIdentity keyIdentity = adapter.decode(spki.getAlgorithm(),
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.
*/

View File

@@ -33,6 +33,7 @@
******************************************************************************/
package zeroecho.pki.impl.core;
import java.io.IOException;
import java.security.GeneralSecurityException;
import java.security.KeyFactory;
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.X509AlgorithmRole;
import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot;
import zeroecho.pki.impl.framework.x509.StreamingDerReader;
import zeroecho.core.alg.BootstrapAlgorithmIdentities;
import zeroecho.core.spec.AlgorithmIdentity;
import zeroecho.pki.impl.framework.x509.bc.BcX509ProfileSupport;
@@ -84,6 +86,7 @@ import zeroecho.pki.impl.framework.x509.bc.BcX509ProfileSupport;
final class CertificateProfileValidator {
private static final String EC_FAMILY = "ec";
private static final int MAXIMUM_SPKI_BYTES = 1024 * 1024;
private CertificateProfileValidator() {
}
@@ -233,6 +236,7 @@ final class CertificateProfileValidator {
}
byte[] encoded = exactPublicKey.bytes();
try {
new StreamingDerReader().validateSubjectPublicKeyInfo(encoded, MAXIMUM_SPKI_BYTES);
SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(encoded);
AlgorithmIdentity identity;
try {
@@ -262,7 +266,7 @@ final class CertificateProfileValidator {
}
} catch (PkiException exception) {
throw exception;
} catch (GeneralSecurityException | IllegalArgumentException exception) {
} catch (GeneralSecurityException | IllegalArgumentException | IOException exception) {
throw reject("SUBJECT_KEY_UNSUPPORTED");
} finally {
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.
*/
final class CredentialContent {
/* default */ static final int MAXIMUM_CERTIFICATE_BYTES = 1024 * 1024;
private CredentialContent() {
}
/* 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);
OutputStream output = sink.outputStream()) {
output.write(encoded);
@@ -67,10 +72,11 @@ final class CredentialContent {
}
/* default */ static byte[] materializeForBc(PkiStore store, DurableContentReference reference) {
if (reference.length() > Integer.MAX_VALUE) {
throw new PkiException("Credential exceeds BC adapter element domain: code=ADAPTER_ELEMENT_LIMIT_EXCEEDED");
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");
}
byte[] result = new byte[(int) reference.length()];
byte[] result = new byte[(int) length];
try {
readExact(store, reference, result);
return result;
@@ -83,6 +89,10 @@ final class CredentialContent {
/* default */ static BcX509SignedObjectValidator.CertificateBindings validateCertificate(PkiStore store,
DurableContentReference reference, X509AuthoritySnapshot authority,
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)) {
return new BcX509SignedObjectValidator(authority).validateCertificate(content, expectedSignature,
CancellationSignal.NONE);

View File

@@ -222,8 +222,12 @@ public final class DefaultStatusObjectService implements StatusObjectService {
SimpleAttributeSet.Builder b = SimpleAttributeSet.builder();
b.putAll(command.attributes());
b.put(BcX509Attributes.ISSUER_CERT_DER,
new AttributeValue.BytesValue(CredentialContent.materializeForBc(store, issuerCred.content())));
byte[] issuerDer = materializeValidatedCertificate(issuerCred);
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()));
StatusObjectGenerateCommand wired = new StatusObjectGenerateCommand(command.issuerCaId(), command.type(),
@@ -248,11 +252,12 @@ public final class DefaultStatusObjectService implements StatusObjectService {
requirePersistableContent(generated.content());
boolean accepted = false;
try {
byte[] issuerDer = CredentialContent.materializeForBc(store, issuer.content());
requireGeneratedMetadata(command, generated);
byte[] issuerDer = materializeValidatedCertificate(issuer);
try (RepeatableContent content = store.stagedContent().openContent(generated.content())) {
X509CertificateHolder holder = new X509CertificateHolder(issuerDer);
new BcX509SignedObjectValidator(authority).validateGeneratedCrl(content, signingPlan,
holder.getSubjectPublicKeyInfo(), CancellationSignal.NONE);
holder, generated.thisUpdate(), generated.nextUpdate(), CancellationSignal.NONE);
} finally {
Arrays.fill(issuerDer, (byte) 0);
}
@@ -274,11 +279,20 @@ public final class DefaultStatusObjectService implements StatusObjectService {
private void requirePersistableContent(DurableContentReference content) {
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");
}
}
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) {
try {
store.stagedContent().retireUnownedContent(content);
@@ -443,7 +457,7 @@ public final class DefaultStatusObjectService implements StatusObjectService {
// cause is intentionally removed at this public service boundary.
@SuppressWarnings("PMD.PreserveStackTrace")
private BigInteger certificateSerial(Credential credential) {
byte[] der = CredentialContent.materializeForBc(store, credential.content());
byte[] der = materializeValidatedCertificate(credential);
BigInteger serial;
try {
serial = new X509CertificateHolder(der).getSerialNumber();
@@ -458,6 +472,16 @@ public final class DefaultStatusObjectService implements StatusObjectService {
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() {
return new PkiException("CRL generation failed: code=" + CRL_GENERATION_FAILED);
}

View File

@@ -33,9 +33,16 @@
******************************************************************************/
package zeroecho.pki.impl.framework.x509;
import java.io.ByteArrayInputStream;
import java.io.IOException;
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.Optional;
import java.util.OptionalLong;
import zeroecho.core.io.CancellationSignal;
import zeroecho.core.io.RepeatableContent;
@@ -58,6 +65,9 @@ import zeroecho.core.io.RepeatableContent;
* fixed depth and comparison-buffer size.
* </p>
*/
// The aggregate branches implement one closed canonical-DER grammar.
@SuppressWarnings({ "PMD.CyclomaticComplexity", "PMD.NPathComplexity", "PMD.AvoidLiteralsInIfCondition",
"PMD.CommentDefaultAccessModifier" })
public final class StreamingDerReader {
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 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.
*
@@ -108,7 +188,7 @@ public final class StreamingDerReader {
try (CountedInput input = new CountedInput(content.openStream())) {
Header outer = HeaderReader.read(input);
requireSequence(outer);
long outerEnd = Math.addExact(input.position(), outer.length());
long outerEnd = checkedAdd(input.position(), outer.length());
Header tbs = HeaderReader.read(input);
requireSequence(tbs);
long tbsTotalLength = tbs.encodedLength();
@@ -133,7 +213,8 @@ public final class StreamingDerReader {
}
return new SignedObjectLayout(tbs.start(), tbsTotalLength, inner.algorithmOffset(),
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);
skip(input, child.length(), cancellation);
child = HeaderReader.read(input);
} else if (child.universal() && child.tagNumber() == 2) {
skip(input, child.length(), cancellation);
child = HeaderReader.read(input);
} else {
return inspectCrlTbs(input, tbs, child, cancellation);
}
requireSequence(child);
long algorithmOffset = child.start();
long algorithmLength = child.encodedLength();
if (kind == SignedObjectKind.CRL) {
return new AlgorithmAndKey(algorithmOffset, algorithmLength, -1L, 0L);
}
skip(input, child.length(), cancellation);
for (int index = 0; index < 3; index++) {
Header field = HeaderReader.read(input);
@@ -167,7 +244,213 @@ public final class StreamingDerReader {
}
Header spki = HeaderReader.read(input);
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) {
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()) {
requireConstructedForm(header);
SetOrdering setOrdering = header.universal() && header.tagNumber() == 17
@@ -198,7 +481,7 @@ public final class StreamingDerReader {
while (input.position() < end) {
cancellation.throwIfCancelled();
Header child = HeaderReader.read(input);
long childEnd = Math.addExact(input.position(), child.length());
long childEnd = checkedAdd(input.position(), child.length());
if (childEnd > end) {
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 {
byte[] buffer = new byte[BUFFER_BYTES];
long remaining = length;
if (remaining == 0L) {
return;
}
byte[] buffer = input.scratch();
while (remaining != 0L) {
cancellation.throwIfCancelled();
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.
*/
@@ -272,14 +566,30 @@ public final class StreamingDerReader {
* @param subjectPublicKeyInfoLength SPKI TLV length, or zero for CRLs
* @param signatureOffset signature octets offset
* @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,
long tbsAlgorithmLength, long outerAlgorithmOffset, long outerAlgorithmLength,
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) {
@@ -295,8 +605,8 @@ public final class StreamingDerReader {
return (firstTag & 0xc0) == 0x80 && tagNumber == expectedTag;
}
private long encodedLength() {
return Math.addExact(valueOffset - start, length);
private long encodedLength() throws IOException {
return checkedAdd(valueOffset - start, length);
}
}
@@ -320,10 +630,20 @@ public final class StreamingDerReader {
if ((octet & 0x7f) == 0) {
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();
}
tagNumber = -1;
if (tagNumber < HIGH_TAG_NUMBER) {
throw new IOException("Non-minimal DER high tag");
}
}
return tagNumber;
}
@@ -539,6 +859,7 @@ public final class StreamingDerReader {
private static final class CountedInput extends InputStream {
private final InputStream delegate;
private long position;
private byte[] scratch;
private CountedInput(InputStream delegate) {
super();
@@ -549,7 +870,7 @@ public final class StreamingDerReader {
public int read() throws IOException {
int value = delegate.read();
if (value >= 0) {
position = Math.addExact(position, 1L);
position = checkedAdd(position, 1L);
}
return value;
}
@@ -558,7 +879,7 @@ public final class StreamingDerReader {
public int read(byte[] bytes, int offset, int length) throws IOException {
int count = delegate.read(bytes, offset, length);
if (count > 0) {
position = Math.addExact(position, count);
position = checkedAdd(position, count);
}
return count;
}
@@ -579,6 +900,13 @@ public final class StreamingDerReader {
private long 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()) {
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 4, 20 -> skip(input, header.length(), cancellation);
case 5 -> readNull(header.length());
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");
}
}
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.util.Objects;
import java.util.Arrays;
import org.bouncycastle.asn1.ASN1Encoding;
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;
import java.io.ByteArrayInputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashSet;
import java.util.HexFormat;
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.GeneralNames;
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.Encoding;
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.SubjectAlternativeName;
import zeroecho.pki.api.request.SubjectRdn;
import zeroecho.pki.impl.framework.x509.StreamingDerReader;
import zeroecho.pki.spi.framework.CertificationRequestParser;
/**
@@ -133,8 +129,13 @@ import zeroecho.pki.spi.framework.CertificationRequestParser;
public final class BcX509CertificationRequestParser implements CertificationRequestParser {
private static final int MAXIMUM_SUBJECT_DER_BYTES = 16 * 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 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
@@ -179,12 +180,20 @@ public final class BcX509CertificationRequestParser implements CertificationRequ
}
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;
try {
PKCS10CertificationRequest csr;
try {
BcX509AlgorithmAdapter.requireCanonicalDer(csrDer);
new StreamingDerReader().validate(csrDer, MAXIMUM_CSR_DER_BYTES);
csr = new PKCS10CertificationRequest(csrDer);
} catch (Exception ex) {
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) {
Objects.requireNonNull(obj, "obj");
return switch (obj.encoding()) {
case DER -> obj.bytes();
case PEM -> pemToDer(obj);
default -> throw new IllegalArgumentException("Unsupported CSR encoding: " + obj.encoding());
};
}
private static byte[] pemToDer(EncodedObject obj) {
byte[] pemBytes = obj.bytes();
byte[] encoded = obj.bytes();
if (obj.encoding() == Encoding.DER) {
return requireDecodedLimit(encoded);
}
try {
return readPemContentOrThrow(pemBytes);
if (encoded.length > MAXIMUM_CSR_ENCODED_BYTES) {
throw new IllegalArgumentException("CSR encoding exceeds element limit");
}
return switch (obj.encoding()) {
case DER -> throw new IllegalStateException("DER handled before PEM conversion");
case PEM -> readPemContentOrThrow(encoded);
default -> throw new IllegalArgumentException("Unsupported CSR encoding: " + obj.encoding());
};
} 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.
*
* <p>
* The input is interpreted as US-ASCII text and parsed using {@link PemReader}.
* If the PEM container is syntactically valid and contains an object, the raw
* decoded content of that object is returned.
* The input is interpreted as US-ASCII and must contain exactly one accepted
* PKCS#10 PEM object with whitespace only outside the object.
* </p>
*
* <p>
@@ -401,20 +410,89 @@ public final class BcX509CertificationRequestParser implements CertificationRequ
* @throws IllegalArgumentException if the PEM input is empty or cannot be
* parsed
*/
@SuppressWarnings("PMD.AvoidThrowingNewInstanceOfSameException")
private static byte[] readPemContentOrThrow(byte[] pemBytes) {
Objects.requireNonNull(pemBytes, "pemBytes");
try (PemReader reader = new PemReader(
new InputStreamReader(new ByteArrayInputStream(pemBytes), StandardCharsets.US_ASCII))) {
PemObject pemObject = reader.readPemObject();
if (pemObject == null) {
throw new IllegalArgumentException("Empty PEM");
for (byte value : pemBytes) {
if (value < 0) {
throw new IllegalArgumentException(CSR_PEM_INVALID);
}
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.util.Date;
import java.util.HexFormat;
import java.util.Optional;
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
import org.bouncycastle.asn1.DEROctetString;
@@ -57,6 +58,8 @@ import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.X509v3CertificateBuilder;
import zeroecho.core.spec.AlgorithmIdentity;
import zeroecho.core.io.CancellationSignal;
import zeroecho.core.io.ImmutableByteContent;
import zeroecho.core.io.RepeatableContent;
import zeroecho.pki.api.EncodedObject;
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.async.PkiSigningBus;
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.store.ContentSink;
@@ -124,9 +129,12 @@ import zeroecho.pki.spi.store.ContentSink;
* </p>
*/
// 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 {
private static final int MAXIMUM_CERTIFICATE_BYTES = 1024 * 1024;
private static final int MAXIMUM_SPKI_BYTES = 1024 * 1024;
private final PkiSigningBus signingBus;
private final AlgorithmIdentity signatureIdentity;
private final Duration signingTtl;
@@ -342,7 +350,7 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
SubjectPublicKeyInfo spki;
PkiId publicKeyId;
try {
spki = SubjectPublicKeyInfo.getInstance(subjectSpki);
spki = parseSubjectPublicKeyInfo(subjectSpki);
publicKeyId = new PkiId("spki:" + sha256Hex(subjectSpki));
} finally {
java.util.Arrays.fill(subjectSpki, (byte) 0);
@@ -394,6 +402,9 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
}
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);
OutputStream output = sink.outputStream()) {
output.write(certificate);
@@ -406,7 +417,7 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
private void validateGeneratedCertificate(DurableContentReference reference, PkiBusContentSigner signer) {
try (RepeatableContent content = signingBus.openContent(reference)) {
new BcX509SignedObjectValidator(signingBus.authority()).validateGeneratedCertificate(content,
signer.executionPlan(), zeroecho.core.io.CancellationSignal.NONE);
signer.executionPlan(), CancellationSignal.NONE);
} catch (IOException | IllegalArgumentException exception) {
signingBus.releaseContent(reference);
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) {
if (reference.length() > Integer.MAX_VALUE) {
throw new PkiException("Certificate exceeds BC adapter element domain: code=ADAPTER_ELEMENT_LIMIT_EXCEEDED");
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");
}
byte[] result = new byte[(int) reference.length()];
byte[] result = new byte[(int) length];
try {
readExact(reference, 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 {
new BcX509SignedObjectValidator(signingBus.authority()).validateCertificate(
new ImmutableByteContent(issuerCertDer), Optional.empty(), CancellationSignal.NONE);
return new X509CertificateHolder(issuerCertDer);
} catch (Exception ex) {
} catch (IOException | IllegalArgumentException ex) {
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();
try {
return SubjectPublicKeyInfo.getInstance(bytes);
return parseSubjectPublicKeyInfo(bytes);
} finally {
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) {
byte[] bytes = encoded.bytes();
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.X509AuthoritySnapshot;
import zeroecho.pki.impl.framework.x509.X509ExecutionPlan;
import zeroecho.pki.impl.framework.x509.StreamingDerReader;
import zeroecho.pki.spi.framework.ProofOfPossessionVerifier;
/**
@@ -199,7 +200,8 @@ public final class BcX509ProofOfPossessionVerifier implements ProofOfPossessionV
try {
PKCS10CertificationRequest csr;
try {
BcX509AlgorithmAdapter.requireCanonicalDer(csrDer);
new StreamingDerReader().validate(csrDer,
BcX509CertificationRequestParser.MAXIMUM_CSR_DER_BYTES);
csr = new PKCS10CertificationRequest(csrDer);
} catch (Exception ex) {
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.InputStream;
import java.security.GeneralSecurityException;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
import org.bouncycastle.asn1.ASN1Primitive;
import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.operator.OperatorCreationException;
import zeroecho.core.io.CancellationSignal;
import zeroecho.core.io.ContentSlice;
import zeroecho.core.io.ImmutableByteContent;
import zeroecho.core.io.RepeatableContent;
import zeroecho.core.spec.AlgorithmIdentity;
import zeroecho.core.spi.AlgorithmExecutionCapability;
@@ -71,6 +74,8 @@ import zeroecho.pki.spi.crypto.SignatureWorkflow;
public final class BcX509SignedObjectValidator {
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 StreamingDerReader reader;
@@ -100,6 +105,10 @@ public final class BcX509SignedObjectValidator {
Objects.requireNonNull(content, "content");
Objects.requireNonNull(expectedSignature, "expectedSignature");
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.SignedObjectKind.CERTIFICATE, cancellation);
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
* public key.
* certificate.
*
* @param content original complete CRL DER
* @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
* @throws IOException if canonical DER, binding, suite, or signature
* validation fails
* @throws IllegalArgumentException if the plan belongs to another runtime
*/
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(plan, "plan");
Objects.requireNonNull(issuerPublicKey, "issuerPublicKey");
Objects.requireNonNull(issuerCertificate, "issuerCertificate");
Objects.requireNonNull(expectedThisUpdate, "expectedThisUpdate");
Objects.requireNonNull(expectedNextUpdate, "expectedNextUpdate");
Objects.requireNonNull(cancellation, "cancellation");
authority.authorize(plan, plan.executor(), AlgorithmExecutionCapability.Direction.SIGN);
StreamingDerReader.SignedObjectLayout layout = reader.inspectSignedObject(content,
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(),
layout.tbsAlgorithmLength());
ContentSlice outerAlgorithm = new ContentSlice(content, layout.outerAlgorithmOffset(),
@@ -172,6 +199,7 @@ public final class BcX509SignedObjectValidator {
if (!plan.selection().requested().equals(signatureIdentity)) {
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(
issuerPublicKey.getAlgorithm(), X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM);
X509SuiteCompatibility.requireCompatible(signatureIdentity, issuerKey);
@@ -231,8 +259,8 @@ public final class BcX509SignedObjectValidator {
}
private static byte[] materializeElement(RepeatableContent content) throws IOException {
long length = content.length().orElseThrow();
if (length > Integer.MAX_VALUE) {
long length = content.length().orElse(-1L);
if (length <= 0L || length > MAXIMUM_ELEMENT_BYTES) {
throw new IOException("X.509 element exceeds adapter domain: code=ADAPTER_ELEMENT_LIMIT_EXCEEDED");
}
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.ContentSlice;
import zeroecho.core.io.ImmutableByteContent;
import zeroecho.core.io.RepeatableContent;
import zeroecho.core.spec.AlgorithmIdentity;
import zeroecho.core.spi.AlgorithmExecutionCapability;
@@ -161,6 +162,7 @@ import zeroecho.pki.spi.store.TemporaryUniqueIndex;
* </p>
*/
public final class BcX509StatusObjectGenerator implements StatusObjectGenerator {
private static final int MAXIMUM_CERTIFICATE_BYTES = 1024 * 1024;
private static final long EMPTY_CONTENT_LENGTH = 0L;
@@ -252,7 +254,7 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator
}
IssuerMaterial issuerMaterial = extractIssuerMaterialOrThrow(command.attributes());
Instant thisUpdate = Instant.now();
Instant thisUpdate = Instant.now().truncatedTo(ChronoUnit.SECONDS);
Instant nextUpdate = thisUpdate.plus(Duration.ofDays(7));
DurableContentReference entries = null;
DurableContentReference tbs = null;
@@ -514,7 +516,8 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator
* unexpected type, or contain an invalid issuer
* 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> issuerKeyAttr = attributes.get(BcX509Attributes.ISSUER_KEYREF);
@@ -525,14 +528,21 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator
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());
X509CertificateHolder issuerHolder;
try {
new BcX509SignedObjectValidator(signingBus.authority()).validateCertificate(
new ImmutableByteContent(issuerDer), Optional.empty(), CancellationSignal.NONE);
issuerHolder = new X509CertificateHolder(issuerDer);
} catch (Exception ex) {
throw new PkiException("Invalid issuer certificate", ex);
} catch (IOException | IllegalArgumentException ex) {
throw new PkiException("Invalid issuer certificate");
} finally {
Arrays.fill(issuerDer, (byte) 0);
}
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.X509AuthoritySnapshot;
import zeroecho.pki.impl.framework.x509.X509ExecutionPlan;
import zeroecho.pki.impl.framework.x509.StreamingDerReader;
import zeroecho.pki.spi.crypto.SignatureWorkflow;
import zeroecho.core.io.CancellationSignal;
import zeroecho.core.io.ImmutableByteContent;
@@ -267,7 +268,8 @@ public final class WorkflowProofOfPossessionVerifier implements ProofOfPossessio
*/
private static Optional<PKCS10CertificationRequest> parseCsr(byte[] csrDer) {
try {
BcX509AlgorithmAdapter.requireCanonicalDer(csrDer);
new StreamingDerReader().validate(csrDer,
BcX509CertificationRequestParser.MAXIMUM_CSR_DER_BYTES);
return Optional.of(new PKCS10CertificationRequest(csrDer));
} catch (IOException | IllegalArgumentException ex) {
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.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.UnaryOperator;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x509.CRLReason;
@@ -250,6 +251,23 @@ final class DefaultStatusObjectServiceCrlTest {
EVALUATION_TIME.minusSeconds(1), Optional.empty())),
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,
template.content());
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,
List<RevocationRecord> records, Map<PkiId, Credential> credentials, boolean failListing) {
int signCount = runtime.submittedSignCount();
@@ -486,6 +591,12 @@ final class DefaultStatusObjectServiceCrlTest {
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,
KeyRef keyRef) throws Exception {
AttributeSet attributes = SimpleAttributeSet.builder()

View File

@@ -60,6 +60,7 @@ import java.util.Set;
import org.bouncycastle.asn1.ASN1Encodable;
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
import org.bouncycastle.asn1.DERBMPString;
import org.bouncycastle.asn1.DERBitString;
import org.bouncycastle.asn1.DERNull;
import org.bouncycastle.asn1.DEROctetString;
import org.bouncycastle.asn1.DERSequence;
@@ -250,6 +251,14 @@ final class H7ProfileEnforcementTest {
ed.getPublicKeyData().getBytes());
assertCode("SUBJECT_KEY_PARAMETERS_UNSUPPORTED",
() -> 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

View File

@@ -33,6 +33,7 @@
******************************************************************************/
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.assertThrows;
@@ -55,11 +56,11 @@ import zeroecho.pki.impl.framework.x509.StreamingDerReader.SignedObjectLayout;
final class StreamingDerReaderTest {
private static final byte[] CRL = {
0x30, 0x32,
0x30, 0x26,
0x30, 0x1a,
0x02, 0x01, 0x01,
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,
0x30, 0x04, 0x06, 0x02, 0x2a, 0x03,
0x03, 0x02, 0x00, (byte) 0xaa
@@ -74,16 +75,87 @@ final class StreamingDerReaderTest {
CancellationSignal.NONE);
System.out.println("...encodedLength=" + reader.validate(content, CancellationSignal.NONE));
assertEquals(2L, layout.tbsOffset());
assertEquals(28L, layout.tbsLength());
assertEquals(40L, layout.tbsLength());
assertEquals(7L, layout.tbsAlgorithmOffset());
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(39L, layout.signatureOffset());
assertEquals(51L, layout.signatureOffset());
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");
}
@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
void rejectsTrailingIndefiniteAndNonMinimalLength() {
System.out.println("rejectsTrailingIndefiniteAndNonMinimalLength");
@@ -116,6 +188,53 @@ final class StreamingDerReaderTest {
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
void enforcesCanonicalSetOrdering() throws Exception {
System.out.println("enforcesCanonicalSetOrdering");
@@ -144,6 +263,26 @@ final class StreamingDerReaderTest {
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) {
int valueLength = Math.multiplyExact(count, 3);
byte[] encoded = new byte[Math.addExact(valueLength, 4)];
@@ -159,6 +298,70 @@ final class StreamingDerReaderTest {
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. */
private static final class CountingContent implements RepeatableContent {
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;
}
}