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:
@@ -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')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 + "]";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() + "]";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 + "]";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
pki/src/main/java/zeroecho/pki/api/profile/LeafKeyUsage.java
Normal file
25
pki/src/main/java/zeroecho/pki/api/profile/LeafKeyUsage.java
Normal 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
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
32
pki/src/main/java/zeroecho/pki/api/request/SubjectRdn.java
Normal file
32
pki/src/main/java/zeroecho/pki/api/request/SubjectRdn.java
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"}]}
|
||||||
@@ -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"]}}
|
||||||
@@ -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"]}}
|
||||||
@@ -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"]}}
|
||||||
@@ -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"]}}
|
||||||
@@ -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() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
236
pki/src/test/java/zeroecho/pki/testkit/H7ProfileDocuments.java
Normal file
236
pki/src/test/java/zeroecho/pki/testkit/H7ProfileDocuments.java
Normal 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(","));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user