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 extends ASN1Encodable> 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 extends ASN1Encodable> 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