feat(pki-server): add public PKI repository API

Add the disclosure-controlled public certificate, chain, CRL and status
repository with capability-based unlisted access, bounded streaming,
conditional caching and isolated public execution resources.

Introduce authoritative issuer generations and explicit chain paths so
issuance bundles and stable public chain routes never rely on inferred
certificate ordering or runtime path guessing.
This commit is contained in:
2026-08-05 01:48:52 +02:00
parent 7328f075dd
commit c3bd3a33e9
58 changed files with 3950 additions and 311 deletions

View File

@@ -44,6 +44,8 @@ import zeroecho.pki.api.ca.CaRolloverCommand;
import zeroecho.pki.api.ca.CaState;
import zeroecho.pki.api.ca.IntermediateCertIssueCommand;
import zeroecho.pki.api.ca.IntermediateCreateCommand;
import zeroecho.pki.api.ca.IssuerChainPath;
import zeroecho.pki.api.ca.IssuerGeneration;
import zeroecho.pki.api.credential.Credential;
/**
@@ -179,4 +181,27 @@ public interface CaService {
* @throws PkiException if listing fails
*/
List<CaRecord> listCas(CaQuery query);
/** Returns one exact issuer generation owned by this PKI authority. */
IssuerGeneration getIssuerGeneration(PkiId issuerId);
/** Returns one exact immutable issuer chain path. */
IssuerChainPath getIssuerChainPath(PkiId pathId);
/** Lists all explicit paths for an exact issuer generation. */
List<IssuerChainPath> listIssuerChainPaths(PkiId issuerId);
/**
* Atomically selects the exact issuer generation and path used for future
* issuance and bundle construction.
*/
void selectIssuancePath(PkiId caId, PkiId issuerId, PkiId pathId, String reason);
/**
* Registers an additional explicit path for an existing issuer generation by
* appending one already-authoritative parent path.
*
* @return immutable registered path
*/
IssuerChainPath registerIssuerChainPath(PkiId caId, PkiId issuerId, PkiId parentPathId);
}

View File

@@ -34,21 +34,38 @@
package zeroecho.pki.api;
/**
* References an issuing CA entity.
* References the exact issuer generation that produced a credential.
*
* @param caId identifier of the CA entity acting as issuer
* @param issuerId canonical issuer-generation identifier
* @param chainPathId exact issuance chain path selected for the credential
*/
public record IssuerRef(PkiId caId) {
public record IssuerRef(PkiId caId, PkiId issuerId, PkiId chainPathId) {
/**
* Creates a transient unresolved reference used only at framework adapter
* boundaries. Application services must replace it before persistence.
*
* @param caId logical issuer authority
*/
public IssuerRef(PkiId caId) {
this(caId, new PkiId("issuer-unresolved:" + caId.value()), new PkiId("path-unresolved:" + caId.value()));
}
/**
* Creates an issuer reference.
*
* @param caId CA identifier
* @throws IllegalArgumentException if {@code caId} is null
* @throws IllegalArgumentException if either identifier is {@code null}
*/
public IssuerRef {
if (caId == null) {
throw new IllegalArgumentException("caId must not be null");
}
if (issuerId == null) {
throw new IllegalArgumentException("issuerId must not be null");
}
if (chainPathId == null) {
throw new IllegalArgumentException("chainPathId must not be null");
}
}
}

View File

@@ -33,17 +33,14 @@
******************************************************************************/
package zeroecho.pki.api.ca;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import zeroecho.pki.api.KeyRef;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.SubjectRef;
/**
* Represents a CA entity and the ordered identifiers of its issued CA
* credentials.
* Represents one logical CA authority and its explicit issuer generations.
*
* <p>
* A CA entity may have multiple CA credentials to support:
@@ -60,12 +57,12 @@ import zeroecho.pki.api.SubjectRef;
* @param issuerKeyRef key reference used for issuing operations (private key
* reference)
* @param subjectRef normalized subject reference
* @param credentialIds ordered identifiers of the credentials currently
* associated with the entity (historical and active);
* duplicates and {@code null} elements are rejected
* @param issuerIds canonical issuer-generation identifiers owned by the authority
* @param currentIssuanceIssuerId exact generation selected for new issuance
* @param issuanceChainPathId exact chain path selected for issuance bundles
*/
public record CaRecord(PkiId caId, CaKind kind, CaState state, KeyRef issuerKeyRef, SubjectRef subjectRef,
List<PkiId> credentialIds) {
List<PkiId> issuerIds, PkiId currentIssuanceIssuerId, PkiId issuanceChainPathId) {
/**
* Creates a CA record.
@@ -90,18 +87,19 @@ public record CaRecord(PkiId caId, CaKind kind, CaState state, KeyRef issuerKeyR
if (subjectRef == null) {
throw new IllegalArgumentException("subjectRef must not be null");
}
if (credentialIds == null) {
throw new IllegalArgumentException("credentialIds must not be null");
if (issuerIds == null || issuerIds.isEmpty()) {
throw new IllegalArgumentException("issuerIds must not be null/empty");
}
Set<PkiId> uniqueCredentialIds = new HashSet<>(credentialIds.size());
for (PkiId credentialId : credentialIds) {
if (credentialId == null) {
throw new IllegalArgumentException("credentialIds must not contain null");
}
if (!uniqueCredentialIds.add(credentialId)) {
throw new IllegalArgumentException("credentialIds must not contain duplicates");
}
if (issuerIds.stream().anyMatch(java.util.Objects::isNull)
|| issuerIds.size() != new java.util.HashSet<>(issuerIds).size()) {
throw new IllegalArgumentException("issuerIds must contain unique non-null identifiers");
}
credentialIds = List.copyOf(credentialIds);
if (currentIssuanceIssuerId == null || !issuerIds.contains(currentIssuanceIssuerId)) {
throw new IllegalArgumentException("currentIssuanceIssuerId must identify an owned generation");
}
if (issuanceChainPathId == null) {
throw new IllegalArgumentException("issuanceChainPathId must not be null");
}
issuerIds = List.copyOf(issuerIds);
}
}

View File

@@ -0,0 +1,104 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.api.ca;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import zeroecho.pki.api.PkiId;
/**
* Immutable explicitly ordered issuer chain from selected issuer certificate to
* its trust-anchor certificate.
*/
public record IssuerChainPath(PkiId pathId, PkiId authorityId, PkiId issuerId,
List<PkiId> orderedCredentialIds, String pathCommitment) {
/** Maximum supported certificates in one explicit PKI path. */
public static final int MAX_CERTIFICATES = 32;
/** Creates and validates an immutable chain path. */
public IssuerChainPath {
Objects.requireNonNull(pathId, "pathId");
Objects.requireNonNull(authorityId, "authorityId");
Objects.requireNonNull(issuerId, "issuerId");
if (orderedCredentialIds == null || orderedCredentialIds.isEmpty()
|| orderedCredentialIds.size() > MAX_CERTIFICATES
|| orderedCredentialIds.stream().anyMatch(Objects::isNull)
|| orderedCredentialIds.size() != new HashSet<>(orderedCredentialIds).size()) {
throw new IllegalArgumentException("orderedCredentialIds must be nonempty, unique, and non-null");
}
orderedCredentialIds = List.copyOf(orderedCredentialIds);
String expected = commitmentFor(authorityId, issuerId, orderedCredentialIds);
if (!expected.equals(pathCommitment)) {
throw new IllegalArgumentException("pathCommitment does not match the ordered path");
}
if (!pathId.equals(idFor(expected))) {
throw new IllegalArgumentException("pathId does not match the path commitment");
}
}
/** Creates a canonical path from its exact ordered credential identities. */
public static IssuerChainPath create(PkiId authorityId, PkiId issuerId, List<PkiId> credentials) {
String commitment = commitmentFor(authorityId, issuerId, credentials);
return new IssuerChainPath(idFor(commitment), authorityId, issuerId, credentials, commitment);
}
/** Returns the canonical path identity for a path commitment. */
public static PkiId idFor(String commitment) {
if (commitment == null || !commitment.matches("[0-9a-f]{64}")) {
throw new IllegalArgumentException("commitment must be lowercase SHA-256 hexadecimal");
}
return new PkiId("path:" + commitment.substring(0, 32));
}
/** Returns the canonical commitment to authority, generation, and order. */
public static String commitmentFor(PkiId authorityId, PkiId issuerId, List<PkiId> credentials) {
Objects.requireNonNull(authorityId, "authorityId");
Objects.requireNonNull(issuerId, "issuerId");
Objects.requireNonNull(credentials, "credentials");
StringBuilder canonical = new StringBuilder(authorityId.value()).append('\n').append(issuerId.value());
credentials.forEach(id -> canonical.append('\n').append(Objects.requireNonNull(id, "credential").value()));
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(canonical.toString().getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException("SHA-256 unavailable", impossible);
}
}
}

View File

@@ -0,0 +1,84 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.api.ca;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Objects;
import zeroecho.pki.api.KeyRef;
import zeroecho.pki.api.PkiId;
/** Immutable authority record for one concrete CA issuer generation. */
public record IssuerGeneration(PkiId issuerId, PkiId authorityId, PkiId credentialId, KeyRef signingKeyRef,
IssuerGenerationState state, String profilePolicyCommitment, String x509BindingCommitment) {
/** Creates a validated issuer generation. */
public IssuerGeneration {
Objects.requireNonNull(issuerId, "issuerId");
Objects.requireNonNull(authorityId, "authorityId");
Objects.requireNonNull(credentialId, "credentialId");
Objects.requireNonNull(signingKeyRef, "signingKeyRef");
Objects.requireNonNull(state, "state");
requireCommitment(profilePolicyCommitment, "profilePolicyCommitment");
requireCommitment(x509BindingCommitment, "x509BindingCommitment");
if (!issuerId.equals(idFor(authorityId, credentialId))) {
throw new IllegalArgumentException("issuerId does not match the authority and credential");
}
}
/** Returns the canonical identity for an authority credential generation. */
public static PkiId idFor(PkiId authorityId, PkiId credentialId) {
Objects.requireNonNull(authorityId, "authorityId");
Objects.requireNonNull(credentialId, "credentialId");
return new PkiId("issuer:" + sha256(authorityId.value() + "\n" + credentialId.value()).substring(0, 32));
}
private static void requireCommitment(String value, String field) {
if (value == null || !value.matches("[0-9a-f]{64}")) {
throw new IllegalArgumentException(field + " must be lowercase SHA-256 hexadecimal");
}
}
private static String sha256(String value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException("SHA-256 unavailable", impossible);
}
}
}

View File

@@ -0,0 +1,46 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.api.ca;
/** Durable lifecycle state of one concrete CA issuer generation. */
public enum IssuerGenerationState {
/** Available for explicitly selected issuance. */
ACTIVE,
/** Retained for validation but unavailable for new issuance. */
RETIRED,
/** Known or suspected compromised generation. */
COMPROMISED,
/** Administratively disabled generation. */
DISABLED
}

View File

@@ -386,7 +386,8 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor {
Map<String, PkiOperationValue> fields = fields();
fields.put("credentialId", text(credential.credentialId().value()));
fields.put(FORMAT_ID, text(credential.formatId().value()));
fields.put("issuerId", text(credential.issuerRef().caId().value()));
fields.put("authorityId", text(credential.issuerRef().caId().value()));
fields.put("issuerId", text(credential.issuerRef().issuerId().value()));
fields.put("publicKeyId", text(credential.publicKeyId().value()));
fields.put("profileId", text(profile.profileId()));
fields.put("profileVersion", integer(profile.profileVersion()));
@@ -623,7 +624,8 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor {
fields.put("caId", text(record.caId().value()));
fields.put("kind", text(record.kind().name()));
fields.put("state", text(record.state().name()));
fields.put("credentialCount", integer(record.credentialIds().size()));
fields.put("issuerGenerationCount", integer(record.issuerIds().size()));
fields.put("currentIssuanceIssuerId", text(record.currentIssuanceIssuerId().value()));
return fields;
}

View File

@@ -0,0 +1,200 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.application;
import java.io.IOException;
import java.io.InputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import org.bouncycastle.cert.X509CRLHolder;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.asn1.ASN1InputStream;
import org.bouncycastle.asn1.x509.CertificateList;
import zeroecho.core.io.RepeatableContent;
import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.ca.CaRecord;
import zeroecho.pki.api.ca.IssuerChainPath;
import zeroecho.pki.api.ca.IssuerGeneration;
import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.status.StatusObject;
import zeroecho.pki.api.status.StatusObjectType;
import zeroecho.pki.impl.framework.x509.bc.BcX509CredentialFramework;
import zeroecho.pki.spi.store.PkiStore;
/** Store-backed read-only repository application service. */
final class DefaultPkiRepository implements PkiRepository {
private static final int MAX_PAGE = 1_000;
private final PkiStore store;
private final Runnable requireOpen;
/* default */ DefaultPkiRepository(PkiStore store, Runnable requireOpen) {
this.store = Objects.requireNonNull(store, "store");
this.requireOpen = Objects.requireNonNull(requireOpen, "requireOpen");
}
@Override public Optional<CaRecord> authority(PkiId authorityId) {
requireOpen.run();
return store.getCa(Objects.requireNonNull(authorityId, "authorityId"));
}
@Override public List<CaRecord> authorities(Optional<PkiId> afterAuthorityId, int limit) {
requireOpen.run();
Objects.requireNonNull(afterAuthorityId, "afterAuthorityId");
if (limit <= 0 || limit > MAX_PAGE) {
throw new IllegalArgumentException("limit must be between 1 and " + MAX_PAGE);
}
return store.listCasPage(afterAuthorityId, limit);
}
@Override public Optional<IssuerGeneration> issuer(PkiId issuerId) {
requireOpen.run();
return store.getIssuerGeneration(Objects.requireNonNull(issuerId, "issuerId"));
}
@Override public Optional<IssuerChainPath> chainPath(PkiId pathId) {
requireOpen.run();
return store.getIssuerChainPath(Objects.requireNonNull(pathId, "pathId"));
}
@Override public Optional<Credential> credential(PkiId credentialId) {
requireOpen.run();
return store.getCredential(Objects.requireNonNull(credentialId, "credentialId"));
}
@Override public Optional<StatusObject> statusObject(PkiId statusObjectId) {
requireOpen.run();
return store.getStatusObject(Objects.requireNonNull(statusObjectId, "statusObjectId"));
}
@SuppressWarnings("PMD.AvoidCatchingGenericException")
@Override public PkiRepositoryContent openCredential(PkiId credentialId) {
requireOpen.run();
Credential credential = credential(credentialId).orElseThrow(() -> new PkiException("Credential not found"));
if (!BcX509CredentialFramework.FORMAT_ID.equals(credential.formatId())) {
throw new PkiException("Credential is not an X.509 repository object");
}
try {
RepeatableContent content = store.stagedContent().openContent(credential.content());
try {
validateCertificate(content, credential.content().length(), credential.content().sha256());
} catch (IOException | RuntimeException failure) {
closeAfterValidationFailure(content, failure);
throw failure;
}
return new PkiRepositoryContent(credential.credentialId(), credential.issuerRef().caId(),
PkiRepositoryContent.Role.CERTIFICATE, credential.content(),
content);
} catch (IOException exception) {
throw new PkiException("Credential content unavailable", exception);
}
}
@SuppressWarnings("PMD.AvoidCatchingGenericException")
@Override public PkiRepositoryContent openStatusObject(PkiId statusObjectId) {
requireOpen.run();
StatusObject object = statusObject(statusObjectId).orElseThrow(() -> new PkiException("Status object not found"));
PkiRepositoryContent.Role role = object.type() == StatusObjectType.CRL
|| object.type() == StatusObjectType.DELTA_CRL ? PkiRepositoryContent.Role.CRL
: PkiRepositoryContent.Role.STATUS_OBJECT;
try {
RepeatableContent content = store.stagedContent().openContent(object.content());
try {
if (role == PkiRepositoryContent.Role.CRL) {
validateCrl(content, object.content().length(), object.content().sha256());
}
} catch (IOException | RuntimeException failure) {
closeAfterValidationFailure(content, failure);
throw failure;
}
return new PkiRepositoryContent(object.statusObjectId(), object.issuerCaId(), role, object.content(),
content);
} catch (IOException exception) {
throw new PkiException("Status object content unavailable", exception);
}
}
private static void validateCertificate(RepeatableContent content, long expectedLength, String expectedDigest)
throws IOException {
try (InputStream input = content.openStream(); ASN1InputStream asn1 = new ASN1InputStream(input)) {
X509CertificateHolder holder = new X509CertificateHolder(
org.bouncycastle.asn1.x509.Certificate.getInstance(asn1.readObject()));
requireComplete(asn1);
requireCanonical(holder.getEncoded(), expectedLength, expectedDigest, "certificate");
}
}
private static void validateCrl(RepeatableContent content, long expectedLength, String expectedDigest)
throws IOException {
try (InputStream input = content.openStream(); ASN1InputStream asn1 = new ASN1InputStream(input)) {
X509CRLHolder holder = new X509CRLHolder(CertificateList.getInstance(asn1.readObject()));
requireComplete(asn1);
requireCanonical(holder.getEncoded(), expectedLength, expectedDigest, "CRL");
}
}
private static void requireComplete(ASN1InputStream input) throws IOException {
if (input.readObject() != null) {
throw new IOException("Repository DER has trailing input");
}
}
private static void closeAfterValidationFailure(RepeatableContent content, Throwable primary) {
try {
content.close();
} catch (IOException closeFailure) {
primary.addSuppressed(closeFailure);
}
}
private static void requireCanonical(byte[] canonical, long expectedLength, String expectedDigest, String role)
throws IOException {
try {
String digest = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonical));
if (canonical.length != expectedLength || !MessageDigest.isEqual(
digest.getBytes(java.nio.charset.StandardCharsets.US_ASCII),
expectedDigest.getBytes(java.nio.charset.StandardCharsets.US_ASCII))) {
throw new IOException("Repository " + role + " is not canonical DER");
}
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException("SHA-256 unavailable", impossible);
}
}
}

