diff --git a/pki/build.gradle b/pki/build.gradle index 2c7c5f0..a0bc769 100644 --- a/pki/build.gradle +++ b/pki/build.gradle @@ -8,6 +8,8 @@ group='org.egothor' dependencies { implementation 'org.apache.commons:commons-text' implementation 'commons-cli:commons-cli' + implementation platform('tools.jackson:jackson-bom:3.1.5') + implementation 'tools.jackson.core:jackson-core' implementation project(':lib') } diff --git a/pki/src/main/java/zeroecho/pki/api/ProfileService.java b/pki/src/main/java/zeroecho/pki/api/ProfileService.java index 377ef9a..5dee9d8 100644 --- a/pki/src/main/java/zeroecho/pki/api/ProfileService.java +++ b/pki/src/main/java/zeroecho/pki/api/ProfileService.java @@ -1,90 +1,49 @@ /******************************************************************************* * 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.api; +import java.io.InputStream; import java.util.List; +import java.util.Optional; -import zeroecho.pki.api.profile.CertificateProfile; -import zeroecho.pki.api.profile.ProfileQuery; +import zeroecho.pki.api.profile.ActiveCertificateProfile; +import zeroecho.pki.api.profile.BuiltInCertificateProfileTemplate; +import zeroecho.pki.api.profile.CertificateProfileRef; +import zeroecho.pki.api.profile.ImportedCertificateProfileVersion; /** - * Profile registry for credential issuance. + * Versioned certificate-profile import, activation, and lookup service. * - *

- * Profiles define required/optional attributes, validity limits, and other - * constraints used by policy and framework mapping. Profiles are referenced by - * id during issuance. - *

+ *

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

*/ public interface ProfileService { + /** Imports a bounded strict JSON profile document. */ + CertificateProfileRef importProfile(byte[] jsonDocument); /** - * Registers or updates a profile. - * - * @param profile profile definition - * @throws IllegalArgumentException if {@code profile} is null - * @throws PkiException if registration fails + * Imports a strict JSON profile document without taking ownership of the + * supplied stream. */ - void register(CertificateProfile profile); + CertificateProfileRef importProfile(InputStream jsonDocument); - /** - * Retrieves a profile by id. - * - * @param profileId profile id - * @return profile - * @throws IllegalArgumentException if {@code profileId} is null/blank - * @throws PkiException if not found or retrieval fails - */ - CertificateProfile get(String profileId); + /** Imports a built-in provisioning template through the same document path. */ + CertificateProfileRef importBuiltIn(BuiltInCertificateProfileTemplate template); - /** - * Lists profiles matching query constraints. - * - * @param query query constraints - * @return list of profiles - * @throws IllegalArgumentException if {@code query} is null - * @throws PkiException if listing fails - */ - List list(ProfileQuery query); + /** Explicitly activates one already imported version. */ + CertificateProfileRef activateProfile(String profileId, long profileVersion); - /** - * Retires a profile to prevent further issuance. - * - * @param profileId profile id - * @param reason non-empty reason suitable for audit - * @throws IllegalArgumentException if inputs are invalid - * @throws PkiException if retire fails - */ - void retire(String profileId, String reason); + /** Resolves and validates the exact active profile. */ + ActiveCertificateProfile requireActiveProfile(String profileId); + + /** Retrieves one immutable imported version for administration. */ + Optional getImportedVersion(String profileId, long profileVersion); + + /** Lists immutable imported versions for one logical profile. */ + List listImportedVersions(String profileId); + + /** Retrieves the current active reference without activating anything. */ + Optional getActiveReference(String profileId); } diff --git a/pki/src/main/java/zeroecho/pki/api/credential/CaProfileBinding.java b/pki/src/main/java/zeroecho/pki/api/credential/CaProfileBinding.java new file mode 100644 index 0000000..6de67d8 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/credential/CaProfileBinding.java @@ -0,0 +1,19 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.api.credential; + +/** + * Explicit CA policy identity for a root or intermediate credential. + * + * @param profileId existing CA policy identifier + */ +public record CaProfileBinding(String profileId) implements CredentialProfileBinding { + /** Creates a CA profile binding. */ + public CaProfileBinding { + if (profileId == null || profileId.isBlank()) { + throw new IllegalArgumentException("profileId must not be null/blank"); + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/api/credential/Credential.java b/pki/src/main/java/zeroecho/pki/api/credential/Credential.java index ab48830..5190e5a 100644 --- a/pki/src/main/java/zeroecho/pki/api/credential/Credential.java +++ b/pki/src/main/java/zeroecho/pki/api/credential/Credential.java @@ -64,7 +64,7 @@ import zeroecho.pki.api.attr.AttributeSet; * @param serialOrUniqueId framework-specific unique identifier (serial for * X.509) * @param publicKeyId stable identifier derived from the subject public key - * @param profileId profile governing issuance + * @param profileBinding closed profile identity governing issuance * @param status persisted issuance and inventory metadata; this value * is not sufficient for a trust decision because current * revocation state and evaluation time are external @@ -74,7 +74,8 @@ import zeroecho.pki.api.attr.AttributeSet; * @param attributes universal attribute set */ public record Credential(PkiId credentialId, FormatId formatId, IssuerRef issuerRef, SubjectRef subjectRef, - Validity validity, String serialOrUniqueId, PkiId publicKeyId, String profileId, CredentialStatus status, + Validity validity, String serialOrUniqueId, PkiId publicKeyId, CredentialProfileBinding profileBinding, + CredentialStatus status, EncodedObject encoded, AttributeSet attributes) { /** @@ -104,8 +105,8 @@ public record Credential(PkiId credentialId, FormatId formatId, IssuerRef issuer if (publicKeyId == null) { throw new IllegalArgumentException("publicKeyId must not be null"); } - if (profileId == null || profileId.isBlank()) { - throw new IllegalArgumentException("profileId must not be null/blank"); + if (profileBinding == null) { + throw new IllegalArgumentException("profileBinding must not be null"); } if (status == null) { throw new IllegalArgumentException("status must not be null"); @@ -117,4 +118,5 @@ public record Credential(PkiId credentialId, FormatId formatId, IssuerRef issuer throw new IllegalArgumentException("attributes must not be null"); } } + } diff --git a/pki/src/main/java/zeroecho/pki/api/credential/CredentialProfileBinding.java b/pki/src/main/java/zeroecho/pki/api/credential/CredentialProfileBinding.java new file mode 100644 index 0000000..ea9b5c1 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/credential/CredentialProfileBinding.java @@ -0,0 +1,11 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.api.credential; + +/** + * Closed immutable identity of the policy governing an issued credential. + */ +public sealed interface CredentialProfileBinding permits EndEntityProfileBinding, CaProfileBinding { +} diff --git a/pki/src/main/java/zeroecho/pki/api/credential/EndEntityProfileBinding.java b/pki/src/main/java/zeroecho/pki/api/credential/EndEntityProfileBinding.java new file mode 100644 index 0000000..e985fd7 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/credential/EndEntityProfileBinding.java @@ -0,0 +1,21 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.api.credential; + +import java.util.Objects; + +import zeroecho.pki.api.profile.CertificateProfileRef; + +/** + * Exact imported and activated profile identity for an end-entity credential. + * + * @param reference exact certificate-profile version + */ +public record EndEntityProfileBinding(CertificateProfileRef reference) implements CredentialProfileBinding { + /** Creates an end-entity binding. */ + public EndEntityProfileBinding { + Objects.requireNonNull(reference, "reference"); + } +} diff --git a/pki/src/main/java/zeroecho/pki/api/issuance/IssueEndEntityCommand.java b/pki/src/main/java/zeroecho/pki/api/issuance/IssueEndEntityCommand.java index 9e44e99..8b737bc 100644 --- a/pki/src/main/java/zeroecho/pki/api/issuance/IssueEndEntityCommand.java +++ b/pki/src/main/java/zeroecho/pki/api/issuance/IssueEndEntityCommand.java @@ -37,7 +37,6 @@ import java.util.Optional; import zeroecho.pki.api.PkiId; import zeroecho.pki.api.Validity; -import zeroecho.pki.api.attr.AttributeSet; import zeroecho.pki.api.request.ParsedCertificationRequest; /** @@ -49,11 +48,9 @@ import zeroecho.pki.api.request.ParsedCertificationRequest; * @param profileId profile id governing issuance * @param validityOverride optional requested validity override * (policy-validated) - * @param overrides additional universal attribute overrides - * (policy-validated; may be empty but not null) */ public record IssueEndEntityCommand(PkiId issuerCaId, ParsedCertificationRequest request, String profileId, - Optional validityOverride, AttributeSet overrides) { + Optional validityOverride) { /** * Creates an issuance command. @@ -74,8 +71,5 @@ public record IssueEndEntityCommand(PkiId issuerCaId, ParsedCertificationRequest if (validityOverride == null) { throw new IllegalArgumentException("validityOverride must not be null"); } - if (overrides == null) { - throw new IllegalArgumentException("overrides must not be null"); - } } } diff --git a/pki/src/main/java/zeroecho/pki/api/profile/ActiveCertificateProfile.java b/pki/src/main/java/zeroecho/pki/api/profile/ActiveCertificateProfile.java new file mode 100644 index 0000000..1035d77 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/profile/ActiveCertificateProfile.java @@ -0,0 +1,47 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.api.profile; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Objects; + +/** + * Validated active profile resolution result used by end-entity issuance. + * + * @param reference exact active version reference + * @param definition authoritative immutable definition + */ +public record ActiveCertificateProfile(CertificateProfileRef reference, + CertificateProfileDefinition definition) { + /** Creates an active profile result. */ + public ActiveCertificateProfile { + Objects.requireNonNull(reference, "reference"); + Objects.requireNonNull(definition, "definition"); + if (!reference.profileId().equals(definition.profileId()) + || reference.profileVersion() != definition.profileVersion() + || !MessageDigest.isEqual(reference.canonicalSha256(), canonicalHash(definition))) { + throw new IllegalArgumentException("active profile identity mismatch"); + } + } + + /** + * Returns the deterministic runtime projection of the authoritative definition. + * + * @return runtime profile + */ + public CertificateProfile profile() { + return CertificateProfile.fromDefinition(definition); + } + + private static byte[] canonicalHash(CertificateProfileDefinition definition) { + try { + return MessageDigest.getInstance("SHA-256") + .digest(CertificateProfileDocumentCodec.writeCanonical(definition)); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 unavailable", impossible); + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/api/profile/BuiltInCertificateProfileCatalog.java b/pki/src/main/java/zeroecho/pki/api/profile/BuiltInCertificateProfileCatalog.java new file mode 100644 index 0000000..5269ef3 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/profile/BuiltInCertificateProfileCatalog.java @@ -0,0 +1,431 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.api.profile; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.nio.ByteBuffer; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonParser; +import tools.jackson.core.JsonToken; +import tools.jackson.core.ObjectReadContext; +import tools.jackson.core.StreamReadConstraints; +import tools.jackson.core.StreamReadFeature; +import tools.jackson.core.exc.StreamConstraintsException; +import tools.jackson.core.json.JsonFactory; +import tools.jackson.core.json.JsonFactoryBuilder; +import tools.jackson.core.json.JsonReadFeature; +import zeroecho.pki.api.PkiException; + +/** + * Loader for the fixed built-in end-entity profile resource catalogue. + * + *

+ * The catalogue is stateless and thread-safe. Every load reparses the packaged + * manifest and profiles, verifies canonical resource bytes, and computes + * SHA-256 over canonical JSON. Built-in templates are provisioning inputs, not + * implicit runtime defaults; this loader performs no persistence or activation. + *

+ * + *

+ * Loading takes linear time in the total bounded resource size and retains one + * immutable canonical copy per returned template. + *

+ */ +@SuppressWarnings({ "PMD.AvoidUncheckedExceptionsInSignatures", "PMD.PreserveStackTrace" }) +public final class BuiltInCertificateProfileCatalog { + + /** Fixed packaged catalogue manifest resource. */ + public static final String MANIFEST_RESOURCE = + "zeroecho/pki/profiles/v1/catalog.json"; + + private static final String PROFILE_RESOURCE_ROOT = "zeroecho/pki/profiles/v1/"; + private static final int MAXIMUM_RESOURCE_BYTES = + CertificateProfileDocumentCodec.MAXIMUM_DOCUMENT_BYTES; + private static final int MAXIMUM_MANIFEST_ENTRIES = 128; + private static final int MAXIMUM_MANIFEST_DEPTH = 16; + private static final int MAXIMUM_MANIFEST_STRING_BYTES = 4_096; + private static final int READ_BUFFER_BYTES = 8_192; + private static final int CATALOGUE_SCHEMA_VERSION = 1; + private static final int EXPECTED_PROFILE_VERSION = 1; + private static final String FIELD_SCHEMA_VERSION = "schemaVersion"; + private static final String FIELD_PROFILES = "profiles"; + private static final String FIELD_RESOURCE = "resource"; + 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"); + private BuiltInCertificateProfileCatalog() { + } + + /** + * Loads and validates the packaged built-in profile templates. + * + * @param classLoader explicit resource-loading boundary + * @return immutable templates in manifest order + * @throws PkiException if the manifest or any profile resource is missing, + * duplicated, malformed, noncanonical, or inconsistent + */ + public static List load(ClassLoader classLoader) { + requireClassLoader(classLoader); + byte[] manifestBytes = readUniqueResource(classLoader, MANIFEST_RESOURCE); + List resourceNames = ManifestParser.parse(manifestBytes); + CatalogueAccumulator accumulator = new CatalogueAccumulator(resourceNames.size()); + for (String resourceName : resourceNames) { + accumulator.add(loadTemplate(classLoader, resourceName)); + } + return accumulator.finish(); + } + + private static void requireClassLoader(ClassLoader classLoader) { + if (classLoader == null) { + throw failure("RESOURCE_ACCESS_INVALID"); + } + } + + private static BuiltInCertificateProfileTemplate loadTemplate(ClassLoader classLoader, + String resourceName) { + byte[] resourceBytes = readUniqueResource(classLoader, resourceName); + CertificateProfileDefinition definition = parseProfile(resourceBytes); + byte[] canonicalJson = canonicalize(definition); + requireCanonicalResource(resourceBytes, canonicalJson); + requireCanonicalRoundTrip(definition, canonicalJson); + return new BuiltInCertificateProfileTemplate(definition, canonicalJson, + sha256(canonicalJson), resourceName); + } + + private static void requireCanonicalResource(byte[] resourceBytes, byte[] canonicalJson) { + if (!Arrays.equals(resourceBytes, canonicalJson)) { + throw failure("PROFILE_RESOURCE_NONCANONICAL"); + } + } + + private static void requireCanonicalRoundTrip(CertificateProfileDefinition definition, + byte[] canonicalJson) { + if (!definition.equals(parseProfile(canonicalJson))) { + throw failure("PROFILE_CANONICALIZATION_INVALID"); + } + } + + private static void validateResourceName(String resourceName) { + if (resourceName == null || resourceName.isEmpty() + || resourceName.startsWith("/") || resourceName.contains("..") + || resourceName.indexOf('\\') >= 0 || resourceName.indexOf(':') >= 0 + || !resourceName.startsWith(PROFILE_RESOURCE_ROOT) + || resourceName.length() <= PROFILE_RESOURCE_ROOT.length()) { + throw failure("MANIFEST_RESOURCE_INVALID"); + } + for (int index = 0; index < resourceName.length(); index++) { + if (Character.isISOControl(resourceName.charAt(index))) { + throw failure("MANIFEST_RESOURCE_INVALID"); + } + } + } + + private static byte[] readUniqueResource(ClassLoader classLoader, String resourceName) { + try { + Enumeration matches = classLoader.getResources(resourceName); + if (!matches.hasMoreElements()) { + throw failure("RESOURCE_MISSING"); + } + URL resource = matches.nextElement(); + if (matches.hasMoreElements()) { + throw failure("RESOURCE_DUPLICATE"); + } + try (InputStream input = resource.openStream()) { + return readBounded(input); + } + } catch (IOException ex) { + throw failure("RESOURCE_READ_FAILED"); + } + } + + private static byte[] readBounded(InputStream input) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream( + Math.min(READ_BUFFER_BYTES, MAXIMUM_RESOURCE_BYTES)); + byte[] buffer = new byte[READ_BUFFER_BYTES]; + int total = 0; + while (true) { + int remaining = MAXIMUM_RESOURCE_BYTES + 1 - total; + int count = input.read(buffer, 0, Math.min(buffer.length, remaining)); + if (count < 0) { + break; + } + if (count == 0) { + int single = input.read(); + if (single < 0) { + break; + } + output.write(single); + total++; + } else { + output.write(buffer, 0, count); + total += count; + } + if (total > MAXIMUM_RESOURCE_BYTES) { + throw failure("RESOURCE_TOO_LARGE"); + } + } + byte[] result = output.toByteArray(); + if (result.length == 0) { + throw failure("RESOURCE_EMPTY"); + } + return result; + } + + private static CertificateProfileDefinition parseProfile(byte[] resourceBytes) { + try { + return CertificateProfileDocumentCodec.parse(resourceBytes); + } catch (PkiException ex) { + throw failure("PROFILE_RESOURCE_INVALID"); + } + } + + private static byte[] canonicalize(CertificateProfileDefinition definition) { + try { + return CertificateProfileDocumentCodec.writeCanonical(definition); + } catch (PkiException ex) { + throw failure("PROFILE_CANONICALIZATION_INVALID"); + } + } + + private static byte[] sha256(byte[] canonicalJson) { + try { + return MessageDigest.getInstance("SHA-256").digest(canonicalJson); + } catch (NoSuchAlgorithmException ex) { + throw failure("PROFILE_HASH_UNAVAILABLE"); + } + } + + private static PkiException failure(String code) { + return new PkiException(ERROR_PREFIX + code); + } + + /** Collects and validates one bounded catalogue load. */ + private static final class CatalogueAccumulator { + + private final List templates; + private final Set identities = new HashSet<>(); + private final Set hashes = new HashSet<>(); + private final Set profileIds = new HashSet<>(); + + private CatalogueAccumulator(int expectedSize) { + templates = new ArrayList<>(expectedSize); + } + + private void add(BuiltInCertificateProfileTemplate template) { + CertificateProfileDefinition definition = template.definition(); + if (!hashes.add(ByteBuffer.wrap(template.canonicalSha256()))) { + throw failure("DUPLICATE_PROFILE_HASH"); + } + if (!identities.add(new ProfileIdentity(definition.profileId(), + definition.profileVersion()))) { + throw failure("DUPLICATE_PROFILE_IDENTITY"); + } + if (definition.profileVersion() != EXPECTED_PROFILE_VERSION + || !profileIds.add(definition.profileId())) { + throw failure("BUILT_IN_PROFILE_SET_INVALID"); + } + templates.add(template); + } + + private List finish() { + if (templates.size() != EXPECTED_PROFILE_IDS.size() + || !profileIds.equals(EXPECTED_PROFILE_IDS)) { + throw failure("BUILT_IN_PROFILE_SET_INVALID"); + } + return List.copyOf(templates); + } + } + + /** Strict Jackson Core streaming parser for the fixed catalogue manifest. */ + private static final class ManifestParser { + + private static final JsonFactory JSON_FACTORY = createJsonFactory(); + + private static List parse(byte[] encoded) { + if (encoded.length == 0) { + throw failure("MANIFEST_EMPTY"); + } + try (JsonParser parser = JSON_FACTORY.createParser(ObjectReadContext.empty(), + encoded, 0, encoded.length)) { + ManifestDocument document = readDocument(parser); + requireEndOfInput(parser); + return validate(document); + } catch (StreamConstraintsException ex) { + throw failure("MANIFEST_LIMIT_EXCEEDED"); + } catch (JacksonException ex) { + throw failure(isDuplicateFailure(ex) + ? "MANIFEST_DUPLICATE_FIELD" : "MANIFEST_MALFORMED"); + } + } + + private static JsonFactory createJsonFactory() { + StreamReadConstraints constraints = StreamReadConstraints.builder() + .maxNestingDepth(MAXIMUM_MANIFEST_DEPTH) + .maxDocumentLength(MAXIMUM_RESOURCE_BYTES) + .maxTokenCount(1_024) + .maxNumberLength(10) + .maxStringLength(MAXIMUM_MANIFEST_STRING_BYTES) + .maxNameLength(32) + .build(); + JsonFactoryBuilder builder = JsonFactory.builder() + .streamReadConstraints(constraints) + .enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION) + .disable(StreamReadFeature.AUTO_CLOSE_SOURCE); + for (JsonReadFeature feature : JsonReadFeature.values()) { + builder.disable(feature); + } + return builder.build(); + } + + private static ManifestDocument readDocument(JsonParser parser) throws JacksonException { + requireToken(parser.nextToken(), JsonToken.START_OBJECT); + boolean schemaSeen = false; + boolean profilesSeen = false; + int schemaVersion = 0; + List profiles = null; + while (parser.nextToken() != JsonToken.END_OBJECT) { + requireToken(parser.currentToken(), JsonToken.PROPERTY_NAME); + String field = parser.currentName(); + requireValue(parser); + switch (field) { + case FIELD_SCHEMA_VERSION -> { + rejectDuplicate(schemaSeen); + schemaSeen = true; + schemaVersion = readSchemaVersion(parser); + } + case FIELD_PROFILES -> { + rejectDuplicate(profilesSeen); + profilesSeen = true; + profiles = readProfiles(parser); + } + default -> throw failure("MANIFEST_UNKNOWN_FIELD"); + } + } + if (!schemaSeen || !profilesSeen) { + throw failure("MANIFEST_MISSING_FIELD"); + } + return new ManifestDocument(schemaVersion, profiles); + } + + private static List validate(ManifestDocument document) { + if (document.schemaVersion() != CATALOGUE_SCHEMA_VERSION) { + throw failure("MANIFEST_VERSION_UNSUPPORTED"); + } + return document.profiles(); + } + + private static int readSchemaVersion(JsonParser parser) throws JacksonException { + requireToken(parser.currentToken(), JsonToken.VALUE_NUMBER_INT); + try { + return parser.getIntValue(); + } catch (JacksonException ex) { + throw failure("MANIFEST_VERSION_UNSUPPORTED"); + } + } + + private static List readProfiles(JsonParser parser) throws JacksonException { + requireToken(parser.currentToken(), JsonToken.START_ARRAY); + List resources = new ArrayList<>(); + Set unique = new LinkedHashSet<>(); + while (parser.nextToken() != JsonToken.END_ARRAY) { + checkEntryLimit(resources.size()); + String resourceName = readEntry(parser); + validateResourceName(resourceName); + if (!unique.add(resourceName)) { + throw failure("MANIFEST_DUPLICATE_RESOURCE"); + } + resources.add(resourceName); + } + if (resources.isEmpty()) { + throw failure("MANIFEST_EMPTY"); + } + return List.copyOf(resources); + } + + private static void checkEntryLimit(int currentSize) { + if (currentSize >= MAXIMUM_MANIFEST_ENTRIES) { + throw failure("MANIFEST_LIMIT_EXCEEDED"); + } + } + + private static String readEntry(JsonParser parser) throws JacksonException { + requireToken(parser.currentToken(), JsonToken.START_OBJECT); + String resourceName = null; + boolean resourceSeen = false; + while (parser.nextToken() != JsonToken.END_OBJECT) { + requireToken(parser.currentToken(), JsonToken.PROPERTY_NAME); + String field = parser.currentName(); + requireValue(parser); + if (!FIELD_RESOURCE.equals(field)) { + throw failure("MANIFEST_UNKNOWN_FIELD"); + } + rejectDuplicate(resourceSeen); + resourceSeen = true; + requireToken(parser.currentToken(), JsonToken.VALUE_STRING); + resourceName = parser.getString(); + } + if (!resourceSeen) { + throw failure("MANIFEST_MISSING_FIELD"); + } + return resourceName; + } + + private static void rejectDuplicate(boolean alreadySeen) { + if (alreadySeen) { + throw failure("MANIFEST_DUPLICATE_FIELD"); + } + } + + private static void requireEndOfInput(JsonParser parser) throws JacksonException { + if (parser.nextToken() != null) { + throw failure("MANIFEST_MALFORMED"); + } + } + + private static void requireToken(JsonToken actual, JsonToken expected) { + if (actual == null) { + throw failure("MANIFEST_MALFORMED"); + } + if (actual != expected) { + throw failure("MANIFEST_WRONG_TYPE"); + } + } + + private static void requireValue(JsonParser parser) throws JacksonException { + JsonToken token = parser.nextToken(); + if (token == null) { + throw failure("MANIFEST_MALFORMED"); + } + if (token == JsonToken.VALUE_NULL) { + throw failure("MANIFEST_WRONG_TYPE"); + } + } + + private static boolean isDuplicateFailure(JacksonException exception) { + String message = exception.getMessage(); + return message != null && message.contains("Duplicate"); + } + + private record ManifestDocument(int schemaVersion, List profiles) { + } + } + + private record ProfileIdentity(String profileId, long profileVersion) { + } +} diff --git a/pki/src/main/java/zeroecho/pki/api/profile/BuiltInCertificateProfileTemplate.java b/pki/src/main/java/zeroecho/pki/api/profile/BuiltInCertificateProfileTemplate.java new file mode 100644 index 0000000..d3b3091 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/profile/BuiltInCertificateProfileTemplate.java @@ -0,0 +1,105 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.api.profile; + +import java.util.Arrays; +import java.util.Objects; + +/** + * Immutable built-in certificate-profile provisioning template. + * + *

+ * A template is configuration input only. Loading it neither persists nor + * activates a profile. The canonical JSON and its SHA-256 hash are defensive + * snapshots suitable for deterministic later provisioning and audit. The hash + * is an identity and audit aid, not a signature or trust anchor. + *

+ */ +public final class BuiltInCertificateProfileTemplate { + + private static final int SHA_256_BYTES = 32; + + private final CertificateProfileDefinition definition; + private final byte[] canonicalJson; + private final byte[] canonicalSha256; + private final String resourceName; + + /* default */ BuiltInCertificateProfileTemplate(CertificateProfileDefinition definition, byte[] canonicalJson, + byte[] canonicalSha256, String resourceName) { + this.definition = Objects.requireNonNull(definition, "definition"); + this.canonicalJson = Objects.requireNonNull(canonicalJson, "canonicalJson").clone(); + this.canonicalSha256 = Objects.requireNonNull(canonicalSha256, "canonicalSha256").clone(); + this.resourceName = Objects.requireNonNull(resourceName, "resourceName"); + if (this.canonicalJson.length == 0) { + throw new IllegalArgumentException("canonicalJson must not be empty"); + } + if (this.canonicalSha256.length != SHA_256_BYTES) { + throw new IllegalArgumentException("canonicalSha256 must contain 32 bytes"); + } + if (resourceName.isBlank()) { + throw new IllegalArgumentException("resourceName must not be blank"); + } + } + + /** + * Returns the validated immutable profile definition. + * + * @return profile definition + */ + public CertificateProfileDefinition definition() { + return definition; + } + + /** + * Returns a copy of the canonical ZeroEcho profile JSON. + * + * @return newly allocated canonical JSON bytes + */ + public byte[] canonicalJson() { + return canonicalJson.clone(); + } + + /** + * Returns a copy of the SHA-256 hash of the canonical JSON. + * + * @return newly allocated 32-byte hash + */ + public byte[] canonicalSha256() { + return canonicalSha256.clone(); + } + + /** + * Returns the validated classpath resource name. + * + * @return resource name + */ + public String resourceName() { + return resourceName; + } + + @Override + public boolean equals(Object other) { + return this == other + || other instanceof BuiltInCertificateProfileTemplate template + && definition.equals(template.definition) + && Arrays.equals(canonicalJson, template.canonicalJson) + && Arrays.equals(canonicalSha256, template.canonicalSha256) + && resourceName.equals(template.resourceName); + } + + @Override + public int hashCode() { + int result = Objects.hash(definition, resourceName); + result = 31 * result + Arrays.hashCode(canonicalJson); + return 31 * result + Arrays.hashCode(canonicalSha256); + } + + @Override + public String toString() { + return "BuiltInCertificateProfileTemplate[profileId=" + definition.profileId() + + ", profileVersion=" + definition.profileVersion() + + ", resourceName=" + resourceName + "]"; + } +} 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 dec8e47..006d7ca 100644 --- a/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfile.java +++ b/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfile.java @@ -33,22 +33,15 @@ ******************************************************************************/ package zeroecho.pki.api.profile; -import java.time.Duration; -import java.util.List; -import java.util.Optional; - import zeroecho.pki.api.FormatId; -import zeroecho.pki.api.attr.AttributeId; /** * Defines issuance constraints and mapping hints for a class of credentials. * *

* A profile is referenced by {@code profileId} during issuance. It defines - * which universal attributes are required or allowed, and provides limits such - * as maximum validity. Framework backends may use the profile as a source of - * mapping hints when translating universal attributes into framework-specific - * fields/extensions. + * the complete deny-by-default identity and extension policy for an end-entity + * credential. Requester fields absent from the profile are forbidden. *

* *

@@ -58,14 +51,10 @@ import zeroecho.pki.api.attr.AttributeId; * @param profileId stable profile identifier * @param formatId framework/format supported by the profile * @param displayName human-readable name - * @param requiredAttributes list of required attribute identifiers - * @param optionalAttributes list of optional attribute identifiers - * @param maxValidity optional maximum validity allowed by the profile - * @param active whether the profile is active for issuance + * @param leafPolicy complete end-entity identity and extension policy */ public record CertificateProfile(String profileId, FormatId formatId, String displayName, - List requiredAttributes, List optionalAttributes, Optional maxValidity, - boolean active) { + LeafCertificatePolicy leafPolicy) { /** * Creates a certificate profile. @@ -83,14 +72,22 @@ public record CertificateProfile(String profileId, FormatId formatId, String dis if (displayName == null || displayName.isBlank()) { throw new IllegalArgumentException("displayName must not be null/blank"); } - if (requiredAttributes == null) { - throw new IllegalArgumentException("requiredAttributes must not be null"); - } - if (optionalAttributes == null) { - throw new IllegalArgumentException("optionalAttributes must not be null"); - } - if (maxValidity == null) { - throw new IllegalArgumentException("maxValidity must not be null"); + if (leafPolicy == null) { + throw new IllegalArgumentException("leafPolicy must not be null"); } } + + /** + * Creates the deterministic runtime projection of a validated definition. + * + * @param definition authoritative profile definition + * @return runtime profile projection + */ + public static CertificateProfile fromDefinition(CertificateProfileDefinition definition) { + if (definition == null) { + throw new IllegalArgumentException("definition must not be null"); + } + return new CertificateProfile(definition.profileId(), definition.formatId(), definition.displayName(), + 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 new file mode 100644 index 0000000..495c52b --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfileDefinition.java @@ -0,0 +1,53 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.api.profile; + +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. + *

+ * + * @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 + */ +public record CertificateProfileDefinition(String profileId, long profileVersion, FormatId formatId, + String displayName, LeafCertificatePolicy leafPolicy) { + + /** Current certificate-profile document schema version. */ + public static final int SCHEMA_VERSION = 1; + + /** + * Creates a certificate-profile definition. + * + * @throws IllegalArgumentException if a required value is absent or the + * profile version is not positive + */ + public CertificateProfileDefinition { + if (profileId == null || profileId.isBlank()) { + throw new IllegalArgumentException("profileId must not be null/blank"); + } + if (profileVersion <= 0) { + throw new IllegalArgumentException("profileVersion must be positive"); + } + if (formatId == null) { + throw new IllegalArgumentException("formatId must not be null"); + } + 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"); + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfileDocumentCodec.java b/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfileDocumentCodec.java new file mode 100644 index 0000000..ff5c11a --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfileDocumentCodec.java @@ -0,0 +1,996 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.api.profile; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.DateTimeException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.EnumSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonGenerator; +import tools.jackson.core.JsonParser; +import tools.jackson.core.JsonToken; +import tools.jackson.core.ObjectReadContext; +import tools.jackson.core.ObjectWriteContext; +import tools.jackson.core.StreamReadConstraints; +import tools.jackson.core.StreamReadFeature; +import tools.jackson.core.exc.StreamConstraintsException; +import tools.jackson.core.json.JsonFactory; +import tools.jackson.core.json.JsonFactoryBuilder; +import tools.jackson.core.json.JsonReadFeature; +import zeroecho.pki.api.FormatId; +import zeroecho.pki.api.PkiException; + +/** + * Strict JSON parser and canonical writer for version 1 certificate-profile + * documents. + * + *

+ * The codec is stateless and thread-safe. It uses only Jackson Core streaming + * tokens and creates the existing typed policy model directly. + *

+ */ +@SuppressWarnings({ "PMD.AvoidDuplicateLiterals", "PMD.AvoidInstantiatingObjectsInLoops", + "PMD.AvoidUncheckedExceptionsInSignatures", "PMD.CyclomaticComplexity", + "PMD.PreserveStackTrace" }) +public final class CertificateProfileDocumentCodec { + + /** Maximum accepted encoded document size. */ + public static final int MAXIMUM_DOCUMENT_BYTES = 262_144; + + private static final int MAXIMUM_DEPTH = 16; + private static final int MAXIMUM_ARRAY_ELEMENTS = 128; + private static final int MAXIMUM_STRING_UTF8_BYTES = 4_096; + private static final int MAXIMUM_PROFILE_ID_UTF8_BYTES = 128; + private static final int MAXIMUM_DISPLAY_NAME_UTF8_BYTES = 256; + private static final int MAXIMUM_FORMAT_ID_UTF8_BYTES = 128; + private static final int MAXIMUM_URI_SCHEME_ASCII_BYTES = 32; + private static final int READ_BUFFER_BYTES = 8_192; + private static final int MAXIMUM_ASCII_VALUE = 127; + + private static final String PREFIX = "Certificate profile document rejected: code="; + private static final String REQUESTER_SOURCE = "REQUESTER"; + private static final String PROFILE_FIXED_SOURCE = "PROFILE_FIXED"; + private static final JsonFactory JSON_FACTORY = createJsonFactory(); + + private CertificateProfileDocumentCodec() { + } + + /** + * Parses an encoded certificate-profile document. + * + * @param encodedDocument exact UTF-8 JSON bytes + * @return immutable profile definition + * @throws PkiException if the document is malformed, exceeds a bound, or + * violates the profile schema or policy invariants + */ + public static CertificateProfileDefinition parse(byte[] encodedDocument) { + if (encodedDocument == null) { + throw failure("WRONG_TYPE", "$"); + } + if (encodedDocument.length > MAXIMUM_DOCUMENT_BYTES) { + throw failure("PROFILE_DOCUMENT_TOO_LARGE", "$"); + } + if (encodedDocument.length == 0 || hasUtf8Bom(encodedDocument)) { + throw failure("MALFORMED_JSON", "$"); + } + preflight(encodedDocument); + try (JsonParser parser = JSON_FACTORY.createParser(ObjectReadContext.empty(), + encodedDocument, 0, encodedDocument.length)) { + CertificateProfileDefinition definition = parseDocument(parser); + if (parser.nextToken() != null) { + throw failure("MALFORMED_JSON", "$"); + } + return definition; + } catch (StreamConstraintsException ex) { + throw failure("LIMIT_EXCEEDED", "$"); + } catch (JacksonException ex) { + throw failure(isDuplicateFailure(ex) ? "DUPLICATE_FIELD" : "MALFORMED_JSON", "$"); + } + } + + /** + * Reads and parses an encoded certificate-profile document. + * + *

+ * This method does not close {@code input}; ownership remains with the + * caller. It reads at most one byte beyond the document limit in order to + * detect oversize input without unbounded buffering. + *

+ * + * @param input caller-owned stream containing UTF-8 JSON + * @return immutable profile definition + * @throws PkiException if reading fails or the document is rejected + */ + public static CertificateProfileDefinition parse(InputStream input) { + if (input == null) { + throw failure("WRONG_TYPE", "$"); + } + ByteArrayOutputStream output = new ByteArrayOutputStream( + Math.min(READ_BUFFER_BYTES, MAXIMUM_DOCUMENT_BYTES)); + byte[] buffer = new byte[READ_BUFFER_BYTES]; + int total = 0; + try { + while (true) { + int remaining = MAXIMUM_DOCUMENT_BYTES + 1 - total; + int count = input.read(buffer, 0, Math.min(buffer.length, remaining)); + if (count < 0) { + break; + } + if (count == 0) { + int single = input.read(); + if (single < 0) { + break; + } + output.write(single); + total++; + } else { + output.write(buffer, 0, count); + total += count; + } + if (total > MAXIMUM_DOCUMENT_BYTES) { + throw failure("PROFILE_DOCUMENT_TOO_LARGE", "$"); + } + } + } catch (IOException ex) { + throw failure("PROFILE_DOCUMENT_READ_FAILED", "$"); + } + return parse(output.toByteArray()); + } + + /** + * Writes the canonical UTF-8 JSON representation. + * + * @param definition definition to encode + * @return newly allocated canonical JSON bytes without a BOM or trailing + * whitespace + * @throws PkiException if the definition cannot be represented by schema + * version 1 + */ + public static byte[] writeCanonical(CertificateProfileDefinition definition) { + validateDefinition(definition); + ByteArrayOutputStream output = new ByteArrayOutputStream(2_048); + try (JsonGenerator generator = JSON_FACTORY.createGenerator(ObjectWriteContext.empty(), + output)) { + writeDocument(generator, definition); + } catch (JacksonException ex) { + throw failure("CANONICALIZATION_FAILED", "$"); + } + byte[] result = output.toByteArray(); + if (result.length > MAXIMUM_DOCUMENT_BYTES) { + throw failure("CANONICALIZATION_FAILED", "$"); + } + return result; + } + + private static JsonFactory createJsonFactory() { + StreamReadConstraints constraints = StreamReadConstraints.builder() + .maxNestingDepth(MAXIMUM_DEPTH) + .maxDocumentLength(MAXIMUM_DOCUMENT_BYTES) + .maxTokenCount(8_192) + .maxNumberLength(20) + .maxStringLength(MAXIMUM_STRING_UTF8_BYTES) + .maxNameLength(64) + .build(); + JsonFactoryBuilder builder = JsonFactory.builder() + .streamReadConstraints(constraints) + .enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION) + .disable(StreamReadFeature.AUTO_CLOSE_SOURCE); + for (JsonReadFeature feature : JsonReadFeature.values()) { + builder.disable(feature); + } + return builder.build(); + } + + private static void preflight(byte[] encodedDocument) { + try (JsonParser parser = JSON_FACTORY.createParser(ObjectReadContext.empty(), + encodedDocument, 0, encodedDocument.length)) { + while (parser.nextToken() != null) { + parser.finishToken(); + } + } catch (StreamConstraintsException ex) { + throw failure("LIMIT_EXCEEDED", "$"); + } catch (JacksonException ex) { + throw failure(isDuplicateFailure(ex) ? "DUPLICATE_FIELD" : "MALFORMED_JSON", "$"); + } + } + + 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; + 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", "$.?"); + } + } + requireAll(seen, 9, "$"); + if (schemaVersion != CertificateProfileDefinition.SCHEMA_VERSION) { + throw failure("SCHEMA_VERSION_UNSUPPORTED", "$.schemaVersion"); + } + if (profileVersion <= 0) { + throw failure("PROFILE_VERSION_INVALID", "$.profileVersion"); + } + validateProfileString(profileId, MAXIMUM_PROFILE_ID_UTF8_BYTES, "$.profileId", + "TOKEN_INVALID"); + validateProfileString(formatId, MAXIMUM_FORMAT_ID_UTF8_BYTES, "$.formatId", + "TOKEN_INVALID"); + validateProfileString(displayName, MAXIMUM_DISPLAY_NAME_UTF8_BYTES, "$.displayName", + "TOKEN_INVALID"); + return constructDefinition(profileId, profileVersion, formatId, displayName, maximumValidity, + subject, san, leaf); + } + + private static CertificateProfileDefinition constructDefinition(String profileId, long profileVersion, + String formatId, String displayName, Duration maximumValidity, SubjectSection subject, + SanSection san, LeafSection leaf) { + 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); + } catch (IllegalArgumentException | ArithmeticException ex) { + throw failure("SEMANTIC_INVALID", "$"); + } + } + + private static SubjectSection readSubject(JsonParser parser, String path) throws JacksonException { + requireToken(parser.currentToken(), JsonToken.START_OBJECT, path); + long seen = 0; + boolean allowEmpty = false; + List rules = 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 "allowEmpty" -> { + seen = mark(seen, 0, path + ".allowEmpty"); + allowEmpty = readBoolean(parser, path + ".allowEmpty"); + } + case "rules" -> { + seen = mark(seen, 1, path + ".rules"); + rules = readSubjectRules(parser, path + ".rules"); + } + default -> throw failure("UNKNOWN_FIELD", path + ".?"); + } + } + requireAll(seen, 2, path); + return new SubjectSection(allowEmpty, rules); + } + + private static List readSubjectRules(JsonParser parser, String path) throws JacksonException { + requireToken(parser.currentToken(), JsonToken.START_ARRAY, path); + List rules = new ArrayList<>(); + while (parser.nextToken() != JsonToken.END_ARRAY) { + checkArrayBound(rules.size(), path); + rules.add(readSubjectRule(parser, path + "[" + rules.size() + "]")); + } + return List.copyOf(rules); + } + + private static SubjectRdnRule readSubjectRule(JsonParser parser, String path) throws JacksonException { + requireToken(parser.currentToken(), JsonToken.START_OBJECT, path); + long seen = 0; + String oid = null; + String source = null; + int minimum = 0; + int maximum = 0; + int maximumUtf8 = 0; + String fixedValue = 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 "oid" -> { + seen = mark(seen, 0, path + ".oid"); + oid = readString(parser, path + ".oid"); + } + case "source" -> { + seen = mark(seen, 1, path + ".source"); + source = readString(parser, path + ".source"); + } + case "minimumOccurrences" -> { + seen = mark(seen, 2, path + ".minimumOccurrences"); + minimum = readInt(parser, path + ".minimumOccurrences"); + } + case "maximumOccurrences" -> { + seen = mark(seen, 3, path + ".maximumOccurrences"); + maximum = readInt(parser, path + ".maximumOccurrences"); + } + case "maximumUtf8Bytes" -> { + seen = mark(seen, 4, path + ".maximumUtf8Bytes"); + maximumUtf8 = readInt(parser, path + ".maximumUtf8Bytes"); + } + case "fixedValue" -> { + seen = mark(seen, 5, path + ".fixedValue"); + fixedValue = readString(parser, path + ".fixedValue"); + } + default -> throw failure("UNKNOWN_FIELD", path + ".?"); + } + } + requireAll(seen, 5, path); + SubjectRdnType type = parseRdnType(oid, path + ".oid"); + boolean fixed = parseSubjectSource(source, path + ".source"); + if (fixed != isSeen(seen, 5)) { + throw failure(fixed ? "MISSING_FIELD" : "UNKNOWN_FIELD", path + ".fixedValue"); + } + try { + SubjectRdnRule rule = new SubjectRdnRule(type, minimum, maximum, maximumUtf8, + fixed ? Optional.of(fixedValue) : Optional.empty(), !fixed); + if (fixed && !fixedValue.equals(rule.fixedValue().orElseThrow())) { + throw failure("TOKEN_INVALID", path + ".fixedValue"); + } + return rule; + } catch (IllegalArgumentException ex) { + throw failure("SEMANTIC_INVALID", path); + } + } + + private static SanSection readSan(JsonParser parser, String path) throws JacksonException { + requireToken(parser.currentToken(), JsonToken.START_OBJECT, path); + long seen = 0; + int minimum = 0; + int maximum = 0; + boolean serviceRequired = false; + boolean emailRequired = false; + boolean critical = false; + SanRules rules = 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 "minimumTotal" -> { + seen = mark(seen, 0, path + ".minimumTotal"); + minimum = readInt(parser, path + ".minimumTotal"); + } + case "maximumTotal" -> { + seen = mark(seen, 1, path + ".maximumTotal"); + maximum = readInt(parser, path + ".maximumTotal"); + } + case "serviceIdentityRequired" -> { + seen = mark(seen, 2, path + ".serviceIdentityRequired"); + serviceRequired = readBoolean(parser, path + ".serviceIdentityRequired"); + } + case "emailIdentityRequired" -> { + seen = mark(seen, 3, path + ".emailIdentityRequired"); + emailRequired = readBoolean(parser, path + ".emailIdentityRequired"); + } + case "criticalWhenSubjectNonEmpty" -> { + seen = mark(seen, 4, path + ".criticalWhenSubjectNonEmpty"); + critical = readBoolean(parser, path + ".criticalWhenSubjectNonEmpty"); + } + case "rules" -> { + seen = mark(seen, 5, path + ".rules"); + rules = readSanRules(parser, path + ".rules"); + } + default -> throw failure("UNKNOWN_FIELD", path + ".?"); + } + } + requireAll(seen, 6, path); + return new SanSection(minimum, maximum, serviceRequired, emailRequired, critical, + rules.rules(), rules.wildcardAllowed(), rules.allowedSchemes()); + } + + private static SanRules readSanRules(JsonParser parser, String path) throws JacksonException { + requireToken(parser.currentToken(), JsonToken.START_ARRAY, path); + List rules = new ArrayList<>(); + boolean wildcardAllowed = false; + Set schemes = Set.of(); + while (parser.nextToken() != JsonToken.END_ARRAY) { + checkArrayBound(rules.size(), path); + ParsedSanRule rule = readSanRule(parser, path + "[" + rules.size() + "]"); + rules.add(rule.rule()); + if (rule.rule().type() == SubjectAlternativeNameType.DNS_NAME) { + wildcardAllowed = rule.wildcardAllowed(); + } else if (rule.rule().type() == SubjectAlternativeNameType.URI) { + schemes = rule.allowedSchemes(); + } + } + return new SanRules(List.copyOf(rules), wildcardAllowed, schemes); + } + + private static ParsedSanRule readSanRule(JsonParser parser, String path) throws JacksonException { + requireToken(parser.currentToken(), JsonToken.START_OBJECT, path); + long seen = 0; + String typeValue = null; + int minimum = 0; + int maximum = 0; + boolean wildcard = false; + boolean ipv4 = false; + boolean ipv6 = false; + Set schemes = 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 "type" -> { + seen = mark(seen, 0, path + ".type"); + typeValue = readString(parser, path + ".type"); + } + case "minimumOccurrences" -> { + seen = mark(seen, 1, path + ".minimumOccurrences"); + minimum = readInt(parser, path + ".minimumOccurrences"); + } + case "maximumOccurrences" -> { + seen = mark(seen, 2, path + ".maximumOccurrences"); + maximum = readInt(parser, path + ".maximumOccurrences"); + } + case "wildcardAllowed" -> { + seen = mark(seen, 3, path + ".wildcardAllowed"); + wildcard = readBoolean(parser, path + ".wildcardAllowed"); + } + case "ipv4Allowed" -> { + seen = mark(seen, 4, path + ".ipv4Allowed"); + ipv4 = readBoolean(parser, path + ".ipv4Allowed"); + } + case "ipv6Allowed" -> { + seen = mark(seen, 5, path + ".ipv6Allowed"); + ipv6 = readBoolean(parser, path + ".ipv6Allowed"); + } + case "allowedSchemes" -> { + seen = mark(seen, 6, path + ".allowedSchemes"); + schemes = readUriSchemes(parser, path + ".allowedSchemes"); + } + default -> throw failure("UNKNOWN_FIELD", path + ".?"); + } + } + requireAll(seen, 3, path); + SubjectAlternativeNameType type = parseSanType(typeValue, path + ".type"); + validateSanShape(type, seen, path); + try { + SubjectAlternativeNameRule rule = new SubjectAlternativeNameRule(type, minimum, maximum, + ipv4, ipv6); + return new ParsedSanRule(rule, wildcard, schemes == null ? Set.of() : schemes); + } catch (IllegalArgumentException ex) { + throw failure("SEMANTIC_INVALID", path); + } + } + + private static void validateSanShape(SubjectAlternativeNameType type, long seen, String path) { + long optionalShape = seen & (bit(3) | bit(4) | bit(5) | bit(6)); + long requiredShape = switch (type) { + case DNS_NAME -> bit(3); + case IP_ADDRESS -> bit(4) | bit(5); + case URI -> bit(6); + case RFC822_NAME -> 0; + }; + if ((optionalShape & requiredShape) != requiredShape) { + throw failure("MISSING_FIELD", path); + } + if ((optionalShape & ~requiredShape) != 0) { + throw failure("UNKNOWN_FIELD", path); + } + } + + private static Set readUriSchemes(JsonParser parser, String path) throws JacksonException { + requireToken(parser.currentToken(), JsonToken.START_ARRAY, path); + Set values = new LinkedHashSet<>(); + while (parser.nextToken() != JsonToken.END_ARRAY) { + checkArrayBound(values.size(), path); + String value = readBoundedString(parser, path + "[" + values.size() + "]", + MAXIMUM_URI_SCHEME_ASCII_BYTES); + if (!isAscii(value) || !value.equals(value.toLowerCase(java.util.Locale.ROOT)) + || !values.add(value)) { + throw failure("TOKEN_INVALID", path); + } + } + return Set.copyOf(values); + } + + private static LeafSection readLeaf(JsonParser parser, String path) throws JacksonException { + requireToken(parser.currentToken(), JsonToken.START_OBJECT, path); + long seen = 0; + boolean basicCritical = false; + boolean keyCritical = false; + Set keyUsage = null; + boolean extendedCritical = false; + Set extended = 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 "keyUsageCritical" -> { + seen = mark(seen, 1, path + ".keyUsageCritical"); + keyCritical = readBoolean(parser, path + ".keyUsageCritical"); + } + case "keyUsage" -> { + seen = mark(seen, 2, path + ".keyUsage"); + keyUsage = readKeyUsages(parser, path + ".keyUsage"); + } + case "extendedKeyUsageCritical" -> { + seen = mark(seen, 3, path + ".extendedKeyUsageCritical"); + extendedCritical = readBoolean(parser, path + ".extendedKeyUsageCritical"); + } + case "extendedKeyUsage" -> { + seen = mark(seen, 4, path + ".extendedKeyUsage"); + extended = readExtendedKeyUsages(parser, path + ".extendedKeyUsage"); + } + case "allowedKeyAlgorithms" -> { + seen = mark(seen, 5, path + ".allowedKeyAlgorithms"); + algorithms = readAlgorithms(parser, path + ".allowedKeyAlgorithms"); + } + default -> throw failure("UNKNOWN_FIELD", path + ".?"); + } + } + requireAll(seen, 6, path); + return new LeafSection(basicCritical, keyCritical, keyUsage, extendedCritical, extended, + algorithms); + } + + private static Set readKeyUsages(JsonParser parser, String path) throws JacksonException { + requireToken(parser.currentToken(), JsonToken.START_ARRAY, path); + Set values = EnumSet.noneOf(LeafKeyUsage.class); + while (parser.nextToken() != JsonToken.END_ARRAY) { + checkArrayBound(values.size(), path); + String token = readString(parser, path + "[" + values.size() + "]"); + LeafKeyUsage value; + try { + value = LeafKeyUsage.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); + Set values = new LinkedHashSet<>(); + while (parser.nextToken() != JsonToken.END_ARRAY) { + checkArrayBound(values.size(), path); + String oid = readString(parser, path + "[" + values.size() + "]"); + ExtendedKeyUsageId value; + try { + value = new ExtendedKeyUsageId(oid); + } catch (IllegalArgumentException ex) { + throw failure("TOKEN_INVALID", path); + } + if (!oid.equals(value.oid()) || !values.add(value)) { + throw failure("SEMANTIC_INVALID", path); + } + } + return Set.copyOf(values); + } + + private static Set readAlgorithms(JsonParser parser, String path) throws JacksonException { + requireToken(parser.currentToken(), JsonToken.START_ARRAY, path); + Set values = new LinkedHashSet<>(); + while (parser.nextToken() != JsonToken.END_ARRAY) { + checkArrayBound(values.size(), path); + String token = readString(parser, path + "[" + values.size() + "]"); + if (!isAllowedAlgorithm(token)) { + throw failure("TOKEN_INVALID", path); + } + if (!values.add(token)) { + throw failure("SEMANTIC_INVALID", path); + } + } + return Set.copyOf(values); + } + + private static void writeDocument(JsonGenerator generator, CertificateProfileDefinition definition) + throws JacksonException { + LeafCertificatePolicy policy = definition.leafPolicy(); + generator.writeStartObject(); + generator.writeNumberProperty("schemaVersion", CertificateProfileDefinition.SCHEMA_VERSION); + 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.writeEndObject(); + } + + private static void writeSubject(JsonGenerator generator, LeafCertificatePolicy policy) + throws JacksonException { + generator.writeObjectPropertyStart("subject"); + generator.writeBooleanProperty("allowEmpty", + policy.subjectAlternativeNamePolicy().allowEmptySubject()); + generator.writeArrayPropertyStart("rules"); + for (SubjectRdnRule rule : policy.subjectPolicy().rules()) { + generator.writeStartObject(); + generator.writeStringProperty("oid", rule.type().oid()); + generator.writeStringProperty("source", + rule.requesterSupplied() ? REQUESTER_SOURCE : PROFILE_FIXED_SOURCE); + generator.writeNumberProperty("minimumOccurrences", rule.minimumOccurrences()); + generator.writeNumberProperty("maximumOccurrences", rule.maximumOccurrences()); + generator.writeNumberProperty("maximumUtf8Bytes", rule.maximumUtf8Bytes()); + if (rule.fixedValue().isPresent()) { + generator.writeStringProperty("fixedValue", rule.fixedValue().orElseThrow()); + } + generator.writeEndObject(); + } + generator.writeEndArray(); + generator.writeEndObject(); + } + + private static void writeSan(JsonGenerator generator, SubjectAlternativeNamePolicy policy) + throws JacksonException { + generator.writeObjectPropertyStart("subjectAlternativeNames"); + generator.writeNumberProperty("minimumTotal", policy.minimumTotal()); + generator.writeNumberProperty("maximumTotal", policy.maximumTotal()); + generator.writeBooleanProperty("serviceIdentityRequired", policy.requireServiceIdentity()); + generator.writeBooleanProperty("emailIdentityRequired", policy.requireEmailIdentity()); + generator.writeBooleanProperty("criticalWhenSubjectNonEmpty", + policy.criticalWithNonemptySubject()); + generator.writeArrayPropertyStart("rules"); + List rules = policy.rules().stream() + .sorted(Comparator.comparing(rule -> rule.type().name())).toList(); + for (SubjectAlternativeNameRule rule : rules) { + generator.writeStartObject(); + generator.writeStringProperty("type", rule.type().name()); + generator.writeNumberProperty("minimumOccurrences", rule.minimum()); + generator.writeNumberProperty("maximumOccurrences", rule.maximum()); + switch (rule.type()) { + case DNS_NAME -> generator.writeBooleanProperty("wildcardAllowed", + policy.allowDnsWildcard()); + case IP_ADDRESS -> { + generator.writeBooleanProperty("ipv4Allowed", rule.allowIpv4()); + generator.writeBooleanProperty("ipv6Allowed", rule.allowIpv6()); + } + case URI -> writeSortedStrings(generator, "allowedSchemes", + policy.allowedUriSchemes()); + case RFC822_NAME -> { + // No type-specific fields. + } + } + generator.writeEndObject(); + } + generator.writeEndArray(); + generator.writeEndObject(); + } + + private static void writeLeaf(JsonGenerator generator, LeafCertificatePolicy policy) + throws JacksonException { + generator.writeObjectPropertyStart("leafCertificate"); + generator.writeBooleanProperty("basicConstraintsCritical", + policy.basicConstraintsCritical()); + generator.writeBooleanProperty("keyUsageCritical", policy.keyUsageCritical()); + writeSortedStrings(generator, "keyUsage", + policy.keyUsages().stream().map(Enum::name).toList()); + generator.writeBooleanProperty("extendedKeyUsageCritical", + policy.extendedKeyUsageCritical()); + writeSortedStrings(generator, "extendedKeyUsage", + policy.extendedKeyUsages().stream().map(ExtendedKeyUsageId::oid).toList()); + writeSortedStrings(generator, "allowedKeyAlgorithms", + policy.allowedSubjectKeyAlgorithmIds()); + generator.writeEndObject(); + } + + private static void writeSortedStrings(JsonGenerator generator, String field, + java.util.Collection values) throws JacksonException { + generator.writeArrayPropertyStart(field); + for (String value : values.stream().sorted().toList()) { + generator.writeString(value); + } + generator.writeEndArray(); + } + + private static void validateDefinition(CertificateProfileDefinition definition) { + if (definition == null) { + throw failure("CANONICALIZATION_FAILED", "$"); + } + validateProfileString(definition.profileId(), MAXIMUM_PROFILE_ID_UTF8_BYTES, + "$.profileId", "CANONICALIZATION_FAILED"); + validateProfileString(definition.formatId().value(), MAXIMUM_FORMAT_ID_UTF8_BYTES, + "$.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, + "$.maxValidity"); + if (leaf.subjectPolicy().rules().size() > MAXIMUM_ARRAY_ELEMENTS + || leaf.subjectAlternativeNamePolicy().rules().size() > MAXIMUM_ARRAY_ELEMENTS + || leaf.subjectAlternativeNamePolicy().allowedUriSchemes().size() + > MAXIMUM_ARRAY_ELEMENTS + || leaf.keyUsages().size() > MAXIMUM_ARRAY_ELEMENTS + || leaf.extendedKeyUsages().size() > MAXIMUM_ARRAY_ELEMENTS + || 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"); + if (!isAscii(scheme)) { + throw failure("CANONICALIZATION_FAILED", + "$.subjectAlternativeNames.rules.allowedSchemes"); + } + } + for (ExtendedKeyUsageId usage : leaf.extendedKeyUsages()) { + validateWritableString(usage.oid(), MAXIMUM_STRING_UTF8_BYTES, + "$.leafCertificate.extendedKeyUsage"); + } + } + + private static void validateWritableString(String value, int maximum, String path) { + if (value == null || utf8Length(value) > maximum || hasUnpairedSurrogate(value)) { + throw failure("CANONICALIZATION_FAILED", path); + } + } + + private static void validateProfileString(String value, int maximum, String path, + String failureCode) { + if (value == null || value.isBlank() || !value.equals(value.strip()) + || hasUnpairedSurrogate(value) || utf8Length(value) > maximum) { + throw failure(failureCode, path); + } + } + + private static String readString(JsonParser parser, String path) throws JacksonException { + return readBoundedString(parser, path, MAXIMUM_STRING_UTF8_BYTES); + } + + private static String readBoundedString(JsonParser parser, String path, int maximumUtf8Bytes) + throws JacksonException { + requireToken(parser.currentToken(), JsonToken.VALUE_STRING, path); + String value = parser.getString(); + if (hasUnpairedSurrogate(value)) { + throw failure("TOKEN_INVALID", path); + } + if (utf8Length(value) > maximumUtf8Bytes) { + throw failure("LIMIT_EXCEEDED", path); + } + return value; + } + + private static boolean readBoolean(JsonParser parser, String path) { + if (parser.currentToken() == JsonToken.VALUE_TRUE) { + return true; + } + if (parser.currentToken() == JsonToken.VALUE_FALSE) { + return false; + } + throw failure("WRONG_TYPE", path); + } + + private static int readInt(JsonParser parser, String path) throws JacksonException { + long value = readLong(parser, path); + if (value < Integer.MIN_VALUE || value > Integer.MAX_VALUE) { + throw failure("LIMIT_EXCEEDED", path); + } + return (int) value; + } + + private static long readLong(JsonParser parser, String path) throws JacksonException { + requireToken(parser.currentToken(), JsonToken.VALUE_NUMBER_INT, path); + try { + return parser.getLongValue(); + } catch (JacksonException ex) { + throw failure("LIMIT_EXCEEDED", path); + } + } + + private static Duration readDuration(JsonParser parser, String path) throws JacksonException { + String value = readString(parser, path); + Duration duration; + try { + duration = Duration.parse(value); + } catch (DateTimeException ex) { + throw failure("TOKEN_INVALID", path); + } + if (duration.isZero() || duration.isNegative() || !value.equals(duration.toString())) { + throw failure("TOKEN_INVALID", path); + } + return duration; + } + + private static SubjectRdnType parseRdnType(String oid, String path) { + try { + return SubjectRdnType.fromOid(oid); + } catch (IllegalArgumentException ex) { + throw failure("TOKEN_INVALID", path); + } + } + + private static boolean parseSubjectSource(String source, String path) { + if (REQUESTER_SOURCE.equals(source)) { + return false; + } + if (PROFILE_FIXED_SOURCE.equals(source)) { + return true; + } + throw failure("TOKEN_INVALID", path); + } + + private static SubjectAlternativeNameType parseSanType(String type, String path) { + try { + return SubjectAlternativeNameType.valueOf(type); + } catch (IllegalArgumentException ex) { + throw failure("TOKEN_INVALID", path); + } + } + + private static void requireToken(JsonToken actual, JsonToken expected, String path) { + if (actual == null) { + throw failure("MALFORMED_JSON", path); + } + if (actual != expected) { + throw failure("WRONG_TYPE", path); + } + } + + private static void requireValue(JsonParser parser, String path) throws JacksonException { + JsonToken token = parser.nextToken(); + if (token == null) { + throw failure("MALFORMED_JSON", path); + } + if (token == JsonToken.VALUE_NULL) { + throw failure("WRONG_TYPE", path); + } + } + + private static long mark(long seen, int field, String path) { + long mask = bit(field); + if ((seen & mask) != 0) { + throw failure("DUPLICATE_FIELD", path); + } + return seen | mask; + } + + private static long bit(int field) { + return 1L << field; + } + + private static boolean isSeen(long seen, int field) { + return (seen & bit(field)) != 0; + } + + private static void requireAll(long seen, int fieldCount, String path) { + long expected = bit(fieldCount) - 1; + if ((seen & expected) != expected) { + throw failure("MISSING_FIELD", path); + } + } + + private static void checkArrayBound(int currentSize, String path) { + if (currentSize >= MAXIMUM_ARRAY_ELEMENTS) { + throw failure("LIMIT_EXCEEDED", path); + } + } + + private static int utf8Length(String value) { + return value.getBytes(StandardCharsets.UTF_8).length; + } + + private static boolean hasUnpairedSurrogate(String value) { + return value.codePoints().anyMatch(codePoint -> + codePoint >= Character.MIN_SURROGATE && codePoint <= Character.MAX_SURROGATE); + } + + private static boolean isAscii(String value) { + for (int index = 0; index < value.length(); index++) { + if (value.charAt(index) > MAXIMUM_ASCII_VALUE) { + return false; + } + } + return true; + } + + private static boolean isAllowedAlgorithm(String value) { + return "RSA".equals(value) || "ECDSA".equals(value) + || "Ed25519".equals(value) || "Ed448".equals(value); + } + + private static boolean hasUtf8Bom(byte[] value) { + return value.length >= 3 && value[0] == (byte) 0xef + && value[1] == (byte) 0xbb && value[2] == (byte) 0xbf; + } + + private static boolean isDuplicateFailure(JacksonException exception) { + String message = exception.getMessage(); + return message != null && message.contains("Duplicate"); + } + + private static PkiException failure(String code, String path) { + return new PkiException(PREFIX + code + " path=" + path); + } + + private record SubjectSection(boolean allowEmpty, List rules) { + } + + private record SanSection(int minimumTotal, int maximumTotal, boolean serviceIdentityRequired, + boolean emailIdentityRequired, boolean criticalWhenSubjectNonEmpty, + List rules, boolean wildcardAllowed, + Set allowedSchemes) { + } + + private record SanRules(List rules, boolean wildcardAllowed, + Set allowedSchemes) { + } + + private record ParsedSanRule(SubjectAlternativeNameRule rule, boolean wildcardAllowed, + Set allowedSchemes) { + } + + private record LeafSection(boolean basicConstraintsCritical, boolean keyUsageCritical, + Set keyUsage, boolean extendedKeyUsageCritical, + Set extendedKeyUsage, Set allowedKeyAlgorithms) { + } +} diff --git a/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfileRef.java b/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfileRef.java new file mode 100644 index 0000000..6c9c8c4 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfileRef.java @@ -0,0 +1,91 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.api.profile; + +import java.security.MessageDigest; +import java.util.Arrays; +import java.util.HexFormat; +import java.util.Objects; + +/** + * Exact immutable identity of one imported certificate-profile version. + */ +public final class CertificateProfileRef { + /** SHA-256 digest length in bytes. */ + public static final int HASH_BYTES = 32; + + private final String profileId; + private final long profileVersion; + private final byte[] canonicalSha256; + + /** + * Creates an exact profile reference. + * + * @param profileId logical profile identifier + * @param profileVersion positive profile version + * @param canonicalSha256 SHA-256 of the canonical JSON document + */ + public CertificateProfileRef(String profileId, long profileVersion, byte[] canonicalSha256) { + if (profileId == null || profileId.isBlank()) { + throw new IllegalArgumentException("profileId must not be null/blank"); + } + if (profileVersion <= 0) { + throw new IllegalArgumentException("profileVersion must be positive"); + } + Objects.requireNonNull(canonicalSha256, "canonicalSha256"); + if (canonicalSha256.length != HASH_BYTES) { + throw new IllegalArgumentException("canonicalSha256 must contain 32 bytes"); + } + this.profileId = profileId; + this.profileVersion = profileVersion; + this.canonicalSha256 = canonicalSha256.clone(); + } + + /** @return logical profile identifier */ + public String profileId() { + return profileId; + } + + /** @return positive profile version */ + public long profileVersion() { + return profileVersion; + } + + /** @return defensive copy of the canonical SHA-256 digest */ + public byte[] canonicalSha256() { + return canonicalSha256.clone(); + } + + /** + * Returns a short non-authoritative fingerprint suitable for audit metadata. + * + * @return first eight hash bytes encoded as lowercase hexadecimal + */ + public String shortFingerprint() { + return HexFormat.of().formatHex(canonicalSha256, 0, 8); + } + + @Override + public boolean equals(Object other) { + return this == other || other instanceof CertificateProfileRef ref + && profileVersion == ref.profileVersion + && profileId.equals(ref.profileId) + && MessageDigest.isEqual(canonicalSha256, ref.canonicalSha256); + } + + @Override + public int hashCode() { + int result = profileId.hashCode(); + result = 31 * result + Long.hashCode(profileVersion); + result = 31 * result + Arrays.hashCode(canonicalSha256); + return result; + } + + @Override + public String toString() { + return "CertificateProfileRef[profileId=" + profileId + ", profileVersion=" + profileVersion + + ", fingerprint=" + shortFingerprint() + "]"; + } +} diff --git a/pki/src/main/java/zeroecho/pki/api/profile/ExtendedKeyUsageId.java b/pki/src/main/java/zeroecho/pki/api/profile/ExtendedKeyUsageId.java new file mode 100644 index 0000000..02fab86 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/profile/ExtendedKeyUsageId.java @@ -0,0 +1,25 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.api.profile; + +import org.bouncycastle.asn1.ASN1ObjectIdentifier; + +/** + * Validated exact X.509 extended-key-usage object identifier. + * + * @param oid dotted-decimal object identifier + */ +public record ExtendedKeyUsageId(String oid) { + + /** + * Validates and constructs the identifier. + */ + public ExtendedKeyUsageId { + if (oid == null) { + throw new IllegalArgumentException("Invalid extended key usage identifier"); + } + oid = new ASN1ObjectIdentifier(oid).getId(); + } +} diff --git a/pki/src/main/java/zeroecho/pki/api/profile/ImportedCertificateProfileVersion.java b/pki/src/main/java/zeroecho/pki/api/profile/ImportedCertificateProfileVersion.java new file mode 100644 index 0000000..e8c1b19 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/profile/ImportedCertificateProfileVersion.java @@ -0,0 +1,95 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.api.profile; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Objects; + +/** + * Immutable persisted profile version and its canonical configuration bytes. + */ +public final class ImportedCertificateProfileVersion { + private final CertificateProfileRef reference; + private final int schemaVersion; + private final CertificateProfileDefinition definition; + private final byte[] canonicalJson; + private final Instant importedAt; + + /** + * Creates an immutable imported version. + * + * @param reference exact version reference + * @param schemaVersion document schema version + * @param definition validated semantic definition + * @param canonicalJson canonical JSON document + * @param importedAt authoritative import time + */ + public ImportedCertificateProfileVersion(CertificateProfileRef reference, int schemaVersion, + CertificateProfileDefinition definition, byte[] canonicalJson, Instant importedAt) { + this.reference = Objects.requireNonNull(reference, "reference"); + if (schemaVersion != CertificateProfileDefinition.SCHEMA_VERSION) { + throw new IllegalArgumentException("unsupported schemaVersion"); + } + this.schemaVersion = schemaVersion; + this.definition = Objects.requireNonNull(definition, "definition"); + Objects.requireNonNull(canonicalJson, "canonicalJson"); + if (canonicalJson.length == 0) { + throw new IllegalArgumentException("canonicalJson must not be empty"); + } + this.canonicalJson = canonicalJson.clone(); + this.importedAt = Objects.requireNonNull(importedAt, "importedAt"); + if (!reference.profileId().equals(definition.profileId()) + || reference.profileVersion() != definition.profileVersion()) { + throw new IllegalArgumentException("profile reference does not match definition"); + } + } + + /** @return exact version reference */ + public CertificateProfileRef reference() { + return reference; + } + + /** @return document schema version */ + public int schemaVersion() { + return schemaVersion; + } + + /** @return validated immutable profile definition */ + public CertificateProfileDefinition definition() { + return definition; + } + + /** @return defensive copy of canonical JSON */ + public byte[] canonicalJson() { + return canonicalJson.clone(); + } + + /** @return authoritative import time */ + public Instant importedAt() { + return importedAt; + } + + @Override + public boolean equals(Object other) { + return this == other || other instanceof ImportedCertificateProfileVersion version + && schemaVersion == version.schemaVersion + && reference.equals(version.reference) + && definition.equals(version.definition) + && importedAt.equals(version.importedAt) + && Arrays.equals(canonicalJson, version.canonicalJson); + } + + @Override + public int hashCode() { + return Objects.hash(reference, schemaVersion, definition, importedAt, Arrays.hashCode(canonicalJson)); + } + + @Override + public String toString() { + return "ImportedCertificateProfileVersion[reference=" + reference + ", schemaVersion=" + schemaVersion + + ", importedAt=" + importedAt + "]"; + } +} diff --git a/pki/src/main/java/zeroecho/pki/api/profile/LeafCertificatePolicy.java b/pki/src/main/java/zeroecho/pki/api/profile/LeafCertificatePolicy.java new file mode 100644 index 0000000..f88878f --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/profile/LeafCertificatePolicy.java @@ -0,0 +1,54 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +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 + * @param keyUsageCritical key-usage criticality + * @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, + Set extendedKeyUsages, boolean keyUsageCritical, boolean extendedKeyUsageCritical, + boolean basicConstraintsCritical, Set allowedSubjectKeyAlgorithmIds, Duration maximumValidity) { + + private static final Set SUPPORTED_SUBJECT_KEY_ALGORITHMS = + Set.of("RSA", "ECDSA", "Ed25519", "Ed448"); + + /** + * Validates and constructs the policy. + */ + public LeafCertificatePolicy { + if (subjectPolicy == null || subjectAlternativeNamePolicy == null || keyUsages == null + || extendedKeyUsages == null || allowedSubjectKeyAlgorithmIds == null || maximumValidity == null) { + throw new IllegalArgumentException("Leaf certificate policy values must not be null"); + } + keyUsages = Set.copyOf(keyUsages); + extendedKeyUsages = Set.copyOf(extendedKeyUsages); + allowedSubjectKeyAlgorithmIds = Set.copyOf(allowedSubjectKeyAlgorithmIds); + if (allowedSubjectKeyAlgorithmIds.isEmpty() + || !SUPPORTED_SUBJECT_KEY_ALGORITHMS.containsAll(allowedSubjectKeyAlgorithmIds)) { + throw new IllegalArgumentException("At least one supported subject key algorithm is required"); + } + if ((keyUsages.contains(LeafKeyUsage.ENCIPHER_ONLY) || keyUsages.contains(LeafKeyUsage.DECIPHER_ONLY)) + && !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/LeafKeyUsage.java b/pki/src/main/java/zeroecho/pki/api/profile/LeafKeyUsage.java new file mode 100644 index 0000000..5ca39d9 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/profile/LeafKeyUsage.java @@ -0,0 +1,25 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.api.profile; + +/** + * Closed X.509 key-usage bit set available to leaf certificate profiles. + */ +public enum LeafKeyUsage { + /** digitalSignature. */ + DIGITAL_SIGNATURE, + /** nonRepudiation/contentCommitment. */ + CONTENT_COMMITMENT, + /** keyEncipherment. */ + KEY_ENCIPHERMENT, + /** dataEncipherment. */ + DATA_ENCIPHERMENT, + /** keyAgreement. */ + KEY_AGREEMENT, + /** encipherOnly. */ + ENCIPHER_ONLY, + /** decipherOnly. */ + DECIPHER_ONLY +} diff --git a/pki/src/main/java/zeroecho/pki/api/profile/SubjectAlternativeNamePolicy.java b/pki/src/main/java/zeroecho/pki/api/profile/SubjectAlternativeNamePolicy.java new file mode 100644 index 0000000..0d5b685 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/profile/SubjectAlternativeNamePolicy.java @@ -0,0 +1,82 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.api.profile; + +import java.util.List; +import java.util.Locale; +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 + * @param allowDnsWildcard whether a complete leftmost DNS wildcard is permitted + * @param allowedUriSchemes exact lowercase allowed URI schemes + * @param criticalWithNonemptySubject SAN criticality for a nonempty subject + * @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, + List rules, boolean allowDnsWildcard, Set allowedUriSchemes, + boolean criticalWithNonemptySubject, boolean requireServiceIdentity, boolean requireEmailIdentity) { + + /** Maximum number of SAN entries. */ + public static final int HARD_MAXIMUM_COUNT = 64; + + /** + * Validates and constructs the policy. + */ + public SubjectAlternativeNamePolicy { + if (minimumTotal < 0 || maximumTotal < minimumTotal || maximumTotal > HARD_MAXIMUM_COUNT) { + throw new IllegalArgumentException("Invalid SAN total bounds"); + } + if (rules == null || allowedUriSchemes == null) { + throw new IllegalArgumentException("SAN policy collections must not be null"); + } + rules = List.copyOf(rules); + Set seen = java.util.EnumSet.noneOf(SubjectAlternativeNameType.class); + int configuredMinimum = 0; + int configuredMaximum = 0; + boolean serviceIdentityPossible = false; + boolean emailIdentityPossible = false; + boolean uriPossible = false; + for (SubjectAlternativeNameRule rule : rules) { + if (rule == null || !seen.add(rule.type()) || rule.maximum() > maximumTotal) { + throw new IllegalArgumentException("SAN type rules must be non-null, unique, and bounded"); + } + configuredMinimum = Math.addExact(configuredMinimum, rule.minimum()); + configuredMaximum = Math.addExact(configuredMaximum, rule.maximum()); + serviceIdentityPossible |= rule.maximum() > 0 && (rule.type() == SubjectAlternativeNameType.DNS_NAME + || rule.type() == SubjectAlternativeNameType.IP_ADDRESS + || rule.type() == SubjectAlternativeNameType.URI); + emailIdentityPossible |= rule.maximum() > 0 + && rule.type() == SubjectAlternativeNameType.RFC822_NAME; + uriPossible |= rule.maximum() > 0 && rule.type() == SubjectAlternativeNameType.URI; + } + Set schemes = new java.util.LinkedHashSet<>(); + for (String scheme : allowedUriSchemes) { + if (scheme == null || !scheme.matches("[A-Za-z][A-Za-z0-9+.-]*")) { + throw new IllegalArgumentException("Invalid URI scheme"); + } + String canonical = scheme.toLowerCase(Locale.ROOT); + if (!schemes.add(canonical)) { + throw new IllegalArgumentException("Duplicate URI scheme"); + } + } + allowedUriSchemes = Set.copyOf(schemes); + if (configuredMinimum > maximumTotal || configuredMaximum < minimumTotal + || requireServiceIdentity && !serviceIdentityPossible + || requireEmailIdentity && !emailIdentityPossible + || 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/SubjectAlternativeNameRule.java b/pki/src/main/java/zeroecho/pki/api/profile/SubjectAlternativeNameRule.java new file mode 100644 index 0000000..daa493c --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/profile/SubjectAlternativeNameRule.java @@ -0,0 +1,34 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.api.profile; + +/** + * Occurrence and IP-family rule for one supported SAN type. + * + * @param type SAN type + * @param minimum minimum occurrences + * @param maximum maximum occurrences + * @param allowIpv4 whether IPv4 is allowed for {@link SubjectAlternativeNameType#IP_ADDRESS} + * @param allowIpv6 whether IPv6 is allowed for {@link SubjectAlternativeNameType#IP_ADDRESS} + */ +public record SubjectAlternativeNameRule(SubjectAlternativeNameType type, int minimum, int maximum, + boolean allowIpv4, boolean allowIpv6) { + + /** + * Validates and constructs the rule. + */ + public SubjectAlternativeNameRule { + if (type == null || minimum < 0 || maximum < minimum || maximum > SubjectAlternativeNamePolicy.HARD_MAXIMUM_COUNT) { + throw new IllegalArgumentException("Invalid SAN type rule"); + } + if (type == SubjectAlternativeNameType.IP_ADDRESS) { + if (maximum > 0 && !allowIpv4 && !allowIpv6) { + throw new IllegalArgumentException("An IP SAN rule must allow at least one address family"); + } + } else if (allowIpv4 || allowIpv6) { + throw new IllegalArgumentException("IP family flags apply only to IP SAN rules"); + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/api/profile/SubjectAlternativeNameType.java b/pki/src/main/java/zeroecho/pki/api/profile/SubjectAlternativeNameType.java new file mode 100644 index 0000000..d9c816d --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/profile/SubjectAlternativeNameType.java @@ -0,0 +1,19 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.api.profile; + +/** + * Closed set of requester-supplied Subject Alternative Name types. + */ +public enum SubjectAlternativeNameType { + /** DNS service name. */ + DNS_NAME, + /** Raw IPv4 or IPv6 address. */ + IP_ADDRESS, + /** Hierarchical absolute URI. */ + URI, + /** RFC 822 mailbox name. */ + RFC822_NAME +} diff --git a/pki/src/main/java/zeroecho/pki/api/profile/SubjectPolicy.java b/pki/src/main/java/zeroecho/pki/api/profile/SubjectPolicy.java new file mode 100644 index 0000000..914ab40 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/profile/SubjectPolicy.java @@ -0,0 +1,40 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.api.profile; + +import java.util.List; +import java.util.Set; + +/** + * Deny-by-default subject distinguished-name policy. + * + * @param rules ordered immutable supported RDN rules + */ +public record SubjectPolicy(List rules) { + + /** Maximum number of subject RDNs. */ + public static final int HARD_MAXIMUM_RDN_COUNT = 32; + + /** + * Validates and constructs the policy. + */ + public SubjectPolicy { + if (rules == null) { + throw new IllegalArgumentException("Subject rules must not be null"); + } + rules = List.copyOf(rules); + Set seen = java.util.EnumSet.noneOf(SubjectRdnType.class); + int configuredMaximum = 0; + for (SubjectRdnRule rule : rules) { + if (rule == null || !seen.add(rule.type())) { + throw new IllegalArgumentException("Subject RDN rules must be non-null and unique"); + } + configuredMaximum = Math.addExact(configuredMaximum, rule.maximumOccurrences()); + } + if (configuredMaximum > HARD_MAXIMUM_RDN_COUNT) { + throw new IllegalArgumentException("Configured subject RDN maximum exceeds the hard limit"); + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/api/profile/SubjectRdnRule.java b/pki/src/main/java/zeroecho/pki/api/profile/SubjectRdnRule.java new file mode 100644 index 0000000..8542828 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/profile/SubjectRdnRule.java @@ -0,0 +1,79 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.api.profile; + +import java.nio.charset.StandardCharsets; +import java.util.Optional; + +/** + * Immutable occurrence and ownership rule for one supported subject RDN type. + * + * @param type exact supported RDN type + * @param minimumOccurrences minimum number of occurrences + * @param maximumOccurrences maximum number of occurrences + * @param maximumUtf8Bytes maximum encoded value length, at most 256 bytes + * @param fixedValue optional profile-fixed value + * @param requesterSupplied whether the requester may supply the value + */ +public record SubjectRdnRule(SubjectRdnType type, int minimumOccurrences, int maximumOccurrences, + int maximumUtf8Bytes, Optional fixedValue, boolean requesterSupplied) { + + /** Maximum hard value size. */ + public static final int HARD_MAXIMUM_UTF8_BYTES = 256; + + /** + * Validates and constructs a rule. + */ + public SubjectRdnRule { + if (type == null || fixedValue == null) { + throw new IllegalArgumentException("Subject RDN rule values must not be null"); + } + if (minimumOccurrences < 0 || maximumOccurrences < minimumOccurrences) { + throw new IllegalArgumentException("Invalid subject RDN occurrence bounds"); + } + if (maximumUtf8Bytes < 1 || maximumUtf8Bytes > HARD_MAXIMUM_UTF8_BYTES) { + throw new IllegalArgumentException("Invalid subject RDN value bound"); + } + if (fixedValue.isPresent()) { + String value = canonicalValue(type, fixedValue.orElseThrow()); + if (value.getBytes(StandardCharsets.UTF_8).length > maximumUtf8Bytes) { + throw new IllegalArgumentException("Fixed subject RDN value is too large"); + } + if (requesterSupplied) { + throw new IllegalArgumentException("A fixed subject RDN cannot be requester supplied"); + } + if (minimumOccurrences != 1 || maximumOccurrences != 1) { + throw new IllegalArgumentException("A fixed subject RDN must occur exactly once"); + } + fixedValue = Optional.of(value); + } else if (!requesterSupplied && maximumOccurrences != 0) { + throw new IllegalArgumentException("A non-requester RDN requires a fixed value"); + } + } + + /** + * Applies the type-specific canonical form and validation. + * + * @param type RDN type + * @param value value to validate + * @return canonical value + */ + public static String canonicalValue(SubjectRdnType type, String value) { + if (value == null || value.isEmpty()) { + throw new IllegalArgumentException("Subject RDN value must not be empty"); + } + if (type == SubjectRdnType.COUNTRY_NAME) { + if (value.length() != 2 || !isAsciiLetter(value.charAt(0)) || !isAsciiLetter(value.charAt(1))) { + throw new IllegalArgumentException("Country name must contain two ASCII letters"); + } + return value.toUpperCase(java.util.Locale.ROOT); + } + return value; + } + + private static boolean isAsciiLetter(char value) { + return value >= 'A' && value <= 'Z' || value >= 'a' && value <= 'z'; + } +} diff --git a/pki/src/main/java/zeroecho/pki/api/profile/SubjectRdnType.java b/pki/src/main/java/zeroecho/pki/api/profile/SubjectRdnType.java new file mode 100644 index 0000000..5af3b03 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/profile/SubjectRdnType.java @@ -0,0 +1,63 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.api.profile; + +/** + * Closed set of X.509 subject distinguished-name attributes supported by + * end-entity certificate profiles. + */ +// OID literals are ASN.1 identifiers, not IP addresses. +@SuppressWarnings("PMD.AvoidUsingHardCodedIP") +public enum SubjectRdnType { + /** X.520 common name. */ + COMMON_NAME("2.5.4.3"), + /** X.520 organization name. */ + ORGANIZATION_NAME("2.5.4.10"), + /** X.520 organizational unit name. */ + ORGANIZATIONAL_UNIT_NAME("2.5.4.11"), + /** X.520 country name. */ + COUNTRY_NAME("2.5.4.6"), + /** X.520 state or province name. */ + STATE_OR_PROVINCE_NAME("2.5.4.8"), + /** X.520 locality name. */ + LOCALITY_NAME("2.5.4.7"), + /** X.520 serial number attribute. */ + SERIAL_NUMBER("2.5.4.5"), + /** PKCS #9 email address. */ + EMAIL_ADDRESS("1.2.840.113549.1.9.1"), + /** X.520 pseudonym. */ + PSEUDONYM("2.5.4.65"); + + private final String oid; + + SubjectRdnType(String oid) { + this.oid = oid; + } + + /** + * Returns the exact ASN.1 object identifier. + * + * @return dotted-decimal object identifier + */ + public String oid() { + return oid; + } + + /** + * Resolves a supported RDN type. + * + * @param oid dotted-decimal object identifier + * @return supported type + * @throws IllegalArgumentException if the identifier is unsupported + */ + public static SubjectRdnType fromOid(String oid) { + for (SubjectRdnType type : values()) { + if (type.oid.equals(oid)) { + return type; + } + } + throw new IllegalArgumentException("Unsupported subject RDN type"); + } +} 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 44fd85d..4357a7b 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 @@ -45,6 +45,16 @@ * framework constructs during credential creation. *

* + *

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

+ * * @since 1.0 */ package zeroecho.pki.api.profile; diff --git a/pki/src/main/java/zeroecho/pki/api/request/ParsedCertificationRequest.java b/pki/src/main/java/zeroecho/pki/api/request/ParsedCertificationRequest.java index 485bf9a..d6e7af5 100644 --- a/pki/src/main/java/zeroecho/pki/api/request/ParsedCertificationRequest.java +++ b/pki/src/main/java/zeroecho/pki/api/request/ParsedCertificationRequest.java @@ -34,6 +34,7 @@ package zeroecho.pki.api.request; import java.util.Optional; +import java.util.List; import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.FormatId; @@ -67,12 +68,40 @@ import zeroecho.pki.api.attr.AttributeSet; * @param requestedValidity optional validity requested by the subject; policy * may override or deny * @param requestedProfileId optional profile hint; policy may override or deny - * @param attributes universal typed attributes extracted from the - * request + * @param subjectRdns ordered typed single-valued subject components + * @param subjectAlternativeNames ordered typed SAN entries + * @param subjectAlternativeNamePresent whether one valid SAN extension was present + * @param attributes proof-carrying parser attributes; issuance policy rejects all + * attributes except the canonical CSR transport attribute */ public record ParsedCertificationRequest(PkiId requestId, FormatId formatId, SubjectRef subjectRef, EncodedObject publicKeyInfo, Optional requestedValidity, Optional requestedProfileId, - AttributeSet attributes) { + List subjectRdns, List subjectAlternativeNames, + boolean subjectAlternativeNamePresent, AttributeSet attributes) { + + /** + * Creates a diagnostic request without typed X.509 identity semantics. + * + *

+ * The authoritative X.509 issuance path reparses the signed CSR and requires + * exact typed-semantic equality, so instances built through this convenience + * constructor cannot bypass typed subject or SAN validation. + *

+ * + * @param requestId request identifier + * @param formatId format identifier + * @param subjectRef diagnostic subject reference + * @param publicKeyInfo public-key information + * @param requestedValidity requested validity + * @param requestedProfileId requested profile + * @param attributes diagnostic attributes + */ + public ParsedCertificationRequest(PkiId requestId, FormatId formatId, SubjectRef subjectRef, + EncodedObject publicKeyInfo, Optional requestedValidity, Optional requestedProfileId, + AttributeSet attributes) { + this(requestId, formatId, subjectRef, publicKeyInfo, requestedValidity, requestedProfileId, List.of(), + List.of(), false, attributes); + } /** * Creates a parsed certification request. @@ -106,6 +135,21 @@ public record ParsedCertificationRequest(PkiId requestId, FormatId formatId, Sub if (requestedProfileId == null) { throw new IllegalArgumentException("requestedProfileId must not be null"); } + if (subjectRdns == null || subjectAlternativeNames == null) { + throw new IllegalArgumentException("Typed identity collections must not be null"); + } + subjectRdns = List.copyOf(subjectRdns); + subjectAlternativeNames = List.copyOf(subjectAlternativeNames); + if (subjectRdns.stream().anyMatch(java.util.Objects::isNull) + || subjectAlternativeNames.stream().anyMatch(java.util.Objects::isNull)) { + throw new IllegalArgumentException("Typed identity collections must not contain null"); + } + if (subjectRdns.size() > 32 || subjectAlternativeNames.size() > 64) { + throw new IllegalArgumentException("Typed identity collection exceeds the hard limit"); + } + if (subjectAlternativeNamePresent != !subjectAlternativeNames.isEmpty()) { + throw new IllegalArgumentException("SAN extension presence is inconsistent"); + } if (attributes == null) { throw new IllegalArgumentException("attributes must not be null"); } diff --git a/pki/src/main/java/zeroecho/pki/api/request/SubjectAlternativeName.java b/pki/src/main/java/zeroecho/pki/api/request/SubjectAlternativeName.java new file mode 100644 index 0000000..dbadfad --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/request/SubjectAlternativeName.java @@ -0,0 +1,272 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.api.request; + +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Locale; + +import zeroecho.pki.api.profile.SubjectAlternativeNameType; + +/** + * Closed immutable canonical Subject Alternative Name representation. + */ +public sealed interface SubjectAlternativeName permits SubjectAlternativeName.DnsName, + SubjectAlternativeName.IpAddress, SubjectAlternativeName.UriName, SubjectAlternativeName.Rfc822Name { + /** IPv4 octet count. */ + int IPV4_OCTET_COUNT = 4; + /** Maximum IPv4 octet value. */ + int MAXIMUM_IPV4_OCTET = 255; + + /** + * Returns the exact SAN type. + * + * @return SAN type + */ + SubjectAlternativeNameType type(); + + /** + * Canonical DNS A-label name. + * + * @param value lowercase ASCII DNS name, optionally with a complete leftmost wildcard + */ + record DnsName(String value) implements SubjectAlternativeName { + /** Maximum encoded DNS name length. */ + public static final int MAXIMUM_BYTES = 253; + + /** + * Canonicalizes and validates the DNS name. + */ + public DnsName { + value = canonicalDns(value, true); + } + + @Override + public SubjectAlternativeNameType type() { + return SubjectAlternativeNameType.DNS_NAME; + } + } + + /** + * Canonical raw IPv4 or IPv6 address. + * + * @param bytes four or sixteen address bytes + */ + record IpAddress(byte[] bytes) implements SubjectAlternativeName { + /** + * Defensively snapshots and validates the address. + */ + public IpAddress { + if (bytes == null || bytes.length != 4 && bytes.length != 16) { + throw new IllegalArgumentException("IP SAN must contain 4 or 16 bytes"); + } + bytes = bytes.clone(); + } + + @Override + public byte[] bytes() { + return bytes.clone(); + } + + @Override + public SubjectAlternativeNameType type() { + return SubjectAlternativeNameType.IP_ADDRESS; + } + + @Override + public boolean equals(Object other) { + return other instanceof IpAddress address && Arrays.equals(bytes, address.bytes); + } + + @Override + public int hashCode() { + return Arrays.hashCode(bytes); + } + + @Override + public String toString() { + return "IpAddress[length=" + bytes.length + "]"; + } + } + + /** + * Canonical ASCII hierarchical absolute URI. + * + * @param value canonical URI text + */ + record UriName(String value) implements SubjectAlternativeName { + /** Maximum encoded URI length. */ + public static final int MAXIMUM_BYTES = 2048; + + /** + * Canonicalizes and validates the URI. + */ + public UriName { + value = canonicalUri(value); + } + + @Override + public SubjectAlternativeNameType type() { + return SubjectAlternativeNameType.URI; + } + } + + /** + * Canonical ASCII RFC 822 mailbox. + * + * @param value mailbox with case-preserved local part and lowercase domain + */ + record Rfc822Name(String value) implements SubjectAlternativeName { + /** Maximum encoded mailbox length. */ + public static final int MAXIMUM_BYTES = 320; + + /** + * Canonicalizes and validates the mailbox. + */ + public Rfc822Name { + value = canonicalMailbox(value); + } + + @Override + public SubjectAlternativeNameType type() { + return SubjectAlternativeNameType.RFC822_NAME; + } + } + + // The branches directly encode the fixed DNS label grammar. + @SuppressWarnings({ "PMD.CyclomaticComplexity", "PMD.NPathComplexity" }) + private static String canonicalDns(String supplied, boolean allowWildcardSyntax) { + requireAscii(supplied, DnsName.MAXIMUM_BYTES, "DNS SAN"); + if (supplied.endsWith(".")) { + throw new IllegalArgumentException("DNS SAN must not have a trailing dot"); + } + String canonical = supplied.toLowerCase(Locale.ROOT); + if (isIpLiteral(canonical)) { + throw new IllegalArgumentException("An IP literal is not a DNS SAN"); + } + String[] labels = canonical.split("\\.", -1); + boolean wildcard = labels.length > 0 && "*".equals(labels[0]); + if (wildcard && (!allowWildcardSyntax || labels.length < 3)) { + throw new IllegalArgumentException("Invalid DNS wildcard"); + } + for (int index = 0; index < labels.length; index++) { + String label = labels[index]; + if (index == 0 && wildcard) { + continue; + } + if (label.isEmpty() || label.length() > 63 || label.charAt(0) == '-' + || label.charAt(label.length() - 1) == '-') { + throw new IllegalArgumentException("Invalid DNS label"); + } + for (int charIndex = 0; charIndex < label.length(); charIndex++) { + char character = label.charAt(charIndex); + if (!(character >= 'a' && character <= 'z') && !(character >= '0' && character <= '9') + && character != '-') { + throw new IllegalArgumentException("Invalid DNS label character"); + } + } + } + if (canonical.indexOf('*') >= 0 && !wildcard) { + throw new IllegalArgumentException("Invalid DNS wildcard"); + } + return canonical; + } + + // The public exception deliberately redacts parser details. + @SuppressWarnings({ "PMD.PreserveStackTrace", "PMD.CyclomaticComplexity" }) + private static String canonicalUri(String supplied) { + requireAscii(supplied, UriName.MAXIMUM_BYTES, "URI SAN"); + rejectControlCharacters(supplied, "URI SAN"); + try { + URI uri = new URI(supplied); + if (!uri.isAbsolute() || uri.isOpaque() || uri.getScheme() == null || uri.getHost() == null + || uri.getHost().isEmpty() || uri.getRawUserInfo() != null || uri.getRawFragment() != null) { + throw new IllegalArgumentException("URI SAN must be an absolute hierarchical host URI"); + } + String host = canonicalDns(uri.getHost(), false); + StringBuilder canonical = new StringBuilder(supplied.length()); + canonical.append(uri.getScheme().toLowerCase(Locale.ROOT)).append("://").append(host); + if (uri.getPort() >= 0) { + canonical.append(':').append(uri.getPort()); + } + canonical.append(uri.getRawPath()); + if (uri.getRawQuery() != null) { + canonical.append('?').append(uri.getRawQuery()); + } + String result = canonical.toString(); + URI reparsed = new URI(result); + if (!host.equals(reparsed.getHost()) || reparsed.getPort() != uri.getPort() + || !java.util.Objects.equals(reparsed.getRawPath(), uri.getRawPath()) + || !java.util.Objects.equals(reparsed.getRawQuery(), uri.getRawQuery())) { + throw new IllegalArgumentException("URI SAN is structurally ambiguous"); + } + return result; + } catch (URISyntaxException exception) { + throw new IllegalArgumentException("Malformed URI SAN"); + } + } + + private static String canonicalMailbox(String supplied) { + requireAscii(supplied, Rfc822Name.MAXIMUM_BYTES, "RFC822 SAN"); + rejectControlCharacters(supplied, "RFC822 SAN"); + if (supplied.indexOf(' ') >= 0 || supplied.indexOf('\t') >= 0 || supplied.indexOf('<') >= 0 + || supplied.indexOf('>') >= 0 || supplied.indexOf('(') >= 0 || supplied.indexOf(')') >= 0) { + throw new IllegalArgumentException("RFC822 SAN must contain one bare mailbox"); + } + int separator = supplied.indexOf('@'); + if (separator <= 0 || separator != supplied.lastIndexOf('@') || separator == supplied.length() - 1) { + throw new IllegalArgumentException("RFC822 SAN must contain one mailbox"); + } + String local = supplied.substring(0, separator); + String domain = canonicalDns(supplied.substring(separator + 1), false); + return local + '@' + domain; + } + + private static void requireAscii(String value, int maximumBytes, String field) { + if (value == null || value.isEmpty()) { + throw new IllegalArgumentException(field + " must not be empty"); + } + byte[] ascii = value.getBytes(StandardCharsets.US_ASCII); + if (ascii.length > maximumBytes || !new String(ascii, StandardCharsets.US_ASCII).equals(value)) { + throw new IllegalArgumentException(field + " must be bounded ASCII"); + } + } + + private static void rejectControlCharacters(String value, String field) { + for (int index = 0; index < value.length(); index++) { + if (Character.isISOControl(value.charAt(index))) { + throw new IllegalArgumentException(field + " must not contain control characters"); + } + } + } + + private static boolean isIpLiteral(String value) { + if (value.indexOf(':') >= 0) { + return true; + } + if (!value.matches("[0-9.]+")) { + return false; + } + String[] octets = value.split("\\.", -1); + if (octets.length != IPV4_OCTET_COUNT) { + return false; + } + for (String octet : octets) { + if (octet.isEmpty() || octet.length() > 3) { + return false; + } + int numeric = 0; + for (int index = 0; index < octet.length(); index++) { + numeric = numeric * 10 + octet.charAt(index) - '0'; + } + if (numeric > MAXIMUM_IPV4_OCTET) { + return false; + } + } + return true; + } +} diff --git a/pki/src/main/java/zeroecho/pki/api/request/SubjectRdn.java b/pki/src/main/java/zeroecho/pki/api/request/SubjectRdn.java new file mode 100644 index 0000000..859a758 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/request/SubjectRdn.java @@ -0,0 +1,32 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.api.request; + +import java.nio.charset.StandardCharsets; + +import zeroecho.pki.api.profile.SubjectRdnRule; +import zeroecho.pki.api.profile.SubjectRdnType; + +/** + * One ordered, single-valued, typed subject distinguished-name component. + * + * @param type supported RDN type + * @param value exact value, with country names canonicalized to uppercase + */ +public record SubjectRdn(SubjectRdnType type, String value) { + + /** + * Validates and constructs the component. + */ + public SubjectRdn { + if (type == null) { + throw new IllegalArgumentException("Subject RDN type must not be null"); + } + value = SubjectRdnRule.canonicalValue(type, value); + if (value.getBytes(StandardCharsets.UTF_8).length > SubjectRdnRule.HARD_MAXIMUM_UTF8_BYTES) { + throw new IllegalArgumentException("Subject RDN value exceeds the hard limit"); + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/ProfileLifecycleFailure.java b/pki/src/main/java/zeroecho/pki/impl/ProfileLifecycleFailure.java new file mode 100644 index 0000000..0d066a7 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/ProfileLifecycleFailure.java @@ -0,0 +1,48 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.impl; + +import zeroecho.pki.api.PkiException; + +/** + * Internal typed classification for redacted profile lifecycle failures. + */ +public final class ProfileLifecycleFailure extends PkiException { + private static final long serialVersionUID = -6689413907385449245L; + + /** Closed profile lifecycle failure codes. */ + public enum Code { + PROFILE_DOCUMENT_INVALID, + PROFILE_IMPORT_VALIDATION_FAILED, + BUILT_IN_PROFILE_INVALID, + PROFILE_VERSION_CONFLICT, + PROFILE_VERSION_CORRUPT, + PROFILE_ACTIVE_POINTER_CORRUPT, + PROFILE_HASH_MISMATCH, + PROFILE_STORE_FAILURE, + PROFILE_DURABILITY_UNCONFIRMED, + PROFILE_IMPORT_FAILED, + PROFILE_STATE_CORRUPT, + PROFILE_VERSION_NOT_FOUND, + PROFILE_ACTIVATION_FAILED, + PROFILE_NOT_ACTIVE, + // Stable public code is intentionally longer than PMD's naming threshold. + @SuppressWarnings("PMD.LongVariable") + PROFILE_ACTIVATION_HISTORY_UNAVAILABLE + } + + private final Code code; + + /** Creates one cause-free failure with a stable code. */ + public ProfileLifecycleFailure(Code code) { + super("Profile lifecycle operation failed: code=" + code); + this.code = code; + } + + /** @return stable failure classification */ + public Code code() { + return 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 new file mode 100644 index 0000000..fef95e0 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/core/CertificateProfileValidator.java @@ -0,0 +1,291 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.impl.core; + +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.MessageDigest; +import java.security.PublicKey; +import java.time.DateTimeException; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import java.security.spec.X509EncodedKeySpec; + +import org.bouncycastle.asn1.ASN1ObjectIdentifier; +import org.bouncycastle.asn1.DERNull; +import org.bouncycastle.asn1.edec.EdECObjectIdentifiers; +import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers; +import org.bouncycastle.asn1.x9.X9ObjectIdentifiers; +import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; + +import zeroecho.pki.api.Encoding; +import zeroecho.pki.api.PkiException; +import zeroecho.pki.api.SubjectRef; +import zeroecho.pki.api.Validity; +import zeroecho.pki.api.attr.AttributeValue; +import zeroecho.pki.api.credential.Credential; +import zeroecho.pki.api.profile.CertificateProfile; +import zeroecho.pki.api.profile.CertificateProfileRef; +import zeroecho.pki.api.profile.LeafCertificatePolicy; +import zeroecho.pki.api.profile.SubjectAlternativeNamePolicy; +import zeroecho.pki.api.profile.SubjectAlternativeNameRule; +import zeroecho.pki.api.profile.SubjectAlternativeNameType; +import zeroecho.pki.api.profile.SubjectRdnRule; +import zeroecho.pki.api.profile.SubjectRdnType; +import zeroecho.pki.api.request.ParsedCertificationRequest; +import zeroecho.pki.api.request.SubjectAlternativeName; +import zeroecho.pki.api.request.SubjectRdn; +import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes; +import zeroecho.pki.impl.framework.x509.bc.BcX509ProfileSupport; + +/** + * Authoritative deny-by-default end-entity profile validation boundary. + */ +// The aggregate complexity is the explicit closed profile-validation grammar. +@SuppressWarnings("PMD.CyclomaticComplexity") +final class CertificateProfileValidator { + + private CertificateProfileValidator() { + } + + /* package */ static ValidatedCertificateRequest validate(VerifiedIssuanceCandidate candidate, + CertificateProfile profile, CertificateProfileRef profileReference, + Credential issuerCredential, Instant evaluationTime) { + ParsedCertificationRequest request = candidate.request(); + LeafCertificatePolicy policy = profile.leafPolicy(); + requireCanonicalRequestAttributes(request); + List approvedSubject = validateSubject(request, policy); + List approvedSans = validateSans(request, policy, approvedSubject.isEmpty()); + requireSubjectKeyAllowed(candidate, policy); + Validity validity = approvedValidity(candidate, request, policy, issuerCredential, evaluationTime); + boolean sanCritical = approvedSubject.isEmpty() + || policy.subjectAlternativeNamePolicy().criticalWithNonemptySubject(); + SubjectRef approvedSubjectRef = new SubjectRef(approvedSubject.isEmpty() + ? "x509:empty-subject" : BcX509ProfileSupport.subject(approvedSubject).toString()); + return new ValidatedCertificateRequest(candidate.issuerCaId(), profileReference, approvedSubjectRef, + approvedSubject, approvedSans, sanCritical, candidate.exactPublicKey(), validity, policy.keyUsages(), + policy.extendedKeyUsages(), policy.keyUsageCritical(), policy.extendedKeyUsageCritical(), + policy.basicConstraintsCritical()); + } + + private static void requireCanonicalRequestAttributes(ParsedCertificationRequest request) { + if (request.attributes().ids().size() != 1 + || !request.attributes().ids().contains(BcX509Attributes.CSR_DER) + || request.attributes().getAll(BcX509Attributes.CSR_DER).size() != 1 + || !(request.attributes().get(BcX509Attributes.CSR_DER).orElse(null) + instanceof AttributeValue.BytesValue)) { + throw reject("REQUEST_ATTRIBUTE_UNSUPPORTED"); + } + } + + // The branches preserve the deny-by-default RDN ownership and cardinality rules. + @SuppressWarnings("PMD.CyclomaticComplexity") + private static List validateSubject(ParsedCertificationRequest request, LeafCertificatePolicy policy) { + Map rules = new EnumMap<>(SubjectRdnType.class); + for (SubjectRdnRule rule : policy.subjectPolicy().rules()) { + rules.put(rule.type(), rule); + } + Map counts = new EnumMap<>(SubjectRdnType.class); + List approved = new ArrayList<>(request.subjectRdns().size() + rules.size()); + for (SubjectRdn rdn : request.subjectRdns()) { + SubjectRdnRule rule = rules.get(rdn.type()); + if (rule == null || !rule.requesterSupplied()) { + throw reject("SUBJECT_RDN_FORBIDDEN"); + } + String canonical = SubjectRdnRule.canonicalValue(rdn.type(), rdn.value()); + if (canonical.getBytes(java.nio.charset.StandardCharsets.UTF_8).length > rule.maximumUtf8Bytes()) { + throw reject("SUBJECT_RDN_TOO_LARGE"); + } + int count = Math.addExact(counts.getOrDefault(rdn.type(), 0), 1); + if (count > rule.maximumOccurrences()) { + throw reject("SUBJECT_RDN_CARDINALITY"); + } + counts.put(rdn.type(), count); + approved.add(new SubjectRdn(rdn.type(), canonical)); + } + for (SubjectRdnRule rule : policy.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("SUBJECT_RDN_REQUIRED"); + } + } + if (approved.isEmpty() && !policy.subjectAlternativeNamePolicy().allowEmptySubject()) { + throw reject("SUBJECT_EMPTY"); + } + if (approved.size() > zeroecho.pki.api.profile.SubjectPolicy.HARD_MAXIMUM_RDN_COUNT) { + throw reject("SUBJECT_TOO_MANY_RDNS"); + } + return List.copyOf(approved); + } + + // The branches preserve the closed SAN type-specific profile grammar. + @SuppressWarnings({ "PMD.CyclomaticComplexity", "PMD.NPathComplexity" }) + private static List validateSans(ParsedCertificationRequest request, + LeafCertificatePolicy policy, boolean emptySubject) { + SubjectAlternativeNamePolicy sanPolicy = policy.subjectAlternativeNamePolicy(); + List sans = request.subjectAlternativeNames(); + if (sans.size() < sanPolicy.minimumTotal() || sans.size() > sanPolicy.maximumTotal()) { + throw reject("SAN_COUNT_INVALID"); + } + Map rules = + new EnumMap<>(SubjectAlternativeNameType.class); + for (SubjectAlternativeNameRule rule : sanPolicy.rules()) { + rules.put(rule.type(), rule); + } + Map counts = new EnumMap<>(SubjectAlternativeNameType.class); + Set unique = new HashSet<>(); + boolean serviceIdentity = false; + boolean emailIdentity = false; + for (SubjectAlternativeName san : sans) { + if (!unique.add(san)) { + throw reject("SAN_DUPLICATE"); + } + SubjectAlternativeNameRule rule = rules.get(san.type()); + if (rule == null) { + throw reject("SAN_TYPE_FORBIDDEN"); + } + int count = Math.addExact(counts.getOrDefault(san.type(), 0), 1); + if (count > rule.maximum()) { + throw reject("SAN_TYPE_CARDINALITY"); + } + counts.put(san.type(), count); + if (san instanceof SubjectAlternativeName.DnsName dns && dns.value().startsWith("*.") + && !sanPolicy.allowDnsWildcard()) { + throw reject("SAN_WILDCARD_FORBIDDEN"); + } + if (san instanceof SubjectAlternativeName.IpAddress ip + && (ip.bytes().length == 4 && !rule.allowIpv4() || ip.bytes().length == 16 && !rule.allowIpv6())) { + throw reject("SAN_IP_FAMILY_FORBIDDEN"); + } + if (san instanceof SubjectAlternativeName.UriName uri) { + String scheme = java.net.URI.create(uri.value()).getScheme(); + if (!sanPolicy.allowedUriSchemes().contains(scheme)) { + throw reject("SAN_URI_SCHEME_FORBIDDEN"); + } + } + serviceIdentity |= san.type() == SubjectAlternativeNameType.DNS_NAME + || san.type() == SubjectAlternativeNameType.IP_ADDRESS + || san.type() == SubjectAlternativeNameType.URI; + emailIdentity |= san.type() == SubjectAlternativeNameType.RFC822_NAME; + } + for (SubjectAlternativeNameRule rule : sanPolicy.rules()) { + int count = counts.getOrDefault(rule.type(), 0); + if (count < rule.minimum() || count > rule.maximum()) { + throw reject("SAN_TYPE_REQUIRED"); + } + } + if (emptySubject && sans.isEmpty() || sanPolicy.requireServiceIdentity() && !serviceIdentity + || sanPolicy.requireEmailIdentity() && !emailIdentity) { + throw reject("SAN_IDENTITY_REQUIRED"); + } + return List.copyOf(sans); + } + + // 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) { + throw reject("SUBJECT_KEY_UNSUPPORTED"); + } + byte[] encoded = candidate.exactPublicKey().bytes(); + try { + SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(encoded); + SubjectKeyAlgorithm algorithm = subjectKeyAlgorithm(spki.getAlgorithm().getAlgorithm()); + requireSupportedParameters(spki, algorithm); + if (!policy.allowedSubjectKeyAlgorithmIds().contains(algorithm.profileId())) { + throw reject("SUBJECT_KEY_ALGORITHM_FORBIDDEN"); + } + PublicKey reconstructed = KeyFactory.getInstance(algorithm.jcaName()) + .generatePublic(new X509EncodedKeySpec(encoded)); + byte[] canonical = reconstructed.getEncoded(); + try { + if (canonical == null || !MessageDigest.isEqual(encoded, canonical)) { + throw reject("SUBJECT_KEY_NOT_CANONICAL"); + } + } finally { + if (canonical != null) { + java.util.Arrays.fill(canonical, (byte) 0); + } + } + } catch (PkiException exception) { + throw exception; + } catch (GeneralSecurityException | IllegalArgumentException exception) { + throw reject("SUBJECT_KEY_UNSUPPORTED"); + } finally { + java.util.Arrays.fill(encoded, (byte) 0); + } + } + + private static SubjectKeyAlgorithm subjectKeyAlgorithm(ASN1ObjectIdentifier oid) { + if (PKCSObjectIdentifiers.rsaEncryption.equals(oid)) { + return new SubjectKeyAlgorithm("RSA", "RSA"); + } + if (X9ObjectIdentifiers.id_ecPublicKey.equals(oid)) { + return new SubjectKeyAlgorithm("ECDSA", "EC"); + } + if (EdECObjectIdentifiers.id_Ed25519.equals(oid)) { + return new SubjectKeyAlgorithm("Ed25519", "Ed25519"); + } + if (EdECObjectIdentifiers.id_Ed448.equals(oid)) { + return new SubjectKeyAlgorithm("Ed448", "Ed448"); + } + throw reject("SUBJECT_KEY_ALGORITHM_UNKNOWN"); + } + + private static void requireSupportedParameters(SubjectPublicKeyInfo spki, SubjectKeyAlgorithm algorithm) { + org.bouncycastle.asn1.ASN1Encodable parameters = spki.getAlgorithm().getParameters(); + boolean supported = switch (algorithm.profileId()) { + case "RSA" -> DERNull.INSTANCE.equals(parameters); + case "ECDSA" -> parameters instanceof ASN1ObjectIdentifier; + case "Ed25519", "Ed448" -> parameters == null; + default -> false; + }; + if (!supported) { + throw reject("SUBJECT_KEY_PARAMETERS_UNSUPPORTED"); + } + } + + // 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) { + 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) { + throw reject("VALIDITY_EXCEEDS_PROFILE"); + } + Instant notAfter; + try { + notAfter = evaluationTime.plus(duration); + } catch (DateTimeException | ArithmeticException exception) { + throw reject("VALIDITY_INVALID"); + } + if (notAfter.isAfter(issuerCredential.validity().notAfter())) { + throw reject("VALIDITY_EXCEEDS_ISSUER"); + } + return new Validity(evaluationTime, notAfter); + } + + private static PkiException reject(String code) { + return new PkiException("End-entity profile validation rejected: code=" + code); + } + + private record SubjectKeyAlgorithm(String profileId, String jcaName) { + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/core/CertificateSerialAllocator.java b/pki/src/main/java/zeroecho/pki/impl/core/CertificateSerialAllocator.java new file mode 100644 index 0000000..10cc143 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/core/CertificateSerialAllocator.java @@ -0,0 +1,41 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.impl.core; + +import java.math.BigInteger; +import java.util.Arrays; + +import zeroecho.core.util.RandomSupport; + +/** + * Allocates positive issuer-controlled X.509 serial numbers. + */ +final class CertificateSerialAllocator { + private static final int SERIAL_BYTES = 20; + + private CertificateSerialAllocator() { + } + + /* package */ static BigInteger allocate() { + byte[] encoded = new byte[SERIAL_BYTES]; + try { + do { + RandomSupport.generateRandom(encoded); + encoded[0] &= 0x7f; + } while (isZero(encoded)); + return new BigInteger(1, encoded); + } finally { + Arrays.fill(encoded, (byte) 0); + } + } + + private static boolean isZero(byte[] bytes) { + int aggregate = 0; + for (byte value : bytes) { + aggregate |= value; + } + return aggregate == 0; + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/core/CredentialProfileBindings.java b/pki/src/main/java/zeroecho/pki/impl/core/CredentialProfileBindings.java new file mode 100644 index 0000000..581bffb --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/core/CredentialProfileBindings.java @@ -0,0 +1,54 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.impl.core; + +import zeroecho.pki.api.PkiException; +import zeroecho.pki.api.credential.CaProfileBinding; +import zeroecho.pki.api.credential.CredentialProfileBinding; +import zeroecho.pki.api.credential.EndEntityProfileBinding; +import zeroecho.pki.api.profile.CertificateProfileRef; + +/** + * Exact credential-category binding validation. + */ +final class CredentialProfileBindings { + + // Shared exact binding validation stays inside the trusted PKI core. + /* default */ static final String MISMATCH_CODE = "CREDENTIAL_PROFILE_BINDING_MISMATCH"; + + private CredentialProfileBindings() { + } + + /* default */ static void requireEndEntityBinding(CredentialProfileBinding binding, + CertificateProfileRef expected) { + if (!(binding instanceof EndEntityProfileBinding endEntity) + || !endEntity.reference().equals(expected)) { + throw mismatch(); + } + } + + /* default */ static void requireCaBinding(CredentialProfileBinding binding, String expectedCaProfileId) { + if (!(binding instanceof CaProfileBinding ca) + || !ca.profileId().equals(expectedCaProfileId)) { + throw mismatch(); + } + } + + private static Mismatch mismatch() { + return new Mismatch(); + } + + /** + * Internal typed mismatch used to preserve stable caller-specific audit mapping. + */ + // Construction and use are restricted to trusted profile-binding callers. + /* default */ static final class Mismatch extends PkiException { + private static final long serialVersionUID = 1L; + + private Mismatch() { + super("Credential profile binding rejected: code=" + MISMATCH_CODE); + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/core/CredentialSnapshots.java b/pki/src/main/java/zeroecho/pki/impl/core/CredentialSnapshots.java index 97fe283..aa33471 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/CredentialSnapshots.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/CredentialSnapshots.java @@ -60,7 +60,8 @@ final class CredentialSnapshots { /* default */ static Credential copy(Credential source) { return new Credential(source.credentialId(), source.formatId(), source.issuerRef(), source.subjectRef(), - source.validity(), source.serialOrUniqueId(), source.publicKeyId(), source.profileId(), source.status(), + source.validity(), source.serialOrUniqueId(), source.publicKeyId(), source.profileBinding(), + source.status(), copy(source.encoded()), copy(source.attributes())); } 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 00759bb..9f386b1 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/DefaultCaService.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/DefaultCaService.java @@ -80,6 +80,7 @@ import zeroecho.pki.api.ca.CaState; import zeroecho.pki.api.ca.IntermediateCertIssueCommand; import zeroecho.pki.api.ca.IntermediateCreateCommand; import zeroecho.pki.api.credential.Credential; +import zeroecho.pki.api.credential.CaProfileBinding; import zeroecho.pki.api.credential.CredentialStatus; import zeroecho.pki.api.credential.CredentialUse; import zeroecho.pki.api.credential.EffectiveCredentialStatus; @@ -150,6 +151,9 @@ import zeroecho.pki.spi.store.PkiStore; public final class DefaultCaService implements CaService { private static final Logger LOG = Logger.getLogger(DefaultCaService.class.getName()); + private static final String CREATE_INT_REJECTED = "CREATE_INTERMEDIATE_REJECTED"; + private static final String ISSUE_INT_REJECTED = "ISSUE_INTERMEDIATE_REJECTED"; + private static final String BACKEND_CRED_MISMATCH = "BACKEND_CREDENTIAL_MISMATCH"; private final PkiStore store; private final CredentialFramework framework; @@ -314,8 +318,9 @@ 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, command.profileId(), CredentialStatus.ISSUED, + serial.toString(), publicKeyId, new CaProfileBinding(command.profileId()), CredentialStatus.ISSUED, new EncodedObject(Encoding.DER, certDer), command.attributes()); + CredentialProfileBindings.requireCaBinding(credential.profileBinding(), command.profileId()); CaRecord ca = new CaRecord(caId, CaKind.ROOT, CaState.ACTIVE, keyRef, subjectRef, List.of(credential)); store.putCa(ca); @@ -387,8 +392,10 @@ public final class DefaultCaService implements CaService { BigInteger serial = holder.getSerialNumber(); Credential credential = new Credential(credId, command.formatId(), new IssuerRef(caId), command.subjectRef(), - validity, serial.toString(), publicKeyId, command.profileId(), CredentialStatus.ISSUED, + validity, serial.toString(), publicKeyId, new CaProfileBinding(command.profileId()), + CredentialStatus.ISSUED, new EncodedObject(Encoding.DER, certDer), command.attributes()); + CredentialProfileBindings.requireCaBinding(credential.profileBinding(), command.profileId()); store.putCredential(credential); CaRecord ca = new CaRecord(caId, CaKind.ROOT, CaState.ACTIVE, command.keyRef(), command.subjectRef(), List.of(credential)); @@ -442,7 +449,7 @@ public final class DefaultCaService implements CaService { throw new PkiException("Issuer CA has no credentials"); } if (!framework.formatId().equals(command.formatId())) { - throw proofGate.rejection("CREATE_INTERMEDIATE_REJECTED", command.formatId(), Optional.empty(), + throw proofGate.rejection(CREATE_INT_REJECTED, command.formatId(), Optional.empty(), "FORMAT_UNSUPPORTED"); } EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation(); @@ -453,9 +460,9 @@ public final class DefaultCaService implements CaService { .getBytes(java.nio.charset.StandardCharsets.UTF_8)).substring(0, 16)); CaProofGate.ManagedKeyProof subjectProof = proofGate.proveManagedKey(command.keyRef().get(), - command.formatId(), "CREATE_INTERMEDIATE_REJECTED", Optional.of(caId)); + command.formatId(), CREATE_INT_REJECTED, Optional.of(caId)); EncodedObject subjectSpki = subjectProof.exactPublicKey(); - requireIssuerKeyBinding(issuer, issuerCredential, command.formatId(), "CREATE_INTERMEDIATE_REJECTED", + requireIssuerKeyBinding(issuer, issuerCredential, command.formatId(), CREATE_INT_REJECTED, Optional.of(caId)); AttributeSet authoritative = authoritativeIntermediateAttributes(command.attributes(), issuer, issuerCredential, subjectSpki, command.subjectRef()); @@ -463,15 +470,24 @@ public final class DefaultCaService implements CaService { ManagedCaIssuance.Operation.CREATE_INTERMEDIATE, command.issuerCaId(), caId, command.profileId(), Optional.empty(), authoritative, command.subjectRef()); + Credential backendCredential; + try { + backendCredential = issuerBackend.issueIntermediateCertificate(issue); + } 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, + command.formatId(), Optional.of(caId)); Credential cred; try { - cred = CredentialSnapshots.copy(issuerBackend.issueIntermediateCertificate(issue)); + cred = CredentialSnapshots.copy(backendCredential); } catch (RuntimeException ex) { // NOPMD - reject malformed or mutable framework output - throw proofGate.rejection("CREATE_INTERMEDIATE_REJECTED", command.formatId(), Optional.of(caId), - "BACKEND_CREDENTIAL_MISMATCH"); + throw proofGate.rejection(CREATE_INT_REJECTED, command.formatId(), Optional.of(caId), + BACKEND_CRED_MISMATCH); } requireIntermediateCredentialMatches(cred, issuerCredential, subjectSpki, command.subjectRef(), - command.issuerCaId(), caId, command.profileId(), "CREATE_INTERMEDIATE_REJECTED"); + command.issuerCaId(), caId, CREATE_INT_REJECTED); store.putCredential(cred); CaRecord subject = new CaRecord(caId, CaKind.INTERMEDIATE, CaState.ACTIVE, command.keyRef().get(), @@ -512,7 +528,8 @@ public final class DefaultCaService implements CaService { CaRecord subject = getCa(command.subjectCaId()); ensureActive(subject, "subject"); if (!framework.formatId().equals(command.formatId())) { - throw proofGate.rejection("ISSUE_INTERMEDIATE_REJECTED", command.formatId(), Optional.of(subject.caId()), + throw proofGate.rejection(ISSUE_INT_REJECTED, command.formatId(), + Optional.of(subject.caId()), "FORMAT_UNSUPPORTED"); } EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation(); @@ -520,9 +537,9 @@ public final class DefaultCaService implements CaService { CredentialUse.INTERMEDIATE_ISSUER, statusEvaluation)); CaProofGate.ManagedKeyProof subjectProof = proofGate.proveManagedKey(subject.issuerKeyRef(), - command.formatId(), "ISSUE_INTERMEDIATE_REJECTED", Optional.of(subject.caId())); + command.formatId(), ISSUE_INT_REJECTED, Optional.of(subject.caId())); EncodedObject subjectSpki = subjectProof.exactPublicKey(); - requireIssuerKeyBinding(issuer, issuerCredential, command.formatId(), "ISSUE_INTERMEDIATE_REJECTED", + requireIssuerKeyBinding(issuer, issuerCredential, command.formatId(), ISSUE_INT_REJECTED, Optional.of(subject.caId())); AttributeSet authoritative = authoritativeIntermediateAttributes(command.attributes(), issuer, issuerCredential, subjectSpki, subject.subjectRef()); @@ -530,15 +547,24 @@ public final class DefaultCaService implements CaService { ManagedCaIssuance.Operation.ISSUE_INTERMEDIATE, command.issuerCaId(), command.subjectCaId(), command.profileId(), command.requestedValidity(), authoritative, subject.subjectRef()); + Credential backendCredential; + try { + backendCredential = issuerBackend.issueIntermediateCertificate(gated); + } 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, + command.formatId(), Optional.of(subject.caId())); Credential cred; try { - cred = CredentialSnapshots.copy(issuerBackend.issueIntermediateCertificate(gated)); + cred = CredentialSnapshots.copy(backendCredential); } catch (RuntimeException ex) { // NOPMD - reject malformed or mutable framework output - throw proofGate.rejection("ISSUE_INTERMEDIATE_REJECTED", command.formatId(), Optional.of(subject.caId()), - "BACKEND_CREDENTIAL_MISMATCH"); + 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(), command.profileId(), "ISSUE_INTERMEDIATE_REJECTED"); + command.issuerCaId(), subject.caId(), ISSUE_INT_REJECTED); store.putCredential(cred); List updated = new ArrayList<>(subject.caCredentials()); @@ -771,16 +797,15 @@ public final class DefaultCaService implements CaService { private void requireIntermediateCredentialMatches(Credential credential, Credential issuerCredential, EncodedObject exactSubjectSpki, SubjectRef subjectRef, PkiId issuerCaId, PkiId subjectCaId, - String profileId, String action) { + String action) { try { if (!framework.formatId().equals(credential.formatId()) || credential.encoded().encoding() != Encoding.DER || credential.status() != CredentialStatus.ISSUED - || !credential.profileId().equals(profileId) || !credential.subjectRef().equals(subjectRef) || !credential.issuerRef().equals(new IssuerRef(issuerCaId))) { throw proofGate.rejection(action, framework.formatId(), Optional.of(subjectCaId), - "BACKEND_CREDENTIAL_MISMATCH"); + BACKEND_CRED_MISMATCH); } X509CertificateHolder holder = new X509CertificateHolder(credential.encoded().bytes()); X509CertificateHolder issuerHolder = new X509CertificateHolder(issuerCredential.encoded().bytes()); @@ -810,13 +835,23 @@ public final class DefaultCaService implements CaService { || credential.validity().notAfter().getEpochSecond() != holder.getNotAfter().toInstant() .getEpochSecond()) { throw proofGate.rejection(action, framework.formatId(), Optional.of(subjectCaId), - "BACKEND_CREDENTIAL_MISMATCH"); + BACKEND_CRED_MISMATCH); } } catch (PkiException ex) { throw ex; } catch (Exception ex) { throw proofGate.rejection(action, framework.formatId(), Optional.of(subjectCaId), - "BACKEND_CREDENTIAL_MISMATCH"); + BACKEND_CRED_MISMATCH); + } + } + + private void requireCaBinding(Credential credential, String expectedCaProfileId, String action, + FormatId formatId, Optional objectId) { + try { + CredentialProfileBindings.requireCaBinding( + credential == null ? null : credential.profileBinding(), expectedCaProfileId); + } catch (PkiException mismatch) { + throw proofGate.rejection(action, formatId, objectId, CredentialProfileBindings.MISMATCH_CODE); } } diff --git a/pki/src/main/java/zeroecho/pki/impl/core/DefaultIssuanceService.java b/pki/src/main/java/zeroecho/pki/impl/core/DefaultIssuanceService.java index ade925a..500084d 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/DefaultIssuanceService.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/DefaultIssuanceService.java @@ -33,7 +33,9 @@ ******************************************************************************/ package zeroecho.pki.impl.core; +import java.math.BigInteger; import java.security.MessageDigest; +import java.time.Clock; import java.time.Instant; import java.util.HexFormat; import java.util.List; @@ -41,7 +43,6 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; -import org.bouncycastle.asn1.x500.X500Name; import org.bouncycastle.cert.X509CertificateHolder; import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder; @@ -49,10 +50,9 @@ import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.Encoding; import zeroecho.pki.api.FormatId; import zeroecho.pki.api.IssuanceService; -import zeroecho.pki.api.KeyRef; import zeroecho.pki.api.PkiException; +import zeroecho.pki.api.ProfileService; import zeroecho.pki.api.PkiId; -import zeroecho.pki.api.attr.AttributeSet; import zeroecho.pki.api.attr.AttributeValue; import zeroecho.pki.api.audit.AuditEvent; import zeroecho.pki.api.audit.Principal; @@ -71,12 +71,14 @@ import zeroecho.pki.api.issuance.ReissueCommand; import zeroecho.pki.api.issuance.RenewCommand; import zeroecho.pki.api.issuance.ReplaceCommand; import zeroecho.pki.api.issuance.VerificationPolicy; +import zeroecho.pki.api.profile.CertificateProfile; +import zeroecho.pki.api.profile.ActiveCertificateProfile; import zeroecho.pki.api.request.CertificationRequest; import zeroecho.pki.api.request.ParsedCertificationRequest; import zeroecho.pki.api.request.ProofOfPossessionResult; import zeroecho.pki.api.request.ProofOfPossessionStatus; -import zeroecho.pki.impl.core.attr.SimpleAttributeSet; import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes; +import zeroecho.pki.impl.framework.x509.bc.BcX509ProfileSupport; import zeroecho.pki.spi.audit.AuditSink; import zeroecho.pki.spi.framework.CredentialFramework; import zeroecho.pki.spi.framework.CredentialIssuerBackend; @@ -95,20 +97,20 @@ import zeroecho.pki.spi.store.PkiStore; *

* *

- * The implementation intentionally enforces only a limited set of issuance-side - * invariants: + * The implementation enforces these issuance-side invariants: *

*
    *
  • the issuer CA must exist,
  • *
  • the issuer CA must be in {@link CaState#ACTIVE} state,
  • *
  • the issuer CA must expose an effectively usable credential for the active * framework {@link FormatId},
  • - *
  • issuer material required by the current X.509 runtime wiring must be - * present in issuance overrides before the backend is invoked,
  • - *
  • the backend result is defensively snapshotted and its X.509 subject key, - * subject, issuer, signature, identifiers, status, profile, serial, and validity - * metadata must match the verified request and selected issuer before - * persistence.
  • + *
  • the signed CSR is reparsed into bounded typed subject and SAN values and + * validated against one exact active deny-by-default profile,
  • + *
  • only a gate-produced {@link ValidatedCertificateRequest} crosses the + * issuer-backend boundary,
  • + *
  • the backend result is reparsed and its subject, SAN, public key, issuer, + * serial, validity, BasicConstraints, KU, EKU, extension criticality, complete + * extension set, and signature must match before persistence.
  • *
* *

@@ -121,8 +123,8 @@ import zeroecho.pki.spi.store.PkiStore; *

Security considerations

*
    *
  • This service does not access private key material directly.
  • - *
  • Issuer key usage is represented only through {@link KeyRef} indirection - * in backend overrides.
  • + *
  • Issuer key usage is represented only through an opaque key reference + * supplied by the trusted issuer selection boundary.
  • *
  • The correctness of issuance semantics depends on the configured * {@link CredentialFramework} matching the requested runtime format and on the * backend honoring the supplied issuer material.
  • @@ -153,6 +155,8 @@ public final class DefaultIssuanceService implements IssuanceService { private final CredentialIssuerBackend issuerBackend; private final AuditSink auditSink; private final EffectiveCredentialStatusResolver statusResolver; + private final ProfileService profileService; + private final Clock clock; /** * Creates the issuance service bound to the supplied persistence and framework @@ -172,12 +176,14 @@ public final class DefaultIssuanceService implements IssuanceService { */ public DefaultIssuanceService(PkiStore store, CredentialFramework framework, CredentialIssuerBackend issuerBackend, AuditSink auditSink, - EffectiveCredentialStatusResolver statusResolver) { + EffectiveCredentialStatusResolver statusResolver, ProfileService profileService, Clock clock) { this.store = Objects.requireNonNull(store, "store"); this.framework = Objects.requireNonNull(framework, "framework"); this.issuerBackend = Objects.requireNonNull(issuerBackend, "issuerBackend"); this.auditSink = Objects.requireNonNull(auditSink, "auditSink"); this.statusResolver = Objects.requireNonNull(statusResolver, "statusResolver"); + this.profileService = Objects.requireNonNull(profileService, "profileService"); + this.clock = Objects.requireNonNull(clock, "clock"); } /** @@ -221,29 +227,42 @@ public final class DefaultIssuanceService implements IssuanceService { throw new PkiException("Issuer CA has no credentials"); } + VerifiedIssuanceCandidate candidate = verifyIssuanceCandidate(command); + ActiveCertificateProfile active; + try { + active = profileService.requireActiveProfile(command.profileId()); + } catch (PkiException failure) { + throw rejection(candidate.request(), "PROFILE_NOT_ACTIVE"); + } + CertificateProfile profile = active.profile(); + if (!command.profileId().equals(active.reference().profileId())) { + throw rejection(candidate.request(), "PROFILE_ID_MISMATCH"); + } + if (!framework.formatId().equals(profile.formatId()) + || !candidate.request().formatId().equals(profile.formatId())) { + throw rejection(candidate.request(), "PROFILE_FORMAT_MISMATCH"); + } + Instant evaluationTime = clock.instant(); EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation(); Credential issuerCred = CredentialSnapshots.copy(selectIssuerCredential(issuer, framework.formatId(), CredentialUse.END_ENTITY_ISSUER, statusEvaluation)); - VerifiedIssuanceCandidate candidate = verifyIssuanceCandidate(command); - AttributeSet enrichedOverrides = enrichOverrides(command.overrides(), issuerCred.encoded(), - issuer.issuerKeyRef()); - - // Defensive invariant: the X.509 backend requires issuer material in overrides. - if (enrichedOverrides.get(BcX509Attributes.ISSUER_CERT_DER).isEmpty()) { - throw new PkiException("Issuer material wiring failed: missing issuer cert DER override"); + ValidatedCertificateRequest validated; + try { + validated = CertificateProfileValidator.validate(candidate, profile, active.reference(), issuerCred, + evaluationTime); + } catch (PkiException exception) { + throw rejection(candidate.request(), statusCode(exception)); } - if (enrichedOverrides.get(BcX509Attributes.ISSUER_KEYREF).isEmpty()) { - throw new PkiException("Issuer material wiring failed: missing issuer keyref override"); - } - candidate = candidate.withAuthoritativeOverrides(command, enrichedOverrides); + BigInteger serial = CertificateSerialAllocator.allocate(); CredentialBundle bundle; try { - bundle = CredentialSnapshots.copy(issuerBackend.issueEndEntity(candidate)); + bundle = CredentialSnapshots.copy(issuerBackend.issueEndEntity(validated, issuerCred.encoded(), + issuer.issuerKeyRef(), serial)); } catch (RuntimeException ex) { // NOPMD - framework output must cross the snapshot boundary throw rejection(candidate.request(), "BACKEND_CREDENTIAL_MISMATCH"); } - requireIssuedCredentialMatches(candidate, command, issuerCred, bundle); + requireIssuedCredentialMatches(validated, issuerCred, serial, bundle, candidate.request()); store.putCredential(bundle.credential()); return bundle; } @@ -301,47 +320,6 @@ public final class DefaultIssuanceService implements IssuanceService { throw new PkiException("Issuer credential unavailable: code=ISSUER_CREDENTIAL_UNAVAILABLE"); } - /** - * Enriches caller-supplied issuance overrides with issuer material required by - * the current X.509 backend wiring. - * - *

    - * Store-authoritative issuer values take precedence. Values for - * {@link BcX509Attributes#ISSUER_CERT_DER} and - * {@link BcX509Attributes#ISSUER_KEYREF} are overwritten from the supplied - * issuer credential encoding and issuer key reference. - *

    - * - *

    - * The returned {@link AttributeSet} is a newly built instance and does not - * mutate the caller-supplied set. - *

    - * - * @param overrides original issuance overrides; must not be {@code null} - * @param issuerCertDer DER-encoded authoritative issuer credential payload; - * must not be {@code null} - * @param issuerKeyRef authoritative issuer key reference; must not be - * {@code null} - * @return enriched attribute set containing authoritative issuer wiring - * @throws NullPointerException if any argument is {@code null} - */ - private static AttributeSet enrichOverrides(AttributeSet overrides, EncodedObject issuerCertDer, - KeyRef issuerKeyRef) { - Objects.requireNonNull(overrides, "overrides"); - Objects.requireNonNull(issuerCertDer, "issuerCertDer"); - Objects.requireNonNull(issuerKeyRef, "issuerKeyRef"); - - SimpleAttributeSet.Builder b = SimpleAttributeSet.builder().putAll(overrides); - byte[] issuerBytes = issuerCertDer.bytes(); - try { - b.put(BcX509Attributes.ISSUER_CERT_DER, new AttributeValue.BytesValue(issuerBytes.clone())); - } finally { - java.util.Arrays.fill(issuerBytes, (byte) 0); - } - b.put(BcX509Attributes.ISSUER_KEYREF, new AttributeValue.StringValue(issuerKeyRef.value())); - return b.build(); - } - private VerifiedIssuanceCandidate verifyIssuanceCandidate(IssueEndEntityCommand command) { ParsedCertificationRequest supplied = command.request(); byte[] csrDer = extractCsrDer(supplied); @@ -350,7 +328,7 @@ public final class DefaultIssuanceService implements IssuanceService { requireExactMatch(supplied, reparsed); ProofOfPossessionStatus proofKind = requireVerifiedProof(supplied, reparsed); return new VerifiedIssuanceCandidate(reparsed, reparsed.requestId(), reparsed.publicKeyInfo(), proofKind, - command, command.overrides()); + command); } finally { java.util.Arrays.fill(csrDer, (byte) 0); } @@ -400,6 +378,15 @@ public final class DefaultIssuanceService implements IssuanceService { if (!supplied.subjectRef().equals(reparsed.subjectRef())) { throw rejection(supplied, "SUBJECT_MISMATCH"); } + if (!supplied.subjectRdns().equals(reparsed.subjectRdns()) + || !supplied.subjectAlternativeNames().equals(reparsed.subjectAlternativeNames()) + || supplied.subjectAlternativeNamePresent() != reparsed.subjectAlternativeNamePresent() + || !supplied.requestedValidity().equals(reparsed.requestedValidity()) + || !supplied.requestedProfileId().equals(reparsed.requestedProfileId()) + || supplied.attributes().ids().size() != 1 + || supplied.attributes().getAll(BcX509Attributes.CSR_DER).size() != 1) { + throw rejection(supplied, "REQUEST_SEMANTICS_MISMATCH"); + } byte[] suppliedSpki = supplied.publicKeyInfo().bytes(); byte[] reparsedSpki = reparsed.publicKeyInfo().bytes(); try { @@ -437,7 +424,7 @@ public final class DefaultIssuanceService implements IssuanceService { private PkiException rejection(ParsedCertificationRequest request, String code) { PkiException rejection = new PkiException("End-entity issuance rejected: " + code); try { - auditSink.record(new AuditEvent(Instant.now(), "ISSUANCE", "ISSUE_END_ENTITY_REJECTED", SYSTEM_PKI, + auditSink.record(new AuditEvent(clock.instant(), "ISSUANCE", "ISSUE_END_ENTITY_REJECTED", SYSTEM_PKI, ISSUANCE_PURPOSE, Optional.empty(), Optional.of(request.formatId()), Map.of("code", code))); } catch (RuntimeException auditFailure) { // NOPMD - preserve stable rejection and fail closed @@ -446,47 +433,58 @@ public final class DefaultIssuanceService implements IssuanceService { return rejection; } - private void requireIssuedCredentialMatches(VerifiedIssuanceCandidate candidate, IssueEndEntityCommand command, - Credential issuerCredential, CredentialBundle bundle) { - ParsedCertificationRequest verifiedRequest = candidate.request(); + private void requireIssuedCredentialMatches(ValidatedCertificateRequest validated, Credential issuerCredential, + BigInteger allocatedSerial, CredentialBundle bundle, ParsedCertificationRequest auditRequest) { if (bundle == null || bundle.credential() == null) { - throw rejection(verifiedRequest, "BACKEND_CREDENTIAL_MISMATCH"); + throw rejection(auditRequest, "BACKEND_CREDENTIAL_MISMATCH"); } Credential credential = bundle.credential(); try { + CredentialProfileBindings.requireEndEntityBinding(credential.profileBinding(), + validated.profileReference()); if (!framework.formatId().equals(credential.formatId()) || credential.encoded().encoding() != Encoding.DER - || !credential.subjectRef().equals(verifiedRequest.subjectRef()) - || !credential.issuerRef().equals(new zeroecho.pki.api.IssuerRef(command.issuerCaId())) - || credential.status() != CredentialStatus.ISSUED - || !credential.profileId().equals(command.profileId())) { - throw rejection(verifiedRequest, "BACKEND_CREDENTIAL_MISMATCH"); + || !credential.subjectRef().equals(validated.subjectRef()) + || !credential.issuerRef().equals(new zeroecho.pki.api.IssuerRef(validated.issuerCaId())) + || credential.status() != CredentialStatus.ISSUED) { + throw rejection(auditRequest, "BACKEND_CREDENTIAL_MISMATCH"); } X509CertificateHolder holder = new X509CertificateHolder(credential.encoded().bytes()); X509CertificateHolder issuerHolder = new X509CertificateHolder(issuerCredential.encoded().bytes()); byte[] actualSpki = holder.getSubjectPublicKeyInfo().getEncoded(); - if (!MessageDigest.isEqual(candidate.exactPublicKey().bytes(), actualSpki) - || !holder.getSubject().equals(new X500Name(verifiedRequest.subjectRef().value())) + if (!MessageDigest.isEqual(validated.exactPublicKey().bytes(), actualSpki) + || !holder.getSubject().equals(BcX509ProfileSupport.subject(validated.subjectRdns())) || !holder.getIssuer().equals(issuerHolder.getSubject()) || !holder.isSignatureValid( new JcaContentVerifierProviderBuilder().build(issuerHolder.getSubjectPublicKeyInfo())) + || !holder.getSerialNumber().equals(allocatedSerial) || !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() + || !credential.validity().equals(validated.validity()) + || validated.validity().notBefore().getEpochSecond() != holder.getNotBefore().toInstant() .getEpochSecond() - || credential.validity().notAfter().getEpochSecond() != holder.getNotAfter().toInstant() - .getEpochSecond()) { - throw rejection(verifiedRequest, "BACKEND_CREDENTIAL_MISMATCH"); + || validated.validity().notAfter().getEpochSecond() != holder.getNotAfter().toInstant() + .getEpochSecond() + || !BcX509ProfileSupport.matchesLeafExtensions(holder, validated)) { + throw rejection(auditRequest, "BACKEND_CREDENTIAL_MISMATCH"); } + } catch (CredentialProfileBindings.Mismatch mismatch) { + throw rejection(auditRequest, CredentialProfileBindings.MISMATCH_CODE); } catch (PkiException ex) { throw ex; } catch (Exception ex) { - throw rejection(verifiedRequest, "BACKEND_CREDENTIAL_MISMATCH"); + throw rejection(auditRequest, "BACKEND_CREDENTIAL_MISMATCH"); } } + private static String statusCode(PkiException exception) { + String message = exception.getMessage(); + int codeIndex = message == null ? -1 : message.indexOf("code="); + return codeIndex < 0 ? "PROFILE_VALIDATION_FAILED" : message.substring(codeIndex + 5); + } + private static String sha256Hex(byte[] input) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(input)); diff --git a/pki/src/main/java/zeroecho/pki/impl/core/DefaultProfileService.java b/pki/src/main/java/zeroecho/pki/impl/core/DefaultProfileService.java new file mode 100644 index 0000000..6197b12 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/core/DefaultProfileService.java @@ -0,0 +1,222 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.impl.core; + +import java.io.InputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Clock; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Supplier; + +import zeroecho.pki.api.ProfileService; +import zeroecho.pki.api.audit.AuditEvent; +import zeroecho.pki.api.audit.Principal; +import zeroecho.pki.api.audit.Purpose; +import zeroecho.pki.api.profile.ActiveCertificateProfile; +import zeroecho.pki.api.profile.BuiltInCertificateProfileTemplate; +import zeroecho.pki.api.profile.CertificateProfileDefinition; +import zeroecho.pki.api.profile.CertificateProfileDocumentCodec; +import zeroecho.pki.api.profile.CertificateProfileRef; +import zeroecho.pki.api.profile.ImportedCertificateProfileVersion; +import zeroecho.pki.impl.ProfileLifecycleFailure; +import zeroecho.pki.impl.ProfileLifecycleFailure.Code; +import zeroecho.pki.spi.audit.AuditSink; +import zeroecho.pki.spi.store.PkiStore; + +/** + * Store-backed implementation of the certificate-profile lifecycle. + */ +public final class DefaultProfileService implements ProfileService { + private static final Principal SYSTEM_PKI = new Principal("SYSTEM", "pki"); + private static final Purpose PROFILE_PURPOSE = new Purpose("PROFILE_LIFECYCLE"); + + private final PkiStore store; + private final Clock clock; + private final AuditSink auditSink; + + /** Creates a profile service using one authoritative clock. */ + public DefaultProfileService(PkiStore store, Clock clock, AuditSink auditSink) { + this.store = Objects.requireNonNull(store, "store"); + this.clock = Objects.requireNonNull(clock, "clock"); + this.auditSink = Objects.requireNonNull(auditSink, "auditSink"); + } + + @Override + public CertificateProfileRef importProfile(byte[] jsonDocument) { + Objects.requireNonNull(jsonDocument, "jsonDocument"); + return executeSanitized(() -> { + CertificateProfileDefinition definition = CertificateProfileDocumentCodec.parse(jsonDocument); + byte[] canonical = CertificateProfileDocumentCodec.writeCanonical(definition); + return importCanonical(definition, canonical, clock.instant()); + }, Code.PROFILE_IMPORT_VALIDATION_FAILED); + } + + @Override + public CertificateProfileRef importProfile(InputStream jsonDocument) { + Objects.requireNonNull(jsonDocument, "jsonDocument"); + return executeSanitized(() -> { + CertificateProfileDefinition definition = CertificateProfileDocumentCodec.parse(jsonDocument); + byte[] canonical = CertificateProfileDocumentCodec.writeCanonical(definition); + return importCanonical(definition, canonical, clock.instant()); + }, Code.PROFILE_IMPORT_VALIDATION_FAILED); + } + + @Override + public CertificateProfileRef importBuiltIn(BuiltInCertificateProfileTemplate template) { + Objects.requireNonNull(template, "template"); + byte[] canonical = template.canonicalJson(); + return executeSanitized(() -> { + CertificateProfileDefinition parsed = CertificateProfileDocumentCodec.parse(canonical); + byte[] reserialized = CertificateProfileDocumentCodec.writeCanonical(parsed); + if (!Arrays.equals(canonical, reserialized) + || !parsed.equals(template.definition()) + || parsed.profileVersion() != template.definition().profileVersion() + || !parsed.profileId().equals(template.definition().profileId()) + || !MessageDigest.isEqual(sha256(canonical), template.canonicalSha256())) { + throw new ProfileLifecycleFailure(Code.BUILT_IN_PROFILE_INVALID); + } + return importProfile(canonical); + }, Code.BUILT_IN_PROFILE_INVALID); + } + + @Override + public CertificateProfileRef activateProfile(String profileId, long profileVersion) { + requireProfileId(profileId); + if (profileVersion <= 0) { + throw new IllegalArgumentException("profileVersion must be positive"); + } + return executeAudited("PROFILE_ACTIVATE", profileId, profileVersion, + Code.PROFILE_ACTIVATION_FAILED, () -> { + CertificateProfileRef result = store.activateProfile(profileId, profileVersion); + audit("PROFILE_ACTIVATE", result, "SUCCESS"); + return result; + }); + } + + @Override + public ActiveCertificateProfile requireActiveProfile(String profileId) { + requireProfileId(profileId); + return executeAudited("PROFILE_RESOLVE", profileId, 0L, + Code.PROFILE_STORE_FAILURE, () -> store.requireActiveProfile(profileId)); + } + + @Override + public Optional getImportedVersion(String profileId, long profileVersion) { + requireProfileId(profileId); + if (profileVersion <= 0) { + throw new IllegalArgumentException("profileVersion must be positive"); + } + return executeSanitized(() -> store.getProfileVersion(profileId, profileVersion), + Code.PROFILE_STORE_FAILURE); + } + + @Override + public List listImportedVersions(String profileId) { + requireProfileId(profileId); + return executeSanitized(() -> store.listProfileVersions(profileId), Code.PROFILE_STORE_FAILURE); + } + + @Override + public Optional getActiveReference(String profileId) { + requireProfileId(profileId); + return executeSanitized(() -> store.getActiveProfileRef(profileId), Code.PROFILE_STORE_FAILURE); + } + + private CertificateProfileRef importCanonical(CertificateProfileDefinition definition, byte[] canonical, + Instant importedAt) { + CertificateProfileRef reference = new CertificateProfileRef(definition.profileId(), + definition.profileVersion(), sha256(canonical)); + ImportedCertificateProfileVersion version = new ImportedCertificateProfileVersion(reference, + CertificateProfileDefinition.SCHEMA_VERSION, definition, canonical, importedAt); + return executeAudited("PROFILE_IMPORT", definition.profileId(), + definition.profileVersion(), Code.PROFILE_IMPORT_FAILED, () -> { + ImportedCertificateProfileVersion committed = store.importProfileVersion(version); + String result = committed.importedAt().equals(importedAt) ? "SUCCESS" : "UNCHANGED"; + audit("PROFILE_IMPORT", committed.reference(), result); + return committed.reference(); + }); + } + + private void audit(String operation, CertificateProfileRef reference, String result) { + auditSafe(new AuditEvent(clock.instant(), "PROFILE", operation, SYSTEM_PKI, PROFILE_PURPOSE, + Optional.empty(), Optional.empty(), Map.of("profileId", reference.profileId(), + "profileVersion", Long.toString(reference.profileVersion()), + "fingerprint", reference.shortFingerprint(), "result", result))); + } + + private void auditFailure(String operation, String profileId, long profileVersion, String code) { + Map details = profileVersion > 0 + ? Map.of("profileId", profileId, "profileVersion", Long.toString(profileVersion), "code", code) + : Map.of("profileId", profileId, "code", code); + auditSafe(new AuditEvent(clock.instant(), "PROFILE", operation + "_REJECTED", SYSTEM_PKI, PROFILE_PURPOSE, + Optional.empty(), Optional.empty(), details)); + } + + // Audit listeners are external best-effort callbacks and cannot affect state. + @SuppressWarnings("PMD.AvoidCatchingGenericException") + private void auditSafe(AuditEvent event) { + try { + auditSink.record(event); + } catch (RuntimeException ignored) { + // Best-effort audit callbacks cannot alter committed profile state. + } + } + + private static byte[] sha256(byte[] value) { + try { + return MessageDigest.getInstance("SHA-256").digest(value); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 unavailable", impossible); + } + } + + private static void requireProfileId(String profileId) { + if (profileId == null || profileId.isBlank()) { + throw new IllegalArgumentException("profileId must not be null/blank"); + } + } + + private static ProfileLifecycleFailure sanitize(RuntimeException failure, Code fallback) { + if (failure instanceof ProfileLifecycleFailure classified) { + return new ProfileLifecycleFailure(classified.code()); + } + return new ProfileLifecycleFailure(fallback); + } + + /* + * Attacker-controlled parser and store causes are intentionally replaced by + * stable, cause-free lifecycle failures. + */ + @SuppressWarnings("PMD.AvoidCatchingGenericException") + private static T executeSanitized(Supplier operation, Code fallback) { + try { + return operation.get(); + } catch (RuntimeException failure) { + throw sanitize(failure, fallback); + } + } + + /* + * This is the audited counterpart of executeSanitized; audit runs only + * after store coordination has returned. + */ + @SuppressWarnings("PMD.AvoidCatchingGenericException") + private T executeAudited(String operation, String profileId, long profileVersion, + Code fallback, Supplier action) { + try { + return action.get(); + } catch (RuntimeException failure) { + ProfileLifecycleFailure sanitized = sanitize(failure, fallback); + auditFailure(operation, profileId, profileVersion, sanitized.code().name()); + throw sanitized; + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/core/ValidatedCertificateRequest.java b/pki/src/main/java/zeroecho/pki/impl/core/ValidatedCertificateRequest.java new file mode 100644 index 0000000..10bcca0 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/core/ValidatedCertificateRequest.java @@ -0,0 +1,136 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.impl.core; + +import java.util.List; +import java.util.Objects; +import java.util.Set; + +import zeroecho.pki.api.EncodedObject; +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.SubjectRef; +import zeroecho.pki.api.Validity; +import zeroecho.pki.api.profile.ExtendedKeyUsageId; +import zeroecho.pki.api.profile.CertificateProfileRef; +import zeroecho.pki.api.profile.LeafKeyUsage; +import zeroecho.pki.api.request.SubjectAlternativeName; +import zeroecho.pki.api.request.SubjectRdn; + +/** + * Immutable gate-produced end-entity certificate construction authority. + * + *

    + * The constructor is package-private so only the core profile validator can + * create an instance. The type carries no generic attributes, raw CSR extension + * bytes, requester serial, or issuer-controlled material. + *

    + */ +@SuppressWarnings({ "PMD.DataClass", "PMD.ExcessiveParameterList" }) +public final class ValidatedCertificateRequest { + private final PkiId issuerCaId; + private final CertificateProfileRef profileReference; + private final SubjectRef subjectRef; + private final List subjectRdns; + private final List subjectAlternativeNames; + private final boolean subjectAlternativeNameCritical; + private final EncodedObject exactPublicKey; + private final Validity validity; + private final Set keyUsages; + private final Set extendedKeyUsages; + private final boolean keyUsageCritical; + private final boolean extendedKeyUsageCritical; + private final boolean basicConstraintsCritical; + + /* default */ ValidatedCertificateRequest(PkiId issuerCaId, CertificateProfileRef profileReference, + SubjectRef subjectRef, + List subjectRdns, List subjectAlternativeNames, + boolean subjectAlternativeNameCritical, EncodedObject exactPublicKey, Validity validity, + Set keyUsages, Set extendedKeyUsages, boolean keyUsageCritical, + boolean extendedKeyUsageCritical, boolean basicConstraintsCritical) { + this.issuerCaId = Objects.requireNonNull(issuerCaId, "issuerCaId"); + this.profileReference = Objects.requireNonNull(profileReference, "profileReference"); + this.subjectRef = Objects.requireNonNull(subjectRef, "subjectRef"); + this.subjectRdns = List.copyOf(subjectRdns); + this.subjectAlternativeNames = List.copyOf(subjectAlternativeNames); + this.subjectAlternativeNameCritical = subjectAlternativeNameCritical; + this.exactPublicKey = new EncodedObject(exactPublicKey.encoding(), exactPublicKey.bytes()); + this.validity = Objects.requireNonNull(validity, "validity"); + this.keyUsages = Set.copyOf(keyUsages); + this.extendedKeyUsages = Set.copyOf(extendedKeyUsages); + this.keyUsageCritical = keyUsageCritical; + this.extendedKeyUsageCritical = extendedKeyUsageCritical; + this.basicConstraintsCritical = basicConstraintsCritical; + } + + /** @return authoritative issuer CA identifier */ + public PkiId issuerCaId() { + return issuerCaId; + } + + /** @return exact selected active profile reference */ + public CertificateProfileRef profileReference() { + return profileReference; + } + + /** @return selected active profile identifier */ + public String profileId() { + return profileReference.profileId(); + } + + /** @return canonical inventory subject reference */ + public SubjectRef subjectRef() { + return subjectRef; + } + + /** @return ordered immutable approved subject RDNs */ + public List subjectRdns() { + return subjectRdns; + } + + /** @return ordered immutable approved SAN entries */ + public List subjectAlternativeNames() { + return subjectAlternativeNames; + } + + /** @return profile-derived SAN criticality */ + public boolean subjectAlternativeNameCritical() { + return subjectAlternativeNameCritical; + } + + /** @return defensive copy of the exact proof-bound SPKI */ + public EncodedObject exactPublicKey() { + return new EncodedObject(exactPublicKey.encoding(), exactPublicKey.bytes()); + } + + /** @return approved issuer-time validity */ + public Validity validity() { + return validity; + } + + /** @return exact profile-derived key usages */ + public Set keyUsages() { + return keyUsages; + } + + /** @return exact profile-derived extended key usages */ + public Set extendedKeyUsages() { + return extendedKeyUsages; + } + + /** @return key-usage criticality */ + public boolean keyUsageCritical() { + return keyUsageCritical; + } + + /** @return extended-key-usage criticality */ + public boolean extendedKeyUsageCritical() { + return extendedKeyUsageCritical; + } + + /** @return leaf BasicConstraints criticality */ + public boolean basicConstraintsCritical() { + return basicConstraintsCritical; + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/core/VerifiedIssuanceCandidate.java b/pki/src/main/java/zeroecho/pki/impl/core/VerifiedIssuanceCandidate.java index ad35cf1..7af519f 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/VerifiedIssuanceCandidate.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/VerifiedIssuanceCandidate.java @@ -33,21 +33,22 @@ ******************************************************************************/ package zeroecho.pki.impl.core; -import java.util.ArrayList; -import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.ArrayList; +import java.util.List; import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.PkiId; 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.issuance.IssueEndEntityCommand; +import zeroecho.pki.api.attr.AttributeSet; +import zeroecho.pki.api.attr.AttributeId; +import zeroecho.pki.api.attr.AttributeValue; import zeroecho.pki.api.request.ParsedCertificationRequest; import zeroecho.pki.api.request.ProofOfPossessionStatus; import zeroecho.pki.impl.core.attr.SimpleAttributeSet; +import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes; /** * Immutable internal authority for a request that passed the issuance PoP gate. @@ -68,11 +69,9 @@ public final class VerifiedIssuanceCandidate { private final PkiId issuerCaId; private final String profileId; private final Optional validityOverride; - private final AttributeSet overrides; /* default */ VerifiedIssuanceCandidate(ParsedCertificationRequest request, PkiId fingerprint, - EncodedObject exactPublicKey, ProofOfPossessionStatus proofKind, IssueEndEntityCommand command, - AttributeSet authoritativeOverrides) { + EncodedObject exactPublicKey, ProofOfPossessionStatus proofKind, IssueEndEntityCommand command) { this.request = snapshot(Objects.requireNonNull(request, "request")); this.fingerprint = Objects.requireNonNull(fingerprint, "fingerprint"); this.exactPublicKey = copy(Objects.requireNonNull(exactPublicKey, "exactPublicKey")); @@ -81,7 +80,6 @@ public final class VerifiedIssuanceCandidate { this.issuerCaId = checkedCommand.issuerCaId(); this.profileId = checkedCommand.profileId(); this.validityOverride = checkedCommand.validityOverride(); - this.overrides = snapshotAttributes(Objects.requireNonNull(authoritativeOverrides, "authoritativeOverrides")); } /** @@ -147,44 +145,40 @@ public final class VerifiedIssuanceCandidate { return validityOverride; } - /** - * Returns a defensive snapshot of authoritative issuance overrides. - * - * @return immutable authoritative overrides, never {@code null} - */ - public AttributeSet overrides() { - return snapshotAttributes(overrides); - } - - /* default */ VerifiedIssuanceCandidate withAuthoritativeOverrides(IssueEndEntityCommand command, - AttributeSet authoritativeOverrides) { - return new VerifiedIssuanceCandidate(request, fingerprint, exactPublicKey, proofKind, command, - authoritativeOverrides); - } - /* default */ static ParsedCertificationRequest snapshot(ParsedCertificationRequest source) { Objects.requireNonNull(source, "source"); return new ParsedCertificationRequest(source.requestId(), source.formatId(), source.subjectRef(), copy(source.publicKeyInfo()), source.requestedValidity(), source.requestedProfileId(), - snapshotAttributes(source.attributes())); + source.subjectRdns(), source.subjectAlternativeNames(), source.subjectAlternativeNamePresent(), + snapshotCsrAttribute(source.attributes())); } private static EncodedObject copy(EncodedObject source) { return new EncodedObject(source.encoding(), source.bytes().clone()); } + private static AttributeSet snapshotCsrAttribute(AttributeSet source) { + AttributeValue value = source.get(BcX509Attributes.CSR_DER) + .orElseThrow(() -> new IllegalArgumentException("Missing canonical CSR attribute")); + if (!(value instanceof AttributeValue.BytesValue bytesValue)) { + throw new IllegalArgumentException("Canonical CSR attribute has the wrong type"); + } + return new SimpleAttributeSet(List.of(new SimpleAttributeSet.Entry(BcX509Attributes.CSR_DER, + List.of(new AttributeValue.BytesValue(bytesValue.value()))))); + } + /* default */ static AttributeSet snapshotAttributes(AttributeSet source) { List entries = new ArrayList<>(); for (AttributeId id : source.ids()) { - List values = source.getAll(id).stream().map(VerifiedIssuanceCandidate::copy).toList(); + List values = source.getAll(id).stream().map(VerifiedIssuanceCandidate::copyValue).toList(); entries.add(new SimpleAttributeSet.Entry(id, values)); } return new SimpleAttributeSet(entries); } - private static AttributeValue copy(AttributeValue value) { + private static AttributeValue copyValue(AttributeValue value) { if (value instanceof AttributeValue.BytesValue bytesValue) { - return new AttributeValue.BytesValue(bytesValue.value().clone()); + return new AttributeValue.BytesValue(bytesValue.value()); } return value; } diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CertificationRequestParser.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CertificationRequestParser.java index 9f1bd82..cd79f9d 100644 --- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CertificationRequestParser.java +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CertificationRequestParser.java @@ -38,10 +38,27 @@ import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.HexFormat; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.Set; +import org.bouncycastle.asn1.ASN1Encodable; +import org.bouncycastle.asn1.ASN1OctetString; +import org.bouncycastle.asn1.ASN1String; +import org.bouncycastle.asn1.DERIA5String; +import org.bouncycastle.asn1.DERPrintableString; +import org.bouncycastle.asn1.DERUTF8String; +import org.bouncycastle.asn1.pkcs.Attribute; +import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers; +import org.bouncycastle.asn1.x500.RDN; import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.Extensions; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; import org.bouncycastle.pkcs.PKCS10CertificationRequest; import org.bouncycastle.util.io.pem.PemObject; import org.bouncycastle.util.io.pem.PemReader; @@ -52,8 +69,11 @@ import zeroecho.pki.api.PkiException; import zeroecho.pki.api.PkiId; import zeroecho.pki.api.SubjectRef; import zeroecho.pki.api.attr.AttributeValue; +import zeroecho.pki.api.profile.SubjectRdnType; import zeroecho.pki.api.request.CertificationRequest; import zeroecho.pki.api.request.ParsedCertificationRequest; +import zeroecho.pki.api.request.SubjectAlternativeName; +import zeroecho.pki.api.request.SubjectRdn; import zeroecho.pki.spi.framework.CertificationRequestParser; /** @@ -108,9 +128,13 @@ import zeroecho.pki.spi.framework.CertificationRequestParser; * This class is stateless and thread-safe. *

    */ -// PMD cannot infer that retaining parser causes would violate the redaction contract. -@SuppressWarnings("PMD.PreserveStackTrace") +// PMD cannot infer the redaction boundary or the explicit closed parsing grammar. +@SuppressWarnings({ "PMD.PreserveStackTrace", "PMD.CyclomaticComplexity" }) public final class BcX509CertificationRequestParser implements CertificationRequestParser { + private static final int MAXIMUM_SUBJECT_DER_BYTES = 16 * 1024; + private static final int MAXIMUM_SAN_DER_BYTES = 32 * 1024; + private static final int MAXIMUM_SUBJECT_RDNS = 32; + private static final int SINGLE_ATTRIBUTE_VALUE = 1; /** * Parses a PKCS#10 certification request into the normalized PKI request * representation. @@ -165,7 +189,9 @@ public final class BcX509CertificationRequestParser implements CertificationRequ } X500Name subject = csr.getSubject(); - SubjectRef subjectRef = new SubjectRef(subject.toString()); + List subjectRdns = parseSubject(subject); + SubjectRef subjectRef = subjectReference(subjectRdns); + List subjectAlternativeNames = parseSubjectAlternativeNames(csr); try { spki = csr.getSubjectPublicKeyInfo().getEncoded(); @@ -180,7 +206,8 @@ public final class BcX509CertificationRequestParser implements CertificationRequ .put(BcX509Attributes.CSR_DER, new AttributeValue.BytesValue(csrDer.clone())).build(); return new ParsedCertificationRequest(requestId, request.formatId(), subjectRef, publicKeyInfo, - Optional.empty(), Optional.empty(), attrs); + Optional.empty(), Optional.empty(), subjectRdns, subjectAlternativeNames, + !subjectAlternativeNames.isEmpty(), attrs); } finally { java.util.Arrays.fill(csrDer, (byte) 0); if (spki != null) { @@ -189,6 +216,127 @@ public final class BcX509CertificationRequestParser implements CertificationRequ } } + // Each approved RDN requires one immutable typed value and round-trip check. + @SuppressWarnings({ "PMD.CyclomaticComplexity", "PMD.AvoidInstantiatingObjectsInLoops" }) + private static List parseSubject(X500Name subject) { + try { + if (subject.getEncoded().length > MAXIMUM_SUBJECT_DER_BYTES) { + throw new PkiException("CSR subject rejected: code=SUBJECT_TOO_LARGE"); + } + RDN[] rdns = subject.getRDNs(); + if (rdns.length > MAXIMUM_SUBJECT_RDNS) { + throw new PkiException("CSR subject rejected: code=SUBJECT_TOO_MANY_RDNS"); + } + List result = new ArrayList<>(rdns.length); + for (RDN rdn : rdns) { + if (rdn == null || rdn.isMultiValued() || rdn.getFirst() == null) { + throw new PkiException("CSR subject rejected: code=SUBJECT_MULTIVALUED"); + } + SubjectRdnType type; + try { + type = SubjectRdnType.fromOid(rdn.getFirst().getType().getId()); + } catch (IllegalArgumentException exception) { + throw new PkiException("CSR subject rejected: code=SUBJECT_RDN_UNSUPPORTED"); + } + ASN1Encodable encodedValue = rdn.getFirst().getValue(); + if (!(encodedValue instanceof DERUTF8String || encodedValue instanceof DERPrintableString + || encodedValue instanceof DERIA5String) || !(encodedValue instanceof ASN1String stringValue)) { + throw new PkiException("CSR subject rejected: code=SUBJECT_VALUE_UNSUPPORTED"); + } + SubjectRdn parsed = new SubjectRdn(type, stringValue.getString()); + X500Name rebuilt = BcX509ProfileSupport.subject(List.of(parsed)); + ASN1Encodable rebuiltValue = rebuilt.getRDNs()[0].getFirst().getValue(); + if (!(rebuiltValue instanceof ASN1String rebuiltString) + || !rebuiltString.getString().equals(parsed.value())) { + throw new PkiException("CSR subject rejected: code=SUBJECT_VALUE_AMBIGUOUS"); + } + result.add(parsed); + } + return List.copyOf(result); + } catch (PkiException exception) { + throw exception; + } catch (Exception exception) { + throw new PkiException("CSR subject rejected: code=SUBJECT_MALFORMED"); + } + } + + private static SubjectRef subjectReference(List rdns) { + return new SubjectRef(rdns.isEmpty() ? "x509:empty-subject" : BcX509ProfileSupport.subject(rdns).toString()); + } + + // The branches enforce the closed PKCS#10 extensionRequest grammar. + @SuppressWarnings({ "PMD.CyclomaticComplexity", "PMD.AvoidRethrowingException" }) + private static List parseSubjectAlternativeNames(PKCS10CertificationRequest csr) { + Attribute[] allAttributes = csr.getAttributes(); + Attribute[] extensionRequests = csr.getAttributes(PKCSObjectIdentifiers.pkcs_9_at_extensionRequest); + if (extensionRequests.length > 1 || allAttributes.length != extensionRequests.length) { + throw new PkiException("CSR attributes rejected: code=CSR_ATTRIBUTE_UNSUPPORTED"); + } + if (extensionRequests.length == 0) { + return List.of(); + } + Attribute request = extensionRequests[0]; + if (request.getAttrValues().size() != SINGLE_ATTRIBUTE_VALUE) { + throw new PkiException("CSR extensions rejected: code=EXTENSION_REQUEST_MALFORMED"); + } + Extensions extensions; + try { + extensions = Extensions.getInstance(request.getAttrValues().getObjectAt(0)); + } catch (IllegalArgumentException exception) { + throw new PkiException("CSR extensions rejected: code=EXTENSION_REQUEST_MALFORMED"); + } + org.bouncycastle.asn1.ASN1ObjectIdentifier[] identifiers = extensions.getExtensionOIDs(); + if (identifiers.length != 1 || !Extension.subjectAlternativeName.equals(identifiers[0])) { + throw new PkiException("CSR extensions rejected: code=EXTENSION_UNSUPPORTED"); + } + Extension sanExtension = extensions.getExtension(Extension.subjectAlternativeName); + if (sanExtension == null || sanExtension.isCritical()) { + throw new PkiException("CSR extensions rejected: code=SAN_CRITICALITY_REQUESTED"); + } + try { + if (sanExtension.getEncoded().length > MAXIMUM_SAN_DER_BYTES) { + throw new PkiException("CSR extensions rejected: code=SAN_TOO_LARGE"); + } + GeneralName[] names = GeneralNames.getInstance(sanExtension.getParsedValue()).getNames(); + if (names.length == 0 || names.length > 64) { + throw new PkiException("CSR extensions rejected: code=SAN_COUNT_INVALID"); + } + List result = new ArrayList<>(names.length); + Set unique = new HashSet<>(); + for (GeneralName name : names) { + SubjectAlternativeName parsed = parseGeneralName(name); + if (!unique.add(parsed)) { + throw new PkiException("CSR extensions rejected: code=SAN_DUPLICATE"); + } + result.add(parsed); + } + return List.copyOf(result); + } catch (PkiException exception) { + throw exception; + } catch (IllegalArgumentException | IllegalStateException | java.io.IOException exception) { + throw new PkiException("CSR extensions rejected: code=SAN_MALFORMED"); + } + } + + private static SubjectAlternativeName parseGeneralName(GeneralName name) { + return switch (name.getTagNo()) { + case GeneralName.dNSName -> new SubjectAlternativeName.DnsName(asAsciiString(name.getName())); + case GeneralName.iPAddress -> new SubjectAlternativeName.IpAddress( + ASN1OctetString.getInstance(name.getName()).getOctets()); + case GeneralName.uniformResourceIdentifier -> + new SubjectAlternativeName.UriName(asAsciiString(name.getName())); + case GeneralName.rfc822Name -> new SubjectAlternativeName.Rfc822Name(asAsciiString(name.getName())); + default -> throw new PkiException("CSR extensions rejected: code=SAN_TYPE_UNSUPPORTED"); + }; + } + + private static String asAsciiString(ASN1Encodable value) { + if (!(value instanceof ASN1String stringValue)) { + throw new PkiException("CSR extensions rejected: code=SAN_MALFORMED"); + } + return stringValue.getString(); + } + /** * Converts the supplied encoded object to DER form. * 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 f8f78ba..bbe4c45 100644 --- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialIssuerBackend.java +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialIssuerBackend.java @@ -41,9 +41,15 @@ import java.util.Date; import java.util.HexFormat; import java.util.Optional; +import org.bouncycastle.asn1.ASN1ObjectIdentifier; +import org.bouncycastle.asn1.DEROctetString; import org.bouncycastle.asn1.x500.X500Name; import org.bouncycastle.asn1.x509.BasicConstraints; import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.ExtendedKeyUsage; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.asn1.x509.KeyPurposeId; import org.bouncycastle.asn1.x509.KeyUsage; import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; import org.bouncycastle.cert.X509CertificateHolder; @@ -62,10 +68,14 @@ 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; 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.VerifiedIssuanceCandidate; +import zeroecho.pki.impl.core.ValidatedCertificateRequest; import zeroecho.pki.impl.core.async.PkiSigningBus; import zeroecho.pki.impl.core.attr.SimpleAttributeSet; import zeroecho.pki.spi.framework.CredentialIssuerBackend; @@ -82,21 +92,11 @@ import zeroecho.pki.spi.framework.CredentialIssuerBackend; *

    * *

    - * The backend expects issuer-side runtime wiring to be supplied through - * framework-specific attributes, especially: - *

    - *
      - *
    • {@link BcX509Attributes#ISSUER_CERT_DER},
    • - *
    • {@link BcX509Attributes#ISSUER_KEYREF},
    • - *
    • optionally {@link BcX509Attributes#SERIAL}.
    • - *
    - * - *

    - * End-entity issuance primarily derives the subject distinguished name and - * subject public key information from the proof-gated - * {@link VerifiedIssuanceCandidate}. Intermediate CA issuance relies on its - * proof-gated managed-CA input and framework attributes because it operates on - * an existing CA subject entity rather than a CSR-centric flow. + * 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. *

    * *

    Signing model

    @@ -162,55 +162,45 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack * Issues an end-entity X.509 certificate and returns it as a credential bundle. * *

    - * The method derives issuer wiring from the supplied overrides, uses the parsed - * verified candidate to obtain the subject distinguished name and public key, - * constructs a - * leaf certificate with basic end-entity extensions, delegates signing through - * {@link PkiSigningBus}, and returns the resulting leaf credential bundled with - * the issuer certificate. + * The method consumes only validated identity and extension policy, trusted + * issuer material, and an issuer-controlled serial. It delegates signing + * through {@link PkiSigningBus}. *

    * - * @param candidate gate-produced verified issuance candidate; must not be - * {@code null} + * @param request gate-produced validated request + * @param issuerCertificate trusted issuer certificate + * @param issuerKeyRef trusted issuer key reference + * @param serial issuer-controlled positive serial * @return issued X.509 credential bundle containing the leaf certificate and * the issuer certificate as accompanying bundle material - * @throws IllegalArgumentException if {@code command} is {@code null} + * @throws IllegalArgumentException if any argument violates the contract * @throws PkiException if issuer wiring is missing or invalid, * certificate construction fails, signing * fails, or certificate encoding fails */ @Override - public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) { - if (candidate == null) { - throw new IllegalArgumentException("candidate must not be null"); + public CredentialBundle issueEndEntity(ValidatedCertificateRequest request, EncodedObject issuerCertificate, + KeyRef issuerKeyRef, BigInteger serial) { + if (request == null || issuerCertificate == null || issuerKeyRef == null || serial == null + || serial.signum() <= 0 || serial.toByteArray().length > 20) { + throw new IllegalArgumentException("Invalid validated end-entity issuance input"); + } + byte[] issuerDer = issuerCertificate.bytes(); + X509CertificateHolder issuer; + try { + issuer = IssuanceContext.parseIssuerCertificateOrThrow(issuerDer); + } finally { + java.util.Arrays.fill(issuerDer, (byte) 0); } - - IssuanceContext ctx = IssuanceContext.from(candidate.overrides()); - X509CertificateHolder issuer = ctx.issuerCertHolder; - zeroecho.pki.api.request.ParsedCertificationRequest request = candidate.request(); - - Instant now = Instant.now(); - Validity validity = candidate.validityOverride().orElseGet( - () -> request.requestedValidity().orElse(new Validity(now, now.plus(Duration.ofDays(365))))); - - BigInteger serial = ctx.serial - .orElse(BigInteger.valueOf(Math.abs(request.requestId().value().hashCode()) + 1L)); - X500Name issuerDn = issuer.getSubject(); - X500Name subjectDn = new X500Name(request.subjectRef().value()); - SubjectPublicKeyInfo spki = parseSubjectPublicKeyInfo(candidate.exactPublicKey()); + X500Name subjectDn = BcX509ProfileSupport.subject(request.subjectRdns()); + SubjectPublicKeyInfo spki = parseSubjectPublicKeyInfo(request.exactPublicKey()); X509v3CertificateBuilder builder = new X509v3CertificateBuilder(issuerDn, serial, - Date.from(validity.notBefore()), Date.from(validity.notAfter()), subjectDn, spki); - try { - builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false)); - builder.addExtension(Extension.keyUsage, true, - new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyEncipherment)); - } catch (Exception ex) { - throw new PkiException("X.509 extension construction failed: code=EXTENSION_BUILD_FAILED"); - } + Date.from(request.validity().notBefore()), Date.from(request.validity().notAfter()), subjectDn, spki); + addLeafExtensions(builder, request); - ContentSigner signer = new PkiBusContentSigner(signingBus, ctx.issuerKeyRef, signatureAlgorithmId, signingTtl); + ContentSigner signer = new PkiBusContentSigner(signingBus, issuerKeyRef, signatureAlgorithmId, signingTtl); X509CertificateHolder leaf; try { leaf = builder.build(signer); @@ -226,22 +216,74 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack } PkiId credId = new PkiId("x509:" + sha256Hex(certDer)); - PkiId publicKeyId = new PkiId("spki:" + fingerprintEncoded(candidate.exactPublicKey())); - - AttributeSet attributes = mergeAttributes(request.attributes(), candidate.overrides()); + PkiId publicKeyId = new PkiId("spki:" + fingerprintEncoded(request.exactPublicKey())); try { Credential credential = new Credential(credId, BcX509CredentialFramework.FORMAT_ID, - new IssuerRef(candidate.issuerCaId()), request.subjectRef(), validity, serial.toString(), - publicKeyId, candidate.profileId(), CredentialStatus.ISSUED, + new IssuerRef(request.issuerCaId()), request.subjectRef(), request.validity(), serial.toString(), + publicKeyId, new EndEntityProfileBinding(request.profileReference()), CredentialStatus.ISSUED, new EncodedObject(Encoding.DER, certDer), - attributes); - return new CredentialBundle(credential, java.util.List.of(ctx.issuerCertEncoded)); + SimpleAttributeSet.builder().build()); + return new CredentialBundle(credential, java.util.List.of(issuerCertificate)); } finally { java.util.Arrays.fill(certDer, (byte) 0); } } + private static void addLeafExtensions(X509v3CertificateBuilder builder, ValidatedCertificateRequest request) { + try { + builder.addExtension(Extension.basicConstraints, request.basicConstraintsCritical(), + new BasicConstraints(false)); + if (!request.keyUsages().isEmpty()) { + builder.addExtension(Extension.keyUsage, request.keyUsageCritical(), + new KeyUsage(toKeyUsageBits(request.keyUsages()))); + } + if (!request.extendedKeyUsages().isEmpty()) { + KeyPurposeId[] purposes = request.extendedKeyUsages().stream() + .map(value -> KeyPurposeId.getInstance(new ASN1ObjectIdentifier(value.oid()))) + .toArray(KeyPurposeId[]::new); + builder.addExtension(Extension.extendedKeyUsage, request.extendedKeyUsageCritical(), + new ExtendedKeyUsage(purposes)); + } + if (!request.subjectAlternativeNames().isEmpty()) { + GeneralName[] names = request.subjectAlternativeNames().stream() + .map(BcX509CredentialIssuerBackend::toGeneralName).toArray(GeneralName[]::new); + builder.addExtension(Extension.subjectAlternativeName, request.subjectAlternativeNameCritical(), + new GeneralNames(names)); + } + } catch (Exception exception) { + throw new PkiException("X.509 extension construction failed: code=EXTENSION_BUILD_FAILED"); + } + } + + private static int toKeyUsageBits(java.util.Set usages) { + int bits = 0; + for (LeafKeyUsage usage : usages) { + bits |= switch (usage) { + case DIGITAL_SIGNATURE -> KeyUsage.digitalSignature; + case CONTENT_COMMITMENT -> KeyUsage.nonRepudiation; + case KEY_ENCIPHERMENT -> KeyUsage.keyEncipherment; + case DATA_ENCIPHERMENT -> KeyUsage.dataEncipherment; + case KEY_AGREEMENT -> KeyUsage.keyAgreement; + case ENCIPHER_ONLY -> KeyUsage.encipherOnly; + case DECIPHER_ONLY -> KeyUsage.decipherOnly; + }; + } + return bits; + } + + private static GeneralName toGeneralName(SubjectAlternativeName name) { + return switch (name) { + case SubjectAlternativeName.DnsName dns -> new GeneralName(GeneralName.dNSName, dns.value()); + case SubjectAlternativeName.IpAddress ip -> + new GeneralName(GeneralName.iPAddress, new DEROctetString(ip.bytes())); + case SubjectAlternativeName.UriName uri -> + new GeneralName(GeneralName.uniformResourceIdentifier, uri.value()); + case SubjectAlternativeName.Rfc822Name email -> + new GeneralName(GeneralName.rfc822Name, email.value()); + }; + } + /** * Issues an intermediate CA X.509 certificate. * @@ -318,7 +360,8 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack try { return new Credential(credId, issuance.formatId(), new IssuerRef(issuance.issuerCaId()), subjectRef, - validity, serial.toString(), publicKeyId, issuance.profileId(), CredentialStatus.ISSUED, + validity, serial.toString(), publicKeyId, new CaProfileBinding(issuance.profileId()), + CredentialStatus.ISSUED, new EncodedObject(Encoding.DER, certDer), issuance.attributes()); } finally { java.util.Arrays.fill(certDer, (byte) 0); @@ -339,7 +382,6 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack private static final class IssuanceContext { private final X509CertificateHolder issuerCertHolder; - private final EncodedObject issuerCertEncoded; private final KeyRef issuerKeyRef; private final Optional serial; @@ -348,16 +390,13 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack * * @param issuerCertHolder parsed issuer certificate holder; must not be * {@code null} - * @param issuerCertEncoded issuer certificate encoded object; 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, EncodedObject issuerCertEncoded, - KeyRef issuerKeyRef, Optional serial) { + private IssuanceContext(X509CertificateHolder issuerCertHolder, KeyRef issuerKeyRef, + Optional serial) { this.issuerCertHolder = issuerCertHolder; - this.issuerCertEncoded = issuerCertEncoded; this.issuerKeyRef = issuerKeyRef; this.serial = serial; } @@ -404,9 +443,7 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack .map(BigInteger::valueOf); X509CertificateHolder issuerHolder = parseIssuerCertificateOrThrow(issuerCertDer); - EncodedObject issuerEncoded = new EncodedObject(Encoding.DER, issuerCertDer); - - return new IssuanceContext(issuerHolder, issuerEncoded, issuerKeyRef, serial); + return new IssuanceContext(issuerHolder, issuerKeyRef, serial); } /** @@ -489,26 +526,6 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack } } - /** - * Merges two attribute sets into a new immutable attribute set. - * - *

    - * Values from {@code b} replace values from {@code a} for the same attribute - * identifier because {@link SimpleAttributeSet.Builder} uses last-write-wins - * semantics. - *

    - * - * @param a base attributes; must not be {@code null} - * @param b overriding attributes; must not be {@code null} - * @return merged immutable attribute set - */ - private static AttributeSet mergeAttributes(AttributeSet a, AttributeSet b) { - SimpleAttributeSet.Builder out = SimpleAttributeSet.builder(); - out.putAll(a); - out.putAll(b); - return out.build(); - } - /** * Computes the SHA-256 digest of the supplied bytes and returns it as a * lowercase hexadecimal string. diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509ProfileSupport.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509ProfileSupport.java new file mode 100644 index 0000000..52308e1 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509ProfileSupport.java @@ -0,0 +1,192 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.impl.framework.x509.bc; + +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import org.bouncycastle.asn1.ASN1ObjectIdentifier; +import org.bouncycastle.asn1.ASN1OctetString; +import org.bouncycastle.asn1.ASN1String; +import org.bouncycastle.asn1.DERIA5String; +import org.bouncycastle.asn1.DERPrintableString; +import org.bouncycastle.asn1.DERUTF8String; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x500.X500NameBuilder; +import org.bouncycastle.asn1.x500.style.BCStyle; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.ExtendedKeyUsage; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.asn1.x509.KeyPurposeId; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.cert.X509CertificateHolder; + +import zeroecho.pki.api.profile.LeafKeyUsage; +import zeroecho.pki.api.request.SubjectAlternativeName; +import zeroecho.pki.api.request.SubjectRdn; +import zeroecho.pki.impl.core.ValidatedCertificateRequest; + +/** + * Exact X.509 representation and postcondition helpers for validated leaf + * profiles. + */ +public final class BcX509ProfileSupport { + private BcX509ProfileSupport() { + } + + /** + * Builds the exact ordered subject name. + * + * @param rdns validated ordered RDNs + * @return exact X.509 name + */ + // ASN.1 value construction is necessarily one object per approved RDN. + @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") + public static X500Name subject(List rdns) { + X500NameBuilder builder = new X500NameBuilder(BCStyle.INSTANCE); + for (SubjectRdn rdn : rdns) { + builder.addRDN(new ASN1ObjectIdentifier(rdn.type().oid()), subjectValue(rdn)); + } + return builder.build(); + } + + private static org.bouncycastle.asn1.ASN1Encodable subjectValue(SubjectRdn rdn) { + return switch (rdn.type()) { + case COUNTRY_NAME, SERIAL_NUMBER -> new DERPrintableString(rdn.value(), true); + case EMAIL_ADDRESS -> new DERIA5String(rdn.value(), true); + case COMMON_NAME, ORGANIZATION_NAME, ORGANIZATIONAL_UNIT_NAME, STATE_OR_PROVINCE_NAME, LOCALITY_NAME, + PSEUDONYM -> new DERUTF8String(rdn.value()); + }; + } + + /** + * Verifies the complete allowed leaf extension set and exact values. + * + * @param holder issued certificate + * @param request validated request + * @return {@code true} only when every extension matches + */ + // Malformed post-signing ASN.1 is a false postcondition, not an exposed parser failure. + @SuppressWarnings("PMD.AvoidCatchingGenericException") + public static boolean matchesLeafExtensions(X509CertificateHolder holder, ValidatedCertificateRequest request) { + try { + Set expectedOids = new LinkedHashSet<>(); + expectedOids.add(Extension.basicConstraints); + if (!request.keyUsages().isEmpty()) { + expectedOids.add(Extension.keyUsage); + } + if (!request.extendedKeyUsages().isEmpty()) { + expectedOids.add(Extension.extendedKeyUsage); + } + if (!request.subjectAlternativeNames().isEmpty()) { + expectedOids.add(Extension.subjectAlternativeName); + } + ASN1ObjectIdentifier[] encodedOids = holder.getExtensions().getExtensionOIDs(); + Set actualOids = new LinkedHashSet<>(Arrays.asList(encodedOids)); + if (actualOids.size() != encodedOids.length || !actualOids.equals(expectedOids)) { + return false; + } + Extension basic = holder.getExtension(Extension.basicConstraints); + if (basic == null || basic.isCritical() != request.basicConstraintsCritical() + || BasicConstraints.getInstance(basic.getParsedValue()).isCA()) { + return false; + } + if (!matchesKeyUsage(holder, request) || !matchesExtendedKeyUsage(holder, request)) { + return false; + } + return matchesSubjectAlternativeNames(holder, request); + } catch (RuntimeException exception) { + return false; + } + } + + private static boolean matchesKeyUsage(X509CertificateHolder holder, ValidatedCertificateRequest request) { + Extension extension = holder.getExtension(Extension.keyUsage); + if (request.keyUsages().isEmpty()) { + return extension == null; + } + if (extension == null || extension.isCritical() != request.keyUsageCritical()) { + return false; + } + KeyUsage actual = KeyUsage.getInstance(extension.getParsedValue()); + KeyUsage expected = new KeyUsage(toKeyUsageBits(request.keyUsages())); + return Arrays.equals(actual.getBytes(), expected.getBytes()) && actual.getPadBits() == expected.getPadBits(); + } + + private static boolean matchesExtendedKeyUsage(X509CertificateHolder holder, + ValidatedCertificateRequest request) { + Extension extension = holder.getExtension(Extension.extendedKeyUsage); + if (request.extendedKeyUsages().isEmpty()) { + return extension == null; + } + if (extension == null || extension.isCritical() != request.extendedKeyUsageCritical()) { + return false; + } + ExtendedKeyUsage actual = ExtendedKeyUsage.getInstance(extension.getParsedValue()); + KeyPurposeId[] actualUsages = actual.getUsages(); + Set actualOids = new LinkedHashSet<>(); + for (KeyPurposeId purpose : actualUsages) { + actualOids.add(purpose.getId()); + } + Set expectedOids = new LinkedHashSet<>(); + request.extendedKeyUsages().forEach(value -> expectedOids.add(value.oid())); + return actualOids.size() == actualUsages.length && actualOids.equals(expectedOids); + } + + private static boolean matchesSubjectAlternativeNames(X509CertificateHolder holder, + ValidatedCertificateRequest request) { + Extension extension = holder.getExtension(Extension.subjectAlternativeName); + if (request.subjectAlternativeNames().isEmpty()) { + return extension == null; + } + if (extension == null || extension.isCritical() != request.subjectAlternativeNameCritical()) { + return false; + } + GeneralName[] names = GeneralNames.getInstance(extension.getParsedValue()).getNames(); + if (names.length != request.subjectAlternativeNames().size()) { + return false; + } + for (int index = 0; index < names.length; index++) { + if (!parse(names[index]).equals(request.subjectAlternativeNames().get(index))) { + return false; + } + } + return true; + } + + private static SubjectAlternativeName parse(GeneralName name) { + return switch (name.getTagNo()) { + case GeneralName.dNSName -> + new SubjectAlternativeName.DnsName(((ASN1String) name.getName()).getString()); + case GeneralName.iPAddress -> + new SubjectAlternativeName.IpAddress(ASN1OctetString.getInstance(name.getName()).getOctets()); + case GeneralName.uniformResourceIdentifier -> + new SubjectAlternativeName.UriName(((ASN1String) name.getName()).getString()); + case GeneralName.rfc822Name -> + new SubjectAlternativeName.Rfc822Name(((ASN1String) name.getName()).getString()); + default -> throw new IllegalArgumentException("Unsupported SAN type"); + }; + } + + private static int toKeyUsageBits(Set usages) { + int bits = 0; + for (LeafKeyUsage usage : usages) { + bits |= switch (usage) { + case DIGITAL_SIGNATURE -> KeyUsage.digitalSignature; + case CONTENT_COMMITMENT -> KeyUsage.nonRepudiation; + case KEY_ENCIPHERMENT -> KeyUsage.keyEncipherment; + case DATA_ENCIPHERMENT -> KeyUsage.dataEncipherment; + case KEY_AGREEMENT -> KeyUsage.keyAgreement; + case ENCIPHER_ONLY -> KeyUsage.encipherOnly; + case DECIPHER_ONLY -> KeyUsage.decipherOnly; + }; + } + return bits; + } +} 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 56c5eb0..68271a1 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java @@ -76,7 +76,9 @@ import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.orch.SigningSubmissionId; import zeroecho.pki.api.orch.WorkflowStateRecord; import zeroecho.pki.api.policy.PolicyTrace; -import zeroecho.pki.api.profile.CertificateProfile; +import zeroecho.pki.api.profile.ActiveCertificateProfile; +import zeroecho.pki.api.profile.CertificateProfileRef; +import zeroecho.pki.api.profile.ImportedCertificateProfileVersion; import zeroecho.pki.api.publication.PublicationRecord; import zeroecho.pki.api.request.ParsedCertificationRequest; import zeroecho.pki.api.revocation.RevocationCommand; @@ -85,6 +87,8 @@ import zeroecho.pki.api.revocation.RevocationReason; import zeroecho.pki.api.revocation.RevocationState; import zeroecho.pki.api.revocation.RevocationTransition; import zeroecho.pki.api.status.StatusObject; +import zeroecho.pki.impl.ProfileLifecycleFailure; +import zeroecho.pki.impl.ProfileLifecycleFailure.Code; import zeroecho.pki.impl.core.async.PkiSigningBus; import zeroecho.pki.spi.store.PkiStore; import zeroecho.pki.spi.store.SignWorkflowStore; @@ -162,6 +166,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { private final ReentrantLock signingTimeLock; private final ConcurrentMap signLocks; private final ConcurrentMap revocationLocks; + private final ConcurrentMap profileLocks; private final AtomicBoolean durabilityUncertain; private final StoreOwnership ownership; @@ -197,6 +202,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { this.clock = Objects.requireNonNull(clock, "clock"); this.signLocks = new ConcurrentHashMap<>(); this.revocationLocks = new ConcurrentHashMap<>(); + this.profileLocks = new ConcurrentHashMap<>(); this.durabilityUncertain = new AtomicBoolean(); this.signingTimeLock = new ReentrantLock(); this.paths = new FsPaths(root); @@ -442,31 +448,123 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { } @Override - public void putProfile(final CertificateProfile profile) { + // Persistence causes may contain filesystem data and are intentionally redacted. + @SuppressWarnings("PMD.PreserveStackTrace") + public ImportedCertificateProfileVersion importProfileVersion(final ImportedCertificateProfileVersion version) { requireStoreUsable(); - Objects.requireNonNull(profile, "profile"); - String profileId = profile.profileId(); - Path current = this.paths.profileCurrent(profileId); - - writeWithHistory(this.paths.profileHistoryDir(profileId), current, - FsCodec.encode(FsCodec.CERTIFICATE_PROFILE, profile), - this.options.profileHistoryPolicy(), "PROFILE", FsUtil.safeSegment(profileId)); - } - - @Override - public Optional getProfile(final String profileId) { - requireStoreUsable(); - if (profileId == null || profileId.isBlank()) { - throw new IllegalArgumentException("profileId must not be null/blank"); + Objects.requireNonNull(version, "version"); + String profileId = version.reference().profileId(); + ProfileLockEntry lock = acquireProfileLock(profileId); + try { + ValidatedImportedProfile.validate(version, profileId, version.reference().profileVersion()); + Path target = paths.profileVersion(profileId, version.reference().profileVersion()); + if (Files.exists(target)) { + ImportedCertificateProfileVersion existing = decodeProfileVersion(target, profileId, + version.reference().profileVersion()); + if (!existing.reference().equals(version.reference())) { + throw new ProfileLifecycleFailure(Code.PROFILE_VERSION_CONFLICT); + } + return existing; + } + try { + FsOperations.writeNewAtomicStrict(target, FsCodec.encode(FsCodec.PROFILE_VERSION, version)); + return version; + } catch (FileAlreadyExistsException conflict) { + ImportedCertificateProfileVersion existing = decodeProfileVersion(target, profileId, + version.reference().profileVersion()); + if (!existing.reference().equals(version.reference())) { + throw new ProfileLifecycleFailure(Code.PROFILE_VERSION_CONFLICT); + } + return existing; + } catch (FsOperations.DurabilityUncertainException failure) { + durabilityUncertain.set(true); + throw new ProfileLifecycleFailure(Code.PROFILE_DURABILITY_UNCONFIRMED); + } catch (IOException failure) { + throw new ProfileLifecycleFailure(Code.PROFILE_IMPORT_FAILED); + } + } finally { + releaseProfileLock(profileId, lock); } - return readOptional(this.paths.profileCurrent(profileId), FsCodec.CERTIFICATE_PROFILE); } @Override - public List listProfiles() { + public Optional getProfileVersion(final String profileId, + final long profileVersion) { requireStoreUsable(); - Path root = this.paths.root().resolve("profiles").resolve("by-id"); - return listCurrentRecords(root, FsCodec.CERTIFICATE_PROFILE); + requireProfileIdentity(profileId, profileVersion); + Path path = paths.profileVersion(profileId, profileVersion); + return Files.exists(path) ? Optional.of(decodeProfileVersion(path, profileId, profileVersion)) + : Optional.empty(); + } + + @Override + // Filesystem failures are normalized to the stable store failure code. + @SuppressWarnings("PMD.PreserveStackTrace") + public List listProfileVersions(final String profileId) { + requireStoreUsable(); + requireProfileIdentity(profileId, 1L); + Path directory = paths.profileVersionsDir(profileId); + if (!Files.isDirectory(directory)) { + return List.of(); + } + try (Stream files = Files.list(directory)) { + List versions = new ArrayList<>(); + for (Path file : files.filter(Files::isRegularFile) + .sorted(Comparator.comparingLong(FilesystemPkiStore::profileVersionFromPath)).toList()) { + long version = profileVersionFromPath(file); + versions.add(decodeProfileVersion(file, profileId, version)); + } + return List.copyOf(versions); + } catch (IOException | SecurityException failure) { + throw new ProfileLifecycleFailure(Code.PROFILE_STORE_FAILURE); + } + } + + @Override + // Persistence causes may contain filesystem data and are intentionally redacted. + @SuppressWarnings("PMD.PreserveStackTrace") + public CertificateProfileRef activateProfile(final String profileId, final long profileVersion) { + requireStoreUsable(); + requireProfileIdentity(profileId, profileVersion); + ProfileLockEntry lock = acquireProfileLock(profileId); + try { + ImportedCertificateProfileVersion version = getProfileVersion(profileId, profileVersion) + .orElseThrow(() -> new ProfileLifecycleFailure(Code.PROFILE_VERSION_NOT_FOUND)); + CertificateProfileRef reference = version.reference(); + Optional current = readActiveProfileRef(profileId); + if (current.filter(reference::equals).isPresent()) { + return reference; + } + try { + FsOperations.writeAtomicStrict(paths.profileActive(profileId), + FsCodec.encode(FsCodec.ACTIVE_PROFILE_REF, reference)); + return reference; + } catch (FsOperations.DurabilityUncertainException failure) { + durabilityUncertain.set(true); + throw new ProfileLifecycleFailure(Code.PROFILE_DURABILITY_UNCONFIRMED); + } catch (IOException failure) { + throw new ProfileLifecycleFailure(Code.PROFILE_ACTIVATION_FAILED); + } + } finally { + releaseProfileLock(profileId, lock); + } + } + + @Override + public Optional getActiveProfileRef(final String profileId) { + requireStoreUsable(); + requireProfileIdentity(profileId, 1L); + return readValidatedActiveProfile(profileId).map(validated -> validated.version().reference()); + } + + @Override + public ActiveCertificateProfile requireActiveProfile(final String profileId) { + requireStoreUsable(); + requireProfileIdentity(profileId, 1L); + ValidatedImportedProfile validated = readValidatedActiveProfile(profileId) + .orElseThrow(() -> new ProfileLifecycleFailure(Code.PROFILE_NOT_ACTIVE)); + ImportedCertificateProfileVersion version = validated.version(); + return new ActiveCertificateProfile(version.reference(), version.definition()); } @Override @@ -822,6 +920,126 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { }); } + private ProfileLockEntry acquireProfileLock(String profileId) { + ProfileLockEntry entry = profileLocks.compute(profileId, (ignored, current) -> { + ProfileLockEntry selected = current == null ? new ProfileLockEntry() : current; + selected.references.incrementAndGet(); + return selected; + }); + entry.lock.lock(); + return entry; + } + + private void releaseProfileLock(String profileId, ProfileLockEntry entry) { + entry.lock.unlock(); + profileLocks.computeIfPresent(profileId, (ignored, current) -> { + if (current != entry) { // NOPMD - identity protects a replacement lock entry + return current; + } + return current.references.decrementAndGet() == 0 ? null : current; + }); + } + + /* + * Strict decoding may throw several runtime parsing failures. They are + * deliberately collapsed without causes at this hostile persistence boundary. + */ + @SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" }) + private Optional readActiveProfileRef(String profileId) { + Path path = paths.profileActive(profileId); + if (!Files.exists(path)) { + return Optional.empty(); + } + try { + CertificateProfileRef reference = FsCodec.decode(FsCodec.ACTIVE_PROFILE_REF, + FsOperations.readAll(path)); + if (!profileId.equals(reference.profileId())) { + throw new ProfileLifecycleFailure(Code.PROFILE_ACTIVE_POINTER_CORRUPT); + } + return Optional.of(reference); + } catch (ProfileLifecycleFailure failure) { + throw failure; + } catch (IOException failure) { + throw new ProfileLifecycleFailure(Code.PROFILE_STORE_FAILURE); + } catch (RuntimeException failure) { + throw new ProfileLifecycleFailure(Code.PROFILE_ACTIVE_POINTER_CORRUPT); + } + } + + private Optional readValidatedActiveProfile(String profileId) { + ProfileLockEntry lock = acquireProfileLock(profileId); + try { + Optional pointer = readActiveProfileRef(profileId); + if (pointer.isEmpty()) { + return Optional.empty(); + } + CertificateProfileRef reference = pointer.orElseThrow(); + Path path = paths.profileVersion(profileId, reference.profileVersion()); + if (!Files.isRegularFile(path)) { + throw new ProfileLifecycleFailure(Code.PROFILE_ACTIVE_POINTER_CORRUPT); + } + ValidatedImportedProfile validated = + decodeValidatedProfileVersion(path, profileId, reference.profileVersion()); + if (!reference.equals(validated.version().reference())) { + throw new ProfileLifecycleFailure(Code.PROFILE_HASH_MISMATCH); + } + return Optional.of(validated); + } finally { + releaseProfileLock(profileId, lock); + } + } + + private static ImportedCertificateProfileVersion decodeProfileVersion(Path path, String profileId, + long profileVersion) { + return decodeValidatedProfileVersion(path, profileId, profileVersion).version(); + } + + /* + * Strict codec and policy constructors expose multiple runtime failure types; + * all are intentionally normalized without their input-bearing causes. + */ + @SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" }) + private static ValidatedImportedProfile decodeValidatedProfileVersion(Path path, String profileId, + long profileVersion) { + try { + return ValidatedImportedProfile.decode(FsOperations.readAll(path), profileId, profileVersion); + } catch (ProfileLifecycleFailure failure) { + throw failure; + } catch (IOException failure) { + throw new ProfileLifecycleFailure(Code.PROFILE_STORE_FAILURE); + } catch (RuntimeException failure) { + throw new ProfileLifecycleFailure(Code.PROFILE_VERSION_CORRUPT); + } + } + + // Numeric path details are intentionally excluded from the public failure. + @SuppressWarnings("PMD.PreserveStackTrace") + private static long profileVersionFromPath(Path path) { + String name = path.getFileName().toString(); + if (!name.endsWith(".bin")) { + throw new ProfileLifecycleFailure(Code.PROFILE_VERSION_CORRUPT); + } + long value; + try { + value = Long.parseLong(name.substring(0, name.length() - 4)); + } catch (NumberFormatException failure) { + throw new ProfileLifecycleFailure(Code.PROFILE_VERSION_CORRUPT); + } + if (value <= 0) { + throw new ProfileLifecycleFailure(Code.PROFILE_VERSION_CORRUPT); + } + return value; + } + + private static void requireProfileIdentity(String profileId, long profileVersion) { + if (profileId == null || profileId.isBlank()) { + throw new IllegalArgumentException("profileId must not be null/blank"); + } + if (profileVersion <= 0) { + throw new IllegalArgumentException("profileVersion must be positive"); + } + } + private Optional readRevocationJournal(PkiId credentialId) { Path path = paths.revocationJournal(credentialId); if (!Files.exists(path)) { @@ -1217,6 +1435,12 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { private final AtomicInteger references = new AtomicInteger(); } + /** Reference-counted per-profile import and activation lock. */ + private static final class ProfileLockEntry { + private final ReentrantLock lock = new ReentrantLock(); + private final AtomicInteger references = new AtomicInteger(); + } + /** * Owns the operating-system resources that exclude a second store process. * 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 ba59130..c50fdde 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java @@ -39,7 +39,6 @@ import java.io.EOFException; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; -import java.time.Duration; import java.time.Instant; import java.time.DateTimeException; import java.util.ArrayList; @@ -65,18 +64,28 @@ import zeroecho.pki.api.audit.Principal; import zeroecho.pki.api.ca.CaKind; import zeroecho.pki.api.ca.CaRecord; import zeroecho.pki.api.ca.CaState; +import zeroecho.pki.api.credential.CaProfileBinding; import zeroecho.pki.api.credential.Credential; +import zeroecho.pki.api.credential.CredentialProfileBinding; import zeroecho.pki.api.credential.CredentialStatus; +import zeroecho.pki.api.credential.EndEntityProfileBinding; import zeroecho.pki.api.orch.OrchestrationDurabilityPolicy; import zeroecho.pki.api.orch.WorkflowStateRecord; import zeroecho.pki.api.policy.PolicyTrace; import zeroecho.pki.api.policy.PolicyTraceStep; -import zeroecho.pki.api.profile.CertificateProfile; +import zeroecho.pki.api.profile.CertificateProfileDefinition; +import zeroecho.pki.api.profile.CertificateProfileDocumentCodec; +import zeroecho.pki.api.profile.CertificateProfileRef; +import zeroecho.pki.api.profile.ImportedCertificateProfileVersion; +import zeroecho.pki.api.profile.SubjectAlternativeNameType; +import zeroecho.pki.api.profile.SubjectRdnType; import zeroecho.pki.api.publication.PublicationRecord; import zeroecho.pki.api.publication.PublicationStatus; import zeroecho.pki.api.publication.PublicationTarget; import zeroecho.pki.api.publication.PublicationTargetType; import zeroecho.pki.api.request.ParsedCertificationRequest; +import zeroecho.pki.api.request.SubjectAlternativeName; +import zeroecho.pki.api.request.SubjectRdn; import zeroecho.pki.api.revocation.RevocationJournal; import zeroecho.pki.api.revocation.RevocationReason; import zeroecho.pki.api.revocation.RevocationState; @@ -104,6 +113,7 @@ import zeroecho.pki.spi.store.SignWorkflowStore; * structurally and decoded to {@link SimpleAttributeSet}. *

    */ +// The closed codec keeps every strict schema in one non-reflective authority. @SuppressWarnings("PMD.CouplingBetweenObjects") final class FsCodec { @@ -119,17 +129,17 @@ final class FsCodec { private static final int TOP_REVOCATION = 4; private static final int TOP_STATUS_OBJECT = 5; private static final int TOP_PUBLICATION = 6; - private static final int TOP_CERTIFICATE_PROFILE = 7; private static final int TOP_POLICY_TRACE = 8; private static final int TOP_WORKFLOW_STATE = 9; private static final int TOP_SIGN_WORKFLOW_RECORD = 10; + private static final int TOP_PROFILE_VERSION = 11; + private static final int TOP_ACTIVE_PROFILE_REF = 12; private static final int TYPE_STRING = 1; private static final int TYPE_BOOLEAN = 2; private static final int TYPE_LONG = 3; private static final int TYPE_BYTES = 4; private static final int TYPE_INSTANT = 5; - private static final int TYPE_DURATION = 6; private static final int TYPE_LIST = 7; private static final int TYPE_OPTIONAL = 8; private static final int TYPE_PKI_ID = 20; @@ -158,6 +168,12 @@ final class FsCodec { private static final int TYPE_SIGN_STATE_ENUM = 59; private static final int TYPE_REVOCATION_STATE_ENUM = 60; private static final int TYPE_REVOCATION_TRANSITION = 61; + private static final int TYPE_SUBJECT_RDN_TYPE_ENUM = 62; + private static final int TYPE_SAN_TYPE_ENUM = 63; + private static final int TYPE_SUBJECT_RDN = 65; + private static final int TYPE_SAN = 66; + private static final int TYPE_PROFILE_REF = 72; + private static final int TYPE_PROFILE_BINDING = 73; private static final int ATTRIBUTE_STRING = 1; private static final int ATTRIBUTE_BOOLEAN = 2; @@ -175,8 +191,16 @@ final class FsCodec { private static final ValueSchema BYTES = valueSchema(TYPE_BYTES, Writer::writeBytes, Reader::readBytes); private static final ValueSchema INSTANT = valueSchema(TYPE_INSTANT, Writer::writeInstant, Reader::readInstant); - private static final ValueSchema DURATION = valueSchema(TYPE_DURATION, Writer::writeDuration, - Reader::readDuration); + private static final ValueSchema PROFILE_REF = valueSchema(TYPE_PROFILE_REF, + (writer, value) -> { + writer.writeValue(STRING, value.profileId()); + writer.writeValue(LONG, value.profileVersion()); + writer.writeValue(BYTES, value.canonicalSha256()); + }, + reader -> new CertificateProfileRef(reader.readValue(STRING), reader.readValue(LONG), + reader.readValue(BYTES))); + private static final ValueSchema PROFILE_BINDING = + valueSchema(TYPE_PROFILE_BINDING, FsCodec::writeProfileBinding, FsCodec::readProfileBinding); private static final ValueSchema ENCODING = enumSchema(TYPE_ENCODING_ENUM, value -> switch (value) { @@ -330,7 +354,44 @@ final class FsCodec { throw new IOException("unknown SignWorkflowStore.State code " + code, ex); } }); - + private static final ValueSchema SUBJECT_RDN_TYPE = enumSchema(TYPE_SUBJECT_RDN_TYPE_ENUM, + value -> switch (value) { + case COMMON_NAME -> 1; + case ORGANIZATION_NAME -> 2; + case ORGANIZATIONAL_UNIT_NAME -> 3; + case COUNTRY_NAME -> 4; + case STATE_OR_PROVINCE_NAME -> 5; + case LOCALITY_NAME -> 6; + case SERIAL_NUMBER -> 7; + case EMAIL_ADDRESS -> 8; + case PSEUDONYM -> 9; + }, + code -> switch (code) { + case 1 -> SubjectRdnType.COMMON_NAME; + case 2 -> SubjectRdnType.ORGANIZATION_NAME; + case 3 -> SubjectRdnType.ORGANIZATIONAL_UNIT_NAME; + case 4 -> SubjectRdnType.COUNTRY_NAME; + case 5 -> SubjectRdnType.STATE_OR_PROVINCE_NAME; + case 6 -> SubjectRdnType.LOCALITY_NAME; + case 7 -> SubjectRdnType.SERIAL_NUMBER; + case 8 -> SubjectRdnType.EMAIL_ADDRESS; + case 9 -> SubjectRdnType.PSEUDONYM; + default -> throw unknownEnum("SubjectRdnType", code); + }); + private static final ValueSchema SAN_TYPE = enumSchema(TYPE_SAN_TYPE_ENUM, + value -> switch (value) { + case DNS_NAME -> 1; + case IP_ADDRESS -> 2; + case URI -> 3; + case RFC822_NAME -> 4; + }, + code -> switch (code) { + case 1 -> SubjectAlternativeNameType.DNS_NAME; + case 2 -> SubjectAlternativeNameType.IP_ADDRESS; + case 3 -> SubjectAlternativeNameType.URI; + case 4 -> SubjectAlternativeNameType.RFC822_NAME; + default -> throw unknownEnum("SubjectAlternativeNameType", code); + }); private static final ValueSchema PKI_ID = valueSchema(TYPE_PKI_ID, (writer, value) -> writer.writeValue(STRING, value.value()), reader -> new PkiId(reader.readValue(STRING))); @@ -363,7 +424,16 @@ final class FsCodec { private static final ValueSchema ATTRIBUTE_ID = valueSchema(TYPE_ATTRIBUTE_ID, (writer, value) -> writer.writeValue(STRING, value.value()), reader -> new AttributeId(reader.readValue(STRING))); - + private static final ValueSchema SUBJECT_RDN = valueSchema(TYPE_SUBJECT_RDN, + (writer, value) -> { + writer.writeValue(SUBJECT_RDN_TYPE, value.type()); + writer.writeValue(STRING, value.value()); + }, + reader -> new SubjectRdn(reader.readValue(SUBJECT_RDN_TYPE), reader.readValue(STRING))); + private static final ValueSchema> SUBJECT_RDNS = listOf(SUBJECT_RDN); + private static final ValueSchema SUBJECT_ALT_NAME = valueSchema(TYPE_SAN, + FsCodec::writeSubjectAlternativeName, FsCodec::readSubjectAlternativeName); + private static final ValueSchema> SUBJECT_ALT_NAMES = listOf(SUBJECT_ALT_NAME); private static final ValueSchema ATTRIBUTE_VALUE = valueSchema(TYPE_ATTRIBUTE_VALUE, FsCodec::writeAttributeValue, FsCodec::readAttributeValue); private static final ValueSchema> ATTRIBUTE_VALUES = listOf(ATTRIBUTE_VALUE); @@ -371,7 +441,6 @@ final class FsCodec { FsCodec::writeAttributeSet, FsCodec::readAttributeSet); private static final ValueSchema> STRINGS = listOf(STRING); - private static final ValueSchema> ATTRIBUTE_IDS = listOf(ATTRIBUTE_ID); private static final ValueSchema POLICY_TRACE_STEP = valueSchema(TYPE_POLICY_TRACE_STEP, (writer, value) -> { writer.writeValue(STRING, value.ruleId()); @@ -393,7 +462,6 @@ final class FsCodec { private static final ValueSchema> OPTIONAL_VALIDITY = optionalOf(VALIDITY); private static final ValueSchema> OPTIONAL_STRING = optionalOf(STRING); private static final ValueSchema> OPTIONAL_INSTANT = optionalOf(INSTANT); - private static final ValueSchema> OPTIONAL_DURATION = optionalOf(DURATION); private static final ValueSchema> OPTIONAL_ENCODED_OBJECT = optionalOf(ENCODED_OBJECT); private static final ValueSchema> OPTIONAL_REVOCATION_REASON = optionalOf(REVOCATION_REASON); @@ -420,9 +488,6 @@ final class FsCodec { valueSchema(103, FsCodec::writeStatusObject, FsCodec::readStatusObject)); /* package */ static final Schema PUBLICATION = topLevel(TOP_PUBLICATION, "PUBLICATION", valueSchema(104, FsCodec::writePublication, FsCodec::readPublication)); - /* package */ static final Schema CERTIFICATE_PROFILE = topLevel(TOP_CERTIFICATE_PROFILE, - "CERTIFICATE_PROFILE", valueSchema(105, FsCodec::writeCertificateProfile, - FsCodec::readCertificateProfile)); /* package */ static final Schema POLICY_TRACE = topLevel(TOP_POLICY_TRACE, "POLICY_TRACE", valueSchema(106, FsCodec::writePolicyTrace, FsCodec::readPolicyTrace)); /* package */ static final Schema WORKFLOW_STATE = topLevel(TOP_WORKFLOW_STATE, @@ -430,6 +495,11 @@ final class FsCodec { /* package */ static final Schema SIGN_WORKFLOW_RECORD = topLevel( TOP_SIGN_WORKFLOW_RECORD, "SIGN_WORKFLOW_RECORD", valueSchema(108, FsCodec::writeSignWorkflowRecord, FsCodec::readSignWorkflowRecord)); + /* package */ static final Schema PROFILE_VERSION = + topLevel(TOP_PROFILE_VERSION, "PROFILE_VERSION", + valueSchema(109, FsCodec::writeProfileVersion, FsCodec::readProfileVersion)); + /* package */ static final Schema ACTIVE_PROFILE_REF = + topLevel(TOP_ACTIVE_PROFILE_REF, "ACTIVE_PROFILE_REF", PROFILE_REF); private static final Map> TOP_LEVEL_SCHEMAS = Map.ofEntries( Map.entry(TOP_CA_RECORD, CA_RECORD), @@ -438,10 +508,11 @@ final class FsCodec { Map.entry(TOP_REVOCATION, REVOCATION_JOURNAL), Map.entry(TOP_STATUS_OBJECT, STATUS_OBJECT), Map.entry(TOP_PUBLICATION, PUBLICATION), - Map.entry(TOP_CERTIFICATE_PROFILE, CERTIFICATE_PROFILE), Map.entry(TOP_POLICY_TRACE, POLICY_TRACE), Map.entry(TOP_WORKFLOW_STATE, WORKFLOW_STATE), - Map.entry(TOP_SIGN_WORKFLOW_RECORD, SIGN_WORKFLOW_RECORD)); + Map.entry(TOP_SIGN_WORKFLOW_RECORD, SIGN_WORKFLOW_RECORD), + Map.entry(TOP_PROFILE_VERSION, PROFILE_VERSION), + Map.entry(TOP_ACTIVE_PROFILE_REF, ACTIVE_PROFILE_REF)); private FsCodec() { // utility @@ -596,7 +667,7 @@ final class FsCodec { writer.writeValue(VALIDITY, value.validity()); writer.writeValue(STRING, value.serialOrUniqueId()); writer.writeValue(PKI_ID, value.publicKeyId()); - writer.writeValue(STRING, value.profileId()); + writer.writeValue(PROFILE_BINDING, value.profileBinding()); writer.writeValue(CREDENTIAL_STATUS, value.status()); writer.writeValue(ENCODED_OBJECT, value.encoded()); writer.writeValue(ATTRIBUTE_SET, value.attributes()); @@ -605,10 +676,31 @@ final class FsCodec { private static Credential readCredential(Reader reader) throws IOException { return new Credential(reader.readValue(PKI_ID), reader.readValue(FORMAT_ID), reader.readValue(ISSUER_REF), reader.readValue(SUBJECT_REF), reader.readValue(VALIDITY), reader.readValue(STRING), - reader.readValue(PKI_ID), reader.readValue(STRING), reader.readValue(CREDENTIAL_STATUS), + reader.readValue(PKI_ID), reader.readValue(PROFILE_BINDING), reader.readValue(CREDENTIAL_STATUS), reader.readValue(ENCODED_OBJECT), reader.readValue(ATTRIBUTE_SET)); } + private static void writeProfileBinding(Writer writer, CredentialProfileBinding value) throws IOException { + switch (value) { + case EndEntityProfileBinding endEntity -> { + writer.writeUnsignedByte(1); + writer.writeValue(PROFILE_REF, endEntity.reference()); + } + case CaProfileBinding ca -> { + writer.writeUnsignedByte(2); + writer.writeValue(STRING, ca.profileId()); + } + } + } + + 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)); + default -> throw new IOException("unknown credential profile binding"); + }; + } + private static void writeCaRecord(Writer writer, CaRecord value) throws IOException { writer.writeValue(PKI_ID, value.caId()); writer.writeValue(CA_KIND, value.kind()); @@ -630,13 +722,17 @@ final class FsCodec { writer.writeValue(ENCODED_OBJECT, value.publicKeyInfo()); writer.writeValue(OPTIONAL_VALIDITY, value.requestedValidity()); writer.writeValue(OPTIONAL_STRING, value.requestedProfileId()); + writer.writeValue(SUBJECT_RDNS, value.subjectRdns()); + writer.writeValue(SUBJECT_ALT_NAMES, value.subjectAlternativeNames()); + writer.writeValue(BOOLEAN, value.subjectAlternativeNamePresent()); writer.writeValue(ATTRIBUTE_SET, value.attributes()); } private static ParsedCertificationRequest readParsedRequest(Reader reader) throws IOException { return new ParsedCertificationRequest(reader.readValue(PKI_ID), reader.readValue(FORMAT_ID), reader.readValue(SUBJECT_REF), reader.readValue(ENCODED_OBJECT), reader.readValue(OPTIONAL_VALIDITY), - reader.readValue(OPTIONAL_STRING), reader.readValue(ATTRIBUTE_SET)); + reader.readValue(OPTIONAL_STRING), reader.readValue(SUBJECT_RDNS), reader.readValue(SUBJECT_ALT_NAMES), + reader.readValue(BOOLEAN), reader.readValue(ATTRIBUTE_SET)); } private static void writeRevocationJournal(Writer writer, RevocationJournal value) throws IOException { @@ -700,20 +796,53 @@ final class FsCodec { reader.readValue(PUBLICATION_STATUS)); } - private static void writeCertificateProfile(Writer writer, CertificateProfile value) throws IOException { - writer.writeValue(STRING, value.profileId()); - writer.writeValue(FORMAT_ID, value.formatId()); - writer.writeValue(STRING, value.displayName()); - writer.writeValue(ATTRIBUTE_IDS, value.requiredAttributes()); - writer.writeValue(ATTRIBUTE_IDS, value.optionalAttributes()); - writer.writeValue(OPTIONAL_DURATION, value.maxValidity()); - writer.writeValue(BOOLEAN, value.active()); + private static void writeProfileVersion(Writer writer, ImportedCertificateProfileVersion value) + throws IOException { + writer.writeValue(PROFILE_REF, value.reference()); + writer.writeValue(LONG, (long) value.schemaVersion()); + writer.writeValue(BYTES, value.canonicalJson()); + writer.writeValue(INSTANT, value.importedAt()); } - private static CertificateProfile readCertificateProfile(Reader reader) throws IOException { - return new CertificateProfile(reader.readValue(STRING), reader.readValue(FORMAT_ID), reader.readValue(STRING), - reader.readValue(ATTRIBUTE_IDS), reader.readValue(ATTRIBUTE_IDS), reader.readValue(OPTIONAL_DURATION), - reader.readValue(BOOLEAN)); + private static ImportedCertificateProfileVersion readProfileVersion(Reader reader) throws IOException { + CertificateProfileRef reference = reader.readValue(PROFILE_REF); + int schemaVersion = toInt(reader.readValue(LONG)); + byte[] canonicalJson = reader.readValue(BYTES); + Instant importedAt = reader.readValue(INSTANT); + CertificateProfileDefinition definition; + try { + definition = CertificateProfileDocumentCodec.parse(canonicalJson); + return new ImportedCertificateProfileVersion(reference, schemaVersion, definition, canonicalJson, + importedAt); + } finally { + Arrays.fill(canonicalJson, (byte) 0); + } + } + + private static void writeSubjectAlternativeName(Writer writer, SubjectAlternativeName value) throws IOException { + writer.writeValue(SAN_TYPE, value.type()); + switch (value) { + case SubjectAlternativeName.DnsName dns -> writer.writeValue(STRING, dns.value()); + case SubjectAlternativeName.IpAddress ip -> writer.writeValue(BYTES, ip.bytes()); + case SubjectAlternativeName.UriName uri -> writer.writeValue(STRING, uri.value()); + case SubjectAlternativeName.Rfc822Name email -> writer.writeValue(STRING, email.value()); + } + } + + private static SubjectAlternativeName readSubjectAlternativeName(Reader reader) throws IOException { + return switch (reader.readValue(SAN_TYPE)) { + case DNS_NAME -> new SubjectAlternativeName.DnsName(reader.readValue(STRING)); + case IP_ADDRESS -> new SubjectAlternativeName.IpAddress(reader.readValue(BYTES)); + case URI -> new SubjectAlternativeName.UriName(reader.readValue(STRING)); + case RFC822_NAME -> new SubjectAlternativeName.Rfc822Name(reader.readValue(STRING)); + }; + } + + private static int toInt(long value) throws IOException { + if (value < Integer.MIN_VALUE || value > Integer.MAX_VALUE) { + throw new IOException("integer value out of range"); + } + return (int) value; } private static void writePolicyTrace(Writer writer, PolicyTrace value) throws IOException { @@ -958,11 +1087,6 @@ final class FsCodec { Util.writePack7I(output, value.getNano()); } - private void writeDuration(Duration value) throws IOException { - Util.writeLong(output, value.getSeconds()); - Util.writePack7I(output, value.getNano()); - } - private void writeCount(int count) throws IOException { if (count < 0 || count > MAX_COLLECTION_ELEMENTS) { throw new IOException("collection size out of range"); @@ -1044,19 +1168,6 @@ final class FsCodec { } } - private Duration readDuration() throws IOException { - long seconds = Util.readLong(input); - int nanos = Util.readPack7I(input); - if (nanos < 0 || nanos > 999_999_999) { - throw new IOException("invalid duration nanoseconds"); - } - try { - return Duration.ofSeconds(seconds, nanos); - } catch (ArithmeticException ex) { - throw new IOException("invalid duration", ex); - } - } - private int readCount() throws IOException { int count = Util.readPack7I(input); if (count < 0 || count > MAX_COLLECTION_ELEMENTS) { diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsOperations.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsOperations.java index 5353c79..e4647c6 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FsOperations.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsOperations.java @@ -206,6 +206,47 @@ final class FsOperations { } } + /** + * Atomically creates one immutable file and fails when the target exists. + * + * @param target immutable target + * @param data complete encoded image + * @throws IOException on persistence failure + * @throws DurabilityUncertainException after an atomic move whose directory + * force failed + */ + // The OS failure is deliberately replaced by the cause-free durability state. + @SuppressWarnings("PMD.PreserveStackTrace") + /* default */ static void writeNewAtomicStrict(final Path target, final byte[] data) throws IOException { + Objects.requireNonNull(target, "target"); + Objects.requireNonNull(data, "data"); + Path parent = requireParent(target); + ensureDir(parent); + Path temporary = tempSibling(target); + boolean moved = false; + try { + Files.createFile(temporary, fileAttributesIfSupported()); + try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE)) { + ByteBuffer buffer = ByteBuffer.wrap(data); + while (buffer.hasRemaining()) { + channel.write(buffer); + } + channel.force(true); + } + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE); + moved = true; + try (FileChannel directory = FileChannel.open(parent, StandardOpenOption.READ)) { + directory.force(true); + } catch (IOException failure) { + throw new DurabilityUncertainException(); + } + } finally { + if (!moved) { + Files.deleteIfExists(temporary); + } + } + } + /** * Signals a post-commit directory durability failure without exposing an * operating-system cause. @@ -214,7 +255,7 @@ final class FsOperations { private static final long serialVersionUID = -2422560154076956224L; private DurabilityUncertainException() { - super("revocation journal durability unconfirmed"); + super("filesystem durability unconfirmed"); } } diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java index ecf9d6d..16fd115 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java @@ -34,6 +34,8 @@ package zeroecho.pki.impl.fs; import java.nio.file.Path; +import java.nio.charset.StandardCharsets; +import java.util.Base64; import java.util.Objects; import zeroecho.pki.api.PkiId; @@ -55,6 +57,7 @@ import zeroecho.pki.api.PkiId; *

    */ final class FsPaths { + private static final String BINARY_EXTENSION = ".bin"; private static final String BY_ID = "by-id"; @@ -109,20 +112,26 @@ final class FsPaths { } // ------------------------------------------------------------------------- - // Profiles (mutable with history) + // Profiles (immutable versions plus one active pointer) // ------------------------------------------------------------------------- /* default */ Path profileDir(final String profileId) { Objects.requireNonNull(profileId, "profileId"); - return this.root.resolve("profiles").resolve(BY_ID).resolve(FsUtil.safeSegment(profileId)); + String encoded = Base64.getUrlEncoder().withoutPadding() + .encodeToString(profileId.getBytes(StandardCharsets.UTF_8)); + return this.root.resolve("profiles").resolve(BY_ID).resolve("id-" + encoded); } - /* default */ Path profileCurrent(final String profileId) { - return profileDir(profileId).resolve(CURRENT_FILE); + /* default */ Path profileVersion(final String profileId, final long profileVersion) { + return profileDir(profileId).resolve("versions").resolve(profileVersion + BINARY_EXTENSION); } - /* default */ Path profileHistoryDir(final String profileId) { - return profileDir(profileId).resolve(HISTORY_DIR); + /* default */ Path profileVersionsDir(final String profileId) { + return profileDir(profileId).resolve("versions"); + } + + /* default */ Path profileActive(final String profileId) { + return profileDir(profileId).resolve("active" + BINARY_EXTENSION); } // ------------------------------------------------------------------------- @@ -131,7 +140,8 @@ final class FsPaths { /* default */ Path credentialPath(final PkiId credentialId) { Objects.requireNonNull(credentialId, "credentialId"); - return this.root.resolve("credentials").resolve(BY_ID).resolve(FsUtil.safeId(credentialId) + ".bin"); + return this.root.resolve("credentials").resolve(BY_ID) + .resolve(FsUtil.safeId(credentialId) + BINARY_EXTENSION); } // ------------------------------------------------------------------------- @@ -140,7 +150,8 @@ final class FsPaths { /* default */ Path requestPath(final PkiId requestId) { Objects.requireNonNull(requestId, "requestId"); - return this.root.resolve("requests").resolve(BY_ID).resolve(FsUtil.safeId(requestId) + ".bin"); + return this.root.resolve("requests").resolve(BY_ID) + .resolve(FsUtil.safeId(requestId) + BINARY_EXTENSION); } /* default */ Path signWorkflowPath(final PkiId submissionId) { diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java index 560da6b..a84d53b 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java @@ -34,14 +34,22 @@ package zeroecho.pki.impl.fs; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.nio.file.LinkOption; import java.nio.file.Path; import java.time.Instant; +import java.util.ArrayList; +import java.util.Base64; import java.util.Comparator; +import java.util.List; import java.util.Objects; import java.util.logging.Level; import java.util.logging.Logger; +import zeroecho.pki.impl.ProfileLifecycleFailure; +import zeroecho.pki.impl.ProfileLifecycleFailure.Code; + /** * Snapshot exporter ("time travel") for {@link FilesystemPkiStore}. * @@ -70,6 +78,8 @@ import java.util.logging.Logger; final class FsSnapshotExporter { private static final Logger LOG = Logger.getLogger(FsSnapshotExporter.class.getName()); + private static final String ACTIVE_POINTER_FILE = "active.bin"; + private static final String BINARY_EXTENSION = ".bin"; private final FsPkiStoreOptions options; @@ -77,12 +87,19 @@ final class FsSnapshotExporter { this.options = Objects.requireNonNull(options, "options"); } + /* + * Snapshot/profile failures deliberately cross this boundary without input- + * bearing causes or filesystem paths. + */ + @SuppressWarnings("PMD.PreserveStackTrace") /* default */ void exportSnapshot(final Path sourceRoot, final Path targetRoot, final Instant at) { Objects.requireNonNull(sourceRoot, "sourceRoot"); Objects.requireNonNull(targetRoot, "targetRoot"); Objects.requireNonNull(at, "at"); try { + List profiles = + preflightImportedProfiles(sourceRoot.resolve("profiles"), at); FsOperations.ensureDir(targetRoot); FsPaths dst = new FsPaths(targetRoot); @@ -98,18 +115,94 @@ final class FsSnapshotExporter { copyTreeIfExists(sourceRoot.resolve("publications"), targetRoot.resolve("publications")); copyTreeIfExists(sourceRoot.resolve("sign-workflows"), targetRoot.resolve("sign-workflows")); copyTreeIfExists(sourceRoot.resolve("revocations"), targetRoot.resolve("revocations")); + copyImportedProfilesAsOf(profiles, targetRoot.resolve("profiles")); // reconstruct mutable entities from history (CAS and profiles) reconstructMutableTree(sourceRoot.resolve("cas"), targetRoot.resolve("cas"), at, this.options.caHistoryPolicy(), this.options.strictSnapshotExport()); - reconstructMutableTree(sourceRoot.resolve("profiles"), targetRoot.resolve("profiles"), at, - this.options.profileHistoryPolicy(), this.options.strictSnapshotExport()); // reconstruct workflow continuation state from history reconstructMutableTree(sourceRoot.resolve("workflows"), targetRoot.resolve("workflows"), at, this.options.workflowHistoryPolicy(), this.options.strictSnapshotExport()); + } catch (SnapshotProfileFailure failure) { + throw new IllegalStateException(failure.getMessage()); } catch (IOException e) { - throw new IllegalStateException("snapshot export failed", e); + throw new IllegalStateException("Snapshot export failed: code=SNAPSHOT_EXPORT_FAILED"); + } + } + + /* + * Strict codec and filesystem traversal can produce several runtime failure + * types; all are normalized without their untrusted causes. + */ + @SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" }) + private static List preflightImportedProfiles(Path profilesRoot, Instant at) + throws IOException { + if (!requireProfileRoot(profilesRoot)) { + return List.of(); + } + List selected = new ArrayList<>(); + try (java.util.stream.Stream files = Files.walk(profilesRoot)) { + for (Path path : files.filter(Files::isRegularFile) + .sorted(Comparator.comparing(Path::toString)).toList()) { + collectProfileArtifact(profilesRoot, path, at, selected); + } + } catch (SnapshotProfileFailure failure) { + throw failure; + } catch (ProfileLifecycleFailure failure) { + throw SnapshotProfileFailure.of(failure.code()); + } catch (IOException failure) { + throw SnapshotProfileFailure.of(Code.PROFILE_STORE_FAILURE); + } catch (RuntimeException failure) { + throw SnapshotProfileFailure.of(Code.PROFILE_VERSION_CORRUPT); + } + return List.copyOf(selected); + } + + private static boolean requireProfileRoot(Path profilesRoot) { + if (!Files.exists(profilesRoot, LinkOption.NOFOLLOW_LINKS)) { + return false; + } + if (!Files.isDirectory(profilesRoot, LinkOption.NOFOLLOW_LINKS) + || !Files.isReadable(profilesRoot)) { + throw SnapshotProfileFailure.of(Code.PROFILE_STORE_FAILURE); + } + return true; + } + + private static void collectProfileArtifact(Path profilesRoot, Path path, Instant at, + List selected) throws IOException { + String fileName = path.getFileName().toString(); + if (ACTIVE_POINTER_FILE.equals(fileName)) { + throw SnapshotProfileFailure.of(Code.PROFILE_ACTIVATION_HISTORY_UNAVAILABLE); + } + if (!fileName.endsWith(BINARY_EXTENSION)) { + throw SnapshotProfileFailure.of(Code.PROFILE_VERSION_CORRUPT); + } + byte[] artifact = Files.readAllBytes(path); + ValidatedImportedProfile validated = ValidatedImportedProfile.decode(artifact); + if (!expectedProfileVersionPath(profilesRoot, validated).equals(path)) { + throw SnapshotProfileFailure.of(Code.PROFILE_VERSION_CORRUPT); + } + if (!validated.version().importedAt().isAfter(at)) { + selected.add(new SnapshotProfileArtifact(profilesRoot.relativize(path), artifact)); + } + } + + private static Path expectedProfileVersionPath(Path profilesRoot, ValidatedImportedProfile validated) { + String profileId = validated.version().reference().profileId(); + String encoded = Base64.getUrlEncoder().withoutPadding() + .encodeToString(profileId.getBytes(StandardCharsets.UTF_8)); + return profilesRoot.resolve("by-id").resolve("id-" + encoded).resolve("versions") + .resolve(validated.version().reference().profileVersion() + BINARY_EXTENSION); + } + + private static void copyImportedProfilesAsOf(List profiles, Path target) + throws IOException { + for (SnapshotProfileArtifact profile : profiles) { + Path output = target.resolve(profile.relativePath()); + FsOperations.ensureDir(output.getParent()); + FsOperations.writeAtomic(output, profile.bytes()); } } @@ -206,4 +299,31 @@ final class FsSnapshotExporter { } FsOperations.writeAtomic(target, Files.readAllBytes(source)); } + + private record SnapshotProfileArtifact(Path relativePath, byte[] bytes) { + private SnapshotProfileArtifact { + bytes = bytes.clone(); + } + + @Override + public byte[] bytes() { + return bytes.clone(); + } + } + + /** Cause-free internal marker for profile preflight rejection. */ + private static final class SnapshotProfileFailure extends RuntimeException { + private static final long serialVersionUID = -4451876406166515230L; + + private SnapshotProfileFailure(String message) { + super(message); + } + + private static SnapshotProfileFailure of(Code code) { + String prefix = code == Code.PROFILE_ACTIVATION_HISTORY_UNAVAILABLE + ? "Historical snapshot unavailable: code=" + : "Historical snapshot failed: code="; + return new SnapshotProfileFailure(prefix + code); + } + } } diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/ValidatedImportedProfile.java b/pki/src/main/java/zeroecho/pki/impl/fs/ValidatedImportedProfile.java new file mode 100644 index 0000000..3d55bd5 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/fs/ValidatedImportedProfile.java @@ -0,0 +1,121 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.impl.fs; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; + +import zeroecho.pki.api.profile.CertificateProfileDefinition; +import zeroecho.pki.api.profile.CertificateProfileDocumentCodec; +import zeroecho.pki.api.profile.ImportedCertificateProfileVersion; +import zeroecho.pki.impl.ProfileLifecycleFailure; +import zeroecho.pki.impl.ProfileLifecycleFailure.Code; + +/** + * Validated immutable imported profile artifact. + * + * @param version strictly decoded and canonically verified version + */ +record ValidatedImportedProfile(ImportedCertificateProfileVersion version) { + + /* + * Strict decoding may throw several runtime parsing failures. The persisted + * bytes and their causes must not cross this boundary. + */ + @SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" }) + /* default */ static ValidatedImportedProfile decode(byte[] artifact) { + try { + ImportedCertificateProfileVersion decoded = + FsCodec.decode(FsCodec.PROFILE_VERSION, artifact); + return validate(decoded, decoded.reference().profileId(), + decoded.reference().profileVersion()); + } catch (ProfileLifecycleFailure failure) { + throw failure; + } catch (RuntimeException failure) { + throw versionCorrupt(); + } + } + + /* + * Strict decoding may throw several runtime parsing failures. The persisted + * bytes and their causes must not cross this boundary. + */ + @SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" }) + /* default */ static ValidatedImportedProfile decode(byte[] artifact, String profileId, long profileVersion) { + try { + ImportedCertificateProfileVersion decoded = + FsCodec.decode(FsCodec.PROFILE_VERSION, artifact); + return validate(decoded, profileId, profileVersion); + } catch (ProfileLifecycleFailure failure) { + throw failure; + } catch (RuntimeException failure) { + throw versionCorrupt(); + } + } + + /* + * Policy constructors and the strict document codec expose multiple runtime + * failures, all deliberately normalized without input-bearing causes. + */ + @SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" }) + /* default */ static ValidatedImportedProfile validate(ImportedCertificateProfileVersion version, + String profileId, long profileVersion) { + try { + byte[] storedCanonical = version.canonicalJson(); + requireDocumentBounds(storedCanonical); + CertificateProfileDefinition parsed = CertificateProfileDocumentCodec.parse(storedCanonical); + byte[] canonical = CertificateProfileDocumentCodec.writeCanonical(parsed); + byte[] expectedHash = sha256(canonical); + requireCanonicalIdentity(version, profileId, profileVersion, storedCanonical, + parsed, canonical); + if (!MessageDigest.isEqual(expectedHash, version.reference().canonicalSha256())) { + throw hashMismatch(); + } + return new ValidatedImportedProfile(version); + } catch (ProfileLifecycleFailure failure) { + throw failure; + } catch (RuntimeException failure) { + throw versionCorrupt(); + } + } + + private static void requireDocumentBounds(byte[] canonicalJson) { + if (canonicalJson.length == 0 + || canonicalJson.length > CertificateProfileDocumentCodec.MAXIMUM_DOCUMENT_BYTES) { + throw versionCorrupt(); + } + } + + private static void requireCanonicalIdentity(ImportedCertificateProfileVersion version, + String profileId, long profileVersion, byte[] storedCanonical, + CertificateProfileDefinition parsed, byte[] canonical) { + if (!Arrays.equals(canonical, storedCanonical) + || !parsed.equals(version.definition()) + || !profileId.equals(version.reference().profileId()) + || profileVersion != version.reference().profileVersion() + || !profileId.equals(parsed.profileId()) + || profileVersion != parsed.profileVersion() + || version.schemaVersion() != CertificateProfileDefinition.SCHEMA_VERSION) { + throw versionCorrupt(); + } + } + + private static byte[] sha256(byte[] value) { + try { + return MessageDigest.getInstance("SHA-256").digest(value); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 unavailable", impossible); + } + } + + private static ProfileLifecycleFailure versionCorrupt() { + return new ProfileLifecycleFailure(Code.PROFILE_VERSION_CORRUPT); + } + + private static ProfileLifecycleFailure hashMismatch() { + return new ProfileLifecycleFailure(Code.PROFILE_HASH_MISMATCH); + } +} 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 3999e80..b51097f 100644 --- a/pki/src/main/java/zeroecho/pki/spi/framework/CredentialIssuerBackend.java +++ b/pki/src/main/java/zeroecho/pki/spi/framework/CredentialIssuerBackend.java @@ -33,10 +33,14 @@ ******************************************************************************/ package zeroecho.pki.spi.framework; +import java.math.BigInteger; + +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.VerifiedIssuanceCandidate; +import zeroecho.pki.impl.core.ValidatedCertificateRequest; /** * SPI contract for framework-specific credential issuance backends. @@ -105,8 +109,8 @@ public interface CredentialIssuerBackend { * *

    * This operation produces a credential for a non-CA subject, typically from a - * cryptographically verified certification request carried by the opaque - * {@link VerifiedIssuanceCandidate}. The returned {@link CredentialBundle} may + * cryptographically verified and profile-authorized request carried by the opaque + * {@link ValidatedCertificateRequest}. The returned {@link CredentialBundle} may * contain the issued leaf credential together with any additional runtime * bundle material defined by the concrete framework, such as chain elements or * accompanying metadata. @@ -118,14 +122,15 @@ public interface CredentialIssuerBackend { *

    * *

    - * The candidate carries all framework-specific issuance inputs - * required by the concrete implementation, including any issuer wiring - * attributes, profile identifiers, validity overrides, and subject request - * material. The exact interpretation of those fields is framework-specific. + * No generic requester attributes or raw CSR extension bytes cross this + * boundary. Issuer material and the positive serial are supplied independently + * by the trusted core issuance service. *

    * - * @param candidate gate-produced verified issuance candidate; must not be - * {@code null} + * @param request gate-produced validated request; must not be {@code null} + * @param issuerCertificate trusted encoded issuer certificate + * @param issuerKeyRef trusted issuer signing-key reference + * @param serial trusted positive issuer-controlled serial * @return issued credential bundle, never {@code null} * @throws IllegalArgumentException if {@code command} is {@code null} or * structurally invalid for the concrete @@ -134,7 +139,8 @@ public interface CredentialIssuerBackend { * or other framework-specific issuance * processing fails */ - CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate); + CredentialBundle issueEndEntity(ValidatedCertificateRequest request, EncodedObject issuerCertificate, + KeyRef issuerKeyRef, BigInteger serial); /** * Issues a CA credential for an existing CA subject entity. 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 30840e7..e94d178 100644 --- a/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java +++ b/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java @@ -42,7 +42,9 @@ import zeroecho.pki.api.ca.CaRecord; import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.orch.WorkflowStateRecord; import zeroecho.pki.api.policy.PolicyTrace; -import zeroecho.pki.api.profile.CertificateProfile; +import zeroecho.pki.api.profile.ActiveCertificateProfile; +import zeroecho.pki.api.profile.CertificateProfileRef; +import zeroecho.pki.api.profile.ImportedCertificateProfileVersion; import zeroecho.pki.api.publication.PublicationRecord; import zeroecho.pki.api.request.ParsedCertificationRequest; import zeroecho.pki.api.revocation.RevocationCommand; @@ -243,44 +245,23 @@ public interface PkiStore extends SignWorkflowStore { */ List listPublicationRecords(); - /** - * Persists or updates a certificate profile. - * - *

    - * A certificate profile represents a reusable issuance template (for example: - * VPN client, VPN server, S/MIME). The profile may be referenced by higher - * layers during certificate issuance. - *

    - * - * @param profile certificate profile (never {@code null}) - * @throws NullPointerException if {@code profile} is {@code null} - * @throws IllegalStateException if persistence fails - */ - void putProfile(CertificateProfile profile); + /** Atomically imports one immutable validated profile version. */ + ImportedCertificateProfileVersion importProfileVersion(ImportedCertificateProfileVersion version); - /** - * Retrieves a certificate profile by profile identifier. - * - *

    - * The identifier is a stable, system-defined key (not a display name). It - * should be suitable for configuration and API use (for example: - * {@code "vpn-client"}). - *

    - * - * @param profileId profile identifier (never {@code null}) - * @return profile if present - * @throws NullPointerException if {@code profileId} is {@code null} - * @throws IllegalStateException if retrieval fails - */ - Optional getProfile(String profileId); + /** Retrieves one imported version. */ + Optional getProfileVersion(String profileId, long profileVersion); - /** - * Lists all stored certificate profiles. - * - * @return list of profiles (never {@code null}) - * @throws IllegalStateException if listing fails - */ - List listProfiles(); + /** Lists imported versions in ascending version order. */ + List listProfileVersions(String profileId); + + /** Atomically activates one imported version. */ + CertificateProfileRef activateProfile(String profileId, long profileVersion); + + /** Retrieves the current active reference. */ + Optional getActiveProfileRef(String profileId); + + /** Resolves the exact active version and validated runtime projection. */ + ActiveCertificateProfile requireActiveProfile(String profileId); /** * Persists a policy trace. diff --git a/pki/src/main/resources/zeroecho/pki/profiles/v1/catalog.json b/pki/src/main/resources/zeroecho/pki/profiles/v1/catalog.json new file mode 100644 index 0000000..fa21c67 --- /dev/null +++ b/pki/src/main/resources/zeroecho/pki/profiles/v1/catalog.json @@ -0,0 +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 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 new file mode 100644 index 0000000..89c9146 --- /dev/null +++ b/pki/src/main/resources/zeroecho/pki/profiles/v1/email-signing.json @@ -0,0 +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 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 new file mode 100644 index 0000000..b147661 --- /dev/null +++ b/pki/src/main/resources/zeroecho/pki/profiles/v1/server-tls.json @@ -0,0 +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 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 new file mode 100644 index 0000000..e831916 --- /dev/null +++ b/pki/src/main/resources/zeroecho/pki/profiles/v1/vpn-client.json @@ -0,0 +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 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 new file mode 100644 index 0000000..350d43f --- /dev/null +++ b/pki/src/main/resources/zeroecho/pki/profiles/v1/vpn-server.json @@ -0,0 +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 diff --git a/pki/src/test/java/zeroecho/pki/api/profile/BuiltInCertificateProfileCatalogTest.java b/pki/src/test/java/zeroecho/pki/api/profile/BuiltInCertificateProfileCatalogTest.java new file mode 100644 index 0000000..c82c1b7 --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/api/profile/BuiltInCertificateProfileCatalogTest.java @@ -0,0 +1,423 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.api.profile; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLStreamHandler; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Enumeration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import zeroecho.pki.api.PkiException; + +final class BuiltInCertificateProfileCatalogTest { + + private static final String ROOT = "zeroecho/pki/profiles/v1/"; + private static final String SERVER = ROOT + "server-tls.json"; + 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 List PROFILE_RESOURCES = + List.of(SERVER, VPN_SERVER, VPN_CLIENT, EMAIL); + private static final List EXPECTED_ORDER = + List.of("server-tls", "vpn-server", "vpn-client", "email-signing"); + private static final Set EXPECTED_ALGORITHMS = + Set.of("RSA", "ECDSA", "Ed25519"); + private static final ExtendedKeyUsageId SERVER_AUTH = + new ExtendedKeyUsageId("1.3.6.1.5.5.7.3.1"); + private static final ExtendedKeyUsageId CLIENT_AUTH = + new ExtendedKeyUsageId("1.3.6.1.5.5.7.3.2"); + private static final ExtendedKeyUsageId EMAIL_PROTECTION = + new ExtendedKeyUsageId("1.3.6.1.5.5.7.3.4"); + + private static boolean sentinelInitialized; + + @Test + void productionCatalogueLoadsCanonicalImmutableTemplatesInDeterministicOrder() + throws NoSuchAlgorithmException { + List first = + BuiltInCertificateProfileCatalog.load(getClass().getClassLoader()); + List second = + BuiltInCertificateProfileCatalog.load(getClass().getClassLoader()); + + assertEquals(4, first.size()); + assertEquals(EXPECTED_ORDER, + first.stream().map(template -> template.definition().profileId()).toList()); + assertEquals(first, second); + assertThrows(UnsupportedOperationException.class, () -> first.add(first.get(0))); + for (BuiltInCertificateProfileTemplate template : first) { + CertificateProfileDefinition definition = template.definition(); + byte[] canonical = template.canonicalJson(); + 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)); + assertArrayEquals(MessageDigest.getInstance("SHA-256").digest(canonical), hash); + assertEquals(32, hash.length); + assertFalse(template.toString().contains(new String(canonical, + StandardCharsets.UTF_8))); + + byte originalJson = canonical[0]; + byte originalHash = hash[0]; + canonical[0] ^= 1; + hash[0] ^= 1; + assertNotEquals(canonical[0], template.canonicalJson()[0]); + assertNotEquals(hash[0], template.canonicalSha256()[0]); + assertEquals(originalJson, template.canonicalJson()[0]); + assertEquals(originalHash, template.canonicalSha256()[0]); + } + } + + @Test + void serverAndVpnServerTemplatesHaveExactServiceIdentityPolicy() { + Map profiles = productionDefinitions(); + for (String profileId : List.of("server-tls", "vpn-server")) { + LeafCertificatePolicy leaf = profiles.get(profileId).leafPolicy(); + SubjectAlternativeNamePolicy san = leaf.subjectAlternativeNamePolicy(); + + assertOptionalCommonName(leaf); + assertTrue(san.allowEmptySubject()); + assertEquals(1, san.minimumTotal()); + assertEquals(64, san.maximumTotal()); + assertTrue(san.requireServiceIdentity()); + assertFalse(san.requireEmailIdentity()); + assertFalse(san.allowDnsWildcard()); + assertFalse(san.criticalWithNonemptySubject()); + assertEquals(Set.of(SubjectAlternativeNameType.DNS_NAME, + SubjectAlternativeNameType.IP_ADDRESS), san.rules().stream() + .map(SubjectAlternativeNameRule::type).collect(java.util.stream.Collectors.toSet())); + SubjectAlternativeNameRule ip = rule(san, SubjectAlternativeNameType.IP_ADDRESS); + assertTrue(ip.allowIpv4()); + assertTrue(ip.allowIpv6()); + assertLeafPolicy(leaf, Set.of(SERVER_AUTH)); + } + } + + @Test + void vpnClientTemplateHasOnlySpiffeUriAndRfc822Identity() { + LeafCertificatePolicy leaf = productionDefinitions().get("vpn-client").leafPolicy(); + SubjectAlternativeNamePolicy san = leaf.subjectAlternativeNamePolicy(); + + assertOptionalCommonName(leaf); + assertTrue(san.allowEmptySubject()); + assertEquals(1, san.minimumTotal()); + assertEquals(16, san.maximumTotal()); + assertFalse(san.requireServiceIdentity()); + assertFalse(san.requireEmailIdentity()); + assertFalse(san.allowDnsWildcard()); + assertEquals(Set.of("spiffe"), san.allowedUriSchemes()); + assertEquals(Set.of(SubjectAlternativeNameType.URI, + SubjectAlternativeNameType.RFC822_NAME), san.rules().stream() + .map(SubjectAlternativeNameRule::type).collect(java.util.stream.Collectors.toSet())); + assertLeafPolicy(leaf, Set.of(CLIENT_AUTH)); + } + + @Test + void emailTemplateRequiresRfc822SanAndDoesNotEnableSubjectEmail() { + LeafCertificatePolicy leaf = productionDefinitions().get("email-signing").leafPolicy(); + SubjectAlternativeNamePolicy san = leaf.subjectAlternativeNamePolicy(); + + assertOptionalCommonName(leaf); + assertEquals(List.of(SubjectRdnType.COMMON_NAME), + leaf.subjectPolicy().rules().stream().map(SubjectRdnRule::type).toList()); + assertTrue(san.allowEmptySubject()); + assertEquals(1, san.minimumTotal()); + assertEquals(16, san.maximumTotal()); + assertFalse(san.requireServiceIdentity()); + assertTrue(san.requireEmailIdentity()); + assertEquals(List.of(SubjectAlternativeNameType.RFC822_NAME), + san.rules().stream().map(SubjectAlternativeNameRule::type).toList()); + assertEquals(1, san.rules().get(0).minimum()); + assertLeafPolicy(leaf, Set.of(EMAIL_PROTECTION)); + } + + @Test + void manifestRejectsUnknownDuplicateMissingInvalidPathsLimitsAndTrailingTokens() { + List invalidManifests = List.of( + "{\"schemaVersion\":1,\"unknown\":true,\"profiles\":[]}", + "{\"schemaVersion\":1,\"schemaVersion\":1,\"profiles\":[]}", + "{\"schemaVersion\":1}", + "{\"schemaVersion\":2,\"profiles\":[{\"resource\":\"" + SERVER + "\"}]}", + "{\"schemaVersion\":1,\"profiles\":[]}", + manifest(List.of(SERVER, SERVER)), + manifest(List.of("/" + SERVER)), + manifest(List.of(ROOT + "../server-tls.json")), + manifest(List.of(ROOT + "nested\\\\server-tls.json")), + manifest(List.of("https:" + SERVER)), + manifest(List.of("outside/server-tls.json")), + "{\"schemaVersion\":1,\"profiles\":[{\"resource\":\"" + SERVER + + "\",\"extra\":true}]}", + "{\"schemaVersion\":1,\"profiles\":[{}]}", + manifest(PROFILE_RESOURCES) + "{}"); + for (String manifest : invalidManifests) { + Map> resources = baseResources(); + resources.put(BuiltInCertificateProfileCatalog.MANIFEST_RESOURCE, + List.of(bytes(manifest))); + assertThrows(PkiException.class, + () -> BuiltInCertificateProfileCatalog.load( + new MemoryResourceClassLoader(resources))); + } + + List tooMany = new ArrayList<>(); + for (int index = 0; index < 129; index++) { + tooMany.add(ROOT + "profile-" + index + ".json"); + } + Map> resources = baseResources(); + resources.put(BuiltInCertificateProfileCatalog.MANIFEST_RESOURCE, + List.of(bytes(manifest(tooMany)))); + assertCode(resources, "MANIFEST_LIMIT_EXCEEDED"); + } + + @Test + void resourcesFailClosedForMissingDuplicateEmptyOversizeAndNoncanonicalContent() { + Map> missing = baseResources(); + missing.put(SERVER, List.of()); + assertCode(missing, "RESOURCE_MISSING"); + + Map> duplicate = baseResources(); + duplicate.put(SERVER, List.of(mainResource(SERVER), mainResource(SERVER))); + assertCode(duplicate, "RESOURCE_DUPLICATE"); + + Map> empty = baseResources(); + empty.put(SERVER, List.of(new byte[0])); + assertCode(empty, "RESOURCE_EMPTY"); + + Map> oversized = baseResources(); + oversized.put(SERVER, List.of(new byte[ + CertificateProfileDocumentCodec.MAXIMUM_DOCUMENT_BYTES + 1])); + assertCode(oversized, "RESOURCE_TOO_LARGE"); + + Map> noncanonical = baseResources(); + byte[] canonical = mainResource(SERVER); + byte[] withNewline = java.util.Arrays.copyOf(canonical, canonical.length + 1); + withNewline[withNewline.length - 1] = '\n'; + noncanonical.put(SERVER, List.of(withNewline)); + assertCode(noncanonical, "PROFILE_RESOURCE_NONCANONICAL"); + } + + @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\":\"" + + InitializationSentinel.CLASS_NAME + "\","), + malformedUtf8(mainResource(SERVER))); + for (byte[] invalid : invalidDocuments) { + Map> resources = baseResources(); + resources.put(SERVER, List.of(invalid)); + assertCode(resources, "PROFILE_RESOURCE_INVALID"); + } + assertFalse(sentinelInitialized); + } + + @Test + void duplicateCanonicalHashIdentityAndUnexpectedBuiltInSetFailClosed() { + Map> duplicateHash = baseResources(); + duplicateHash.put(VPN_SERVER, List.of(mainResource(SERVER))); + assertCode(duplicateHash, "DUPLICATE_PROFILE_HASH"); + + CertificateProfileDefinition server = + CertificateProfileDocumentCodec.parse(mainResource(SERVER)); + CertificateProfileDefinition changed = new CertificateProfileDefinition( + server.profileId(), server.profileVersion(), server.formatId(), + "Changed display", server.leafPolicy()); + Map> duplicateIdentity = baseResources(); + duplicateIdentity.put(VPN_SERVER, List.of( + CertificateProfileDocumentCodec.writeCanonical(changed))); + assertCode(duplicateIdentity, "DUPLICATE_PROFILE_IDENTITY"); + + byte[] unexpected = replace(mainResource(SERVER), "\"server-tls\"", + "\"unexpected\""); + Map> wrongSet = baseResources(); + wrongSet.put(SERVER, List.of(unexpected)); + 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); + assertEquals(SubjectRdnType.COMMON_NAME, commonName.type()); + assertEquals(0, commonName.minimumOccurrences()); + assertEquals(1, commonName.maximumOccurrences()); + assertEquals(253, commonName.maximumUtf8Bytes()); + assertTrue(commonName.requesterSupplied()); + assertTrue(commonName.fixedValue().isEmpty()); + } + + private static void assertLeafPolicy(LeafCertificatePolicy leaf, + Set expectedExtendedUsages) { + assertEquals(Set.of(LeafKeyUsage.DIGITAL_SIGNATURE), leaf.keyUsages()); + assertTrue(leaf.keyUsageCritical()); + assertEquals(expectedExtendedUsages, leaf.extendedKeyUsages()); + assertFalse(leaf.extendedKeyUsageCritical()); + assertTrue(leaf.basicConstraintsCritical()); + assertEquals(EXPECTED_ALGORITHMS, leaf.allowedSubjectKeyAlgorithmIds()); + assertEquals(Duration.ofDays(365), leaf.maximumValidity()); + } + + private static SubjectAlternativeNameRule rule(SubjectAlternativeNamePolicy policy, + SubjectAlternativeNameType type) { + return policy.rules().stream().filter(candidate -> candidate.type() == type) + .findFirst().orElseThrow(); + } + + private static Map productionDefinitions() { + Map result = new LinkedHashMap<>(); + for (BuiltInCertificateProfileTemplate template : + BuiltInCertificateProfileCatalog.load( + BuiltInCertificateProfileCatalogTest.class.getClassLoader())) { + result.put(template.definition().profileId(), template.definition()); + } + return Map.copyOf(result); + } + + private static Map> baseResources() { + Map> resources = new LinkedHashMap<>(); + resources.put(BuiltInCertificateProfileCatalog.MANIFEST_RESOURCE, + List.of(mainResource(BuiltInCertificateProfileCatalog.MANIFEST_RESOURCE))); + for (String resourceName : PROFILE_RESOURCES) { + resources.put(resourceName, List.of(mainResource(resourceName))); + } + return resources; + } + + private static byte[] mainResource(String resourceName) { + try (InputStream input = BuiltInCertificateProfileCatalogTest.class.getClassLoader() + .getResourceAsStream(resourceName)) { + if (input == null) { + throw new AssertionError("Missing test resource"); + } + return input.readAllBytes(); + } catch (IOException ex) { + throw new AssertionError("Unable to read test resource", ex); + } + } + + private static String manifest(List resourceNames) { + StringBuilder result = new StringBuilder("{\"schemaVersion\":1,\"profiles\":["); + for (int index = 0; index < resourceNames.size(); index++) { + if (index > 0) { + result.append(','); + } + result.append("{\"resource\":\"").append(resourceNames.get(index)).append("\"}"); + } + return result.append("]}").toString(); + } + + private static byte[] replace(byte[] input, String target, String replacement) { + String value = new String(input, StandardCharsets.UTF_8); + if (!value.contains(target)) { + throw new AssertionError("Replacement target absent"); + } + return bytes(value.replace(target, replacement)); + } + + private static byte[] malformedUtf8(byte[] input) { + byte[] result = input.clone(); + int index = new String(result, StandardCharsets.UTF_8).indexOf("Server TLS"); + result[index] = (byte) 0xc3; + result[index + 1] = (byte) 0x28; + return result; + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } + + private static void assertCode(Map> resources, String code) { + PkiException exception = assertThrows(PkiException.class, + () -> BuiltInCertificateProfileCatalog.load( + new MemoryResourceClassLoader(resources))); + assertTrue(exception.getMessage().contains("code=" + code)); + assertNull(exception.getCause()); + assertEquals(0, exception.getSuppressed().length); + } + + private static final class MemoryResourceClassLoader extends ClassLoader { + + private final Map> resources; + + private MemoryResourceClassLoader(Map> resources) { + super(null); + Map> copied = new LinkedHashMap<>(); + resources.forEach((name, values) -> copied.put(name, + values.stream().map(byte[]::clone).toList())); + this.resources = Map.copyOf(copied); + } + + @Override + public Enumeration getResources(String name) throws IOException { + List urls = new ArrayList<>(); + List values = resources.getOrDefault(name, List.of()); + for (int index = 0; index < values.size(); index++) { + urls.add(memoryUrl(name, index, values.get(index))); + } + return Collections.enumeration(urls); + } + + private static URL memoryUrl(String name, int index, byte[] value) throws IOException { + return URL.of(URI.create("memory:/" + index + "/" + name), + new URLStreamHandler() { + @Override + protected URLConnection openConnection(URL url) { + return new URLConnection(url) { + @Override + public void connect() { + connected = true; + } + + @Override + public InputStream getInputStream() { + return new ByteArrayInputStream(value); + } + }; + } + }); + } + } + + private static final class InitializationSentinel { + private static final String CLASS_NAME = + "zeroecho.pki.api.profile.BuiltInCertificateProfileCatalogTest" + + "$InitializationSentinel"; + + static { + sentinelInitialized = true; + } + + private InitializationSentinel() { + } + } +} diff --git a/pki/src/test/java/zeroecho/pki/api/profile/BuiltInProfileImportIntegrityTest.java b/pki/src/test/java/zeroecho/pki/api/profile/BuiltInProfileImportIntegrityTest.java new file mode 100644 index 0000000..84046b4 --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/api/profile/BuiltInProfileImportIntegrityTest.java @@ -0,0 +1,127 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.api.profile; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Proxy; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Handler; +import java.util.logging.LogRecord; +import java.util.logging.Logger; + +import org.junit.jupiter.api.Test; + +import zeroecho.pki.api.PkiException; +import zeroecho.pki.impl.audit.InMemoryAuditSink; +import zeroecho.pki.impl.core.DefaultProfileService; +import zeroecho.pki.spi.store.PkiStore; + +/** + * Built-in template integrity checks at the production import boundary. + */ +final class BuiltInProfileImportIntegrityTest { + private static final String SENTINEL = "profile-integrity-sentinel"; + private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-07-30T10:00:00Z"), ZoneOffset.UTC); + + @Test + void invalidBuiltInArtifactsFailBeforeStoreAuditOrDiagnosticMutation() throws Exception { + BuiltInCertificateProfileTemplate server = builtIn("server-tls"); + BuiltInCertificateProfileTemplate email = builtIn("email-signing"); + byte[] noncanonical = (new String(server.canonicalJson(), StandardCharsets.UTF_8) + "\n") + .getBytes(StandardCharsets.UTF_8); + List invalid = List.of( + new BuiltInCertificateProfileTemplate(server.definition(), server.canonicalJson(), + new byte[CertificateProfileRef.HASH_BYTES], SENTINEL), + new BuiltInCertificateProfileTemplate(email.definition(), server.canonicalJson(), + server.canonicalSha256(), SENTINEL), + new BuiltInCertificateProfileTemplate(server.definition(), noncanonical, + MessageDigest.getInstance("SHA-256").digest(noncanonical), SENTINEL)); + AtomicInteger storeCalls = new AtomicInteger(); + InMemoryAuditSink audit = new InMemoryAuditSink(); + DefaultProfileService service = new DefaultProfileService(countingStore(storeCalls), CLOCK, audit); + CollectingHandler logs = new CollectingHandler(); + Logger root = Logger.getLogger(""); + root.addHandler(logs); + try { + for (BuiltInCertificateProfileTemplate template : invalid) { + PkiException failure = assertThrows(PkiException.class, + () -> service.importBuiltIn(template)); + assertEquals("Profile lifecycle operation failed: code=BUILT_IN_PROFILE_INVALID", + failure.getMessage()); + assertEquals(null, failure.getCause()); + assertEquals(0, failure.getSuppressed().length); + assertFalse(failure.getMessage().contains(SENTINEL)); + } + } finally { + root.removeHandler(logs); + logs.close(); + } + assertEquals(0, storeCalls.get()); + assertTrue(audit.snapshot().isEmpty()); + assertFalse(logs.text().contains(SENTINEL)); + } + + private static PkiStore countingStore(AtomicInteger calls) { + return (PkiStore) Proxy.newProxyInstance(PkiStore.class.getClassLoader(), + new Class[] { PkiStore.class }, (proxy, method, arguments) -> { + if (method.getDeclaringClass() == Object.class) { + return switch (method.getName()) { + case "hashCode" -> System.identityHashCode(proxy); + case "equals" -> proxy == arguments[0]; + case "toString" -> "CountingPkiStore"; + default -> throw new AssertionError("unexpected Object method"); + }; + } + calls.incrementAndGet(); + throw new AssertionError("store must not be called"); + }); + } + + private static BuiltInCertificateProfileTemplate builtIn(String profileId) { + return BuiltInCertificateProfileCatalog.load( + BuiltInProfileImportIntegrityTest.class.getClassLoader()).stream() + .filter(template -> profileId.equals(template.definition().profileId())) + .findFirst().orElseThrow(); + } + + private static final class CollectingHandler extends Handler { + private final List messages = new ArrayList<>(); + + @Override + public void publish(LogRecord record) { + if (record != null) { + messages.add(String.valueOf(record.getMessage())); + if (record.getThrown() != null) { + messages.add(String.valueOf(record.getThrown().getMessage())); + } + } + } + + @Override + public void flush() { + // In-memory only. + } + + @Override + public void close() { + // No external resource. + } + + private String text() { + return String.join("\n", messages); + } + } +} diff --git a/pki/src/test/java/zeroecho/pki/api/profile/CertificateProfileDocumentCodecTest.java b/pki/src/test/java/zeroecho/pki/api/profile/CertificateProfileDocumentCodecTest.java new file mode 100644 index 0000000..567f521 --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/api/profile/CertificateProfileDocumentCodecTest.java @@ -0,0 +1,655 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.api.profile; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.logging.Handler; +import java.util.logging.LogRecord; +import java.util.logging.Logger; +import java.util.stream.IntStream; + +import org.junit.jupiter.api.Test; + +import zeroecho.pki.api.FormatId; +import zeroecho.pki.api.PkiException; + +final class CertificateProfileDocumentCodecTest { + + private static boolean initializationProbe; + + private static final String VALID_DOCUMENT = """ + { + "schemaVersion": 1, + "profileId": "tls-service", + "profileVersion": 7, + "formatId": "x509", + "displayName": "TLS service", + "maxValidity": "PT24H", + "subject": { + "allowEmpty": false, + "rules": [ + { + "oid": "2.5.4.3", + "source": "REQUESTER", + "minimumOccurrences": 1, + "maximumOccurrences": 1, + "maximumUtf8Bytes": 128 + }, + { + "oid": "2.5.4.10", + "source": "PROFILE_FIXED", + "minimumOccurrences": 1, + "maximumOccurrences": 1, + "maximumUtf8Bytes": 64, + "fixedValue": "ZeroEcho" + } + ] + }, + "subjectAlternativeNames": { + "minimumTotal": 1, + "maximumTotal": 4, + "serviceIdentityRequired": true, + "emailIdentityRequired": true, + "criticalWhenSubjectNonEmpty": false, + "rules": [ + { + "type": "URI", + "minimumOccurrences": 0, + "maximumOccurrences": 1, + "allowedSchemes": ["spiffe", "https"] + }, + { + "type": "DNS_NAME", + "minimumOccurrences": 0, + "maximumOccurrences": 1, + "wildcardAllowed": true + }, + { + "type": "IP_ADDRESS", + "minimumOccurrences": 0, + "maximumOccurrences": 1, + "ipv4Allowed": true, + "ipv6Allowed": true + }, + { + "type": "RFC822_NAME", + "minimumOccurrences": 1, + "maximumOccurrences": 1 + } + ] + }, + "leafCertificate": { + "basicConstraintsCritical": true, + "keyUsageCritical": true, + "keyUsage": ["KEY_AGREEMENT", "ENCIPHER_ONLY", "DIGITAL_SIGNATURE"], + "extendedKeyUsageCritical": false, + "extendedKeyUsage": ["1.3.6.1.5.5.7.3.2", "1.3.6.1.5.5.7.3.1"], + "allowedKeyAlgorithms": ["RSA", "Ed25519", "ECDSA", "Ed448"] + } + } + """; + + @Test + void parsesEverySupportedRuleShapeIntoAuthoritativeTypedPolicies() { + CertificateProfileDefinition definition = parse(VALID_DOCUMENT); + + assertEquals(CertificateProfileDefinition.SCHEMA_VERSION, 1); + 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(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()); + assertTrue(leaf.subjectAlternativeNamePolicy().allowDnsWildcard()); + assertEquals(Set.of("https", "spiffe"), + leaf.subjectAlternativeNamePolicy().allowedUriSchemes()); + assertEquals(Set.of(LeafKeyUsage.DIGITAL_SIGNATURE, LeafKeyUsage.KEY_AGREEMENT, + LeafKeyUsage.ENCIPHER_ONLY), leaf.keyUsages()); + assertEquals(Set.of("RSA", "ECDSA", "Ed25519", "Ed448"), + leaf.allowedSubjectKeyAlgorithmIds()); + assertEquals(Set.of(new ExtendedKeyUsageId("1.3.6.1.5.5.7.3.1"), + new ExtendedKeyUsageId("1.3.6.1.5.5.7.3.2")), leaf.extendedKeyUsages()); + } + + @Test + void roundTripsEveryRequiredTypedProfileCategoryWithoutActivationState() { + List definitions = List.of( + definition("cn-only", requesterCn(), noSan(), Set.of(), Set.of("RSA")), + definition("dns", requesterCn(), dnsSan(false, false), eku(), Set.of("RSA")), + definition("wildcard-dns", requesterCn(), dnsSan(true, false), eku(), Set.of("RSA")), + definition("ipv4", requesterCn(), ipSan(true, false), eku(), Set.of("ECDSA")), + definition("ipv6", requesterCn(), ipSan(false, true), eku(), Set.of("Ed25519")), + 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), + eku(), Set.of("RSA")), + definition("fixed-rdn", fixedOrganization(), noSan(), eku(), Set.of("RSA")), + definition("multiple-algorithms", requesterCn(), noSan(), Set.of(), + Set.of("RSA", "ECDSA", "Ed25519", "Ed448"))); + + for (CertificateProfileDefinition expected : definitions) { + byte[] encoded = CertificateProfileDocumentCodec.writeCanonical(expected); + CertificateProfileDefinition actual = CertificateProfileDocumentCodec.parse(encoded); + String json = new String(encoded, StandardCharsets.UTF_8); + + assertEquals(expected, actual, expected.profileId()); + assertFalse(json.contains("\"active\""), expected.profileId()); + assertArrayEquals(encoded, CertificateProfileDocumentCodec.writeCanonical(actual), + expected.profileId()); + } + + CertificateProfileDefinition immutable = CertificateProfileDocumentCodec.parse( + CertificateProfileDocumentCodec.writeCanonical(definitions.get(7))); + assertThrows(UnsupportedOperationException.class, + () -> immutable.leafPolicy().subjectPolicy().rules().add( + new SubjectRdnRule(SubjectRdnType.PSEUDONYM, 0, 0, 32, + Optional.empty(), true))); + assertThrows(UnsupportedOperationException.class, + () -> immutable.leafPolicy().keyUsages().add(LeafKeyUsage.CONTENT_COMMITMENT)); + assertThrows(UnsupportedOperationException.class, + () -> immutable.leafPolicy().subjectAlternativeNamePolicy() + .allowedUriSchemes().add("ssh")); + } + + @Test + void canonicalOutputHasFixedOrderSortedSetsAndIsIdempotent() { + byte[] first = CertificateProfileDocumentCodec.writeCanonical(parse(VALID_DOCUMENT)); + byte[] second = CertificateProfileDocumentCodec.writeCanonical( + CertificateProfileDocumentCodec.parse(first)); + String json = new String(first, StandardCharsets.UTF_8); + + assertArrayEquals(first, second); + assertFalse(json.startsWith("\ufeff")); + assertFalse(json.endsWith("\n")); + assertFalse(json.contains("\n")); + assertFalse(json.contains(": ")); + assertFalse(json.contains(", ")); + assertTrue(json.indexOf("\"schemaVersion\"") < 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\"")); + assertTrue(json.contains("\"allowedSchemes\":[\"https\",\"spiffe\"]")); + assertTrue(json.contains("\"source\":\"REQUESTER\"")); + assertTrue(json.contains("\"source\":\"PROFILE_FIXED\"")); + assertTrue(json.contains("\"keyUsage\":[\"DIGITAL_SIGNATURE\",\"ENCIPHER_ONLY\"," + + "\"KEY_AGREEMENT\"]")); + assertTrue(json.contains("\"allowedKeyAlgorithms\":[\"ECDSA\",\"Ed25519\",\"Ed448\",\"RSA\"]")); + } + + @Test + void writerRejectsProfileStringsThatItsParserWouldReject() { + 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())); + + for (CertificateProfileDefinition definition : invalid) { + assertWriteCode(definition, "CANONICALIZATION_FAILED"); + } + } + + @Test + void inputStreamRemainsCallerOwned() { + TrackingInputStream input = new TrackingInputStream(bytes(VALID_DOCUMENT)); + + CertificateProfileDefinition definition = CertificateProfileDocumentCodec.parse(input); + + assertEquals("tls-service", definition.profileId()); + assertFalse(input.closed); + } + + @Test + void rejectsMalformedJsonExtensionsTrailingContentBomAndMalformedUtf8() { + assertCode(VALID_DOCUMENT.replace("{", "{/*comment*/"), "MALFORMED_JSON"); + assertCode(VALID_DOCUMENT + "{}", "MALFORMED_JSON"); + assertCode(VALID_DOCUMENT.substring(0, VALID_DOCUMENT.length() - 4), "MALFORMED_JSON"); + + byte[] bom = new byte[bytes(VALID_DOCUMENT).length + 3]; + bom[0] = (byte) 0xef; + bom[1] = (byte) 0xbb; + bom[2] = (byte) 0xbf; + System.arraycopy(bytes(VALID_DOCUMENT), 0, bom, 3, bytes(VALID_DOCUMENT).length); + assertCode(bom, "MALFORMED_JSON"); + + byte[] malformed = bytes(VALID_DOCUMENT); + int display = indexOf(malformed, bytes("TLS service")); + malformed[display] = (byte) 0xc3; + malformed[display + 1] = (byte) 0x28; + assertCode(malformed, "MALFORMED_JSON"); + } + + @Test + void rejectsDuplicateUnknownMissingNullWrongAndNonintegralFields() { + assertCode(VALID_DOCUMENT.replace("\"schemaVersion\": 1,", + "\"schemaVersion\": 1,\"schemaVersion\": 1,"), "DUPLICATE_FIELD"); + for (String document : List.of( + VALID_DOCUMENT.replace("\"schemaVersion\": 1,", + "\"secret-field\": true,\"schemaVersion\": 1,"), + VALID_DOCUMENT.replace("\"allowEmpty\": false,", + "\"unknown\": true,\"allowEmpty\": false,"), + VALID_DOCUMENT.replace("\"oid\": \"2.5.4.3\",", + "\"unknown\": true,\"oid\": \"2.5.4.3\","), + VALID_DOCUMENT.replace("\"minimumTotal\": 1,", + "\"unknown\": true,\"minimumTotal\": 1,"), + VALID_DOCUMENT.replace("\"type\": \"URI\",", + "\"unknown\": true,\"type\": \"URI\","), + VALID_DOCUMENT.replace("\"basicConstraintsCritical\": true,", + "\"unknown\": true,\"basicConstraintsCritical\": true,"))) { + assertCode(document, "UNKNOWN_FIELD"); + } + for (String document : List.of( + VALID_DOCUMENT.replace("\"allowEmpty\": false,", + "\"allowEmpty\": false,\"allowEmpty\": false,"), + VALID_DOCUMENT.replace("\"type\": \"URI\",", + "\"type\": \"URI\",\"type\": \"URI\","), + VALID_DOCUMENT.replace("\"keyUsageCritical\": true,", + "\"keyUsageCritical\": true,\"keyUsageCritical\": true,"))) { + assertCode(document, "DUPLICATE_FIELD"); + } + for (String document : List.of( + VALID_DOCUMENT.replace("\"schemaVersion\": 1,\n", ""), + VALID_DOCUMENT.replace("\"allowEmpty\": false,\n", ""), + VALID_DOCUMENT.replace("\"oid\": \"2.5.4.3\",\n", ""), + VALID_DOCUMENT.replace("\"minimumTotal\": 1,\n", ""), + VALID_DOCUMENT.replace("\"type\": \"URI\",\n", ""), + VALID_DOCUMENT.replace("\"basicConstraintsCritical\": true,\n", ""))) { + assertCode(document, "MISSING_FIELD"); + } + assertCode(VALID_DOCUMENT.replace("\"displayName\": \"TLS service\"", + "\"displayName\": null"), "WRONG_TYPE"); + assertCode(VALID_DOCUMENT.replace("\"allowEmpty\": false", + "\"allowEmpty\": \"false\""), "WRONG_TYPE"); + assertCode(VALID_DOCUMENT.replace("\"minimumTotal\": 1", + "\"minimumTotal\": \"1\""), "WRONG_TYPE"); + assertCode(VALID_DOCUMENT.replace("\"keyUsage\": [\"KEY_AGREEMENT\", " + + "\"ENCIPHER_ONLY\", \"DIGITAL_SIGNATURE\"]", "\"keyUsage\": true"), + "WRONG_TYPE"); + assertCode(VALID_DOCUMENT.replace("\"profileVersion\": 7", + "\"profileVersion\": \"7\""), "WRONG_TYPE"); + assertCode(VALID_DOCUMENT.replace("\"profileVersion\": 7", + "\"profileVersion\": -1"), "PROFILE_VERSION_INVALID"); + assertCode(VALID_DOCUMENT.replace("\"profileVersion\": 7", + "\"profileVersion\": 9223372036854775808"), "LIMIT_EXCEEDED"); + assertCode(VALID_DOCUMENT.replace("\"profileVersion\": 7", + "\"profileVersion\": 7.0"), "WRONG_TYPE"); + assertCode(VALID_DOCUMENT.replace("\"profileVersion\": 7", + "\"profileVersion\": 7e0"), "WRONG_TYPE"); + } + + @Test + void rejectsUnsupportedVersionsTokensCaseWhitespaceAndNoncanonicalDuration() { + assertCode(VALID_DOCUMENT.replace("\"schemaVersion\": 1", + "\"schemaVersion\": 2"), "SCHEMA_VERSION_UNSUPPORTED"); + assertCode(VALID_DOCUMENT.replace("\"profileVersion\": 7", + "\"profileVersion\": 0"), "PROFILE_VERSION_INVALID"); + assertCode(VALID_DOCUMENT.replace("\"type\": \"DNS_NAME\"", + "\"type\": \"dns_name\""), "TOKEN_INVALID"); + assertCode(VALID_DOCUMENT.replace("\"source\": \"REQUESTER\"", + "\"source\": \" REQUESTER\""), "TOKEN_INVALID"); + assertCode(VALID_DOCUMENT.replace("\"profileId\": \"tls-service\"", + "\"profileId\": \" tls-service\""), "TOKEN_INVALID"); + assertCode(VALID_DOCUMENT.replace("\"formatId\": \"x509\"", + "\"formatId\": \"x509 \""), "TOKEN_INVALID"); + assertCode(VALID_DOCUMENT.replace("\"displayName\": \"TLS service\"", + "\"displayName\": \" TLS service\""), "TOKEN_INVALID"); + assertCode(VALID_DOCUMENT.replace("\"oid\": \"2.5.4.3\"", + "\"oid\": \"2.5.4.999\""), "TOKEN_INVALID"); + assertCode(VALID_DOCUMENT.replace("\"type\": \"DNS_NAME\"", + "\"type\": \"OTHER_NAME\""), "TOKEN_INVALID"); + assertCode(VALID_DOCUMENT.replace("\"DIGITAL_SIGNATURE\"", + "\"CERTIFICATE_SIGN\""), "TOKEN_INVALID"); + assertCode(VALID_DOCUMENT.replace("\"Ed448\"", "\"ed448\""), "TOKEN_INVALID"); + assertCode(VALID_DOCUMENT.replace("\"1.3.6.1.5.5.7.3.2\"", + "\"not-an-oid\""), "TOKEN_INVALID"); + for (String duration : List.of("P1D", "PT0S", "PT-1S", "one-day")) { + assertCode(VALID_DOCUMENT.replace("\"maxValidity\": \"PT24H\"", + "\"maxValidity\": \"" + duration + "\""), "TOKEN_INVALID"); + } + } + + @Test + void rejectsTypeSpecificShapeViolationsAndSemanticInvariants() { + assertCode(VALID_DOCUMENT.replace("\"wildcardAllowed\": true", + "\"ipv4Allowed\": true"), "MISSING_FIELD"); + assertCode(VALID_DOCUMENT.replace("\"type\": \"RFC822_NAME\",", + "\"type\": \"RFC822_NAME\",\"wildcardAllowed\": false,"), + "UNKNOWN_FIELD"); + assertCode(VALID_DOCUMENT.replace("\"source\": \"REQUESTER\",", + "\"source\": \"REQUESTER\",\"fixedValue\": \"forbidden\","), + "UNKNOWN_FIELD"); + assertCode(VALID_DOCUMENT.replace(",\n \"fixedValue\": \"ZeroEcho\"", ""), + "MISSING_FIELD"); + assertCode(VALID_DOCUMENT.replace("\"allowEmpty\": false", + "\"allowEmpty\": true").replace("\"minimumTotal\": 1", + "\"minimumTotal\": 0"), + "SEMANTIC_INVALID"); + assertCode(VALID_DOCUMENT.replace("\"ipv4Allowed\": true", + "\"ipv4Allowed\": false").replace("\"ipv6Allowed\": true", + "\"ipv6Allowed\": false"), + "SEMANTIC_INVALID"); + assertCode(VALID_DOCUMENT.replace("\"keyUsage\": [\"KEY_AGREEMENT\", " + + "\"ENCIPHER_ONLY\", \"DIGITAL_SIGNATURE\"]", + "\"keyUsage\": [\"ENCIPHER_ONLY\"]"), "SEMANTIC_INVALID"); + + String rich = canonical(parse(VALID_DOCUMENT)); + assertCode(rich.replace("\"oid\":\"2.5.4.10\"", "\"oid\":\"2.5.4.3\""), + "SEMANTIC_INVALID"); + assertCode(replaceFirst(rich, "\"minimumOccurrences\":1", + "\"minimumOccurrences\":2"), "SEMANTIC_INVALID"); + assertCode(replaceFirst(rich, "\"maximumOccurrences\":1", + "\"maximumOccurrences\":33"), "SEMANTIC_INVALID"); + assertCode(rich.replace("\"minimumTotal\":1,\"maximumTotal\":4", + "\"minimumTotal\":5,\"maximumTotal\":4"), "SEMANTIC_INVALID"); + assertCode(replaceFirst(rich, "\"minimumOccurrences\":0", + "\"minimumOccurrences\":2"), "SEMANTIC_INVALID"); + String dnsRule = "{\"type\":\"DNS_NAME\",\"minimumOccurrences\":0," + + "\"maximumOccurrences\":1,\"wildcardAllowed\":true}"; + assertCode(rich.replace(dnsRule, dnsRule + "," + dnsRule), "SEMANTIC_INVALID"); + + String uri = canonical(definition("uri-invalid", requesterCn(), uriSan(), eku(), + Set.of("RSA"))); + assertCode(uri.replace("\"allowedSchemes\":[\"https\"]", + "\"allowedSchemes\":[\"not a scheme\"]"), "SEMANTIC_INVALID"); + assertCode(uri.replace("\"allowedSchemes\":[\"https\"]", + "\"allowedSchemes\":[\"https\",\"https\"]"), "TOKEN_INVALID"); + + String dns = canonical(definition("dns-identity", requesterCn(), dnsSan(false, false), + eku(), Set.of("RSA"))); + assertCode(dns.replace("\"emailIdentityRequired\":false", + "\"emailIdentityRequired\":true"), "SEMANTIC_INVALID"); + String email = canonical(definition("service-identity", requesterCn(), emailSan(), + eku(), Set.of("RSA"))); + assertCode(email.replace("\"serviceIdentityRequired\":false", + "\"serviceIdentityRequired\":true"), "SEMANTIC_INVALID"); + String empty = canonical(definition("empty-invalid", new SubjectPolicy(List.of()), + dnsSan(false, true), eku(), Set.of("RSA"))); + assertCode(empty.replace("\"minimumTotal\":1", "\"minimumTotal\":0"), + "SEMANTIC_INVALID"); + } + + @Test + void rejectsDuplicateSetLikeValues() { + assertCode(VALID_DOCUMENT.replace("\"DIGITAL_SIGNATURE\"]", + "\"DIGITAL_SIGNATURE\",\"DIGITAL_SIGNATURE\"]"), "SEMANTIC_INVALID"); + assertCode(VALID_DOCUMENT.replace("\"1.3.6.1.5.5.7.3.1\"]", + "\"1.3.6.1.5.5.7.3.1\",\"1.3.6.1.5.5.7.3.1\"]"), + "SEMANTIC_INVALID"); + assertCode(VALID_DOCUMENT.replace("\"Ed448\"]", "\"Ed448\",\"Ed448\"]"), + "SEMANTIC_INVALID"); + assertCode(VALID_DOCUMENT.replace("\"spiffe\", \"https\"", + "\"spiffe\", \"https\", \"https\""), "TOKEN_INVALID"); + } + + @Test + void enforcesDocumentArrayAndUtf8StringBounds() { + byte[] oversized = new byte[CertificateProfileDocumentCodec.MAXIMUM_DOCUMENT_BYTES + 1]; + assertCode(oversized, "PROFILE_DOCUMENT_TOO_LARGE"); + assertCode(new ByteArrayInputStream(oversized), "PROFILE_DOCUMENT_TOO_LARGE"); + + String longProfileId = "a".repeat(129); + assertCode(VALID_DOCUMENT.replace("tls-service", longProfileId), "LIMIT_EXCEEDED"); + assertEquals("a".repeat(128), + parse(VALID_DOCUMENT.replace("tls-service", "a".repeat(128))).profileId()); + assertCode(VALID_DOCUMENT.replace("\"displayName\": \"TLS service\"", + "\"displayName\": \"" + "d".repeat(257) + "\""), "LIMIT_EXCEEDED"); + assertEquals("d".repeat(256), parse(VALID_DOCUMENT.replace( + "\"displayName\": \"TLS service\"", + "\"displayName\": \"" + "d".repeat(256) + "\"")).displayName()); + assertCode(VALID_DOCUMENT.replace("\"formatId\": \"x509\"", + "\"formatId\": \"" + "f".repeat(129) + "\""), "LIMIT_EXCEEDED"); + assertEquals("f".repeat(128), parse(VALID_DOCUMENT.replace( + "\"formatId\": \"x509\"", + "\"formatId\": \"" + "f".repeat(128) + "\"")).formatId().value()); + assertCode(VALID_DOCUMENT.replace("\"spiffe\"", "\"" + "s".repeat(33) + "\""), + "LIMIT_EXCEEDED"); + assertTrue(parse(VALID_DOCUMENT.replace("\"spiffe\"", "\"" + "s".repeat(32) + "\"")) + .leafPolicy().subjectAlternativeNamePolicy().allowedUriSchemes() + .contains("s".repeat(32))); + assertCode(VALID_DOCUMENT.replace("\"fixedValue\": \"ZeroEcho\"", + "\"fixedValue\": \"" + "v".repeat(4_097) + "\""), "LIMIT_EXCEEDED"); + + String schemes = IntStream.range(0, 129) + .mapToObj(index -> "\"a" + index + "\"") + .reduce((left, right) -> left + "," + right).orElseThrow(); + assertCode(VALID_DOCUMENT.replace("\"spiffe\", \"https\"", schemes), "LIMIT_EXCEEDED"); + } + + @Test + void enforcesDepthBeforeSchemaDispatch() { + String document = "{\"unknown\":" + "[".repeat(17) + "0" + "]".repeat(17) + "}"; + + assertCode(document, "LIMIT_EXCEEDED"); + } + + @Test + void redactsHostileInputAndParserDetailsFromFailures() { + String secret = "do-not-disclose-credential"; + String hostile = VALID_DOCUMENT.replace("\"schemaVersion\": 1,", + "\"" + secret + "\": true,\"schemaVersion\": 1,"); + + PkiException exception = assertThrows(PkiException.class, () -> parse(hostile)); + + assertFalse(exception.getMessage().contains(secret)); + assertNull(exception.getCause()); + assertEquals(0, exception.getSuppressed().length); + assertTrue(exception.getMessage().startsWith( + "Certificate profile document rejected: code=UNKNOWN_FIELD path=$.?")); + } + + @Test + void rejectsPolymorphicMetadataWithoutLoadingClassesOrLoggingInput() { + String probeName = "zeroecho.pki.api.profile.CertificateProfileDocumentCodecTest" + + "$InitializationProbe"; + List hostileFields = List.of("@class", "class", "typeName", "java.lang.Runtime"); + Logger logger = Logger.getLogger(CertificateProfileDocumentCodec.class.getName()); + List records = new ArrayList<>(); + Handler handler = new Handler() { + @Override + public void publish(LogRecord record) { + records.add(record); + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + }; + logger.addHandler(handler); + try { + for (String field : hostileFields) { + String document = VALID_DOCUMENT.replace("\"schemaVersion\": 1,", + "\"" + field + "\":\"" + probeName + "\",\"schemaVersion\": 1,"); + PkiException exception = assertThrows(PkiException.class, () -> parse(document)); + assertTrue(exception.getMessage().contains("code=UNKNOWN_FIELD ")); + assertFalse(exception.getMessage().contains(field)); + assertNull(exception.getCause()); + assertEquals(0, exception.getSuppressed().length); + } + } finally { + logger.removeHandler(handler); + } + assertFalse(initializationProbe); + assertTrue(records.isEmpty()); + } + + private static CertificateProfileDefinition parse(String document) { + return CertificateProfileDocumentCodec.parse(bytes(document)); + } + + private static String canonical(CertificateProfileDefinition definition) { + return new String(CertificateProfileDocumentCodec.writeCanonical(definition), + StandardCharsets.UTF_8); + } + + private static CertificateProfileDefinition definition(String id, SubjectPolicy subject, + SubjectAlternativeNamePolicy san, Set extendedKeyUsages, + Set algorithms) { + LeafCertificatePolicy leaf = new LeafCertificatePolicy(subject, san, + Set.of(LeafKeyUsage.DIGITAL_SIGNATURE), extendedKeyUsages, true, false, true, + algorithms, Duration.ofDays(1)); + return new CertificateProfileDefinition(id, 1, new FormatId("x509"), id, leaf); + } + + private static SubjectPolicy requesterCn() { + return new SubjectPolicy(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, + 1, 1, 64, Optional.of("ZeroEcho"), false))); + } + + private static SubjectAlternativeNamePolicy noSan() { + return new SubjectAlternativeNamePolicy(false, 0, 0, List.of(), false, Set.of(), + false, false, false); + } + + private static SubjectAlternativeNamePolicy dnsSan(boolean wildcard, boolean emptySubject) { + return new SubjectAlternativeNamePolicy(emptySubject, 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, + 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, + 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, + 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( + new SubjectAlternativeNameRule(SubjectAlternativeNameType.DNS_NAME, + 0, 1, false, false), + new SubjectAlternativeNameRule(SubjectAlternativeNameType.IP_ADDRESS, + 0, 1, true, true), + new SubjectAlternativeNameRule(SubjectAlternativeNameType.RFC822_NAME, + 0, 1, false, false), + new SubjectAlternativeNameRule(SubjectAlternativeNameType.URI, + 0, 1, false, false)), + false, Set.of("https"), false, false, false); + } + + private static Set eku() { + return Set.of(new ExtendedKeyUsageId("1.3.6.1.5.5.7.3.1")); + } + + private static String replaceFirst(String value, String target, String replacement) { + int index = value.indexOf(target); + if (index < 0) { + throw new AssertionError("Test fixture value not found: " + target); + } + return value.substring(0, index) + replacement + value.substring(index + target.length()); + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } + + private static void assertCode(String document, String code) { + assertCode(bytes(document), code); + } + + private static void assertCode(byte[] document, String code) { + PkiException exception = assertThrows(PkiException.class, + () -> CertificateProfileDocumentCodec.parse(document)); + assertTrue(exception.getMessage().contains("code=" + code + " "), + exception.getMessage()); + assertNull(exception.getCause()); + } + + private static void assertCode(ByteArrayInputStream input, String code) { + PkiException exception = assertThrows(PkiException.class, + () -> CertificateProfileDocumentCodec.parse(input)); + assertTrue(exception.getMessage().contains("code=" + code + " "), + exception.getMessage()); + } + + private static void assertWriteCode(CertificateProfileDefinition definition, String code) { + PkiException exception = assertThrows(PkiException.class, + () -> CertificateProfileDocumentCodec.writeCanonical(definition)); + assertTrue(exception.getMessage().contains("code=" + code + " "), + exception.getMessage()); + assertNull(exception.getCause()); + assertEquals(0, exception.getSuppressed().length); + } + + private static int indexOf(byte[] haystack, byte[] needle) { + for (int start = 0; start <= haystack.length - needle.length; start++) { + boolean matches = true; + for (int offset = 0; offset < needle.length; offset++) { + matches &= haystack[start + offset] == needle[offset]; + } + if (matches) { + return start; + } + } + throw new AssertionError("Test fixture value not found"); + } + + private static final class TrackingInputStream extends ByteArrayInputStream { + private boolean closed; + + private TrackingInputStream(byte[] buffer) { + super(buffer); + } + + @Override + public void close() { + closed = true; + } + } + + private static final class InitializationProbe { + static { + initializationProbe = true; + } + + private InitializationProbe() { + } + } +} diff --git a/pki/src/test/java/zeroecho/pki/e2e/H7EndEntityAcceptanceE2eTest.java b/pki/src/test/java/zeroecho/pki/e2e/H7EndEntityAcceptanceE2eTest.java new file mode 100644 index 0000000..fdeb4fa --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/e2e/H7EndEntityAcceptanceE2eTest.java @@ -0,0 +1,998 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.e2e; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigInteger; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.logging.Handler; +import java.util.logging.LogRecord; +import java.util.logging.Logger; + +import org.bouncycastle.asn1.ASN1Encodable; +import org.bouncycastle.asn1.ASN1ObjectIdentifier; +import org.bouncycastle.asn1.DERIA5String; +import org.bouncycastle.asn1.DERNull; +import org.bouncycastle.asn1.DEROctetString; +import org.bouncycastle.asn1.DERUTF8String; +import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers; +import org.bouncycastle.asn1.x500.RDN; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x500.style.BCStyle; +import org.bouncycastle.asn1.x509.AuthorityKeyIdentifier; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.ExtendedKeyUsage; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.ExtensionsGenerator; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.asn1.x509.KeyPurposeId; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.asn1.x509.SubjectKeyIdentifier; +import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder; +import org.bouncycastle.pkcs.PKCS10CertificationRequest; +import org.bouncycastle.pkcs.PKCS10CertificationRequestBuilder; +import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequestBuilder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import zeroecho.pki.api.EncodedObject; +import zeroecho.pki.api.Encoding; +import zeroecho.pki.api.FormatId; +import zeroecho.pki.api.IssuerRef; +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.audit.AuditEvent; +import zeroecho.pki.api.ca.CaCreateCommand; +import zeroecho.pki.api.credential.Credential; +import zeroecho.pki.api.credential.EndEntityProfileBinding; +import zeroecho.pki.api.profile.CertificateProfileRef; +import zeroecho.pki.api.profile.BuiltInCertificateProfileCatalog; +import zeroecho.pki.api.profile.BuiltInCertificateProfileTemplate; +import zeroecho.pki.api.credential.CredentialBundle; +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.ValidatedCertificateRequest; +import zeroecho.pki.impl.core.attr.SimpleAttributeSet; +import zeroecho.pki.spi.framework.CredentialIssuerBackend; +import zeroecho.pki.testkit.PkiTestRuntime; +import zeroecho.pki.testkit.H7ProfileDocuments; + +/** + * End-to-end H7 acceptance evidence from signed PKCS#10 requests through durable + * real X.509 leaf certificates. + */ +final class H7EndEntityAcceptanceE2eTest { + private static final int POSITIVE_ISSUANCE_CASE_COUNT = 16; + private static final int MAIN_BACKEND_MUTATION_CASE_COUNT = 61; + private static final int BACKEND_MUTATION_CASE_COUNT = 63; + private static final String REDACTION_SENTINEL = "DO-NOT-LOG-H7-SENTINEL"; + private static final Set LEAF_EXTENSION_OIDS = Set.of( + Extension.basicConstraints, Extension.keyUsage, Extension.extendedKeyUsage, + Extension.subjectAlternativeName); + + @Test + void everyBuiltInTemplateRequiresImportAndActivationBeforeRealIssuance(@TempDir Path tempDir) + throws Exception { + KeyPair rootKey = rsa(); + KeyPair leafKey = rsa(); + KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:h7-built-in:root"); + try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), + Map.of(rootKeyRef, rootKey))) { + List templates = BuiltInCertificateProfileCatalog.load( + H7EndEntityAcceptanceE2eTest.class.getClassLoader()); + assertEquals(4, 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 SimpleAttributeSet())); + for (BuiltInCertificateProfileTemplate template : templates) { + String profileId = template.definition().profileId(); + CertificateProfileRef imported = runtime.profileService().importBuiltIn(template); + assertTrue(runtime.profileService().getActiveReference(profileId).isEmpty()); + assertThrows(PkiException.class, () -> runtime.profileService().requireActiveProfile(profileId)); + assertEquals(imported, runtime.profileService().activateProfile(profileId, 1)); + + GeneralName identity = builtInIdentity(profileId); + ParsedCertificationRequest request = parse(runtime, leafKey, + new X500Name("CN=" + profileId), List.of(identity)); + Credential issued = runtime.issuanceService().issueEndEntity(new IssueEndEntityCommand(rootCaId, + request, profileId, Optional.empty())).credential(); + EndEntityProfileBinding binding = (EndEntityProfileBinding) issued.profileBinding(); + assertEquals(imported, binding.reference()); + X509CertificateHolder holder = new X509CertificateHolder(issued.encoded().bytes()); + GeneralName[] names = GeneralNames.fromExtensions(holder.getExtensions(), + Extension.subjectAlternativeName).getNames(); + assertEquals(1, names.length); + assertEquals(identity.getTagNo(), names[0].getTagNo()); + } + } + } + + private static GeneralName builtInIdentity(String profileId) { + return switch (profileId) { + case "server-tls", "vpn-server" -> + new GeneralName(GeneralName.dNSName, profileId + ".example.com"); + case "vpn-client" -> + new GeneralName(GeneralName.uniformResourceIdentifier, "spiffe://example.test/workload"); + case "email-signing" -> + new GeneralName(GeneralName.rfc822Name, "Signer@example.com"); + default -> throw new IllegalArgumentException("unexpected built-in profile"); + }; + } + + @Test + void realSignedCsrMatrixIssuesExactProfilesAndSurvivesReopen(@TempDir Path tempDir) throws Exception { + KeyPair rootKey = rsa(); + KeyPair leafKey = rsa(); + KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:h7-acceptance:root"); + Path busFile = tempDir.resolve("bus.log"); + Map durableCredentials = new LinkedHashMap<>(); + List allocatedSerials = new ArrayList<>(); + + try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, busFile, Map.of(rootKeyRef, rootKey))) { + 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 SimpleAttributeSet())); + + CredentialIssuerBackend serialCapturingBackend = serialCapturingBackend(runtime.issuerBackend(), + allocatedSerials); + List cases = List.of( + new IssuanceCase("h7-dns", new X500Name("CN=DNS Leaf"), false, + List.of(new GeneralName(GeneralName.dNSName, "WWW.Example.COM")), + List.of("2:www.example.com")), + new IssuanceCase("h7-dns-multiple", new X500Name("CN=Multiple DNS Leaf"), false, + List.of(new GeneralName(GeneralName.dNSName, "one.example.com"), + new GeneralName(GeneralName.dNSName, "two.example.com"), + new GeneralName(GeneralName.dNSName, "three.example.com")), + List.of("2:one.example.com", "2:two.example.com", "2:three.example.com")), + new IssuanceCase("h7-wildcard", new X500Name("CN=Wildcard Leaf"), false, + List.of(new GeneralName(GeneralName.dNSName, "*.Example.COM")), + List.of("2:*.example.com")), + new IssuanceCase("h7-dns-empty", new X500Name(""), true, + List.of(new GeneralName(GeneralName.dNSName, "empty.example.com")), + List.of("2:empty.example.com")), + new IssuanceCase("h7-dns-critical", new X500Name("CN=Critical DNS Leaf"), true, + List.of(new GeneralName(GeneralName.dNSName, "critical.example.com")), + List.of("2:critical.example.com")), + new IssuanceCase("h7-ip", new X500Name("CN=IPv4 Leaf"), false, + List.of(ipName(new byte[] { (byte) 192, 0, 2, 10 })), + List.of("7:c000020a")), + new IssuanceCase("h7-ip", new X500Name("CN=IPv6 Leaf"), false, + List.of(ipName(HexFormat.of().parseHex("20010db8000000000000000000000001"))), + List.of("7:20010db8000000000000000000000001")), + new IssuanceCase("h7-ip-mixed", new X500Name("CN=Mixed IP Leaf"), false, + List.of(ipName(new byte[] { (byte) 192, 0, 2, 11 }), + ipName(HexFormat.of().parseHex("20010db8000000000000000000000002"))), + List.of("7:c000020b", "7:20010db8000000000000000000000002")), + new IssuanceCase("h7-ip-empty", new X500Name(""), true, + List.of(ipName(new byte[] { (byte) 192, 0, 2, 12 })), + List.of("7:c000020c")), + new IssuanceCase("h7-uri", new X500Name("CN=URI Leaf"), false, + List.of(new GeneralName(GeneralName.uniformResourceIdentifier, + "HTTPS://Service.Example.COM/a%2Fb?q=%2F")), + List.of("6:https://service.example.com/a%2Fb?q=%2F")), + new IssuanceCase("h7-uri-multiple", new X500Name("CN=Multiple URI Leaf"), false, + List.of(new GeneralName(GeneralName.uniformResourceIdentifier, + "https://one.example.com/a"), + new GeneralName(GeneralName.uniformResourceIdentifier, + "HTTPS://Two.Example.COM/b%2Fc?q=%2F")), + List.of("6:https://one.example.com/a", + "6:https://two.example.com/b%2Fc?q=%2F")), + new IssuanceCase("h7-uri-empty", new X500Name(""), true, + List.of(new GeneralName(GeneralName.uniformResourceIdentifier, + "https://empty.example.com/service")), + List.of("6:https://empty.example.com/service")), + new IssuanceCase("h7-rfc822", new X500Name("CN=Mail Leaf"), false, + List.of(new GeneralName(GeneralName.rfc822Name, "Local@Example.COM")), + List.of("1:Local@example.com")), + new IssuanceCase("h7-rfc822-multiple", new X500Name("CN=Multiple Mail Leaf"), false, + List.of(new GeneralName(GeneralName.rfc822Name, "First@Example.COM"), + new GeneralName(GeneralName.rfc822Name, "second@Example.COM")), + List.of("1:First@example.com", "1:second@example.com")), + new IssuanceCase("h7-rfc822-empty", new X500Name(""), true, + List.of(new GeneralName(GeneralName.rfc822Name, "Empty@Example.COM")), + List.of("1:Empty@example.com")), + new IssuanceCase("h7-mixed", new X500Name("CN=Mixed Leaf"), false, + List.of(new GeneralName(GeneralName.dNSName, "mixed.example.com"), + ipName(new byte[] { (byte) 198, 51, 100, 7 }), + new GeneralName(GeneralName.uniformResourceIdentifier, + "https://mixed.example.com/service"), + new GeneralName(GeneralName.rfc822Name, "Mixed@Example.COM")), + List.of("2:mixed.example.com", "7:c6336407", + "6:https://mixed.example.com/service", "1:Mixed@example.com"))); + assertEquals(POSITIVE_ISSUANCE_CASE_COUNT, cases.size()); + + for (IssuanceCase issuanceCase : cases) { + Credential credential = issue(runtime, serialCapturingBackend, rootCaId, leafKey, issuanceCase); + BigInteger allocatedSerial = allocatedSerials.get(allocatedSerials.size() - 1); + PersistedExpectation expectation = new PersistedExpectation(issuanceCase, allocatedSerial, + credential.validity(), credential.publicKeyId()); + assertExactLeaf(credential, rootKey, leafKey, expectation); + durableCredentials.put(credential.credentialId(), expectation); + } + } + + assertEquals(durableCredentials.size(), allocatedSerials.size()); + for (BigInteger serial : allocatedSerials) { + assertTrue(serial.signum() > 0); + assertTrue(serial.toByteArray().length <= 20); + } + assertEquals(List.of("issuerCaId", "request", "profileId", "validityOverride"), + Arrays.stream(IssueEndEntityCommand.class.getRecordComponents()) + .map(component -> component.getName()).toList()); + + try (PkiTestRuntime reopened = PkiTestRuntime.create(tempDir, busFile, Map.of(rootKeyRef, rootKey))) { + for (Map.Entry entry : durableCredentials.entrySet()) { + Credential persisted = reopened.store().getCredential(entry.getKey()).orElseThrow(); + assertExactLeaf(persisted, rootKey, leafKey, entry.getValue()); + } + } + } + + @Test + void realSignedCsrProfileViolationsRejectBeforeBackendPersistenceOrSuccessAudit(@TempDir Path tempDir) + throws Exception { + KeyPair rootKey = rsa(); + KeyPair leafKey = rsa(); + KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:h7-rejection:root"); + try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), + 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 SimpleAttributeSet())); + + assertProfileRejected(runtime, rootCaId, leafKey, "h7-dns", + List.of(new GeneralName(GeneralName.dNSName, "*.example.com")), + "SAN_WILDCARD_FORBIDDEN"); + assertProfileRejected(runtime, rootCaId, leafKey, "h7-ip", + List.of(new GeneralName(GeneralName.dNSName, "wrong-type.example.com")), + "SAN_TYPE_FORBIDDEN"); + assertProfileRejected(runtime, rootCaId, leafKey, "h7-ipv4", + List.of(ipName(HexFormat.of().parseHex("20010db8000000000000000000000001"))), + "SAN_IP_FAMILY_FORBIDDEN"); + assertProfileRejected(runtime, rootCaId, leafKey, "h7-ipv6", + List.of(ipName(new byte[] { (byte) 192, 0, 2, 20 })), + "SAN_IP_FAMILY_FORBIDDEN"); + assertProfileRejected(runtime, rootCaId, leafKey, "h7-uri", + List.of(new GeneralName(GeneralName.uniformResourceIdentifier, + "http://service.example.com/path")), + "SAN_URI_SCHEME_FORBIDDEN"); + assertProfileRejected(runtime, rootCaId, leafKey, "h7-rfc822", + List.of(new GeneralName(GeneralName.dNSName, "mail.example.com")), + "SAN_TYPE_FORBIDDEN"); + assertProfileRejected(runtime, rootCaId, leafKey, "h7-mixed", + List.of(new GeneralName(GeneralName.dNSName, "mixed.example.com"), + ipName(new byte[] { (byte) 198, 51, 100, 8 }), + new GeneralName(GeneralName.uniformResourceIdentifier, + "https://mixed.example.com/service")), + "SAN_COUNT_INVALID"); + } + } + + @Test + void maliciousRealDerMutationMatrixRejectsEveryLeafPostcondition(@TempDir Path tempDir) throws Exception { + KeyPair rootKey = rsa(); + KeyPair leafKey = rsa(); + KeyPair substituteKey = rsa(); + KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:h7-mutation:root"); + CollectingLogHandler logHandler = new CollectingLogHandler(); + Logger rootLogger = Logger.getLogger(""); + rootLogger.addHandler(logHandler); + try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), + Map.of(rootKeyRef, rootKey))) { + 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 SimpleAttributeSet())); + ParsedCertificationRequest request = parse(runtime, leafKey, + new X500Name("CN=Mutation Leaf,O=Example"), + List.of(new GeneralName(GeneralName.dNSName, "base.example.com"), + ipName(new byte[] { (byte) 203, 0, 113, 9 }), + new GeneralName(GeneralName.uniformResourceIdentifier, + "https://base.example.com/service"), + new GeneralName(GeneralName.rfc822Name, "Base@example.com"))); + + assertEquals(BACKEND_MUTATION_CASE_COUNT, LeafMutation.values().length); + assertEquals(MAIN_BACKEND_MUTATION_CASE_COUNT, + Arrays.stream(LeafMutation.values()).filter(LeafMutation::mainProfile).count()); + for (LeafMutation mutation : LeafMutation.values()) { + if (mutation.mainProfile()) { + assertMaliciousMutationRejected(runtime, rootCaId, request, "h7-backend-mutation", + rootKey, substituteKey, mutation); + } + } + ParsedCertificationRequest noExtensions = parseWithoutExtensions(runtime, leafKey, + new X500Name("CN=No Extensions Leaf")); + assertMaliciousMutationRejected(runtime, rootCaId, noExtensions, "h7-no-san-eku", + rootKey, substituteKey, LeafMutation.SUBJECT_ALTERNATIVE_NAME_ADDED_WHEN_FORBIDDEN); + assertMaliciousMutationRejected(runtime, rootCaId, noExtensions, "h7-no-san-eku", + rootKey, substituteKey, LeafMutation.EXTENDED_KEY_USAGE_ADDED_WHEN_FORBIDDEN); + assertFalse(logHandler.messages().contains(REDACTION_SENTINEL)); + } finally { + rootLogger.removeHandler(logHandler); + } + } + + @Test + void bcConstructionCannotEmitDuplicateCertificateExtensionOids() throws Exception { + for (ASN1ObjectIdentifier oid : LEAF_EXTENSION_OIDS) { + ExtensionsGenerator generator = new ExtensionsGenerator(); + ASN1Encodable value = extensionValue(oid); + generator.addExtension(oid, true, value); + try { + generator.addExtension(oid, false, value); + assertEquals(1, Arrays.stream(generator.generate().getExtensionOIDs()) + .filter(oid::equals).count(), oid::getId); + } catch (RuntimeException expectedRejection) { + assertFalse(String.valueOf(expectedRejection.getMessage()).isBlank(), oid::getId); + } + } + } + + private static ASN1Encodable extensionValue(ASN1ObjectIdentifier oid) { + if (Extension.basicConstraints.equals(oid)) { + return new BasicConstraints(false); + } + if (Extension.keyUsage.equals(oid)) { + return new KeyUsage(KeyUsage.digitalSignature); + } + if (Extension.extendedKeyUsage.equals(oid)) { + return new ExtendedKeyUsage(KeyPurposeId.id_kp_serverAuth); + } + return new GeneralNames(new GeneralName(GeneralName.dNSName, "duplicate.example.com")); + } + + private static List acceptanceProfileDocuments() { + return List.of(H7ProfileDocuments.dnsProfile(), H7ProfileDocuments.multipleDnsProfile(), + H7ProfileDocuments.wildcardDnsProfile(), H7ProfileDocuments.emptySubjectDnsProfile(), + H7ProfileDocuments.criticalDnsProfile(), H7ProfileDocuments.ipProfile(), + H7ProfileDocuments.mixedIpProfile(), H7ProfileDocuments.emptySubjectIpProfile(), + H7ProfileDocuments.ipv4Profile(), H7ProfileDocuments.ipv6Profile(), + H7ProfileDocuments.uriProfile(), H7ProfileDocuments.multipleUriProfile(), + H7ProfileDocuments.emptySubjectUriProfile(), H7ProfileDocuments.rfc822Profile(), + H7ProfileDocuments.multipleRfc822Profile(), H7ProfileDocuments.emptySubjectRfc822Profile(), + H7ProfileDocuments.mixedSanProfile()); + } + + private static Credential issue(PkiTestRuntime runtime, CredentialIssuerBackend backend, PkiId rootCaId, + KeyPair leafKey, IssuanceCase issuanceCase) throws Exception { + ParsedCertificationRequest request = parse(runtime, leafKey, issuanceCase.subject(), + issuanceCase.sans()); + CredentialBundle bundle = runtime.issuanceService(backend, runtime.statusResolver()).issueEndEntity( + new IssueEndEntityCommand(rootCaId, request, issuanceCase.profileId(), Optional.empty())); + return bundle.credential(); + } + + private static ParsedCertificationRequest parse(PkiTestRuntime runtime, KeyPair leafKey, String commonName, + List sans) throws Exception { + return parse(runtime, leafKey, new X500Name("CN=" + commonName), sans); + } + + private static ParsedCertificationRequest parse(PkiTestRuntime runtime, KeyPair leafKey, X500Name subject, + List sans) throws Exception { + PKCS10CertificationRequestBuilder builder = new JcaPKCS10CertificationRequestBuilder( + subject, leafKey.getPublic()); + ExtensionsGenerator extensionGenerator = new ExtensionsGenerator(); + extensionGenerator.addExtension(Extension.subjectAlternativeName, false, + new GeneralNames(sans.toArray(GeneralName[]::new))); + builder.addAttribute(PKCSObjectIdentifiers.pkcs_9_at_extensionRequest, extensionGenerator.generate()); + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(leafKey.getPrivate()); + PKCS10CertificationRequest csr = builder.build(signer); + return runtime.certificationRequestService().parse(new CertificationRequest(runtime.framework().formatId(), + new EncodedObject(Encoding.DER, csr.getEncoded()))); + } + + private static ParsedCertificationRequest parseWithoutExtensions(PkiTestRuntime runtime, KeyPair leafKey, + X500Name subject) throws Exception { + PKCS10CertificationRequestBuilder builder = + new JcaPKCS10CertificationRequestBuilder(subject, leafKey.getPublic()); + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(leafKey.getPrivate()); + PKCS10CertificationRequest csr = builder.build(signer); + return runtime.certificationRequestService().parse(new CertificationRequest(runtime.framework().formatId(), + new EncodedObject(Encoding.DER, csr.getEncoded()))); + } + + private static CredentialIssuerBackend serialCapturingBackend(CredentialIssuerBackend delegate, + List allocatedSerials) { + return new CredentialIssuerBackend() { + @Override + public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, + EncodedObject issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { + allocatedSerials.add(serial); + return delegate.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial); + } + + @Override + public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) { + return delegate.issueIntermediateCertificate(issuance); + } + }; + } + + private static void assertExactLeaf(Credential credential, KeyPair rootKey, KeyPair leafKey, + PersistedExpectation expectation) throws Exception { + assertEquals(Encoding.DER, credential.encoded().encoding()); + EndEntityProfileBinding binding = assertInstanceOf(EndEntityProfileBinding.class, + credential.profileBinding()); + assertEquals(expectation.issuanceCase().profileId(), binding.reference().profileId()); + assertEquals(expectation.validity(), credential.validity()); + assertEquals(expectation.publicKeyId(), credential.publicKeyId()); + X509CertificateHolder holder = new X509CertificateHolder(credential.encoded().bytes()); + assertEquals(expectation.issuanceCase().subject(), holder.getSubject()); + SubjectRef expectedSubjectRef = expectation.issuanceCase().subject().getRDNs().length == 0 + ? new SubjectRef("x509:empty-subject") + : new SubjectRef(holder.getSubject().toString()); + assertEquals(expectedSubjectRef, credential.subjectRef()); + assertEquals(expectation.issuanceCase().expectedSans(), encodedSans(holder)); + assertArrayEquals(leafKey.getPublic().getEncoded(), holder.getSubjectPublicKeyInfo().getEncoded()); + assertTrue(holder.isSignatureValid(new JcaContentVerifierProviderBuilder().build(rootKey.getPublic()))); + assertEquals(credential.serialOrUniqueId(), holder.getSerialNumber().toString()); + assertEquals(expectation.allocatedSerial(), holder.getSerialNumber()); + assertTrue(holder.getSerialNumber().signum() > 0); + assertTrue(holder.getSerialNumber().toByteArray().length <= 20); + assertEquals(credential.validity().notBefore().getEpochSecond(), + holder.getNotBefore().toInstant().getEpochSecond()); + assertEquals(credential.validity().notAfter().getEpochSecond(), + holder.getNotAfter().toInstant().getEpochSecond()); + + assertEquals(LEAF_EXTENSION_OIDS, Arrays.stream(holder.getExtensions().getExtensionOIDs()).collect( + java.util.stream.Collectors.toUnmodifiableSet())); + Extension basicConstraints = holder.getExtension(Extension.basicConstraints); + assertTrue(basicConstraints.isCritical()); + assertFalse(BasicConstraints.getInstance(basicConstraints.getParsedValue()).isCA()); + Extension keyUsage = holder.getExtension(Extension.keyUsage); + assertTrue(keyUsage.isCritical()); + KeyUsage usages = KeyUsage.getInstance(keyUsage.getParsedValue()); + assertTrue(usages.hasUsages(KeyUsage.digitalSignature | KeyUsage.keyEncipherment)); + assertFalse(usages.hasUsages(KeyUsage.keyCertSign)); + Extension extendedKeyUsage = holder.getExtension(Extension.extendedKeyUsage); + assertFalse(extendedKeyUsage.isCritical()); + assertArrayEquals(new KeyPurposeId[] { KeyPurposeId.id_kp_serverAuth }, + ExtendedKeyUsage.getInstance(extendedKeyUsage.getParsedValue()).getUsages()); + assertEquals(expectation.issuanceCase().sanCritical(), + holder.getExtension(Extension.subjectAlternativeName).isCritical()); + } + + private static List encodedSans(X509CertificateHolder holder) { + GeneralName[] names = GeneralNames.fromExtensions(holder.getExtensions(), + Extension.subjectAlternativeName).getNames(); + List encoded = new ArrayList<>(names.length); + for (GeneralName name : names) { + if (name.getTagNo() == GeneralName.iPAddress) { + encoded.add(name.getTagNo() + ":" + HexFormat.of().formatHex( + DEROctetString.getInstance(name.getName()).getOctets())); + } else { + encoded.add(name.getTagNo() + ":" + DERIA5String.getInstance(name.getName()).getString()); + } + } + return List.copyOf(encoded); + } + + private static GeneralName ipName(byte[] address) { + return new GeneralName(GeneralName.iPAddress, new DEROctetString(address)); + } + + private static void assertProfileRejected(PkiTestRuntime runtime, PkiId rootCaId, KeyPair leafKey, + String profileId, List sans, String code) throws Exception { + ParsedCertificationRequest request = parse(runtime, leafKey, "Rejected Leaf", sans); + AtomicInteger backendCalls = new AtomicInteger(); + CredentialIssuerBackend backend = countingBackend(runtime.issuerBackend(), backendCalls); + int auditBefore = runtime.auditSink().snapshot().size(); + int signsBefore = runtime.submittedSignCount(); + + PkiException rejection = assertThrows(PkiException.class, + () -> runtime.issuanceService(backend, runtime.statusResolver()).issueEndEntity( + new IssueEndEntityCommand(rootCaId, request, profileId, Optional.empty()))); + + assertTrue(rejection.getMessage().contains(code), profileId); + assertEquals(0, backendCalls.get(), profileId); + assertEquals(signsBefore, runtime.submittedSignCount(), profileId); + assertSingleRejectionAudit(runtime, auditBefore, code); + } + + private static CredentialIssuerBackend countingBackend(CredentialIssuerBackend delegate, + AtomicInteger backendCalls) { + return new CredentialIssuerBackend() { + @Override + public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, + EncodedObject issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { + backendCalls.incrementAndGet(); + return delegate.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial); + } + + @Override + public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) { + return delegate.issueIntermediateCertificate(issuance); + } + }; + } + + private static void assertMaliciousMutationRejected(PkiTestRuntime runtime, PkiId rootCaId, + ParsedCertificationRequest request, String profileId, KeyPair rootKey, KeyPair substituteKey, + LeafMutation mutation) throws Exception { + AtomicReference maliciousCredential = new AtomicReference<>(); + AtomicReference baselineCredential = new AtomicReference<>(); + AtomicReference deliveredSerial = new AtomicReference<>(); + AtomicInteger backendCalls = new AtomicInteger(); + CredentialIssuerBackend backend = mutatingBackend(runtime.issuerBackend(), rootKey, substituteKey, + mutation, baselineCredential, maliciousCredential, deliveredSerial, backendCalls); + int auditBefore = runtime.auditSink().snapshot().size(); + + PkiException rejection = assertThrows(PkiException.class, + () -> runtime.issuanceService(backend, runtime.statusResolver()).issueEndEntity( + new IssueEndEntityCommand(rootCaId, request, profileId, Optional.empty())), + mutation.name()); + + String expectedCode = mutation == LeafMutation.PROFILE_ID_METADATA + ? "CREDENTIAL_PROFILE_BINDING_MISMATCH" : "BACKEND_CREDENTIAL_MISMATCH"; + assertTrue(rejection.getMessage().contains(expectedCode), mutation.name()); + assertEquals(1, backendCalls.get(), mutation.name()); + assertTrue(deliveredSerial.get().signum() > 0, mutation.name()); + assertTrue(deliveredSerial.get().toByteArray().length <= 20, mutation.name()); + assertTrue(runtime.store().getCredential(maliciousCredential.get().credentialId()).isEmpty(), + mutation.name()); + assertTrue(runtime.store().getCredential(baselineCredential.get().credentialId()).isEmpty(), + mutation.name()); + assertSingleRejectionAudit(runtime, auditBefore, expectedCode); + assertRedacted(rejection, REDACTION_SENTINEL); + assertFalse(runtime.auditSink().snapshot().toString().contains(REDACTION_SENTINEL), mutation.name()); + } + + private static CredentialIssuerBackend mutatingBackend(CredentialIssuerBackend delegate, KeyPair rootKey, + KeyPair substituteKey, LeafMutation mutation, AtomicReference baselineCredential, + AtomicReference maliciousCredential, AtomicReference deliveredSerial, + AtomicInteger backendCalls) { + return new CredentialIssuerBackend() { + @Override + public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, + EncodedObject issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { + backendCalls.incrementAndGet(); + deliveredSerial.set(serial); + CredentialBundle baseline = delegate.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, + serial); + baselineCredential.set(baseline.credential()); + Credential mutated = mutateCredential(baseline.credential(), rootKey, substituteKey, mutation); + maliciousCredential.set(mutated); + return new CredentialBundle(mutated, baseline.supportingObjects()); + } + + @Override + public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) { + return delegate.issueIntermediateCertificate(issuance); + } + }; + } + + private static Credential mutateCredential(Credential credential, KeyPair rootKey, KeyPair substituteKey, + LeafMutation mutation) { + try { + X509CertificateHolder original = new X509CertificateHolder(credential.encoded().bytes()); + X500Name issuer = mutation == LeafMutation.ISSUER + ? new X500Name("CN=Wrong H7 Issuer") : original.getIssuer(); + X500Name subject = mutatedSubject(original.getSubject(), mutation); + SubjectPublicKeyInfo publicKeyInfo = mutation == LeafMutation.PUBLIC_KEY + ? SubjectPublicKeyInfo.getInstance(substituteKey.getPublic().getEncoded()) + : original.getSubjectPublicKeyInfo(); + BigInteger serial = switch (mutation) { + case SERIAL -> original.getSerialNumber().add(BigInteger.ONE); + case SERIAL_ZERO -> BigInteger.ZERO; + case SERIAL_NEGATIVE -> BigInteger.ONE.negate(); + default -> original.getSerialNumber(); + }; + Date notBefore = mutation == LeafMutation.NOT_BEFORE_MOVED + ? Date.from(original.getNotBefore().toInstant().plusSeconds(60)) : original.getNotBefore(); + Date notAfter = switch (mutation) { + case NOT_AFTER_EXTENDED -> Date.from(original.getNotAfter().toInstant().plusSeconds(60)); + case VALIDITY_SHORTENED -> Date.from(original.getNotAfter().toInstant().minusSeconds(60)); + case VALIDITY_EXCEEDS_ISSUER -> Date.from(java.time.Instant.parse("2099-01-01T00:00:00Z")); + default -> original.getNotAfter(); + }; + X509v3CertificateBuilder builder = new X509v3CertificateBuilder(issuer, serial, notBefore, notAfter, + subject, publicKeyInfo); + addMutatedExtensions(builder, original, mutation); + KeyPair signingKey = mutation == LeafMutation.SIGNED_BY_OTHER_KEY ? substituteKey : rootKey; + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(signingKey.getPrivate()); + byte[] der = builder.build(signer).getEncoded(); + if (mutation == LeafMutation.CORRUPT_SIGNATURE) { + der[der.length - 1] ^= 0x01; + } + X509CertificateHolder mutated = new X509CertificateHolder(der); + byte[] spki = mutated.getSubjectPublicKeyInfo().getEncoded(); + Validity validity = new Validity(mutated.getNotBefore().toInstant(), mutated.getNotAfter().toInstant()); + PkiId credentialId = mutation == LeafMutation.CREDENTIAL_ID_METADATA + ? new PkiId("x509:wrong-metadata") : new PkiId("x509:" + sha256Hex(der)); + FormatId formatId = mutation == LeafMutation.FORMAT_METADATA + ? new FormatId("wrong-format") : credential.formatId(); + IssuerRef issuerRef = mutation == LeafMutation.ISSUER_REF_METADATA + ? new IssuerRef(new PkiId("ca:wrong-metadata")) : credential.issuerRef(); + SubjectRef subjectRef = mutated.getSubject().getRDNs().length == 0 + ? new SubjectRef("x509:empty-subject") : new SubjectRef(mutated.getSubject().toString()); + if (mutation == LeafMutation.SUBJECT_REF_METADATA) { + subjectRef = new SubjectRef("CN=Wrong Metadata Subject"); + } + Validity metadataValidity = mutation == LeafMutation.VALIDITY_METADATA + ? new Validity(validity.notBefore().plusSeconds(1), validity.notAfter()) : validity; + String metadataSerial = mutation == LeafMutation.SERIAL_METADATA + ? original.getSerialNumber().add(BigInteger.TEN).toString() + : mutated.getSerialNumber().toString(); + PkiId publicKeyId = mutation == LeafMutation.PUBLIC_KEY_ID_METADATA + ? new PkiId("spki:wrong-metadata") : new PkiId("spki:" + sha256Hex(spki)); + EndEntityProfileBinding originalBinding = (EndEntityProfileBinding) credential.profileBinding(); + CertificateProfileRef originalRef = originalBinding.reference(); + EndEntityProfileBinding profileBinding = mutation == LeafMutation.PROFILE_ID_METADATA + ? new EndEntityProfileBinding(new CertificateProfileRef("wrong-profile", + originalRef.profileVersion(), originalRef.canonicalSha256())) + : originalBinding; + CredentialStatus status = mutation == LeafMutation.STATUS_METADATA + ? CredentialStatus.REVOKED : credential.status(); + Encoding encoding = mutation == LeafMutation.ENCODING_METADATA ? Encoding.PEM : Encoding.DER; + return new Credential(credentialId, formatId, issuerRef, subjectRef, metadataValidity, + metadataSerial, publicKeyId, profileBinding, status, new EncodedObject(encoding, der), + credential.attributes()); + } catch (Exception exception) { + throw new IllegalStateException("Failed to build controlled H7 mutation " + mutation.name()); + } + } + + private static X500Name mutatedSubject(X500Name original, LeafMutation mutation) { + RDN[] rdns = original.getRDNs(); + if (mutation == LeafMutation.SUBJECT_REPLACED) { + return new X500Name("CN=" + REDACTION_SENTINEL + ",O=Example"); + } + if (mutation == LeafMutation.SUBJECT_REMOVED) { + return new X500Name(Arrays.copyOf(rdns, rdns.length - 1)); + } + if (mutation == LeafMutation.SUBJECT_ADDED) { + RDN[] added = Arrays.copyOf(rdns, rdns.length + 1); + added[rdns.length] = new RDN(BCStyle.OU, new DERUTF8String("Unexpected")); + return new X500Name(added); + } + if (mutation == LeafMutation.SUBJECT_DUPLICATED) { + RDN[] duplicated = Arrays.copyOf(rdns, rdns.length + 1); + duplicated[rdns.length] = rdns[0]; + return new X500Name(duplicated); + } + if (mutation == LeafMutation.SUBJECT_REORDERED) { + RDN[] reordered = rdns.clone(); + for (int index = 0; index < reordered.length / 2; index++) { + RDN swap = reordered[index]; + reordered[index] = reordered[reordered.length - index - 1]; + reordered[reordered.length - index - 1] = swap; + } + return new X500Name(reordered); + } + return original; + } + + private static void addMutatedExtensions(X509v3CertificateBuilder builder, X509CertificateHolder original, + LeafMutation mutation) throws Exception { + Extension originalBasicConstraints = original.getExtension(Extension.basicConstraints); + if (mutation != LeafMutation.BASIC_CONSTRAINTS_ABSENT) { + if (mutation == LeafMutation.BASIC_CONSTRAINTS_MALFORMED) { + builder.addExtension(Extension.basicConstraints, true, DERNull.INSTANCE); + } else { + boolean critical = mutation == LeafMutation.BASIC_CONSTRAINTS_NONCRITICAL + ? false : originalBasicConstraints.isCritical(); + BasicConstraints value = switch (mutation) { + case BASIC_CONSTRAINTS_CA -> new BasicConstraints(true); + case BASIC_CONSTRAINTS_PATH_LENGTH -> new BasicConstraints(0); + default -> BasicConstraints.getInstance(originalBasicConstraints.getParsedValue()); + }; + builder.addExtension(Extension.basicConstraints, critical, value); + } + } + + Extension originalKeyUsage = original.getExtension(Extension.keyUsage); + if (mutation != LeafMutation.KEY_USAGE_ABSENT) { + if (mutation == LeafMutation.KEY_USAGE_MALFORMED) { + builder.addExtension(Extension.keyUsage, true, DERNull.INSTANCE); + } else { + boolean critical = mutation == LeafMutation.KEY_USAGE_NONCRITICAL + ? false : originalKeyUsage.isCritical(); + KeyUsage value = switch (mutation) { + case KEY_USAGE_ADD -> new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyEncipherment + | KeyUsage.dataEncipherment); + case KEY_USAGE_REMOVE -> new KeyUsage(KeyUsage.digitalSignature); + case KEY_USAGE_REPLACE -> new KeyUsage(KeyUsage.keyAgreement); + default -> KeyUsage.getInstance(originalKeyUsage.getParsedValue()); + }; + builder.addExtension(Extension.keyUsage, critical, value); + } + } + + Extension originalExtendedKeyUsage = original.getExtension(Extension.extendedKeyUsage); + if (mutation == LeafMutation.EXTENDED_KEY_USAGE_ADDED_WHEN_FORBIDDEN) { + builder.addExtension(Extension.extendedKeyUsage, false, + new ExtendedKeyUsage(KeyPurposeId.id_kp_serverAuth)); + } else if (originalExtendedKeyUsage != null && mutation != LeafMutation.EXTENDED_KEY_USAGE_ABSENT) { + if (mutation == LeafMutation.EXTENDED_KEY_USAGE_MALFORMED) { + builder.addExtension(Extension.extendedKeyUsage, false, DERNull.INSTANCE); + } else { + boolean critical = mutation == LeafMutation.EXTENDED_KEY_USAGE_CRITICAL + || originalExtendedKeyUsage.isCritical(); + KeyPurposeId[] usages; + if (mutation == LeafMutation.EXTENDED_KEY_USAGE_ADD) { + usages = new KeyPurposeId[] { KeyPurposeId.id_kp_serverAuth, KeyPurposeId.id_kp_clientAuth, + KeyPurposeId.id_kp_codeSigning }; + } else if (mutation == LeafMutation.EXTENDED_KEY_USAGE_REMOVE) { + usages = new KeyPurposeId[] { KeyPurposeId.id_kp_serverAuth }; + } else if (mutation == LeafMutation.EXTENDED_KEY_USAGE_REPLACE) { + usages = new KeyPurposeId[] { KeyPurposeId.id_kp_codeSigning }; + } else if (mutation == LeafMutation.EXTENDED_KEY_USAGE_DUPLICATE) { + usages = new KeyPurposeId[] { KeyPurposeId.id_kp_serverAuth, + KeyPurposeId.id_kp_clientAuth, KeyPurposeId.id_kp_serverAuth }; + } else { + usages = ExtendedKeyUsage.getInstance(originalExtendedKeyUsage.getParsedValue()).getUsages(); + } + builder.addExtension(Extension.extendedKeyUsage, critical, new ExtendedKeyUsage(usages)); + } + } + + Extension originalSan = original.getExtension(Extension.subjectAlternativeName); + if (mutation == LeafMutation.SUBJECT_ALTERNATIVE_NAME_ADDED_WHEN_FORBIDDEN) { + builder.addExtension(Extension.subjectAlternativeName, false, + new GeneralNames(new GeneralName(GeneralName.dNSName, "forbidden.example.com"))); + } else if (originalSan != null && mutation != LeafMutation.SUBJECT_ALTERNATIVE_NAME_ABSENT) { + if (mutation == LeafMutation.SUBJECT_ALTERNATIVE_NAME_MALFORMED) { + builder.addExtension(Extension.subjectAlternativeName, false, DERNull.INSTANCE); + } else { + GeneralName[] names = GeneralNames.fromExtensions(original.getExtensions(), + Extension.subjectAlternativeName).getNames(); + if (mutation == LeafMutation.SUBJECT_ALTERNATIVE_NAME_ADD_ONE) { + GeneralName[] added = Arrays.copyOf(names, names.length + 1); + added[names.length] = new GeneralName(GeneralName.dNSName, "added.example.com"); + names = added; + } else if (mutation == LeafMutation.SUBJECT_ALTERNATIVE_NAME_REMOVE_ONE) { + names = Arrays.copyOf(names, names.length - 1); + } else if (mutation == LeafMutation.SUBJECT_ALTERNATIVE_NAME_REPLACE) { + names[0] = new GeneralName(GeneralName.dNSName, + REDACTION_SENTINEL.toLowerCase(java.util.Locale.ROOT) + ".example.com"); + } else if (mutation == LeafMutation.SUBJECT_ALTERNATIVE_NAME_CHANGE_TYPE) { + names[0] = new GeneralName(GeneralName.rfc822Name, "Changed@example.com"); + } else if (mutation == LeafMutation.SUBJECT_ALTERNATIVE_NAME_DUPLICATE) { + GeneralName[] duplicate = Arrays.copyOf(names, names.length + 1); + duplicate[names.length] = names[0]; + names = duplicate; + } else if (mutation == LeafMutation.SUBJECT_ALTERNATIVE_NAME_DNS_CASE) { + names[0] = new GeneralName(GeneralName.dNSName, "BASE.Example.COM"); + } else if (mutation == LeafMutation.SUBJECT_ALTERNATIVE_NAME_IP_BYTES) { + names[1] = ipName(new byte[] { (byte) 203, 0, 113, 10 }); + } else if (mutation == LeafMutation.SUBJECT_ALTERNATIVE_NAME_URI_VALUE) { + names[2] = new GeneralName(GeneralName.uniformResourceIdentifier, + "https://" + REDACTION_SENTINEL.toLowerCase(java.util.Locale.ROOT) + ".example.com/"); + } else if (mutation == LeafMutation.SUBJECT_ALTERNATIVE_NAME_RFC822_VALUE) { + names[3] = new GeneralName(GeneralName.rfc822Name, + REDACTION_SENTINEL + "@example.com"); + } else if (mutation == LeafMutation.SUBJECT_ALTERNATIVE_NAME_REORDERED) { + GeneralName first = names[0]; + names[0] = names[1]; + names[1] = first; + } + boolean critical = mutation == LeafMutation.SUBJECT_ALTERNATIVE_NAME_CRITICAL + || originalSan.isCritical(); + builder.addExtension(Extension.subjectAlternativeName, critical, new GeneralNames(names)); + } + } + + if (mutation == LeafMutation.UNKNOWN_NONCRITICAL_EXTENSION) { + builder.addExtension(new ASN1ObjectIdentifier("1.2.3.4.5.6.7"), false, DERNull.INSTANCE); + } else if (mutation == LeafMutation.UNKNOWN_CRITICAL_EXTENSION) { + builder.addExtension(new ASN1ObjectIdentifier("1.2.3.4.5.6.8"), true, DERNull.INSTANCE); + } else if (mutation == LeafMutation.SUBJECT_KEY_IDENTIFIER_EXTENSION) { + builder.addExtension(Extension.subjectKeyIdentifier, false, new SubjectKeyIdentifier(new byte[20])); + } else if (mutation == LeafMutation.AUTHORITY_KEY_IDENTIFIER_EXTENSION) { + builder.addExtension(Extension.authorityKeyIdentifier, false, + new AuthorityKeyIdentifier(new byte[20])); + } + } + + private static void assertSingleRejectionAudit(PkiTestRuntime runtime, int auditBefore, String code) { + List events = runtime.auditSink().snapshot(); + assertEquals(auditBefore + 1, events.size(), code); + AuditEvent event = events.get(auditBefore); + assertEquals("ISSUE_END_ENTITY_REJECTED", event.action()); + assertEquals(code, event.details().get("code")); + } + + private static void assertRedacted(Throwable failure, String sentinel) { + Throwable current = failure; + while (current != null) { + assertFalse(String.valueOf(current.getMessage()).contains(sentinel)); + for (Throwable suppressed : current.getSuppressed()) { + assertRedacted(suppressed, sentinel); + } + current = current.getCause(); + } + } + + private static KeyPair rsa() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + return generator.generateKeyPair(); + } + + private static String sha256Hex(byte[] value) throws Exception { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value)); + } + + private record IssuanceCase(String profileId, X500Name subject, boolean sanCritical, List sans, + List expectedSans) { + private IssuanceCase { + sans = List.copyOf(sans); + expectedSans = List.copyOf(expectedSans); + } + } + + private record PersistedExpectation(IssuanceCase issuanceCase, BigInteger allocatedSerial, + Validity validity, PkiId publicKeyId) { + } + + private enum LeafMutation { + SUBJECT_REPLACED, + SUBJECT_REMOVED, + SUBJECT_ADDED, + SUBJECT_DUPLICATED, + SUBJECT_REORDERED, + SUBJECT_ALTERNATIVE_NAME_ADD_ONE, + SUBJECT_ALTERNATIVE_NAME_REMOVE_ONE, + SUBJECT_ALTERNATIVE_NAME_REPLACE, + SUBJECT_ALTERNATIVE_NAME_CHANGE_TYPE, + SUBJECT_ALTERNATIVE_NAME_DUPLICATE, + SUBJECT_ALTERNATIVE_NAME_DNS_CASE, + SUBJECT_ALTERNATIVE_NAME_IP_BYTES, + SUBJECT_ALTERNATIVE_NAME_URI_VALUE, + SUBJECT_ALTERNATIVE_NAME_RFC822_VALUE, + SUBJECT_ALTERNATIVE_NAME_REORDERED, + SUBJECT_ALTERNATIVE_NAME_CRITICAL, + SUBJECT_ALTERNATIVE_NAME_ABSENT, + SUBJECT_ALTERNATIVE_NAME_MALFORMED, + SUBJECT_ALTERNATIVE_NAME_ADDED_WHEN_FORBIDDEN(false), + PUBLIC_KEY, + ISSUER, + SERIAL, + SERIAL_ZERO, + SERIAL_NEGATIVE, + NOT_BEFORE_MOVED, + NOT_AFTER_EXTENDED, + VALIDITY_SHORTENED, + VALIDITY_EXCEEDS_ISSUER, + PROFILE_ID_METADATA, + CREDENTIAL_ID_METADATA, + FORMAT_METADATA, + ISSUER_REF_METADATA, + SUBJECT_REF_METADATA, + VALIDITY_METADATA, + SERIAL_METADATA, + PUBLIC_KEY_ID_METADATA, + STATUS_METADATA, + ENCODING_METADATA, + BASIC_CONSTRAINTS_ABSENT, + BASIC_CONSTRAINTS_CA, + BASIC_CONSTRAINTS_PATH_LENGTH, + BASIC_CONSTRAINTS_NONCRITICAL, + BASIC_CONSTRAINTS_MALFORMED, + KEY_USAGE_ABSENT, + KEY_USAGE_ADD, + KEY_USAGE_REMOVE, + KEY_USAGE_REPLACE, + KEY_USAGE_NONCRITICAL, + KEY_USAGE_MALFORMED, + EXTENDED_KEY_USAGE_ABSENT, + EXTENDED_KEY_USAGE_ADD, + EXTENDED_KEY_USAGE_REMOVE, + EXTENDED_KEY_USAGE_REPLACE, + EXTENDED_KEY_USAGE_DUPLICATE, + EXTENDED_KEY_USAGE_CRITICAL, + EXTENDED_KEY_USAGE_MALFORMED, + EXTENDED_KEY_USAGE_ADDED_WHEN_FORBIDDEN(false), + UNKNOWN_NONCRITICAL_EXTENSION, + UNKNOWN_CRITICAL_EXTENSION, + SUBJECT_KEY_IDENTIFIER_EXTENSION, + AUTHORITY_KEY_IDENTIFIER_EXTENSION, + CORRUPT_SIGNATURE, + SIGNED_BY_OTHER_KEY; + + private final boolean mainProfile; + + LeafMutation() { + this(true); + } + + LeafMutation(boolean mainProfile) { + this.mainProfile = mainProfile; + } + + private boolean mainProfile() { + return mainProfile; + } + } + + private static final class CollectingLogHandler extends Handler { + private final List messages = java.util.Collections.synchronizedList(new ArrayList<>()); + + @Override + public void publish(LogRecord record) { + if (record == null) { + return; + } + messages.add(String.valueOf(record.getMessage())); + Object[] parameters = record.getParameters(); + if (parameters != null) { + for (Object parameter : parameters) { + messages.add(String.valueOf(parameter)); + } + } + if (record.getThrown() != null) { + collectThrowable(record.getThrown()); + } + } + + private void collectThrowable(Throwable failure) { + Throwable current = failure; + while (current != null) { + messages.add(String.valueOf(current.getMessage())); + for (Throwable suppressed : current.getSuppressed()) { + collectThrowable(suppressed); + } + current = current.getCause(); + } + } + + private String messages() { + synchronized (messages) { + return String.join("\n", messages); + } + } + + @Override + public void flush() { + // No buffered destination. + } + + @Override + public void close() { + messages.clear(); + } + } +} diff --git a/pki/src/test/java/zeroecho/pki/e2e/H7EndEntityCsrRejectionE2eTest.java b/pki/src/test/java/zeroecho/pki/e2e/H7EndEntityCsrRejectionE2eTest.java new file mode 100644 index 0000000..4b371f0 --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/e2e/H7EndEntityCsrRejectionE2eTest.java @@ -0,0 +1,436 @@ +/******************************************************************************* + * 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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Proxy; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.time.Clock; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; + +import org.bouncycastle.asn1.ASN1Encodable; +import org.bouncycastle.asn1.ASN1ObjectIdentifier; +import org.bouncycastle.asn1.DERNull; +import org.bouncycastle.asn1.DEROctetString; +import org.bouncycastle.asn1.DERSequence; +import org.bouncycastle.asn1.DERUTF8String; +import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.Extensions; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder; +import org.bouncycastle.pkcs.PKCS10CertificationRequest; +import org.bouncycastle.pkcs.PKCS10CertificationRequestBuilder; +import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequestBuilder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import zeroecho.pki.api.EncodedObject; +import zeroecho.pki.api.Encoding; +import zeroecho.pki.api.KeyRef; +import zeroecho.pki.api.PkiException; +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.SubjectRef; +import zeroecho.pki.api.attr.AttributeSet; +import zeroecho.pki.api.attr.AttributeValue; +import zeroecho.pki.api.audit.AuditEvent; +import zeroecho.pki.api.ca.CaCreateCommand; +import zeroecho.pki.api.credential.Credential; +import zeroecho.pki.api.credential.CredentialBundle; +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.ValidatedCertificateRequest; +import zeroecho.pki.impl.core.attr.SimpleAttributeSet; +import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes; +import zeroecho.pki.spi.framework.CredentialIssuerBackend; +import zeroecho.pki.spi.store.PkiStore; +import zeroecho.pki.testkit.PkiTestRuntime; +import zeroecho.pki.testkit.H7ProfileDocuments; + +/** + * Real signed-CSR rejection evidence for the closed H7 SAN and extension grammar. + */ +final class H7EndEntityCsrRejectionE2eTest { + private static final int DNS_AND_IP_REJECTION_CASE_COUNT = 11; + private static final int URI_AND_RFC822_REJECTION_CASE_COUNT = 17; + private static final int CSR_STRUCTURE_REJECTION_CASE_COUNT = 10; + private static final int CSR_REJECTION_CASE_COUNT = 38; + private static final String REDACTION_SENTINEL = "DO-NOT-LOG-H7-CSR-SENTINEL"; + + @Test + void signedDnsAndIpInvalidMatrixRejectsWithoutIssuanceSideEffects(@TempDir Path tempDir) throws Exception { + try (RejectionFixture fixture = RejectionFixture.create(tempDir)) { + List parserCases = List.of( + sanCase("dns-trailing-dot", dns(REDACTION_SENTINEL + ".example."), "SAN_MALFORMED"), + sanCase("dns-partial-wildcard", dns("www*.example.com"), "SAN_MALFORMED"), + sanCase("dns-multiple-wildcard", dns("*.*.example.com"), "SAN_MALFORMED"), + sanCase("dns-ip-literal", dns("192.0.2.1"), "SAN_MALFORMED"), + sanCase("dns-duplicate-lowercase", + List.of(dns("Example.COM"), dns("example.com")), "SAN_DUPLICATE"), + sanCase("ip-invalid-length", ip(new byte[] { 1, 2, 3, 4, 5 }), "SAN_MALFORMED"), + sanCase("ip-duplicate", + List.of(ip(new byte[] { (byte) 192, 0, 2, 1 }), + ip(new byte[] { (byte) 192, 0, 2, 1 })), + "SAN_DUPLICATE"), + sanCase("ip-address-invalid-length", ip(new byte[15]), "SAN_MALFORMED")); + assertParserCases(fixture, parserCases); + + fixture.assertProfileRejected("dns-wildcard-disabled", "h7-dns", + signedSanCsr(fixture.leafKey(), new X500Name("CN=Leaf"), + List.of(dns("*.example.com"))), + "SAN_WILDCARD_FORBIDDEN"); + fixture.assertProfileRejected("ipv6-forbidden-by-ipv4-profile", "h7-ipv4", + signedSanCsr(fixture.leafKey(), new X500Name("CN=Leaf"), + List.of(ip(hex("20010db8000000000000000000000001")))), + "SAN_IP_FAMILY_FORBIDDEN"); + fixture.assertProfileRejected("ipv4-forbidden-by-ipv6-profile", "h7-ipv6", + signedSanCsr(fixture.leafKey(), new X500Name("CN=Leaf"), + List.of(ip(new byte[] { (byte) 192, 0, 2, 2 }))), + "SAN_IP_FAMILY_FORBIDDEN"); + assertEquals(CSR_REJECTION_CASE_COUNT, DNS_AND_IP_REJECTION_CASE_COUNT + + URI_AND_RFC822_REJECTION_CASE_COUNT + CSR_STRUCTURE_REJECTION_CASE_COUNT); + assertEquals(DNS_AND_IP_REJECTION_CASE_COUNT, fixture.assertedCaseCount()); + } + } + + @Test + void signedUriAndRfc822InvalidMatrixRejectsWithoutIssuanceSideEffects(@TempDir Path tempDir) + throws Exception { + try (RejectionFixture fixture = RejectionFixture.create(tempDir)) { + String oversizedUri = "https://oversize.example.com/" + "a".repeat(2050); + List parserCases = List.of( + sanCase("uri-relative", uri("/relative/path"), "SAN_MALFORMED"), + sanCase("uri-missing-host", uri("https:///missing-host"), "SAN_MALFORMED"), + sanCase("uri-opaque", uri("mailto:user@example.com"), "SAN_MALFORMED"), + sanCase("uri-userinfo", uri("https://user@example.com/path"), "SAN_MALFORMED"), + sanCase("uri-fragment", uri("https://example.com/path#fragment"), "SAN_MALFORMED"), + sanCase("uri-duplicate", + List.of(uri("HTTPS://Example.COM/path"), uri("https://example.com/path")), + "SAN_DUPLICATE"), + sanCase("uri-oversize", uri(oversizedUri), "SAN_MALFORMED"), + sanCase("rfc822-display-name", rfc822("Display "), "SAN_MALFORMED"), + sanCase("rfc822-comments", rfc822("user(comment)@example.com"), "SAN_MALFORMED"), + sanCase("rfc822-whitespace", rfc822("user @example.com"), "SAN_MALFORMED"), + sanCase("rfc822-multiple-at", rfc822("user@@example.com"), "SAN_MALFORMED"), + sanCase("rfc822-empty-local", rfc822("@example.com"), "SAN_MALFORMED"), + sanCase("rfc822-empty-domain", rfc822("user@"), "SAN_MALFORMED"), + sanCase("rfc822-invalid-domain", rfc822("user@-example.com"), "SAN_MALFORMED"), + sanCase("rfc822-duplicate", + List.of(rfc822("Local@Example.COM"), rfc822("Local@example.com")), + "SAN_DUPLICATE")); + assertParserCases(fixture, parserCases); + + fixture.assertProfileRejected("uri-forbidden-scheme", "h7-uri", + signedSanCsr(fixture.leafKey(), new X500Name("CN=Leaf"), + List.of(uri("http://example.com/path"))), + "SAN_URI_SCHEME_FORBIDDEN"); + PKCS10CertificationRequest subjectEmail = signedCsr(fixture.leafKey(), + new X500Name("CN=Leaf,E=user@example.com"), List.of()); + fixture.assertProfileRejected("subject-email-does-not-replace-rfc822-san", + "h7-subject-email-rfc822", subjectEmail, "SAN_COUNT_INVALID"); + assertEquals(URI_AND_RFC822_REJECTION_CASE_COUNT, fixture.assertedCaseCount()); + } + } + + @Test + void signedCsrExtensionStructureMatrixRejectsBeforeBackendAndPersistence(@TempDir Path tempDir) + throws Exception { + try (RejectionFixture fixture = RejectionFixture.create(tempDir)) { + Extension san = sanExtension(false, List.of(dns("structure.example.com"))); + Extension keyUsage = new Extension(Extension.keyUsage, true, + new DEROctetString(new KeyUsage(KeyUsage.digitalSignature).getEncoded())); + Extension basicConstraints = new Extension(Extension.basicConstraints, true, + new DEROctetString(new BasicConstraints(false).getEncoded())); + Extension unknownCritical = new Extension(new ASN1ObjectIdentifier("1.2.3.4.10"), true, + new DEROctetString(DERNull.INSTANCE.getEncoded())); + Extension unknownNoncritical = new Extension(new ASN1ObjectIdentifier("1.2.3.4.11"), false, + new DEROctetString(DERNull.INSTANCE.getEncoded())); + Extension criticalSan = sanExtension(true, List.of(dns("critical-request.example.com"))); + Extension emptySan = new Extension(Extension.subjectAlternativeName, false, + new DEROctetString(new DERSequence().getEncoded())); + Extension otherName = sanExtension(false, + List.of(new GeneralName(GeneralName.otherName, new DERUTF8String(REDACTION_SENTINEL)))); + Extension directoryName = sanExtension(false, + List.of(new GeneralName(GeneralName.directoryName, new X500Name("CN=Nested")))); + DERSequence duplicateSan = new DERSequence(new ASN1Encodable[] { san, san }); + + List cases = List.of( + extensionCase("san-plus-key-usage", new DERSequence(new ASN1Encodable[] { san, keyUsage }), + "EXTENSION_UNSUPPORTED"), + extensionCase("san-plus-basic-constraints", + new DERSequence(new ASN1Encodable[] { san, basicConstraints }), + "EXTENSION_UNSUPPORTED"), + extensionCase("unknown-critical", new Extensions(unknownCritical), "EXTENSION_UNSUPPORTED"), + extensionCase("unknown-noncritical", new Extensions(unknownNoncritical), + "EXTENSION_UNSUPPORTED"), + extensionCase("two-san-extensions", duplicateSan, "EXTENSION_REQUEST_MALFORMED"), + new SignedCsrCase("two-extension-request-attributes", new X500Name("CN=Leaf"), + List.of(new Extensions(san), new Extensions(san)), "CSR_ATTRIBUTE_UNSUPPORTED"), + extensionCase("empty-san", new Extensions(emptySan), "SAN_COUNT_INVALID"), + extensionCase("other-name", new Extensions(otherName), "SAN_MALFORMED"), + extensionCase("directory-name", new Extensions(directoryName), "SAN_TYPE_UNSUPPORTED"), + extensionCase("criticality-mismatch", new Extensions(criticalSan), + "SAN_CRITICALITY_REQUESTED")); + assertParserCases(fixture, cases); + assertEquals(CSR_STRUCTURE_REJECTION_CASE_COUNT, fixture.assertedCaseCount()); + } + } + + private static void assertParserCases(RejectionFixture fixture, List cases) throws Exception { + for (SignedCsrCase testCase : cases) { + PKCS10CertificationRequest csr = signedCsr(fixture.leafKey(), testCase.subject(), + testCase.extensionRequests()); + fixture.assertParserAndGateRejected(testCase.name(), csr, testCase.parserCode()); + } + } + + private static SignedCsrCase sanCase(String name, GeneralName nameValue, String code) throws Exception { + return sanCase(name, List.of(nameValue), code); + } + + private static SignedCsrCase sanCase(String name, List names, String code) throws Exception { + return extensionCase(name, new Extensions(sanExtension(false, names)), code); + } + + private static SignedCsrCase extensionCase(String name, ASN1Encodable extensionRequest, String code) { + return new SignedCsrCase(name, new X500Name("CN=Leaf"), List.of(extensionRequest), code); + } + + private static PKCS10CertificationRequest signedSanCsr(KeyPair keyPair, X500Name subject, + List names) throws Exception { + return signedCsr(keyPair, subject, List.of(new Extensions(sanExtension(false, names)))); + } + + private static PKCS10CertificationRequest signedCsr(KeyPair keyPair, X500Name subject, + List extensionRequests) throws Exception { + PKCS10CertificationRequestBuilder builder = + new JcaPKCS10CertificationRequestBuilder(subject, keyPair.getPublic()); + for (ASN1Encodable extensionRequest : extensionRequests) { + builder.addAttribute(PKCSObjectIdentifiers.pkcs_9_at_extensionRequest, extensionRequest); + } + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(keyPair.getPrivate()); + return builder.build(signer); + } + + private static Extension sanExtension(boolean critical, List names) throws Exception { + return new Extension(Extension.subjectAlternativeName, critical, + new DEROctetString(new GeneralNames(names.toArray(GeneralName[]::new)).getEncoded())); + } + + private static GeneralName dns(String value) { + return new GeneralName(GeneralName.dNSName, value); + } + + private static GeneralName ip(byte[] value) { + return new GeneralName(GeneralName.iPAddress, new DEROctetString(value)); + } + + private static GeneralName uri(String value) { + return new GeneralName(GeneralName.uniformResourceIdentifier, value); + } + + private static GeneralName rfc822(String value) { + return new GeneralName(GeneralName.rfc822Name, value); + } + + private static byte[] hex(String value) { + return java.util.HexFormat.of().parseHex(value); + } + + private record SignedCsrCase(String name, X500Name subject, List extensionRequests, + String parserCode) { + private SignedCsrCase { + extensionRequests = List.copyOf(extensionRequests); + } + } + + private static final class RejectionFixture implements AutoCloseable { + private final PkiTestRuntime runtime; + private final KeyPair leafKey; + private final PkiId rootCaId; + private final ParsedCertificationRequest validTemplate; + private int assertedCases; + + private RejectionFixture(PkiTestRuntime runtime, KeyPair leafKey, PkiId rootCaId, + ParsedCertificationRequest validTemplate) { + this.runtime = runtime; + this.leafKey = leafKey; + this.rootCaId = rootCaId; + this.validTemplate = validTemplate; + this.assertedCases = 0; + } + + private static RejectionFixture create(Path tempDir) throws Exception { + KeyPair rootKey = rsa(); + KeyPair leafKey = rsa(); + KeyRef rootRef = new KeyRef("kref:v1:keyring:h7-csr-rejection:root"); + PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), + Map.of(rootRef, rootKey)); + runtime.importAndActivate(H7ProfileDocuments.dnsProfile()); + runtime.importAndActivate(H7ProfileDocuments.ipv4Profile()); + runtime.importAndActivate(H7ProfileDocuments.ipv6Profile()); + 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 SimpleAttributeSet())); + PKCS10CertificationRequest valid = signedSanCsr(leafKey, new X500Name("CN=Template"), + List.of(dns("template.example.com"))); + ParsedCertificationRequest template = parse(runtime, valid); + return new RejectionFixture(runtime, leafKey, rootCaId, template); + } + + private KeyPair leafKey() { + return leafKey; + } + + private int assertedCaseCount() { + return assertedCases; + } + + private void assertParserAndGateRejected(String name, PKCS10CertificationRequest csr, String parserCode) + throws Exception { + assertTrue(csr.isSignatureValid(new JcaContentVerifierProviderBuilder().build( + csr.getSubjectPublicKeyInfo())), name); + int signsBefore = runtime.submittedSignCount(); + int auditBefore = runtime.auditSink().snapshot().size(); + PkiException parserRejection = assertThrows(PkiException.class, () -> parse(runtime, csr), name); + assertTrue(parserRejection.getMessage().contains(parserCode), name); + assertRedacted(parserRejection); + assertEquals(signsBefore, runtime.submittedSignCount(), name); + assertEquals(auditBefore, runtime.auditSink().snapshot().size(), name); + + ParsedCertificationRequest supplied = withCsr(validTemplate, csr.getEncoded()); + assertGateRejected(name, supplied, "default", "CSR_MALFORMED", signsBefore, auditBefore); + assertedCases++; + } + + private void assertProfileRejected(String name, String profileId, PKCS10CertificationRequest csr, + String code) throws Exception { + assertTrue(csr.isSignatureValid(new JcaContentVerifierProviderBuilder().build( + csr.getSubjectPublicKeyInfo())), name); + ParsedCertificationRequest parsed = parse(runtime, csr); + int signsBefore = runtime.submittedSignCount(); + int auditBefore = runtime.auditSink().snapshot().size(); + assertGateRejected(name, parsed, profileId, code, signsBefore, auditBefore); + assertedCases++; + } + + private void assertGateRejected(String name, ParsedCertificationRequest request, String profileId, + String code, int signsBefore, int auditBefore) { + AtomicInteger backendCalls = new AtomicInteger(); + AtomicInteger persistenceCalls = new AtomicInteger(); + CredentialIssuerBackend backend = countingBackend(runtime.issuerBackend(), backendCalls); + PkiStore trackingStore = trackingStore(runtime.store(), persistenceCalls); + DefaultIssuanceService service = new DefaultIssuanceService(trackingStore, runtime.framework(), + backend, runtime.auditSink(), runtime.statusResolver(), runtime.profileService(), + Clock.systemUTC()); + + PkiException rejection = assertThrows(PkiException.class, + () -> service.issueEndEntity(new IssueEndEntityCommand(rootCaId, request, profileId, + Optional.empty())), + name); + + assertTrue(rejection.getMessage().contains(code), name); + assertRedacted(rejection); + assertEquals(0, backendCalls.get(), name); + assertEquals(0, persistenceCalls.get(), name); + assertEquals(signsBefore, runtime.submittedSignCount(), name); + List events = runtime.auditSink().snapshot(); + assertEquals(auditBefore + 1, events.size(), name); + AuditEvent event = events.get(auditBefore); + assertEquals("ISSUE_END_ENTITY_REJECTED", event.action(), name); + assertEquals(code, event.details().get("code"), name); + assertFalse(events.toString().contains(REDACTION_SENTINEL), name); + } + + @Override + public void close() throws Exception { + runtime.close(); + } + } + + private static ParsedCertificationRequest parse(PkiTestRuntime runtime, PKCS10CertificationRequest csr) + throws Exception { + return runtime.certificationRequestService().parse(new CertificationRequest(runtime.framework().formatId(), + new EncodedObject(Encoding.DER, csr.getEncoded()))); + } + + private static ParsedCertificationRequest withCsr(ParsedCertificationRequest source, byte[] csrDer) { + AttributeSet attributes = SimpleAttributeSet.builder() + .put(BcX509Attributes.CSR_DER, new AttributeValue.BytesValue(csrDer)).build(); + return new ParsedCertificationRequest(source.requestId(), source.formatId(), source.subjectRef(), + source.publicKeyInfo(), source.requestedValidity(), source.requestedProfileId(), + source.subjectRdns(), source.subjectAlternativeNames(), source.subjectAlternativeNamePresent(), + attributes); + } + + private static CredentialIssuerBackend countingBackend(CredentialIssuerBackend delegate, + AtomicInteger backendCalls) { + return new CredentialIssuerBackend() { + @Override + public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, + EncodedObject issuerCertificate, KeyRef issuerKeyRef, java.math.BigInteger serial) { + backendCalls.incrementAndGet(); + return delegate.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial); + } + + @Override + public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) { + return delegate.issueIntermediateCertificate(issuance); + } + }; + } + + private static PkiStore trackingStore(PkiStore delegate, AtomicInteger persistenceCalls) { + return (PkiStore) Proxy.newProxyInstance(PkiStore.class.getClassLoader(), new Class[] { PkiStore.class }, + (proxy, method, arguments) -> { + if ("putCredential".equals(method.getName())) { + persistenceCalls.incrementAndGet(); + } + try { + return method.invoke(delegate, arguments); + } catch (InvocationTargetException exception) { + throw exception.getCause(); + } + }); + } + + private static void assertRedacted(Throwable failure) { + assertFalse(String.valueOf(failure.getMessage()).contains(REDACTION_SENTINEL)); + for (Throwable suppressed : failure.getSuppressed()) { + assertRedacted(suppressed); + } + if (failure.getCause() != null) { + assertRedacted(failure.getCause()); + } + } + + private static KeyPair rsa() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + return generator.generateKeyPair(); + } +} diff --git a/pki/src/test/java/zeroecho/pki/e2e/PkiCoreE2eTest.java b/pki/src/test/java/zeroecho/pki/e2e/PkiCoreE2eTest.java index ea34075..655ff57 100644 --- a/pki/src/test/java/zeroecho/pki/e2e/PkiCoreE2eTest.java +++ b/pki/src/test/java/zeroecho/pki/e2e/PkiCoreE2eTest.java @@ -33,6 +33,7 @@ ******************************************************************************/ package zeroecho.pki.e2e; +import java.math.BigInteger; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -101,7 +102,7 @@ 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.VerifiedIssuanceCandidate; +import zeroecho.pki.impl.core.ValidatedCertificateRequest; import zeroecho.pki.spi.framework.CredentialIssuerBackend; import zeroecho.pki.testkit.PkiTestRuntime; @@ -160,8 +161,7 @@ public final class PkiCoreE2eTest { new CertificationRequest(runtime.framework().formatId(), new EncodedObject(Encoding.DER, makeCsr(leafKey, "CN=Matrix Leaf").getEncoded()))); - issuance.issueEndEntity(new IssueEndEntityCommand(rootCaId, leafRequest, "default", Optional.empty(), - emptyAttributes())); + issuance.issueEndEntity(new IssueEndEntityCommand(rootCaId, leafRequest, "default", Optional.empty())); assertEquals(List.of(unusable.credentialId(), usable.credentialId()), List.copyOf(resolved)); resolved.clear(); @@ -215,7 +215,7 @@ public final class PkiCoreE2eTest { ParsedCertificationRequest parsed = runtime.certificationRequestService().parse(request); PkiException endEntityFailure = assertThrows(PkiException.class, () -> runtime.issuanceService().issueEndEntity(new IssueEndEntityCommand(rootCaId, parsed, - "default", Optional.empty(), emptyAttributes()))); + "default", Optional.empty()))); assertTrue(endEntityFailure.getMessage().contains("ISSUER_CREDENTIAL_UNAVAILABLE")); assertThrows(PkiException.class, @@ -282,9 +282,7 @@ public final class PkiCoreE2eTest { reqSvc.store(parsed, RequestStorePolicy.STORE_ALWAYS); CredentialBundle bundle = issSvc.issueEndEntity(new IssueEndEntityCommand(rootCaId, parsed, "default", - Optional.of(new Validity(Instant.now().minus(Duration.ofMinutes(1)), - Instant.now().plus(Duration.ofDays(365)))), - emptyAttributes())); + Optional.empty())); assertNotNull(bundle); System.out.println("...issuedCredentialId=" + bundle.credential().credentialId().value()); @@ -342,7 +340,7 @@ public final class PkiCoreE2eTest { assertThrows(PkiException.class, () -> issuance.issueEndEntity(new IssueEndEntityCommand(rootCaId, leafRequest, "default", - Optional.empty(), emptyAttributes()))); + Optional.empty()))); assertThrows(PkiException.class, () -> caService.createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(), rootCaId, new SubjectRef("CN=Rejected Next"), "default", @@ -399,7 +397,8 @@ public final class PkiCoreE2eTest { private static Credential copyWithId(Credential source, PkiId id) { return new Credential(id, source.formatId(), source.issuerRef(), source.subjectRef(), source.validity(), - source.serialOrUniqueId(), source.publicKeyId(), source.profileId(), source.status(), source.encoded(), + source.serialOrUniqueId(), source.publicKeyId(), source.profileBinding(), source.status(), + source.encoded(), source.attributes()); } @@ -415,9 +414,9 @@ public final class PkiCoreE2eTest { } @Override - public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) { + public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, EncodedObject issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { endEntityCalls.incrementAndGet(); - return delegate.issueEndEntity(candidate); + return delegate.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial); } @Override diff --git a/pki/src/test/java/zeroecho/pki/e2e/PkiProofGateE2eTest.java b/pki/src/test/java/zeroecho/pki/e2e/PkiProofGateE2eTest.java index c5e375f..1d72175 100644 --- a/pki/src/test/java/zeroecho/pki/e2e/PkiProofGateE2eTest.java +++ b/pki/src/test/java/zeroecho/pki/e2e/PkiProofGateE2eTest.java @@ -41,6 +41,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.lang.reflect.Method; import java.lang.reflect.Modifier; +import java.math.BigInteger; import java.nio.file.Files; import java.nio.file.Path; import java.security.KeyPair; @@ -48,6 +49,7 @@ import java.security.KeyPairGenerator; import java.security.MessageDigest; import java.security.PublicKey; import java.time.Duration; +import java.time.Clock; import java.time.Instant; import java.util.HashMap; import java.util.HexFormat; @@ -96,10 +98,14 @@ import zeroecho.pki.api.ca.CaRecord; import zeroecho.pki.api.ca.CaRolloverCommand; 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.credential.CredentialStatus; +import zeroecho.pki.api.credential.CredentialProfileBinding; +import zeroecho.pki.api.credential.EndEntityProfileBinding; import zeroecho.pki.api.issuance.IssueEndEntityCommand; +import zeroecho.pki.api.profile.CertificateProfileRef; import zeroecho.pki.api.issuance.ReissueCommand; import zeroecho.pki.api.issuance.RenewCommand; import zeroecho.pki.api.issuance.ReplaceCommand; @@ -109,7 +115,7 @@ 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.VerifiedIssuanceCandidate; +import zeroecho.pki.impl.core.ValidatedCertificateRequest; import zeroecho.pki.impl.core.attr.SimpleAttributeSet; import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes; import zeroecho.pki.impl.framework.x509.bc.BcX509CredentialIssuerBackend; @@ -127,9 +133,9 @@ final class PkiProofGateE2eTest { void issuerBackendApiRequiresOpaqueGateProducedInputs() throws Exception { System.out.println("issuerBackendApiRequiresOpaqueGateProducedInputs"); - assertTrue(Modifier.isFinal(VerifiedIssuanceCandidate.class.getModifiers())); + assertTrue(Modifier.isFinal(ValidatedCertificateRequest.class.getModifiers())); assertTrue(Modifier.isFinal(ManagedCaIssuance.class.getModifiers())); - assertTrue(java.util.Arrays.stream(VerifiedIssuanceCandidate.class.getDeclaredConstructors()) + assertTrue(java.util.Arrays.stream(ValidatedCertificateRequest.class.getDeclaredConstructors()) .noneMatch(constructor -> Modifier.isPublic(constructor.getModifiers()))); assertTrue(java.util.Arrays.stream(ManagedCaIssuance.class.getDeclaredConstructors()) .noneMatch(constructor -> Modifier.isPublic(constructor.getModifiers()))); @@ -145,7 +151,8 @@ final class PkiProofGateE2eTest { Method intermediate = java.util.Arrays.stream(CredentialIssuerBackend.class.getMethods()) .filter(method -> method.getName().equals("issueIntermediateCertificate")) .findFirst().orElseThrow(); - assertArrayEquals(new Class[] { VerifiedIssuanceCandidate.class }, endEntity.getParameterTypes()); + assertArrayEquals(new Class[] { ValidatedCertificateRequest.class, EncodedObject.class, KeyRef.class, + BigInteger.class }, endEntity.getParameterTypes()); assertArrayEquals(new Class[] { ManagedCaIssuance.class }, intermediate.getParameterTypes()); assertTrue(java.util.Arrays.stream(BcX509CredentialIssuerBackend.class.getMethods()) .filter(method -> method.getName().startsWith("issue")) @@ -172,19 +179,19 @@ final class PkiProofGateE2eTest { try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), keys)) { CountingIssuerBackend counting = new CountingIssuerBackend(runtime.issuerBackend()); DefaultIssuanceService issuance = new DefaultIssuanceService(runtime.store(), runtime.framework(), - counting, runtime.auditSink(), runtime.statusResolver()); + counting, runtime.auditSink(), runtime.statusResolver(), runtime.profileService(), + Clock.systemUTC()); ParsedCertificationRequest valid = parse(runtime, makeCsr(subjectKey, subjectKey, "CN=Leaf")); assertThrows(PkiException.class, () -> issuance.issueEndEntity(new IssueEndEntityCommand(new PkiId("ca:absent"), - parse(runtime, makeCsr(subjectKey, wrongKey, "CN=Leaf")), "default", Optional.empty(), - new SimpleAttributeSet()))); + parse(runtime, makeCsr(subjectKey, wrongKey, "CN=Leaf")), "default", Optional.empty()))); 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())); CredentialBundle issued = issuance.issueEndEntity(new IssueEndEntityCommand(rootCaId, valid, "default", - Optional.empty(), new SimpleAttributeSet())); + Optional.empty())); assertEquals(1, counting.endEntityCalls.get()); assertArrayEquals(subjectKey.getPublic().getEncoded(), new X509CertificateHolder(issued.credential().encoded().bytes()).getSubjectPublicKeyInfo() @@ -215,9 +222,11 @@ 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()); + counting, runtime.auditSink(), runtime.statusResolver(), runtime.profileService(), + Clock.systemUTC()); CaService caService = runtime.caService(counting); ParsedCertificationRequest request = new ParsedCertificationRequest(new PkiId("csr:unsupported"), runtime.framework().formatId(), new SubjectRef("CN=Unsupported"), @@ -253,7 +262,7 @@ final class PkiProofGateE2eTest { assertEquals(0, runtime.submittedSignCount()); assertTrue(runtime.store().listCas().isEmpty()); assertTrue(runtime.store().listWorkflowStates().isEmpty()); - assertTrue(runtime.auditSink().snapshot().isEmpty()); + assertEquals(auditCount, runtime.auditSink().snapshot().size()); System.out.println("...unsupported operations=5"); } @@ -308,22 +317,26 @@ final class PkiProofGateE2eTest { assertRejected(runtime, rootCaId, new ParsedCertificationRequest(new PkiId("csr:substituted"), valid.formatId(), valid.subjectRef(), valid.publicKeyInfo(), valid.requestedValidity(), valid.requestedProfileId(), - valid.attributes()), + valid.subjectRdns(), valid.subjectAlternativeNames(), + valid.subjectAlternativeNamePresent(), valid.attributes()), "REQUEST_ID_MISMATCH"); assertRejected(runtime, rootCaId, new ParsedCertificationRequest(valid.requestId(), valid.formatId(), new SubjectRef("CN=Other"), valid.publicKeyInfo(), valid.requestedValidity(), valid.requestedProfileId(), - valid.attributes()), + valid.subjectRdns(), valid.subjectAlternativeNames(), + valid.subjectAlternativeNamePresent(), valid.attributes()), "SUBJECT_MISMATCH"); assertRejected(runtime, rootCaId, new ParsedCertificationRequest(valid.requestId(), valid.formatId(), valid.subjectRef(), new EncodedObject(Encoding.DER, otherKey.getPublic().getEncoded()), - valid.requestedValidity(), valid.requestedProfileId(), valid.attributes()), + valid.requestedValidity(), valid.requestedProfileId(), valid.subjectRdns(), + valid.subjectAlternativeNames(), valid.subjectAlternativeNamePresent(), valid.attributes()), "SPKI_MISMATCH"); assertRejected(runtime, rootCaId, new ParsedCertificationRequest(valid.requestId(), new FormatId("unsupported"), valid.subjectRef(), valid.publicKeyInfo(), valid.requestedValidity(), valid.requestedProfileId(), - valid.attributes()), + valid.subjectRdns(), valid.subjectAlternativeNames(), + valid.subjectAlternativeNamePresent(), valid.attributes()), "FORMAT_UNSUPPORTED"); byte[] maximum = new byte[1024 * 1024]; System.arraycopy(csrDer(valid), 0, maximum, 0, csrDer(valid).length); @@ -367,8 +380,8 @@ final class PkiProofGateE2eTest { ParsedCertificationRequest parsed = parse(runtime, makeCsr(subjectKey, subjectKey, "CN=Subject")); assertThrows(PkiException.class, () -> issue(runtime, rootCaId, parsed)); assertTrue(required.get()); - assertEquals("PROOF_" + status.name(), - runtime.auditSink().snapshot().get(0).details().get("code")); + assertTrue(runtime.auditSink().snapshot().stream() + .anyMatch(event -> ("PROOF_" + status.name()).equals(event.details().get("code")))); assertEquals(1, runtime.store().listCas().size()); } } @@ -412,8 +425,7 @@ final class PkiProofGateE2eTest { .put(BcX509Attributes.ISSUER_KEYREF, new AttributeValue.StringValue("attacker-key")) .build(); CredentialBundle bundle = runtime.issuanceService() - .issueEndEntity(new IssueEndEntityCommand(rootCaId, parsed, "default", Optional.empty(), - hostileOverrides)); + .issueEndEntity(new IssueEndEntityCommand(rootCaId, parsed, "default", Optional.empty())); X509CertificateHolder issued = new X509CertificateHolder(bundle.credential().encoded().bytes()); assertEquals("CN=Root", issued.getIssuer().toString()); @@ -442,8 +454,8 @@ final class PkiProofGateE2eTest { assertTrue(runtime.store().listCas().isEmpty()); assertTrue(runtime.store().listWorkflowStates().isEmpty()); assertEquals(1, runtime.submittedSignCount()); - assertEquals("MANAGED_KEY_PROOF_FAILED", - runtime.auditSink().snapshot().get(0).details().get("code")); + assertTrue(runtime.auditSink().snapshot().stream() + .anyMatch(event -> "MANAGED_KEY_PROOF_FAILED".equals(event.details().get("code")))); } Path failedDir = tempDir.resolve("failed-workflow"); try (PkiTestRuntime runtime = PkiTestRuntime.create(failedDir, failedDir.resolve("bus.log"), Map.of(), @@ -558,8 +570,7 @@ final class PkiProofGateE2eTest { .bytes().clone(); ParsedCertificationRequest leaf = parse(source, makeCsr(subjectKey, subjectKey, "CN=Leaf")); leafCertificate = source.issuanceService() - .issueEndEntity(new IssueEndEntityCommand(rootCaId, leaf, "default", Optional.empty(), - new SimpleAttributeSet())) + .issueEndEntity(new IssueEndEntityCommand(rootCaId, leaf, "default", Optional.empty())) .credential().encoded().bytes().clone(); } Path importDir = tempDir.resolve("import-mismatch"); @@ -584,6 +595,8 @@ final class PkiProofGateE2eTest { PkiId importedCaId = target.caService().importRoot(new CaImportCommand(target.framework().formatId(), new SubjectRef("CN=Root"), "default", rootKeyRef, new EncodedObject(Encoding.DER, callerOwnedCertificate), new SimpleAttributeSet())); + assertTrue(target.caService().getCa(importedCaId).caCredentials().get(0) + .profileBinding() instanceof CaProfileBinding); assertArrayEquals(expectedImportedCertificate, target.caService().getCa(importedCaId).caCredentials().get(0).encoded().bytes()); } @@ -652,7 +665,7 @@ final class PkiProofGateE2eTest { CredentialIssuerBackend delegateBackend = runtime.issuerBackend(); CredentialIssuerBackend throwingBackend = new CredentialIssuerBackend() { @Override - public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) { + public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, EncodedObject issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { throw new IllegalStateException("DO_NOT_LOG_SIGNATURE_SENTINEL"); } @@ -662,20 +675,21 @@ final class PkiProofGateE2eTest { } }; DefaultIssuanceService throwingBackendService = new DefaultIssuanceService(runtime.store(), - runtime.framework(), throwingBackend, runtime.auditSink(), runtime.statusResolver()); + runtime.framework(), throwingBackend, runtime.auditSink(), runtime.statusResolver(), + runtime.profileService(), Clock.systemUTC()); PkiException backendRejection = assertThrows(PkiException.class, () -> throwingBackendService.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, - "default", Optional.empty(), new SimpleAttributeSet()))); + "default", Optional.empty()))); assertThrowableRedacted(backendRejection, "DO_NOT_LOG_SIGNATURE_SENTINEL"); CredentialIssuerBackend maliciousBackend = new CredentialIssuerBackend() { @Override - public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) { - CredentialBundle bundle = delegateBackend.issueEndEntity(candidate); + public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, EncodedObject issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { + CredentialBundle bundle = delegateBackend.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial); Credential raw = bundle.credential(); Credential forgedMetadata = new Credential(raw.credentialId(), raw.formatId(), raw.issuerRef(), substitute.subjectRef(), raw.validity(), raw.serialOrUniqueId(), raw.publicKeyId(), - raw.profileId(), raw.status(), raw.encoded(), raw.attributes()); + raw.profileBinding(), raw.status(), raw.encoded(), raw.attributes()); bundle = new CredentialBundle(forgedMetadata, bundle.supportingObjects()); substitutedBundle.set(bundle); return bundle; @@ -687,25 +701,54 @@ final class PkiProofGateE2eTest { } }; DefaultIssuanceService service = new DefaultIssuanceService(runtime.store(), runtime.framework(), - maliciousBackend, runtime.auditSink(), runtime.statusResolver()); + maliciousBackend, runtime.auditSink(), runtime.statusResolver(), runtime.profileService(), + Clock.systemUTC()); assertThrows(PkiException.class, () -> service.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, "default", - Optional.empty(), new SimpleAttributeSet()))); + Optional.empty()))); Credential substitutedCredential = substitutedBundle.get().credential(); assertTrue(runtime.store().getCredential(substitutedCredential.credentialId()).isEmpty()); assertEquals("BACKEND_CREDENTIAL_MISMATCH", runtime.auditSink().snapshot().get(runtime.auditSink().snapshot().size() - 1).details().get("code")); + AtomicReference wrongBindingCredential = new AtomicReference<>(); + CredentialIssuerBackend wrongEndEntityBindingBackend = new CredentialIssuerBackend() { + @Override + public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, + EncodedObject issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { + CredentialBundle rawBundle = delegateBackend.issueEndEntity(candidate, issuerCertificate, + issuerKeyRef, serial); + Credential raw = rawBundle.credential(); + Credential wrongBinding = copyWithBinding(raw, new CaProfileBinding(candidate.profileReference() + .profileId())); + wrongBindingCredential.set(wrongBinding); + return new CredentialBundle(wrongBinding, rawBundle.supportingObjects()); + } + + @Override + public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) { + return delegateBackend.issueIntermediateCertificate(issuance); + } + }; + DefaultIssuanceService wrongBindingService = new DefaultIssuanceService(runtime.store(), + runtime.framework(), wrongEndEntityBindingBackend, runtime.auditSink(), runtime.statusResolver(), + runtime.profileService(), Clock.systemUTC()); + PkiException wrongBinding = assertThrows(PkiException.class, + () -> wrongBindingService.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, + "default", Optional.empty()))); + assertTrue(wrongBinding.getMessage().contains("CREDENTIAL_PROFILE_BINDING_MISMATCH")); + assertTrue(runtime.store().getCredential(wrongBindingCredential.get().credentialId()).isEmpty()); + CredentialIssuerBackend invalidSignatureBackend = new CredentialIssuerBackend() { @Override - public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) { - CredentialBundle rawBundle = delegateBackend.issueEndEntity(candidate); + public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, EncodedObject issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { + CredentialBundle rawBundle = delegateBackend.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial); Credential raw = rawBundle.credential(); byte[] invalid = raw.encoded().bytes().clone(); invalid[invalid.length - 1] ^= 0x01; Credential invalidCredential = new Credential(raw.credentialId(), raw.formatId(), raw.issuerRef(), raw.subjectRef(), raw.validity(), raw.serialOrUniqueId(), - raw.publicKeyId(), raw.profileId(), raw.status(), + raw.publicKeyId(), raw.profileBinding(), raw.status(), new EncodedObject(Encoding.DER, invalid), raw.attributes()); return new CredentialBundle(invalidCredential, rawBundle.supportingObjects()); } @@ -716,16 +759,17 @@ final class PkiProofGateE2eTest { } }; DefaultIssuanceService invalidSignatureService = new DefaultIssuanceService(runtime.store(), - runtime.framework(), invalidSignatureBackend, runtime.auditSink(), runtime.statusResolver()); + runtime.framework(), invalidSignatureBackend, runtime.auditSink(), runtime.statusResolver(), + runtime.profileService(), Clock.systemUTC()); assertThrows(PkiException.class, () -> invalidSignatureService.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, - "default", Optional.empty(), new SimpleAttributeSet()))); + "default", Optional.empty()))); AtomicReference rawBundle = new AtomicReference<>(); CredentialIssuerBackend mutableBackend = new CredentialIssuerBackend() { @Override - public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) { - CredentialBundle raw = delegateBackend.issueEndEntity(candidate); + public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, EncodedObject issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { + CredentialBundle raw = delegateBackend.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial); rawBundle.set(raw); return raw; } @@ -736,9 +780,10 @@ final class PkiProofGateE2eTest { } }; DefaultIssuanceService snapshotService = new DefaultIssuanceService(runtime.store(), - runtime.framework(), mutableBackend, runtime.auditSink(), runtime.statusResolver()); + runtime.framework(), mutableBackend, runtime.auditSink(), runtime.statusResolver(), + runtime.profileService(), Clock.systemUTC()); CredentialBundle returned = snapshotService.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, - "default", Optional.empty(), new SimpleAttributeSet())); + "default", Optional.empty())); byte[] expectedLeaf = returned.credential().encoded().bytes().clone(); rawBundle.get().credential().encoded().bytes()[0] ^= 0x01; rawBundle.get().supportingObjects().get(0).bytes()[0] ^= 0x01; @@ -751,25 +796,25 @@ final class PkiProofGateE2eTest { Credential original = root.caCredentials().get(0); Credential revoked = new Credential(original.credentialId(), original.formatId(), original.issuerRef(), original.subjectRef(), original.validity(), original.serialOrUniqueId(), original.publicKeyId(), - original.profileId(), CredentialStatus.REVOKED, original.encoded(), original.attributes()); + original.profileBinding(), CredentialStatus.REVOKED, original.encoded(), original.attributes()); runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(), root.subjectRef(), List.of(revoked))); int before = runtime.submittedSignCount(); assertThrows(PkiException.class, () -> runtime.issuanceService().issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, - "default", Optional.empty(), new SimpleAttributeSet()))); + "default", Optional.empty()))); assertEquals(before, runtime.submittedSignCount()); Validity expiredValidity = new Validity(Instant.now().minus(Duration.ofDays(2)), Instant.now().minus(Duration.ofDays(1))); Credential expired = new Credential(original.credentialId(), original.formatId(), original.issuerRef(), original.subjectRef(), expiredValidity, original.serialOrUniqueId(), original.publicKeyId(), - original.profileId(), CredentialStatus.ISSUED, original.encoded(), original.attributes()); + original.profileBinding(), CredentialStatus.ISSUED, original.encoded(), original.attributes()); runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(), root.subjectRef(), List.of(expired))); assertThrows(PkiException.class, () -> runtime.issuanceService().issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, - "default", Optional.empty(), new SimpleAttributeSet()))); + "default", Optional.empty()))); assertEquals(before, runtime.submittedSignCount()); runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(), @@ -793,17 +838,16 @@ final class PkiProofGateE2eTest { }; PkiException parserRejection = assertThrows(PkiException.class, () -> runtime.issuanceService().issueEndEntity(new IssueEndEntityCommand(rootCaId, - withAttributes(subject, hostileAttributes), "default", Optional.empty(), - new SimpleAttributeSet()))); + withAttributes(subject, hostileAttributes), "default", Optional.empty()))); assertThrowableRedacted(parserRejection, "DO_NOT_LOG_CSR_SENTINEL"); DefaultIssuanceService failingAudit = new DefaultIssuanceService(runtime.store(), runtime.framework(), runtime.issuerBackend(), event -> { throw new IllegalStateException("DO_NOT_LOG_PAYLOAD_SENTINEL"); - }, runtime.statusResolver()); + }, runtime.statusResolver(), runtime.profileService(), Clock.systemUTC()); PkiException rejection = assertThrows(PkiException.class, () -> failingAudit.issueEndEntity(new IssueEndEntityCommand(rootCaId, missing, "default", - Optional.empty(), new SimpleAttributeSet()))); + Optional.empty()))); assertTrue(rejection.getMessage().contains("CSR_MISSING")); assertThrowableRedacted(rejection, "DO_NOT_LOG_PAYLOAD_SENTINEL"); } @@ -822,11 +866,29 @@ final class PkiProofGateE2eTest { 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())); + assertTrue(runtime.caService().getCa(rootCaId).caCredentials().get(0) + .profileBinding() instanceof CaProfileBinding); CredentialIssuerBackend delegate = runtime.issuerBackend(); + for (BindingVariantMutation mutation : BindingVariantMutation.values()) { + AtomicReference produced = new AtomicReference<>(); + CaService wrongBindingService = runtime.caService(bindingMutationBackend(delegate, mutation, + produced)); + PkiException rejected = assertThrows(PkiException.class, + () -> wrongBindingService.createIntermediate(new IntermediateCreateCommand( + runtime.framework().formatId(), rootCaId, + new SubjectRef("CN=BindingRejectedIntermediate"), "default", + 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()); + if (produced.get() != null) { + assertTrue(runtime.store().getCredential(produced.get().credentialId()).isEmpty(), + mutation.name()); + } + } CredentialIssuerBackend wrongKeyBackend = new CredentialIssuerBackend() { @Override - public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) { - return delegate.issueEndEntity(candidate); + public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, EncodedObject issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { + return delegate.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial); } @Override @@ -847,10 +909,26 @@ final class PkiProofGateE2eTest { .createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(), rootCaId, new SubjectRef("CN=Intermediate"), "default", Optional.of(intermediateKeyRef), new SimpleAttributeSet())); + for (BindingVariantMutation mutation : BindingVariantMutation.values()) { + AtomicReference produced = new AtomicReference<>(); + CaService wrongBindingService = runtime.caService(bindingMutationBackend(delegate, mutation, + produced)); + PkiException rejected = assertThrows(PkiException.class, + () -> wrongBindingService.issueIntermediateCertificate(new IntermediateCertIssueCommand( + runtime.framework().formatId(), rootCaId, intermediateCaId, "default", + Optional.empty(), new SimpleAttributeSet())), mutation.name()); + assertTrue(rejected.getMessage().contains("CREDENTIAL_PROFILE_BINDING_MISMATCH"), mutation.name()); + assertEquals(1, runtime.caService().getCa(intermediateCaId).caCredentials().size(), + mutation.name()); + if (produced.get() != null) { + assertTrue(runtime.store().getCredential(produced.get().credentialId()).isEmpty(), + mutation.name()); + } + } CredentialIssuerBackend wrongSubjectBackend = new CredentialIssuerBackend() { @Override - public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) { - return delegate.issueEndEntity(candidate); + public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, EncodedObject issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { + return delegate.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial); } @Override @@ -869,8 +947,8 @@ final class PkiProofGateE2eTest { CredentialIssuerBackend invalidSignatureBackend = new CredentialIssuerBackend() { @Override - public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) { - return delegate.issueEndEntity(candidate); + public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, EncodedObject issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { + return delegate.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial); } @Override @@ -880,7 +958,7 @@ final class PkiProofGateE2eTest { invalid[invalid.length - 1] ^= 0x01; return new Credential(raw.credentialId(), raw.formatId(), raw.issuerRef(), raw.subjectRef(), raw.validity(), raw.serialOrUniqueId(), raw.publicKeyId(), - raw.profileId(), raw.status(), new EncodedObject(Encoding.DER, invalid), + raw.profileBinding(), raw.status(), new EncodedObject(Encoding.DER, invalid), raw.attributes()); } }; @@ -905,8 +983,8 @@ final class PkiProofGateE2eTest { AtomicReference rawCredential = new AtomicReference<>(); CredentialIssuerBackend mutableBackend = new CredentialIssuerBackend() { @Override - public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) { - return delegate.issueEndEntity(candidate); + public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, EncodedObject issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { + return delegate.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial); } @Override @@ -937,6 +1015,13 @@ final class PkiProofGateE2eTest { INCOMPATIBLE_KEY_USAGE } + private enum BindingVariantMutation { + END_ENTITY_SAME_ID, + END_ENTITY_OTHER_ID, + CA_OTHER_ID, + NULL_CREDENTIAL + } + private static final class CountingIssuerBackend implements CredentialIssuerBackend { private final CredentialIssuerBackend delegate; @@ -950,9 +1035,9 @@ final class PkiProofGateE2eTest { } @Override - public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) { + public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, EncodedObject issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { endEntityCalls.incrementAndGet(); - return delegate.issueEndEntity(candidate); + return delegate.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial); } @Override @@ -967,8 +1052,8 @@ final class PkiProofGateE2eTest { IntermediateExtensionVariant variant) { return new CredentialIssuerBackend() { @Override - public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) { - return delegate.issueEndEntity(candidate); + public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, EncodedObject issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { + return delegate.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial); } @Override @@ -979,6 +1064,45 @@ final class PkiProofGateE2eTest { }; } + private static CredentialIssuerBackend bindingMutationBackend(CredentialIssuerBackend delegate, + BindingVariantMutation mutation, AtomicReference produced) { + return new CredentialIssuerBackend() { + @Override + public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, + EncodedObject issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { + return delegate.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial); + } + + @Override + public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) { + Credential raw = delegate.issueIntermediateCertificate(issuance); + produced.set(raw); + if (mutation == BindingVariantMutation.NULL_CREDENTIAL) { + return null; + } + return copyWithBinding(raw, bindingFor(mutation, issuance.profileId())); + } + }; + } + + private static CredentialProfileBinding bindingFor(BindingVariantMutation mutation, String profileId) { + return switch (mutation) { + case END_ENTITY_SAME_ID -> new EndEntityProfileBinding(new CertificateProfileRef(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 NULL_CREDENTIAL -> throw new IllegalStateException("null credential has no binding"); + }; + } + + private static Credential copyWithBinding(Credential credential, CredentialProfileBinding binding) { + return new Credential(credential.credentialId(), credential.formatId(), credential.issuerRef(), + credential.subjectRef(), credential.validity(), credential.serialOrUniqueId(), + credential.publicKeyId(), binding, credential.status(), credential.encoded(), + credential.attributes()); + } + private static Credential rebuildIntermediateIdentity(Credential credential, KeyPair issuerKey, Optional subjectPublicKey, Optional subjectName) { try { @@ -997,7 +1121,7 @@ final class PkiProofGateE2eTest { return new Credential(new PkiId("x509:" + sha256Hex(encoded)), credential.formatId(), credential.issuerRef(), new SubjectRef(subject.toString()), credential.validity(), credential.serialOrUniqueId(), new PkiId("spki:" + sha256Hex(publicKeyInfo.getEncoded())), - credential.profileId(), credential.status(), new EncodedObject(Encoding.DER, encoded), + credential.profileBinding(), credential.status(), new EncodedObject(Encoding.DER, encoded), credential.attributes()); } catch (Exception ex) { throw new PkiException("Failed to create adversarial intermediate identity", ex); @@ -1030,7 +1154,7 @@ final class PkiProofGateE2eTest { byte[] encoded = builder.build(signer).getEncoded(); return new Credential(new PkiId("x509:" + sha256Hex(encoded)), credential.formatId(), credential.issuerRef(), credential.subjectRef(), credential.validity(), - credential.serialOrUniqueId(), credential.publicKeyId(), credential.profileId(), + credential.serialOrUniqueId(), credential.publicKeyId(), credential.profileBinding(), credential.status(), new EncodedObject(Encoding.DER, encoded), credential.attributes()); } catch (Exception ex) { throw new PkiException("Failed to create adversarial intermediate certificate", ex); @@ -1101,7 +1225,7 @@ final class PkiProofGateE2eTest { private static void issue(PkiTestRuntime runtime, PkiId issuerCaId, ParsedCertificationRequest request) { runtime.issuanceService().issueEndEntity(new IssueEndEntityCommand(issuerCaId, request, "default", - Optional.empty(), new SimpleAttributeSet())); + Optional.empty())); } private static AttributeSet hostileIntermediateAttributes(PublicKey wrongPublicKey) { diff --git a/pki/src/test/java/zeroecho/pki/impl/core/DefaultProfileServiceTest.java b/pki/src/test/java/zeroecho/pki/impl/core/DefaultProfileServiceTest.java new file mode 100644 index 0000000..31a5cee --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/impl/core/DefaultProfileServiceTest.java @@ -0,0 +1,341 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.impl.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Proxy; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.logging.Handler; +import java.util.logging.LogRecord; +import java.util.logging.Logger; + +import org.junit.jupiter.api.Test; +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.CertificateProfileRef; +import zeroecho.pki.impl.audit.InMemoryAuditSink; +import zeroecho.pki.impl.fs.FilesystemPkiStore; +import zeroecho.pki.impl.fs.FsPkiStoreOptions; +import zeroecho.pki.spi.store.PkiStore; + +/** + * Focused persisted profile lifecycle tests. + */ +final class DefaultProfileServiceTest { + private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-07-30T10:00:00Z"), ZoneOffset.UTC); + + @Test + void builtInImportIsInactiveUntilExplicitActivationAndSurvivesRestart(@TempDir Path directory) + throws Exception { + BuiltInCertificateProfileTemplate template = builtIn("server-tls"); + Path root = directory.resolve("store"); + CertificateProfileRef reference; + try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), CLOCK)) { + DefaultProfileService service = service(store); + assertTrue(service.getActiveReference("server-tls").isEmpty()); + reference = service.importBuiltIn(template); + assertTrue(service.getImportedVersion("server-tls", 1).isPresent()); + assertTrue(service.getActiveReference("server-tls").isEmpty()); + assertThrows(PkiException.class, () -> service.requireActiveProfile("server-tls")); + assertEquals(reference, service.activateProfile("server-tls", 1)); + assertEquals(reference, service.requireActiveProfile("server-tls").reference()); + } + try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), CLOCK)) { + assertEquals(reference, service(reopened).requireActiveProfile("server-tls").reference()); + } + } + + @Test + void repeatImportIsIdempotentAndConflictingVersionIsRejected(@TempDir Path directory) throws Exception { + BuiltInCertificateProfileTemplate template = builtIn("server-tls"); + try (FilesystemPkiStore store = new FilesystemPkiStore(directory.resolve("store"), + FsPkiStoreOptions.defaults(), CLOCK)) { + DefaultProfileService service = service(store); + CertificateProfileRef first = service.importProfile(template.canonicalJson()); + CertificateProfileRef repeated = service.importProfile(template.canonicalJson()); + assertEquals(first, repeated); + String changed = new String(template.canonicalJson(), StandardCharsets.UTF_8) + .replace("\"displayName\":\"Server TLS\"", "\"displayName\":\"Changed\""); + PkiException conflict = assertThrows(PkiException.class, + () -> service.importProfile(changed.getBytes(StandardCharsets.UTF_8))); + assertTrue(conflict.getMessage().contains("PROFILE_VERSION_CONFLICT")); + assertEquals(1, service.listImportedVersions("server-tls").size()); + } + } + + @Test + void activationSwitchesExactVersionWithoutRewritingVersionOne(@TempDir Path directory) throws Exception { + BuiltInCertificateProfileTemplate template = builtIn("vpn-client"); + String versionTwoText = new String(template.canonicalJson(), StandardCharsets.UTF_8) + .replace("\"profileVersion\":1", "\"profileVersion\":2") + .replace("\"displayName\":\"VPN Client\"", "\"displayName\":\"VPN Client v2\""); + byte[] versionTwo = versionTwoText.getBytes(StandardCharsets.UTF_8); + try (FilesystemPkiStore store = new FilesystemPkiStore(directory.resolve("store"), + FsPkiStoreOptions.defaults(), CLOCK)) { + DefaultProfileService service = service(store); + CertificateProfileRef one = service.importProfile(template.canonicalJson()); + service.activateProfile("vpn-client", 1); + CertificateProfileRef two = service.importProfile(versionTwo); + assertEquals(one, service.requireActiveProfile("vpn-client").reference()); + assertFalse(one.equals(two)); + service.activateProfile("vpn-client", 2); + assertEquals(two, service.requireActiveProfile("vpn-client").reference()); + assertEquals(List.of(1L, 2L), service.listImportedVersions("vpn-client").stream() + .map(version -> version.reference().profileVersion()).toList()); + } + } + + @Test + void historicalSnapshotFailsBeforeTargetCreationWhenActivePointerExists(@TempDir Path directory) + throws Exception { + Path target = directory.resolve("snapshot"); + try (FilesystemPkiStore store = new FilesystemPkiStore(directory.resolve("store"), + FsPkiStoreOptions.defaults(), CLOCK)) { + DefaultProfileService service = service(store); + service.importBuiltIn(builtIn("email-signing")); + service.activateProfile("email-signing", 1); + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> store.exportSnapshot(target, CLOCK.instant())); + assertTrue(failure.getMessage().contains("PROFILE_ACTIVATION_HISTORY_UNAVAILABLE")); + assertFalse(Files.exists(target)); + } + } + + @Test + void concurrentIdenticalAndConflictingImportsAreSerializedPerProfile(@TempDir Path directory) + throws Exception { + BuiltInCertificateProfileTemplate template = builtIn("server-tls"); + try (FilesystemPkiStore store = new FilesystemPkiStore(directory.resolve("store"), + FsPkiStoreOptions.defaults(), CLOCK)) { + DefaultProfileService service = service(store); + List identical = runConcurrently( + () -> service.importProfile(template.canonicalJson()), + () -> service.importProfile(template.canonicalJson())); + assertEquals(identical.get(0), identical.get(1)); + assertEquals(1, service.listImportedVersions("server-tls").size()); + + byte[] versionTwo = new String(template.canonicalJson(), StandardCharsets.UTF_8) + .replace("\"profileVersion\":1", "\"profileVersion\":2") + .getBytes(StandardCharsets.UTF_8); + byte[] conflictingVersionTwo = new String(versionTwo, StandardCharsets.UTF_8) + .replace("\"displayName\":\"Server TLS\"", "\"displayName\":\"Changed v2\"") + .getBytes(StandardCharsets.UTF_8); + List conflicting = runAttempts( + () -> service.importProfile(versionTwo), + () -> service.importProfile(conflictingVersionTwo)); + assertEquals(1L, conflicting.stream().filter(Attempt::succeeded).count()); + assertEquals(1L, conflicting.stream().filter(attempt -> attempt.message() + .contains("PROFILE_VERSION_CONFLICT")).count()); + assertEquals(2, service.listImportedVersions("server-tls").size()); + } + } + + @Test + void concurrentActivationsLeaveOneCompleteExactPointer(@TempDir Path directory) throws Exception { + BuiltInCertificateProfileTemplate template = builtIn("vpn-client"); + byte[] versionTwo = new String(template.canonicalJson(), StandardCharsets.UTF_8) + .replace("\"profileVersion\":1", "\"profileVersion\":2") + .replace("\"displayName\":\"VPN Client\"", "\"displayName\":\"VPN Client v2\"") + .getBytes(StandardCharsets.UTF_8); + try (FilesystemPkiStore store = new FilesystemPkiStore(directory.resolve("store"), + FsPkiStoreOptions.defaults(), CLOCK)) { + DefaultProfileService service = service(store); + CertificateProfileRef one = service.importProfile(template.canonicalJson()); + CertificateProfileRef two = service.importProfile(versionTwo); + List results = runConcurrently( + () -> service.activateProfile("vpn-client", 1), + () -> service.activateProfile("vpn-client", 2)); + assertTrue(results.contains(one)); + assertTrue(results.contains(two)); + CertificateProfileRef active = service.getActiveReference("vpn-client").orElseThrow(); + assertTrue(active.equals(one) || active.equals(two)); + assertEquals(active, service.requireActiveProfile("vpn-client").reference()); + } + } + + @Test + void activationRequiresAnImportedVersionAndRepeatedActivationIsIdempotent(@TempDir Path directory) + throws Exception { + try (FilesystemPkiStore store = new FilesystemPkiStore(directory.resolve("store"), + FsPkiStoreOptions.defaults(), CLOCK)) { + DefaultProfileService service = service(store); + PkiException missing = assertThrows(PkiException.class, + () -> service.activateProfile("server-tls", 1)); + assertTrue(missing.getMessage().contains("PROFILE_VERSION_NOT_FOUND")); + assertTrue(service.getActiveReference("server-tls").isEmpty()); + + CertificateProfileRef imported = service.importBuiltIn(builtIn("server-tls")); + CertificateProfileRef first = service.activateProfile("server-tls", 1); + CertificateProfileRef repeated = service.activateProfile("server-tls", 1); + assertEquals(imported, first); + assertEquals(first, repeated); + assertEquals(first, service.getActiveReference("server-tls").orElseThrow()); + assertEquals(1, service.listImportedVersions("server-tls").size()); + } + } + + @Test + void lifecycleOperationsSanitizeStoreDiagnosticsAndAuditCodes() { + String sentinel = "unsafe-profile-store-sentinel"; + InMemoryAuditSink audit = new InMemoryAuditSink(); + DefaultProfileService service = new DefaultProfileService(failingStore(sentinel), CLOCK, audit); + CollectingHandler logs = new CollectingHandler(); + Logger root = Logger.getLogger(""); + root.addHandler(logs); + try { + assertSanitized(() -> service.importProfile(new byte[] { 1, 2, 3 }), + "PROFILE_IMPORT_VALIDATION_FAILED", sentinel); + assertSanitized(() -> service.importProfile(builtIn("server-tls").canonicalJson()), + "PROFILE_IMPORT_FAILED", sentinel); + assertSanitized(() -> service.activateProfile("server-tls", 1), + "PROFILE_ACTIVATION_FAILED", sentinel); + assertSanitized(() -> service.requireActiveProfile("server-tls"), + "PROFILE_STORE_FAILURE", sentinel); + assertSanitized(() -> service.getImportedVersion("server-tls", 1), + "PROFILE_STORE_FAILURE", sentinel); + assertSanitized(() -> service.listImportedVersions("server-tls"), + "PROFILE_STORE_FAILURE", sentinel); + assertSanitized(() -> service.getActiveReference("server-tls"), + "PROFILE_STORE_FAILURE", sentinel); + } finally { + root.removeHandler(logs); + logs.close(); + } + assertTrue(audit.snapshot().stream().flatMap(event -> event.details().values().stream()) + .noneMatch(value -> value.contains(sentinel))); + assertFalse(logs.text().contains(sentinel)); + } + + private static DefaultProfileService service(FilesystemPkiStore store) { + return new DefaultProfileService(store, CLOCK, new InMemoryAuditSink()); + } + + private static BuiltInCertificateProfileTemplate builtIn(String profileId) { + return BuiltInCertificateProfileCatalog.load(DefaultProfileServiceTest.class.getClassLoader()).stream() + .filter(template -> profileId.equals(template.definition().profileId())) + .findFirst().orElseThrow(); + } + + private static PkiStore failingStore(String sentinel) { + return (PkiStore) Proxy.newProxyInstance(PkiStore.class.getClassLoader(), + new Class[] { PkiStore.class }, (proxy, method, arguments) -> { + if (method.getDeclaringClass() == Object.class) { + return switch (method.getName()) { + case "hashCode" -> System.identityHashCode(proxy); + case "equals" -> proxy == arguments[0]; + case "toString" -> "FailingPkiStore"; + default -> throw new AssertionError("unexpected Object method"); + }; + } + throw new IllegalStateException(sentinel); + }); + } + + private static void assertSanitized(Callable operation, String code, String sentinel) { + PkiException failure = assertThrows(PkiException.class, operation::call); + assertEquals("Profile lifecycle operation failed: code=" + code, failure.getMessage()); + assertFalse(failure.getMessage().contains(sentinel)); + assertEquals(null, failure.getCause()); + assertEquals(0, failure.getSuppressed().length); + } + + private static List runConcurrently(Callable first, Callable second) throws Exception { + CyclicBarrier barrier = new CyclicBarrier(2); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future firstResult = executor.submit(() -> { + barrier.await(); + return first.call(); + }); + Future secondResult = executor.submit(() -> { + barrier.await(); + return second.call(); + }); + return List.of(firstResult.get(), secondResult.get()); + } finally { + executor.shutdownNow(); + } + } + + private static List runAttempts(Callable first, + Callable second) throws Exception { + CyclicBarrier barrier = new CyclicBarrier(2); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future firstResult = executor.submit(() -> { + barrier.await(); + return first.call(); + }); + Future secondResult = executor.submit(() -> { + barrier.await(); + return second.call(); + }); + return List.of(attempt(firstResult), attempt(secondResult)); + } finally { + executor.shutdownNow(); + } + } + + private static Attempt attempt(Future result) throws InterruptedException { + try { + return new Attempt(result.get(), ""); + } catch (ExecutionException failure) { + return new Attempt(null, failure.getCause().getMessage()); + } + } + + private record Attempt(CertificateProfileRef reference, String message) { + private boolean succeeded() { + return reference != null; + } + } + + private static final class CollectingHandler extends Handler { + private final StringBuilder messages = new StringBuilder(); + + @Override + public void publish(LogRecord record) { + if (record != null) { + messages.append(record.getMessage()); + if (record.getThrown() != null) { + messages.append(record.getThrown().getMessage()); + } + } + } + + @Override + public void flush() { + // In-memory only. + } + + @Override + public void close() { + // No external resource. + } + + private String text() { + return messages.toString(); + } + } +} 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 610dda7..e4bb677 100644 --- a/pki/src/test/java/zeroecho/pki/impl/core/DefaultStatusObjectServiceCrlTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/core/DefaultStatusObjectServiceCrlTest.java @@ -6,6 +6,7 @@ package zeroecho.pki.impl.core; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -16,7 +17,6 @@ import java.math.BigInteger; import java.nio.file.Path; import java.security.KeyPair; import java.security.KeyPairGenerator; -import java.time.Duration; import java.time.Instant; import java.time.ZoneOffset; import java.time.temporal.ChronoUnit; @@ -51,12 +51,12 @@ 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.attr.AttributeValue; import zeroecho.pki.api.ca.CaCreateCommand; import zeroecho.pki.api.ca.CaRecord; import zeroecho.pki.api.credential.Credential; +import zeroecho.pki.api.credential.CaProfileBinding; import zeroecho.pki.api.credential.CredentialStatus; import zeroecho.pki.api.credential.CredentialUse; import zeroecho.pki.api.credential.EffectiveCredentialStatus; @@ -369,9 +369,10 @@ final class DefaultStatusObjectServiceCrlTest { } private static Credential copy(Credential template, String suffix, FormatId formatId, EncodedObject encoded) { + 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(), template.profileId(), CredentialStatus.ISSUED, encoded, + template.publicKeyId(), new CaProfileBinding(binding.profileId()), CredentialStatus.ISSUED, encoded, template.attributes()); } @@ -395,9 +396,7 @@ final class DefaultStatusObjectServiceCrlTest { new VerificationPolicy(true, Optional.empty())); IssuanceService issuance = runtime.issuanceService(); return issuance.issueEndEntity(new IssueEndEntityCommand(caId, parsed, "default", - Optional.of(new Validity(EVALUATION_TIME.minus(Duration.ofDays(1)), - EVALUATION_TIME.plus(Duration.ofDays(365)))), - emptyAttributes())).credential(); + Optional.empty())).credential(); } private static PKCS10CertificationRequest certificationRequest(KeyPair pair, String commonName) diff --git a/pki/src/test/java/zeroecho/pki/impl/core/H7ProfileEnforcementTest.java b/pki/src/test/java/zeroecho/pki/impl/core/H7ProfileEnforcementTest.java new file mode 100644 index 0000000..8dbf49a --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/impl/core/H7ProfileEnforcementTest.java @@ -0,0 +1,492 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.impl.core; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Proxy; +import java.math.BigInteger; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.spec.ECGenParameterSpec; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import org.bouncycastle.asn1.ASN1Encodable; +import org.bouncycastle.asn1.ASN1ObjectIdentifier; +import org.bouncycastle.asn1.DERBMPString; +import org.bouncycastle.asn1.DERIA5String; +import org.bouncycastle.asn1.DERNull; +import org.bouncycastle.asn1.DEROctetString; +import org.bouncycastle.asn1.DERPrintableString; +import org.bouncycastle.asn1.DERSequence; +import org.bouncycastle.asn1.DERUTF8String; +import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x500.X500NameBuilder; +import org.bouncycastle.asn1.x500.style.BCStyle; +import org.bouncycastle.asn1.x509.AlgorithmIdentifier; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.Extensions; +import org.bouncycastle.asn1.x509.ExtendedKeyUsage; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.asn1.x509.KeyPurposeId; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.pkcs.PKCS10CertificationRequest; +import org.bouncycastle.pkcs.PKCS10CertificationRequestBuilder; +import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequestBuilder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import zeroecho.pki.api.CertificationRequestService; +import zeroecho.pki.api.EncodedObject; +import zeroecho.pki.api.Encoding; +import zeroecho.pki.api.FormatId; +import zeroecho.pki.api.IssuerRef; +import zeroecho.pki.api.KeyRef; +import zeroecho.pki.api.PkiException; +import zeroecho.pki.api.ProfileService; +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.attr.AttributeValue; +import zeroecho.pki.api.ca.CaCreateCommand; +import zeroecho.pki.api.credential.CredentialProfileBinding; +import zeroecho.pki.api.credential.Credential; +import zeroecho.pki.api.credential.CaProfileBinding; +import zeroecho.pki.api.credential.CredentialStatus; +import zeroecho.pki.api.credential.EndEntityProfileBinding; +import zeroecho.pki.api.issuance.IssueEndEntityCommand; +import zeroecho.pki.api.profile.CertificateProfile; +import zeroecho.pki.api.profile.CertificateProfileDefinition; +import zeroecho.pki.api.profile.CertificateProfileDocumentCodec; +import zeroecho.pki.api.profile.CertificateProfileRef; +import zeroecho.pki.api.profile.ActiveCertificateProfile; +import zeroecho.pki.api.profile.ExtendedKeyUsageId; +import zeroecho.pki.api.profile.LeafCertificatePolicy; +import zeroecho.pki.api.profile.LeafKeyUsage; +import zeroecho.pki.api.profile.SubjectAlternativeNamePolicy; +import zeroecho.pki.api.profile.SubjectAlternativeNameRule; +import zeroecho.pki.api.profile.SubjectAlternativeNameType; +import zeroecho.pki.api.profile.SubjectPolicy; +import zeroecho.pki.api.profile.SubjectRdnRule; +import zeroecho.pki.api.profile.SubjectRdnType; +import zeroecho.pki.api.request.CertificationRequest; +import zeroecho.pki.api.request.ParsedCertificationRequest; +import zeroecho.pki.api.request.ProofOfPossessionStatus; +import zeroecho.pki.api.request.SubjectAlternativeName; +import zeroecho.pki.api.request.SubjectRdn; +import zeroecho.pki.impl.core.attr.SimpleAttributeSet; +import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes; +import zeroecho.pki.impl.framework.x509.bc.BcX509CertificationRequestParser; +import zeroecho.pki.impl.framework.x509.bc.BcX509CredentialFramework; +import zeroecho.pki.impl.framework.x509.bc.BcX509ProfileSupport; +import zeroecho.pki.spi.store.PkiStore; +import zeroecho.pki.testkit.H7ProfileDocuments; +import zeroecho.pki.testkit.PkiTestRuntime; + +/** + * Focused H7 profile, parser, validator, and postcondition regression evidence. + */ +final class H7ProfileEnforcementTest { + private static final Instant NOW = Instant.parse("2026-06-01T00:00:00Z"); + + @Test + void sanCanonicalizationPreservesTypedIdentityAndRawUriEscapes() { + assertEquals("www.example.com", new SubjectAlternativeName.DnsName("WWW.Example.COM").value()); + assertArrayEquals(new byte[] { 127, 0, 0, 1 }, + new SubjectAlternativeName.IpAddress(new byte[] { 127, 0, 0, 1 }).bytes()); + assertEquals("https://example.com/a%2Fb?q=%2F", + new SubjectAlternativeName.UriName("HTTPS://Example.COM/a%2Fb?q=%2F").value()); + assertEquals("Local@example.com", new SubjectAlternativeName.Rfc822Name("Local@Example.COM").value()); + + assertThrows(IllegalArgumentException.class, () -> new SubjectAlternativeName.DnsName("127.0.0.1")); + assertThrows(IllegalArgumentException.class, () -> new SubjectAlternativeName.DnsName("bad.example.")); + assertThrows(IllegalArgumentException.class, () -> new SubjectAlternativeName.IpAddress(new byte[5])); + assertThrows(IllegalArgumentException.class, () -> new SubjectAlternativeName.UriName("mailto:a@example.com")); + assertThrows(IllegalArgumentException.class, + () -> new SubjectAlternativeName.UriName("https://user@example.com/path")); + assertThrows(IllegalArgumentException.class, + () -> new SubjectAlternativeName.Rfc822Name("Display ")); + } + + @Test + void profileConstructionRejectsUnsatisfiableAndOpenPolicy() { + 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, + false, false)); + assertThrows(IllegalArgumentException.class, + () -> new SubjectAlternativeNamePolicy(false, 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, + false, false)); + assertThrows(IllegalArgumentException.class, () -> leaf(Set.of(LeafKeyUsage.ENCIPHER_ONLY), + Set.of("RSA"), Set.of())); + assertThrows(IllegalArgumentException.class, + () -> leaf(Set.of(LeafKeyUsage.DIGITAL_SIGNATURE), Set.of("rsa"), Set.of())); + assertThrows(IllegalArgumentException.class, () -> new ExtendedKeyUsageId("1.40.1")); + assertEquals("1.3.6.1.5.5.7.3.1", new ExtendedKeyUsageId("1.3.6.1.5.5.7.3.1").oid()); + } + + @Test + void parserUsesLogicalSubjectStringsAndFourClosedSanTypes() throws Exception { + KeyPair keyPair = rsa(); + X500NameBuilder subject = new X500NameBuilder(BCStyle.INSTANCE); + subject.addRDN(BCStyle.CN, new DERUTF8String("#literal\\name")); + Extensions extensions = extensions(false, + new GeneralName(GeneralName.dNSName, "WWW.Example.COM"), + new GeneralName(GeneralName.iPAddress, new DEROctetString(new byte[] { 10, 0, 0, 1 })), + new GeneralName(GeneralName.uniformResourceIdentifier, "HTTPS://Example.COM/a%2Fb"), + new GeneralName(GeneralName.rfc822Name, "Local@Example.COM")); + + ParsedCertificationRequest parsed = parse(csr(keyPair, subject.build(), List.of(extensions))); + + assertEquals(List.of(new SubjectRdn(SubjectRdnType.COMMON_NAME, "#literal\\name")), + parsed.subjectRdns()); + assertEquals(BcX509ProfileSupport.subject(parsed.subjectRdns()).toString(), parsed.subjectRef().value()); + assertEquals(List.of(new SubjectAlternativeName.DnsName("www.example.com"), + new SubjectAlternativeName.IpAddress(new byte[] { 10, 0, 0, 1 }), + new SubjectAlternativeName.UriName("https://example.com/a%2Fb"), + new SubjectAlternativeName.Rfc822Name("Local@example.com")), + parsed.subjectAlternativeNames()); + } + + @Test + void parserRejectsUnsupportedSubjectAndCsrExtensionShapes() throws Exception { + KeyPair keyPair = rsa(); + X500NameBuilder unsupportedSubject = new X500NameBuilder(BCStyle.INSTANCE); + unsupportedSubject.addRDN(BCStyle.CN, new DERBMPString("unsupported")); + assertCode("SUBJECT_VALUE_UNSUPPORTED", () -> parse(csr(keyPair, unsupportedSubject.build(), List.of()))); + + Extensions unknown = new Extensions(new Extension(new ASN1ObjectIdentifier("1.2.3.4"), false, + new byte[] { 0x05, 0x00 })); + assertCode("EXTENSION_UNSUPPORTED", () -> parse(csr(keyPair, new X500Name("CN=Leaf"), List.of(unknown)))); + + Extensions criticalSan = extensions(true, new GeneralName(GeneralName.dNSName, "example.com")); + assertCode("SAN_CRITICALITY_REQUESTED", + () -> parse(csr(keyPair, new X500Name("CN=Leaf"), List.of(criticalSan)))); + + Extensions san = extensions(false, new GeneralName(GeneralName.dNSName, "example.com")); + assertCode("CSR_ATTRIBUTE_UNSUPPORTED", + () -> parse(csr(keyPair, new X500Name("CN=Leaf"), List.of(san, san)))); + + Extension sanExtension = san.getExtension(Extension.subjectAlternativeName); + DERSequence duplicateSan = new DERSequence(new ASN1Encodable[] { sanExtension, sanExtension }); + assertCode("EXTENSION_REQUEST_MALFORMED", + () -> parse(csr(keyPair, new X500Name("CN=Leaf"), List.of(duplicateSan)))); + + Extensions unsupportedName = extensions(false, + new GeneralName(GeneralName.directoryName, new X500Name("CN=Nested"))); + assertCode("SAN_TYPE_UNSUPPORTED", + () -> parse(csr(keyPair, new X500Name("CN=Leaf"), List.of(unsupportedName)))); + } + + @Test + void validatorRequiresCanonicalSupportedSpkiParametersAndDerivesFinalSubject() throws Exception { + for (String algorithm : List.of("RSA", "ECDSA", "Ed25519", "Ed448")) { + KeyPair pair = keyPair(algorithm); + ValidatedCertificateRequest validated = validate(pair.getPublic().getEncoded(), algorithm, + policyWithFixedOrganization(algorithm)); + assertTrue(validated.subjectRef().value().contains("O=Profile Fixed")); + } + + KeyPair rsa = rsa(); + SubjectPublicKeyInfo original = SubjectPublicKeyInfo.getInstance(rsa.getPublic().getEncoded()); + SubjectPublicKeyInfo missingNull = new SubjectPublicKeyInfo( + new AlgorithmIdentifier(PKCSObjectIdentifiers.rsaEncryption), + original.getPublicKeyData().getBytes()); + assertCode("SUBJECT_KEY_PARAMETERS_UNSUPPORTED", + () -> validate(missingNull.getEncoded(), "RSA", policyWithFixedOrganization("RSA"))); + + SubjectPublicKeyInfo ed = SubjectPublicKeyInfo.getInstance(keyPair("Ed25519").getPublic().getEncoded()); + SubjectPublicKeyInfo edWithNull = new SubjectPublicKeyInfo( + new AlgorithmIdentifier(ed.getAlgorithm().getAlgorithm(), DERNull.INSTANCE), + ed.getPublicKeyData().getBytes()); + assertCode("SUBJECT_KEY_PARAMETERS_UNSUPPORTED", + () -> validate(edWithNull.getEncoded(), "Ed25519", policyWithFixedOrganization("Ed25519"))); + } + + @Test + void postconditionRejectsDuplicateEkuAndUnknownExtensions() throws Exception { + KeyPair pair = rsa(); + ValidatedCertificateRequest request = validatedRequest(pair); + X509CertificateHolder exact = certificate(pair, request, false, false); + X509CertificateHolder duplicateEku = certificate(pair, request, true, false); + X509CertificateHolder unknownExtension = certificate(pair, request, false, true); + + assertTrue(BcX509ProfileSupport.matchesLeafExtensions(exact, request)); + assertFalse(BcX509ProfileSupport.matchesLeafExtensions(duplicateEku, request)); + assertFalse(BcX509ProfileSupport.matchesLeafExtensions(unknownExtension, request)); + } + + @Test + void activeProfileCannotSubstitutePolicyForItsCanonicalReference() { + CertificateProfileDefinition definition = + CertificateProfileDocumentCodec.parse(H7ProfileDocuments.defaultProfile()); + CertificateProfileRef substituted = new CertificateProfileRef(definition.profileId(), + definition.profileVersion(), new byte[CertificateProfileRef.HASH_BYTES]); + + assertThrows(IllegalArgumentException.class, + () -> new ActiveCertificateProfile(substituted, definition)); + } + + @Test + void credentialProfileBindingVariantsAreClosedAndExact() { + CertificateProfileRef reference = new CertificateProfileRef("shared-profile", 1, + new byte[CertificateProfileRef.HASH_BYTES]); + EndEntityProfileBinding endEntity = new EndEntityProfileBinding(reference); + CaProfileBinding ca = new CaProfileBinding(reference.profileId()); + + assertTrue(CredentialProfileBinding.class.isSealed()); + assertEquals(Set.of(EndEntityProfileBinding.class, CaProfileBinding.class), + Set.of(CredentialProfileBinding.class.getPermittedSubclasses())); + assertTrue(java.util.Arrays.stream(CredentialProfileBinding.class.getMethods()) + .noneMatch(method -> method.getName().equals("profileId"))); + assertTrue(java.util.Arrays.stream(Credential.class.getMethods()) + .noneMatch(method -> method.getName().equals("profileId"))); + + CredentialProfileBindings.requireEndEntityBinding(endEntity, reference); + CredentialProfileBindings.requireCaBinding(ca, reference.profileId()); + assertCode(CredentialProfileBindings.MISMATCH_CODE, + () -> CredentialProfileBindings.requireEndEntityBinding(ca, reference)); + assertCode(CredentialProfileBindings.MISMATCH_CODE, + () -> CredentialProfileBindings.requireCaBinding(endEntity, reference.profileId())); + assertCode(CredentialProfileBindings.MISMATCH_CODE, + () -> CredentialProfileBindings.requireCaBinding(new CaProfileBinding("other"), + reference.profileId())); + assertCode(CredentialProfileBindings.MISMATCH_CODE, + () -> CredentialProfileBindings.requireCaBinding(null, reference.profileId())); + } + + @Test + void profileResolutionFailuresPrecedeSigningAndPersistence(@TempDir Path tempDir) throws Exception { + KeyPair root = rsa(); + KeyPair leaf = rsa(); + KeyRef rootRef = new KeyRef("kref:v1:keyring:h7:root"); + 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())); + ParsedCertificationRequest request = runtime.certificationRequestService().parse( + new CertificationRequest(runtime.framework().formatId(), + new EncodedObject(Encoding.DER, + csr(leaf, new X500Name("CN=Leaf"), List.of()).getEncoded()))); + int signs = runtime.submittedSignCount(); + + assertRejectedWithoutSideEffects(runtime, caId, request, "missing", Optional.empty(), signs); + + runtime.profileService().importProfile(H7ProfileDocuments.inactiveProfile()); + assertRejectedWithoutSideEffects(runtime, caId, request, "inactive", Optional.empty(), signs); + + runtime.importAndActivate(H7ProfileDocuments.wrongFormatProfile()); + assertRejectedWithoutSideEffects(runtime, caId, request, "wrong-format", Optional.empty(), signs); + + runtime.importAndActivate(H7ProfileDocuments.excessiveValidityProfile()); + Validity tooLong = new Validity(NOW, NOW.plus(Duration.ofDays(366))); + assertRejectedWithoutSideEffects(runtime, caId, request, "too-long", Optional.of(tooLong), signs); + + ProfileService mismatched = mismatchedProfileService(runtime.profileService()); + DefaultIssuanceService service = new DefaultIssuanceService(runtime.store(), runtime.framework(), + runtime.issuerBackend(), runtime.auditSink(), runtime.statusResolver(), mismatched, + Clock.systemUTC()); + assertThrows(PkiException.class, () -> service.issueEndEntity( + new IssueEndEntityCommand(caId, request, "requested", Optional.empty()))); + assertEquals(signs, runtime.submittedSignCount()); + + assertEquals(List.of("issuerCaId", "request", "profileId", "validityOverride"), + java.util.Arrays.stream(IssueEndEntityCommand.class.getRecordComponents()) + .map(component -> component.getName()).toList()); + } + } + + private static void assertRejectedWithoutSideEffects(PkiTestRuntime runtime, PkiId caId, + ParsedCertificationRequest request, String profileId, Optional validity, int signs) { + assertThrows(PkiException.class, () -> runtime.issuanceService() + .issueEndEntity(new IssueEndEntityCommand(caId, request, profileId, validity))); + assertEquals(signs, runtime.submittedSignCount()); + } + + private static ProfileService mismatchedProfileService(ProfileService delegate) { + return (ProfileService) Proxy.newProxyInstance(ProfileService.class.getClassLoader(), + new Class[] { ProfileService.class }, + (proxy, method, args) -> { + if ("requireActiveProfile".equals(method.getName())) { + CertificateProfileDefinition definition = + CertificateProfileDocumentCodec.parse(H7ProfileDocuments.defaultProfile()); + return new ActiveCertificateProfile(profileRef(definition), definition); + } + try { + return method.invoke(delegate, args); + } catch (InvocationTargetException exception) { + throw exception.getCause(); + } + }); + } + + private static CertificateProfileRef profileRef(CertificateProfileDefinition definition) { + try { + byte[] canonical = CertificateProfileDocumentCodec.writeCanonical(definition); + return new CertificateProfileRef(definition.profileId(), definition.profileVersion(), + MessageDigest.getInstance("SHA-256").digest(canonical)); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException(impossible); + } + } + + private static ValidatedCertificateRequest validate(byte[] encoded, String algorithm, + CertificateProfile profile) { + ParsedCertificationRequest request = parsedRequest(encoded); + IssueEndEntityCommand command = + new IssueEndEntityCommand(new PkiId("ca:h7"), request, profile.profileId(), Optional.empty()); + VerifiedIssuanceCandidate candidate = new VerifiedIssuanceCandidate(request, request.requestId(), + request.publicKeyInfo(), ProofOfPossessionStatus.VERIFIED, command); + CertificateProfileRef reference = new CertificateProfileRef(profile.profileId(), 1, + new byte[CertificateProfileRef.HASH_BYTES]); + return CertificateProfileValidator.validate(candidate, profile, reference, issuerCredential(), NOW); + } + + private static ParsedCertificationRequest parsedRequest(byte[] encoded) { + AttributeSet attributes = SimpleAttributeSet.builder() + .put(BcX509Attributes.CSR_DER, new AttributeValue.BytesValue(new byte[] { 1 })).build(); + return new ParsedCertificationRequest(new PkiId("csr:h7"), BcX509CredentialFramework.FORMAT_ID, + new SubjectRef("CN=Leaf"), new EncodedObject(Encoding.DER, encoded), Optional.empty(), + Optional.empty(), List.of(new SubjectRdn(SubjectRdnType.COMMON_NAME, "Leaf")), + List.of(), false, attributes); + } + + private static Credential issuerCredential() { + 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 EncodedObject(Encoding.DER, new byte[] { 1 }), new SimpleAttributeSet()); + } + + private static CertificateProfile policyWithFixedOrganization(String algorithm) { + SubjectPolicy subject = new SubjectPolicy(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); + } + + private static LeafCertificatePolicy leaf(Set usages, Set algorithms, + Set ekus) { + return new LeafCertificatePolicy(new SubjectPolicy(List.of()), + new SubjectAlternativeNamePolicy(true, 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)); + } + + private static ParsedCertificationRequest parse(PKCS10CertificationRequest request) throws Exception { + CertificationRequest input = new CertificationRequest(BcX509CredentialFramework.FORMAT_ID, + new EncodedObject(Encoding.DER, request.getEncoded())); + return new BcX509CertificationRequestParser().parse(input); + } + + private static PKCS10CertificationRequest csr(KeyPair pair, X500Name subject, + List extensionRequests) throws Exception { + PKCS10CertificationRequestBuilder builder = + new JcaPKCS10CertificationRequestBuilder(subject, pair.getPublic()); + for (ASN1Encodable extensions : extensionRequests) { + builder.addAttribute(PKCSObjectIdentifiers.pkcs_9_at_extensionRequest, extensions); + } + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(pair.getPrivate()); + return builder.build(signer); + } + + private static Extensions extensions(boolean critical, GeneralName... names) throws Exception { + GeneralNames generalNames = new GeneralNames(names); + return new Extensions(new Extension(Extension.subjectAlternativeName, critical, + new DEROctetString(generalNames.getEncoded()))); + } + + private static KeyPair rsa() throws Exception { + return keyPair("RSA"); + } + + private static KeyPair keyPair(String algorithm) throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("ECDSA".equals(algorithm) ? "EC" : algorithm); + if ("RSA".equals(algorithm)) { + generator.initialize(2048); + } else if ("ECDSA".equals(algorithm)) { + generator.initialize(new ECGenParameterSpec("secp256r1")); + } + return generator.generateKeyPair(); + } + + private static ValidatedCertificateRequest validatedRequest(KeyPair pair) { + return new ValidatedCertificateRequest(new PkiId("ca:h7"), + new CertificateProfileRef("h7", 1, new byte[CertificateProfileRef.HASH_BYTES]), + new SubjectRef("CN=Leaf"), + List.of(new SubjectRdn(SubjectRdnType.COMMON_NAME, "Leaf")), + List.of(new SubjectAlternativeName.DnsName("example.com")), false, + new EncodedObject(Encoding.DER, pair.getPublic().getEncoded()), + new Validity(NOW, NOW.plus(Duration.ofDays(1))), Set.of(LeafKeyUsage.DIGITAL_SIGNATURE), + Set.of(new ExtendedKeyUsageId(KeyPurposeId.id_kp_serverAuth.getId())), true, false, true); + } + + private static X509CertificateHolder certificate(KeyPair pair, ValidatedCertificateRequest request, + boolean duplicateEku, boolean unknownExtension) throws Exception { + X500Name subject = BcX509ProfileSupport.subject(request.subjectRdns()); + X509v3CertificateBuilder builder = new X509v3CertificateBuilder(subject, BigInteger.ONE, + Date.from(request.validity().notBefore()), Date.from(request.validity().notAfter()), subject, + SubjectPublicKeyInfo.getInstance(pair.getPublic().getEncoded())); + builder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false)); + builder.addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature)); + KeyPurposeId[] usages = duplicateEku + ? new KeyPurposeId[] { KeyPurposeId.id_kp_serverAuth, KeyPurposeId.id_kp_serverAuth } + : new KeyPurposeId[] { KeyPurposeId.id_kp_serverAuth }; + builder.addExtension(Extension.extendedKeyUsage, false, new ExtendedKeyUsage(usages)); + builder.addExtension(Extension.subjectAlternativeName, false, + new GeneralNames(new GeneralName(GeneralName.dNSName, "example.com"))); + if (unknownExtension) { + builder.addExtension(new ASN1ObjectIdentifier("1.2.3.4"), false, DERNull.INSTANCE); + } + return builder.build(new JcaContentSignerBuilder("SHA256withRSA").build(pair.getPrivate())); + } + + private static void assertCode(String code, ThrowingAction action) { + PkiException exception = assertThrows(PkiException.class, action::run); + assertTrue(exception.getMessage().contains(code)); + } + + @FunctionalInterface + private interface ThrowingAction { + void run() 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 d6608ac..f561c22 100644 --- a/pki/src/test/java/zeroecho/pki/impl/core/StoreBackedEffectiveCredentialStatusResolverTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/core/StoreBackedEffectiveCredentialStatusResolverTest.java @@ -61,6 +61,7 @@ import zeroecho.pki.api.SubjectRef; import zeroecho.pki.api.Validity; import zeroecho.pki.api.audit.AuditEvent; import zeroecho.pki.api.credential.Credential; +import zeroecho.pki.api.credential.CaProfileBinding; import zeroecho.pki.api.credential.CredentialStatus; import zeroecho.pki.api.credential.CredentialUse; import zeroecho.pki.api.credential.EffectiveCredentialStatus; @@ -175,7 +176,8 @@ 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"), - "default", CredentialStatus.ISSUED, new EncodedObject(Encoding.DER, new byte[] { 1 }), + new CaProfileBinding("default"), CredentialStatus.ISSUED, + new EncodedObject(Encoding.DER, new byte[] { 1 }), new SimpleAttributeSet()); AtomicReference recorded = new AtomicReference<>(); @@ -236,7 +238,8 @@ final class StoreBackedEffectiveCredentialStatusResolverTest { private static Credential credential(String suffix, CredentialStatus status, Instant notBefore, Instant notAfter) { 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), "default", status, + new Validity(notBefore, notAfter), suffix, new PkiId("key:" + suffix), + new CaProfileBinding("default"), status, new EncodedObject(Encoding.DER, new byte[] { 1, 2, 3 }), new SimpleAttributeSet()); } diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreOwnershipTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreOwnershipTest.java index 20379c2..e5cb52a 100644 --- a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreOwnershipTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreOwnershipTest.java @@ -78,16 +78,19 @@ final class FilesystemPkiStoreOwnershipTest { FilesystemPkiStore first = new FilesystemPkiStore(root, options); try { - first.putProfile(FilesystemPkiStoreTest.TestObjects.minimalProfile("ownership-profile", true)); + FilesystemPkiStoreTest.importProfile(first, + FilesystemPkiStoreTest.TestObjects.minimalProfile("ownership-profile"), + java.time.Instant.now()); + first.activateProfile("ownership-profile", 1); assertOwnershipRejected(root); - assertTrue(first.getProfile("ownership-profile").isPresent()); + assertTrue(first.getActiveProfileRef("ownership-profile").isPresent()); } finally { first.close(); } first.close(); try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, options)) { - assertTrue(reopened.getProfile("ownership-profile").isPresent()); + assertTrue(reopened.getActiveProfileRef("ownership-profile").isPresent()); } System.out.println("sameJvmOwnerExcludesSecondStoreAndReleasesOnClose...ok"); } @@ -99,12 +102,18 @@ final class FilesystemPkiStoreOwnershipTest { FsPkiStoreOptions.defaults()); FilesystemPkiStore second = new FilesystemPkiStore(tempDir.resolve("second"), FsPkiStoreOptions.defaults())) { - first.putProfile(FilesystemPkiStoreTest.TestObjects.minimalProfile("first-profile", true)); - second.putProfile(FilesystemPkiStoreTest.TestObjects.minimalProfile("second-profile", true)); - assertTrue(first.getProfile("first-profile").isPresent()); - assertFalse(first.getProfile("second-profile").isPresent()); - assertTrue(second.getProfile("second-profile").isPresent()); - assertFalse(second.getProfile("first-profile").isPresent()); + FilesystemPkiStoreTest.importProfile(first, + FilesystemPkiStoreTest.TestObjects.minimalProfile("first-profile"), + java.time.Instant.now()); + first.activateProfile("first-profile", 1); + FilesystemPkiStoreTest.importProfile(second, + FilesystemPkiStoreTest.TestObjects.minimalProfile("second-profile"), + java.time.Instant.now()); + second.activateProfile("second-profile", 1); + assertTrue(first.getActiveProfileRef("first-profile").isPresent()); + assertFalse(first.getActiveProfileRef("second-profile").isPresent()); + assertTrue(second.getActiveProfileRef("second-profile").isPresent()); + assertFalse(second.getActiveProfileRef("first-profile").isPresent()); } System.out.println("separateStoreRootsRemainIndependent...ok"); } 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 9704bb5..2588290 100644 --- a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java @@ -34,6 +34,7 @@ package zeroecho.pki.impl.fs; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -41,6 +42,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.security.MessageDigest; import java.time.Duration; import java.time.Instant; import java.util.Comparator; @@ -69,8 +71,20 @@ import zeroecho.pki.api.ca.CaKind; import zeroecho.pki.api.ca.CaRecord; import zeroecho.pki.api.ca.CaState; import zeroecho.pki.api.credential.Credential; +import zeroecho.pki.api.credential.CaProfileBinding; 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.CertificateProfileRef; +import zeroecho.pki.api.profile.ExtendedKeyUsageId; +import zeroecho.pki.api.profile.ImportedCertificateProfileVersion; +import zeroecho.pki.api.profile.LeafCertificatePolicy; +import zeroecho.pki.api.profile.LeafKeyUsage; +import zeroecho.pki.api.profile.SubjectAlternativeNamePolicy; +import zeroecho.pki.api.profile.SubjectPolicy; +import zeroecho.pki.api.profile.SubjectRdnRule; +import zeroecho.pki.api.profile.SubjectRdnType; import zeroecho.pki.api.policy.PolicyTrace; import zeroecho.pki.api.policy.PolicyTraceStep; import zeroecho.pki.api.publication.PublicationRecord; @@ -127,7 +141,7 @@ public final class FilesystemPkiStoreTest { PublicationRecord publication = new PublicationRecord(new PkiId("publication-all"), now, new PublicationTarget(PublicationTargetType.FILESYSTEM, "target-all", attributes), credential.credentialId(), "CREDENTIAL", PublicationStatus.PUBLISHED); - CertificateProfile profile = TestObjects.minimalProfile("profile-all", true); + CertificateProfile profile = TestObjects.minimalProfile("profile-all"); PolicyTrace trace = new PolicyTrace(new PkiId("decision-all"), List.of(new PolicyTraceStep("rule-all", "ALLOW", List.of("approved")))); WorkflowStateRecord workflow = new WorkflowStateRecord(new PkiId("workflow-all"), "ISSUANCE", @@ -143,7 +157,8 @@ public final class FilesystemPkiStoreTest { credential.credentialId(), RevocationReason.KEY_COMPROMISE, attributes), now); store.putStatusObject(status); store.putPublicationRecord(publication); - store.putProfile(profile); + importProfile(store, profile, now); + store.activateProfile(profile.profileId(), 1); store.putPolicyTrace(trace); store.putWorkflowState(workflow); @@ -156,7 +171,7 @@ public final class FilesystemPkiStoreTest { assertEquals(status.statusObjectId(), store.getStatusObject(status.statusObjectId()).orElseThrow().statusObjectId()); assertEquals(publication.publicationId(), store.listPublicationRecords().get(0).publicationId()); - assertEquals(profile, store.getProfile(profile.profileId()).orElseThrow()); + assertEquals(profile, store.requireActiveProfile(profile.profileId()).profile()); assertEquals(trace, store.getPolicyTrace(trace.decisionId()).orElseThrow()); assertEquals(workflow.opId(), store.getWorkflowState(workflow.opId()).orElseThrow().opId()); } @@ -252,7 +267,7 @@ public final class FilesystemPkiStoreTest { store.putCa(ca1); Instant at = Instant.now(); - store.putProfile(TestObjects.minimalProfile("profile-snap-1", true)); + importProfile(store, TestObjects.minimalProfile("profile-snap-1"), Instant.now()); store.exportSnapshot(snapshot, at); } @@ -282,8 +297,8 @@ public final class FilesystemPkiStoreTest { CaRecord caA = TestObjects.minimalCaRecord("ca-a", CaState.ACTIVE); CaRecord caB = TestObjects.minimalCaRecord("ca-b", CaState.ACTIVE); - CertificateProfile pA = TestObjects.minimalProfile("profile-a", true); - CertificateProfile pB = TestObjects.minimalProfile("profile-b", true); + CertificateProfile pA = TestObjects.minimalProfile("profile-a"); + CertificateProfile pB = TestObjects.minimalProfile("profile-b"); Instant at1; Instant at2; @@ -295,12 +310,12 @@ public final class FilesystemPkiStoreTest { sleepMillis(120L); store.putCa(caB); - store.putProfile(pA); + importProfile(store, pA, Instant.now()); at2 = Instant.now(); sleepMillis(120L); - store.putProfile(pB); + importProfile(store, pB, Instant.now()); // Export snapshots after all writes; in non-strict mode export must not fail. store.exportSnapshot(snap1, at1); @@ -318,10 +333,11 @@ public final class FilesystemPkiStoreTest { try (FilesystemPkiStore s2 = new FilesystemPkiStore(snap2, options)) { List cas = s2.listCas(); - List profiles = s2.listProfiles(); + List profiles = s2.listProfileVersions("profile-a"); Set caIds = cas.stream().map(r -> r.caId().toString()).collect(Collectors.toSet()); - Set profileIds = profiles.stream().map(CertificateProfile::profileId).collect(Collectors.toSet()); + Set profileIds = profiles.stream().map(v -> v.reference().profileId()) + .collect(Collectors.toSet()); System.out.println("...snapshot2 caIds: " + caIds); System.out.println("...snapshot2 profileIds: " + profileIds); @@ -329,7 +345,7 @@ public final class FilesystemPkiStoreTest { assertTrue(caIds.contains("ca-a")); assertTrue(caIds.contains("ca-b")); assertTrue(profileIds.contains("profile-a")); - assertTrue(profileIds.contains("profile-b")); + assertFalse(profileIds.contains("profile-b")); } System.out.println("snapshotExportWithMultipleObjectsNonStrictDoesNotFail...ok"); @@ -344,7 +360,6 @@ public final class FilesystemPkiStoreTest { FsPkiStoreOptions options = strictSnapshotOptions(); - IllegalStateException ex; try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) { // Create CA first. store.putCa(TestObjects.minimalCaRecord("ca-strict-1", CaState.ACTIVE)); @@ -354,14 +369,12 @@ public final class FilesystemPkiStoreTest { sleepMillis(120L); // Now create a new object that does not have history entry <= at. - store.putProfile(TestObjects.minimalProfile("profile-strict-1", true)); + importProfile(store, TestObjects.minimalProfile("profile-strict-1"), Instant.now()); - // Strict export must fail because current tree contains profile without history - // <= at. - ex = assertThrows(IllegalStateException.class, () -> store.exportSnapshot(snapshot, at)); + // Immutable versions imported after the requested time are omitted. + store.exportSnapshot(snapshot, at); } - System.out.println("...exception: " + shorten(ex.toString(), 200)); System.out.println("...store tree:"); dumpTree(root); @@ -379,7 +392,7 @@ public final class FilesystemPkiStoreTest { try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) { store.putCa(TestObjects.minimalCaRecord("ca-strict-ok", CaState.ACTIVE)); - store.putProfile(TestObjects.minimalProfile("profile-strict-ok", true)); + importProfile(store, TestObjects.minimalProfile("profile-strict-ok"), Instant.now()); // "at" after all writes: strict export should succeed. Instant at = Instant.now(); @@ -394,8 +407,8 @@ public final class FilesystemPkiStoreTest { try (FilesystemPkiStore snap = new FilesystemPkiStore(snapshot, options)) { Set caIds = snap.listCas().stream().map(r -> r.caId().toString()).collect(Collectors.toSet()); - Set profileIds = snap.listProfiles().stream().map(CertificateProfile::profileId) - .collect(Collectors.toSet()); + Set profileIds = snap.listProfileVersions("profile-strict-ok").stream() + .map(v -> v.reference().profileId()).collect(Collectors.toSet()); System.out.println("...snapshot caIds: " + caIds); System.out.println("...snapshot profileIds: " + profileIds); @@ -512,6 +525,17 @@ public final class FilesystemPkiStoreTest { /** * Deterministic domain fixtures. */ + static void importProfile(FilesystemPkiStore store, CertificateProfile profile, Instant importedAt) + throws Exception { + CertificateProfileDefinition definition = new CertificateProfileDefinition(profile.profileId(), 1, + profile.formatId(), profile.displayName(), 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, + importedAt)); + } + static final class TestObjects { private static final AtomicLong SEQ = new AtomicLong(1L); @@ -528,16 +552,17 @@ public final class FilesystemPkiStoreTest { return new CaRecord(id, CaKind.ROOT, state, issuerKeyRef, subjectRef, caCredentials); } - static CertificateProfile minimalProfile(String profileId, boolean active) { + static CertificateProfile minimalProfile(String profileId) { FormatId formatId = new FormatId("fmt-x509"); - String displayName = "Profile " + profileId; - - List required = List.of(new AttributeId("req-1")); - List optional = List.of(new AttributeId("opt-1")); - - Optional maxValidity = Optional.of(Duration.ofDays(365)); - - return new CertificateProfile(profileId, formatId, displayName, required, optional, maxValidity, active); + SubjectPolicy subject = new SubjectPolicy(List.of(new SubjectRdnRule( + SubjectRdnType.COMMON_NAME, 1, 1, 256, Optional.empty(), true))); + 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, 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); } static Credential minimalCredential(String serial, String profileId) { @@ -559,7 +584,7 @@ public final class FilesystemPkiStoreTest { AttributeSet attrs = emptyAttributes(); return new Credential(credentialId, formatId, issuerRef, subjectRef, validity, serial, publicKeyId, - profileId, status, encoded, attrs); + new CaProfileBinding(profileId), 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 new file mode 100644 index 0000000..5a08869 --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemProfileLifecycleTest.java @@ -0,0 +1,315 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.impl.fs; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; +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.CertificateProfileRef; +import zeroecho.pki.api.profile.ImportedCertificateProfileVersion; +import zeroecho.pki.impl.audit.InMemoryAuditSink; +import zeroecho.pki.impl.core.DefaultProfileService; + +/** + * Atomic persistence and fail-closed lookup evidence for profile lifecycle state. + */ +final class FilesystemProfileLifecycleTest { + private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-07-30T10:00:00Z"), ZoneOffset.UTC); + + @Test + void precommitFailuresPreserveAbsentVersionAndPreviousActivePointer(@TempDir Path directory) + throws Exception { + Path root = directory.resolve("store"); + BuiltInCertificateProfileTemplate template = builtIn("server-tls"); + byte[] versionTwo = version(template, 2, "Server TLS v2"); + try (FilesystemPkiStore store = store(root)) { + DefaultProfileService service = service(store); + CertificateProfileRef one = service.importBuiltIn(template); + service.activateProfile("server-tls", 1); + + Path versionTemporary = new FsPaths(root).profileVersion("server-tls", 2) + .resolveSibling("2.bin.tmp"); + Files.createDirectories(versionTemporary); + Files.writeString(versionTemporary.resolve("block"), "blocked"); + assertThrows(PkiException.class, () -> service.importProfile(versionTwo)); + assertTrue(service.getImportedVersion("server-tls", 2).isEmpty()); + assertFalse(Files.exists(new FsPaths(root).profileVersion("server-tls", 2))); + + Files.delete(versionTemporary.resolve("block")); + Files.delete(versionTemporary); + CertificateProfileRef two = service.importProfile(versionTwo); + + Path pointerTemporary = new FsPaths(root).profileActive("server-tls") + .resolveSibling("active.bin.tmp"); + Files.createDirectories(pointerTemporary); + Files.writeString(pointerTemporary.resolve("block"), "blocked"); + assertThrows(PkiException.class, () -> service.activateProfile("server-tls", 2)); + assertEquals(one, service.getActiveReference("server-tls").orElseThrow()); + assertEquals(one, service.requireActiveProfile("server-tls").reference()); + assertFalse(one.equals(two)); + } + } + + @Test + void corruptAndNoncanonicalVersionsFailClosedAndCannotBeOverwritten(@TempDir Path directory) + throws Exception { + BuiltInCertificateProfileTemplate template = builtIn("server-tls"); + Path corruptRoot = directory.resolve("corrupt"); + try (FilesystemPkiStore store = store(corruptRoot)) { + DefaultProfileService service = service(store); + service.importBuiltIn(template); + Path versionPath = new FsPaths(corruptRoot).profileVersion("server-tls", 1); + Files.write(versionPath, new byte[] { 1, 2, 3 }); + byte[] corrupt = Files.readAllBytes(versionPath); + assertCode(() -> service.getImportedVersion("server-tls", 1), "PROFILE_VERSION_CORRUPT"); + assertCode(() -> service.activateProfile("server-tls", 1), "PROFILE_VERSION_CORRUPT"); + assertTrue(service.getActiveReference("server-tls").isEmpty()); + assertCode(() -> service.importBuiltIn(template), "PROFILE_VERSION_CORRUPT"); + assertArrayEquals(corrupt, Files.readAllBytes(versionPath)); + } + + Path noncanonicalRoot = directory.resolve("noncanonical"); + try (FilesystemPkiStore store = store(noncanonicalRoot)) { + byte[] noncanonical = (new String(template.canonicalJson(), StandardCharsets.UTF_8) + "\n") + .getBytes(StandardCharsets.UTF_8); + ImportedCertificateProfileVersion invalid = new ImportedCertificateProfileVersion( + new CertificateProfileRef(template.definition().profileId(), + template.definition().profileVersion(), template.canonicalSha256()), + 1, 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)); + + assertCode(() -> store.getProfileVersion("server-tls", 1), "PROFILE_VERSION_CORRUPT"); + assertCode(() -> service(store).importBuiltIn(template), "PROFILE_VERSION_CORRUPT"); + } + + Path hashMismatchRoot = directory.resolve("hash-mismatch"); + try (FilesystemPkiStore store = store(hashMismatchRoot)) { + ImportedCertificateProfileVersion invalid = new ImportedCertificateProfileVersion( + new CertificateProfileRef(template.definition().profileId(), + template.definition().profileVersion(), + new byte[CertificateProfileRef.HASH_BYTES]), + 1, 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)); + + assertCode(() -> store.getProfileVersion("server-tls", 1), "PROFILE_HASH_MISMATCH"); + assertCode(() -> service(store).importBuiltIn(template), "PROFILE_HASH_MISMATCH"); + } + } + + @Test + void unrelatedProfileIdsProceedWhileAnotherProfileCoordinatorIsHeld(@TempDir Path directory) + throws Exception { + try (FilesystemPkiStore store = store(directory.resolve("store"))) { + DefaultProfileService service = service(store); + Method acquire = FilesystemPkiStore.class.getDeclaredMethod("acquireProfileLock", String.class); + Method release = FilesystemPkiStore.class.getDeclaredMethod("releaseProfileLock", + String.class, Class.forName( + "zeroecho.pki.impl.fs.FilesystemPkiStore$ProfileLockEntry")); + acquire.setAccessible(true); + release.setAccessible(true); + CountDownLatch held = new CountDownLatch(1); + CountDownLatch releaseHeld = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future holder = executor.submit(() -> { + Object entry = invoke(acquire, store, "server-tls"); + held.countDown(); + try { + releaseHeld.await(); + } finally { + invoke(release, store, "server-tls", entry); + } + return null; + }); + assertTrue(held.await(5, TimeUnit.SECONDS)); + + Future unrelated = + executor.submit(() -> service.importBuiltIn(builtIn("email-signing"))); + CertificateProfileRef imported = unrelated.get(5, TimeUnit.SECONDS); + assertEquals("email-signing", imported.profileId()); + assertTrue(service.getImportedVersion("email-signing", 1).isPresent()); + assertFalse(holder.isDone()); + + releaseHeld.countDown(); + holder.get(5, TimeUnit.SECONDS); + } finally { + releaseHeld.countDown(); + executor.shutdownNow(); + } + } + } + + @Test + void missingCorruptAndHashMismatchedPointersNeverFallback(@TempDir Path directory) throws Exception { + BuiltInCertificateProfileTemplate template = builtIn("vpn-client"); + Path root = directory.resolve("store"); + try (FilesystemPkiStore store = store(root)) { + DefaultProfileService service = service(store); + CertificateProfileRef one = service.importBuiltIn(template); + service.importProfile(version(template, 2, "VPN Client v2")); + + assertThrows(PkiException.class, () -> service.requireActiveProfile("vpn-client")); + assertTrue(service.getActiveReference("vpn-client").isEmpty()); + + Path pointer = new FsPaths(root).profileActive("vpn-client"); + FsOperations.writeAtomicStrict(pointer, FsCodec.encode(FsCodec.ACTIVE_PROFILE_REF, + new CertificateProfileRef("vpn-client", 3, new byte[CertificateProfileRef.HASH_BYTES]))); + assertActiveChainFailure(service, "vpn-client", "PROFILE_ACTIVE_POINTER_CORRUPT"); + + FsOperations.writeAtomicStrict(pointer, FsCodec.encode(FsCodec.ACTIVE_PROFILE_REF, + new CertificateProfileRef("vpn-client", 1, new byte[CertificateProfileRef.HASH_BYTES]))); + assertActiveChainFailure(service, "vpn-client", "PROFILE_HASH_MISMATCH"); + + Files.write(pointer, new byte[] { 7, 8, 9 }); + assertActiveChainFailure(service, "vpn-client", "PROFILE_ACTIVE_POINTER_CORRUPT"); + + FsOperations.writeAtomicStrict(pointer, FsCodec.encode(FsCodec.ACTIVE_PROFILE_REF, one)); + Files.delete(new FsPaths(root).profileVersion("vpn-client", 1)); + assertActiveChainFailure(service, "vpn-client", "PROFILE_ACTIVE_POINTER_CORRUPT"); + } + } + + @Test + void snapshotPreflightsCorruptProfileArtifactsAndPathIdentityBeforeOutput(@TempDir Path directory) + throws Exception { + BuiltInCertificateProfileTemplate template = builtIn("server-tls"); + Path corruptRoot = directory.resolve("corrupt-store"); + Path corruptTarget = directory.resolve("corrupt-snapshot"); + try (FilesystemPkiStore store = store(corruptRoot)) { + service(store).importBuiltIn(template); + Path version = new FsPaths(corruptRoot).profileVersion("server-tls", 1); + Files.write(version, new byte[] { 1, 2, 3 }); + + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> store.exportSnapshot(corruptTarget, CLOCK.instant())); + assertEquals("Historical snapshot failed: code=PROFILE_VERSION_CORRUPT", + failure.getMessage()); + assertEquals(null, failure.getCause()); + assertEquals(0, failure.getSuppressed().length); + assertFalse(failure.getMessage().contains(corruptRoot.toString())); + assertFalse(failure.getMessage().contains("server-tls")); + assertFalse(Files.exists(corruptTarget)); + } + + Path pathRoot = directory.resolve("path-store"); + Path pathTarget = directory.resolve("path-snapshot"); + try (FilesystemPkiStore store = store(pathRoot)) { + service(store).importBuiltIn(template); + Path original = new FsPaths(pathRoot).profileVersion("server-tls", 1); + Path misplaced = pathRoot.resolve("profiles").resolve("by-id").resolve("id-wrong") + .resolve("versions").resolve("1.bin"); + FsOperations.ensureDir(misplaced.getParent()); + Files.write(misplaced, Files.readAllBytes(original)); + + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> store.exportSnapshot(pathTarget, CLOCK.instant())); + assertEquals("Historical snapshot failed: code=PROFILE_VERSION_CORRUPT", + failure.getMessage()); + assertFalse(Files.exists(pathTarget)); + } + + Path invalidRoot = directory.resolve("invalid-root-store"); + Path invalidTarget = directory.resolve("invalid-root-snapshot"); + try (FilesystemPkiStore store = store(invalidRoot)) { + Files.writeString(invalidRoot.resolve("profiles"), "not-a-directory"); + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> store.exportSnapshot(invalidTarget, CLOCK.instant())); + assertEquals("Historical snapshot failed: code=PROFILE_STORE_FAILURE", + failure.getMessage()); + assertEquals(null, failure.getCause()); + assertEquals(0, failure.getSuppressed().length); + assertFalse(Files.exists(invalidTarget)); + } + } + + private static byte[] version(BuiltInCertificateProfileTemplate template, int version, String displayName) { + return new String(template.canonicalJson(), StandardCharsets.UTF_8) + .replace("\"profileVersion\":1", "\"profileVersion\":" + version) + .replace("\"displayName\":\"" + template.definition().displayName() + "\"", + "\"displayName\":\"" + displayName + "\"") + .getBytes(StandardCharsets.UTF_8); + } + + private static FilesystemPkiStore store(Path root) throws Exception { + return new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), CLOCK); + } + + private static DefaultProfileService service(FilesystemPkiStore store) { + return new DefaultProfileService(store, CLOCK, new InMemoryAuditSink()); + } + + private static BuiltInCertificateProfileTemplate builtIn(String profileId) { + return BuiltInCertificateProfileCatalog.load(FilesystemProfileLifecycleTest.class.getClassLoader()).stream() + .filter(template -> profileId.equals(template.definition().profileId())) + .findFirst().orElseThrow(); + } + + private static void assertActiveChainFailure(DefaultProfileService service, String profileId, String code) { + PkiException referenceFailure = assertThrows(PkiException.class, + () -> service.getActiveReference(profileId)); + PkiException profileFailure = assertThrows(PkiException.class, + () -> service.requireActiveProfile(profileId)); + assertEquals("Profile lifecycle operation failed: code=" + code, + referenceFailure.getMessage()); + assertEquals(referenceFailure.getMessage(), profileFailure.getMessage()); + assertEquals(null, referenceFailure.getCause()); + assertEquals(null, profileFailure.getCause()); + assertEquals(0, referenceFailure.getSuppressed().length); + assertEquals(0, profileFailure.getSuppressed().length); + } + + private static void assertCode(ThrowingOperation operation, String code) { + PkiException failure = assertThrows(PkiException.class, operation::run); + assertEquals("Profile lifecycle operation failed: code=" + code, failure.getMessage()); + assertEquals(null, failure.getCause()); + assertEquals(0, failure.getSuppressed().length); + } + + @FunctionalInterface + private interface ThrowingOperation { + void run() throws Exception; + } + + private static Object invoke(Method method, Object target, Object... arguments) { + try { + return method.invoke(target, arguments); + } catch (IllegalAccessException impossible) { + throw new IllegalStateException(impossible); + } catch (InvocationTargetException failure) { + Throwable cause = failure.getCause(); + if (cause instanceof RuntimeException runtime) { + throw runtime; + } + throw new IllegalStateException(cause); + } + } +} 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 42d7f6c..31ab5aa 100644 --- a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationJournalTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationJournalTest.java @@ -48,6 +48,7 @@ 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.CredentialStatus; import zeroecho.pki.api.credential.EffectiveCredentialStatus; import zeroecho.pki.api.revocation.RevocationCommand; @@ -498,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), "default", CredentialStatus.ISSUED, + new PkiId("key:" + suffix), new CaProfileBinding("default"), 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 9e8ec3a..00a5636 100644 --- a/pki/src/test/java/zeroecho/pki/impl/fs/FsCodecTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/fs/FsCodecTest.java @@ -41,6 +41,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.time.Duration; import java.time.Instant; import java.util.Arrays; @@ -55,13 +56,30 @@ import org.junit.jupiter.api.Test; import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.Encoding; import zeroecho.pki.api.FormatId; +import zeroecho.pki.api.IssuerRef; 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.CaProfileBinding; +import zeroecho.pki.api.credential.Credential; +import zeroecho.pki.api.credential.CredentialProfileBinding; +import zeroecho.pki.api.credential.CredentialStatus; +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.CertificateProfileRef; +import zeroecho.pki.api.profile.ExtendedKeyUsageId; +import zeroecho.pki.api.profile.ImportedCertificateProfileVersion; +import zeroecho.pki.api.profile.LeafCertificatePolicy; +import zeroecho.pki.api.profile.LeafKeyUsage; +import zeroecho.pki.api.profile.SubjectAlternativeNamePolicy; +import zeroecho.pki.api.profile.SubjectPolicy; +import zeroecho.pki.api.profile.SubjectRdnRule; +import zeroecho.pki.api.profile.SubjectRdnType; import zeroecho.pki.api.request.ParsedCertificationRequest; import zeroecho.pki.impl.core.attr.SimpleAttributeSet; @@ -77,10 +95,10 @@ final class FsCodecTest { @Test void approvedSchemaRoundTripAndTypeBinding() { System.out.println("approvedSchemaRoundTripAndTypeBinding"); - CertificateProfile profile = profile(); + ImportedCertificateProfileVersion profile = profileVersion(); - byte[] encoded = FsCodec.encode(FsCodec.CERTIFICATE_PROFILE, profile); - CertificateProfile decoded = FsCodec.decode(FsCodec.CERTIFICATE_PROFILE, encoded); + byte[] encoded = FsCodec.encode(FsCodec.PROFILE_VERSION, profile); + ImportedCertificateProfileVersion decoded = FsCodec.decode(FsCodec.PROFILE_VERSION, encoded); System.out.println("...encoded length=" + encoded.length); assertEquals(profile, decoded); @@ -91,7 +109,7 @@ final class FsCodecTest { @Test void currentEnvelopeRejectsUnknownOldAndCorruptedPayloads() { System.out.println("currentEnvelopeRejectsUnknownOldAndCorruptedPayloads"); - byte[] valid = FsCodec.encode(FsCodec.CERTIFICATE_PROFILE, profile()); + byte[] valid = FsCodec.encode(FsCodec.PROFILE_VERSION, profileVersion()); byte[] unsupportedVersion = valid.clone(); unsupportedVersion[VERSION_OFFSET] = (byte) (FsCodec.CURRENT_CODEC_VERSION + 1); @@ -182,7 +200,7 @@ final class FsCodecTest { @Test void malformedPackedLengthAndOptionalMarkerFailClosed() { System.out.println("malformedPackedLengthAndOptionalMarkerFailClosed"); - byte[] encoded = FsCodec.encode(FsCodec.CERTIFICATE_PROFILE, profile()); + byte[] encoded = FsCodec.encode(FsCodec.PROFILE_VERSION, profileVersion()); byte[] malformedLength = encoded.clone(); malformedLength[FIRST_FIELD_TYPE_OFFSET + 1] = (byte) 0xFF; assertInvalid(malformedLength); @@ -192,6 +210,21 @@ final class FsCodecTest { System.out.println("...ok"); } + @Test + void credentialProfileBindingDiscriminatorRoundTripsExactly() { + CertificateProfileRef reference = new CertificateProfileRef("profile-a", 1, + new byte[CertificateProfileRef.HASH_BYTES]); + List bindings = List.of(new EndEntityProfileBinding(reference), + new CaProfileBinding(reference.profileId())); + + for (CredentialProfileBinding binding : bindings) { + Credential original = credential(binding); + Credential decoded = FsCodec.decode(FsCodec.CREDENTIAL, FsCodec.encode(FsCodec.CREDENTIAL, original)); + assertEquals(binding, decoded.profileBinding()); + assertEquals(binding.getClass(), decoded.profileBinding().getClass()); + } + } + private static ParsedCertificationRequest roundTripRequest(AttributeSet attributes) { byte[] encoded = FsCodec.encode(FsCodec.PARSED_REQUEST, request(attributes)); return FsCodec.decode(FsCodec.PARSED_REQUEST, encoded); @@ -206,14 +239,42 @@ final class FsCodecTest { } private static CertificateProfile profile() { - return new CertificateProfile("profile-a", new FormatId("x509"), "Profile A", - List.of(new AttributeId("required")), List.of(new AttributeId("optional")), - Optional.of(Duration.ofDays(365)), true); + SubjectPolicy subject = new SubjectPolicy(List.of(new SubjectRdnRule( + SubjectRdnType.COMMON_NAME, 1, 1, 256, Optional.empty(), true))); + SubjectAlternativeNamePolicy sans = new SubjectAlternativeNamePolicy(false, 0, 0, + List.of(), false, java.util.Set.of(), false, false, false); + LeafCertificatePolicy leaf = new LeafCertificatePolicy(subject, 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); + } + + private static Credential credential(CredentialProfileBinding binding) { + return new Credential(new PkiId("credential-1"), new FormatId("x509"), + new IssuerRef(new PkiId("ca-1")), new SubjectRef("subject-1"), + new Validity(Instant.EPOCH, Instant.EPOCH.plusSeconds(1)), "1", + new PkiId("spki-1"), binding, CredentialStatus.ISSUED, + new EncodedObject(Encoding.DER, new byte[] { 1 }), new SimpleAttributeSet()); + } + + private static ImportedCertificateProfileVersion profileVersion() { + try { + CertificateProfile profile = profile(); + CertificateProfileDefinition definition = new CertificateProfileDefinition(profile.profileId(), 1, + profile.formatId(), profile.displayName(), 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); + } catch (java.security.NoSuchAlgorithmException impossible) { + throw new IllegalStateException(impossible); + } } private static void assertInvalid(byte[] encoded) { IllegalStateException failure = assertThrows(IllegalStateException.class, - () -> FsCodec.decode(FsCodec.CERTIFICATE_PROFILE, encoded)); + () -> FsCodec.decode(FsCodec.PROFILE_VERSION, encoded)); assertTrue(failure.getMessage().contains("INVALID_CURRENT_PAYLOAD")); } diff --git a/pki/src/test/java/zeroecho/pki/testkit/H7ProfileDocuments.java b/pki/src/test/java/zeroecho/pki/testkit/H7ProfileDocuments.java new file mode 100644 index 0000000..59fb709 --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/testkit/H7ProfileDocuments.java @@ -0,0 +1,236 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.testkit; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +/** + * Explicit strict JSON documents used by H7 issuance tests. + * + *

    These fixtures deliberately contain document syntax and never construct a + * runtime profile or profile definition before entering the production import + * boundary.

    + */ +public final class H7ProfileDocuments { + private static final String CN = + "{\"oid\":\"2.5.4.3\",\"source\":\"REQUESTER\",\"minimumOccurrences\":1," + + "\"maximumOccurrences\":1,\"maximumUtf8Bytes\":256}"; + private static final String SUBJECT_EMAIL = + "{\"oid\":\"1.2.840.113549.1.9.1\",\"source\":\"REQUESTER\",\"minimumOccurrences\":1," + + "\"maximumOccurrences\":1,\"maximumUtf8Bytes\":256}"; + private static final String DNS_ONE = + "{\"type\":\"DNS_NAME\",\"minimumOccurrences\":1,\"maximumOccurrences\":1," + + "\"wildcardAllowed\":false}"; + private static final String IPV4_ONE = + "{\"type\":\"IP_ADDRESS\",\"minimumOccurrences\":1,\"maximumOccurrences\":1," + + "\"ipv4Allowed\":true,\"ipv6Allowed\":false}"; + private static final String IPV6_ONE = + "{\"type\":\"IP_ADDRESS\",\"minimumOccurrences\":1,\"maximumOccurrences\":1," + + "\"ipv4Allowed\":false,\"ipv6Allowed\":true}"; + private static final String IP_BOTH_ONE = + "{\"type\":\"IP_ADDRESS\",\"minimumOccurrences\":1,\"maximumOccurrences\":1," + + "\"ipv4Allowed\":true,\"ipv6Allowed\":true}"; + private static final String URI_ONE = + "{\"type\":\"URI\",\"minimumOccurrences\":1,\"maximumOccurrences\":1," + + "\"allowedSchemes\":[\"https\"]}"; + private static final String RFC822_ONE = + "{\"type\":\"RFC822_NAME\",\"minimumOccurrences\":1,\"maximumOccurrences\":1}"; + + private H7ProfileDocuments() { + } + + /** @return default CN-only profile document */ + public static byte[] defaultProfile() { + return profile("default", false, List.of(CN), 0, 0, false, false, false, List.of(), false, + List.of("1.3.6.1.5.5.7.3.1")); + } + + /** @return imported but intentionally inactive profile document */ + public static byte[] inactiveProfile() { + return profile("inactive", false, List.of(CN), 0, 0, false, false, false, List.of(), false, + serverAuth()); + } + + /** @return profile document using a deliberately mismatched format */ + public static byte[] wrongFormatProfile() { + return profile("wrong-format", "wrong", false, List.of(CN), 0, 0, false, false, false, List.of(), + false, serverAuth()); + } + + /** @return profile document used to reject an excessive validity request */ + public static byte[] excessiveValidityProfile() { + return profile("too-long", false, List.of(CN), 0, 0, false, false, false, List.of(), false, + serverAuth()); + } + + /** @return one-DNS profile document */ + public static byte[] dnsProfile() { + return profile("h7-dns", false, List.of(CN), 1, 1, true, false, false, List.of(DNS_ONE), false, + serverAuth()); + } + + /** @return multiple-DNS profile document */ + public static byte[] multipleDnsProfile() { + return profile("h7-dns-multiple", false, List.of(CN), 1, 3, true, false, false, + List.of("{\"type\":\"DNS_NAME\",\"minimumOccurrences\":1,\"maximumOccurrences\":3," + + "\"wildcardAllowed\":false}"), false, serverAuth()); + } + + /** @return wildcard-DNS profile document */ + public static byte[] wildcardDnsProfile() { + return profile("h7-wildcard", false, List.of(CN), 1, 1, true, false, false, + List.of("{\"type\":\"DNS_NAME\",\"minimumOccurrences\":1,\"maximumOccurrences\":1," + + "\"wildcardAllowed\":true}"), false, serverAuth()); + } + + /** @return empty-subject DNS profile document */ + public static byte[] emptySubjectDnsProfile() { + return profile("h7-dns-empty", true, List.of(), 1, 1, true, false, false, List.of(DNS_ONE), false, + serverAuth()); + } + + /** @return critical nonempty-subject DNS profile document */ + public static byte[] criticalDnsProfile() { + return profile("h7-dns-critical", false, List.of(CN), 1, 1, true, false, true, List.of(DNS_ONE), false, + serverAuth()); + } + + /** @return one IPv4-or-IPv6 profile document */ + public static byte[] ipProfile() { + return profile("h7-ip", false, List.of(CN), 1, 1, true, false, false, List.of(IP_BOTH_ONE), false, + serverAuth()); + } + + /** @return exact mixed IPv4/IPv6 profile document */ + public static byte[] mixedIpProfile() { + String rule = "{\"type\":\"IP_ADDRESS\",\"minimumOccurrences\":2,\"maximumOccurrences\":2," + + "\"ipv4Allowed\":true,\"ipv6Allowed\":true}"; + return profile("h7-ip-mixed", false, List.of(CN), 2, 2, true, false, false, List.of(rule), false, + serverAuth()); + } + + /** @return empty-subject IP profile document */ + public static byte[] emptySubjectIpProfile() { + return profile("h7-ip-empty", true, List.of(), 1, 1, true, false, false, List.of(IP_BOTH_ONE), false, + serverAuth()); + } + + /** @return IPv4-only profile document */ + public static byte[] ipv4Profile() { + return profile("h7-ipv4", false, List.of(CN), 1, 1, true, false, false, List.of(IPV4_ONE), false, + serverAuth()); + } + + /** @return IPv6-only profile document */ + public static byte[] ipv6Profile() { + return profile("h7-ipv6", false, List.of(CN), 1, 1, true, false, false, List.of(IPV6_ONE), false, + serverAuth()); + } + + /** @return one-URI profile document */ + public static byte[] uriProfile() { + return profile("h7-uri", false, List.of(CN), 1, 1, true, false, false, List.of(URI_ONE), false, + serverAuth()); + } + + /** @return multiple-URI profile document */ + public static byte[] multipleUriProfile() { + String rule = "{\"type\":\"URI\",\"minimumOccurrences\":1,\"maximumOccurrences\":3," + + "\"allowedSchemes\":[\"https\"]}"; + return profile("h7-uri-multiple", false, List.of(CN), 1, 3, true, false, false, List.of(rule), false, + serverAuth()); + } + + /** @return empty-subject URI profile document */ + public static byte[] emptySubjectUriProfile() { + return profile("h7-uri-empty", true, List.of(), 1, 1, true, false, false, List.of(URI_ONE), false, + serverAuth()); + } + + /** @return one-RFC822 profile document */ + public static byte[] rfc822Profile() { + return profile("h7-rfc822", false, List.of(CN), 1, 1, false, true, false, List.of(RFC822_ONE), false, + serverAuth()); + } + + /** @return multiple-RFC822 profile document */ + public static byte[] multipleRfc822Profile() { + String rule = "{\"type\":\"RFC822_NAME\",\"minimumOccurrences\":1,\"maximumOccurrences\":3}"; + return profile("h7-rfc822-multiple", false, List.of(CN), 1, 3, false, true, false, List.of(rule), false, + serverAuth()); + } + + /** @return empty-subject RFC822 profile document */ + public static byte[] emptySubjectRfc822Profile() { + return profile("h7-rfc822-empty", true, List.of(), 1, 1, false, true, false, List.of(RFC822_ONE), false, + serverAuth()); + } + + /** @return independent subject-email and RFC822-SAN profile document */ + public static byte[] subjectEmailAndRfc822Profile() { + return profile("h7-subject-email-rfc822", false, List.of(CN, SUBJECT_EMAIL), 1, 1, false, true, false, + List.of(RFC822_ONE), false, serverAuth()); + } + + /** @return mixed supported-SAN profile document */ + public static byte[] mixedSanProfile() { + return profile("h7-mixed", false, List.of(CN), 4, 4, true, true, false, + List.of(DNS_ONE, IP_BOTH_ONE, RFC822_ONE, URI_ONE), false, serverAuth()); + } + + /** @return adversarial-backend baseline profile document */ + public static byte[] backendMutationProfile() { + String organization = "{\"oid\":\"2.5.4.10\",\"source\":\"REQUESTER\",\"minimumOccurrences\":1," + + "\"maximumOccurrences\":1,\"maximumUtf8Bytes\":256}"; + return profile("h7-backend-mutation", false, List.of(CN, organization), 4, 4, true, true, false, + List.of(DNS_ONE, IP_BOTH_ONE, RFC822_ONE, URI_ONE), false, + List.of("1.3.6.1.5.5.7.3.1", "1.3.6.1.5.5.7.3.2")); + } + + /** @return CN-only profile forbidding SAN and EKU */ + public static byte[] noSanOrEkuProfile() { + return profile("h7-no-san-eku", false, List.of(CN), 0, 0, false, false, false, List.of(), false, + List.of()); + } + + private static List serverAuth() { + return List.of("1.3.6.1.5.5.7.3.1"); + } + + private static byte[] profile(String id, boolean allowEmpty, List subjectRules, + int minimumTotal, int maximumTotal, boolean serviceIdentity, boolean emailIdentity, + boolean critical, List sanRules, boolean noKeyEncipherment, List eku) { + return profile(id, "x509", allowEmpty, subjectRules, minimumTotal, maximumTotal, serviceIdentity, + emailIdentity, critical, sanRules, noKeyEncipherment, eku); + } + + private static byte[] profile(String id, String formatId, boolean allowEmpty, List subjectRules, + int minimumTotal, int maximumTotal, boolean serviceIdentity, boolean emailIdentity, + boolean critical, List sanRules, boolean noKeyEncipherment, List eku) { + String keyUsage = noKeyEncipherment + ? "[\"DIGITAL_SIGNATURE\"]" : "[\"DIGITAL_SIGNATURE\",\"KEY_ENCIPHERMENT\"]"; + String json = "{\"schemaVersion\":1,\"profileId\":\"" + id + + "\",\"profileVersion\":1,\"formatId\":\"" + formatId + + "\",\"displayName\":\"H7 Test Profile\"," + + "\"maxValidity\":\"PT8760H\",\"subject\":{\"allowEmpty\":" + allowEmpty + + ",\"rules\":[" + String.join(",", subjectRules) + "]}," + + "\"subjectAlternativeNames\":{\"minimumTotal\":" + minimumTotal + + ",\"maximumTotal\":" + maximumTotal + + ",\"serviceIdentityRequired\":" + serviceIdentity + + ",\"emailIdentityRequired\":" + emailIdentity + + ",\"criticalWhenSubjectNonEmpty\":" + critical + + ",\"rules\":[" + String.join(",", sanRules) + "]}," + + "\"leafCertificate\":{\"basicConstraintsCritical\":true,\"keyUsageCritical\":true," + + "\"keyUsage\":" + keyUsage + ",\"extendedKeyUsageCritical\":false," + + "\"extendedKeyUsage\":[" + quoted(eku) + "],\"allowedKeyAlgorithms\":[\"RSA\"]}}"; + return json.getBytes(StandardCharsets.UTF_8); + } + + private static String quoted(List values) { + return values.stream().map(value -> "\"" + value + "\"") + .collect(java.util.stream.Collectors.joining(",")); + } +} diff --git a/pki/src/test/java/zeroecho/pki/testkit/PkiTestRuntime.java b/pki/src/test/java/zeroecho/pki/testkit/PkiTestRuntime.java index 6e7849d..db3805c 100644 --- a/pki/src/test/java/zeroecho/pki/testkit/PkiTestRuntime.java +++ b/pki/src/test/java/zeroecho/pki/testkit/PkiTestRuntime.java @@ -51,12 +51,14 @@ import zeroecho.pki.api.Encoding; import zeroecho.pki.api.IssuanceService; import zeroecho.pki.api.KeyRef; import zeroecho.pki.api.RevocationService; +import zeroecho.pki.api.ProfileService; import zeroecho.pki.api.StatusObjectService; import zeroecho.pki.api.credential.EffectiveCredentialStatusResolver; import zeroecho.pki.impl.core.DefaultCaService; import zeroecho.pki.impl.core.DefaultCertificationRequestService; import zeroecho.pki.impl.core.DefaultIssuanceService; import zeroecho.pki.impl.core.DefaultRevocationService; +import zeroecho.pki.impl.core.DefaultProfileService; import zeroecho.pki.impl.core.DefaultStatusObjectService; import zeroecho.pki.impl.core.StoreBackedEffectiveCredentialStatusResolver; import zeroecho.pki.impl.core.async.PkiSigningBus; @@ -92,6 +94,7 @@ public final class PkiTestRuntime implements AutoCloseable { private final CredentialFramework framework; private final CredentialIssuerBackend issuerBackend; private final EffectiveCredentialStatusResolver statusResolver; + private final ProfileService profileService; private final CaService caService; private final CertificationRequestService certificationRequestService; @@ -113,12 +116,15 @@ public final class PkiTestRuntime implements AutoCloseable { this.issuerBackend = issuerBackend; Clock clock = Clock.systemUTC(); this.statusResolver = new StoreBackedEffectiveCredentialStatusResolver(store, clock); + this.profileService = new DefaultProfileService(store, clock, auditSink); this.publicKeysByKeyRef = publicKeysByKeyRef; this.publicKeyResolveHook = () -> { }; this.certificationRequestService = new DefaultCertificationRequestService(store, framework); - this.issuanceService = new DefaultIssuanceService(store, framework, issuerBackend, auditSink, statusResolver); + importAndActivate(H7ProfileDocuments.defaultProfile()); + this.issuanceService = new DefaultIssuanceService(store, framework, issuerBackend, auditSink, statusResolver, + profileService, clock); this.revocationService = new DefaultRevocationService(store, clock, auditSink); this.statusObjectService = new DefaultStatusObjectService(store, framework, auditSink, statusResolver); @@ -286,6 +292,19 @@ public final class PkiTestRuntime implements AutoCloseable { return statusResolver; } + /** @return production profile lifecycle service */ + public ProfileService profileService() { + return profileService; + } + + /** + * Imports and activates one strict JSON test document through production APIs. + */ + public void importAndActivate(byte[] document) { + zeroecho.pki.api.profile.CertificateProfileRef reference = profileService.importProfile(document); + profileService.activateProfile(reference.profileId(), reference.profileVersion()); + } + public CaService caService() { return caService; } @@ -319,7 +338,7 @@ public final class PkiTestRuntime implements AutoCloseable { public IssuanceService issuanceService(CredentialIssuerBackend backend, EffectiveCredentialStatusResolver resolver) { return new DefaultIssuanceService(store, framework, Objects.requireNonNull(backend, "backend"), auditSink, - Objects.requireNonNull(resolver, "resolver")); + Objects.requireNonNull(resolver, "resolver"), profileService, Clock.systemUTC()); } public RevocationService revocationService() {