security(pki): enforce configuration-driven certificate profiles

* add strict versioned JSON profile documents and canonical serialization
* package validated built-in end-entity profile templates
* persist immutable profile versions with canonical hashes
* require explicit atomic profile activation
* resolve issuance profiles only from validated active store references
* enforce deny-by-default subject and SAN policies
* support typed DNS, IP, URI, and RFC822 SAN identities
* remove requester control over certificate serials and reserved fields
* bind issued credentials to exact profile ID, version, and hash
* enforce closed end-entity and CA credential profile-binding variants
* validate issued DER against the complete approved profile
* add real SAN issuance, CSR rejection, malicious-backend, lifecycle, snapshot, and redaction coverage

BREAKING CHANGE: replaces direct and mutable pre-release profile handling with strict JSON import,
immutable version persistence, explicit activation, and exact credential profile bindings.
This commit is contained in:
2026-07-30 17:15:33 +02:00
parent f06b25fa39
commit 9e40e8b5a2
72 changed files with 9258 additions and 596 deletions

View File

@@ -8,6 +8,8 @@ group='org.egothor'
dependencies { dependencies {
implementation 'org.apache.commons:commons-text' implementation 'org.apache.commons:commons-text'
implementation 'commons-cli:commons-cli' implementation 'commons-cli:commons-cli'
implementation platform('tools.jackson:jackson-bom:3.1.5')
implementation 'tools.jackson.core:jackson-core'
implementation project(':lib') implementation project(':lib')
} }

View File

@@ -1,90 +1,49 @@
/******************************************************************************* /*******************************************************************************
* Copyright (C) 2026, Leo Galambos * Copyright (C) 2026, Leo Galambos
* All rights reserved. * All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/ ******************************************************************************/
package zeroecho.pki.api; package zeroecho.pki.api;
import java.io.InputStream;
import java.util.List; import java.util.List;
import java.util.Optional;
import zeroecho.pki.api.profile.CertificateProfile; import zeroecho.pki.api.profile.ActiveCertificateProfile;
import zeroecho.pki.api.profile.ProfileQuery; import zeroecho.pki.api.profile.BuiltInCertificateProfileTemplate;
import zeroecho.pki.api.profile.CertificateProfileRef;
import zeroecho.pki.api.profile.ImportedCertificateProfileVersion;
/** /**
* Profile registry for credential issuance. * Versioned certificate-profile import, activation, and lookup service.
* *
* <p> * <p>Import never activates a profile. End-entity issuance must resolve only an
* Profiles define required/optional attributes, validity limits, and other * explicitly activated persisted version through {@link #requireActiveProfile(String)}.</p>
* constraints used by policy and framework mapping. Profiles are referenced by
* id during issuance.
* </p>
*/ */
public interface ProfileService { public interface ProfileService {
/** Imports a bounded strict JSON profile document. */
CertificateProfileRef importProfile(byte[] jsonDocument);
/** /**
* Registers or updates a profile. * Imports a strict JSON profile document without taking ownership of the
* * supplied stream.
* @param profile profile definition
* @throws IllegalArgumentException if {@code profile} is null
* @throws PkiException if registration fails
*/ */
void register(CertificateProfile profile); CertificateProfileRef importProfile(InputStream jsonDocument);
/** /** Imports a built-in provisioning template through the same document path. */
* Retrieves a profile by id. CertificateProfileRef importBuiltIn(BuiltInCertificateProfileTemplate template);
*
* @param profileId profile id
* @return profile
* @throws IllegalArgumentException if {@code profileId} is null/blank
* @throws PkiException if not found or retrieval fails
*/
CertificateProfile get(String profileId);
/** /** Explicitly activates one already imported version. */
* Lists profiles matching query constraints. CertificateProfileRef activateProfile(String profileId, long profileVersion);
*
* @param query query constraints
* @return list of profiles
* @throws IllegalArgumentException if {@code query} is null
* @throws PkiException if listing fails
*/
List<CertificateProfile> list(ProfileQuery query);
/** /** Resolves and validates the exact active profile. */
* Retires a profile to prevent further issuance. ActiveCertificateProfile requireActiveProfile(String profileId);
*
* @param profileId profile id /** Retrieves one immutable imported version for administration. */
* @param reason non-empty reason suitable for audit Optional<ImportedCertificateProfileVersion> getImportedVersion(String profileId, long profileVersion);
* @throws IllegalArgumentException if inputs are invalid
* @throws PkiException if retire fails /** Lists immutable imported versions for one logical profile. */
*/ List<ImportedCertificateProfileVersion> listImportedVersions(String profileId);
void retire(String profileId, String reason);
/** Retrieves the current active reference without activating anything. */
Optional<CertificateProfileRef> getActiveReference(String profileId);
} }

View File

@@ -0,0 +1,19 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.pki.api.credential;
/**
* Explicit CA policy identity for a root or intermediate credential.
*
* @param profileId existing CA policy identifier
*/
public record CaProfileBinding(String profileId) implements CredentialProfileBinding {
/** Creates a CA profile binding. */
public CaProfileBinding {
if (profileId == null || profileId.isBlank()) {
throw new IllegalArgumentException("profileId must not be null/blank");
}
}
}

View File

@@ -64,7 +64,7 @@ import zeroecho.pki.api.attr.AttributeSet;
* @param serialOrUniqueId framework-specific unique identifier (serial for * @param serialOrUniqueId framework-specific unique identifier (serial for
* X.509) * X.509)
* @param publicKeyId stable identifier derived from the subject public key * @param publicKeyId stable identifier derived from the subject public key
* @param profileId profile governing issuance * @param profileBinding closed profile identity governing issuance
* @param status persisted issuance and inventory metadata; this value * @param status persisted issuance and inventory metadata; this value
* is not sufficient for a trust decision because current * is not sufficient for a trust decision because current
* revocation state and evaluation time are external * revocation state and evaluation time are external
@@ -74,7 +74,8 @@ import zeroecho.pki.api.attr.AttributeSet;
* @param attributes universal attribute set * @param attributes universal attribute set
*/ */
public record Credential(PkiId credentialId, FormatId formatId, IssuerRef issuerRef, SubjectRef subjectRef, public record Credential(PkiId credentialId, FormatId formatId, IssuerRef issuerRef, SubjectRef subjectRef,
Validity validity, String serialOrUniqueId, PkiId publicKeyId, String profileId, CredentialStatus status, Validity validity, String serialOrUniqueId, PkiId publicKeyId, CredentialProfileBinding profileBinding,
CredentialStatus status,
EncodedObject encoded, AttributeSet attributes) { EncodedObject encoded, AttributeSet attributes) {
/** /**
@@ -104,8 +105,8 @@ public record Credential(PkiId credentialId, FormatId formatId, IssuerRef issuer
if (publicKeyId == null) { if (publicKeyId == null) {
throw new IllegalArgumentException("publicKeyId must not be null"); throw new IllegalArgumentException("publicKeyId must not be null");
} }
if (profileId == null || profileId.isBlank()) { if (profileBinding == null) {
throw new IllegalArgumentException("profileId must not be null/blank"); throw new IllegalArgumentException("profileBinding must not be null");
} }
if (status == null) { if (status == null) {
throw new IllegalArgumentException("status must not be null"); throw new IllegalArgumentException("status must not be null");
@@ -117,4 +118,5 @@ public record Credential(PkiId credentialId, FormatId formatId, IssuerRef issuer
throw new IllegalArgumentException("attributes must not be null"); throw new IllegalArgumentException("attributes must not be null");
} }
} }
} }

View File

@@ -0,0 +1,11 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.pki.api.credential;
/**
* Closed immutable identity of the policy governing an issued credential.
*/
public sealed interface CredentialProfileBinding permits EndEntityProfileBinding, CaProfileBinding {
}

View File

@@ -0,0 +1,21 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.pki.api.credential;
import java.util.Objects;
import zeroecho.pki.api.profile.CertificateProfileRef;
/**
* Exact imported and activated profile identity for an end-entity credential.
*
* @param reference exact certificate-profile version
*/
public record EndEntityProfileBinding(CertificateProfileRef reference) implements CredentialProfileBinding {
/** Creates an end-entity binding. */
public EndEntityProfileBinding {
Objects.requireNonNull(reference, "reference");
}
}

View File

@@ -37,7 +37,6 @@ import java.util.Optional;
import zeroecho.pki.api.PkiId; import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.Validity; import zeroecho.pki.api.Validity;
import zeroecho.pki.api.attr.AttributeSet;
import zeroecho.pki.api.request.ParsedCertificationRequest; import zeroecho.pki.api.request.ParsedCertificationRequest;
/** /**
@@ -49,11 +48,9 @@ import zeroecho.pki.api.request.ParsedCertificationRequest;
* @param profileId profile id governing issuance * @param profileId profile id governing issuance
* @param validityOverride optional requested validity override * @param validityOverride optional requested validity override
* (policy-validated) * (policy-validated)
* @param overrides additional universal attribute overrides
* (policy-validated; may be empty but not null)
*/ */
public record IssueEndEntityCommand(PkiId issuerCaId, ParsedCertificationRequest request, String profileId, public record IssueEndEntityCommand(PkiId issuerCaId, ParsedCertificationRequest request, String profileId,
Optional<Validity> validityOverride, AttributeSet overrides) { Optional<Validity> validityOverride) {
/** /**
* Creates an issuance command. * Creates an issuance command.
@@ -74,8 +71,5 @@ public record IssueEndEntityCommand(PkiId issuerCaId, ParsedCertificationRequest
if (validityOverride == null) { if (validityOverride == null) {
throw new IllegalArgumentException("validityOverride must not be null"); throw new IllegalArgumentException("validityOverride must not be null");
} }
if (overrides == null) {
throw new IllegalArgumentException("overrides must not be null");
}
} }
} }

View File

@@ -0,0 +1,47 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.pki.api.profile;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Objects;
/**
* Validated active profile resolution result used by end-entity issuance.
*
* @param reference exact active version reference
* @param definition authoritative immutable definition
*/
public record ActiveCertificateProfile(CertificateProfileRef reference,
CertificateProfileDefinition definition) {
/** Creates an active profile result. */
public ActiveCertificateProfile {
Objects.requireNonNull(reference, "reference");
Objects.requireNonNull(definition, "definition");
if (!reference.profileId().equals(definition.profileId())
|| reference.profileVersion() != definition.profileVersion()
|| !MessageDigest.isEqual(reference.canonicalSha256(), canonicalHash(definition))) {
throw new IllegalArgumentException("active profile identity mismatch");
}
}
/**
* Returns the deterministic runtime projection of the authoritative definition.
*
* @return runtime profile
*/
public CertificateProfile profile() {
return CertificateProfile.fromDefinition(definition);
}
private static byte[] canonicalHash(CertificateProfileDefinition definition) {
try {
return MessageDigest.getInstance("SHA-256")
.digest(CertificateProfileDocumentCodec.writeCanonical(definition));
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException("SHA-256 unavailable", impossible);
}
}
}

View File

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

View File

@@ -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.
*
* <p>
* 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.
* </p>
*/
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 + "]";
}
}

View File

@@ -33,22 +33,15 @@
******************************************************************************/ ******************************************************************************/
package zeroecho.pki.api.profile; 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.FormatId;
import zeroecho.pki.api.attr.AttributeId;
/** /**
* Defines issuance constraints and mapping hints for a class of credentials. * Defines issuance constraints and mapping hints for a class of credentials.
* *
* <p> * <p>
* A profile is referenced by {@code profileId} during issuance. It defines * A profile is referenced by {@code profileId} during issuance. It defines
* which universal attributes are required or allowed, and provides limits such * the complete deny-by-default identity and extension policy for an end-entity
* as maximum validity. Framework backends may use the profile as a source of * credential. Requester fields absent from the profile are forbidden.
* mapping hints when translating universal attributes into framework-specific
* fields/extensions.
* </p> * </p>
* *
* <p> * <p>
@@ -58,14 +51,10 @@ import zeroecho.pki.api.attr.AttributeId;
* @param profileId stable profile identifier * @param profileId stable profile identifier
* @param formatId framework/format supported by the profile * @param formatId framework/format supported by the profile
* @param displayName human-readable name * @param displayName human-readable name
* @param requiredAttributes list of required attribute identifiers * @param leafPolicy complete end-entity identity and extension policy
* @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
*/ */
public record CertificateProfile(String profileId, FormatId formatId, String displayName, public record CertificateProfile(String profileId, FormatId formatId, String displayName,
List<AttributeId> requiredAttributes, List<AttributeId> optionalAttributes, Optional<Duration> maxValidity, LeafCertificatePolicy leafPolicy) {
boolean active) {
/** /**
* Creates a certificate profile. * Creates a certificate profile.
@@ -83,14 +72,22 @@ public record CertificateProfile(String profileId, FormatId formatId, String dis
if (displayName == null || displayName.isBlank()) { if (displayName == null || displayName.isBlank()) {
throw new IllegalArgumentException("displayName must not be null/blank"); throw new IllegalArgumentException("displayName must not be null/blank");
} }
if (requiredAttributes == null) { if (leafPolicy == null) {
throw new IllegalArgumentException("requiredAttributes must not be null"); throw new IllegalArgumentException("leafPolicy 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");
} }
} }
/**
* 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());
}
} }

View File

@@ -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.
*
* <p>
* This definition deliberately excludes runtime activation state. Its
* {@link LeafCertificatePolicy} is the authoritative typed policy used by the
* issuance path.
* </p>
*
* @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");
}
}
}

View File

@@ -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.
*
* <p>
* The codec is stateless and thread-safe. It uses only Jackson Core streaming
* tokens and creates the existing typed policy model directly.
* </p>
*/
@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.
*
* <p>
* 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.
* </p>
*
* @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<SubjectRdnRule> 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<SubjectRdnRule> readSubjectRules(JsonParser parser, String path) throws JacksonException {
requireToken(parser.currentToken(), JsonToken.START_ARRAY, path);
List<SubjectRdnRule> 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<SubjectAlternativeNameRule> rules = new ArrayList<>();
boolean wildcardAllowed = false;
Set<String> 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<String> 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<String> readUriSchemes(JsonParser parser, String path) throws JacksonException {
requireToken(parser.currentToken(), JsonToken.START_ARRAY, path);
Set<String> 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<LeafKeyUsage> keyUsage = null;
boolean extendedCritical = false;
Set<ExtendedKeyUsageId> extended = null;
Set<String> 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<LeafKeyUsage> readKeyUsages(JsonParser parser, String path) throws JacksonException {
requireToken(parser.currentToken(), JsonToken.START_ARRAY, path);
Set<LeafKeyUsage> 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<ExtendedKeyUsageId> readExtendedKeyUsages(JsonParser parser, String path)
throws JacksonException {
requireToken(parser.currentToken(), JsonToken.START_ARRAY, path);
Set<ExtendedKeyUsageId> 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<String> readAlgorithms(JsonParser parser, String path) throws JacksonException {
requireToken(parser.currentToken(), JsonToken.START_ARRAY, path);
Set<String> 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<SubjectAlternativeNameRule> 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<String> 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<SubjectRdnRule> rules) {
}
private record SanSection(int minimumTotal, int maximumTotal, boolean serviceIdentityRequired,
boolean emailIdentityRequired, boolean criticalWhenSubjectNonEmpty,
List<SubjectAlternativeNameRule> rules, boolean wildcardAllowed,
Set<String> allowedSchemes) {
}
private record SanRules(List<SubjectAlternativeNameRule> rules, boolean wildcardAllowed,
Set<String> allowedSchemes) {
}
private record ParsedSanRule(SubjectAlternativeNameRule rule, boolean wildcardAllowed,
Set<String> allowedSchemes) {
}
private record LeafSection(boolean basicConstraintsCritical, boolean keyUsageCritical,
Set<LeafKeyUsage> keyUsage, boolean extendedKeyUsageCritical,
Set<ExtendedKeyUsageId> extendedKeyUsage, Set<String> allowedKeyAlgorithms) {
}
}

View File

@@ -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() + "]";
}
}

View File

@@ -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();
}
}

View File

@@ -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 + "]";
}
}

View File

@@ -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<LeafKeyUsage> keyUsages,
Set<ExtendedKeyUsageId> extendedKeyUsages, boolean keyUsageCritical, boolean extendedKeyUsageCritical,
boolean basicConstraintsCritical, Set<String> allowedSubjectKeyAlgorithmIds, Duration maximumValidity) {
private static final Set<String> 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");
}
}
}

View File

@@ -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
}

View File

@@ -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<SubjectAlternativeNameRule> rules, boolean allowDnsWildcard, Set<String> 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<SubjectAlternativeNameType> 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<String> 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");
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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
}

View File

@@ -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<SubjectRdnRule> 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<SubjectRdnType> 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");
}
}
}

View File

@@ -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<String> 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';
}
}

View File

@@ -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");
}
}

View File

@@ -45,6 +45,16 @@
* framework constructs during credential creation. * framework constructs during credential creation.
* </p> * </p>
* *
* <p>
* 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.
* </p>
*
* @since 1.0 * @since 1.0
*/ */
package zeroecho.pki.api.profile; package zeroecho.pki.api.profile;

View File

@@ -34,6 +34,7 @@
package zeroecho.pki.api.request; package zeroecho.pki.api.request;
import java.util.Optional; import java.util.Optional;
import java.util.List;
import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.FormatId; import zeroecho.pki.api.FormatId;
@@ -67,12 +68,40 @@ import zeroecho.pki.api.attr.AttributeSet;
* @param requestedValidity optional validity requested by the subject; policy * @param requestedValidity optional validity requested by the subject; policy
* may override or deny * may override or deny
* @param requestedProfileId optional profile hint; policy may override or deny * @param requestedProfileId optional profile hint; policy may override or deny
* @param attributes universal typed attributes extracted from the * @param subjectRdns ordered typed single-valued subject components
* request * @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, public record ParsedCertificationRequest(PkiId requestId, FormatId formatId, SubjectRef subjectRef,
EncodedObject publicKeyInfo, Optional<Validity> requestedValidity, Optional<String> requestedProfileId,
List<SubjectRdn> subjectRdns, List<SubjectAlternativeName> subjectAlternativeNames,
boolean subjectAlternativeNamePresent, AttributeSet attributes) {
/**
* Creates a diagnostic request without typed X.509 identity semantics.
*
* <p>
* 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.
* </p>
*
* @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<Validity> requestedValidity, Optional<String> requestedProfileId, EncodedObject publicKeyInfo, Optional<Validity> requestedValidity, Optional<String> requestedProfileId,
AttributeSet attributes) { AttributeSet attributes) {
this(requestId, formatId, subjectRef, publicKeyInfo, requestedValidity, requestedProfileId, List.of(),
List.of(), false, attributes);
}
/** /**
* Creates a parsed certification request. * Creates a parsed certification request.
@@ -106,6 +135,21 @@ public record ParsedCertificationRequest(PkiId requestId, FormatId formatId, Sub
if (requestedProfileId == null) { if (requestedProfileId == null) {
throw new IllegalArgumentException("requestedProfileId must not be 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) { if (attributes == null) {
throw new IllegalArgumentException("attributes must not be null"); throw new IllegalArgumentException("attributes must not be null");
} }

View File

@@ -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;
}
}

View File

@@ -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");
}
}
}

View File

@@ -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;
}
}

View File

@@ -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<SubjectRdn> approvedSubject = validateSubject(request, policy);
List<SubjectAlternativeName> 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<SubjectRdn> validateSubject(ParsedCertificationRequest request, LeafCertificatePolicy policy) {
Map<SubjectRdnType, SubjectRdnRule> rules = new EnumMap<>(SubjectRdnType.class);
for (SubjectRdnRule rule : policy.subjectPolicy().rules()) {
rules.put(rule.type(), rule);
}
Map<SubjectRdnType, Integer> counts = new EnumMap<>(SubjectRdnType.class);
List<SubjectRdn> 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<SubjectAlternativeName> validateSans(ParsedCertificationRequest request,
LeafCertificatePolicy policy, boolean emptySubject) {
SubjectAlternativeNamePolicy sanPolicy = policy.subjectAlternativeNamePolicy();
List<SubjectAlternativeName> sans = request.subjectAlternativeNames();
if (sans.size() < sanPolicy.minimumTotal() || sans.size() > sanPolicy.maximumTotal()) {
throw reject("SAN_COUNT_INVALID");
}
Map<SubjectAlternativeNameType, SubjectAlternativeNameRule> rules =
new EnumMap<>(SubjectAlternativeNameType.class);
for (SubjectAlternativeNameRule rule : sanPolicy.rules()) {
rules.put(rule.type(), rule);
}
Map<SubjectAlternativeNameType, Integer> counts = new EnumMap<>(SubjectAlternativeNameType.class);
Set<SubjectAlternativeName> 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<Validity> 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) {
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -60,7 +60,8 @@ final class CredentialSnapshots {
/* default */ static Credential copy(Credential source) { /* default */ static Credential copy(Credential source) {
return new Credential(source.credentialId(), source.formatId(), source.issuerRef(), source.subjectRef(), 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())); copy(source.encoded()), copy(source.attributes()));
} }

View File

