security(pki): enforce authoritative revocation state
This commit is contained in:
@@ -33,7 +33,6 @@
|
|||||||
******************************************************************************/
|
******************************************************************************/
|
||||||
package zeroecho.pki.api;
|
package zeroecho.pki.api;
|
||||||
|
|
||||||
import zeroecho.pki.api.revocation.RevokedRecord;
|
|
||||||
import zeroecho.pki.api.transfer.ExportArtifact;
|
import zeroecho.pki.api.transfer.ExportArtifact;
|
||||||
import zeroecho.pki.api.transfer.ExportFormat;
|
import zeroecho.pki.api.transfer.ExportFormat;
|
||||||
import zeroecho.pki.api.transfer.ExportQuery;
|
import zeroecho.pki.api.transfer.ExportQuery;
|
||||||
@@ -73,17 +72,6 @@ public interface ImportExportService {
|
|||||||
*/
|
*/
|
||||||
PkiId importCaCertificate(PkiId caId, EncodedObject caCertificate, ImportPolicy policy);
|
PkiId importCaCertificate(PkiId caId, EncodedObject caCertificate, ImportPolicy policy);
|
||||||
|
|
||||||
/**
|
|
||||||
* Imports a revocation record.
|
|
||||||
*
|
|
||||||
* @param record revocation record
|
|
||||||
* @param policy import policy
|
|
||||||
* @return imported revocation record id (implementation-defined)
|
|
||||||
* @throws IllegalArgumentException if inputs are invalid
|
|
||||||
* @throws PkiException if import fails
|
|
||||||
*/
|
|
||||||
PkiId importRevocation(RevokedRecord record, ImportPolicy policy);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exports credentials matching the query constraints in the requested export
|
* Exports credentials matching the query constraints in the requested export
|
||||||
* format.
|
* format.
|
||||||
|
|||||||
@@ -1,99 +1,58 @@
|
|||||||
/*******************************************************************************
|
/*******************************************************************************
|
||||||
* Copyright (C) 2026, Leo Galambos
|
* Copyright (C) 2026, Leo Galambos
|
||||||
* All rights reserved.
|
* All rights reserved.
|
||||||
*
|
|
||||||
* Redistribution and use in source and binary forms, with or without modification,
|
|
||||||
* are permitted provided that the following conditions are met:
|
|
||||||
*
|
|
||||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
|
||||||
* list of conditions and the following disclaimer.
|
|
||||||
*
|
|
||||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
|
||||||
* this list of conditions and the following disclaimer in the documentation
|
|
||||||
* and/or other materials provided with the distribution.
|
|
||||||
*
|
|
||||||
* 3. All advertising materials mentioning features or use of this software must
|
|
||||||
* display the following acknowledgement:
|
|
||||||
* This product includes software developed by the Egothor project.
|
|
||||||
*
|
|
||||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
|
||||||
* may be used to endorse or promote products derived from this software without
|
|
||||||
* specific prior written permission.
|
|
||||||
*
|
|
||||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
|
||||||
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
|
||||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
||||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
|
||||||
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
|
||||||
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
|
||||||
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
|
||||||
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
|
||||||
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
|
||||||
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
||||||
******************************************************************************/
|
******************************************************************************/
|
||||||
package zeroecho.pki.api;
|
package zeroecho.pki.api;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
import zeroecho.pki.api.revocation.HoldCommand;
|
import zeroecho.pki.api.revocation.RevocationCommand;
|
||||||
|
import zeroecho.pki.api.revocation.RevocationJournal;
|
||||||
import zeroecho.pki.api.revocation.RevocationQuery;
|
import zeroecho.pki.api.revocation.RevocationQuery;
|
||||||
import zeroecho.pki.api.revocation.RevokeCommand;
|
|
||||||
import zeroecho.pki.api.revocation.RevokedRecord;
|
|
||||||
import zeroecho.pki.api.revocation.UnholdCommand;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Revocation operations and revocation record management.
|
* Authoritative revocation transition and journal administration.
|
||||||
*/
|
*/
|
||||||
public interface RevocationService {
|
public interface RevocationService {
|
||||||
|
|
||||||
/**
|
|
||||||
* Revokes a credential.
|
|
||||||
*
|
|
||||||
* @param command revoke command
|
|
||||||
* @return revocation record
|
|
||||||
* @throws IllegalArgumentException if {@code command} is invalid
|
|
||||||
* @throws PkiException if revocation fails
|
|
||||||
*/
|
|
||||||
RevokedRecord revoke(RevokeCommand command);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Places a credential on hold.
|
* Places a credential on hold.
|
||||||
*
|
*
|
||||||
* @param command hold command
|
* @param command hold command
|
||||||
* @return revocation record
|
* @return committed journal
|
||||||
* @throws IllegalArgumentException if {@code command} is invalid
|
|
||||||
* @throws PkiException if hold fails
|
|
||||||
*/
|
*/
|
||||||
RevokedRecord hold(HoldCommand command);
|
RevocationJournal hold(RevocationCommand.Hold command);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes a hold from a credential.
|
* Removes an existing hold.
|
||||||
*
|
*
|
||||||
* @param command unhold command
|
* @param command unhold command
|
||||||
* @return revocation record
|
* @return committed journal
|
||||||
* @throws IllegalArgumentException if {@code command} is invalid
|
|
||||||
* @throws PkiException if unhold fails
|
|
||||||
*/
|
*/
|
||||||
RevokedRecord unhold(UnholdCommand command);
|
RevocationJournal unhold(RevocationCommand.Unhold command);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves revocation record for a credential.
|
* Permanently revokes a credential.
|
||||||
*
|
*
|
||||||
* @param credentialId credential id
|
* @param command permanent revocation command
|
||||||
* @return record if present
|
* @return committed journal
|
||||||
* @throws IllegalArgumentException if {@code credentialId} is null
|
|
||||||
* @throws PkiException if retrieval fails
|
|
||||||
*/
|
*/
|
||||||
Optional<RevokedRecord> get(PkiId credentialId);
|
RevocationJournal revokePermanently(RevocationCommand.RevokePermanently command);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Searches revocation records.
|
* Retrieves the authoritative journal for one credential.
|
||||||
*
|
*
|
||||||
* @param query query constraints
|
* @param credentialId credential identifier
|
||||||
* @return matching records
|
* @return journal when present
|
||||||
* @throws IllegalArgumentException if {@code query} is null
|
|
||||||
* @throws PkiException if search fails
|
|
||||||
*/
|
*/
|
||||||
List<RevokedRecord> search(RevocationQuery query);
|
Optional<RevocationJournal> get(PkiId credentialId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searches authoritative journals by their latest transition.
|
||||||
|
*
|
||||||
|
* @param query administrative query
|
||||||
|
* @return matching journals
|
||||||
|
*/
|
||||||
|
List<RevocationJournal> search(RevocationQuery query);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,11 @@ import zeroecho.pki.api.attr.AttributeSet;
|
|||||||
* X.509)
|
* X.509)
|
||||||
* @param publicKeyId stable identifier derived from the subject public key
|
* @param publicKeyId stable identifier derived from the subject public key
|
||||||
* @param profileId profile governing issuance
|
* @param profileId profile governing issuance
|
||||||
* @param status inventory status
|
* @param status persisted issuance and inventory metadata; this value
|
||||||
|
* is not sufficient for a trust decision because current
|
||||||
|
* revocation state and evaluation time are external
|
||||||
|
* runtime inputs. Security-sensitive callers must use
|
||||||
|
* {@link EffectiveCredentialStatusResolver}.
|
||||||
* @param encoded encoded credential bytes
|
* @param encoded encoded credential bytes
|
||||||
* @param attributes universal attribute set
|
* @param attributes universal attribute set
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -37,8 +37,9 @@ package zeroecho.pki.api.credential;
|
|||||||
* Status of a credential as tracked by PKI inventory.
|
* Status of a credential as tracked by PKI inventory.
|
||||||
*
|
*
|
||||||
* <p>
|
* <p>
|
||||||
* Status may be computed from validity and revocation state or stored directly
|
* This value is persisted issuance and inventory metadata. It is not an
|
||||||
* depending on implementation.
|
* authoritative trust decision: current revocation state and evaluation time
|
||||||
|
* must be resolved through {@link EffectiveCredentialStatusResolver}.
|
||||||
* </p>
|
* </p>
|
||||||
*/
|
*/
|
||||||
public enum CredentialStatus {
|
public enum CredentialStatus {
|
||||||
|
|||||||
@@ -31,36 +31,30 @@
|
|||||||
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||||
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
******************************************************************************/
|
******************************************************************************/
|
||||||
package zeroecho.pki.api.revocation;
|
package zeroecho.pki.api.credential;
|
||||||
|
|
||||||
import zeroecho.pki.api.PkiId;
|
|
||||||
import zeroecho.pki.api.attr.AttributeSet;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Command to remove a hold from a credential.
|
* Closed categories of currently reachable credential trust decisions.
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* Frameworks may map this to X.509 {@code removeFromCRL} or equivalent
|
|
||||||
* semantics.
|
|
||||||
* </p>
|
|
||||||
*
|
|
||||||
* @param credentialId credential identifier
|
|
||||||
* @param attributes optional additional attributes (may be empty but not
|
|
||||||
* null)
|
|
||||||
*/
|
*/
|
||||||
public record UnholdCommand(PkiId credentialId, AttributeSet attributes) {
|
public enum CredentialUse {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates an unhold command.
|
* Issuer credential used for end-entity issuance.
|
||||||
*
|
|
||||||
* @throws IllegalArgumentException if inputs are null
|
|
||||||
*/
|
*/
|
||||||
public UnholdCommand {
|
END_ENTITY_ISSUER,
|
||||||
if (credentialId == null) {
|
|
||||||
throw new IllegalArgumentException("credentialId must not be null");
|
/**
|
||||||
}
|
* Issuer credential used for intermediate CA issuance.
|
||||||
if (attributes == null) {
|
*/
|
||||||
throw new IllegalArgumentException("attributes must not be null");
|
INTERMEDIATE_ISSUER,
|
||||||
}
|
|
||||||
}
|
/**
|
||||||
|
* Issuer credential used to sign a status object.
|
||||||
|
*/
|
||||||
|
STATUS_OBJECT_ISSUER,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leaf credential delivered in a trusted credential bundle.
|
||||||
|
*/
|
||||||
|
BUNDLE_DELIVERY
|
||||||
}
|
}
|
||||||
@@ -31,36 +31,41 @@
|
|||||||
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||||
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
******************************************************************************/
|
******************************************************************************/
|
||||||
package zeroecho.pki.api.revocation;
|
package zeroecho.pki.api.credential;
|
||||||
|
|
||||||
import zeroecho.pki.api.PkiId;
|
|
||||||
import zeroecho.pki.api.attr.AttributeSet;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Command to place a credential on hold.
|
* Runtime status used when deciding whether a credential may participate in a
|
||||||
|
* security-sensitive operation.
|
||||||
*
|
*
|
||||||
* <p>
|
* <p>
|
||||||
* Frameworks may map this to X.509 {@code certificateHold} or equivalent
|
* This status is derived from persisted inventory metadata, current revocation
|
||||||
* semantics.
|
* state, and one authoritative evaluation time. It is not persisted.
|
||||||
* </p>
|
* </p>
|
||||||
*
|
|
||||||
* @param credentialId credential identifier
|
|
||||||
* @param attributes optional additional attributes (may be empty but not
|
|
||||||
* null)
|
|
||||||
*/
|
*/
|
||||||
public record HoldCommand(PkiId credentialId, AttributeSet attributes) {
|
public enum EffectiveCredentialStatus {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a hold command.
|
* The credential is issued, unrevoked, and within its validity interval.
|
||||||
*
|
|
||||||
* @throws IllegalArgumentException if inputs are null
|
|
||||||
*/
|
*/
|
||||||
public HoldCommand {
|
USABLE,
|
||||||
if (credentialId == null) {
|
|
||||||
throw new IllegalArgumentException("credentialId must not be null");
|
/**
|
||||||
}
|
* The credential validity interval has not started.
|
||||||
if (attributes == null) {
|
*/
|
||||||
throw new IllegalArgumentException("attributes must not be null");
|
NOT_YET_VALID,
|
||||||
}
|
|
||||||
}
|
/**
|
||||||
|
* The credential is expired by metadata or evaluation time.
|
||||||
|
*/
|
||||||
|
EXPIRED,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The credential is temporarily held.
|
||||||
|
*/
|
||||||
|
HELD,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The credential is permanently revoked.
|
||||||
|
*/
|
||||||
|
PERMANENTLY_REVOKED
|
||||||
}
|
}
|
||||||
@@ -31,40 +31,64 @@
|
|||||||
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||||
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
******************************************************************************/
|
******************************************************************************/
|
||||||
package zeroecho.pki.api.revocation;
|
package zeroecho.pki.api.credential;
|
||||||
|
|
||||||
import zeroecho.pki.api.PkiId;
|
import java.time.Instant;
|
||||||
import zeroecho.pki.api.attr.AttributeSet;
|
|
||||||
|
import zeroecho.pki.api.PkiException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Command to revoke a credential.
|
* Resolves authoritative runtime credential status for trust decisions.
|
||||||
*
|
*
|
||||||
* <p>
|
* <p>
|
||||||
* Additional revocation metadata (e.g., invalidity date) may be conveyed via
|
* One {@link Evaluation} captures one authoritative instant and must be reused
|
||||||
* {@code attributes} using universal attribute definitions.
|
* for every candidate considered by the same operation.
|
||||||
* </p>
|
* </p>
|
||||||
*
|
|
||||||
* @param credentialId credential identifier to revoke
|
|
||||||
* @param reason revocation reason
|
|
||||||
* @param attributes additional revocation attributes (may be empty but not
|
|
||||||
* null)
|
|
||||||
*/
|
*/
|
||||||
public record RevokeCommand(PkiId credentialId, RevocationReason reason, AttributeSet attributes) {
|
@FunctionalInterface
|
||||||
|
public interface EffectiveCredentialStatusResolver {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a revoke command.
|
* Starts one status evaluation using one captured authoritative time.
|
||||||
*
|
*
|
||||||
* @throws IllegalArgumentException if inputs are null
|
* @return evaluation session
|
||||||
|
* @throws PkiException if the authoritative time cannot be obtained safely
|
||||||
*/
|
*/
|
||||||
public RevokeCommand {
|
Evaluation beginEvaluation();
|
||||||
if (credentialId == null) {
|
|
||||||
throw new IllegalArgumentException("credentialId must not be null");
|
/**
|
||||||
}
|
* One immutable-time effective-status evaluation.
|
||||||
if (reason == null) {
|
*/
|
||||||
throw new IllegalArgumentException("reason must not be null");
|
interface Evaluation {
|
||||||
}
|
|
||||||
if (attributes == null) {
|
/**
|
||||||
throw new IllegalArgumentException("attributes must not be null");
|
* Returns the authoritative instant captured for this evaluation.
|
||||||
}
|
*
|
||||||
|
* @return evaluation instant
|
||||||
|
*/
|
||||||
|
Instant evaluationTime();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves one credential using current revocation state and the captured
|
||||||
|
* evaluation time.
|
||||||
|
*
|
||||||
|
* @param credential credential to resolve
|
||||||
|
* @return effective runtime status
|
||||||
|
* @throws NullPointerException if {@code credential} is {@code null}
|
||||||
|
* @throws PkiException if current status cannot be resolved safely
|
||||||
|
*/
|
||||||
|
EffectiveCredentialStatus resolve(Credential credential);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requires a credential to be usable for the supplied operation category.
|
||||||
|
*
|
||||||
|
* @param credential credential to check
|
||||||
|
* @param use trust-decision category
|
||||||
|
* @return the supplied credential when usable
|
||||||
|
* @throws NullPointerException if an argument is {@code null}
|
||||||
|
* @throws PkiException if status resolution fails or the credential is not
|
||||||
|
* usable
|
||||||
|
*/
|
||||||
|
Credential requireUsable(Credential credential, CredentialUse use);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -43,8 +43,8 @@
|
|||||||
* <h2>Notes</h2>
|
* <h2>Notes</h2>
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>Credentials are treated as immutable artifacts once issued.</li>
|
* <li>Credentials are treated as immutable artifacts once issued.</li>
|
||||||
* <li>Status values capture the operational lifecycle (e.g., issued, expired,
|
* <li>Persisted inventory status does not replace runtime effective-status
|
||||||
* revoked, on hold).</li>
|
* resolution for security-sensitive credential use.</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
*
|
*
|
||||||
* @since 1.0
|
* @since 1.0
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2026, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
******************************************************************************/
|
||||||
|
package zeroecho.pki.api.revocation;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
import zeroecho.pki.api.PkiId;
|
||||||
|
import zeroecho.pki.api.attr.AttributeSet;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closed, immutable commands accepted by the revocation state machine.
|
||||||
|
*/
|
||||||
|
public sealed interface RevocationCommand
|
||||||
|
permits RevocationCommand.Hold, RevocationCommand.Unhold, RevocationCommand.RevokePermanently {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the target credential.
|
||||||
|
*
|
||||||
|
* @return credential identifier
|
||||||
|
*/
|
||||||
|
PkiId credentialId();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an immutable snapshot of safe administrative metadata.
|
||||||
|
*
|
||||||
|
* @return administrative attributes
|
||||||
|
*/
|
||||||
|
AttributeSet attributes();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Places a credential on hold.
|
||||||
|
*
|
||||||
|
* @param credentialId credential identifier
|
||||||
|
* @param attributes safe administrative metadata
|
||||||
|
*/
|
||||||
|
record Hold(PkiId credentialId, AttributeSet attributes) implements RevocationCommand {
|
||||||
|
/** Validates and snapshots the command. */
|
||||||
|
public Hold {
|
||||||
|
Objects.requireNonNull(credentialId, "credentialId");
|
||||||
|
attributes = RevocationJournal.snapshot(attributes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes an existing hold.
|
||||||
|
*
|
||||||
|
* @param credentialId credential identifier
|
||||||
|
* @param attributes safe administrative metadata
|
||||||
|
*/
|
||||||
|
record Unhold(PkiId credentialId, AttributeSet attributes) implements RevocationCommand {
|
||||||
|
/** Validates and snapshots the command. */
|
||||||
|
public Unhold {
|
||||||
|
Objects.requireNonNull(credentialId, "credentialId");
|
||||||
|
attributes = RevocationJournal.snapshot(attributes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanently revokes a credential.
|
||||||
|
*
|
||||||
|
* @param credentialId credential identifier
|
||||||
|
* @param reason permanent revocation reason
|
||||||
|
* @param attributes safe administrative metadata
|
||||||
|
*/
|
||||||
|
record RevokePermanently(PkiId credentialId, RevocationReason reason, AttributeSet attributes)
|
||||||
|
implements RevocationCommand {
|
||||||
|
/** Validates and snapshots the command. */
|
||||||
|
public RevokePermanently {
|
||||||
|
Objects.requireNonNull(credentialId, "credentialId");
|
||||||
|
Objects.requireNonNull(reason, "reason");
|
||||||
|
if (reason == RevocationReason.CERTIFICATE_HOLD || reason == RevocationReason.REMOVE_FROM_CRL) {
|
||||||
|
throw new IllegalArgumentException("reason must be permanent");
|
||||||
|
}
|
||||||
|
attributes = RevocationJournal.snapshot(attributes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2026, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
******************************************************************************/
|
||||||
|
package zeroecho.pki.api.revocation;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
import zeroecho.pki.api.PkiId;
|
||||||
|
import zeroecho.pki.api.attr.AttributeId;
|
||||||
|
import zeroecho.pki.api.attr.AttributeSet;
|
||||||
|
import zeroecho.pki.api.attr.AttributeValue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable single-file authority for one credential's revocation lifecycle.
|
||||||
|
*
|
||||||
|
* @param credentialId credential namespace identity
|
||||||
|
* @param transitions ordered committed transitions
|
||||||
|
*/
|
||||||
|
public record RevocationJournal(PkiId credentialId, List<RevocationTransition> transitions) {
|
||||||
|
|
||||||
|
/** Current journal format version. */
|
||||||
|
public static final int CURRENT_VERSION = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an immutable journal snapshot.
|
||||||
|
*/
|
||||||
|
public RevocationJournal {
|
||||||
|
Objects.requireNonNull(credentialId, "credentialId");
|
||||||
|
Objects.requireNonNull(transitions, "transitions");
|
||||||
|
transitions = List.copyOf(transitions);
|
||||||
|
validate(transitions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validate(List<RevocationTransition> transitions) {
|
||||||
|
if (transitions.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("transitions must not be empty");
|
||||||
|
}
|
||||||
|
RevocationState previous = null;
|
||||||
|
Instant previousTime = null;
|
||||||
|
long expectedRevision = 1L;
|
||||||
|
for (RevocationTransition transition : transitions) {
|
||||||
|
if (transition.revision() != expectedRevision
|
||||||
|
|| previousTime != null && transition.time().isBefore(previousTime)
|
||||||
|
|| !validTransition(previous, transition)) {
|
||||||
|
throw new IllegalArgumentException("invalid revocation journal");
|
||||||
|
}
|
||||||
|
expectedRevision++;
|
||||||
|
previous = transition.state();
|
||||||
|
previousTime = transition.time();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean validTransition(RevocationState previous, RevocationTransition transition) {
|
||||||
|
boolean reasonValid = transition.state() == RevocationState.PERMANENTLY_REVOKED
|
||||||
|
? transition.permanentReason().filter(RevocationJournal::isPermanent).isPresent()
|
||||||
|
: transition.permanentReason().isEmpty();
|
||||||
|
if (!reasonValid || previous == RevocationState.PERMANENTLY_REVOKED) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (previous == null) {
|
||||||
|
return transition.state() == RevocationState.HELD
|
||||||
|
|| transition.state() == RevocationState.PERMANENTLY_REVOKED;
|
||||||
|
}
|
||||||
|
return switch (previous) {
|
||||||
|
case CLEAR -> transition.state() == RevocationState.HELD
|
||||||
|
|| transition.state() == RevocationState.PERMANENTLY_REVOKED;
|
||||||
|
case HELD -> transition.state() == RevocationState.CLEAR
|
||||||
|
|| transition.state() == RevocationState.PERMANENTLY_REVOKED;
|
||||||
|
case PERMANENTLY_REVOKED -> false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isPermanent(RevocationReason reason) {
|
||||||
|
return reason != RevocationReason.CERTIFICATE_HOLD && reason != RevocationReason.REMOVE_FROM_CRL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the last committed transition.
|
||||||
|
*
|
||||||
|
* @return last transition
|
||||||
|
* @throws IllegalStateException if the journal is empty
|
||||||
|
*/
|
||||||
|
public RevocationTransition latest() {
|
||||||
|
if (transitions.isEmpty()) {
|
||||||
|
throw new IllegalStateException("Revocation journal is empty");
|
||||||
|
}
|
||||||
|
return transitions.get(transitions.size() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defensive snapshotting necessarily allocates immutable entries while walking
|
||||||
|
// the caller-owned attribute collection.
|
||||||
|
@SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
|
||||||
|
/* default */ static AttributeSet snapshot(AttributeSet source) {
|
||||||
|
Objects.requireNonNull(source, "source");
|
||||||
|
List<SnapshotEntry> entries = new ArrayList<>();
|
||||||
|
for (AttributeId id : source.ids()) {
|
||||||
|
List<AttributeValue> values = new ArrayList<>();
|
||||||
|
for (AttributeValue value : source.getAll(id)) {
|
||||||
|
values.add(snapshotValue(value));
|
||||||
|
}
|
||||||
|
entries.add(new SnapshotEntry(id, List.copyOf(values)));
|
||||||
|
}
|
||||||
|
return new SnapshotAttributeSet(List.copyOf(entries));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AttributeValue snapshotValue(AttributeValue value) {
|
||||||
|
Objects.requireNonNull(value, "value");
|
||||||
|
if (value instanceof AttributeValue.BytesValue bytesValue) {
|
||||||
|
return new AttributeValue.BytesValue(bytesValue.value().clone());
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One immutable attribute entry owned by the journal.
|
||||||
|
*/
|
||||||
|
private record SnapshotEntry(AttributeId id, List<AttributeValue> values) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable defensive implementation used for journal metadata ownership.
|
||||||
|
*/
|
||||||
|
private static final class SnapshotAttributeSet implements AttributeSet {
|
||||||
|
private final List<SnapshotEntry> entries;
|
||||||
|
|
||||||
|
private SnapshotAttributeSet(List<SnapshotEntry> entries) {
|
||||||
|
this.entries = entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Set<AttributeId> ids() {
|
||||||
|
Set<AttributeId> ids = new LinkedHashSet<>();
|
||||||
|
for (SnapshotEntry entry : entries) {
|
||||||
|
ids.add(entry.id());
|
||||||
|
}
|
||||||
|
return Set.copyOf(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<AttributeValue> get(AttributeId id) {
|
||||||
|
List<AttributeValue> values = getAll(id);
|
||||||
|
return values.isEmpty() ? Optional.empty() : Optional.of(values.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Byte values are copied per accessor invocation so callers cannot mutate
|
||||||
|
// journal-owned storage.
|
||||||
|
@SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops")
|
||||||
|
@Override
|
||||||
|
public List<AttributeValue> getAll(AttributeId id) {
|
||||||
|
Objects.requireNonNull(id, "id");
|
||||||
|
for (SnapshotEntry entry : entries) {
|
||||||
|
if (entry.id().equals(id)) {
|
||||||
|
List<AttributeValue> copies = new ArrayList<>(entry.values().size());
|
||||||
|
for (AttributeValue value : entry.values()) {
|
||||||
|
copies.add(snapshotValue(value));
|
||||||
|
}
|
||||||
|
return List.copyOf(copies);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2026, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
******************************************************************************/
|
||||||
|
package zeroecho.pki.api.revocation;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effective state recorded by an authoritative revocation journal.
|
||||||
|
*/
|
||||||
|
public enum RevocationState {
|
||||||
|
/** Credential has no effective revocation restriction. */
|
||||||
|
CLEAR,
|
||||||
|
/** Credential is temporarily held. */
|
||||||
|
HELD,
|
||||||
|
/** Credential is permanently revoked. */
|
||||||
|
PERMANENTLY_REVOKED
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2026, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
******************************************************************************/
|
||||||
|
package zeroecho.pki.api.revocation;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import zeroecho.pki.api.attr.AttributeSet;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One committed transition in an authoritative revocation journal.
|
||||||
|
*
|
||||||
|
* @param revision positive contiguous revision
|
||||||
|
* @param state resulting state
|
||||||
|
* @param time authoritative transition time
|
||||||
|
* @param permanentReason permanent reason only for permanent state
|
||||||
|
* @param attributes safe administrative metadata
|
||||||
|
*/
|
||||||
|
public record RevocationTransition(long revision, RevocationState state, Instant time,
|
||||||
|
Optional<RevocationReason> permanentReason, AttributeSet attributes) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates structural transition fields.
|
||||||
|
*/
|
||||||
|
public RevocationTransition {
|
||||||
|
if (revision <= 0L || state == null || time == null || permanentReason == null || attributes == null) {
|
||||||
|
throw new IllegalArgumentException("Invalid revocation transition");
|
||||||
|
}
|
||||||
|
attributes = RevocationJournal.snapshot(attributes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,47 +31,44 @@
|
|||||||
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||||
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
******************************************************************************/
|
******************************************************************************/
|
||||||
package zeroecho.pki.api.revocation;
|
package zeroecho.pki.impl.core;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
import zeroecho.pki.api.PkiId;
|
import zeroecho.pki.api.audit.AuditEvent;
|
||||||
import zeroecho.pki.api.attr.AttributeSet;
|
import zeroecho.pki.api.audit.Principal;
|
||||||
|
import zeroecho.pki.api.audit.Purpose;
|
||||||
|
import zeroecho.pki.api.credential.Credential;
|
||||||
|
import zeroecho.pki.api.credential.CredentialUse;
|
||||||
|
import zeroecho.pki.api.credential.EffectiveCredentialStatus;
|
||||||
|
import zeroecho.pki.spi.audit.AuditSink;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persisted revocation record.
|
* Emits one best-effort, redacted caller-level credential trust rejection.
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* This record is the authoritative input for generating status objects (CRLs,
|
|
||||||
* OCSP responses, or framework-specific revocation lists).
|
|
||||||
* </p>
|
|
||||||
*
|
|
||||||
* @param credentialId revoked credential id
|
|
||||||
* @param revocationTime server time when revocation was recorded
|
|
||||||
* @param reason revocation reason
|
|
||||||
* @param attributes additional revocation attributes (e.g., invalidity
|
|
||||||
* date), must not contain secrets
|
|
||||||
*/
|
*/
|
||||||
public record RevokedRecord(PkiId credentialId, Instant revocationTime, RevocationReason reason,
|
final class CredentialTrustAudit {
|
||||||
AttributeSet attributes) {
|
private static final Principal SYSTEM_PKI = new Principal("SYSTEM", "pki");
|
||||||
|
private static final Purpose TRUST_PURPOSE = new Purpose("CREDENTIAL_TRUST");
|
||||||
|
|
||||||
/**
|
private CredentialTrustAudit() {
|
||||||
* Creates a revocation record.
|
// Utility class.
|
||||||
*
|
|
||||||
* @throws IllegalArgumentException if inputs are null
|
|
||||||
*/
|
|
||||||
public RevokedRecord {
|
|
||||||
if (credentialId == null) {
|
|
||||||
throw new IllegalArgumentException("credentialId must not be null");
|
|
||||||
}
|
}
|
||||||
if (revocationTime == null) {
|
|
||||||
throw new IllegalArgumentException("revocationTime must not be null");
|
// Best-effort audit callbacks are untrusted; broad catch and empty handling preserve
|
||||||
}
|
// the authoritative trust rejection without exposing listener diagnostics.
|
||||||
if (reason == null) {
|
@SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.EmptyCatchBlock" })
|
||||||
throw new IllegalArgumentException("reason must not be null");
|
/* default */ static void rejected(AuditSink sink, Instant time, Credential credential, CredentialUse use,
|
||||||
}
|
String code, EffectiveCredentialStatus status) {
|
||||||
if (attributes == null) {
|
Map<String, String> details = status == null
|
||||||
throw new IllegalArgumentException("attributes must not be null");
|
? Map.of("code", code, "operation", use.name())
|
||||||
|
: Map.of("code", code, "operation", use.name(), "effectiveStatus", status.name());
|
||||||
|
try {
|
||||||
|
sink.record(new AuditEvent(time, "CREDENTIAL_TRUST", "CREDENTIAL_REJECTED", SYSTEM_PKI, TRUST_PURPOSE,
|
||||||
|
Optional.of(credential.credentialId()), Optional.empty(), details));
|
||||||
|
} catch (RuntimeException auditFailure) {
|
||||||
|
// Best-effort auditing must not change the trust decision or expose sink data.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,6 +81,9 @@ import zeroecho.pki.api.ca.IntermediateCertIssueCommand;
|
|||||||
import zeroecho.pki.api.ca.IntermediateCreateCommand;
|
import zeroecho.pki.api.ca.IntermediateCreateCommand;
|
||||||
import zeroecho.pki.api.credential.Credential;
|
import zeroecho.pki.api.credential.Credential;
|
||||||
import zeroecho.pki.api.credential.CredentialStatus;
|
import zeroecho.pki.api.credential.CredentialStatus;
|
||||||
|
import zeroecho.pki.api.credential.CredentialUse;
|
||||||
|
import zeroecho.pki.api.credential.EffectiveCredentialStatus;
|
||||||
|
import zeroecho.pki.api.credential.EffectiveCredentialStatusResolver;
|
||||||
import zeroecho.pki.impl.core.async.PkiSigningBus;
|
import zeroecho.pki.impl.core.async.PkiSigningBus;
|
||||||
import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
|
import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
|
||||||
import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes;
|
import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes;
|
||||||
@@ -152,6 +155,8 @@ public final class DefaultCaService implements CaService {
|
|||||||
private final CredentialFramework framework;
|
private final CredentialFramework framework;
|
||||||
private final CredentialIssuerBackend issuerBackend;
|
private final CredentialIssuerBackend issuerBackend;
|
||||||
private final CaProofGate proofGate;
|
private final CaProofGate proofGate;
|
||||||
|
private final AuditSink auditSink;
|
||||||
|
private final EffectiveCredentialStatusResolver statusResolver;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a CA service bound to a specific store, credential framework, and
|
* Creates a CA service bound to a specific store, credential framework, and
|
||||||
@@ -189,6 +194,8 @@ public final class DefaultCaService implements CaService {
|
|||||||
* {@code null}
|
* {@code null}
|
||||||
* @param auditSink required sink for safe CA proof rejection events;
|
* @param auditSink required sink for safe CA proof rejection events;
|
||||||
* must not be {@code null}
|
* must not be {@code null}
|
||||||
|
* @param statusResolver authoritative runtime credential-status resolver;
|
||||||
|
* must not be {@code null}
|
||||||
* @param signatureAlgorithmId non-blank JCA signature algorithm identifier used
|
* @param signatureAlgorithmId non-blank JCA signature algorithm identifier used
|
||||||
* for certificate signing requests initiated by
|
* for certificate signing requests initiated by
|
||||||
* this service
|
* this service
|
||||||
@@ -203,14 +210,15 @@ public final class DefaultCaService implements CaService {
|
|||||||
*/
|
*/
|
||||||
public DefaultCaService(PkiStore store, CredentialFramework framework, CredentialIssuerBackend issuerBackend,
|
public DefaultCaService(PkiStore store, CredentialFramework framework, CredentialIssuerBackend issuerBackend,
|
||||||
PublicKeyInfoResolver publicKeyResolver, PkiSigningBus signingBus, AuditSink auditSink,
|
PublicKeyInfoResolver publicKeyResolver, PkiSigningBus signingBus, AuditSink auditSink,
|
||||||
String signatureAlgorithmId, Duration signingTtl) {
|
EffectiveCredentialStatusResolver statusResolver, String signatureAlgorithmId, Duration signingTtl) {
|
||||||
|
|
||||||
this.store = Objects.requireNonNull(store, "store");
|
this.store = Objects.requireNonNull(store, "store");
|
||||||
this.framework = Objects.requireNonNull(framework, "framework");
|
this.framework = Objects.requireNonNull(framework, "framework");
|
||||||
this.issuerBackend = Objects.requireNonNull(issuerBackend, "issuerBackend");
|
this.issuerBackend = Objects.requireNonNull(issuerBackend, "issuerBackend");
|
||||||
Objects.requireNonNull(publicKeyResolver, "publicKeyResolver");
|
Objects.requireNonNull(publicKeyResolver, "publicKeyResolver");
|
||||||
Objects.requireNonNull(signingBus, "signingBus");
|
Objects.requireNonNull(signingBus, "signingBus");
|
||||||
Objects.requireNonNull(auditSink, "auditSink");
|
this.auditSink = Objects.requireNonNull(auditSink, "auditSink");
|
||||||
|
this.statusResolver = Objects.requireNonNull(statusResolver, "statusResolver");
|
||||||
if (signatureAlgorithmId == null || signatureAlgorithmId.isBlank()) {
|
if (signatureAlgorithmId == null || signatureAlgorithmId.isBlank()) {
|
||||||
throw new IllegalArgumentException("signatureAlgorithmId must not be null/blank");
|
throw new IllegalArgumentException("signatureAlgorithmId must not be null/blank");
|
||||||
}
|
}
|
||||||
@@ -406,6 +414,13 @@ public final class DefaultCaService implements CaService {
|
|||||||
* generation is performed by this service.
|
* generation is performed by this service.
|
||||||
* </p>
|
* </p>
|
||||||
*
|
*
|
||||||
|
* <p>
|
||||||
|
* Issuer credentials are evaluated against current revocation state and one
|
||||||
|
* authoritative evaluation instant before managed-key proof or signing work.
|
||||||
|
* Non-usable candidates are skipped; status-resolution failure aborts the
|
||||||
|
* operation.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
* @param command intermediate CA creation command; must not be {@code null}
|
* @param command intermediate CA creation command; must not be {@code null}
|
||||||
* @return identifier of the newly persisted intermediate CA
|
* @return identifier of the newly persisted intermediate CA
|
||||||
* @throws NullPointerException if {@code command} is {@code null}
|
* @throws NullPointerException if {@code command} is {@code null}
|
||||||
@@ -430,6 +445,9 @@ public final class DefaultCaService implements CaService {
|
|||||||
throw proofGate.rejection("CREATE_INTERMEDIATE_REJECTED", command.formatId(), Optional.empty(),
|
throw proofGate.rejection("CREATE_INTERMEDIATE_REJECTED", command.formatId(), Optional.empty(),
|
||||||
"FORMAT_UNSUPPORTED");
|
"FORMAT_UNSUPPORTED");
|
||||||
}
|
}
|
||||||
|
EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation();
|
||||||
|
Credential issuerCredential = CredentialSnapshots.copy(selectIssuerCredential(issuer, command.formatId(),
|
||||||
|
CredentialUse.INTERMEDIATE_ISSUER, statusEvaluation));
|
||||||
|
|
||||||
PkiId caId = new PkiId("ca:" + sha256Hex((issuer.caId().value() + "\n" + command.subjectRef().value())
|
PkiId caId = new PkiId("ca:" + sha256Hex((issuer.caId().value() + "\n" + command.subjectRef().value())
|
||||||
.getBytes(java.nio.charset.StandardCharsets.UTF_8)).substring(0, 16));
|
.getBytes(java.nio.charset.StandardCharsets.UTF_8)).substring(0, 16));
|
||||||
@@ -437,7 +455,6 @@ public final class DefaultCaService implements CaService {
|
|||||||
CaProofGate.ManagedKeyProof subjectProof = proofGate.proveManagedKey(command.keyRef().get(),
|
CaProofGate.ManagedKeyProof subjectProof = proofGate.proveManagedKey(command.keyRef().get(),
|
||||||
command.formatId(), "CREATE_INTERMEDIATE_REJECTED", Optional.of(caId));
|
command.formatId(), "CREATE_INTERMEDIATE_REJECTED", Optional.of(caId));
|
||||||
EncodedObject subjectSpki = subjectProof.exactPublicKey();
|
EncodedObject subjectSpki = subjectProof.exactPublicKey();
|
||||||
Credential issuerCredential = CredentialSnapshots.copy(selectIssuerCredential(issuer, command.formatId()));
|
|
||||||
requireIssuerKeyBinding(issuer, issuerCredential, command.formatId(), "CREATE_INTERMEDIATE_REJECTED",
|
requireIssuerKeyBinding(issuer, issuerCredential, command.formatId(), "CREATE_INTERMEDIATE_REJECTED",
|
||||||
Optional.of(caId));
|
Optional.of(caId));
|
||||||
AttributeSet authoritative = authoritativeIntermediateAttributes(command.attributes(), issuer,
|
AttributeSet authoritative = authoritativeIntermediateAttributes(command.attributes(), issuer,
|
||||||
@@ -473,6 +490,13 @@ public final class DefaultCaService implements CaService {
|
|||||||
* stored and appended to the subject CA credential history.
|
* stored and appended to the subject CA credential history.
|
||||||
* </p>
|
* </p>
|
||||||
*
|
*
|
||||||
|
* <p>
|
||||||
|
* Issuer credentials are evaluated against current revocation state and one
|
||||||
|
* authoritative evaluation instant before managed-key proof or signing work.
|
||||||
|
* Non-usable candidates are skipped; status-resolution failure aborts the
|
||||||
|
* operation.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
* @param command intermediate certificate issuance command; must not be
|
* @param command intermediate certificate issuance command; must not be
|
||||||
* {@code null}
|
* {@code null}
|
||||||
* @return issued credential persisted for the subject CA
|
* @return issued credential persisted for the subject CA
|
||||||
@@ -491,11 +515,13 @@ public final class DefaultCaService implements CaService {
|
|||||||
throw proofGate.rejection("ISSUE_INTERMEDIATE_REJECTED", command.formatId(), Optional.of(subject.caId()),
|
throw proofGate.rejection("ISSUE_INTERMEDIATE_REJECTED", command.formatId(), Optional.of(subject.caId()),
|
||||||
"FORMAT_UNSUPPORTED");
|
"FORMAT_UNSUPPORTED");
|
||||||
}
|
}
|
||||||
|
EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation();
|
||||||
|
Credential issuerCredential = CredentialSnapshots.copy(selectIssuerCredential(issuer, command.formatId(),
|
||||||
|
CredentialUse.INTERMEDIATE_ISSUER, statusEvaluation));
|
||||||
|
|
||||||
CaProofGate.ManagedKeyProof subjectProof = proofGate.proveManagedKey(subject.issuerKeyRef(),
|
CaProofGate.ManagedKeyProof subjectProof = proofGate.proveManagedKey(subject.issuerKeyRef(),
|
||||||
command.formatId(), "ISSUE_INTERMEDIATE_REJECTED", Optional.of(subject.caId()));
|
command.formatId(), "ISSUE_INTERMEDIATE_REJECTED", Optional.of(subject.caId()));
|
||||||
EncodedObject subjectSpki = subjectProof.exactPublicKey();
|
EncodedObject subjectSpki = subjectProof.exactPublicKey();
|
||||||
Credential issuerCredential = CredentialSnapshots.copy(selectIssuerCredential(issuer, command.formatId()));
|
|
||||||
requireIssuerKeyBinding(issuer, issuerCredential, command.formatId(), "ISSUE_INTERMEDIATE_REJECTED",
|
requireIssuerKeyBinding(issuer, issuerCredential, command.formatId(), "ISSUE_INTERMEDIATE_REJECTED",
|
||||||
Optional.of(subject.caId()));
|
Optional.of(subject.caId()));
|
||||||
AttributeSet authoritative = authoritativeIntermediateAttributes(command.attributes(), issuer,
|
AttributeSet authoritative = authoritativeIntermediateAttributes(command.attributes(), issuer,
|
||||||
@@ -669,17 +695,33 @@ public final class DefaultCaService implements CaService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Credential selectIssuerCredential(CaRecord issuer, FormatId formatId) {
|
private Credential selectIssuerCredential(CaRecord issuer, FormatId formatId, CredentialUse use,
|
||||||
Instant now = Instant.now();
|
EffectiveCredentialStatusResolver.Evaluation evaluation) {
|
||||||
|
Credential lastRejected = null;
|
||||||
|
EffectiveCredentialStatus lastStatus = null;
|
||||||
for (Credential credential : issuer.caCredentials()) {
|
for (Credential credential : issuer.caCredentials()) {
|
||||||
if (credential != null && formatId.equals(credential.formatId())
|
if (credential == null || !formatId.equals(credential.formatId())) {
|
||||||
&& credential.status() == CredentialStatus.ISSUED
|
continue;
|
||||||
&& !now.isBefore(credential.validity().notBefore())
|
}
|
||||||
&& !now.isAfter(credential.validity().notAfter())) {
|
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;
|
return credential;
|
||||||
}
|
}
|
||||||
|
lastRejected = credential;
|
||||||
|
lastStatus = status;
|
||||||
}
|
}
|
||||||
throw new PkiException("Issuer CA has no current issued credential for formatId " + formatId.value());
|
if (lastRejected != null) {
|
||||||
|
CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), lastRejected, use,
|
||||||
|
"ISSUER_CREDENTIAL_UNAVAILABLE", lastStatus);
|
||||||
|
}
|
||||||
|
throw new PkiException("Issuer credential unavailable: code=ISSUER_CREDENTIAL_UNAVAILABLE");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void requireValidImportedRoot(CaImportCommand command, X509CertificateHolder holder) {
|
private void requireValidImportedRoot(CaImportCommand command, X509CertificateHolder holder) {
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ import zeroecho.pki.api.ca.CaState;
|
|||||||
import zeroecho.pki.api.credential.Credential;
|
import zeroecho.pki.api.credential.Credential;
|
||||||
import zeroecho.pki.api.credential.CredentialBundle;
|
import zeroecho.pki.api.credential.CredentialBundle;
|
||||||
import zeroecho.pki.api.credential.CredentialStatus;
|
import zeroecho.pki.api.credential.CredentialStatus;
|
||||||
|
import zeroecho.pki.api.credential.CredentialUse;
|
||||||
|
import zeroecho.pki.api.credential.EffectiveCredentialStatus;
|
||||||
|
import zeroecho.pki.api.credential.EffectiveCredentialStatusResolver;
|
||||||
import zeroecho.pki.api.issuance.BundleCommand;
|
import zeroecho.pki.api.issuance.BundleCommand;
|
||||||
import zeroecho.pki.api.issuance.IssueEndEntityCommand;
|
import zeroecho.pki.api.issuance.IssueEndEntityCommand;
|
||||||
import zeroecho.pki.api.issuance.ReissueCommand;
|
import zeroecho.pki.api.issuance.ReissueCommand;
|
||||||
@@ -98,9 +101,8 @@ import zeroecho.pki.spi.store.PkiStore;
|
|||||||
* <ul>
|
* <ul>
|
||||||
* <li>the issuer CA must exist,</li>
|
* <li>the issuer CA must exist,</li>
|
||||||
* <li>the issuer CA must be in {@link CaState#ACTIVE} state,</li>
|
* <li>the issuer CA must be in {@link CaState#ACTIVE} state,</li>
|
||||||
* <li>the issuer CA must expose a currently valid
|
* <li>the issuer CA must expose an effectively usable credential for the active
|
||||||
* {@link CredentialStatus#ISSUED} credential for the active framework
|
* framework {@link FormatId},</li>
|
||||||
* {@link FormatId},</li>
|
|
||||||
* <li>issuer material required by the current X.509 runtime wiring must be
|
* <li>issuer material required by the current X.509 runtime wiring must be
|
||||||
* present in issuance overrides before the backend is invoked,</li>
|
* present in issuance overrides before the backend is invoked,</li>
|
||||||
* <li>the backend result is defensively snapshotted and its X.509 subject key,
|
* <li>the backend result is defensively snapshotted and its X.509 subject key,
|
||||||
@@ -150,6 +152,7 @@ public final class DefaultIssuanceService implements IssuanceService {
|
|||||||
private final CredentialFramework framework;
|
private final CredentialFramework framework;
|
||||||
private final CredentialIssuerBackend issuerBackend;
|
private final CredentialIssuerBackend issuerBackend;
|
||||||
private final AuditSink auditSink;
|
private final AuditSink auditSink;
|
||||||
|
private final EffectiveCredentialStatusResolver statusResolver;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates the issuance service bound to the supplied persistence and framework
|
* Creates the issuance service bound to the supplied persistence and framework
|
||||||
@@ -163,14 +166,18 @@ public final class DefaultIssuanceService implements IssuanceService {
|
|||||||
* gate-produced candidates; must not be {@code null}
|
* gate-produced candidates; must not be {@code null}
|
||||||
* @param auditSink required sink for safe rejection audit events; must not be
|
* @param auditSink required sink for safe rejection audit events; must not be
|
||||||
* {@code null}
|
* {@code null}
|
||||||
|
* @param statusResolver authoritative runtime credential-status resolver; must
|
||||||
|
* not be {@code null}
|
||||||
* @throws NullPointerException if an argument is {@code null}
|
* @throws NullPointerException if an argument is {@code null}
|
||||||
*/
|
*/
|
||||||
public DefaultIssuanceService(PkiStore store, CredentialFramework framework,
|
public DefaultIssuanceService(PkiStore store, CredentialFramework framework,
|
||||||
CredentialIssuerBackend issuerBackend, AuditSink auditSink) {
|
CredentialIssuerBackend issuerBackend, AuditSink auditSink,
|
||||||
|
EffectiveCredentialStatusResolver statusResolver) {
|
||||||
this.store = Objects.requireNonNull(store, "store");
|
this.store = Objects.requireNonNull(store, "store");
|
||||||
this.framework = Objects.requireNonNull(framework, "framework");
|
this.framework = Objects.requireNonNull(framework, "framework");
|
||||||
this.issuerBackend = Objects.requireNonNull(issuerBackend, "issuerBackend");
|
this.issuerBackend = Objects.requireNonNull(issuerBackend, "issuerBackend");
|
||||||
this.auditSink = Objects.requireNonNull(auditSink, "auditSink");
|
this.auditSink = Objects.requireNonNull(auditSink, "auditSink");
|
||||||
|
this.statusResolver = Objects.requireNonNull(statusResolver, "statusResolver");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -195,8 +202,9 @@ public final class DefaultIssuanceService implements IssuanceService {
|
|||||||
* @return defensive snapshot of the validated credential bundle
|
* @return defensive snapshot of the validated credential bundle
|
||||||
* @throws NullPointerException if {@code command} is {@code null}
|
* @throws NullPointerException if {@code command} is {@code null}
|
||||||
* @throws PkiException if the issuer CA does not exist, is not active,
|
* @throws PkiException if the issuer CA does not exist, is not active,
|
||||||
* has no credentials, no compatible issuer
|
* has no credentials, no compatible effectively
|
||||||
* current issued credential can be selected,
|
* usable issuer credential can be selected, or
|
||||||
|
* current revocation state cannot be resolved,
|
||||||
* issuer material enrichment fails, backend
|
* issuer material enrichment fails, backend
|
||||||
* issuance or result validation fails, or
|
* issuance or result validation fails, or
|
||||||
* persistence of the validated leaf fails
|
* persistence of the validated leaf fails
|
||||||
@@ -204,7 +212,6 @@ public final class DefaultIssuanceService implements IssuanceService {
|
|||||||
@Override
|
@Override
|
||||||
public CredentialBundle issueEndEntity(IssueEndEntityCommand command) {
|
public CredentialBundle issueEndEntity(IssueEndEntityCommand command) {
|
||||||
Objects.requireNonNull(command, "command");
|
Objects.requireNonNull(command, "command");
|
||||||
VerifiedIssuanceCandidate candidate = verifyIssuanceCandidate(command);
|
|
||||||
CaRecord issuer = store.getCa(command.issuerCaId()).orElseThrow(() -> new PkiException("Issuer CA not found"));
|
CaRecord issuer = store.getCa(command.issuerCaId()).orElseThrow(() -> new PkiException("Issuer CA not found"));
|
||||||
|
|
||||||
if (issuer.state() != CaState.ACTIVE) {
|
if (issuer.state() != CaState.ACTIVE) {
|
||||||
@@ -214,7 +221,10 @@ public final class DefaultIssuanceService implements IssuanceService {
|
|||||||
throw new PkiException("Issuer CA has no credentials");
|
throw new PkiException("Issuer CA has no credentials");
|
||||||
}
|
}
|
||||||
|
|
||||||
Credential issuerCred = CredentialSnapshots.copy(selectIssuerCredential(issuer, framework.formatId()));
|
EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation();
|
||||||
|
Credential issuerCred = CredentialSnapshots.copy(selectIssuerCredential(issuer, framework.formatId(),
|
||||||
|
CredentialUse.END_ENTITY_ISSUER, statusEvaluation));
|
||||||
|
VerifiedIssuanceCandidate candidate = verifyIssuanceCandidate(command);
|
||||||
AttributeSet enrichedOverrides = enrichOverrides(command.overrides(), issuerCred.encoded(),
|
AttributeSet enrichedOverrides = enrichOverrides(command.overrides(), issuerCred.encoded(),
|
||||||
issuer.issuerKeyRef());
|
issuer.issuerKeyRef());
|
||||||
|
|
||||||
@@ -243,15 +253,10 @@ public final class DefaultIssuanceService implements IssuanceService {
|
|||||||
* format.
|
* format.
|
||||||
*
|
*
|
||||||
* <p>
|
* <p>
|
||||||
* The selected credential must match the requested {@link FormatId}, have
|
* The selected credential must match the requested {@link FormatId} and
|
||||||
* {@link CredentialStatus#ISSUED} status, and contain the current instant
|
* resolve as effectively usable against current revocation state and the
|
||||||
* within its inclusive validity interval. No status or validity fallback is
|
* operation's captured evaluation instant. Resolution failures abort
|
||||||
* permitted.
|
* selection.
|
||||||
* </p>
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* This method does not evaluate profile suitability or revocation information
|
|
||||||
* external to {@link CredentialStatus}.
|
|
||||||
* </p>
|
* </p>
|
||||||
*
|
*
|
||||||
* @param issuer issuer CA record containing candidate credentials; must not
|
* @param issuer issuer CA record containing candidate credentials; must not
|
||||||
@@ -261,24 +266,39 @@ public final class DefaultIssuanceService implements IssuanceService {
|
|||||||
* @return selected issuer credential
|
* @return selected issuer credential
|
||||||
* @throws NullPointerException if {@code issuer} or {@code formatId} is
|
* @throws NullPointerException if {@code issuer} or {@code formatId} is
|
||||||
* {@code null}
|
* {@code null}
|
||||||
* @throws PkiException if the issuer CA has no current issued
|
* @throws PkiException if the issuer CA has no compatible effectively
|
||||||
* credential compatible with the requested format
|
* usable credential or revocation resolution fails
|
||||||
*/
|
*/
|
||||||
private static Credential selectIssuerCredential(CaRecord issuer, FormatId formatId) {
|
private Credential selectIssuerCredential(CaRecord issuer, FormatId formatId, CredentialUse use,
|
||||||
|
EffectiveCredentialStatusResolver.Evaluation evaluation) {
|
||||||
Objects.requireNonNull(issuer, "issuer");
|
Objects.requireNonNull(issuer, "issuer");
|
||||||
Objects.requireNonNull(formatId, "formatId");
|
Objects.requireNonNull(formatId, "formatId");
|
||||||
|
|
||||||
Instant now = Instant.now();
|
Credential lastRejected = null;
|
||||||
|
EffectiveCredentialStatus lastStatus = null;
|
||||||
for (Credential c : issuer.caCredentials()) {
|
for (Credential c : issuer.caCredentials()) {
|
||||||
if (c == null) {
|
if (c == null || !formatId.equals(c.formatId())) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (formatId.equals(c.formatId()) && c.status() == CredentialStatus.ISSUED
|
EffectiveCredentialStatus status;
|
||||||
&& !now.isBefore(c.validity().notBefore()) && !now.isAfter(c.validity().notAfter())) {
|
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;
|
return c;
|
||||||
}
|
}
|
||||||
|
lastRejected = c;
|
||||||
|
lastStatus = status;
|
||||||
}
|
}
|
||||||
throw new PkiException("Issuer CA has no current issued credential for formatId " + formatId.value());
|
if (lastRejected != null) {
|
||||||
|
CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), lastRejected, use,
|
||||||
|
"ISSUER_CREDENTIAL_UNAVAILABLE", lastStatus);
|
||||||
|
}
|
||||||
|
throw new PkiException("Issuer credential unavailable: code=ISSUER_CREDENTIAL_UNAVAILABLE");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -561,13 +581,30 @@ public final class DefaultIssuanceService implements IssuanceService {
|
|||||||
* no chain elements
|
* no chain elements
|
||||||
* @throws NullPointerException if {@code command} is {@code null}
|
* @throws NullPointerException if {@code command} is {@code null}
|
||||||
* @throws PkiException if the requested credential does not exist in
|
* @throws PkiException if the requested credential does not exist in
|
||||||
* the store
|
* the store or is not currently usable
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public CredentialBundle buildBundle(BundleCommand command) {
|
public CredentialBundle buildBundle(BundleCommand command) {
|
||||||
Objects.requireNonNull(command, "command");
|
Objects.requireNonNull(command, "command");
|
||||||
PkiId credId = command.credentialId();
|
PkiId credId = command.credentialId();
|
||||||
Credential leaf = store.getCredential(credId).orElseThrow(() -> new PkiException("Credential not found"));
|
Credential leaf = store.getCredential(credId).orElseThrow(() -> new PkiException("Credential not found"));
|
||||||
|
EffectiveCredentialStatusResolver.Evaluation evaluation = statusResolver.beginEvaluation();
|
||||||
|
EffectiveCredentialStatus status;
|
||||||
|
try {
|
||||||
|
status = evaluation.resolve(leaf);
|
||||||
|
} catch (PkiException exception) {
|
||||||
|
CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), leaf,
|
||||||
|
CredentialUse.BUNDLE_DELIVERY,
|
||||||
|
StoreBackedEffectiveCredentialStatusResolver.RESOLUTION_FAILED_CODE, null);
|
||||||
|
throw exception;
|
||||||
|
}
|
||||||
|
if (status != EffectiveCredentialStatus.USABLE) {
|
||||||
|
CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), leaf,
|
||||||
|
CredentialUse.BUNDLE_DELIVERY,
|
||||||
|
StoreBackedEffectiveCredentialStatusResolver.NOT_USABLE_CODE, status);
|
||||||
|
throw new PkiException("Credential trust rejected: code="
|
||||||
|
+ StoreBackedEffectiveCredentialStatusResolver.NOT_USABLE_CODE);
|
||||||
|
}
|
||||||
// Minimal bundle: leaf only. Chain selection and publication are higher-layer
|
// Minimal bundle: leaf only. Chain selection and publication are higher-layer
|
||||||
// concerns.
|
// concerns.
|
||||||
return new CredentialBundle(leaf, List.of());
|
return new CredentialBundle(leaf, List.of());
|
||||||
|
|||||||
@@ -1,288 +1,181 @@
|
|||||||
/*******************************************************************************
|
/*******************************************************************************
|
||||||
* Copyright (C) 2026, Leo Galambos
|
* Copyright (C) 2026, Leo Galambos
|
||||||
* All rights reserved.
|
* All rights reserved.
|
||||||
*
|
|
||||||
* Redistribution and use in source and binary forms, with or without modification,
|
|
||||||
* are permitted provided that the following conditions are met:
|
|
||||||
*
|
|
||||||
* 1. Redistributions of source code must retain the above copyright notice, this
|
|
||||||
* list of conditions and the following disclaimer.
|
|
||||||
*
|
|
||||||
* 2. Redistributions in binary form must reproduce the above copyright notice,
|
|
||||||
* this list of conditions and the following disclaimer in the documentation
|
|
||||||
* and/or other materials provided with the distribution.
|
|
||||||
*
|
|
||||||
* 3. All advertising materials mentioning features or use of this software must
|
|
||||||
* display the following acknowledgement:
|
|
||||||
* This product includes software developed by the Egothor project.
|
|
||||||
*
|
|
||||||
* 4. Neither the name of the copyright holder nor the names of its contributors
|
|
||||||
* may be used to endorse or promote products derived from this software without
|
|
||||||
* specific prior written permission.
|
|
||||||
*
|
|
||||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
|
||||||
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
|
||||||
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
||||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
|
|
||||||
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
|
||||||
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
|
||||||
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
|
||||||
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
|
||||||
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
|
||||||
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
||||||
******************************************************************************/
|
******************************************************************************/
|
||||||
package zeroecho.pki.impl.core;
|
package zeroecho.pki.impl.core;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
import zeroecho.pki.api.PkiException;
|
import zeroecho.pki.api.PkiException;
|
||||||
import zeroecho.pki.api.PkiId;
|
import zeroecho.pki.api.PkiId;
|
||||||
import zeroecho.pki.api.RevocationService;
|
import zeroecho.pki.api.RevocationService;
|
||||||
import zeroecho.pki.api.revocation.HoldCommand;
|
import zeroecho.pki.api.audit.AuditEvent;
|
||||||
|
import zeroecho.pki.api.audit.Principal;
|
||||||
|
import zeroecho.pki.api.audit.Purpose;
|
||||||
|
import zeroecho.pki.api.revocation.RevocationCommand;
|
||||||
|
import zeroecho.pki.api.revocation.RevocationJournal;
|
||||||
import zeroecho.pki.api.revocation.RevocationQuery;
|
import zeroecho.pki.api.revocation.RevocationQuery;
|
||||||
import zeroecho.pki.api.revocation.RevocationReason;
|
import zeroecho.pki.api.revocation.RevocationTransition;
|
||||||
import zeroecho.pki.api.revocation.RevokeCommand;
|
import zeroecho.pki.spi.audit.AuditSink;
|
||||||
import zeroecho.pki.api.revocation.RevokedRecord;
|
|
||||||
import zeroecho.pki.api.revocation.UnholdCommand;
|
|
||||||
import zeroecho.pki.spi.store.PkiStore;
|
import zeroecho.pki.spi.store.PkiStore;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default implementation of {@link RevocationService}.
|
* Store-backed authoritative revocation transition service.
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* This service provides the store-backed runtime path for credential revocation
|
|
||||||
* state recording and lookup. It validates that the referenced credential
|
|
||||||
* exists, creates a corresponding {@link RevokedRecord}, and persists that
|
|
||||||
* record into the configured {@link PkiStore}.
|
|
||||||
* </p>
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* The implementation is intentionally minimal and does not attempt to enforce a
|
|
||||||
* complex revocation state machine. In particular, it does not prevent repeated
|
|
||||||
* writes for the same credential, does not reconcile prior revocation records,
|
|
||||||
* and does not validate higher-level business transitions such as whether a
|
|
||||||
* hold can be removed only after a prior hold. Such governance is expected to
|
|
||||||
* be handled by higher layers or by stricter store policies.
|
|
||||||
* </p>
|
|
||||||
*
|
|
||||||
* <h2>Persistence model</h2>
|
|
||||||
* <ul>
|
|
||||||
* <li>Each successful operation creates a new {@link RevokedRecord} timestamped
|
|
||||||
* with the current service time.</li>
|
|
||||||
* <li>The resulting record is written to the configured {@link PkiStore}
|
|
||||||
* through {@link PkiStore#putRevocation(RevokedRecord)}.</li>
|
|
||||||
* <li>Lookup and search operations read from the store and do not maintain an
|
|
||||||
* internal cache.</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* <h2>Security considerations</h2>
|
|
||||||
* <ul>
|
|
||||||
* <li>This service does not modify the credential object itself; it records
|
|
||||||
* revocation state separately.</li>
|
|
||||||
* <li>The trustworthiness of revocation status therefore depends on consumers
|
|
||||||
* consulting the revocation store or derivative status objects such as CRLs or
|
|
||||||
* OCSP-equivalent artifacts.</li>
|
|
||||||
* <li>Attributes carried in revocation commands are passed through to the
|
|
||||||
* stored record unchanged and should therefore be governed upstream to avoid
|
|
||||||
* leaking sensitive material.</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* <h2>Thread-safety</h2>
|
|
||||||
* <p>
|
|
||||||
* This class is stateless apart from its immutable store dependency. It is safe
|
|
||||||
* for concurrent use provided that the configured {@link PkiStore} is safe for
|
|
||||||
* the intended concurrency model.
|
|
||||||
* </p>
|
|
||||||
*/
|
*/
|
||||||
public final class DefaultRevocationService implements RevocationService {
|
public final class DefaultRevocationService implements RevocationService {
|
||||||
|
private static final Instant UNKNOWN_AUDIT_TIME = Instant.EPOCH;
|
||||||
|
private static final Principal SYSTEM_PKI = new Principal("SYSTEM", "pki");
|
||||||
|
private static final Purpose REVOCATION_PURPOSE = new Purpose("REVOCATION");
|
||||||
|
private static final List<String> SAFE_STORE_CODES = List.of(
|
||||||
|
"REVOCATION_CREDENTIAL_NOT_FOUND", "REVOCATION_TRANSITION_ILLEGAL", "REVOCATION_TERMINAL",
|
||||||
|
"REVOCATION_TRANSITION_CONFLICT", "REVOCATION_STATE_CORRUPT", "REVOCATION_PERSIST_FAILED",
|
||||||
|
"REVOCATION_DURABILITY_UNCONFIRMED", "STORE_DURABILITY_UNCONFIRMED");
|
||||||
|
|
||||||
private final PkiStore store;
|
private final PkiStore store;
|
||||||
|
private final Clock clock;
|
||||||
|
private final AuditSink auditSink;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates the revocation service backed by the supplied PKI store.
|
* Creates the service with one authoritative clock.
|
||||||
*
|
*
|
||||||
* @param store PKI store used for credential existence checks, revocation
|
* @param store authoritative store
|
||||||
* persistence, and revocation lookup; must not be {@code null}
|
* @param clock authoritative clock
|
||||||
* @throws NullPointerException if {@code store} is {@code null}
|
* @param auditSink best-effort audit sink
|
||||||
*/
|
*/
|
||||||
public DefaultRevocationService(PkiStore store) {
|
public DefaultRevocationService(PkiStore store, Clock clock, AuditSink auditSink) {
|
||||||
this.store = Objects.requireNonNull(store, "store");
|
this.store = Objects.requireNonNull(store, "store");
|
||||||
|
this.clock = Objects.requireNonNull(clock, "clock");
|
||||||
|
this.auditSink = Objects.requireNonNull(auditSink, "auditSink");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Records a revocation entry for an existing credential using the caller-
|
|
||||||
* supplied revocation reason.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* The referenced credential must already exist in the store. On success, this
|
|
||||||
* method creates a new {@link RevokedRecord} timestamped with
|
|
||||||
* {@link Instant#now()}, persists it, and returns the stored record.
|
|
||||||
* </p>
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* This method does not check whether the credential has already been revoked or
|
|
||||||
* whether the requested reason conflicts with a previously persisted
|
|
||||||
* revocation-state history.
|
|
||||||
* </p>
|
|
||||||
*
|
|
||||||
* @param command revocation request describing the target credential, reason,
|
|
||||||
* and auxiliary attributes; must not be {@code null}
|
|
||||||
* @return newly created revocation record
|
|
||||||
* @throws NullPointerException if {@code command} is {@code null}
|
|
||||||
* @throws PkiException if the referenced credential does not exist or
|
|
||||||
* if the revocation record cannot be persisted
|
|
||||||
*/
|
|
||||||
@Override
|
@Override
|
||||||
public RevokedRecord revoke(RevokeCommand command) {
|
public RevocationJournal hold(RevocationCommand.Hold command) {
|
||||||
Objects.requireNonNull(command, "command");
|
return transition(Objects.requireNonNull(command, "command"), "HOLD");
|
||||||
store.getCredential(command.credentialId()).orElseThrow(() -> new PkiException("Credential not found"));
|
|
||||||
RevokedRecord rr = new RevokedRecord(command.credentialId(), Instant.now(), command.reason(),
|
|
||||||
command.attributes());
|
|
||||||
store.putRevocation(rr);
|
|
||||||
return rr;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Records a certificate-hold entry for an existing credential.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* This method is a specialization of revocation recording that always uses
|
|
||||||
* {@link RevocationReason#CERTIFICATE_HOLD} as the persisted reason.
|
|
||||||
* </p>
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* The method does not verify whether the credential is already on hold or
|
|
||||||
* whether a prior revocation history would make the hold semantically invalid.
|
|
||||||
* </p>
|
|
||||||
*
|
|
||||||
* @param command hold request describing the target credential and auxiliary
|
|
||||||
* attributes; must not be {@code null}
|
|
||||||
* @return newly created hold record
|
|
||||||
* @throws NullPointerException if {@code command} is {@code null}
|
|
||||||
* @throws PkiException if the referenced credential does not exist or
|
|
||||||
* if the hold record cannot be persisted
|
|
||||||
*/
|
|
||||||
@Override
|
@Override
|
||||||
public RevokedRecord hold(HoldCommand command) {
|
public RevocationJournal unhold(RevocationCommand.Unhold command) {
|
||||||
Objects.requireNonNull(command, "command");
|
return transition(Objects.requireNonNull(command, "command"), "UNHOLD");
|
||||||
store.getCredential(command.credentialId()).orElseThrow(() -> new PkiException("Credential not found"));
|
|
||||||
RevokedRecord rr = new RevokedRecord(command.credentialId(), Instant.now(), RevocationReason.CERTIFICATE_HOLD,
|
|
||||||
command.attributes());
|
|
||||||
store.putRevocation(rr);
|
|
||||||
return rr;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Records a removal-from-CRL entry for an existing credential.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* This method is a specialization of revocation recording that always uses
|
|
||||||
* {@link RevocationReason#REMOVE_FROM_CRL} as the persisted reason.
|
|
||||||
* </p>
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* The method does not verify whether the credential was previously placed on
|
|
||||||
* hold or whether the resulting transition is semantically valid according to a
|
|
||||||
* stricter revocation-state model.
|
|
||||||
* </p>
|
|
||||||
*
|
|
||||||
* @param command unhold request describing the target credential and auxiliary
|
|
||||||
* attributes; must not be {@code null}
|
|
||||||
* @return newly created unhold record
|
|
||||||
* @throws NullPointerException if {@code command} is {@code null}
|
|
||||||
* @throws PkiException if the referenced credential does not exist or
|
|
||||||
* if the unhold record cannot be persisted
|
|
||||||
*/
|
|
||||||
@Override
|
@Override
|
||||||
public RevokedRecord unhold(UnholdCommand command) {
|
public RevocationJournal revokePermanently(RevocationCommand.RevokePermanently command) {
|
||||||
Objects.requireNonNull(command, "command");
|
return transition(Objects.requireNonNull(command, "command"), "REVOKE_PERMANENTLY");
|
||||||
store.getCredential(command.credentialId()).orElseThrow(() -> new PkiException("Credential not found"));
|
|
||||||
RevokedRecord rr = new RevokedRecord(command.credentialId(), Instant.now(), RevocationReason.REMOVE_FROM_CRL,
|
|
||||||
command.attributes());
|
|
||||||
store.putRevocation(rr);
|
|
||||||
return rr;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves the current revocation record associated with a credential
|
|
||||||
* identifier.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* The exact meaning of the returned record depends on the configured
|
|
||||||
* {@link PkiStore} implementation. For stores that keep only one effective
|
|
||||||
* revocation entry per credential, this method returns that effective record.
|
|
||||||
* For stores with historical tracking, the SPI contract determines which record
|
|
||||||
* is exposed as the direct lookup result.
|
|
||||||
* </p>
|
|
||||||
*
|
|
||||||
* @param credentialId identifier of the credential whose revocation state is to
|
|
||||||
* be resolved; must not be {@code null}
|
|
||||||
* @return optional containing a revocation record when one is available for the
|
|
||||||
* credential, or an empty optional otherwise
|
|
||||||
* @throws NullPointerException if {@code credentialId} is {@code null}
|
|
||||||
*/
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<RevokedRecord> get(PkiId credentialId) {
|
@SuppressWarnings("PMD.AvoidCatchingGenericException")
|
||||||
|
public Optional<RevocationJournal> get(PkiId credentialId) {
|
||||||
Objects.requireNonNull(credentialId, "credentialId");
|
Objects.requireNonNull(credentialId, "credentialId");
|
||||||
return store.getRevocation(credentialId);
|
try {
|
||||||
|
return store.getRevocationJournal(credentialId);
|
||||||
|
} catch (RuntimeException failure) {
|
||||||
|
throw sanitized(failure);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Searches revocation records matching the supplied query constraints.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* Filtering is performed in memory over the revocation records returned by the
|
|
||||||
* configured {@link PkiStore}. Only constraints present in the
|
|
||||||
* {@link RevocationQuery} are applied.
|
|
||||||
* </p>
|
|
||||||
*
|
|
||||||
* <h4>Time filtering semantics</h4>
|
|
||||||
* <ul>
|
|
||||||
* <li>{@code revokedAfter}: records strictly earlier than the supplied instant
|
|
||||||
* are excluded; records exactly at the supplied instant are retained.</li>
|
|
||||||
* <li>{@code revokedBefore}: records at or after the supplied instant are
|
|
||||||
* excluded; only records strictly before the supplied instant are
|
|
||||||
* retained.</li>
|
|
||||||
* </ul>
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* When {@code issuerCaId} is present, the service resolves each revocation's
|
|
||||||
* credential from the store and compares its issuer CA identifier. Revocation
|
|
||||||
* records whose referenced credential can no longer be resolved are excluded
|
|
||||||
* from the search result for that branch of filtering.
|
|
||||||
* </p>
|
|
||||||
*
|
|
||||||
* @param query revocation search criteria; must not be {@code null}
|
|
||||||
* @return immutable list of revocation records matching the supplied criteria;
|
|
||||||
* never {@code null}
|
|
||||||
* @throws NullPointerException if {@code query} is {@code null}
|
|
||||||
*/
|
|
||||||
@Override
|
@Override
|
||||||
public List<RevokedRecord> search(RevocationQuery query) {
|
@SuppressWarnings("PMD.AvoidCatchingGenericException")
|
||||||
|
public List<RevocationJournal> search(RevocationQuery query) {
|
||||||
Objects.requireNonNull(query, "query");
|
Objects.requireNonNull(query, "query");
|
||||||
List<RevokedRecord> all = store.listRevocations();
|
try {
|
||||||
return all.stream().filter(r -> {
|
return store.listRevocationJournals().stream().filter(journal -> matches(journal, query)).toList();
|
||||||
if (query.reason().isPresent() && query.reason().get() != r.reason()) {
|
} catch (RuntimeException failure) {
|
||||||
|
throw sanitized(failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" })
|
||||||
|
private RevocationJournal transition(RevocationCommand command, String operation) {
|
||||||
|
Instant time = null;
|
||||||
|
try {
|
||||||
|
time = Objects.requireNonNull(clock.instant(), "clock instant");
|
||||||
|
RevocationJournal journal = store.transitionRevocation(command, time);
|
||||||
|
auditSuccess(time, journal, operation);
|
||||||
|
return journal;
|
||||||
|
} catch (RuntimeException failure) {
|
||||||
|
PkiException sanitized = time == null
|
||||||
|
? new PkiException("Revocation operation failed: code=REVOCATION_TIME_UNAVAILABLE")
|
||||||
|
: sanitized(failure);
|
||||||
|
auditRejected(time == null ? UNKNOWN_AUDIT_TIME : time, command.credentialId(), operation,
|
||||||
|
codeOf(sanitized), time == null ? Optional.empty() : currentState(command));
|
||||||
|
throw sanitized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean matches(RevocationJournal journal, RevocationQuery query) {
|
||||||
|
RevocationTransition latest = journal.latest();
|
||||||
|
if (query.reason().isPresent() && !latest.permanentReason().filter(query.reason().get()::equals).isPresent()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (query.revokedAfter().isPresent() && r.revocationTime().isBefore(query.revokedAfter().get())) {
|
if (query.revokedAfter().isPresent() && latest.time().isBefore(query.revokedAfter().get())) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (query.revokedBefore().isPresent() && !r.revocationTime().isBefore(query.revokedBefore().get())) {
|
if (query.revokedBefore().isPresent() && !latest.time().isBefore(query.revokedBefore().get())) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (query.issuerCaId().isPresent()) {
|
return query.issuerCaId().isEmpty() || store.getCredential(journal.credentialId())
|
||||||
Optional<zeroecho.pki.api.credential.Credential> c = store.getCredential(r.credentialId());
|
.map(credential -> query.issuerCaId().get().equals(credential.issuerRef().caId())).orElse(false);
|
||||||
if (c.isEmpty()) {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
PkiId issuerId = c.get().issuerRef().caId();
|
|
||||||
return query.issuerCaId().get().equals(issuerId);
|
@SuppressWarnings("PMD.AvoidCatchingGenericException")
|
||||||
|
private Optional<String> currentState(RevocationCommand command) {
|
||||||
|
try {
|
||||||
|
return store.getRevocationJournal(command.credentialId()).map(RevocationJournal::latest)
|
||||||
|
.map(transition -> transition.state().name());
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
return Optional.empty();
|
||||||
}
|
}
|
||||||
return true;
|
}
|
||||||
}).toList();
|
|
||||||
|
private void auditSuccess(Instant time, RevocationJournal journal, String operation) {
|
||||||
|
RevocationTransition latest = journal.latest();
|
||||||
|
audit(time, journal.credentialId(), operation, Map.of("result", "COMMITTED", "state", latest.state().name(),
|
||||||
|
"revision", Long.toString(latest.revision())));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void auditRejected(Instant time, PkiId credentialId, String operation, String code,
|
||||||
|
Optional<String> currentState) {
|
||||||
|
Map<String, String> details = currentState
|
||||||
|
.map(state -> Map.of("result", "REJECTED", "code", code, "state", state))
|
||||||
|
.orElseGet(() -> Map.of("result", "REJECTED", "code", code));
|
||||||
|
audit(time, credentialId, operation, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit listeners are external and best-effort; their failures cannot alter
|
||||||
|
// authoritative transition results.
|
||||||
|
@SuppressWarnings("PMD.AvoidCatchingGenericException")
|
||||||
|
private void audit(Instant time, PkiId credentialId, String operation, Map<String, String> details) {
|
||||||
|
try {
|
||||||
|
auditSink.record(new AuditEvent(time, "REVOCATION", operation, SYSTEM_PKI, REVOCATION_PURPOSE,
|
||||||
|
Optional.of(credentialId), Optional.empty(), details));
|
||||||
|
} catch (RuntimeException ignored) {
|
||||||
|
// Best-effort audit must not change revocation state.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store failures are hostile boundaries. Preserve only enumerated stable codes,
|
||||||
|
// never raw messages or causes.
|
||||||
|
private static PkiException sanitized(RuntimeException failure) {
|
||||||
|
String message = failure.getMessage();
|
||||||
|
if (message != null) {
|
||||||
|
for (String code : SAFE_STORE_CODES) {
|
||||||
|
if (message.contains("code=" + code)) {
|
||||||
|
return new PkiException("Revocation operation failed: code=" + code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new PkiException("Revocation operation failed: code=REVOCATION_STATE_UPDATE_FAILED");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String codeOf(PkiException failure) {
|
||||||
|
String message = failure.getMessage();
|
||||||
|
int marker = message.indexOf("code=");
|
||||||
|
return marker < 0 ? "REVOCATION_STATE_UPDATE_FAILED" : message.substring(marker + 5);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,29 +34,40 @@
|
|||||||
package zeroecho.pki.impl.core;
|
package zeroecho.pki.impl.core;
|
||||||
|
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.logging.Level;
|
import java.util.Set;
|
||||||
import java.util.logging.Logger;
|
|
||||||
|
|
||||||
import org.bouncycastle.cert.X509CertificateHolder;
|
import org.bouncycastle.cert.X509CertificateHolder;
|
||||||
|
|
||||||
|
import zeroecho.pki.api.Encoding;
|
||||||
import zeroecho.pki.api.PkiException;
|
import zeroecho.pki.api.PkiException;
|
||||||
import zeroecho.pki.api.PkiId;
|
import zeroecho.pki.api.PkiId;
|
||||||
import zeroecho.pki.api.StatusObjectService;
|
import zeroecho.pki.api.StatusObjectService;
|
||||||
import zeroecho.pki.api.attr.AttributeId;
|
|
||||||
import zeroecho.pki.api.attr.AttributeValue;
|
import zeroecho.pki.api.attr.AttributeValue;
|
||||||
import zeroecho.pki.api.ca.CaRecord;
|
import zeroecho.pki.api.ca.CaRecord;
|
||||||
import zeroecho.pki.api.ca.CaState;
|
import zeroecho.pki.api.ca.CaState;
|
||||||
import zeroecho.pki.api.credential.Credential;
|
import zeroecho.pki.api.credential.Credential;
|
||||||
import zeroecho.pki.api.revocation.RevokedRecord;
|
import zeroecho.pki.api.credential.CredentialUse;
|
||||||
|
import zeroecho.pki.api.credential.EffectiveCredentialStatus;
|
||||||
|
import zeroecho.pki.api.credential.EffectiveCredentialStatusResolver;
|
||||||
|
import zeroecho.pki.api.revocation.RevocationJournal;
|
||||||
|
import zeroecho.pki.api.revocation.RevocationReason;
|
||||||
|
import zeroecho.pki.api.revocation.RevocationState;
|
||||||
|
import zeroecho.pki.api.revocation.RevocationTransition;
|
||||||
import zeroecho.pki.api.status.StatusObject;
|
import zeroecho.pki.api.status.StatusObject;
|
||||||
import zeroecho.pki.api.status.StatusObjectGenerateCommand;
|
import zeroecho.pki.api.status.StatusObjectGenerateCommand;
|
||||||
import zeroecho.pki.api.status.StatusObjectQuery;
|
import zeroecho.pki.api.status.StatusObjectQuery;
|
||||||
import zeroecho.pki.api.status.StatusObjectType;
|
import zeroecho.pki.api.status.StatusObjectType;
|
||||||
import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
|
import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
|
||||||
import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes;
|
import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes;
|
||||||
|
import zeroecho.pki.impl.framework.x509.bc.BcX509CredentialFramework;
|
||||||
|
import zeroecho.pki.spi.audit.AuditSink;
|
||||||
|
import zeroecho.pki.spi.framework.CrlEntry;
|
||||||
import zeroecho.pki.spi.framework.CredentialFramework;
|
import zeroecho.pki.spi.framework.CredentialFramework;
|
||||||
import zeroecho.pki.spi.store.PkiStore;
|
import zeroecho.pki.spi.store.PkiStore;
|
||||||
|
|
||||||
@@ -68,15 +79,15 @@ import zeroecho.pki.spi.store.PkiStore;
|
|||||||
* the configured {@link PkiStore} and delegates format-specific object creation
|
* the configured {@link PkiStore} and delegates format-specific object creation
|
||||||
* to the active {@link CredentialFramework}. The store acts as the
|
* to the active {@link CredentialFramework}. The store acts as the
|
||||||
* authoritative source of issuer CA state, issuer credentials, revocation
|
* authoritative source of issuer CA state, issuer credentials, revocation
|
||||||
* records, and previously generated status objects.
|
* journals, and previously generated status objects.
|
||||||
* </p>
|
* </p>
|
||||||
*
|
*
|
||||||
* <p>
|
* <p>
|
||||||
* The current runtime implementation primarily supports generation workflows
|
* The current runtime implementation primarily supports generation workflows
|
||||||
* that require issuer certificate material and issuer signing key indirection
|
* that require issuer certificate material and issuer signing key indirection
|
||||||
* to be provided through status-object attributes. For X.509 CRL generation,
|
* to be provided through status-object attributes. For X.509 CRL generation,
|
||||||
* this class additionally derives revoked certificate serial numbers from the
|
* this class derives structured CRL entries from authoritative journals and the
|
||||||
* revocation and credential records currently available in the store.
|
* referenced X.509 credentials.
|
||||||
* </p>
|
* </p>
|
||||||
*
|
*
|
||||||
* <h2>Persistence model</h2>
|
* <h2>Persistence model</h2>
|
||||||
@@ -93,9 +104,9 @@ import zeroecho.pki.spi.store.PkiStore;
|
|||||||
* <li>This service does not access private key material directly.</li>
|
* <li>This service does not access private key material directly.</li>
|
||||||
* <li>Issuer signing capability is conveyed only through
|
* <li>Issuer signing capability is conveyed only through
|
||||||
* {@link BcX509Attributes#ISSUER_KEYREF}.</li>
|
* {@link BcX509Attributes#ISSUER_KEYREF}.</li>
|
||||||
* <li>For CRL generation, malformed credentials and unresolved revocation
|
* <li>For CRL generation, every active journal and referenced target is
|
||||||
* targets are silently ignored by the current implementation rather than
|
* validated before signing. Any unresolved or malformed state aborts generation
|
||||||
* failing the entire generation request.</li>
|
* with a stable redacted failure.</li>
|
||||||
* <li>The correctness of the generated status object depends on the configured
|
* <li>The correctness of the generated status object depends on the configured
|
||||||
* {@link CredentialFramework} matching the requested runtime format and
|
* {@link CredentialFramework} matching the requested runtime format and
|
||||||
* honoring the supplied attributes.</li>
|
* honoring the supplied attributes.</li>
|
||||||
@@ -109,23 +120,12 @@ import zeroecho.pki.spi.store.PkiStore;
|
|||||||
* </p>
|
* </p>
|
||||||
*/
|
*/
|
||||||
public final class DefaultStatusObjectService implements StatusObjectService {
|
public final class DefaultStatusObjectService implements StatusObjectService {
|
||||||
private static final Logger LOG = Logger.getLogger(DefaultStatusObjectService.class.getName());
|
private static final String CRL_GENERATION_FAILED = "CRL_GENERATION_FAILED";
|
||||||
|
|
||||||
/**
|
|
||||||
* Attribute identifier used to pass one revoked certificate serial number to
|
|
||||||
* the current X.509 CRL backend wiring.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* The current implementation adds this attribute repeatedly during CRL input
|
|
||||||
* assembly. The effective handling of multiple values is therefore determined
|
|
||||||
* by the semantics of {@link SimpleAttributeSet.Builder} and by the downstream
|
|
||||||
* status object generator contract.
|
|
||||||
* </p>
|
|
||||||
*/
|
|
||||||
private static final AttributeId CRL_REVOKED_SERIAL = new AttributeId("urn:zeroecho:pki:x509:crl:revoked");
|
|
||||||
|
|
||||||
private final PkiStore store;
|
private final PkiStore store;
|
||||||
private final CredentialFramework framework;
|
private final CredentialFramework framework;
|
||||||
|
private final AuditSink auditSink;
|
||||||
|
private final EffectiveCredentialStatusResolver statusResolver;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a status object service bound to the supplied persistence and
|
* Creates a status object service bound to the supplied persistence and
|
||||||
@@ -136,12 +136,16 @@ public final class DefaultStatusObjectService implements StatusObjectService {
|
|||||||
* not be {@code null}
|
* not be {@code null}
|
||||||
* @param framework credential framework providing the format-specific status
|
* @param framework credential framework providing the format-specific status
|
||||||
* object generator; must not be {@code null}
|
* object generator; must not be {@code null}
|
||||||
* @throws NullPointerException if {@code store} or {@code framework} is
|
* @param auditSink required sink for safe trust-rejection audit events
|
||||||
* {@code null}
|
* @param statusResolver authoritative runtime credential-status resolver
|
||||||
|
* @throws NullPointerException if an argument is {@code null}
|
||||||
*/
|
*/
|
||||||
public DefaultStatusObjectService(PkiStore store, CredentialFramework framework) {
|
public DefaultStatusObjectService(PkiStore store, CredentialFramework framework, AuditSink auditSink,
|
||||||
|
EffectiveCredentialStatusResolver statusResolver) {
|
||||||
this.store = Objects.requireNonNull(store, "store");
|
this.store = Objects.requireNonNull(store, "store");
|
||||||
this.framework = Objects.requireNonNull(framework, "framework");
|
this.framework = Objects.requireNonNull(framework, "framework");
|
||||||
|
this.auditSink = Objects.requireNonNull(auditSink, "auditSink");
|
||||||
|
this.statusResolver = Objects.requireNonNull(statusResolver, "statusResolver");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -150,9 +154,9 @@ public final class DefaultStatusObjectService implements StatusObjectService {
|
|||||||
*
|
*
|
||||||
* <p>
|
* <p>
|
||||||
* The issuer CA must already exist, must be in {@link CaState#ACTIVE} state,
|
* The issuer CA must already exist, must be in {@link CaState#ACTIVE} state,
|
||||||
* and must expose at least one CA credential. The current implementation uses
|
* and must expose at least one usable credential matching the requested
|
||||||
* the last credential in {@link CaRecord#caCredentials()} as the effective
|
* format. Candidates are evaluated newest-first against current revocation
|
||||||
* issuer credential for status-object generation.
|
* state and one authoritative evaluation time.
|
||||||
* </p>
|
* </p>
|
||||||
*
|
*
|
||||||
* <p>
|
* <p>
|
||||||
@@ -167,17 +171,17 @@ public final class DefaultStatusObjectService implements StatusObjectService {
|
|||||||
* </ul>
|
* </ul>
|
||||||
*
|
*
|
||||||
* <p>
|
* <p>
|
||||||
* When {@link StatusObjectType#CRL} is requested, the service additionally
|
* When {@link StatusObjectType#CRL} is requested, the service validates every
|
||||||
* scans all revocation records in the store, excludes entries with
|
* authoritative active journal before signing. Current hold and permanent
|
||||||
* {@code REMOVE_FROM_CRL}, resolves the referenced credentials, filters them to
|
* states are transported with the exact positive X.509 serial, authoritative
|
||||||
* the requested issuer CA, extracts X.509 serial numbers, and forwards serials
|
* transition time, and explicit reason. Current {@code CLEAR} states are
|
||||||
* that fit into a signed 64-bit integer as repeated {@link #CRL_REVOKED_SERIAL}
|
* omitted.
|
||||||
* attributes.
|
|
||||||
* </p>
|
* </p>
|
||||||
*
|
*
|
||||||
* <p>
|
* <p>
|
||||||
* Malformed credential payloads encountered during CRL input derivation are
|
* Missing targets, malformed credentials, duplicate serials, future
|
||||||
* ignored by the current implementation and do not abort generation.
|
* transitions, corrupt journals, and store failures abort the complete CRL
|
||||||
|
* before generator invocation or persistence with a stable redacted error.
|
||||||
* </p>
|
* </p>
|
||||||
*
|
*
|
||||||
* @param command status object generation request; must not be {@code null}
|
* @param command status object generation request; must not be {@code null}
|
||||||
@@ -200,44 +204,144 @@ public final class DefaultStatusObjectService implements StatusObjectService {
|
|||||||
if (ca.caCredentials().isEmpty()) {
|
if (ca.caCredentials().isEmpty()) {
|
||||||
throw new PkiException("Issuer CA has no credentials");
|
throw new PkiException("Issuer CA has no credentials");
|
||||||
}
|
}
|
||||||
Credential issuerCred = ca.caCredentials().get(ca.caCredentials().size() - 1);
|
EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation();
|
||||||
|
Credential issuerCred = selectIssuerCredential(ca, command, statusEvaluation);
|
||||||
|
List<CrlEntry> crlEntries = command.type() == StatusObjectType.CRL
|
||||||
|
? collectCrlEntries(command.issuerCaId(), statusEvaluation.evaluationTime())
|
||||||
|
: List.of();
|
||||||
|
|
||||||
SimpleAttributeSet.Builder b = SimpleAttributeSet.builder();
|
SimpleAttributeSet.Builder b = SimpleAttributeSet.builder();
|
||||||
b.putAll(command.attributes());
|
b.putAll(command.attributes());
|
||||||
b.put(BcX509Attributes.ISSUER_CERT_DER, new AttributeValue.BytesValue(issuerCred.encoded().bytes()));
|
b.put(BcX509Attributes.ISSUER_CERT_DER, new AttributeValue.BytesValue(issuerCred.encoded().bytes()));
|
||||||
b.put(BcX509Attributes.ISSUER_KEYREF, new AttributeValue.StringValue(ca.issuerKeyRef().value()));
|
b.put(BcX509Attributes.ISSUER_KEYREF, new AttributeValue.StringValue(ca.issuerKeyRef().value()));
|
||||||
|
|
||||||
|
StatusObjectGenerateCommand wired = new StatusObjectGenerateCommand(command.issuerCaId(), command.type(),
|
||||||
|
command.formatId(), b.build());
|
||||||
if (command.type() == StatusObjectType.CRL) {
|
if (command.type() == StatusObjectType.CRL) {
|
||||||
List<RevokedRecord> revs = store.listRevocations();
|
return generateAndPersistCrl(wired, crlEntries);
|
||||||
for (RevokedRecord rr : revs) {
|
|
||||||
if (rr.reason() == zeroecho.pki.api.revocation.RevocationReason.REMOVE_FROM_CRL) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
Optional<Credential> credOpt = store.getCredential(rr.credentialId());
|
StatusObject obj = framework.statusObjectGenerator().generate(wired, crlEntries);
|
||||||
if (credOpt.isEmpty()) {
|
store.putStatusObject(obj);
|
||||||
continue;
|
return obj;
|
||||||
}
|
|
||||||
Credential cred = credOpt.get();
|
|
||||||
if (!command.issuerCaId().equals(cred.issuerRef().caId())) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Framework, signing, and store failures may carry provider or persisted
|
||||||
|
// material. CRL generation deliberately replaces the complete boundary with
|
||||||
|
// one fresh cause-free and suppressed-free exception.
|
||||||
|
@SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" })
|
||||||
|
private StatusObject generateAndPersistCrl(StatusObjectGenerateCommand command, List<CrlEntry> entries) {
|
||||||
try {
|
try {
|
||||||
X509CertificateHolder h = new X509CertificateHolder(cred.encoded().bytes()); // NOPMD
|
StatusObject generated = framework.statusObjectGenerator().generate(command, entries);
|
||||||
BigInteger serial = h.getSerialNumber();
|
store.putStatusObject(generated);
|
||||||
if (serial.bitLength() <= 63) { // NOPMD
|
return generated;
|
||||||
b.put(CRL_REVOKED_SERIAL, new AttributeValue.IntegerValue(serial.longValue())); // NOPMD
|
} catch (RuntimeException exception) {
|
||||||
}
|
throw crlGenerationFailure();
|
||||||
} catch (Exception ex) {
|
|
||||||
LOG.log(Level.FINE, "malformed credential ignored", ex);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
StatusObjectGenerateCommand wired = new StatusObjectGenerateCommand(command.issuerCaId(), command.type(),
|
// Store and parser failures may contain persisted material; the complete
|
||||||
command.formatId(), b.build());
|
// collection boundary deliberately replaces every cause with one stable code.
|
||||||
StatusObject obj = framework.statusObjectGenerator().generate(wired);
|
@SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" })
|
||||||
store.putStatusObject(obj);
|
private List<CrlEntry> collectCrlEntries(PkiId issuerCaId, Instant evaluationTime) {
|
||||||
return obj;
|
try {
|
||||||
|
List<RevocationJournal> journals =
|
||||||
|
Objects.requireNonNull(store.listRevocationJournals(), "revocation journals");
|
||||||
|
List<CrlEntry> entries = new java.util.ArrayList<>();
|
||||||
|
Set<BigInteger> serials = new HashSet<>();
|
||||||
|
for (RevocationJournal journal : journals) {
|
||||||
|
collectCrlEntry(issuerCaId, evaluationTime, journal, serials).ifPresent(entries::add);
|
||||||
|
}
|
||||||
|
return List.copyOf(entries);
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
throw crlGenerationFailure();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<CrlEntry> collectCrlEntry(PkiId issuerCaId, Instant evaluationTime,
|
||||||
|
RevocationJournal journal, Set<BigInteger> serials) {
|
||||||
|
Objects.requireNonNull(journal, "journal");
|
||||||
|
RevocationTransition latest = Objects.requireNonNull(journal.latest(), "latest transition");
|
||||||
|
if (latest.time().isAfter(evaluationTime)) {
|
||||||
|
throw crlGenerationFailure();
|
||||||
|
}
|
||||||
|
if (latest.state() == RevocationState.CLEAR) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
Credential credential = store.getCredential(journal.credentialId()).orElseThrow(
|
||||||
|
DefaultStatusObjectService::crlGenerationFailure);
|
||||||
|
if (!issuerCaId.equals(credential.issuerRef().caId())) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
if (!BcX509CredentialFramework.FORMAT_ID.equals(credential.formatId())
|
||||||
|
|| credential.encoded().encoding() != Encoding.DER) {
|
||||||
|
throw crlGenerationFailure();
|
||||||
|
}
|
||||||
|
BigInteger serial = certificateSerial(credential);
|
||||||
|
if (!serials.add(serial)) {
|
||||||
|
throw crlGenerationFailure();
|
||||||
|
}
|
||||||
|
RevocationReason reason = switch (latest.state()) {
|
||||||
|
case HELD -> RevocationReason.CERTIFICATE_HOLD;
|
||||||
|
case PERMANENTLY_REVOKED -> latest.permanentReason().orElseThrow(
|
||||||
|
DefaultStatusObjectService::crlGenerationFailure);
|
||||||
|
case CLEAR -> throw crlGenerationFailure();
|
||||||
|
};
|
||||||
|
return Optional.of(new CrlEntry(serial, latest.time(), reason));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parser failures can contain persisted certificate details; the original
|
||||||
|
// cause is intentionally removed at this public service boundary.
|
||||||
|
@SuppressWarnings("PMD.PreserveStackTrace")
|
||||||
|
private static BigInteger certificateSerial(Credential credential) {
|
||||||
|
byte[] der = credential.encoded().bytes();
|
||||||
|
BigInteger serial;
|
||||||
|
try {
|
||||||
|
serial = new X509CertificateHolder(der).getSerialNumber();
|
||||||
|
} catch (Exception exception) {
|
||||||
|
throw crlGenerationFailure();
|
||||||
|
} finally {
|
||||||
|
Arrays.fill(der, (byte) 0);
|
||||||
|
}
|
||||||
|
if (serial == null || serial.signum() <= 0) {
|
||||||
|
throw crlGenerationFailure();
|
||||||
|
}
|
||||||
|
return serial;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PkiException crlGenerationFailure() {
|
||||||
|
return new PkiException("CRL generation failed: code=" + CRL_GENERATION_FAILED);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Credential selectIssuerCredential(CaRecord ca, StatusObjectGenerateCommand command,
|
||||||
|
EffectiveCredentialStatusResolver.Evaluation evaluation) {
|
||||||
|
Credential lastRejected = null;
|
||||||
|
EffectiveCredentialStatus lastStatus = null;
|
||||||
|
List<Credential> credentials = ca.caCredentials();
|
||||||
|
for (int index = credentials.size() - 1; index >= 0; index--) {
|
||||||
|
Credential credential = credentials.get(index);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
if (lastRejected != null) {
|
||||||
|
CredentialTrustAudit.rejected(auditSink, evaluation.evaluationTime(), lastRejected,
|
||||||
|
CredentialUse.STATUS_OBJECT_ISSUER, "ISSUER_CREDENTIAL_UNAVAILABLE", lastStatus);
|
||||||
|
}
|
||||||
|
throw new PkiException("Issuer credential unavailable: code=ISSUER_CREDENTIAL_UNAVAILABLE");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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.impl.core;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import zeroecho.pki.api.PkiException;
|
||||||
|
import zeroecho.pki.api.credential.Credential;
|
||||||
|
import zeroecho.pki.api.credential.CredentialStatus;
|
||||||
|
import zeroecho.pki.api.credential.CredentialUse;
|
||||||
|
import zeroecho.pki.api.credential.EffectiveCredentialStatus;
|
||||||
|
import zeroecho.pki.api.credential.EffectiveCredentialStatusResolver;
|
||||||
|
import zeroecho.pki.api.revocation.RevocationJournal;
|
||||||
|
import zeroecho.pki.api.revocation.RevocationState;
|
||||||
|
import zeroecho.pki.api.revocation.RevocationTransition;
|
||||||
|
import zeroecho.pki.spi.store.PkiStore;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store-backed authoritative resolver for runtime credential status.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Revocation lookup errors and inconsistent current records fail closed with a
|
||||||
|
* stable redacted error. The resolver never falls back to persisted inventory
|
||||||
|
* status when revocation state cannot be read safely.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
public final class StoreBackedEffectiveCredentialStatusResolver implements EffectiveCredentialStatusResolver {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable status-resolution failure code.
|
||||||
|
*/
|
||||||
|
public static final String RESOLUTION_FAILED_CODE = "CREDENTIAL_STATUS_RESOLUTION_FAILED";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable unusable-credential failure code.
|
||||||
|
*/
|
||||||
|
public static final String NOT_USABLE_CODE = "CREDENTIAL_NOT_USABLE";
|
||||||
|
|
||||||
|
private final PkiStore store;
|
||||||
|
private final Clock clock;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a resolver using the supplied authoritative store and clock.
|
||||||
|
*
|
||||||
|
* @param store current PKI store
|
||||||
|
* @param clock authoritative status-evaluation clock
|
||||||
|
* @throws NullPointerException if an argument is {@code null}
|
||||||
|
*/
|
||||||
|
public StoreBackedEffectiveCredentialStatusResolver(PkiStore store, Clock clock) {
|
||||||
|
this.store = Objects.requireNonNull(store, "store");
|
||||||
|
this.clock = Objects.requireNonNull(clock, "clock");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clock implementations are external boundaries; their causes are deliberately
|
||||||
|
// removed from the stable redacted status-resolution exception.
|
||||||
|
@SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" })
|
||||||
|
@Override
|
||||||
|
public Evaluation beginEvaluation() {
|
||||||
|
Instant evaluationTime;
|
||||||
|
try {
|
||||||
|
evaluationTime = Objects.requireNonNull(clock.instant(), "clock instant");
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
throw resolutionFailure();
|
||||||
|
}
|
||||||
|
return new StoreEvaluation(evaluationTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluation session bound to one captured authoritative instant.
|
||||||
|
*/
|
||||||
|
private final class StoreEvaluation implements Evaluation {
|
||||||
|
private final Instant evaluationTime;
|
||||||
|
|
||||||
|
private StoreEvaluation(Instant evaluationTime) {
|
||||||
|
this.evaluationTime = evaluationTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Instant evaluationTime() {
|
||||||
|
return evaluationTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public EffectiveCredentialStatus resolve(Credential credential) {
|
||||||
|
Objects.requireNonNull(credential, "credential");
|
||||||
|
RevocationTransition revocation = currentRevocation(credential);
|
||||||
|
return resolveStatus(credential, revocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store implementations are external boundaries; their causes are deliberately
|
||||||
|
// removed from the stable redacted status-resolution exception.
|
||||||
|
@SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" })
|
||||||
|
private RevocationTransition currentRevocation(Credential credential) {
|
||||||
|
Optional<RevocationJournal> current;
|
||||||
|
try {
|
||||||
|
current = store.getRevocationJournal(credential.credentialId());
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
throw resolutionFailure();
|
||||||
|
}
|
||||||
|
if (current == null) {
|
||||||
|
throw resolutionFailure();
|
||||||
|
}
|
||||||
|
if (current.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
RevocationJournal journal = current.get();
|
||||||
|
if (!credential.credentialId().equals(journal.credentialId())) {
|
||||||
|
throw resolutionFailure();
|
||||||
|
}
|
||||||
|
RevocationTransition latest;
|
||||||
|
try {
|
||||||
|
latest = journal.latest();
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
throw resolutionFailure();
|
||||||
|
}
|
||||||
|
if (latest.time().isAfter(evaluationTime)) {
|
||||||
|
throw resolutionFailure();
|
||||||
|
}
|
||||||
|
return latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
private EffectiveCredentialStatus resolveStatus(Credential credential, RevocationTransition revocation) {
|
||||||
|
if (credential.status() == CredentialStatus.REVOKED) {
|
||||||
|
return EffectiveCredentialStatus.PERMANENTLY_REVOKED;
|
||||||
|
}
|
||||||
|
if (revocation != null) {
|
||||||
|
if (revocation.state() == RevocationState.PERMANENTLY_REVOKED) {
|
||||||
|
return EffectiveCredentialStatus.PERMANENTLY_REVOKED;
|
||||||
|
}
|
||||||
|
if (revocation.state() == RevocationState.HELD) {
|
||||||
|
return EffectiveCredentialStatus.HELD;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resolveValidity(credential, revocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
private EffectiveCredentialStatus resolveValidity(Credential credential, RevocationTransition revocation) {
|
||||||
|
if (credential.status() == CredentialStatus.EXPIRED
|
||||||
|
|| evaluationTime.isAfter(credential.validity().notAfter())) {
|
||||||
|
return EffectiveCredentialStatus.EXPIRED;
|
||||||
|
}
|
||||||
|
if (evaluationTime.isBefore(credential.validity().notBefore())) {
|
||||||
|
return EffectiveCredentialStatus.NOT_YET_VALID;
|
||||||
|
}
|
||||||
|
if (credential.status() == CredentialStatus.ISSUED
|
||||||
|
&& (revocation == null || revocation.state() == RevocationState.CLEAR)) {
|
||||||
|
return EffectiveCredentialStatus.USABLE;
|
||||||
|
}
|
||||||
|
throw resolutionFailure();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Credential requireUsable(Credential credential, CredentialUse use) {
|
||||||
|
Objects.requireNonNull(use, "use");
|
||||||
|
if (resolve(credential) != EffectiveCredentialStatus.USABLE) {
|
||||||
|
throw new PkiException("Credential trust rejected: code=" + NOT_USABLE_CODE);
|
||||||
|
}
|
||||||
|
return credential;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PkiException resolutionFailure() {
|
||||||
|
return new PkiException("Credential status resolution failed: code=" + RESOLUTION_FAILED_CODE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -278,12 +278,14 @@ public final class BcX509CredentialFramework implements CredentialFramework {
|
|||||||
* status-object generator has been wired.
|
* status-object generator has been wired.
|
||||||
*
|
*
|
||||||
* @param command ignored command parameter
|
* @param command ignored command parameter
|
||||||
|
* @param crlEntries ignored structured CRL entries
|
||||||
* @return never returns normally
|
* @return never returns normally
|
||||||
* @throws UnsupportedOperationException always
|
* @throws UnsupportedOperationException always
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public zeroecho.pki.api.status.StatusObject generate(
|
public zeroecho.pki.api.status.StatusObject generate(
|
||||||
zeroecho.pki.api.status.StatusObjectGenerateCommand command) {
|
zeroecho.pki.api.status.StatusObjectGenerateCommand command,
|
||||||
|
java.util.List<zeroecho.pki.spi.framework.CrlEntry> crlEntries) {
|
||||||
throw new UnsupportedOperationException("X.509 status object generator not wired");
|
throw new UnsupportedOperationException("X.509 status object generator not wired");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,17 +33,19 @@
|
|||||||
******************************************************************************/
|
******************************************************************************/
|
||||||
package zeroecho.pki.impl.framework.x509.bc;
|
package zeroecho.pki.impl.framework.x509.bc;
|
||||||
|
|
||||||
import java.math.BigInteger;
|
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.HexFormat;
|
import java.util.HexFormat;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
import org.bouncycastle.asn1.x500.X500Name;
|
import org.bouncycastle.asn1.x500.X500Name;
|
||||||
import org.bouncycastle.asn1.x509.AuthorityKeyIdentifier;
|
import org.bouncycastle.asn1.x509.AuthorityKeyIdentifier;
|
||||||
|
import org.bouncycastle.asn1.x509.CRLReason;
|
||||||
import org.bouncycastle.asn1.x509.Extension;
|
import org.bouncycastle.asn1.x509.Extension;
|
||||||
import org.bouncycastle.cert.X509CRLHolder;
|
import org.bouncycastle.cert.X509CRLHolder;
|
||||||
import org.bouncycastle.cert.X509CertificateHolder;
|
import org.bouncycastle.cert.X509CertificateHolder;
|
||||||
@@ -57,14 +59,14 @@ import zeroecho.pki.api.FormatId;
|
|||||||
import zeroecho.pki.api.KeyRef;
|
import zeroecho.pki.api.KeyRef;
|
||||||
import zeroecho.pki.api.PkiException;
|
import zeroecho.pki.api.PkiException;
|
||||||
import zeroecho.pki.api.PkiId;
|
import zeroecho.pki.api.PkiId;
|
||||||
import zeroecho.pki.api.attr.AttributeId;
|
|
||||||
import zeroecho.pki.api.attr.AttributeSet;
|
import zeroecho.pki.api.attr.AttributeSet;
|
||||||
import zeroecho.pki.api.attr.AttributeValue;
|
import zeroecho.pki.api.attr.AttributeValue;
|
||||||
import zeroecho.pki.api.attr.AttributeValue.IntegerValue;
|
import zeroecho.pki.api.revocation.RevocationReason;
|
||||||
import zeroecho.pki.api.status.StatusObject;
|
import zeroecho.pki.api.status.StatusObject;
|
||||||
import zeroecho.pki.api.status.StatusObjectGenerateCommand;
|
import zeroecho.pki.api.status.StatusObjectGenerateCommand;
|
||||||
import zeroecho.pki.api.status.StatusObjectType;
|
import zeroecho.pki.api.status.StatusObjectType;
|
||||||
import zeroecho.pki.impl.core.async.PkiSigningBus;
|
import zeroecho.pki.impl.core.async.PkiSigningBus;
|
||||||
|
import zeroecho.pki.spi.framework.CrlEntry;
|
||||||
import zeroecho.pki.spi.framework.StatusObjectGenerator;
|
import zeroecho.pki.spi.framework.StatusObjectGenerator;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,8 +75,8 @@ import zeroecho.pki.spi.framework.StatusObjectGenerator;
|
|||||||
* <p>
|
* <p>
|
||||||
* The current implementation supports only generation of X.509 certificate
|
* The current implementation supports only generation of X.509 certificate
|
||||||
* revocation lists (CRLs). It assembles a version 2 CRL from issuer-side wiring
|
* revocation lists (CRLs). It assembles a version 2 CRL from issuer-side wiring
|
||||||
* attributes, optional revoked-certificate serial numbers supplied through the
|
* attributes, structured {@link CrlEntry} values, and delegated signing
|
||||||
* attribute set, and delegated signing performed via {@link PkiSigningBus}.
|
* performed via {@link PkiSigningBus}.
|
||||||
* </p>
|
* </p>
|
||||||
*
|
*
|
||||||
* <p>
|
* <p>
|
||||||
@@ -86,10 +88,7 @@ import zeroecho.pki.spi.framework.StatusObjectGenerator;
|
|||||||
* {@link AttributeValue.BytesValue} containing the issuer certificate in DER
|
* {@link AttributeValue.BytesValue} containing the issuer certificate in DER
|
||||||
* form,</li>
|
* form,</li>
|
||||||
* <li>{@link BcX509Attributes#ISSUER_KEYREF} as an
|
* <li>{@link BcX509Attributes#ISSUER_KEYREF} as an
|
||||||
* {@link AttributeValue.StringValue} identifying the issuer signing key,</li>
|
* {@link AttributeValue.StringValue} identifying the issuer signing key.</li>
|
||||||
* <li>optionally one or more {@link #CRL_REVOKED_SERIAL} values as
|
|
||||||
* {@link AttributeValue.IntegerValue} instances representing serial numbers of
|
|
||||||
* revoked certificates.</li>
|
|
||||||
* </ul>
|
* </ul>
|
||||||
*
|
*
|
||||||
* <p>
|
* <p>
|
||||||
@@ -105,10 +104,11 @@ import zeroecho.pki.spi.framework.StatusObjectGenerator;
|
|||||||
*
|
*
|
||||||
* <h2>Revoked-entry handling</h2>
|
* <h2>Revoked-entry handling</h2>
|
||||||
* <p>
|
* <p>
|
||||||
* Revoked certificate serial numbers are read from all values associated with
|
* Structured entries preserve the full positive {@code BigInteger} serial,
|
||||||
* {@link #CRL_REVOKED_SERIAL}. Only {@link IntegerValue} entries with a
|
* authoritative transition instant, and exact supported revocation reason.
|
||||||
* positive numeric value are included as CRL entries. Non-positive serials and
|
* X.509 {@code Date} carries millisecond precision; this generator deliberately
|
||||||
* non-integer attribute values are ignored.
|
* truncates each transition instant to whole seconds only at the DER CRL-entry
|
||||||
|
* boundary.
|
||||||
* </p>
|
* </p>
|
||||||
*
|
*
|
||||||
* <h2>Update semantics</h2>
|
* <h2>Update semantics</h2>
|
||||||
@@ -143,18 +143,6 @@ import zeroecho.pki.spi.framework.StatusObjectGenerator;
|
|||||||
*/
|
*/
|
||||||
public final class BcX509StatusObjectGenerator implements StatusObjectGenerator {
|
public final class BcX509StatusObjectGenerator implements StatusObjectGenerator {
|
||||||
|
|
||||||
/**
|
|
||||||
* Attribute identifier used to carry one revoked certificate serial number for
|
|
||||||
* CRL generation.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* Multiple values associated with this identifier may be supplied in the input
|
|
||||||
* {@link AttributeSet}. Each positive {@link IntegerValue} contributes one CRL
|
|
||||||
* entry.
|
|
||||||
* </p>
|
|
||||||
*/
|
|
||||||
private static final AttributeId CRL_REVOKED_SERIAL = new AttributeId("urn:zeroecho:pki:x509:crl:revoked");
|
|
||||||
|
|
||||||
private final PkiSigningBus signingBus;
|
private final PkiSigningBus signingBus;
|
||||||
private final String signatureAlgorithmId;
|
private final String signatureAlgorithmId;
|
||||||
private final Duration signingTtl;
|
private final Duration signingTtl;
|
||||||
@@ -212,6 +200,7 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator
|
|||||||
* </ul>
|
* </ul>
|
||||||
*
|
*
|
||||||
* @param command status object generation command; must not be {@code null}
|
* @param command status object generation command; must not be {@code null}
|
||||||
|
* @param crlEntries structured current CRL entries; must not be {@code null}
|
||||||
* @return generated CRL status object
|
* @return generated CRL status object
|
||||||
* @throws IllegalArgumentException if {@code command} is {@code null}, if the
|
* @throws IllegalArgumentException if {@code command} is {@code null}, if the
|
||||||
* requested status object type is unsupported,
|
* requested status object type is unsupported,
|
||||||
@@ -223,8 +212,9 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator
|
|||||||
* if signing fails, or if CRL encoding fails
|
* if signing fails, or if CRL encoding fails
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public StatusObject generate(StatusObjectGenerateCommand command) {
|
public StatusObject generate(StatusObjectGenerateCommand command, List<CrlEntry> crlEntries) {
|
||||||
validateCommandOrThrow(command);
|
validateCommandOrThrow(command);
|
||||||
|
List<CrlEntry> validatedEntries = validateEntries(crlEntries);
|
||||||
|
|
||||||
IssuerMaterial issuerMaterial = extractIssuerMaterialOrThrow(command.attributes());
|
IssuerMaterial issuerMaterial = extractIssuerMaterialOrThrow(command.attributes());
|
||||||
Instant thisUpdate = Instant.now();
|
Instant thisUpdate = Instant.now();
|
||||||
@@ -232,7 +222,7 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator
|
|||||||
Date thisUpdateDate = Date.from(thisUpdate);
|
Date thisUpdateDate = Date.from(thisUpdate);
|
||||||
|
|
||||||
X509v2CRLBuilder builder = newCrlBuilder(issuerMaterial.issuerHolder(), thisUpdateDate, nextUpdate);
|
X509v2CRLBuilder builder = newCrlBuilder(issuerMaterial.issuerHolder(), thisUpdateDate, nextUpdate);
|
||||||
addRevokedEntries(builder, command.attributes(), thisUpdateDate);
|
addRevokedEntries(builder, validatedEntries);
|
||||||
addAuthorityKeyIdentifierOrThrow(builder, issuerMaterial.issuerHolder());
|
addAuthorityKeyIdentifierOrThrow(builder, issuerMaterial.issuerHolder());
|
||||||
|
|
||||||
X509CRLHolder crl = buildSignedCrlOrThrow(builder, issuerMaterial.keyRef());
|
X509CRLHolder crl = buildSignedCrlOrThrow(builder, issuerMaterial.keyRef());
|
||||||
@@ -241,6 +231,11 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator
|
|||||||
return toStatusObject(command, crlDer, thisUpdate, nextUpdate);
|
return toStatusObject(command, crlDer, thisUpdate, nextUpdate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static List<CrlEntry> validateEntries(List<CrlEntry> crlEntries) {
|
||||||
|
Objects.requireNonNull(crlEntries, "crlEntries");
|
||||||
|
return List.copyOf(crlEntries);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates the high-level command contract for CRL generation.
|
* Validates the high-level command contract for CRL generation.
|
||||||
*
|
*
|
||||||
@@ -318,34 +313,32 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds revoked-certificate entries to the CRL builder from framework
|
* Adds validated structured revoked-certificate entries to the CRL builder.
|
||||||
* attributes.
|
|
||||||
*
|
|
||||||
* <p>
|
|
||||||
* Only positive {@link IntegerValue} attribute values associated with
|
|
||||||
* {@link #CRL_REVOKED_SERIAL} are added. Non-integer and non-positive values
|
|
||||||
* are ignored.
|
|
||||||
* </p>
|
|
||||||
*
|
*
|
||||||
* @param builder target CRL builder; must not be {@code null}
|
* @param builder target CRL builder; must not be {@code null}
|
||||||
* @param attributes source attributes; must not be {@code null}
|
* @param entries validated structured entries
|
||||||
* @param revocationDate date to use for all generated CRL entries; must not be
|
|
||||||
* {@code null}
|
|
||||||
*/
|
*/
|
||||||
private static void addRevokedEntries(X509v2CRLBuilder builder, AttributeSet attributes, Date revocationDate) {
|
private static void addRevokedEntries(X509v2CRLBuilder builder, List<CrlEntry> entries) {
|
||||||
List<AttributeValue> revoked = attributes.getAll(CRL_REVOKED_SERIAL);
|
for (CrlEntry entry : entries) {
|
||||||
for (AttributeValue value : revoked) {
|
Instant encodedTime = entry.transitionTime().truncatedTo(ChronoUnit.SECONDS);
|
||||||
if (!(value instanceof IntegerValue)) {
|
builder.addCRLEntry(entry.serialNumber(), Date.from(encodedTime), reasonCode(entry.reason()));
|
||||||
continue;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
long serial = ((IntegerValue) value).value();
|
private static int reasonCode(RevocationReason reason) {
|
||||||
if (serial <= 0) {
|
return switch (reason) {
|
||||||
continue;
|
case UNSPECIFIED -> CRLReason.unspecified;
|
||||||
}
|
case KEY_COMPROMISE -> CRLReason.keyCompromise;
|
||||||
|
case CA_COMPROMISE -> CRLReason.cACompromise;
|
||||||
builder.addCRLEntry(BigInteger.valueOf(serial), revocationDate, 0);
|
case AFFILIATION_CHANGED -> CRLReason.affiliationChanged;
|
||||||
}
|
case SUPERSEDED -> CRLReason.superseded;
|
||||||
|
case CESSATION_OF_OPERATION -> CRLReason.cessationOfOperation;
|
||||||
|
case CERTIFICATE_HOLD -> CRLReason.certificateHold;
|
||||||
|
case REMOVE_FROM_CRL -> throw new IllegalArgumentException(
|
||||||
|
"REMOVE_FROM_CRL is not an active CRL entry");
|
||||||
|
case PRIVILEGE_WITHDRAWN -> CRLReason.privilegeWithdrawn;
|
||||||
|
case AA_COMPROMISE -> CRLReason.aACompromise;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -62,11 +62,14 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||||||
import java.util.concurrent.ConcurrentMap;
|
import java.util.concurrent.ConcurrentMap;
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import java.util.concurrent.locks.ReentrantLock;
|
import java.util.concurrent.locks.ReentrantLock;
|
||||||
import java.util.logging.Level;
|
import java.util.logging.Level;
|
||||||
import java.util.logging.Logger;
|
import java.util.logging.Logger;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
import zeroecho.pki.api.EncodedObject;
|
import zeroecho.pki.api.EncodedObject;
|
||||||
|
import zeroecho.pki.api.PkiException;
|
||||||
import zeroecho.pki.api.PkiId;
|
import zeroecho.pki.api.PkiId;
|
||||||
import zeroecho.pki.api.ca.CaRecord;
|
import zeroecho.pki.api.ca.CaRecord;
|
||||||
import zeroecho.pki.api.credential.Credential;
|
import zeroecho.pki.api.credential.Credential;
|
||||||
@@ -76,7 +79,11 @@ import zeroecho.pki.api.policy.PolicyTrace;
|
|||||||
import zeroecho.pki.api.profile.CertificateProfile;
|
import zeroecho.pki.api.profile.CertificateProfile;
|
||||||
import zeroecho.pki.api.publication.PublicationRecord;
|
import zeroecho.pki.api.publication.PublicationRecord;
|
||||||
import zeroecho.pki.api.request.ParsedCertificationRequest;
|
import zeroecho.pki.api.request.ParsedCertificationRequest;
|
||||||
import zeroecho.pki.api.revocation.RevokedRecord;
|
import zeroecho.pki.api.revocation.RevocationCommand;
|
||||||
|
import zeroecho.pki.api.revocation.RevocationJournal;
|
||||||
|
import zeroecho.pki.api.revocation.RevocationReason;
|
||||||
|
import zeroecho.pki.api.revocation.RevocationState;
|
||||||
|
import zeroecho.pki.api.revocation.RevocationTransition;
|
||||||
import zeroecho.pki.api.status.StatusObject;
|
import zeroecho.pki.api.status.StatusObject;
|
||||||
import zeroecho.pki.impl.core.async.PkiSigningBus;
|
import zeroecho.pki.impl.core.async.PkiSigningBus;
|
||||||
import zeroecho.pki.spi.store.PkiStore;
|
import zeroecho.pki.spi.store.PkiStore;
|
||||||
@@ -102,11 +109,10 @@ import zeroecho.pki.spi.store.SignWorkflowStore;
|
|||||||
* fails with {@link IllegalStateException}. This is intentional to surface
|
* fails with {@link IllegalStateException}. This is intentional to surface
|
||||||
* anomalous behavior for audit and incident analysis.
|
* anomalous behavior for audit and incident analysis.
|
||||||
*
|
*
|
||||||
* <li><strong>Audit history for mutable entities:</strong> CA records,
|
* <li><strong>Audit history for mutable entities:</strong> CA records and
|
||||||
* profiles, and revocations are treated as "mutable but auditable": each update
|
* profiles append history before updating {@code current.bin}. Revocation state
|
||||||
* appends an immutable history entry and then updates {@code current.bin}
|
* is instead one atomically replaced, internally ordered journal per
|
||||||
* atomically. This supports forensic reconstruction, and snapshot export ("time
|
* credential.</li>
|
||||||
* travel") without mutating the store.</li>
|
|
||||||
*
|
*
|
||||||
* <li><strong>Deterministic behavior:</strong> filenames, ordering, and cleanup
|
* <li><strong>Deterministic behavior:</strong> filenames, ordering, and cleanup
|
||||||
* semantics are deterministic. Cleanup occurs only during writes
|
* semantics are deterministic. Cleanup occurs only during writes
|
||||||
@@ -155,6 +161,8 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
|||||||
private final AtomicLong signingTimeWatermark;
|
private final AtomicLong signingTimeWatermark;
|
||||||
private final ReentrantLock signingTimeLock;
|
private final ReentrantLock signingTimeLock;
|
||||||
private final ConcurrentMap<PkiId, SignLockEntry> signLocks;
|
private final ConcurrentMap<PkiId, SignLockEntry> signLocks;
|
||||||
|
private final ConcurrentMap<PkiId, RevocationLockEntry> revocationLocks;
|
||||||
|
private final AtomicBoolean durabilityUncertain;
|
||||||
|
|
||||||
private final StoreOwnership ownership;
|
private final StoreOwnership ownership;
|
||||||
|
|
||||||
@@ -188,6 +196,8 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
|||||||
Objects.requireNonNull(root, "root");
|
Objects.requireNonNull(root, "root");
|
||||||
this.clock = Objects.requireNonNull(clock, "clock");
|
this.clock = Objects.requireNonNull(clock, "clock");
|
||||||
this.signLocks = new ConcurrentHashMap<>();
|
this.signLocks = new ConcurrentHashMap<>();
|
||||||
|
this.revocationLocks = new ConcurrentHashMap<>();
|
||||||
|
this.durabilityUncertain = new AtomicBoolean();
|
||||||
this.signingTimeLock = new ReentrantLock();
|
this.signingTimeLock = new ReentrantLock();
|
||||||
this.paths = new FsPaths(root);
|
this.paths = new FsPaths(root);
|
||||||
|
|
||||||
@@ -239,6 +249,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
|||||||
* @throws IllegalStateException if export fails
|
* @throws IllegalStateException if export fails
|
||||||
*/
|
*/
|
||||||
public void exportSnapshot(final Path targetRoot, final Instant at) {
|
public void exportSnapshot(final Path targetRoot, final Instant at) {
|
||||||
|
requireStoreUsable();
|
||||||
Objects.requireNonNull(targetRoot, "targetRoot");
|
Objects.requireNonNull(targetRoot, "targetRoot");
|
||||||
Objects.requireNonNull(at, "at");
|
Objects.requireNonNull(at, "at");
|
||||||
new FsSnapshotExporter(this.options).exportSnapshot(this.paths.root(), targetRoot, at);
|
new FsSnapshotExporter(this.options).exportSnapshot(this.paths.root(), targetRoot, at);
|
||||||
@@ -246,6 +257,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void putCa(final CaRecord record) {
|
public void putCa(final CaRecord record) {
|
||||||
|
requireStoreUsable();
|
||||||
Objects.requireNonNull(record, "record");
|
Objects.requireNonNull(record, "record");
|
||||||
PkiId caId = record.caId();
|
PkiId caId = record.caId();
|
||||||
Path current = this.paths.caCurrent(caId);
|
Path current = this.paths.caCurrent(caId);
|
||||||
@@ -256,6 +268,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<CaRecord> getCa(final PkiId caId) {
|
public Optional<CaRecord> getCa(final PkiId caId) {
|
||||||
|
requireStoreUsable();
|
||||||
Objects.requireNonNull(caId, "caId");
|
Objects.requireNonNull(caId, "caId");
|
||||||
Path p = this.paths.caCurrent(caId);
|
Path p = this.paths.caCurrent(caId);
|
||||||
return readOptional(p, FsCodec.CA_RECORD);
|
return readOptional(p, FsCodec.CA_RECORD);
|
||||||
@@ -263,12 +276,14 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<CaRecord> listCas() {
|
public List<CaRecord> listCas() {
|
||||||
|
requireStoreUsable();
|
||||||
Path casRoot = this.paths.root().resolve("cas").resolve("by-id");
|
Path casRoot = this.paths.root().resolve("cas").resolve("by-id");
|
||||||
return listCurrentRecords(casRoot, FsCodec.CA_RECORD);
|
return listCurrentRecords(casRoot, FsCodec.CA_RECORD);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void putCredential(final Credential credential) {
|
public void putCredential(final Credential credential) {
|
||||||
|
requireStoreUsable();
|
||||||
Objects.requireNonNull(credential, "credential");
|
Objects.requireNonNull(credential, "credential");
|
||||||
PkiId id = credential.credentialId();
|
PkiId id = credential.credentialId();
|
||||||
Path p = this.paths.credentialPath(id);
|
Path p = this.paths.credentialPath(id);
|
||||||
@@ -277,12 +292,14 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<Credential> getCredential(final PkiId credentialId) {
|
public Optional<Credential> getCredential(final PkiId credentialId) {
|
||||||
|
requireStoreUsable();
|
||||||
Objects.requireNonNull(credentialId, "credentialId");
|
Objects.requireNonNull(credentialId, "credentialId");
|
||||||
return readOptional(this.paths.credentialPath(credentialId), FsCodec.CREDENTIAL);
|
return readOptional(this.paths.credentialPath(credentialId), FsCodec.CREDENTIAL);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void putRequest(final ParsedCertificationRequest request) {
|
public void putRequest(final ParsedCertificationRequest request) {
|
||||||
|
requireStoreUsable();
|
||||||
Objects.requireNonNull(request, "request");
|
Objects.requireNonNull(request, "request");
|
||||||
PkiId id = request.requestId();
|
PkiId id = request.requestId();
|
||||||
writeOnce(this.paths.requestPath(id), FsCodec.encode(FsCodec.PARSED_REQUEST, request), "REQUEST",
|
writeOnce(this.paths.requestPath(id), FsCodec.encode(FsCodec.PARSED_REQUEST, request), "REQUEST",
|
||||||
@@ -291,34 +308,91 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<ParsedCertificationRequest> getRequest(final PkiId requestId) {
|
public Optional<ParsedCertificationRequest> getRequest(final PkiId requestId) {
|
||||||
|
requireStoreUsable();
|
||||||
Objects.requireNonNull(requestId, "requestId");
|
Objects.requireNonNull(requestId, "requestId");
|
||||||
return readOptional(this.paths.requestPath(requestId), FsCodec.PARSED_REQUEST);
|
return readOptional(this.paths.requestPath(requestId), FsCodec.PARSED_REQUEST);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void putRevocation(final RevokedRecord record) {
|
// Store failures are intentionally replaced by one stable redacted boundary.
|
||||||
Objects.requireNonNull(record, "record");
|
@SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" })
|
||||||
PkiId credId = record.credentialId();
|
public RevocationJournal transitionRevocation(final RevocationCommand command, final Instant transitionTime) {
|
||||||
Path current = this.paths.revocationCurrent(credId);
|
requireStoreUsable();
|
||||||
|
Objects.requireNonNull(command, "command");
|
||||||
writeWithHistory(this.paths.revocationHistoryDir(credId), current, FsCodec.encode(FsCodec.REVOCATION, record),
|
Objects.requireNonNull(transitionTime, "transitionTime");
|
||||||
this.options.revocationHistoryPolicy(), "REVOCATION", FsUtil.safeId(credId));
|
PkiId credentialId = command.credentialId();
|
||||||
|
RevocationLockEntry lock = acquireRevocationLock(credentialId);
|
||||||
|
try {
|
||||||
|
Optional<Credential> credential;
|
||||||
|
try {
|
||||||
|
credential = getCredential(credentialId);
|
||||||
|
} catch (RuntimeException failure) {
|
||||||
|
throw corruptRevocationState();
|
||||||
|
}
|
||||||
|
if (credential.isEmpty()) {
|
||||||
|
throw new PkiException("Revocation target unavailable: code=REVOCATION_CREDENTIAL_NOT_FOUND");
|
||||||
|
}
|
||||||
|
Optional<RevocationJournal> current;
|
||||||
|
try {
|
||||||
|
current = readRevocationJournal(credentialId);
|
||||||
|
} catch (RuntimeException failure) {
|
||||||
|
throw corruptRevocationState();
|
||||||
|
}
|
||||||
|
RevocationJournal updated = appendRevocation(current, credentialId, command, transitionTime);
|
||||||
|
try {
|
||||||
|
FsOperations.writeAtomicStrict(paths.revocationJournal(credentialId),
|
||||||
|
FsCodec.encode(FsCodec.REVOCATION_JOURNAL, updated));
|
||||||
|
} catch (FsOperations.DurabilityUncertainException failure) {
|
||||||
|
durabilityUncertain.set(true);
|
||||||
|
throw new PkiException(
|
||||||
|
"Revocation durability unconfirmed: code=REVOCATION_DURABILITY_UNCONFIRMED");
|
||||||
|
} catch (IOException ex) {
|
||||||
|
throw new PkiException("Revocation persistence failed: code=REVOCATION_PERSIST_FAILED");
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
} finally {
|
||||||
|
releaseRevocationLock(credentialId, lock);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<RevokedRecord> getRevocation(final PkiId credentialId) {
|
public Optional<RevocationJournal> getRevocationJournal(final PkiId credentialId) {
|
||||||
|
requireStoreUsable();
|
||||||
Objects.requireNonNull(credentialId, "credentialId");
|
Objects.requireNonNull(credentialId, "credentialId");
|
||||||
return readOptional(this.paths.revocationCurrent(credentialId), FsCodec.REVOCATION);
|
return readRevocationJournal(credentialId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<RevokedRecord> listRevocations() {
|
// Listing failures are intentionally replaced by one stable redacted boundary.
|
||||||
|
@SuppressWarnings("PMD.PreserveStackTrace")
|
||||||
|
public List<RevocationJournal> listRevocationJournals() {
|
||||||
|
requireStoreUsable();
|
||||||
Path root = this.paths.root().resolve("revocations").resolve("by-credential");
|
Path root = this.paths.root().resolve("revocations").resolve("by-credential");
|
||||||
return listCurrentRecords(root, FsCodec.REVOCATION);
|
if (!Files.isDirectory(root)) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
try (Stream<Path> directories = Files.list(root)) {
|
||||||
|
List<RevocationJournal> journals = new ArrayList<>();
|
||||||
|
for (Path entityDir : directories.filter(Files::isDirectory)
|
||||||
|
.sorted(Comparator.comparing(path -> path.getFileName().toString())).toList()) {
|
||||||
|
Path journalPath = entityDir.resolve("journal.bin");
|
||||||
|
if (Files.exists(journalPath)) {
|
||||||
|
RevocationJournal journal = decodeRevocationJournal(journalPath);
|
||||||
|
if (!entityDir.getFileName().toString().equals(FsUtil.safeId(journal.credentialId()))) {
|
||||||
|
throw corruptRevocationState();
|
||||||
|
}
|
||||||
|
journals.add(journal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return List.copyOf(journals);
|
||||||
|
} catch (IOException ex) {
|
||||||
|
throw corruptRevocationState();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void putStatusObject(final StatusObject object) {
|
public void putStatusObject(final StatusObject object) {
|
||||||
|
requireStoreUsable();
|
||||||
Objects.requireNonNull(object, "object");
|
Objects.requireNonNull(object, "object");
|
||||||
PkiId id = object.statusObjectId();
|
PkiId id = object.statusObjectId();
|
||||||
writeOnce(this.paths.statusObjectPath(id), FsCodec.encode(FsCodec.STATUS_OBJECT, object), "STATUS_OBJECT",
|
writeOnce(this.paths.statusObjectPath(id), FsCodec.encode(FsCodec.STATUS_OBJECT, object), "STATUS_OBJECT",
|
||||||
@@ -327,12 +401,14 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<StatusObject> getStatusObject(final PkiId statusObjectId) {
|
public Optional<StatusObject> getStatusObject(final PkiId statusObjectId) {
|
||||||
|
requireStoreUsable();
|
||||||
Objects.requireNonNull(statusObjectId, "statusObjectId");
|
Objects.requireNonNull(statusObjectId, "statusObjectId");
|
||||||
return readOptional(this.paths.statusObjectPath(statusObjectId), FsCodec.STATUS_OBJECT);
|
return readOptional(this.paths.statusObjectPath(statusObjectId), FsCodec.STATUS_OBJECT);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<StatusObject> listStatusObjects(final PkiId issuerCaId) {
|
public List<StatusObject> listStatusObjects(final PkiId issuerCaId) {
|
||||||
|
requireStoreUsable();
|
||||||
Objects.requireNonNull(issuerCaId, "issuerCaId");
|
Objects.requireNonNull(issuerCaId, "issuerCaId");
|
||||||
|
|
||||||
// Deterministic but coarse: scan all and filter by issuer id.
|
// Deterministic but coarse: scan all and filter by issuer id.
|
||||||
@@ -351,6 +427,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void putPublicationRecord(final PublicationRecord record) {
|
public void putPublicationRecord(final PublicationRecord record) {
|
||||||
|
requireStoreUsable();
|
||||||
Objects.requireNonNull(record, "record");
|
Objects.requireNonNull(record, "record");
|
||||||
PkiId id = record.publicationId();
|
PkiId id = record.publicationId();
|
||||||
writeOnce(this.paths.publicationPath(id), FsCodec.encode(FsCodec.PUBLICATION, record), "PUBLICATION",
|
writeOnce(this.paths.publicationPath(id), FsCodec.encode(FsCodec.PUBLICATION, record), "PUBLICATION",
|
||||||
@@ -359,12 +436,14 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<PublicationRecord> listPublicationRecords() {
|
public List<PublicationRecord> listPublicationRecords() {
|
||||||
|
requireStoreUsable();
|
||||||
Path byId = this.paths.root().resolve("publications").resolve("by-id");
|
Path byId = this.paths.root().resolve("publications").resolve("by-id");
|
||||||
return listBinaryFiles(byId, FsCodec.PUBLICATION);
|
return listBinaryFiles(byId, FsCodec.PUBLICATION);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void putProfile(final CertificateProfile profile) {
|
public void putProfile(final CertificateProfile profile) {
|
||||||
|
requireStoreUsable();
|
||||||
Objects.requireNonNull(profile, "profile");
|
Objects.requireNonNull(profile, "profile");
|
||||||
String profileId = profile.profileId();
|
String profileId = profile.profileId();
|
||||||
Path current = this.paths.profileCurrent(profileId);
|
Path current = this.paths.profileCurrent(profileId);
|
||||||
@@ -376,6 +455,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<CertificateProfile> getProfile(final String profileId) {
|
public Optional<CertificateProfile> getProfile(final String profileId) {
|
||||||
|
requireStoreUsable();
|
||||||
if (profileId == null || profileId.isBlank()) {
|
if (profileId == null || profileId.isBlank()) {
|
||||||
throw new IllegalArgumentException("profileId must not be null/blank");
|
throw new IllegalArgumentException("profileId must not be null/blank");
|
||||||
}
|
}
|
||||||
@@ -384,12 +464,14 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<CertificateProfile> listProfiles() {
|
public List<CertificateProfile> listProfiles() {
|
||||||
|
requireStoreUsable();
|
||||||
Path root = this.paths.root().resolve("profiles").resolve("by-id");
|
Path root = this.paths.root().resolve("profiles").resolve("by-id");
|
||||||
return listCurrentRecords(root, FsCodec.CERTIFICATE_PROFILE);
|
return listCurrentRecords(root, FsCodec.CERTIFICATE_PROFILE);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void putPolicyTrace(final PolicyTrace trace) {
|
public void putPolicyTrace(final PolicyTrace trace) {
|
||||||
|
requireStoreUsable();
|
||||||
Objects.requireNonNull(trace, "trace");
|
Objects.requireNonNull(trace, "trace");
|
||||||
PkiId id = trace.decisionId();
|
PkiId id = trace.decisionId();
|
||||||
writeOnce(this.paths.policyTracePath(id), FsCodec.encode(FsCodec.POLICY_TRACE, trace), "POLICY_TRACE",
|
writeOnce(this.paths.policyTracePath(id), FsCodec.encode(FsCodec.POLICY_TRACE, trace), "POLICY_TRACE",
|
||||||
@@ -398,6 +480,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<PolicyTrace> getPolicyTrace(final PkiId decisionId) {
|
public Optional<PolicyTrace> getPolicyTrace(final PkiId decisionId) {
|
||||||
|
requireStoreUsable();
|
||||||
Objects.requireNonNull(decisionId, "decisionId");
|
Objects.requireNonNull(decisionId, "decisionId");
|
||||||
return readOptional(this.paths.policyTracePath(decisionId), FsCodec.POLICY_TRACE);
|
return readOptional(this.paths.policyTracePath(decisionId), FsCodec.POLICY_TRACE);
|
||||||
}
|
}
|
||||||
@@ -408,6 +491,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void putWorkflowState(final WorkflowStateRecord record) {
|
public void putWorkflowState(final WorkflowStateRecord record) {
|
||||||
|
requireStoreUsable();
|
||||||
Objects.requireNonNull(record, "record");
|
Objects.requireNonNull(record, "record");
|
||||||
|
|
||||||
PkiId opId = record.opId();
|
PkiId opId = record.opId();
|
||||||
@@ -420,12 +504,14 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<WorkflowStateRecord> getWorkflowState(final PkiId opId) {
|
public Optional<WorkflowStateRecord> getWorkflowState(final PkiId opId) {
|
||||||
|
requireStoreUsable();
|
||||||
Objects.requireNonNull(opId, "opId");
|
Objects.requireNonNull(opId, "opId");
|
||||||
return readOptional(this.paths.workflowCurrent(opId), FsCodec.WORKFLOW_STATE);
|
return readOptional(this.paths.workflowCurrent(opId), FsCodec.WORKFLOW_STATE);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void deleteWorkflowState(final PkiId opId) {
|
public void deleteWorkflowState(final PkiId opId) {
|
||||||
|
requireStoreUsable();
|
||||||
Objects.requireNonNull(opId, "opId");
|
Objects.requireNonNull(opId, "opId");
|
||||||
Path current = this.paths.workflowCurrent(opId);
|
Path current = this.paths.workflowCurrent(opId);
|
||||||
|
|
||||||
@@ -438,12 +524,14 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<WorkflowStateRecord> listWorkflowStates() {
|
public List<WorkflowStateRecord> listWorkflowStates() {
|
||||||
|
requireStoreUsable();
|
||||||
// List by operation directory (workflows/by-op/<opId>/current.bin)
|
// List by operation directory (workflows/by-op/<opId>/current.bin)
|
||||||
return listCurrentRecords(this.paths.workflowRoot(), FsCodec.WORKFLOW_STATE);
|
return listCurrentRecords(this.paths.workflowRoot(), FsCodec.WORKFLOW_STATE);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Instant signingNow() {
|
public Instant signingNow() {
|
||||||
|
requireStoreUsable();
|
||||||
signingTimeLock.lock();
|
signingTimeLock.lock();
|
||||||
try {
|
try {
|
||||||
long monotonic = Math.max(signingTimeWatermark.get(), clock.instant().toEpochMilli());
|
long monotonic = Math.max(signingTimeWatermark.get(), clock.instant().toEpochMilli());
|
||||||
@@ -457,21 +545,25 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String signingNamespace() {
|
public String signingNamespace() {
|
||||||
|
requireStoreUsable();
|
||||||
return signingNamespace;
|
return signingNamespace;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Duration signingHorizon() {
|
public Duration signingHorizon() {
|
||||||
|
requireStoreUsable();
|
||||||
return options.signingOperationHorizon();
|
return options.signingOperationHorizon();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Duration signingPermittedSkew() {
|
public Duration signingPermittedSkew() {
|
||||||
|
requireStoreUsable();
|
||||||
return options.signingIdPermittedSkew();
|
return options.signingIdPermittedSkew();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public SignWorkflowStore.CreateResult createSignIntent(SignWorkflowStore.Record intent) {
|
public SignWorkflowStore.CreateResult createSignIntent(SignWorkflowStore.Record intent) {
|
||||||
|
requireStoreUsable();
|
||||||
Objects.requireNonNull(intent, "intent");
|
Objects.requireNonNull(intent, "intent");
|
||||||
SignLockEntry lock = acquireSignLock(intent.submissionId());
|
SignLockEntry lock = acquireSignLock(intent.submissionId());
|
||||||
try {
|
try {
|
||||||
@@ -504,6 +596,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<SignWorkflowStore.Record> getSignRecord(PkiId submissionId) {
|
public Optional<SignWorkflowStore.Record> getSignRecord(PkiId submissionId) {
|
||||||
|
requireStoreUsable();
|
||||||
Objects.requireNonNull(submissionId, "submissionId");
|
Objects.requireNonNull(submissionId, "submissionId");
|
||||||
SignLockEntry lock = acquireSignLock(submissionId);
|
SignLockEntry lock = acquireSignLock(submissionId);
|
||||||
try {
|
try {
|
||||||
@@ -515,11 +608,12 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<SignWorkflowStore.Record> listSignRecords() {
|
public List<SignWorkflowStore.Record> listSignRecords() {
|
||||||
|
requireStoreUsable();
|
||||||
Path root = paths.signWorkflowRoot();
|
Path root = paths.signWorkflowRoot();
|
||||||
if (!Files.isDirectory(root)) {
|
if (!Files.isDirectory(root)) {
|
||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
try (java.util.stream.Stream<Path> directories = Files.list(root)) {
|
try (Stream<Path> directories = Files.list(root)) {
|
||||||
return directories.filter(Files::isDirectory)
|
return directories.filter(Files::isDirectory)
|
||||||
.map(directory -> directory.resolve(FsPaths.CURRENT_FILE))
|
.map(directory -> directory.resolve(FsPaths.CURRENT_FILE))
|
||||||
.filter(Files::isRegularFile)
|
.filter(Files::isRegularFile)
|
||||||
@@ -535,6 +629,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
|||||||
@Override
|
@Override
|
||||||
public Optional<SignWorkflowStore.Record> tryClaimSign(PkiId submissionId, long expectedRevision,
|
public Optional<SignWorkflowStore.Record> tryClaimSign(PkiId submissionId, long expectedRevision,
|
||||||
Duration lease) {
|
Duration lease) {
|
||||||
|
requireStoreUsable();
|
||||||
requirePositive(lease, "lease");
|
requirePositive(lease, "lease");
|
||||||
SignLockEntry lock = acquireSignLock(submissionId);
|
SignLockEntry lock = acquireSignLock(submissionId);
|
||||||
try {
|
try {
|
||||||
@@ -562,6 +657,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
|||||||
@Override
|
@Override
|
||||||
public Optional<SignWorkflowStore.Record> renewSignClaim(PkiId submissionId, long expectedRevision, long fence,
|
public Optional<SignWorkflowStore.Record> renewSignClaim(PkiId submissionId, long expectedRevision, long fence,
|
||||||
Duration lease) {
|
Duration lease) {
|
||||||
|
requireStoreUsable();
|
||||||
requirePositive(lease, "lease");
|
requirePositive(lease, "lease");
|
||||||
SignLockEntry lock = acquireSignLock(submissionId);
|
SignLockEntry lock = acquireSignLock(submissionId);
|
||||||
try {
|
try {
|
||||||
@@ -587,6 +683,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
|||||||
public Optional<SignWorkflowStore.Record> transitionSign(PkiId submissionId, long expectedRevision, long fence,
|
public Optional<SignWorkflowStore.Record> transitionSign(PkiId submissionId, long expectedRevision, long fence,
|
||||||
SignWorkflowStore.State target, Optional<String> detailCode, Optional<EncodedObject> result,
|
SignWorkflowStore.State target, Optional<String> detailCode, Optional<EncodedObject> result,
|
||||||
Optional<Instant> providerUpdatedAt) {
|
Optional<Instant> providerUpdatedAt) {
|
||||||
|
requireStoreUsable();
|
||||||
Objects.requireNonNull(target, "target");
|
Objects.requireNonNull(target, "target");
|
||||||
Objects.requireNonNull(detailCode, "detailCode");
|
Objects.requireNonNull(detailCode, "detailCode");
|
||||||
Objects.requireNonNull(result, "result");
|
Objects.requireNonNull(result, "result");
|
||||||
@@ -622,6 +719,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Optional<SignWorkflowStore.Record> retireSign(PkiId submissionId, long expectedRevision, long fence) {
|
public Optional<SignWorkflowStore.Record> retireSign(PkiId submissionId, long expectedRevision, long fence) {
|
||||||
|
requireStoreUsable();
|
||||||
SignLockEntry lock = acquireSignLock(submissionId);
|
SignLockEntry lock = acquireSignLock(submissionId);
|
||||||
try {
|
try {
|
||||||
Optional<SignWorkflowStore.Record> optional = readSignRecord(submissionId);
|
Optional<SignWorkflowStore.Record> optional = readSignRecord(submissionId);
|
||||||
@@ -645,6 +743,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int purgeExpiredSignRecords() {
|
public int purgeExpiredSignRecords() {
|
||||||
|
requireStoreUsable();
|
||||||
Path root = paths.signWorkflowRoot();
|
Path root = paths.signWorkflowRoot();
|
||||||
if (!Files.isDirectory(root)) {
|
if (!Files.isDirectory(root)) {
|
||||||
return 0;
|
return 0;
|
||||||
@@ -677,6 +776,12 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
|||||||
this.ownership.close();
|
this.ownership.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void requireStoreUsable() {
|
||||||
|
if (durabilityUncertain.get()) {
|
||||||
|
throw new PkiException("Store durability unconfirmed: code=STORE_DURABILITY_UNCONFIRMED");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private SignLockEntry acquireSignLock(PkiId submissionId) {
|
private SignLockEntry acquireSignLock(PkiId submissionId) {
|
||||||
SignLockEntry entry = signLocks.compute(submissionId, (ignored, current) -> {
|
SignLockEntry entry = signLocks.compute(submissionId, (ignored, current) -> {
|
||||||
SignLockEntry selected = current == null ? new SignLockEntry() : current;
|
SignLockEntry selected = current == null ? new SignLockEntry() : current;
|
||||||
@@ -697,6 +802,115 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private RevocationLockEntry acquireRevocationLock(PkiId credentialId) {
|
||||||
|
RevocationLockEntry entry = revocationLocks.compute(credentialId, (ignored, current) -> {
|
||||||
|
RevocationLockEntry selected = current == null ? new RevocationLockEntry() : current;
|
||||||
|
selected.references.incrementAndGet();
|
||||||
|
return selected;
|
||||||
|
});
|
||||||
|
entry.lock.lock();
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void releaseRevocationLock(PkiId credentialId, RevocationLockEntry entry) {
|
||||||
|
entry.lock.unlock();
|
||||||
|
revocationLocks.computeIfPresent(credentialId, (ignored, current) -> {
|
||||||
|
if (current != entry) { // NOPMD - identity protects a replacement lock entry
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return current.references.decrementAndGet() == 0 ? null : current;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<RevocationJournal> readRevocationJournal(PkiId credentialId) {
|
||||||
|
Path path = paths.revocationJournal(credentialId);
|
||||||
|
if (!Files.exists(path)) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
RevocationJournal journal = decodeRevocationJournal(path);
|
||||||
|
validateRevocationJournal(credentialId, journal);
|
||||||
|
return Optional.of(journal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Codec and filesystem failures are external persisted-state boundaries; raw
|
||||||
|
// causes are deliberately removed from the stable corruption exception.
|
||||||
|
@SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" })
|
||||||
|
private static RevocationJournal decodeRevocationJournal(Path path) {
|
||||||
|
try {
|
||||||
|
return FsCodec.decode(FsCodec.REVOCATION_JOURNAL, FsOperations.readAll(path));
|
||||||
|
} catch (IOException | RuntimeException ex) {
|
||||||
|
throw corruptRevocationState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revision overflow is persisted-state corruption and is mapped to the stable,
|
||||||
|
// redacted state-corruption boundary.
|
||||||
|
@SuppressWarnings("PMD.PreserveStackTrace")
|
||||||
|
private static RevocationJournal appendRevocation(Optional<RevocationJournal> current, PkiId credentialId,
|
||||||
|
RevocationCommand command, Instant transitionTime) {
|
||||||
|
if (current.isPresent() && transitionTime.isBefore(current.get().latest().time())) {
|
||||||
|
throw new PkiException("Revocation transition conflict: code=REVOCATION_TRANSITION_CONFLICT");
|
||||||
|
}
|
||||||
|
RevocationState previous = current.map(RevocationJournal::latest).map(RevocationTransition::state)
|
||||||
|
.orElse(null);
|
||||||
|
RevocationState next = nextRevocationState(previous, command);
|
||||||
|
long revision = current.map(RevocationJournal::latest).map(RevocationTransition::revision)
|
||||||
|
.map(value -> {
|
||||||
|
try {
|
||||||
|
return Math.addExact(value, 1L);
|
||||||
|
} catch (ArithmeticException ex) {
|
||||||
|
throw corruptRevocationState();
|
||||||
|
}
|
||||||
|
}).orElse(1L);
|
||||||
|
List<RevocationTransition> transitions = new ArrayList<>(
|
||||||
|
current.map(RevocationJournal::transitions).orElseGet(List::of));
|
||||||
|
Optional<RevocationReason> permanentReason = command instanceof RevocationCommand.RevokePermanently revoke
|
||||||
|
? Optional.of(revoke.reason()) : Optional.empty();
|
||||||
|
transitions.add(new RevocationTransition(revision, next, transitionTime,
|
||||||
|
permanentReason, command.attributes()));
|
||||||
|
RevocationJournal journal = new RevocationJournal(credentialId, transitions);
|
||||||
|
validateRevocationJournal(credentialId, journal);
|
||||||
|
return journal;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RevocationState nextRevocationState(RevocationState previous, RevocationCommand command) {
|
||||||
|
if (previous == RevocationState.PERMANENTLY_REVOKED) {
|
||||||
|
throw new PkiException("Revocation is terminal: code=REVOCATION_TERMINAL");
|
||||||
|
}
|
||||||
|
boolean hold = command instanceof RevocationCommand.Hold;
|
||||||
|
boolean unhold = command instanceof RevocationCommand.Unhold;
|
||||||
|
if (previous == null) {
|
||||||
|
return unhold ? throwIllegalTransition()
|
||||||
|
: hold ? RevocationState.HELD : RevocationState.PERMANENTLY_REVOKED;
|
||||||
|
}
|
||||||
|
return switch (previous) {
|
||||||
|
case CLEAR -> unhold ? throwIllegalTransition()
|
||||||
|
: hold ? RevocationState.HELD : RevocationState.PERMANENTLY_REVOKED;
|
||||||
|
case HELD -> hold ? throwIllegalTransition()
|
||||||
|
: unhold ? RevocationState.CLEAR : RevocationState.PERMANENTLY_REVOKED;
|
||||||
|
case PERMANENTLY_REVOKED -> throw new PkiException(
|
||||||
|
"Revocation is terminal: code=REVOCATION_TERMINAL");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validateRevocationJournal(PkiId expectedId, RevocationJournal journal) {
|
||||||
|
if (!expectedId.equals(journal.credentialId())) {
|
||||||
|
throw corruptRevocationState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RevocationState throwIllegalTransition() {
|
||||||
|
throw illegalRevocationTransition();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PkiException illegalRevocationTransition() {
|
||||||
|
return new PkiException("Revocation transition rejected: code=REVOCATION_TRANSITION_ILLEGAL");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PkiException corruptRevocationState() {
|
||||||
|
return new PkiException("Revocation state invalid: code=REVOCATION_STATE_CORRUPT");
|
||||||
|
}
|
||||||
|
|
||||||
private Optional<SignWorkflowStore.Record> readSignRecord(PkiId submissionId) {
|
private Optional<SignWorkflowStore.Record> readSignRecord(PkiId submissionId) {
|
||||||
Path path = paths.signWorkflowPath(submissionId);
|
Path path = paths.signWorkflowPath(submissionId);
|
||||||
if (!Files.exists(path)) {
|
if (!Files.exists(path)) {
|
||||||
@@ -995,6 +1209,14 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
|||||||
private final AtomicInteger references = new AtomicInteger();
|
private final AtomicInteger references = new AtomicInteger();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference-counted per-credential revocation transition lock.
|
||||||
|
*/
|
||||||
|
private static final class RevocationLockEntry {
|
||||||
|
private final ReentrantLock lock = new ReentrantLock();
|
||||||
|
private final AtomicInteger references = new AtomicInteger();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owns the operating-system resources that exclude a second store process.
|
* Owns the operating-system resources that exclude a second store process.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -77,8 +77,10 @@ import zeroecho.pki.api.publication.PublicationStatus;
|
|||||||
import zeroecho.pki.api.publication.PublicationTarget;
|
import zeroecho.pki.api.publication.PublicationTarget;
|
||||||
import zeroecho.pki.api.publication.PublicationTargetType;
|
import zeroecho.pki.api.publication.PublicationTargetType;
|
||||||
import zeroecho.pki.api.request.ParsedCertificationRequest;
|
import zeroecho.pki.api.request.ParsedCertificationRequest;
|
||||||
|
import zeroecho.pki.api.revocation.RevocationJournal;
|
||||||
import zeroecho.pki.api.revocation.RevocationReason;
|
import zeroecho.pki.api.revocation.RevocationReason;
|
||||||
import zeroecho.pki.api.revocation.RevokedRecord;
|
import zeroecho.pki.api.revocation.RevocationState;
|
||||||
|
import zeroecho.pki.api.revocation.RevocationTransition;
|
||||||
import zeroecho.pki.api.status.StatusObject;
|
import zeroecho.pki.api.status.StatusObject;
|
||||||
import zeroecho.pki.api.status.StatusObjectType;
|
import zeroecho.pki.api.status.StatusObjectType;
|
||||||
import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
|
import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
|
||||||
@@ -154,6 +156,8 @@ final class FsCodec {
|
|||||||
private static final int TYPE_PUBLICATION_STATUS_ENUM = 57;
|
private static final int TYPE_PUBLICATION_STATUS_ENUM = 57;
|
||||||
private static final int TYPE_DURABILITY_POLICY_ENUM = 58;
|
private static final int TYPE_DURABILITY_POLICY_ENUM = 58;
|
||||||
private static final int TYPE_SIGN_STATE_ENUM = 59;
|
private static final int TYPE_SIGN_STATE_ENUM = 59;
|
||||||
|
private static final int TYPE_REVOCATION_STATE_ENUM = 60;
|
||||||
|
private static final int TYPE_REVOCATION_TRANSITION = 61;
|
||||||
|
|
||||||
private static final int ATTRIBUTE_STRING = 1;
|
private static final int ATTRIBUTE_STRING = 1;
|
||||||
private static final int ATTRIBUTE_BOOLEAN = 2;
|
private static final int ATTRIBUTE_BOOLEAN = 2;
|
||||||
@@ -248,6 +252,18 @@ final class FsCodec {
|
|||||||
case 10 -> RevocationReason.AA_COMPROMISE;
|
case 10 -> RevocationReason.AA_COMPROMISE;
|
||||||
default -> throw unknownEnum("RevocationReason", code);
|
default -> throw unknownEnum("RevocationReason", code);
|
||||||
});
|
});
|
||||||
|
private static final ValueSchema<RevocationState> REVOCATION_STATE = enumSchema(TYPE_REVOCATION_STATE_ENUM,
|
||||||
|
value -> switch (value) {
|
||||||
|
case CLEAR -> 1;
|
||||||
|
case HELD -> 2;
|
||||||
|
case PERMANENTLY_REVOKED -> 3;
|
||||||
|
},
|
||||||
|
code -> switch (code) {
|
||||||
|
case 1 -> RevocationState.CLEAR;
|
||||||
|
case 2 -> RevocationState.HELD;
|
||||||
|
case 3 -> RevocationState.PERMANENTLY_REVOKED;
|
||||||
|
default -> throw unknownEnum("RevocationState", code);
|
||||||
|
});
|
||||||
private static final ValueSchema<StatusObjectType> STATUS_OBJECT_TYPE = enumSchema(TYPE_STATUS_OBJECT_TYPE_ENUM,
|
private static final ValueSchema<StatusObjectType> STATUS_OBJECT_TYPE = enumSchema(TYPE_STATUS_OBJECT_TYPE_ENUM,
|
||||||
value -> switch (value) {
|
value -> switch (value) {
|
||||||
case CRL -> 1;
|
case CRL -> 1;
|
||||||
@@ -379,6 +395,13 @@ final class FsCodec {
|
|||||||
private static final ValueSchema<Optional<Instant>> OPTIONAL_INSTANT = optionalOf(INSTANT);
|
private static final ValueSchema<Optional<Instant>> OPTIONAL_INSTANT = optionalOf(INSTANT);
|
||||||
private static final ValueSchema<Optional<Duration>> OPTIONAL_DURATION = optionalOf(DURATION);
|
private static final ValueSchema<Optional<Duration>> OPTIONAL_DURATION = optionalOf(DURATION);
|
||||||
private static final ValueSchema<Optional<EncodedObject>> OPTIONAL_ENCODED_OBJECT = optionalOf(ENCODED_OBJECT);
|
private static final ValueSchema<Optional<EncodedObject>> OPTIONAL_ENCODED_OBJECT = optionalOf(ENCODED_OBJECT);
|
||||||
|
private static final ValueSchema<Optional<RevocationReason>> OPTIONAL_REVOCATION_REASON =
|
||||||
|
optionalOf(REVOCATION_REASON);
|
||||||
|
private static final ValueSchema<RevocationTransition> REVOCATION_TRANSITION =
|
||||||
|
valueSchema(TYPE_REVOCATION_TRANSITION, FsCodec::writeRevocationTransition,
|
||||||
|
FsCodec::readRevocationTransition);
|
||||||
|
private static final ValueSchema<List<RevocationTransition>> REVOCATION_TRANSITIONS =
|
||||||
|
listOf(REVOCATION_TRANSITION);
|
||||||
|
|
||||||
private static final ValueSchema<Credential> CREDENTIAL_VALUE = valueSchema(TYPE_CREDENTIAL_RECORD,
|
private static final ValueSchema<Credential> CREDENTIAL_VALUE = valueSchema(TYPE_CREDENTIAL_RECORD,
|
||||||
FsCodec::writeCredential, FsCodec::readCredential);
|
FsCodec::writeCredential, FsCodec::readCredential);
|
||||||
@@ -390,8 +413,9 @@ final class FsCodec {
|
|||||||
CREDENTIAL_VALUE);
|
CREDENTIAL_VALUE);
|
||||||
/* package */ static final Schema<ParsedCertificationRequest> PARSED_REQUEST = topLevel(TOP_PARSED_REQUEST,
|
/* package */ static final Schema<ParsedCertificationRequest> PARSED_REQUEST = topLevel(TOP_PARSED_REQUEST,
|
||||||
"PARSED_REQUEST", valueSchema(101, FsCodec::writeParsedRequest, FsCodec::readParsedRequest));
|
"PARSED_REQUEST", valueSchema(101, FsCodec::writeParsedRequest, FsCodec::readParsedRequest));
|
||||||
/* package */ static final Schema<RevokedRecord> REVOCATION = topLevel(TOP_REVOCATION, "REVOCATION",
|
/* package */ static final Schema<RevocationJournal> REVOCATION_JOURNAL =
|
||||||
valueSchema(102, FsCodec::writeRevocation, FsCodec::readRevocation));
|
topLevel(TOP_REVOCATION, "REVOCATION_JOURNAL",
|
||||||
|
valueSchema(102, FsCodec::writeRevocationJournal, FsCodec::readRevocationJournal));
|
||||||
/* package */ static final Schema<StatusObject> STATUS_OBJECT = topLevel(TOP_STATUS_OBJECT, "STATUS_OBJECT",
|
/* package */ static final Schema<StatusObject> STATUS_OBJECT = topLevel(TOP_STATUS_OBJECT, "STATUS_OBJECT",
|
||||||
valueSchema(103, FsCodec::writeStatusObject, FsCodec::readStatusObject));
|
valueSchema(103, FsCodec::writeStatusObject, FsCodec::readStatusObject));
|
||||||
/* package */ static final Schema<PublicationRecord> PUBLICATION = topLevel(TOP_PUBLICATION, "PUBLICATION",
|
/* package */ static final Schema<PublicationRecord> PUBLICATION = topLevel(TOP_PUBLICATION, "PUBLICATION",
|
||||||
@@ -411,7 +435,7 @@ final class FsCodec {
|
|||||||
Map.entry(TOP_CA_RECORD, CA_RECORD),
|
Map.entry(TOP_CA_RECORD, CA_RECORD),
|
||||||
Map.entry(TOP_CREDENTIAL, CREDENTIAL),
|
Map.entry(TOP_CREDENTIAL, CREDENTIAL),
|
||||||
Map.entry(TOP_PARSED_REQUEST, PARSED_REQUEST),
|
Map.entry(TOP_PARSED_REQUEST, PARSED_REQUEST),
|
||||||
Map.entry(TOP_REVOCATION, REVOCATION),
|
Map.entry(TOP_REVOCATION, REVOCATION_JOURNAL),
|
||||||
Map.entry(TOP_STATUS_OBJECT, STATUS_OBJECT),
|
Map.entry(TOP_STATUS_OBJECT, STATUS_OBJECT),
|
||||||
Map.entry(TOP_PUBLICATION, PUBLICATION),
|
Map.entry(TOP_PUBLICATION, PUBLICATION),
|
||||||
Map.entry(TOP_CERTIFICATE_PROFILE, CERTIFICATE_PROFILE),
|
Map.entry(TOP_CERTIFICATE_PROFILE, CERTIFICATE_PROFILE),
|
||||||
@@ -615,16 +639,33 @@ final class FsCodec {
|
|||||||
reader.readValue(OPTIONAL_STRING), reader.readValue(ATTRIBUTE_SET));
|
reader.readValue(OPTIONAL_STRING), reader.readValue(ATTRIBUTE_SET));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void writeRevocation(Writer writer, RevokedRecord value) throws IOException {
|
private static void writeRevocationJournal(Writer writer, RevocationJournal value) throws IOException {
|
||||||
writer.writeValue(PKI_ID, value.credentialId());
|
writer.writeValue(PKI_ID, value.credentialId());
|
||||||
writer.writeValue(INSTANT, value.revocationTime());
|
writer.writeValue(LONG, (long) RevocationJournal.CURRENT_VERSION);
|
||||||
writer.writeValue(REVOCATION_REASON, value.reason());
|
writer.writeValue(REVOCATION_TRANSITIONS, value.transitions());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RevocationJournal readRevocationJournal(Reader reader) throws IOException {
|
||||||
|
PkiId credentialId = reader.readValue(PKI_ID);
|
||||||
|
long version = reader.readValue(LONG);
|
||||||
|
if (version != RevocationJournal.CURRENT_VERSION) {
|
||||||
|
throw new IOException("Unsupported revocation journal version");
|
||||||
|
}
|
||||||
|
return new RevocationJournal(credentialId, reader.readValue(REVOCATION_TRANSITIONS));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeRevocationTransition(Writer writer, RevocationTransition value) throws IOException {
|
||||||
|
writer.writeValue(LONG, value.revision());
|
||||||
|
writer.writeValue(REVOCATION_STATE, value.state());
|
||||||
|
writer.writeValue(INSTANT, value.time());
|
||||||
|
writer.writeValue(OPTIONAL_REVOCATION_REASON, value.permanentReason());
|
||||||
writer.writeValue(ATTRIBUTE_SET, value.attributes());
|
writer.writeValue(ATTRIBUTE_SET, value.attributes());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static RevokedRecord readRevocation(Reader reader) throws IOException {
|
private static RevocationTransition readRevocationTransition(Reader reader) throws IOException {
|
||||||
return new RevokedRecord(reader.readValue(PKI_ID), reader.readValue(INSTANT),
|
return new RevocationTransition(reader.readValue(LONG), reader.readValue(REVOCATION_STATE),
|
||||||
reader.readValue(REVOCATION_REASON), reader.readValue(ATTRIBUTE_SET));
|
reader.readValue(INSTANT), reader.readValue(OPTIONAL_REVOCATION_REASON),
|
||||||
|
reader.readValue(ATTRIBUTE_SET));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void writeStatusObject(Writer writer, StatusObject value) throws IOException {
|
private static void writeStatusObject(Writer writer, StatusObject value) throws IOException {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ package zeroecho.pki.impl.fs;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.io.OutputStream;
|
import java.io.OutputStream;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
import java.nio.channels.FileChannel;
|
import java.nio.channels.FileChannel;
|
||||||
import java.nio.file.AtomicMoveNotSupportedException;
|
import java.nio.file.AtomicMoveNotSupportedException;
|
||||||
import java.nio.file.FileAlreadyExistsException;
|
import java.nio.file.FileAlreadyExistsException;
|
||||||
@@ -157,6 +158,66 @@ final class FsOperations {
|
|||||||
forceDirectoryBestEffort(parent);
|
forceDirectoryBestEffort(parent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strictly persists one authoritative revocation journal image.
|
||||||
|
*
|
||||||
|
* <p>The namespace commit point is an {@link StandardCopyOption#ATOMIC_MOVE}
|
||||||
|
* in the target directory. No non-atomic fallback is permitted. A directory
|
||||||
|
* force failure after that move is reported distinctly because the durable
|
||||||
|
* authoritative image is then uncertain.</p>
|
||||||
|
*
|
||||||
|
* @param target journal target
|
||||||
|
* @param data complete encoded journal
|
||||||
|
* @throws IOException on a pre-commit persistence failure
|
||||||
|
* @throws DurabilityUncertainException after a committed move whose directory
|
||||||
|
* force failed
|
||||||
|
*/
|
||||||
|
// The original directory-force cause is replaced by a marker that cannot expose
|
||||||
|
// a filesystem path or operating-system diagnostic.
|
||||||
|
@SuppressWarnings("PMD.PreserveStackTrace")
|
||||||
|
/* default */ static void writeAtomicStrict(final Path target, final byte[] data) throws IOException {
|
||||||
|
Objects.requireNonNull(target, "target");
|
||||||
|
Objects.requireNonNull(data, "data");
|
||||||
|
Path parent = requireParent(target);
|
||||||
|
ensureDir(parent);
|
||||||
|
Path temporary = tempSibling(target);
|
||||||
|
boolean moved = false;
|
||||||
|
try {
|
||||||
|
createOrTruncateTemp(temporary);
|
||||||
|
try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE,
|
||||||
|
StandardOpenOption.TRUNCATE_EXISTING)) {
|
||||||
|
ByteBuffer buffer = ByteBuffer.wrap(data);
|
||||||
|
while (buffer.hasRemaining()) {
|
||||||
|
channel.write(buffer);
|
||||||
|
}
|
||||||
|
channel.force(true);
|
||||||
|
}
|
||||||
|
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||||
|
moved = true;
|
||||||
|
try (FileChannel directory = FileChannel.open(parent, StandardOpenOption.READ)) {
|
||||||
|
directory.force(true);
|
||||||
|
} catch (IOException failure) {
|
||||||
|
throw new DurabilityUncertainException();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!moved) {
|
||||||
|
Files.deleteIfExists(temporary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signals a post-commit directory durability failure without exposing an
|
||||||
|
* operating-system cause.
|
||||||
|
*/
|
||||||
|
/* default */ static final class DurabilityUncertainException extends IOException {
|
||||||
|
private static final long serialVersionUID = -2422560154076956224L;
|
||||||
|
|
||||||
|
private DurabilityUncertainException() {
|
||||||
|
super("revocation journal durability unconfirmed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Writes bytes to {@code target} as a write-once operation.
|
* Writes bytes to {@code target} as a write-once operation.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ final class FsPaths {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Revocations (mutable with history)
|
// Revocations (single authoritative journal)
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
/* default */ Path revocationDir(final PkiId credentialId) {
|
/* default */ Path revocationDir(final PkiId credentialId) {
|
||||||
@@ -162,12 +162,8 @@ final class FsPaths {
|
|||||||
return this.root.resolve("revocations").resolve("by-credential").resolve(FsUtil.safeId(credentialId));
|
return this.root.resolve("revocations").resolve("by-credential").resolve(FsUtil.safeId(credentialId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* default */ Path revocationCurrent(final PkiId credentialId) {
|
/* default */ Path revocationJournal(final PkiId credentialId) {
|
||||||
return revocationDir(credentialId).resolve(CURRENT_FILE);
|
return revocationDir(credentialId).resolve("journal.bin");
|
||||||
}
|
|
||||||
|
|
||||||
/* default */ Path revocationHistoryDir(final PkiId credentialId) {
|
|
||||||
return revocationDir(credentialId).resolve(HISTORY_DIR);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -97,14 +97,13 @@ final class FsSnapshotExporter {
|
|||||||
copyTreeIfExists(sourceRoot.resolve("policy"), targetRoot.resolve("policy"));
|
copyTreeIfExists(sourceRoot.resolve("policy"), targetRoot.resolve("policy"));
|
||||||
copyTreeIfExists(sourceRoot.resolve("publications"), targetRoot.resolve("publications"));
|
copyTreeIfExists(sourceRoot.resolve("publications"), targetRoot.resolve("publications"));
|
||||||
copyTreeIfExists(sourceRoot.resolve("sign-workflows"), targetRoot.resolve("sign-workflows"));
|
copyTreeIfExists(sourceRoot.resolve("sign-workflows"), targetRoot.resolve("sign-workflows"));
|
||||||
|
copyTreeIfExists(sourceRoot.resolve("revocations"), targetRoot.resolve("revocations"));
|
||||||
|
|
||||||
// reconstruct mutable entities from history (CAS, PROFILES, REVOCATIONS)
|
// reconstruct mutable entities from history (CAS and profiles)
|
||||||
reconstructMutableTree(sourceRoot.resolve("cas"), targetRoot.resolve("cas"), at,
|
reconstructMutableTree(sourceRoot.resolve("cas"), targetRoot.resolve("cas"), at,
|
||||||
this.options.caHistoryPolicy(), this.options.strictSnapshotExport());
|
this.options.caHistoryPolicy(), this.options.strictSnapshotExport());
|
||||||
reconstructMutableTree(sourceRoot.resolve("profiles"), targetRoot.resolve("profiles"), at,
|
reconstructMutableTree(sourceRoot.resolve("profiles"), targetRoot.resolve("profiles"), at,
|
||||||
this.options.profileHistoryPolicy(), this.options.strictSnapshotExport());
|
this.options.profileHistoryPolicy(), this.options.strictSnapshotExport());
|
||||||
reconstructMutableTree(sourceRoot.resolve("revocations"), targetRoot.resolve("revocations"), at,
|
|
||||||
this.options.revocationHistoryPolicy(), this.options.strictSnapshotExport());
|
|
||||||
// reconstruct workflow continuation state from history
|
// reconstruct workflow continuation state from history
|
||||||
reconstructMutableTree(sourceRoot.resolve("workflows"), targetRoot.resolve("workflows"), at,
|
reconstructMutableTree(sourceRoot.resolve("workflows"), targetRoot.resolve("workflows"), at,
|
||||||
this.options.workflowHistoryPolicy(), this.options.strictSnapshotExport());
|
this.options.workflowHistoryPolicy(), this.options.strictSnapshotExport());
|
||||||
|
|||||||
45
pki/src/main/java/zeroecho/pki/spi/framework/CrlEntry.java
Normal file
45
pki/src/main/java/zeroecho/pki/spi/framework/CrlEntry.java
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2026, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
******************************************************************************/
|
||||||
|
package zeroecho.pki.spi.framework;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
import zeroecho.pki.api.revocation.RevocationReason;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable structured input for one certificate-revocation-list entry.
|
||||||
|
*
|
||||||
|
* <p>The serial number retains the full positive X.509 integer domain. The
|
||||||
|
* transition time is authoritative runtime state and is not replaced by CRL
|
||||||
|
* generation time. {@link RevocationReason#REMOVE_FROM_CRL} is excluded because
|
||||||
|
* removal is represented by omission from the current CRL.</p>
|
||||||
|
*
|
||||||
|
* @param serialNumber positive certificate serial number
|
||||||
|
* @param transitionTime authoritative revocation or hold transition time
|
||||||
|
* @param reason explicit CRL reason
|
||||||
|
*/
|
||||||
|
public record CrlEntry(BigInteger serialNumber, Instant transitionTime, RevocationReason reason) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates one structured CRL entry.
|
||||||
|
*
|
||||||
|
* @throws IllegalArgumentException if the serial is non-positive, a value is
|
||||||
|
* missing, or the reason is
|
||||||
|
* {@code REMOVE_FROM_CRL}
|
||||||
|
*/
|
||||||
|
public CrlEntry {
|
||||||
|
Objects.requireNonNull(serialNumber, "serialNumber");
|
||||||
|
Objects.requireNonNull(transitionTime, "transitionTime");
|
||||||
|
Objects.requireNonNull(reason, "reason");
|
||||||
|
if (serialNumber.signum() <= 0) {
|
||||||
|
throw new IllegalArgumentException("serialNumber must be positive");
|
||||||
|
}
|
||||||
|
if (reason == RevocationReason.REMOVE_FROM_CRL) {
|
||||||
|
throw new IllegalArgumentException("REMOVE_FROM_CRL is not an active CRL entry");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,8 @@
|
|||||||
******************************************************************************/
|
******************************************************************************/
|
||||||
package zeroecho.pki.spi.framework;
|
package zeroecho.pki.spi.framework;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
import zeroecho.pki.api.status.StatusObject;
|
import zeroecho.pki.api.status.StatusObject;
|
||||||
import zeroecho.pki.api.status.StatusObjectGenerateCommand;
|
import zeroecho.pki.api.status.StatusObjectGenerateCommand;
|
||||||
|
|
||||||
@@ -47,9 +49,11 @@ public interface StatusObjectGenerator {
|
|||||||
* Generates a status object.
|
* Generates a status object.
|
||||||
*
|
*
|
||||||
* @param command generation command
|
* @param command generation command
|
||||||
|
* @param crlEntries structured CRL entries; empty for non-CRL objects
|
||||||
* @return generated status object
|
* @return generated status object
|
||||||
* @throws IllegalArgumentException if {@code command} is invalid
|
* @throws IllegalArgumentException if {@code command} or {@code crlEntries}
|
||||||
|
* is invalid
|
||||||
* @throws RuntimeException if generation fails
|
* @throws RuntimeException if generation fails
|
||||||
*/
|
*/
|
||||||
StatusObject generate(StatusObjectGenerateCommand command);
|
StatusObject generate(StatusObjectGenerateCommand command, List<CrlEntry> crlEntries);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
******************************************************************************/
|
******************************************************************************/
|
||||||
package zeroecho.pki.spi.store;
|
package zeroecho.pki.spi.store;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@@ -44,7 +45,8 @@ import zeroecho.pki.api.policy.PolicyTrace;
|
|||||||
import zeroecho.pki.api.profile.CertificateProfile;
|
import zeroecho.pki.api.profile.CertificateProfile;
|
||||||
import zeroecho.pki.api.publication.PublicationRecord;
|
import zeroecho.pki.api.publication.PublicationRecord;
|
||||||
import zeroecho.pki.api.request.ParsedCertificationRequest;
|
import zeroecho.pki.api.request.ParsedCertificationRequest;
|
||||||
import zeroecho.pki.api.revocation.RevokedRecord;
|
import zeroecho.pki.api.revocation.RevocationCommand;
|
||||||
|
import zeroecho.pki.api.revocation.RevocationJournal;
|
||||||
import zeroecho.pki.api.status.StatusObject;
|
import zeroecho.pki.api.status.StatusObject;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -154,31 +156,28 @@ public interface PkiStore extends SignWorkflowStore {
|
|||||||
Optional<ParsedCertificationRequest> getRequest(PkiId requestId);
|
Optional<ParsedCertificationRequest> getRequest(PkiId requestId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persists or updates a revocation record.
|
* Atomically validates and appends one legal revocation transition.
|
||||||
*
|
*
|
||||||
* @param record revocation record (never {@code null})
|
* @param command trusted transition command
|
||||||
* @throws NullPointerException if {@code record} is {@code null}
|
* @param transitionTime authoritative transition time
|
||||||
* @throws IllegalStateException if persistence fails
|
* @return committed journal
|
||||||
*/
|
*/
|
||||||
void putRevocation(RevokedRecord record);
|
RevocationJournal transitionRevocation(RevocationCommand command, Instant transitionTime);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieves a revocation record for a given credential.
|
* Retrieves the authoritative revocation journal for a credential.
|
||||||
*
|
*
|
||||||
* @param credentialId credential identifier (never {@code null})
|
* @param credentialId credential identifier
|
||||||
* @return revocation record if present
|
* @return journal when present
|
||||||
* @throws NullPointerException if {@code credentialId} is {@code null}
|
|
||||||
* @throws IllegalStateException if retrieval fails
|
|
||||||
*/
|
*/
|
||||||
Optional<RevokedRecord> getRevocation(PkiId credentialId);
|
Optional<RevocationJournal> getRevocationJournal(PkiId credentialId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lists all revocation records.
|
* Lists authoritative revocation journals.
|
||||||
*
|
*
|
||||||
* @return list of revocation records (never {@code null})
|
* @return immutable journal list
|
||||||
* @throws IllegalStateException if listing fails
|
|
||||||
*/
|
*/
|
||||||
List<RevokedRecord> listRevocations();
|
List<RevocationJournal> listRevocationJournals();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persists a status object.
|
* Persists a status object.
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ package zeroecho.pki.e2e;
|
|||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertAll;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
@@ -42,9 +45,13 @@ import java.security.KeyPair;
|
|||||||
import java.security.KeyPairGenerator;
|
import java.security.KeyPairGenerator;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
import org.bouncycastle.asn1.x500.X500Name;
|
import org.bouncycastle.asn1.x500.X500Name;
|
||||||
import org.bouncycastle.cert.X509CRLHolder;
|
import org.bouncycastle.cert.X509CRLHolder;
|
||||||
@@ -63,6 +70,7 @@ import zeroecho.pki.api.EncodedObject;
|
|||||||
import zeroecho.pki.api.Encoding;
|
import zeroecho.pki.api.Encoding;
|
||||||
import zeroecho.pki.api.IssuanceService;
|
import zeroecho.pki.api.IssuanceService;
|
||||||
import zeroecho.pki.api.KeyRef;
|
import zeroecho.pki.api.KeyRef;
|
||||||
|
import zeroecho.pki.api.PkiException;
|
||||||
import zeroecho.pki.api.PkiId;
|
import zeroecho.pki.api.PkiId;
|
||||||
import zeroecho.pki.api.RevocationService;
|
import zeroecho.pki.api.RevocationService;
|
||||||
import zeroecho.pki.api.StatusObjectService;
|
import zeroecho.pki.api.StatusObjectService;
|
||||||
@@ -70,7 +78,15 @@ import zeroecho.pki.api.SubjectRef;
|
|||||||
import zeroecho.pki.api.Validity;
|
import zeroecho.pki.api.Validity;
|
||||||
import zeroecho.pki.api.attr.AttributeSet;
|
import zeroecho.pki.api.attr.AttributeSet;
|
||||||
import zeroecho.pki.api.ca.CaCreateCommand;
|
import zeroecho.pki.api.ca.CaCreateCommand;
|
||||||
|
import zeroecho.pki.api.ca.CaRecord;
|
||||||
|
import zeroecho.pki.api.ca.IntermediateCertIssueCommand;
|
||||||
|
import zeroecho.pki.api.ca.IntermediateCreateCommand;
|
||||||
|
import zeroecho.pki.api.credential.Credential;
|
||||||
import zeroecho.pki.api.credential.CredentialBundle;
|
import zeroecho.pki.api.credential.CredentialBundle;
|
||||||
|
import zeroecho.pki.api.credential.CredentialUse;
|
||||||
|
import zeroecho.pki.api.credential.EffectiveCredentialStatus;
|
||||||
|
import zeroecho.pki.api.credential.EffectiveCredentialStatusResolver;
|
||||||
|
import zeroecho.pki.api.issuance.BundleCommand;
|
||||||
import zeroecho.pki.api.issuance.IssueEndEntityCommand;
|
import zeroecho.pki.api.issuance.IssueEndEntityCommand;
|
||||||
import zeroecho.pki.api.issuance.VerificationPolicy;
|
import zeroecho.pki.api.issuance.VerificationPolicy;
|
||||||
import zeroecho.pki.api.request.CertificationRequest;
|
import zeroecho.pki.api.request.CertificationRequest;
|
||||||
@@ -79,11 +95,14 @@ import zeroecho.pki.api.request.ProofOfPossessionResult;
|
|||||||
import zeroecho.pki.api.request.ProofOfPossessionStatus;
|
import zeroecho.pki.api.request.ProofOfPossessionStatus;
|
||||||
import zeroecho.pki.api.request.RequestStorePolicy;
|
import zeroecho.pki.api.request.RequestStorePolicy;
|
||||||
import zeroecho.pki.api.revocation.RevocationReason;
|
import zeroecho.pki.api.revocation.RevocationReason;
|
||||||
import zeroecho.pki.api.revocation.RevokeCommand;
|
import zeroecho.pki.api.revocation.RevocationCommand;
|
||||||
import zeroecho.pki.api.status.StatusObject;
|
import zeroecho.pki.api.status.StatusObject;
|
||||||
import zeroecho.pki.api.status.StatusObjectGenerateCommand;
|
import zeroecho.pki.api.status.StatusObjectGenerateCommand;
|
||||||
import zeroecho.pki.api.status.StatusObjectType;
|
import zeroecho.pki.api.status.StatusObjectType;
|
||||||
import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
|
import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
|
||||||
|
import zeroecho.pki.impl.core.ManagedCaIssuance;
|
||||||
|
import zeroecho.pki.impl.core.VerifiedIssuanceCandidate;
|
||||||
|
import zeroecho.pki.spi.framework.CredentialIssuerBackend;
|
||||||
import zeroecho.pki.testkit.PkiTestRuntime;
|
import zeroecho.pki.testkit.PkiTestRuntime;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,6 +110,136 @@ import zeroecho.pki.testkit.PkiTestRuntime;
|
|||||||
*/
|
*/
|
||||||
public final class PkiCoreE2eTest {
|
public final class PkiCoreE2eTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nonUsableAndIndeterminateCredentialsFailClosedAcrossReachableTrustPaths(@TempDir Path tempDir)
|
||||||
|
throws Exception {
|
||||||
|
assertAll(
|
||||||
|
() -> assertRejectedAcrossTrustPaths(tempDir.resolve("held"), EffectiveCredentialStatus.HELD, false),
|
||||||
|
() -> assertRejectedAcrossTrustPaths(tempDir.resolve("expired"), EffectiveCredentialStatus.EXPIRED,
|
||||||
|
false),
|
||||||
|
() -> assertRejectedAcrossTrustPaths(tempDir.resolve("not-yet-valid"),
|
||||||
|
EffectiveCredentialStatus.NOT_YET_VALID, false),
|
||||||
|
() -> assertRejectedAcrossTrustPaths(tempDir.resolve("permanently-revoked"),
|
||||||
|
EffectiveCredentialStatus.PERMANENTLY_REVOKED, false),
|
||||||
|
() -> assertRejectedAcrossTrustPaths(tempDir.resolve("resolution-failure"), null, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void everyIssuerPathSkipsEarlierUnusableCredentialAndSelectsLaterUsable(@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);
|
||||||
|
|
||||||
|
try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), keys)) {
|
||||||
|
PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(),
|
||||||
|
new SubjectRef("CN=Matrix Root"), "default", Optional.of(rootKeyRef), emptyAttributes()));
|
||||||
|
Credential usable = runtime.caService().getCa(rootCaId).caCredentials().get(0);
|
||||||
|
Credential unusable = copyWithId(usable, new PkiId("credential:matrix-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, usable)));
|
||||||
|
|
||||||
|
List<PkiId> resolved = new ArrayList<>();
|
||||||
|
EffectiveCredentialStatusResolver resolver = scriptedResolver(credential -> {
|
||||||
|
resolved.add(credential.credentialId());
|
||||||
|
return credential.credentialId().equals(unusable.credentialId())
|
||||||
|
? EffectiveCredentialStatus.HELD : EffectiveCredentialStatus.USABLE;
|
||||||
|
}, 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())));
|
||||||
|
|
||||||
|
issuance.issueEndEntity(new IssueEndEntityCommand(rootCaId, leafRequest, "default", Optional.empty(),
|
||||||
|
emptyAttributes()));
|
||||||
|
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"), "default",
|
||||||
|
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, "default", 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, unusable)));
|
||||||
|
statusService.generate(new StatusObjectGenerateCommand(rootCaId, StatusObjectType.CRL,
|
||||||
|
runtime.framework().formatId(), emptyAttributes()));
|
||||||
|
assertEquals(List.of(unusable.credentialId(), usable.credentialId()), List.copyOf(resolved));
|
||||||
|
assertEquals(1, backend.endEntityCalls.get());
|
||||||
|
assertEquals(2, backend.intermediateCalls.get());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void permanentlyRevokedIssuerIsRejectedByEveryReachableTrustPath(@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:h6-root");
|
||||||
|
KeyRef intermediateKeyRef = new KeyRef("kref:v1:keyring:test:h6-intermediate");
|
||||||
|
KeyRef nextIntermediateKeyRef = new KeyRef("kref:v1:keyring:test:h6-next-intermediate");
|
||||||
|
Map<KeyRef, KeyPair> keys = Map.of(rootKeyRef, rootKey, intermediateKeyRef, intermediateKey,
|
||||||
|
nextIntermediateKeyRef, nextIntermediateKey);
|
||||||
|
|
||||||
|
try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), keys)) {
|
||||||
|
PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(),
|
||||||
|
new SubjectRef("CN=H6 Root"), "default", Optional.of(rootKeyRef), emptyAttributes()));
|
||||||
|
PkiId intermediateCaId = runtime.caService()
|
||||||
|
.createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(), rootCaId,
|
||||||
|
new SubjectRef("CN=H6 Intermediate"), "default", Optional.of(intermediateKeyRef),
|
||||||
|
emptyAttributes()));
|
||||||
|
PkiId rootCredentialId = runtime.caService().getCa(rootCaId).caCredentials().get(0).credentialId();
|
||||||
|
runtime.revocationService().revokePermanently(new RevocationCommand.RevokePermanently(rootCredentialId,
|
||||||
|
RevocationReason.KEY_COMPROMISE, emptyAttributes()));
|
||||||
|
int submissionsBeforeRejections = runtime.submittedSignCount();
|
||||||
|
|
||||||
|
CertificationRequest request = new CertificationRequest(runtime.framework().formatId(),
|
||||||
|
new EncodedObject(Encoding.DER, makeCsr(leafKey, "CN=H6 Leaf").getEncoded()));
|
||||||
|
ParsedCertificationRequest parsed = runtime.certificationRequestService().parse(request);
|
||||||
|
PkiException endEntityFailure = assertThrows(PkiException.class,
|
||||||
|
() -> runtime.issuanceService().issueEndEntity(new IssueEndEntityCommand(rootCaId, parsed,
|
||||||
|
"default", Optional.empty(), emptyAttributes())));
|
||||||
|
assertTrue(endEntityFailure.getMessage().contains("ISSUER_CREDENTIAL_UNAVAILABLE"));
|
||||||
|
|
||||||
|
assertThrows(PkiException.class,
|
||||||
|
() -> runtime.caService()
|
||||||
|
.createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(), rootCaId,
|
||||||
|
new SubjectRef("CN=H6 Rejected"), "default",
|
||||||
|
Optional.of(nextIntermediateKeyRef), emptyAttributes())));
|
||||||
|
assertThrows(PkiException.class,
|
||||||
|
() -> runtime.caService().issueIntermediateCertificate(
|
||||||
|
new IntermediateCertIssueCommand(runtime.framework().formatId(), rootCaId,
|
||||||
|
intermediateCaId, "default", Optional.empty(), emptyAttributes())));
|
||||||
|
assertThrows(PkiException.class,
|
||||||
|
() -> runtime.statusObjectService().generate(new StatusObjectGenerateCommand(rootCaId,
|
||||||
|
StatusObjectType.CRL, runtime.framework().formatId(), emptyAttributes())));
|
||||||
|
assertThrows(PkiException.class,
|
||||||
|
() -> runtime.issuanceService().buildBundle(
|
||||||
|
new BundleCommand(rootCredentialId, Optional.empty(), Optional.empty())));
|
||||||
|
|
||||||
|
assertEquals(submissionsBeforeRejections, runtime.submittedSignCount());
|
||||||
|
assertTrue(runtime.store().getCredential(rootCredentialId).isPresent());
|
||||||
|
assertTrue(runtime.caService().getCa(intermediateCaId).caCredentials().size() == 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void e2eRootIssueRevokeCrl(@TempDir Path tempDir) throws Exception {
|
void e2eRootIssueRevokeCrl(@TempDir Path tempDir) throws Exception {
|
||||||
System.out.println("e2eRootIssueRevokeCrl");
|
System.out.println("e2eRootIssueRevokeCrl");
|
||||||
@@ -144,8 +293,8 @@ public final class PkiCoreE2eTest {
|
|||||||
assertEquals("CN=Root", eeCert.getIssuer().toString());
|
assertEquals("CN=Root", eeCert.getIssuer().toString());
|
||||||
assertEquals("CN=Alice", eeCert.getSubject().toString());
|
assertEquals("CN=Alice", eeCert.getSubject().toString());
|
||||||
|
|
||||||
revSvc.revoke(new RevokeCommand(bundle.credential().credentialId(), RevocationReason.KEY_COMPROMISE,
|
revSvc.revokePermanently(new RevocationCommand.RevokePermanently(bundle.credential().credentialId(),
|
||||||
emptyAttributes()));
|
RevocationReason.KEY_COMPROMISE, emptyAttributes()));
|
||||||
|
|
||||||
StatusObject crl = stSvc.generate(new StatusObjectGenerateCommand(rootCaId, StatusObjectType.CRL,
|
StatusObject crl = stSvc.generate(new StatusObjectGenerateCommand(rootCaId, StatusObjectType.CRL,
|
||||||
runtime.framework().formatId(), emptyAttributes()));
|
runtime.framework().formatId(), emptyAttributes()));
|
||||||
@@ -159,6 +308,125 @@ public final class PkiCoreE2eTest {
|
|||||||
System.out.println("e2eRootIssueRevokeCrl...ok");
|
System.out.println("e2eRootIssueRevokeCrl...ok");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void assertRejectedAcrossTrustPaths(Path tempDir, EffectiveCredentialStatus status,
|
||||||
|
boolean resolutionFailure) throws Exception {
|
||||||
|
KeyPair rootKey = genRsa();
|
||||||
|
KeyPair intermediateKey = genRsa();
|
||||||
|
KeyPair nextIntermediateKey = genRsa();
|
||||||
|
KeyPair leafKey = genRsa();
|
||||||
|
KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:reject-root");
|
||||||
|
KeyRef intermediateKeyRef = new KeyRef("kref:v1:keyring:test:reject-intermediate");
|
||||||
|
KeyRef nextIntermediateKeyRef = new KeyRef("kref:v1:keyring:test:reject-next");
|
||||||
|
Map<KeyRef, KeyPair> keys = Map.of(rootKeyRef, rootKey, intermediateKeyRef, intermediateKey,
|
||||||
|
nextIntermediateKeyRef, nextIntermediateKey);
|
||||||
|
|
||||||
|
try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), keys)) {
|
||||||
|
PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(),
|
||||||
|
new SubjectRef("CN=Rejected Root"), "default", Optional.of(rootKeyRef), emptyAttributes()));
|
||||||
|
PkiId intermediateCaId = runtime.caService().createIntermediate(new IntermediateCreateCommand(
|
||||||
|
runtime.framework().formatId(), rootCaId, new SubjectRef("CN=Rejected Intermediate"), "default",
|
||||||
|
Optional.of(intermediateKeyRef), emptyAttributes()));
|
||||||
|
Credential rootCredential = runtime.caService().getCa(rootCaId).caCredentials().get(0);
|
||||||
|
EffectiveCredentialStatusResolver resolver = scriptedResolver(credential -> status, resolutionFailure);
|
||||||
|
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=Rejected Leaf").getEncoded())));
|
||||||
|
int signCount = runtime.submittedSignCount();
|
||||||
|
int caCount = runtime.store().listCas().size();
|
||||||
|
int statusCount = runtime.store().listStatusObjects(rootCaId).size();
|
||||||
|
int intermediateCredentialCount = runtime.caService().getCa(intermediateCaId).caCredentials().size();
|
||||||
|
|
||||||
|
assertThrows(PkiException.class,
|
||||||
|
() -> issuance.issueEndEntity(new IssueEndEntityCommand(rootCaId, leafRequest, "default",
|
||||||
|
Optional.empty(), emptyAttributes())));
|
||||||
|
assertThrows(PkiException.class,
|
||||||
|
() -> caService.createIntermediate(new IntermediateCreateCommand(runtime.framework().formatId(),
|
||||||
|
rootCaId, new SubjectRef("CN=Rejected Next"), "default",
|
||||||
|
Optional.of(nextIntermediateKeyRef), emptyAttributes())));
|
||||||
|
assertThrows(PkiException.class,
|
||||||
|
() -> caService.issueIntermediateCertificate(new IntermediateCertIssueCommand(
|
||||||
|
runtime.framework().formatId(), rootCaId, intermediateCaId, "default", Optional.empty(),
|
||||||
|
emptyAttributes())));
|
||||||
|
assertThrows(PkiException.class,
|
||||||
|
() -> statusService.generate(new StatusObjectGenerateCommand(rootCaId, StatusObjectType.CRL,
|
||||||
|
runtime.framework().formatId(), emptyAttributes())));
|
||||||
|
assertThrows(PkiException.class,
|
||||||
|
() -> issuance.buildBundle(new BundleCommand(rootCredential.credentialId(), Optional.empty(),
|
||||||
|
Optional.empty())));
|
||||||
|
|
||||||
|
assertEquals(0, backend.endEntityCalls.get());
|
||||||
|
assertEquals(0, backend.intermediateCalls.get());
|
||||||
|
assertEquals(signCount, runtime.submittedSignCount());
|
||||||
|
assertEquals(caCount, runtime.store().listCas().size());
|
||||||
|
assertEquals(statusCount, runtime.store().listStatusObjects(rootCaId).size());
|
||||||
|
assertEquals(intermediateCredentialCount,
|
||||||
|
runtime.caService().getCa(intermediateCaId).caCredentials().size());
|
||||||
|
assertTrue(runtime.store().getCredential(rootCredential.credentialId()).isPresent());
|
||||||
|
assertFalse(runtime.auditSink().snapshot().toString().contains("DO_NOT_EXPOSE_REVOCATION_SENTINEL"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static EffectiveCredentialStatusResolver scriptedResolver(
|
||||||
|
Function<Credential, EffectiveCredentialStatus> status, boolean fail) {
|
||||||
|
return () -> new EffectiveCredentialStatusResolver.Evaluation() {
|
||||||
|
@Override
|
||||||
|
public Instant evaluationTime() {
|
||||||
|
return Instant.parse("2026-06-01T12:00:00Z");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public EffectiveCredentialStatus resolve(Credential credential) {
|
||||||
|
if (fail) {
|
||||||
|
throw new PkiException(
|
||||||
|
"Credential status resolution failed: code=CREDENTIAL_STATUS_RESOLUTION_FAILED");
|
||||||
|
}
|
||||||
|
return status.apply(credential);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Credential requireUsable(Credential credential, CredentialUse use) {
|
||||||
|
if (resolve(credential) != EffectiveCredentialStatus.USABLE) {
|
||||||
|
throw new PkiException("Credential trust rejected: code=CREDENTIAL_NOT_USABLE");
|
||||||
|
}
|
||||||
|
return credential;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Credential copyWithId(Credential source, PkiId id) {
|
||||||
|
return new Credential(id, source.formatId(), source.issuerRef(), source.subjectRef(), source.validity(),
|
||||||
|
source.serialOrUniqueId(), source.publicKeyId(), source.profileId(), source.status(), source.encoded(),
|
||||||
|
source.attributes());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class CountingIssuerBackend implements CredentialIssuerBackend {
|
||||||
|
private final CredentialIssuerBackend delegate;
|
||||||
|
private final AtomicInteger endEntityCalls;
|
||||||
|
private final AtomicInteger intermediateCalls;
|
||||||
|
|
||||||
|
private CountingIssuerBackend(CredentialIssuerBackend delegate) {
|
||||||
|
this.delegate = delegate;
|
||||||
|
this.endEntityCalls = new AtomicInteger();
|
||||||
|
this.intermediateCalls = new AtomicInteger();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public CredentialBundle issueEndEntity(VerifiedIssuanceCandidate candidate) {
|
||||||
|
endEntityCalls.incrementAndGet();
|
||||||
|
return delegate.issueEndEntity(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Credential issueIntermediateCertificate(ManagedCaIssuance issuance) {
|
||||||
|
intermediateCalls.incrementAndGet();
|
||||||
|
return delegate.issueIntermediateCertificate(issuance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static KeyPair genRsa() throws Exception {
|
private static KeyPair genRsa() throws Exception {
|
||||||
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
|
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
|
||||||
kpg.initialize(2048);
|
kpg.initialize(2048);
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ final class PkiProofGateE2eTest {
|
|||||||
try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), keys)) {
|
try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), keys)) {
|
||||||
CountingIssuerBackend counting = new CountingIssuerBackend(runtime.issuerBackend());
|
CountingIssuerBackend counting = new CountingIssuerBackend(runtime.issuerBackend());
|
||||||
DefaultIssuanceService issuance = new DefaultIssuanceService(runtime.store(), runtime.framework(),
|
DefaultIssuanceService issuance = new DefaultIssuanceService(runtime.store(), runtime.framework(),
|
||||||
counting, runtime.auditSink());
|
counting, runtime.auditSink(), runtime.statusResolver());
|
||||||
ParsedCertificationRequest valid = parse(runtime, makeCsr(subjectKey, subjectKey, "CN=Leaf"));
|
ParsedCertificationRequest valid = parse(runtime, makeCsr(subjectKey, subjectKey, "CN=Leaf"));
|
||||||
|
|
||||||
assertThrows(PkiException.class,
|
assertThrows(PkiException.class,
|
||||||
@@ -217,7 +217,7 @@ final class PkiProofGateE2eTest {
|
|||||||
try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), Map.of())) {
|
try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"), Map.of())) {
|
||||||
CountingIssuerBackend counting = new CountingIssuerBackend(runtime.issuerBackend());
|
CountingIssuerBackend counting = new CountingIssuerBackend(runtime.issuerBackend());
|
||||||
DefaultIssuanceService issuance = new DefaultIssuanceService(runtime.store(), runtime.framework(),
|
DefaultIssuanceService issuance = new DefaultIssuanceService(runtime.store(), runtime.framework(),
|
||||||
counting, runtime.auditSink());
|
counting, runtime.auditSink(), runtime.statusResolver());
|
||||||
CaService caService = runtime.caService(counting);
|
CaService caService = runtime.caService(counting);
|
||||||
ParsedCertificationRequest request = new ParsedCertificationRequest(new PkiId("csr:unsupported"),
|
ParsedCertificationRequest request = new ParsedCertificationRequest(new PkiId("csr:unsupported"),
|
||||||
runtime.framework().formatId(), new SubjectRef("CN=Unsupported"),
|
runtime.framework().formatId(), new SubjectRef("CN=Unsupported"),
|
||||||
@@ -265,11 +265,15 @@ final class PkiProofGateE2eTest {
|
|||||||
void endEntityGateRejectsMalformedTamperedAndSubstitutedRequests(@TempDir Path tempDir) throws Exception {
|
void endEntityGateRejectsMalformedTamperedAndSubstitutedRequests(@TempDir Path tempDir) throws Exception {
|
||||||
System.out.println("endEntityGateRejectsMalformedTamperedAndSubstitutedRequests");
|
System.out.println("endEntityGateRejectsMalformedTamperedAndSubstitutedRequests");
|
||||||
|
|
||||||
|
KeyPair rootKey = generateRsa();
|
||||||
KeyPair subjectKey = generateRsa();
|
KeyPair subjectKey = generateRsa();
|
||||||
KeyPair otherKey = generateRsa();
|
KeyPair otherKey = generateRsa();
|
||||||
|
KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:root");
|
||||||
KeyRef subjectKeyRef = new KeyRef("kref:v1:keyring:test:subject");
|
KeyRef subjectKeyRef = new KeyRef("kref:v1:keyring:test:subject");
|
||||||
try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"),
|
try (PkiTestRuntime runtime = PkiTestRuntime.create(tempDir, tempDir.resolve("bus.log"),
|
||||||
Map.of(subjectKeyRef, subjectKey))) {
|
Map.of(rootKeyRef, rootKey, subjectKeyRef, subjectKey))) {
|
||||||
|
PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(),
|
||||||
|
new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef), new SimpleAttributeSet()));
|
||||||
PKCS10CertificationRequest validCsr = makeCsr(subjectKey, subjectKey, "CN=Subject");
|
PKCS10CertificationRequest validCsr = makeCsr(subjectKey, subjectKey, "CN=Subject");
|
||||||
ParsedCertificationRequest valid = parse(runtime, validCsr);
|
ParsedCertificationRequest valid = parse(runtime, validCsr);
|
||||||
ParsedCertificationRequest pss = parse(runtime,
|
ParsedCertificationRequest pss = parse(runtime,
|
||||||
@@ -289,44 +293,44 @@ final class PkiProofGateE2eTest {
|
|||||||
new zeroecho.pki.api.issuance.VerificationPolicy(true, Optional.empty()));
|
new zeroecho.pki.api.issuance.VerificationPolicy(true, Optional.empty()));
|
||||||
assertEquals(ProofOfPossessionStatus.FAILED, unsupportedProof.status());
|
assertEquals(ProofOfPossessionStatus.FAILED, unsupportedProof.status());
|
||||||
|
|
||||||
assertRejected(runtime, withAttributes(valid, new SimpleAttributeSet()), "CSR_MISSING");
|
assertRejected(runtime, rootCaId, withAttributes(valid, new SimpleAttributeSet()), "CSR_MISSING");
|
||||||
assertRejected(runtime, withCsr(valid, new byte[] { 0x01 }), "CSR_MALFORMED");
|
assertRejected(runtime, rootCaId, withCsr(valid, new byte[] { 0x01 }), "CSR_MALFORMED");
|
||||||
|
|
||||||
byte[] tampered = csrDer(valid).clone();
|
byte[] tampered = csrDer(valid).clone();
|
||||||
tampered[tampered.length - 1] ^= 0x01;
|
tampered[tampered.length - 1] ^= 0x01;
|
||||||
ParsedCertificationRequest tamperedParsed = parse(runtime, new PKCS10CertificationRequest(tampered));
|
ParsedCertificationRequest tamperedParsed = parse(runtime, new PKCS10CertificationRequest(tampered));
|
||||||
assertRejected(runtime, tamperedParsed, "PROOF_FAILED");
|
assertRejected(runtime, rootCaId, tamperedParsed, "PROOF_FAILED");
|
||||||
|
|
||||||
ParsedCertificationRequest wrongSigner = parse(runtime,
|
ParsedCertificationRequest wrongSigner = parse(runtime,
|
||||||
makeCsr(subjectKey, otherKey, "CN=Subject"));
|
makeCsr(subjectKey, otherKey, "CN=Subject"));
|
||||||
assertRejected(runtime, wrongSigner, "PROOF_FAILED");
|
assertRejected(runtime, rootCaId, wrongSigner, "PROOF_FAILED");
|
||||||
|
|
||||||
assertRejected(runtime,
|
assertRejected(runtime, rootCaId,
|
||||||
new ParsedCertificationRequest(new PkiId("csr:substituted"), valid.formatId(), valid.subjectRef(),
|
new ParsedCertificationRequest(new PkiId("csr:substituted"), valid.formatId(), valid.subjectRef(),
|
||||||
valid.publicKeyInfo(), valid.requestedValidity(), valid.requestedProfileId(),
|
valid.publicKeyInfo(), valid.requestedValidity(), valid.requestedProfileId(),
|
||||||
valid.attributes()),
|
valid.attributes()),
|
||||||
"REQUEST_ID_MISMATCH");
|
"REQUEST_ID_MISMATCH");
|
||||||
assertRejected(runtime,
|
assertRejected(runtime, rootCaId,
|
||||||
new ParsedCertificationRequest(valid.requestId(), valid.formatId(), new SubjectRef("CN=Other"),
|
new ParsedCertificationRequest(valid.requestId(), valid.formatId(), new SubjectRef("CN=Other"),
|
||||||
valid.publicKeyInfo(), valid.requestedValidity(), valid.requestedProfileId(),
|
valid.publicKeyInfo(), valid.requestedValidity(), valid.requestedProfileId(),
|
||||||
valid.attributes()),
|
valid.attributes()),
|
||||||
"SUBJECT_MISMATCH");
|
"SUBJECT_MISMATCH");
|
||||||
assertRejected(runtime,
|
assertRejected(runtime, rootCaId,
|
||||||
new ParsedCertificationRequest(valid.requestId(), valid.formatId(), valid.subjectRef(),
|
new ParsedCertificationRequest(valid.requestId(), valid.formatId(), valid.subjectRef(),
|
||||||
new EncodedObject(Encoding.DER, otherKey.getPublic().getEncoded()),
|
new EncodedObject(Encoding.DER, otherKey.getPublic().getEncoded()),
|
||||||
valid.requestedValidity(), valid.requestedProfileId(), valid.attributes()),
|
valid.requestedValidity(), valid.requestedProfileId(), valid.attributes()),
|
||||||
"SPKI_MISMATCH");
|
"SPKI_MISMATCH");
|
||||||
assertRejected(runtime,
|
assertRejected(runtime, rootCaId,
|
||||||
new ParsedCertificationRequest(valid.requestId(), new FormatId("unsupported"), valid.subjectRef(),
|
new ParsedCertificationRequest(valid.requestId(), new FormatId("unsupported"), valid.subjectRef(),
|
||||||
valid.publicKeyInfo(), valid.requestedValidity(), valid.requestedProfileId(),
|
valid.publicKeyInfo(), valid.requestedValidity(), valid.requestedProfileId(),
|
||||||
valid.attributes()),
|
valid.attributes()),
|
||||||
"FORMAT_UNSUPPORTED");
|
"FORMAT_UNSUPPORTED");
|
||||||
byte[] maximum = new byte[1024 * 1024];
|
byte[] maximum = new byte[1024 * 1024];
|
||||||
System.arraycopy(csrDer(valid), 0, maximum, 0, csrDer(valid).length);
|
System.arraycopy(csrDer(valid), 0, maximum, 0, csrDer(valid).length);
|
||||||
assertRejected(runtime, withCsr(valid, maximum), "CSR_MALFORMED");
|
assertRejected(runtime, rootCaId, withCsr(valid, maximum), "CSR_MALFORMED");
|
||||||
assertRejected(runtime, withCsr(valid, new byte[1024 * 1024 + 1]), "CSR_TOO_LARGE");
|
assertRejected(runtime, rootCaId, withCsr(valid, new byte[1024 * 1024 + 1]), "CSR_TOO_LARGE");
|
||||||
|
|
||||||
assertTrue(runtime.store().listCas().isEmpty());
|
assertEquals(1, runtime.store().listCas().size());
|
||||||
AuditEvent last = runtime.auditSink().snapshot().get(runtime.auditSink().snapshot().size() - 1);
|
AuditEvent last = runtime.auditSink().snapshot().get(runtime.auditSink().snapshot().size() - 1);
|
||||||
assertEquals("ISSUE_END_ENTITY_REJECTED", last.action());
|
assertEquals("ISSUE_END_ENTITY_REJECTED", last.action());
|
||||||
assertEquals("SYSTEM", last.principal().type());
|
assertEquals("SYSTEM", last.principal().type());
|
||||||
@@ -341,7 +345,9 @@ final class PkiProofGateE2eTest {
|
|||||||
void endEntityGateForcesRequiredPolicyAndRejectsEveryNonVerifiedStatus(@TempDir Path tempDir) throws Exception {
|
void endEntityGateForcesRequiredPolicyAndRejectsEveryNonVerifiedStatus(@TempDir Path tempDir) throws Exception {
|
||||||
System.out.println("endEntityGateForcesRequiredPolicyAndRejectsEveryNonVerifiedStatus");
|
System.out.println("endEntityGateForcesRequiredPolicyAndRejectsEveryNonVerifiedStatus");
|
||||||
|
|
||||||
|
KeyPair rootKey = generateRsa();
|
||||||
KeyPair subjectKey = generateRsa();
|
KeyPair subjectKey = generateRsa();
|
||||||
|
KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:root");
|
||||||
KeyRef subjectKeyRef = new KeyRef("kref:v1:keyring:test:subject");
|
KeyRef subjectKeyRef = new KeyRef("kref:v1:keyring:test:subject");
|
||||||
for (ProofOfPossessionStatus status : new ProofOfPossessionStatus[] {
|
for (ProofOfPossessionStatus status : new ProofOfPossessionStatus[] {
|
||||||
ProofOfPossessionStatus.NOT_PRESENT,
|
ProofOfPossessionStatus.NOT_PRESENT,
|
||||||
@@ -350,17 +356,20 @@ final class PkiProofGateE2eTest {
|
|||||||
AtomicBoolean required = new AtomicBoolean();
|
AtomicBoolean required = new AtomicBoolean();
|
||||||
Path caseDir = tempDir.resolve(status.name());
|
Path caseDir = tempDir.resolve(status.name());
|
||||||
try (PkiTestRuntime runtime = PkiTestRuntime.create(caseDir, caseDir.resolve("bus.log"),
|
try (PkiTestRuntime runtime = PkiTestRuntime.create(caseDir, caseDir.resolve("bus.log"),
|
||||||
Map.of(subjectKeyRef, subjectKey), Map.of(subjectKeyRef, subjectKey.getPublic()),
|
Map.of(rootKeyRef, rootKey, subjectKeyRef, subjectKey),
|
||||||
|
Map.of(rootKeyRef, rootKey.getPublic(), subjectKeyRef, subjectKey.getPublic()),
|
||||||
(request, policy) -> {
|
(request, policy) -> {
|
||||||
required.set(policy.requireProofOfPossession());
|
required.set(policy.requireProofOfPossession());
|
||||||
return new ProofOfPossessionResult(status, Optional.empty());
|
return new ProofOfPossessionResult(status, Optional.empty());
|
||||||
})) {
|
})) {
|
||||||
|
PkiId rootCaId = runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(),
|
||||||
|
new SubjectRef("CN=Root"), "default", Optional.of(rootKeyRef), new SimpleAttributeSet()));
|
||||||
ParsedCertificationRequest parsed = parse(runtime, makeCsr(subjectKey, subjectKey, "CN=Subject"));
|
ParsedCertificationRequest parsed = parse(runtime, makeCsr(subjectKey, subjectKey, "CN=Subject"));
|
||||||
assertThrows(PkiException.class, () -> issue(runtime, parsed));
|
assertThrows(PkiException.class, () -> issue(runtime, rootCaId, parsed));
|
||||||
assertTrue(required.get());
|
assertTrue(required.get());
|
||||||
assertEquals("PROOF_" + status.name(),
|
assertEquals("PROOF_" + status.name(),
|
||||||
runtime.auditSink().snapshot().get(0).details().get("code"));
|
runtime.auditSink().snapshot().get(0).details().get("code"));
|
||||||
assertTrue(runtime.store().listCas().isEmpty());
|
assertEquals(1, runtime.store().listCas().size());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -653,7 +662,7 @@ final class PkiProofGateE2eTest {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
DefaultIssuanceService throwingBackendService = new DefaultIssuanceService(runtime.store(),
|
DefaultIssuanceService throwingBackendService = new DefaultIssuanceService(runtime.store(),
|
||||||
runtime.framework(), throwingBackend, runtime.auditSink());
|
runtime.framework(), throwingBackend, runtime.auditSink(), runtime.statusResolver());
|
||||||
PkiException backendRejection = assertThrows(PkiException.class,
|
PkiException backendRejection = assertThrows(PkiException.class,
|
||||||
() -> throwingBackendService.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject,
|
() -> throwingBackendService.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject,
|
||||||
"default", Optional.empty(), new SimpleAttributeSet())));
|
"default", Optional.empty(), new SimpleAttributeSet())));
|
||||||
@@ -678,7 +687,7 @@ final class PkiProofGateE2eTest {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
DefaultIssuanceService service = new DefaultIssuanceService(runtime.store(), runtime.framework(),
|
DefaultIssuanceService service = new DefaultIssuanceService(runtime.store(), runtime.framework(),
|
||||||
maliciousBackend, runtime.auditSink());
|
maliciousBackend, runtime.auditSink(), runtime.statusResolver());
|
||||||
assertThrows(PkiException.class,
|
assertThrows(PkiException.class,
|
||||||
() -> service.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, "default",
|
() -> service.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, "default",
|
||||||
Optional.empty(), new SimpleAttributeSet())));
|
Optional.empty(), new SimpleAttributeSet())));
|
||||||
@@ -707,7 +716,7 @@ final class PkiProofGateE2eTest {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
DefaultIssuanceService invalidSignatureService = new DefaultIssuanceService(runtime.store(),
|
DefaultIssuanceService invalidSignatureService = new DefaultIssuanceService(runtime.store(),
|
||||||
runtime.framework(), invalidSignatureBackend, runtime.auditSink());
|
runtime.framework(), invalidSignatureBackend, runtime.auditSink(), runtime.statusResolver());
|
||||||
assertThrows(PkiException.class,
|
assertThrows(PkiException.class,
|
||||||
() -> invalidSignatureService.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject,
|
() -> invalidSignatureService.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject,
|
||||||
"default", Optional.empty(), new SimpleAttributeSet())));
|
"default", Optional.empty(), new SimpleAttributeSet())));
|
||||||
@@ -727,7 +736,7 @@ final class PkiProofGateE2eTest {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
DefaultIssuanceService snapshotService = new DefaultIssuanceService(runtime.store(),
|
DefaultIssuanceService snapshotService = new DefaultIssuanceService(runtime.store(),
|
||||||
runtime.framework(), mutableBackend, runtime.auditSink());
|
runtime.framework(), mutableBackend, runtime.auditSink(), runtime.statusResolver());
|
||||||
CredentialBundle returned = snapshotService.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject,
|
CredentialBundle returned = snapshotService.issueEndEntity(new IssueEndEntityCommand(rootCaId, subject,
|
||||||
"default", Optional.empty(), new SimpleAttributeSet()));
|
"default", Optional.empty(), new SimpleAttributeSet()));
|
||||||
byte[] expectedLeaf = returned.credential().encoded().bytes().clone();
|
byte[] expectedLeaf = returned.credential().encoded().bytes().clone();
|
||||||
@@ -763,6 +772,8 @@ final class PkiProofGateE2eTest {
|
|||||||
"default", Optional.empty(), new SimpleAttributeSet())));
|
"default", Optional.empty(), new SimpleAttributeSet())));
|
||||||
assertEquals(before, runtime.submittedSignCount());
|
assertEquals(before, runtime.submittedSignCount());
|
||||||
|
|
||||||
|
runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(),
|
||||||
|
root.subjectRef(), List.of(original)));
|
||||||
ParsedCertificationRequest missing = withAttributes(subject, new SimpleAttributeSet());
|
ParsedCertificationRequest missing = withAttributes(subject, new SimpleAttributeSet());
|
||||||
AttributeSet hostileAttributes = new AttributeSet() {
|
AttributeSet hostileAttributes = new AttributeSet() {
|
||||||
@Override
|
@Override
|
||||||
@@ -789,7 +800,7 @@ final class PkiProofGateE2eTest {
|
|||||||
DefaultIssuanceService failingAudit = new DefaultIssuanceService(runtime.store(), runtime.framework(),
|
DefaultIssuanceService failingAudit = new DefaultIssuanceService(runtime.store(), runtime.framework(),
|
||||||
runtime.issuerBackend(), event -> {
|
runtime.issuerBackend(), event -> {
|
||||||
throw new IllegalStateException("DO_NOT_LOG_PAYLOAD_SENTINEL");
|
throw new IllegalStateException("DO_NOT_LOG_PAYLOAD_SENTINEL");
|
||||||
});
|
}, runtime.statusResolver());
|
||||||
PkiException rejection = assertThrows(PkiException.class,
|
PkiException rejection = assertThrows(PkiException.class,
|
||||||
() -> failingAudit.issueEndEntity(new IssueEndEntityCommand(rootCaId, missing, "default",
|
() -> failingAudit.issueEndEntity(new IssueEndEntityCommand(rootCaId, missing, "default",
|
||||||
Optional.empty(), new SimpleAttributeSet())));
|
Optional.empty(), new SimpleAttributeSet())));
|
||||||
@@ -1080,15 +1091,16 @@ final class PkiProofGateE2eTest {
|
|||||||
source.publicKeyInfo(), source.requestedValidity(), source.requestedProfileId(), attributes);
|
source.publicKeyInfo(), source.requestedValidity(), source.requestedProfileId(), attributes);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void assertRejected(PkiTestRuntime runtime, ParsedCertificationRequest request, String code) {
|
private static void assertRejected(PkiTestRuntime runtime, PkiId issuerCaId,
|
||||||
|
ParsedCertificationRequest request, String code) {
|
||||||
int before = runtime.auditSink().snapshot().size();
|
int before = runtime.auditSink().snapshot().size();
|
||||||
assertThrows(PkiException.class, () -> issue(runtime, request));
|
assertThrows(PkiException.class, () -> issue(runtime, issuerCaId, request));
|
||||||
assertEquals(before + 1, runtime.auditSink().snapshot().size());
|
assertEquals(before + 1, runtime.auditSink().snapshot().size());
|
||||||
assertEquals(code, runtime.auditSink().snapshot().get(before).details().get("code"));
|
assertEquals(code, runtime.auditSink().snapshot().get(before).details().get("code"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void issue(PkiTestRuntime runtime, ParsedCertificationRequest request) {
|
private static void issue(PkiTestRuntime runtime, PkiId issuerCaId, ParsedCertificationRequest request) {
|
||||||
runtime.issuanceService().issueEndEntity(new IssueEndEntityCommand(new PkiId("ca:absent"), request, "default",
|
runtime.issuanceService().issueEndEntity(new IssueEndEntityCommand(issuerCaId, request, "default",
|
||||||
Optional.empty(), new SimpleAttributeSet()));
|
Optional.empty(), new SimpleAttributeSet()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2026, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
******************************************************************************/
|
||||||
|
package zeroecho.pki.impl.core;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.lang.reflect.Proxy;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import zeroecho.pki.api.PkiException;
|
||||||
|
import zeroecho.pki.api.PkiId;
|
||||||
|
import zeroecho.pki.api.audit.AuditEvent;
|
||||||
|
import zeroecho.pki.api.revocation.RevocationCommand;
|
||||||
|
import zeroecho.pki.api.revocation.RevocationJournal;
|
||||||
|
import zeroecho.pki.api.revocation.RevocationState;
|
||||||
|
import zeroecho.pki.api.revocation.RevocationTransition;
|
||||||
|
import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
|
||||||
|
import zeroecho.pki.spi.store.PkiStore;
|
||||||
|
|
||||||
|
final class DefaultRevocationServiceTest {
|
||||||
|
private static final Instant NOW = Instant.parse("2026-07-01T12:00:00Z");
|
||||||
|
private static final PkiId CREDENTIAL_ID = new PkiId("credential:audit");
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void successAuditsCommittedStateAndRevisionOnce() {
|
||||||
|
RevocationJournal committed = new RevocationJournal(CREDENTIAL_ID,
|
||||||
|
List.of(new RevocationTransition(1L, RevocationState.HELD, NOW, Optional.empty(),
|
||||||
|
new SimpleAttributeSet())));
|
||||||
|
List<AuditEvent> events = new ArrayList<>();
|
||||||
|
DefaultRevocationService service = new DefaultRevocationService(store((method, arguments) -> {
|
||||||
|
if ("transitionRevocation".equals(method)) {
|
||||||
|
return committed;
|
||||||
|
}
|
||||||
|
throw new UnsupportedOperationException(method);
|
||||||
|
}), Clock.fixed(NOW, ZoneOffset.UTC), events::add);
|
||||||
|
|
||||||
|
assertEquals(committed,
|
||||||
|
service.hold(new RevocationCommand.Hold(CREDENTIAL_ID, new SimpleAttributeSet())));
|
||||||
|
assertEquals(1, events.size());
|
||||||
|
assertEquals("COMMITTED", events.get(0).details().get("result"));
|
||||||
|
assertEquals("HELD", events.get(0).details().get("state"));
|
||||||
|
assertEquals("1", events.get(0).details().get("revision"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hostileStoreFailureIsRedactedAndProducesOnlyOneRejectionAudit() {
|
||||||
|
String sentinel = "DO_NOT_EXPOSE_REVOCATION_STORE_SENTINEL";
|
||||||
|
List<AuditEvent> events = new ArrayList<>();
|
||||||
|
AtomicBoolean lookupAttempted = new AtomicBoolean();
|
||||||
|
DefaultRevocationService service = new DefaultRevocationService(store((method, arguments) -> {
|
||||||
|
if ("transitionRevocation".equals(method)) {
|
||||||
|
throw new IllegalStateException(sentinel);
|
||||||
|
}
|
||||||
|
if ("getRevocationJournal".equals(method)) {
|
||||||
|
lookupAttempted.set(true);
|
||||||
|
throw new IllegalStateException(sentinel);
|
||||||
|
}
|
||||||
|
throw new UnsupportedOperationException(method);
|
||||||
|
}), Clock.fixed(NOW, ZoneOffset.UTC), events::add);
|
||||||
|
|
||||||
|
PkiException failure = assertThrows(PkiException.class,
|
||||||
|
() -> service.hold(new RevocationCommand.Hold(CREDENTIAL_ID, new SimpleAttributeSet())));
|
||||||
|
assertEquals("Revocation operation failed: code=REVOCATION_STATE_UPDATE_FAILED", failure.getMessage());
|
||||||
|
assertNull(failure.getCause());
|
||||||
|
assertFalse(failure.toString().contains(sentinel));
|
||||||
|
assertEquals(1, events.size());
|
||||||
|
assertEquals("REJECTED", events.get(0).details().get("result"));
|
||||||
|
assertEquals("REVOCATION_STATE_UPDATE_FAILED", events.get(0).details().get("code"));
|
||||||
|
assertFalse(events.toString().contains(sentinel));
|
||||||
|
assertTrue(lookupAttempted.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hostileClockFailureIsRedactedAuditedOnceAndNeverReachesStoreTransition() {
|
||||||
|
String sentinel = "DO_NOT_EXPOSE_REVOCATION_CLOCK_SENTINEL";
|
||||||
|
List<AuditEvent> events = new ArrayList<>();
|
||||||
|
AtomicBoolean storeInvoked = new AtomicBoolean();
|
||||||
|
Clock failingClock = new Clock() {
|
||||||
|
@Override
|
||||||
|
public ZoneId getZone() {
|
||||||
|
return ZoneOffset.UTC;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Clock withZone(ZoneId zone) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Instant instant() {
|
||||||
|
throw new IllegalStateException(sentinel);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
DefaultRevocationService service = new DefaultRevocationService(store((method, arguments) -> {
|
||||||
|
storeInvoked.set(true);
|
||||||
|
throw new AssertionError("Store must not be invoked after clock failure");
|
||||||
|
}), failingClock, events::add);
|
||||||
|
|
||||||
|
PkiException failure = assertThrows(PkiException.class,
|
||||||
|
() -> service.hold(new RevocationCommand.Hold(CREDENTIAL_ID, new SimpleAttributeSet())));
|
||||||
|
assertEquals("Revocation operation failed: code=REVOCATION_TIME_UNAVAILABLE", failure.getMessage());
|
||||||
|
assertNull(failure.getCause());
|
||||||
|
assertFalse(failure.toString().contains(sentinel));
|
||||||
|
assertFalse(storeInvoked.get());
|
||||||
|
assertEquals(1, events.size());
|
||||||
|
assertEquals("REJECTED", events.get(0).details().get("result"));
|
||||||
|
assertEquals("REVOCATION_TIME_UNAVAILABLE", events.get(0).details().get("code"));
|
||||||
|
assertFalse(events.toString().contains(sentinel));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PkiStore store(Invocation invocation) {
|
||||||
|
return (PkiStore) Proxy.newProxyInstance(PkiStore.class.getClassLoader(), new Class<?>[] { PkiStore.class },
|
||||||
|
(proxy, method, arguments) -> invocation.invoke(method.getName(), arguments));
|
||||||
|
}
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
private interface Invocation {
|
||||||
|
Object invoke(String method, Object[] arguments);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2026, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
******************************************************************************/
|
||||||
|
package zeroecho.pki.impl.core;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.lang.reflect.InvocationTargetException;
|
||||||
|
import java.lang.reflect.Proxy;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.security.KeyPair;
|
||||||
|
import java.security.KeyPairGenerator;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
import org.bouncycastle.asn1.x500.X500Name;
|
||||||
|
import org.bouncycastle.asn1.x509.CRLReason;
|
||||||
|
import org.bouncycastle.asn1.x509.Extension;
|
||||||
|
import org.bouncycastle.cert.X509CRLEntryHolder;
|
||||||
|
import org.bouncycastle.cert.X509CRLHolder;
|
||||||
|
import org.bouncycastle.operator.ContentSigner;
|
||||||
|
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
|
||||||
|
import org.bouncycastle.pkcs.PKCS10CertificationRequest;
|
||||||
|
import org.bouncycastle.pkcs.PKCS10CertificationRequestBuilder;
|
||||||
|
import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequestBuilder;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import zeroecho.pki.api.EncodedObject;
|
||||||
|
import zeroecho.pki.api.Encoding;
|
||||||
|
import zeroecho.pki.api.FormatId;
|
||||||
|
import zeroecho.pki.api.IssuanceService;
|
||||||
|
import zeroecho.pki.api.KeyRef;
|
||||||
|
import zeroecho.pki.api.PkiException;
|
||||||
|
import zeroecho.pki.api.PkiId;
|
||||||
|
import zeroecho.pki.api.SubjectRef;
|
||||||
|
import zeroecho.pki.api.Validity;
|
||||||
|
import zeroecho.pki.api.attr.AttributeSet;
|
||||||
|
import zeroecho.pki.api.attr.AttributeValue;
|
||||||
|
import zeroecho.pki.api.ca.CaCreateCommand;
|
||||||
|
import zeroecho.pki.api.ca.CaRecord;
|
||||||
|
import zeroecho.pki.api.credential.Credential;
|
||||||
|
import zeroecho.pki.api.credential.CredentialStatus;
|
||||||
|
import zeroecho.pki.api.credential.CredentialUse;
|
||||||
|
import zeroecho.pki.api.credential.EffectiveCredentialStatus;
|
||||||
|
import zeroecho.pki.api.credential.EffectiveCredentialStatusResolver;
|
||||||
|
import zeroecho.pki.api.issuance.IssueEndEntityCommand;
|
||||||
|
import zeroecho.pki.api.issuance.VerificationPolicy;
|
||||||
|
import zeroecho.pki.api.request.CertificationRequest;
|
||||||
|
import zeroecho.pki.api.request.ParsedCertificationRequest;
|
||||||
|
import zeroecho.pki.api.revocation.RevocationCommand;
|
||||||
|
import zeroecho.pki.api.revocation.RevocationJournal;
|
||||||
|
import zeroecho.pki.api.revocation.RevocationReason;
|
||||||
|
import zeroecho.pki.api.revocation.RevocationState;
|
||||||
|
import zeroecho.pki.api.revocation.RevocationTransition;
|
||||||
|
import zeroecho.pki.api.status.StatusObject;
|
||||||
|
import zeroecho.pki.api.status.StatusObjectGenerateCommand;
|
||||||
|
import zeroecho.pki.api.status.StatusObjectType;
|
||||||
|
import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
|
||||||
|
import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes;
|
||||||
|
import zeroecho.pki.impl.framework.x509.bc.BcX509CredentialFramework;
|
||||||
|
import zeroecho.pki.spi.framework.CrlEntry;
|
||||||
|
import zeroecho.pki.spi.framework.CredentialFramework;
|
||||||
|
import zeroecho.pki.spi.framework.StatusObjectGenerator;
|
||||||
|
import zeroecho.pki.spi.store.PkiStore;
|
||||||
|
import zeroecho.pki.testkit.PkiTestRuntime;
|
||||||
|
|
||||||
|
final class DefaultStatusObjectServiceCrlTest {
|
||||||
|
private static final Instant EVALUATION_TIME = Instant.parse("2026-07-30T12:00:00Z");
|
||||||
|
private static final String SENTINEL = "DO_NOT_EXPOSE_CRL_SENTINEL";
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bouncyCastleGeneratorPreservesFullSerialReasonAndTransitionTime(@TempDir Path root) throws Exception {
|
||||||
|
KeyPair rootKey = generateRsa();
|
||||||
|
KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:crl-generator");
|
||||||
|
try (PkiTestRuntime runtime = PkiTestRuntime.create(root, root.resolve("bus.log"),
|
||||||
|
Map.of(rootKeyRef, rootKey))) {
|
||||||
|
PkiId caId = createRoot(runtime, rootKeyRef, "CRL Generator Root");
|
||||||
|
Credential issuer = runtime.caService().getCa(caId).caCredentials().get(0);
|
||||||
|
StatusObjectGenerateCommand command = crlCommand(caId, issuer, rootKeyRef);
|
||||||
|
List<RevocationReason> reasons = activeReasons();
|
||||||
|
List<BigInteger> serials = serials(reasons.size());
|
||||||
|
List<CrlEntry> entries = new ArrayList<>();
|
||||||
|
for (int index = 0; index < reasons.size(); index++) {
|
||||||
|
entries.add(new CrlEntry(serials.get(index),
|
||||||
|
EVALUATION_TIME.minusSeconds(index + 1L).plusNanos(987_654_321L),
|
||||||
|
reasons.get(index)));
|
||||||
|
}
|
||||||
|
|
||||||
|
StatusObject status = runtime.framework().statusObjectGenerator().generate(command, entries);
|
||||||
|
X509CRLHolder crl = new X509CRLHolder(status.encoded().bytes());
|
||||||
|
for (CrlEntry entry : entries) {
|
||||||
|
X509CRLEntryHolder encoded = crl.getRevokedCertificate(entry.serialNumber());
|
||||||
|
assertTrue(encoded != null, () -> "missing serial " + entry.serialNumber().bitLength());
|
||||||
|
assertEquals(Date.from(entry.transitionTime().truncatedTo(ChronoUnit.SECONDS)),
|
||||||
|
encoded.getRevocationDate());
|
||||||
|
assertEquals(reasonCode(entry.reason()), encodedReason(encoded));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void serviceProjectsLatestHeldAndPermanentEntriesAndOmitsClear(@TempDir Path root) throws Exception {
|
||||||
|
KeyPair rootKey = generateRsa();
|
||||||
|
KeyPair heldKey = generateRsa();
|
||||||
|
KeyPair permanentKey = generateRsa();
|
||||||
|
KeyPair clearKey = generateRsa();
|
||||||
|
KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:crl-service");
|
||||||
|
try (PkiTestRuntime runtime = PkiTestRuntime.create(root, root.resolve("bus.log"),
|
||||||
|
Map.of(rootKeyRef, rootKey))) {
|
||||||
|
PkiId caId = createRoot(runtime, rootKeyRef, "CRL Service Root");
|
||||||
|
Credential held = issue(runtime, caId, heldKey, "Held");
|
||||||
|
Credential permanent = issue(runtime, caId, permanentKey, "Permanent");
|
||||||
|
Credential clear = issue(runtime, caId, clearKey, "Clear");
|
||||||
|
|
||||||
|
RevocationJournal heldJournal = runtime.revocationService().hold(
|
||||||
|
new RevocationCommand.Hold(held.credentialId(), emptyAttributes()));
|
||||||
|
RevocationJournal permanentJournal = runtime.revocationService().revokePermanently(
|
||||||
|
new RevocationCommand.RevokePermanently(permanent.credentialId(),
|
||||||
|
RevocationReason.AA_COMPROMISE, emptyAttributes()));
|
||||||
|
runtime.revocationService().hold(
|
||||||
|
new RevocationCommand.Hold(clear.credentialId(), emptyAttributes()));
|
||||||
|
runtime.revocationService().unhold(
|
||||||
|
new RevocationCommand.Unhold(clear.credentialId(), emptyAttributes()));
|
||||||
|
|
||||||
|
StatusObject status = runtime.statusObjectService().generate(new StatusObjectGenerateCommand(
|
||||||
|
caId, StatusObjectType.CRL, runtime.framework().formatId(), emptyAttributes()));
|
||||||
|
X509CRLHolder crl = new X509CRLHolder(status.encoded().bytes());
|
||||||
|
X509CertificateHolderView heldCertificate = certificate(held);
|
||||||
|
X509CertificateHolderView permanentCertificate = certificate(permanent);
|
||||||
|
X509CertificateHolderView clearCertificate = certificate(clear);
|
||||||
|
assertEntry(crl, heldCertificate.serial(), heldJournal.latest().time(),
|
||||||
|
RevocationReason.CERTIFICATE_HOLD);
|
||||||
|
assertEntry(crl, permanentCertificate.serial(), permanentJournal.latest().time(),
|
||||||
|
RevocationReason.AA_COMPROMISE);
|
||||||
|
assertNull(crl.getRevokedCertificate(clearCertificate.serial()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void malformedAuthoritativeInputsAbortBeforeSigningAndPersistence(@TempDir Path root) throws Exception {
|
||||||
|
KeyPair rootKey = generateRsa();
|
||||||
|
KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:crl-fail-closed");
|
||||||
|
try (PkiTestRuntime runtime = PkiTestRuntime.create(root, root.resolve("bus.log"),
|
||||||
|
Map.of(rootKeyRef, rootKey))) {
|
||||||
|
PkiId caId = createRoot(runtime, rootKeyRef, "CRL Failure Root");
|
||||||
|
Credential template = runtime.caService().getCa(caId).caCredentials().get(0);
|
||||||
|
StatusObjectGenerateCommand command = new StatusObjectGenerateCommand(caId, StatusObjectType.CRL,
|
||||||
|
runtime.framework().formatId(), emptyAttributes());
|
||||||
|
|
||||||
|
assertCrlFailure(runtime, command,
|
||||||
|
List.of(journal(new PkiId("credential:missing"), RevocationState.HELD,
|
||||||
|
EVALUATION_TIME.minusSeconds(1), Optional.empty())),
|
||||||
|
Map.of(), false);
|
||||||
|
|
||||||
|
Credential wrongFormat = copy(template, "wrong-format", new FormatId("not-x509"),
|
||||||
|
template.encoded());
|
||||||
|
assertCrlFailure(runtime, command,
|
||||||
|
List.of(journal(wrongFormat.credentialId(), RevocationState.HELD,
|
||||||
|
EVALUATION_TIME.minusSeconds(1), Optional.empty())),
|
||||||
|
Map.of(wrongFormat.credentialId(), wrongFormat), false);
|
||||||
|
|
||||||
|
Credential wrongEncoding = copy(template, "wrong-encoding",
|
||||||
|
BcX509CredentialFramework.FORMAT_ID,
|
||||||
|
new EncodedObject(Encoding.PEM, template.encoded().bytes()));
|
||||||
|
assertCrlFailure(runtime, command,
|
||||||
|
List.of(journal(wrongEncoding.credentialId(), RevocationState.HELD,
|
||||||
|
EVALUATION_TIME.minusSeconds(1), Optional.empty())),
|
||||||
|
Map.of(wrongEncoding.credentialId(), wrongEncoding), false);
|
||||||
|
|
||||||
|
Credential malformed = copy(template, "malformed", BcX509CredentialFramework.FORMAT_ID,
|
||||||
|
new EncodedObject(Encoding.DER, new byte[] { 1, 2, 3 }));
|
||||||
|
assertCrlFailure(runtime, command,
|
||||||
|
List.of(journal(malformed.credentialId(), RevocationState.HELD,
|
||||||
|
EVALUATION_TIME.minusSeconds(1), Optional.empty())),
|
||||||
|
Map.of(malformed.credentialId(), malformed), false);
|
||||||
|
|
||||||
|
Credential duplicateOne = copy(template, "duplicate-one", BcX509CredentialFramework.FORMAT_ID,
|
||||||
|
template.encoded());
|
||||||
|
Credential duplicateTwo = copy(template, "duplicate-two", BcX509CredentialFramework.FORMAT_ID,
|
||||||
|
template.encoded());
|
||||||
|
assertCrlFailure(runtime, command, List.of(
|
||||||
|
journal(duplicateOne.credentialId(), RevocationState.HELD,
|
||||||
|
EVALUATION_TIME.minusSeconds(1), Optional.empty()),
|
||||||
|
journal(duplicateTwo.credentialId(), RevocationState.PERMANENTLY_REVOKED,
|
||||||
|
EVALUATION_TIME.minusSeconds(1), Optional.of(RevocationReason.KEY_COMPROMISE))),
|
||||||
|
Map.of(duplicateOne.credentialId(), duplicateOne,
|
||||||
|
duplicateTwo.credentialId(), duplicateTwo), false);
|
||||||
|
|
||||||
|
Credential future = copy(template, "future", BcX509CredentialFramework.FORMAT_ID,
|
||||||
|
template.encoded());
|
||||||
|
assertCrlFailure(runtime, command,
|
||||||
|
List.of(journal(future.credentialId(), RevocationState.HELD,
|
||||||
|
EVALUATION_TIME.plusSeconds(1), Optional.empty())),
|
||||||
|
Map.of(future.credentialId(), future), false);
|
||||||
|
|
||||||
|
assertCrlFailure(runtime, command, List.of(), Map.of(), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void generatorAndPersistenceFailuresExposeNoPartialCrlOrHostileDiagnostics(@TempDir Path root)
|
||||||
|
throws Exception {
|
||||||
|
KeyPair rootKey = generateRsa();
|
||||||
|
KeyRef rootKeyRef = new KeyRef("kref:v1:keyring:test:crl-boundary");
|
||||||
|
try (PkiTestRuntime runtime = PkiTestRuntime.create(root, root.resolve("bus.log"),
|
||||||
|
Map.of(rootKeyRef, rootKey))) {
|
||||||
|
PkiId caId = createRoot(runtime, rootKeyRef, "CRL Boundary Root");
|
||||||
|
StatusObjectGenerateCommand command = new StatusObjectGenerateCommand(caId, StatusObjectType.CRL,
|
||||||
|
runtime.framework().formatId(), emptyAttributes());
|
||||||
|
int signCount = runtime.submittedSignCount();
|
||||||
|
int statusCount = runtime.store().listStatusObjects(caId).size();
|
||||||
|
|
||||||
|
AtomicInteger generatorCalls = new AtomicInteger();
|
||||||
|
StatusObjectGenerator hostileGenerator = (ignoredCommand, ignoredEntries) -> {
|
||||||
|
generatorCalls.incrementAndGet();
|
||||||
|
throw new IllegalStateException(SENTINEL);
|
||||||
|
};
|
||||||
|
DefaultStatusObjectService generatorFailureService = new DefaultStatusObjectService(
|
||||||
|
runtime.store(), frameworkView(runtime.framework(), hostileGenerator),
|
||||||
|
runtime.auditSink(), usableResolver());
|
||||||
|
AtomicReference<StatusObject> generatorResult = new AtomicReference<>();
|
||||||
|
PkiException generatorFailure = assertThrows(PkiException.class,
|
||||||
|
() -> generatorResult.set(generatorFailureService.generate(command)));
|
||||||
|
assertBoundaryFailure(generatorFailure);
|
||||||
|
assertNull(generatorResult.get());
|
||||||
|
assertEquals(1, generatorCalls.get());
|
||||||
|
assertEquals(statusCount, runtime.store().listStatusObjects(caId).size());
|
||||||
|
assertEquals(signCount, runtime.submittedSignCount());
|
||||||
|
|
||||||
|
AtomicInteger persistenceCalls = new AtomicInteger();
|
||||||
|
StatusObject generated = new StatusObject(new PkiId("status:controlled-crl"),
|
||||||
|
command.formatId(), caId, StatusObjectType.CRL, EVALUATION_TIME,
|
||||||
|
Optional.empty(), new EncodedObject(Encoding.DER, new byte[] { 1 }),
|
||||||
|
emptyAttributes());
|
||||||
|
StatusObjectGenerator controlledGenerator = (ignoredCommand, ignoredEntries) -> generated;
|
||||||
|
DefaultStatusObjectService persistenceFailureService = new DefaultStatusObjectService(
|
||||||
|
failingPersistenceStore(runtime.store(), persistenceCalls),
|
||||||
|
frameworkView(runtime.framework(), controlledGenerator),
|
||||||
|
runtime.auditSink(), usableResolver());
|
||||||
|
AtomicReference<StatusObject> persistenceResult = new AtomicReference<>();
|
||||||
|
PkiException persistenceFailure = assertThrows(PkiException.class,
|
||||||
|
() -> persistenceResult.set(persistenceFailureService.generate(command)));
|
||||||
|
assertBoundaryFailure(persistenceFailure);
|
||||||
|
assertNull(persistenceResult.get());
|
||||||
|
assertEquals(1, persistenceCalls.get());
|
||||||
|
assertEquals(statusCount, runtime.store().listStatusObjects(caId).size());
|
||||||
|
assertEquals(signCount, runtime.submittedSignCount());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertCrlFailure(PkiTestRuntime runtime, StatusObjectGenerateCommand command,
|
||||||
|
List<RevocationJournal> journals, Map<PkiId, Credential> credentials, boolean failListing) {
|
||||||
|
int signCount = runtime.submittedSignCount();
|
||||||
|
int statusCount = runtime.store().listStatusObjects(command.issuerCaId()).size();
|
||||||
|
PkiStore view = storeView(runtime.store(), journals, credentials, failListing);
|
||||||
|
DefaultStatusObjectService service = new DefaultStatusObjectService(view, runtime.framework(),
|
||||||
|
runtime.auditSink(), usableResolver());
|
||||||
|
|
||||||
|
PkiException failure = assertThrows(PkiException.class, () -> service.generate(command));
|
||||||
|
assertTrue(failure.getMessage().contains("code=CRL_GENERATION_FAILED"));
|
||||||
|
assertFalse(failure.getMessage().contains(SENTINEL));
|
||||||
|
assertNull(failure.getCause());
|
||||||
|
assertEquals(signCount, runtime.submittedSignCount());
|
||||||
|
assertEquals(statusCount, runtime.store().listStatusObjects(command.issuerCaId()).size());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PkiStore storeView(PkiStore delegate, List<RevocationJournal> journals,
|
||||||
|
Map<PkiId, Credential> credentials, boolean failListing) {
|
||||||
|
return (PkiStore) Proxy.newProxyInstance(PkiStore.class.getClassLoader(), new Class<?>[] { PkiStore.class },
|
||||||
|
(proxy, method, arguments) -> {
|
||||||
|
if (method.getName().equals("listRevocationJournals")) {
|
||||||
|
if (failListing) {
|
||||||
|
throw new IllegalStateException(SENTINEL);
|
||||||
|
}
|
||||||
|
return journals;
|
||||||
|
}
|
||||||
|
if (method.getName().equals("getCredential")) {
|
||||||
|
PkiId id = (PkiId) arguments[0];
|
||||||
|
if (credentials.containsKey(id)) {
|
||||||
|
return Optional.of(credentials.get(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return method.invoke(delegate, arguments);
|
||||||
|
} catch (InvocationTargetException exception) {
|
||||||
|
throw exception.getCause();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PkiStore failingPersistenceStore(PkiStore delegate, AtomicInteger persistenceCalls) {
|
||||||
|
return (PkiStore) Proxy.newProxyInstance(PkiStore.class.getClassLoader(), new Class<?>[] { PkiStore.class },
|
||||||
|
(proxy, method, arguments) -> {
|
||||||
|
if (method.getName().equals("putStatusObject")) {
|
||||||
|
persistenceCalls.incrementAndGet();
|
||||||
|
throw new IllegalStateException(SENTINEL);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return method.invoke(delegate, arguments);
|
||||||
|
} catch (InvocationTargetException exception) {
|
||||||
|
throw exception.getCause();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CredentialFramework frameworkView(CredentialFramework delegate,
|
||||||
|
StatusObjectGenerator generator) {
|
||||||
|
return (CredentialFramework) Proxy.newProxyInstance(CredentialFramework.class.getClassLoader(),
|
||||||
|
new Class<?>[] { CredentialFramework.class }, (proxy, method, arguments) -> {
|
||||||
|
if (method.getName().equals("statusObjectGenerator")) {
|
||||||
|
return generator;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return method.invoke(delegate, arguments);
|
||||||
|
} catch (InvocationTargetException exception) {
|
||||||
|
throw exception.getCause();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertBoundaryFailure(PkiException failure) {
|
||||||
|
assertTrue(failure.getMessage().contains("code=CRL_GENERATION_FAILED"));
|
||||||
|
assertFalse(failure.getMessage().contains(SENTINEL));
|
||||||
|
assertNull(failure.getCause());
|
||||||
|
assertEquals(0, failure.getSuppressed().length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static EffectiveCredentialStatusResolver usableResolver() {
|
||||||
|
return () -> new EffectiveCredentialStatusResolver.Evaluation() {
|
||||||
|
@Override
|
||||||
|
public Instant evaluationTime() {
|
||||||
|
return EVALUATION_TIME;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public EffectiveCredentialStatus resolve(Credential credential) {
|
||||||
|
return EffectiveCredentialStatus.USABLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Credential requireUsable(Credential credential, CredentialUse use) {
|
||||||
|
return credential;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RevocationJournal journal(PkiId credentialId, RevocationState state, Instant time,
|
||||||
|
Optional<RevocationReason> reason) {
|
||||||
|
return new RevocationJournal(credentialId, List.of(new RevocationTransition(
|
||||||
|
1L, state, time, reason, emptyAttributes())));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Credential copy(Credential template, String suffix, FormatId formatId, EncodedObject encoded) {
|
||||||
|
return new Credential(new PkiId("credential:" + suffix), formatId, template.issuerRef(),
|
||||||
|
template.subjectRef(), template.validity(), template.serialOrUniqueId(),
|
||||||
|
template.publicKeyId(), template.profileId(), CredentialStatus.ISSUED, encoded,
|
||||||
|
template.attributes());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StatusObjectGenerateCommand crlCommand(PkiId caId, Credential issuer, KeyRef keyRef) {
|
||||||
|
AttributeSet attributes = SimpleAttributeSet.builder()
|
||||||
|
.put(BcX509Attributes.ISSUER_CERT_DER,
|
||||||
|
new AttributeValue.BytesValue(issuer.encoded().bytes()))
|
||||||
|
.put(BcX509Attributes.ISSUER_KEYREF, new AttributeValue.StringValue(keyRef.value()))
|
||||||
|
.build();
|
||||||
|
return new StatusObjectGenerateCommand(caId, StatusObjectType.CRL,
|
||||||
|
BcX509CredentialFramework.FORMAT_ID, attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Credential issue(PkiTestRuntime runtime, PkiId caId, KeyPair subjectKey, String commonName)
|
||||||
|
throws Exception {
|
||||||
|
PKCS10CertificationRequest request = certificationRequest(subjectKey, commonName);
|
||||||
|
ParsedCertificationRequest parsed = runtime.certificationRequestService().parse(
|
||||||
|
new CertificationRequest(runtime.framework().formatId(),
|
||||||
|
new EncodedObject(Encoding.DER, request.getEncoded())));
|
||||||
|
runtime.certificationRequestService().verifyProofOfPossession(parsed,
|
||||||
|
new VerificationPolicy(true, Optional.empty()));
|
||||||
|
IssuanceService issuance = runtime.issuanceService();
|
||||||
|
return issuance.issueEndEntity(new IssueEndEntityCommand(caId, parsed, "default",
|
||||||
|
Optional.of(new Validity(EVALUATION_TIME.minus(Duration.ofDays(1)),
|
||||||
|
EVALUATION_TIME.plus(Duration.ofDays(365)))),
|
||||||
|
emptyAttributes())).credential();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PKCS10CertificationRequest certificationRequest(KeyPair pair, String commonName)
|
||||||
|
throws Exception {
|
||||||
|
X500Name subject = new X500Name("CN=" + commonName);
|
||||||
|
PKCS10CertificationRequestBuilder builder =
|
||||||
|
new JcaPKCS10CertificationRequestBuilder(subject, pair.getPublic());
|
||||||
|
ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(pair.getPrivate());
|
||||||
|
return builder.build(signer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PkiId createRoot(PkiTestRuntime runtime, KeyRef keyRef, String commonName) {
|
||||||
|
return runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(),
|
||||||
|
new SubjectRef("CN=" + commonName), "default", Optional.of(keyRef), emptyAttributes()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static KeyPair generateRsa() throws Exception {
|
||||||
|
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||||
|
generator.initialize(2048);
|
||||||
|
return generator.generateKeyPair();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<RevocationReason> activeReasons() {
|
||||||
|
return Arrays.stream(RevocationReason.values())
|
||||||
|
.filter(reason -> reason != RevocationReason.REMOVE_FROM_CRL)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<BigInteger> serials(int size) {
|
||||||
|
List<BigInteger> values = new ArrayList<>(List.of(
|
||||||
|
BigInteger.ONE,
|
||||||
|
BigInteger.valueOf(Long.MAX_VALUE),
|
||||||
|
BigInteger.valueOf(Long.MAX_VALUE).add(BigInteger.ONE),
|
||||||
|
BigInteger.ONE.shiftLeft(159).subtract(BigInteger.ONE)));
|
||||||
|
for (long value = 2L; values.size() < size; value++) {
|
||||||
|
values.add(BigInteger.valueOf(value));
|
||||||
|
}
|
||||||
|
return List.copyOf(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int encodedReason(X509CRLEntryHolder entry) {
|
||||||
|
org.bouncycastle.asn1.x509.Extension extension = entry.getExtension(Extension.reasonCode);
|
||||||
|
if (extension == null) {
|
||||||
|
return CRLReason.unspecified;
|
||||||
|
}
|
||||||
|
return CRLReason.getInstance(extension.getParsedValue()).getValue().intValueExact();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int reasonCode(RevocationReason reason) {
|
||||||
|
return switch (reason) {
|
||||||
|
case UNSPECIFIED -> CRLReason.unspecified;
|
||||||
|
case KEY_COMPROMISE -> CRLReason.keyCompromise;
|
||||||
|
case CA_COMPROMISE -> CRLReason.cACompromise;
|
||||||
|
case AFFILIATION_CHANGED -> CRLReason.affiliationChanged;
|
||||||
|
case SUPERSEDED -> CRLReason.superseded;
|
||||||
|
case CESSATION_OF_OPERATION -> CRLReason.cessationOfOperation;
|
||||||
|
case CERTIFICATE_HOLD -> CRLReason.certificateHold;
|
||||||
|
case REMOVE_FROM_CRL -> throw new IllegalArgumentException("inactive reason");
|
||||||
|
case PRIVILEGE_WITHDRAWN -> CRLReason.privilegeWithdrawn;
|
||||||
|
case AA_COMPROMISE -> CRLReason.aACompromise;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertEntry(X509CRLHolder crl, BigInteger serial, Instant time,
|
||||||
|
RevocationReason reason) {
|
||||||
|
X509CRLEntryHolder entry = crl.getRevokedCertificate(serial);
|
||||||
|
assertTrue(entry != null);
|
||||||
|
assertEquals(Date.from(time.truncatedTo(ChronoUnit.SECONDS)), entry.getRevocationDate());
|
||||||
|
assertEquals(reasonCode(reason), encodedReason(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static X509CertificateHolderView certificate(Credential credential) throws Exception {
|
||||||
|
byte[] encoded = credential.encoded().bytes();
|
||||||
|
try {
|
||||||
|
return new X509CertificateHolderView(
|
||||||
|
new org.bouncycastle.cert.X509CertificateHolder(encoded).getSerialNumber());
|
||||||
|
} finally {
|
||||||
|
Arrays.fill(encoded, (byte) 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SimpleAttributeSet emptyAttributes() {
|
||||||
|
return new SimpleAttributeSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
private record X509CertificateHolderView(BigInteger serial) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* 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 static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
|
||||||
|
import java.lang.reflect.Proxy;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import zeroecho.pki.api.EncodedObject;
|
||||||
|
import zeroecho.pki.api.Encoding;
|
||||||
|
import zeroecho.pki.api.FormatId;
|
||||||
|
import zeroecho.pki.api.IssuerRef;
|
||||||
|
import zeroecho.pki.api.PkiException;
|
||||||
|
import zeroecho.pki.api.PkiId;
|
||||||
|
import zeroecho.pki.api.SubjectRef;
|
||||||
|
import zeroecho.pki.api.Validity;
|
||||||
|
import zeroecho.pki.api.audit.AuditEvent;
|
||||||
|
import zeroecho.pki.api.credential.Credential;
|
||||||
|
import zeroecho.pki.api.credential.CredentialStatus;
|
||||||
|
import zeroecho.pki.api.credential.CredentialUse;
|
||||||
|
import zeroecho.pki.api.credential.EffectiveCredentialStatus;
|
||||||
|
import zeroecho.pki.api.credential.EffectiveCredentialStatusResolver;
|
||||||
|
import zeroecho.pki.api.revocation.RevocationJournal;
|
||||||
|
import zeroecho.pki.api.revocation.RevocationReason;
|
||||||
|
import zeroecho.pki.api.revocation.RevocationState;
|
||||||
|
import zeroecho.pki.api.revocation.RevocationTransition;
|
||||||
|
import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
|
||||||
|
import zeroecho.pki.spi.store.PkiStore;
|
||||||
|
|
||||||
|
final class StoreBackedEffectiveCredentialStatusResolverTest {
|
||||||
|
private static final Instant NOW = Instant.parse("2026-06-01T12:00:00Z");
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resolvesInventoryValidityAndCurrentRevocationPrecedence() {
|
||||||
|
Credential usable = credential("usable", CredentialStatus.ISSUED, NOW.minusSeconds(60), NOW.plusSeconds(60));
|
||||||
|
assertStatus(usable, Optional.empty(), EffectiveCredentialStatus.USABLE);
|
||||||
|
|
||||||
|
Credential future = credential("future", CredentialStatus.ISSUED, NOW.plusSeconds(1), NOW.plusSeconds(60));
|
||||||
|
assertStatus(future, Optional.empty(), EffectiveCredentialStatus.NOT_YET_VALID);
|
||||||
|
|
||||||
|
Credential exactNotAfter = credential("boundary", CredentialStatus.ISSUED, NOW.minusSeconds(60), NOW);
|
||||||
|
assertStatus(exactNotAfter, Optional.empty(), EffectiveCredentialStatus.USABLE);
|
||||||
|
|
||||||
|
Credential expiredByTime = credential("expired-time", CredentialStatus.ISSUED, NOW.minusSeconds(60),
|
||||||
|
NOW.minusNanos(1));
|
||||||
|
assertStatus(expiredByTime, Optional.empty(), EffectiveCredentialStatus.EXPIRED);
|
||||||
|
|
||||||
|
Credential expiredMetadata = credential("expired-metadata", CredentialStatus.EXPIRED, NOW.minusSeconds(60),
|
||||||
|
NOW.plusSeconds(60));
|
||||||
|
assertStatus(expiredMetadata, Optional.empty(), EffectiveCredentialStatus.EXPIRED);
|
||||||
|
|
||||||
|
Credential held = credential("held", CredentialStatus.ISSUED, NOW.minusSeconds(60), NOW.plusSeconds(60));
|
||||||
|
assertStatus(held, revocation(held, RevocationReason.CERTIFICATE_HOLD, NOW),
|
||||||
|
EffectiveCredentialStatus.HELD);
|
||||||
|
|
||||||
|
Credential revoked = credential("revoked", CredentialStatus.ISSUED, NOW.minusSeconds(60),
|
||||||
|
NOW.plusSeconds(60));
|
||||||
|
assertStatus(revoked, revocation(revoked, RevocationReason.KEY_COMPROMISE, NOW),
|
||||||
|
EffectiveCredentialStatus.PERMANENTLY_REVOKED);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void embeddedRevokedIsPermanentFloorAcrossHoldAndUnholdRecords() {
|
||||||
|
Credential revoked = credential("floor", CredentialStatus.REVOKED, NOW.minusSeconds(60),
|
||||||
|
NOW.plusSeconds(60));
|
||||||
|
assertStatus(revoked, Optional.empty(), EffectiveCredentialStatus.PERMANENTLY_REVOKED);
|
||||||
|
assertStatus(revoked, revocation(revoked, RevocationReason.CERTIFICATE_HOLD, NOW),
|
||||||
|
EffectiveCredentialStatus.PERMANENTLY_REVOKED);
|
||||||
|
assertStatus(revoked, revocation(revoked, RevocationReason.REMOVE_FROM_CRL, NOW),
|
||||||
|
EffectiveCredentialStatus.PERMANENTLY_REVOKED);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void futureAndMismatchedJournalsFailClosed() {
|
||||||
|
Credential credential = credential("invalid", CredentialStatus.ISSUED, NOW.minusSeconds(60),
|
||||||
|
NOW.plusSeconds(60));
|
||||||
|
assertResolutionFailure(credential,
|
||||||
|
revocation(credential, RevocationReason.KEY_COMPROMISE, NOW.plusNanos(1)));
|
||||||
|
RevocationJournal mismatch = new RevocationJournal(new PkiId("credential:other"),
|
||||||
|
List.of(new RevocationTransition(1L, RevocationState.PERMANENTLY_REVOKED, NOW,
|
||||||
|
Optional.of(RevocationReason.KEY_COMPROMISE), new SimpleAttributeSet())));
|
||||||
|
assertResolutionFailure(credential, Optional.of(mismatch));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void lookupFailureIsRedactedAndRequireUsableUsesStableCode() {
|
||||||
|
Credential credential = credential("failure", CredentialStatus.ISSUED, NOW.minusSeconds(60),
|
||||||
|
NOW.plusSeconds(60));
|
||||||
|
PkiStore failing = store(id -> {
|
||||||
|
throw new IllegalStateException("DO_NOT_EXPOSE_REVOCATION_SENTINEL");
|
||||||
|
}, new AtomicInteger());
|
||||||
|
StoreBackedEffectiveCredentialStatusResolver resolver =
|
||||||
|
new StoreBackedEffectiveCredentialStatusResolver(failing, Clock.fixed(NOW, ZoneOffset.UTC));
|
||||||
|
|
||||||
|
PkiException failure = assertThrows(PkiException.class,
|
||||||
|
() -> resolver.beginEvaluation().resolve(credential));
|
||||||
|
assertEquals("Credential status resolution failed: code=CREDENTIAL_STATUS_RESOLUTION_FAILED",
|
||||||
|
failure.getMessage());
|
||||||
|
assertFalse(messages(failure).contains("DO_NOT_EXPOSE_REVOCATION_SENTINEL"));
|
||||||
|
|
||||||
|
Credential held = credential("not-usable", CredentialStatus.ISSUED, NOW.minusSeconds(60),
|
||||||
|
NOW.plusSeconds(60));
|
||||||
|
EffectiveCredentialStatusResolver.Evaluation evaluation = resolver(
|
||||||
|
revocation(held, RevocationReason.CERTIFICATE_HOLD, NOW)).beginEvaluation();
|
||||||
|
PkiException rejected = assertThrows(PkiException.class,
|
||||||
|
() -> evaluation.requireUsable(held, CredentialUse.BUNDLE_DELIVERY));
|
||||||
|
assertEquals("Credential trust rejected: code=CREDENTIAL_NOT_USABLE", rejected.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void oneEvaluationCapturesClockOnceAndLooksUpEachCredentialOnce() {
|
||||||
|
CountingClock clock = new CountingClock(NOW);
|
||||||
|
AtomicInteger lookups = new AtomicInteger();
|
||||||
|
StoreBackedEffectiveCredentialStatusResolver resolver =
|
||||||
|
new StoreBackedEffectiveCredentialStatusResolver(store(id -> Optional.empty(), lookups), clock);
|
||||||
|
EffectiveCredentialStatusResolver.Evaluation evaluation = resolver.beginEvaluation();
|
||||||
|
Credential first = credential("first", CredentialStatus.ISSUED, NOW.minusSeconds(60), NOW.plusSeconds(60));
|
||||||
|
Credential second = credential("second", CredentialStatus.ISSUED, NOW.minusSeconds(60), NOW.plusSeconds(60));
|
||||||
|
|
||||||
|
assertEquals(EffectiveCredentialStatus.USABLE, evaluation.resolve(first));
|
||||||
|
assertSame(second, evaluation.requireUsable(second, CredentialUse.END_ENTITY_ISSUER));
|
||||||
|
assertEquals(NOW, evaluation.evaluationTime());
|
||||||
|
assertEquals(1, clock.reads());
|
||||||
|
assertEquals(2, lookups.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void trustAuditOmitsFormatAndSensitiveMetadata() {
|
||||||
|
String sentinel = "DO_NOT_AUDIT_FORMAT_SENTINEL";
|
||||||
|
Credential credential = new Credential(new PkiId("credential:audit"), new FormatId(sentinel),
|
||||||
|
new IssuerRef(new PkiId("ca:issuer")), new SubjectRef("CN=Audit"),
|
||||||
|
new Validity(NOW.minusSeconds(60), NOW.plusSeconds(60)), "audit", new PkiId("key:audit"),
|
||||||
|
"default", CredentialStatus.ISSUED, new EncodedObject(Encoding.DER, new byte[] { 1 }),
|
||||||
|
new SimpleAttributeSet());
|
||||||
|
AtomicReference<AuditEvent> recorded = new AtomicReference<>();
|
||||||
|
|
||||||
|
CredentialTrustAudit.rejected(recorded::set, NOW, credential, CredentialUse.END_ENTITY_ISSUER,
|
||||||
|
"ISSUER_CREDENTIAL_UNAVAILABLE", EffectiveCredentialStatus.HELD);
|
||||||
|
|
||||||
|
AuditEvent event = recorded.get();
|
||||||
|
assertEquals(Optional.empty(), event.formatId());
|
||||||
|
assertFalse(event.toString().contains(sentinel));
|
||||||
|
assertFalse(event.details().toString().contains(sentinel));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertStatus(Credential credential, Optional<RevocationJournal> revocation,
|
||||||
|
EffectiveCredentialStatus expected) {
|
||||||
|
assertEquals(expected, resolver(revocation).beginEvaluation().resolve(credential));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertResolutionFailure(Credential credential, Optional<RevocationJournal> revocation) {
|
||||||
|
PkiException failure = assertThrows(PkiException.class,
|
||||||
|
() -> resolver(revocation).beginEvaluation().resolve(credential));
|
||||||
|
assertEquals("Credential status resolution failed: code=CREDENTIAL_STATUS_RESOLUTION_FAILED",
|
||||||
|
failure.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StoreBackedEffectiveCredentialStatusResolver resolver(Optional<RevocationJournal> revocation) {
|
||||||
|
return new StoreBackedEffectiveCredentialStatusResolver(store(id -> revocation, new AtomicInteger()),
|
||||||
|
Clock.fixed(NOW, ZoneOffset.UTC));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PkiStore store(Function<PkiId, Optional<RevocationJournal>> lookup, AtomicInteger calls) {
|
||||||
|
return (PkiStore) Proxy.newProxyInstance(PkiStore.class.getClassLoader(), new Class<?>[] { PkiStore.class },
|
||||||
|
(proxy, method, arguments) -> {
|
||||||
|
if ("getRevocationJournal".equals(method.getName())) {
|
||||||
|
calls.incrementAndGet();
|
||||||
|
return lookup.apply((PkiId) arguments[0]);
|
||||||
|
}
|
||||||
|
throw new UnsupportedOperationException(method.getName());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Optional<RevocationJournal> revocation(Credential credential, RevocationReason reason,
|
||||||
|
Instant time) {
|
||||||
|
if (reason == RevocationReason.REMOVE_FROM_CRL) {
|
||||||
|
return Optional.of(new RevocationJournal(credential.credentialId(), List.of(
|
||||||
|
new RevocationTransition(1L, RevocationState.HELD, time.minusNanos(1), Optional.empty(),
|
||||||
|
new SimpleAttributeSet()),
|
||||||
|
new RevocationTransition(2L, RevocationState.CLEAR, time, Optional.empty(),
|
||||||
|
new SimpleAttributeSet()))));
|
||||||
|
}
|
||||||
|
RevocationState state = reason == RevocationReason.CERTIFICATE_HOLD
|
||||||
|
? RevocationState.HELD : RevocationState.PERMANENTLY_REVOKED;
|
||||||
|
Optional<RevocationReason> permanentReason = state == RevocationState.PERMANENTLY_REVOKED
|
||||||
|
? Optional.of(reason) : Optional.empty();
|
||||||
|
return Optional.of(new RevocationJournal(credential.credentialId(),
|
||||||
|
List.of(new RevocationTransition(1L, state, time, permanentReason, new SimpleAttributeSet()))));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Credential credential(String suffix, CredentialStatus status, Instant notBefore, Instant notAfter) {
|
||||||
|
return new Credential(new PkiId("credential:" + suffix), new FormatId("x509"),
|
||||||
|
new IssuerRef(new PkiId("ca:issuer")), new SubjectRef("CN=" + suffix),
|
||||||
|
new Validity(notBefore, notAfter), suffix, new PkiId("key:" + suffix), "default", status,
|
||||||
|
new EncodedObject(Encoding.DER, new byte[] { 1, 2, 3 }), new SimpleAttributeSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String messages(Throwable throwable) {
|
||||||
|
StringBuilder messages = new StringBuilder();
|
||||||
|
Throwable current = throwable;
|
||||||
|
while (current != null) {
|
||||||
|
if (current.getMessage() != null) {
|
||||||
|
messages.append(current.getMessage());
|
||||||
|
}
|
||||||
|
current = current.getCause();
|
||||||
|
}
|
||||||
|
return messages.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class CountingClock extends Clock {
|
||||||
|
private final Instant instant;
|
||||||
|
private final AtomicInteger reads;
|
||||||
|
|
||||||
|
private CountingClock(Instant instant) {
|
||||||
|
this.instant = instant;
|
||||||
|
this.reads = new AtomicInteger();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ZoneId getZone() {
|
||||||
|
return ZoneOffset.UTC;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Clock withZone(ZoneId zone) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Instant instant() {
|
||||||
|
reads.incrementAndGet();
|
||||||
|
return instant;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int reads() {
|
||||||
|
return reads.get();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,8 +78,9 @@ import zeroecho.pki.api.publication.PublicationStatus;
|
|||||||
import zeroecho.pki.api.publication.PublicationTarget;
|
import zeroecho.pki.api.publication.PublicationTarget;
|
||||||
import zeroecho.pki.api.publication.PublicationTargetType;
|
import zeroecho.pki.api.publication.PublicationTargetType;
|
||||||
import zeroecho.pki.api.request.ParsedCertificationRequest;
|
import zeroecho.pki.api.request.ParsedCertificationRequest;
|
||||||
|
import zeroecho.pki.api.revocation.RevocationCommand;
|
||||||
|
import zeroecho.pki.api.revocation.RevocationJournal;
|
||||||
import zeroecho.pki.api.revocation.RevocationReason;
|
import zeroecho.pki.api.revocation.RevocationReason;
|
||||||
import zeroecho.pki.api.revocation.RevokedRecord;
|
|
||||||
import zeroecho.pki.api.status.StatusObject;
|
import zeroecho.pki.api.status.StatusObject;
|
||||||
import zeroecho.pki.api.status.StatusObjectType;
|
import zeroecho.pki.api.status.StatusObjectType;
|
||||||
import zeroecho.pki.api.orch.OrchestrationDurabilityPolicy;
|
import zeroecho.pki.api.orch.OrchestrationDurabilityPolicy;
|
||||||
@@ -120,8 +121,6 @@ public final class FilesystemPkiStoreTest {
|
|||||||
new FormatId("fmt-x509"), new SubjectRef("CN=request-all"),
|
new FormatId("fmt-x509"), new SubjectRef("CN=request-all"),
|
||||||
new EncodedObject(Encoding.DER, new byte[] { 4, 5, 6 }), Optional.empty(),
|
new EncodedObject(Encoding.DER, new byte[] { 4, 5, 6 }), Optional.empty(),
|
||||||
Optional.of("profile-all"), attributes);
|
Optional.of("profile-all"), attributes);
|
||||||
RevokedRecord revocation = TestObjects.minimalRevocation(credential.credentialId().value(), now,
|
|
||||||
RevocationReason.KEY_COMPROMISE);
|
|
||||||
StatusObject status = new StatusObject(new PkiId("status-all"), new FormatId("fmt-x509"), ca.caId(),
|
StatusObject status = new StatusObject(new PkiId("status-all"), new FormatId("fmt-x509"), ca.caId(),
|
||||||
StatusObjectType.CRL, now, Optional.of(now.plusSeconds(60L)),
|
StatusObjectType.CRL, now, Optional.of(now.plusSeconds(60L)),
|
||||||
new EncodedObject(Encoding.DER, new byte[] { 7, 8 }), attributes);
|
new EncodedObject(Encoding.DER, new byte[] { 7, 8 }), attributes);
|
||||||
@@ -140,7 +139,8 @@ public final class FilesystemPkiStoreTest {
|
|||||||
store.putCa(ca);
|
store.putCa(ca);
|
||||||
store.putCredential(credential);
|
store.putCredential(credential);
|
||||||
store.putRequest(request);
|
store.putRequest(request);
|
||||||
store.putRevocation(revocation);
|
RevocationJournal revocation = store.transitionRevocation(new RevocationCommand.RevokePermanently(
|
||||||
|
credential.credentialId(), RevocationReason.KEY_COMPROMISE, attributes), now);
|
||||||
store.putStatusObject(status);
|
store.putStatusObject(status);
|
||||||
store.putPublicationRecord(publication);
|
store.putPublicationRecord(publication);
|
||||||
store.putProfile(profile);
|
store.putProfile(profile);
|
||||||
@@ -152,7 +152,7 @@ public final class FilesystemPkiStoreTest {
|
|||||||
store.getCredential(credential.credentialId()).orElseThrow().credentialId());
|
store.getCredential(credential.credentialId()).orElseThrow().credentialId());
|
||||||
assertEquals(request.requestId(), store.getRequest(request.requestId()).orElseThrow().requestId());
|
assertEquals(request.requestId(), store.getRequest(request.requestId()).orElseThrow().requestId());
|
||||||
assertEquals(revocation.credentialId(),
|
assertEquals(revocation.credentialId(),
|
||||||
store.getRevocation(revocation.credentialId()).orElseThrow().credentialId());
|
store.getRevocationJournal(revocation.credentialId()).orElseThrow().credentialId());
|
||||||
assertEquals(status.statusObjectId(),
|
assertEquals(status.statusObjectId(),
|
||||||
store.getStatusObject(status.statusObjectId()).orElseThrow().statusObjectId());
|
store.getStatusObject(status.statusObjectId()).orElseThrow().statusObjectId());
|
||||||
assertEquals(publication.publicationId(), store.listPublicationRecords().get(0).publicationId());
|
assertEquals(publication.publicationId(), store.listPublicationRecords().get(0).publicationId());
|
||||||
@@ -213,30 +213,29 @@ public final class FilesystemPkiStoreTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void revocationHistorySupportsOverwriteWithTrail() throws Exception {
|
void revocationJournalPersistsLegalTransitions() throws Exception {
|
||||||
System.out.println("revocationHistorySupportsOverwriteWithTrail");
|
System.out.println("revocationJournalPersistsLegalTransitions");
|
||||||
|
|
||||||
Path root = tmp.resolve("store-revocation-history");
|
Path root = tmp.resolve("store-revocation-history");
|
||||||
FsPkiStoreOptions options = FsPkiStoreOptions.defaults();
|
FsPkiStoreOptions options = FsPkiStoreOptions.defaults();
|
||||||
|
|
||||||
try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) {
|
try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) {
|
||||||
RevokedRecord r1 = TestObjects.minimalRevocation("cred-rev-1", Instant.EPOCH.plusSeconds(10L),
|
Credential credential = TestObjects.minimalCredential("SERIAL-REV", "profile-rev");
|
||||||
RevocationReason.KEY_COMPROMISE);
|
store.putCredential(credential);
|
||||||
store.putRevocation(r1);
|
store.transitionRevocation(new RevocationCommand.Hold(credential.credentialId(),
|
||||||
|
TestObjects.emptyAttributes()), Instant.EPOCH.plusSeconds(10L));
|
||||||
|
store.transitionRevocation(new RevocationCommand.Unhold(credential.credentialId(),
|
||||||
|
TestObjects.emptyAttributes()), Instant.EPOCH.plusSeconds(11L));
|
||||||
|
|
||||||
RevokedRecord r2 = new RevokedRecord(r1.credentialId(), r1.revocationTime().plusSeconds(1L), r1.reason(),
|
Optional<RevocationJournal> loaded = store.getRevocationJournal(credential.credentialId());
|
||||||
r1.attributes());
|
|
||||||
store.putRevocation(r2);
|
|
||||||
|
|
||||||
Optional<RevokedRecord> loaded = store.getRevocation(r1.credentialId());
|
|
||||||
assertTrue(loaded.isPresent());
|
assertTrue(loaded.isPresent());
|
||||||
assertEquals(r2.revocationTime(), loaded.get().revocationTime());
|
assertEquals(2L, loaded.get().latest().revision());
|
||||||
}
|
}
|
||||||
|
|
||||||
System.out.println("...store tree:");
|
System.out.println("...store tree:");
|
||||||
dumpTree(root);
|
dumpTree(root);
|
||||||
|
|
||||||
System.out.println("revocationHistorySupportsOverwriteWithTrail...ok");
|
System.out.println("revocationJournalPersistsLegalTransitions...ok");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -563,12 +562,6 @@ public final class FilesystemPkiStoreTest {
|
|||||||
profileId, status, encoded, attrs);
|
profileId, status, encoded, attrs);
|
||||||
}
|
}
|
||||||
|
|
||||||
static RevokedRecord minimalRevocation(String credentialId, Instant when, RevocationReason reason) {
|
|
||||||
PkiId id = new PkiId(credentialId);
|
|
||||||
AttributeSet attrs = emptyAttributes();
|
|
||||||
return new RevokedRecord(id, when, reason, attrs);
|
|
||||||
}
|
|
||||||
|
|
||||||
static AttributeSet emptyAttributes() {
|
static AttributeSet emptyAttributes() {
|
||||||
return new TestAttributeSet(List.of());
|
return new TestAttributeSet(List.of());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,688 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2026, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
******************************************************************************/
|
||||||
|
package zeroecho.pki.impl.fs;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.lang.reflect.Field;
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.CyclicBarrier;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.DynamicTest;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.TestFactory;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import zeroecho.core.io.Util;
|
||||||
|
import zeroecho.pki.api.EncodedObject;
|
||||||
|
import zeroecho.pki.api.Encoding;
|
||||||
|
import zeroecho.pki.api.FormatId;
|
||||||
|
import zeroecho.pki.api.IssuerRef;
|
||||||
|
import zeroecho.pki.api.PkiException;
|
||||||
|
import zeroecho.pki.api.PkiId;
|
||||||
|
import zeroecho.pki.api.SubjectRef;
|
||||||
|
import zeroecho.pki.api.Validity;
|
||||||
|
import zeroecho.pki.api.attr.AttributeId;
|
||||||
|
import zeroecho.pki.api.attr.AttributeSet;
|
||||||
|
import zeroecho.pki.api.attr.AttributeValue;
|
||||||
|
import zeroecho.pki.api.credential.Credential;
|
||||||
|
import zeroecho.pki.api.credential.CredentialStatus;
|
||||||
|
import zeroecho.pki.api.credential.EffectiveCredentialStatus;
|
||||||
|
import zeroecho.pki.api.revocation.RevocationCommand;
|
||||||
|
import zeroecho.pki.api.revocation.RevocationJournal;
|
||||||
|
import zeroecho.pki.api.revocation.RevocationReason;
|
||||||
|
import zeroecho.pki.api.revocation.RevocationState;
|
||||||
|
import zeroecho.pki.api.revocation.RevocationTransition;
|
||||||
|
import zeroecho.pki.impl.core.StoreBackedEffectiveCredentialStatusResolver;
|
||||||
|
import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
|
||||||
|
|
||||||
|
final class FilesystemRevocationJournalTest {
|
||||||
|
private static final int CODEC_MAGIC = 0x5A454346;
|
||||||
|
private static final int CODEC_VERSION = 2;
|
||||||
|
private static final int TOP_REVOCATION = 4;
|
||||||
|
private static final int TYPE_STRING = 1;
|
||||||
|
private static final int TYPE_LONG = 3;
|
||||||
|
private static final int TYPE_INSTANT = 5;
|
||||||
|
private static final int TYPE_LIST = 7;
|
||||||
|
private static final int TYPE_OPTIONAL = 8;
|
||||||
|
private static final int TYPE_PKI_ID = 20;
|
||||||
|
private static final int TYPE_ATTRIBUTE_SET = 31;
|
||||||
|
private static final int TYPE_REVOCATION_REASON = 54;
|
||||||
|
private static final int TYPE_REVOCATION_STATE = 60;
|
||||||
|
private static final int TYPE_REVOCATION_TRANSITION = 61;
|
||||||
|
private static final Instant TIME = Instant.parse("2026-07-01T12:00:00Z");
|
||||||
|
private static final AttributeId NOTE = new AttributeId("test.note");
|
||||||
|
|
||||||
|
@TempDir
|
||||||
|
private Path temporaryDirectory;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void legalTransitionsAreContiguousDurableAndPermanentIsTerminal() throws Exception {
|
||||||
|
Path root = temporaryDirectory.resolve("legal");
|
||||||
|
Credential credential = credential("legal");
|
||||||
|
try (FilesystemPkiStore store = store(root)) {
|
||||||
|
store.putCredential(credential);
|
||||||
|
assertTransition(store, hold(credential), 1L, RevocationState.HELD);
|
||||||
|
assertTransition(store, unhold(credential), 2L, RevocationState.CLEAR);
|
||||||
|
assertTransition(store, hold(credential), 3L, RevocationState.HELD);
|
||||||
|
assertTransition(store, revoke(credential, RevocationReason.KEY_COMPROMISE), 4L,
|
||||||
|
RevocationState.PERMANENTLY_REVOKED);
|
||||||
|
assertCode("REVOCATION_TERMINAL", () -> transition(store, unhold(credential)));
|
||||||
|
assertCode("REVOCATION_TERMINAL",
|
||||||
|
() -> transition(store, revoke(credential, RevocationReason.CA_COMPROMISE)));
|
||||||
|
}
|
||||||
|
try (FilesystemPkiStore reopened = store(root)) {
|
||||||
|
RevocationJournal journal = reopened.getRevocationJournal(credential.credentialId()).orElseThrow();
|
||||||
|
assertEquals(4, journal.transitions().size());
|
||||||
|
assertEquals(RevocationState.PERMANENTLY_REVOKED, journal.latest().state());
|
||||||
|
assertEquals(RevocationReason.KEY_COMPROMISE, journal.latest().permanentReason().orElseThrow());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void everyLegalPermanentTransitionIsAccepted() throws Exception {
|
||||||
|
try (FilesystemPkiStore store = store(temporaryDirectory.resolve("legal-permanent"))) {
|
||||||
|
Credential direct = credential("permanent-from-none");
|
||||||
|
Credential fromClear = credential("permanent-from-clear");
|
||||||
|
Credential fromHeld = credential("permanent-from-held");
|
||||||
|
store.putCredential(direct);
|
||||||
|
store.putCredential(fromClear);
|
||||||
|
store.putCredential(fromHeld);
|
||||||
|
|
||||||
|
RevocationJournal directJournal = transition(store,
|
||||||
|
revoke(direct, RevocationReason.KEY_COMPROMISE));
|
||||||
|
assertEquals(List.of(RevocationState.PERMANENTLY_REVOKED),
|
||||||
|
states(directJournal));
|
||||||
|
assertEquals(1L, directJournal.latest().revision());
|
||||||
|
|
||||||
|
transition(store, hold(fromClear));
|
||||||
|
transition(store, unhold(fromClear));
|
||||||
|
RevocationJournal clearJournal = transition(store,
|
||||||
|
revoke(fromClear, RevocationReason.CA_COMPROMISE));
|
||||||
|
assertEquals(List.of(RevocationState.HELD, RevocationState.CLEAR,
|
||||||
|
RevocationState.PERMANENTLY_REVOKED), states(clearJournal));
|
||||||
|
assertEquals(3L, clearJournal.latest().revision());
|
||||||
|
|
||||||
|
transition(store, hold(fromHeld));
|
||||||
|
RevocationJournal heldJournal = transition(store,
|
||||||
|
revoke(fromHeld, RevocationReason.SUPERSEDED));
|
||||||
|
assertEquals(List.of(RevocationState.HELD, RevocationState.PERMANENTLY_REVOKED),
|
||||||
|
states(heldJournal));
|
||||||
|
assertEquals(2L, heldJournal.latest().revision());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void illegalAndInvalidCommandsFailBeforeJournalMutation() throws Exception {
|
||||||
|
try (FilesystemPkiStore store = store(temporaryDirectory.resolve("illegal"))) {
|
||||||
|
Credential unknown = credential("unknown");
|
||||||
|
assertCode("REVOCATION_CREDENTIAL_NOT_FOUND",
|
||||||
|
() -> transition(store, hold(unknown)));
|
||||||
|
|
||||||
|
Credential credential = credential("known");
|
||||||
|
store.putCredential(credential);
|
||||||
|
assertCode("REVOCATION_TRANSITION_ILLEGAL",
|
||||||
|
() -> transition(store, unhold(credential)));
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> revoke(credential, RevocationReason.CERTIFICATE_HOLD));
|
||||||
|
assertTrue(store.getRevocationJournal(credential.credentialId()).isEmpty());
|
||||||
|
|
||||||
|
transition(store, hold(credential));
|
||||||
|
assertCode("REVOCATION_TRANSITION_ILLEGAL",
|
||||||
|
() -> transition(store, hold(credential)));
|
||||||
|
assertEquals(1, store.getRevocationJournal(credential.credentialId()).orElseThrow().transitions().size());
|
||||||
|
|
||||||
|
transition(store, unhold(credential));
|
||||||
|
Path journalPath = new FsPaths(temporaryDirectory.resolve("illegal"))
|
||||||
|
.revocationJournal(credential.credentialId());
|
||||||
|
byte[] clearBytes = FsOperations.readAll(journalPath);
|
||||||
|
assertCode("REVOCATION_TRANSITION_ILLEGAL",
|
||||||
|
() -> transition(store, unhold(credential)));
|
||||||
|
assertArrayEquals(clearBytes, FsOperations.readAll(journalPath));
|
||||||
|
|
||||||
|
transition(store, revoke(credential, RevocationReason.KEY_COMPROMISE));
|
||||||
|
byte[] permanentBytes = FsOperations.readAll(journalPath);
|
||||||
|
assertCode("REVOCATION_TERMINAL", () -> transition(store, hold(credential)));
|
||||||
|
assertCode("REVOCATION_TERMINAL",
|
||||||
|
() -> transition(store, revoke(credential, RevocationReason.KEY_COMPROMISE)));
|
||||||
|
assertCode("REVOCATION_TERMINAL",
|
||||||
|
() -> transition(store, revoke(credential, RevocationReason.CA_COMPROMISE)));
|
||||||
|
assertArrayEquals(permanentBytes, FsOperations.readAll(journalPath));
|
||||||
|
|
||||||
|
Credential invalidRemove = credential("invalid-remove");
|
||||||
|
store.putCredential(invalidRemove);
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> revoke(invalidRemove, RevocationReason.REMOVE_FROM_CRL));
|
||||||
|
assertTrue(store.getRevocationJournal(invalidRemove.credentialId()).isEmpty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sameCredentialConcurrentHoldHasExactlyOneCommittedWinner() throws Exception {
|
||||||
|
try (FilesystemPkiStore store = store(temporaryDirectory.resolve("concurrent"))) {
|
||||||
|
Credential credential = credential("concurrent");
|
||||||
|
store.putCredential(credential);
|
||||||
|
CyclicBarrier barrier = new CyclicBarrier(2);
|
||||||
|
ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||||
|
try {
|
||||||
|
CompletableFuture<Boolean> first = attemptHold(store, credential, barrier, executor);
|
||||||
|
CompletableFuture<Boolean> second = attemptHold(store, credential, barrier, executor);
|
||||||
|
assertEquals(1L, List.of(first.join(), second.join()).stream().filter(Boolean::booleanValue).count());
|
||||||
|
} finally {
|
||||||
|
executor.shutdownNow();
|
||||||
|
}
|
||||||
|
RevocationJournal journal = store.getRevocationJournal(credential.credentialId()).orElseThrow();
|
||||||
|
assertEquals(1, journal.transitions().size());
|
||||||
|
assertEquals(1L, journal.latest().revision());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void conflictingTransitionsSerializeWithoutLostUpdates() throws Exception {
|
||||||
|
try (FilesystemPkiStore store = store(temporaryDirectory.resolve("concurrent-conflicts"))) {
|
||||||
|
ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||||
|
try {
|
||||||
|
Credential fromNone = credential("hold-vs-permanent");
|
||||||
|
store.putCredential(fromNone);
|
||||||
|
runConcurrent(store, hold(fromNone),
|
||||||
|
revoke(fromNone, RevocationReason.KEY_COMPROMISE), executor);
|
||||||
|
RevocationJournal fromNoneJournal =
|
||||||
|
store.getRevocationJournal(fromNone.credentialId()).orElseThrow();
|
||||||
|
assertEquals(RevocationState.PERMANENTLY_REVOKED, fromNoneJournal.latest().state());
|
||||||
|
assertTrue(fromNoneJournal.transitions().size() == 1
|
||||||
|
|| fromNoneJournal.transitions().size() == 2);
|
||||||
|
|
||||||
|
Credential fromHeld = credential("unhold-vs-permanent");
|
||||||
|
store.putCredential(fromHeld);
|
||||||
|
transition(store, hold(fromHeld));
|
||||||
|
runConcurrent(store, unhold(fromHeld),
|
||||||
|
revoke(fromHeld, RevocationReason.CA_COMPROMISE), executor);
|
||||||
|
RevocationJournal fromHeldJournal =
|
||||||
|
store.getRevocationJournal(fromHeld.credentialId()).orElseThrow();
|
||||||
|
assertEquals(RevocationState.PERMANENTLY_REVOKED, fromHeldJournal.latest().state());
|
||||||
|
assertTrue(fromHeldJournal.transitions().size() == 2
|
||||||
|
|| fromHeldJournal.transitions().size() == 3);
|
||||||
|
|
||||||
|
Credential reasons = credential("competing-permanent-reasons");
|
||||||
|
store.putCredential(reasons);
|
||||||
|
List<Boolean> results = runConcurrent(store,
|
||||||
|
revoke(reasons, RevocationReason.KEY_COMPROMISE),
|
||||||
|
revoke(reasons, RevocationReason.CA_COMPROMISE), executor);
|
||||||
|
assertEquals(1L, results.stream().filter(Boolean::booleanValue).count());
|
||||||
|
RevocationJournal reasonsJournal =
|
||||||
|
store.getRevocationJournal(reasons.credentialId()).orElseThrow();
|
||||||
|
assertEquals(1, reasonsJournal.transitions().size());
|
||||||
|
assertTrue(reasonsJournal.latest().permanentReason()
|
||||||
|
.filter(reason -> reason == RevocationReason.KEY_COMPROMISE
|
||||||
|
|| reason == RevocationReason.CA_COMPROMISE)
|
||||||
|
.isPresent());
|
||||||
|
} finally {
|
||||||
|
executor.shutdownNow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unrelatedCredentialTransitionProgressesWhileAnotherCredentialLockIsHeld() throws Exception {
|
||||||
|
try (FilesystemPkiStore store = store(temporaryDirectory.resolve("independent-locks"))) {
|
||||||
|
Credential blocked = credential("blocked");
|
||||||
|
Credential independent = credential("independent");
|
||||||
|
store.putCredential(blocked);
|
||||||
|
store.putCredential(independent);
|
||||||
|
|
||||||
|
Method acquire = FilesystemPkiStore.class.getDeclaredMethod("acquireRevocationLock", PkiId.class);
|
||||||
|
Method release = Arrays.stream(FilesystemPkiStore.class.getDeclaredMethods())
|
||||||
|
.filter(method -> method.getName().equals("releaseRevocationLock"))
|
||||||
|
.findFirst().orElseThrow();
|
||||||
|
acquire.setAccessible(true);
|
||||||
|
release.setAccessible(true);
|
||||||
|
Object heldLock = acquire.invoke(store, blocked.credentialId());
|
||||||
|
ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||||
|
CountDownLatch blockedStarted = new CountDownLatch(1);
|
||||||
|
try {
|
||||||
|
CompletableFuture<RevocationJournal> blockedTransition = CompletableFuture.supplyAsync(() -> {
|
||||||
|
blockedStarted.countDown();
|
||||||
|
return transition(store, hold(blocked));
|
||||||
|
}, executor);
|
||||||
|
assertTrue(blockedStarted.await(5, TimeUnit.SECONDS));
|
||||||
|
CompletableFuture<RevocationJournal> independentTransition = CompletableFuture.supplyAsync(
|
||||||
|
() -> transition(store, hold(independent)), executor);
|
||||||
|
assertEquals(RevocationState.HELD,
|
||||||
|
independentTransition.get(5, TimeUnit.SECONDS).latest().state());
|
||||||
|
assertFalse(blockedTransition.isDone());
|
||||||
|
release.invoke(store, blocked.credentialId(), heldLock);
|
||||||
|
heldLock = null;
|
||||||
|
assertEquals(RevocationState.HELD,
|
||||||
|
blockedTransition.get(5, TimeUnit.SECONDS).latest().state());
|
||||||
|
} finally {
|
||||||
|
if (heldLock != null) {
|
||||||
|
release.invoke(store, blocked.credentialId(), heldLock);
|
||||||
|
}
|
||||||
|
executor.shutdownNow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void copiedNamespaceJournalAndRegressingTransitionTimeFailClosed() throws Exception {
|
||||||
|
Path root = temporaryDirectory.resolve("corrupt");
|
||||||
|
Credential credential = credential("corrupt");
|
||||||
|
try (FilesystemPkiStore store = store(root)) {
|
||||||
|
store.putCredential(credential);
|
||||||
|
transition(store, hold(credential));
|
||||||
|
assertCode("REVOCATION_TRANSITION_CONFLICT",
|
||||||
|
() -> store.transitionRevocation(unhold(credential), TIME.minusSeconds(1)));
|
||||||
|
|
||||||
|
RevocationJournal invalid = new RevocationJournal(new PkiId("credential:copied"),
|
||||||
|
List.of(new RevocationTransition(1L, RevocationState.HELD, TIME, Optional.empty(),
|
||||||
|
new SimpleAttributeSet())));
|
||||||
|
FsOperations.writeAtomic(new FsPaths(root).revocationJournal(credential.credentialId()),
|
||||||
|
FsCodec.encode(FsCodec.REVOCATION_JOURNAL, invalid));
|
||||||
|
assertCode("REVOCATION_STATE_CORRUPT", () -> store.getRevocationJournal(credential.credentialId()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void transitionMetadataDefensivelySnapshotsByteValues() {
|
||||||
|
byte[] source = { 1, 2, 3 };
|
||||||
|
AttributeSet attributes = SimpleAttributeSet.builder()
|
||||||
|
.put(NOTE, new AttributeValue.BytesValue(source)).build();
|
||||||
|
RevocationTransition transition = new RevocationTransition(1L, RevocationState.HELD, TIME,
|
||||||
|
Optional.empty(), attributes);
|
||||||
|
source[0] = 9;
|
||||||
|
byte[] first = ((AttributeValue.BytesValue) transition.attributes().get(NOTE).orElseThrow()).value();
|
||||||
|
assertArrayEquals(new byte[] { 1, 2, 3 }, first);
|
||||||
|
first[1] = 9;
|
||||||
|
byte[] second = ((AttributeValue.BytesValue) transition.attributes().get(NOTE).orElseThrow()).value();
|
||||||
|
assertArrayEquals(new byte[] { 1, 2, 3 }, second);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void journalConstructorRejectsEveryHostileSequenceShape() {
|
||||||
|
PkiId id = new PkiId("credential:hostile");
|
||||||
|
SimpleAttributeSet attributes = new SimpleAttributeSet();
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> new RevocationJournal(id, List.of(new RevocationTransition(1L, RevocationState.CLEAR, TIME,
|
||||||
|
Optional.empty(), attributes))));
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> new RevocationJournal(id, List.of(new RevocationTransition(2L, RevocationState.HELD, TIME,
|
||||||
|
Optional.empty(), attributes))));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> new RevocationJournal(id, List.of(
|
||||||
|
new RevocationTransition(1L, RevocationState.HELD, TIME, Optional.empty(), attributes),
|
||||||
|
new RevocationTransition(2L, RevocationState.CLEAR, TIME.minusNanos(1), Optional.empty(),
|
||||||
|
attributes))));
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> new RevocationJournal(id, List.of(new RevocationTransition(1L,
|
||||||
|
RevocationState.PERMANENTLY_REVOKED, TIME,
|
||||||
|
Optional.of(RevocationReason.CERTIFICATE_HOLD), attributes))));
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> new RevocationJournal(id, List.of(
|
||||||
|
new RevocationTransition(1L, RevocationState.PERMANENTLY_REVOKED, TIME,
|
||||||
|
Optional.of(RevocationReason.KEY_COMPROMISE), attributes),
|
||||||
|
new RevocationTransition(2L, RevocationState.HELD, TIME, Optional.empty(), attributes))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@TestFactory
|
||||||
|
Stream<DynamicTest> strictCodecCorruptionFailsReadAndTransitionWithoutOverwrite() {
|
||||||
|
return Arrays.stream(CorruptionCase.values()).map(corruption -> DynamicTest.dynamicTest(
|
||||||
|
corruption.description, () -> assertPersistedCorruptionRejected(corruption)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void obsoleteCurrentAndHistoryLayoutCannotInfluenceTrustedJournalOrResolver() throws Exception {
|
||||||
|
Path root = temporaryDirectory.resolve("obsolete-layout");
|
||||||
|
Credential credential = credential("obsolete-layout");
|
||||||
|
try (FilesystemPkiStore store = store(root)) {
|
||||||
|
store.putCredential(credential);
|
||||||
|
}
|
||||||
|
Path legacyDirectory = new FsPaths(root).revocationDir(credential.credentialId());
|
||||||
|
FsOperations.ensureDir(legacyDirectory.resolve("history"));
|
||||||
|
FsOperations.writeAtomic(legacyDirectory.resolve("current.bin"),
|
||||||
|
oldRevokedRecordPayload(credential.credentialId()));
|
||||||
|
FsOperations.writeAtomic(legacyDirectory.resolve("history").resolve("legacy.bin"),
|
||||||
|
oldRevokedRecordPayload(credential.credentialId()));
|
||||||
|
|
||||||
|
try (FilesystemPkiStore reopened = store(root)) {
|
||||||
|
assertTrue(reopened.getRevocationJournal(credential.credentialId()).isEmpty());
|
||||||
|
StoreBackedEffectiveCredentialStatusResolver resolver =
|
||||||
|
new StoreBackedEffectiveCredentialStatusResolver(reopened, Clock.fixed(TIME,
|
||||||
|
java.time.ZoneOffset.UTC));
|
||||||
|
assertEquals(EffectiveCredentialStatus.USABLE,
|
||||||
|
resolver.beginEvaluation().resolve(
|
||||||
|
reopened.getCredential(credential.credentialId()).orElseThrow()));
|
||||||
|
transition(reopened, hold(credential));
|
||||||
|
assertEquals(RevocationState.HELD,
|
||||||
|
reopened.getRevocationJournal(credential.credentialId()).orElseThrow().latest().state());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void durabilityUncertaintyMakesEveryStoreOperationFailClosed() throws Exception {
|
||||||
|
try (FilesystemPkiStore store = store(temporaryDirectory.resolve("uncertain"))) {
|
||||||
|
Credential credential = credential("uncertain");
|
||||||
|
store.putCredential(credential);
|
||||||
|
Field field = FilesystemPkiStore.class.getDeclaredField("durabilityUncertain");
|
||||||
|
field.setAccessible(true);
|
||||||
|
((AtomicBoolean) field.get(store)).set(true);
|
||||||
|
|
||||||
|
assertCode("STORE_DURABILITY_UNCONFIRMED",
|
||||||
|
() -> store.getCredential(credential.credentialId()));
|
||||||
|
assertCode("STORE_DURABILITY_UNCONFIRMED", store::listCas);
|
||||||
|
assertCode("STORE_DURABILITY_UNCONFIRMED", store::signingNow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CompletableFuture<Boolean> attemptHold(FilesystemPkiStore store, Credential credential,
|
||||||
|
CyclicBarrier barrier, ExecutorService executor) {
|
||||||
|
return CompletableFuture.supplyAsync(() -> {
|
||||||
|
try {
|
||||||
|
barrier.await();
|
||||||
|
transition(store, hold(credential));
|
||||||
|
return true;
|
||||||
|
} catch (PkiException expected) {
|
||||||
|
assertTrue(expected.getMessage().contains("REVOCATION_TRANSITION_ILLEGAL"));
|
||||||
|
return false;
|
||||||
|
} catch (Exception unexpected) {
|
||||||
|
throw new IllegalStateException(unexpected);
|
||||||
|
}
|
||||||
|
}, executor);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Boolean> runConcurrent(FilesystemPkiStore store, RevocationCommand firstCommand,
|
||||||
|
RevocationCommand secondCommand, ExecutorService executor) {
|
||||||
|
CyclicBarrier barrier = new CyclicBarrier(2);
|
||||||
|
CompletableFuture<Boolean> first = attemptTransition(store, firstCommand, barrier, executor);
|
||||||
|
CompletableFuture<Boolean> second = attemptTransition(store, secondCommand, barrier, executor);
|
||||||
|
return List.of(first.join(), second.join());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CompletableFuture<Boolean> attemptTransition(FilesystemPkiStore store,
|
||||||
|
RevocationCommand command, CyclicBarrier barrier, ExecutorService executor) {
|
||||||
|
return CompletableFuture.supplyAsync(() -> {
|
||||||
|
try {
|
||||||
|
barrier.await();
|
||||||
|
transition(store, command);
|
||||||
|
return true;
|
||||||
|
} catch (PkiException expected) {
|
||||||
|
assertTrue(expected.getMessage().contains("code=REVOCATION_TERMINAL")
|
||||||
|
|| expected.getMessage().contains("code=REVOCATION_TRANSITION_ILLEGAL"));
|
||||||
|
return false;
|
||||||
|
} catch (Exception unexpected) {
|
||||||
|
throw new IllegalStateException(unexpected);
|
||||||
|
}
|
||||||
|
}, executor);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertPersistedCorruptionRejected(CorruptionCase corruption) throws Exception {
|
||||||
|
String suffix = "corrupt-" + corruption.name().toLowerCase(java.util.Locale.ROOT);
|
||||||
|
Path root = temporaryDirectory.resolve(suffix);
|
||||||
|
Credential credential = credential(suffix);
|
||||||
|
Path journalPath;
|
||||||
|
byte[] corrupt;
|
||||||
|
try (FilesystemPkiStore store = store(root)) {
|
||||||
|
store.putCredential(credential);
|
||||||
|
journalPath = new FsPaths(root).revocationJournal(credential.credentialId());
|
||||||
|
corrupt = corruption.bytes(credential.credentialId());
|
||||||
|
FsOperations.writeAtomic(journalPath, corrupt);
|
||||||
|
}
|
||||||
|
|
||||||
|
try (FilesystemPkiStore reopened = store(root)) {
|
||||||
|
byte[] before = FsOperations.readAll(journalPath);
|
||||||
|
assertCode("REVOCATION_STATE_CORRUPT",
|
||||||
|
() -> reopened.getRevocationJournal(credential.credentialId()));
|
||||||
|
assertCode("REVOCATION_STATE_CORRUPT",
|
||||||
|
() -> transition(reopened, hold(credential)));
|
||||||
|
assertArrayEquals(before, FsOperations.readAll(journalPath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<RevocationState> states(RevocationJournal journal) {
|
||||||
|
return journal.transitions().stream().map(RevocationTransition::state).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertTransition(FilesystemPkiStore store, RevocationCommand command, long revision,
|
||||||
|
RevocationState state) {
|
||||||
|
RevocationJournal journal = transition(store, command);
|
||||||
|
assertEquals(revision, journal.latest().revision());
|
||||||
|
assertEquals(state, journal.latest().state());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RevocationJournal transition(FilesystemPkiStore store, RevocationCommand command) {
|
||||||
|
return store.transitionRevocation(command, TIME);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RevocationCommand.Hold hold(Credential credential) {
|
||||||
|
return new RevocationCommand.Hold(credential.credentialId(), new SimpleAttributeSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RevocationCommand.Unhold unhold(Credential credential) {
|
||||||
|
return new RevocationCommand.Unhold(credential.credentialId(), new SimpleAttributeSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RevocationCommand.RevokePermanently revoke(Credential credential, RevocationReason reason) {
|
||||||
|
return new RevocationCommand.RevokePermanently(credential.credentialId(), reason,
|
||||||
|
new SimpleAttributeSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertCode(String code, Runnable operation) {
|
||||||
|
PkiException failure = assertThrows(PkiException.class, operation::run);
|
||||||
|
assertTrue(failure.getMessage().contains("code=" + code));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FilesystemPkiStore store(Path root) {
|
||||||
|
return new FilesystemPkiStore(root, FsPkiStoreOptions.defaults());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Credential credential(String suffix) {
|
||||||
|
return new Credential(new PkiId("credential:" + suffix), new FormatId("x509"),
|
||||||
|
new IssuerRef(new PkiId("ca:issuer")), new SubjectRef("CN=" + suffix),
|
||||||
|
new Validity(TIME.minusSeconds(60), TIME.plusSeconds(60)), suffix,
|
||||||
|
new PkiId("key:" + suffix), "default", CredentialStatus.ISSUED,
|
||||||
|
new EncodedObject(Encoding.DER, new byte[] { 1, 2, 3 }), new SimpleAttributeSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] journalPayload(PkiId credentialId, long journalVersion, RawTransition... transitions)
|
||||||
|
throws IOException {
|
||||||
|
ByteArrayOutputStream output = envelope();
|
||||||
|
writePkiId(output, credentialId);
|
||||||
|
writeTypedLong(output, journalVersion);
|
||||||
|
output.write(TYPE_LIST);
|
||||||
|
output.write(TYPE_REVOCATION_TRANSITION);
|
||||||
|
Util.writePack7I(output, transitions.length);
|
||||||
|
for (RawTransition transition : transitions) {
|
||||||
|
output.write(TYPE_REVOCATION_TRANSITION);
|
||||||
|
writeTypedLong(output, transition.revision);
|
||||||
|
output.write(TYPE_REVOCATION_STATE);
|
||||||
|
output.write(stateCode(transition.state));
|
||||||
|
output.write(TYPE_INSTANT);
|
||||||
|
Util.writeLong(output, transition.time.getEpochSecond());
|
||||||
|
Util.writePack7I(output, transition.time.getNano());
|
||||||
|
output.write(TYPE_OPTIONAL);
|
||||||
|
output.write(TYPE_REVOCATION_REASON);
|
||||||
|
if (transition.reason.isPresent()) {
|
||||||
|
output.write(1);
|
||||||
|
output.write(TYPE_REVOCATION_REASON);
|
||||||
|
output.write(reasonCode(transition.reason.orElseThrow()));
|
||||||
|
} else {
|
||||||
|
output.write(0);
|
||||||
|
}
|
||||||
|
output.write(TYPE_ATTRIBUTE_SET);
|
||||||
|
Util.writePack7I(output, 0);
|
||||||
|
}
|
||||||
|
return output.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] oldRevokedRecordPayload(PkiId credentialId) throws IOException {
|
||||||
|
ByteArrayOutputStream output = envelope();
|
||||||
|
writePkiId(output, credentialId);
|
||||||
|
output.write(TYPE_INSTANT);
|
||||||
|
Util.writeLong(output, TIME.getEpochSecond());
|
||||||
|
Util.writePack7I(output, TIME.getNano());
|
||||||
|
output.write(TYPE_REVOCATION_REASON);
|
||||||
|
output.write(reasonCode(RevocationReason.KEY_COMPROMISE));
|
||||||
|
output.write(TYPE_ATTRIBUTE_SET);
|
||||||
|
Util.writePack7I(output, 0);
|
||||||
|
return output.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ByteArrayOutputStream envelope() {
|
||||||
|
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||||
|
output.write(CODEC_MAGIC >>> 24);
|
||||||
|
output.write(CODEC_MAGIC >>> 16);
|
||||||
|
output.write(CODEC_MAGIC >>> 8);
|
||||||
|
output.write(CODEC_MAGIC);
|
||||||
|
output.write(CODEC_VERSION);
|
||||||
|
output.write(TOP_REVOCATION);
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writePkiId(ByteArrayOutputStream output, PkiId id) throws IOException {
|
||||||
|
output.write(TYPE_PKI_ID);
|
||||||
|
output.write(TYPE_STRING);
|
||||||
|
Util.writeUTF8(output, id.value());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeTypedLong(ByteArrayOutputStream output, long value) throws IOException {
|
||||||
|
output.write(TYPE_LONG);
|
||||||
|
Util.writeLong(output, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int stateCode(RevocationState state) {
|
||||||
|
return switch (state) {
|
||||||
|
case CLEAR -> 1;
|
||||||
|
case HELD -> 2;
|
||||||
|
case PERMANENTLY_REVOKED -> 3;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int reasonCode(RevocationReason reason) {
|
||||||
|
return switch (reason) {
|
||||||
|
case UNSPECIFIED -> 1;
|
||||||
|
case KEY_COMPROMISE -> 2;
|
||||||
|
case CA_COMPROMISE -> 3;
|
||||||
|
case AFFILIATION_CHANGED -> 4;
|
||||||
|
case SUPERSEDED -> 5;
|
||||||
|
case CESSATION_OF_OPERATION -> 6;
|
||||||
|
case CERTIFICATE_HOLD -> 7;
|
||||||
|
case REMOVE_FROM_CRL -> 8;
|
||||||
|
case PRIVILEGE_WITHDRAWN -> 9;
|
||||||
|
case AA_COMPROMISE -> 10;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private record RawTransition(long revision, RevocationState state, Instant time,
|
||||||
|
Optional<RevocationReason> reason) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CorruptionCase {
|
||||||
|
ZERO_TRANSITIONS("zero transitions"),
|
||||||
|
ZERO_REVISION("zero transition revision"),
|
||||||
|
FIRST_REVISION_NOT_ONE("first revision is not one"),
|
||||||
|
REVISION_GAP("revision gap"),
|
||||||
|
DUPLICATE_REVISION("duplicate revision"),
|
||||||
|
BACKWARD_TIME("backward transition time"),
|
||||||
|
CLEAR_FIRST("CLEAR as first state"),
|
||||||
|
CLEAR_NOT_AFTER_HELD("CLEAR not immediately after HELD"),
|
||||||
|
REPEATED_HELD("repeated HELD"),
|
||||||
|
PERMANENT_REASON_MISSING("permanent revocation without reason"),
|
||||||
|
PERMANENT_REASON_CERTIFICATE_HOLD("permanent revocation with hold reason"),
|
||||||
|
PERMANENT_REASON_REMOVE_FROM_CRL("permanent revocation with remove-from-CRL reason"),
|
||||||
|
HELD_WITH_REASON("HELD with permanent reason"),
|
||||||
|
CLEAR_WITH_REASON("CLEAR with permanent reason"),
|
||||||
|
TRANSITION_AFTER_PERMANENT("transition after permanent revocation"),
|
||||||
|
MISMATCHED_CREDENTIAL_ID("mismatched credential namespace"),
|
||||||
|
UNSUPPORTED_JOURNAL_VERSION("unsupported embedded journal version"),
|
||||||
|
OLD_REVOKED_RECORD_PAYLOAD("old RevokedRecord payload"),
|
||||||
|
TRUNCATED_PAYLOAD("truncated current journal"),
|
||||||
|
TRAILING_PAYLOAD("trailing journal data");
|
||||||
|
|
||||||
|
private final String description;
|
||||||
|
|
||||||
|
CorruptionCase(String description) {
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] bytes(PkiId credentialId) throws IOException {
|
||||||
|
RawTransition held = raw(1L, RevocationState.HELD, TIME);
|
||||||
|
return switch (this) {
|
||||||
|
case ZERO_TRANSITIONS -> journalPayload(credentialId, 1L);
|
||||||
|
case ZERO_REVISION -> journalPayload(credentialId, 1L,
|
||||||
|
raw(0L, RevocationState.HELD, TIME));
|
||||||
|
case FIRST_REVISION_NOT_ONE -> journalPayload(credentialId, 1L,
|
||||||
|
raw(2L, RevocationState.HELD, TIME));
|
||||||
|
case REVISION_GAP -> journalPayload(credentialId, 1L, held,
|
||||||
|
raw(3L, RevocationState.CLEAR, TIME.plusSeconds(1)));
|
||||||
|
case DUPLICATE_REVISION -> journalPayload(credentialId, 1L, held,
|
||||||
|
raw(1L, RevocationState.CLEAR, TIME.plusSeconds(1)));
|
||||||
|
case BACKWARD_TIME -> journalPayload(credentialId, 1L, held,
|
||||||
|
raw(2L, RevocationState.CLEAR, TIME.minusSeconds(1)));
|
||||||
|
case CLEAR_FIRST -> journalPayload(credentialId, 1L,
|
||||||
|
raw(1L, RevocationState.CLEAR, TIME));
|
||||||
|
case CLEAR_NOT_AFTER_HELD -> journalPayload(credentialId, 1L, held,
|
||||||
|
raw(2L, RevocationState.CLEAR, TIME.plusSeconds(1)),
|
||||||
|
raw(3L, RevocationState.CLEAR, TIME.plusSeconds(2)));
|
||||||
|
case REPEATED_HELD -> journalPayload(credentialId, 1L, held,
|
||||||
|
raw(2L, RevocationState.HELD, TIME.plusSeconds(1)));
|
||||||
|
case PERMANENT_REASON_MISSING -> journalPayload(credentialId, 1L,
|
||||||
|
raw(1L, RevocationState.PERMANENTLY_REVOKED, TIME));
|
||||||
|
case PERMANENT_REASON_CERTIFICATE_HOLD -> journalPayload(credentialId, 1L,
|
||||||
|
raw(1L, RevocationState.PERMANENTLY_REVOKED, TIME,
|
||||||
|
RevocationReason.CERTIFICATE_HOLD));
|
||||||
|
case PERMANENT_REASON_REMOVE_FROM_CRL -> journalPayload(credentialId, 1L,
|
||||||
|
raw(1L, RevocationState.PERMANENTLY_REVOKED, TIME,
|
||||||
|
RevocationReason.REMOVE_FROM_CRL));
|
||||||
|
case HELD_WITH_REASON -> journalPayload(credentialId, 1L,
|
||||||
|
raw(1L, RevocationState.HELD, TIME, RevocationReason.KEY_COMPROMISE));
|
||||||
|
case CLEAR_WITH_REASON -> journalPayload(credentialId, 1L, held,
|
||||||
|
raw(2L, RevocationState.CLEAR, TIME.plusSeconds(1),
|
||||||
|
RevocationReason.KEY_COMPROMISE));
|
||||||
|
case TRANSITION_AFTER_PERMANENT -> journalPayload(credentialId, 1L,
|
||||||
|
raw(1L, RevocationState.PERMANENTLY_REVOKED, TIME,
|
||||||
|
RevocationReason.KEY_COMPROMISE),
|
||||||
|
raw(2L, RevocationState.HELD, TIME.plusSeconds(1)));
|
||||||
|
case MISMATCHED_CREDENTIAL_ID -> journalPayload(
|
||||||
|
new PkiId("credential:other-namespace"), 1L, held);
|
||||||
|
case UNSUPPORTED_JOURNAL_VERSION -> journalPayload(credentialId, 2L, held);
|
||||||
|
case OLD_REVOKED_RECORD_PAYLOAD -> oldRevokedRecordPayload(credentialId);
|
||||||
|
case TRUNCATED_PAYLOAD -> {
|
||||||
|
byte[] valid = journalPayload(credentialId, 1L, held);
|
||||||
|
yield Arrays.copyOf(valid, valid.length - 1);
|
||||||
|
}
|
||||||
|
case TRAILING_PAYLOAD -> {
|
||||||
|
byte[] valid = journalPayload(credentialId, 1L, held);
|
||||||
|
yield Arrays.copyOf(valid, valid.length + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RawTransition raw(long revision, RevocationState state, Instant time) {
|
||||||
|
return new RawTransition(revision, state, time, Optional.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RawTransition raw(long revision, RevocationState state, Instant time,
|
||||||
|
RevocationReason reason) {
|
||||||
|
return new RawTransition(revision, state, time, Optional.of(reason));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* Copyright (C) 2026, Leo Galambos
|
||||||
|
* All rights reserved.
|
||||||
|
******************************************************************************/
|
||||||
|
package zeroecho.pki.spi.framework;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import zeroecho.pki.api.revocation.RevocationReason;
|
||||||
|
|
||||||
|
final class CrlEntryTest {
|
||||||
|
private static final Instant TIME = Instant.parse("2026-07-30T12:00:00.987654321Z");
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void acceptsEveryExplicitActiveReasonAndFullPositiveSerialDomain() {
|
||||||
|
BigInteger largestFeasibleX509Serial = BigInteger.ONE.shiftLeft(159).subtract(BigInteger.ONE);
|
||||||
|
BigInteger[] serials = {
|
||||||
|
BigInteger.ONE,
|
||||||
|
BigInteger.valueOf(Long.MAX_VALUE),
|
||||||
|
BigInteger.valueOf(Long.MAX_VALUE).add(BigInteger.ONE),
|
||||||
|
largestFeasibleX509Serial
|
||||||
|
};
|
||||||
|
for (RevocationReason reason : activeReasons()) {
|
||||||
|
for (BigInteger serial : serials) {
|
||||||
|
CrlEntry entry = new CrlEntry(serial, TIME, reason);
|
||||||
|
assertEquals(serial, entry.serialNumber());
|
||||||
|
assertEquals(TIME, entry.transitionTime());
|
||||||
|
assertEquals(reason, entry.reason());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsInvalidSerialTimeAndReason() {
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> new CrlEntry(BigInteger.ZERO, TIME, RevocationReason.UNSPECIFIED));
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> new CrlEntry(BigInteger.valueOf(-1L), TIME, RevocationReason.UNSPECIFIED));
|
||||||
|
assertThrows(NullPointerException.class,
|
||||||
|
() -> new CrlEntry(BigInteger.ONE, null, RevocationReason.UNSPECIFIED));
|
||||||
|
assertThrows(NullPointerException.class,
|
||||||
|
() -> new CrlEntry(BigInteger.ONE, TIME, null));
|
||||||
|
assertThrows(IllegalArgumentException.class,
|
||||||
|
() -> new CrlEntry(BigInteger.ONE, TIME, RevocationReason.REMOVE_FROM_CRL));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RevocationReason[] activeReasons() {
|
||||||
|
return Arrays.stream(RevocationReason.values())
|
||||||
|
.filter(reason -> reason != RevocationReason.REMOVE_FROM_CRL)
|
||||||
|
.toArray(RevocationReason[]::new);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ import java.io.IOException;
|
|||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.security.KeyPair;
|
import java.security.KeyPair;
|
||||||
import java.security.PublicKey;
|
import java.security.PublicKey;
|
||||||
|
import java.time.Clock;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -51,11 +52,13 @@ import zeroecho.pki.api.IssuanceService;
|
|||||||
import zeroecho.pki.api.KeyRef;
|
import zeroecho.pki.api.KeyRef;
|
||||||
import zeroecho.pki.api.RevocationService;
|
import zeroecho.pki.api.RevocationService;
|
||||||
import zeroecho.pki.api.StatusObjectService;
|
import zeroecho.pki.api.StatusObjectService;
|
||||||
|
import zeroecho.pki.api.credential.EffectiveCredentialStatusResolver;
|
||||||
import zeroecho.pki.impl.core.DefaultCaService;
|
import zeroecho.pki.impl.core.DefaultCaService;
|
||||||
import zeroecho.pki.impl.core.DefaultCertificationRequestService;
|
import zeroecho.pki.impl.core.DefaultCertificationRequestService;
|
||||||
import zeroecho.pki.impl.core.DefaultIssuanceService;
|
import zeroecho.pki.impl.core.DefaultIssuanceService;
|
||||||
import zeroecho.pki.impl.core.DefaultRevocationService;
|
import zeroecho.pki.impl.core.DefaultRevocationService;
|
||||||
import zeroecho.pki.impl.core.DefaultStatusObjectService;
|
import zeroecho.pki.impl.core.DefaultStatusObjectService;
|
||||||
|
import zeroecho.pki.impl.core.StoreBackedEffectiveCredentialStatusResolver;
|
||||||
import zeroecho.pki.impl.core.async.PkiSigningBus;
|
import zeroecho.pki.impl.core.async.PkiSigningBus;
|
||||||
import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
|
import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
|
||||||
import zeroecho.pki.impl.audit.InMemoryAuditSink;
|
import zeroecho.pki.impl.audit.InMemoryAuditSink;
|
||||||
@@ -88,6 +91,7 @@ public final class PkiTestRuntime implements AutoCloseable {
|
|||||||
|
|
||||||
private final CredentialFramework framework;
|
private final CredentialFramework framework;
|
||||||
private final CredentialIssuerBackend issuerBackend;
|
private final CredentialIssuerBackend issuerBackend;
|
||||||
|
private final EffectiveCredentialStatusResolver statusResolver;
|
||||||
|
|
||||||
private final CaService caService;
|
private final CaService caService;
|
||||||
private final CertificationRequestService certificationRequestService;
|
private final CertificationRequestService certificationRequestService;
|
||||||
@@ -107,17 +111,19 @@ public final class PkiTestRuntime implements AutoCloseable {
|
|||||||
this.auditSink = new InMemoryAuditSink();
|
this.auditSink = new InMemoryAuditSink();
|
||||||
this.framework = framework;
|
this.framework = framework;
|
||||||
this.issuerBackend = issuerBackend;
|
this.issuerBackend = issuerBackend;
|
||||||
|
Clock clock = Clock.systemUTC();
|
||||||
|
this.statusResolver = new StoreBackedEffectiveCredentialStatusResolver(store, clock);
|
||||||
this.publicKeysByKeyRef = publicKeysByKeyRef;
|
this.publicKeysByKeyRef = publicKeysByKeyRef;
|
||||||
this.publicKeyResolveHook = () -> {
|
this.publicKeyResolveHook = () -> {
|
||||||
};
|
};
|
||||||
|
|
||||||
this.certificationRequestService = new DefaultCertificationRequestService(store, framework);
|
this.certificationRequestService = new DefaultCertificationRequestService(store, framework);
|
||||||
this.issuanceService = new DefaultIssuanceService(store, framework, issuerBackend, auditSink);
|
this.issuanceService = new DefaultIssuanceService(store, framework, issuerBackend, auditSink, statusResolver);
|
||||||
this.revocationService = new DefaultRevocationService(store);
|
this.revocationService = new DefaultRevocationService(store, clock, auditSink);
|
||||||
this.statusObjectService = new DefaultStatusObjectService(store, framework);
|
this.statusObjectService = new DefaultStatusObjectService(store, framework, auditSink, statusResolver);
|
||||||
|
|
||||||
this.caService = new DefaultCaService(store, framework, issuerBackend, this::resolvePublicKeyInfo, signingBus,
|
this.caService = new DefaultCaService(store, framework, issuerBackend, this::resolvePublicKeyInfo, signingBus,
|
||||||
auditSink, "SHA256withRSA", signingTtl);
|
auditSink, statusResolver, "SHA256withRSA", signingTtl);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -276,19 +282,30 @@ public final class PkiTestRuntime implements AutoCloseable {
|
|||||||
return issuerBackend;
|
return issuerBackend;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public EffectiveCredentialStatusResolver statusResolver() {
|
||||||
|
return statusResolver;
|
||||||
|
}
|
||||||
|
|
||||||
public CaService caService() {
|
public CaService caService() {
|
||||||
return caService;
|
return caService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public CaService caService(CredentialFramework credentialFramework) {
|
public CaService caService(CredentialFramework credentialFramework) {
|
||||||
return new DefaultCaService(store, Objects.requireNonNull(credentialFramework, "credentialFramework"),
|
return new DefaultCaService(store, Objects.requireNonNull(credentialFramework, "credentialFramework"),
|
||||||
issuerBackend, this::resolvePublicKeyInfo, signingBus, auditSink, "SHA256withRSA",
|
issuerBackend, this::resolvePublicKeyInfo, signingBus, auditSink, statusResolver, "SHA256withRSA",
|
||||||
Duration.ofSeconds(2));
|
Duration.ofSeconds(2));
|
||||||
}
|
}
|
||||||
|
|
||||||
public CaService caService(CredentialIssuerBackend backend) {
|
public CaService caService(CredentialIssuerBackend backend) {
|
||||||
return new DefaultCaService(store, framework, Objects.requireNonNull(backend, "backend"),
|
return new DefaultCaService(store, framework, Objects.requireNonNull(backend, "backend"),
|
||||||
this::resolvePublicKeyInfo, signingBus, auditSink, "SHA256withRSA", Duration.ofSeconds(2));
|
this::resolvePublicKeyInfo, signingBus, auditSink, statusResolver, "SHA256withRSA",
|
||||||
|
Duration.ofSeconds(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
public CaService caService(CredentialIssuerBackend backend, EffectiveCredentialStatusResolver resolver) {
|
||||||
|
return new DefaultCaService(store, framework, Objects.requireNonNull(backend, "backend"),
|
||||||
|
this::resolvePublicKeyInfo, signingBus, auditSink, Objects.requireNonNull(resolver, "resolver"),
|
||||||
|
"SHA256withRSA", Duration.ofSeconds(2));
|
||||||
}
|
}
|
||||||
|
|
||||||
public CertificationRequestService certificationRequestService() {
|
public CertificationRequestService certificationRequestService() {
|
||||||
@@ -299,6 +316,12 @@ public final class PkiTestRuntime implements AutoCloseable {
|
|||||||
return issuanceService;
|
return issuanceService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public IssuanceService issuanceService(CredentialIssuerBackend backend,
|
||||||
|
EffectiveCredentialStatusResolver resolver) {
|
||||||
|
return new DefaultIssuanceService(store, framework, Objects.requireNonNull(backend, "backend"), auditSink,
|
||||||
|
Objects.requireNonNull(resolver, "resolver"));
|
||||||
|
}
|
||||||
|
|
||||||
public RevocationService revocationService() {
|
public RevocationService revocationService() {
|
||||||
return revocationService;
|
return revocationService;
|
||||||
}
|
}
|
||||||
@@ -307,6 +330,11 @@ public final class PkiTestRuntime implements AutoCloseable {
|
|||||||
return statusObjectService;
|
return statusObjectService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public StatusObjectService statusObjectService(EffectiveCredentialStatusResolver resolver) {
|
||||||
|
return new DefaultStatusObjectService(store, framework, auditSink, Objects.requireNonNull(resolver,
|
||||||
|
"resolver"));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a new empty attribute set suitable for test commands.
|
* Returns a new empty attribute set suitable for test commands.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user