View File

@@ -116,6 +116,7 @@ final class DefaultPkiSession implements PkiSession {
private final X509AlgorithmBindingRegistry algorithmBindings;
private final PkiResourceScopeResolver resourceScopes;
private final PkiOperationExecutor operations;
private final PkiRepository repository;
private final AtomicBoolean closed = new AtomicBoolean();
private DefaultPkiSession(PkiSessionConfiguration configuration, PkiStore store, AuditSink audit,
@@ -134,10 +135,17 @@ final class DefaultPkiSession implements PkiSession {
this.signatureWorkflow = graph.signatureWorkflow();
this.algorithmBindings = Objects.requireNonNull(algorithmBindings, "algorithmBindings");
this.resourceScopes = new DefaultPkiResourceScopeResolver(store, this::requireOpen);
this.repository = new DefaultPkiRepository(store, this::requireOpen);
this.operations = new DefaultPkiOperationExecutor(configuration, store, profiles, revocations, authorities,
requests, issuance, statusObjects, publications, algorithmBindings, this::requireOpen);
}
@Override
public PkiRepository repository() {
requireOpen();
return repository;
}
/* default */ static PkiSession open(PkiSessionConfiguration configuration) {
return open(configuration, runtimeDependencies(configuration), Clock.systemUTC(), ProductionBootstrap.INSTANCE);
}

View File

@@ -0,0 +1,64 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.application;
import java.util.List;
import java.util.Optional;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.ca.CaRecord;
import zeroecho.pki.api.ca.IssuerChainPath;
import zeroecho.pki.api.ca.IssuerGeneration;
import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.status.StatusObject;
/** Read-only authoritative repository facade owned by one {@link PkiSession}. */
public interface PkiRepository {
/** Returns an exact logical authority. */
Optional<CaRecord> authority(PkiId authorityId);
/** Returns a finite canonical authority page after an optional exclusive key. */
List<CaRecord> authorities(Optional<PkiId> afterAuthorityId, int limit);
/** Returns an exact issuer generation. */
Optional<IssuerGeneration> issuer(PkiId issuerId);
/** Returns an exact immutable issuer chain path. */
Optional<IssuerChainPath> chainPath(PkiId pathId);
/** Returns an exact credential metadata record. */
Optional<Credential> credential(PkiId credentialId);
/** Returns an exact status-object metadata record. */
Optional<StatusObject> statusObject(PkiId statusObjectId);
/** Opens validated immutable certificate content. */
PkiRepositoryContent openCredential(PkiId credentialId);
/** Opens validated immutable status-object content. */
PkiRepositoryContent openStatusObject(PkiId statusObjectId);
}

View File

@@ -0,0 +1,87 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.application;
import java.io.IOException;
import java.io.InputStream;
import java.util.Objects;
import zeroecho.core.io.RepeatableContent;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.content.DurableContentReference;
/** Lifecycle-owned immutable repository content lease without store internals. */
public final class PkiRepositoryContent implements AutoCloseable {
/** Closed semantic role of public repository content. */
public enum Role {
/** X.509 certificate DER. */
CERTIFICATE,
/** X.509 certificate-revocation-list DER. */
CRL,
/** Other immutable status-object representation. */
STATUS_OBJECT
}
private final PkiId objectId;
private final PkiId authorityId;
private final Role role;
private final DurableContentReference reference;
private final RepeatableContent content;
/** Creates a validated application-layer content lease. */
/* default */ PkiRepositoryContent(PkiId objectId, PkiId authorityId, Role role,
DurableContentReference reference,
RepeatableContent content) {
this.objectId = Objects.requireNonNull(objectId, "objectId");
this.authorityId = Objects.requireNonNull(authorityId, "authorityId");
this.role = Objects.requireNonNull(role, "role");
this.reference = Objects.requireNonNull(reference, "reference");
this.content = Objects.requireNonNull(content, "content");
}
/** @return exact object identity */
public PkiId objectId() { return objectId; }
/** @return exact owning authority */
public PkiId authorityId() { return authorityId; }
/** @return semantic representation role */
public Role role() { return role; }
/** @return exact validated content length */
public long length() { return reference.length(); }
/** @return immutable SHA-256 content commitment */
public String sha256() { return reference.sha256(); }
/** Opens a new integrity-checking sequential content stream. */
public InputStream openStream() throws IOException { return content.openStream(); }
/** Releases provider-owned lease resources. */
@Override public void close() throws IOException { content.close(); }
}

View File

@@ -135,6 +135,9 @@ public interface PkiSession extends AutoCloseable {
/** @return shared typed operation executor owned by this session */
PkiOperationExecutor operations();
/** @return read-only authoritative repository facade owned by this session */
PkiRepository repository();
/**
* Closes services and backend resources in reverse construction order.
* Repeated calls are harmless; primary and suppressed failures are preserved.

View File

@@ -376,10 +376,13 @@ public final class DefaultCaService implements CaService {
PkiId credId = new PkiId("x509:" + sha256Hex(certDer));
PkiId caId = new PkiId("ca:" + sha256Hex(certDer).substring(0, 16));
PkiId issuerId = zeroecho.pki.api.ca.IssuerGeneration.idFor(caId, credId);
zeroecho.pki.api.ca.IssuerChainPath rootPath = IssuerAuthorities.rootPath(caId, issuerId, credId);
PkiId publicKeyId = new PkiId("spki:" + sha256Hex(spki.bytes()));
Credential credential = new Credential(credId, command.formatId(), new IssuerRef(caId), request.subjectRef(),
Credential credential = new Credential(credId, command.formatId(),
new IssuerRef(caId, issuerId, rootPath.pathId()), request.subjectRef(),
validity, serial.toString(), publicKeyId, new CaProfileBinding(request.profileReference()),
CredentialStatus.ISSUED, CredentialContent.stage(store, certDer),
SimpleAttributeSet.builder().build());
@@ -387,8 +390,10 @@ public final class DefaultCaService implements CaService {
requireCaCertificateMatches(credential, credential, request, caId, CREATE_ROOT_REJECTED, BACKEND_CRED_MISMATCH);
store.putCredential(credential);
store.putIssuerGeneration(IssuerAuthorities.generation(caId, keyRef, credential));
store.putIssuerChainPath(rootPath);
CaRecord ca = new CaRecord(caId, CaKind.ROOT, CaState.ACTIVE, keyRef, request.subjectRef(),
List.of(credential.credentialId()));
List.of(issuerId), issuerId, rootPath.pathId());
store.putCa(ca);
return caId;
}
@@ -448,6 +453,8 @@ public final class DefaultCaService implements CaService {
PkiId credId = new PkiId("x509:" + sha256Hex(certDer));
PkiId caId = new PkiId("ca:" + sha256Hex(certDer).substring(0, 16));
PkiId issuerId = zeroecho.pki.api.ca.IssuerGeneration.idFor(caId, credId);
zeroecho.pki.api.ca.IssuerChainPath rootPath = IssuerAuthorities.rootPath(caId, issuerId, credId);
byte[] spkiDer;
try {
@@ -464,7 +471,8 @@ public final class DefaultCaService implements CaService {
ValidatedCaCertificateRequest.Operation.IMPORT_ROOT, activeProfile, CertificateProfileKind.ROOT_CA,
command.formatId(), caId, caId, command.subjectRef(), spki, Optional.of(validity), evaluationTime,
Optional.empty(), serial, authority);
Credential credential = new Credential(credId, command.formatId(), new IssuerRef(caId), request.subjectRef(),
Credential credential = new Credential(credId, command.formatId(),
new IssuerRef(caId, issuerId, rootPath.pathId()), request.subjectRef(),
validity, serial.toString(), publicKeyId, new CaProfileBinding(request.profileReference()),
CredentialStatus.ISSUED, command.existingCaCredential(),
SimpleAttributeSet.builder().build());
@@ -473,8 +481,10 @@ public final class DefaultCaService implements CaService {
ROOT_CREDENTIAL_INVALID);
requireValidImportedRoot(command, holder);
store.putCredential(credential);
store.putIssuerGeneration(IssuerAuthorities.generation(caId, command.keyRef(), credential));
store.putIssuerChainPath(rootPath);
CaRecord ca = new CaRecord(caId, CaKind.ROOT, CaState.ACTIVE, command.keyRef(), request.subjectRef(),
List.of(credential.credentialId()));
List.of(issuerId), issuerId, rootPath.pathId());
store.putCa(ca);
return caId;
}
@@ -526,15 +536,14 @@ public final class DefaultCaService implements CaService {
CaRecord issuer = getCa(command.issuerCaId());
ensureActive(issuer, "issuer");
if (issuer.credentialIds().isEmpty()) {
throw new PkiException("Issuer CA has no credentials");
}
if (!framework.formatId().equals(command.formatId())) {
throw proofGate.rejection(CREATE_INT_REJECTED, command.formatId(), Optional.empty(), "FORMAT_UNSUPPORTED");
}
EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation();
Credential issuerCredential = CredentialSnapshots.copy(selectIssuerCredential(issuer, command.formatId(),
CredentialUse.INTERMEDIATE_ISSUER, statusEvaluation));
zeroecho.pki.api.ca.IssuerGeneration parentGeneration = IssuerAuthorities.current(store, issuer);
zeroecho.pki.api.ca.IssuerChainPath parentPath = IssuerAuthorities.issuancePath(store, issuer);
requireHistoricalCaProfile(issuerCredential,
issuer.kind() == CaKind.ROOT ? CertificateProfileKind.ROOT_CA : CertificateProfileKind.INTERMEDIATE_CA);
@@ -568,18 +577,28 @@ public final class DefaultCaService implements CaService {
}
requireCaBinding(backendCredential, issue.profileReference(), CREATE_INT_REJECTED, command.formatId(),
Optional.of(caId));
Credential cred;
Credential rawCredential;
try {
cred = CredentialSnapshots.copy(backendCredential);
rawCredential = CredentialSnapshots.copy(backendCredential);
} catch (RuntimeException ex) { // NOPMD - reject malformed or mutable framework output
throw proofGate.rejection(CREATE_INT_REJECTED, command.formatId(), Optional.of(caId),
BACKEND_CRED_MISMATCH);
}
requireCaCertificateMatches(cred, issuerCredential, issue, caId, CREATE_INT_REJECTED, BACKEND_CRED_MISMATCH);
requireCaCertificateMatches(rawCredential, issuerCredential, issue, caId, CREATE_INT_REJECTED,
BACKEND_CRED_MISMATCH);
Credential cred = IssuerAuthorities.withIssuer(rawCredential,
new IssuerRef(issuer.caId(), parentGeneration.issuerId(), parentPath.pathId()));
store.putCredential(cred);
zeroecho.pki.api.ca.IssuerGeneration subjectGeneration = IssuerAuthorities.generation(caId,
command.keyRef().orElseThrow(), cred);
zeroecho.pki.api.ca.IssuerChainPath subjectPath = IssuerAuthorities.childPath(caId,
subjectGeneration.issuerId(), cred.credentialId(), parentPath);
store.putIssuerGeneration(subjectGeneration);
store.putIssuerChainPath(subjectPath);
CaRecord subject = new CaRecord(caId, CaKind.INTERMEDIATE, CaState.ACTIVE, command.keyRef().get(),
issue.subjectRef(), List.of(cred.credentialId()));
issue.subjectRef(), List.of(subjectGeneration.issuerId()), subjectGeneration.issuerId(),
subjectPath.pathId());
store.putCa(subject);
return caId;
}
@@ -631,6 +650,8 @@ public final class DefaultCaService implements CaService {
EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation();
Credential issuerCredential = CredentialSnapshots.copy(selectIssuerCredential(issuer, command.formatId(),
CredentialUse.INTERMEDIATE_ISSUER, statusEvaluation));
zeroecho.pki.api.ca.IssuerGeneration parentGeneration = IssuerAuthorities.current(store, issuer);
zeroecho.pki.api.ca.IssuerChainPath parentPath = IssuerAuthorities.issuancePath(store, issuer);
requireHistoricalCaProfile(issuerCredential,
issuer.kind() == CaKind.ROOT ? CertificateProfileKind.ROOT_CA : CertificateProfileKind.INTERMEDIATE_CA);
@@ -661,21 +682,30 @@ public final class DefaultCaService implements CaService {
}
requireCaBinding(backendCredential, gated.profileReference(), ISSUE_INT_REJECTED, command.formatId(),
Optional.of(subject.caId()));
Credential cred;
Credential rawCredential;
try {
cred = CredentialSnapshots.copy(backendCredential);
rawCredential = CredentialSnapshots.copy(backendCredential);
} 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);
}
requireCaCertificateMatches(cred, issuerCredential, gated, subject.caId(), ISSUE_INT_REJECTED,
requireCaCertificateMatches(rawCredential, issuerCredential, gated, subject.caId(), ISSUE_INT_REJECTED,
BACKEND_CRED_MISMATCH);
Credential cred = IssuerAuthorities.withIssuer(rawCredential,
new IssuerRef(issuer.caId(), parentGeneration.issuerId(), parentPath.pathId()));
store.putCredential(cred);
zeroecho.pki.api.ca.IssuerGeneration subjectGeneration = IssuerAuthorities.generation(subject.caId(),
subject.issuerKeyRef(), cred);
zeroecho.pki.api.ca.IssuerChainPath subjectPath = IssuerAuthorities.childPath(subject.caId(),
subjectGeneration.issuerId(), cred.credentialId(), parentPath);
store.putIssuerGeneration(subjectGeneration);
store.putIssuerChainPath(subjectPath);
List<PkiId> updated = new ArrayList<>(subject.credentialIds());
updated.add(cred.credentialId());
List<PkiId> updated = new ArrayList<>(subject.issuerIds());
updated.add(subjectGeneration.issuerId());
CaRecord updatedCa = new CaRecord(subject.caId(), subject.kind(), subject.state(), subject.issuerKeyRef(),
subject.subjectRef(), List.copyOf(updated));
subject.subjectRef(), List.copyOf(updated), subject.currentIssuanceIssuerId(),
subject.issuanceChainPathId());
store.putCa(updatedCa);
return cred;
}
@@ -758,7 +788,8 @@ public final class DefaultCaService implements CaService {
}
CaRecord updated = new CaRecord(existing.caId(), existing.kind(), state, existing.issuerKeyRef(),
existing.subjectRef(), existing.credentialIds());
existing.subjectRef(), existing.issuerIds(), existing.currentIssuanceIssuerId(),
existing.issuanceChainPathId());
store.putCa(updated);
if (LOG.isLoggable(Level.INFO)) {
@@ -810,17 +841,67 @@ public final class DefaultCaService implements CaService {
return false;
}
if (query.formatId().isPresent()) {
if (r.credentialIds().isEmpty()) {
return false;
}
PkiId lastId = r.credentialIds().get(r.credentialIds().size() - 1);
Credential last = requireCredential(lastId);
return query.formatId().get().equals(last.formatId());
Credential current = IssuerAuthorities.currentCredential(store, r);
return query.formatId().get().equals(current.formatId());
}
return true;
}).toList();
}
@Override
public zeroecho.pki.api.ca.IssuerGeneration getIssuerGeneration(PkiId issuerId) {
Objects.requireNonNull(issuerId, "issuerId");
return store.getIssuerGeneration(issuerId).orElseThrow(() -> new PkiException("Issuer generation not found"));
}
@Override
public zeroecho.pki.api.ca.IssuerChainPath getIssuerChainPath(PkiId pathId) {
Objects.requireNonNull(pathId, "pathId");
return store.getIssuerChainPath(pathId).orElseThrow(() -> new PkiException("Issuer chain path not found"));
}
@Override
public List<zeroecho.pki.api.ca.IssuerChainPath> listIssuerChainPaths(PkiId issuerId) {
return store.listIssuerChainPaths(Objects.requireNonNull(issuerId, "issuerId"));
}
@Override
public void selectIssuancePath(PkiId caId, PkiId issuerId, PkiId pathId, String reason) {
Objects.requireNonNull(caId, "caId");
Objects.requireNonNull(issuerId, "issuerId");
Objects.requireNonNull(pathId, "pathId");
if (reason == null || reason.isBlank()) {
throw new IllegalArgumentException("reason must not be null/blank");
}
CaRecord ca = getCa(caId);
zeroecho.pki.api.ca.IssuerGeneration generation = getIssuerGeneration(issuerId);
zeroecho.pki.api.ca.IssuerChainPath path = getIssuerChainPath(pathId);
if (!ca.issuerIds().contains(issuerId) || !caId.equals(generation.authorityId())
|| generation.state() != zeroecho.pki.api.ca.IssuerGenerationState.ACTIVE
|| !caId.equals(path.authorityId()) || !issuerId.equals(path.issuerId())) {
throw new PkiException("Issuance selection is outside the authority");
}
store.putCa(new CaRecord(ca.caId(), ca.kind(), ca.state(), ca.issuerKeyRef(), ca.subjectRef(),
ca.issuerIds(), issuerId, pathId));
}
@Override
public zeroecho.pki.api.ca.IssuerChainPath registerIssuerChainPath(PkiId caId, PkiId issuerId,
PkiId parentPathId) {
CaRecord ca = getCa(Objects.requireNonNull(caId, "caId"));
zeroecho.pki.api.ca.IssuerGeneration generation = getIssuerGeneration(
Objects.requireNonNull(issuerId, "issuerId"));
zeroecho.pki.api.ca.IssuerChainPath parent = getIssuerChainPath(
Objects.requireNonNull(parentPathId, "parentPathId"));
if (!ca.issuerIds().contains(issuerId) || !caId.equals(generation.authorityId())) {
throw new PkiException("Issuer generation is outside the authority");
}
zeroecho.pki.api.ca.IssuerChainPath path = IssuerAuthorities.childPath(caId, issuerId,
generation.credentialId(), parent);
store.putIssuerChainPath(path);
return path;
}
private static void ensureActive(CaRecord ca, String role) {
if (ca.state() != CaState.ACTIVE) {
throw new PkiException("CA not ACTIVE: " + role);
@@ -829,37 +910,17 @@ public final class DefaultCaService implements CaService {
private Credential selectIssuerCredential(CaRecord issuer, FormatId formatId, CredentialUse use,
EffectiveCredentialStatusResolver.Evaluation evaluation) {
Credential lastRejected = null;
EffectiveCredentialStatus lastStatus = null;
for (PkiId credentialId : issuer.credentialIds()) {
Credential credential = requireCredential(credentialId);
if (credential == null || !formatId.equals(credential.formatId())) {
continue;
}
EffectiveCredentialStatus status;
try {
status = evaluation.resolve(credential);
} catch (PkiException exception) {
CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), credential, use,
StoreBackedEffectiveCredentialStatusResolver.RESOLUTION_FAILED_CODE, null);
throw exception;
}
if (status == EffectiveCredentialStatus.USABLE) {
return credential;
}
lastRejected = credential;
lastStatus = status;
Credential credential = IssuerAuthorities.currentCredential(store, issuer);
if (!formatId.equals(credential.formatId())) {
throw new PkiException("Issuer credential unavailable: code=ISSUER_CREDENTIAL_UNAVAILABLE");
}
if (lastRejected != null) {
CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), lastRejected, use,
"ISSUER_CREDENTIAL_UNAVAILABLE", lastStatus);
EffectiveCredentialStatus status = evaluation.resolve(credential);
if (status != EffectiveCredentialStatus.USABLE) {
CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), credential, use,
"ISSUER_CREDENTIAL_UNAVAILABLE", status);
throw new PkiException("Issuer credential unavailable: code=ISSUER_CREDENTIAL_UNAVAILABLE");
}
throw new PkiException("Issuer credential unavailable: code=ISSUER_CREDENTIAL_UNAVAILABLE");
}
private Credential requireCredential(PkiId credentialId) {
return store.getCredential(credentialId)
.orElseThrow(() -> new PkiException("CA credential not found"));
return credential;
}
private void requireValidImportedRoot(CaImportCommand command, X509CertificateHolder holder) {
@@ -943,9 +1004,9 @@ public final class DefaultCaService implements CaService {
return framework.formatId().equals(credential.formatId()) && credential.content().encoding() == Encoding.DER
&& credential.status() == CredentialStatus.ISSUED
&& credential.subjectRef().equals(request.subjectRef())
&& credential.issuerRef()
.equals(new IssuerRef(request.certificateType() == CertificateProfileKind.ROOT_CA ? subjectCaId
: request.issuerCaId()));
&& credential.issuerRef().caId()
.equals(request.certificateType() == CertificateProfileKind.ROOT_CA ? subjectCaId
: request.issuerCaId());
}
private static boolean matchesCaCertificateIdentity(X509CertificateHolder holder,

View File

@@ -234,10 +234,6 @@ public final class DefaultIssuanceService implements IssuanceService {
if (issuer.state() != CaState.ACTIVE) {
throw new PkiException("Issuer CA not ACTIVE");
}
if (issuer.credentialIds().isEmpty()) {
throw new PkiException("Issuer CA has no credentials");
}
VerifiedIssuanceCandidate candidate = verifyIssuanceCandidate(command);
ActiveCertificateProfile active;
try {
@@ -257,6 +253,8 @@ public final class DefaultIssuanceService implements IssuanceService {
EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation();
Credential issuerCred = CredentialSnapshots.copy(selectIssuerCredential(issuer, framework.formatId(),
CredentialUse.END_ENTITY_ISSUER, statusEvaluation));
zeroecho.pki.api.ca.IssuerGeneration generation = IssuerAuthorities.current(store, issuer);
zeroecho.pki.api.ca.IssuerChainPath issuancePath = IssuerAuthorities.issuancePath(store, issuer);
ValidatedCertificateRequest validated;
try {
validated = CertificateProfileValidator.validate(candidate, profile, active.reference(), issuerCred,
@@ -274,8 +272,10 @@ public final class DefaultIssuanceService implements IssuanceService {
throw rejection(candidate.request(), "BACKEND_CREDENTIAL_MISMATCH");
}
requireIssuedCredentialMatches(validated, issuerCred, serial, bundle, candidate.request());
store.putCredential(bundle.credential());
return bundle;
Credential exactCredential = IssuerAuthorities.withIssuer(bundle.credential(),
new zeroecho.pki.api.IssuerRef(issuer.caId(), generation.issuerId(), issuancePath.pathId()));
store.putCredential(exactCredential);
return new CredentialBundle(exactCredential, pathContent(issuancePath));
}
/**
@@ -303,32 +303,17 @@ public final class DefaultIssuanceService implements IssuanceService {
Objects.requireNonNull(issuer, "issuer");
Objects.requireNonNull(formatId, "formatId");
Credential lastRejected = null;
EffectiveCredentialStatus lastStatus = null;
for (PkiId credentialId : issuer.credentialIds()) {
Credential c = requireIssuerCredential(credentialId);
if (c == null || !formatId.equals(c.formatId())) {
continue;
}
EffectiveCredentialStatus status;
try {
status = evaluation.resolve(c);
} catch (PkiException exception) {
CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), c, use,
StoreBackedEffectiveCredentialStatusResolver.RESOLUTION_FAILED_CODE, null);
throw exception;
}
if (status == EffectiveCredentialStatus.USABLE) {
return c;
}
lastRejected = c;
lastStatus = status;
Credential credential = IssuerAuthorities.currentCredential(store, issuer);
if (!formatId.equals(credential.formatId())) {
throw new PkiException("Issuer credential unavailable: code=ISSUER_CREDENTIAL_UNAVAILABLE");
}
if (lastRejected != null) {
CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), lastRejected, use,
"ISSUER_CREDENTIAL_UNAVAILABLE", lastStatus);
EffectiveCredentialStatus status = evaluation.resolve(credential);
if (status != EffectiveCredentialStatus.USABLE) {
CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), credential, use,
"ISSUER_CREDENTIAL_UNAVAILABLE", status);
throw new PkiException("Issuer credential unavailable: code=ISSUER_CREDENTIAL_UNAVAILABLE");
}
throw new PkiException("Issuer credential unavailable: code=ISSUER_CREDENTIAL_UNAVAILABLE");
return credential;
}
private Credential requireIssuerCredential(PkiId credentialId) {
@@ -459,7 +444,7 @@ public final class DefaultIssuanceService implements IssuanceService {
validated.profileReference());
if (!framework.formatId().equals(credential.formatId()) || credential.content().encoding() != Encoding.DER
|| !credential.subjectRef().equals(validated.subjectRef())
|| !credential.issuerRef().equals(new zeroecho.pki.api.IssuerRef(validated.issuerCaId()))
|| !credential.issuerRef().caId().equals(validated.issuerCaId())
|| credential.status() != CredentialStatus.ISSUED) {
throw rejection(auditRequest, "BACKEND_CREDENTIAL_MISMATCH");
}
@@ -585,16 +570,15 @@ public final class DefaultIssuanceService implements IssuanceService {
* credential.
*
* <p>
* This implementation currently returns a minimal bundle containing only the
* resolved leaf credential and an empty chain. Chain discovery, issuer path
* construction, and publication-aware bundle assembly are intentionally left to
* higher layers.
* This implementation returns the resolved leaf credential with the exact
* immutable chain path selected and persisted when the credential was issued.
* It never infers a path from collection order, dates, filenames, or the public
* repository's independently managed current-chain alias.
* </p>
*
* @param command bundle construction command identifying the leaf credential;
* must not be {@code null}
* @return minimal credential bundle containing the resolved leaf credential and
* no chain elements
* @return credential bundle containing the resolved leaf and its exact issuance path
* @throws NullPointerException if {@code command} is {@code null}
* @throws PkiException if the requested credential does not exist in
* the store or is not currently usable
@@ -619,8 +603,18 @@ public final class DefaultIssuanceService implements IssuanceService {
throw new PkiException(
"Credential trust rejected: code=" + StoreBackedEffectiveCredentialStatusResolver.NOT_USABLE_CODE);
}
// Minimal bundle: leaf only. Chain selection and publication are higher-layer
// concerns.
return new CredentialBundle(leaf, List.of());
zeroecho.pki.api.ca.IssuerChainPath path = store.getIssuerChainPath(leaf.issuerRef().chainPathId())
.orElseThrow(() -> new PkiException("Credential issuance chain path not found"));
if (!leaf.issuerRef().issuerId().equals(path.issuerId())
|| !leaf.issuerRef().caId().equals(path.authorityId())) {
throw new PkiException("Credential issuance chain path mismatch");
}
return new CredentialBundle(leaf, pathContent(path));
}
private List<zeroecho.pki.api.content.DurableContentReference> pathContent(
zeroecho.pki.api.ca.IssuerChainPath path) {
return path.orderedCredentialIds().stream().map(this::requireIssuerCredential)
.map(Credential::content).toList();
}
}

View File

@@ -214,9 +214,6 @@ public final class DefaultStatusObjectService implements StatusObjectService {
if (ca.state() != CaState.ACTIVE) {
throw new PkiException("Issuer CA not ACTIVE");
}
if (ca.credentialIds().isEmpty()) {
throw new PkiException("Issuer CA has no credentials");
}
EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation();
Credential issuerCred = selectIssuerCredential(ca, command, statusEvaluation);
@@ -488,35 +485,17 @@ public final class DefaultStatusObjectService implements StatusObjectService {
private Credential selectIssuerCredential(CaRecord ca, StatusObjectGenerateCommand command,
EffectiveCredentialStatusResolver.Evaluation evaluation) {
Credential lastRejected = null;
EffectiveCredentialStatus lastStatus = null;
List<PkiId> credentialIds = ca.credentialIds();
for (int index = credentialIds.size() - 1; index >= 0; index--) {
Credential credential = store.getCredential(credentialIds.get(index))
.orElseThrow(DefaultStatusObjectService::crlGenerationFailure);
if (credential == null || !command.formatId().equals(credential.formatId())) {
continue;
}
EffectiveCredentialStatus status;
try {
status = evaluation.resolve(credential);
} catch (PkiException exception) {
CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), credential,
CredentialUse.STATUS_OBJECT_ISSUER,
StoreBackedEffectiveCredentialStatusResolver.RESOLUTION_FAILED_CODE, null);
throw exception;
}
if (status == EffectiveCredentialStatus.USABLE) {
return credential;
}
lastRejected = credential;
lastStatus = status;
Credential credential = IssuerAuthorities.currentCredential(store, ca);
if (!command.formatId().equals(credential.formatId())) {
throw new PkiException("Issuer credential unavailable: code=ISSUER_CREDENTIAL_UNAVAILABLE");
}
if (lastRejected != null) {
CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), lastRejected,
CredentialUse.STATUS_OBJECT_ISSUER, "ISSUER_CREDENTIAL_UNAVAILABLE", lastStatus);
EffectiveCredentialStatus status = evaluation.resolve(credential);
if (status != EffectiveCredentialStatus.USABLE) {
CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), credential,
CredentialUse.STATUS_OBJECT_ISSUER, "ISSUER_CREDENTIAL_UNAVAILABLE", status);
throw new PkiException("Issuer credential unavailable: code=ISSUER_CREDENTIAL_UNAVAILABLE");
}
throw new PkiException("Issuer credential unavailable: code=ISSUER_CREDENTIAL_UNAVAILABLE");
return credential;
}
/**

View File

@@ -0,0 +1,110 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.impl.core;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import zeroecho.pki.api.IssuerRef;
import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.ca.CaRecord;
import zeroecho.pki.api.ca.IssuerChainPath;
import zeroecho.pki.api.ca.IssuerGeneration;
import zeroecho.pki.api.ca.IssuerGenerationState;
import zeroecho.pki.api.credential.CaProfileBinding;
import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.profile.CertificateProfileRef;
import zeroecho.pki.spi.store.PkiStore;
/** Exact issuer-generation and chain-path composition shared by PKI services. */
final class IssuerAuthorities {
private IssuerAuthorities() {
// utility
}
/* default */ static IssuerGeneration current(PkiStore store, CaRecord authority) {
IssuerGeneration generation = store.getIssuerGeneration(authority.currentIssuanceIssuerId())
.orElseThrow(() -> new PkiException("Current issuer generation not found"));
if (!authority.caId().equals(generation.authorityId()) || generation.state() != IssuerGenerationState.ACTIVE) {
throw new PkiException("Current issuer generation unavailable");
}
return generation;
}
/* default */ static IssuerChainPath issuancePath(PkiStore store, CaRecord authority) {
IssuerChainPath path = store.getIssuerChainPath(authority.issuanceChainPathId())
.orElseThrow(() -> new PkiException("Issuance chain path not found"));
if (!authority.caId().equals(path.authorityId())
|| !authority.currentIssuanceIssuerId().equals(path.issuerId())) {
throw new PkiException("Issuance chain selection mismatch");
}
return path;
}
/* default */ static Credential currentCredential(PkiStore store, CaRecord authority) {
IssuerGeneration generation = current(store, authority);
return store.getCredential(generation.credentialId())
.orElseThrow(() -> new PkiException("Current issuer credential not found"));
}
/* default */ static IssuerGeneration generation(PkiId authorityId, zeroecho.pki.api.KeyRef keyRef,
Credential credential) {
CertificateProfileRef profile = ((CaProfileBinding) credential.profileBinding()).reference();
String profileCommitment = HexFormat.of().formatHex(profile.canonicalSha256());
return new IssuerGeneration(IssuerGeneration.idFor(authorityId, credential.credentialId()), authorityId,
credential.credentialId(), keyRef, IssuerGenerationState.ACTIVE, profileCommitment,
profileCommitment);
}
/* default */ static Credential withIssuer(Credential credential, IssuerRef issuerRef) {
Objects.requireNonNull(credential, "credential");
return new Credential(credential.credentialId(), credential.formatId(), issuerRef, credential.subjectRef(),
credential.validity(), credential.serialOrUniqueId(), credential.publicKeyId(),
credential.profileBinding(), credential.status(), credential.content(), credential.attributes());
}
/* default */ static IssuerChainPath rootPath(PkiId authorityId, PkiId issuerId, PkiId credentialId) {
return IssuerChainPath.create(authorityId, issuerId, List.of(credentialId));
}
/* default */ static IssuerChainPath childPath(PkiId authorityId, PkiId issuerId, PkiId credentialId,
IssuerChainPath parentPath) {
List<PkiId> credentials = new java.util.ArrayList<>();
credentials.add(credentialId);
credentials.addAll(parentPath.orderedCredentialIds());
return IssuerChainPath.create(authorityId, issuerId, credentials);
}
}

View File

@@ -73,10 +73,16 @@ import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Stream;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder;
import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.ca.CaRecord;
import zeroecho.pki.api.ca.IssuerChainPath;
import zeroecho.pki.api.ca.IssuerGeneration;
import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.orch.SigningSubmissionId;
import zeroecho.pki.api.orch.WorkflowStateRecord;
@@ -167,12 +173,12 @@ import zeroecho.pki.spi.store.RevocationHistory;
*/
@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.CyclomaticComplexity", "PMD.TooManyMethods",
"PMD.ExcessivePublicCount", "PMD.UseTryWithResources", "PMD.ExceptionAsFlowControl",
"PMD.PreserveStackTrace" })
"PMD.PreserveStackTrace", "PMD.NcssCount" })
public final class FilesystemPkiStore implements PkiStore, Closeable {
private static final Logger LOG = Logger.getLogger(FilesystemPkiStore.class.getName());
/* package */ static final String CURRENT_STORE_VERSION = "v4";
/* package */ static final String CURRENT_STORE_VERSION = "v5";
private static final String SIGN_RECORD_NAMESPACE = "io.zeroecho.pki.signing-record";
private static final String SIGN_OWNER_NAMESPACE = "io.zeroecho.pki.signing-owner";
private static final String STATUS_RECORD_NAMESPACE = "io.zeroecho.pki.status-object-record";
@@ -580,6 +586,101 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
.toList();
}
@Override
public List<CaRecord> listCasPage(Optional<PkiId> afterCaId, int limit) {
requireStoreUsable();
Objects.requireNonNull(afterCaId, "afterCaId");
if (limit <= 0 || limit > 1_000) {
throw new IllegalArgumentException("limit must be between 1 and 1000");
}
Path root = paths.root().resolve("cas").resolve("by-id");
if (!Files.isDirectory(root)) {
return List.of();
}
String after = afterCaId.map(PkiId::value).orElse("");
List<CaRecord> selected = new ArrayList<>(limit);
try (java.nio.file.DirectoryStream<Path> entries = Files.newDirectoryStream(root)) {
for (Path entry : entries) {
Path current = entry.resolve(FsPaths.CURRENT_FILE);
if (!Files.isDirectory(entry) || !Files.isRegularFile(current)) {
continue;
}
CaRecord record = validateCaCredentialReferences(
FsCodec.decode(FsCodec.CA_RECORD, FsOperations.readAll(current), stagedContent));
if (record.caId().value().compareTo(after) <= 0) {
continue;
}
int position = java.util.Collections.binarySearch(selected, record,
Comparator.comparing(value -> value.caId().value()));
selected.add(position < 0 ? -position - 1 : position, record);
if (selected.size() > limit) {
selected.remove(limit);
}
}
return List.copyOf(selected);
} catch (IOException exception) {
throw new IllegalStateException("list CA page failed", exception);
}
}
@Override
public void putIssuerGeneration(IssuerGeneration generation) {
requireStoreUsable();
Objects.requireNonNull(generation, "generation");
Credential credential = getCredential(generation.credentialId())
.orElseThrow(() -> new IllegalStateException("Issuer credential reference is missing"));
if (!credential.credentialId().equals(generation.credentialId())) {
throw new IllegalStateException("Issuer credential identity mismatch");
}
writeOnce(paths.issuerGenerationPath(generation.issuerId()),
FsCodec.encode(FsCodec.ISSUER_GENERATION, generation), "ISSUER_GENERATION",
FsUtil.safeId(generation.issuerId()));
}
@Override
public Optional<IssuerGeneration> getIssuerGeneration(PkiId issuerId) {
requireStoreUsable();
Objects.requireNonNull(issuerId, "issuerId");
return readOptional(paths.issuerGenerationPath(issuerId), FsCodec.ISSUER_GENERATION)
.map(this::validateIssuerGeneration);
}
@Override
public List<IssuerGeneration> listIssuerGenerations(PkiId authorityId) {
requireStoreUsable();
Objects.requireNonNull(authorityId, "authorityId");
Path root = paths.root().resolve("issuer-generations").resolve("by-id");
return listBinaryRecords(root, FsCodec.ISSUER_GENERATION).stream().map(this::validateIssuerGeneration)
.filter(value -> authorityId.equals(value.authorityId())).toList();
}
@Override
public void putIssuerChainPath(IssuerChainPath path) {
requireStoreUsable();
Objects.requireNonNull(path, "path");
validateIssuerChainPath(path);
writeOnce(paths.issuerChainPath(path.pathId()), FsCodec.encode(FsCodec.ISSUER_CHAIN_PATH, path),
"ISSUER_CHAIN_PATH", FsUtil.safeId(path.pathId()));
}
@Override
public Optional<IssuerChainPath> getIssuerChainPath(PkiId pathId) {
requireStoreUsable();
Objects.requireNonNull(pathId, "pathId");
return readOptional(paths.issuerChainPath(pathId), FsCodec.ISSUER_CHAIN_PATH)
.map(this::validateIssuerChainPath);
}
@Override
public List<IssuerChainPath> listIssuerChainPaths(PkiId issuerId) {
requireStoreUsable();
Objects.requireNonNull(issuerId, "issuerId");
Path root = paths.root().resolve("issuer-chain-paths").resolve("by-id");
return listBinaryRecords(root, FsCodec.ISSUER_CHAIN_PATH).stream()
.map(this::validateIssuerChainPath)
.filter(value -> issuerId.equals(value.issuerId())).toList();
}
@Override
public void putCredential(final Credential credential) {
requireStoreUsable();
@@ -603,15 +704,112 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
}
}
@SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
private CaRecord validateCaCredentialReferences(CaRecord record) {
for (PkiId credentialId : record.credentialIds()) {
if (getCredential(credentialId).isEmpty()) {
throw new IllegalStateException("CA credential reference is missing");
for (PkiId issuerId : record.issuerIds()) {
IssuerGeneration generation = getIssuerGeneration(issuerId)
.orElseThrow(() -> new IllegalStateException("CA issuer-generation reference is missing"));
if (!record.caId().equals(generation.authorityId())) {
throw new IllegalStateException("CA issuer-generation authority mismatch");
}
}
IssuerChainPath issuancePath = getIssuerChainPath(record.issuanceChainPathId())
.orElseThrow(() -> new IllegalStateException("CA issuance chain path is missing"));
if (!record.caId().equals(issuancePath.authorityId())
|| !record.currentIssuanceIssuerId().equals(issuancePath.issuerId())) {
throw new IllegalStateException("CA issuance selection mismatch");
}
return record;
}
private IssuerGeneration validateIssuerGeneration(IssuerGeneration generation) {
getCredential(generation.credentialId())
.orElseThrow(() -> new IllegalStateException("Issuer credential reference is missing"));
return generation;
}
@SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
private IssuerChainPath validateIssuerChainPath(IssuerChainPath path) {
IssuerGeneration generation = getIssuerGeneration(path.issuerId())
.orElseThrow(() -> new IllegalStateException("Issuer generation is missing"));
if (!path.authorityId().equals(generation.authorityId())
|| !path.orderedCredentialIds().get(0).equals(generation.credentialId())) {
throw new IllegalStateException("Issuer chain path generation mismatch");
}
List<Credential> credentials = path.orderedCredentialIds().stream()
.map(id -> getCredential(id).orElseThrow(
() -> new IllegalStateException("Issuer chain credential is missing"))).toList();
long aggregate = 0L;
for (Credential credential : credentials) {
aggregate = Math.addExact(aggregate, credential.content().length());
if (aggregate > 32L * 1024L * 1024L) {
throw new IllegalStateException("Issuer chain exceeds the aggregate artifact limit");
}
}
try {
List<X509CertificateHolder> holders = new ArrayList<>(credentials.size());
for (Credential credential : credentials) {
holders.add(certificateHolder(credential));
}
for (int index = 0; index + 1 < credentials.size(); index++) {
Credential credential = credentials.get(index);
Credential parent = credentials.get(index + 1);
X509CertificateHolder holder = holders.get(index);
X509CertificateHolder parentHolder = holders.get(index + 1);
IssuerGeneration signingGeneration = getIssuerGeneration(credential.issuerRef().issuerId())
.orElseThrow(() -> new IllegalStateException("Issuer chain parent generation is missing"));
boolean parentGeneration = listIssuerGenerations(credential.issuerRef().caId()).stream()
.anyMatch(candidate -> parent.credentialId().equals(candidate.credentialId()));
if (!credential.issuerRef().caId().equals(signingGeneration.authorityId())
|| !parentGeneration
|| !holder.getIssuer().equals(parentHolder.getSubject())
|| !holder.isSignatureValid(new JcaContentVerifierProviderBuilder()
.build(parentHolder.getSubjectPublicKeyInfo()))) {
throw new IllegalStateException("Issuer chain relationship is invalid");
}
}
X509CertificateHolder anchor = holders.get(holders.size() - 1);
if (!anchor.getIssuer().equals(anchor.getSubject()) || !anchor.isSignatureValid(
new JcaContentVerifierProviderBuilder().build(anchor.getSubjectPublicKeyInfo()))) {
throw new IllegalStateException("Issuer chain trust anchor is invalid");
}
} catch (IllegalStateException exception) {
throw exception;
} catch (Exception exception) {
throw new IllegalStateException("Issuer chain validation failed", exception);
}
return path;
}
private X509CertificateHolder certificateHolder(Credential credential) throws IOException {
long length = credential.content().length();
if (length <= 0L || length > 1024L * 1024L || length > Integer.MAX_VALUE) {
throw new IllegalStateException("Issuer certificate exceeds the artifact limit");
}
byte[] encoded = new byte[(int) length];
try (RepeatableContent content = stagedContent.openContent(credential.content());
InputStream input = content.openStream()) {
int offset = 0;
while (offset < encoded.length) {
int count = input.read(encoded, offset, encoded.length - offset);
if (count < 0) {
throw new IOException("Issuer certificate is truncated");
}
offset += count;
}
if (input.read() >= 0) {
throw new IOException("Issuer certificate length changed");
}
X509CertificateHolder holder = new X509CertificateHolder(encoded);
if (!MessageDigest.isEqual(encoded, holder.getEncoded())) {
throw new IOException("Issuer certificate is not canonical DER");
}
return holder;
} finally {
Arrays.fill(encoded, (byte) 0);
}
}
@Override
public void putRequest(final ParsedCertificationRequest request) {
requireStoreUsable();
@@ -2650,6 +2848,22 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
}
}
private <T> List<T> listBinaryRecords(final Path byIdDir, final FsCodec.Schema<T> schema) {
if (!Files.isDirectory(byIdDir)) {
return List.of();
}
try (Stream<Path> files = Files.list(byIdDir)) {
List<T> records = new ArrayList<>();
for (Path file : files.filter(Files::isRegularFile)
.sorted(Comparator.comparing(path -> path.getFileName().toString())).toList()) {
records.add(FsCodec.decode(schema, FsOperations.readAll(file), stagedContent));
}
return List.copyOf(records);
} catch (IOException exception) {
throw new IllegalStateException("list immutable records failed", exception);
}
}
private static void writeOnce(final Path target, final byte[] data, final String kind, final String safeId) {
try {
FsOperations.ensureDir(target.getParent());

View File

@@ -64,6 +64,9 @@ import zeroecho.pki.api.audit.Principal;
import zeroecho.pki.api.ca.CaKind;
import zeroecho.pki.api.ca.CaRecord;
import zeroecho.pki.api.ca.CaState;
import zeroecho.pki.api.ca.IssuerChainPath;
import zeroecho.pki.api.ca.IssuerGeneration;
import zeroecho.pki.api.ca.IssuerGenerationState;
import zeroecho.pki.api.credential.CaProfileBinding;
import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.credential.CredentialProfileBinding;
@@ -112,7 +115,7 @@ import zeroecho.pki.spi.store.SignWorkflowStore;
final class FsCodec {
/* package */ static final int MAX_COMPONENT_BYTES = 256 * 1024;
/* package */ static final int CURRENT_CODEC_VERSION = 3;
/* package */ static final int CURRENT_CODEC_VERSION = 4;
private static final int CODEC_MAGIC = 0x5A454346;
private static final int MAX_COLLECTION_ELEMENTS = MAX_COMPONENT_BYTES;
@@ -127,6 +130,8 @@ final class FsCodec {
private static final int DURABLE_CONTENT_VERSION = 1;
private static final int TOP_PROFILE_VERSION = 11;
private static final int TOP_ACTIVE_PROFILE_REF = 12;
private static final int TOP_ISSUER_GENERATION = 13;
private static final int TOP_ISSUER_CHAIN_PATH = 14;
private static final int TYPE_STRING = 1;
private static final int TYPE_BOOLEAN = 2;
@@ -162,6 +167,7 @@ final class FsCodec {
private static final int TYPE_PROFILE_REF = 72;
private static final int TYPE_PROFILE_BINDING = 73;
private static final int TYPE_DURABLE_CONTENT = 74;
private static final int TYPE_ISSUER_GENERATION_STATE_ENUM = 75;
private static final int ATTRIBUTE_STRING = 1;
private static final int ATTRIBUTE_BOOLEAN = 2;
@@ -218,6 +224,19 @@ final class FsCodec {
case 4 -> CaState.DISABLED;
default -> throw unknownEnum("CaState", code);
});
private static final ValueSchema<IssuerGenerationState> ISSUER_GENERATION_STATE = enumSchema(
TYPE_ISSUER_GENERATION_STATE_ENUM, value -> switch (value) {
case ACTIVE -> 1;
case RETIRED -> 2;
case COMPROMISED -> 3;
case DISABLED -> 4;
}, code -> switch (code) {
case 1 -> IssuerGenerationState.ACTIVE;
case 2 -> IssuerGenerationState.RETIRED;
case 3 -> IssuerGenerationState.COMPROMISED;
case 4 -> IssuerGenerationState.DISABLED;
default -> throw unknownEnum("IssuerGenerationState", code);
});
private static final ValueSchema<CredentialStatus> CREDENTIAL_STATUS = enumSchema(TYPE_CREDENTIAL_STATUS_ENUM,
value -> switch (value) {
case ISSUED -> 1;
@@ -307,8 +326,11 @@ final class FsCodec {
(writer, value) -> writer.writeValue(STRING, value.value()),
reader -> new SubjectRef(reader.readValue(STRING)));
private static final ValueSchema<IssuerRef> ISSUER_REF = valueSchema(TYPE_ISSUER_REF,
(writer, value) -> writer.writeValue(PKI_ID, value.caId()),
reader -> new IssuerRef(reader.readValue(PKI_ID)));
(writer, value) -> {
writer.writeValue(PKI_ID, value.caId());
writer.writeValue(PKI_ID, value.issuerId());
writer.writeValue(PKI_ID, value.chainPathId());
}, reader -> new IssuerRef(reader.readValue(PKI_ID), reader.readValue(PKI_ID), reader.readValue(PKI_ID)));
private static final ValueSchema<FormatId> FORMAT_ID = valueSchema(TYPE_FORMAT_ID,
(writer, value) -> writer.writeValue(STRING, value.value()),
reader -> new FormatId(reader.readValue(STRING)));
@@ -377,13 +399,19 @@ final class FsCodec {
"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);
/* package */ static final Schema<IssuerGeneration> ISSUER_GENERATION = topLevel(TOP_ISSUER_GENERATION,
"ISSUER_GENERATION", valueSchema(110, FsCodec::writeIssuerGeneration, FsCodec::readIssuerGeneration));
/* package */ static final Schema<IssuerChainPath> ISSUER_CHAIN_PATH = topLevel(TOP_ISSUER_CHAIN_PATH,
"ISSUER_CHAIN_PATH", valueSchema(111, FsCodec::writeIssuerChainPath, FsCodec::readIssuerChainPath));
private static final Map<Integer, Schema<?>> TOP_LEVEL_SCHEMAS = Map.ofEntries(Map.entry(TOP_CA_RECORD, CA_RECORD),
Map.entry(TOP_CREDENTIAL, CREDENTIAL), Map.entry(TOP_PARSED_REQUEST, PARSED_REQUEST),
Map.entry(TOP_STATUS_OBJECT, STATUS_OBJECT),
Map.entry(TOP_POLICY_TRACE, POLICY_TRACE),
Map.entry(TOP_WORKFLOW_STATE, WORKFLOW_STATE), Map.entry(TOP_SIGN_WORKFLOW_RECORD, SIGN_WORKFLOW_RECORD),
Map.entry(TOP_PROFILE_VERSION, PROFILE_VERSION), Map.entry(TOP_ACTIVE_PROFILE_REF, ACTIVE_PROFILE_REF));
Map.entry(TOP_PROFILE_VERSION, PROFILE_VERSION), Map.entry(TOP_ACTIVE_PROFILE_REF, ACTIVE_PROFILE_REF),
Map.entry(TOP_ISSUER_GENERATION, ISSUER_GENERATION),
Map.entry(TOP_ISSUER_CHAIN_PATH, ISSUER_CHAIN_PATH));
private FsCodec() {
// utility
@@ -616,12 +644,44 @@ final class FsCodec {
writer.writeValue(CA_STATE, value.state());
writer.writeValue(KEY_REF, value.issuerKeyRef());
writer.writeValue(SUBJECT_REF, value.subjectRef());
writer.writeValue(PKI_IDS, value.credentialIds());
writer.writeValue(PKI_IDS, value.issuerIds());
writer.writeValue(PKI_ID, value.currentIssuanceIssuerId());
writer.writeValue(PKI_ID, value.issuanceChainPathId());
}
private static CaRecord readCaRecord(Reader reader) throws IOException {
return new CaRecord(reader.readValue(PKI_ID), reader.readValue(CA_KIND), reader.readValue(CA_STATE),
reader.readValue(KEY_REF), reader.readValue(SUBJECT_REF), reader.readValue(PKI_IDS));
reader.readValue(KEY_REF), reader.readValue(SUBJECT_REF), reader.readValue(PKI_IDS),
reader.readValue(PKI_ID), reader.readValue(PKI_ID));
}
private static void writeIssuerGeneration(Writer writer, IssuerGeneration value) throws IOException {
writer.writeValue(PKI_ID, value.issuerId());
writer.writeValue(PKI_ID, value.authorityId());
writer.writeValue(PKI_ID, value.credentialId());
writer.writeValue(KEY_REF, value.signingKeyRef());
writer.writeValue(ISSUER_GENERATION_STATE, value.state());
writer.writeValue(STRING, value.profilePolicyCommitment());
writer.writeValue(STRING, value.x509BindingCommitment());
}
private static IssuerGeneration readIssuerGeneration(Reader reader) throws IOException {
return new IssuerGeneration(reader.readValue(PKI_ID), reader.readValue(PKI_ID), reader.readValue(PKI_ID),
reader.readValue(KEY_REF), reader.readValue(ISSUER_GENERATION_STATE), reader.readValue(STRING),
reader.readValue(STRING));
}
private static void writeIssuerChainPath(Writer writer, IssuerChainPath value) throws IOException {
writer.writeValue(PKI_ID, value.pathId());
writer.writeValue(PKI_ID, value.authorityId());
writer.writeValue(PKI_ID, value.issuerId());
writer.writeValue(PKI_IDS, value.orderedCredentialIds());
writer.writeValue(STRING, value.pathCommitment());
}
private static IssuerChainPath readIssuerChainPath(Reader reader) throws IOException {
return new IssuerChainPath(reader.readValue(PKI_ID), reader.readValue(PKI_ID), reader.readValue(PKI_ID),
reader.readValue(PKI_IDS), reader.readValue(STRING));
}
private static void writeParsedRequest(Writer writer, ParsedCertificationRequest value) throws IOException {

View File

@@ -125,6 +125,16 @@ final class FsPaths {
return caDir(caId).resolve(HISTORY_DIR);
}
/* default */ Path issuerGenerationPath(final PkiId issuerId) {
Objects.requireNonNull(issuerId, "issuerId");
return root.resolve("issuer-generations").resolve(BY_ID).resolve(FsUtil.safeId(issuerId) + BINARY_EXTENSION);
}
/* default */ Path issuerChainPath(final PkiId pathId) {
Objects.requireNonNull(pathId, "pathId");
return root.resolve("issuer-chain-paths").resolve(BY_ID).resolve(FsUtil.safeId(pathId) + BINARY_EXTENSION);
}
// -------------------------------------------------------------------------
// Profiles (immutable versions plus one active pointer)
// -------------------------------------------------------------------------

View File

@@ -60,6 +60,8 @@ import zeroecho.core.io.CancellationSignal;
import zeroecho.core.io.RepeatableContent;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.ca.CaRecord;
import zeroecho.pki.api.ca.IssuerChainPath;
import zeroecho.pki.api.ca.IssuerGeneration;
import zeroecho.pki.api.content.DurableContentReference;
import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.publication.PublicationCursor;
@@ -219,12 +221,18 @@ final class FsSnapshotExporter {
private SnapshotAuthority plan(Instant at) throws IOException {
CredentialInventory inventory = inventoryCredentials();
List<CaRecord> cas = selectCas(at, inventory.credentials());
List<IssuerGeneration> generations = cas.stream().flatMap(ca -> ca.issuerIds().stream())
.map(id -> source.getIssuerGeneration(id).orElseThrow(
() -> new SnapshotAuthorityFailure("Snapshot issuer generation is missing"))).toList();
List<IssuerChainPath> paths = generations.stream()
.flatMap(generation -> source.listIssuerChainPaths(generation.issuerId()).stream()).toList();
List<StatusObject> statuses = source.snapshotStatusObjects();
Set<String> remintedContentIds = new HashSet<>(inventory.contentIds());
for (StatusObject status : statuses) {
remintedContentIds.add(status.content().contentId());
}
return new SnapshotAuthority(cas, inventory.credentials(), statuses, remintedContentIds);
return new SnapshotAuthority(cas, generations, paths, inventory.credentials(), statuses,
remintedContentIds);
}
private CredentialInventory inventoryCredentials() throws IOException {
@@ -277,7 +285,7 @@ final class FsSnapshotExporter {
List<CaRecord> selected = new ArrayList<>();
for (Path record : selectedRecords) {
CaRecord ca = loadCa(record);
if (ca != null && credentials.keySet().containsAll(ca.credentialIds())) {
if (ca != null && validCa(ca, credentials)) {
selected.add(ca);
} else if (ca != null) {
rejectCa();
@@ -286,6 +294,24 @@ final class FsSnapshotExporter {
return List.copyOf(selected);
}
private boolean validCa(CaRecord ca, Map<PkiId, Credential> credentials) {
try {
for (PkiId issuerId : ca.issuerIds()) {
IssuerGeneration generation = source.getIssuerGeneration(issuerId).orElseThrow();
if (!ca.caId().equals(generation.authorityId())
|| !credentials.containsKey(generation.credentialId())) {
return false;
}
}
IssuerChainPath path = source.getIssuerChainPath(ca.issuanceChainPathId()).orElseThrow();
return ca.caId().equals(path.authorityId())
&& ca.currentIssuanceIssuerId().equals(path.issuerId())
&& path.orderedCredentialIds().stream().allMatch(credentials::containsKey);
} catch (IllegalStateException | java.util.NoSuchElementException failure) {
return false;
}
}
private CaRecord loadCa(Path record) throws IOException {
try {
return FsCodec.decode(FsCodec.CA_RECORD, FsOperations.readAll(record), source.stagedContent());
@@ -448,8 +474,19 @@ final class FsSnapshotExporter {
transferred.add(entry.getKey());
}
}
for (IssuerGeneration generation : authority.generations()) {
if (transferred.contains(generation.credentialId())) {
target.putIssuerGeneration(generation);
}
}
for (IssuerChainPath path : authority.paths()) {
if (transferred.containsAll(path.orderedCredentialIds())) {
target.putIssuerChainPath(path);
}
}
for (CaRecord ca : authority.cas()) {
if (transferred.containsAll(ca.credentialIds())) {
if (ca.issuerIds().stream().allMatch(id -> target.getIssuerGeneration(id).isPresent())
&& target.getIssuerChainPath(ca.issuanceChainPathId()).isPresent()) {
persistCa(target, ca);
}
}
@@ -789,10 +826,13 @@ final class FsSnapshotExporter {
}
}
private record SnapshotAuthority(List<CaRecord> cas, Map<PkiId, Credential> credentials,
private record SnapshotAuthority(List<CaRecord> cas, List<IssuerGeneration> generations,
List<IssuerChainPath> paths, Map<PkiId, Credential> credentials,
List<StatusObject> statuses, Set<String> remintedContentIds) {
private SnapshotAuthority {
cas = List.copyOf(cas);
generations = List.copyOf(generations);
paths = List.copyOf(paths);
credentials = Collections.unmodifiableMap(new LinkedHashMap<>(credentials));
statuses = List.copyOf(statuses);
remintedContentIds = Set.copyOf(remintedContentIds);

View File

@@ -39,6 +39,8 @@ import java.util.Optional;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.ca.CaRecord;
import zeroecho.pki.api.ca.IssuerChainPath;
import zeroecho.pki.api.ca.IssuerGeneration;
import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.orch.WorkflowStateRecord;
import zeroecho.pki.api.policy.PolicyTrace;
@@ -105,8 +107,8 @@ public interface PkiStore extends SignWorkflowStore, AutoCloseable {
* <p>
* Implementations must store CA records atomically. Replacing an existing
* record should be either fully visible or not visible at all. Every
* identifier in {@link CaRecord#credentialIds()} must resolve through
* {@link #getCredential(PkiId)} before the CA record is published.
* identifier in {@link CaRecord#issuerIds()} must resolve through
* {@link #getIssuerGeneration(PkiId)} before the CA record is published.
* </p>
*
* @param record CA record (never {@code null})
@@ -139,6 +141,33 @@ public interface PkiStore extends SignWorkflowStore, AutoCloseable {
*/
List<CaRecord> listCas();
/**
* Returns a bounded canonical keyset page of CA records.
*
* @param afterCaId optional exclusive lower bound
* @param limit positive finite page size
* @return records ordered by canonical CA identity
*/
List<CaRecord> listCasPage(Optional<PkiId> afterCaId, int limit);
/** Persists one immutable canonical issuer-generation record. */
void putIssuerGeneration(IssuerGeneration generation);
/** Retrieves one canonical issuer-generation record. */
Optional<IssuerGeneration> getIssuerGeneration(PkiId issuerId);
/** Lists issuer generations owned by an exact logical authority. */
List<IssuerGeneration> listIssuerGenerations(PkiId authorityId);
/** Persists one immutable, already validated issuer chain path. */
void putIssuerChainPath(IssuerChainPath path);
/** Retrieves one immutable issuer chain path. */
Optional<IssuerChainPath> getIssuerChainPath(PkiId pathId);
/** Lists explicit paths for an exact issuer generation. */
List<IssuerChainPath> listIssuerChainPaths(PkiId issuerId);
/**
* Persists a credential record.
*

View File

@@ -48,25 +48,25 @@ import zeroecho.pki.api.SubjectRef;
final class CaRecordTest {
@Test
void credentialIdentifiersAreOrderedImmutableAndUnique() {
System.out.println("credentialIdentifiersAreOrderedImmutableAndUnique");
PkiId first = new PkiId("credential-first");
PkiId second = new PkiId("credential-second");
void issuerIdentifiersAreOrderedImmutableAndUnique() {
System.out.println("issuerIdentifiersAreOrderedImmutableAndUnique");
PkiId first = new PkiId("issuer-first");
PkiId second = new PkiId("issuer-second");
List<PkiId> source = new ArrayList<>(List.of(first, second));
CaRecord record = record(source);
source.clear();
assertEquals(List.of(first, second), record.credentialIds());
assertEquals(List.of(first, second), record.issuerIds());
assertThrows(UnsupportedOperationException.class,
() -> record.credentialIds().add(new PkiId("credential-third")));
() -> record.issuerIds().add(new PkiId("issuer-third")));
assertThrows(IllegalArgumentException.class, () -> record(List.of(first, first)));
assertThrows(IllegalArgumentException.class, () -> record(java.util.Arrays.asList(first, null)));
System.out.println("credentialIdentifiersAreOrderedImmutableAndUnique...ok");
System.out.println("issuerIdentifiersAreOrderedImmutableAndUnique...ok");
}
private static CaRecord record(List<PkiId> credentialIds) {
private static CaRecord record(List<PkiId> issuerIds) {
return new CaRecord(new PkiId("ca-test"), CaKind.ROOT, CaState.ACTIVE, new KeyRef("key-test"),
new SubjectRef("CN=Test"), credentialIds);
new SubjectRef("CN=Test"), issuerIds, issuerIds.getFirst(), new PkiId("path-test"));
}
}

View File

@@ -78,6 +78,8 @@ import zeroecho.pki.api.SubjectRef;
import zeroecho.pki.api.Validity;
import zeroecho.pki.api.ca.CaCreateCommand;
import zeroecho.pki.api.ca.CaImportCommand;
import zeroecho.pki.api.ca.IssuerChainPath;
import zeroecho.pki.api.ca.IssuerGeneration;
import zeroecho.pki.api.ca.CaRecord;
import zeroecho.pki.api.ca.IntermediateCertIssueCommand;
import zeroecho.pki.api.ca.IntermediateCreateCommand;
@@ -140,7 +142,7 @@ final class CaProfileIssuanceEnforcementTest {
assertEquals(holder.getSubject().toString(), intermediate.subjectRef().value());
assertEquals(intermediate.subjectRef(), credential.subjectRef());
assertEquals(intermediate.subjectRef(), additional.subjectRef());
assertEquals(2, intermediate.credentialIds().size());
assertEquals(2, intermediate.issuerIds().size());
}
}
@@ -225,7 +227,7 @@ final class CaProfileIssuanceEnforcementTest {
.caCredential(runtime.caService().getCa(rootId), 0)
.profileBinding();
assertEquals(1, issuerBinding.reference().profileVersion());
assertEquals(1, runtime.caService().getCa(intermediateId).credentialIds().size());
assertEquals(1, runtime.caService().getCa(intermediateId).issuerIds().size());
}
}
@@ -436,8 +438,18 @@ final class CaProfileIssuanceEnforcementTest {
original.serialOrUniqueId(), original.publicKeyId(), new CaProfileBinding(wrongFormat),
original.status(), original.content(), original.attributes());
runtime.store().putCredential(mutated);
IssuerGeneration originalGeneration = runtime.store()
.getIssuerGeneration(root.currentIssuanceIssuerId()).orElseThrow();
IssuerGeneration generation = new IssuerGeneration(
IssuerGeneration.idFor(root.caId(), mutated.credentialId()), root.caId(),
mutated.credentialId(), originalGeneration.signingKeyRef(), originalGeneration.state(),
originalGeneration.profilePolicyCommitment(), originalGeneration.x509BindingCommitment());
runtime.store().putIssuerGeneration(generation);
IssuerChainPath path = IssuerChainPath.create(root.caId(), generation.issuerId(),
List.of(mutated.credentialId()));
runtime.store().putIssuerChainPath(path);
runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(),
root.subjectRef(), List.of(mutated.credentialId())));
root.subjectRef(), List.of(generation.issuerId()), generation.issuerId(), path.pathId()));
int signCount = runtime.submittedSignCount();
assertThrows(PkiException.class,
@@ -521,7 +533,7 @@ final class CaProfileIssuanceEnforcementTest {
new IntermediateCertIssueCommand(runtime.framework().formatId(), rootId, rootId,
"intermediate-ca", Optional.empty(), new SimpleAttributeSet())));
assertEquals(signCount, runtime.submittedSignCount());
assertEquals(1, runtime.caService().getCa(rootId).credentialIds().size());
assertEquals(1, runtime.caService().getCa(rootId).issuerIds().size());
}
}
@@ -548,7 +560,7 @@ final class CaProfileIssuanceEnforcementTest {
runtime.framework().formatId(), rootId, intermediateId, "intermediate-ca",
Optional.of(invalid), new SimpleAttributeSet())));
assertEquals(signCount, runtime.submittedSignCount());
assertEquals(1, runtime.caService().getCa(intermediateId).credentialIds().size());
assertEquals(1, runtime.caService().getCa(intermediateId).issuerIds().size());
}
}
@@ -670,7 +682,7 @@ final class CaProfileIssuanceEnforcementTest {
private static Credential onlyCredential(PkiTestRuntime runtime, PkiId caId) {
CaRecord ca = runtime.caService().getCa(caId);
assertEquals(1, ca.credentialIds().size());
assertEquals(1, ca.issuerIds().size());
return runtime.caCredential(ca, 0);
}

View File

@@ -123,16 +123,11 @@ public final class PkiCoreE2eTest {
}
@Test
void everyIssuerPathSkipsEarlierUnusableCredentialAndSelectsLaterUsable(@TempDir Path tempDir) throws Exception {
void explicitIssuerSelectionNeverFallsBackByCollectionOrder(@TempDir Path tempDir) throws Exception {
KeyPair rootKey = genRsa();
KeyPair intermediateKey = genRsa();
KeyPair nextIntermediateKey = genRsa();
KeyPair leafKey = genRsa();
KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:matrix-root");
KeyRef intermediateKeyRef = new KeyRef("kref:v1:keyring:test:matrix-intermediate");
KeyRef nextIntermediateKeyRef = new KeyRef("kref:v1:keyring:test:matrix-next");
Map<KeyRef, KeyPair> keys = Map.of(rootKeyRef, rootKey, intermediateKeyRef, intermediateKey,
nextIntermediateKeyRef, nextIntermediateKey);
Map<KeyRef, KeyPair> keys = Map.of(rootKeyRef, rootKey);
try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), keys)) {
PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(),
@@ -141,8 +136,7 @@ public final class PkiCoreE2eTest {
Credential unusable = copyWithId(usable, new PkiId("credential:matrix-unusable"));
runtime.store().putCredential(unusable);
CaRecord root = runtime.caService().getCa(rootCaId);
runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(),
root.subjectRef(), List.of(unusable.credentialId(), usable.credentialId())));
runtime.selectIssuerCredential(root, unusable, true);
List<PkiId> resolved = new ArrayList<>();
EffectiveCredentialStatusResolver resolver = scriptedResolver(credential -> {
@@ -152,37 +146,64 @@ public final class PkiCoreE2eTest {
}, false);
CountingIssuerBackend backend = new CountingIssuerBackend(runtime.issuerBackend());
IssuanceService issuance = runtime.issuanceService(backend, resolver);
CaService caService = runtime.caService(backend, resolver);
StatusObjectService statusService = runtime.statusObjectService(resolver);
ParsedCertificationRequest leafRequest = runtime.certificationRequestService()
.parse(new CertificationRequest(runtime.framework().formatId(),
new EncodedObject(Encoding.DER, makeCsr(leafKey, "CN=Matrix Leaf").getEncoded())));
assertThrows(PkiException.class, () -> issuance.issueEndEntity(
new IssueEndEntityCommand(rootCaId, leafRequest, "default", Optional.empty())));
assertEquals(List.of(unusable.credentialId()), List.copyOf(resolved));
resolved.clear();
runtime.caService().selectIssuancePath(rootCaId, root.currentIssuanceIssuerId(),
root.issuanceChainPathId(), "restore explicit test selection");
issuance.issueEndEntity(new IssueEndEntityCommand(rootCaId, leafRequest, "default", Optional.empty()));
assertEquals(List.of(unusable.credentialId(), usable.credentialId()), List.copyOf(resolved));
resolved.clear();
PkiId intermediateCaId = caService.createIntermediate(new IntermediateCreateCommand(
runtime.framework().formatId(), rootCaId, new SubjectRef("CN=Matrix Intermediate"),
"intermediate-ca", Optional.of(intermediateKeyRef), emptyAttributes()));
assertEquals(List.of(unusable.credentialId(), usable.credentialId()), List.copyOf(resolved));
resolved.clear();
caService.issueIntermediateCertificate(new IntermediateCertIssueCommand(runtime.framework().formatId(),
rootCaId, intermediateCaId, "intermediate-ca", Optional.empty(), emptyAttributes()));
assertEquals(List.of(unusable.credentialId(), usable.credentialId()), List.copyOf(resolved));
resolved.clear();
runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(),
root.subjectRef(), List.of(usable.credentialId(), unusable.credentialId())));
statusService.generate(new StatusObjectGenerateCommand(rootCaId, StatusObjectType.CRL,
runtime.framework().formatId(), emptyAttributes()));
assertEquals(List.of(unusable.credentialId(), usable.credentialId()), List.copyOf(resolved));
assertEquals(List.of(usable.credentialId()), List.copyOf(resolved));
assertEquals(1, backend.endEntityCalls.get());
assertEquals(2, backend.intermediateCalls.get());
}
}
@Test
void crossSignedIssuerPathsRemainExplicitAndDoNotChangeIssuanceSelection(@TempDir Path tempDir)
throws Exception {
System.out.println("crossSignedIssuerPathsRemainExplicitAndDoNotChangeIssuanceSelection");
KeyPair rootKey = genRsa();
KeyPair intermediateKey = genRsa();
KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:cross-root");
KeyRef intermediateKeyRef = new KeyRef("kref:v1:keyring:test:cross-intermediate");
try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"),
Map.of(rootKeyRef, rootKey, intermediateKeyRef, intermediateKey))) {
PkiId rootId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(),
new SubjectRef("CN=Cross Root"), "root-ca", Optional.of(rootKeyRef), emptyAttributes()));
PkiId intermediateId = runtime.caService().createIntermediate(new IntermediateCreateCommand(
runtime.framework().formatId(), rootId, new SubjectRef("CN=Cross Intermediate"),
"intermediate-ca", Optional.of(intermediateKeyRef), emptyAttributes()));
CaRecord originalRoot = runtime.caService().getCa(rootId);
CaRecord intermediate = runtime.caService().getCa(intermediateId);
PkiId originalIssuancePath = intermediate.issuanceChainPathId();
Credential rootCredential = runtime.caCredential(originalRoot, 0);
Credential alternateRootCredential = copyWithId(rootCredential,
new PkiId("credential:cross-root-alternate"));
runtime.store().putCredential(alternateRootCredential);
CaRecord rotatedRoot = runtime.selectIssuerCredential(originalRoot, alternateRootCredential, true);
PkiId alternateRootPath = rotatedRoot.issuanceChainPathId();
zeroecho.pki.api.ca.IssuerChainPath alternate = runtime.caService().registerIssuerChainPath(
intermediateId, intermediate.currentIssuanceIssuerId(), alternateRootPath);
assertEquals(2, runtime.caService().listIssuerChainPaths(
intermediate.currentIssuanceIssuerId()).size());
assertFalse(originalIssuancePath.equals(alternate.pathId()));
assertEquals(originalIssuancePath,
runtime.caService().getCa(intermediateId).issuanceChainPathId());
assertEquals(alternateRootCredential.credentialId(),
alternate.orderedCredentialIds().getLast());
System.out.println("...issuerId=" + intermediate.currentIssuanceIssuerId().value()
+ " paths=2 selected=" + originalIssuancePath.value());
}
System.out.println("...ok");
}
@Test
void permanentlyRevokedIssuerIsRejectedByEveryReachableTrustPath(@TempDir Path tempDir) throws Exception {
KeyPair rootKey = genRsa();
@@ -202,7 +223,8 @@ public final class PkiCoreE2eTest {
.createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(), rootCaId,
new SubjectRef("CN=H6 Intermediate"), "intermediate-ca", Optional.of(intermediateKeyRef),
emptyAttributes()));
PkiId rootCredentialId = runtime.caService().getCa(rootCaId).credentialIds().get(0);
PkiId rootCredentialId = runtime.store().getIssuerGeneration(
runtime.caService().getCa(rootCaId).currentIssuanceIssuerId()).orElseThrow().credentialId();
runtime.revocationService().revokePermanently(new RevocationCommand.RevokePermanently(rootCredentialId,
RevocationReason.KEY_COMPROMISE, emptyAttributes()));
int submissionsBeforeRejections = runtime.submittedSignCount();
@@ -231,7 +253,7 @@ public final class PkiCoreE2eTest {
assertEquals(submissionsBeforeRejections, runtime.submittedSignCount());
assertTrue(runtime.store().getCredential(rootCredentialId).isPresent());
assertTrue(runtime.caService().getCa(intermediateCaId).credentialIds().size() == 1);
assertTrue(runtime.caService().getCa(intermediateCaId).issuerIds().size() == 1);
}
}
@@ -280,6 +302,10 @@ public final class PkiCoreE2eTest {
.issueEndEntity(new IssueEndEntityCommand(rootCaId, parsed, "default", Optional.empty()));
assertNotNull(bundle);
assertEquals(1, bundle.supportingObjects().size());
CredentialBundle rebuilt = issSvc.buildBundle(new BundleCommand(
bundle.credential().credentialId(), Optional.empty(), Optional.empty()));
assertEquals(bundle.supportingObjects(), rebuilt.supportingObjects());
System.out.println("...issuedCredentialId=" + bundle.credential().credentialId().value());
X509CertificateHolder eeCert = new X509CertificateHolder(runtime.credentialBytes(bundle.credential()));
@@ -333,7 +359,7 @@ public final class PkiCoreE2eTest {
int signCount = runtime.submittedSignCount();
int caCount = runtime.store().listCas().size();
int statusCount = runtime.store().listStatusObjects(rootCaId).size();
int intermediateCredentialCount = runtime.caService().getCa(intermediateCaId).credentialIds().size();
int intermediateCredentialCount = runtime.caService().getCa(intermediateCaId).issuerIds().size();
assertThrows(PkiException.class, () -> issuance
.issueEndEntity(new IssueEndEntityCommand(rootCaId, leafRequest, "default", Optional.empty())));
@@ -356,7 +382,7 @@ public final class PkiCoreE2eTest {
assertEquals(caCount, runtime.store().listCas().size());
assertEquals(statusCount, runtime.store().listStatusObjects(rootCaId).size());
assertEquals(intermediateCredentialCount,
runtime.caService().getCa(intermediateCaId).credentialIds().size());
runtime.caService().getCa(intermediateCaId).issuerIds().size());
assertTrue(runtime.store().getCredential(rootCredential.credentialId()).isPresent());
assertFalse(runtime.auditSink().snapshot().toString().contains("DO_NOT_EXPOSE_REVOCATION_SENTINEL"));
}

View File

@@ -538,7 +538,7 @@ final class PkiProofGateE2eTest {
runtime.framework().formatId(), rootCaId, intermediateCaId, "intermediate-ca",
Optional.empty(), new SimpleAttributeSet())));
assertEquals(7, runtime.submittedSignCount());
assertEquals(1, runtime.caService().getCa(intermediateCaId).credentialIds().size());
assertEquals(1, runtime.caService().getCa(intermediateCaId).issuerIds().size());
assertTrue(runtime.store().listWorkflowStates().isEmpty());
}
@@ -796,8 +796,7 @@ final class PkiProofGateE2eTest {
original.subjectRef(), original.validity(), original.serialOrUniqueId(), original.publicKeyId(),
original.profileBinding(), CredentialStatus.REVOKED, original.content(), original.attributes());
runtime.store().putCredential(revoked);
runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(),
root.subjectRef(), List.of(revoked.credentialId())));
runtime.selectIssuerCredential(root, revoked, true);
int before = runtime.submittedSignCount();
assertThrows(PkiException.class, () -> runtime.issuanceService()
.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, "default", Optional.empty())));
@@ -810,14 +809,13 @@ final class PkiProofGateE2eTest {
original.serialOrUniqueId(), original.publicKeyId(), original.profileBinding(),
CredentialStatus.ISSUED, original.content(), original.attributes());
runtime.store().putCredential(expired);
runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(),
root.subjectRef(), List.of(expired.credentialId())));
runtime.selectIssuerCredential(root, expired, true);
assertThrows(PkiException.class, () -> runtime.issuanceService()
.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, "default", Optional.empty())));
assertEquals(before, runtime.submittedSignCount());
runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(),
root.subjectRef(), List.of(original.credentialId())));
runtime.caService().selectIssuancePath(root.caId(), root.currentIssuanceIssuerId(),
root.issuanceChainPathId(), "restore explicit test selection");
ParsedCertificationRequest missing = withAttributes(subject, new SimpleAttributeSet());
AttributeSet hostileAttributes = new AttributeSet() {
@Override
@@ -917,7 +915,7 @@ final class PkiProofGateE2eTest {
Optional.empty(), new SimpleAttributeSet())),
mutation.name());
assertTrue(rejected.getMessage().contains("CREDENTIAL_PROFILE_BINDING_MISMATCH"), mutation.name());
assertEquals(1, runtime.caService().getCa(intermediateCaId).credentialIds().size(), mutation.name());
assertEquals(1, runtime.caService().getCa(intermediateCaId).issuerIds().size(), mutation.name());
if (produced.get() != null) {
assertTrue(runtime.store().getCredential(produced.get().credentialId()).isEmpty(), mutation.name());
}
@@ -942,7 +940,7 @@ final class PkiProofGateE2eTest {
() -> wrongSubjectService.issueIntermediateCertificate(
new IntermediateCertIssueCommand(runtime.framework().formatId(), rootCaId, intermediateCaId,
"intermediate-ca", Optional.empty(), new SimpleAttributeSet())));
assertEquals(1, runtime.caService().getCa(intermediateCaId).credentialIds().size());
assertEquals(1, runtime.caService().getCa(intermediateCaId).issuerIds().size());
CredentialIssuerBackend invalidSignatureBackend = new CredentialIssuerBackend() {
@Override
@@ -967,7 +965,7 @@ final class PkiProofGateE2eTest {
() -> invalidSignatureService.issueIntermediateCertificate(
new IntermediateCertIssueCommand(runtime.framework().formatId(), rootCaId, intermediateCaId,
"intermediate-ca", Optional.empty(), new SimpleAttributeSet())));
assertEquals(1, runtime.caService().getCa(intermediateCaId).credentialIds().size());
assertEquals(1, runtime.caService().getCa(intermediateCaId).issuerIds().size());
for (IntermediateExtensionVariant variant : IntermediateExtensionVariant.values()) {
CaService maliciousExtensionService = runtime
@@ -977,7 +975,7 @@ final class PkiProofGateE2eTest {
runtime.framework().formatId(), rootCaId, intermediateCaId, "intermediate-ca",
Optional.empty(), new SimpleAttributeSet())),
variant.name());
assertEquals(1, runtime.caService().getCa(intermediateCaId).credentialIds().size(), variant.name());
assertEquals(1, runtime.caService().getCa(intermediateCaId).issuerIds().size(), variant.name());
}
AtomicReference<Credential> rawCredential = new AtomicReference<>();

View File

@@ -51,6 +51,9 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.security.MessageDigest;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.math.BigInteger;
import java.time.Duration;
import java.time.Instant;
import java.util.Comparator;
@@ -68,6 +71,10 @@ import java.util.stream.Collectors;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.cert.X509v3CertificateBuilder;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import zeroecho.core.io.CancellationSignal;
import zeroecho.core.io.RepeatableContent;
@@ -90,6 +97,9 @@ import zeroecho.pki.api.audit.Principal;
import zeroecho.pki.api.ca.CaKind;
import zeroecho.pki.api.ca.CaRecord;
import zeroecho.pki.api.ca.CaState;
import zeroecho.pki.api.ca.IssuerChainPath;
import zeroecho.pki.api.ca.IssuerGeneration;
import zeroecho.pki.api.ca.IssuerGenerationState;
import zeroecho.pki.api.credential.CaProfileBinding;
import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.credential.CredentialStatus;
@@ -557,7 +567,7 @@ public final class FilesystemPkiStoreTest {
store.putCa(ca1);
CaRecord ca2 = new CaRecord(ca1.caId(), ca1.kind(), CaState.DISABLED, ca1.issuerKeyRef(), ca1.subjectRef(),
ca1.credentialIds());
ca1.issuerIds(), ca1.currentIssuanceIssuerId(), ca1.issuanceChainPathId());
store.putCa(ca2);
Optional<CaRecord> loaded = store.getCa(ca1.caId());
@@ -571,28 +581,42 @@ public final class FilesystemPkiStoreTest {
System.out.println("caHistoryCreatesCurrentAndHistory...ok");
}
@Test
void caKeysetPageUsesCanonicalIdentityRatherThanFilesystemEncoding() throws Exception {
System.out.println("caKeysetPageUsesCanonicalIdentityRatherThanFilesystemEncoding");
Path root = tmp.resolve("store-ca-keyset-page");
try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults())) {
CaRecord first = TestObjects.minimalCaRecord(store, "ca:z", CaState.ACTIVE);
CaRecord second = TestObjects.minimalCaRecord(store, "ca:zz", CaState.ACTIVE);
store.putCa(first);
store.putCa(second);
assertEquals(List.of(first.caId()), store.listCasPage(Optional.empty(), 1).stream()
.map(CaRecord::caId).toList());
assertEquals(List.of(second.caId()), store.listCasPage(Optional.of(first.caId()), 1).stream()
.map(CaRecord::caId).toList());
System.out.println("...after=" + first.caId() + " next=" + second.caId());
}
System.out.println("caKeysetPageUsesCanonicalIdentityRatherThanFilesystemEncoding...ok");
}
@Test
void caReferencesRequireValidStandaloneCredentialsAndPreserveOrder() throws Exception {
System.out.println("caReferencesRequireValidStandaloneCredentialsAndPreserveOrder");
Path root = tmp.resolve("store-ca-authority");
try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults())) {
Credential first = TestObjects.minimalCredential(store, "SERIAL-FIRST", "profile-ca");
Credential second = TestObjects.minimalCredential(store, "SERIAL-SECOND", "profile-ca");
store.putCredential(first);
store.putCredential(second);
CaRecord ordered = new CaRecord(new PkiId("ca-ordered"), CaKind.ROOT, CaState.ACTIVE,
new KeyRef("key-ordered"), new SubjectRef("CN=Ordered"),
List.of(second.credentialId(), first.credentialId()));
CaRecord ordered = TestObjects.minimalCaRecord(store, "ca-ordered", CaState.ACTIVE);
store.putCa(ordered);
assertEquals(List.of(second.credentialId(), first.credentialId()),
store.getCa(ordered.caId()).orElseThrow().credentialIds());
assertEquals(ordered.issuerIds(), store.getCa(ordered.caId()).orElseThrow().issuerIds());
PkiId missingIssuer = new PkiId("issuer-missing");
CaRecord missing = new CaRecord(new PkiId("ca-missing"), CaKind.ROOT, CaState.ACTIVE,
new KeyRef("key-missing"), new SubjectRef("CN=Missing"),
List.of(new PkiId("credential-missing")));
List.of(missingIssuer), missingIssuer, new PkiId("path-missing"));
assertThrows(IllegalStateException.class, () -> store.putCa(missing));
Files.write(root.resolve("staged-content").resolve(first.content().contentId() + ".content"),
Credential selected = store.getCredential(store.getIssuerGeneration(
ordered.currentIssuanceIssuerId()).orElseThrow().credentialId()).orElseThrow();
Files.write(root.resolve("staged-content").resolve(selected.content().contentId() + ".content"),
new byte[] { 9, 9, 9 });
assertThrows(IllegalStateException.class, () -> store.getCa(ordered.caId()));
assertThrows(IllegalStateException.class, store::listCas);
@@ -613,22 +637,20 @@ public final class FilesystemPkiStoreTest {
}
@Test
void snapshotReconstructsSharedCredentialOnceWithNewTargetReference() throws Exception {
System.out.println("snapshotReconstructsSharedCredentialOnceWithNewTargetReference");
void snapshotReconstructsIssuerGenerationsWithNewTargetReferences() throws Exception {
System.out.println("snapshotReconstructsIssuerGenerationsWithNewTargetReferences");
Path root = tmp.resolve("store-snapshot-authority");
Path snapshot = tmp.resolve("snapshot-authority");
FsPkiStoreOptions options = nonStrictSnapshotOptions();
PkiId credentialId;
String sourceContentId;
try (FilesystemPkiStore source = new FilesystemPkiStore(root, options)) {
Credential shared = TestObjects.minimalCredential(source, "SERIAL-SHARED", "profile-ca");
source.putCredential(shared);
credentialId = shared.credentialId();
sourceContentId = shared.content().contentId();
source.putCa(new CaRecord(new PkiId("ca-shared-one"), CaKind.ROOT, CaState.ACTIVE,
new KeyRef("key-shared-one"), new SubjectRef("CN=Shared One"), List.of(credentialId)));
source.putCa(new CaRecord(new PkiId("ca-shared-two"), CaKind.ROOT, CaState.ACTIVE,
new KeyRef("key-shared-two"), new SubjectRef("CN=Shared Two"), List.of(credentialId)));
CaRecord first = TestObjects.minimalCaRecord(source, "ca-shared-one", CaState.ACTIVE);
CaRecord second = TestObjects.minimalCaRecord(source, "ca-shared-two", CaState.ACTIVE);
source.putCa(first);
source.putCa(second);
credentialId = source.getIssuerGeneration(first.currentIssuanceIssuerId()).orElseThrow().credentialId();
sourceContentId = source.getCredential(credentialId).orElseThrow().content().contentId();
source.exportSnapshot(snapshot, Instant.now());
}
@@ -636,16 +658,17 @@ public final class FilesystemPkiStoreTest {
assertEquals(2, restored.listCas().size());
Credential credential = restored.getCredential(credentialId).orElseThrow();
assertFalse(sourceContentId.equals(credential.content().contentId()));
assertEquals(List.of(credentialId),
restored.getCa(new PkiId("ca-shared-one")).orElseThrow().credentialIds());
CaRecord restoredAuthority = restored.getCa(new PkiId("ca-shared-one")).orElseThrow();
assertEquals(credentialId, restored.getIssuerGeneration(
restoredAuthority.currentIssuanceIssuerId()).orElseThrow().credentialId());
}
try (java.util.stream.Stream<Path> records = Files.list(snapshot.resolve("credentials").resolve("by-id"))) {
assertEquals(1, records.filter(Files::isRegularFile).count());
assertEquals(2, records.filter(Files::isRegularFile).count());
}
assertFalse(Files.exists(snapshot.resolve("staged-content").resolve(sourceContentId + ".content")));
assertFalse(Files.exists(snapshot.resolve("staged-content").resolve(sourceContentId + ".meta")));
assertFalse(Files.exists(snapshot.resolve("staged-content").resolve(sourceContentId + ".owners")));
System.out.println("snapshotReconstructsSharedCredentialOnceWithNewTargetReference...ok");
System.out.println("snapshotReconstructsIssuerGenerationsWithNewTargetReferences...ok");
}
@Test
@@ -672,7 +695,8 @@ public final class FilesystemPkiStoreTest {
Credential standalone = restored.getCredential(standaloneId).orElseThrow();
assertFalse(standaloneSourceContentId.equals(standalone.content().contentId()));
CaRecord ca = restored.getCa(caId).orElseThrow();
assertTrue(restored.getCredential(ca.credentialIds().get(0)).isPresent());
assertTrue(restored.getCredential(restored.getIssuerGeneration(
ca.currentIssuanceIssuerId()).orElseThrow().credentialId()).isPresent());
}
System.out.println("snapshotPreservesStandaloneCredentialWithNewReferenceAndResolvableCa...ok");
}
@@ -755,8 +779,8 @@ public final class FilesystemPkiStoreTest {
}
@Test
void snapshotStrictFailsAndNonStrictOmitsOnlyInconsistentCa() throws Exception {
System.out.println("snapshotStrictFailsAndNonStrictOmitsOnlyInconsistentCa");
void snapshotFailsClosedForMissingIssuerAuthorityInEveryMode() throws Exception {
System.out.println("snapshotFailsClosedForMissingIssuerAuthorityInEveryMode");
Path root = tmp.resolve("store-snapshot-inconsistent");
Path strictSnapshot = tmp.resolve("snapshot-inconsistent-strict");
Path nonStrictSnapshot = tmp.resolve("snapshot-inconsistent-nonstrict");
@@ -766,20 +790,19 @@ public final class FilesystemPkiStoreTest {
CaRecord inconsistent = TestObjects.minimalCaRecord(source, "ca-inconsistent", CaState.ACTIVE);
source.putCa(valid);
source.putCa(inconsistent);
Files.delete(new FsPaths(root).credentialPath(inconsistent.credentialIds().get(0)));
Files.delete(new FsPaths(root).credentialPath(source.getIssuerGeneration(
inconsistent.currentIssuanceIssuerId()).orElseThrow().credentialId()));
FsPkiStoreOptions strict = strictSnapshotOptions();
assertThrows(IllegalStateException.class,
() -> new FsSnapshotExporter(strict).exportSnapshot(source, strictSnapshot, Instant.now()));
assertFalse(Files.exists(strictSnapshot));
source.exportSnapshot(nonStrictSnapshot, Instant.now());
assertThrows(IllegalStateException.class, () -> source.exportSnapshot(nonStrictSnapshot, Instant.now()));
assertFalse(Files.exists(nonStrictSnapshot));
}
try (FilesystemPkiStore restored = new FilesystemPkiStore(nonStrictSnapshot, nonStrict)) {
assertEquals(List.of("ca-valid"), restored.listCas().stream()
.map(ca -> ca.caId().value()).toList());
}
System.out.println("snapshotStrictFailsAndNonStrictOmitsOnlyInconsistentCa...ok");
System.out.println("...missing-issuer-authority=rejected");
System.out.println("snapshotFailsClosedForMissingIssuerAuthorityInEveryMode...ok");
}
@Test
@@ -1283,14 +1306,46 @@ public final class FilesystemPkiStoreTest {
static CaRecord minimalCaRecord(PkiStore store, String caId, CaState state) throws IOException {
PkiId id = new PkiId(caId);
KeyRef issuerKeyRef = new KeyRef("issuer-key-" + caId);
SubjectRef subjectRef = new SubjectRef("CN=" + caId);
Credential cred = minimalCredential(store, "CA-" + caId, "profile-ca");
store.putCredential(cred);
PkiId credentialId = new PkiId("cred-ca-" + caId);
PkiId issuerId = IssuerGeneration.idFor(id, credentialId);
IssuerChainPath path = IssuerChainPath.create(id, issuerId, List.of(credentialId));
Credential credential = caCredential(store, id, credentialId, issuerId, path.pathId(), subjectRef);
store.putCredential(credential);
String commitment = "0".repeat(64);
store.putIssuerGeneration(new IssuerGeneration(issuerId, id, credentialId, issuerKeyRef,
IssuerGenerationState.ACTIVE, commitment, commitment));
store.putIssuerChainPath(path);
return new CaRecord(id, CaKind.ROOT, state, issuerKeyRef, subjectRef,
List.of(cred.credentialId()));
List.of(issuerId), issuerId, path.pathId());
}
private static Credential caCredential(PkiStore store, PkiId authorityId, PkiId credentialId,
PkiId issuerId, PkiId pathId, SubjectRef subject) throws IOException {
try {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
KeyPair keyPair = generator.generateKeyPair();
X500Name name = new X500Name(subject.value());
Instant notBefore = Instant.parse("2020-01-01T00:00:00Z");
Instant notAfter = Instant.parse("2030-01-01T00:00:00Z");
X509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder(name, BigInteger.ONE,
java.util.Date.from(notBefore), java.util.Date.from(notAfter), name, keyPair.getPublic());
byte[] der = builder.build(new JcaContentSignerBuilder("SHA256withRSA")
.build(keyPair.getPrivate())).getEncoded();
zeroecho.pki.api.content.DurableContentReference content =
zeroecho.pki.testkit.PkiTestRuntime.fixtureReference(store.stagedContent(), Encoding.DER, der);
return new Credential(credentialId, new FormatId("fmt-x509"),
new IssuerRef(authorityId, issuerId, pathId), subject,
new Validity(notBefore, notAfter), "CA-" + authorityId.value(),
new PkiId("pk-" + authorityId.value()),
new CaProfileBinding(new CertificateProfileRef("profile-ca", 1, new byte[32])),
CredentialStatus.ISSUED, content, emptyAttributes());
} catch (java.security.GeneralSecurityException | org.bouncycastle.operator.OperatorCreationException
exception) {
throw new IOException("CA fixture construction failed", exception);
}
}
static CertificateProfile minimalProfile(String profileId) {

View File

@@ -249,15 +249,16 @@ final class FsCodecTest {
@Test
void caRecordRoundTripsOnlyOrderedCredentialIdentifiersAndRejectsEmbeddedValueTag() {
System.out.println("caRecordRoundTripsOnlyOrderedCredentialIdentifiersAndRejectsEmbeddedValueTag");
PkiId first = new PkiId("credential-first");
PkiId second = new PkiId("credential-second");
PkiId first = new PkiId("issuer-first");
PkiId second = new PkiId("issuer-second");
CaRecord original = new CaRecord(new PkiId("ca-codec"), CaKind.ROOT, CaState.ACTIVE,
new KeyRef("key-codec"), new SubjectRef("CN=Codec"), List.of(first, second));
new KeyRef("key-codec"), new SubjectRef("CN=Codec"), List.of(first, second), first,
new PkiId("path-codec"));
byte[] encoded = FsCodec.encode(FsCodec.CA_RECORD, original);
CaRecord decoded = FsCodec.decode(FsCodec.CA_RECORD, encoded);
assertEquals(original, decoded);
assertEquals(List.of(first, second), decoded.credentialIds());
assertEquals(List.of(first, second), decoded.issuerIds());
byte[] embeddedValueTag = encoded.clone();
int firstIdentifier = indexOf(embeddedValueTag, first.value().getBytes(StandardCharsets.UTF_8));

View File

@@ -209,10 +209,36 @@ public final class PkiTestRuntime implements AutoCloseable {
}
public Credential caCredential(zeroecho.pki.api.ca.CaRecord ca, int index) {
PkiId credentialId = ca.credentialIds().get(index);
PkiId credentialId = store.getIssuerGeneration(ca.issuerIds().get(index)).orElseThrow().credentialId();
return store.getCredential(credentialId).orElseThrow();
}
public zeroecho.pki.api.ca.CaRecord selectIssuerCredential(zeroecho.pki.api.ca.CaRecord authority,
Credential credential, boolean retainExisting) {
zeroecho.pki.api.ca.IssuerGeneration previous = store
.getIssuerGeneration(authority.currentIssuanceIssuerId()).orElseThrow();
zeroecho.pki.api.ca.IssuerGeneration generation = new zeroecho.pki.api.ca.IssuerGeneration(
zeroecho.pki.api.ca.IssuerGeneration.idFor(authority.caId(), credential.credentialId()),
authority.caId(), credential.credentialId(), previous.signingKeyRef(), previous.state(),
previous.profilePolicyCommitment(), previous.x509BindingCommitment());
store.putIssuerGeneration(generation);
zeroecho.pki.api.ca.IssuerChainPath oldPath = store
.getIssuerChainPath(authority.issuanceChainPathId()).orElseThrow();
java.util.List<PkiId> credentials = new java.util.ArrayList<>(oldPath.orderedCredentialIds());
credentials.set(0, credential.credentialId());
zeroecho.pki.api.ca.IssuerChainPath path = zeroecho.pki.api.ca.IssuerChainPath.create(authority.caId(),
generation.issuerId(), credentials);
store.putIssuerChainPath(path);
java.util.List<PkiId> issuers = new java.util.ArrayList<>();
if (retainExisting) issuers.addAll(authority.issuerIds());
issuers.add(generation.issuerId());
zeroecho.pki.api.ca.CaRecord updated = new zeroecho.pki.api.ca.CaRecord(authority.caId(), authority.kind(),
authority.state(), authority.issuerKeyRef(), authority.subjectRef(), issuers,
generation.issuerId(), path.pathId());
store.putCa(updated);
return updated;
}
private record UntrustedReference(String storeId, String contentId, Encoding encoding, long length, String sha256,
DurableContentReference.Lifecycle lifecycle) implements DurableContentReference {
}