From 849c8c82cb04924c51a16cf0e85751a5da46c6ac Mon Sep 17 00:00:00 2001 From: Leo Galambos Date: Thu, 30 Jul 2026 20:14:11 +0200 Subject: [PATCH] security(pki): enforce configuration-driven CA profiles * add versioned root and intermediate CA profile documents * extend the strict profile schema with closed certificate kinds * package canonical built-in root and intermediate profiles * enforce immutable import and explicit activation for CA profiles * bind CA credentials to exact profile ID, version, and canonical hash * resolve active profiles for root and intermediate issuance * validate issuer-controlled CA requests before backend execution * enforce complete CA DER and extension postconditions * reject inactive, mismatched, and malicious profile/backend inputs * preserve historical credential bindings across profile activation changes * add root and intermediate profile version-switch coverage BREAKING CHANGE: root and intermediate CA issuance now requires an explicitly imported and activated versioned CA profile. --- .../java/zeroecho/pki/api/ProfileService.java | 10 +- .../pki/api/credential/CaProfileBinding.java | 14 +- .../BuiltInCertificateProfileCatalog.java | 16 +- .../pki/api/profile/CaCertificatePolicy.java | 63 ++ .../zeroecho/pki/api/profile/CaKeyUsage.java | 15 + .../pki/api/profile/CertificatePolicy.java | 11 + .../pki/api/profile/CertificateProfile.java | 18 +- .../profile/CertificateProfileDefinition.java | 74 +- .../CertificateProfileDocumentCodec.java | 365 +++++-- .../api/profile/CertificateProfileKind.java | 17 + .../api/profile/LeafCertificatePolicy.java | 17 +- .../profile/SubjectAlternativeNamePolicy.java | 6 +- .../pki/api/profile/SubjectPolicy.java | 5 +- .../pki/api/profile/package-info.java | 14 +- .../pki/impl/ProfileLifecycleFailure.java | 1 + .../core/CaCertificateProfileValidator.java | 261 +++++ .../zeroecho/pki/impl/core/CaProofGate.java | 32 +- .../core/CertificateProfileValidator.java | 29 +- .../impl/core/CredentialProfileBindings.java | 4 +- .../pki/impl/core/DefaultCaService.java | 333 +++++-- .../pki/impl/core/ManagedCaIssuance.java | 194 ---- .../core/ValidatedCaCertificateRequest.java | 138 +++ .../bc/BcX509CredentialIssuerBackend.java | 236 +---- .../pki/impl/fs/FilesystemPkiStore.java | 11 + .../java/zeroecho/pki/impl/fs/FsCodec.java | 4 +- .../framework/CredentialIssuerBackend.java | 16 +- .../java/zeroecho/pki/spi/store/PkiStore.java | 19 +- .../zeroecho/pki/profiles/v1/catalog.json | 2 +- .../pki/profiles/v1/email-signing.json | 2 +- .../pki/profiles/v1/intermediate-ca.json | 1 + .../zeroecho/pki/profiles/v1/root-ca.json | 1 + .../zeroecho/pki/profiles/v1/server-tls.json | 2 +- .../zeroecho/pki/profiles/v1/vpn-client.json | 2 +- .../zeroecho/pki/profiles/v1/vpn-server.json | 2 +- .../BuiltInCertificateProfileCatalogTest.java | 82 +- .../CertificateProfileDocumentCodecTest.java | 145 ++- .../e2e/CaProfileIssuanceEnforcementTest.java | 919 ++++++++++++++++++ .../pki/e2e/H7EndEntityAcceptanceE2eTest.java | 29 +- .../e2e/H7EndEntityCsrRejectionE2eTest.java | 8 +- .../java/zeroecho/pki/e2e/PkiCoreE2eTest.java | 30 +- .../zeroecho/pki/e2e/PkiProofGateE2eTest.java | 139 +-- .../DefaultStatusObjectServiceCrlTest.java | 4 +- .../impl/core/H7ProfileEnforcementTest.java | 44 +- ...EffectiveCredentialStatusResolverTest.java | 4 +- .../pki/impl/fs/FilesystemPkiStoreTest.java | 23 +- .../fs/FilesystemProfileLifecycleTest.java | 53 +- .../fs/FilesystemRevocationJournalTest.java | 2 +- .../zeroecho/pki/impl/fs/FsCodecTest.java | 50 +- .../pki/testkit/H7ProfileDocuments.java | 2 +- .../zeroecho/pki/testkit/PkiTestRuntime.java | 38 +- 50 files changed, 2608 insertions(+), 899 deletions(-) create mode 100644 pki/src/main/java/zeroecho/pki/api/profile/CaCertificatePolicy.java create mode 100644 pki/src/main/java/zeroecho/pki/api/profile/CaKeyUsage.java create mode 100644 pki/src/main/java/zeroecho/pki/api/profile/CertificatePolicy.java create mode 100644 pki/src/main/java/zeroecho/pki/api/profile/CertificateProfileKind.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/core/CaCertificateProfileValidator.java delete mode 100644 pki/src/main/java/zeroecho/pki/impl/core/ManagedCaIssuance.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/core/ValidatedCaCertificateRequest.java create mode 100644 pki/src/main/resources/zeroecho/pki/profiles/v1/intermediate-ca.json create mode 100644 pki/src/main/resources/zeroecho/pki/profiles/v1/root-ca.json create mode 100644 pki/src/test/java/zeroecho/pki/e2e/CaProfileIssuanceEnforcementTest.java diff --git a/pki/src/main/java/zeroecho/pki/api/ProfileService.java b/pki/src/main/java/zeroecho/pki/api/ProfileService.java index 5dee9d8..68e8fb6 100644 --- a/pki/src/main/java/zeroecho/pki/api/ProfileService.java +++ b/pki/src/main/java/zeroecho/pki/api/ProfileService.java @@ -16,8 +16,14 @@ import zeroecho.pki.api.profile.ImportedCertificateProfileVersion; /** * Versioned certificate-profile import, activation, and lookup service. * - *

Import never activates a profile. End-entity issuance must resolve only an - * explicitly activated persisted version through {@link #requireActiveProfile(String)}.

+ *

+ * Import never activates a profile. All versions of one logical profile ID + * have one immutable certificate kind. End-entity issuance resolves only an + * explicitly activated persisted version through + * {@link #requireActiveProfile(String)}. Root and intermediate issuance use the + * same authoritative active-profile lookup and bind issued credentials to the + * exact resolved version. + *

*/ public interface ProfileService { /** Imports a bounded strict JSON profile document. */ diff --git a/pki/src/main/java/zeroecho/pki/api/credential/CaProfileBinding.java b/pki/src/main/java/zeroecho/pki/api/credential/CaProfileBinding.java index 6de67d8..82c8d04 100644 --- a/pki/src/main/java/zeroecho/pki/api/credential/CaProfileBinding.java +++ b/pki/src/main/java/zeroecho/pki/api/credential/CaProfileBinding.java @@ -4,16 +4,18 @@ ******************************************************************************/ package zeroecho.pki.api.credential; +import java.util.Objects; + +import zeroecho.pki.api.profile.CertificateProfileRef; + /** - * Explicit CA policy identity for a root or intermediate credential. + * Exact versioned CA profile identity for a root or intermediate credential. * - * @param profileId existing CA policy identifier + * @param reference imported CA profile version used for issuance */ -public record CaProfileBinding(String profileId) implements CredentialProfileBinding { +public record CaProfileBinding(CertificateProfileRef reference) implements CredentialProfileBinding { /** Creates a CA profile binding. */ public CaProfileBinding { - if (profileId == null || profileId.isBlank()) { - throw new IllegalArgumentException("profileId must not be null/blank"); - } + Objects.requireNonNull(reference, "reference"); } } diff --git a/pki/src/main/java/zeroecho/pki/api/profile/BuiltInCertificateProfileCatalog.java b/pki/src/main/java/zeroecho/pki/api/profile/BuiltInCertificateProfileCatalog.java index 5269ef3..0953fce 100644 --- a/pki/src/main/java/zeroecho/pki/api/profile/BuiltInCertificateProfileCatalog.java +++ b/pki/src/main/java/zeroecho/pki/api/profile/BuiltInCertificateProfileCatalog.java @@ -32,7 +32,7 @@ import tools.jackson.core.json.JsonReadFeature; import zeroecho.pki.api.PkiException; /** - * Loader for the fixed built-in end-entity profile resource catalogue. + * Loader for the fixed built-in certificate-profile resource catalogue. * *

* The catalogue is stateless and thread-safe. Every load reparses the packaged @@ -68,7 +68,8 @@ public final class BuiltInCertificateProfileCatalog { private static final String ERROR_PREFIX = "Built-in certificate profile catalogue rejected: code="; private static final Set EXPECTED_PROFILE_IDS = - Set.of("server-tls", "vpn-server", "vpn-client", "email-signing"); + Set.of("server-tls", "vpn-server", "vpn-client", "email-signing", + "root-ca", "intermediate-ca"); private BuiltInCertificateProfileCatalog() { } @@ -222,6 +223,9 @@ public final class BuiltInCertificateProfileCatalog { private final Set identities = new HashSet<>(); private final Set hashes = new HashSet<>(); private final Set profileIds = new HashSet<>(); + private int endEntityCount; + private int rootCount; + private int intermediateCount; private CatalogueAccumulator(int expectedSize) { templates = new ArrayList<>(expectedSize); @@ -240,12 +244,18 @@ public final class BuiltInCertificateProfileCatalog { || !profileIds.add(definition.profileId())) { throw failure("BUILT_IN_PROFILE_SET_INVALID"); } + switch (definition.certificateType()) { + case END_ENTITY -> endEntityCount++; + case ROOT_CA -> rootCount++; + case INTERMEDIATE_CA -> intermediateCount++; + } templates.add(template); } private List finish() { if (templates.size() != EXPECTED_PROFILE_IDS.size() - || !profileIds.equals(EXPECTED_PROFILE_IDS)) { + || !profileIds.equals(EXPECTED_PROFILE_IDS) + || endEntityCount != 4 || rootCount != 1 || intermediateCount != 1) { throw failure("BUILT_IN_PROFILE_SET_INVALID"); } return List.copyOf(templates); diff --git a/pki/src/main/java/zeroecho/pki/api/profile/CaCertificatePolicy.java b/pki/src/main/java/zeroecho/pki/api/profile/CaCertificatePolicy.java new file mode 100644 index 0000000..684c078 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/profile/CaCertificatePolicy.java @@ -0,0 +1,63 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.api.profile; + +import java.util.Set; + +/** + * Immutable issuer-controlled CA certificate policy. + * + *

+ * CA status is always {@code true}. Basic Constraints and Key Usage are always + * critical. SAN and EKU are not representable for CA profiles. Subject and + * authority key identifiers, when emitted by a CA implementation, remain fixed + * repository invariants rather than configurable profile values. + *

+ * + * @param basicConstraintsCritical Basic Constraints criticality, required + * to be {@code true} + * @param pathLengthConstraint nonnegative maximum subordinate-CA depth + * @param keyUsageCritical Key Usage criticality, required to be + * {@code true} + * @param keyUsages exact CA key-usage set + * @param allowedSubjectKeyAlgorithmIds exact canonical ZeroEcho subject-key + * algorithm identifiers + */ +public record CaCertificatePolicy(boolean basicConstraintsCritical, int pathLengthConstraint, + boolean keyUsageCritical, Set keyUsages, + Set allowedSubjectKeyAlgorithmIds) implements CertificatePolicy { + + /** Maximum supported path-length constraint. */ + public static final int MAXIMUM_PATH_LENGTH = 32; + + private static final Set SUPPORTED_SUBJECT_KEY_ALGORITHMS = + Set.of("RSA", "ECDSA", "Ed25519", "Ed448"); + + /** Validates and constructs the CA policy. */ + public CaCertificatePolicy { + if (!basicConstraintsCritical) { + throw new IllegalArgumentException("CA Basic Constraints must be critical"); + } + if (pathLengthConstraint < 0 || pathLengthConstraint > MAXIMUM_PATH_LENGTH) { + throw new IllegalArgumentException("CA path length is outside the supported range"); + } + if (!keyUsageCritical) { + throw new IllegalArgumentException("CA Key Usage must be critical"); + } + if (keyUsages == null || allowedSubjectKeyAlgorithmIds == null) { + throw new IllegalArgumentException("CA policy collections must not be null"); + } + keyUsages = Set.copyOf(keyUsages); + allowedSubjectKeyAlgorithmIds = Set.copyOf(allowedSubjectKeyAlgorithmIds); + if (!keyUsages.contains(CaKeyUsage.KEY_CERT_SIGN) + || !keyUsages.contains(CaKeyUsage.CRL_SIGN)) { + throw new IllegalArgumentException("CA Key Usage must permit certificate and CRL signing"); + } + if (allowedSubjectKeyAlgorithmIds.isEmpty() + || !SUPPORTED_SUBJECT_KEY_ALGORITHMS.containsAll(allowedSubjectKeyAlgorithmIds)) { + throw new IllegalArgumentException("At least one supported CA subject-key algorithm is required"); + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/api/profile/CaKeyUsage.java b/pki/src/main/java/zeroecho/pki/api/profile/CaKeyUsage.java new file mode 100644 index 0000000..b418385 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/profile/CaKeyUsage.java @@ -0,0 +1,15 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.api.profile; + +/** + * Closed X.509 key-usage set available to CA certificate profiles. + */ +public enum CaKeyUsage { + /** keyCertSign. */ + KEY_CERT_SIGN, + /** cRLSign. */ + CRL_SIGN +} diff --git a/pki/src/main/java/zeroecho/pki/api/profile/CertificatePolicy.java b/pki/src/main/java/zeroecho/pki/api/profile/CertificatePolicy.java new file mode 100644 index 0000000..16a1bb1 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/profile/CertificatePolicy.java @@ -0,0 +1,11 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.api.profile; + +/** + * Closed certificate-specific policy variant. + */ +public sealed interface CertificatePolicy permits LeafCertificatePolicy, CaCertificatePolicy { +} diff --git a/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfile.java b/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfile.java index 006d7ca..f679d68 100644 --- a/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfile.java +++ b/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfile.java @@ -33,6 +33,8 @@ ******************************************************************************/ package zeroecho.pki.api.profile; +import java.time.Duration; + import zeroecho.pki.api.FormatId; /** @@ -51,10 +53,12 @@ import zeroecho.pki.api.FormatId; * @param profileId stable profile identifier * @param formatId framework/format supported by the profile * @param displayName human-readable name - * @param leafPolicy complete end-entity identity and extension policy + * @param maximumValidity positive maximum validity + * @param subjectPolicy complete end-entity subject policy + * @param leafPolicy complete end-entity extension policy */ public record CertificateProfile(String profileId, FormatId formatId, String displayName, - LeafCertificatePolicy leafPolicy) { + Duration maximumValidity, SubjectPolicy subjectPolicy, LeafCertificatePolicy leafPolicy) { /** * Creates a certificate profile. @@ -72,8 +76,9 @@ public record CertificateProfile(String profileId, FormatId formatId, String dis if (displayName == null || displayName.isBlank()) { throw new IllegalArgumentException("displayName must not be null/blank"); } - if (leafPolicy == null) { - throw new IllegalArgumentException("leafPolicy must not be null"); + if (maximumValidity == null || maximumValidity.isZero() || maximumValidity.isNegative() + || subjectPolicy == null || leafPolicy == null) { + throw new IllegalArgumentException("profile policies and maximum validity must be valid"); } } @@ -87,7 +92,10 @@ public record CertificateProfile(String profileId, FormatId formatId, String dis if (definition == null) { throw new IllegalArgumentException("definition must not be null"); } + if (definition.certificateType() != CertificateProfileKind.END_ENTITY) { + throw new IllegalArgumentException("Only end-entity definitions have a runtime issuance projection"); + } return new CertificateProfile(definition.profileId(), definition.formatId(), definition.displayName(), - definition.leafPolicy()); + definition.maximumValidity(), definition.subjectPolicy(), definition.leafPolicy()); } } diff --git a/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfileDefinition.java b/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfileDefinition.java index 495c52b..c8cbcce 100644 --- a/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfileDefinition.java +++ b/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfileDefinition.java @@ -4,28 +4,35 @@ ******************************************************************************/ package zeroecho.pki.api.profile; +import java.time.Duration; + import zeroecho.pki.api.FormatId; /** * Immutable, versioned certificate-profile configuration. * *

- * This definition deliberately excludes runtime activation state. Its - * {@link LeafCertificatePolicy} is the authoritative typed policy used by the - * issuance path. + * Schema version 2 replaces the pre-release version 1 shape. The certificate + * kind and its closed policy variant are immutable across the definition. + * Runtime activation state is deliberately excluded. *

* - * @param profileId stable profile identifier - * @param profileVersion positive configuration version - * @param formatId framework/format identifier - * @param displayName human-readable profile name - * @param leafPolicy complete leaf certificate policy + * @param certificateType kind of certificate governed by the profile + * @param profileId stable profile identifier + * @param profileVersion positive configuration version + * @param formatId framework/format identifier + * @param displayName human-readable profile name + * @param maximumValidity positive maximum validity + * @param subjectPolicy complete subject policy + * @param certificatePolicy closed certificate-specific policy variant */ -public record CertificateProfileDefinition(String profileId, long profileVersion, FormatId formatId, - String displayName, LeafCertificatePolicy leafPolicy) { +public record CertificateProfileDefinition(CertificateProfileKind certificateType, + String profileId, long profileVersion, FormatId formatId, String displayName, + Duration maximumValidity, SubjectPolicy subjectPolicy, + CertificatePolicy certificatePolicy) { /** Current certificate-profile document schema version. */ - public static final int SCHEMA_VERSION = 1; + public static final int SCHEMA_VERSION = 2; /** * Creates a certificate-profile definition. @@ -34,6 +41,9 @@ public record CertificateProfileDefinition(String profileId, long profileVersion * profile version is not positive */ public CertificateProfileDefinition { + if (certificateType == null) { + throw new IllegalArgumentException("certificateType must not be null"); + } if (profileId == null || profileId.isBlank()) { throw new IllegalArgumentException("profileId must not be null/blank"); } @@ -46,8 +56,46 @@ public record CertificateProfileDefinition(String profileId, long profileVersion if (displayName == null || displayName.isBlank()) { throw new IllegalArgumentException("displayName must not be null/blank"); } - if (leafPolicy == null) { - throw new IllegalArgumentException("leafPolicy must not be null"); + if (maximumValidity == null || maximumValidity.isZero() || maximumValidity.isNegative()) { + throw new IllegalArgumentException("maximumValidity must be positive"); + } + if (subjectPolicy == null || certificatePolicy == null) { + throw new IllegalArgumentException("profile policies must not be null"); + } + boolean leaf = certificatePolicy instanceof LeafCertificatePolicy; + if (certificateType == CertificateProfileKind.END_ENTITY != leaf) { + throw new IllegalArgumentException("certificate type and policy variant do not match"); + } + if (certificateType != CertificateProfileKind.END_ENTITY + && (subjectPolicy.allowEmpty() || subjectPolicy.rules().isEmpty() + || subjectPolicy.rules().stream().noneMatch(rule -> rule.minimumOccurrences() > 0))) { + throw new IllegalArgumentException("CA profiles require a nonempty subject policy"); } } + + /** + * Returns the end-entity policy. + * + * @return leaf policy + * @throws IllegalStateException if this is a CA profile + */ + public LeafCertificatePolicy leafPolicy() { + if (certificatePolicy instanceof LeafCertificatePolicy leaf) { + return leaf; + } + throw new IllegalStateException("Profile is not an end-entity profile"); + } + + /** + * Returns the CA policy. + * + * @return CA policy + * @throws IllegalStateException if this is an end-entity profile + */ + public CaCertificatePolicy caPolicy() { + if (certificatePolicy instanceof CaCertificatePolicy ca) { + return ca; + } + throw new IllegalStateException("Profile is not a CA profile"); + } } diff --git a/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfileDocumentCodec.java b/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfileDocumentCodec.java index ff5c11a..3b59767 100644 --- a/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfileDocumentCodec.java +++ b/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfileDocumentCodec.java @@ -34,7 +34,7 @@ import zeroecho.pki.api.FormatId; import zeroecho.pki.api.PkiException; /** - * Strict JSON parser and canonical writer for version 1 certificate-profile + * Strict JSON parser and canonical writer for version 2 certificate-profile * documents. * *

@@ -42,9 +42,10 @@ import zeroecho.pki.api.PkiException; * tokens and creates the existing typed policy model directly. *

*/ +// The closed streaming grammar intentionally keeps all bounded field handlers in one codec. @SuppressWarnings({ "PMD.AvoidDuplicateLiterals", "PMD.AvoidInstantiatingObjectsInLoops", "PMD.AvoidUncheckedExceptionsInSignatures", "PMD.CyclomaticComplexity", - "PMD.PreserveStackTrace" }) + "PMD.PreserveStackTrace", "PMD.TooManyMethods" }) public final class CertificateProfileDocumentCodec { /** Maximum accepted encoded document size. */ @@ -157,7 +158,7 @@ public final class CertificateProfileDocumentCodec { * @return newly allocated canonical JSON bytes without a BOM or trailing * whitespace * @throws PkiException if the definition cannot be represented by schema - * version 1 + * version 2 */ public static byte[] writeCanonical(CertificateProfileDefinition definition) { validateDefinition(definition); @@ -209,92 +210,155 @@ public final class CertificateProfileDocumentCodec { private static CertificateProfileDefinition parseDocument(JsonParser parser) throws JacksonException { requireToken(parser.nextToken(), JsonToken.START_OBJECT, "$"); - long seen = 0; - int schemaVersion = 0; - String profileId = null; - long profileVersion = 0; - String formatId = null; - String displayName = null; - Duration maximumValidity = null; - SubjectSection subject = null; - SanSection san = null; - LeafSection leaf = null; + DocumentFields document = new DocumentFields(); while (parser.nextToken() != JsonToken.END_OBJECT) { requireToken(parser.currentToken(), JsonToken.PROPERTY_NAME, "$"); String field = parser.currentName(); requireValue(parser, "$." + field); - switch (field) { - case "schemaVersion" -> { - seen = mark(seen, 0, "$.schemaVersion"); - schemaVersion = readInt(parser, "$.schemaVersion"); - } - case "profileId" -> { - seen = mark(seen, 1, "$.profileId"); - profileId = readBoundedString(parser, "$.profileId", MAXIMUM_PROFILE_ID_UTF8_BYTES); - } - case "profileVersion" -> { - seen = mark(seen, 2, "$.profileVersion"); - profileVersion = readLong(parser, "$.profileVersion"); - } - case "formatId" -> { - seen = mark(seen, 3, "$.formatId"); - formatId = readBoundedString(parser, "$.formatId", MAXIMUM_FORMAT_ID_UTF8_BYTES); - } - case "displayName" -> { - seen = mark(seen, 4, "$.displayName"); - displayName = readBoundedString(parser, "$.displayName", MAXIMUM_DISPLAY_NAME_UTF8_BYTES); - } - case "maxValidity" -> { - seen = mark(seen, 5, "$.maxValidity"); - maximumValidity = readDuration(parser, "$.maxValidity"); - } - case "subject" -> { - seen = mark(seen, 6, "$.subject"); - subject = readSubject(parser, "$.subject"); - } - case "subjectAlternativeNames" -> { - seen = mark(seen, 7, "$.subjectAlternativeNames"); - san = readSan(parser, "$.subjectAlternativeNames"); - } - case "leafCertificate" -> { - seen = mark(seen, 8, "$.leafCertificate"); - leaf = readLeaf(parser, "$.leafCertificate"); - } - default -> throw failure("UNKNOWN_FIELD", "$.?"); - } + readDocumentField(parser, field, document); } - requireAll(seen, 9, "$"); - if (schemaVersion != CertificateProfileDefinition.SCHEMA_VERSION) { + requireAll(document.seen, 8, "$"); + if (document.schemaVersion != CertificateProfileDefinition.SCHEMA_VERSION) { throw failure("SCHEMA_VERSION_UNSUPPORTED", "$.schemaVersion"); } - if (profileVersion <= 0) { + if (document.profileVersion <= 0) { throw failure("PROFILE_VERSION_INVALID", "$.profileVersion"); } - validateProfileString(profileId, MAXIMUM_PROFILE_ID_UTF8_BYTES, "$.profileId", + validateProfileString(document.profileId, MAXIMUM_PROFILE_ID_UTF8_BYTES, "$.profileId", "TOKEN_INVALID"); - validateProfileString(formatId, MAXIMUM_FORMAT_ID_UTF8_BYTES, "$.formatId", + validateProfileString(document.formatId, MAXIMUM_FORMAT_ID_UTF8_BYTES, "$.formatId", "TOKEN_INVALID"); - validateProfileString(displayName, MAXIMUM_DISPLAY_NAME_UTF8_BYTES, "$.displayName", + validateProfileString(document.displayName, MAXIMUM_DISPLAY_NAME_UTF8_BYTES, "$.displayName", "TOKEN_INVALID"); - return constructDefinition(profileId, profileVersion, formatId, displayName, maximumValidity, - subject, san, leaf); + requirePolicyFields(document.certificateType, document.seen); + return constructDefinition(document); } - private static CertificateProfileDefinition constructDefinition(String profileId, long profileVersion, - String formatId, String displayName, Duration maximumValidity, SubjectSection subject, - SanSection san, LeafSection leaf) { + private static void readDocumentField(JsonParser parser, String field, DocumentFields document) + throws JacksonException { + switch (field) { + case "schemaVersion" -> { + document.seen = mark(document.seen, 0, "$.schemaVersion"); + document.schemaVersion = readInt(parser, "$.schemaVersion"); + } + case "certificateType" -> { + document.seen = mark(document.seen, 1, "$.certificateType"); + document.certificateType = readCertificateType(parser, "$.certificateType"); + } + case "profileId" -> { + document.seen = mark(document.seen, 2, "$.profileId"); + document.profileId = readBoundedString(parser, "$.profileId", MAXIMUM_PROFILE_ID_UTF8_BYTES); + } + case "profileVersion" -> { + document.seen = mark(document.seen, 3, "$.profileVersion"); + document.profileVersion = readLong(parser, "$.profileVersion"); + } + case "formatId" -> { + document.seen = mark(document.seen, 4, "$.formatId"); + document.formatId = readBoundedString(parser, "$.formatId", MAXIMUM_FORMAT_ID_UTF8_BYTES); + } + case "displayName" -> { + document.seen = mark(document.seen, 5, "$.displayName"); + document.displayName = + readBoundedString(parser, "$.displayName", MAXIMUM_DISPLAY_NAME_UTF8_BYTES); + } + case "maxValidity" -> { + document.seen = mark(document.seen, 6, "$.maxValidity"); + document.maximumValidity = readDuration(parser, "$.maxValidity"); + } + case "subject" -> { + document.seen = mark(document.seen, 7, "$.subject"); + document.subject = readSubject(parser, "$.subject"); + } + case "subjectAlternativeNames" -> readDocumentSan(parser, document); + case "leafCertificate" -> readDocumentLeaf(parser, document); + case "caCertificate" -> readDocumentCa(parser, document); + default -> throw failure("UNKNOWN_FIELD", "$.?"); + } + } + + private static void readDocumentSan(JsonParser parser, DocumentFields document) + throws JacksonException { + if (document.certificateType != null + && document.certificateType != CertificateProfileKind.END_ENTITY) { + throw failure("FIELD_FORBIDDEN_FOR_CERTIFICATE_TYPE", "$.subjectAlternativeNames"); + } + document.seen = mark(document.seen, 8, "$.subjectAlternativeNames"); + document.san = readSan(parser, "$.subjectAlternativeNames"); + } + + private static void readDocumentLeaf(JsonParser parser, DocumentFields document) + throws JacksonException { + if (document.certificateType != null + && document.certificateType != CertificateProfileKind.END_ENTITY) { + throw failure("FIELD_FORBIDDEN_FOR_CERTIFICATE_TYPE", "$.leafCertificate"); + } + document.seen = mark(document.seen, 9, "$.leafCertificate"); + document.leaf = readLeaf(parser, "$.leafCertificate"); + } + + private static void readDocumentCa(JsonParser parser, DocumentFields document) + throws JacksonException { + if (document.certificateType == CertificateProfileKind.END_ENTITY) { + throw failure("FIELD_FORBIDDEN_FOR_CERTIFICATE_TYPE", "$.caCertificate"); + } + document.seen = mark(document.seen, 10, "$.caCertificate"); + document.ca = readCa(parser, "$.caCertificate"); + } + + private static void requirePolicyFields(CertificateProfileKind certificateType, long seen) { + boolean hasSan = isSeen(seen, 8); + boolean hasLeaf = isSeen(seen, 9); + boolean hasCa = isSeen(seen, 10); + if (certificateType == CertificateProfileKind.END_ENTITY) { + if (hasCa) { + throw failure("FIELD_FORBIDDEN_FOR_CERTIFICATE_TYPE", "$.caCertificate"); + } + if (!hasSan || !hasLeaf) { + throw failure("MISSING_FIELD", "$"); + } + } else { + if (hasSan || hasLeaf) { + throw failure("FIELD_FORBIDDEN_FOR_CERTIFICATE_TYPE", "$"); + } + if (!hasCa) { + throw failure("MISSING_FIELD", "$.caCertificate"); + } + } + } + + /* + * Typed policy constructors are the authoritative semantic validators, so + * their validation exceptions are deliberately normalized at this boundary. + */ + @SuppressWarnings("PMD.ExceptionAsFlowControl") + private static CertificateProfileDefinition constructDefinition(DocumentFields document) { try { - SubjectPolicy subjectPolicy = new SubjectPolicy(subject.rules()); - SubjectAlternativeNamePolicy sanPolicy = new SubjectAlternativeNamePolicy(subject.allowEmpty(), - san.minimumTotal(), san.maximumTotal(), san.rules(), san.wildcardAllowed(), - san.allowedSchemes(), san.criticalWhenSubjectNonEmpty(), san.serviceIdentityRequired(), - san.emailIdentityRequired()); - LeafCertificatePolicy leafPolicy = new LeafCertificatePolicy(subjectPolicy, sanPolicy, - leaf.keyUsage(), leaf.extendedKeyUsage(), leaf.keyUsageCritical(), - leaf.extendedKeyUsageCritical(), leaf.basicConstraintsCritical(), - leaf.allowedKeyAlgorithms(), maximumValidity); - return new CertificateProfileDefinition(profileId, profileVersion, new FormatId(formatId), - displayName, leafPolicy); + SubjectPolicy subjectPolicy = new SubjectPolicy(document.subject.allowEmpty(), + document.subject.rules()); + CertificatePolicy policy; + if (document.certificateType == CertificateProfileKind.END_ENTITY) { + SubjectAlternativeNamePolicy sanPolicy = new SubjectAlternativeNamePolicy( + document.san.minimumTotal(), document.san.maximumTotal(), document.san.rules(), + document.san.wildcardAllowed(), document.san.allowedSchemes(), + document.san.criticalWhenSubjectNonEmpty(), + document.san.serviceIdentityRequired(), document.san.emailIdentityRequired()); + if (document.subject.allowEmpty() && document.san.minimumTotal() < 1) { + throw new IllegalArgumentException("An empty subject requires at least one SAN"); + } + policy = new LeafCertificatePolicy(sanPolicy, document.leaf.keyUsage(), + document.leaf.extendedKeyUsage(), document.leaf.keyUsageCritical(), + document.leaf.extendedKeyUsageCritical(), + document.leaf.basicConstraintsCritical(), + document.leaf.allowedKeyAlgorithms()); + } else { + policy = new CaCertificatePolicy(document.ca.basicConstraintsCritical(), + document.ca.pathLengthConstraint(), document.ca.keyUsageCritical(), + document.ca.keyUsages(), document.ca.allowedKeyAlgorithms()); + } + return new CertificateProfileDefinition(document.certificateType, document.profileId, + document.profileVersion, new FormatId(document.formatId), document.displayName, + document.maximumValidity, subjectPolicy, policy); } catch (IllegalArgumentException | ArithmeticException ex) { throw failure("SEMANTIC_INVALID", "$"); } @@ -612,6 +676,66 @@ public final class CertificateProfileDocumentCodec { return Set.copyOf(values); } + private static CaSection readCa(JsonParser parser, String path) throws JacksonException { + requireToken(parser.currentToken(), JsonToken.START_OBJECT, path); + long seen = 0; + boolean basicCritical = false; + int pathLength = -1; + boolean keyCritical = false; + Set keyUsages = null; + Set algorithms = null; + while (parser.nextToken() != JsonToken.END_OBJECT) { + requireToken(parser.currentToken(), JsonToken.PROPERTY_NAME, path); + String field = parser.currentName(); + requireValue(parser, path + "." + field); + switch (field) { + case "basicConstraintsCritical" -> { + seen = mark(seen, 0, path + ".basicConstraintsCritical"); + basicCritical = readBoolean(parser, path + ".basicConstraintsCritical"); + } + case "pathLengthConstraint" -> { + seen = mark(seen, 1, path + ".pathLengthConstraint"); + pathLength = readInt(parser, path + ".pathLengthConstraint"); + } + case "keyUsageCritical" -> { + seen = mark(seen, 2, path + ".keyUsageCritical"); + keyCritical = readBoolean(parser, path + ".keyUsageCritical"); + } + case "keyUsages" -> { + seen = mark(seen, 3, path + ".keyUsages"); + keyUsages = readCaKeyUsages(parser, path + ".keyUsages"); + } + case "allowedSubjectKeyAlgorithms" -> { + seen = mark(seen, 4, path + ".allowedSubjectKeyAlgorithms"); + algorithms = readAlgorithms(parser, path + ".allowedSubjectKeyAlgorithms"); + } + default -> throw failure("UNKNOWN_FIELD", path + ".?"); + } + } + requireAll(seen, 5, path); + return new CaSection(basicCritical, pathLength, keyCritical, keyUsages, algorithms); + } + + private static Set readCaKeyUsages(JsonParser parser, String path) + throws JacksonException { + requireToken(parser.currentToken(), JsonToken.START_ARRAY, path); + Set values = EnumSet.noneOf(CaKeyUsage.class); + while (parser.nextToken() != JsonToken.END_ARRAY) { + checkArrayBound(values.size(), path); + String token = readString(parser, path + "[" + values.size() + "]"); + CaKeyUsage value; + try { + value = CaKeyUsage.valueOf(token); + } catch (IllegalArgumentException ex) { + throw failure("TOKEN_INVALID", path); + } + if (!values.add(value)) { + throw failure("SEMANTIC_INVALID", path); + } + } + return Set.copyOf(values); + } + private static Set readExtendedKeyUsages(JsonParser parser, String path) throws JacksonException { requireToken(parser.currentToken(), JsonToken.START_ARRAY, path); @@ -650,27 +774,31 @@ public final class CertificateProfileDocumentCodec { private static void writeDocument(JsonGenerator generator, CertificateProfileDefinition definition) throws JacksonException { - LeafCertificatePolicy policy = definition.leafPolicy(); generator.writeStartObject(); generator.writeNumberProperty("schemaVersion", CertificateProfileDefinition.SCHEMA_VERSION); + generator.writeStringProperty("certificateType", definition.certificateType().name()); generator.writeStringProperty("profileId", definition.profileId()); generator.writeNumberProperty("profileVersion", definition.profileVersion()); generator.writeStringProperty("formatId", definition.formatId().value()); generator.writeStringProperty("displayName", definition.displayName()); - generator.writeStringProperty("maxValidity", policy.maximumValidity().toString()); - writeSubject(generator, policy); - writeSan(generator, policy.subjectAlternativeNamePolicy()); - writeLeaf(generator, policy); + generator.writeStringProperty("maxValidity", definition.maximumValidity().toString()); + writeSubject(generator, definition.subjectPolicy()); + if (definition.certificateType() == CertificateProfileKind.END_ENTITY) { + LeafCertificatePolicy leaf = definition.leafPolicy(); + writeSan(generator, leaf.subjectAlternativeNamePolicy()); + writeLeaf(generator, leaf); + } else { + writeCa(generator, definition.caPolicy()); + } generator.writeEndObject(); } - private static void writeSubject(JsonGenerator generator, LeafCertificatePolicy policy) + private static void writeSubject(JsonGenerator generator, SubjectPolicy policy) throws JacksonException { generator.writeObjectPropertyStart("subject"); - generator.writeBooleanProperty("allowEmpty", - policy.subjectAlternativeNamePolicy().allowEmptySubject()); + generator.writeBooleanProperty("allowEmpty", policy.allowEmpty()); generator.writeArrayPropertyStart("rules"); - for (SubjectRdnRule rule : policy.subjectPolicy().rules()) { + for (SubjectRdnRule rule : policy.rules()) { generator.writeStartObject(); generator.writeStringProperty("oid", rule.type().oid()); generator.writeStringProperty("source", @@ -740,6 +868,20 @@ public final class CertificateProfileDocumentCodec { generator.writeEndObject(); } + private static void writeCa(JsonGenerator generator, CaCertificatePolicy policy) + throws JacksonException { + generator.writeObjectPropertyStart("caCertificate"); + generator.writeBooleanProperty("basicConstraintsCritical", + policy.basicConstraintsCritical()); + generator.writeNumberProperty("pathLengthConstraint", policy.pathLengthConstraint()); + generator.writeBooleanProperty("keyUsageCritical", policy.keyUsageCritical()); + writeSortedStrings(generator, "keyUsages", + policy.keyUsages().stream().map(Enum::name).toList()); + writeSortedStrings(generator, "allowedSubjectKeyAlgorithms", + policy.allowedSubjectKeyAlgorithmIds()); + generator.writeEndObject(); + } + private static void writeSortedStrings(JsonGenerator generator, String field, java.util.Collection values) throws JacksonException { generator.writeArrayPropertyStart(field); @@ -759,11 +901,25 @@ public final class CertificateProfileDocumentCodec { "$.formatId", "CANONICALIZATION_FAILED"); validateProfileString(definition.displayName(), MAXIMUM_DISPLAY_NAME_UTF8_BYTES, "$.displayName", "CANONICALIZATION_FAILED"); - LeafCertificatePolicy leaf = definition.leafPolicy(); - validateWritableString(leaf.maximumValidity().toString(), MAXIMUM_STRING_UTF8_BYTES, + validateWritableString(definition.maximumValidity().toString(), MAXIMUM_STRING_UTF8_BYTES, "$.maxValidity"); - if (leaf.subjectPolicy().rules().size() > MAXIMUM_ARRAY_ELEMENTS - || leaf.subjectAlternativeNamePolicy().rules().size() > MAXIMUM_ARRAY_ELEMENTS + if (definition.subjectPolicy().rules().size() > MAXIMUM_ARRAY_ELEMENTS) { + throw failure("CANONICALIZATION_FAILED", "$"); + } + for (SubjectRdnRule rule : definition.subjectPolicy().rules()) { + rule.fixedValue().ifPresent(value -> validateWritableString(value, + MAXIMUM_STRING_UTF8_BYTES, "$.subject.rules.fixedValue")); + } + if (definition.certificateType() != CertificateProfileKind.END_ENTITY) { + CaCertificatePolicy ca = definition.caPolicy(); + if (ca.keyUsages().size() > MAXIMUM_ARRAY_ELEMENTS + || ca.allowedSubjectKeyAlgorithmIds().size() > MAXIMUM_ARRAY_ELEMENTS) { + throw failure("CANONICALIZATION_FAILED", "$"); + } + return; + } + LeafCertificatePolicy leaf = definition.leafPolicy(); + if (leaf.subjectAlternativeNamePolicy().rules().size() > MAXIMUM_ARRAY_ELEMENTS || leaf.subjectAlternativeNamePolicy().allowedUriSchemes().size() > MAXIMUM_ARRAY_ELEMENTS || leaf.keyUsages().size() > MAXIMUM_ARRAY_ELEMENTS @@ -771,10 +927,6 @@ public final class CertificateProfileDocumentCodec { || leaf.allowedSubjectKeyAlgorithmIds().size() > MAXIMUM_ARRAY_ELEMENTS) { throw failure("CANONICALIZATION_FAILED", "$"); } - for (SubjectRdnRule rule : leaf.subjectPolicy().rules()) { - rule.fixedValue().ifPresent(value -> validateWritableString(value, - MAXIMUM_STRING_UTF8_BYTES, "$.subject.rules.fixedValue")); - } for (String scheme : leaf.subjectAlternativeNamePolicy().allowedUriSchemes()) { validateWritableString(scheme, MAXIMUM_URI_SCHEME_ASCII_BYTES, "$.subjectAlternativeNames.rules.allowedSchemes"); @@ -869,6 +1021,16 @@ public final class CertificateProfileDocumentCodec { } } + private static CertificateProfileKind readCertificateType(JsonParser parser, String path) + throws JacksonException { + String token = readString(parser, path); + try { + return CertificateProfileKind.valueOf(token); + } catch (IllegalArgumentException ex) { + throw failure("CERTIFICATE_TYPE_UNSUPPORTED", path); + } + } + private static boolean parseSubjectSource(String source, String path) { if (REQUESTER_SOURCE.equals(source)) { return false; @@ -972,6 +1134,22 @@ public final class CertificateProfileDocumentCodec { return new PkiException(PREFIX + code + " path=" + path); } + /** Mutable state confined to one streaming top-level document parse. */ + private static final class DocumentFields { + private long seen; + private int schemaVersion; + private CertificateProfileKind certificateType; + private String profileId; + private long profileVersion; + private String formatId; + private String displayName; + private Duration maximumValidity; + private SubjectSection subject; + private SanSection san; + private LeafSection leaf; + private CaSection ca; + } + private record SubjectSection(boolean allowEmpty, List rules) { } @@ -993,4 +1171,9 @@ public final class CertificateProfileDocumentCodec { Set keyUsage, boolean extendedKeyUsageCritical, Set extendedKeyUsage, Set allowedKeyAlgorithms) { } + + private record CaSection(boolean basicConstraintsCritical, int pathLengthConstraint, + boolean keyUsageCritical, Set keyUsages, + Set allowedKeyAlgorithms) { + } } diff --git a/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfileKind.java b/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfileKind.java new file mode 100644 index 0000000..06c63ac --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfileKind.java @@ -0,0 +1,17 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.api.profile; + +/** + * Closed certificate category governed by a profile definition. + */ +public enum CertificateProfileKind { + /** End-entity certificate. */ + END_ENTITY, + /** Self-signed root certification-authority certificate. */ + ROOT_CA, + /** Issuer-signed intermediate certification-authority certificate. */ + INTERMEDIATE_CA +} diff --git a/pki/src/main/java/zeroecho/pki/api/profile/LeafCertificatePolicy.java b/pki/src/main/java/zeroecho/pki/api/profile/LeafCertificatePolicy.java index f88878f..0e5021f 100644 --- a/pki/src/main/java/zeroecho/pki/api/profile/LeafCertificatePolicy.java +++ b/pki/src/main/java/zeroecho/pki/api/profile/LeafCertificatePolicy.java @@ -4,13 +4,11 @@ ******************************************************************************/ package zeroecho.pki.api.profile; -import java.time.Duration; import java.util.Set; /** * Complete issuer-controlled leaf certificate extension and identity policy. * - * @param subjectPolicy subject policy * @param subjectAlternativeNamePolicy SAN policy * @param keyUsages exact key-usage set * @param extendedKeyUsages exact extended-key-usage set @@ -18,12 +16,12 @@ import java.util.Set; * @param extendedKeyUsageCritical extended-key-usage criticality * @param basicConstraintsCritical BasicConstraints criticality * @param allowedSubjectKeyAlgorithmIds exact canonical ZeroEcho key algorithm identifiers - * @param maximumValidity positive maximum validity */ -public record LeafCertificatePolicy(SubjectPolicy subjectPolicy, - SubjectAlternativeNamePolicy subjectAlternativeNamePolicy, Set keyUsages, +public record LeafCertificatePolicy(SubjectAlternativeNamePolicy subjectAlternativeNamePolicy, + Set keyUsages, Set extendedKeyUsages, boolean keyUsageCritical, boolean extendedKeyUsageCritical, - boolean basicConstraintsCritical, Set allowedSubjectKeyAlgorithmIds, Duration maximumValidity) { + boolean basicConstraintsCritical, + Set allowedSubjectKeyAlgorithmIds) implements CertificatePolicy { private static final Set SUPPORTED_SUBJECT_KEY_ALGORITHMS = Set.of("RSA", "ECDSA", "Ed25519", "Ed448"); @@ -32,8 +30,8 @@ public record LeafCertificatePolicy(SubjectPolicy subjectPolicy, * Validates and constructs the policy. */ public LeafCertificatePolicy { - if (subjectPolicy == null || subjectAlternativeNamePolicy == null || keyUsages == null - || extendedKeyUsages == null || allowedSubjectKeyAlgorithmIds == null || maximumValidity == null) { + if (subjectAlternativeNamePolicy == null || keyUsages == null + || extendedKeyUsages == null || allowedSubjectKeyAlgorithmIds == null) { throw new IllegalArgumentException("Leaf certificate policy values must not be null"); } keyUsages = Set.copyOf(keyUsages); @@ -47,8 +45,5 @@ public record LeafCertificatePolicy(SubjectPolicy subjectPolicy, && !keyUsages.contains(LeafKeyUsage.KEY_AGREEMENT)) { throw new IllegalArgumentException("encipherOnly and decipherOnly require keyAgreement"); } - if (maximumValidity.isZero() || maximumValidity.isNegative()) { - throw new IllegalArgumentException("Maximum validity must be positive"); - } } } diff --git a/pki/src/main/java/zeroecho/pki/api/profile/SubjectAlternativeNamePolicy.java b/pki/src/main/java/zeroecho/pki/api/profile/SubjectAlternativeNamePolicy.java index 0d5b685..db78980 100644 --- a/pki/src/main/java/zeroecho/pki/api/profile/SubjectAlternativeNamePolicy.java +++ b/pki/src/main/java/zeroecho/pki/api/profile/SubjectAlternativeNamePolicy.java @@ -11,7 +11,6 @@ import java.util.Set; /** * Deny-by-default Subject Alternative Name policy. * - * @param allowEmptySubject whether the subject DN may be empty * @param minimumTotal minimum total SAN count * @param maximumTotal maximum total SAN count * @param rules permitted SAN type rules @@ -21,7 +20,7 @@ import java.util.Set; * @param requireServiceIdentity whether DNS, IP, or URI identity is required * @param requireEmailIdentity whether an RFC822 identity is required */ -public record SubjectAlternativeNamePolicy(boolean allowEmptySubject, int minimumTotal, int maximumTotal, +public record SubjectAlternativeNamePolicy(int minimumTotal, int maximumTotal, List rules, boolean allowDnsWildcard, Set allowedUriSchemes, boolean criticalWithNonemptySubject, boolean requireServiceIdentity, boolean requireEmailIdentity) { @@ -75,8 +74,5 @@ public record SubjectAlternativeNamePolicy(boolean allowEmptySubject, int minimu || uriPossible != !allowedUriSchemes.isEmpty()) { throw new IllegalArgumentException("SAN policy requirements are not satisfiable"); } - if (allowEmptySubject && minimumTotal < 1) { - throw new IllegalArgumentException("An empty subject requires at least one SAN"); - } } } diff --git a/pki/src/main/java/zeroecho/pki/api/profile/SubjectPolicy.java b/pki/src/main/java/zeroecho/pki/api/profile/SubjectPolicy.java index 914ab40..12a4a50 100644 --- a/pki/src/main/java/zeroecho/pki/api/profile/SubjectPolicy.java +++ b/pki/src/main/java/zeroecho/pki/api/profile/SubjectPolicy.java @@ -10,9 +10,10 @@ import java.util.Set; /** * Deny-by-default subject distinguished-name policy. * - * @param rules ordered immutable supported RDN rules + * @param allowEmpty whether an empty subject is permitted + * @param rules ordered immutable supported RDN rules */ -public record SubjectPolicy(List rules) { +public record SubjectPolicy(boolean allowEmpty, List rules) { /** Maximum number of subject RDNs. */ public static final int HARD_MAXIMUM_RDN_COUNT = 32; diff --git a/pki/src/main/java/zeroecho/pki/api/profile/package-info.java b/pki/src/main/java/zeroecho/pki/api/profile/package-info.java index 4357a7b..baf00b7 100644 --- a/pki/src/main/java/zeroecho/pki/api/profile/package-info.java +++ b/pki/src/main/java/zeroecho/pki/api/profile/package-info.java @@ -46,13 +46,13 @@ *

* *

- * Built-in end-entity profile resources are deterministic baseline - * provisioning templates. Loading the catalogue neither persists nor activates - * a profile, and the resources are never implicit issuance defaults. - * Administrator-supplied profiles will use the same strict document schema. - * Canonical JSON and SHA-256 hashes support deterministic provisioning and - * audit. CA profile resources are intentionally absent until the CA profile - * policy model is defined. + * Document schema version 2 distinguishes end-entity, root-CA, and + * intermediate-CA profiles through a closed certificate-policy variant. + * Built-in resources are deterministic provisioning templates. Loading the + * catalogue neither persists nor activates a profile, and resources are never + * implicit issuance defaults. A logical profile ID cannot change certificate + * kind across versions. Active definitions of every supported certificate kind + * are authoritative issuance inputs. *

* * @since 1.0 diff --git a/pki/src/main/java/zeroecho/pki/impl/ProfileLifecycleFailure.java b/pki/src/main/java/zeroecho/pki/impl/ProfileLifecycleFailure.java index 0d066a7..196be45 100644 --- a/pki/src/main/java/zeroecho/pki/impl/ProfileLifecycleFailure.java +++ b/pki/src/main/java/zeroecho/pki/impl/ProfileLifecycleFailure.java @@ -18,6 +18,7 @@ public final class ProfileLifecycleFailure extends PkiException { PROFILE_IMPORT_VALIDATION_FAILED, BUILT_IN_PROFILE_INVALID, PROFILE_VERSION_CONFLICT, + PROFILE_KIND_CONFLICT, PROFILE_VERSION_CORRUPT, PROFILE_ACTIVE_POINTER_CORRUPT, PROFILE_HASH_MISMATCH, diff --git a/pki/src/main/java/zeroecho/pki/impl/core/CaCertificateProfileValidator.java b/pki/src/main/java/zeroecho/pki/impl/core/CaCertificateProfileValidator.java new file mode 100644 index 0000000..34b668a --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/core/CaCertificateProfileValidator.java @@ -0,0 +1,261 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.impl.core; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.time.DateTimeException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.bouncycastle.asn1.x500.AttributeTypeAndValue; +import org.bouncycastle.asn1.x500.RDN; +import org.bouncycastle.asn1.x500.X500Name; + +import zeroecho.pki.api.EncodedObject; +import zeroecho.pki.api.FormatId; +import zeroecho.pki.api.PkiException; +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.SubjectRef; +import zeroecho.pki.api.Validity; +import zeroecho.pki.api.profile.ActiveCertificateProfile; +import zeroecho.pki.api.profile.CertificateProfileDefinition; +import zeroecho.pki.api.profile.CertificateProfileKind; +import zeroecho.pki.api.profile.SubjectRdnRule; +import zeroecho.pki.api.profile.SubjectRdnType; +import zeroecho.pki.api.request.SubjectRdn; +import zeroecho.pki.impl.framework.x509.bc.BcX509ProfileSupport; + +/** + * Authoritative active-profile gate for root and intermediate certificate + * requests. + */ +// The closed gate deliberately keeps cohesive profile inputs together and +// redacts attacker-controlled parser failures rather than retaining their causes. +@SuppressWarnings({ "PMD.CyclomaticComplexity", "PMD.ExcessiveParameterList", + "PMD.PreserveStackTrace", "PMD.AvoidRethrowingException" }) +final class CaCertificateProfileValidator { + private static final int MAXIMUM_SUBJECT_DER_BYTES = 16 * 1024; + private static final int SINGLE_VALUE = 1; + + private CaCertificateProfileValidator() { + } + + /* package */ static ValidatedCaCertificateRequest validate( + ValidatedCaCertificateRequest.Operation operation, ActiveCertificateProfile activeProfile, + CertificateProfileKind expectedKind, FormatId formatId, PkiId issuerCaId, PkiId subjectCaId, + SubjectRef requestedSubject, EncodedObject exactPublicKey, Optional requestedValidity, + Instant evaluationTime, Optional issuerNotAfter, BigInteger serial) { + CertificateProfileDefinition definition = activeProfile.definition(); + requireProfileShape(definition, expectedKind, formatId); + List approvedSubject = validateSubject(requestedSubject, definition); + return validateApprovedSubject(operation, activeProfile, expectedKind, formatId, issuerCaId, subjectCaId, + approvedSubject, exactPublicKey, requestedValidity, evaluationTime, issuerNotAfter, serial); + } + + /* + * Package-private continuation used only after validateSubject has approved the + * requester input. It preserves profile-fixed RDNs without treating them as a + * second requester submission. + */ + /* package */ static ValidatedCaCertificateRequest validateApprovedSubject( + ValidatedCaCertificateRequest.Operation operation, ActiveCertificateProfile activeProfile, + CertificateProfileKind expectedKind, FormatId formatId, PkiId issuerCaId, PkiId subjectCaId, + List approvedSubject, EncodedObject exactPublicKey, Optional requestedValidity, + Instant evaluationTime, Optional issuerNotAfter, BigInteger serial) { + CertificateProfileDefinition definition = activeProfile.definition(); + requireProfileShape(definition, expectedKind, formatId); + List subjectSnapshot = requireApprovedSubject(approvedSubject); + CertificateProfileValidator.requireSubjectKeyAllowed(exactPublicKey, + definition.caPolicy().allowedSubjectKeyAlgorithmIds()); + Validity validity = approvedValidity(requestedValidity, definition, evaluationTime, issuerNotAfter, + operation == ValidatedCaCertificateRequest.Operation.IMPORT_ROOT); + SubjectRef canonicalSubject = new SubjectRef(BcX509ProfileSupport.subject(subjectSnapshot).toString()); + return new ValidatedCaCertificateRequest(operation, formatId, issuerCaId, subjectCaId, + activeProfile.reference(), expectedKind, canonicalSubject, subjectSnapshot, exactPublicKey, + validity, serial, definition.caPolicy()); + } + + /* package */ static void requireProfileShape(ActiveCertificateProfile activeProfile, + CertificateProfileKind expectedKind, FormatId formatId) { + requireProfileShape(activeProfile.definition(), expectedKind, formatId); + } + + private static void requireProfileShape(CertificateProfileDefinition definition, + CertificateProfileKind expectedKind, FormatId formatId) { + if (definition.certificateType() != expectedKind) { + throw reject("CA_PROFILE_KIND_MISMATCH"); + } + if (!definition.formatId().equals(formatId)) { + throw reject("CA_PROFILE_FORMAT_MISMATCH"); + } + } + + /* package */ static List validateSubject(SubjectRef requestedSubject, + CertificateProfileDefinition definition) { + List requested = parseSubject(requestedSubject); + Map rules = new EnumMap<>(SubjectRdnType.class); + for (SubjectRdnRule rule : definition.subjectPolicy().rules()) { + rules.put(rule.type(), rule); + } + Map counts = new EnumMap<>(SubjectRdnType.class); + List approved = new ArrayList<>(requested.size() + rules.size()); + for (SubjectRdn rdn : requested) { + SubjectRdnRule rule = rules.get(rdn.type()); + if (rule == null || !rule.requesterSupplied()) { + throw reject("CA_SUBJECT_RDN_FORBIDDEN"); + } + String canonical = SubjectRdnRule.canonicalValue(rdn.type(), rdn.value()); + if (canonical.getBytes(StandardCharsets.UTF_8).length > rule.maximumUtf8Bytes()) { + throw reject("CA_SUBJECT_RDN_TOO_LARGE"); + } + int count = Math.addExact(counts.getOrDefault(rdn.type(), 0), 1); + if (count > rule.maximumOccurrences()) { + throw reject("CA_SUBJECT_RDN_CARDINALITY"); + } + counts.put(rdn.type(), count); + approved.add(new SubjectRdn(rdn.type(), canonical)); + } + for (SubjectRdnRule rule : definition.subjectPolicy().rules()) { + if (rule.fixedValue().isPresent()) { + approved.add(new SubjectRdn(rule.type(), rule.fixedValue().orElseThrow())); + counts.put(rule.type(), 1); + } + int count = counts.getOrDefault(rule.type(), 0); + if (count < rule.minimumOccurrences() || count > rule.maximumOccurrences()) { + throw reject("CA_SUBJECT_RDN_REQUIRED"); + } + } + if (approved.isEmpty() || approved.size() > zeroecho.pki.api.profile.SubjectPolicy.HARD_MAXIMUM_RDN_COUNT) { + throw reject("CA_SUBJECT_INVALID"); + } + try { + if (BcX509ProfileSupport.subject(approved).getEncoded().length > MAXIMUM_SUBJECT_DER_BYTES) { + throw reject("CA_SUBJECT_TOO_LARGE"); + } + } catch (java.io.IOException exception) { + throw reject("CA_SUBJECT_INVALID"); + } + return List.copyOf(approved); + } + + /* + * Validates a previously approved canonical subject without reclassifying + * profile-fixed RDNs as requester input. + */ + /* package */ static List validateTrustedSubject(SubjectRef trustedSubject, + CertificateProfileDefinition definition) { + List existing = parseSubject(trustedSubject); + Map rules = new EnumMap<>(SubjectRdnType.class); + for (SubjectRdnRule rule : definition.subjectPolicy().rules()) { + rules.put(rule.type(), rule); + } + Map counts = new EnumMap<>(SubjectRdnType.class); + List requesterValues = new ArrayList<>(existing.size()); + for (SubjectRdn rdn : existing) { + SubjectRdnRule rule = rules.get(rdn.type()); + if (rule == null) { + throw reject("CA_SUBJECT_RDN_FORBIDDEN"); + } + String canonical = SubjectRdnRule.canonicalValue(rdn.type(), rdn.value()); + if (!canonical.equals(rdn.value()) + || canonical.getBytes(StandardCharsets.UTF_8).length > rule.maximumUtf8Bytes()) { + throw reject("CA_SUBJECT_RDN_INVALID"); + } + int count = Math.addExact(counts.getOrDefault(rdn.type(), 0), 1); + if (count > rule.maximumOccurrences()) { + throw reject("CA_SUBJECT_RDN_CARDINALITY"); + } + counts.put(rdn.type(), count); + if (rule.requesterSupplied()) { + requesterValues.add(rdn); + } else if (rule.fixedValue().isEmpty() + || !rule.fixedValue().orElseThrow().equals(rdn.value())) { + throw reject("CA_SUBJECT_FIXED_VALUE_MISMATCH"); + } + } + List expected = new ArrayList<>(existing.size()); + expected.addAll(requesterValues); + for (SubjectRdnRule rule : definition.subjectPolicy().rules()) { + int count = counts.getOrDefault(rule.type(), 0); + if (count < rule.minimumOccurrences() || count > rule.maximumOccurrences()) { + throw reject("CA_SUBJECT_RDN_REQUIRED"); + } + rule.fixedValue().ifPresent(value -> expected.add(new SubjectRdn(rule.type(), value))); + } + if (!existing.equals(expected)) { + throw reject("CA_SUBJECT_ORDER_INVALID"); + } + return requireApprovedSubject(expected); + } + + private static List requireApprovedSubject(List approvedSubject) { + List snapshot = List.copyOf(approvedSubject); + if (snapshot.isEmpty() || snapshot.size() > zeroecho.pki.api.profile.SubjectPolicy.HARD_MAXIMUM_RDN_COUNT) { + throw reject("CA_SUBJECT_INVALID"); + } + try { + if (BcX509ProfileSupport.subject(snapshot).getEncoded().length > MAXIMUM_SUBJECT_DER_BYTES) { + throw reject("CA_SUBJECT_TOO_LARGE"); + } + } catch (java.io.IOException exception) { + throw reject("CA_SUBJECT_INVALID"); + } + return snapshot; + } + + private static List parseSubject(SubjectRef subjectRef) { + try { + X500Name name = new X500Name(subjectRef.value()); + List result = new ArrayList<>(); + for (RDN rdn : name.getRDNs()) { + AttributeTypeAndValue[] values = rdn.getTypesAndValues(); + if (values.length != SINGLE_VALUE) { + throw reject("CA_SUBJECT_MULTIVALUED_RDN"); + } + result.add(new SubjectRdn(SubjectRdnType.fromOid(values[0].getType().getId()), + values[0].getValue().toString())); + } + return List.copyOf(result); + } catch (PkiException exception) { + throw exception; + } catch (IllegalArgumentException exception) { + throw reject("CA_SUBJECT_INVALID"); + } + } + + private static Validity approvedValidity(Optional requested, + CertificateProfileDefinition definition, Instant evaluationTime, Optional issuerNotAfter, + boolean imported) { + try { + Instant ceiling = evaluationTime.plus(definition.maximumValidity()); + if (issuerNotAfter.isPresent() && issuerNotAfter.orElseThrow().isBefore(ceiling)) { + ceiling = issuerNotAfter.orElseThrow(); + } + Validity validity = requested.isPresent() ? requested.orElseThrow() + : new Validity(evaluationTime, ceiling); + boolean invalidStart = imported ? validity.notBefore().isAfter(evaluationTime) + : !validity.notBefore().equals(evaluationTime); + if (invalidStart || !validity.notAfter().isAfter(evaluationTime) + || java.time.Duration.between(validity.notBefore(), validity.notAfter()) + .compareTo(definition.maximumValidity()) > 0 + || !imported && validity.notAfter().isAfter(ceiling) + || !validity.notAfter().isAfter(validity.notBefore())) { + throw reject("CA_VALIDITY_REJECTED"); + } + return validity; + } catch (DateTimeException | ArithmeticException exception) { + throw reject("CA_VALIDITY_REJECTED"); + } + } + + private static PkiException reject(String code) { + return new PkiException("CA certificate profile rejected: code=" + code); + } +} 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 137a2e7..47856f4 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/CaProofGate.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/CaProofGate.java @@ -59,9 +59,6 @@ import zeroecho.pki.api.FormatId; import zeroecho.pki.api.KeyRef; import zeroecho.pki.api.PkiException; import zeroecho.pki.api.PkiId; -import zeroecho.pki.api.SubjectRef; -import zeroecho.pki.api.Validity; -import zeroecho.pki.api.attr.AttributeSet; import zeroecho.pki.api.audit.AccessContext; import zeroecho.pki.api.audit.AuditEvent; import zeroecho.pki.api.audit.Principal; @@ -127,16 +124,7 @@ final class CaProofGate { /* default */ ManagedKeyProof proveManagedKey(KeyRef keyRef, FormatId formatId, String auditAction, Optional subjectCaId) { - EncodedObject resolved; - try { - resolved = publicKeyResolver.resolveSpkiDer(keyRef); - } catch (RuntimeException ex) { // NOPMD - managed-key resolution boundary fails closed - throw rejection(auditAction, formatId, subjectCaId, "MANAGED_KEY_UNAVAILABLE"); - } - if (resolved == null || resolved.encoding() != Encoding.DER) { - throw rejection(auditAction, formatId, subjectCaId, "MANAGED_KEY_INVALID"); - } - + EncodedObject resolved = resolveManagedKey(keyRef, formatId, auditAction, subjectCaId); byte[] exactSpki = resolved.bytes().clone(); byte[] challenge = new byte[MANAGED_KEY_CHALLENGE_DOMAIN.length + CHALLENGE_NONCE_BYTES]; CHALLENGE_RANDOM.nextBytes(challenge); @@ -160,12 +148,18 @@ final class CaProofGate { } } - /* default */ ManagedCaIssuance authorizeIntermediate(ManagedKeyProof proof, - ManagedCaIssuance.Operation operation, PkiId issuerCaId, PkiId subjectCaId, String profileId, - Optional requestedValidity, AttributeSet attributes, SubjectRef subjectRef) { - Objects.requireNonNull(proof, "proof"); - return new ManagedCaIssuance(proof, operation, issuerCaId, subjectCaId, profileId, requestedValidity, - attributes, subjectRef); + /* default */ EncodedObject resolveManagedKey(KeyRef keyRef, FormatId formatId, String auditAction, + Optional subjectCaId) { + EncodedObject resolved; + try { + resolved = publicKeyResolver.resolveSpkiDer(keyRef); + } catch (RuntimeException ex) { // NOPMD - managed-key resolution boundary fails closed + throw rejection(auditAction, formatId, subjectCaId, "MANAGED_KEY_UNAVAILABLE"); + } + if (resolved == null || resolved.encoding() != Encoding.DER) { + throw rejection(auditAction, formatId, subjectCaId, "MANAGED_KEY_INVALID"); + } + return new EncodedObject(Encoding.DER, resolved.bytes()); } /* default */ PkiException rejection(String action, FormatId formatId, Optional objectId, String code) { 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 fef95e0..154928d 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/CertificateProfileValidator.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/CertificateProfileValidator.java @@ -64,10 +64,10 @@ final class CertificateProfileValidator { ParsedCertificationRequest request = candidate.request(); LeafCertificatePolicy policy = profile.leafPolicy(); requireCanonicalRequestAttributes(request); - List approvedSubject = validateSubject(request, policy); + List approvedSubject = validateSubject(request, profile); List approvedSans = validateSans(request, policy, approvedSubject.isEmpty()); - requireSubjectKeyAllowed(candidate, policy); - Validity validity = approvedValidity(candidate, request, policy, issuerCredential, evaluationTime); + requireSubjectKeyAllowed(candidate.exactPublicKey(), policy.allowedSubjectKeyAlgorithmIds()); + Validity validity = approvedValidity(candidate, request, profile, issuerCredential, evaluationTime); boolean sanCritical = approvedSubject.isEmpty() || policy.subjectAlternativeNamePolicy().criticalWithNonemptySubject(); SubjectRef approvedSubjectRef = new SubjectRef(approvedSubject.isEmpty() @@ -90,9 +90,9 @@ final class CertificateProfileValidator { // The branches preserve the deny-by-default RDN ownership and cardinality rules. @SuppressWarnings("PMD.CyclomaticComplexity") - private static List validateSubject(ParsedCertificationRequest request, LeafCertificatePolicy policy) { + private static List validateSubject(ParsedCertificationRequest request, CertificateProfile profile) { Map rules = new EnumMap<>(SubjectRdnType.class); - for (SubjectRdnRule rule : policy.subjectPolicy().rules()) { + for (SubjectRdnRule rule : profile.subjectPolicy().rules()) { rules.put(rule.type(), rule); } Map counts = new EnumMap<>(SubjectRdnType.class); @@ -113,7 +113,7 @@ final class CertificateProfileValidator { counts.put(rdn.type(), count); approved.add(new SubjectRdn(rdn.type(), canonical)); } - for (SubjectRdnRule rule : policy.subjectPolicy().rules()) { + for (SubjectRdnRule rule : profile.subjectPolicy().rules()) { if (rule.fixedValue().isPresent()) { approved.add(new SubjectRdn(rule.type(), rule.fixedValue().orElseThrow())); counts.put(rule.type(), 1); @@ -123,7 +123,7 @@ final class CertificateProfileValidator { throw reject("SUBJECT_RDN_REQUIRED"); } } - if (approved.isEmpty() && !policy.subjectAlternativeNamePolicy().allowEmptySubject()) { + if (approved.isEmpty() && !profile.subjectPolicy().allowEmpty()) { throw reject("SUBJECT_EMPTY"); } if (approved.size() > zeroecho.pki.api.profile.SubjectPolicy.HARD_MAXIMUM_RDN_COUNT) { @@ -197,16 +197,17 @@ final class CertificateProfileValidator { // The public exception deliberately redacts ASN.1 parser details. @SuppressWarnings({ "PMD.PreserveStackTrace", "PMD.AvoidRethrowingException" }) - private static void requireSubjectKeyAllowed(VerifiedIssuanceCandidate candidate, LeafCertificatePolicy policy) { - if (candidate.exactPublicKey().encoding() != Encoding.DER) { + /* package */ static void requireSubjectKeyAllowed(zeroecho.pki.api.EncodedObject exactPublicKey, + Set allowedAlgorithms) { + if (exactPublicKey.encoding() != Encoding.DER) { throw reject("SUBJECT_KEY_UNSUPPORTED"); } - byte[] encoded = candidate.exactPublicKey().bytes(); + byte[] encoded = exactPublicKey.bytes(); try { SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(encoded); SubjectKeyAlgorithm algorithm = subjectKeyAlgorithm(spki.getAlgorithm().getAlgorithm()); requireSupportedParameters(spki, algorithm); - if (!policy.allowedSubjectKeyAlgorithmIds().contains(algorithm.profileId())) { + if (!allowedAlgorithms.contains(algorithm.profileId())) { throw reject("SUBJECT_KEY_ALGORITHM_FORBIDDEN"); } PublicKey reconstructed = KeyFactory.getInstance(algorithm.jcaName()) @@ -262,12 +263,12 @@ final class CertificateProfileValidator { // The public exception deliberately redacts temporal arithmetic details. @SuppressWarnings("PMD.PreserveStackTrace") private static Validity approvedValidity(VerifiedIssuanceCandidate candidate, ParsedCertificationRequest request, - LeafCertificatePolicy policy, Credential issuerCredential, Instant evaluationTime) { + CertificateProfile profile, Credential issuerCredential, Instant evaluationTime) { Optional supplied = candidate.validityOverride().isPresent() ? candidate.validityOverride() : request.requestedValidity(); Duration duration = supplied.map(value -> Duration.between(value.notBefore(), value.notAfter())) - .orElse(policy.maximumValidity()); - if (duration.isZero() || duration.isNegative() || duration.compareTo(policy.maximumValidity()) > 0) { + .orElse(profile.maximumValidity()); + if (duration.isZero() || duration.isNegative() || duration.compareTo(profile.maximumValidity()) > 0) { throw reject("VALIDITY_EXCEEDS_PROFILE"); } Instant notAfter; diff --git a/pki/src/main/java/zeroecho/pki/impl/core/CredentialProfileBindings.java b/pki/src/main/java/zeroecho/pki/impl/core/CredentialProfileBindings.java index 581bffb..e9d3478 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/CredentialProfileBindings.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/CredentialProfileBindings.java @@ -29,9 +29,9 @@ final class CredentialProfileBindings { } } - /* default */ static void requireCaBinding(CredentialProfileBinding binding, String expectedCaProfileId) { + /* default */ static void requireCaBinding(CredentialProfileBinding binding, CertificateProfileRef expected) { if (!(binding instanceof CaProfileBinding ca) - || !ca.profileId().equals(expectedCaProfileId)) { + || !ca.reference().equals(expected)) { throw mismatch(); } } diff --git a/pki/src/main/java/zeroecho/pki/impl/core/DefaultCaService.java b/pki/src/main/java/zeroecho/pki/impl/core/DefaultCaService.java index 9f386b1..d06c1c5 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/DefaultCaService.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/DefaultCaService.java @@ -36,6 +36,7 @@ package zeroecho.pki.impl.core; import java.io.IOException; import java.math.BigInteger; import java.security.MessageDigest; +import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.util.ArrayList; @@ -52,9 +53,11 @@ import org.bouncycastle.asn1.x509.BasicConstraints; import org.bouncycastle.asn1.x509.Extension; import org.bouncycastle.asn1.x509.KeyUsage; import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; +import org.bouncycastle.cert.CertException; import org.bouncycastle.cert.X509CertificateHolder; import org.bouncycastle.cert.X509v3CertificateBuilder; import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.OperatorCreationException; import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder; import zeroecho.pki.api.CaService; @@ -65,10 +68,10 @@ import zeroecho.pki.api.IssuerRef; import zeroecho.pki.api.KeyRef; import zeroecho.pki.api.PkiException; import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.ProfileService; import zeroecho.pki.api.SubjectRef; import zeroecho.pki.api.Validity; import zeroecho.pki.api.attr.AttributeSet; -import zeroecho.pki.api.attr.AttributeValue; import zeroecho.pki.api.ca.CaCreateCommand; import zeroecho.pki.api.ca.CaImportCommand; import zeroecho.pki.api.ca.CaKeyRotationCommand; @@ -85,9 +88,10 @@ import zeroecho.pki.api.credential.CredentialStatus; import zeroecho.pki.api.credential.CredentialUse; import zeroecho.pki.api.credential.EffectiveCredentialStatus; import zeroecho.pki.api.credential.EffectiveCredentialStatusResolver; +import zeroecho.pki.api.profile.ActiveCertificateProfile; +import zeroecho.pki.api.profile.CertificateProfileKind; import zeroecho.pki.impl.core.async.PkiSigningBus; import zeroecho.pki.impl.core.attr.SimpleAttributeSet; -import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes; import zeroecho.pki.spi.audit.AuditSink; import zeroecho.pki.spi.framework.CredentialFramework; import zeroecho.pki.spi.framework.CredentialIssuerBackend; @@ -147,13 +151,17 @@ import zeroecho.pki.spi.store.PkiStore; *

*/ // PMD cannot infer that retaining boundary causes would violate the redaction contract. -@SuppressWarnings({ "PMD.CyclomaticComplexity", "PMD.PreserveStackTrace" }) +@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.CyclomaticComplexity", + "PMD.ExcessiveParameterList", "PMD.PreserveStackTrace" }) public final class DefaultCaService implements CaService { private static final Logger LOG = Logger.getLogger(DefaultCaService.class.getName()); + private static final String CREATE_ROOT_REJECTED = "CREATE_ROOT_REJECTED"; private static final String CREATE_INT_REJECTED = "CREATE_INTERMEDIATE_REJECTED"; private static final String ISSUE_INT_REJECTED = "ISSUE_INTERMEDIATE_REJECTED"; + private static final String IMPORT_ROOT_REJECTED = "IMPORT_ROOT_REJECTED"; private static final String BACKEND_CRED_MISMATCH = "BACKEND_CREDENTIAL_MISMATCH"; + private static final String ROOT_CREDENTIAL_INVALID = "ROOT_CREDENTIAL_INVALID"; private final PkiStore store; private final CredentialFramework framework; @@ -161,6 +169,8 @@ public final class DefaultCaService implements CaService { private final CaProofGate proofGate; private final AuditSink auditSink; private final EffectiveCredentialStatusResolver statusResolver; + private final ProfileService profileService; + private final Clock clock; /** * Creates a CA service bound to a specific store, credential framework, and @@ -214,7 +224,8 @@ public final class DefaultCaService implements CaService { */ public DefaultCaService(PkiStore store, CredentialFramework framework, CredentialIssuerBackend issuerBackend, PublicKeyInfoResolver publicKeyResolver, PkiSigningBus signingBus, AuditSink auditSink, - EffectiveCredentialStatusResolver statusResolver, String signatureAlgorithmId, Duration signingTtl) { + EffectiveCredentialStatusResolver statusResolver, ProfileService profileService, Clock clock, + String signatureAlgorithmId, Duration signingTtl) { this.store = Objects.requireNonNull(store, "store"); this.framework = Objects.requireNonNull(framework, "framework"); @@ -223,6 +234,8 @@ public final class DefaultCaService implements CaService { Objects.requireNonNull(signingBus, "signingBus"); this.auditSink = Objects.requireNonNull(auditSink, "auditSink"); this.statusResolver = Objects.requireNonNull(statusResolver, "statusResolver"); + this.profileService = Objects.requireNonNull(profileService, "profileService"); + this.clock = Objects.requireNonNull(clock, "clock"); if (signatureAlgorithmId == null || signatureAlgorithmId.isBlank()) { throw new IllegalArgumentException("signatureAlgorithmId must not be null/blank"); } @@ -263,30 +276,42 @@ public final class DefaultCaService implements CaService { @Override public PkiId createRoot(CaCreateCommand command) { Objects.requireNonNull(command, "command"); // NOPMD + ActiveCertificateProfile activeProfile = profileService.requireActiveProfile(command.profileId()); + CaCertificateProfileValidator.requireProfileShape(activeProfile, CertificateProfileKind.ROOT_CA, + command.formatId()); + Instant evaluationTime = clock.instant(); if (command.keyRef().isEmpty()) { throw new PkiException("Root CA creation requires keyRef (key generation not wired)"); } + requireNoCaOverrides(command.attributes()); if (!framework.formatId().equals(command.formatId())) { throw new PkiException("Unsupported formatId for this runtime"); } KeyRef keyRef = command.keyRef().get(); - - SubjectRef subjectRef = command.subjectRef(); - CaProofGate.ManagedKeyProof proof = proofGate.proveManagedKey(keyRef, command.formatId(), - "CREATE_ROOT_REJECTED", Optional.empty()); - EncodedObject spki = proof.exactPublicKey(); + EncodedObject spki = proofGate.resolveManagedKey(keyRef, command.formatId(), + CREATE_ROOT_REJECTED, Optional.empty()); + BigInteger serial = CertificateSerialAllocator.allocate(); + ValidatedCaCertificateRequest request = CaCertificateProfileValidator.validate( + ValidatedCaCertificateRequest.Operation.CREATE_ROOT, activeProfile, + CertificateProfileKind.ROOT_CA, + command.formatId(), new PkiId("ca:pending-root"), new PkiId("ca:pending-root"), + command.subjectRef(), spki, Optional.empty(), evaluationTime, Optional.empty(), serial); SubjectPublicKeyInfo rootPublicKeyInfo = proofGate.parseRootSpki(spki, command.formatId()); + CaProofGate.ManagedKeyProof proof = proofGate.proveManagedKey(keyRef, command.formatId(), + CREATE_ROOT_REJECTED, Optional.empty()); + requireSameManagedKey(spki, proof.exactPublicKey(), CREATE_ROOT_REJECTED, + command.formatId(), Optional.empty()); - Instant now = Instant.now(); - Validity validity = new Validity(now.minus(Duration.ofMinutes(1)), now.plus(Duration.ofDays(3650))); - X500Name dn = new X500Name(subjectRef.value()); + Validity validity = request.validity(); + X500Name dn = zeroecho.pki.impl.framework.x509.bc.BcX509ProfileSupport.subject(request.subjectRdns()); - BigInteger serial = BigInteger.valueOf(Math.abs(now.toEpochMilli()) + 1L); X509v3CertificateBuilder b = new X509v3CertificateBuilder(dn, serial, Date.from(validity.notBefore()), Date.from(validity.notAfter()), dn, rootPublicKeyInfo); try { - b.addExtension(Extension.basicConstraints, true, new BasicConstraints(true)); - b.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyCertSign | KeyUsage.cRLSign)); + b.addExtension(Extension.basicConstraints, request.policy().basicConstraintsCritical(), + new BasicConstraints(request.policy().pathLengthConstraint())); + b.addExtension(Extension.keyUsage, request.policy().keyUsageCritical(), + new KeyUsage(KeyUsage.keyCertSign | KeyUsage.cRLSign)); } catch (Exception ex) { throw new PkiException("Root extension construction failed: code=ROOT_EXTENSION_BUILD_FAILED"); } @@ -296,12 +321,12 @@ public final class DefaultCaService implements CaService { try { cert = b.build(signer); } catch (RuntimeException ex) { // NOPMD - throw proofGate.rejection("CREATE_ROOT_REJECTED", command.formatId(), Optional.empty(), + throw proofGate.rejection(CREATE_ROOT_REJECTED, command.formatId(), Optional.empty(), "ROOT_SIGNING_FAILED"); } if (!proofGate.rootProofIsValid(cert, spki)) { - throw proofGate.rejection("CREATE_ROOT_REJECTED", command.formatId(), Optional.empty(), + throw proofGate.rejection(CREATE_ROOT_REJECTED, command.formatId(), Optional.empty(), "ROOT_SELF_SIGNATURE_INVALID"); } @@ -317,12 +342,16 @@ public final class DefaultCaService implements CaService { PkiId publicKeyId = new PkiId("spki:" + sha256Hex(spki.bytes())); - Credential credential = new Credential(credId, command.formatId(), new IssuerRef(caId), subjectRef, validity, - serial.toString(), publicKeyId, new CaProfileBinding(command.profileId()), CredentialStatus.ISSUED, - new EncodedObject(Encoding.DER, certDer), command.attributes()); - CredentialProfileBindings.requireCaBinding(credential.profileBinding(), command.profileId()); + Credential credential = new Credential(credId, command.formatId(), new IssuerRef(caId), request.subjectRef(), + validity, serial.toString(), publicKeyId, new CaProfileBinding(request.profileReference()), + CredentialStatus.ISSUED, new EncodedObject(Encoding.DER, certDer), + SimpleAttributeSet.builder().build()); + CredentialProfileBindings.requireCaBinding(credential.profileBinding(), request.profileReference()); - CaRecord ca = new CaRecord(caId, CaKind.ROOT, CaState.ACTIVE, keyRef, subjectRef, List.of(credential)); + requireCaCertificateMatches(credential, credential, request, caId, CREATE_ROOT_REJECTED, + BACKEND_CRED_MISMATCH); + CaRecord ca = new CaRecord(caId, CaKind.ROOT, CaState.ACTIVE, keyRef, request.subjectRef(), + List.of(credential)); store.putCa(ca); store.putCredential(credential); return caId; @@ -356,6 +385,11 @@ public final class DefaultCaService implements CaService { @Override public PkiId importRoot(CaImportCommand command) { Objects.requireNonNull(command, "command"); + ActiveCertificateProfile activeProfile = profileService.requireActiveProfile(command.profileId()); + CaCertificateProfileValidator.requireProfileShape(activeProfile, CertificateProfileKind.ROOT_CA, + command.formatId()); + Instant evaluationTime = clock.instant(); + requireNoCaOverrides(command.attributes()); if (!framework.formatId().equals(command.formatId())) { throw new PkiException("Unsupported formatId for this runtime"); } @@ -371,10 +405,7 @@ public final class DefaultCaService implements CaService { throw new PkiException("Invalid X.509 credential: code=CREDENTIAL_INVALID"); } - requireValidImportedRoot(command, holder); - Instant notBefore = holder.getNotBefore().toInstant(); - Instant notAfter = holder.getNotAfter().toInstant(); Validity validity = new Validity(notBefore, notAfter); @@ -391,13 +422,22 @@ public final class DefaultCaService implements CaService { PkiId publicKeyId = new PkiId("spki:" + sha256Hex(spkiDer)); BigInteger serial = holder.getSerialNumber(); - Credential credential = new Credential(credId, command.formatId(), new IssuerRef(caId), command.subjectRef(), - validity, serial.toString(), publicKeyId, new CaProfileBinding(command.profileId()), + EncodedObject spki = new EncodedObject(Encoding.DER, spkiDer); + ValidatedCaCertificateRequest request = CaCertificateProfileValidator.validate( + ValidatedCaCertificateRequest.Operation.IMPORT_ROOT, activeProfile, + CertificateProfileKind.ROOT_CA, + command.formatId(), caId, caId, command.subjectRef(), spki, Optional.of(validity), + evaluationTime, Optional.empty(), serial); + Credential credential = new Credential(credId, command.formatId(), new IssuerRef(caId), request.subjectRef(), + validity, serial.toString(), publicKeyId, new CaProfileBinding(request.profileReference()), CredentialStatus.ISSUED, - new EncodedObject(Encoding.DER, certDer), command.attributes()); - CredentialProfileBindings.requireCaBinding(credential.profileBinding(), command.profileId()); + new EncodedObject(Encoding.DER, certDer), SimpleAttributeSet.builder().build()); + CredentialProfileBindings.requireCaBinding(credential.profileBinding(), request.profileReference()); + requireCaCertificateMatches(credential, credential, request, caId, IMPORT_ROOT_REJECTED, + ROOT_CREDENTIAL_INVALID); + requireValidImportedRoot(command, holder); store.putCredential(credential); - CaRecord ca = new CaRecord(caId, CaKind.ROOT, CaState.ACTIVE, command.keyRef(), command.subjectRef(), + CaRecord ca = new CaRecord(caId, CaKind.ROOT, CaState.ACTIVE, command.keyRef(), request.subjectRef(), List.of(credential)); store.putCa(ca); return caId; @@ -439,9 +479,14 @@ public final class DefaultCaService implements CaService { @Override public PkiId createIntermediate(IntermediateCreateCommand command) { Objects.requireNonNull(command, "command"); + ActiveCertificateProfile activeProfile = profileService.requireActiveProfile(command.profileId()); + CaCertificateProfileValidator.requireProfileShape(activeProfile, CertificateProfileKind.INTERMEDIATE_CA, + command.formatId()); + Instant evaluationTime = clock.instant(); if (command.keyRef().isEmpty()) { throw new PkiException("Intermediate creation requires keyRef (key generation not wired)"); } + requireNoCaOverrides(command.attributes()); CaRecord issuer = getCa(command.issuerCaId()); ensureActive(issuer, "issuer"); @@ -455,29 +500,40 @@ public final class DefaultCaService implements CaService { EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation(); Credential issuerCredential = CredentialSnapshots.copy(selectIssuerCredential(issuer, command.formatId(), CredentialUse.INTERMEDIATE_ISSUER, statusEvaluation)); + requireHistoricalCaProfile(issuerCredential, + issuer.kind() == CaKind.ROOT ? CertificateProfileKind.ROOT_CA + : CertificateProfileKind.INTERMEDIATE_CA); - PkiId caId = new PkiId("ca:" + sha256Hex((issuer.caId().value() + "\n" + command.subjectRef().value()) + List approvedSubject = + CaCertificateProfileValidator.validateSubject(command.subjectRef(), activeProfile.definition()); + SubjectRef canonicalSubject = new SubjectRef( + zeroecho.pki.impl.framework.x509.bc.BcX509ProfileSupport.subject(approvedSubject).toString()); + PkiId caId = new PkiId("ca:" + sha256Hex((issuer.caId().value() + "\n" + canonicalSubject.value()) .getBytes(java.nio.charset.StandardCharsets.UTF_8)).substring(0, 16)); + EncodedObject subjectSpki = proofGate.resolveManagedKey(command.keyRef().get(), + command.formatId(), CREATE_INT_REJECTED, Optional.of(caId)); + ValidatedCaCertificateRequest issue = CaCertificateProfileValidator.validateApprovedSubject( + ValidatedCaCertificateRequest.Operation.CREATE_INTERMEDIATE, activeProfile, + CertificateProfileKind.INTERMEDIATE_CA, command.formatId(), command.issuerCaId(), caId, + approvedSubject, subjectSpki, Optional.empty(), evaluationTime, + Optional.of(issuerCredential.validity().notAfter()), CertificateSerialAllocator.allocate()); CaProofGate.ManagedKeyProof subjectProof = proofGate.proveManagedKey(command.keyRef().get(), command.formatId(), CREATE_INT_REJECTED, Optional.of(caId)); - EncodedObject subjectSpki = subjectProof.exactPublicKey(); + requireSameManagedKey(subjectSpki, subjectProof.exactPublicKey(), CREATE_INT_REJECTED, + command.formatId(), Optional.of(caId)); requireIssuerKeyBinding(issuer, issuerCredential, command.formatId(), CREATE_INT_REJECTED, Optional.of(caId)); - AttributeSet authoritative = authoritativeIntermediateAttributes(command.attributes(), issuer, - issuerCredential, subjectSpki, command.subjectRef()); - ManagedCaIssuance issue = proofGate.authorizeIntermediate(subjectProof, - ManagedCaIssuance.Operation.CREATE_INTERMEDIATE, command.issuerCaId(), caId, command.profileId(), - Optional.empty(), authoritative, command.subjectRef()); Credential backendCredential; try { - backendCredential = issuerBackend.issueIntermediateCertificate(issue); + backendCredential = issuerBackend.issueIntermediateCertificate(issue, issuerCredential.encoded(), + issuer.issuerKeyRef()); } catch (RuntimeException ex) { // NOPMD - reject malformed or mutable framework output throw proofGate.rejection(CREATE_INT_REJECTED, command.formatId(), Optional.of(caId), BACKEND_CRED_MISMATCH); } - requireCaBinding(backendCredential, command.profileId(), CREATE_INT_REJECTED, + requireCaBinding(backendCredential, issue.profileReference(), CREATE_INT_REJECTED, command.formatId(), Optional.of(caId)); Credential cred; try { @@ -486,12 +542,12 @@ public final class DefaultCaService implements CaService { throw proofGate.rejection(CREATE_INT_REJECTED, command.formatId(), Optional.of(caId), BACKEND_CRED_MISMATCH); } - requireIntermediateCredentialMatches(cred, issuerCredential, subjectSpki, command.subjectRef(), - command.issuerCaId(), caId, CREATE_INT_REJECTED); + requireCaCertificateMatches(cred, issuerCredential, issue, caId, CREATE_INT_REJECTED, + BACKEND_CRED_MISMATCH); store.putCredential(cred); CaRecord subject = new CaRecord(caId, CaKind.INTERMEDIATE, CaState.ACTIVE, command.keyRef().get(), - command.subjectRef(), List.of(cred)); + issue.subjectRef(), List.of(cred)); store.putCa(subject); return caId; } @@ -523,10 +579,19 @@ public final class DefaultCaService implements CaService { @Override public Credential issueIntermediateCertificate(IntermediateCertIssueCommand command) { Objects.requireNonNull(command, "command"); + ActiveCertificateProfile activeProfile = profileService.requireActiveProfile(command.profileId()); + CaCertificateProfileValidator.requireProfileShape(activeProfile, CertificateProfileKind.INTERMEDIATE_CA, + command.formatId()); + Instant evaluationTime = clock.instant(); + requireNoCaOverrides(command.attributes()); CaRecord issuer = getCa(command.issuerCaId()); ensureActive(issuer, "issuer"); CaRecord subject = getCa(command.subjectCaId()); ensureActive(subject, "subject"); + if (subject.kind() != CaKind.INTERMEDIATE) { + throw proofGate.rejection(ISSUE_INT_REJECTED, command.formatId(), + Optional.of(subject.caId()), "CA_SUBJECT_KIND_INVALID"); + } if (!framework.formatId().equals(command.formatId())) { throw proofGate.rejection(ISSUE_INT_REJECTED, command.formatId(), Optional.of(subject.caId()), @@ -535,26 +600,37 @@ public final class DefaultCaService implements CaService { EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation(); Credential issuerCredential = CredentialSnapshots.copy(selectIssuerCredential(issuer, command.formatId(), CredentialUse.INTERMEDIATE_ISSUER, statusEvaluation)); + requireHistoricalCaProfile(issuerCredential, + issuer.kind() == CaKind.ROOT ? CertificateProfileKind.ROOT_CA + : CertificateProfileKind.INTERMEDIATE_CA); + List approvedSubject = + CaCertificateProfileValidator.validateTrustedSubject(subject.subjectRef(), + activeProfile.definition()); + EncodedObject subjectSpki = proofGate.resolveManagedKey(subject.issuerKeyRef(), + command.formatId(), ISSUE_INT_REJECTED, Optional.of(subject.caId())); + ValidatedCaCertificateRequest gated = CaCertificateProfileValidator.validateApprovedSubject( + ValidatedCaCertificateRequest.Operation.ISSUE_INTERMEDIATE, activeProfile, + CertificateProfileKind.INTERMEDIATE_CA, command.formatId(), command.issuerCaId(), + command.subjectCaId(), approvedSubject, subjectSpki, command.requestedValidity(), + evaluationTime, Optional.of(issuerCredential.validity().notAfter()), + CertificateSerialAllocator.allocate()); CaProofGate.ManagedKeyProof subjectProof = proofGate.proveManagedKey(subject.issuerKeyRef(), command.formatId(), ISSUE_INT_REJECTED, Optional.of(subject.caId())); - EncodedObject subjectSpki = subjectProof.exactPublicKey(); + requireSameManagedKey(subjectSpki, subjectProof.exactPublicKey(), ISSUE_INT_REJECTED, + command.formatId(), Optional.of(subject.caId())); requireIssuerKeyBinding(issuer, issuerCredential, command.formatId(), ISSUE_INT_REJECTED, Optional.of(subject.caId())); - AttributeSet authoritative = authoritativeIntermediateAttributes(command.attributes(), issuer, - issuerCredential, subjectSpki, subject.subjectRef()); - ManagedCaIssuance gated = proofGate.authorizeIntermediate(subjectProof, - ManagedCaIssuance.Operation.ISSUE_INTERMEDIATE, command.issuerCaId(), command.subjectCaId(), - command.profileId(), command.requestedValidity(), authoritative, subject.subjectRef()); Credential backendCredential; try { - backendCredential = issuerBackend.issueIntermediateCertificate(gated); + backendCredential = issuerBackend.issueIntermediateCertificate(gated, issuerCredential.encoded(), + issuer.issuerKeyRef()); } catch (RuntimeException ex) { // NOPMD - reject malformed or mutable framework output throw proofGate.rejection(ISSUE_INT_REJECTED, command.formatId(), Optional.of(subject.caId()), BACKEND_CRED_MISMATCH); } - requireCaBinding(backendCredential, command.profileId(), ISSUE_INT_REJECTED, + requireCaBinding(backendCredential, gated.profileReference(), ISSUE_INT_REJECTED, command.formatId(), Optional.of(subject.caId())); Credential cred; try { @@ -563,8 +639,8 @@ public final class DefaultCaService implements CaService { throw proofGate.rejection(ISSUE_INT_REJECTED, command.formatId(), Optional.of(subject.caId()), BACKEND_CRED_MISMATCH); } - requireIntermediateCredentialMatches(cred, issuerCredential, subjectSpki, subject.subjectRef(), - command.issuerCaId(), subject.caId(), ISSUE_INT_REJECTED); + requireCaCertificateMatches(cred, issuerCredential, gated, subject.caId(), ISSUE_INT_REJECTED, + BACKEND_CRED_MISMATCH); store.putCredential(cred); List updated = new ArrayList<>(subject.caCredentials()); @@ -752,26 +828,17 @@ public final class DefaultCaService implements CaService { private void requireValidImportedRoot(CaImportCommand command, X509CertificateHolder holder) { try { - BasicConstraints constraints = BasicConstraints - .getInstance(holder.getExtension(Extension.basicConstraints).getParsedValue()); - if (!holder.getSubject().equals(holder.getIssuer()) || !constraints.isCA() - || !holder.getSubject().equals(new X500Name(command.subjectRef().value())) - || !holder.isSignatureValid( - new JcaContentVerifierProviderBuilder().build(holder.getSubjectPublicKeyInfo()))) { - throw proofGate.rejection("IMPORT_ROOT_REJECTED", command.formatId(), Optional.empty(), - "ROOT_CREDENTIAL_INVALID"); - } CaProofGate.ManagedKeyProof proof = proofGate.proveManagedKey(command.keyRef(), command.formatId(), - "IMPORT_ROOT_REJECTED", Optional.empty()); + IMPORT_ROOT_REJECTED, Optional.empty()); if (!MessageDigest.isEqual(proof.exactPublicKey().bytes(), holder.getSubjectPublicKeyInfo().getEncoded())) { - throw proofGate.rejection("IMPORT_ROOT_REJECTED", command.formatId(), Optional.empty(), + throw proofGate.rejection(IMPORT_ROOT_REJECTED, command.formatId(), Optional.empty(), "ROOT_MANAGED_KEY_MISMATCH"); } } catch (PkiException ex) { throw ex; } catch (Exception ex) { - throw proofGate.rejection("IMPORT_ROOT_REJECTED", command.formatId(), Optional.empty(), - "ROOT_CREDENTIAL_INVALID"); + throw proofGate.rejection(IMPORT_ROOT_REJECTED, command.formatId(), Optional.empty(), + ROOT_CREDENTIAL_INVALID); } } @@ -795,17 +862,12 @@ public final class DefaultCaService implements CaService { } } - private void requireIntermediateCredentialMatches(Credential credential, Credential issuerCredential, - EncodedObject exactSubjectSpki, SubjectRef subjectRef, PkiId issuerCaId, PkiId subjectCaId, - String action) { + private void requireCaCertificateMatches(Credential credential, Credential issuerCredential, + ValidatedCaCertificateRequest request, PkiId subjectCaId, String action, String mismatchCode) { try { - if (!framework.formatId().equals(credential.formatId()) - || credential.encoded().encoding() != Encoding.DER - || credential.status() != CredentialStatus.ISSUED - || !credential.subjectRef().equals(subjectRef) - || !credential.issuerRef().equals(new IssuerRef(issuerCaId))) { + if (!matchesCaCredentialEnvelope(credential, request, subjectCaId)) { throw proofGate.rejection(action, framework.formatId(), Optional.of(subjectCaId), - BACKEND_CRED_MISMATCH); + mismatchCode); } X509CertificateHolder holder = new X509CertificateHolder(credential.encoded().bytes()); X509CertificateHolder issuerHolder = new X509CertificateHolder(issuerCredential.encoded().bytes()); @@ -816,40 +878,82 @@ public final class DefaultCaService implements CaService { Extension keyUsageExtension = holder.getExtension(Extension.keyUsage); KeyUsage keyUsage = keyUsageExtension == null ? null : KeyUsage.getInstance(keyUsageExtension.getParsedValue()); - if (!MessageDigest.isEqual(exactSubjectSpki.bytes(), actualSpki) - || !holder.getSubject().equals(new X500Name(subjectRef.value())) - || !holder.getIssuer().equals(issuerHolder.getSubject()) - || !holder.isSignatureValid( - new JcaContentVerifierProviderBuilder().build(issuerHolder.getSubjectPublicKeyInfo())) - || constraintsExtension == null || !constraintsExtension.isCritical() - || constraints == null || !constraints.isCA() - || !BigInteger.ZERO.equals(constraints.getPathLenConstraint()) - || keyUsageExtension == null || !keyUsageExtension.isCritical() - || !hasIntermediateKeyUsage(keyUsage) - || !credential.publicKeyId().equals(new PkiId("spki:" + sha256Hex(actualSpki))) - || !credential.credentialId() - .equals(new PkiId("x509:" + sha256Hex(credential.encoded().bytes()))) - || !credential.serialOrUniqueId().equals(holder.getSerialNumber().toString()) - || credential.validity().notBefore().getEpochSecond() != holder.getNotBefore().toInstant() - .getEpochSecond() - || credential.validity().notAfter().getEpochSecond() != holder.getNotAfter().toInstant() - .getEpochSecond()) { + X500Name expectedSubject = + zeroecho.pki.impl.framework.x509.bc.BcX509ProfileSupport.subject(request.subjectRdns()); + X500Name expectedIssuer = request.certificateType() == CertificateProfileKind.ROOT_CA + ? expectedSubject : issuerHolder.getSubject(); + if (!matchesCaCertificateIdentity(holder, issuerHolder, request, expectedSubject, expectedIssuer, + actualSpki) + || !matchesCaCertificatePolicy(holder, request, constraintsExtension, constraints, + keyUsageExtension, keyUsage) + || !matchesCaCredentialMetadata(credential, holder, request, actualSpki)) { throw proofGate.rejection(action, framework.formatId(), Optional.of(subjectCaId), - BACKEND_CRED_MISMATCH); + mismatchCode); } + CredentialProfileBindings.requireCaBinding(credential.profileBinding(), request.profileReference()); } catch (PkiException ex) { throw ex; } catch (Exception ex) { throw proofGate.rejection(action, framework.formatId(), Optional.of(subjectCaId), - BACKEND_CRED_MISMATCH); + mismatchCode); } } - private void requireCaBinding(Credential credential, String expectedCaProfileId, String action, + private boolean matchesCaCredentialEnvelope(Credential credential, ValidatedCaCertificateRequest request, + PkiId subjectCaId) { + return framework.formatId().equals(credential.formatId()) + && credential.encoded().encoding() == Encoding.DER + && credential.status() == CredentialStatus.ISSUED + && credential.subjectRef().equals(request.subjectRef()) + && credential.issuerRef().equals(new IssuerRef( + request.certificateType() == CertificateProfileKind.ROOT_CA + ? subjectCaId : request.issuerCaId())); + } + + private static boolean matchesCaCertificateIdentity(X509CertificateHolder holder, + X509CertificateHolder issuerHolder, ValidatedCaCertificateRequest request, X500Name expectedSubject, + X500Name expectedIssuer, byte[] actualSpki) + throws IOException, OperatorCreationException, CertException { + return MessageDigest.isEqual(request.exactPublicKey().bytes(), actualSpki) + && MessageDigest.isEqual(holder.getSubject().getEncoded(), expectedSubject.getEncoded()) + && MessageDigest.isEqual(holder.getIssuer().getEncoded(), expectedIssuer.getEncoded()) + && holder.isSignatureValid( + new JcaContentVerifierProviderBuilder().build(issuerHolder.getSubjectPublicKeyInfo())); + } + + private static boolean matchesCaCertificatePolicy(X509CertificateHolder holder, + ValidatedCaCertificateRequest request, Extension constraintsExtension, BasicConstraints constraints, + Extension keyUsageExtension, KeyUsage keyUsage) { + return constraintsExtension != null && constraintsExtension.isCritical() + && constraints != null && constraints.isCA() + && BigInteger.valueOf(request.policy().pathLengthConstraint()) + .equals(constraints.getPathLenConstraint()) + && keyUsageExtension != null && keyUsageExtension.isCritical() + && hasIntermediateKeyUsage(keyUsage) + && holder.getExtensions().getExtensionOIDs().length == 2; + } + + private static boolean matchesCaCredentialMetadata(Credential credential, X509CertificateHolder holder, + ValidatedCaCertificateRequest request, byte[] actualSpki) { + return credential.publicKeyId().equals(new PkiId("spki:" + sha256Hex(actualSpki))) + && credential.credentialId() + .equals(new PkiId("x509:" + sha256Hex(credential.encoded().bytes()))) + && credential.serialOrUniqueId().equals(holder.getSerialNumber().toString()) + && credential.validity().notBefore().getEpochSecond() == holder.getNotBefore().toInstant() + .getEpochSecond() + && credential.validity().notAfter().getEpochSecond() == holder.getNotAfter().toInstant() + .getEpochSecond() + && credential.validity().equals(request.validity()) + && credential.serialOrUniqueId().equals(request.serial().toString()) + && credential.attributes().ids().isEmpty(); + } + + private void requireCaBinding(Credential credential, + zeroecho.pki.api.profile.CertificateProfileRef expectedCaProfile, String action, FormatId formatId, Optional objectId) { try { CredentialProfileBindings.requireCaBinding( - credential == null ? null : credential.profileBinding(), expectedCaProfileId); + credential == null ? null : credential.profileBinding(), expectedCaProfile); } catch (PkiException mismatch) { throw proofGate.rejection(action, formatId, objectId, CredentialProfileBindings.MISMATCH_CODE); } @@ -868,16 +972,33 @@ public final class DefaultCaService implements CaService { && !keyUsage.hasUsages(KeyUsage.decipherOnly); } - private static AttributeSet authoritativeIntermediateAttributes(AttributeSet callerAttributes, CaRecord issuer, - Credential issuerCredential, EncodedObject subjectSpki, SubjectRef subjectRef) { - SimpleAttributeSet.Builder builder = SimpleAttributeSet.builder().putAll(callerAttributes); - builder.put(BcX509Attributes.ISSUER_CERT_DER, - new AttributeValue.BytesValue(issuerCredential.encoded().bytes().clone())); - builder.put(BcX509Attributes.ISSUER_KEYREF, new AttributeValue.StringValue(issuer.issuerKeyRef().value())); - builder.put(BcX509Attributes.SUBJECT_SPKI_DER, - new AttributeValue.BytesValue(subjectSpki.bytes().clone())); - builder.put(BcX509Attributes.SUBJECT_DN, new AttributeValue.StringValue(subjectRef.value())); - return builder.build(); + private static void requireNoCaOverrides(AttributeSet attributes) { + if (!attributes.ids().isEmpty()) { + throw new PkiException("CA certificate profile rejected: code=CA_REQUEST_ATTRIBUTE_UNSUPPORTED"); + } + } + + private void requireHistoricalCaProfile(Credential credential, CertificateProfileKind expectedKind) { + if (!(credential.profileBinding() instanceof CaProfileBinding binding)) { + throw new PkiException("CA issuer profile rejected: code=CREDENTIAL_PROFILE_BINDING_MISMATCH"); + } + zeroecho.pki.api.profile.CertificateProfileRef reference = binding.reference(); + zeroecho.pki.api.profile.ImportedCertificateProfileVersion version = profileService + .getImportedVersion(reference.profileId(), reference.profileVersion()) + .orElseThrow(() -> new PkiException("CA issuer profile rejected: code=CA_PROFILE_VERSION_MISSING")); + if (!version.reference().equals(reference) || version.definition().certificateType() != expectedKind + || !version.definition().formatId().equals(credential.formatId()) + || !credential.formatId().equals(framework.formatId())) { + throw new PkiException("CA issuer profile rejected: code=CA_PROFILE_REFERENCE_MISMATCH"); + } + } + + private void requireSameManagedKey(EncodedObject expected, EncodedObject actual, String action, + FormatId formatId, Optional objectId) { + if (expected.encoding() != actual.encoding() + || !MessageDigest.isEqual(expected.bytes(), actual.bytes())) { + throw proofGate.rejection(action, formatId, objectId, "MANAGED_KEY_CHANGED"); + } } private static String sha256Hex(byte[] in) { diff --git a/pki/src/main/java/zeroecho/pki/impl/core/ManagedCaIssuance.java b/pki/src/main/java/zeroecho/pki/impl/core/ManagedCaIssuance.java deleted file mode 100644 index a39f96b..0000000 --- a/pki/src/main/java/zeroecho/pki/impl/core/ManagedCaIssuance.java +++ /dev/null @@ -1,194 +0,0 @@ -/******************************************************************************* - * 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 following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, this - * list of conditions and the following disclaimer. - * - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * - * 3. All advertising materials mentioning features or use of this software must - * display the following acknowledgement: - * This product includes software developed by the Egothor project. - * - * 4. Neither the name of the copyright holder nor the names of its contributors - * may be used to endorse or promote products derived from this software without - * specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND - * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED - * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR - * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - ******************************************************************************/ -package zeroecho.pki.impl.core; - -import java.util.Objects; -import java.util.Optional; - -import zeroecho.pki.api.EncodedObject; -import zeroecho.pki.api.FormatId; -import zeroecho.pki.api.KeyRef; -import zeroecho.pki.api.PkiId; -import zeroecho.pki.api.SubjectRef; -import zeroecho.pki.api.Validity; -import zeroecho.pki.api.attr.AttributeSet; - -/** - * Immutable opaque authority for an intermediate CA issuance operation. - * - *

- * Instances are constructed only inside the core CA proof gate after a successful - * managed-key possession challenge. The class has no public constructor or - * factory, so callers outside the core implementation cannot convert raw - * attributes or public-key bytes into issuance authority. - *

- * - *

- * All mutable values are defensively snapshotted. Accessors return defensive - * copies where necessary, allowing a backend to consume the object concurrently - * without observing caller mutation. - *

- */ -@SuppressWarnings("PMD.DataClass") -public final class ManagedCaIssuance { - - /** - * Supported proof-bound CA issuance operations. - */ - public enum Operation { - /** Creates the initial credential for a newly defined intermediate CA. */ - CREATE_INTERMEDIATE, - /** Issues an additional credential for an existing intermediate CA. */ - ISSUE_INTERMEDIATE - } - - private final Operation operation; - private final FormatId formatId; - private final PkiId issuerCaId; - private final PkiId subjectCaId; - private final String profileId; - private final Optional requestedValidity; - private final AttributeSet attributes; - private final SubjectRef subjectRef; - private final KeyRef subjectKeyRef; - private final EncodedObject exactPublicKey; - - /* default */ ManagedCaIssuance(CaProofGate.ManagedKeyProof proof, Operation operation, PkiId issuerCaId, - PkiId subjectCaId, String profileId, Optional requestedValidity, AttributeSet attributes, - SubjectRef subjectRef) { - CaProofGate.ManagedKeyProof managedKeyProof = Objects.requireNonNull(proof, "proof"); - this.operation = Objects.requireNonNull(operation, "operation"); - this.formatId = managedKeyProof.formatId(); - this.issuerCaId = Objects.requireNonNull(issuerCaId, "issuerCaId"); - this.subjectCaId = Objects.requireNonNull(subjectCaId, "subjectCaId"); - this.profileId = Objects.requireNonNull(profileId, "profileId"); - this.requestedValidity = Objects.requireNonNull(requestedValidity, "requestedValidity"); - this.attributes = VerifiedIssuanceCandidate.snapshotAttributes(Objects.requireNonNull(attributes, - "attributes")); - this.subjectRef = Objects.requireNonNull(subjectRef, "subjectRef"); - this.subjectKeyRef = managedKeyProof.keyRef(); - EncodedObject publicKey = managedKeyProof.exactPublicKey(); - this.exactPublicKey = new EncodedObject(publicKey.encoding(), publicKey.bytes()); - } - - /** - * Returns the authorized operation. - * - * @return authorized operation, never {@code null} - */ - public Operation operation() { - return operation; - } - - /** - * Returns the credential format. - * - * @return format identifier, never {@code null} - */ - public FormatId formatId() { - return formatId; - } - - /** - * Returns the issuing CA identifier. - * - * @return issuing CA identifier, never {@code null} - */ - public PkiId issuerCaId() { - return issuerCaId; - } - - /** - * Returns the subject CA identifier. - * - * @return subject CA identifier, never {@code null} - */ - public PkiId subjectCaId() { - return subjectCaId; - } - - /** - * Returns the validated profile identifier. - * - * @return profile identifier, never blank - */ - public String profileId() { - return profileId; - } - - /** - * Returns the optional validated validity request. - * - * @return optional validity, never {@code null} - */ - public Optional requestedValidity() { - return requestedValidity; - } - - /** - * Returns a defensive snapshot of authoritative CA attributes. - * - * @return immutable attribute snapshot, never {@code null} - */ - public AttributeSet attributes() { - return VerifiedIssuanceCandidate.snapshotAttributes(attributes); - } - - /** - * Returns the subject bound to the possession proof. - * - * @return exact subject, never {@code null} - */ - public SubjectRef subjectRef() { - return subjectRef; - } - - /** - * Returns the managed key reference that completed the challenge. - * - * @return proven managed key reference, never {@code null} - */ - public KeyRef subjectKeyRef() { - return subjectKeyRef; - } - - /** - * Returns a defensive copy of the exact public key bound to the proof. - * - * @return DER-encoded public key, never {@code null} - */ - public EncodedObject exactPublicKey() { - return new EncodedObject(exactPublicKey.encoding(), exactPublicKey.bytes()); - } -} diff --git a/pki/src/main/java/zeroecho/pki/impl/core/ValidatedCaCertificateRequest.java b/pki/src/main/java/zeroecho/pki/impl/core/ValidatedCaCertificateRequest.java new file mode 100644 index 0000000..c14a9a5 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/core/ValidatedCaCertificateRequest.java @@ -0,0 +1,138 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.impl.core; + +import java.math.BigInteger; +import java.util.List; +import java.util.Objects; + +import zeroecho.pki.api.EncodedObject; +import zeroecho.pki.api.FormatId; +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.SubjectRef; +import zeroecho.pki.api.Validity; +import zeroecho.pki.api.profile.CaCertificatePolicy; +import zeroecho.pki.api.profile.CertificateProfileKind; +import zeroecho.pki.api.profile.CertificateProfileRef; +import zeroecho.pki.api.request.SubjectRdn; + +/** + * Immutable gate-produced CA certificate request accepted by the issuer backend. + * + *

The request contains only values approved against one exact active profile + * version. It carries no generic attributes, raw profile document, or caller + * selected extension material.

+ */ +@SuppressWarnings("PMD.DataClass") +public final class ValidatedCaCertificateRequest { + /** Closed CA certificate operations authorized by this gate. */ + public enum Operation { + /** Creates a self-signed root credential. */ + CREATE_ROOT, + /** Imports an existing root credential after full profile validation. */ + IMPORT_ROOT, + /** Creates an intermediate CA's initial credential. */ + CREATE_INTERMEDIATE, + /** Issues an additional intermediate CA credential. */ + ISSUE_INTERMEDIATE + } + + private final Operation operation; + private final FormatId formatId; + private final PkiId issuerCaId; + private final PkiId subjectCaId; + private final CertificateProfileRef profileReference; + private final CertificateProfileKind certificateType; + private final SubjectRef subjectRef; + private final List subjectRdns; + private final EncodedObject exactPublicKey; + private final Validity validity; + private final BigInteger serial; + private final CaCertificatePolicy policy; + + // The constructor is the single cohesive gate output boundary. + @SuppressWarnings("PMD.ExcessiveParameterList") + /* package */ ValidatedCaCertificateRequest(Operation operation, FormatId formatId, + PkiId issuerCaId, PkiId subjectCaId, CertificateProfileRef profileReference, + CertificateProfileKind certificateType, SubjectRef subjectRef, List subjectRdns, + EncodedObject exactPublicKey, Validity validity, BigInteger serial, CaCertificatePolicy policy) { + this.operation = Objects.requireNonNull(operation, "operation"); + this.formatId = Objects.requireNonNull(formatId, "formatId"); + this.issuerCaId = Objects.requireNonNull(issuerCaId, "issuerCaId"); + this.subjectCaId = Objects.requireNonNull(subjectCaId, "subjectCaId"); + this.profileReference = Objects.requireNonNull(profileReference, "profileReference"); + this.certificateType = Objects.requireNonNull(certificateType, "certificateType"); + this.subjectRef = Objects.requireNonNull(subjectRef, "subjectRef"); + this.subjectRdns = List.copyOf(Objects.requireNonNull(subjectRdns, "subjectRdns")); + EncodedObject publicKey = Objects.requireNonNull(exactPublicKey, "exactPublicKey"); + this.exactPublicKey = new EncodedObject(publicKey.encoding(), publicKey.bytes()); + this.validity = Objects.requireNonNull(validity, "validity"); + this.serial = Objects.requireNonNull(serial, "serial"); + this.policy = Objects.requireNonNull(policy, "policy"); + if (serial.signum() <= 0 || serial.toByteArray().length > 20) { + throw new IllegalArgumentException("serial must be positive and at most 20 bytes"); + } + } + + /** Returns the authorized operation. */ + public Operation operation() { + return operation; + } + + /** Returns the credential format. */ + public FormatId formatId() { + return formatId; + } + + /** Returns the issuing CA identifier. */ + public PkiId issuerCaId() { + return issuerCaId; + } + + /** Returns the subject CA identifier. */ + public PkiId subjectCaId() { + return subjectCaId; + } + + /** Returns the exact active profile reference. */ + public CertificateProfileRef profileReference() { + return profileReference; + } + + /** Returns the required certificate kind. */ + public CertificateProfileKind certificateType() { + return certificateType; + } + + /** Returns the canonical subject reference. */ + public SubjectRef subjectRef() { + return subjectRef; + } + + /** Returns the ordered canonical subject components. */ + public List subjectRdns() { + return subjectRdns; + } + + /** Returns a defensive copy of the proof-bound public key. */ + public EncodedObject exactPublicKey() { + return new EncodedObject(exactPublicKey.encoding(), exactPublicKey.bytes()); + } + + /** Returns the approved exact validity. */ + public Validity validity() { + return validity; + } + + /** Returns the issuer-controlled serial. */ + public BigInteger serial() { + return serial; + } + + /** Returns the exact profile-derived CA policy. */ + public CaCertificatePolicy policy() { + return policy; + } +} 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 bbe4c45..4cdfe6a 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 @@ -36,10 +36,8 @@ package zeroecho.pki.impl.framework.x509.bc; import java.math.BigInteger; import java.security.MessageDigest; import java.time.Duration; -import java.time.Instant; import java.util.Date; import java.util.HexFormat; -import java.util.Optional; import org.bouncycastle.asn1.ASN1ObjectIdentifier; import org.bouncycastle.asn1.DEROctetString; @@ -64,9 +62,6 @@ import zeroecho.pki.api.PkiException; import zeroecho.pki.api.PkiId; import zeroecho.pki.api.SubjectRef; import zeroecho.pki.api.Validity; -import zeroecho.pki.api.attr.AttributeId; -import zeroecho.pki.api.attr.AttributeSet; -import zeroecho.pki.api.attr.AttributeValue; import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.credential.CaProfileBinding; import zeroecho.pki.api.credential.EndEntityProfileBinding; @@ -74,7 +69,7 @@ import zeroecho.pki.api.credential.CredentialBundle; import zeroecho.pki.api.credential.CredentialStatus; import zeroecho.pki.api.profile.LeafKeyUsage; import zeroecho.pki.api.request.SubjectAlternativeName; -import zeroecho.pki.impl.core.ManagedCaIssuance; +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; @@ -94,9 +89,9 @@ import zeroecho.pki.spi.framework.CredentialIssuerBackend; *

* End-entity issuance derives all requester-influenced certificate material * exclusively from a profile-gated {@link ValidatedCertificateRequest}. - * Intermediate CA issuance retains its proof-gated managed-CA input and - * framework attributes because it operates on an existing CA subject entity - * rather than the end-entity CSR flow. + * CA issuance derives certificate content exclusively from a proof-bound, + * active-profile-validated request and separately supplied trusted issuer + * material. *

* *

Signing model

@@ -188,7 +183,7 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack byte[] issuerDer = issuerCertificate.bytes(); X509CertificateHolder issuer; try { - issuer = IssuanceContext.parseIssuerCertificateOrThrow(issuerDer); + issuer = parseIssuerCertificateOrThrow(issuerDer); } finally { java.util.Arrays.fill(issuerDer, (byte) 0); } @@ -288,16 +283,19 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack * Issues an intermediate CA X.509 certificate. * *

- * The method derives issuer and subject wiring from framework attributes, - * constructs an intermediate CA certificate with CA-oriented extensions, - * delegates signing through {@link PkiSigningBus}, and returns the resulting - * credential. + * The method consumes only the gate-produced validated CA request and trusted + * issuer inputs, constructs an intermediate CA certificate with CA-oriented + * extensions, delegates signing through {@link PkiSigningBus}, and returns the + * resulting credential. *

* - * @param issuance gate-produced managed CA issuance authority; must not be - * {@code null} + * @param request gate-produced validated CA certificate request; + * must not be {@code null} + * @param issuerCertificate trusted issuer certificate; must not be {@code null} + * @param issuerKeyRef trusted issuer signing-key reference; must not be + * {@code null} * @return issued intermediate CA credential - * @throws IllegalArgumentException if {@code command} is {@code null} or uses + * @throws IllegalArgumentException if {@code request} is {@code null} or uses * an unsupported format identifier * @throws PkiException if issuer wiring or subject wiring is * missing or invalid, certificate construction @@ -305,17 +303,23 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack * encoding fails */ @Override - public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) { - if (issuance == null) { - throw new IllegalArgumentException("issuance must not be null"); + public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest request, + EncodedObject issuerCertificate, KeyRef issuerKeyRef) { + if (request == null || issuerCertificate == null || issuerKeyRef == null) { + throw new IllegalArgumentException("validated CA issuance inputs must not be null"); } - if (!BcX509CredentialFramework.FORMAT_ID.equals(issuance.formatId())) { + if (!BcX509CredentialFramework.FORMAT_ID.equals(request.formatId())) { throw new IllegalArgumentException("Unsupported formatId"); } - IssuanceContext ctx = IssuanceContext.from(issuance.attributes()); - X509CertificateHolder issuer = ctx.issuerCertHolder; - byte[] subjectSpki = issuance.exactPublicKey().bytes(); + byte[] issuerDer = issuerCertificate.bytes(); + X509CertificateHolder issuer; + try { + issuer = parseIssuerCertificateOrThrow(issuerDer); + } finally { + java.util.Arrays.fill(issuerDer, (byte) 0); + } + byte[] subjectSpki = request.exactPublicKey().bytes(); SubjectPublicKeyInfo spki; PkiId publicKeyId; try { @@ -324,24 +328,24 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack } finally { java.util.Arrays.fill(subjectSpki, (byte) 0); } - SubjectRef subjectRef = issuance.subjectRef(); - - Instant now = Instant.now(); - Validity validity = issuance.requestedValidity().orElse(new Validity(now, now.plus(Duration.ofDays(3650)))); - BigInteger serial = ctx.serial.orElse(BigInteger.valueOf(Math.abs(System.nanoTime()))); + SubjectRef subjectRef = request.subjectRef(); + Validity validity = request.validity(); + BigInteger serial = request.serial(); X500Name issuerDn = issuer.getSubject(); - X500Name subjectDn = new X500Name(subjectRef.value()); + X500Name subjectDn = BcX509ProfileSupport.subject(request.subjectRdns()); X509v3CertificateBuilder builder = new X509v3CertificateBuilder(issuerDn, serial, Date.from(validity.notBefore()), Date.from(validity.notAfter()), subjectDn, spki); try { - builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(0)); - builder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.keyCertSign | KeyUsage.cRLSign)); + builder.addExtension(Extension.basicConstraints, request.policy().basicConstraintsCritical(), + new BasicConstraints(request.policy().pathLengthConstraint())); + builder.addExtension(Extension.keyUsage, request.policy().keyUsageCritical(), + new KeyUsage(KeyUsage.keyCertSign | KeyUsage.cRLSign)); } catch (Exception ex) { throw new PkiException("X.509 extension construction failed: code=EXTENSION_BUILD_FAILED"); } - ContentSigner signer = new PkiBusContentSigner(signingBus, ctx.issuerKeyRef, signatureAlgorithmId, signingTtl); + ContentSigner signer = new PkiBusContentSigner(signingBus, issuerKeyRef, signatureAlgorithmId, signingTtl); X509CertificateHolder certificate; try { certificate = builder.build(signer); @@ -359,170 +363,20 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack PkiId credId = new PkiId("x509:" + sha256Hex(certDer)); try { - return new Credential(credId, issuance.formatId(), new IssuerRef(issuance.issuerCaId()), subjectRef, - validity, serial.toString(), publicKeyId, new CaProfileBinding(issuance.profileId()), + return new Credential(credId, request.formatId(), new IssuerRef(request.issuerCaId()), subjectRef, + validity, serial.toString(), publicKeyId, new CaProfileBinding(request.profileReference()), CredentialStatus.ISSUED, - new EncodedObject(Encoding.DER, certDer), issuance.attributes()); + new EncodedObject(Encoding.DER, certDer), SimpleAttributeSet.builder().build()); } finally { java.util.Arrays.fill(certDer, (byte) 0); } } - /** - * Immutable issuance context derived from framework-specific adapter - * attributes. - * - *

- * This helper encapsulates the issuer-side material and optional subject-side - * overrides required by the X.509 issuance backend. It keeps the main issuance - * methods focused on certificate construction while centralizing attribute - * validation and conversion. - *

- */ - private static final class IssuanceContext { - - private final X509CertificateHolder issuerCertHolder; - private final KeyRef issuerKeyRef; - private final Optional serial; - - /** - * Creates the issuance context. - * - * @param issuerCertHolder parsed issuer certificate holder; must not be - * {@code null} - * @param issuerKeyRef issuer signing key reference; must not be - * {@code null} - * @param serial optional serial override; must not be {@code null} - */ - private IssuanceContext(X509CertificateHolder issuerCertHolder, KeyRef issuerKeyRef, - Optional serial) { - this.issuerCertHolder = issuerCertHolder; - this.issuerKeyRef = issuerKeyRef; - this.serial = serial; - } - - /** - * Builds an issuance context from framework-specific attributes. - * - *

- * Required attributes: - *

- *
    - *
  • {@link BcX509Attributes#ISSUER_CERT_DER} as - * {@link AttributeValue.BytesValue}
  • - *
  • {@link BcX509Attributes#ISSUER_KEYREF} as - * {@link AttributeValue.StringValue}
  • - *
- * - *

- * Optional attributes: - *

- *
    - *
  • {@link BcX509Attributes#SERIAL} as - * {@link AttributeValue.IntegerValue}
  • - *
- * - * @param attrs source attribute set; must not be {@code null} - * @return validated issuance context - * @throws IllegalArgumentException if {@code attrs} is {@code null} - * @throws PkiException if required attributes are missing, - * attributes use an unexpected value type, or - * the issuer certificate DER cannot be parsed - */ - private static IssuanceContext from(AttributeSet attrs) { - if (attrs == null) { - throw new IllegalArgumentException("attrs must not be null"); - } - - byte[] issuerCertDer = requiredBytes(attrs, BcX509Attributes.ISSUER_CERT_DER, - "Missing issuer certificate DER attribute", "Issuer certificate DER must be BytesValue"); - KeyRef issuerKeyRef = new KeyRef(requiredString(attrs, BcX509Attributes.ISSUER_KEYREF, - "Missing issuer keyref attribute", "Issuer keyref must be StringValue")); - - Optional serial = optionalInteger(attrs, BcX509Attributes.SERIAL, "Serial must be IntegerValue") - .map(BigInteger::valueOf); - - X509CertificateHolder issuerHolder = parseIssuerCertificateOrThrow(issuerCertDer); - return new IssuanceContext(issuerHolder, issuerKeyRef, serial); - } - - /** - * Resolves a required bytes-valued attribute. - * - * @param attrs source attributes; must not be {@code null} - * @param id attribute identifier; must not be {@code null} - * @param missingMessage exception message used when the attribute is absent - * @param typeMessage exception message used when the attribute has an - * unexpected type - * @return bytes stored in the attribute - * @throws PkiException if the attribute is missing or has an unexpected value - * type - */ - private static byte[] requiredBytes(AttributeSet attrs, AttributeId id, String missingMessage, - String typeMessage) { - AttributeValue value = attrs.get(id).orElseThrow(() -> new PkiException(missingMessage)); - if (!(value instanceof AttributeValue.BytesValue)) { - throw new PkiException(typeMessage); - } - return ((AttributeValue.BytesValue) value).value(); - } - - /** - * Resolves a required string-valued attribute. - * - * @param attrs source attributes; must not be {@code null} - * @param id attribute identifier; must not be {@code null} - * @param missingMessage exception message used when the attribute is absent - * @param typeMessage exception message used when the attribute has an - * unexpected type - * @return string stored in the attribute - * @throws PkiException if the attribute is missing or has an unexpected value - * type - */ - private static String requiredString(AttributeSet attrs, AttributeId id, String missingMessage, - String typeMessage) { - AttributeValue value = attrs.get(id).orElseThrow(() -> new PkiException(missingMessage)); - if (!(value instanceof AttributeValue.StringValue)) { - throw new PkiException(typeMessage); - } - return ((AttributeValue.StringValue) value).value(); - } - - /** - * Resolves an optional integer-valued attribute. - * - * @param attrs source attributes; must not be {@code null} - * @param id attribute identifier; must not be {@code null} - * @param typeMessage exception message used when the attribute has an - * unexpected type - * @return optional integer value - * @throws PkiException if the attribute is present but has an unexpected value - * type - */ - private static Optional optionalInteger(AttributeSet attrs, AttributeId id, String typeMessage) { - Optional value = attrs.get(id); - if (value.isEmpty()) { - return Optional.empty(); - } - if (!(value.get() instanceof AttributeValue.IntegerValue)) { - throw new PkiException(typeMessage); - } - return Optional.of(((AttributeValue.IntegerValue) value.get()).value()); - } - - /** - * Parses the issuer certificate DER into an X.509 certificate holder. - * - * @param issuerCertDer DER-encoded issuer certificate; must not be {@code null} - * @return parsed issuer certificate holder - * @throws PkiException if the issuer certificate DER is invalid - */ - private static X509CertificateHolder parseIssuerCertificateOrThrow(byte[] issuerCertDer) { - try { - return new X509CertificateHolder(issuerCertDer); - } catch (Exception ex) { - throw new PkiException("Invalid issuer certificate: code=ISSUER_CERTIFICATE_INVALID"); - } + private static X509CertificateHolder parseIssuerCertificateOrThrow(byte[] issuerCertDer) { + try { + return new X509CertificateHolder(issuerCertDer); + } catch (Exception ex) { + throw new PkiException("Invalid issuer certificate: code=ISSUER_CERTIFICATE_INVALID"); } } diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java index 68271a1..ff4bdfc 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java @@ -466,6 +466,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { } return existing; } + requireConsistentProfileKind(version); try { FsOperations.writeNewAtomicStrict(target, FsCodec.encode(FsCodec.PROFILE_VERSION, version)); return version; @@ -487,6 +488,16 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { } } + private void requireConsistentProfileKind(ImportedCertificateProfileVersion candidate) { + for (ImportedCertificateProfileVersion existing : + listProfileVersions(candidate.reference().profileId())) { + if (existing.definition().certificateType() + != candidate.definition().certificateType()) { + throw new ProfileLifecycleFailure(Code.PROFILE_KIND_CONFLICT); + } + } + } + @Override public Optional getProfileVersion(final String profileId, final long profileVersion) { diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java index c50fdde..1657d9b 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java @@ -688,7 +688,7 @@ final class FsCodec { } case CaProfileBinding ca -> { writer.writeUnsignedByte(2); - writer.writeValue(STRING, ca.profileId()); + writer.writeValue(PROFILE_REF, ca.reference()); } } } @@ -696,7 +696,7 @@ final class FsCodec { private static CredentialProfileBinding readProfileBinding(Reader reader) throws IOException { return switch (reader.readUnsignedByte()) { case 1 -> new EndEntityProfileBinding(reader.readValue(PROFILE_REF)); - case 2 -> new CaProfileBinding(reader.readValue(STRING)); + case 2 -> new CaProfileBinding(reader.readValue(PROFILE_REF)); default -> throw new IOException("unknown credential profile binding"); }; } diff --git a/pki/src/main/java/zeroecho/pki/spi/framework/CredentialIssuerBackend.java b/pki/src/main/java/zeroecho/pki/spi/framework/CredentialIssuerBackend.java index b51097f..59d0129 100644 --- a/pki/src/main/java/zeroecho/pki/spi/framework/CredentialIssuerBackend.java +++ b/pki/src/main/java/zeroecho/pki/spi/framework/CredentialIssuerBackend.java @@ -39,7 +39,7 @@ import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.KeyRef; import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.credential.CredentialBundle; -import zeroecho.pki.impl.core.ManagedCaIssuance; +import zeroecho.pki.impl.core.ValidatedCaCertificateRequest; import zeroecho.pki.impl.core.ValidatedCertificateRequest; /** @@ -153,13 +153,14 @@ public interface CredentialIssuerBackend { *

* *

- * The supplied {@link ManagedCaIssuance} can be constructed only after the core - * CA proof gate has completed a managed-key possession challenge and bound the - * exact public key, subject, operation, and authoritative attributes. + * The supplied {@link ValidatedCaCertificateRequest} can be constructed only + * after the core CA proof and active-profile gates have bound the exact public + * key, subject, profile version, policy, validity, and serial. *

* - * @param issuance gate-produced managed CA issuance authority; must not be - * {@code null} + * @param request gate-produced validated CA request; must not be {@code null} + * @param issuerCertificate trusted encoded issuer certificate + * @param issuerKeyRef trusted issuer signing-key reference * @return issued CA credential, never {@code null} * @throws IllegalArgumentException if {@code command} is {@code null} or * structurally invalid for the concrete @@ -168,5 +169,6 @@ public interface CredentialIssuerBackend { * or other framework-specific issuance * processing fails */ - Credential issueIntermediateCertificate(ManagedCaIssuance issuance); + Credential issueIntermediateCertificate(ValidatedCaCertificateRequest request, + EncodedObject issuerCertificate, KeyRef issuerKeyRef); } diff --git a/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java b/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java index e94d178..7e4681c 100644 --- a/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java +++ b/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java @@ -245,7 +245,24 @@ public interface PkiStore extends SignWorkflowStore { */ List listPublicationRecords(); - /** Atomically imports one immutable validated profile version. */ + /** + * Atomically imports one immutable validated profile version. + * + *

+ * Every version stored under one logical profile ID must have the same + * certificate kind. An implementation must reject an import whose kind + * differs from any already imported version for that ID. For an existing + * identical ID and version, exact canonical-reference equality is + * idempotent and returns the committed version; a different reference for + * that same version is a version conflict. This same-version decision takes + * precedence over the cross-version kind check. + *

+ * + * @param version validated immutable version to import + * @return the newly committed version or the identical existing version + * @throws RuntimeException if validation, kind consistency, conflict + * handling, or durable persistence fails + */ ImportedCertificateProfileVersion importProfileVersion(ImportedCertificateProfileVersion version); /** Retrieves one imported version. */ diff --git a/pki/src/main/resources/zeroecho/pki/profiles/v1/catalog.json b/pki/src/main/resources/zeroecho/pki/profiles/v1/catalog.json index fa21c67..d723fd1 100644 --- a/pki/src/main/resources/zeroecho/pki/profiles/v1/catalog.json +++ b/pki/src/main/resources/zeroecho/pki/profiles/v1/catalog.json @@ -1 +1 @@ -{"schemaVersion":1,"profiles":[{"resource":"zeroecho/pki/profiles/v1/server-tls.json"},{"resource":"zeroecho/pki/profiles/v1/vpn-server.json"},{"resource":"zeroecho/pki/profiles/v1/vpn-client.json"},{"resource":"zeroecho/pki/profiles/v1/email-signing.json"}]} \ No newline at end of file +{"schemaVersion":1,"profiles":[{"resource":"zeroecho/pki/profiles/v1/server-tls.json"},{"resource":"zeroecho/pki/profiles/v1/vpn-server.json"},{"resource":"zeroecho/pki/profiles/v1/vpn-client.json"},{"resource":"zeroecho/pki/profiles/v1/email-signing.json"},{"resource":"zeroecho/pki/profiles/v1/root-ca.json"},{"resource":"zeroecho/pki/profiles/v1/intermediate-ca.json"}]} \ No newline at end of file diff --git a/pki/src/main/resources/zeroecho/pki/profiles/v1/email-signing.json b/pki/src/main/resources/zeroecho/pki/profiles/v1/email-signing.json index 89c9146..d14a2e8 100644 --- a/pki/src/main/resources/zeroecho/pki/profiles/v1/email-signing.json +++ b/pki/src/main/resources/zeroecho/pki/profiles/v1/email-signing.json @@ -1 +1 @@ -{"schemaVersion":1,"profileId":"email-signing","profileVersion":1,"formatId":"x509","displayName":"Email Signing","maxValidity":"PT8760H","subject":{"allowEmpty":true,"rules":[{"oid":"2.5.4.3","source":"REQUESTER","minimumOccurrences":0,"maximumOccurrences":1,"maximumUtf8Bytes":253}]},"subjectAlternativeNames":{"minimumTotal":1,"maximumTotal":16,"serviceIdentityRequired":false,"emailIdentityRequired":true,"criticalWhenSubjectNonEmpty":false,"rules":[{"type":"RFC822_NAME","minimumOccurrences":1,"maximumOccurrences":16}]},"leafCertificate":{"basicConstraintsCritical":true,"keyUsageCritical":true,"keyUsage":["DIGITAL_SIGNATURE"],"extendedKeyUsageCritical":false,"extendedKeyUsage":["1.3.6.1.5.5.7.3.4"],"allowedKeyAlgorithms":["ECDSA","Ed25519","RSA"]}} \ No newline at end of file +{"schemaVersion":2,"certificateType":"END_ENTITY","profileId":"email-signing","profileVersion":1,"formatId":"x509","displayName":"Email Signing","maxValidity":"PT8760H","subject":{"allowEmpty":true,"rules":[{"oid":"2.5.4.3","source":"REQUESTER","minimumOccurrences":0,"maximumOccurrences":1,"maximumUtf8Bytes":253}]},"subjectAlternativeNames":{"minimumTotal":1,"maximumTotal":16,"serviceIdentityRequired":false,"emailIdentityRequired":true,"criticalWhenSubjectNonEmpty":false,"rules":[{"type":"RFC822_NAME","minimumOccurrences":1,"maximumOccurrences":16}]},"leafCertificate":{"basicConstraintsCritical":true,"keyUsageCritical":true,"keyUsage":["DIGITAL_SIGNATURE"],"extendedKeyUsageCritical":false,"extendedKeyUsage":["1.3.6.1.5.5.7.3.4"],"allowedKeyAlgorithms":["ECDSA","Ed25519","RSA"]}} \ No newline at end of file diff --git a/pki/src/main/resources/zeroecho/pki/profiles/v1/intermediate-ca.json b/pki/src/main/resources/zeroecho/pki/profiles/v1/intermediate-ca.json new file mode 100644 index 0000000..18110e6 --- /dev/null +++ b/pki/src/main/resources/zeroecho/pki/profiles/v1/intermediate-ca.json @@ -0,0 +1 @@ +{"schemaVersion":2,"certificateType":"INTERMEDIATE_CA","profileId":"intermediate-ca","profileVersion":1,"formatId":"x509","displayName":"Intermediate CA","maxValidity":"PT43800H","subject":{"allowEmpty":false,"rules":[{"oid":"2.5.4.3","source":"REQUESTER","minimumOccurrences":1,"maximumOccurrences":1,"maximumUtf8Bytes":253}]},"caCertificate":{"basicConstraintsCritical":true,"pathLengthConstraint":0,"keyUsageCritical":true,"keyUsages":["CRL_SIGN","KEY_CERT_SIGN"],"allowedSubjectKeyAlgorithms":["ECDSA","Ed25519","RSA"]}} \ No newline at end of file diff --git a/pki/src/main/resources/zeroecho/pki/profiles/v1/root-ca.json b/pki/src/main/resources/zeroecho/pki/profiles/v1/root-ca.json new file mode 100644 index 0000000..388a908 --- /dev/null +++ b/pki/src/main/resources/zeroecho/pki/profiles/v1/root-ca.json @@ -0,0 +1 @@ +{"schemaVersion":2,"certificateType":"ROOT_CA","profileId":"root-ca","profileVersion":1,"formatId":"x509","displayName":"Root CA","maxValidity":"PT87600H","subject":{"allowEmpty":false,"rules":[{"oid":"2.5.4.3","source":"REQUESTER","minimumOccurrences":1,"maximumOccurrences":1,"maximumUtf8Bytes":253}]},"caCertificate":{"basicConstraintsCritical":true,"pathLengthConstraint":1,"keyUsageCritical":true,"keyUsages":["CRL_SIGN","KEY_CERT_SIGN"],"allowedSubjectKeyAlgorithms":["ECDSA","Ed25519","RSA"]}} \ No newline at end of file diff --git a/pki/src/main/resources/zeroecho/pki/profiles/v1/server-tls.json b/pki/src/main/resources/zeroecho/pki/profiles/v1/server-tls.json index b147661..0bfb195 100644 --- a/pki/src/main/resources/zeroecho/pki/profiles/v1/server-tls.json +++ b/pki/src/main/resources/zeroecho/pki/profiles/v1/server-tls.json @@ -1 +1 @@ -{"schemaVersion":1,"profileId":"server-tls","profileVersion":1,"formatId":"x509","displayName":"Server TLS","maxValidity":"PT8760H","subject":{"allowEmpty":true,"rules":[{"oid":"2.5.4.3","source":"REQUESTER","minimumOccurrences":0,"maximumOccurrences":1,"maximumUtf8Bytes":253}]},"subjectAlternativeNames":{"minimumTotal":1,"maximumTotal":64,"serviceIdentityRequired":true,"emailIdentityRequired":false,"criticalWhenSubjectNonEmpty":false,"rules":[{"type":"DNS_NAME","minimumOccurrences":0,"maximumOccurrences":64,"wildcardAllowed":false},{"type":"IP_ADDRESS","minimumOccurrences":0,"maximumOccurrences":16,"ipv4Allowed":true,"ipv6Allowed":true}]},"leafCertificate":{"basicConstraintsCritical":true,"keyUsageCritical":true,"keyUsage":["DIGITAL_SIGNATURE"],"extendedKeyUsageCritical":false,"extendedKeyUsage":["1.3.6.1.5.5.7.3.1"],"allowedKeyAlgorithms":["ECDSA","Ed25519","RSA"]}} \ No newline at end of file +{"schemaVersion":2,"certificateType":"END_ENTITY","profileId":"server-tls","profileVersion":1,"formatId":"x509","displayName":"Server TLS","maxValidity":"PT8760H","subject":{"allowEmpty":true,"rules":[{"oid":"2.5.4.3","source":"REQUESTER","minimumOccurrences":0,"maximumOccurrences":1,"maximumUtf8Bytes":253}]},"subjectAlternativeNames":{"minimumTotal":1,"maximumTotal":64,"serviceIdentityRequired":true,"emailIdentityRequired":false,"criticalWhenSubjectNonEmpty":false,"rules":[{"type":"DNS_NAME","minimumOccurrences":0,"maximumOccurrences":64,"wildcardAllowed":false},{"type":"IP_ADDRESS","minimumOccurrences":0,"maximumOccurrences":16,"ipv4Allowed":true,"ipv6Allowed":true}]},"leafCertificate":{"basicConstraintsCritical":true,"keyUsageCritical":true,"keyUsage":["DIGITAL_SIGNATURE"],"extendedKeyUsageCritical":false,"extendedKeyUsage":["1.3.6.1.5.5.7.3.1"],"allowedKeyAlgorithms":["ECDSA","Ed25519","RSA"]}} \ No newline at end of file diff --git a/pki/src/main/resources/zeroecho/pki/profiles/v1/vpn-client.json b/pki/src/main/resources/zeroecho/pki/profiles/v1/vpn-client.json index e831916..aded624 100644 --- a/pki/src/main/resources/zeroecho/pki/profiles/v1/vpn-client.json +++ b/pki/src/main/resources/zeroecho/pki/profiles/v1/vpn-client.json @@ -1 +1 @@ -{"schemaVersion":1,"profileId":"vpn-client","profileVersion":1,"formatId":"x509","displayName":"VPN Client","maxValidity":"PT8760H","subject":{"allowEmpty":true,"rules":[{"oid":"2.5.4.3","source":"REQUESTER","minimumOccurrences":0,"maximumOccurrences":1,"maximumUtf8Bytes":253}]},"subjectAlternativeNames":{"minimumTotal":1,"maximumTotal":16,"serviceIdentityRequired":false,"emailIdentityRequired":false,"criticalWhenSubjectNonEmpty":false,"rules":[{"type":"RFC822_NAME","minimumOccurrences":0,"maximumOccurrences":16},{"type":"URI","minimumOccurrences":0,"maximumOccurrences":16,"allowedSchemes":["spiffe"]}]},"leafCertificate":{"basicConstraintsCritical":true,"keyUsageCritical":true,"keyUsage":["DIGITAL_SIGNATURE"],"extendedKeyUsageCritical":false,"extendedKeyUsage":["1.3.6.1.5.5.7.3.2"],"allowedKeyAlgorithms":["ECDSA","Ed25519","RSA"]}} \ No newline at end of file +{"schemaVersion":2,"certificateType":"END_ENTITY","profileId":"vpn-client","profileVersion":1,"formatId":"x509","displayName":"VPN Client","maxValidity":"PT8760H","subject":{"allowEmpty":true,"rules":[{"oid":"2.5.4.3","source":"REQUESTER","minimumOccurrences":0,"maximumOccurrences":1,"maximumUtf8Bytes":253}]},"subjectAlternativeNames":{"minimumTotal":1,"maximumTotal":16,"serviceIdentityRequired":false,"emailIdentityRequired":false,"criticalWhenSubjectNonEmpty":false,"rules":[{"type":"RFC822_NAME","minimumOccurrences":0,"maximumOccurrences":16},{"type":"URI","minimumOccurrences":0,"maximumOccurrences":16,"allowedSchemes":["spiffe"]}]},"leafCertificate":{"basicConstraintsCritical":true,"keyUsageCritical":true,"keyUsage":["DIGITAL_SIGNATURE"],"extendedKeyUsageCritical":false,"extendedKeyUsage":["1.3.6.1.5.5.7.3.2"],"allowedKeyAlgorithms":["ECDSA","Ed25519","RSA"]}} \ No newline at end of file diff --git a/pki/src/main/resources/zeroecho/pki/profiles/v1/vpn-server.json b/pki/src/main/resources/zeroecho/pki/profiles/v1/vpn-server.json index 350d43f..1c9a714 100644 --- a/pki/src/main/resources/zeroecho/pki/profiles/v1/vpn-server.json +++ b/pki/src/main/resources/zeroecho/pki/profiles/v1/vpn-server.json @@ -1 +1 @@ -{"schemaVersion":1,"profileId":"vpn-server","profileVersion":1,"formatId":"x509","displayName":"VPN Server","maxValidity":"PT8760H","subject":{"allowEmpty":true,"rules":[{"oid":"2.5.4.3","source":"REQUESTER","minimumOccurrences":0,"maximumOccurrences":1,"maximumUtf8Bytes":253}]},"subjectAlternativeNames":{"minimumTotal":1,"maximumTotal":64,"serviceIdentityRequired":true,"emailIdentityRequired":false,"criticalWhenSubjectNonEmpty":false,"rules":[{"type":"DNS_NAME","minimumOccurrences":0,"maximumOccurrences":64,"wildcardAllowed":false},{"type":"IP_ADDRESS","minimumOccurrences":0,"maximumOccurrences":16,"ipv4Allowed":true,"ipv6Allowed":true}]},"leafCertificate":{"basicConstraintsCritical":true,"keyUsageCritical":true,"keyUsage":["DIGITAL_SIGNATURE"],"extendedKeyUsageCritical":false,"extendedKeyUsage":["1.3.6.1.5.5.7.3.1"],"allowedKeyAlgorithms":["ECDSA","Ed25519","RSA"]}} \ No newline at end of file +{"schemaVersion":2,"certificateType":"END_ENTITY","profileId":"vpn-server","profileVersion":1,"formatId":"x509","displayName":"VPN Server","maxValidity":"PT8760H","subject":{"allowEmpty":true,"rules":[{"oid":"2.5.4.3","source":"REQUESTER","minimumOccurrences":0,"maximumOccurrences":1,"maximumUtf8Bytes":253}]},"subjectAlternativeNames":{"minimumTotal":1,"maximumTotal":64,"serviceIdentityRequired":true,"emailIdentityRequired":false,"criticalWhenSubjectNonEmpty":false,"rules":[{"type":"DNS_NAME","minimumOccurrences":0,"maximumOccurrences":64,"wildcardAllowed":false},{"type":"IP_ADDRESS","minimumOccurrences":0,"maximumOccurrences":16,"ipv4Allowed":true,"ipv6Allowed":true}]},"leafCertificate":{"basicConstraintsCritical":true,"keyUsageCritical":true,"keyUsage":["DIGITAL_SIGNATURE"],"extendedKeyUsageCritical":false,"extendedKeyUsage":["1.3.6.1.5.5.7.3.1"],"allowedKeyAlgorithms":["ECDSA","Ed25519","RSA"]}} \ No newline at end of file diff --git a/pki/src/test/java/zeroecho/pki/api/profile/BuiltInCertificateProfileCatalogTest.java b/pki/src/test/java/zeroecho/pki/api/profile/BuiltInCertificateProfileCatalogTest.java index c82c1b7..3b65f64 100644 --- a/pki/src/test/java/zeroecho/pki/api/profile/BuiltInCertificateProfileCatalogTest.java +++ b/pki/src/test/java/zeroecho/pki/api/profile/BuiltInCertificateProfileCatalogTest.java @@ -42,10 +42,13 @@ final class BuiltInCertificateProfileCatalogTest { private static final String VPN_SERVER = ROOT + "vpn-server.json"; private static final String VPN_CLIENT = ROOT + "vpn-client.json"; private static final String EMAIL = ROOT + "email-signing.json"; + private static final String ROOT_CA = ROOT + "root-ca.json"; + private static final String INTERMEDIATE_CA = ROOT + "intermediate-ca.json"; private static final List PROFILE_RESOURCES = - List.of(SERVER, VPN_SERVER, VPN_CLIENT, EMAIL); + List.of(SERVER, VPN_SERVER, VPN_CLIENT, EMAIL, ROOT_CA, INTERMEDIATE_CA); private static final List EXPECTED_ORDER = - List.of("server-tls", "vpn-server", "vpn-client", "email-signing"); + List.of("server-tls", "vpn-server", "vpn-client", "email-signing", + "root-ca", "intermediate-ca"); private static final Set EXPECTED_ALGORITHMS = Set.of("RSA", "ECDSA", "Ed25519"); private static final ExtendedKeyUsageId SERVER_AUTH = @@ -65,7 +68,7 @@ final class BuiltInCertificateProfileCatalogTest { List second = BuiltInCertificateProfileCatalog.load(getClass().getClassLoader()); - assertEquals(4, first.size()); + assertEquals(6, first.size()); assertEquals(EXPECTED_ORDER, first.stream().map(template -> template.definition().profileId()).toList()); assertEquals(first, second); @@ -76,7 +79,6 @@ final class BuiltInCertificateProfileCatalogTest { byte[] hash = template.canonicalSha256(); assertEquals(1, definition.profileVersion()); assertEquals("x509", definition.formatId().value()); - assertEquals(Duration.ofDays(365), definition.leafPolicy().maximumValidity()); assertArrayEquals(canonical, CertificateProfileDocumentCodec.writeCanonical(definition)); assertEquals(definition, CertificateProfileDocumentCodec.parse(canonical)); @@ -103,8 +105,8 @@ final class BuiltInCertificateProfileCatalogTest { LeafCertificatePolicy leaf = profiles.get(profileId).leafPolicy(); SubjectAlternativeNamePolicy san = leaf.subjectAlternativeNamePolicy(); - assertOptionalCommonName(leaf); - assertTrue(san.allowEmptySubject()); + assertOptionalCommonName(profiles.get(profileId)); + assertTrue(profiles.get(profileId).subjectPolicy().allowEmpty()); assertEquals(1, san.minimumTotal()); assertEquals(64, san.maximumTotal()); assertTrue(san.requireServiceIdentity()); @@ -123,11 +125,12 @@ final class BuiltInCertificateProfileCatalogTest { @Test void vpnClientTemplateHasOnlySpiffeUriAndRfc822Identity() { - LeafCertificatePolicy leaf = productionDefinitions().get("vpn-client").leafPolicy(); + CertificateProfileDefinition definition = productionDefinitions().get("vpn-client"); + LeafCertificatePolicy leaf = definition.leafPolicy(); SubjectAlternativeNamePolicy san = leaf.subjectAlternativeNamePolicy(); - assertOptionalCommonName(leaf); - assertTrue(san.allowEmptySubject()); + assertOptionalCommonName(definition); + assertTrue(definition.subjectPolicy().allowEmpty()); assertEquals(1, san.minimumTotal()); assertEquals(16, san.maximumTotal()); assertFalse(san.requireServiceIdentity()); @@ -142,13 +145,14 @@ final class BuiltInCertificateProfileCatalogTest { @Test void emailTemplateRequiresRfc822SanAndDoesNotEnableSubjectEmail() { - LeafCertificatePolicy leaf = productionDefinitions().get("email-signing").leafPolicy(); + CertificateProfileDefinition definition = productionDefinitions().get("email-signing"); + LeafCertificatePolicy leaf = definition.leafPolicy(); SubjectAlternativeNamePolicy san = leaf.subjectAlternativeNamePolicy(); - assertOptionalCommonName(leaf); + assertOptionalCommonName(definition); assertEquals(List.of(SubjectRdnType.COMMON_NAME), - leaf.subjectPolicy().rules().stream().map(SubjectRdnRule::type).toList()); - assertTrue(san.allowEmptySubject()); + definition.subjectPolicy().rules().stream().map(SubjectRdnRule::type).toList()); + assertTrue(definition.subjectPolicy().allowEmpty()); assertEquals(1, san.minimumTotal()); assertEquals(16, san.maximumTotal()); assertFalse(san.requireServiceIdentity()); @@ -159,6 +163,15 @@ final class BuiltInCertificateProfileCatalogTest { assertLeafPolicy(leaf, Set.of(EMAIL_PROTECTION)); } + @Test + void caTemplatesHaveExactKindsSubjectAndCertificatePolicies() { + Map profiles = productionDefinitions(); + assertCaProfile(profiles.get("root-ca"), CertificateProfileKind.ROOT_CA, + Duration.ofHours(87_600), 1); + assertCaProfile(profiles.get("intermediate-ca"), + CertificateProfileKind.INTERMEDIATE_CA, Duration.ofHours(43_800), 0); + } + @Test void manifestRejectsUnknownDuplicateMissingInvalidPathsLimitsAndTrailingTokens() { List invalidManifests = List.of( @@ -226,12 +239,12 @@ final class BuiltInCertificateProfileCatalogTest { @Test void profileDocumentsFailClosedForInvalidSchemaFieldsEncodingAndClassMetadata() { List invalidDocuments = List.of( - replace(mainResource(SERVER), "\"schemaVersion\":1", - "\"schemaVersion\":2"), - replace(mainResource(SERVER), "\"schemaVersion\":1,", - "\"schemaVersion\":1,\"active\":true,"), - replace(mainResource(SERVER), "\"schemaVersion\":1,", - "\"schemaVersion\":1,\"@class\":\"" + replace(mainResource(SERVER), "\"schemaVersion\":2", + "\"schemaVersion\":1"), + replace(mainResource(SERVER), "\"schemaVersion\":2,", + "\"schemaVersion\":2,\"active\":true,"), + replace(mainResource(SERVER), "\"schemaVersion\":2,", + "\"schemaVersion\":2,\"@class\":\"" + InitializationSentinel.CLASS_NAME + "\","), malformedUtf8(mainResource(SERVER))); for (byte[] invalid : invalidDocuments) { @@ -251,8 +264,9 @@ final class BuiltInCertificateProfileCatalogTest { CertificateProfileDefinition server = CertificateProfileDocumentCodec.parse(mainResource(SERVER)); CertificateProfileDefinition changed = new CertificateProfileDefinition( - server.profileId(), server.profileVersion(), server.formatId(), - "Changed display", server.leafPolicy()); + server.certificateType(), server.profileId(), server.profileVersion(), + server.formatId(), "Changed display", server.maximumValidity(), + server.subjectPolicy(), server.certificatePolicy()); Map> duplicateIdentity = baseResources(); duplicateIdentity.put(VPN_SERVER, List.of( CertificateProfileDocumentCodec.writeCanonical(changed))); @@ -265,9 +279,9 @@ final class BuiltInCertificateProfileCatalogTest { assertCode(wrongSet, "BUILT_IN_PROFILE_SET_INVALID"); } - private static void assertOptionalCommonName(LeafCertificatePolicy leaf) { - assertEquals(1, leaf.subjectPolicy().rules().size()); - SubjectRdnRule commonName = leaf.subjectPolicy().rules().get(0); + private static void assertOptionalCommonName(CertificateProfileDefinition definition) { + assertEquals(1, definition.subjectPolicy().rules().size()); + SubjectRdnRule commonName = definition.subjectPolicy().rules().get(0); assertEquals(SubjectRdnType.COMMON_NAME, commonName.type()); assertEquals(0, commonName.minimumOccurrences()); assertEquals(1, commonName.maximumOccurrences()); @@ -284,7 +298,25 @@ final class BuiltInCertificateProfileCatalogTest { assertFalse(leaf.extendedKeyUsageCritical()); assertTrue(leaf.basicConstraintsCritical()); assertEquals(EXPECTED_ALGORITHMS, leaf.allowedSubjectKeyAlgorithmIds()); - assertEquals(Duration.ofDays(365), leaf.maximumValidity()); + } + + private static void assertCaProfile(CertificateProfileDefinition definition, + CertificateProfileKind kind, Duration maximumValidity, int pathLength) { + assertEquals(kind, definition.certificateType()); + assertEquals(maximumValidity, definition.maximumValidity()); + assertFalse(definition.subjectPolicy().allowEmpty()); + assertEquals(1, definition.subjectPolicy().rules().size()); + SubjectRdnRule commonName = definition.subjectPolicy().rules().get(0); + assertEquals(SubjectRdnType.COMMON_NAME, commonName.type()); + assertEquals(1, commonName.minimumOccurrences()); + assertEquals(1, commonName.maximumOccurrences()); + assertEquals(253, commonName.maximumUtf8Bytes()); + CaCertificatePolicy ca = definition.caPolicy(); + assertTrue(ca.basicConstraintsCritical()); + assertEquals(pathLength, ca.pathLengthConstraint()); + assertTrue(ca.keyUsageCritical()); + assertEquals(Set.of(CaKeyUsage.KEY_CERT_SIGN, CaKeyUsage.CRL_SIGN), ca.keyUsages()); + assertEquals(EXPECTED_ALGORITHMS, ca.allowedSubjectKeyAlgorithmIds()); } private static SubjectAlternativeNameRule rule(SubjectAlternativeNamePolicy policy, diff --git a/pki/src/test/java/zeroecho/pki/api/profile/CertificateProfileDocumentCodecTest.java b/pki/src/test/java/zeroecho/pki/api/profile/CertificateProfileDocumentCodecTest.java index 567f521..e4b3b90 100644 --- a/pki/src/test/java/zeroecho/pki/api/profile/CertificateProfileDocumentCodecTest.java +++ b/pki/src/test/java/zeroecho/pki/api/profile/CertificateProfileDocumentCodecTest.java @@ -34,7 +34,8 @@ final class CertificateProfileDocumentCodecTest { private static final String VALID_DOCUMENT = """ { - "schemaVersion": 1, + "schemaVersion": 2, + "certificateType": "END_ENTITY", "profileId": "tls-service", "profileVersion": 7, "formatId": "x509", @@ -108,17 +109,18 @@ final class CertificateProfileDocumentCodecTest { void parsesEverySupportedRuleShapeIntoAuthoritativeTypedPolicies() { CertificateProfileDefinition definition = parse(VALID_DOCUMENT); - assertEquals(CertificateProfileDefinition.SCHEMA_VERSION, 1); + assertEquals(2, CertificateProfileDefinition.SCHEMA_VERSION); + assertEquals(CertificateProfileKind.END_ENTITY, definition.certificateType()); assertEquals("tls-service", definition.profileId()); assertEquals(7, definition.profileVersion()); assertEquals("x509", definition.formatId().value()); assertEquals("TLS service", definition.displayName()); LeafCertificatePolicy leaf = definition.leafPolicy(); - assertEquals(Duration.ofDays(1), leaf.maximumValidity()); + assertEquals(Duration.ofDays(1), definition.maximumValidity()); assertEquals(List.of(SubjectRdnType.COMMON_NAME, SubjectRdnType.ORGANIZATION_NAME), - leaf.subjectPolicy().rules().stream().map(SubjectRdnRule::type).toList()); - assertEquals("ZeroEcho", leaf.subjectPolicy().rules().get(1).fixedValue().orElseThrow()); - assertFalse(leaf.subjectAlternativeNamePolicy().allowEmptySubject()); + definition.subjectPolicy().rules().stream().map(SubjectRdnRule::type).toList()); + assertEquals("ZeroEcho", definition.subjectPolicy().rules().get(1).fixedValue().orElseThrow()); + assertFalse(definition.subjectPolicy().allowEmpty()); assertTrue(leaf.subjectAlternativeNamePolicy().allowDnsWildcard()); assertEquals(Set.of("https", "spiffe"), leaf.subjectAlternativeNamePolicy().allowedUriSchemes()); @@ -141,7 +143,7 @@ final class CertificateProfileDocumentCodecTest { definition("uri", requesterCn(), uriSan(), eku(), Set.of("Ed448")), definition("email", requesterCn(), emailSan(), eku(), Set.of("RSA")), definition("mixed", requesterCn(), mixedSan(), eku(), Set.of("RSA", "ECDSA")), - definition("empty-subject", new SubjectPolicy(List.of()), dnsSan(false, true), + definition("empty-subject", new SubjectPolicy(true, List.of()), dnsSan(false, true), eku(), Set.of("RSA")), definition("fixed-rdn", fixedOrganization(), noSan(), eku(), Set.of("RSA")), definition("multiple-algorithms", requesterCn(), noSan(), Set.of(), @@ -161,7 +163,7 @@ final class CertificateProfileDocumentCodecTest { CertificateProfileDefinition immutable = CertificateProfileDocumentCodec.parse( CertificateProfileDocumentCodec.writeCanonical(definitions.get(7))); assertThrows(UnsupportedOperationException.class, - () -> immutable.leafPolicy().subjectPolicy().rules().add( + () -> immutable.subjectPolicy().rules().add( new SubjectRdnRule(SubjectRdnType.PSEUDONYM, 0, 0, 32, Optional.empty(), true))); assertThrows(UnsupportedOperationException.class, @@ -171,6 +173,55 @@ final class CertificateProfileDocumentCodecTest { .allowedUriSchemes().add("ssh")); } + @Test + void roundTripsRootAndIntermediateCaPoliciesAndRejectsWrongKindShapes() { + CertificateProfileDefinition root = caDefinition("root-test", + CertificateProfileKind.ROOT_CA, 1); + CertificateProfileDefinition intermediate = caDefinition("intermediate-test", + CertificateProfileKind.INTERMEDIATE_CA, 0); + CertificateProfileDefinition delegatedIntermediate = caDefinition( + "delegated-intermediate-test", CertificateProfileKind.INTERMEDIATE_CA, 1); + for (CertificateProfileDefinition expected : + List.of(root, intermediate, delegatedIntermediate)) { + byte[] canonical = CertificateProfileDocumentCodec.writeCanonical(expected); + CertificateProfileDefinition actual = CertificateProfileDocumentCodec.parse(canonical); + assertEquals(expected, actual); + assertArrayEquals(canonical, CertificateProfileDocumentCodec.writeCanonical(actual)); + } + + String rootJson = canonical(root); + assertCode(rootJson.replace("\"certificateType\":\"ROOT_CA\"", + "\"certificateType\":\"END_ENTITY\""), + "FIELD_FORBIDDEN_FOR_CERTIFICATE_TYPE"); + assertCode(rootJson.replace("\"caCertificate\":", "\"leafCertificate\":"), + "FIELD_FORBIDDEN_FOR_CERTIFICATE_TYPE"); + assertCode(rootJson.replace("\"caCertificate\":", "\"subjectAlternativeNames\":"), + "FIELD_FORBIDDEN_FOR_CERTIFICATE_TYPE"); + assertCode(rootJson.replace(",\"caCertificate\":{", ",\"leafCertificate\":{}" + + ",\"caCertificate\":{"), "FIELD_FORBIDDEN_FOR_CERTIFICATE_TYPE"); + assertCode(rootJson.replace("\"allowEmpty\":false", "\"allowEmpty\":true"), + "SEMANTIC_INVALID"); + assertCode(rootJson.replace("\"pathLengthConstraint\":1", + "\"pathLengthConstraint\":-1"), "SEMANTIC_INVALID"); + assertCode(rootJson.replace("\"pathLengthConstraint\":1", + "\"pathLengthConstraint\":33"), "SEMANTIC_INVALID"); + assertCode(rootJson.replace("\"basicConstraintsCritical\":true", + "\"basicConstraintsCritical\":false"), "SEMANTIC_INVALID"); + assertCode(rootJson.replace("\"keyUsageCritical\":true", + "\"keyUsageCritical\":false"), "SEMANTIC_INVALID"); + assertCode(rootJson.replace("[\"CRL_SIGN\",\"KEY_CERT_SIGN\"]", + "[\"KEY_CERT_SIGN\"]"), "SEMANTIC_INVALID"); + assertCode(rootJson.replace("[\"CRL_SIGN\",\"KEY_CERT_SIGN\"]", + "[\"CRL_SIGN\"]"), "SEMANTIC_INVALID"); + assertCode(rootJson.replace("[\"CRL_SIGN\",\"KEY_CERT_SIGN\"]", + "[\"CRL_SIGN\",\"CRL_SIGN\",\"KEY_CERT_SIGN\"]"), "SEMANTIC_INVALID"); + assertCode(rootJson.replace("\"allowedSubjectKeyAlgorithms\":[\"RSA\"]", + "\"allowedSubjectKeyAlgorithms\":[]"), "SEMANTIC_INVALID"); + assertCode(rootJson.replace("\"allowedSubjectKeyAlgorithms\":", + "\"extendedKeyUsage\":[],\"allowedSubjectKeyAlgorithms\":"), + "UNKNOWN_FIELD"); + } + @Test void canonicalOutputHasFixedOrderSortedSetsAndIsIdempotent() { byte[] first = CertificateProfileDocumentCodec.writeCanonical(parse(VALID_DOCUMENT)); @@ -184,7 +235,8 @@ final class CertificateProfileDocumentCodecTest { assertFalse(json.contains("\n")); assertFalse(json.contains(": ")); assertFalse(json.contains(", ")); - assertTrue(json.indexOf("\"schemaVersion\"") < json.indexOf("\"profileId\"")); + assertTrue(json.indexOf("\"schemaVersion\"") < json.indexOf("\"certificateType\"")); + assertTrue(json.indexOf("\"certificateType\"") < json.indexOf("\"profileId\"")); assertTrue(json.indexOf("\"DNS_NAME\"") < json.indexOf("\"IP_ADDRESS\"")); assertTrue(json.indexOf("\"IP_ADDRESS\"") < json.indexOf("\"RFC822_NAME\"")); assertTrue(json.indexOf("\"RFC822_NAME\"") < json.indexOf("\"URI\"")); @@ -201,12 +253,15 @@ final class CertificateProfileDocumentCodecTest { CertificateProfileDefinition valid = definition("valid", requesterCn(), noSan(), Set.of(), Set.of("RSA")); List invalid = List.of( - new CertificateProfileDefinition(" padded", valid.profileVersion(), - valid.formatId(), valid.displayName(), valid.leafPolicy()), - new CertificateProfileDefinition(valid.profileId(), valid.profileVersion(), - new FormatId("x509 "), valid.displayName(), valid.leafPolicy()), - new CertificateProfileDefinition(valid.profileId(), valid.profileVersion(), - valid.formatId(), " padded ", valid.leafPolicy())); + new CertificateProfileDefinition(valid.certificateType(), " padded", valid.profileVersion(), + valid.formatId(), valid.displayName(), valid.maximumValidity(), + valid.subjectPolicy(), valid.certificatePolicy()), + new CertificateProfileDefinition(valid.certificateType(), valid.profileId(), + valid.profileVersion(), new FormatId("x509 "), valid.displayName(), + valid.maximumValidity(), valid.subjectPolicy(), valid.certificatePolicy()), + new CertificateProfileDefinition(valid.certificateType(), valid.profileId(), + valid.profileVersion(), valid.formatId(), " padded ", valid.maximumValidity(), + valid.subjectPolicy(), valid.certificatePolicy())); for (CertificateProfileDefinition definition : invalid) { assertWriteCode(definition, "CANONICALIZATION_FAILED"); @@ -245,11 +300,11 @@ final class CertificateProfileDocumentCodecTest { @Test void rejectsDuplicateUnknownMissingNullWrongAndNonintegralFields() { - assertCode(VALID_DOCUMENT.replace("\"schemaVersion\": 1,", - "\"schemaVersion\": 1,\"schemaVersion\": 1,"), "DUPLICATE_FIELD"); + assertCode(VALID_DOCUMENT.replace("\"schemaVersion\": 2,", + "\"schemaVersion\": 2,\"schemaVersion\": 2,"), "DUPLICATE_FIELD"); for (String document : List.of( - VALID_DOCUMENT.replace("\"schemaVersion\": 1,", - "\"secret-field\": true,\"schemaVersion\": 1,"), + VALID_DOCUMENT.replace("\"schemaVersion\": 2,", + "\"secret-field\": true,\"schemaVersion\": 2,"), VALID_DOCUMENT.replace("\"allowEmpty\": false,", "\"unknown\": true,\"allowEmpty\": false,"), VALID_DOCUMENT.replace("\"oid\": \"2.5.4.3\",", @@ -272,7 +327,7 @@ final class CertificateProfileDocumentCodecTest { assertCode(document, "DUPLICATE_FIELD"); } for (String document : List.of( - VALID_DOCUMENT.replace("\"schemaVersion\": 1,\n", ""), + VALID_DOCUMENT.replace("\"schemaVersion\": 2,\n", ""), VALID_DOCUMENT.replace("\"allowEmpty\": false,\n", ""), VALID_DOCUMENT.replace("\"oid\": \"2.5.4.3\",\n", ""), VALID_DOCUMENT.replace("\"minimumTotal\": 1,\n", ""), @@ -303,8 +358,10 @@ final class CertificateProfileDocumentCodecTest { @Test void rejectsUnsupportedVersionsTokensCaseWhitespaceAndNoncanonicalDuration() { - assertCode(VALID_DOCUMENT.replace("\"schemaVersion\": 1", - "\"schemaVersion\": 2"), "SCHEMA_VERSION_UNSUPPORTED"); + assertCode(VALID_DOCUMENT.replace("\"schemaVersion\": 2", + "\"schemaVersion\": 1"), "SCHEMA_VERSION_UNSUPPORTED"); + assertCode(VALID_DOCUMENT.replace("\"certificateType\": \"END_ENTITY\"", + "\"certificateType\": \"end_entity\""), "CERTIFICATE_TYPE_UNSUPPORTED"); assertCode(VALID_DOCUMENT.replace("\"profileVersion\": 7", "\"profileVersion\": 0"), "PROFILE_VERSION_INVALID"); assertCode(VALID_DOCUMENT.replace("\"type\": \"DNS_NAME\"", @@ -386,7 +443,7 @@ final class CertificateProfileDocumentCodecTest { eku(), Set.of("RSA"))); assertCode(email.replace("\"serviceIdentityRequired\":false", "\"serviceIdentityRequired\":true"), "SEMANTIC_INVALID"); - String empty = canonical(definition("empty-invalid", new SubjectPolicy(List.of()), + String empty = canonical(definition("empty-invalid", new SubjectPolicy(true, List.of()), dnsSan(false, true), eku(), Set.of("RSA"))); assertCode(empty.replace("\"minimumTotal\":1", "\"minimumTotal\":0"), "SEMANTIC_INVALID"); @@ -449,8 +506,8 @@ final class CertificateProfileDocumentCodecTest { @Test void redactsHostileInputAndParserDetailsFromFailures() { String secret = "do-not-disclose-credential"; - String hostile = VALID_DOCUMENT.replace("\"schemaVersion\": 1,", - "\"" + secret + "\": true,\"schemaVersion\": 1,"); + String hostile = VALID_DOCUMENT.replace("\"schemaVersion\": 2,", + "\"" + secret + "\": true,\"schemaVersion\": 2,"); PkiException exception = assertThrows(PkiException.class, () -> parse(hostile)); @@ -485,8 +542,8 @@ final class CertificateProfileDocumentCodecTest { logger.addHandler(handler); try { for (String field : hostileFields) { - String document = VALID_DOCUMENT.replace("\"schemaVersion\": 1,", - "\"" + field + "\":\"" + probeName + "\",\"schemaVersion\": 1,"); + String document = VALID_DOCUMENT.replace("\"schemaVersion\": 2,", + "\"" + field + "\":\"" + probeName + "\",\"schemaVersion\": 2,"); PkiException exception = assertThrows(PkiException.class, () -> parse(document)); assertTrue(exception.getMessage().contains("code=UNKNOWN_FIELD ")); assertFalse(exception.getMessage().contains(field)); @@ -512,57 +569,69 @@ final class CertificateProfileDocumentCodecTest { private static CertificateProfileDefinition definition(String id, SubjectPolicy subject, SubjectAlternativeNamePolicy san, Set extendedKeyUsages, Set algorithms) { - LeafCertificatePolicy leaf = new LeafCertificatePolicy(subject, san, + LeafCertificatePolicy leaf = new LeafCertificatePolicy(san, Set.of(LeafKeyUsage.DIGITAL_SIGNATURE), extendedKeyUsages, true, false, true, - algorithms, Duration.ofDays(1)); - return new CertificateProfileDefinition(id, 1, new FormatId("x509"), id, leaf); + algorithms); + return new CertificateProfileDefinition(CertificateProfileKind.END_ENTITY, id, 1, + new FormatId("x509"), id, Duration.ofDays(1), subject, leaf); + } + + private static CertificateProfileDefinition caDefinition(String id, + CertificateProfileKind kind, int pathLength) { + SubjectPolicy subject = new SubjectPolicy(false, + List.of(new SubjectRdnRule(SubjectRdnType.COMMON_NAME, 1, 1, 253, + Optional.empty(), true))); + CaCertificatePolicy ca = new CaCertificatePolicy(true, pathLength, true, + Set.of(CaKeyUsage.KEY_CERT_SIGN, CaKeyUsage.CRL_SIGN), Set.of("RSA")); + return new CertificateProfileDefinition(kind, id, 1, new FormatId("x509"), id, + Duration.ofDays(365), subject, ca); } private static SubjectPolicy requesterCn() { - return new SubjectPolicy(List.of(new SubjectRdnRule(SubjectRdnType.COMMON_NAME, + return new SubjectPolicy(false, List.of(new SubjectRdnRule(SubjectRdnType.COMMON_NAME, 1, 1, 128, Optional.empty(), true))); } private static SubjectPolicy fixedOrganization() { - return new SubjectPolicy(List.of(new SubjectRdnRule(SubjectRdnType.ORGANIZATION_NAME, + return new SubjectPolicy(false, List.of(new SubjectRdnRule(SubjectRdnType.ORGANIZATION_NAME, 1, 1, 64, Optional.of("ZeroEcho"), false))); } private static SubjectAlternativeNamePolicy noSan() { - return new SubjectAlternativeNamePolicy(false, 0, 0, List.of(), false, Set.of(), + return new SubjectAlternativeNamePolicy(0, 0, List.of(), false, Set.of(), false, false, false); } private static SubjectAlternativeNamePolicy dnsSan(boolean wildcard, boolean emptySubject) { - return new SubjectAlternativeNamePolicy(emptySubject, 1, 1, + return new SubjectAlternativeNamePolicy(1, 1, List.of(new SubjectAlternativeNameRule(SubjectAlternativeNameType.DNS_NAME, 1, 1, false, false)), wildcard, Set.of(), emptySubject, true, false); } private static SubjectAlternativeNamePolicy ipSan(boolean ipv4, boolean ipv6) { - return new SubjectAlternativeNamePolicy(false, 1, 1, + return new SubjectAlternativeNamePolicy(1, 1, List.of(new SubjectAlternativeNameRule(SubjectAlternativeNameType.IP_ADDRESS, 1, 1, ipv4, ipv6)), false, Set.of(), false, true, false); } private static SubjectAlternativeNamePolicy uriSan() { - return new SubjectAlternativeNamePolicy(false, 1, 1, + return new SubjectAlternativeNamePolicy(1, 1, List.of(new SubjectAlternativeNameRule(SubjectAlternativeNameType.URI, 1, 1, false, false)), false, Set.of("https"), false, true, false); } private static SubjectAlternativeNamePolicy emailSan() { - return new SubjectAlternativeNamePolicy(false, 1, 1, + return new SubjectAlternativeNamePolicy(1, 1, List.of(new SubjectAlternativeNameRule(SubjectAlternativeNameType.RFC822_NAME, 1, 1, false, false)), false, Set.of(), false, false, true); } private static SubjectAlternativeNamePolicy mixedSan() { - return new SubjectAlternativeNamePolicy(false, 0, 4, List.of( + return new SubjectAlternativeNamePolicy(0, 4, List.of( new SubjectAlternativeNameRule(SubjectAlternativeNameType.DNS_NAME, 0, 1, false, false), new SubjectAlternativeNameRule(SubjectAlternativeNameType.IP_ADDRESS, diff --git a/pki/src/test/java/zeroecho/pki/e2e/CaProfileIssuanceEnforcementTest.java b/pki/src/test/java/zeroecho/pki/e2e/CaProfileIssuanceEnforcementTest.java new file mode 100644 index 0000000..b542a6d --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/e2e/CaProfileIssuanceEnforcementTest.java @@ -0,0 +1,919 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.e2e; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.MessageDigest; +import java.time.Instant; +import java.util.Date; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; + +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.DEROctetString; +import org.bouncycastle.asn1.DERPrintableString; +import org.bouncycastle.asn1.x500.RDN; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x500.style.BCStyle; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import zeroecho.pki.api.EncodedObject; +import zeroecho.pki.api.Encoding; +import zeroecho.pki.api.KeyRef; +import zeroecho.pki.api.CaService; +import zeroecho.pki.api.PkiException; +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.ProfileService; +import zeroecho.pki.api.SubjectRef; +import zeroecho.pki.api.Validity; +import zeroecho.pki.api.ca.CaCreateCommand; +import zeroecho.pki.api.ca.CaImportCommand; +import zeroecho.pki.api.ca.CaRecord; +import zeroecho.pki.api.ca.IntermediateCertIssueCommand; +import zeroecho.pki.api.ca.IntermediateCreateCommand; +import zeroecho.pki.api.credential.CaProfileBinding; +import zeroecho.pki.api.credential.Credential; +import zeroecho.pki.api.credential.CredentialBundle; +import zeroecho.pki.api.profile.BuiltInCertificateProfileCatalog; +import zeroecho.pki.api.profile.ActiveCertificateProfile; +import zeroecho.pki.api.profile.BuiltInCertificateProfileTemplate; +import zeroecho.pki.api.profile.CertificateProfileRef; +import zeroecho.pki.api.profile.ImportedCertificateProfileVersion; +import zeroecho.pki.impl.core.ValidatedCaCertificateRequest; +import zeroecho.pki.impl.core.ValidatedCertificateRequest; +import zeroecho.pki.impl.core.attr.SimpleAttributeSet; +import zeroecho.pki.spi.framework.CredentialIssuerBackend; +import zeroecho.pki.testkit.PkiTestRuntime; + +/** + * Focused real-certificate acceptance tests for active CA profile enforcement. + */ +final class CaProfileIssuanceEnforcementTest { + + @Test + void fixedSubjectIntermediateIsValidatedOnceAndPersistsCanonicalSubject(@TempDir Path directory) + throws Exception { + KeyPair rootKey = rsa(); + KeyPair intermediateKey = rsa(); + KeyRef rootRef = new KeyRef("kref:v1:keyring:ca-profile:fixed-root"); + KeyRef intermediateRef = new KeyRef("kref:v1:keyring:ca-profile:fixed-intermediate"); + try (PkiTestRuntime runtime = PkiTestRuntime.create(directory, directory.resolve("bus.log"), + Map.of(rootRef, rootKey, intermediateRef, intermediateKey))) { + byte[] fixedProfile = builtIn("intermediate-ca") + .replace("\"profileId\":\"intermediate-ca\"", + "\"profileId\":\"fixed-intermediate-ca\"") + .replace("\"maximumUtf8Bytes\":253}]}", + "\"maximumUtf8Bytes\":253},{\"oid\":\"2.5.4.10\",\"source\":\"PROFILE_FIXED\"," + + "\"minimumOccurrences\":1,\"maximumOccurrences\":1," + + "\"maximumUtf8Bytes\":256,\"fixedValue\":\"Fixed Organization\"}]}") + .getBytes(StandardCharsets.UTF_8); + CertificateProfileRef reference = runtime.profileService().importProfile(fixedProfile); + runtime.profileService().activateProfile(reference.profileId(), reference.profileVersion()); + + PkiId rootId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), + new SubjectRef("CN=Fixed Subject Root"), "root-ca", Optional.of(rootRef), + new SimpleAttributeSet())); + PkiId intermediateId = runtime.caService().createIntermediate(new IntermediateCreateCommand( + runtime.framework().formatId(), rootId, new SubjectRef("CN=Fixed Subject Intermediate"), + "fixed-intermediate-ca", Optional.of(intermediateRef), new SimpleAttributeSet())); + Credential additional = runtime.caService().issueIntermediateCertificate( + new IntermediateCertIssueCommand(runtime.framework().formatId(), rootId, intermediateId, + "fixed-intermediate-ca", Optional.empty(), new SimpleAttributeSet())); + + CaRecord intermediate = runtime.caService().getCa(intermediateId); + Credential credential = intermediate.caCredentials().get(0); + X509CertificateHolder holder = new X509CertificateHolder(credential.encoded().bytes()); + X509CertificateHolder additionalHolder = new X509CertificateHolder(additional.encoded().bytes()); + assertEquals("Fixed Organization", + holder.getSubject().getRDNs(BCStyle.O)[0].getFirst().getValue().toString()); + assertEquals("Fixed Organization", + additionalHolder.getSubject().getRDNs(BCStyle.O)[0].getFirst().getValue().toString()); + assertEquals(holder.getSubject().toString(), intermediate.subjectRef().value()); + assertEquals(intermediate.subjectRef(), credential.subjectRef()); + assertEquals(intermediate.subjectRef(), additional.subjectRef()); + assertEquals(2, intermediate.caCredentials().size()); + } + } + + @Test + void rootIntermediateAdditionalAndRestartRetainExactReferences(@TempDir Path directory) throws Exception { + KeyPair rootKey = rsa(); + KeyPair intermediateKey = rsa(); + KeyRef rootRef = new KeyRef("kref:v1:keyring:ca-profile:root"); + KeyRef intermediateRef = new KeyRef("kref:v1:keyring:ca-profile:intermediate"); + Path store = directory.resolve("store"); + Path bus = directory.resolve("bus.log"); + PkiId rootId; + PkiId intermediateId; + CertificateProfileRef rootProfile; + CertificateProfileRef intermediateProfile; + try (PkiTestRuntime runtime = PkiTestRuntime.create(store, bus, + Map.of(rootRef, rootKey, intermediateRef, intermediateKey))) { + rootId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), + new SubjectRef("CN=Profile Root"), "root-ca", Optional.of(rootRef), + new SimpleAttributeSet())); + intermediateId = runtime.caService().createIntermediate(new IntermediateCreateCommand( + runtime.framework().formatId(), rootId, new SubjectRef("CN=Profile Intermediate"), + "intermediate-ca", Optional.of(intermediateRef), new SimpleAttributeSet())); + Credential additional = runtime.caService().issueIntermediateCertificate( + new IntermediateCertIssueCommand(runtime.framework().formatId(), rootId, intermediateId, + "intermediate-ca", Optional.empty(), new SimpleAttributeSet())); + rootProfile = ((CaProfileBinding) runtime.caService().getCa(rootId).caCredentials().get(0) + .profileBinding()).reference(); + intermediateProfile = ((CaProfileBinding) additional.profileBinding()).reference(); + assertCaCertificate(runtime.caService().getCa(rootId).caCredentials().get(0), 1); + assertCaCertificate(additional, 0); + } + try (PkiTestRuntime reopened = PkiTestRuntime.create(store, directory.resolve("reopened-bus.log"), + Map.of(rootRef, rootKey, intermediateRef, intermediateKey))) { + assertEquals(rootProfile, ((CaProfileBinding) reopened.caService().getCa(rootId) + .caCredentials().get(0).profileBinding()).reference()); + assertEquals(intermediateProfile, ((CaProfileBinding) reopened.caService().getCa(intermediateId) + .caCredentials().get(1).profileBinding()).reference()); + } + } + + @Test + void missingAndWrongKindActiveProfilesFailBeforeProof(@TempDir Path directory) throws Exception { + KeyPair rootKey = rsa(); + KeyRef rootRef = new KeyRef("kref:v1:keyring:ca-profile:missing"); + try (PkiTestRuntime runtime = PkiTestRuntime.create(directory, directory.resolve("bus.log"), + Map.of(rootRef, rootKey))) { + assertThrows(PkiException.class, () -> runtime.caService().createRoot(new CaCreateCommand( + runtime.framework().formatId(), new SubjectRef("CN=Missing"), "missing-ca-profile", + Optional.of(rootRef), new SimpleAttributeSet()))); + assertThrows(PkiException.class, () -> runtime.caService().createRoot(new CaCreateCommand( + runtime.framework().formatId(), new SubjectRef("CN=Wrong Kind"), "default", + Optional.of(rootRef), new SimpleAttributeSet()))); + assertEquals(0, runtime.submittedSignCount()); + assertTrue(runtime.store().listCas().isEmpty()); + } + } + + @Test + void historicalIssuerVersionRemainsValidAfterActiveSwitch(@TempDir Path directory) throws Exception { + KeyPair rootKey = rsa(); + KeyPair intermediateKey = rsa(); + KeyRef rootRef = new KeyRef("kref:v1:keyring:ca-profile:history-root"); + KeyRef intermediateRef = new KeyRef("kref:v1:keyring:ca-profile:history-intermediate"); + try (PkiTestRuntime runtime = PkiTestRuntime.create(directory, directory.resolve("bus.log"), + Map.of(rootRef, rootKey, intermediateRef, intermediateKey))) { + PkiId rootId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), + new SubjectRef("CN=Historical Root"), "root-ca", Optional.of(rootRef), + new SimpleAttributeSet())); + byte[] versionTwo = builtIn("root-ca").replace("\"profileVersion\":1", + "\"profileVersion\":2").getBytes(StandardCharsets.UTF_8); + CertificateProfileRef imported = runtime.profileService().importProfile(versionTwo); + runtime.profileService().activateProfile(imported.profileId(), imported.profileVersion()); + + PkiId intermediateId = runtime.caService().createIntermediate(new IntermediateCreateCommand( + runtime.framework().formatId(), rootId, new SubjectRef("CN=Historical Intermediate"), + "intermediate-ca", Optional.of(intermediateRef), new SimpleAttributeSet())); + CaProfileBinding issuerBinding = (CaProfileBinding) runtime.caService().getCa(rootId) + .caCredentials().get(0).profileBinding(); + assertEquals(1, issuerBinding.reference().profileVersion()); + assertEquals(1, runtime.caService().getCa(intermediateId).caCredentials().size()); + } + } + + @Test + void rootCaProfileVersionSwitchA_B_C(@TempDir Path directory) throws Exception { + System.out.println("rootCaProfileVersionSwitchA_B_C"); + String profileId = "ca-version-switch-root"; + byte[] versionOneDocument = caProfileDocument("root-ca", profileId, 1, 1, 1); + byte[] versionTwoDocument = caProfileDocument("root-ca", profileId, 2, 1, 2); + KeyPair keyA = rsa(); + KeyPair keyB = rsa(); + KeyPair keyC = rsa(); + KeyRef keyRefA = new KeyRef("kref:v1:keyring:ca-version-switch:root-a"); + KeyRef keyRefB = new KeyRef("kref:v1:keyring:ca-version-switch:root-b"); + KeyRef keyRefC = new KeyRef("kref:v1:keyring:ca-version-switch:root-c"); + Map keys = Map.of(keyRefA, keyA, keyRefB, keyB, keyRefC, keyC); + Path store = directory.resolve("store"); + CertificateProfileRef versionOne; + CertificateProfileRef versionTwo; + byte[] persistedVersionOne; + byte[] persistedVersionOneHash; + PkiId caA; + PkiId caB; + PkiId caC; + PkiId credentialA; + PkiId credentialB; + PkiId credentialC; + + try (PkiTestRuntime runtime = PkiTestRuntime.create(store, directory.resolve("bus.log"), keys)) { + versionOne = runtime.profileService().importProfile(versionOneDocument); + assertEquals(profileId, versionOne.profileId()); + assertEquals(1, versionOne.profileVersion()); + runtime.profileService().activateProfile(profileId, 1); + + caA = createRoot(runtime, keyRefA, "CN=Version Switch Root A", profileId); + Credential issuedA = onlyCredential(runtime.caService(), caA); + assertCaProfileCredential("A", issuedA, versionOne, 1); + credentialA = issuedA.credentialId(); + + ImportedCertificateProfileVersion storedOne = runtime.profileService() + .getImportedVersion(profileId, 1).orElseThrow(); + persistedVersionOne = storedOne.canonicalJson(); + persistedVersionOneHash = storedOne.reference().canonicalSha256(); + + versionTwo = runtime.profileService().importProfile(versionTwoDocument); + assertEquals(profileId, versionTwo.profileId()); + assertEquals(2, versionTwo.profileVersion()); + assertTrue(!MessageDigest.isEqual(versionOne.canonicalSha256(), versionTwo.canonicalSha256())); + assertEquals(versionOne, runtime.profileService().getActiveReference(profileId).orElseThrow()); + + caB = createRoot(runtime, keyRefB, "CN=Version Switch Root B", profileId); + Credential issuedB = onlyCredential(runtime.caService(), caB); + assertCaProfileCredential("B", issuedB, versionOne, 1); + credentialB = issuedB.credentialId(); + + assertEquals(versionTwo, runtime.profileService().activateProfile(profileId, 2)); + caC = createRoot(runtime, keyRefC, "CN=Version Switch Root C", profileId); + Credential issuedC = onlyCredential(runtime.caService(), caC); + assertCaProfileCredential("C", issuedC, versionTwo, 2); + credentialC = issuedC.credentialId(); + + assertCaProfileCredential("A-reread", runtime.store().getCredential(credentialA).orElseThrow(), + versionOne, 1); + assertCaProfileCredential("B-reread", runtime.store().getCredential(credentialB).orElseThrow(), + versionOne, 1); + ImportedCertificateProfileVersion unchanged = runtime.profileService() + .getImportedVersion(profileId, 1).orElseThrow(); + assertArrayEquals(persistedVersionOne, unchanged.canonicalJson()); + assertArrayEquals(persistedVersionOneHash, unchanged.reference().canonicalSha256()); + } + + try (PkiTestRuntime reopened = PkiTestRuntime.create(store, directory.resolve("reopened-bus.log"), keys)) { + assertEquals(versionTwo, reopened.profileService().getActiveReference(profileId).orElseThrow()); + assertCaProfileCredential("A-restart", reopened.store().getCredential(credentialA).orElseThrow(), + versionOne, 1); + assertCaProfileCredential("B-restart", reopened.store().getCredential(credentialB).orElseThrow(), + versionOne, 1); + assertCaProfileCredential("C-restart", reopened.store().getCredential(credentialC).orElseThrow(), + versionTwo, 2); + ImportedCertificateProfileVersion unchanged = reopened.profileService() + .getImportedVersion(profileId, 1).orElseThrow(); + assertArrayEquals(persistedVersionOne, unchanged.canonicalJson()); + assertArrayEquals(persistedVersionOneHash, unchanged.reference().canonicalSha256()); + } + System.out.println("...ok"); + } + + @Test + void intermediateCaProfileVersionSwitchA_B_C(@TempDir Path directory) throws Exception { + System.out.println("intermediateCaProfileVersionSwitchA_B_C"); + String rootProfileId = "ca-version-switch-issuer-root"; + String intermediateProfileId = "ca-version-switch-intermediate"; + byte[] rootProfileDocument = caProfileDocument("root-ca", rootProfileId, 1, 1, 2); + byte[] versionOneDocument = caProfileDocument("intermediate-ca", intermediateProfileId, 1, 0, 0); + byte[] versionTwoDocument = caProfileDocument("intermediate-ca", intermediateProfileId, 2, 0, 1); + KeyPair rootKey = rsa(); + KeyPair keyA = rsa(); + KeyPair keyB = rsa(); + KeyPair keyC = rsa(); + KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:ca-version-switch:issuer-root"); + KeyRef keyRefA = new KeyRef("kref:v1:keyring:ca-version-switch:intermediate-a"); + KeyRef keyRefB = new KeyRef("kref:v1:keyring:ca-version-switch:intermediate-b"); + KeyRef keyRefC = new KeyRef("kref:v1:keyring:ca-version-switch:intermediate-c"); + Map keys = Map.of(rootKeyRef, rootKey, keyRefA, keyA, keyRefB, keyB, keyRefC, keyC); + Path store = directory.resolve("store"); + CertificateProfileRef rootProfile; + CertificateProfileRef versionOne; + CertificateProfileRef versionTwo; + byte[] persistedVersionOne; + byte[] persistedVersionOneHash; + PkiId rootId; + PkiId caA; + PkiId caB; + PkiId caC; + PkiId credentialA; + PkiId credentialB; + PkiId credentialC; + + try (PkiTestRuntime runtime = PkiTestRuntime.create(store, directory.resolve("bus.log"), keys)) { + rootProfile = runtime.profileService().importProfile(rootProfileDocument); + runtime.profileService().activateProfile(rootProfileId, 1); + rootId = createRoot(runtime, rootKeyRef, "CN=Version Switch Issuer Root", rootProfileId); + Credential rootCredential = onlyCredential(runtime.caService(), rootId); + assertCaProfileCredential("issuer", rootCredential, rootProfile, 2); + + versionOne = runtime.profileService().importProfile(versionOneDocument); + assertEquals(intermediateProfileId, versionOne.profileId()); + assertEquals(1, versionOne.profileVersion()); + runtime.profileService().activateProfile(intermediateProfileId, 1); + + caA = createIntermediate(runtime, rootId, keyRefA, "CN=Version Switch Intermediate A", + intermediateProfileId); + Credential issuedA = onlyCredential(runtime.caService(), caA); + assertCaProfileCredential("A", issuedA, versionOne, 0); + credentialA = issuedA.credentialId(); + assertCaProfileCredential("issuer-after-A", onlyCredential(runtime.caService(), rootId), + rootProfile, 2); + + ImportedCertificateProfileVersion storedOne = runtime.profileService() + .getImportedVersion(intermediateProfileId, 1).orElseThrow(); + persistedVersionOne = storedOne.canonicalJson(); + persistedVersionOneHash = storedOne.reference().canonicalSha256(); + + versionTwo = runtime.profileService().importProfile(versionTwoDocument); + assertEquals(intermediateProfileId, versionTwo.profileId()); + assertEquals(2, versionTwo.profileVersion()); + assertTrue(!MessageDigest.isEqual(versionOne.canonicalSha256(), versionTwo.canonicalSha256())); + assertEquals(versionOne, + runtime.profileService().getActiveReference(intermediateProfileId).orElseThrow()); + + caB = createIntermediate(runtime, rootId, keyRefB, "CN=Version Switch Intermediate B", + intermediateProfileId); + Credential issuedB = onlyCredential(runtime.caService(), caB); + assertCaProfileCredential("B", issuedB, versionOne, 0); + credentialB = issuedB.credentialId(); + + assertEquals(versionTwo, runtime.profileService().activateProfile(intermediateProfileId, 2)); + caC = createIntermediate(runtime, rootId, keyRefC, "CN=Version Switch Intermediate C", + intermediateProfileId); + Credential issuedC = onlyCredential(runtime.caService(), caC); + assertCaProfileCredential("C", issuedC, versionTwo, 1); + credentialC = issuedC.credentialId(); + + assertCaProfileCredential("A-reread", runtime.store().getCredential(credentialA).orElseThrow(), + versionOne, 0); + assertCaProfileCredential("B-reread", runtime.store().getCredential(credentialB).orElseThrow(), + versionOne, 0); + assertCaProfileCredential("issuer-reread", onlyCredential(runtime.caService(), rootId), + rootProfile, 2); + ImportedCertificateProfileVersion unchanged = runtime.profileService() + .getImportedVersion(intermediateProfileId, 1).orElseThrow(); + assertArrayEquals(persistedVersionOne, unchanged.canonicalJson()); + assertArrayEquals(persistedVersionOneHash, unchanged.reference().canonicalSha256()); + } + + try (PkiTestRuntime reopened = PkiTestRuntime.create(store, directory.resolve("reopened-bus.log"), keys)) { + assertEquals(versionTwo, + reopened.profileService().getActiveReference(intermediateProfileId).orElseThrow()); + assertCaProfileCredential("A-restart", reopened.store().getCredential(credentialA).orElseThrow(), + versionOne, 0); + assertCaProfileCredential("B-restart", reopened.store().getCredential(credentialB).orElseThrow(), + versionOne, 0); + assertCaProfileCredential("C-restart", reopened.store().getCredential(credentialC).orElseThrow(), + versionTwo, 1); + assertCaProfileCredential("issuer-restart", onlyCredential(reopened.caService(), rootId), + rootProfile, 2); + ImportedCertificateProfileVersion unchanged = reopened.profileService() + .getImportedVersion(intermediateProfileId, 1).orElseThrow(); + assertArrayEquals(persistedVersionOne, unchanged.canonicalJson()); + assertArrayEquals(persistedVersionOneHash, unchanged.reference().canonicalSha256()); + } + System.out.println("...ok"); + } + + @Test + void historicalIssuerFormatMismatchFailsBeforeProof(@TempDir Path directory) throws Exception { + KeyPair rootKey = rsa(); + KeyPair intermediateKey = rsa(); + KeyRef rootRef = new KeyRef("kref:v1:keyring:ca-profile:format-root"); + KeyRef intermediateRef = new KeyRef("kref:v1:keyring:ca-profile:format-intermediate"); + try (PkiTestRuntime runtime = PkiTestRuntime.create(directory, directory.resolve("bus.log"), + Map.of(rootRef, rootKey, intermediateRef, intermediateKey))) { + PkiId rootId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), + new SubjectRef("CN=Format Root"), "root-ca", Optional.of(rootRef), + new SimpleAttributeSet())); + CertificateProfileRef wrongFormat = runtime.profileService().importProfile(builtIn("root-ca") + .replace("\"profileId\":\"root-ca\"", "\"profileId\":\"other-format-root\"") + .replace("\"formatId\":\"x509\"", "\"formatId\":\"other\"") + .getBytes(StandardCharsets.UTF_8)); + CaRecord root = runtime.caService().getCa(rootId); + Credential original = root.caCredentials().get(0); + Credential mutated = new Credential(original.credentialId(), original.formatId(), original.issuerRef(), + original.subjectRef(), original.validity(), original.serialOrUniqueId(), original.publicKeyId(), + new CaProfileBinding(wrongFormat), original.status(), original.encoded(), original.attributes()); + runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(), + root.subjectRef(), List.of(mutated))); + + int signCount = runtime.submittedSignCount(); + assertThrows(PkiException.class, () -> runtime.caService().createIntermediate( + new IntermediateCreateCommand(runtime.framework().formatId(), rootId, + new SubjectRef("CN=Format Intermediate"), "intermediate-ca", + Optional.of(intermediateRef), new SimpleAttributeSet()))); + assertEquals(signCount, runtime.submittedSignCount()); + assertEquals(1, runtime.store().listCas().size()); + } + } + + @Test + void invalidCaSubjectAndKeyAlgorithmFailBeforeProof(@TempDir Path directory) throws Exception { + KeyPair rootKey = rsa(); + KeyPair dsaKey = keyPair("DSA"); + KeyRef rootRef = new KeyRef("kref:v1:keyring:ca-profile:invalid-root"); + KeyRef dsaRef = new KeyRef("kref:v1:keyring:ca-profile:invalid-dsa"); + try (PkiTestRuntime runtime = PkiTestRuntime.create(directory, directory.resolve("bus.log"), + Map.of(rootRef, rootKey, dsaRef, dsaKey))) { + assertThrows(PkiException.class, () -> runtime.caService().createRoot(new CaCreateCommand( + runtime.framework().formatId(), new SubjectRef("O=Forbidden"), "root-ca", + Optional.of(rootRef), new SimpleAttributeSet()))); + assertThrows(PkiException.class, () -> runtime.caService().createRoot(new CaCreateCommand( + runtime.framework().formatId(), new SubjectRef("CN=Unsupported Algorithm"), "root-ca", + Optional.of(dsaRef), new SimpleAttributeSet()))); + assertEquals(0, runtime.submittedSignCount()); + assertTrue(runtime.store().listCas().isEmpty()); + } + } + + @Test + void eachCaOperationResolvesItsActiveProfileExactlyOnce(@TempDir Path directory) throws Exception { + KeyPair rootKey = rsa(); + KeyPair intermediateKey = rsa(); + KeyRef rootRef = new KeyRef("kref:v1:keyring:ca-profile:lookup-root"); + KeyRef intermediateRef = new KeyRef("kref:v1:keyring:ca-profile:lookup-intermediate"); + byte[] rootCertificate; + try (PkiTestRuntime runtime = PkiTestRuntime.create(directory.resolve("main"), + directory.resolve("main-bus.log"), Map.of(rootRef, rootKey, intermediateRef, intermediateKey))) { + runtime.caService(); + CountingProfileService profiles = new CountingProfileService(runtime.profileService()); + CaService service = runtime.caService(profiles); + + PkiId rootId = service.createRoot(new CaCreateCommand(runtime.framework().formatId(), + new SubjectRef("CN=Lookup Root"), "root-ca", Optional.of(rootRef), + new SimpleAttributeSet())); + profiles.assertAndReset("root-ca"); + PkiId intermediateId = service.createIntermediate(new IntermediateCreateCommand( + runtime.framework().formatId(), rootId, new SubjectRef("CN=Lookup Intermediate"), + "intermediate-ca", Optional.of(intermediateRef), new SimpleAttributeSet())); + profiles.assertAndReset("intermediate-ca"); + service.issueIntermediateCertificate(new IntermediateCertIssueCommand(runtime.framework().formatId(), + rootId, intermediateId, "intermediate-ca", Optional.empty(), new SimpleAttributeSet())); + profiles.assertAndReset("intermediate-ca"); + rootCertificate = service.getCa(rootId).caCredentials().get(0).encoded().bytes(); + } + try (PkiTestRuntime target = PkiTestRuntime.create(directory.resolve("import"), + directory.resolve("import-bus.log"), Map.of(rootRef, rootKey))) { + target.caService(); + CountingProfileService profiles = new CountingProfileService(target.profileService()); + CaService service = target.caService(profiles); + service.importRoot(new CaImportCommand(target.framework().formatId(), new SubjectRef("CN=Lookup Root"), + "root-ca", rootRef, new EncodedObject(Encoding.DER, rootCertificate), + new SimpleAttributeSet())); + profiles.assertAndReset("root-ca"); + } + } + + + @Test + void rootCannotBeUsedAsAdditionalIntermediateSubject(@TempDir Path directory) throws Exception { + KeyPair rootKey = rsa(); + KeyRef rootRef = new KeyRef("kref:v1:keyring:ca-profile:root-subject"); + try (PkiTestRuntime runtime = PkiTestRuntime.create(directory, directory.resolve("bus.log"), + Map.of(rootRef, rootKey))) { + PkiId rootId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), + new SubjectRef("CN=Root Subject"), "root-ca", Optional.of(rootRef), + new SimpleAttributeSet())); + int signCount = runtime.submittedSignCount(); + assertThrows(PkiException.class, () -> runtime.caService().issueIntermediateCertificate( + new IntermediateCertIssueCommand(runtime.framework().formatId(), rootId, rootId, + "intermediate-ca", Optional.empty(), new SimpleAttributeSet()))); + assertEquals(signCount, runtime.submittedSignCount()); + assertEquals(1, runtime.caService().getCa(rootId).caCredentials().size()); + } + } + + @Test + void invalidRequestedIntermediateValidityFailsBeforeProof(@TempDir Path directory) throws Exception { + KeyPair rootKey = rsa(); + KeyPair intermediateKey = rsa(); + KeyRef rootRef = new KeyRef("kref:v1:keyring:ca-profile:validity-root"); + KeyRef intermediateRef = new KeyRef("kref:v1:keyring:ca-profile:validity-intermediate"); + try (PkiTestRuntime runtime = PkiTestRuntime.create(directory, directory.resolve("bus.log"), + Map.of(rootRef, rootKey, intermediateRef, intermediateKey))) { + PkiId rootId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), + new SubjectRef("CN=Validity Root"), "root-ca", Optional.of(rootRef), + new SimpleAttributeSet())); + PkiId intermediateId = runtime.caService().createIntermediate(new IntermediateCreateCommand( + runtime.framework().formatId(), rootId, new SubjectRef("CN=Validity Intermediate"), + "intermediate-ca", Optional.of(intermediateRef), new SimpleAttributeSet())); + int signCount = runtime.submittedSignCount(); + Validity invalid = new Validity(Instant.EPOCH, Instant.EPOCH.plusSeconds(3_153_600_000L)); + + assertThrows(PkiException.class, () -> runtime.caService().issueIntermediateCertificate( + new IntermediateCertIssueCommand(runtime.framework().formatId(), rootId, intermediateId, + "intermediate-ca", Optional.of(invalid), new SimpleAttributeSet()))); + assertEquals(signCount, runtime.submittedSignCount()); + assertEquals(1, runtime.caService().getCa(intermediateId).caCredentials().size()); + } + } + + @Test + void generatedRootCanBeImportedAgainstTheSameActivatedProfile(@TempDir Path directory) throws Exception { + KeyPair rootKey = rsa(); + KeyRef rootRef = new KeyRef("kref:v1:keyring:ca-profile:import"); + byte[] encoded; + try (PkiTestRuntime source = PkiTestRuntime.create(directory.resolve("source"), + directory.resolve("source-bus.log"), Map.of(rootRef, rootKey))) { + PkiId rootId = source.caService().createRoot(new CaCreateCommand(source.framework().formatId(), + new SubjectRef("CN=Imported Root"), "root-ca", Optional.of(rootRef), + new SimpleAttributeSet())); + encoded = source.caService().getCa(rootId).caCredentials().get(0).encoded().bytes(); + } + try (PkiTestRuntime target = PkiTestRuntime.create(directory.resolve("target"), + directory.resolve("target-bus.log"), Map.of(rootRef, rootKey))) { + PkiId imported = target.caService().importRoot(new CaImportCommand(target.framework().formatId(), + new SubjectRef("CN=Imported Root"), "root-ca", rootRef, + new EncodedObject(Encoding.DER, encoded), new SimpleAttributeSet())); + Credential credential = target.caService().getCa(imported).caCredentials().get(0); + assertEquals(target.profileService().getActiveReference("root-ca").orElseThrow(), + ((CaProfileBinding) credential.profileBinding()).reference()); + } + } + + @ParameterizedTest + @EnumSource(ImportMutation.class) + void importedRootMutationFailsBeforePersistence(ImportMutation mutation, @TempDir Path directory) + throws Exception { + KeyPair rootKey = rsa(); + KeyRef rootRef = new KeyRef("kref:v1:keyring:ca-profile:import-mutation"); + byte[] encoded; + try (PkiTestRuntime source = PkiTestRuntime.create(directory.resolve("source"), + directory.resolve("source-bus.log"), Map.of(rootRef, rootKey))) { + PkiId rootId = source.caService().createRoot(new CaCreateCommand(source.framework().formatId(), + new SubjectRef("CN=Import Mutation Root"), "root-ca", Optional.of(rootRef), + new SimpleAttributeSet())); + Credential sourceCredential = source.caService().getCa(rootId).caCredentials().get(0); + encoded = mutation.mutate(sourceCredential, rootKey); + } + try (PkiTestRuntime target = PkiTestRuntime.create(directory.resolve("target"), + directory.resolve("target-bus.log"), Map.of(rootRef, rootKey))) { + CaImportCommand command = mutation.command(target, rootRef, encoded); + assertThrows(PkiException.class, () -> target.caService().importRoot(command)); + assertTrue(target.store().listCas().isEmpty()); + assertEquals(0, target.submittedSignCount()); + } + } + + @ParameterizedTest + @EnumSource(MetadataMutation.class) + void intermediateBackendMetadataMutationFailsBeforePersistence(MetadataMutation mutation, + @TempDir Path directory) throws Exception { + KeyPair rootKey = rsa(); + KeyPair intermediateKey = rsa(); + KeyRef rootRef = new KeyRef("kref:v1:keyring:ca-profile:mutation-root"); + KeyRef intermediateRef = new KeyRef("kref:v1:keyring:ca-profile:mutation-intermediate"); + try (PkiTestRuntime runtime = PkiTestRuntime.create(directory, directory.resolve("bus.log"), + Map.of(rootRef, rootKey, intermediateRef, intermediateKey))) { + PkiId rootId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), + new SubjectRef("CN=Mutation Root"), "root-ca", Optional.of(rootRef), + new SimpleAttributeSet())); + CredentialIssuerBackend delegate = runtime.issuerBackend(); + CredentialIssuerBackend backend = new CredentialIssuerBackend() { + @Override + public CredentialBundle issueEndEntity(ValidatedCertificateRequest request, + EncodedObject issuerCertificate, KeyRef issuerKeyRef, java.math.BigInteger serial) { + return delegate.issueEndEntity(request, issuerCertificate, issuerKeyRef, serial); + } + + @Override + public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest request, + EncodedObject issuerCertificate, KeyRef issuerKeyRef) { + return mutation.apply(delegate.issueIntermediateCertificate(request, issuerCertificate, + issuerKeyRef), rootKey); + } + }; + assertThrows(PkiException.class, () -> runtime.caService(backend).createIntermediate( + new IntermediateCreateCommand(runtime.framework().formatId(), rootId, + new SubjectRef("CN=Mutation Intermediate"), "intermediate-ca", + Optional.of(intermediateRef), new SimpleAttributeSet()))); + assertEquals(1, runtime.store().listCas().size()); + } + } + + private static void assertCaCertificate(Credential credential, int pathLength) throws Exception { + X509CertificateHolder holder = new X509CertificateHolder(credential.encoded().bytes()); + BasicConstraints constraints = BasicConstraints.getInstance( + holder.getExtension(Extension.basicConstraints).getParsedValue()); + KeyUsage usage = KeyUsage.getInstance(holder.getExtension(Extension.keyUsage).getParsedValue()); + assertTrue(holder.getExtension(Extension.basicConstraints).isCritical()); + assertTrue(holder.getExtension(Extension.keyUsage).isCritical()); + assertEquals(pathLength, constraints.getPathLenConstraint().intValueExact()); + assertTrue(usage.hasUsages(KeyUsage.keyCertSign | KeyUsage.cRLSign)); + assertEquals(2, holder.getExtensions().getExtensionOIDs().length); + } + + private static void assertCaProfileCredential(String label, Credential credential, + CertificateProfileRef expectedProfile, int pathLength) throws Exception { + assertTrue(credential.profileBinding() instanceof CaProfileBinding); + assertEquals(expectedProfile, ((CaProfileBinding) credential.profileBinding()).reference()); + assertCaCertificate(credential, pathLength); + System.out.println("..." + label + " profileVersion=" + expectedProfile.profileVersion() + + " hash=" + abbreviatedHash(expectedProfile) + + " credentialId=" + credential.credentialId().value() + + " pathLength=" + pathLength); + } + + private static PkiId createRoot(PkiTestRuntime runtime, KeyRef keyRef, String subject, String profileId) { + return runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), + new SubjectRef(subject), profileId, Optional.of(keyRef), new SimpleAttributeSet())); + } + + private static PkiId createIntermediate(PkiTestRuntime runtime, PkiId issuerId, KeyRef keyRef, + String subject, String profileId) { + return runtime.caService().createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(), + issuerId, new SubjectRef(subject), profileId, Optional.of(keyRef), new SimpleAttributeSet())); + } + + private static Credential onlyCredential(CaService service, PkiId caId) { + CaRecord ca = service.getCa(caId); + assertEquals(1, ca.caCredentials().size()); + return ca.caCredentials().get(0); + } + + private static byte[] caProfileDocument(String builtInProfileId, String profileId, + long profileVersion, int originalPathLength, int pathLength) { + return builtIn(builtInProfileId) + .replace("\"profileId\":\"" + builtInProfileId + "\"", + "\"profileId\":\"" + profileId + "\"") + .replace("\"profileVersion\":1", "\"profileVersion\":" + profileVersion) + .replace("\"pathLengthConstraint\":" + originalPathLength, + "\"pathLengthConstraint\":" + pathLength) + .getBytes(StandardCharsets.UTF_8); + } + + private static String abbreviatedHash(CertificateProfileRef reference) { + byte[] hash = reference.canonicalSha256(); + return HexFormat.of().formatHex(hash, 0, 6) + "..."; + } + + private static String builtIn(String profileId) { + return BuiltInCertificateProfileCatalog.load(CaProfileIssuanceEnforcementTest.class.getClassLoader()) + .stream().filter(template -> template.definition().profileId().equals(profileId)) + .map(template -> new String(template.canonicalJson(), StandardCharsets.UTF_8)) + .findFirst().orElseThrow(); + } + + private static KeyPair rsa() throws Exception { + return keyPair("RSA"); + } + + private static KeyPair keyPair(String algorithm) throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance(algorithm); + generator.initialize(2048); + return generator.generateKeyPair(); + } + + private static final class CountingProfileService implements ProfileService { + private final ProfileService delegate; + private final AtomicInteger activeLookups = new AtomicInteger(); + private String lastProfileId; + + private CountingProfileService(ProfileService delegate) { + this.delegate = delegate; + } + + @Override + public CertificateProfileRef importProfile(byte[] jsonDocument) { + return delegate.importProfile(jsonDocument); + } + + @Override + public CertificateProfileRef importProfile(java.io.InputStream jsonDocument) { + return delegate.importProfile(jsonDocument); + } + + @Override + public CertificateProfileRef importBuiltIn(BuiltInCertificateProfileTemplate template) { + return delegate.importBuiltIn(template); + } + + @Override + public CertificateProfileRef activateProfile(String profileId, long profileVersion) { + return delegate.activateProfile(profileId, profileVersion); + } + + @Override + public ActiveCertificateProfile requireActiveProfile(String profileId) { + lastProfileId = profileId; + activeLookups.incrementAndGet(); + return delegate.requireActiveProfile(profileId); + } + + @Override + public Optional getImportedVersion(String profileId, + long profileVersion) { + return delegate.getImportedVersion(profileId, profileVersion); + } + + @Override + public List listImportedVersions(String profileId) { + return delegate.listImportedVersions(profileId); + } + + @Override + public Optional getActiveReference(String profileId) { + return delegate.getActiveReference(profileId); + } + + private void assertAndReset(String profileId) { + assertEquals(1, activeLookups.getAndSet(0)); + assertEquals(profileId, lastProfileId); + lastProfileId = null; + } + } + + private enum MetadataMutation { + PROFILE { + @Override Credential apply(Credential value, KeyPair issuerKey) { + return copy(value, value.serialOrUniqueId(), value.validity(), + new CaProfileBinding(new CertificateProfileRef("other", 1, new byte[32]))); + } + }, + SERIAL { + @Override Credential apply(Credential value, KeyPair issuerKey) { + return copy(value, value.serialOrUniqueId() + "1", value.validity(), value.profileBinding()); + } + }, + VALIDITY { + @Override Credential apply(Credential value, KeyPair issuerKey) { + return copy(value, value.serialOrUniqueId(), + new zeroecho.pki.api.Validity(value.validity().notBefore(), + value.validity().notAfter().minusSeconds(1)), + value.profileBinding()); + } + }, + SUBJECT { + @Override Credential apply(Credential value, KeyPair issuerKey) { + return new Credential(value.credentialId(), value.formatId(), value.issuerRef(), + new SubjectRef("CN=Substituted"), value.validity(), value.serialOrUniqueId(), + value.publicKeyId(), value.profileBinding(), value.status(), value.encoded(), + value.attributes()); + } + }, + ATTRIBUTES { + @Override Credential apply(Credential value, KeyPair issuerKey) { + zeroecho.pki.api.attr.AttributeSet attributes = SimpleAttributeSet.builder() + .put(new zeroecho.pki.api.attr.AttributeId("test.unexpected"), + new zeroecho.pki.api.attr.AttributeValue.StringValue("unexpected")) + .build(); + return new Credential(value.credentialId(), value.formatId(), value.issuerRef(), + value.subjectRef(), value.validity(), value.serialOrUniqueId(), value.publicKeyId(), + value.profileBinding(), value.status(), value.encoded(), attributes); + } + }, + EXTRA_EXTENSION { + @Override Credential apply(Credential value, KeyPair issuerKey) { + return rebuild(value, issuerKey, null, true); + } + }, + SUBJECT_DER { + @Override Credential apply(Credential value, KeyPair issuerKey) { + X500Name alternate = new X500Name(new RDN[] { + new RDN(BCStyle.CN, new DERPrintableString("Mutation Intermediate")) + }); + return rebuild(value, issuerKey, alternate, false); + } + }; + + abstract Credential apply(Credential value, KeyPair issuerKey); + + private static Credential copy(Credential value, String serial, zeroecho.pki.api.Validity validity, + zeroecho.pki.api.credential.CredentialProfileBinding binding) { + return new Credential(value.credentialId(), value.formatId(), value.issuerRef(), value.subjectRef(), + validity, serial, value.publicKeyId(), binding, value.status(), value.encoded(), + value.attributes()); + } + + private static Credential rebuild(Credential value, KeyPair issuerKey, X500Name alternateSubject, + boolean extraExtension) { + return rebuild(value, issuerKey, alternateSubject, extraExtension, null, null, null); + } + + private static Credential rebuild(Credential value, KeyPair issuerKey, X500Name alternateSubject, + boolean extraExtension, BasicConstraints alternateConstraints, KeyUsage alternateKeyUsage, + Date alternateNotAfter) { + try { + X509CertificateHolder original = new X509CertificateHolder(value.encoded().bytes()); + X500Name subject = alternateSubject == null ? original.getSubject() : alternateSubject; + X509v3CertificateBuilder builder = new X509v3CertificateBuilder(original.getIssuer(), + original.getSerialNumber(), Date.from(original.getNotBefore().toInstant()), + alternateNotAfter == null ? Date.from(original.getNotAfter().toInstant()) : alternateNotAfter, + subject, original.getSubjectPublicKeyInfo()); + if (alternateConstraints == null) { + builder.addExtension(original.getExtension(Extension.basicConstraints)); + } else { + builder.addExtension(Extension.basicConstraints, true, alternateConstraints); + } + if (alternateKeyUsage == null) { + builder.addExtension(original.getExtension(Extension.keyUsage)); + } else { + builder.addExtension(Extension.keyUsage, true, alternateKeyUsage); + } + if (extraExtension) { + builder.addExtension(Extension.subjectKeyIdentifier, false, + new DEROctetString(new byte[] { 1 })); + } + byte[] encoded = builder.build(new JcaContentSignerBuilder("SHA256withRSA") + .build(issuerKey.getPrivate())).getEncoded(); + return new Credential(new PkiId("x509:" + sha256(encoded)), value.formatId(), value.issuerRef(), + value.subjectRef(), value.validity(), value.serialOrUniqueId(), value.publicKeyId(), + value.profileBinding(), value.status(), new EncodedObject(Encoding.DER, encoded), + value.attributes()); + } catch (Exception exception) { + throw new IllegalStateException("test certificate mutation failed", exception); + } + } + + private static String sha256(byte[] value) throws Exception { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value)); + } + } + + private enum ImportMutation { + SUBJECT { + @Override CaImportCommand command(PkiTestRuntime runtime, KeyRef keyRef, byte[] encoded) { + return importCommand(runtime, keyRef, encoded, new SubjectRef("CN=Substituted"), "root-ca"); + } + }, + KIND { + @Override CaImportCommand command(PkiTestRuntime runtime, KeyRef keyRef, byte[] encoded) { + return importCommand(runtime, keyRef, encoded, new SubjectRef("CN=Import Mutation Root"), + "intermediate-ca"); + } + }, + MALFORMED_DER { + @Override CaImportCommand command(PkiTestRuntime runtime, KeyRef keyRef, byte[] encoded) { + return importCommand(runtime, keyRef, new byte[] { 0x30, 0x01, 0x00 }, + new SubjectRef("CN=Import Mutation Root"), "root-ca"); + } + }, + EXTRA_EXTENSION { + @Override CaImportCommand command(PkiTestRuntime runtime, KeyRef keyRef, byte[] encoded) { + return importCommand(runtime, keyRef, encoded, new SubjectRef("CN=Import Mutation Root"), + "root-ca"); + } + @Override byte[] mutate(Credential credential, KeyPair issuerKey) { + return MetadataMutation.rebuild(credential, issuerKey, null, true).encoded().bytes(); + } + }, + BASIC_CONSTRAINTS { + @Override CaImportCommand command(PkiTestRuntime runtime, KeyRef keyRef, byte[] encoded) { + return importCommand(runtime, keyRef, encoded, new SubjectRef("CN=Import Mutation Root"), + "root-ca"); + } + @Override byte[] mutate(Credential credential, KeyPair issuerKey) { + return MetadataMutation.rebuild(credential, issuerKey, null, false, + new BasicConstraints(false), null, null).encoded().bytes(); + } + }, + KEY_USAGE { + @Override CaImportCommand command(PkiTestRuntime runtime, KeyRef keyRef, byte[] encoded) { + return importCommand(runtime, keyRef, encoded, new SubjectRef("CN=Import Mutation Root"), + "root-ca"); + } + @Override byte[] mutate(Credential credential, KeyPair issuerKey) { + return MetadataMutation.rebuild(credential, issuerKey, null, false, null, + new KeyUsage(KeyUsage.digitalSignature), null).encoded().bytes(); + } + }, + VALIDITY { + @Override CaImportCommand command(PkiTestRuntime runtime, KeyRef keyRef, byte[] encoded) { + return importCommand(runtime, keyRef, encoded, new SubjectRef("CN=Import Mutation Root"), + "root-ca"); + } + @Override byte[] mutate(Credential credential, KeyPair issuerKey) { + Date extended = Date.from(credential.validity().notAfter().plusSeconds(1)); + return MetadataMutation.rebuild(credential, issuerKey, null, false, null, null, extended) + .encoded().bytes(); + } + }; + + abstract CaImportCommand command(PkiTestRuntime runtime, KeyRef keyRef, byte[] encoded); + + byte[] mutate(Credential credential, KeyPair issuerKey) { + return credential.encoded().bytes(); + } + + private static CaImportCommand importCommand(PkiTestRuntime runtime, KeyRef keyRef, byte[] encoded, + SubjectRef subject, String profileId) { + return new CaImportCommand(runtime.framework().formatId(), subject, profileId, keyRef, + new EncodedObject(Encoding.DER, encoded), new SimpleAttributeSet()); + } + } +} diff --git a/pki/src/test/java/zeroecho/pki/e2e/H7EndEntityAcceptanceE2eTest.java b/pki/src/test/java/zeroecho/pki/e2e/H7EndEntityAcceptanceE2eTest.java index fdeb4fa..f375497 100644 --- a/pki/src/test/java/zeroecho/pki/e2e/H7EndEntityAcceptanceE2eTest.java +++ b/pki/src/test/java/zeroecho/pki/e2e/H7EndEntityAcceptanceE2eTest.java @@ -84,7 +84,7 @@ import zeroecho.pki.api.credential.CredentialStatus; import zeroecho.pki.api.issuance.IssueEndEntityCommand; import zeroecho.pki.api.request.CertificationRequest; import zeroecho.pki.api.request.ParsedCertificationRequest; -import zeroecho.pki.impl.core.ManagedCaIssuance; +import zeroecho.pki.impl.core.ValidatedCaCertificateRequest; import zeroecho.pki.impl.core.ValidatedCertificateRequest; import zeroecho.pki.impl.core.attr.SimpleAttributeSet; import zeroecho.pki.spi.framework.CredentialIssuerBackend; @@ -114,15 +114,18 @@ final class H7EndEntityAcceptanceE2eTest { Map.of(rootKeyRef, rootKey))) { List templates = BuiltInCertificateProfileCatalog.load( H7EndEntityAcceptanceE2eTest.class.getClassLoader()); - assertEquals(4, templates.size()); + assertEquals(6, templates.size()); for (BuiltInCertificateProfileTemplate template : templates) { String profileId = template.definition().profileId(); assertTrue(runtime.profileService().getImportedVersion(profileId, 1).isEmpty()); } PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), - new SubjectRef("CN=H7 Built-in Root"), "default", Optional.of(rootKeyRef), + new SubjectRef("CN=H7 Built-in Root"), "root-ca", Optional.of(rootKeyRef), new SimpleAttributeSet())); - for (BuiltInCertificateProfileTemplate template : templates) { + for (BuiltInCertificateProfileTemplate template : templates.stream() + .filter(template -> template.definition().certificateType() + == zeroecho.pki.api.profile.CertificateProfileKind.END_ENTITY) + .toList()) { String profileId = template.definition().profileId(); CertificateProfileRef imported = runtime.profileService().importBuiltIn(template); assertTrue(runtime.profileService().getActiveReference(profileId).isEmpty()); @@ -170,7 +173,7 @@ final class H7EndEntityAcceptanceE2eTest { List profiles = acceptanceProfileDocuments(); profiles.forEach(runtime::importAndActivate); PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), - new SubjectRef("CN=H7 Acceptance Root"), "default", Optional.of(rootKeyRef), + new SubjectRef("CN=H7 Acceptance Root"), "root-ca", Optional.of(rootKeyRef), new SimpleAttributeSet())); CredentialIssuerBackend serialCapturingBackend = serialCapturingBackend(runtime.issuerBackend(), @@ -278,7 +281,7 @@ final class H7EndEntityAcceptanceE2eTest { Map.of(rootKeyRef, rootKey))) { acceptanceProfileDocuments().forEach(runtime::importAndActivate); PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), - new SubjectRef("CN=H7 Rejection Root"), "default", Optional.of(rootKeyRef), + new SubjectRef("CN=H7 Rejection Root"), "root-ca", Optional.of(rootKeyRef), new SimpleAttributeSet())); assertProfileRejected(runtime, rootCaId, leafKey, "h7-dns", @@ -323,7 +326,7 @@ final class H7EndEntityAcceptanceE2eTest { runtime.importAndActivate(H7ProfileDocuments.backendMutationProfile()); runtime.importAndActivate(H7ProfileDocuments.noSanOrEkuProfile()); PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), - new SubjectRef("CN=H7 Mutation Root"), "default", Optional.of(rootKeyRef), + new SubjectRef("CN=H7 Mutation Root"), "root-ca", Optional.of(rootKeyRef), new SimpleAttributeSet())); ParsedCertificationRequest request = parse(runtime, leafKey, new X500Name("CN=Mutation Leaf,O=Example"), @@ -444,8 +447,8 @@ final class H7EndEntityAcceptanceE2eTest { } @Override - public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) { - return delegate.issueIntermediateCertificate(issuance); + public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest issuance, EncodedObject issuerCertificate, KeyRef issuerKeyRef) { + return delegate.issueIntermediateCertificate(issuance, issuerCertificate, issuerKeyRef); } }; } @@ -542,8 +545,8 @@ final class H7EndEntityAcceptanceE2eTest { } @Override - public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) { - return delegate.issueIntermediateCertificate(issuance); + public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest issuance, EncodedObject issuerCertificate, KeyRef issuerKeyRef) { + return delegate.issueIntermediateCertificate(issuance, issuerCertificate, issuerKeyRef); } }; } @@ -598,8 +601,8 @@ final class H7EndEntityAcceptanceE2eTest { } @Override - public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) { - return delegate.issueIntermediateCertificate(issuance); + public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest issuance, EncodedObject issuerCertificate, KeyRef issuerKeyRef) { + return delegate.issueIntermediateCertificate(issuance, issuerCertificate, issuerKeyRef); } }; } diff --git a/pki/src/test/java/zeroecho/pki/e2e/H7EndEntityCsrRejectionE2eTest.java b/pki/src/test/java/zeroecho/pki/e2e/H7EndEntityCsrRejectionE2eTest.java index 4b371f0..801f23b 100644 --- a/pki/src/test/java/zeroecho/pki/e2e/H7EndEntityCsrRejectionE2eTest.java +++ b/pki/src/test/java/zeroecho/pki/e2e/H7EndEntityCsrRejectionE2eTest.java @@ -60,7 +60,7 @@ import zeroecho.pki.api.issuance.IssueEndEntityCommand; import zeroecho.pki.api.request.CertificationRequest; import zeroecho.pki.api.request.ParsedCertificationRequest; import zeroecho.pki.impl.core.DefaultIssuanceService; -import zeroecho.pki.impl.core.ManagedCaIssuance; +import zeroecho.pki.impl.core.ValidatedCaCertificateRequest; import zeroecho.pki.impl.core.ValidatedCertificateRequest; import zeroecho.pki.impl.core.attr.SimpleAttributeSet; import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes; @@ -294,7 +294,7 @@ final class H7EndEntityCsrRejectionE2eTest { runtime.importAndActivate(H7ProfileDocuments.uriProfile()); runtime.importAndActivate(H7ProfileDocuments.subjectEmailAndRfc822Profile()); PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), - new SubjectRef("CN=H7 CSR Rejection Root"), "default", Optional.of(rootRef), + new SubjectRef("CN=H7 CSR Rejection Root"), "root-ca", Optional.of(rootRef), new SimpleAttributeSet())); PKCS10CertificationRequest valid = signedSanCsr(leafKey, new X500Name("CN=Template"), List.of(dns("template.example.com"))); @@ -398,8 +398,8 @@ final class H7EndEntityCsrRejectionE2eTest { } @Override - public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) { - return delegate.issueIntermediateCertificate(issuance); + public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest issuance, EncodedObject issuerCertificate, KeyRef issuerKeyRef) { + return delegate.issueIntermediateCertificate(issuance, issuerCertificate, issuerKeyRef); } }; } diff --git a/pki/src/test/java/zeroecho/pki/e2e/PkiCoreE2eTest.java b/pki/src/test/java/zeroecho/pki/e2e/PkiCoreE2eTest.java index 655ff57..2504f50 100644 --- a/pki/src/test/java/zeroecho/pki/e2e/PkiCoreE2eTest.java +++ b/pki/src/test/java/zeroecho/pki/e2e/PkiCoreE2eTest.java @@ -101,7 +101,7 @@ import zeroecho.pki.api.status.StatusObject; import zeroecho.pki.api.status.StatusObjectGenerateCommand; import zeroecho.pki.api.status.StatusObjectType; import zeroecho.pki.impl.core.attr.SimpleAttributeSet; -import zeroecho.pki.impl.core.ManagedCaIssuance; +import zeroecho.pki.impl.core.ValidatedCaCertificateRequest; import zeroecho.pki.impl.core.ValidatedCertificateRequest; import zeroecho.pki.spi.framework.CredentialIssuerBackend; import zeroecho.pki.testkit.PkiTestRuntime; @@ -140,7 +140,7 @@ public final class PkiCoreE2eTest { try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), keys)) { PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), - new SubjectRef("CN=Matrix Root"), "default", Optional.of(rootKeyRef), emptyAttributes())); + new SubjectRef("CN=Matrix Root"), "root-ca", Optional.of(rootKeyRef), emptyAttributes())); Credential usable = runtime.caService().getCa(rootCaId).caCredentials().get(0); Credential unusable = copyWithId(usable, new PkiId("credential:matrix-unusable")); CaRecord root = runtime.caService().getCa(rootCaId); @@ -166,13 +166,13 @@ public final class PkiCoreE2eTest { resolved.clear(); PkiId intermediateCaId = caService.createIntermediate(new IntermediateCreateCommand( - runtime.framework().formatId(), rootCaId, new SubjectRef("CN=Matrix Intermediate"), "default", + runtime.framework().formatId(), rootCaId, new SubjectRef("CN=Matrix Intermediate"), "intermediate-ca", Optional.of(intermediateKeyRef), emptyAttributes())); assertEquals(List.of(unusable.credentialId(), usable.credentialId()), List.copyOf(resolved)); resolved.clear(); caService.issueIntermediateCertificate(new IntermediateCertIssueCommand(runtime.framework().formatId(), - rootCaId, intermediateCaId, "default", Optional.empty(), emptyAttributes())); + rootCaId, intermediateCaId, "intermediate-ca", Optional.empty(), emptyAttributes())); assertEquals(List.of(unusable.credentialId(), usable.credentialId()), List.copyOf(resolved)); resolved.clear(); @@ -200,10 +200,10 @@ public final class PkiCoreE2eTest { try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), keys)) { PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), - new SubjectRef("CN=H6 Root"), "default", Optional.of(rootKeyRef), emptyAttributes())); + new SubjectRef("CN=H6 Root"), "root-ca", Optional.of(rootKeyRef), emptyAttributes())); PkiId intermediateCaId = runtime.caService() .createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(), rootCaId, - new SubjectRef("CN=H6 Intermediate"), "default", Optional.of(intermediateKeyRef), + new SubjectRef("CN=H6 Intermediate"), "intermediate-ca", Optional.of(intermediateKeyRef), emptyAttributes())); PkiId rootCredentialId = runtime.caService().getCa(rootCaId).caCredentials().get(0).credentialId(); runtime.revocationService().revokePermanently(new RevocationCommand.RevokePermanently(rootCredentialId, @@ -221,12 +221,12 @@ public final class PkiCoreE2eTest { assertThrows(PkiException.class, () -> runtime.caService() .createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(), rootCaId, - new SubjectRef("CN=H6 Rejected"), "default", + new SubjectRef("CN=H6 Rejected"), "intermediate-ca", Optional.of(nextIntermediateKeyRef), emptyAttributes()))); assertThrows(PkiException.class, () -> runtime.caService().issueIntermediateCertificate( new IntermediateCertIssueCommand(runtime.framework().formatId(), rootCaId, - intermediateCaId, "default", Optional.empty(), emptyAttributes()))); + intermediateCaId, "intermediate-ca", Optional.empty(), emptyAttributes()))); assertThrows(PkiException.class, () -> runtime.statusObjectService().generate(new StatusObjectGenerateCommand(rootCaId, StatusObjectType.CRL, runtime.framework().formatId(), emptyAttributes()))); @@ -265,7 +265,7 @@ public final class PkiCoreE2eTest { // Create ROOT CA (self-signed). PkiId rootCaId = caSvc.createRoot(new CaCreateCommand(runtime.framework().formatId(), - new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef), emptyAttributes())); + new SubjectRef("CN=Root"), "root-ca", Optional.of(rootKeyRef), emptyAttributes())); System.out.println("...rootCaId=" + rootCaId.value()); // CSR for end entity. @@ -320,9 +320,9 @@ public final class PkiCoreE2eTest { try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), keys)) { PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), - new SubjectRef("CN=Rejected Root"), "default", Optional.of(rootKeyRef), emptyAttributes())); + new SubjectRef("CN=Rejected Root"), "root-ca", Optional.of(rootKeyRef), emptyAttributes())); PkiId intermediateCaId = runtime.caService().createIntermediate(new IntermediateCreateCommand( - runtime.framework().formatId(), rootCaId, new SubjectRef("CN=Rejected Intermediate"), "default", + runtime.framework().formatId(), rootCaId, new SubjectRef("CN=Rejected Intermediate"), "intermediate-ca", Optional.of(intermediateKeyRef), emptyAttributes())); Credential rootCredential = runtime.caService().getCa(rootCaId).caCredentials().get(0); EffectiveCredentialStatusResolver resolver = scriptedResolver(credential -> status, resolutionFailure); @@ -343,11 +343,11 @@ public final class PkiCoreE2eTest { Optional.empty()))); assertThrows(PkiException.class, () -> caService.createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(), - rootCaId, new SubjectRef("CN=Rejected Next"), "default", + rootCaId, new SubjectRef("CN=Rejected Next"), "intermediate-ca", Optional.of(nextIntermediateKeyRef), emptyAttributes()))); assertThrows(PkiException.class, () -> caService.issueIntermediateCertificate(new IntermediateCertIssueCommand( - runtime.framework().formatId(), rootCaId, intermediateCaId, "default", Optional.empty(), + runtime.framework().formatId(), rootCaId, intermediateCaId, "intermediate-ca", Optional.empty(), emptyAttributes()))); assertThrows(PkiException.class, () -> statusService.generate(new StatusObjectGenerateCommand(rootCaId, StatusObjectType.CRL, @@ -420,9 +420,9 @@ public final class PkiCoreE2eTest { } @Override - public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) { + public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest issuance, EncodedObject issuerCertificate, KeyRef issuerKeyRef) { intermediateCalls.incrementAndGet(); - return delegate.issueIntermediateCertificate(issuance); + return delegate.issueIntermediateCertificate(issuance, issuerCertificate, issuerKeyRef); } } diff --git a/pki/src/test/java/zeroecho/pki/e2e/PkiProofGateE2eTest.java b/pki/src/test/java/zeroecho/pki/e2e/PkiProofGateE2eTest.java index 1d72175..14b2cc8 100644 --- a/pki/src/test/java/zeroecho/pki/e2e/PkiProofGateE2eTest.java +++ b/pki/src/test/java/zeroecho/pki/e2e/PkiProofGateE2eTest.java @@ -114,7 +114,7 @@ import zeroecho.pki.api.request.ParsedCertificationRequest; import zeroecho.pki.api.request.ProofOfPossessionResult; import zeroecho.pki.api.request.ProofOfPossessionStatus; import zeroecho.pki.impl.core.DefaultIssuanceService; -import zeroecho.pki.impl.core.ManagedCaIssuance; +import zeroecho.pki.impl.core.ValidatedCaCertificateRequest; import zeroecho.pki.impl.core.ValidatedCertificateRequest; import zeroecho.pki.impl.core.attr.SimpleAttributeSet; import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes; @@ -134,10 +134,10 @@ final class PkiProofGateE2eTest { System.out.println("issuerBackendApiRequiresOpaqueGateProducedInputs"); assertTrue(Modifier.isFinal(ValidatedCertificateRequest.class.getModifiers())); - assertTrue(Modifier.isFinal(ManagedCaIssuance.class.getModifiers())); + assertTrue(Modifier.isFinal(ValidatedCaCertificateRequest.class.getModifiers())); assertTrue(java.util.Arrays.stream(ValidatedCertificateRequest.class.getDeclaredConstructors()) .noneMatch(constructor -> Modifier.isPublic(constructor.getModifiers()))); - assertTrue(java.util.Arrays.stream(ManagedCaIssuance.class.getDeclaredConstructors()) + assertTrue(java.util.Arrays.stream(ValidatedCaCertificateRequest.class.getDeclaredConstructors()) .noneMatch(constructor -> Modifier.isPublic(constructor.getModifiers()))); Class managedKeyProof = Class.forName("zeroecho.pki.impl.core.CaProofGate$ManagedKeyProof"); assertTrue(java.util.Arrays.stream(managedKeyProof.getDeclaredConstructors()) @@ -153,7 +153,8 @@ final class PkiProofGateE2eTest { .findFirst().orElseThrow(); assertArrayEquals(new Class[] { ValidatedCertificateRequest.class, EncodedObject.class, KeyRef.class, BigInteger.class }, endEntity.getParameterTypes()); - assertArrayEquals(new Class[] { ManagedCaIssuance.class }, intermediate.getParameterTypes()); + assertArrayEquals(new Class[] { ValidatedCaCertificateRequest.class, EncodedObject.class, KeyRef.class }, + intermediate.getParameterTypes()); assertTrue(java.util.Arrays.stream(BcX509CredentialIssuerBackend.class.getMethods()) .filter(method -> method.getName().startsWith("issue")) .noneMatch(method -> java.util.Arrays.asList(method.getParameterTypes()) @@ -189,7 +190,7 @@ final class PkiProofGateE2eTest { assertEquals(0, counting.endEntityCalls.get()); PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), - new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef), new SimpleAttributeSet())); + new SubjectRef("CN=Root"), "root-ca", Optional.of(rootKeyRef), new SimpleAttributeSet())); CredentialBundle issued = issuance.issueEndEntity(new IssueEndEntityCommand(rootCaId, valid, "default", Optional.empty())); assertEquals(1, counting.endEntityCalls.get()); @@ -199,14 +200,14 @@ final class PkiProofGateE2eTest { CaService caService = runtime.caService(counting); caService.createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(), rootCaId, - new SubjectRef("CN=Intermediate"), "default", Optional.of(subjectKeyRef), + new SubjectRef("CN=Intermediate"), "intermediate-ca", Optional.of(subjectKeyRef), new SimpleAttributeSet())); assertEquals(1, counting.intermediateCalls.get()); runtime.replaceResolvedKey(subjectKeyRef, wrongKey.getPublic()); assertThrows(PkiException.class, () -> caService.createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(), - rootCaId, new SubjectRef("CN=Rejected"), "default", Optional.of(subjectKeyRef), + rootCaId, new SubjectRef("CN=Rejected"), "intermediate-ca", Optional.of(subjectKeyRef), new SimpleAttributeSet()))); assertEquals(1, counting.intermediateCalls.get()); @@ -222,12 +223,12 @@ final class PkiProofGateE2eTest { System.out.println("unsupportedIssuanceVariantsFailWithoutSideEffects"); try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), Map.of())) { - int auditCount = runtime.auditSink().snapshot().size(); CountingIssuerBackend counting = new CountingIssuerBackend(runtime.issuerBackend()); DefaultIssuanceService issuance = new DefaultIssuanceService(runtime.store(), runtime.framework(), counting, runtime.auditSink(), runtime.statusResolver(), runtime.profileService(), Clock.systemUTC()); CaService caService = runtime.caService(counting); + int auditCount = runtime.auditSink().snapshot().size(); ParsedCertificationRequest request = new ParsedCertificationRequest(new PkiId("csr:unsupported"), runtime.framework().formatId(), new SubjectRef("CN=Unsupported"), new EncodedObject(Encoding.DER, new byte[] { 1 }), Optional.empty(), Optional.empty(), @@ -282,7 +283,7 @@ final class PkiProofGateE2eTest { try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), Map.of(rootKeyRef, rootKey, subjectKeyRef, subjectKey))) { PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), - new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef), new SimpleAttributeSet())); + new SubjectRef("CN=Root"), "root-ca", Optional.of(rootKeyRef), new SimpleAttributeSet())); PKCS10CertificationRequest validCsr = makeCsr(subjectKey, subjectKey, "CN=Subject"); ParsedCertificationRequest valid = parse(runtime, validCsr); ParsedCertificationRequest pss = parse(runtime, @@ -376,7 +377,7 @@ final class PkiProofGateE2eTest { return new ProofOfPossessionResult(status, Optional.empty()); })) { PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), - new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef), new SimpleAttributeSet())); + new SubjectRef("CN=Root"), "root-ca", Optional.of(rootKeyRef), new SimpleAttributeSet())); ParsedCertificationRequest parsed = parse(runtime, makeCsr(subjectKey, subjectKey, "CN=Subject")); assertThrows(PkiException.class, () -> issue(runtime, rootCaId, parsed)); assertTrue(required.get()); @@ -411,7 +412,7 @@ final class PkiProofGateE2eTest { return result; })) { PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), - new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef), new SimpleAttributeSet())); + new SubjectRef("CN=Root"), "root-ca", Optional.of(rootKeyRef), new SimpleAttributeSet())); ParsedCertificationRequest parsed = parse(runtime, makeCsr(subjectKey, subjectKey, "CN=Subject")); byte[] callerCsr = csrDer(parsed); byte[] callerSpki = parsed.publicKeyInfo().bytes(); @@ -449,7 +450,7 @@ final class PkiProofGateE2eTest { new BcX509ProofOfPossessionVerifier())) { assertThrows(PkiException.class, () -> runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), - new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef), + new SubjectRef("CN=Root"), "root-ca", Optional.of(rootKeyRef), new SimpleAttributeSet()))); assertTrue(runtime.store().listCas().isEmpty()); assertTrue(runtime.store().listWorkflowStates().isEmpty()); @@ -462,7 +463,7 @@ final class PkiProofGateE2eTest { Map.of(rootKeyRef, expectedRoot.getPublic()), new BcX509ProofOfPossessionVerifier())) { assertThrows(PkiException.class, () -> runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), - new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef), + new SubjectRef("CN=Root"), "root-ca", Optional.of(rootKeyRef), new SimpleAttributeSet()))); assertTrue(runtime.store().listWorkflowStates().isEmpty()); assertEquals(1, runtime.submittedSignCount()); @@ -477,11 +478,12 @@ final class PkiProofGateE2eTest { keys.put(intermediateKeyRef, intermediateKey); try (PkiTestRuntime runtime = PkiTestRuntime.create(validDir, validDir.resolve("bus.log"), keys)) { PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), - new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef), new SimpleAttributeSet())); - AttributeSet hostile = hostileIntermediateAttributes(expectedRoot.getPublic()); + new SubjectRef("CN=Root"), "root-ca", Optional.of(rootKeyRef), new SimpleAttributeSet())); + AttributeSet approved = new SimpleAttributeSet(); PkiId intermediateCaId = runtime.caService() .createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(), rootCaId, - new SubjectRef("CN=Intermediate"), "default", Optional.of(intermediateKeyRef), hostile)); + new SubjectRef("CN=Intermediate"), "intermediate-ca", + Optional.of(intermediateKeyRef), approved)); Credential first = runtime.caService().getCa(intermediateCaId).caCredentials().get(0); X509CertificateHolder firstHolder = new X509CertificateHolder(first.encoded().bytes()); @@ -492,7 +494,7 @@ final class PkiProofGateE2eTest { Credential additional = runtime.caService() .issueIntermediateCertificate(new IntermediateCertIssueCommand(runtime.framework().formatId(), - rootCaId, intermediateCaId, "default", Optional.empty(), hostile)); + rootCaId, intermediateCaId, "intermediate-ca", Optional.empty(), approved)); X509CertificateHolder additionalHolder = new X509CertificateHolder(additional.encoded().bytes()); assertEquals("CN=Intermediate", additionalHolder.getSubject().toString()); assertArrayEquals(intermediateKey.getPublic().getEncoded(), @@ -517,13 +519,13 @@ final class PkiProofGateE2eTest { try (PkiTestRuntime runtime = PkiTestRuntime.create(issuerDir, issuerDir.resolve("bus.log"), Map.of(rootKeyRef, rootKey, intermediateKeyRef, intermediateKey))) { PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), - new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef), new SimpleAttributeSet())); + new SubjectRef("CN=Root"), "root-ca", Optional.of(rootKeyRef), new SimpleAttributeSet())); assertEquals(2, runtime.submittedSignCount()); runtime.replaceManagedKey(rootKeyRef, replacementRootKey); assertThrows(PkiException.class, () -> runtime.caService() .createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(), - rootCaId, new SubjectRef("CN=Intermediate"), "default", + rootCaId, new SubjectRef("CN=Intermediate"), "intermediate-ca", Optional.of(intermediateKeyRef), new SimpleAttributeSet()))); assertEquals(4, runtime.submittedSignCount()); assertEquals(1, runtime.store().listCas().size()); @@ -537,17 +539,17 @@ final class PkiProofGateE2eTest { try (PkiTestRuntime runtime = PkiTestRuntime.create(additionalDir, additionalDir.resolve("bus.log"), Map.of(rootKeyRef, rootKey, intermediateKeyRef, intermediateKey))) { PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), - new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef), new SimpleAttributeSet())); + new SubjectRef("CN=Root"), "root-ca", Optional.of(rootKeyRef), new SimpleAttributeSet())); PkiId intermediateCaId = runtime.caService() .createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(), rootCaId, - new SubjectRef("CN=Intermediate"), "default", Optional.of(intermediateKeyRef), + new SubjectRef("CN=Intermediate"), "intermediate-ca", Optional.of(intermediateKeyRef), new SimpleAttributeSet())); assertEquals(5, runtime.submittedSignCount()); runtime.replaceManagedKey(rootKeyRef, replacementRootKey); assertThrows(PkiException.class, () -> runtime.caService().issueIntermediateCertificate( new IntermediateCertIssueCommand(runtime.framework().formatId(), rootCaId, - intermediateCaId, "default", Optional.empty(), new SimpleAttributeSet()))); + intermediateCaId, "intermediate-ca", Optional.empty(), new SimpleAttributeSet()))); assertEquals(7, runtime.submittedSignCount()); assertEquals(1, runtime.caService().getCa(intermediateCaId).caCredentials().size()); assertTrue(runtime.store().listWorkflowStates().isEmpty()); @@ -560,11 +562,11 @@ final class PkiProofGateE2eTest { try (PkiTestRuntime source = PkiTestRuntime.create(sourceDir, sourceDir.resolve("bus.log"), Map.of(rootKeyRef, rootKey, intermediateKeyRef, intermediateKey))) { PkiId rootCaId = source.caService().createRoot(new CaCreateCommand(source.framework().formatId(), - new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef), new SimpleAttributeSet())); + new SubjectRef("CN=Root"), "root-ca", Optional.of(rootKeyRef), new SimpleAttributeSet())); rootCertificate = source.caService().getCa(rootCaId).caCredentials().get(0).encoded().bytes().clone(); PkiId intermediateCaId = source.caService() .createIntermediate(new IntermediateCreateCommand(source.framework().formatId(), rootCaId, - new SubjectRef("CN=Intermediate"), "default", Optional.of(intermediateKeyRef), + new SubjectRef("CN=Intermediate"), "intermediate-ca", Optional.of(intermediateKeyRef), new SimpleAttributeSet())); intermediateCertificate = source.caService().getCa(intermediateCaId).caCredentials().get(0).encoded() .bytes().clone(); @@ -577,7 +579,7 @@ final class PkiProofGateE2eTest { try (PkiTestRuntime target = PkiTestRuntime.create(importDir, importDir.resolve("bus.log"), Map.of(rootKeyRef, replacementRootKey))) { CaImportCommand command = new CaImportCommand(target.framework().formatId(), new SubjectRef("CN=Root"), - "default", rootKeyRef, new EncodedObject(Encoding.DER, rootCertificate), + "root-ca", rootKeyRef, new EncodedObject(Encoding.DER, rootCertificate), new SimpleAttributeSet()); assertThrows(PkiException.class, () -> target.caService().importRoot(command)); assertTrue(target.store().listCas().isEmpty()); @@ -593,7 +595,7 @@ final class PkiProofGateE2eTest { Map.of(rootKeyRef, rootKey))) { target.onPublicKeyResolve(() -> callerOwnedCertificate[callerOwnedCertificate.length - 1] ^= 0x01); PkiId importedCaId = target.caService().importRoot(new CaImportCommand(target.framework().formatId(), - new SubjectRef("CN=Root"), "default", rootKeyRef, + new SubjectRef("CN=Root"), "root-ca", rootKeyRef, new EncodedObject(Encoding.DER, callerOwnedCertificate), new SimpleAttributeSet())); assertTrue(target.caService().getCa(importedCaId).caCredentials().get(0) .profileBinding() instanceof CaProfileBinding); @@ -616,7 +618,7 @@ final class PkiProofGateE2eTest { assertThrows(PkiException.class, () -> signingFailure.caService().createRoot(new CaCreateCommand( signingFailure.framework().formatId(), - new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef), + new SubjectRef("CN=Root"), "root-ca", Optional.of(rootKeyRef), new SimpleAttributeSet()))); assertTrue(signingFailure.store().listCas().isEmpty()); assertTrue(signingFailure.store().listWorkflowStates().isEmpty()); @@ -637,7 +639,7 @@ final class PkiProofGateE2eTest { try (PkiTestRuntime runtime = PkiTestRuntime.create(rootDir, rootDir.resolve("bus.log"), Map.of(keyRef, keyPair))) { CaImportCommand command = new CaImportCommand(runtime.framework().formatId(), new SubjectRef(subject), - "default", keyRef, new EncodedObject(Encoding.DER, certificate), new SimpleAttributeSet()); + "root-ca", keyRef, new EncodedObject(Encoding.DER, certificate), new SimpleAttributeSet()); assertThrows(PkiException.class, () -> runtime.caService().importRoot(command)); assertTrue(runtime.store().listCas().isEmpty()); assertEquals(0, runtime.submittedSignCount()); @@ -657,7 +659,7 @@ final class PkiProofGateE2eTest { try (PkiTestRuntime runtime = PkiTestRuntime.create(runtimeDir, runtimeDir.resolve("bus.log"), Map.of(rootKeyRef, rootKey))) { PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), - new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef), new SimpleAttributeSet())); + new SubjectRef("CN=Root"), "root-ca", Optional.of(rootKeyRef), new SimpleAttributeSet())); ParsedCertificationRequest subject = parse(runtime, makeCsr(subjectKey, subjectKey, "CN=Subject")); ParsedCertificationRequest substitute = parse(runtime, makeCsr(subjectKey, subjectKey, "CN=Substitute")); @@ -670,7 +672,7 @@ final class PkiProofGateE2eTest { } @Override - public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) { + public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest issuance, EncodedObject issuerCertificate, KeyRef issuerKeyRef) { throw new IllegalStateException("DO_NOT_LOG_SIGNATURE_SENTINEL"); } }; @@ -696,8 +698,8 @@ final class PkiProofGateE2eTest { } @Override - public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) { - return delegateBackend.issueIntermediateCertificate(issuance); + public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest issuance, EncodedObject issuerCertificate, KeyRef issuerKeyRef) { + return delegateBackend.issueIntermediateCertificate(issuance, issuerCertificate, issuerKeyRef); } }; DefaultIssuanceService service = new DefaultIssuanceService(runtime.store(), runtime.framework(), @@ -719,15 +721,15 @@ final class PkiProofGateE2eTest { CredentialBundle rawBundle = delegateBackend.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial); Credential raw = rawBundle.credential(); - Credential wrongBinding = copyWithBinding(raw, new CaProfileBinding(candidate.profileReference() - .profileId())); + Credential wrongBinding = copyWithBinding(raw, + new CaProfileBinding(candidate.profileReference())); wrongBindingCredential.set(wrongBinding); return new CredentialBundle(wrongBinding, rawBundle.supportingObjects()); } @Override - public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) { - return delegateBackend.issueIntermediateCertificate(issuance); + public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest issuance, EncodedObject issuerCertificate, KeyRef issuerKeyRef) { + return delegateBackend.issueIntermediateCertificate(issuance, issuerCertificate, issuerKeyRef); } }; DefaultIssuanceService wrongBindingService = new DefaultIssuanceService(runtime.store(), @@ -754,8 +756,8 @@ final class PkiProofGateE2eTest { } @Override - public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) { - return delegateBackend.issueIntermediateCertificate(issuance); + public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest issuance, EncodedObject issuerCertificate, KeyRef issuerKeyRef) { + return delegateBackend.issueIntermediateCertificate(issuance, issuerCertificate, issuerKeyRef); } }; DefaultIssuanceService invalidSignatureService = new DefaultIssuanceService(runtime.store(), @@ -775,8 +777,8 @@ final class PkiProofGateE2eTest { } @Override - public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) { - return delegateBackend.issueIntermediateCertificate(issuance); + public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest issuance, EncodedObject issuerCertificate, KeyRef issuerKeyRef) { + return delegateBackend.issueIntermediateCertificate(issuance, issuerCertificate, issuerKeyRef); } }; DefaultIssuanceService snapshotService = new DefaultIssuanceService(runtime.store(), @@ -865,7 +867,7 @@ final class PkiProofGateE2eTest { try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), Map.of(rootKeyRef, rootKey, intermediateKeyRef, intermediateKey))) { PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), - new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef), new SimpleAttributeSet())); + new SubjectRef("CN=Root"), "root-ca", Optional.of(rootKeyRef), new SimpleAttributeSet())); assertTrue(runtime.caService().getCa(rootCaId).caCredentials().get(0) .profileBinding() instanceof CaProfileBinding); CredentialIssuerBackend delegate = runtime.issuerBackend(); @@ -876,7 +878,7 @@ final class PkiProofGateE2eTest { PkiException rejected = assertThrows(PkiException.class, () -> wrongBindingService.createIntermediate(new IntermediateCreateCommand( runtime.framework().formatId(), rootCaId, - new SubjectRef("CN=BindingRejectedIntermediate"), "default", + new SubjectRef("CN=BindingRejectedIntermediate"), "intermediate-ca", Optional.of(intermediateKeyRef), new SimpleAttributeSet())), mutation.name()); assertTrue(rejected.getMessage().contains("CREDENTIAL_PROFILE_BINDING_MISMATCH"), mutation.name()); assertEquals(1, runtime.store().listCas().size(), mutation.name()); @@ -892,8 +894,8 @@ final class PkiProofGateE2eTest { } @Override - public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) { - Credential raw = delegate.issueIntermediateCertificate(issuance); + public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest issuance, EncodedObject issuerCertificate, KeyRef issuerKeyRef) { + Credential raw = delegate.issueIntermediateCertificate(issuance, issuerCertificate, issuerKeyRef); return rebuildIntermediateIdentity(raw, rootKey, Optional.of(wrongKey.getPublic()), Optional.empty()); } @@ -901,13 +903,13 @@ final class PkiProofGateE2eTest { CaService wrongKeyService = runtime.caService(wrongKeyBackend); assertThrows(PkiException.class, () -> wrongKeyService.createIntermediate(new IntermediateCreateCommand(runtime.framework() - .formatId(), rootCaId, new SubjectRef("CN=Intermediate"), "default", + .formatId(), rootCaId, new SubjectRef("CN=Intermediate"), "intermediate-ca", Optional.of(intermediateKeyRef), new SimpleAttributeSet()))); assertEquals(1, runtime.store().listCas().size()); PkiId intermediateCaId = runtime.caService() .createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(), rootCaId, - new SubjectRef("CN=Intermediate"), "default", Optional.of(intermediateKeyRef), + new SubjectRef("CN=Intermediate"), "intermediate-ca", Optional.of(intermediateKeyRef), new SimpleAttributeSet())); for (BindingVariantMutation mutation : BindingVariantMutation.values()) { AtomicReference produced = new AtomicReference<>(); @@ -915,7 +917,7 @@ final class PkiProofGateE2eTest { produced)); PkiException rejected = assertThrows(PkiException.class, () -> wrongBindingService.issueIntermediateCertificate(new IntermediateCertIssueCommand( - runtime.framework().formatId(), rootCaId, intermediateCaId, "default", + runtime.framework().formatId(), rootCaId, intermediateCaId, "intermediate-ca", Optional.empty(), new SimpleAttributeSet())), mutation.name()); assertTrue(rejected.getMessage().contains("CREDENTIAL_PROFILE_BINDING_MISMATCH"), mutation.name()); assertEquals(1, runtime.caService().getCa(intermediateCaId).caCredentials().size(), @@ -932,8 +934,8 @@ final class PkiProofGateE2eTest { } @Override - public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) { - Credential raw = delegate.issueIntermediateCertificate(issuance); + public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest issuance, EncodedObject issuerCertificate, KeyRef issuerKeyRef) { + Credential raw = delegate.issueIntermediateCertificate(issuance, issuerCertificate, issuerKeyRef); return rebuildIntermediateIdentity(raw, rootKey, Optional.empty(), Optional.of("CN=WrongIntermediate")); } @@ -941,7 +943,7 @@ final class PkiProofGateE2eTest { CaService wrongSubjectService = runtime.caService(wrongSubjectBackend); assertThrows(PkiException.class, () -> wrongSubjectService.issueIntermediateCertificate(new IntermediateCertIssueCommand( - runtime.framework().formatId(), rootCaId, intermediateCaId, "default", Optional.empty(), + runtime.framework().formatId(), rootCaId, intermediateCaId, "intermediate-ca", Optional.empty(), new SimpleAttributeSet()))); assertEquals(1, runtime.caService().getCa(intermediateCaId).caCredentials().size()); @@ -952,8 +954,8 @@ final class PkiProofGateE2eTest { } @Override - public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) { - Credential raw = delegate.issueIntermediateCertificate(issuance); + public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest issuance, EncodedObject issuerCertificate, KeyRef issuerKeyRef) { + Credential raw = delegate.issueIntermediateCertificate(issuance, issuerCertificate, issuerKeyRef); byte[] invalid = raw.encoded().bytes().clone(); invalid[invalid.length - 1] ^= 0x01; return new Credential(raw.credentialId(), raw.formatId(), raw.issuerRef(), @@ -965,7 +967,7 @@ final class PkiProofGateE2eTest { CaService invalidSignatureService = runtime.caService(invalidSignatureBackend); assertThrows(PkiException.class, () -> invalidSignatureService.issueIntermediateCertificate(new IntermediateCertIssueCommand( - runtime.framework().formatId(), rootCaId, intermediateCaId, "default", Optional.empty(), + runtime.framework().formatId(), rootCaId, intermediateCaId, "intermediate-ca", Optional.empty(), new SimpleAttributeSet()))); assertEquals(1, runtime.caService().getCa(intermediateCaId).caCredentials().size()); @@ -975,7 +977,7 @@ final class PkiProofGateE2eTest { assertThrows(PkiException.class, () -> maliciousExtensionService.issueIntermediateCertificate( new IntermediateCertIssueCommand(runtime.framework().formatId(), rootCaId, - intermediateCaId, "default", Optional.empty(), new SimpleAttributeSet())), + intermediateCaId, "intermediate-ca", Optional.empty(), new SimpleAttributeSet())), variant.name()); assertEquals(1, runtime.caService().getCa(intermediateCaId).caCredentials().size(), variant.name()); } @@ -988,15 +990,15 @@ final class PkiProofGateE2eTest { } @Override - public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) { - Credential raw = delegate.issueIntermediateCertificate(issuance); + public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest issuance, EncodedObject issuerCertificate, KeyRef issuerKeyRef) { + Credential raw = delegate.issueIntermediateCertificate(issuance, issuerCertificate, issuerKeyRef); rawCredential.set(raw); return raw; } }; CaService snapshotService = runtime.caService(mutableBackend); Credential returned = snapshotService.issueIntermediateCertificate(new IntermediateCertIssueCommand( - runtime.framework().formatId(), rootCaId, intermediateCaId, "default", Optional.empty(), + runtime.framework().formatId(), rootCaId, intermediateCaId, "intermediate-ca", Optional.empty(), new SimpleAttributeSet())); byte[] expected = returned.encoded().bytes().clone(); rawCredential.get().encoded().bytes()[0] ^= 0x01; @@ -1041,9 +1043,9 @@ final class PkiProofGateE2eTest { } @Override - public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) { + public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest issuance, EncodedObject issuerCertificate, KeyRef issuerKeyRef) { intermediateCalls.incrementAndGet(); - return delegate.issueIntermediateCertificate(issuance); + return delegate.issueIntermediateCertificate(issuance, issuerCertificate, issuerKeyRef); } } @@ -1057,8 +1059,8 @@ final class PkiProofGateE2eTest { } @Override - public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) { - Credential credential = delegate.issueIntermediateCertificate(issuance); + public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest issuance, EncodedObject issuerCertificate, KeyRef issuerKeyRef) { + Credential credential = delegate.issueIntermediateCertificate(issuance, issuerCertificate, issuerKeyRef); return rebuildIntermediateExtensions(credential, issuerKey, variant); } }; @@ -1074,24 +1076,27 @@ final class PkiProofGateE2eTest { } @Override - public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) { - Credential raw = delegate.issueIntermediateCertificate(issuance); + public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest issuance, EncodedObject issuerCertificate, KeyRef issuerKeyRef) { + Credential raw = delegate.issueIntermediateCertificate(issuance, issuerCertificate, issuerKeyRef); produced.set(raw); if (mutation == BindingVariantMutation.NULL_CREDENTIAL) { return null; } - return copyWithBinding(raw, bindingFor(mutation, issuance.profileId())); + return copyWithBinding(raw, bindingFor(mutation, issuance.profileReference())); } }; } - private static CredentialProfileBinding bindingFor(BindingVariantMutation mutation, String profileId) { + private static CredentialProfileBinding bindingFor(BindingVariantMutation mutation, + CertificateProfileRef profileReference) { return switch (mutation) { - case END_ENTITY_SAME_ID -> new EndEntityProfileBinding(new CertificateProfileRef(profileId, 1, + case END_ENTITY_SAME_ID -> new EndEntityProfileBinding(new CertificateProfileRef( + profileReference.profileId(), 1, new byte[CertificateProfileRef.HASH_BYTES])); case END_ENTITY_OTHER_ID -> new EndEntityProfileBinding(new CertificateProfileRef("other", 1, new byte[CertificateProfileRef.HASH_BYTES])); - case CA_OTHER_ID -> new CaProfileBinding("other"); + case CA_OTHER_ID -> new CaProfileBinding(new CertificateProfileRef("other", 1, + new byte[CertificateProfileRef.HASH_BYTES])); case NULL_CREDENTIAL -> throw new IllegalStateException("null credential has no binding"); }; } 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 e4bb677..d968f5b 100644 --- a/pki/src/test/java/zeroecho/pki/impl/core/DefaultStatusObjectServiceCrlTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/core/DefaultStatusObjectServiceCrlTest.java @@ -372,7 +372,7 @@ final class DefaultStatusObjectServiceCrlTest { CaProfileBinding binding = assertInstanceOf(CaProfileBinding.class, template.profileBinding()); return new Credential(new PkiId("credential:" + suffix), formatId, template.issuerRef(), template.subjectRef(), template.validity(), template.serialOrUniqueId(), - template.publicKeyId(), new CaProfileBinding(binding.profileId()), CredentialStatus.ISSUED, encoded, + template.publicKeyId(), new CaProfileBinding(binding.reference()), CredentialStatus.ISSUED, encoded, template.attributes()); } @@ -410,7 +410,7 @@ final class DefaultStatusObjectServiceCrlTest { private static PkiId createRoot(PkiTestRuntime runtime, KeyRef keyRef, String commonName) { return runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), - new SubjectRef("CN=" + commonName), "default", Optional.of(keyRef), emptyAttributes())); + new SubjectRef("CN=" + commonName), "root-ca", Optional.of(keyRef), emptyAttributes())); } private static KeyPair generateRsa() throws Exception { 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 8dbf49a..3f1034d 100644 --- a/pki/src/test/java/zeroecho/pki/impl/core/H7ProfileEnforcementTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/core/H7ProfileEnforcementTest.java @@ -139,13 +139,13 @@ final class H7ProfileEnforcementTest { SubjectAlternativeNameRule dns = new SubjectAlternativeNameRule(SubjectAlternativeNameType.DNS_NAME, 0, 1, false, false); assertThrows(IllegalArgumentException.class, - () -> new SubjectAlternativeNamePolicy(false, 2, 2, List.of(dns), false, Set.of(), false, + () -> new SubjectAlternativeNamePolicy(2, 2, List.of(dns), false, Set.of(), false, false, false)); assertThrows(IllegalArgumentException.class, - () -> new SubjectAlternativeNamePolicy(false, 0, 1, List.of(), false, Set.of(), false, + () -> new SubjectAlternativeNamePolicy(0, 1, List.of(), false, Set.of(), false, true, false)); assertThrows(IllegalArgumentException.class, - () -> new SubjectAlternativeNamePolicy(false, 0, 1, List.of(dns), false, Set.of("https"), false, + () -> new SubjectAlternativeNamePolicy(0, 1, List.of(dns), false, Set.of("https"), false, false, false)); assertThrows(IllegalArgumentException.class, () -> leaf(Set.of(LeafKeyUsage.ENCIPHER_ONLY), Set.of("RSA"), Set.of())); @@ -262,7 +262,7 @@ final class H7ProfileEnforcementTest { CertificateProfileRef reference = new CertificateProfileRef("shared-profile", 1, new byte[CertificateProfileRef.HASH_BYTES]); EndEntityProfileBinding endEntity = new EndEntityProfileBinding(reference); - CaProfileBinding ca = new CaProfileBinding(reference.profileId()); + CaProfileBinding ca = new CaProfileBinding(reference); assertTrue(CredentialProfileBinding.class.isSealed()); assertEquals(Set.of(EndEntityProfileBinding.class, CaProfileBinding.class), @@ -273,16 +273,17 @@ final class H7ProfileEnforcementTest { .noneMatch(method -> method.getName().equals("profileId"))); CredentialProfileBindings.requireEndEntityBinding(endEntity, reference); - CredentialProfileBindings.requireCaBinding(ca, reference.profileId()); + CredentialProfileBindings.requireCaBinding(ca, reference); assertCode(CredentialProfileBindings.MISMATCH_CODE, () -> CredentialProfileBindings.requireEndEntityBinding(ca, reference)); assertCode(CredentialProfileBindings.MISMATCH_CODE, - () -> CredentialProfileBindings.requireCaBinding(endEntity, reference.profileId())); + () -> CredentialProfileBindings.requireCaBinding(endEntity, reference)); assertCode(CredentialProfileBindings.MISMATCH_CODE, - () -> CredentialProfileBindings.requireCaBinding(new CaProfileBinding("other"), - reference.profileId())); + () -> CredentialProfileBindings.requireCaBinding( + new CaProfileBinding(new CertificateProfileRef("other", 1, new byte[32])), + reference)); assertCode(CredentialProfileBindings.MISMATCH_CODE, - () -> CredentialProfileBindings.requireCaBinding(null, reference.profileId())); + () -> CredentialProfileBindings.requireCaBinding(null, reference)); } @Test @@ -293,7 +294,7 @@ final class H7ProfileEnforcementTest { try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), Map.of(rootRef, root))) { PkiId caId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), - new SubjectRef("CN=Root"), "default", Optional.of(rootRef), new SimpleAttributeSet())); + new SubjectRef("CN=Root"), "root-ca", Optional.of(rootRef), new SimpleAttributeSet())); ParsedCertificationRequest request = runtime.certificationRequestService().parse( new CertificationRequest(runtime.framework().formatId(), new EncodedObject(Encoding.DER, @@ -385,31 +386,34 @@ final class H7ProfileEnforcementTest { return new Credential(new PkiId("credential:issuer"), BcX509CredentialFramework.FORMAT_ID, new IssuerRef(new PkiId("ca:issuer")), new SubjectRef("CN=Issuer"), new Validity(NOW.minus(Duration.ofDays(1)), NOW.plus(Duration.ofDays(1000))), "1", - new PkiId("spki:issuer"), new CaProfileBinding("root"), CredentialStatus.ISSUED, + new PkiId("spki:issuer"), + new CaProfileBinding(new CertificateProfileRef("root", 1, new byte[32])), + CredentialStatus.ISSUED, new EncodedObject(Encoding.DER, new byte[] { 1 }), new SimpleAttributeSet()); } private static CertificateProfile policyWithFixedOrganization(String algorithm) { - SubjectPolicy subject = new SubjectPolicy(List.of( + SubjectPolicy subject = new SubjectPolicy(false, List.of( new SubjectRdnRule(SubjectRdnType.COMMON_NAME, 1, 1, 256, Optional.empty(), true), new SubjectRdnRule(SubjectRdnType.ORGANIZATION_NAME, 1, 1, 256, Optional.of("Profile Fixed"), false))); SubjectAlternativeNamePolicy sans = - new SubjectAlternativeNamePolicy(false, 0, 0, List.of(), false, Set.of(), false, false, false); - LeafCertificatePolicy leaf = new LeafCertificatePolicy(subject, sans, - Set.of(LeafKeyUsage.DIGITAL_SIGNATURE), Set.of(), true, false, true, Set.of(algorithm), - Duration.ofDays(365)); - return new CertificateProfile("h7", BcX509CredentialFramework.FORMAT_ID, "H7", leaf); + new SubjectAlternativeNamePolicy(0, 0, List.of(), false, Set.of(), false, false, false); + LeafCertificatePolicy leaf = new LeafCertificatePolicy(sans, + Set.of(LeafKeyUsage.DIGITAL_SIGNATURE), Set.of(), true, false, true, + Set.of(algorithm)); + return new CertificateProfile("h7", BcX509CredentialFramework.FORMAT_ID, "H7", + Duration.ofDays(365), subject, leaf); } private static LeafCertificatePolicy leaf(Set usages, Set algorithms, Set ekus) { - return new LeafCertificatePolicy(new SubjectPolicy(List.of()), - new SubjectAlternativeNamePolicy(true, 1, 1, + return new LeafCertificatePolicy( + new SubjectAlternativeNamePolicy(1, 1, List.of(new SubjectAlternativeNameRule(SubjectAlternativeNameType.DNS_NAME, 1, 1, false, false)), false, Set.of(), false, true, false), - usages, ekus, true, false, true, algorithms, Duration.ofDays(1)); + usages, ekus, true, false, true, algorithms); } private static ParsedCertificationRequest parse(PKCS10CertificationRequest request) throws Exception { diff --git a/pki/src/test/java/zeroecho/pki/impl/core/StoreBackedEffectiveCredentialStatusResolverTest.java b/pki/src/test/java/zeroecho/pki/impl/core/StoreBackedEffectiveCredentialStatusResolverTest.java index f561c22..124ed6a 100644 --- a/pki/src/test/java/zeroecho/pki/impl/core/StoreBackedEffectiveCredentialStatusResolverTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/core/StoreBackedEffectiveCredentialStatusResolverTest.java @@ -176,7 +176,7 @@ final class StoreBackedEffectiveCredentialStatusResolverTest { Credential credential = new Credential(new PkiId("credential:audit"), new FormatId(sentinel), new IssuerRef(new PkiId("ca:issuer")), new SubjectRef("CN=Audit"), new Validity(NOW.minusSeconds(60), NOW.plusSeconds(60)), "audit", new PkiId("key:audit"), - new CaProfileBinding("default"), CredentialStatus.ISSUED, + new CaProfileBinding(new zeroecho.pki.api.profile.CertificateProfileRef("default", 1, new byte[32])), CredentialStatus.ISSUED, new EncodedObject(Encoding.DER, new byte[] { 1 }), new SimpleAttributeSet()); AtomicReference recorded = new AtomicReference<>(); @@ -239,7 +239,7 @@ final class StoreBackedEffectiveCredentialStatusResolverTest { return new Credential(new PkiId("credential:" + suffix), new FormatId("x509"), new IssuerRef(new PkiId("ca:issuer")), new SubjectRef("CN=" + suffix), new Validity(notBefore, notAfter), suffix, new PkiId("key:" + suffix), - new CaProfileBinding("default"), status, + new CaProfileBinding(new zeroecho.pki.api.profile.CertificateProfileRef("default", 1, new byte[32])), status, new EncodedObject(Encoding.DER, new byte[] { 1, 2, 3 }), new SimpleAttributeSet()); } diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java index 2588290..451c42c 100644 --- a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java @@ -76,6 +76,7 @@ import zeroecho.pki.api.credential.CredentialStatus; import zeroecho.pki.api.profile.CertificateProfile; import zeroecho.pki.api.profile.CertificateProfileDefinition; import zeroecho.pki.api.profile.CertificateProfileDocumentCodec; +import zeroecho.pki.api.profile.CertificateProfileKind; import zeroecho.pki.api.profile.CertificateProfileRef; import zeroecho.pki.api.profile.ExtendedKeyUsageId; import zeroecho.pki.api.profile.ImportedCertificateProfileVersion; @@ -527,12 +528,15 @@ public final class FilesystemPkiStoreTest { */ static void importProfile(FilesystemPkiStore store, CertificateProfile profile, Instant importedAt) throws Exception { - CertificateProfileDefinition definition = new CertificateProfileDefinition(profile.profileId(), 1, - profile.formatId(), profile.displayName(), profile.leafPolicy()); + CertificateProfileDefinition definition = new CertificateProfileDefinition( + CertificateProfileKind.END_ENTITY, profile.profileId(), 1, profile.formatId(), + profile.displayName(), profile.maximumValidity(), profile.subjectPolicy(), + profile.leafPolicy()); byte[] canonical = CertificateProfileDocumentCodec.writeCanonical(definition); byte[] hash = MessageDigest.getInstance("SHA-256").digest(canonical); CertificateProfileRef reference = new CertificateProfileRef(profile.profileId(), 1, hash); - store.importProfileVersion(new ImportedCertificateProfileVersion(reference, 1, definition, canonical, + store.importProfileVersion(new ImportedCertificateProfileVersion(reference, + CertificateProfileDefinition.SCHEMA_VERSION, definition, canonical, importedAt)); } @@ -554,15 +558,16 @@ public final class FilesystemPkiStoreTest { static CertificateProfile minimalProfile(String profileId) { FormatId formatId = new FormatId("fmt-x509"); - SubjectPolicy subject = new SubjectPolicy(List.of(new SubjectRdnRule( + SubjectPolicy subject = new SubjectPolicy(false, List.of(new SubjectRdnRule( SubjectRdnType.COMMON_NAME, 1, 1, 256, Optional.empty(), true))); - SubjectAlternativeNamePolicy sans = new SubjectAlternativeNamePolicy(false, 0, 0, + SubjectAlternativeNamePolicy sans = new SubjectAlternativeNamePolicy(0, 0, List.of(), false, Set.of(), false, false, false); - LeafCertificatePolicy leaf = new LeafCertificatePolicy(subject, sans, + LeafCertificatePolicy leaf = new LeafCertificatePolicy(sans, Set.of(LeafKeyUsage.DIGITAL_SIGNATURE, LeafKeyUsage.KEY_ENCIPHERMENT), Set.of(new ExtendedKeyUsageId("1.3.6.1.5.5.7.3.1")), true, false, true, - Set.of("RSA"), Duration.ofDays(365)); - return new CertificateProfile(profileId, formatId, "Test leaf profile", leaf); + Set.of("RSA")); + return new CertificateProfile(profileId, formatId, "Test leaf profile", + Duration.ofDays(365), subject, leaf); } static Credential minimalCredential(String serial, String profileId) { @@ -584,7 +589,7 @@ public final class FilesystemPkiStoreTest { AttributeSet attrs = emptyAttributes(); return new Credential(credentialId, formatId, issuerRef, subjectRef, validity, serial, publicKeyId, - new CaProfileBinding(profileId), status, encoded, attrs); + new CaProfileBinding(new zeroecho.pki.api.profile.CertificateProfileRef(profileId, 1, new byte[32])), status, encoded, attrs); } static AttributeSet emptyAttributes() { diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemProfileLifecycleTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemProfileLifecycleTest.java index 5a08869..faedfff 100644 --- a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemProfileLifecycleTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemProfileLifecycleTest.java @@ -30,7 +30,9 @@ import org.junit.jupiter.api.io.TempDir; import zeroecho.pki.api.PkiException; import zeroecho.pki.api.profile.BuiltInCertificateProfileCatalog; import zeroecho.pki.api.profile.BuiltInCertificateProfileTemplate; +import zeroecho.pki.api.profile.CertificateProfileDefinition; import zeroecho.pki.api.profile.CertificateProfileRef; +import zeroecho.pki.api.profile.CertificateProfileKind; import zeroecho.pki.api.profile.ImportedCertificateProfileVersion; import zeroecho.pki.impl.audit.InMemoryAuditSink; import zeroecho.pki.impl.core.DefaultProfileService; @@ -100,7 +102,8 @@ final class FilesystemProfileLifecycleTest { ImportedCertificateProfileVersion invalid = new ImportedCertificateProfileVersion( new CertificateProfileRef(template.definition().profileId(), template.definition().profileVersion(), template.canonicalSha256()), - 1, template.definition(), noncanonical, CLOCK.instant()); + CertificateProfileDefinition.SCHEMA_VERSION, template.definition(), + noncanonical, CLOCK.instant()); Path path = new FsPaths(noncanonicalRoot).profileVersion("server-tls", 1); FsOperations.ensureDir(path.getParent()); Files.write(path, FsCodec.encode(FsCodec.PROFILE_VERSION, invalid)); @@ -115,7 +118,8 @@ final class FilesystemProfileLifecycleTest { new CertificateProfileRef(template.definition().profileId(), template.definition().profileVersion(), new byte[CertificateProfileRef.HASH_BYTES]), - 1, template.definition(), template.canonicalJson(), CLOCK.instant()); + CertificateProfileDefinition.SCHEMA_VERSION, template.definition(), + template.canonicalJson(), CLOCK.instant()); Path path = new FsPaths(hashMismatchRoot).profileVersion("server-tls", 1); FsOperations.ensureDir(path.getParent()); Files.write(path, FsCodec.encode(FsCodec.PROFILE_VERSION, invalid)); @@ -168,6 +172,51 @@ final class FilesystemProfileLifecycleTest { } } + @Test + void caKindsUseLifecycleAndOneLogicalIdCannotChangeKindAcrossVersions( + @TempDir Path directory) throws Exception { + Path root = directory.resolve("store"); + CertificateProfileRef rootRef; + CertificateProfileRef intermediateRef; + try (FilesystemPkiStore store = store(root)) { + DefaultProfileService service = service(store); + rootRef = service.importBuiltIn(builtIn("root-ca")); + intermediateRef = service.importBuiltIn(builtIn("intermediate-ca")); + assertTrue(service.getActiveReference("root-ca").isEmpty()); + assertTrue(service.getActiveReference("intermediate-ca").isEmpty()); + assertEquals(rootRef, service.activateProfile("root-ca", 1)); + assertEquals(intermediateRef, service.activateProfile("intermediate-ca", 1)); + assertEquals(CertificateProfileKind.ROOT_CA, + service.requireActiveProfile("root-ca").definition().certificateType()); + assertEquals(CertificateProfileKind.INTERMEDIATE_CA, + service.requireActiveProfile("intermediate-ca").definition().certificateType()); + + byte[] sameKindVersion = version(builtIn("root-ca"), 2, "Root CA v2"); + CertificateProfileRef versionTwo = service.importProfile(sameKindVersion); + assertEquals(2, versionTwo.profileVersion()); + + byte[] conflictingKind = new String(builtIn("intermediate-ca").canonicalJson(), + StandardCharsets.UTF_8) + .replace("\"profileId\":\"intermediate-ca\"", "\"profileId\":\"root-ca\"") + .replace("\"profileVersion\":1", "\"profileVersion\":3") + .getBytes(StandardCharsets.UTF_8); + assertCode(() -> service.importProfile(conflictingKind), "PROFILE_KIND_CONFLICT"); + assertTrue(service.getImportedVersion("root-ca", 3).isEmpty()); + assertEquals(rootRef, service.getActiveReference("root-ca").orElseThrow()); + } + + try (FilesystemPkiStore reopened = store(root)) { + DefaultProfileService service = service(reopened); + assertEquals(rootRef, service.getActiveReference("root-ca").orElseThrow()); + assertEquals(intermediateRef, + service.getActiveReference("intermediate-ca").orElseThrow()); + assertEquals(CertificateProfileKind.ROOT_CA, + service.requireActiveProfile("root-ca").definition().certificateType()); + assertEquals(CertificateProfileKind.INTERMEDIATE_CA, + service.requireActiveProfile("intermediate-ca").definition().certificateType()); + } + } + @Test void missingCorruptAndHashMismatchedPointersNeverFallback(@TempDir Path directory) throws Exception { BuiltInCertificateProfileTemplate template = builtIn("vpn-client"); diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationJournalTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationJournalTest.java index 31ab5aa..2a48937 100644 --- a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationJournalTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationJournalTest.java @@ -499,7 +499,7 @@ final class FilesystemRevocationJournalTest { return new Credential(new PkiId("credential:" + suffix), new FormatId("x509"), new IssuerRef(new PkiId("ca:issuer")), new SubjectRef("CN=" + suffix), new Validity(TIME.minusSeconds(60), TIME.plusSeconds(60)), suffix, - new PkiId("key:" + suffix), new CaProfileBinding("default"), CredentialStatus.ISSUED, + new PkiId("key:" + suffix), new CaProfileBinding(new zeroecho.pki.api.profile.CertificateProfileRef("default", 1, new byte[32])), CredentialStatus.ISSUED, new EncodedObject(Encoding.DER, new byte[] { 1, 2, 3 }), new SimpleAttributeSet()); } diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FsCodecTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FsCodecTest.java index 00a5636..6949056 100644 --- a/pki/src/test/java/zeroecho/pki/impl/fs/FsCodecTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/fs/FsCodecTest.java @@ -71,6 +71,7 @@ import zeroecho.pki.api.credential.EndEntityProfileBinding; import zeroecho.pki.api.profile.CertificateProfile; import zeroecho.pki.api.profile.CertificateProfileDefinition; import zeroecho.pki.api.profile.CertificateProfileDocumentCodec; +import zeroecho.pki.api.profile.CertificateProfileKind; import zeroecho.pki.api.profile.CertificateProfileRef; import zeroecho.pki.api.profile.ExtendedKeyUsageId; import zeroecho.pki.api.profile.ImportedCertificateProfileVersion; @@ -215,7 +216,7 @@ final class FsCodecTest { CertificateProfileRef reference = new CertificateProfileRef("profile-a", 1, new byte[CertificateProfileRef.HASH_BYTES]); List bindings = List.of(new EndEntityProfileBinding(reference), - new CaProfileBinding(reference.profileId())); + new CaProfileBinding(reference)); for (CredentialProfileBinding binding : bindings) { Credential original = credential(binding); @@ -225,6 +226,33 @@ final class FsCodecTest { } } + @Test + void obsoleteBareStringCaBindingFailsStrictDecode() { + byte[] encoded = FsCodec.encode(FsCodec.CREDENTIAL, + credential(new CaProfileBinding(new CertificateProfileRef("profile-a", 1, + new byte[CertificateProfileRef.HASH_BYTES])))); + int binding = indexOf(encoded, new byte[] { 73, 2, 72 }); + assertTrue(binding >= 0); + encoded[binding + 2] = 1; + assertInvalid(encoded); + } + + private static int indexOf(byte[] source, byte[] target) { + for (int index = 0; index <= source.length - target.length; index++) { + boolean matches = true; + for (int offset = 0; offset < target.length; offset++) { + if (source[index + offset] != target[offset]) { + matches = false; + break; + } + } + if (matches) { + return index; + } + } + return -1; + } + private static ParsedCertificationRequest roundTripRequest(AttributeSet attributes) { byte[] encoded = FsCodec.encode(FsCodec.PARSED_REQUEST, request(attributes)); return FsCodec.decode(FsCodec.PARSED_REQUEST, encoded); @@ -239,15 +267,16 @@ final class FsCodecTest { } private static CertificateProfile profile() { - SubjectPolicy subject = new SubjectPolicy(List.of(new SubjectRdnRule( + SubjectPolicy subject = new SubjectPolicy(false, List.of(new SubjectRdnRule( SubjectRdnType.COMMON_NAME, 1, 1, 256, Optional.empty(), true))); - SubjectAlternativeNamePolicy sans = new SubjectAlternativeNamePolicy(false, 0, 0, + SubjectAlternativeNamePolicy sans = new SubjectAlternativeNamePolicy(0, 0, List.of(), false, java.util.Set.of(), false, false, false); - LeafCertificatePolicy leaf = new LeafCertificatePolicy(subject, sans, + LeafCertificatePolicy leaf = new LeafCertificatePolicy(sans, java.util.Set.of(LeafKeyUsage.DIGITAL_SIGNATURE, LeafKeyUsage.KEY_ENCIPHERMENT), java.util.Set.of(new ExtendedKeyUsageId("1.3.6.1.5.5.7.3.1")), true, false, true, - java.util.Set.of("RSA"), Duration.ofDays(365)); - return new CertificateProfile("profile-a", new FormatId("x509"), "Test leaf profile", leaf); + java.util.Set.of("RSA")); + return new CertificateProfile("profile-a", new FormatId("x509"), "Test leaf profile", + Duration.ofDays(365), subject, leaf); } private static Credential credential(CredentialProfileBinding binding) { @@ -261,12 +290,15 @@ final class FsCodecTest { private static ImportedCertificateProfileVersion profileVersion() { try { CertificateProfile profile = profile(); - CertificateProfileDefinition definition = new CertificateProfileDefinition(profile.profileId(), 1, - profile.formatId(), profile.displayName(), profile.leafPolicy()); + CertificateProfileDefinition definition = new CertificateProfileDefinition( + CertificateProfileKind.END_ENTITY, profile.profileId(), 1, profile.formatId(), + profile.displayName(), profile.maximumValidity(), profile.subjectPolicy(), + profile.leafPolicy()); byte[] canonical = CertificateProfileDocumentCodec.writeCanonical(definition); CertificateProfileRef reference = new CertificateProfileRef(profile.profileId(), 1, MessageDigest.getInstance("SHA-256").digest(canonical)); - return new ImportedCertificateProfileVersion(reference, 1, definition, canonical, Instant.EPOCH); + return new ImportedCertificateProfileVersion(reference, + CertificateProfileDefinition.SCHEMA_VERSION, definition, canonical, Instant.EPOCH); } catch (java.security.NoSuchAlgorithmException impossible) { throw new IllegalStateException(impossible); } diff --git a/pki/src/test/java/zeroecho/pki/testkit/H7ProfileDocuments.java b/pki/src/test/java/zeroecho/pki/testkit/H7ProfileDocuments.java index 59fb709..27e9e39 100644 --- a/pki/src/test/java/zeroecho/pki/testkit/H7ProfileDocuments.java +++ b/pki/src/test/java/zeroecho/pki/testkit/H7ProfileDocuments.java @@ -212,7 +212,7 @@ public final class H7ProfileDocuments { boolean critical, List sanRules, boolean noKeyEncipherment, List eku) { String keyUsage = noKeyEncipherment ? "[\"DIGITAL_SIGNATURE\"]" : "[\"DIGITAL_SIGNATURE\",\"KEY_ENCIPHERMENT\"]"; - String json = "{\"schemaVersion\":1,\"profileId\":\"" + id + String json = "{\"schemaVersion\":2,\"certificateType\":\"END_ENTITY\",\"profileId\":\"" + id + "\",\"profileVersion\":1,\"formatId\":\"" + formatId + "\",\"displayName\":\"H7 Test Profile\"," + "\"maxValidity\":\"PT8760H\",\"subject\":{\"allowEmpty\":" + allowEmpty diff --git a/pki/src/test/java/zeroecho/pki/testkit/PkiTestRuntime.java b/pki/src/test/java/zeroecho/pki/testkit/PkiTestRuntime.java index db3805c..2c9d838 100644 --- a/pki/src/test/java/zeroecho/pki/testkit/PkiTestRuntime.java +++ b/pki/src/test/java/zeroecho/pki/testkit/PkiTestRuntime.java @@ -54,6 +54,7 @@ import zeroecho.pki.api.RevocationService; import zeroecho.pki.api.ProfileService; import zeroecho.pki.api.StatusObjectService; import zeroecho.pki.api.credential.EffectiveCredentialStatusResolver; +import zeroecho.pki.api.profile.BuiltInCertificateProfileCatalog; import zeroecho.pki.impl.core.DefaultCaService; import zeroecho.pki.impl.core.DefaultCertificationRequestService; import zeroecho.pki.impl.core.DefaultIssuanceService; @@ -104,6 +105,7 @@ public final class PkiTestRuntime implements AutoCloseable { private final Map publicKeysByKeyRef; private Runnable publicKeyResolveHook; + private boolean caProfilesProvisioned; private PkiTestRuntime(FilesystemPkiStore store, PkiSigningBus signingBus, SignatureWorkflow signatureWorkflow, CredentialFramework framework, CredentialIssuerBackend issuerBackend, @@ -129,7 +131,7 @@ public final class PkiTestRuntime implements AutoCloseable { this.statusObjectService = new DefaultStatusObjectService(store, framework, auditSink, statusResolver); this.caService = new DefaultCaService(store, framework, issuerBackend, this::resolvePublicKeyInfo, signingBus, - auditSink, statusResolver, "SHA256withRSA", signingTtl); + auditSink, statusResolver, profileService, clock, "SHA256withRSA", signingTtl); } /** @@ -306,27 +308,53 @@ public final class PkiTestRuntime implements AutoCloseable { } public CaService caService() { + provisionCaProfiles(); return caService; } public CaService caService(CredentialFramework credentialFramework) { + provisionCaProfiles(); return new DefaultCaService(store, Objects.requireNonNull(credentialFramework, "credentialFramework"), - issuerBackend, this::resolvePublicKeyInfo, signingBus, auditSink, statusResolver, "SHA256withRSA", - Duration.ofSeconds(2)); + issuerBackend, this::resolvePublicKeyInfo, signingBus, auditSink, statusResolver, profileService, + Clock.systemUTC(), "SHA256withRSA", Duration.ofSeconds(2)); } public CaService caService(CredentialIssuerBackend backend) { + provisionCaProfiles(); return new DefaultCaService(store, framework, Objects.requireNonNull(backend, "backend"), - this::resolvePublicKeyInfo, signingBus, auditSink, statusResolver, "SHA256withRSA", - Duration.ofSeconds(2)); + this::resolvePublicKeyInfo, signingBus, auditSink, statusResolver, profileService, Clock.systemUTC(), + "SHA256withRSA", Duration.ofSeconds(2)); } public CaService caService(CredentialIssuerBackend backend, EffectiveCredentialStatusResolver resolver) { + provisionCaProfiles(); return new DefaultCaService(store, framework, Objects.requireNonNull(backend, "backend"), this::resolvePublicKeyInfo, signingBus, auditSink, Objects.requireNonNull(resolver, "resolver"), + profileService, Clock.systemUTC(), "SHA256withRSA", Duration.ofSeconds(2)); + } + + public CaService caService(ProfileService profiles) { + provisionCaProfiles(); + return new DefaultCaService(store, framework, issuerBackend, this::resolvePublicKeyInfo, signingBus, + auditSink, statusResolver, Objects.requireNonNull(profiles, "profiles"), Clock.systemUTC(), "SHA256withRSA", Duration.ofSeconds(2)); } + private synchronized void provisionCaProfiles() { + if (caProfilesProvisioned) { + return; + } + BuiltInCertificateProfileCatalog.load(PkiTestRuntime.class.getClassLoader()).stream() + .filter(template -> template.definition().profileId().equals("root-ca") + || template.definition().profileId().equals("intermediate-ca")) + .forEach(template -> { + zeroecho.pki.api.profile.CertificateProfileRef reference = + profileService.importBuiltIn(template); + profileService.activateProfile(reference.profileId(), reference.profileVersion()); + }); + caProfilesProvisioned = true; + } + public CertificationRequestService certificationRequestService() { return certificationRequestService; }