@@ -80,6 +80,7 @@ import zeroecho.pki.api.ca.CaState;
import zeroecho.pki.api.ca.IntermediateCertIssueCommand; import zeroecho.pki.api.ca.IntermediateCertIssueCommand;
import zeroecho.pki.api.ca.IntermediateCreateCommand; import zeroecho.pki.api.ca.IntermediateCreateCommand;
import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.credential.CaProfileBinding;
import zeroecho.pki.api.credential.CredentialStatus; import zeroecho.pki.api.credential.CredentialStatus;
import zeroecho.pki.api.credential.CredentialUse; import zeroecho.pki.api.credential.CredentialUse;
import zeroecho.pki.api.credential.EffectiveCredentialStatus; import zeroecho.pki.api.credential.EffectiveCredentialStatus;
@@ -150,6 +151,9 @@ import zeroecho.pki.spi.store.PkiStore;
public final class DefaultCaService implements CaService { public final class DefaultCaService implements CaService {
private static final Logger LOG = Logger.getLogger(DefaultCaService.class.getName()); 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 PkiStore store;
private final CredentialFramework framework; private final CredentialFramework framework;
@@ -314,8 +318,9 @@ public final class DefaultCaService implements CaService {
PkiId publicKeyId = new PkiId("spki:" + sha256Hex(spki.bytes())); PkiId publicKeyId = new PkiId("spki:" + sha256Hex(spki.bytes()));
Credential credential = new Credential(credId, command.formatId(), new IssuerRef(caId), subjectRef, validity, 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()); 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)); CaRecord ca = new CaRecord(caId, CaKind.ROOT, CaState.ACTIVE, keyRef, subjectRef, List.of(credential));
store.putCa(ca); store.putCa(ca);
@@ -387,8 +392,10 @@ public final class DefaultCaService implements CaService {
BigInteger serial = holder.getSerialNumber(); BigInteger serial = holder.getSerialNumber();
Credential credential = new Credential(credId, command.formatId(), new IssuerRef(caId), command.subjectRef(), 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()); new EncodedObject(Encoding.DER, certDer), command.attributes());
CredentialProfileBindings.requireCaBinding(credential.profileBinding(), command.profileId());
store.putCredential(credential); store.putCredential(credential);
CaRecord ca = new CaRecord(caId, CaKind.ROOT, CaState.ACTIVE, command.keyRef(), command.subjectRef(), CaRecord ca = new CaRecord(caId, CaKind.ROOT, CaState.ACTIVE, command.keyRef(), command.subjectRef(),
List.of(credential)); List.of(credential));
@@ -442,7 +449,7 @@ public final class DefaultCaService implements CaService {
throw new PkiException("Issuer CA has no credentials"); throw new PkiException("Issuer CA has no credentials");
} }
if (!framework.formatId().equals(command.formatId())) { 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"); "FORMAT_UNSUPPORTED");
} }
EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation(); 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)); .getBytes(java.nio.charset.StandardCharsets.UTF_8)).substring(0, 16));
CaProofGate.ManagedKeyProof subjectProof = proofGate.proveManagedKey(command.keyRef().get(), 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(); EncodedObject subjectSpki = subjectProof.exactPublicKey();
requireIssuerKeyBinding(issuer, issuerCredential, command.formatId(), "CREATE_INTERMEDIATE_REJECTED", requireIssuerKeyBinding(issuer, issuerCredential, command.formatId(), CREATE_INT_REJECTED,
Optional.of(caId)); Optional.of(caId));
AttributeSet authoritative = authoritativeIntermediateAttributes(command.attributes(), issuer, AttributeSet authoritative = authoritativeIntermediateAttributes(command.attributes(), issuer,
issuerCredential, subjectSpki, command.subjectRef()); issuerCredential, subjectSpki, command.subjectRef());
@@ -463,15 +470,24 @@ public final class DefaultCaService implements CaService {
ManagedCaIssuance.Operation.CREATE_INTERMEDIATE, command.issuerCaId(), caId, command.profileId(), ManagedCaIssuance.Operation.CREATE_INTERMEDIATE, command.issuerCaId(), caId, command.profileId(),
Optional.empty(), authoritative, command.subjectRef()); 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; Credential cred;
try { try {
cred = CredentialSnapshots.copy(issuerBackend.issueIntermediateCertificate(issue)); cred = CredentialSnapshots.copy(backendCredential);
} catch (RuntimeException ex) { // NOPMD - reject malformed or mutable framework output } catch (RuntimeException ex) { // NOPMD - reject malformed or mutable framework output
throw proofGate.rejection("CREATE_INTERMEDIATE_REJECTED", command.formatId(), Optional.of(caId), throw proofGate.rejection(CREATE_INT_REJECTED, command.formatId(), Optional.of(caId),
"BACKEND_CREDENTIAL_MISMATCH"); BACKEND_CRED_MISMATCH);
} }
requireIntermediateCredentialMatches(cred, issuerCredential, subjectSpki, command.subjectRef(), requireIntermediateCredentialMatches(cred, issuerCredential, subjectSpki, command.subjectRef(),
command.issuerCaId(), caId, command.profileId(), "CREATE_INTERMEDIATE_REJECTED"); command.issuerCaId(), caId, CREATE_INT_REJECTED);
store.putCredential(cred); store.putCredential(cred);
CaRecord subject = new CaRecord(caId, CaKind.INTERMEDIATE, CaState.ACTIVE, command.keyRef().get(), 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()); CaRecord subject = getCa(command.subjectCaId());
ensureActive(subject, "subject"); ensureActive(subject, "subject");
if (!framework.formatId().equals(command.formatId())) { 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"); "FORMAT_UNSUPPORTED");
} }
EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation(); EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation();
@@ -520,9 +537,9 @@ public final class DefaultCaService implements CaService {
CredentialUse.INTERMEDIATE_ISSUER, statusEvaluation)); CredentialUse.INTERMEDIATE_ISSUER, statusEvaluation));
CaProofGate.ManagedKeyProof subjectProof = proofGate.proveManagedKey(subject.issuerKeyRef(), 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(); EncodedObject subjectSpki = subjectProof.exactPublicKey();
requireIssuerKeyBinding(issuer, issuerCredential, command.formatId(), "ISSUE_INTERMEDIATE_REJECTED", requireIssuerKeyBinding(issuer, issuerCredential, command.formatId(), ISSUE_INT_REJECTED,
Optional.of(subject.caId())); Optional.of(subject.caId()));
AttributeSet authoritative = authoritativeIntermediateAttributes(command.attributes(), issuer, AttributeSet authoritative = authoritativeIntermediateAttributes(command.attributes(), issuer,
issuerCredential, subjectSpki, subject.subjectRef()); issuerCredential, subjectSpki, subject.subjectRef());
@@ -530,15 +547,24 @@ public final class DefaultCaService implements CaService {
ManagedCaIssuance.Operation.ISSUE_INTERMEDIATE, command.issuerCaId(), command.subjectCaId(), ManagedCaIssuance.Operation.ISSUE_INTERMEDIATE, command.issuerCaId(), command.subjectCaId(),
command.profileId(), command.requestedValidity(), authoritative, subject.subjectRef()); 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; Credential cred;
try { try {
cred = CredentialSnapshots.copy(issuerBackend.issueIntermediateCertificate(gated)); cred = CredentialSnapshots.copy(backendCredential);
} catch (RuntimeException ex) { // NOPMD - reject malformed or mutable framework output } catch (RuntimeException ex) { // NOPMD - reject malformed or mutable framework output
throw proofGate.rejection("ISSUE_INTERMEDIATE_REJECTED", command.formatId(), Optional.of(subject.caId()), throw proofGate.rejection(ISSUE_INT_REJECTED, command.formatId(),
"BACKEND_CREDENTIAL_MISMATCH"); Optional.of(subject.caId()), BACKEND_CRED_MISMATCH);
} }
requireIntermediateCredentialMatches(cred, issuerCredential, subjectSpki, subject.subjectRef(), 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); store.putCredential(cred);
List<Credential> updated = new ArrayList<>(subject.caCredentials()); List<Credential> updated = new ArrayList<>(subject.caCredentials());
@@ -771,16 +797,15 @@ public final class DefaultCaService implements CaService {
private void requireIntermediateCredentialMatches(Credential credential, Credential issuerCredential, private void requireIntermediateCredentialMatches(Credential credential, Credential issuerCredential,
EncodedObject exactSubjectSpki, SubjectRef subjectRef, PkiId issuerCaId, PkiId subjectCaId, EncodedObject exactSubjectSpki, SubjectRef subjectRef, PkiId issuerCaId, PkiId subjectCaId,
String profileId, String action) { String action) {
try { try {
if (!framework.formatId().equals(credential.formatId()) if (!framework.formatId().equals(credential.formatId())
|| credential.encoded().encoding() != Encoding.DER || credential.encoded().encoding() != Encoding.DER
|| credential.status() != CredentialStatus.ISSUED || credential.status() != CredentialStatus.ISSUED
|| !credential.profileId().equals(profileId)
|| !credential.subjectRef().equals(subjectRef) || !credential.subjectRef().equals(subjectRef)
|| !credential.issuerRef().equals(new IssuerRef(issuerCaId))) { || !credential.issuerRef().equals(new IssuerRef(issuerCaId))) {
throw proofGate.rejection(action, framework.formatId(), Optional.of(subjectCaId), throw proofGate.rejection(action, framework.formatId(), Optional.of(subjectCaId),
"BACKEND_CREDENTIAL_MISMATCH"); BACKEND_CRED_MISMATCH);
} }
X509CertificateHolder holder = new X509CertificateHolder(credential.encoded().bytes()); X509CertificateHolder holder = new X509CertificateHolder(credential.encoded().bytes());
X509CertificateHolder issuerHolder = new X509CertificateHolder(issuerCredential.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() || credential.validity().notAfter().getEpochSecond() != holder.getNotAfter().toInstant()
.getEpochSecond()) { .getEpochSecond()) {
throw proofGate.rejection(action, framework.formatId(), Optional.of(subjectCaId), throw proofGate.rejection(action, framework.formatId(), Optional.of(subjectCaId),
"BACKEND_CREDENTIAL_MISMATCH"); BACKEND_CRED_MISMATCH);
} }
} catch (PkiException ex) { } catch (PkiException ex) {
throw ex; throw ex;
} catch (Exception ex) { } catch (Exception ex) {
throw proofGate.rejection(action, framework.formatId(), Optional.of(subjectCaId), 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<PkiId> objectId) {
try {
CredentialProfileBindings.requireCaBinding(
credential == null ? null : credential.profileBinding(), expectedCaProfileId);
} catch (PkiException mismatch) {
throw proofGate.rejection(action, formatId, objectId, CredentialProfileBindings.MISMATCH_CODE);
} }
} }

View File

@@ -33,7 +33,9 @@
******************************************************************************/ ******************************************************************************/
package zeroecho.pki.impl.core; package zeroecho.pki.impl.core;
import java.math.BigInteger;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.time.Clock;
import java.time.Instant; import java.time.Instant;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.List; import java.util.List;
@@ -41,7 +43,6 @@ import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.cert.X509CertificateHolder; import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder; import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder;
@@ -49,10 +50,9 @@ import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.Encoding; import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.FormatId; import zeroecho.pki.api.FormatId;
import zeroecho.pki.api.IssuanceService; import zeroecho.pki.api.IssuanceService;
import zeroecho.pki.api.KeyRef;
import zeroecho.pki.api.PkiException; import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.ProfileService;
import zeroecho.pki.api.PkiId; import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.attr.AttributeSet;
import zeroecho.pki.api.attr.AttributeValue; import zeroecho.pki.api.attr.AttributeValue;
import zeroecho.pki.api.audit.AuditEvent; import zeroecho.pki.api.audit.AuditEvent;
import zeroecho.pki.api.audit.Principal; 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.RenewCommand;
import zeroecho.pki.api.issuance.ReplaceCommand; import zeroecho.pki.api.issuance.ReplaceCommand;
import zeroecho.pki.api.issuance.VerificationPolicy; 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.CertificationRequest;
import zeroecho.pki.api.request.ParsedCertificationRequest; import zeroecho.pki.api.request.ParsedCertificationRequest;
import zeroecho.pki.api.request.ProofOfPossessionResult; import zeroecho.pki.api.request.ProofOfPossessionResult;
import zeroecho.pki.api.request.ProofOfPossessionStatus; 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.BcX509Attributes;
import zeroecho.pki.impl.framework.x509.bc.BcX509ProfileSupport;
import zeroecho.pki.spi.audit.AuditSink; import zeroecho.pki.spi.audit.AuditSink;
import zeroecho.pki.spi.framework.CredentialFramework; import zeroecho.pki.spi.framework.CredentialFramework;
import zeroecho.pki.spi.framework.CredentialIssuerBackend; import zeroecho.pki.spi.framework.CredentialIssuerBackend;
@@ -95,20 +97,20 @@ import zeroecho.pki.spi.store.PkiStore;
* </p> * </p>
* *
* <p> * <p>
* The implementation intentionally enforces only a limited set of issuance-side * The implementation enforces these issuance-side invariants:
* invariants:
* </p> * </p>
* <ul> * <ul>
* <li>the issuer CA must exist,</li> * <li>the issuer CA must exist,</li>
* <li>the issuer CA must be in {@link CaState#ACTIVE} state,</li> * <li>the issuer CA must be in {@link CaState#ACTIVE} state,</li>
* <li>the issuer CA must expose an effectively usable credential for the active * <li>the issuer CA must expose an effectively usable credential for the active
* framework {@link FormatId},</li> * framework {@link FormatId},</li>
* <li>issuer material required by the current X.509 runtime wiring must be * <li>the signed CSR is reparsed into bounded typed subject and SAN values and
* present in issuance overrides before the backend is invoked,</li> * validated against one exact active deny-by-default profile,</li>
* <li>the backend result is defensively snapshotted and its X.509 subject key, * <li>only a gate-produced {@link ValidatedCertificateRequest} crosses the
* subject, issuer, signature, identifiers, status, profile, serial, and validity * issuer-backend boundary,</li>
* metadata must match the verified request and selected issuer before * <li>the backend result is reparsed and its subject, SAN, public key, issuer,
* persistence.</li> * serial, validity, BasicConstraints, KU, EKU, extension criticality, complete
* extension set, and signature must match before persistence.</li>
* </ul> * </ul>
* *
* <p> * <p>
@@ -121,8 +123,8 @@ import zeroecho.pki.spi.store.PkiStore;
* <h2>Security considerations</h2> * <h2>Security considerations</h2>
* <ul> * <ul>
* <li>This service does not access private key material directly.</li> * <li>This service does not access private key material directly.</li>
* <li>Issuer key usage is represented only through {@link KeyRef} indirection * <li>Issuer key usage is represented only through an opaque key reference
* in backend overrides.</li> * supplied by the trusted issuer selection boundary.</li>
* <li>The correctness of issuance semantics depends on the configured * <li>The correctness of issuance semantics depends on the configured
* {@link CredentialFramework} matching the requested runtime format and on the * {@link CredentialFramework} matching the requested runtime format and on the
* backend honoring the supplied issuer material.</li> * backend honoring the supplied issuer material.</li>
@@ -153,6 +155,8 @@ public final class DefaultIssuanceService implements IssuanceService {
private final CredentialIssuerBackend issuerBackend; private final CredentialIssuerBackend issuerBackend;
private final AuditSink auditSink; private final AuditSink auditSink;
private final EffectiveCredentialStatusResolver statusResolver; private final EffectiveCredentialStatusResolver statusResolver;
private final ProfileService profileService;
private final Clock clock;
/** /**
* Creates the issuance service bound to the supplied persistence and framework * 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, public DefaultIssuanceService(PkiStore store, CredentialFramework framework,
CredentialIssuerBackend issuerBackend, AuditSink auditSink, CredentialIssuerBackend issuerBackend, AuditSink auditSink,
EffectiveCredentialStatusResolver statusResolver) { EffectiveCredentialStatusResolver statusResolver, ProfileService profileService, Clock clock) {
this.store = Objects.requireNonNull(store, "store"); this.store = Objects.requireNonNull(store, "store");
this.framework = Objects.requireNonNull(framework, "framework"); this.framework = Objects.requireNonNull(framework, "framework");
this.issuerBackend = Objects.requireNonNull(issuerBackend, "issuerBackend"); this.issuerBackend = Objects.requireNonNull(issuerBackend, "issuerBackend");
this.auditSink = Objects.requireNonNull(auditSink, "auditSink"); this.auditSink = Objects.requireNonNull(auditSink, "auditSink");
this.statusResolver = Objects.requireNonNull(statusResolver, "statusResolver"); 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"); 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(); EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation();
Credential issuerCred = CredentialSnapshots.copy(selectIssuerCredential(issuer, framework.formatId(), Credential issuerCred = CredentialSnapshots.copy(selectIssuerCredential(issuer, framework.formatId(),
CredentialUse.END_ENTITY_ISSUER, statusEvaluation)); CredentialUse.END_ENTITY_ISSUER, statusEvaluation));
VerifiedIssuanceCandidate candidate = verifyIssuanceCandidate(command); ValidatedCertificateRequest validated;
AttributeSet enrichedOverrides = enrichOverrides(command.overrides(), issuerCred.encoded(), try {
issuer.issuerKeyRef()); validated = CertificateProfileValidator.validate(candidate, profile, active.reference(), issuerCred,
evaluationTime);
// Defensive invariant: the X.509 backend requires issuer material in overrides. } catch (PkiException exception) {
if (enrichedOverrides.get(BcX509Attributes.ISSUER_CERT_DER).isEmpty()) { throw rejection(candidate.request(), statusCode(exception));
throw new PkiException("Issuer material wiring failed: missing issuer cert DER override");
} }
if (enrichedOverrides.get(BcX509Attributes.ISSUER_KEYREF).isEmpty()) { BigInteger serial = CertificateSerialAllocator.allocate();
throw new PkiException("Issuer material wiring failed: missing issuer keyref override");
}
candidate = candidate.withAuthoritativeOverrides(command, enrichedOverrides);
CredentialBundle bundle; CredentialBundle bundle;
try { 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 } catch (RuntimeException ex) { // NOPMD - framework output must cross the snapshot boundary
throw rejection(candidate.request(), "BACKEND_CREDENTIAL_MISMATCH"); throw rejection(candidate.request(), "BACKEND_CREDENTIAL_MISMATCH");
} }
requireIssuedCredentialMatches(candidate, command, issuerCred, bundle); requireIssuedCredentialMatches(validated, issuerCred, serial, bundle, candidate.request());
store.putCredential(bundle.credential()); store.putCredential(bundle.credential());
return bundle; return bundle;
} }
@@ -301,47 +320,6 @@ public final class DefaultIssuanceService implements IssuanceService {
throw new PkiException("Issuer credential unavailable: code=ISSUER_CREDENTIAL_UNAVAILABLE"); 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.
*
* <p>
* 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.
* </p>
*
* <p>
* The returned {@link AttributeSet} is a newly built instance and does not
* mutate the caller-supplied set.
* </p>
*
* @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) { private VerifiedIssuanceCandidate verifyIssuanceCandidate(IssueEndEntityCommand command) {
ParsedCertificationRequest supplied = command.request(); ParsedCertificationRequest supplied = command.request();
byte[] csrDer = extractCsrDer(supplied); byte[] csrDer = extractCsrDer(supplied);
@@ -350,7 +328,7 @@ public final class DefaultIssuanceService implements IssuanceService {
requireExactMatch(supplied, reparsed); requireExactMatch(supplied, reparsed);
ProofOfPossessionStatus proofKind = requireVerifiedProof(supplied, reparsed); ProofOfPossessionStatus proofKind = requireVerifiedProof(supplied, reparsed);
return new VerifiedIssuanceCandidate(reparsed, reparsed.requestId(), reparsed.publicKeyInfo(), proofKind, return new VerifiedIssuanceCandidate(reparsed, reparsed.requestId(), reparsed.publicKeyInfo(), proofKind,
command, command.overrides()); command);
} finally { } finally {
java.util.Arrays.fill(csrDer, (byte) 0); java.util.Arrays.fill(csrDer, (byte) 0);
} }
@@ -400,6 +378,15 @@ public final class DefaultIssuanceService implements IssuanceService {
if (!supplied.subjectRef().equals(reparsed.subjectRef())) { if (!supplied.subjectRef().equals(reparsed.subjectRef())) {
throw rejection(supplied, "SUBJECT_MISMATCH"); 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[] suppliedSpki = supplied.publicKeyInfo().bytes();
byte[] reparsedSpki = reparsed.publicKeyInfo().bytes(); byte[] reparsedSpki = reparsed.publicKeyInfo().bytes();
try { try {
@@ -437,7 +424,7 @@ public final class DefaultIssuanceService implements IssuanceService {
private PkiException rejection(ParsedCertificationRequest request, String code) { private PkiException rejection(ParsedCertificationRequest request, String code) {
PkiException rejection = new PkiException("End-entity issuance rejected: " + code); PkiException rejection = new PkiException("End-entity issuance rejected: " + code);
try { 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()), ISSUANCE_PURPOSE, Optional.empty(), Optional.of(request.formatId()),
Map.of("code", code))); Map.of("code", code)));
} catch (RuntimeException auditFailure) { // NOPMD - preserve stable rejection and fail closed } catch (RuntimeException auditFailure) { // NOPMD - preserve stable rejection and fail closed
@@ -446,47 +433,58 @@ public final class DefaultIssuanceService implements IssuanceService {
return rejection; return rejection;
} }
private void requireIssuedCredentialMatches(VerifiedIssuanceCandidate candidate, IssueEndEntityCommand command, private void requireIssuedCredentialMatches(ValidatedCertificateRequest validated, Credential issuerCredential,
Credential issuerCredential, CredentialBundle bundle) { BigInteger allocatedSerial, CredentialBundle bundle, ParsedCertificationRequest auditRequest) {
ParsedCertificationRequest verifiedRequest = candidate.request();
if (bundle == null || bundle.credential() == null) { if (bundle == null || bundle.credential() == null) {
throw rejection(verifiedRequest, "BACKEND_CREDENTIAL_MISMATCH"); throw rejection(auditRequest, "BACKEND_CREDENTIAL_MISMATCH");
} }
Credential credential = bundle.credential(); Credential credential = bundle.credential();
try { try {
CredentialProfileBindings.requireEndEntityBinding(credential.profileBinding(),
validated.profileReference());
if (!framework.formatId().equals(credential.formatId()) if (!framework.formatId().equals(credential.formatId())
|| credential.encoded().encoding() != Encoding.DER || credential.encoded().encoding() != Encoding.DER
|| !credential.subjectRef().equals(verifiedRequest.subjectRef()) || !credential.subjectRef().equals(validated.subjectRef())
|| !credential.issuerRef().equals(new zeroecho.pki.api.IssuerRef(command.issuerCaId())) || !credential.issuerRef().equals(new zeroecho.pki.api.IssuerRef(validated.issuerCaId()))
|| credential.status() != CredentialStatus.ISSUED || credential.status() != CredentialStatus.ISSUED) {
|| !credential.profileId().equals(command.profileId())) { throw rejection(auditRequest, "BACKEND_CREDENTIAL_MISMATCH");
throw rejection(verifiedRequest, "BACKEND_CREDENTIAL_MISMATCH");
} }
X509CertificateHolder holder = new X509CertificateHolder(credential.encoded().bytes()); X509CertificateHolder holder = new X509CertificateHolder(credential.encoded().bytes());
X509CertificateHolder issuerHolder = new X509CertificateHolder(issuerCredential.encoded().bytes()); X509CertificateHolder issuerHolder = new X509CertificateHolder(issuerCredential.encoded().bytes());
byte[] actualSpki = holder.getSubjectPublicKeyInfo().getEncoded(); byte[] actualSpki = holder.getSubjectPublicKeyInfo().getEncoded();
if (!MessageDigest.isEqual(candidate.exactPublicKey().bytes(), actualSpki) if (!MessageDigest.isEqual(validated.exactPublicKey().bytes(), actualSpki)
|| !holder.getSubject().equals(new X500Name(verifiedRequest.subjectRef().value())) || !holder.getSubject().equals(BcX509ProfileSupport.subject(validated.subjectRdns()))
|| !holder.getIssuer().equals(issuerHolder.getSubject()) || !holder.getIssuer().equals(issuerHolder.getSubject())
|| !holder.isSignatureValid( || !holder.isSignatureValid(
new JcaContentVerifierProviderBuilder().build(issuerHolder.getSubjectPublicKeyInfo())) new JcaContentVerifierProviderBuilder().build(issuerHolder.getSubjectPublicKeyInfo()))
|| !holder.getSerialNumber().equals(allocatedSerial)
|| !credential.publicKeyId().equals(new PkiId("spki:" + sha256Hex(actualSpki))) || !credential.publicKeyId().equals(new PkiId("spki:" + sha256Hex(actualSpki)))
|| !credential.credentialId() || !credential.credentialId()
.equals(new PkiId("x509:" + sha256Hex(credential.encoded().bytes()))) .equals(new PkiId("x509:" + sha256Hex(credential.encoded().bytes())))
|| !credential.serialOrUniqueId().equals(holder.getSerialNumber().toString()) || !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() .getEpochSecond()
|| credential.validity().notAfter().getEpochSecond() != holder.getNotAfter().toInstant() || validated.validity().notAfter().getEpochSecond() != holder.getNotAfter().toInstant()
.getEpochSecond()) { .getEpochSecond()
throw rejection(verifiedRequest, "BACKEND_CREDENTIAL_MISMATCH"); || !BcX509ProfileSupport.matchesLeafExtensions(holder, validated)) {
throw rejection(auditRequest, "BACKEND_CREDENTIAL_MISMATCH");
} }
} catch (CredentialProfileBindings.Mismatch mismatch) {
throw rejection(auditRequest, CredentialProfileBindings.MISMATCH_CODE);
} catch (PkiException ex) { } catch (PkiException ex) {
throw ex; throw ex;
} catch (Exception 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) { private static String sha256Hex(byte[] input) {
try { try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(input)); return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(input));

View File

@@ -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<ImportedCertificateProfileVersion> 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<ImportedCertificateProfileVersion> listImportedVersions(String profileId) {
requireProfileId(profileId);
return executeSanitized(() -> store.listProfileVersions(profileId), Code.PROFILE_STORE_FAILURE);
}
@Override
public Optional<CertificateProfileRef> 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<String, String> 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> T executeSanitized(Supplier<T> 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> T executeAudited(String operation, String profileId, long profileVersion,
Code fallback, Supplier<T> action) {
try {
return action.get();
} catch (RuntimeException failure) {
ProfileLifecycleFailure sanitized = sanitize(failure, fallback);
auditFailure(operation, profileId, profileVersion, sanitized.code().name());
throw sanitized;
}
}
}

View File

@@ -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.
*
* <p>
* 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.
* </p>
*/
@SuppressWarnings({ "PMD.DataClass", "PMD.ExcessiveParameterList" })
public final class ValidatedCertificateRequest {
private final PkiId issuerCaId;
private final CertificateProfileRef profileReference;
private final SubjectRef subjectRef;
private final List<SubjectRdn> subjectRdns;
private final List<SubjectAlternativeName> subjectAlternativeNames;
private final boolean subjectAlternativeNameCritical;
private final EncodedObject exactPublicKey;
private final Validity validity;
private final Set<LeafKeyUsage> keyUsages;
private final Set<ExtendedKeyUsageId> extendedKeyUsages;
private final boolean keyUsageCritical;
private final boolean extendedKeyUsageCritical;
private final boolean basicConstraintsCritical;
/* default */ ValidatedCertificateRequest(PkiId issuerCaId, CertificateProfileRef profileReference,
SubjectRef subjectRef,
List<SubjectRdn> subjectRdns, List<SubjectAlternativeName> subjectAlternativeNames,
boolean subjectAlternativeNameCritical, EncodedObject exactPublicKey, Validity validity,
Set<LeafKeyUsage> keyUsages, Set<ExtendedKeyUsageId> 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<SubjectRdn> subjectRdns() {
return subjectRdns;
}
/** @return ordered immutable approved SAN entries */
public List<SubjectAlternativeName> 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<LeafKeyUsage> keyUsages() {
return keyUsages;
}
/** @return exact profile-derived extended key usages */
public Set<ExtendedKeyUsageId> 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;
}
}

View File

@@ -33,21 +33,22 @@
******************************************************************************/ ******************************************************************************/
package zeroecho.pki.impl.core; package zeroecho.pki.impl.core;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.ArrayList;
import java.util.List;
import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.PkiId; import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.Validity; 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.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.ParsedCertificationRequest;
import zeroecho.pki.api.request.ProofOfPossessionStatus; import zeroecho.pki.api.request.ProofOfPossessionStatus;
import zeroecho.pki.impl.core.attr.SimpleAttributeSet; 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. * 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 PkiId issuerCaId;
private final String profileId; private final String profileId;
private final Optional<Validity> validityOverride; private final Optional<Validity> validityOverride;
private final AttributeSet overrides;
/* default */ VerifiedIssuanceCandidate(ParsedCertificationRequest request, PkiId fingerprint, /* default */ VerifiedIssuanceCandidate(ParsedCertificationRequest request, PkiId fingerprint,
EncodedObject exactPublicKey, ProofOfPossessionStatus proofKind, IssueEndEntityCommand command, EncodedObject exactPublicKey, ProofOfPossessionStatus proofKind, IssueEndEntityCommand command) {
AttributeSet authoritativeOverrides) {
this.request = snapshot(Objects.requireNonNull(request, "request")); this.request = snapshot(Objects.requireNonNull(request, "request"));
this.fingerprint = Objects.requireNonNull(fingerprint, "fingerprint"); this.fingerprint = Objects.requireNonNull(fingerprint, "fingerprint");
this.exactPublicKey = copy(Objects.requireNonNull(exactPublicKey, "exactPublicKey")); this.exactPublicKey = copy(Objects.requireNonNull(exactPublicKey, "exactPublicKey"));
@@ -81,7 +80,6 @@ public final class VerifiedIssuanceCandidate {
this.issuerCaId = checkedCommand.issuerCaId(); this.issuerCaId = checkedCommand.issuerCaId();
this.profileId = checkedCommand.profileId(); this.profileId = checkedCommand.profileId();
this.validityOverride = checkedCommand.validityOverride(); this.validityOverride = checkedCommand.validityOverride();
this.overrides = snapshotAttributes(Objects.requireNonNull(authoritativeOverrides, "authoritativeOverrides"));
} }
/** /**
@@ -147,44 +145,40 @@ public final class VerifiedIssuanceCandidate {
return validityOverride; 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) { /* default */ static ParsedCertificationRequest snapshot(ParsedCertificationRequest source) {
Objects.requireNonNull(source, "source"); Objects.requireNonNull(source, "source");
return new ParsedCertificationRequest(source.requestId(), source.formatId(), source.subjectRef(), return new ParsedCertificationRequest(source.requestId(), source.formatId(), source.subjectRef(),
copy(source.publicKeyInfo()), source.requestedValidity(), source.requestedProfileId(), 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) { private static EncodedObject copy(EncodedObject source) {
return new EncodedObject(source.encoding(), source.bytes().clone()); 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) { /* default */ static AttributeSet snapshotAttributes(AttributeSet source) {
List<SimpleAttributeSet.Entry> entries = new ArrayList<>(); List<SimpleAttributeSet.Entry> entries = new ArrayList<>();
for (AttributeId id : source.ids()) { for (AttributeId id : source.ids()) {
List<AttributeValue> values = source.getAll(id).stream().map(VerifiedIssuanceCandidate::copy).toList(); List<AttributeValue> values = source.getAll(id).stream().map(VerifiedIssuanceCandidate::copyValue).toList();
entries.add(new SimpleAttributeSet.Entry(id, values)); entries.add(new SimpleAttributeSet.Entry(id, values));
} }
return new SimpleAttributeSet(entries); return new SimpleAttributeSet(entries);
} }
private static AttributeValue copy(AttributeValue value) { private static AttributeValue copyValue(AttributeValue value) {
if (value instanceof AttributeValue.BytesValue bytesValue) { if (value instanceof AttributeValue.BytesValue bytesValue) {
return new AttributeValue.BytesValue(bytesValue.value().clone()); return new AttributeValue.BytesValue(bytesValue.value());
} }
return value; return value;
} }

View File

@@ -38,10 +38,27 @@ import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; 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.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.pkcs.PKCS10CertificationRequest;
import org.bouncycastle.util.io.pem.PemObject; import org.bouncycastle.util.io.pem.PemObject;
import org.bouncycastle.util.io.pem.PemReader; 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.PkiId;
import zeroecho.pki.api.SubjectRef; import zeroecho.pki.api.SubjectRef;
import zeroecho.pki.api.attr.AttributeValue; import zeroecho.pki.api.attr.AttributeValue;
import zeroecho.pki.api.profile.SubjectRdnType;
import zeroecho.pki.api.request.CertificationRequest; import zeroecho.pki.api.request.CertificationRequest;
import zeroecho.pki.api.request.ParsedCertificationRequest; import zeroecho.pki.api.request.ParsedCertificationRequest;
import zeroecho.pki.api.request.SubjectAlternativeName;
import zeroecho.pki.api.request.SubjectRdn;
import zeroecho.pki.spi.framework.CertificationRequestParser; import zeroecho.pki.spi.framework.CertificationRequestParser;
/** /**
@@ -108,9 +128,13 @@ import zeroecho.pki.spi.framework.CertificationRequestParser;
* This class is stateless and thread-safe. * This class is stateless and thread-safe.
* </p> * </p>
*/ */
// PMD cannot infer that retaining parser causes would violate the redaction contract. // PMD cannot infer the redaction boundary or the explicit closed parsing grammar.
@SuppressWarnings("PMD.PreserveStackTrace") @SuppressWarnings({ "PMD.PreserveStackTrace", "PMD.CyclomaticComplexity" })
public final class BcX509CertificationRequestParser implements CertificationRequestParser { public final class BcX509CertificationRequestParser implements CertificationRequestParser {
private static final int MAXIMUM_SUBJECT_DER_BYTES = 16 * 1024;
private static final int MAXIMUM_SAN_DER_BYTES = 32 * 1024;
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 * Parses a PKCS#10 certification request into the normalized PKI request
* representation. * representation.
@@ -165,7 +189,9 @@ public final class BcX509CertificationRequestParser implements CertificationRequ
} }
X500Name subject = csr.getSubject(); X500Name subject = csr.getSubject();
SubjectRef subjectRef = new SubjectRef(subject.toString()); List<SubjectRdn> subjectRdns = parseSubject(subject);
SubjectRef subjectRef = subjectReference(subjectRdns);
List<SubjectAlternativeName> subjectAlternativeNames = parseSubjectAlternativeNames(csr);
try { try {
spki = csr.getSubjectPublicKeyInfo().getEncoded(); spki = csr.getSubjectPublicKeyInfo().getEncoded();
@@ -180,7 +206,8 @@ public final class BcX509CertificationRequestParser implements CertificationRequ
.put(BcX509Attributes.CSR_DER, new AttributeValue.BytesValue(csrDer.clone())).build(); .put(BcX509Attributes.CSR_DER, new AttributeValue.BytesValue(csrDer.clone())).build();
return new ParsedCertificationRequest(requestId, request.formatId(), subjectRef, publicKeyInfo, return new ParsedCertificationRequest(requestId, request.formatId(), subjectRef, publicKeyInfo,
Optional.empty(), Optional.empty(), attrs); Optional.empty(), Optional.empty(), subjectRdns, subjectAlternativeNames,
!subjectAlternativeNames.isEmpty(), attrs);
} finally { } finally {
java.util.Arrays.fill(csrDer, (byte) 0); java.util.Arrays.fill(csrDer, (byte) 0);
if (spki != null) { 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<SubjectRdn> 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<SubjectRdn> 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<SubjectRdn> 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<SubjectAlternativeName> 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<SubjectAlternativeName> result = new ArrayList<>(names.length);
Set<SubjectAlternativeName> 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. * Converts the supplied encoded object to DER form.
* *

View File

@@ -41,9 +41,15 @@ import java.util.Date;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.Optional; import java.util.Optional;
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
import org.bouncycastle.asn1.DEROctetString;
import org.bouncycastle.asn1.x500.X500Name; import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x509.BasicConstraints; import org.bouncycastle.asn1.x509.BasicConstraints;
import org.bouncycastle.asn1.x509.Extension; 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.KeyUsage;
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
import org.bouncycastle.cert.X509CertificateHolder; 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.AttributeSet;
import zeroecho.pki.api.attr.AttributeValue; import zeroecho.pki.api.attr.AttributeValue;
import zeroecho.pki.api.credential.Credential; 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.CredentialBundle;
import zeroecho.pki.api.credential.CredentialStatus; 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.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.async.PkiSigningBus;
import zeroecho.pki.impl.core.attr.SimpleAttributeSet; import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
import zeroecho.pki.spi.framework.CredentialIssuerBackend; import zeroecho.pki.spi.framework.CredentialIssuerBackend;
@@ -82,21 +92,11 @@ import zeroecho.pki.spi.framework.CredentialIssuerBackend;
* </p> * </p>
* *
* <p> * <p>
* The backend expects issuer-side runtime wiring to be supplied through * End-entity issuance derives all requester-influenced certificate material
* framework-specific attributes, especially: * exclusively from a profile-gated {@link ValidatedCertificateRequest}.
* </p> * Intermediate CA issuance retains its proof-gated managed-CA input and
* <ul> * framework attributes because it operates on an existing CA subject entity
* <li>{@link BcX509Attributes#ISSUER_CERT_DER},</li> * rather than the end-entity CSR flow.
* <li>{@link BcX509Attributes#ISSUER_KEYREF},</li>
* <li>optionally {@link BcX509Attributes#SERIAL}.</li>
* </ul>
*
* <p>
* 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.
* </p> * </p>
* *
* <h2>Signing model</h2> * <h2>Signing model</h2>
@@ -162,55 +162,45 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
* Issues an end-entity X.509 certificate and returns it as a credential bundle. * Issues an end-entity X.509 certificate and returns it as a credential bundle.
* *
* <p> * <p>
* The method derives issuer wiring from the supplied overrides, uses the parsed * The method consumes only validated identity and extension policy, trusted
* verified candidate to obtain the subject distinguished name and public key, * issuer material, and an issuer-controlled serial. It delegates signing
* constructs a * through {@link PkiSigningBus}.
* leaf certificate with basic end-entity extensions, delegates signing through
* {@link PkiSigningBus}, and returns the resulting leaf credential bundled with
* the issuer certificate.
* </p> * </p>
* *
* @param candidate gate-produced verified issuance candidate; must not be * @param request gate-produced validated request
* {@code null} * @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 * @return issued X.509 credential bundle containing the leaf certificate and
* the issuer certificate as accompanying bundle material * 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, * @throws PkiException if issuer wiring is missing or invalid,
* certificate construction fails, signing * certificate construction fails, signing
* fails, or certificate encoding fails * fails, or certificate encoding fails
*/ */
@Override @Override
public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) { public CredentialBundle issueEndEntity(ValidatedCertificateRequest request, EncodedObject issuerCertificate,
if (candidate == null) { KeyRef issuerKeyRef, BigInteger serial) {
throw new IllegalArgumentException("candidate must not be null"); 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 issuerDn = issuer.getSubject();
X500Name subjectDn = new X500Name(request.subjectRef().value()); X500Name subjectDn = BcX509ProfileSupport.subject(request.subjectRdns());
SubjectPublicKeyInfo spki = parseSubjectPublicKeyInfo(candidate.exactPublicKey()); SubjectPublicKeyInfo spki = parseSubjectPublicKeyInfo(request.exactPublicKey());
X509v3CertificateBuilder builder = new X509v3CertificateBuilder(issuerDn, serial, X509v3CertificateBuilder builder = new X509v3CertificateBuilder(issuerDn, serial,
Date.from(validity.notBefore()), Date.from(validity.notAfter()), subjectDn, spki); Date.from(request.validity().notBefore()), Date.from(request.validity().notAfter()), subjectDn, spki);
try { addLeafExtensions(builder, request);
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");
}
ContentSigner signer = new PkiBusContentSigner(signingBus, ctx.issuerKeyRef, signatureAlgorithmId, signingTtl); ContentSigner signer = new PkiBusContentSigner(signingBus, issuerKeyRef, signatureAlgorithmId, signingTtl);
X509CertificateHolder leaf; X509CertificateHolder leaf;
try { try {
leaf = builder.build(signer); leaf = builder.build(signer);
@@ -226,22 +216,74 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
} }
PkiId credId = new PkiId("x509:" + sha256Hex(certDer)); PkiId credId = new PkiId("x509:" + sha256Hex(certDer));
PkiId publicKeyId = new PkiId("spki:" + fingerprintEncoded(candidate.exactPublicKey())); PkiId publicKeyId = new PkiId("spki:" + fingerprintEncoded(request.exactPublicKey()));
AttributeSet attributes = mergeAttributes(request.attributes(), candidate.overrides());
try { try {
Credential credential = new Credential(credId, BcX509CredentialFramework.FORMAT_ID, Credential credential = new Credential(credId, BcX509CredentialFramework.FORMAT_ID,
new IssuerRef(candidate.issuerCaId()), request.subjectRef(), validity, serial.toString(), new IssuerRef(request.issuerCaId()), request.subjectRef(), request.validity(), serial.toString(),
publicKeyId, candidate.profileId(), CredentialStatus.ISSUED, publicKeyId, new EndEntityProfileBinding(request.profileReference()), CredentialStatus.ISSUED,
new EncodedObject(Encoding.DER, certDer), new EncodedObject(Encoding.DER, certDer),
attributes); SimpleAttributeSet.builder().build());
return new CredentialBundle(credential, java.util.List.of(ctx.issuerCertEncoded)); return new CredentialBundle(credential, java.util.List.of(issuerCertificate));
} finally { } finally {
java.util.Arrays.fill(certDer, (byte) 0); 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<LeafKeyUsage> 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. * Issues an intermediate CA X.509 certificate.
* *
@@ -318,7 +360,8 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
try { try {
return new Credential(credId, issuance.formatId(), new IssuerRef(issuance.issuerCaId()), subjectRef, 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()); new EncodedObject(Encoding.DER, certDer), issuance.attributes());
} finally { } finally {
java.util.Arrays.fill(certDer, (byte) 0); java.util.Arrays.fill(certDer, (byte) 0);
@@ -339,7 +382,6 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
private static final class IssuanceContext { private static final class IssuanceContext {
private final X509CertificateHolder issuerCertHolder; private final X509CertificateHolder issuerCertHolder;
private final EncodedObject issuerCertEncoded;
private final KeyRef issuerKeyRef; private final KeyRef issuerKeyRef;
private final Optional<BigInteger> serial; private final Optional<BigInteger> serial;
@@ -348,16 +390,13 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
* *
* @param issuerCertHolder parsed issuer certificate holder; must not be * @param issuerCertHolder parsed issuer certificate holder; must not be
* {@code null} * {@code null}
* @param issuerCertEncoded issuer certificate encoded object; must not be
* {@code null}
* @param issuerKeyRef issuer signing key reference; must not be * @param issuerKeyRef issuer signing key reference; must not be
* {@code null} * {@code null}
* @param serial optional serial override; must not be {@code null} * @param serial optional serial override; must not be {@code null}
*/ */
private IssuanceContext(X509CertificateHolder issuerCertHolder, EncodedObject issuerCertEncoded, private IssuanceContext(X509CertificateHolder issuerCertHolder, KeyRef issuerKeyRef,
KeyRef issuerKeyRef, Optional<BigInteger> serial) { Optional<BigInteger> serial) {
this.issuerCertHolder = issuerCertHolder; this.issuerCertHolder = issuerCertHolder;
this.issuerCertEncoded = issuerCertEncoded;
this.issuerKeyRef = issuerKeyRef; this.issuerKeyRef = issuerKeyRef;
this.serial = serial; this.serial = serial;
} }
@@ -404,9 +443,7 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
.map(BigInteger::valueOf); .map(BigInteger::valueOf);
X509CertificateHolder issuerHolder = parseIssuerCertificateOrThrow(issuerCertDer); X509CertificateHolder issuerHolder = parseIssuerCertificateOrThrow(issuerCertDer);
EncodedObject issuerEncoded = new EncodedObject(Encoding.DER, issuerCertDer); return new IssuanceContext(issuerHolder, issuerKeyRef, serial);
return new IssuanceContext(issuerHolder, issuerEncoded, issuerKeyRef, serial);
} }
/** /**
@@ -489,26 +526,6 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
} }
} }
/**
* Merges two attribute sets into a new immutable attribute set.
*
* <p>
* Values from {@code b} replace values from {@code a} for the same attribute
* identifier because {@link SimpleAttributeSet.Builder} uses last-write-wins
* semantics.
* </p>
*
* @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 * Computes the SHA-256 digest of the supplied bytes and returns it as a
* lowercase hexadecimal string. * lowercase hexadecimal string.

View File

@@ -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<SubjectRdn> 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<ASN1ObjectIdentifier> 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<ASN1ObjectIdentifier> 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<String> actualOids = new LinkedHashSet<>();
for (KeyPurposeId purpose : actualUsages) {
actualOids.add(purpose.getId());
}
Set<String> 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<LeafKeyUsage> 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;
}
}

View File

@@ -76,7 +76,9 @@ import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.orch.SigningSubmissionId; import zeroecho.pki.api.orch.SigningSubmissionId;
import zeroecho.pki.api.orch.WorkflowStateRecord; import zeroecho.pki.api.orch.WorkflowStateRecord;
import zeroecho.pki.api.policy.PolicyTrace; 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.publication.PublicationRecord;
import zeroecho.pki.api.request.ParsedCertificationRequest; import zeroecho.pki.api.request.ParsedCertificationRequest;
import zeroecho.pki.api.revocation.RevocationCommand; 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.RevocationState;
import zeroecho.pki.api.revocation.RevocationTransition; import zeroecho.pki.api.revocation.RevocationTransition;
import zeroecho.pki.api.status.StatusObject; 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.impl.core.async.PkiSigningBus;
import zeroecho.pki.spi.store.PkiStore; import zeroecho.pki.spi.store.PkiStore;
import zeroecho.pki.spi.store.SignWorkflowStore; import zeroecho.pki.spi.store.SignWorkflowStore;
@@ -162,6 +166,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
private final ReentrantLock signingTimeLock; private final ReentrantLock signingTimeLock;
private final ConcurrentMap<PkiId, SignLockEntry> signLocks; private final ConcurrentMap<PkiId, SignLockEntry> signLocks;
private final ConcurrentMap<PkiId, RevocationLockEntry> revocationLocks; private final ConcurrentMap<PkiId, RevocationLockEntry> revocationLocks;
private final ConcurrentMap<String, ProfileLockEntry> profileLocks;
private final AtomicBoolean durabilityUncertain; private final AtomicBoolean durabilityUncertain;
private final StoreOwnership ownership; private final StoreOwnership ownership;
@@ -197,6 +202,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
this.clock = Objects.requireNonNull(clock, "clock"); this.clock = Objects.requireNonNull(clock, "clock");
this.signLocks = new ConcurrentHashMap<>(); this.signLocks = new ConcurrentHashMap<>();
this.revocationLocks = new ConcurrentHashMap<>(); this.revocationLocks = new ConcurrentHashMap<>();
this.profileLocks = new ConcurrentHashMap<>();
this.durabilityUncertain = new AtomicBoolean(); this.durabilityUncertain = new AtomicBoolean();
this.signingTimeLock = new ReentrantLock(); this.signingTimeLock = new ReentrantLock();
this.paths = new FsPaths(root); this.paths = new FsPaths(root);
@@ -442,31 +448,123 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
} }
@Override @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(); requireStoreUsable();
Objects.requireNonNull(profile, "profile"); Objects.requireNonNull(version, "version");
String profileId = profile.profileId(); String profileId = version.reference().profileId();
Path current = this.paths.profileCurrent(profileId); ProfileLockEntry lock = acquireProfileLock(profileId);
try {
writeWithHistory(this.paths.profileHistoryDir(profileId), current, ValidatedImportedProfile.validate(version, profileId, version.reference().profileVersion());
FsCodec.encode(FsCodec.CERTIFICATE_PROFILE, profile), Path target = paths.profileVersion(profileId, version.reference().profileVersion());
this.options.profileHistoryPolicy(), "PROFILE", FsUtil.safeSegment(profileId)); 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);
}
} }
@Override @Override
public Optional<CertificateProfile> getProfile(final String profileId) { public Optional<ImportedCertificateProfileVersion> getProfileVersion(final String profileId,
final long profileVersion) {
requireStoreUsable(); requireStoreUsable();
if (profileId == null || profileId.isBlank()) { requireProfileIdentity(profileId, profileVersion);
throw new IllegalArgumentException("profileId must not be null/blank"); Path path = paths.profileVersion(profileId, profileVersion);
} return Files.exists(path) ? Optional.of(decodeProfileVersion(path, profileId, profileVersion))
return readOptional(this.paths.profileCurrent(profileId), FsCodec.CERTIFICATE_PROFILE); : Optional.empty();
} }
@Override @Override
public List<CertificateProfile> listProfiles() { // Filesystem failures are normalized to the stable store failure code.
@SuppressWarnings("PMD.PreserveStackTrace")
public List<ImportedCertificateProfileVersion> listProfileVersions(final String profileId) {
requireStoreUsable(); requireStoreUsable();
Path root = this.paths.root().resolve("profiles").resolve("by-id"); requireProfileIdentity(profileId, 1L);
return listCurrentRecords(root, FsCodec.CERTIFICATE_PROFILE); Path directory = paths.profileVersionsDir(profileId);
if (!Files.isDirectory(directory)) {
return List.of();
}
try (Stream<Path> files = Files.list(directory)) {
List<ImportedCertificateProfileVersion> 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<CertificateProfileRef> 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<CertificateProfileRef> 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 @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<CertificateProfileRef> 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<ValidatedImportedProfile> readValidatedActiveProfile(String profileId) {
ProfileLockEntry lock = acquireProfileLock(profileId);
try {
Optional<CertificateProfileRef> 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<RevocationJournal> readRevocationJournal(PkiId credentialId) { private Optional<RevocationJournal> readRevocationJournal(PkiId credentialId) {
Path path = paths.revocationJournal(credentialId); Path path = paths.revocationJournal(credentialId);
if (!Files.exists(path)) { if (!Files.exists(path)) {
@@ -1217,6 +1435,12 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
private final AtomicInteger references = new AtomicInteger(); 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. * Owns the operating-system resources that exclude a second store process.
* *

View File

@@ -39,7 +39,6 @@ import java.io.EOFException;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.OutputStream; import java.io.OutputStream;
import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.DateTimeException; import java.time.DateTimeException;
import java.util.ArrayList; 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.CaKind;
import zeroecho.pki.api.ca.CaRecord; import zeroecho.pki.api.ca.CaRecord;
import zeroecho.pki.api.ca.CaState; import zeroecho.pki.api.ca.CaState;
import zeroecho.pki.api.credential.CaProfileBinding;
import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.credential.CredentialProfileBinding;
import zeroecho.pki.api.credential.CredentialStatus; import zeroecho.pki.api.credential.CredentialStatus;
import zeroecho.pki.api.credential.EndEntityProfileBinding;
import zeroecho.pki.api.orch.OrchestrationDurabilityPolicy; import zeroecho.pki.api.orch.OrchestrationDurabilityPolicy;
import zeroecho.pki.api.orch.WorkflowStateRecord; import zeroecho.pki.api.orch.WorkflowStateRecord;
import zeroecho.pki.api.policy.PolicyTrace; import zeroecho.pki.api.policy.PolicyTrace;
import zeroecho.pki.api.policy.PolicyTraceStep; 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.PublicationRecord;
import zeroecho.pki.api.publication.PublicationStatus; import zeroecho.pki.api.publication.PublicationStatus;
import zeroecho.pki.api.publication.PublicationTarget; import zeroecho.pki.api.publication.PublicationTarget;
import zeroecho.pki.api.publication.PublicationTargetType; import zeroecho.pki.api.publication.PublicationTargetType;
import zeroecho.pki.api.request.ParsedCertificationRequest; 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.RevocationJournal;
import zeroecho.pki.api.revocation.RevocationReason; import zeroecho.pki.api.revocation.RevocationReason;
import zeroecho.pki.api.revocation.RevocationState; import zeroecho.pki.api.revocation.RevocationState;
@@ -104,6 +113,7 @@ import zeroecho.pki.spi.store.SignWorkflowStore;
* structurally and decoded to {@link SimpleAttributeSet}. * structurally and decoded to {@link SimpleAttributeSet}.
* </p> * </p>
*/ */
// The closed codec keeps every strict schema in one non-reflective authority.
@SuppressWarnings("PMD.CouplingBetweenObjects") @SuppressWarnings("PMD.CouplingBetweenObjects")
final class FsCodec { final class FsCodec {
@@ -119,17 +129,17 @@ final class FsCodec {
private static final int TOP_REVOCATION = 4; private static final int TOP_REVOCATION = 4;
private static final int TOP_STATUS_OBJECT = 5; private static final int TOP_STATUS_OBJECT = 5;
private static final int TOP_PUBLICATION = 6; 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_POLICY_TRACE = 8;
private static final int TOP_WORKFLOW_STATE = 9; private static final int TOP_WORKFLOW_STATE = 9;
private static final int TOP_SIGN_WORKFLOW_RECORD = 10; 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_STRING = 1;
private static final int TYPE_BOOLEAN = 2; private static final int TYPE_BOOLEAN = 2;
private static final int TYPE_LONG = 3; private static final int TYPE_LONG = 3;
private static final int TYPE_BYTES = 4; private static final int TYPE_BYTES = 4;
private static final int TYPE_INSTANT = 5; 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_LIST = 7;
private static final int TYPE_OPTIONAL = 8; private static final int TYPE_OPTIONAL = 8;
private static final int TYPE_PKI_ID = 20; 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_SIGN_STATE_ENUM = 59;
private static final int TYPE_REVOCATION_STATE_ENUM = 60; private static final int TYPE_REVOCATION_STATE_ENUM = 60;
private static final int TYPE_REVOCATION_TRANSITION = 61; 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_STRING = 1;
private static final int ATTRIBUTE_BOOLEAN = 2; private static final int ATTRIBUTE_BOOLEAN = 2;
@@ -175,8 +191,16 @@ final class FsCodec {
private static final ValueSchema<byte[]> BYTES = valueSchema(TYPE_BYTES, Writer::writeBytes, Reader::readBytes); private static final ValueSchema<byte[]> BYTES = valueSchema(TYPE_BYTES, Writer::writeBytes, Reader::readBytes);
private static final ValueSchema<Instant> INSTANT = valueSchema(TYPE_INSTANT, Writer::writeInstant, private static final ValueSchema<Instant> INSTANT = valueSchema(TYPE_INSTANT, Writer::writeInstant,
Reader::readInstant); Reader::readInstant);
private static final ValueSchema<Duration> DURATION = valueSchema(TYPE_DURATION, Writer::writeDuration, private static final ValueSchema<CertificateProfileRef> PROFILE_REF = valueSchema(TYPE_PROFILE_REF,
Reader::readDuration); (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<CredentialProfileBinding> PROFILE_BINDING =
valueSchema(TYPE_PROFILE_BINDING, FsCodec::writeProfileBinding, FsCodec::readProfileBinding);
private static final ValueSchema<Encoding> ENCODING = enumSchema(TYPE_ENCODING_ENUM, private static final ValueSchema<Encoding> ENCODING = enumSchema(TYPE_ENCODING_ENUM,
value -> switch (value) { value -> switch (value) {
@@ -330,7 +354,44 @@ final class FsCodec {
throw new IOException("unknown SignWorkflowStore.State code " + code, ex); throw new IOException("unknown SignWorkflowStore.State code " + code, ex);
} }
}); });
private static final ValueSchema<SubjectRdnType> 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<SubjectAlternativeNameType> 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<PkiId> PKI_ID = valueSchema(TYPE_PKI_ID, private static final ValueSchema<PkiId> PKI_ID = valueSchema(TYPE_PKI_ID,
(writer, value) -> writer.writeValue(STRING, value.value()), (writer, value) -> writer.writeValue(STRING, value.value()),
reader -> new PkiId(reader.readValue(STRING))); reader -> new PkiId(reader.readValue(STRING)));
@@ -363,7 +424,16 @@ final class FsCodec {
private static final ValueSchema<AttributeId> ATTRIBUTE_ID = valueSchema(TYPE_ATTRIBUTE_ID, private static final ValueSchema<AttributeId> ATTRIBUTE_ID = valueSchema(TYPE_ATTRIBUTE_ID,
(writer, value) -> writer.writeValue(STRING, value.value()), (writer, value) -> writer.writeValue(STRING, value.value()),
reader -> new AttributeId(reader.readValue(STRING))); reader -> new AttributeId(reader.readValue(STRING)));
private static final ValueSchema<SubjectRdn> 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<List<SubjectRdn>> SUBJECT_RDNS = listOf(SUBJECT_RDN);
private static final ValueSchema<SubjectAlternativeName> SUBJECT_ALT_NAME = valueSchema(TYPE_SAN,
FsCodec::writeSubjectAlternativeName, FsCodec::readSubjectAlternativeName);
private static final ValueSchema<List<SubjectAlternativeName>> SUBJECT_ALT_NAMES = listOf(SUBJECT_ALT_NAME);
private static final ValueSchema<AttributeValue> ATTRIBUTE_VALUE = valueSchema(TYPE_ATTRIBUTE_VALUE, private static final ValueSchema<AttributeValue> ATTRIBUTE_VALUE = valueSchema(TYPE_ATTRIBUTE_VALUE,
FsCodec::writeAttributeValue, FsCodec::readAttributeValue); FsCodec::writeAttributeValue, FsCodec::readAttributeValue);
private static final ValueSchema<List<AttributeValue>> ATTRIBUTE_VALUES = listOf(ATTRIBUTE_VALUE); private static final ValueSchema<List<AttributeValue>> ATTRIBUTE_VALUES = listOf(ATTRIBUTE_VALUE);
@@ -371,7 +441,6 @@ final class FsCodec {
FsCodec::writeAttributeSet, FsCodec::readAttributeSet); FsCodec::writeAttributeSet, FsCodec::readAttributeSet);
private static final ValueSchema<List<String>> STRINGS = listOf(STRING); private static final ValueSchema<List<String>> STRINGS = listOf(STRING);
private static final ValueSchema<List<AttributeId>> ATTRIBUTE_IDS = listOf(ATTRIBUTE_ID);
private static final ValueSchema<PolicyTraceStep> POLICY_TRACE_STEP = valueSchema(TYPE_POLICY_TRACE_STEP, private static final ValueSchema<PolicyTraceStep> POLICY_TRACE_STEP = valueSchema(TYPE_POLICY_TRACE_STEP,
(writer, value) -> { (writer, value) -> {
writer.writeValue(STRING, value.ruleId()); writer.writeValue(STRING, value.ruleId());
@@ -393,7 +462,6 @@ final class FsCodec {
private static final ValueSchema<Optional<Validity>> OPTIONAL_VALIDITY = optionalOf(VALIDITY); private static final ValueSchema<Optional<Validity>> OPTIONAL_VALIDITY = optionalOf(VALIDITY);
private static final ValueSchema<Optional<String>> OPTIONAL_STRING = optionalOf(STRING); private static final ValueSchema<Optional<String>> OPTIONAL_STRING = optionalOf(STRING);
private static final ValueSchema<Optional<Instant>> OPTIONAL_INSTANT = optionalOf(INSTANT); private static final ValueSchema<Optional<Instant>> OPTIONAL_INSTANT = optionalOf(INSTANT);
private static final ValueSchema<Optional<Duration>> OPTIONAL_DURATION = optionalOf(DURATION);
private static final ValueSchema<Optional<EncodedObject>> OPTIONAL_ENCODED_OBJECT = optionalOf(ENCODED_OBJECT); private static final ValueSchema<Optional<EncodedObject>> OPTIONAL_ENCODED_OBJECT = optionalOf(ENCODED_OBJECT);
private static final ValueSchema<Optional<RevocationReason>> OPTIONAL_REVOCATION_REASON = private static final ValueSchema<Optional<RevocationReason>> OPTIONAL_REVOCATION_REASON =
optionalOf(REVOCATION_REASON); optionalOf(REVOCATION_REASON);
@@ -420,9 +488,6 @@ final class FsCodec {
valueSchema(103, FsCodec::writeStatusObject, FsCodec::readStatusObject)); valueSchema(103, FsCodec::writeStatusObject, FsCodec::readStatusObject));
/* package */ static final Schema<PublicationRecord> PUBLICATION = topLevel(TOP_PUBLICATION, "PUBLICATION", /* package */ static final Schema<PublicationRecord> PUBLICATION = topLevel(TOP_PUBLICATION, "PUBLICATION",
valueSchema(104, FsCodec::writePublication, FsCodec::readPublication)); valueSchema(104, FsCodec::writePublication, FsCodec::readPublication));
/* package */ static final Schema<CertificateProfile> CERTIFICATE_PROFILE = topLevel(TOP_CERTIFICATE_PROFILE,
"CERTIFICATE_PROFILE", valueSchema(105, FsCodec::writeCertificateProfile,
FsCodec::readCertificateProfile));
/* package */ static final Schema<PolicyTrace> POLICY_TRACE = topLevel(TOP_POLICY_TRACE, "POLICY_TRACE", /* package */ static final Schema<PolicyTrace> POLICY_TRACE = topLevel(TOP_POLICY_TRACE, "POLICY_TRACE",
valueSchema(106, FsCodec::writePolicyTrace, FsCodec::readPolicyTrace)); valueSchema(106, FsCodec::writePolicyTrace, FsCodec::readPolicyTrace));
/* package */ static final Schema<WorkflowStateRecord> WORKFLOW_STATE = topLevel(TOP_WORKFLOW_STATE, /* package */ static final Schema<WorkflowStateRecord> WORKFLOW_STATE = topLevel(TOP_WORKFLOW_STATE,
@@ -430,6 +495,11 @@ final class FsCodec {
/* package */ static final Schema<SignWorkflowStore.Record> SIGN_WORKFLOW_RECORD = topLevel( /* package */ static final Schema<SignWorkflowStore.Record> SIGN_WORKFLOW_RECORD = topLevel(
TOP_SIGN_WORKFLOW_RECORD, "SIGN_WORKFLOW_RECORD", TOP_SIGN_WORKFLOW_RECORD, "SIGN_WORKFLOW_RECORD",
valueSchema(108, FsCodec::writeSignWorkflowRecord, FsCodec::readSignWorkflowRecord)); valueSchema(108, FsCodec::writeSignWorkflowRecord, FsCodec::readSignWorkflowRecord));
/* package */ static final Schema<ImportedCertificateProfileVersion> PROFILE_VERSION =
topLevel(TOP_PROFILE_VERSION, "PROFILE_VERSION",
valueSchema(109, FsCodec::writeProfileVersion, FsCodec::readProfileVersion));
/* package */ static final Schema<CertificateProfileRef> ACTIVE_PROFILE_REF =
topLevel(TOP_ACTIVE_PROFILE_REF, "ACTIVE_PROFILE_REF", PROFILE_REF);
private static final Map<Integer, Schema<?>> TOP_LEVEL_SCHEMAS = Map.ofEntries( private static final Map<Integer, Schema<?>> TOP_LEVEL_SCHEMAS = Map.ofEntries(
Map.entry(TOP_CA_RECORD, CA_RECORD), Map.entry(TOP_CA_RECORD, CA_RECORD),
@@ -438,10 +508,11 @@ final class FsCodec {
Map.entry(TOP_REVOCATION, REVOCATION_JOURNAL), Map.entry(TOP_REVOCATION, REVOCATION_JOURNAL),
Map.entry(TOP_STATUS_OBJECT, STATUS_OBJECT), Map.entry(TOP_STATUS_OBJECT, STATUS_OBJECT),
Map.entry(TOP_PUBLICATION, PUBLICATION), Map.entry(TOP_PUBLICATION, PUBLICATION),
Map.entry(TOP_CERTIFICATE_PROFILE, CERTIFICATE_PROFILE),
Map.entry(TOP_POLICY_TRACE, POLICY_TRACE), Map.entry(TOP_POLICY_TRACE, POLICY_TRACE),
Map.entry(TOP_WORKFLOW_STATE, WORKFLOW_STATE), 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() { private FsCodec() {
// utility // utility
@@ -596,7 +667,7 @@ final class FsCodec {
writer.writeValue(VALIDITY, value.validity()); writer.writeValue(VALIDITY, value.validity());
writer.writeValue(STRING, value.serialOrUniqueId()); writer.writeValue(STRING, value.serialOrUniqueId());
writer.writeValue(PKI_ID, value.publicKeyId()); 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(CREDENTIAL_STATUS, value.status());
writer.writeValue(ENCODED_OBJECT, value.encoded()); writer.writeValue(ENCODED_OBJECT, value.encoded());
writer.writeValue(ATTRIBUTE_SET, value.attributes()); writer.writeValue(ATTRIBUTE_SET, value.attributes());
@@ -605,10 +676,31 @@ final class FsCodec {
private static Credential readCredential(Reader reader) throws IOException { private static Credential readCredential(Reader reader) throws IOException {
return new Credential(reader.readValue(PKI_ID), reader.readValue(FORMAT_ID), reader.readValue(ISSUER_REF), 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(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)); 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 { private static void writeCaRecord(Writer writer, CaRecord value) throws IOException {
writer.writeValue(PKI_ID, value.caId()); writer.writeValue(PKI_ID, value.caId());
writer.writeValue(CA_KIND, value.kind()); writer.writeValue(CA_KIND, value.kind());
@@ -630,13 +722,17 @@ final class FsCodec {
writer.writeValue(ENCODED_OBJECT, value.publicKeyInfo()); writer.writeValue(ENCODED_OBJECT, value.publicKeyInfo());
writer.writeValue(OPTIONAL_VALIDITY, value.requestedValidity()); writer.writeValue(OPTIONAL_VALIDITY, value.requestedValidity());
writer.writeValue(OPTIONAL_STRING, value.requestedProfileId()); 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()); writer.writeValue(ATTRIBUTE_SET, value.attributes());
} }
private static ParsedCertificationRequest readParsedRequest(Reader reader) throws IOException { private static ParsedCertificationRequest readParsedRequest(Reader reader) throws IOException {
return new ParsedCertificationRequest(reader.readValue(PKI_ID), reader.readValue(FORMAT_ID), 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(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 { private static void writeRevocationJournal(Writer writer, RevocationJournal value) throws IOException {
@@ -700,20 +796,53 @@ final class FsCodec {
reader.readValue(PUBLICATION_STATUS)); reader.readValue(PUBLICATION_STATUS));
} }
private static void writeCertificateProfile(Writer writer, CertificateProfile value) throws IOException { private static void writeProfileVersion(Writer writer, ImportedCertificateProfileVersion value)
writer.writeValue(STRING, value.profileId()); throws IOException {
writer.writeValue(FORMAT_ID, value.formatId()); writer.writeValue(PROFILE_REF, value.reference());
writer.writeValue(STRING, value.displayName()); writer.writeValue(LONG, (long) value.schemaVersion());
writer.writeValue(ATTRIBUTE_IDS, value.requiredAttributes()); writer.writeValue(BYTES, value.canonicalJson());
writer.writeValue(ATTRIBUTE_IDS, value.optionalAttributes()); writer.writeValue(INSTANT, value.importedAt());
writer.writeValue(OPTIONAL_DURATION, value.maxValidity());
writer.writeValue(BOOLEAN, value.active());
} }
private static CertificateProfile readCertificateProfile(Reader reader) throws IOException { private static ImportedCertificateProfileVersion readProfileVersion(Reader reader) throws IOException {
return new CertificateProfile(reader.readValue(STRING), reader.readValue(FORMAT_ID), reader.readValue(STRING), CertificateProfileRef reference = reader.readValue(PROFILE_REF);
reader.readValue(ATTRIBUTE_IDS), reader.readValue(ATTRIBUTE_IDS), reader.readValue(OPTIONAL_DURATION), int schemaVersion = toInt(reader.readValue(LONG));
reader.readValue(BOOLEAN)); 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 { private static void writePolicyTrace(Writer writer, PolicyTrace value) throws IOException {
@@ -958,11 +1087,6 @@ final class FsCodec {
Util.writePack7I(output, value.getNano()); 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 { private void writeCount(int count) throws IOException {
if (count < 0 || count > MAX_COLLECTION_ELEMENTS) { if (count < 0 || count > MAX_COLLECTION_ELEMENTS) {
throw new IOException("collection size out of range"); 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 { private int readCount() throws IOException {
int count = Util.readPack7I(input); int count = Util.readPack7I(input);
if (count < 0 || count > MAX_COLLECTION_ELEMENTS) { if (count < 0 || count > MAX_COLLECTION_ELEMENTS) {

View File

@@ -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 * Signals a post-commit directory durability failure without exposing an
* operating-system cause. * operating-system cause.
@@ -214,7 +255,7 @@ final class FsOperations {
private static final long serialVersionUID = -2422560154076956224L; private static final long serialVersionUID = -2422560154076956224L;
private DurabilityUncertainException() { private DurabilityUncertainException() {
super("revocation journal durability unconfirmed"); super("filesystem durability unconfirmed");
} }
} }

View File

@@ -34,6 +34,8 @@
package zeroecho.pki.impl.fs; package zeroecho.pki.impl.fs;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Objects; import java.util.Objects;
import zeroecho.pki.api.PkiId; import zeroecho.pki.api.PkiId;
@@ -55,6 +57,7 @@ import zeroecho.pki.api.PkiId;
* </p> * </p>
*/ */
final class FsPaths { final class FsPaths {
private static final String BINARY_EXTENSION = ".bin";
private static final String BY_ID = "by-id"; 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) { /* default */ Path profileDir(final String profileId) {
Objects.requireNonNull(profileId, "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) { /* default */ Path profileVersion(final String profileId, final long profileVersion) {
return profileDir(profileId).resolve(CURRENT_FILE); return profileDir(profileId).resolve("versions").resolve(profileVersion + BINARY_EXTENSION);
} }
/* default */ Path profileHistoryDir(final String profileId) { /* default */ Path profileVersionsDir(final String profileId) {
return profileDir(profileId).resolve(HISTORY_DIR); 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) { /* default */ Path credentialPath(final PkiId credentialId) {
Objects.requireNonNull(credentialId, "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) { /* default */ Path requestPath(final PkiId requestId) {
Objects.requireNonNull(requestId, "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) { /* default */ Path signWorkflowPath(final PkiId submissionId) {

View File

@@ -34,14 +34,22 @@
package zeroecho.pki.impl.fs; package zeroecho.pki.impl.fs;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Comparator; import java.util.Comparator;
import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.logging.Level; import java.util.logging.Level;
import java.util.logging.Logger; import java.util.logging.Logger;
import zeroecho.pki.impl.ProfileLifecycleFailure;
import zeroecho.pki.impl.ProfileLifecycleFailure.Code;
/** /**
* Snapshot exporter ("time travel") for {@link FilesystemPkiStore}. * Snapshot exporter ("time travel") for {@link FilesystemPkiStore}.
* *
@@ -70,6 +78,8 @@ import java.util.logging.Logger;
final class FsSnapshotExporter { final class FsSnapshotExporter {
private static final Logger LOG = Logger.getLogger(FsSnapshotExporter.class.getName()); 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; private final FsPkiStoreOptions options;
@@ -77,12 +87,19 @@ final class FsSnapshotExporter {
this.options = Objects.requireNonNull(options, "options"); 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) { /* default */ void exportSnapshot(final Path sourceRoot, final Path targetRoot, final Instant at) {
Objects.requireNonNull(sourceRoot, "sourceRoot"); Objects.requireNonNull(sourceRoot, "sourceRoot");
Objects.requireNonNull(targetRoot, "targetRoot"); Objects.requireNonNull(targetRoot, "targetRoot");
Objects.requireNonNull(at, "at"); Objects.requireNonNull(at, "at");
try { try {
List<SnapshotProfileArtifact> profiles =
preflightImportedProfiles(sourceRoot.resolve("profiles"), at);
FsOperations.ensureDir(targetRoot); FsOperations.ensureDir(targetRoot);
FsPaths dst = new FsPaths(targetRoot); FsPaths dst = new FsPaths(targetRoot);
@@ -98,18 +115,94 @@ final class FsSnapshotExporter {
copyTreeIfExists(sourceRoot.resolve("publications"), targetRoot.resolve("publications")); copyTreeIfExists(sourceRoot.resolve("publications"), targetRoot.resolve("publications"));
copyTreeIfExists(sourceRoot.resolve("sign-workflows"), targetRoot.resolve("sign-workflows")); copyTreeIfExists(sourceRoot.resolve("sign-workflows"), targetRoot.resolve("sign-workflows"));
copyTreeIfExists(sourceRoot.resolve("revocations"), targetRoot.resolve("revocations")); copyTreeIfExists(sourceRoot.resolve("revocations"), targetRoot.resolve("revocations"));
copyImportedProfilesAsOf(profiles, targetRoot.resolve("profiles"));
// reconstruct mutable entities from history (CAS and profiles) // reconstruct mutable entities from history (CAS and profiles)
reconstructMutableTree(sourceRoot.resolve("cas"), targetRoot.resolve("cas"), at, reconstructMutableTree(sourceRoot.resolve("cas"), targetRoot.resolve("cas"), at,
this.options.caHistoryPolicy(), this.options.strictSnapshotExport()); 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 // reconstruct workflow continuation state from history
reconstructMutableTree(sourceRoot.resolve("workflows"), targetRoot.resolve("workflows"), at, reconstructMutableTree(sourceRoot.resolve("workflows"), targetRoot.resolve("workflows"), at,
this.options.workflowHistoryPolicy(), this.options.strictSnapshotExport()); this.options.workflowHistoryPolicy(), this.options.strictSnapshotExport());
} catch (SnapshotProfileFailure failure) {
throw new IllegalStateException(failure.getMessage());
} catch (IOException e) { } 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<SnapshotProfileArtifact> preflightImportedProfiles(Path profilesRoot, Instant at)
throws IOException {
if (!requireProfileRoot(profilesRoot)) {
return List.of();
}
List<SnapshotProfileArtifact> selected = new ArrayList<>();
try (java.util.stream.Stream<Path> 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<SnapshotProfileArtifact> 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<SnapshotProfileArtifact> 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)); 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);
}
}
} }

View File

@@ -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);
}
}

View File

@@ -33,10 +33,14 @@
******************************************************************************/ ******************************************************************************/
package zeroecho.pki.spi.framework; 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.Credential;
import zeroecho.pki.api.credential.CredentialBundle; import zeroecho.pki.api.credential.CredentialBundle;
import zeroecho.pki.impl.core.ManagedCaIssuance; 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. * SPI contract for framework-specific credential issuance backends.
@@ -105,8 +109,8 @@ public interface CredentialIssuerBackend {
* *
* <p> * <p>
* This operation produces a credential for a non-CA subject, typically from a * This operation produces a credential for a non-CA subject, typically from a
* cryptographically verified certification request carried by the opaque * cryptographically verified and profile-authorized request carried by the opaque
* {@link VerifiedIssuanceCandidate}. The returned {@link CredentialBundle} may * {@link ValidatedCertificateRequest}. The returned {@link CredentialBundle} may
* contain the issued leaf credential together with any additional runtime * contain the issued leaf credential together with any additional runtime
* bundle material defined by the concrete framework, such as chain elements or * bundle material defined by the concrete framework, such as chain elements or
* accompanying metadata. * accompanying metadata.
@@ -118,14 +122,15 @@ public interface CredentialIssuerBackend {
* </p> * </p>
* *
* <p> * <p>
* The candidate carries all framework-specific issuance inputs * No generic requester attributes or raw CSR extension bytes cross this
* required by the concrete implementation, including any issuer wiring * boundary. Issuer material and the positive serial are supplied independently
* attributes, profile identifiers, validity overrides, and subject request * by the trusted core issuance service.
* material. The exact interpretation of those fields is framework-specific.
* </p> * </p>
* *
* @param candidate gate-produced verified issuance candidate; must not be * @param request gate-produced validated request; must not be {@code null}
* {@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} * @return issued credential bundle, never {@code null}
* @throws IllegalArgumentException if {@code command} is {@code null} or * @throws IllegalArgumentException if {@code command} is {@code null} or
* structurally invalid for the concrete * structurally invalid for the concrete
@@ -134,7 +139,8 @@ public interface CredentialIssuerBackend {
* or other framework-specific issuance * or other framework-specific issuance
* processing fails * 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. * Issues a CA credential for an existing CA subject entity.

View File

@@ -42,7 +42,9 @@ import zeroecho.pki.api.ca.CaRecord;
import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.orch.WorkflowStateRecord; import zeroecho.pki.api.orch.WorkflowStateRecord;
import zeroecho.pki.api.policy.PolicyTrace; 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.publication.PublicationRecord;
import zeroecho.pki.api.request.ParsedCertificationRequest; import zeroecho.pki.api.request.ParsedCertificationRequest;
import zeroecho.pki.api.revocation.RevocationCommand; import zeroecho.pki.api.revocation.RevocationCommand;
@@ -243,44 +245,23 @@ public interface PkiStore extends SignWorkflowStore {
*/ */
List<PublicationRecord> listPublicationRecords(); List<PublicationRecord> listPublicationRecords();
/** /** Atomically imports one immutable validated profile version. */
* Persists or updates a certificate profile. ImportedCertificateProfileVersion importProfileVersion(ImportedCertificateProfileVersion version);
*
* <p>
* 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.
* </p>
*
* @param profile certificate profile (never {@code null})
* @throws NullPointerException if {@code profile} is {@code null}
* @throws IllegalStateException if persistence fails
*/
void putProfile(CertificateProfile profile);
/** /** Retrieves one imported version. */
* Retrieves a certificate profile by profile identifier. Optional<ImportedCertificateProfileVersion> getProfileVersion(String profileId, long profileVersion);
*
* <p>
* 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"}).
* </p>
*
* @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<CertificateProfile> getProfile(String profileId);
/** /** Lists imported versions in ascending version order. */
* Lists all stored certificate profiles. List<ImportedCertificateProfileVersion> listProfileVersions(String profileId);
*
* @return list of profiles (never {@code null}) /** Atomically activates one imported version. */
* @throws IllegalStateException if listing fails CertificateProfileRef activateProfile(String profileId, long profileVersion);
*/
List<CertificateProfile> listProfiles(); /** Retrieves the current active reference. */
Optional<CertificateProfileRef> getActiveProfileRef(String profileId);
/** Resolves the exact active version and validated runtime projection. */
ActiveCertificateProfile requireActiveProfile(String profileId);
/** /**
* Persists a policy trace. * Persists a policy trace.

View File

@@ -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"}]}

View File

@@ -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"]}}

View File

@@ -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"]}}

View File

@@ -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"]}}

View File

@@ -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"]}}

View File

@@ -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<String> PROFILE_RESOURCES =
List.of(SERVER, VPN_SERVER, VPN_CLIENT, EMAIL);
private static final List<String> EXPECTED_ORDER =
List.of("server-tls", "vpn-server", "vpn-client", "email-signing");
private static final Set<String> 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<BuiltInCertificateProfileTemplate> first =
BuiltInCertificateProfileCatalog.load(getClass().getClassLoader());
List<BuiltInCertificateProfileTemplate> 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<String, CertificateProfileDefinition> 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<String> 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<String, List<byte[]>> resources = baseResources();
resources.put(BuiltInCertificateProfileCatalog.MANIFEST_RESOURCE,
List.of(bytes(manifest)));
assertThrows(PkiException.class,
() -> BuiltInCertificateProfileCatalog.load(
new MemoryResourceClassLoader(resources)));
}
List<String> tooMany = new ArrayList<>();
for (int index = 0; index < 129; index++) {
tooMany.add(ROOT + "profile-" + index + ".json");
}
Map<String, List<byte[]>> resources = baseResources();
resources.put(BuiltInCertificateProfileCatalog.MANIFEST_RESOURCE,
List.of(bytes(manifest(tooMany))));
assertCode(resources, "MANIFEST_LIMIT_EXCEEDED");
}
@Test
void resourcesFailClosedForMissingDuplicateEmptyOversizeAndNoncanonicalContent() {
Map<String, List<byte[]>> missing = baseResources();
missing.put(SERVER, List.of());
assertCode(missing, "RESOURCE_MISSING");
Map<String, List<byte[]>> duplicate = baseResources();
duplicate.put(SERVER, List.of(mainResource(SERVER), mainResource(SERVER)));
assertCode(duplicate, "RESOURCE_DUPLICATE");
Map<String, List<byte[]>> empty = baseResources();
empty.put(SERVER, List.of(new byte[0]));
assertCode(empty, "RESOURCE_EMPTY");
Map<String, List<byte[]>> oversized = baseResources();
oversized.put(SERVER, List.of(new byte[
CertificateProfileDocumentCodec.MAXIMUM_DOCUMENT_BYTES + 1]));
assertCode(oversized, "RESOURCE_TOO_LARGE");
Map<String, List<byte[]>> 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<byte[]> 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<String, List<byte[]>> resources = baseResources();
resources.put(SERVER, List.of(invalid));
assertCode(resources, "PROFILE_RESOURCE_INVALID");
}
assertFalse(sentinelInitialized);
}
@Test
void duplicateCanonicalHashIdentityAndUnexpectedBuiltInSetFailClosed() {
Map<String, List<byte[]>> 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<String, List<byte[]>> 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<String, List<byte[]>> 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<ExtendedKeyUsageId> 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<String, CertificateProfileDefinition> productionDefinitions() {
Map<String, CertificateProfileDefinition> 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<String, List<byte[]>> baseResources() {
Map<String, List<byte[]>> 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<String> 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<String, List<byte[]>> 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<String, List<byte[]>> resources;
private MemoryResourceClassLoader(Map<String, List<byte[]>> resources) {
super(null);
Map<String, List<byte[]>> copied = new LinkedHashMap<>();
resources.forEach((name, values) -> copied.put(name,
values.stream().map(byte[]::clone).toList()));
this.resources = Map.copyOf(copied);
}
@Override
public Enumeration<URL> getResources(String name) throws IOException {
List<URL> urls = new ArrayList<>();
List<byte[]> 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() {
}
}
}

View File

@@ -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<BuiltInCertificateProfileTemplate> 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<String> 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);
}
}
}

View File

@@ -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<CertificateProfileDefinition> 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<CertificateProfileDefinition> 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<String> hostileFields = List.of("@class", "class", "typeName", "java.lang.Runtime");
Logger logger = Logger.getLogger(CertificateProfileDocumentCodec.class.getName());
List<LogRecord> 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<ExtendedKeyUsageId> extendedKeyUsages,
Set<String> 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<ExtendedKeyUsageId> 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() {
}
}
}

View File

@@ -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<ASN1ObjectIdentifier> 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<BuiltInCertificateProfileTemplate> 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<PkiId, PersistedExpectation> durableCredentials = new LinkedHashMap<>();
List<BigInteger> allocatedSerials = new ArrayList<>();
try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, busFile, Map.of(rootKeyRef, rootKey))) {
List<byte[]> 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<IssuanceCase> 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<PkiId, PersistedExpectation> 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<byte[]> 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<GeneralName> sans) throws Exception {
return parse(runtime, leafKey, new X500Name("CN=" + commonName), sans);
}
private static ParsedCertificationRequest parse(PkiTestRuntime runtime, KeyPair leafKey, X500Name subject,
List<GeneralName> 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<BigInteger> 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<String> encodedSans(X509CertificateHolder holder) {
GeneralName[] names = GeneralNames.fromExtensions(holder.getExtensions(),
Extension.subjectAlternativeName).getNames();
List<String> 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<GeneralName> 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<Credential> maliciousCredential = new AtomicReference<>();
AtomicReference<Credential> baselineCredential = new AtomicReference<>();
AtomicReference<BigInteger> 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<Credential> baselineCredential,
AtomicReference<Credential> maliciousCredential, AtomicReference<BigInteger> 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<AuditEvent> 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<GeneralName> sans,
List<String> 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<String> 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();
}
}
}

View File

@@ -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<SignedCsrCase> 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<SignedCsrCase> 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 <user@example.com>"), "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<SignedCsrCase> 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<SignedCsrCase> 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<GeneralName> 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<GeneralName> 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<GeneralName> 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<ASN1Encodable> 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<AuditEvent> 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();
}
}

View File

@@ -33,6 +33,7 @@
******************************************************************************/ ******************************************************************************/
package zeroecho.pki.e2e; package zeroecho.pki.e2e;
import java.math.BigInteger;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertFalse; 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.api.status.StatusObjectType;
import zeroecho.pki.impl.core.attr.SimpleAttributeSet; import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
import zeroecho.pki.impl.core.ManagedCaIssuance; 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.spi.framework.CredentialIssuerBackend;
import zeroecho.pki.testkit.PkiTestRuntime; import zeroecho.pki.testkit.PkiTestRuntime;
@@ -160,8 +161,7 @@ public final class PkiCoreE2eTest {
new CertificationRequest(runtime.framework().formatId(), new CertificationRequest(runtime.framework().formatId(),
new EncodedObject(Encoding.DER, makeCsr(leafKey, "CN=Matrix Leaf").getEncoded()))); new EncodedObject(Encoding.DER, makeCsr(leafKey, "CN=Matrix Leaf").getEncoded())));
issuance.issueEndEntity(new IssueEndEntityCommand(rootCaId, leafRequest, "default", Optional.empty(), issuance.issueEndEntity(new IssueEndEntityCommand(rootCaId, leafRequest, "default", Optional.empty()));
emptyAttributes()));
assertEquals(List.of(unusable.credentialId(), usable.credentialId()), List.copyOf(resolved)); assertEquals(List.of(unusable.credentialId(), usable.credentialId()), List.copyOf(resolved));
resolved.clear(); resolved.clear();
@@ -215,7 +215,7 @@ public final class PkiCoreE2eTest {
ParsedCertificationRequest parsed = runtime.certificationRequestService().parse(request); ParsedCertificationRequest parsed = runtime.certificationRequestService().parse(request);
PkiException endEntityFailure = assertThrows(PkiException.class, PkiException endEntityFailure = assertThrows(PkiException.class,
() -> runtime.issuanceService().issueEndEntity(new IssueEndEntityCommand(rootCaId, parsed, () -> runtime.issuanceService().issueEndEntity(new IssueEndEntityCommand(rootCaId, parsed,
"default", Optional.empty(), emptyAttributes()))); "default", Optional.empty())));
assertTrue(endEntityFailure.getMessage().contains("ISSUER_CREDENTIAL_UNAVAILABLE")); assertTrue(endEntityFailure.getMessage().contains("ISSUER_CREDENTIAL_UNAVAILABLE"));
assertThrows(PkiException.class, assertThrows(PkiException.class,
@@ -282,9 +282,7 @@ public final class PkiCoreE2eTest {
reqSvc.store(parsed, RequestStorePolicy.STORE_ALWAYS); reqSvc.store(parsed, RequestStorePolicy.STORE_ALWAYS);
CredentialBundle bundle = issSvc.issueEndEntity(new IssueEndEntityCommand(rootCaId, parsed, "default", CredentialBundle bundle = issSvc.issueEndEntity(new IssueEndEntityCommand(rootCaId, parsed, "default",
Optional.of(new Validity(Instant.now().minus(Duration.ofMinutes(1)), Optional.empty()));
Instant.now().plus(Duration.ofDays(365)))),
emptyAttributes()));
assertNotNull(bundle); assertNotNull(bundle);
System.out.println("...issuedCredentialId=" + bundle.credential().credentialId().value()); System.out.println("...issuedCredentialId=" + bundle.credential().credentialId().value());
@@ -342,7 +340,7 @@ public final class PkiCoreE2eTest {
assertThrows(PkiException.class, assertThrows(PkiException.class,
() -> issuance.issueEndEntity(new IssueEndEntityCommand(rootCaId, leafRequest, "default", () -> issuance.issueEndEntity(new IssueEndEntityCommand(rootCaId, leafRequest, "default",
Optional.empty(), emptyAttributes()))); Optional.empty())));
assertThrows(PkiException.class, assertThrows(PkiException.class,
() -> caService.createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(), () -> caService.createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(),
rootCaId, new SubjectRef("CN=Rejected Next"), "default", rootCaId, new SubjectRef("CN=Rejected Next"), "default",
@@ -399,7 +397,8 @@ public final class PkiCoreE2eTest {
private static Credential copyWithId(Credential source, PkiId id) { private static Credential copyWithId(Credential source, PkiId id) {
return new Credential(id, source.formatId(), source.issuerRef(), source.subjectRef(), source.validity(), 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()); source.attributes());
} }
@@ -415,9 +414,9 @@ public final class PkiCoreE2eTest {
} }
@Override @Override
public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) { public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, EncodedObject issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) {
endEntityCalls.incrementAndGet(); endEntityCalls.incrementAndGet();
return delegate.issueEndEntity(candidate); return delegate.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial);
} }
@Override @Override

View File

@@ -41,6 +41,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.lang.reflect.Modifier; import java.lang.reflect.Modifier;
import java.math.BigInteger;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.security.KeyPair; import java.security.KeyPair;
@@ -48,6 +49,7 @@ import java.security.KeyPairGenerator;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.security.PublicKey; import java.security.PublicKey;
import java.time.Duration; import java.time.Duration;
import java.time.Clock;
import java.time.Instant; import java.time.Instant;
import java.util.HashMap; import java.util.HashMap;
import java.util.HexFormat; 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.CaRolloverCommand;
import zeroecho.pki.api.ca.IntermediateCertIssueCommand; import zeroecho.pki.api.ca.IntermediateCertIssueCommand;
import zeroecho.pki.api.ca.IntermediateCreateCommand; import zeroecho.pki.api.ca.IntermediateCreateCommand;
import zeroecho.pki.api.credential.CaProfileBinding;
import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.credential.CredentialBundle; import zeroecho.pki.api.credential.CredentialBundle;
import zeroecho.pki.api.credential.CredentialStatus; 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.issuance.IssueEndEntityCommand;
import zeroecho.pki.api.profile.CertificateProfileRef;
import zeroecho.pki.api.issuance.ReissueCommand; import zeroecho.pki.api.issuance.ReissueCommand;
import zeroecho.pki.api.issuance.RenewCommand; import zeroecho.pki.api.issuance.RenewCommand;
import zeroecho.pki.api.issuance.ReplaceCommand; 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.api.request.ProofOfPossessionStatus;
import zeroecho.pki.impl.core.DefaultIssuanceService; import zeroecho.pki.impl.core.DefaultIssuanceService;
import zeroecho.pki.impl.core.ManagedCaIssuance; 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.core.attr.SimpleAttributeSet;
import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes; import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes;
import zeroecho.pki.impl.framework.x509.bc.BcX509CredentialIssuerBackend; import zeroecho.pki.impl.framework.x509.bc.BcX509CredentialIssuerBackend;
@@ -127,9 +133,9 @@ final class PkiProofGateE2eTest {
void issuerBackendApiRequiresOpaqueGateProducedInputs() throws Exception { void issuerBackendApiRequiresOpaqueGateProducedInputs() throws Exception {
System.out.println("issuerBackendApiRequiresOpaqueGateProducedInputs"); System.out.println("issuerBackendApiRequiresOpaqueGateProducedInputs");
assertTrue(Modifier.isFinal(VerifiedIssuanceCandidate.class.getModifiers())); assertTrue(Modifier.isFinal(ValidatedCertificateRequest.class.getModifiers()));
assertTrue(Modifier.isFinal(ManagedCaIssuance.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()))); .noneMatch(constructor -> Modifier.isPublic(constructor.getModifiers())));
assertTrue(java.util.Arrays.stream(ManagedCaIssuance.class.getDeclaredConstructors()) assertTrue(java.util.Arrays.stream(ManagedCaIssuance.class.getDeclaredConstructors())
.noneMatch(constructor -> Modifier.isPublic(constructor.getModifiers()))); .noneMatch(constructor -> Modifier.isPublic(constructor.getModifiers())));
@@ -145,7 +151,8 @@ final class PkiProofGateE2eTest {
Method intermediate = java.util.Arrays.stream(CredentialIssuerBackend.class.getMethods()) Method intermediate = java.util.Arrays.stream(CredentialIssuerBackend.class.getMethods())
.filter(method -> method.getName().equals("issueIntermediateCertificate")) .filter(method -> method.getName().equals("issueIntermediateCertificate"))
.findFirst().orElseThrow(); .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()); assertArrayEquals(new Class<?>[] { ManagedCaIssuance.class }, intermediate.getParameterTypes());
assertTrue(java.util.Arrays.stream(BcX509CredentialIssuerBackend.class.getMethods()) assertTrue(java.util.Arrays.stream(BcX509CredentialIssuerBackend.class.getMethods())
.filter(method -> method.getName().startsWith("issue")) .filter(method -> method.getName().startsWith("issue"))
@@ -172,19 +179,19 @@ final class PkiProofGateE2eTest {
try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), keys)) { try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), keys)) {
CountingIssuerBackend counting = new CountingIssuerBackend(runtime.issuerBackend()); CountingIssuerBackend counting = new CountingIssuerBackend(runtime.issuerBackend());
DefaultIssuanceService issuance = new DefaultIssuanceService(runtime.store(), runtime.framework(), 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")); ParsedCertificationRequest valid = parse(runtime, makeCsr(subjectKey, subjectKey, "CN=Leaf"));
assertThrows(PkiException.class, assertThrows(PkiException.class,
() -> issuance.issueEndEntity(new IssueEndEntityCommand(new PkiId("ca:absent"), () -> issuance.issueEndEntity(new IssueEndEntityCommand(new PkiId("ca:absent"),
parse(runtime, makeCsr(subjectKey, wrongKey, "CN=Leaf")), "default", Optional.empty(), parse(runtime, makeCsr(subjectKey, wrongKey, "CN=Leaf")), "default", Optional.empty())));
new SimpleAttributeSet())));
assertEquals(0, counting.endEntityCalls.get()); assertEquals(0, counting.endEntityCalls.get());
PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(),
new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef), new SimpleAttributeSet())); new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef), new SimpleAttributeSet()));
CredentialBundle issued = issuance.issueEndEntity(new IssueEndEntityCommand(rootCaId, valid, "default", CredentialBundle issued = issuance.issueEndEntity(new IssueEndEntityCommand(rootCaId, valid, "default",
Optional.empty(), new SimpleAttributeSet())); Optional.empty()));
assertEquals(1, counting.endEntityCalls.get()); assertEquals(1, counting.endEntityCalls.get());
assertArrayEquals(subjectKey.getPublic().getEncoded(), assertArrayEquals(subjectKey.getPublic().getEncoded(),
new X509CertificateHolder(issued.credential().encoded().bytes()).getSubjectPublicKeyInfo() new X509CertificateHolder(issued.credential().encoded().bytes()).getSubjectPublicKeyInfo()
@@ -215,9 +222,11 @@ final class PkiProofGateE2eTest {
System.out.println("unsupportedIssuanceVariantsFailWithoutSideEffects"); System.out.println("unsupportedIssuanceVariantsFailWithoutSideEffects");
try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), Map.of())) { try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), Map.of())) {
int auditCount = runtime.auditSink().snapshot().size();
CountingIssuerBackend counting = new CountingIssuerBackend(runtime.issuerBackend()); CountingIssuerBackend counting = new CountingIssuerBackend(runtime.issuerBackend());
DefaultIssuanceService issuance = new DefaultIssuanceService(runtime.store(), runtime.framework(), 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); CaService caService = runtime.caService(counting);
ParsedCertificationRequest request = new ParsedCertificationRequest(new PkiId("csr:unsupported"), ParsedCertificationRequest request = new ParsedCertificationRequest(new PkiId("csr:unsupported"),
runtime.framework().formatId(), new SubjectRef("CN=Unsupported"), runtime.framework().formatId(), new SubjectRef("CN=Unsupported"),
@@ -253,7 +262,7 @@ final class PkiProofGateE2eTest {
assertEquals(0, runtime.submittedSignCount()); assertEquals(0, runtime.submittedSignCount());
assertTrue(runtime.store().listCas().isEmpty()); assertTrue(runtime.store().listCas().isEmpty());
assertTrue(runtime.store().listWorkflowStates().isEmpty()); assertTrue(runtime.store().listWorkflowStates().isEmpty());
assertTrue(runtime.auditSink().snapshot().isEmpty()); assertEquals(auditCount, runtime.auditSink().snapshot().size());
System.out.println("...unsupported operations=5"); System.out.println("...unsupported operations=5");
} }
@@ -308,22 +317,26 @@ final class PkiProofGateE2eTest {
assertRejected(runtime, rootCaId, assertRejected(runtime, rootCaId,
new ParsedCertificationRequest(new PkiId("csr:substituted"), valid.formatId(), valid.subjectRef(), new ParsedCertificationRequest(new PkiId("csr:substituted"), valid.formatId(), valid.subjectRef(),
valid.publicKeyInfo(), valid.requestedValidity(), valid.requestedProfileId(), valid.publicKeyInfo(), valid.requestedValidity(), valid.requestedProfileId(),
valid.attributes()), valid.subjectRdns(), valid.subjectAlternativeNames(),
valid.subjectAlternativeNamePresent(), valid.attributes()),
"REQUEST_ID_MISMATCH"); "REQUEST_ID_MISMATCH");
assertRejected(runtime, rootCaId, assertRejected(runtime, rootCaId,
new ParsedCertificationRequest(valid.requestId(), valid.formatId(), new SubjectRef("CN=Other"), new ParsedCertificationRequest(valid.requestId(), valid.formatId(), new SubjectRef("CN=Other"),
valid.publicKeyInfo(), valid.requestedValidity(), valid.requestedProfileId(), valid.publicKeyInfo(), valid.requestedValidity(), valid.requestedProfileId(),
valid.attributes()), valid.subjectRdns(), valid.subjectAlternativeNames(),
valid.subjectAlternativeNamePresent(), valid.attributes()),
"SUBJECT_MISMATCH"); "SUBJECT_MISMATCH");
assertRejected(runtime, rootCaId, assertRejected(runtime, rootCaId,
new ParsedCertificationRequest(valid.requestId(), valid.formatId(), valid.subjectRef(), new ParsedCertificationRequest(valid.requestId(), valid.formatId(), valid.subjectRef(),
new EncodedObject(Encoding.DER, otherKey.getPublic().getEncoded()), 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"); "SPKI_MISMATCH");
assertRejected(runtime, rootCaId, assertRejected(runtime, rootCaId,
new ParsedCertificationRequest(valid.requestId(), new FormatId("unsupported"), valid.subjectRef(), new ParsedCertificationRequest(valid.requestId(), new FormatId("unsupported"), valid.subjectRef(),
valid.publicKeyInfo(), valid.requestedValidity(), valid.requestedProfileId(), valid.publicKeyInfo(), valid.requestedValidity(), valid.requestedProfileId(),
valid.attributes()), valid.subjectRdns(), valid.subjectAlternativeNames(),
valid.subjectAlternativeNamePresent(), valid.attributes()),
"FORMAT_UNSUPPORTED"); "FORMAT_UNSUPPORTED");
byte[] maximum = new byte[1024 * 1024]; byte[] maximum = new byte[1024 * 1024];
System.arraycopy(csrDer(valid), 0, maximum, 0, csrDer(valid).length); 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")); ParsedCertificationRequest parsed = parse(runtime, makeCsr(subjectKey, subjectKey, "CN=Subject"));
assertThrows(PkiException.class, () -> issue(runtime, rootCaId, parsed)); assertThrows(PkiException.class, () -> issue(runtime, rootCaId, parsed));
assertTrue(required.get()); assertTrue(required.get());
assertEquals("PROOF_" + status.name(), assertTrue(runtime.auditSink().snapshot().stream()
runtime.auditSink().snapshot().get(0).details().get("code")); .anyMatch(event -> ("PROOF_" + status.name()).equals(event.details().get("code"))));
assertEquals(1, runtime.store().listCas().size()); assertEquals(1, runtime.store().listCas().size());
} }
} }
@@ -412,8 +425,7 @@ final class PkiProofGateE2eTest {
.put(BcX509Attributes.ISSUER_KEYREF, new AttributeValue.StringValue("attacker-key")) .put(BcX509Attributes.ISSUER_KEYREF, new AttributeValue.StringValue("attacker-key"))
.build(); .build();
CredentialBundle bundle = runtime.issuanceService() CredentialBundle bundle = runtime.issuanceService()
.issueEndEntity(new IssueEndEntityCommand(rootCaId, parsed, "default", Optional.empty(), .issueEndEntity(new IssueEndEntityCommand(rootCaId, parsed, "default", Optional.empty()));
hostileOverrides));
X509CertificateHolder issued = new X509CertificateHolder(bundle.credential().encoded().bytes()); X509CertificateHolder issued = new X509CertificateHolder(bundle.credential().encoded().bytes());
assertEquals("CN=Root", issued.getIssuer().toString()); assertEquals("CN=Root", issued.getIssuer().toString());
@@ -442,8 +454,8 @@ final class PkiProofGateE2eTest {
assertTrue(runtime.store().listCas().isEmpty()); assertTrue(runtime.store().listCas().isEmpty());
assertTrue(runtime.store().listWorkflowStates().isEmpty()); assertTrue(runtime.store().listWorkflowStates().isEmpty());
assertEquals(1, runtime.submittedSignCount()); assertEquals(1, runtime.submittedSignCount());
assertEquals("MANAGED_KEY_PROOF_FAILED", assertTrue(runtime.auditSink().snapshot().stream()
runtime.auditSink().snapshot().get(0).details().get("code")); .anyMatch(event -> "MANAGED_KEY_PROOF_FAILED".equals(event.details().get("code"))));
} }
Path failedDir = tempDir.resolve("failed-workflow"); Path failedDir = tempDir.resolve("failed-workflow");
try (PkiTestRuntime runtime = PkiTestRuntime.create(failedDir, failedDir.resolve("bus.log"), Map.of(), try (PkiTestRuntime runtime = PkiTestRuntime.create(failedDir, failedDir.resolve("bus.log"), Map.of(),
@@ -558,8 +570,7 @@ final class PkiProofGateE2eTest {
.bytes().clone(); .bytes().clone();
ParsedCertificationRequest leaf = parse(source, makeCsr(subjectKey, subjectKey, "CN=Leaf")); ParsedCertificationRequest leaf = parse(source, makeCsr(subjectKey, subjectKey, "CN=Leaf"));
leafCertificate = source.issuanceService() leafCertificate = source.issuanceService()
.issueEndEntity(new IssueEndEntityCommand(rootCaId, leaf, "default", Optional.empty(), .issueEndEntity(new IssueEndEntityCommand(rootCaId, leaf, "default", Optional.empty()))
new SimpleAttributeSet()))
.credential().encoded().bytes().clone(); .credential().encoded().bytes().clone();
} }
Path importDir = tempDir.resolve("import-mismatch"); Path importDir = tempDir.resolve("import-mismatch");
@@ -584,6 +595,8 @@ final class PkiProofGateE2eTest {
PkiId importedCaId = target.caService().importRoot(new CaImportCommand(target.framework().formatId(), PkiId importedCaId = target.caService().importRoot(new CaImportCommand(target.framework().formatId(),
new SubjectRef("CN=Root"), "default", rootKeyRef, new SubjectRef("CN=Root"), "default", rootKeyRef,
new EncodedObject(Encoding.DER, callerOwnedCertificate), new SimpleAttributeSet())); new EncodedObject(Encoding.DER, callerOwnedCertificate), new SimpleAttributeSet()));
assertTrue(target.caService().getCa(importedCaId).caCredentials().get(0)
.profileBinding() instanceof CaProfileBinding);
assertArrayEquals(expectedImportedCertificate, assertArrayEquals(expectedImportedCertificate,
target.caService().getCa(importedCaId).caCredentials().get(0).encoded().bytes()); target.caService().getCa(importedCaId).caCredentials().get(0).encoded().bytes());
} }
@@ -652,7 +665,7 @@ final class PkiProofGateE2eTest {
CredentialIssuerBackend delegateBackend = runtime.issuerBackend(); CredentialIssuerBackend delegateBackend = runtime.issuerBackend();
CredentialIssuerBackend throwingBackend = new CredentialIssuerBackend() { CredentialIssuerBackend throwingBackend = new CredentialIssuerBackend() {
@Override @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"); throw new IllegalStateException("DO_NOT_LOG_SIGNATURE_SENTINEL");
} }
@@ -662,20 +675,21 @@ final class PkiProofGateE2eTest {
} }
}; };
DefaultIssuanceService throwingBackendService = new DefaultIssuanceService(runtime.store(), 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, PkiException backendRejection = assertThrows(PkiException.class,
() -> throwingBackendService.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, () -> throwingBackendService.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject,
"default", Optional.empty(), new SimpleAttributeSet()))); "default", Optional.empty())));
assertThrowableRedacted(backendRejection, "DO_NOT_LOG_SIGNATURE_SENTINEL"); assertThrowableRedacted(backendRejection, "DO_NOT_LOG_SIGNATURE_SENTINEL");
CredentialIssuerBackend maliciousBackend = new CredentialIssuerBackend() { CredentialIssuerBackend maliciousBackend = new CredentialIssuerBackend() {
@Override @Override
public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) { public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, EncodedObject issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) {
CredentialBundle bundle = delegateBackend.issueEndEntity(candidate); CredentialBundle bundle = delegateBackend.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial);
Credential raw = bundle.credential(); Credential raw = bundle.credential();
Credential forgedMetadata = new Credential(raw.credentialId(), raw.formatId(), raw.issuerRef(), Credential forgedMetadata = new Credential(raw.credentialId(), raw.formatId(), raw.issuerRef(),
substitute.subjectRef(), raw.validity(), raw.serialOrUniqueId(), raw.publicKeyId(), 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()); bundle = new CredentialBundle(forgedMetadata, bundle.supportingObjects());
substitutedBundle.set(bundle); substitutedBundle.set(bundle);
return bundle; return bundle;
@@ -687,25 +701,54 @@ final class PkiProofGateE2eTest {
} }
}; };
DefaultIssuanceService service = new DefaultIssuanceService(runtime.store(), runtime.framework(), 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, assertThrows(PkiException.class,
() -> service.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, "default", () -> service.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, "default",
Optional.empty(), new SimpleAttributeSet()))); Optional.empty())));
Credential substitutedCredential = substitutedBundle.get().credential(); Credential substitutedCredential = substitutedBundle.get().credential();
assertTrue(runtime.store().getCredential(substitutedCredential.credentialId()).isEmpty()); assertTrue(runtime.store().getCredential(substitutedCredential.credentialId()).isEmpty());
assertEquals("BACKEND_CREDENTIAL_MISMATCH", assertEquals("BACKEND_CREDENTIAL_MISMATCH",
runtime.auditSink().snapshot().get(runtime.auditSink().snapshot().size() - 1).details().get("code")); runtime.auditSink().snapshot().get(runtime.auditSink().snapshot().size() - 1).details().get("code"));
AtomicReference<Credential> 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() { CredentialIssuerBackend invalidSignatureBackend = new CredentialIssuerBackend() {
@Override @Override
public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) { public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, EncodedObject issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) {
CredentialBundle rawBundle = delegateBackend.issueEndEntity(candidate); CredentialBundle rawBundle = delegateBackend.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial);
Credential raw = rawBundle.credential(); Credential raw = rawBundle.credential();
byte[] invalid = raw.encoded().bytes().clone(); byte[] invalid = raw.encoded().bytes().clone();
invalid[invalid.length - 1] ^= 0x01; invalid[invalid.length - 1] ^= 0x01;
Credential invalidCredential = new Credential(raw.credentialId(), raw.formatId(), Credential invalidCredential = new Credential(raw.credentialId(), raw.formatId(),
raw.issuerRef(), raw.subjectRef(), raw.validity(), raw.serialOrUniqueId(), 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()); new EncodedObject(Encoding.DER, invalid), raw.attributes());
return new CredentialBundle(invalidCredential, rawBundle.supportingObjects()); return new CredentialBundle(invalidCredential, rawBundle.supportingObjects());
} }
@@ -716,16 +759,17 @@ final class PkiProofGateE2eTest {
} }
}; };
DefaultIssuanceService invalidSignatureService = new DefaultIssuanceService(runtime.store(), 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, assertThrows(PkiException.class,
() -> invalidSignatureService.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, () -> invalidSignatureService.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject,
"default", Optional.empty(), new SimpleAttributeSet()))); "default", Optional.empty())));
AtomicReference<CredentialBundle> rawBundle = new AtomicReference<>(); AtomicReference<CredentialBundle> rawBundle = new AtomicReference<>();
CredentialIssuerBackend mutableBackend = new CredentialIssuerBackend() { CredentialIssuerBackend mutableBackend = new CredentialIssuerBackend() {
@Override @Override
public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) { public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, EncodedObject issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) {
CredentialBundle raw = delegateBackend.issueEndEntity(candidate); CredentialBundle raw = delegateBackend.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial);
rawBundle.set(raw); rawBundle.set(raw);
return raw; return raw;
} }
@@ -736,9 +780,10 @@ final class PkiProofGateE2eTest {
} }
}; };
DefaultIssuanceService snapshotService = new DefaultIssuanceService(runtime.store(), 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, CredentialBundle returned = snapshotService.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject,
"default", Optional.empty(), new SimpleAttributeSet())); "default", Optional.empty()));
byte[] expectedLeaf = returned.credential().encoded().bytes().clone(); byte[] expectedLeaf = returned.credential().encoded().bytes().clone();
rawBundle.get().credential().encoded().bytes()[0] ^= 0x01; rawBundle.get().credential().encoded().bytes()[0] ^= 0x01;
rawBundle.get().supportingObjects().get(0).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 original = root.caCredentials().get(0);
Credential revoked = new Credential(original.credentialId(), original.formatId(), original.issuerRef(), Credential revoked = new Credential(original.credentialId(), original.formatId(), original.issuerRef(),
original.subjectRef(), original.validity(), original.serialOrUniqueId(), original.publicKeyId(), 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(), runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(),
root.subjectRef(), List.of(revoked))); root.subjectRef(), List.of(revoked)));
int before = runtime.submittedSignCount(); int before = runtime.submittedSignCount();
assertThrows(PkiException.class, assertThrows(PkiException.class,
() -> runtime.issuanceService().issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, () -> runtime.issuanceService().issueEndEntity(new IssueEndEntityCommand(rootCaId, subject,
"default", Optional.empty(), new SimpleAttributeSet()))); "default", Optional.empty())));
assertEquals(before, runtime.submittedSignCount()); assertEquals(before, runtime.submittedSignCount());
Validity expiredValidity = new Validity(Instant.now().minus(Duration.ofDays(2)), Validity expiredValidity = new Validity(Instant.now().minus(Duration.ofDays(2)),
Instant.now().minus(Duration.ofDays(1))); Instant.now().minus(Duration.ofDays(1)));
Credential expired = new Credential(original.credentialId(), original.formatId(), original.issuerRef(), Credential expired = new Credential(original.credentialId(), original.formatId(), original.issuerRef(),
original.subjectRef(), expiredValidity, original.serialOrUniqueId(), original.publicKeyId(), 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(), runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(),
root.subjectRef(), List.of(expired))); root.subjectRef(), List.of(expired)));
assertThrows(PkiException.class, assertThrows(PkiException.class,
() -> runtime.issuanceService().issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, () -> runtime.issuanceService().issueEndEntity(new IssueEndEntityCommand(rootCaId, subject,
"default", Optional.empty(), new SimpleAttributeSet()))); "default", Optional.empty())));
assertEquals(before, runtime.submittedSignCount()); assertEquals(before, runtime.submittedSignCount());
runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(), 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, PkiException parserRejection = assertThrows(PkiException.class,
() -> runtime.issuanceService().issueEndEntity(new IssueEndEntityCommand(rootCaId, () -> runtime.issuanceService().issueEndEntity(new IssueEndEntityCommand(rootCaId,
withAttributes(subject, hostileAttributes), "default", Optional.empty(), withAttributes(subject, hostileAttributes), "default", Optional.empty())));
new SimpleAttributeSet())));
assertThrowableRedacted(parserRejection, "DO_NOT_LOG_CSR_SENTINEL"); assertThrowableRedacted(parserRejection, "DO_NOT_LOG_CSR_SENTINEL");
DefaultIssuanceService failingAudit = new DefaultIssuanceService(runtime.store(), runtime.framework(), DefaultIssuanceService failingAudit = new DefaultIssuanceService(runtime.store(), runtime.framework(),
runtime.issuerBackend(), event -> { runtime.issuerBackend(), event -> {
throw new IllegalStateException("DO_NOT_LOG_PAYLOAD_SENTINEL"); throw new IllegalStateException("DO_NOT_LOG_PAYLOAD_SENTINEL");
}, runtime.statusResolver()); }, runtime.statusResolver(), runtime.profileService(), Clock.systemUTC());
PkiException rejection = assertThrows(PkiException.class, PkiException rejection = assertThrows(PkiException.class,
() -> failingAudit.issueEndEntity(new IssueEndEntityCommand(rootCaId, missing, "default", () -> failingAudit.issueEndEntity(new IssueEndEntityCommand(rootCaId, missing, "default",
Optional.empty(), new SimpleAttributeSet()))); Optional.empty())));
assertTrue(rejection.getMessage().contains("CSR_MISSING")); assertTrue(rejection.getMessage().contains("CSR_MISSING"));
assertThrowableRedacted(rejection, "DO_NOT_LOG_PAYLOAD_SENTINEL"); assertThrowableRedacted(rejection, "DO_NOT_LOG_PAYLOAD_SENTINEL");
} }
@@ -822,11 +866,29 @@ final class PkiProofGateE2eTest {
Map.of(rootKeyRef, rootKey, intermediateKeyRef, intermediateKey))) { Map.of(rootKeyRef, rootKey, intermediateKeyRef, intermediateKey))) {
PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(),
new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef), new SimpleAttributeSet())); 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(); CredentialIssuerBackend delegate = runtime.issuerBackend();
for (BindingVariantMutation mutation : BindingVariantMutation.values()) {
AtomicReference<Credential> 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() { CredentialIssuerBackend wrongKeyBackend = new CredentialIssuerBackend() {
@Override @Override
public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) { public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, EncodedObject issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) {
return delegate.issueEndEntity(candidate); return delegate.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial);
} }
@Override @Override
@@ -847,10 +909,26 @@ final class PkiProofGateE2eTest {
.createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(), rootCaId, .createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(), rootCaId,
new SubjectRef("CN=Intermediate"), "default", Optional.of(intermediateKeyRef), new SubjectRef("CN=Intermediate"), "default", Optional.of(intermediateKeyRef),
new SimpleAttributeSet())); new SimpleAttributeSet()));
for (BindingVariantMutation mutation : BindingVariantMutation.values()) {
AtomicReference<Credential> 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() { CredentialIssuerBackend wrongSubjectBackend = new CredentialIssuerBackend() {
@Override @Override
public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) { public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, EncodedObject issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) {
return delegate.issueEndEntity(candidate); return delegate.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial);
} }
@Override @Override
@@ -869,8 +947,8 @@ final class PkiProofGateE2eTest {
CredentialIssuerBackend invalidSignatureBackend = new CredentialIssuerBackend() { CredentialIssuerBackend invalidSignatureBackend = new CredentialIssuerBackend() {
@Override @Override
public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) { public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, EncodedObject issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) {
return delegate.issueEndEntity(candidate); return delegate.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial);
} }
@Override @Override
@@ -880,7 +958,7 @@ final class PkiProofGateE2eTest {
invalid[invalid.length - 1] ^= 0x01; invalid[invalid.length - 1] ^= 0x01;
return new Credential(raw.credentialId(), raw.formatId(), raw.issuerRef(), return new Credential(raw.credentialId(), raw.formatId(), raw.issuerRef(),
raw.subjectRef(), raw.validity(), raw.serialOrUniqueId(), raw.publicKeyId(), 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()); raw.attributes());
} }
}; };
@@ -905,8 +983,8 @@ final class PkiProofGateE2eTest {
AtomicReference<Credential> rawCredential = new AtomicReference<>(); AtomicReference<Credential> rawCredential = new AtomicReference<>();
CredentialIssuerBackend mutableBackend = new CredentialIssuerBackend() { CredentialIssuerBackend mutableBackend = new CredentialIssuerBackend() {
@Override @Override
public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) { public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, EncodedObject issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) {
return delegate.issueEndEntity(candidate); return delegate.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial);
} }
@Override @Override
@@ -937,6 +1015,13 @@ final class PkiProofGateE2eTest {
INCOMPATIBLE_KEY_USAGE 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 static final class CountingIssuerBackend implements CredentialIssuerBackend {
private final CredentialIssuerBackend delegate; private final CredentialIssuerBackend delegate;
@@ -950,9 +1035,9 @@ final class PkiProofGateE2eTest {
} }
@Override @Override
public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) { public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, EncodedObject issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) {
endEntityCalls.incrementAndGet(); endEntityCalls.incrementAndGet();
return delegate.issueEndEntity(candidate); return delegate.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial);
} }
@Override @Override
@@ -967,8 +1052,8 @@ final class PkiProofGateE2eTest {
IntermediateExtensionVariant variant) { IntermediateExtensionVariant variant) {
return new CredentialIssuerBackend() { return new CredentialIssuerBackend() {
@Override @Override
public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) { public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, EncodedObject issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) {
return delegate.issueEndEntity(candidate); return delegate.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial);
} }
@Override @Override
@@ -979,6 +1064,45 @@ final class PkiProofGateE2eTest {
}; };
} }
private static CredentialIssuerBackend bindingMutationBackend(CredentialIssuerBackend delegate,
BindingVariantMutation mutation, AtomicReference<Credential> 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, private static Credential rebuildIntermediateIdentity(Credential credential, KeyPair issuerKey,
Optional<PublicKey> subjectPublicKey, Optional<String> subjectName) { Optional<PublicKey> subjectPublicKey, Optional<String> subjectName) {
try { try {
@@ -997,7 +1121,7 @@ final class PkiProofGateE2eTest {
return new Credential(new PkiId("x509:" + sha256Hex(encoded)), credential.formatId(), return new Credential(new PkiId("x509:" + sha256Hex(encoded)), credential.formatId(),
credential.issuerRef(), new SubjectRef(subject.toString()), credential.validity(), credential.issuerRef(), new SubjectRef(subject.toString()), credential.validity(),
credential.serialOrUniqueId(), new PkiId("spki:" + sha256Hex(publicKeyInfo.getEncoded())), 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()); credential.attributes());
} catch (Exception ex) { } catch (Exception ex) {
throw new PkiException("Failed to create adversarial intermediate identity", ex); throw new PkiException("Failed to create adversarial intermediate identity", ex);
@@ -1030,7 +1154,7 @@ final class PkiProofGateE2eTest {
byte[] encoded = builder.build(signer).getEncoded(); byte[] encoded = builder.build(signer).getEncoded();
return new Credential(new PkiId("x509:" + sha256Hex(encoded)), credential.formatId(), return new Credential(new PkiId("x509:" + sha256Hex(encoded)), credential.formatId(),
credential.issuerRef(), credential.subjectRef(), credential.validity(), 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()); credential.status(), new EncodedObject(Encoding.DER, encoded), credential.attributes());
} catch (Exception ex) { } catch (Exception ex) {
throw new PkiException("Failed to create adversarial intermediate certificate", 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) { private static void issue(PkiTestRuntime runtime, PkiId issuerCaId, ParsedCertificationRequest request) {
runtime.issuanceService().issueEndEntity(new IssueEndEntityCommand(issuerCaId, request, "default", runtime.issuanceService().issueEndEntity(new IssueEndEntityCommand(issuerCaId, request, "default",
Optional.empty(), new SimpleAttributeSet())); Optional.empty()));
} }
private static AttributeSet hostileIntermediateAttributes(PublicKey wrongPublicKey) { private static AttributeSet hostileIntermediateAttributes(PublicKey wrongPublicKey) {

View File

@@ -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<CertificateProfileRef> 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<Attempt> 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<CertificateProfileRef> 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 <T> List<T> runConcurrently(Callable<T> first, Callable<T> second) throws Exception {
CyclicBarrier barrier = new CyclicBarrier(2);
ExecutorService executor = Executors.newFixedThreadPool(2);
try {
Future<T> firstResult = executor.submit(() -> {
barrier.await();
return first.call();
});
Future<T> secondResult = executor.submit(() -> {
barrier.await();
return second.call();
});
return List.of(firstResult.get(), secondResult.get());
} finally {
executor.shutdownNow();
}
}
private static List<Attempt> runAttempts(Callable<CertificateProfileRef> first,
Callable<CertificateProfileRef> second) throws Exception {
CyclicBarrier barrier = new CyclicBarrier(2);
ExecutorService executor = Executors.newFixedThreadPool(2);
try {
Future<CertificateProfileRef> firstResult = executor.submit(() -> {
barrier.await();
return first.call();
});
Future<CertificateProfileRef> secondResult = executor.submit(() -> {
barrier.await();
return second.call();
});
return List.of(attempt(firstResult), attempt(secondResult));
} finally {
executor.shutdownNow();
}
}
private static Attempt attempt(Future<CertificateProfileRef> 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();
}
}
}

View File

@@ -6,6 +6,7 @@ package zeroecho.pki.impl.core;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; 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.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -16,7 +17,6 @@ import java.math.BigInteger;
import java.nio.file.Path; import java.nio.file.Path;
import java.security.KeyPair; import java.security.KeyPair;
import java.security.KeyPairGenerator; import java.security.KeyPairGenerator;
import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.ZoneOffset; import java.time.ZoneOffset;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
@@ -51,12 +51,12 @@ import zeroecho.pki.api.KeyRef;
import zeroecho.pki.api.PkiException; import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.PkiId; import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.SubjectRef; import zeroecho.pki.api.SubjectRef;
import zeroecho.pki.api.Validity;
import zeroecho.pki.api.attr.AttributeSet; import zeroecho.pki.api.attr.AttributeSet;
import zeroecho.pki.api.attr.AttributeValue; import zeroecho.pki.api.attr.AttributeValue;
import zeroecho.pki.api.ca.CaCreateCommand; import zeroecho.pki.api.ca.CaCreateCommand;
import zeroecho.pki.api.ca.CaRecord; import zeroecho.pki.api.ca.CaRecord;
import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.credential.CaProfileBinding;
import zeroecho.pki.api.credential.CredentialStatus; import zeroecho.pki.api.credential.CredentialStatus;
import zeroecho.pki.api.credential.CredentialUse; import zeroecho.pki.api.credential.CredentialUse;
import zeroecho.pki.api.credential.EffectiveCredentialStatus; 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) { 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(), return new Credential(new PkiId("credential:" + suffix), formatId, template.issuerRef(),
template.subjectRef(), template.validity(), template.serialOrUniqueId(), template.subjectRef(), template.validity(), template.serialOrUniqueId(),
template.publicKeyId(), template.profileId(), CredentialStatus.ISSUED, encoded, template.publicKeyId(), new CaProfileBinding(binding.profileId()), CredentialStatus.ISSUED, encoded,
template.attributes()); template.attributes());
} }
@@ -395,9 +396,7 @@ final class DefaultStatusObjectServiceCrlTest {
new VerificationPolicy(true, Optional.empty())); new VerificationPolicy(true, Optional.empty()));
IssuanceService issuance = runtime.issuanceService(); IssuanceService issuance = runtime.issuanceService();
return issuance.issueEndEntity(new IssueEndEntityCommand(caId, parsed, "default", return issuance.issueEndEntity(new IssueEndEntityCommand(caId, parsed, "default",
Optional.of(new Validity(EVALUATION_TIME.minus(Duration.ofDays(1)), Optional.empty())).credential();
EVALUATION_TIME.plus(Duration.ofDays(365)))),
emptyAttributes())).credential();
} }
private static PKCS10CertificationRequest certificationRequest(KeyPair pair, String commonName) private static PKCS10CertificationRequest certificationRequest(KeyPair pair, String commonName)

View File

@@ -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 <a@example.com>"));
}
@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> 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<LeafKeyUsage> usages, Set<String> algorithms,
Set<ExtendedKeyUsageId> 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;
}
}

View File

@@ -61,6 +61,7 @@ import zeroecho.pki.api.SubjectRef;
import zeroecho.pki.api.Validity; import zeroecho.pki.api.Validity;
import zeroecho.pki.api.audit.AuditEvent; import zeroecho.pki.api.audit.AuditEvent;
import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.credential.CaProfileBinding;
import zeroecho.pki.api.credential.CredentialStatus; import zeroecho.pki.api.credential.CredentialStatus;
import zeroecho.pki.api.credential.CredentialUse; import zeroecho.pki.api.credential.CredentialUse;
import zeroecho.pki.api.credential.EffectiveCredentialStatus; import zeroecho.pki.api.credential.EffectiveCredentialStatus;
@@ -175,7 +176,8 @@ final class StoreBackedEffectiveCredentialStatusResolverTest {
Credential credential = new Credential(new PkiId("credential:audit"), new FormatId(sentinel), Credential credential = new Credential(new PkiId("credential:audit"), new FormatId(sentinel),
new IssuerRef(new PkiId("ca:issuer")), new SubjectRef("CN=Audit"), new IssuerRef(new PkiId("ca:issuer")), new SubjectRef("CN=Audit"),
new Validity(NOW.minusSeconds(60), NOW.plusSeconds(60)), "audit", new PkiId("key: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()); new SimpleAttributeSet());
AtomicReference<AuditEvent> recorded = new AtomicReference<>(); AtomicReference<AuditEvent> recorded = new AtomicReference<>();
@@ -236,7 +238,8 @@ final class StoreBackedEffectiveCredentialStatusResolverTest {
private static Credential credential(String suffix, CredentialStatus status, Instant notBefore, Instant notAfter) { private static Credential credential(String suffix, CredentialStatus status, Instant notBefore, Instant notAfter) {
return new Credential(new PkiId("credential:" + suffix), new FormatId("x509"), return new Credential(new PkiId("credential:" + suffix), new FormatId("x509"),
new IssuerRef(new PkiId("ca:issuer")), new SubjectRef("CN=" + suffix), 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()); new EncodedObject(Encoding.DER, new byte[] { 1, 2, 3 }), new SimpleAttributeSet());
} }

View File

@@ -78,16 +78,19 @@ final class FilesystemPkiStoreOwnershipTest {
FilesystemPkiStore first = new FilesystemPkiStore(root, options); FilesystemPkiStore first = new FilesystemPkiStore(root, options);
try { 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); assertOwnershipRejected(root);
assertTrue(first.getProfile("ownership-profile").isPresent()); assertTrue(first.getActiveProfileRef("ownership-profile").isPresent());
} finally { } finally {
first.close(); first.close();
} }
first.close(); first.close();
try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, options)) { try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, options)) {
assertTrue(reopened.getProfile("ownership-profile").isPresent()); assertTrue(reopened.getActiveProfileRef("ownership-profile").isPresent());
} }
System.out.println("sameJvmOwnerExcludesSecondStoreAndReleasesOnClose...ok"); System.out.println("sameJvmOwnerExcludesSecondStoreAndReleasesOnClose...ok");
} }
@@ -99,12 +102,18 @@ final class FilesystemPkiStoreOwnershipTest {
FsPkiStoreOptions.defaults()); FsPkiStoreOptions.defaults());
FilesystemPkiStore second = new FilesystemPkiStore(tempDir.resolve("second"), FilesystemPkiStore second = new FilesystemPkiStore(tempDir.resolve("second"),
FsPkiStoreOptions.defaults())) { FsPkiStoreOptions.defaults())) {
first.putProfile(FilesystemPkiStoreTest.TestObjects.minimalProfile("first-profile", true)); FilesystemPkiStoreTest.importProfile(first,
second.putProfile(FilesystemPkiStoreTest.TestObjects.minimalProfile("second-profile", true)); FilesystemPkiStoreTest.TestObjects.minimalProfile("first-profile"),
assertTrue(first.getProfile("first-profile").isPresent()); java.time.Instant.now());
assertFalse(first.getProfile("second-profile").isPresent()); first.activateProfile("first-profile", 1);
assertTrue(second.getProfile("second-profile").isPresent()); FilesystemPkiStoreTest.importProfile(second,
assertFalse(second.getProfile("first-profile").isPresent()); 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"); System.out.println("separateStoreRootsRemainIndependent...ok");
} }

View File

@@ -34,6 +34,7 @@
package zeroecho.pki.impl.fs; package zeroecho.pki.impl.fs;
import static org.junit.jupiter.api.Assertions.assertEquals; 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.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue; 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.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.security.MessageDigest;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.Comparator; 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.CaRecord;
import zeroecho.pki.api.ca.CaState; import zeroecho.pki.api.ca.CaState;
import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.credential.CaProfileBinding;
import zeroecho.pki.api.credential.CredentialStatus; import zeroecho.pki.api.credential.CredentialStatus;
import zeroecho.pki.api.profile.CertificateProfile; 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.PolicyTrace;
import zeroecho.pki.api.policy.PolicyTraceStep; import zeroecho.pki.api.policy.PolicyTraceStep;
import zeroecho.pki.api.publication.PublicationRecord; import zeroecho.pki.api.publication.PublicationRecord;
@@ -127,7 +141,7 @@ public final class FilesystemPkiStoreTest {
PublicationRecord publication = new PublicationRecord(new PkiId("publication-all"), now, PublicationRecord publication = new PublicationRecord(new PkiId("publication-all"), now,
new PublicationTarget(PublicationTargetType.FILESYSTEM, "target-all", attributes), new PublicationTarget(PublicationTargetType.FILESYSTEM, "target-all", attributes),
credential.credentialId(), "CREDENTIAL", PublicationStatus.PUBLISHED); 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"), PolicyTrace trace = new PolicyTrace(new PkiId("decision-all"),
List.of(new PolicyTraceStep("rule-all", "ALLOW", List.of("approved")))); List.of(new PolicyTraceStep("rule-all", "ALLOW", List.of("approved"))));
WorkflowStateRecord workflow = new WorkflowStateRecord(new PkiId("workflow-all"), "ISSUANCE", WorkflowStateRecord workflow = new WorkflowStateRecord(new PkiId("workflow-all"), "ISSUANCE",
@@ -143,7 +157,8 @@ public final class FilesystemPkiStoreTest {
credential.credentialId(), RevocationReason.KEY_COMPROMISE, attributes), now); credential.credentialId(), RevocationReason.KEY_COMPROMISE, attributes), now);
store.putStatusObject(status); store.putStatusObject(status);
store.putPublicationRecord(publication); store.putPublicationRecord(publication);
store.putProfile(profile); importProfile(store, profile, now);
store.activateProfile(profile.profileId(), 1);
store.putPolicyTrace(trace); store.putPolicyTrace(trace);
store.putWorkflowState(workflow); store.putWorkflowState(workflow);
@@ -156,7 +171,7 @@ public final class FilesystemPkiStoreTest {
assertEquals(status.statusObjectId(), assertEquals(status.statusObjectId(),
store.getStatusObject(status.statusObjectId()).orElseThrow().statusObjectId()); store.getStatusObject(status.statusObjectId()).orElseThrow().statusObjectId());
assertEquals(publication.publicationId(), store.listPublicationRecords().get(0).publicationId()); 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(trace, store.getPolicyTrace(trace.decisionId()).orElseThrow());
assertEquals(workflow.opId(), store.getWorkflowState(workflow.opId()).orElseThrow().opId()); assertEquals(workflow.opId(), store.getWorkflowState(workflow.opId()).orElseThrow().opId());
} }
@@ -252,7 +267,7 @@ public final class FilesystemPkiStoreTest {
store.putCa(ca1); store.putCa(ca1);
Instant at = Instant.now(); 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); store.exportSnapshot(snapshot, at);
} }
@@ -282,8 +297,8 @@ public final class FilesystemPkiStoreTest {
CaRecord caA = TestObjects.minimalCaRecord("ca-a", CaState.ACTIVE); CaRecord caA = TestObjects.minimalCaRecord("ca-a", CaState.ACTIVE);
CaRecord caB = TestObjects.minimalCaRecord("ca-b", CaState.ACTIVE); CaRecord caB = TestObjects.minimalCaRecord("ca-b", CaState.ACTIVE);
CertificateProfile pA = TestObjects.minimalProfile("profile-a", true); CertificateProfile pA = TestObjects.minimalProfile("profile-a");
CertificateProfile pB = TestObjects.minimalProfile("profile-b", true); CertificateProfile pB = TestObjects.minimalProfile("profile-b");
Instant at1; Instant at1;
Instant at2; Instant at2;
@@ -295,12 +310,12 @@ public final class FilesystemPkiStoreTest {
sleepMillis(120L); sleepMillis(120L);
store.putCa(caB); store.putCa(caB);
store.putProfile(pA); importProfile(store, pA, Instant.now());
at2 = Instant.now(); at2 = Instant.now();
sleepMillis(120L); sleepMillis(120L);
store.putProfile(pB); importProfile(store, pB, Instant.now());
// Export snapshots after all writes; in non-strict mode export must not fail. // Export snapshots after all writes; in non-strict mode export must not fail.
store.exportSnapshot(snap1, at1); store.exportSnapshot(snap1, at1);
@@ -318,10 +333,11 @@ public final class FilesystemPkiStoreTest {
try (FilesystemPkiStore s2 = new FilesystemPkiStore(snap2, options)) { try (FilesystemPkiStore s2 = new FilesystemPkiStore(snap2, options)) {
List<CaRecord> cas = s2.listCas(); List<CaRecord> cas = s2.listCas();
List<CertificateProfile> profiles = s2.listProfiles(); List<ImportedCertificateProfileVersion> profiles = s2.listProfileVersions("profile-a");
Set<String> caIds = cas.stream().map(r -> r.caId().toString()).collect(Collectors.toSet()); Set<String> caIds = cas.stream().map(r -> r.caId().toString()).collect(Collectors.toSet());
Set<String> profileIds = profiles.stream().map(CertificateProfile::profileId).collect(Collectors.toSet()); Set<String> profileIds = profiles.stream().map(v -> v.reference().profileId())
.collect(Collectors.toSet());
System.out.println("...snapshot2 caIds: " + caIds); System.out.println("...snapshot2 caIds: " + caIds);
System.out.println("...snapshot2 profileIds: " + profileIds); System.out.println("...snapshot2 profileIds: " + profileIds);
@@ -329,7 +345,7 @@ public final class FilesystemPkiStoreTest {
assertTrue(caIds.contains("ca-a")); assertTrue(caIds.contains("ca-a"));
assertTrue(caIds.contains("ca-b")); assertTrue(caIds.contains("ca-b"));
assertTrue(profileIds.contains("profile-a")); assertTrue(profileIds.contains("profile-a"));
assertTrue(profileIds.contains("profile-b")); assertFalse(profileIds.contains("profile-b"));
} }
System.out.println("snapshotExportWithMultipleObjectsNonStrictDoesNotFail...ok"); System.out.println("snapshotExportWithMultipleObjectsNonStrictDoesNotFail...ok");
@@ -344,7 +360,6 @@ public final class FilesystemPkiStoreTest {
FsPkiStoreOptions options = strictSnapshotOptions(); FsPkiStoreOptions options = strictSnapshotOptions();
IllegalStateException ex;
try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) { try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) {
// Create CA first. // Create CA first.
store.putCa(TestObjects.minimalCaRecord("ca-strict-1", CaState.ACTIVE)); store.putCa(TestObjects.minimalCaRecord("ca-strict-1", CaState.ACTIVE));
@@ -354,14 +369,12 @@ public final class FilesystemPkiStoreTest {
sleepMillis(120L); sleepMillis(120L);
// Now create a new object that does not have history entry <= at. // Now create a new object that does not have history entry <= at.
store.putProfile(TestObjects.minimalProfile("profile-strict-1", true)); importProfile(store, TestObjects.minimalProfile("profile-strict-1"), Instant.now());
// Strict export must fail because current tree contains profile without history // Immutable versions imported after the requested time are omitted.
// <= at. store.exportSnapshot(snapshot, at);
ex = assertThrows(IllegalStateException.class, () -> store.exportSnapshot(snapshot, at));
} }
System.out.println("...exception: " + shorten(ex.toString(), 200));
System.out.println("...store tree:"); System.out.println("...store tree:");
dumpTree(root); dumpTree(root);
@@ -379,7 +392,7 @@ public final class FilesystemPkiStoreTest {
try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) { try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) {
store.putCa(TestObjects.minimalCaRecord("ca-strict-ok", CaState.ACTIVE)); store.putCa(TestObjects.minimalCaRecord("ca-strict-ok", CaState.ACTIVE));
store.putProfile(TestObjects.minimalProfile("profile-strict-ok", true)); importProfile(store, TestObjects.minimalProfile("profile-strict-ok"), Instant.now());
// "at" after all writes: strict export should succeed. // "at" after all writes: strict export should succeed.
Instant at = Instant.now(); Instant at = Instant.now();
@@ -394,8 +407,8 @@ public final class FilesystemPkiStoreTest {
try (FilesystemPkiStore snap = new FilesystemPkiStore(snapshot, options)) { try (FilesystemPkiStore snap = new FilesystemPkiStore(snapshot, options)) {
Set<String> caIds = snap.listCas().stream().map(r -> r.caId().toString()).collect(Collectors.toSet()); Set<String> caIds = snap.listCas().stream().map(r -> r.caId().toString()).collect(Collectors.toSet());
Set<String> profileIds = snap.listProfiles().stream().map(CertificateProfile::profileId) Set<String> profileIds = snap.listProfileVersions("profile-strict-ok").stream()
.collect(Collectors.toSet()); .map(v -> v.reference().profileId()).collect(Collectors.toSet());
System.out.println("...snapshot caIds: " + caIds); System.out.println("...snapshot caIds: " + caIds);
System.out.println("...snapshot profileIds: " + profileIds); System.out.println("...snapshot profileIds: " + profileIds);
@@ -512,6 +525,17 @@ public final class FilesystemPkiStoreTest {
/** /**
* Deterministic domain fixtures. * Deterministic domain fixtures.
*/ */
static void importProfile(FilesystemPkiStore store, CertificateProfile profile, Instant importedAt)
throws Exception {
CertificateProfileDefinition definition = new CertificateProfileDefinition(profile.profileId(), 1,
profile.formatId(), profile.displayName(), profile.leafPolicy());
byte[] canonical = CertificateProfileDocumentCodec.writeCanonical(definition);
byte[] hash = MessageDigest.getInstance("SHA-256").digest(canonical);
CertificateProfileRef reference = new CertificateProfileRef(profile.profileId(), 1, hash);
store.importProfileVersion(new ImportedCertificateProfileVersion(reference, 1, definition, canonical,
importedAt));
}
static final class TestObjects { static final class TestObjects {
private static final AtomicLong SEQ = new AtomicLong(1L); private static final AtomicLong SEQ = new AtomicLong(1L);
@@ -528,16 +552,17 @@ public final class FilesystemPkiStoreTest {
return new CaRecord(id, CaKind.ROOT, state, issuerKeyRef, subjectRef, caCredentials); return new CaRecord(id, CaKind.ROOT, state, issuerKeyRef, subjectRef, caCredentials);
} }
static CertificateProfile minimalProfile(String profileId, boolean active) { static CertificateProfile minimalProfile(String profileId) {
FormatId formatId = new FormatId("fmt-x509"); FormatId formatId = new FormatId("fmt-x509");
String displayName = "Profile " + profileId; SubjectPolicy subject = new SubjectPolicy(List.of(new SubjectRdnRule(
SubjectRdnType.COMMON_NAME, 1, 1, 256, Optional.empty(), true)));
List<AttributeId> required = List.of(new AttributeId("req-1")); SubjectAlternativeNamePolicy sans = new SubjectAlternativeNamePolicy(false, 0, 0,
List<AttributeId> optional = List.of(new AttributeId("opt-1")); List.of(), false, Set.of(), false, false, false);
LeafCertificatePolicy leaf = new LeafCertificatePolicy(subject, sans,
Optional<Duration> maxValidity = Optional.of(Duration.ofDays(365)); Set.of(LeafKeyUsage.DIGITAL_SIGNATURE, LeafKeyUsage.KEY_ENCIPHERMENT),
Set.of(new ExtendedKeyUsageId("1.3.6.1.5.5.7.3.1")), true, false, true,
return new CertificateProfile(profileId, formatId, displayName, required, optional, maxValidity, active); Set.of("RSA"), Duration.ofDays(365));
return new CertificateProfile(profileId, formatId, "Test leaf profile", leaf);
} }
static Credential minimalCredential(String serial, String profileId) { static Credential minimalCredential(String serial, String profileId) {
@@ -559,7 +584,7 @@ public final class FilesystemPkiStoreTest {
AttributeSet attrs = emptyAttributes(); AttributeSet attrs = emptyAttributes();
return new Credential(credentialId, formatId, issuerRef, subjectRef, validity, serial, publicKeyId, return new Credential(credentialId, formatId, issuerRef, subjectRef, validity, serial, publicKeyId,
profileId, status, encoded, attrs); new CaProfileBinding(profileId), status, encoded, attrs);
} }
static AttributeSet emptyAttributes() { static AttributeSet emptyAttributes() {

View File

@@ -0,0 +1,315 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.pki.impl.fs;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.profile.BuiltInCertificateProfileCatalog;
import zeroecho.pki.api.profile.BuiltInCertificateProfileTemplate;
import zeroecho.pki.api.profile.CertificateProfileRef;
import zeroecho.pki.api.profile.ImportedCertificateProfileVersion;
import zeroecho.pki.impl.audit.InMemoryAuditSink;
import zeroecho.pki.impl.core.DefaultProfileService;
/**
* Atomic persistence and fail-closed lookup evidence for profile lifecycle state.
*/
final class FilesystemProfileLifecycleTest {
private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-07-30T10:00:00Z"), ZoneOffset.UTC);
@Test
void precommitFailuresPreserveAbsentVersionAndPreviousActivePointer(@TempDir Path directory)
throws Exception {
Path root = directory.resolve("store");
BuiltInCertificateProfileTemplate template = builtIn("server-tls");
byte[] versionTwo = version(template, 2, "Server TLS v2");
try (FilesystemPkiStore store = store(root)) {
DefaultProfileService service = service(store);
CertificateProfileRef one = service.importBuiltIn(template);
service.activateProfile("server-tls", 1);
Path versionTemporary = new FsPaths(root).profileVersion("server-tls", 2)
.resolveSibling("2.bin.tmp");
Files.createDirectories(versionTemporary);
Files.writeString(versionTemporary.resolve("block"), "blocked");
assertThrows(PkiException.class, () -> service.importProfile(versionTwo));
assertTrue(service.getImportedVersion("server-tls", 2).isEmpty());
assertFalse(Files.exists(new FsPaths(root).profileVersion("server-tls", 2)));
Files.delete(versionTemporary.resolve("block"));
Files.delete(versionTemporary);
CertificateProfileRef two = service.importProfile(versionTwo);
Path pointerTemporary = new FsPaths(root).profileActive("server-tls")
.resolveSibling("active.bin.tmp");
Files.createDirectories(pointerTemporary);
Files.writeString(pointerTemporary.resolve("block"), "blocked");
assertThrows(PkiException.class, () -> service.activateProfile("server-tls", 2));
assertEquals(one, service.getActiveReference("server-tls").orElseThrow());
assertEquals(one, service.requireActiveProfile("server-tls").reference());
assertFalse(one.equals(two));
}
}
@Test
void corruptAndNoncanonicalVersionsFailClosedAndCannotBeOverwritten(@TempDir Path directory)
throws Exception {
BuiltInCertificateProfileTemplate template = builtIn("server-tls");
Path corruptRoot = directory.resolve("corrupt");
try (FilesystemPkiStore store = store(corruptRoot)) {
DefaultProfileService service = service(store);
service.importBuiltIn(template);
Path versionPath = new FsPaths(corruptRoot).profileVersion("server-tls", 1);
Files.write(versionPath, new byte[] { 1, 2, 3 });
byte[] corrupt = Files.readAllBytes(versionPath);
assertCode(() -> service.getImportedVersion("server-tls", 1), "PROFILE_VERSION_CORRUPT");
assertCode(() -> service.activateProfile("server-tls", 1), "PROFILE_VERSION_CORRUPT");
assertTrue(service.getActiveReference("server-tls").isEmpty());
assertCode(() -> service.importBuiltIn(template), "PROFILE_VERSION_CORRUPT");
assertArrayEquals(corrupt, Files.readAllBytes(versionPath));
}
Path noncanonicalRoot = directory.resolve("noncanonical");
try (FilesystemPkiStore store = store(noncanonicalRoot)) {
byte[] noncanonical = (new String(template.canonicalJson(), StandardCharsets.UTF_8) + "\n")
.getBytes(StandardCharsets.UTF_8);
ImportedCertificateProfileVersion invalid = new ImportedCertificateProfileVersion(
new CertificateProfileRef(template.definition().profileId(),
template.definition().profileVersion(), template.canonicalSha256()),
1, template.definition(), noncanonical, CLOCK.instant());
Path path = new FsPaths(noncanonicalRoot).profileVersion("server-tls", 1);
FsOperations.ensureDir(path.getParent());
Files.write(path, FsCodec.encode(FsCodec.PROFILE_VERSION, invalid));
assertCode(() -> store.getProfileVersion("server-tls", 1), "PROFILE_VERSION_CORRUPT");
assertCode(() -> service(store).importBuiltIn(template), "PROFILE_VERSION_CORRUPT");
}
Path hashMismatchRoot = directory.resolve("hash-mismatch");
try (FilesystemPkiStore store = store(hashMismatchRoot)) {
ImportedCertificateProfileVersion invalid = new ImportedCertificateProfileVersion(
new CertificateProfileRef(template.definition().profileId(),
template.definition().profileVersion(),
new byte[CertificateProfileRef.HASH_BYTES]),
1, template.definition(), template.canonicalJson(), CLOCK.instant());
Path path = new FsPaths(hashMismatchRoot).profileVersion("server-tls", 1);
FsOperations.ensureDir(path.getParent());
Files.write(path, FsCodec.encode(FsCodec.PROFILE_VERSION, invalid));
assertCode(() -> store.getProfileVersion("server-tls", 1), "PROFILE_HASH_MISMATCH");
assertCode(() -> service(store).importBuiltIn(template), "PROFILE_HASH_MISMATCH");
}
}
@Test
void unrelatedProfileIdsProceedWhileAnotherProfileCoordinatorIsHeld(@TempDir Path directory)
throws Exception {
try (FilesystemPkiStore store = store(directory.resolve("store"))) {
DefaultProfileService service = service(store);
Method acquire = FilesystemPkiStore.class.getDeclaredMethod("acquireProfileLock", String.class);
Method release = FilesystemPkiStore.class.getDeclaredMethod("releaseProfileLock",
String.class, Class.forName(
"zeroecho.pki.impl.fs.FilesystemPkiStore$ProfileLockEntry"));
acquire.setAccessible(true);
release.setAccessible(true);
CountDownLatch held = new CountDownLatch(1);
CountDownLatch releaseHeld = new CountDownLatch(1);
ExecutorService executor = Executors.newFixedThreadPool(2);
try {
Future<?> holder = executor.submit(() -> {
Object entry = invoke(acquire, store, "server-tls");
held.countDown();
try {
releaseHeld.await();
} finally {
invoke(release, store, "server-tls", entry);
}
return null;
});
assertTrue(held.await(5, TimeUnit.SECONDS));
Future<CertificateProfileRef> unrelated =
executor.submit(() -> service.importBuiltIn(builtIn("email-signing")));
CertificateProfileRef imported = unrelated.get(5, TimeUnit.SECONDS);
assertEquals("email-signing", imported.profileId());
assertTrue(service.getImportedVersion("email-signing", 1).isPresent());
assertFalse(holder.isDone());
releaseHeld.countDown();
holder.get(5, TimeUnit.SECONDS);
} finally {
releaseHeld.countDown();
executor.shutdownNow();
}
}
}
@Test
void missingCorruptAndHashMismatchedPointersNeverFallback(@TempDir Path directory) throws Exception {
BuiltInCertificateProfileTemplate template = builtIn("vpn-client");
Path root = directory.resolve("store");
try (FilesystemPkiStore store = store(root)) {
DefaultProfileService service = service(store);
CertificateProfileRef one = service.importBuiltIn(template);
service.importProfile(version(template, 2, "VPN Client v2"));
assertThrows(PkiException.class, () -> service.requireActiveProfile("vpn-client"));
assertTrue(service.getActiveReference("vpn-client").isEmpty());
Path pointer = new FsPaths(root).profileActive("vpn-client");
FsOperations.writeAtomicStrict(pointer, FsCodec.encode(FsCodec.ACTIVE_PROFILE_REF,
new CertificateProfileRef("vpn-client", 3, new byte[CertificateProfileRef.HASH_BYTES])));
assertActiveChainFailure(service, "vpn-client", "PROFILE_ACTIVE_POINTER_CORRUPT");
FsOperations.writeAtomicStrict(pointer, FsCodec.encode(FsCodec.ACTIVE_PROFILE_REF,
new CertificateProfileRef("vpn-client", 1, new byte[CertificateProfileRef.HASH_BYTES])));
assertActiveChainFailure(service, "vpn-client", "PROFILE_HASH_MISMATCH");
Files.write(pointer, new byte[] { 7, 8, 9 });
assertActiveChainFailure(service, "vpn-client", "PROFILE_ACTIVE_POINTER_CORRUPT");
FsOperations.writeAtomicStrict(pointer, FsCodec.encode(FsCodec.ACTIVE_PROFILE_REF, one));
Files.delete(new FsPaths(root).profileVersion("vpn-client", 1));
assertActiveChainFailure(service, "vpn-client", "PROFILE_ACTIVE_POINTER_CORRUPT");
}
}
@Test
void snapshotPreflightsCorruptProfileArtifactsAndPathIdentityBeforeOutput(@TempDir Path directory)
throws Exception {
BuiltInCertificateProfileTemplate template = builtIn("server-tls");
Path corruptRoot = directory.resolve("corrupt-store");
Path corruptTarget = directory.resolve("corrupt-snapshot");
try (FilesystemPkiStore store = store(corruptRoot)) {
service(store).importBuiltIn(template);
Path version = new FsPaths(corruptRoot).profileVersion("server-tls", 1);
Files.write(version, new byte[] { 1, 2, 3 });
IllegalStateException failure = assertThrows(IllegalStateException.class,
() -> store.exportSnapshot(corruptTarget, CLOCK.instant()));
assertEquals("Historical snapshot failed: code=PROFILE_VERSION_CORRUPT",
failure.getMessage());
assertEquals(null, failure.getCause());
assertEquals(0, failure.getSuppressed().length);
assertFalse(failure.getMessage().contains(corruptRoot.toString()));
assertFalse(failure.getMessage().contains("server-tls"));
assertFalse(Files.exists(corruptTarget));
}
Path pathRoot = directory.resolve("path-store");
Path pathTarget = directory.resolve("path-snapshot");
try (FilesystemPkiStore store = store(pathRoot)) {
service(store).importBuiltIn(template);
Path original = new FsPaths(pathRoot).profileVersion("server-tls", 1);
Path misplaced = pathRoot.resolve("profiles").resolve("by-id").resolve("id-wrong")
.resolve("versions").resolve("1.bin");
FsOperations.ensureDir(misplaced.getParent());
Files.write(misplaced, Files.readAllBytes(original));
IllegalStateException failure = assertThrows(IllegalStateException.class,
() -> store.exportSnapshot(pathTarget, CLOCK.instant()));
assertEquals("Historical snapshot failed: code=PROFILE_VERSION_CORRUPT",
failure.getMessage());
assertFalse(Files.exists(pathTarget));
}
Path invalidRoot = directory.resolve("invalid-root-store");
Path invalidTarget = directory.resolve("invalid-root-snapshot");
try (FilesystemPkiStore store = store(invalidRoot)) {
Files.writeString(invalidRoot.resolve("profiles"), "not-a-directory");
IllegalStateException failure = assertThrows(IllegalStateException.class,
() -> store.exportSnapshot(invalidTarget, CLOCK.instant()));
assertEquals("Historical snapshot failed: code=PROFILE_STORE_FAILURE",
failure.getMessage());
assertEquals(null, failure.getCause());
assertEquals(0, failure.getSuppressed().length);
assertFalse(Files.exists(invalidTarget));
}
}
private static byte[] version(BuiltInCertificateProfileTemplate template, int version, String displayName) {
return new String(template.canonicalJson(), StandardCharsets.UTF_8)
.replace("\"profileVersion\":1", "\"profileVersion\":" + version)
.replace("\"displayName\":\"" + template.definition().displayName() + "\"",
"\"displayName\":\"" + displayName + "\"")
.getBytes(StandardCharsets.UTF_8);
}
private static FilesystemPkiStore store(Path root) throws Exception {
return new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), CLOCK);
}
private static DefaultProfileService service(FilesystemPkiStore store) {
return new DefaultProfileService(store, CLOCK, new InMemoryAuditSink());
}
private static BuiltInCertificateProfileTemplate builtIn(String profileId) {
return BuiltInCertificateProfileCatalog.load(FilesystemProfileLifecycleTest.class.getClassLoader()).stream()
.filter(template -> profileId.equals(template.definition().profileId()))
.findFirst().orElseThrow();
}
private static void assertActiveChainFailure(DefaultProfileService service, String profileId, String code) {
PkiException referenceFailure = assertThrows(PkiException.class,
() -> service.getActiveReference(profileId));
PkiException profileFailure = assertThrows(PkiException.class,
() -> service.requireActiveProfile(profileId));
assertEquals("Profile lifecycle operation failed: code=" + code,
referenceFailure.getMessage());
assertEquals(referenceFailure.getMessage(), profileFailure.getMessage());
assertEquals(null, referenceFailure.getCause());
assertEquals(null, profileFailure.getCause());
assertEquals(0, referenceFailure.getSuppressed().length);
assertEquals(0, profileFailure.getSuppressed().length);
}
private static void assertCode(ThrowingOperation operation, String code) {
PkiException failure = assertThrows(PkiException.class, operation::run);
assertEquals("Profile lifecycle operation failed: code=" + code, failure.getMessage());
assertEquals(null, failure.getCause());
assertEquals(0, failure.getSuppressed().length);
}
@FunctionalInterface
private interface ThrowingOperation {
void run() throws Exception;
}
private static Object invoke(Method method, Object target, Object... arguments) {
try {
return method.invoke(target, arguments);
} catch (IllegalAccessException impossible) {
throw new IllegalStateException(impossible);
} catch (InvocationTargetException failure) {
Throwable cause = failure.getCause();
if (cause instanceof RuntimeException runtime) {
throw runtime;
}
throw new IllegalStateException(cause);
}
}
}

View File

@@ -48,6 +48,7 @@ import zeroecho.pki.api.attr.AttributeId;
import zeroecho.pki.api.attr.AttributeSet; import zeroecho.pki.api.attr.AttributeSet;
import zeroecho.pki.api.attr.AttributeValue; import zeroecho.pki.api.attr.AttributeValue;
import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.credential.CaProfileBinding;
import zeroecho.pki.api.credential.CredentialStatus; import zeroecho.pki.api.credential.CredentialStatus;
import zeroecho.pki.api.credential.EffectiveCredentialStatus; import zeroecho.pki.api.credential.EffectiveCredentialStatus;
import zeroecho.pki.api.revocation.RevocationCommand; import zeroecho.pki.api.revocation.RevocationCommand;
@@ -498,7 +499,7 @@ final class FilesystemRevocationJournalTest {
return new Credential(new PkiId("credential:" + suffix), new FormatId("x509"), return new Credential(new PkiId("credential:" + suffix), new FormatId("x509"),
new IssuerRef(new PkiId("ca:issuer")), new SubjectRef("CN=" + suffix), new IssuerRef(new PkiId("ca:issuer")), new SubjectRef("CN=" + suffix),
new Validity(TIME.minusSeconds(60), TIME.plusSeconds(60)), suffix, new Validity(TIME.minusSeconds(60), TIME.plusSeconds(60)), suffix,
new PkiId("key:" + suffix), "default", CredentialStatus.ISSUED, new PkiId("key:" + suffix), new CaProfileBinding("default"), CredentialStatus.ISSUED,
new EncodedObject(Encoding.DER, new byte[] { 1, 2, 3 }), new SimpleAttributeSet()); new EncodedObject(Encoding.DER, new byte[] { 1, 2, 3 }), new SimpleAttributeSet());
} }

View File

@@ -41,6 +41,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.Arrays; import java.util.Arrays;
@@ -55,13 +56,30 @@ import org.junit.jupiter.api.Test;
import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.Encoding; import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.FormatId; import zeroecho.pki.api.FormatId;
import zeroecho.pki.api.IssuerRef;
import zeroecho.pki.api.PkiId; import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.SubjectRef; import zeroecho.pki.api.SubjectRef;
import zeroecho.pki.api.Validity; import zeroecho.pki.api.Validity;
import zeroecho.pki.api.attr.AttributeId; import zeroecho.pki.api.attr.AttributeId;
import zeroecho.pki.api.attr.AttributeSet; import zeroecho.pki.api.attr.AttributeSet;
import zeroecho.pki.api.attr.AttributeValue; import zeroecho.pki.api.attr.AttributeValue;
import zeroecho.pki.api.credential.CaProfileBinding;
import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.credential.CredentialProfileBinding;
import zeroecho.pki.api.credential.CredentialStatus;
import zeroecho.pki.api.credential.EndEntityProfileBinding;
import zeroecho.pki.api.profile.CertificateProfile; import zeroecho.pki.api.profile.CertificateProfile;
import zeroecho.pki.api.profile.CertificateProfileDefinition;
import zeroecho.pki.api.profile.CertificateProfileDocumentCodec;
import zeroecho.pki.api.profile.CertificateProfileRef;
import zeroecho.pki.api.profile.ExtendedKeyUsageId;
import zeroecho.pki.api.profile.ImportedCertificateProfileVersion;
import zeroecho.pki.api.profile.LeafCertificatePolicy;
import zeroecho.pki.api.profile.LeafKeyUsage;
import zeroecho.pki.api.profile.SubjectAlternativeNamePolicy;
import zeroecho.pki.api.profile.SubjectPolicy;
import zeroecho.pki.api.profile.SubjectRdnRule;
import zeroecho.pki.api.profile.SubjectRdnType;
import zeroecho.pki.api.request.ParsedCertificationRequest; import zeroecho.pki.api.request.ParsedCertificationRequest;
import zeroecho.pki.impl.core.attr.SimpleAttributeSet; import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
@@ -77,10 +95,10 @@ final class FsCodecTest {
@Test @Test
void approvedSchemaRoundTripAndTypeBinding() { void approvedSchemaRoundTripAndTypeBinding() {
System.out.println("approvedSchemaRoundTripAndTypeBinding"); System.out.println("approvedSchemaRoundTripAndTypeBinding");
CertificateProfile profile = profile(); ImportedCertificateProfileVersion profile = profileVersion();
byte[] encoded = FsCodec.encode(FsCodec.CERTIFICATE_PROFILE, profile); byte[] encoded = FsCodec.encode(FsCodec.PROFILE_VERSION, profile);
CertificateProfile decoded = FsCodec.decode(FsCodec.CERTIFICATE_PROFILE, encoded); ImportedCertificateProfileVersion decoded = FsCodec.decode(FsCodec.PROFILE_VERSION, encoded);
System.out.println("...encoded length=" + encoded.length); System.out.println("...encoded length=" + encoded.length);
assertEquals(profile, decoded); assertEquals(profile, decoded);
@@ -91,7 +109,7 @@ final class FsCodecTest {
@Test @Test
void currentEnvelopeRejectsUnknownOldAndCorruptedPayloads() { void currentEnvelopeRejectsUnknownOldAndCorruptedPayloads() {
System.out.println("currentEnvelopeRejectsUnknownOldAndCorruptedPayloads"); System.out.println("currentEnvelopeRejectsUnknownOldAndCorruptedPayloads");
byte[] valid = FsCodec.encode(FsCodec.CERTIFICATE_PROFILE, profile()); byte[] valid = FsCodec.encode(FsCodec.PROFILE_VERSION, profileVersion());
byte[] unsupportedVersion = valid.clone(); byte[] unsupportedVersion = valid.clone();
unsupportedVersion[VERSION_OFFSET] = (byte) (FsCodec.CURRENT_CODEC_VERSION + 1); unsupportedVersion[VERSION_OFFSET] = (byte) (FsCodec.CURRENT_CODEC_VERSION + 1);
@@ -182,7 +200,7 @@ final class FsCodecTest {
@Test @Test
void malformedPackedLengthAndOptionalMarkerFailClosed() { void malformedPackedLengthAndOptionalMarkerFailClosed() {
System.out.println("malformedPackedLengthAndOptionalMarkerFailClosed"); System.out.println("malformedPackedLengthAndOptionalMarkerFailClosed");
byte[] encoded = FsCodec.encode(FsCodec.CERTIFICATE_PROFILE, profile()); byte[] encoded = FsCodec.encode(FsCodec.PROFILE_VERSION, profileVersion());
byte[] malformedLength = encoded.clone(); byte[] malformedLength = encoded.clone();
malformedLength[FIRST_FIELD_TYPE_OFFSET + 1] = (byte) 0xFF; malformedLength[FIRST_FIELD_TYPE_OFFSET + 1] = (byte) 0xFF;
assertInvalid(malformedLength); assertInvalid(malformedLength);
@@ -192,6 +210,21 @@ final class FsCodecTest {
System.out.println("...ok"); System.out.println("...ok");
} }
@Test
void credentialProfileBindingDiscriminatorRoundTripsExactly() {
CertificateProfileRef reference = new CertificateProfileRef("profile-a", 1,
new byte[CertificateProfileRef.HASH_BYTES]);
List<CredentialProfileBinding> bindings = List.of(new EndEntityProfileBinding(reference),
new CaProfileBinding(reference.profileId()));
for (CredentialProfileBinding binding : bindings) {
Credential original = credential(binding);
Credential decoded = FsCodec.decode(FsCodec.CREDENTIAL, FsCodec.encode(FsCodec.CREDENTIAL, original));
assertEquals(binding, decoded.profileBinding());
assertEquals(binding.getClass(), decoded.profileBinding().getClass());
}
}
private static ParsedCertificationRequest roundTripRequest(AttributeSet attributes) { private static ParsedCertificationRequest roundTripRequest(AttributeSet attributes) {
byte[] encoded = FsCodec.encode(FsCodec.PARSED_REQUEST, request(attributes)); byte[] encoded = FsCodec.encode(FsCodec.PARSED_REQUEST, request(attributes));
return FsCodec.decode(FsCodec.PARSED_REQUEST, encoded); return FsCodec.decode(FsCodec.PARSED_REQUEST, encoded);
@@ -206,14 +239,42 @@ final class FsCodecTest {
} }
private static CertificateProfile profile() { private static CertificateProfile profile() {
return new CertificateProfile("profile-a", new FormatId("x509"), "Profile A", SubjectPolicy subject = new SubjectPolicy(List.of(new SubjectRdnRule(
List.of(new AttributeId("required")), List.of(new AttributeId("optional")), SubjectRdnType.COMMON_NAME, 1, 1, 256, Optional.empty(), true)));
Optional.of(Duration.ofDays(365)), true); SubjectAlternativeNamePolicy sans = new SubjectAlternativeNamePolicy(false, 0, 0,
List.of(), false, java.util.Set.of(), false, false, false);
LeafCertificatePolicy leaf = new LeafCertificatePolicy(subject, sans,
java.util.Set.of(LeafKeyUsage.DIGITAL_SIGNATURE, LeafKeyUsage.KEY_ENCIPHERMENT),
java.util.Set.of(new ExtendedKeyUsageId("1.3.6.1.5.5.7.3.1")), true, false, true,
java.util.Set.of("RSA"), Duration.ofDays(365));
return new CertificateProfile("profile-a", new FormatId("x509"), "Test leaf profile", leaf);
}
private static Credential credential(CredentialProfileBinding binding) {
return new Credential(new PkiId("credential-1"), new FormatId("x509"),
new IssuerRef(new PkiId("ca-1")), new SubjectRef("subject-1"),
new Validity(Instant.EPOCH, Instant.EPOCH.plusSeconds(1)), "1",
new PkiId("spki-1"), binding, CredentialStatus.ISSUED,
new EncodedObject(Encoding.DER, new byte[] { 1 }), new SimpleAttributeSet());
}
private static ImportedCertificateProfileVersion profileVersion() {
try {
CertificateProfile profile = profile();
CertificateProfileDefinition definition = new CertificateProfileDefinition(profile.profileId(), 1,
profile.formatId(), profile.displayName(), profile.leafPolicy());
byte[] canonical = CertificateProfileDocumentCodec.writeCanonical(definition);
CertificateProfileRef reference = new CertificateProfileRef(profile.profileId(), 1,
MessageDigest.getInstance("SHA-256").digest(canonical));
return new ImportedCertificateProfileVersion(reference, 1, definition, canonical, Instant.EPOCH);
} catch (java.security.NoSuchAlgorithmException impossible) {
throw new IllegalStateException(impossible);
}
} }
private static void assertInvalid(byte[] encoded) { private static void assertInvalid(byte[] encoded) {
IllegalStateException failure = assertThrows(IllegalStateException.class, IllegalStateException failure = assertThrows(IllegalStateException.class,
() -> FsCodec.decode(FsCodec.CERTIFICATE_PROFILE, encoded)); () -> FsCodec.decode(FsCodec.PROFILE_VERSION, encoded));
assertTrue(failure.getMessage().contains("INVALID_CURRENT_PAYLOAD")); assertTrue(failure.getMessage().contains("INVALID_CURRENT_PAYLOAD"));
} }

View File

@@ -0,0 +1,236 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
******************************************************************************/
package zeroecho.pki.testkit;
import java.nio.charset.StandardCharsets;
import java.util.List;
/**
* Explicit strict JSON documents used by H7 issuance tests.
*
* <p>These fixtures deliberately contain document syntax and never construct a
* runtime profile or profile definition before entering the production import
* boundary.</p>
*/
public final class H7ProfileDocuments {
private static final String CN =
"{\"oid\":\"2.5.4.3\",\"source\":\"REQUESTER\",\"minimumOccurrences\":1,"
+ "\"maximumOccurrences\":1,\"maximumUtf8Bytes\":256}";
private static final String SUBJECT_EMAIL =
"{\"oid\":\"1.2.840.113549.1.9.1\",\"source\":\"REQUESTER\",\"minimumOccurrences\":1,"
+ "\"maximumOccurrences\":1,\"maximumUtf8Bytes\":256}";
private static final String DNS_ONE =
"{\"type\":\"DNS_NAME\",\"minimumOccurrences\":1,\"maximumOccurrences\":1,"
+ "\"wildcardAllowed\":false}";
private static final String IPV4_ONE =
"{\"type\":\"IP_ADDRESS\",\"minimumOccurrences\":1,\"maximumOccurrences\":1,"
+ "\"ipv4Allowed\":true,\"ipv6Allowed\":false}";
private static final String IPV6_ONE =
"{\"type\":\"IP_ADDRESS\",\"minimumOccurrences\":1,\"maximumOccurrences\":1,"
+ "\"ipv4Allowed\":false,\"ipv6Allowed\":true}";
private static final String IP_BOTH_ONE =
"{\"type\":\"IP_ADDRESS\",\"minimumOccurrences\":1,\"maximumOccurrences\":1,"
+ "\"ipv4Allowed\":true,\"ipv6Allowed\":true}";
private static final String URI_ONE =
"{\"type\":\"URI\",\"minimumOccurrences\":1,\"maximumOccurrences\":1,"
+ "\"allowedSchemes\":[\"https\"]}";
private static final String RFC822_ONE =
"{\"type\":\"RFC822_NAME\",\"minimumOccurrences\":1,\"maximumOccurrences\":1}";
private H7ProfileDocuments() {
}
/** @return default CN-only profile document */
public static byte[] defaultProfile() {
return profile("default", false, List.of(CN), 0, 0, false, false, false, List.of(), false,
List.of("1.3.6.1.5.5.7.3.1"));
}
/** @return imported but intentionally inactive profile document */
public static byte[] inactiveProfile() {
return profile("inactive", false, List.of(CN), 0, 0, false, false, false, List.of(), false,
serverAuth());
}
/** @return profile document using a deliberately mismatched format */
public static byte[] wrongFormatProfile() {
return profile("wrong-format", "wrong", false, List.of(CN), 0, 0, false, false, false, List.of(),
false, serverAuth());
}
/** @return profile document used to reject an excessive validity request */
public static byte[] excessiveValidityProfile() {
return profile("too-long", false, List.of(CN), 0, 0, false, false, false, List.of(), false,
serverAuth());
}
/** @return one-DNS profile document */
public static byte[] dnsProfile() {
return profile("h7-dns", false, List.of(CN), 1, 1, true, false, false, List.of(DNS_ONE), false,
serverAuth());
}
/** @return multiple-DNS profile document */
public static byte[] multipleDnsProfile() {
return profile("h7-dns-multiple", false, List.of(CN), 1, 3, true, false, false,
List.of("{\"type\":\"DNS_NAME\",\"minimumOccurrences\":1,\"maximumOccurrences\":3,"
+ "\"wildcardAllowed\":false}"), false, serverAuth());
}
/** @return wildcard-DNS profile document */
public static byte[] wildcardDnsProfile() {
return profile("h7-wildcard", false, List.of(CN), 1, 1, true, false, false,
List.of("{\"type\":\"DNS_NAME\",\"minimumOccurrences\":1,\"maximumOccurrences\":1,"
+ "\"wildcardAllowed\":true}"), false, serverAuth());
}
/** @return empty-subject DNS profile document */
public static byte[] emptySubjectDnsProfile() {
return profile("h7-dns-empty", true, List.of(), 1, 1, true, false, false, List.of(DNS_ONE), false,
serverAuth());
}
/** @return critical nonempty-subject DNS profile document */
public static byte[] criticalDnsProfile() {
return profile("h7-dns-critical", false, List.of(CN), 1, 1, true, false, true, List.of(DNS_ONE), false,
serverAuth());
}
/** @return one IPv4-or-IPv6 profile document */
public static byte[] ipProfile() {
return profile("h7-ip", false, List.of(CN), 1, 1, true, false, false, List.of(IP_BOTH_ONE), false,
serverAuth());
}
/** @return exact mixed IPv4/IPv6 profile document */
public static byte[] mixedIpProfile() {
String rule = "{\"type\":\"IP_ADDRESS\",\"minimumOccurrences\":2,\"maximumOccurrences\":2,"
+ "\"ipv4Allowed\":true,\"ipv6Allowed\":true}";
return profile("h7-ip-mixed", false, List.of(CN), 2, 2, true, false, false, List.of(rule), false,
serverAuth());
}
/** @return empty-subject IP profile document */
public static byte[] emptySubjectIpProfile() {
return profile("h7-ip-empty", true, List.of(), 1, 1, true, false, false, List.of(IP_BOTH_ONE), false,
serverAuth());
}
/** @return IPv4-only profile document */
public static byte[] ipv4Profile() {
return profile("h7-ipv4", false, List.of(CN), 1, 1, true, false, false, List.of(IPV4_ONE), false,
serverAuth());
}
/** @return IPv6-only profile document */
public static byte[] ipv6Profile() {
return profile("h7-ipv6", false, List.of(CN), 1, 1, true, false, false, List.of(IPV6_ONE), false,
serverAuth());
}
/** @return one-URI profile document */
public static byte[] uriProfile() {
return profile("h7-uri", false, List.of(CN), 1, 1, true, false, false, List.of(URI_ONE), false,
serverAuth());
}
/** @return multiple-URI profile document */
public static byte[] multipleUriProfile() {
String rule = "{\"type\":\"URI\",\"minimumOccurrences\":1,\"maximumOccurrences\":3,"
+ "\"allowedSchemes\":[\"https\"]}";
return profile("h7-uri-multiple", false, List.of(CN), 1, 3, true, false, false, List.of(rule), false,
serverAuth());
}
/** @return empty-subject URI profile document */
public static byte[] emptySubjectUriProfile() {
return profile("h7-uri-empty", true, List.of(), 1, 1, true, false, false, List.of(URI_ONE), false,
serverAuth());
}
/** @return one-RFC822 profile document */
public static byte[] rfc822Profile() {
return profile("h7-rfc822", false, List.of(CN), 1, 1, false, true, false, List.of(RFC822_ONE), false,
serverAuth());
}
/** @return multiple-RFC822 profile document */
public static byte[] multipleRfc822Profile() {
String rule = "{\"type\":\"RFC822_NAME\",\"minimumOccurrences\":1,\"maximumOccurrences\":3}";
return profile("h7-rfc822-multiple", false, List.of(CN), 1, 3, false, true, false, List.of(rule), false,
serverAuth());
}
/** @return empty-subject RFC822 profile document */
public static byte[] emptySubjectRfc822Profile() {
return profile("h7-rfc822-empty", true, List.of(), 1, 1, false, true, false, List.of(RFC822_ONE), false,
serverAuth());
}
/** @return independent subject-email and RFC822-SAN profile document */
public static byte[] subjectEmailAndRfc822Profile() {
return profile("h7-subject-email-rfc822", false, List.of(CN, SUBJECT_EMAIL), 1, 1, false, true, false,
List.of(RFC822_ONE), false, serverAuth());
}
/** @return mixed supported-SAN profile document */
public static byte[] mixedSanProfile() {
return profile("h7-mixed", false, List.of(CN), 4, 4, true, true, false,
List.of(DNS_ONE, IP_BOTH_ONE, RFC822_ONE, URI_ONE), false, serverAuth());
}
/** @return adversarial-backend baseline profile document */
public static byte[] backendMutationProfile() {
String organization = "{\"oid\":\"2.5.4.10\",\"source\":\"REQUESTER\",\"minimumOccurrences\":1,"
+ "\"maximumOccurrences\":1,\"maximumUtf8Bytes\":256}";
return profile("h7-backend-mutation", false, List.of(CN, organization), 4, 4, true, true, false,
List.of(DNS_ONE, IP_BOTH_ONE, RFC822_ONE, URI_ONE), false,
List.of("1.3.6.1.5.5.7.3.1", "1.3.6.1.5.5.7.3.2"));
}
/** @return CN-only profile forbidding SAN and EKU */
public static byte[] noSanOrEkuProfile() {
return profile("h7-no-san-eku", false, List.of(CN), 0, 0, false, false, false, List.of(), false,
List.of());
}
private static List<String> serverAuth() {
return List.of("1.3.6.1.5.5.7.3.1");
}
private static byte[] profile(String id, boolean allowEmpty, List<String> subjectRules,
int minimumTotal, int maximumTotal, boolean serviceIdentity, boolean emailIdentity,
boolean critical, List<String> sanRules, boolean noKeyEncipherment, List<String> eku) {
return profile(id, "x509", allowEmpty, subjectRules, minimumTotal, maximumTotal, serviceIdentity,
emailIdentity, critical, sanRules, noKeyEncipherment, eku);
}
private static byte[] profile(String id, String formatId, boolean allowEmpty, List<String> subjectRules,
int minimumTotal, int maximumTotal, boolean serviceIdentity, boolean emailIdentity,
boolean critical, List<String> sanRules, boolean noKeyEncipherment, List<String> eku) {
String keyUsage = noKeyEncipherment
? "[\"DIGITAL_SIGNATURE\"]" : "[\"DIGITAL_SIGNATURE\",\"KEY_ENCIPHERMENT\"]";
String json = "{\"schemaVersion\":1,\"profileId\":\"" + id
+ "\",\"profileVersion\":1,\"formatId\":\"" + formatId
+ "\",\"displayName\":\"H7 Test Profile\","
+ "\"maxValidity\":\"PT8760H\",\"subject\":{\"allowEmpty\":" + allowEmpty
+ ",\"rules\":[" + String.join(",", subjectRules) + "]},"
+ "\"subjectAlternativeNames\":{\"minimumTotal\":" + minimumTotal
+ ",\"maximumTotal\":" + maximumTotal
+ ",\"serviceIdentityRequired\":" + serviceIdentity
+ ",\"emailIdentityRequired\":" + emailIdentity
+ ",\"criticalWhenSubjectNonEmpty\":" + critical
+ ",\"rules\":[" + String.join(",", sanRules) + "]},"
+ "\"leafCertificate\":{\"basicConstraintsCritical\":true,\"keyUsageCritical\":true,"
+ "\"keyUsage\":" + keyUsage + ",\"extendedKeyUsageCritical\":false,"
+ "\"extendedKeyUsage\":[" + quoted(eku) + "],\"allowedKeyAlgorithms\":[\"RSA\"]}}";
return json.getBytes(StandardCharsets.UTF_8);
}
private static String quoted(List<String> values) {
return values.stream().map(value -> "\"" + value + "\"")
.collect(java.util.stream.Collectors.joining(","));
}
}

View File

@@ -51,12 +51,14 @@ import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.IssuanceService; import zeroecho.pki.api.IssuanceService;
import zeroecho.pki.api.KeyRef; import zeroecho.pki.api.KeyRef;
import zeroecho.pki.api.RevocationService; import zeroecho.pki.api.RevocationService;
import zeroecho.pki.api.ProfileService;
import zeroecho.pki.api.StatusObjectService; import zeroecho.pki.api.StatusObjectService;
import zeroecho.pki.api.credential.EffectiveCredentialStatusResolver; import zeroecho.pki.api.credential.EffectiveCredentialStatusResolver;
import zeroecho.pki.impl.core.DefaultCaService; import zeroecho.pki.impl.core.DefaultCaService;
import zeroecho.pki.impl.core.DefaultCertificationRequestService; import zeroecho.pki.impl.core.DefaultCertificationRequestService;
import zeroecho.pki.impl.core.DefaultIssuanceService; import zeroecho.pki.impl.core.DefaultIssuanceService;
import zeroecho.pki.impl.core.DefaultRevocationService; import zeroecho.pki.impl.core.DefaultRevocationService;
import zeroecho.pki.impl.core.DefaultProfileService;
import zeroecho.pki.impl.core.DefaultStatusObjectService; import zeroecho.pki.impl.core.DefaultStatusObjectService;
import zeroecho.pki.impl.core.StoreBackedEffectiveCredentialStatusResolver; import zeroecho.pki.impl.core.StoreBackedEffectiveCredentialStatusResolver;
import zeroecho.pki.impl.core.async.PkiSigningBus; import zeroecho.pki.impl.core.async.PkiSigningBus;
@@ -92,6 +94,7 @@ public final class PkiTestRuntime implements AutoCloseable {
private final CredentialFramework framework; private final CredentialFramework framework;
private final CredentialIssuerBackend issuerBackend; private final CredentialIssuerBackend issuerBackend;
private final EffectiveCredentialStatusResolver statusResolver; private final EffectiveCredentialStatusResolver statusResolver;
private final ProfileService profileService;
private final CaService caService; private final CaService caService;
private final CertificationRequestService certificationRequestService; private final CertificationRequestService certificationRequestService;
@@ -113,12 +116,15 @@ public final class PkiTestRuntime implements AutoCloseable {
this.issuerBackend = issuerBackend; this.issuerBackend = issuerBackend;
Clock clock = Clock.systemUTC(); Clock clock = Clock.systemUTC();
this.statusResolver = new StoreBackedEffectiveCredentialStatusResolver(store, clock); this.statusResolver = new StoreBackedEffectiveCredentialStatusResolver(store, clock);
this.profileService = new DefaultProfileService(store, clock, auditSink);
this.publicKeysByKeyRef = publicKeysByKeyRef; this.publicKeysByKeyRef = publicKeysByKeyRef;
this.publicKeyResolveHook = () -> { this.publicKeyResolveHook = () -> {
}; };
this.certificationRequestService = new DefaultCertificationRequestService(store, framework); this.certificationRequestService = new DefaultCertificationRequestService(store, framework);
this.issuanceService = new DefaultIssuanceService(store, framework, issuerBackend, auditSink, statusResolver); importAndActivate(H7ProfileDocuments.defaultProfile());
this.issuanceService = new DefaultIssuanceService(store, framework, issuerBackend, auditSink, statusResolver,
profileService, clock);
this.revocationService = new DefaultRevocationService(store, clock, auditSink); this.revocationService = new DefaultRevocationService(store, clock, auditSink);
this.statusObjectService = new DefaultStatusObjectService(store, framework, auditSink, statusResolver); this.statusObjectService = new DefaultStatusObjectService(store, framework, auditSink, statusResolver);
@@ -286,6 +292,19 @@ public final class PkiTestRuntime implements AutoCloseable {
return statusResolver; return statusResolver;
} }
/** @return production profile lifecycle service */
public ProfileService profileService() {
return profileService;
}
/**
* Imports and activates one strict JSON test document through production APIs.
*/
public void importAndActivate(byte[] document) {
zeroecho.pki.api.profile.CertificateProfileRef reference = profileService.importProfile(document);
profileService.activateProfile(reference.profileId(), reference.profileVersion());
}
public CaService caService() { public CaService caService() {
return caService; return caService;
} }
@@ -319,7 +338,7 @@ public final class PkiTestRuntime implements AutoCloseable {
public IssuanceService issuanceService(CredentialIssuerBackend backend, public IssuanceService issuanceService(CredentialIssuerBackend backend,
EffectiveCredentialStatusResolver resolver) { EffectiveCredentialStatusResolver resolver) {
return new DefaultIssuanceService(store, framework, Objects.requireNonNull(backend, "backend"), auditSink, return new DefaultIssuanceService(store, framework, Objects.requireNonNull(backend, "backend"), auditSink,
Objects.requireNonNull(resolver, "resolver")); Objects.requireNonNull(resolver, "resolver"), profileService, Clock.systemUTC());
} }
public RevocationService revocationService() { public RevocationService revocationService() {