diff --git a/lib/src/main/java/zeroecho/core/alg/common/sig/SignatureInteropProfile.java b/lib/src/main/java/zeroecho/core/alg/common/sig/SignatureInteropProfile.java index 9885d90..9afd4a5 100644 --- a/lib/src/main/java/zeroecho/core/alg/common/sig/SignatureInteropProfile.java +++ b/lib/src/main/java/zeroecho/core/alg/common/sig/SignatureInteropProfile.java @@ -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. * */ -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()) { diff --git a/lib/src/test/java/zeroecho/core/alg/common/sig/SignatureInteropProfilesTest.java b/lib/src/test/java/zeroecho/core/alg/common/sig/SignatureInteropProfilesTest.java index c090b29..eb228eb 100644 --- a/lib/src/test/java/zeroecho/core/alg/common/sig/SignatureInteropProfilesTest.java +++ b/lib/src/test/java/zeroecho/core/alg/common/sig/SignatureInteropProfilesTest.java @@ -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]; diff --git a/pki/src/main/java/zeroecho/pki/impl/core/CaProofGate.java b/pki/src/main/java/zeroecho/pki/impl/core/CaProofGate.java index b1535c9..e625e1b 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/CaProofGate.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/CaProofGate.java @@ -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 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. */ diff --git a/pki/src/main/java/zeroecho/pki/impl/core/CertificateProfileValidator.java b/pki/src/main/java/zeroecho/pki/impl/core/CertificateProfileValidator.java index 88d6f1a..9b992ff 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/CertificateProfileValidator.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/CertificateProfileValidator.java @@ -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); diff --git a/pki/src/main/java/zeroecho/pki/impl/core/CredentialContent.java b/pki/src/main/java/zeroecho/pki/impl/core/CredentialContent.java index d31ee34..e934bc8 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/CredentialContent.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/CredentialContent.java @@ -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 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); diff --git a/pki/src/main/java/zeroecho/pki/impl/core/DefaultStatusObjectService.java b/pki/src/main/java/zeroecho/pki/impl/core/DefaultStatusObjectService.java index ff6bd08..a71d4c0 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/DefaultStatusObjectService.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/DefaultStatusObjectService.java @@ -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); } diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/StreamingDerReader.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/StreamingDerReader.java index 229c38c..3679784 100644 --- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/StreamingDerReader.java +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/StreamingDerReader.java @@ -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. *

*/ +// 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. + * + *

+ * 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. + *

+ * + * @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 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 thisUpdate, + Optional 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 thisUpdate, Optional 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. + } } } diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509AlgorithmAdapter.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509AlgorithmAdapter.java index 72a5ef8..6cca5fb 100644 --- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509AlgorithmAdapter.java +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509AlgorithmAdapter.java @@ -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); - } - } } diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CertificationRequestParser.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CertificationRequestParser.java index 7637d75..a78f70d 100644 --- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CertificationRequestParser.java +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CertificationRequestParser.java @@ -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. * *

- * 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. *

* *

@@ -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 == '/'; } /** diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialIssuerBackend.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialIssuerBackend.java index 67f0993..92be442 100644 --- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialIssuerBackend.java +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialIssuerBackend.java @@ -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; *

*/ // 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 { diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509ProofOfPossessionVerifier.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509ProofOfPossessionVerifier.java index 713bca8..3ddacd2 100644 --- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509ProofOfPossessionVerifier.java +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509ProofOfPossessionVerifier.java @@ -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")); diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509SignedObjectValidator.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509SignedObjectValidator.java index 3292ede..c5daf28 100644 --- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509SignedObjectValidator.java +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509SignedObjectValidator.java @@ -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 plan, - SubjectPublicKeyInfo issuerPublicKey, CancellationSignal cancellation) throws IOException { + X509CertificateHolder issuerCertificate, Instant expectedThisUpdate, + Optional 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]; diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509StatusObjectGenerator.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509StatusObjectGenerator.java index 8b3c1a0..c7d7a53 100644 --- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509StatusObjectGenerator.java +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509StatusObjectGenerator.java @@ -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; *

*/ 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 issuerCertAttr = attributes.get(BcX509Attributes.ISSUER_CERT_DER); Optional 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); diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/WorkflowProofOfPossessionVerifier.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/WorkflowProofOfPossessionVerifier.java index 4088320..35f3023 100644 --- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/WorkflowProofOfPossessionVerifier.java +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/WorkflowProofOfPossessionVerifier.java @@ -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 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(); diff --git a/pki/src/test/java/zeroecho/pki/impl/core/CredentialContentBoundaryTest.java b/pki/src/test/java/zeroecho/pki/impl/core/CredentialContentBoundaryTest.java new file mode 100644 index 0000000..26f5f64 --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/impl/core/CredentialContentBoundaryTest.java @@ -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; + } + }; + } +} diff --git a/pki/src/test/java/zeroecho/pki/impl/core/DefaultStatusObjectServiceCrlTest.java b/pki/src/test/java/zeroecho/pki/impl/core/DefaultStatusObjectServiceCrlTest.java index da6a7fa..4e5244a 100644 --- a/pki/src/test/java/zeroecho/pki/impl/core/DefaultStatusObjectServiceCrlTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/core/DefaultStatusObjectServiceCrlTest.java @@ -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> 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 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 records, Map 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 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() diff --git a/pki/src/test/java/zeroecho/pki/impl/core/H7ProfileEnforcementTest.java b/pki/src/test/java/zeroecho/pki/impl/core/H7ProfileEnforcementTest.java index 39281ef..85576c0 100644 --- a/pki/src/test/java/zeroecho/pki/impl/core/H7ProfileEnforcementTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/core/H7ProfileEnforcementTest.java @@ -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 diff --git a/pki/src/test/java/zeroecho/pki/impl/framework/x509/StreamingDerReaderTest.java b/pki/src/test/java/zeroecho/pki/impl/framework/x509/StreamingDerReaderTest.java index f2065fd..0add81a 100644 --- a/pki/src/test/java/zeroecho/pki/impl/framework/x509/StreamingDerReaderTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/framework/x509/StreamingDerReaderTest.java @@ -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; diff --git a/pki/src/test/java/zeroecho/pki/impl/framework/x509/bc/BcX509CertificationRequestParserDerTest.java b/pki/src/test/java/zeroecho/pki/impl/framework/x509/bc/BcX509CertificationRequestParserDerTest.java new file mode 100644 index 0000000..eb93752 --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/impl/framework/x509/bc/BcX509CertificationRequestParserDerTest.java @@ -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; + } +}