feat(pki-server): add multi-CA security foundation

Add the durable multi-authority realm, scoped default-deny authorization,
approval and break-glass workflows, auditor views and disclosure policy.

Enforce all administration through the transport-neutral secured operation
gateway while preserving immutable PKI authority and future HTTP reuse.
This commit is contained in:
2026-08-04 18:46:00 +02:00
parent e997996316
commit 8a5cbb61b3
30 changed files with 4791 additions and 6 deletions

18
pki-server/build.gradle Normal file
View File

@@ -0,0 +1,18 @@
plugins {
id 'buildlogic.java-library-conventions'
id 'com.palantir.git-version'
}
group = 'org.egothor'
dependencies {
api project(':pki')
implementation project(':lib')
implementation platform('tools.jackson:jackson-bom:3.1.5')
implementation 'tools.jackson.core:jackson-core'
}
javadoc {
options.links("https://www.egothor.org/javadoc/zeroecho/lib")
options.links("https://www.egothor.org/javadoc/zeroecho/pki")
}

View File

@@ -0,0 +1,332 @@
/*******************************************************************************
* 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.server;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
/** Durable exact-operation multi-party approval service. */
@SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.FieldDeclarationsShouldBeAtStartOfClass",
"PMD.UseObjectForClearerAPI", "PMD.AvoidSynchronizedAtMethodLevel" })
public final class ApprovalService {
/** Approval lifecycle states. */
public enum State {
PENDING(1), APPROVED(2), REJECTED(3), EXPIRED(4), CANCELLED(5), EXECUTING(6), EXECUTED(7);
private final int code;
State(int code) { this.code = code; }
/** @return stable persistence code */ public int code() { return code; }
/** Resolves a stable persistence code. */
public static State fromCode(int code) {
for (State candidate : values()) if (candidate.code == code) return candidate;
throw new IllegalArgumentException("Unknown approval-state code");
}
}
/** Individual immutable approval choice. */
public enum Choice {
APPROVE(1), REJECT(2);
private final int code;
Choice(int code) { this.code = code; }
/** @return stable persistence code */ public int code() { return code; }
/** Resolves a stable persistence code. */
public static Choice fromCode(int code) {
return switch (code) { case 1 -> APPROVE; case 2 -> REJECT;
default -> throw new IllegalArgumentException("Unknown approval-choice code"); };
}
}
/**
* Immutable approval policy.
*
* @param policyId stable policy identity
* @param threshold required distinct approvals
* @param eligibleApprovers finite M-set, empty only when a required role defines eligibility
* @param requiredRoleTemplateIds eligible approver role-template IDs
* @param requesterSeparation whether the requester is barred from approving
* @param lifetime positive approval lifetime
* @param justificationRequired whether a nonblank justification is mandatory
*/
public record Policy(String policyId, int threshold, Set<String> eligibleApprovers,
Set<String> requiredRoleTemplateIds, boolean requesterSeparation, Duration lifetime,
boolean justificationRequired) {
/** Validates the finite N-of-M policy. */
public Policy {
Permission.requireId(policyId, "approval policy");
eligibleApprovers = Set.copyOf(Objects.requireNonNull(eligibleApprovers, "eligibleApprovers"));
requiredRoleTemplateIds = Set.copyOf(
Objects.requireNonNull(requiredRoleTemplateIds, "requiredRoleTemplateIds"));
eligibleApprovers.forEach(Permission::requirePrincipal);
requiredRoleTemplateIds.forEach(value -> Permission.requireId(value, "role template"));
if (threshold <= 0 || eligibleApprovers.isEmpty() && requiredRoleTemplateIds.isEmpty()
|| !eligibleApprovers.isEmpty() && threshold > eligibleApprovers.size()) {
throw new IllegalArgumentException("Approval threshold or eligibility set is invalid");
}
Objects.requireNonNull(lifetime, "lifetime");
if (lifetime.isZero() || lifetime.isNegative() || lifetime.compareTo(Duration.ofDays(30)) > 0) {
throw new IllegalArgumentException("Approval lifetime is invalid");
}
}
/** Returns a stable commitment for this policy. */
public String commitment() {
List<String> approvers = eligibleApprovers.stream().sorted().toList();
List<String> roles = requiredRoleTemplateIds.stream().sorted().toList();
return digest(policyId + '|' + threshold + '|' + String.join(",", approvers) + '|'
+ String.join(",", roles) + '|' + requesterSeparation + '|' + lifetime.toSeconds()
+ '|' + justificationRequired);
}
}
/** One attributable immutable decision. */
public record Decision(String principalId, Choice choice, String justification, Instant decidedAt) {
/** Validates the decision. */
public Decision {
Permission.requirePrincipal(principalId);
Objects.requireNonNull(choice, "choice");
Permission.requireBounded(justification, 2048, "justification");
Objects.requireNonNull(decidedAt, "decidedAt");
}
}
/**
* Durable approval authority record.
*
* @param approvalId stable record identity
* @param operationId stable typed operation name
* @param operationCommitment exact safe operation commitment
* @param scope exact realm/authority/issuer/profile scope
* @param requesterPrincipalId requester identity
* @param createdAt creation time
* @param expiresAt expiry time
* @param policy approval policy
* @param policyCommitment exact policy commitment
* @param state lifecycle state
* @param decisions immutable attributable decisions
* @param resultClassification safe linked execution classification
*/
public record Request(String approvalId, String operationId, String operationCommitment,
Permission.Scope scope, String requesterPrincipalId, Instant createdAt, Instant expiresAt,
Policy policy, String policyCommitment, State state, List<Decision> decisions,
Optional<String> resultClassification) {
/** Validates and snapshots the durable record. */
public Request {
Permission.requireId(approvalId, "approval");
Permission.requireBounded(operationId, 256, "operation ID");
requireDigest(operationCommitment);
Objects.requireNonNull(scope, "scope");
Permission.requirePrincipal(requesterPrincipalId);
Objects.requireNonNull(createdAt, "createdAt");
Objects.requireNonNull(expiresAt, "expiresAt");
if (!expiresAt.isAfter(createdAt)) throw new IllegalArgumentException("Approval expiry is invalid");
Objects.requireNonNull(policy, "policy");
requireDigest(policyCommitment);
if (!policy.commitment().equals(policyCommitment)) {
throw new IllegalArgumentException("Approval-policy commitment mismatch");
}
Objects.requireNonNull(state, "state");
decisions = List.copyOf(Objects.requireNonNull(decisions, "decisions"));
resultClassification = Objects.requireNonNull(resultClassification, "resultClassification")
.map(value -> Permission.requireBounded(value, 256, "result classification"));
if (state == State.EXECUTED != resultClassification.isPresent()) {
throw new IllegalArgumentException("Executed approval result linkage is invalid");
}
}
}
private final ServerControlStore store;
private final Clock clock;
private final SafeAudit audit;
/** Creates a durable approval service over one server-control authority. */
public ApprovalService(ServerControlStore store, Clock clock, zeroecho.pki.spi.audit.AuditSink auditSink) {
this.store = Objects.requireNonNull(store, "store");
this.clock = Objects.requireNonNull(clock, "clock");
this.audit = new SafeAudit(clock, auditSink);
}
/** Creates a new pending exact-operation request. */
public synchronized Request request(String approvalId, String operationId, String operationCommitment,
Permission.Scope scope, String requesterPrincipalId, Policy policy) {
store.requirePrincipal(requesterPrincipalId);
Instant now = clock.instant();
Request request = new Request(approvalId, operationId, operationCommitment, scope, requesterPrincipalId,
now, now.plus(policy.lifetime()), policy, policy.commitment(), State.PENDING, List.of(),
Optional.empty());
store.createApproval(request);
audit.record("APPROVAL_REQUEST", requesterPrincipalId, Optional.empty(), Map.of("state", "PENDING"));
return request;
}
/** Records one distinct qualified decision and advances the durable state. */
public synchronized Request decide(String approvalId, String principalId,
Choice choice, String justification) {
SecurityPrincipal principal = store.requirePrincipal(principalId);
Request current = requireCurrent(approvalId);
requirePending(current);
if (!principal.enabled() || current.policy().requesterSeparation()
&& current.requesterPrincipalId().equals(principal.principalId())) {
throw new IllegalStateException("Principal is not eligible to decide this approval");
}
boolean direct = current.policy().eligibleApprovers().contains(principal.principalId());
boolean role = store.assignmentsFor(principal.principalId()).stream()
.filter(RoleTemplateCatalog.Assignment::enabled)
.filter(assignment -> contains(assignment.scope(), current.scope()))
.map(RoleTemplateCatalog.Assignment::templateId)
.anyMatch(current.policy().requiredRoleTemplateIds()::contains);
if (!direct && !role) throw new IllegalStateException("Principal is not an eligible approver");
if (current.decisions().stream().anyMatch(item -> item.principalId().equals(principal.principalId()))) {
throw new IllegalStateException("Principal has already decided this approval");
}
if (current.policy().justificationRequired() && (justification == null || justification.isBlank())) {
throw new IllegalArgumentException("Approval justification is required");
}
List<Decision> decisions = new ArrayList<>(current.decisions());
decisions.add(new Decision(principal.principalId(), choice,
Permission.requireBounded(justification, 2048, "justification"), clock.instant()));
decisions.sort(Comparator.comparing(Decision::decidedAt).thenComparing(Decision::principalId));
State state = choice == Choice.REJECT ? State.REJECTED
: decisions.stream().filter(item -> item.choice() == Choice.APPROVE).count()
>= current.policy().threshold() ? State.APPROVED : State.PENDING;
Request updated = copy(current, state, decisions, Optional.empty());
store.replaceApproval(current, updated);
audit.record("APPROVAL_DECISION", principal.principalId(), Optional.empty(), Map.of("state", state.name()));
return updated;
}
/** Cancels a pending request by its requester. */
public synchronized Request cancel(String approvalId, String requesterPrincipalId) {
Request current = requireCurrent(approvalId);
requirePending(current);
if (!current.requesterPrincipalId().equals(requesterPrincipalId)) {
throw new IllegalStateException("Only the requester may cancel a pending approval");
}
Request updated = copy(current, State.CANCELLED, current.decisions(), Optional.empty());
store.replaceApproval(current, updated);
audit.record("APPROVAL_CANCEL", requesterPrincipalId, Optional.empty(), Map.of("state", "CANCELLED"));
return updated;
}
/** Validates and durably claims an approved operation before dispatch. */
public synchronized Request claim(String approvalId, String operationId, String operationCommitment,
Permission.Scope scope) {
Request current = requireCurrent(approvalId);
if (!current.operationId().equals(operationId) || !current.operationCommitment().equals(operationCommitment)
|| !current.scope().equals(scope)) {
throw new IllegalStateException("Approval does not bind the requested operation");
}
current = expireIfNecessary(current);
if (current.state() != State.APPROVED) throw new IllegalStateException("Approval is not executable");
Request executing = copy(current, State.EXECUTING, current.decisions(), Optional.empty());
store.replaceApproval(current, executing);
return executing;
}
/** Durably links the safe result of one claimed execution. */
public synchronized Request complete(String approvalId, String safeResultClassification) {
Request current = requireCurrent(approvalId);
if (current.state() != State.EXECUTING) throw new IllegalStateException("Approval is not executing");
Request executed = copy(current, State.EXECUTED, current.decisions(),
Optional.of(Permission.requireBounded(safeResultClassification, 256, "result classification")));
store.replaceApproval(current, executed);
audit.record("APPROVAL_EXECUTION", current.requesterPrincipalId(), Optional.empty(),
Map.of("result", safeResultClassification));
return executed;
}
/** Returns the current record, applying durable expiry when necessary. */
public synchronized Request requireCurrent(String approvalId) {
Request current = store.requireApproval(approvalId);
return expireIfNecessary(current);
}
private Request expireIfNecessary(Request current) {
if ((current.state() == State.PENDING || current.state() == State.APPROVED)
&& !clock.instant().isBefore(current.expiresAt())) {
Request expired = copy(current, State.EXPIRED, current.decisions(), Optional.empty());
store.replaceApproval(current, expired);
audit.record("APPROVAL_EXPIRY", current.requesterPrincipalId(), Optional.empty(),
Map.of("state", "EXPIRED"));
return expired;
}
return current;
}
private static void requirePending(Request request) {
if (request.state() != State.PENDING) throw new IllegalStateException("Approval is not pending");
}
private static boolean contains(Permission.Scope grant, Permission.Scope request) {
return grant.realmId().equals(request.realmId())
&& grant.authorityId().map(value -> request.authorityId().filter(value::equals).isPresent())
.orElse(true)
&& grant.issuerId().map(value -> request.issuerId().filter(value::equals).isPresent()).orElse(true)
&& grant.profileId().map(value -> request.profileId().filter(value::equals).isPresent()).orElse(true);
}
private static Request copy(Request source, State state, List<Decision> decisions,
Optional<String> result) {
return new Request(source.approvalId(), source.operationId(), source.operationCommitment(), source.scope(),
source.requesterPrincipalId(), source.createdAt(), source.expiresAt(), source.policy(),
source.policyCommitment(), state, decisions, result);
}
/** Computes a deterministic SHA-256 hexadecimal commitment. */
public static String digest(String canonical) {
Objects.requireNonNull(canonical, "canonical");
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(canonical.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException("SHA-256 is unavailable", impossible);
}
}
private static void requireDigest(String value) {
if (value == null || !value.matches("[0-9a-f]{64}")) {
throw new IllegalArgumentException("Commitment is not canonical SHA-256");
}
}
}

View File

@@ -0,0 +1,143 @@
/*******************************************************************************
* 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.server;
import java.time.Clock;
import java.time.Instant;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import zeroecho.pki.api.PkiId;
/** Explicit certificate/request/status projection boundary for auditor data views. */
@SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.FieldDeclarationsShouldBeAtStartOfClass" })
public final class AuditorViews {
/** Safe source metadata from which authorized projections are produced. */
public record Source(PkiId objectId, Permission.ResourceType type, Permission.Scope scope, String lifecycleState,
Instant createdAt, String algorithmId, Optional<String> bindingId, String commitment,
boolean subjectPresent, int subjectAlternativeNameCount, Optional<String> fullSubject,
java.util.List<String> fullSubjectAlternativeNames, Optional<byte[]> content) {
/** Validates and snapshots finite source data. */
public Source {
Objects.requireNonNull(objectId, "objectId");
Objects.requireNonNull(type, "type");
Objects.requireNonNull(scope, "scope");
Permission.requireBounded(lifecycleState, 128, "lifecycle state");
Objects.requireNonNull(createdAt, "createdAt");
Permission.requireBounded(algorithmId, 1024, "algorithm identity");
bindingId = Objects.requireNonNull(bindingId, "bindingId");
if (commitment == null || !commitment.matches("[0-9a-f]{64}"))
throw new IllegalArgumentException("Object commitment is invalid");
if (subjectAlternativeNameCount < 0) throw new IllegalArgumentException("SAN count is negative");
fullSubject = Objects.requireNonNull(fullSubject, "fullSubject");
fullSubjectAlternativeNames = java.util.List.copyOf(
Objects.requireNonNull(fullSubjectAlternativeNames, "fullSubjectAlternativeNames"));
content = Objects.requireNonNull(content, "content").map(byte[]::clone);
}
}
/** Redacted metadata projection containing indicators rather than PII. */
public record Redacted(PkiId objectId, Permission.ResourceType type, Permission.Scope scope,
String lifecycleState, Instant createdAt, String algorithmId, Optional<String> bindingId,
String commitment, boolean subjectPresent, int subjectAlternativeNameCount) { }
/** Full non-PII metadata projection. */
public record FullMetadata(Redacted redacted, Map<String, String> safeMetadata) {
/** Defensively snapshots safe metadata. */
public FullMetadata {
Objects.requireNonNull(redacted, "redacted");
safeMetadata = Map.copyOf(Objects.requireNonNull(safeMetadata, "safeMetadata"));
}
}
/** Full binary content projection; byte content is never redacted in place. */
public record FullContent(FullMetadata metadata, byte[] content) {
/** Defensively snapshots content. */
public FullContent {
Objects.requireNonNull(metadata, "metadata");
content = Objects.requireNonNull(content, "content").clone();
}
/** Returns a defensive content copy. */ @Override public byte[] content() { return content.clone(); }
}
/** Explicit PII-enabled projection, separately authorized from content. */
public record Pii(FullMetadata metadata, Optional<String> subject, java.util.List<String> subjectAlternativeNames) {
/** Defensively snapshots PII data. */
public Pii {
Objects.requireNonNull(metadata, "metadata");
subject = Objects.requireNonNull(subject, "subject");
subjectAlternativeNames = java.util.List.copyOf(
Objects.requireNonNull(subjectAlternativeNames, "subjectAlternativeNames"));
}
}
private final SafeAudit audit;
/** Creates an auditor-view boundary using an injected clock and audit sink. */
public AuditorViews(Clock clock, zeroecho.pki.spi.audit.AuditSink auditSink) {
this.audit = new SafeAudit(clock, auditSink);
}
/** Returns a standard redacted view with no full subject, SAN, or DER. */
public Redacted redacted(Source source) {
Source exact = Objects.requireNonNull(source, "source");
return new Redacted(exact.objectId(), exact.type(), exact.scope(), exact.lifecycleState(), exact.createdAt(),
exact.algorithmId(), exact.bindingId(), exact.commitment(), exact.subjectPresent(),
exact.subjectAlternativeNameCount());
}
/** Returns full non-PII metadata after explicit authorization by the caller. */
public FullMetadata fullMetadata(Source source, boolean authorized) {
if (!authorized) throw new SecurityException("Auditor view is not authorized");
return new FullMetadata(redacted(source), Map.of("view", "METADATA_FULL"));
}
/** Returns full binary content only with separate content permission. */
public FullContent fullContent(Source source, boolean metadataFullAuthorized, boolean contentAuthorized) {
if (!contentAuthorized || source.content().isEmpty()) throw new SecurityException("Content view is unavailable");
return new FullContent(fullMetadata(source, metadataFullAuthorized), source.content().orElseThrow());
}
/** Returns PII only with explicit reason-bound authorization and records access safely. */
public Pii pii(Source source, SecurityPrincipal principal, String reasonReference,
boolean metadataFullAuthorized, boolean piiAuthorized) {
if (!metadataFullAuthorized || !piiAuthorized) throw new SecurityException("PII view is not authorized");
Permission.requireBounded(reasonReference, 256, "PII reason reference");
audit.record("PII_VIEW", principal.principalId(), Optional.of(source.objectId()),
Map.of("reasonReference", reasonReference));
return new Pii(fullMetadata(source, metadataFullAuthorized), source.fullSubject(),
source.fullSubjectAlternativeNames());
}
}

View File

@@ -0,0 +1,101 @@
/*******************************************************************************
* 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.server;
import java.util.Objects;
import java.util.Set;
import zeroecho.pki.api.PkiId;
/**
* Immutable process policy controlling which logical authorities one realm
* context may expose.
*
* @param mode exposure mode
* @param authorityIds exact exposed authorities for explicit mode
* @param authorityCreationPermitted whether realm-scoped authority creation is permitted
*/
public record AuthorityExposurePolicy(Mode mode, Set<PkiId> authorityIds,
boolean authorityCreationPermitted) {
/** Closed authority-exposure modes. */
public enum Mode {
/** Every authority in the realm session is eligible for scoped exposure. */
ALL_REALM_AUTHORITIES(1),
/** Only the exact configured authority identities are eligible. */
EXPLICIT_AUTHORITIES(2);
private final int code;
Mode(int code) {
this.code = code;
}
/** @return stable persistence code */
public int code() {
return code;
}
/** Resolves one stable persistence code. */
public static Mode fromCode(int code) {
return switch (code) {
case 1 -> ALL_REALM_AUTHORITIES;
case 2 -> EXPLICIT_AUTHORITIES;
default -> throw new IllegalArgumentException("Unknown authority exposure code");
};
}
}
/** Validates and defensively snapshots the exposure policy. */
public AuthorityExposurePolicy {
Objects.requireNonNull(mode, "mode");
authorityIds = Set.copyOf(Objects.requireNonNull(authorityIds, "authorityIds"));
if (mode == Mode.ALL_REALM_AUTHORITIES && !authorityIds.isEmpty()) {
throw new IllegalArgumentException("All-authority mode cannot carry an explicit set");
}
if (mode == Mode.EXPLICIT_AUTHORITIES && authorityIds.isEmpty()) {
throw new IllegalArgumentException("Explicit authority mode requires at least one authority");
}
}
/**
* Tests process exposure for one canonical authority.
*
* @param authorityId authority to test
* @return whether the process may expose it
*/
public boolean allows(PkiId authorityId) {
Objects.requireNonNull(authorityId, "authorityId");
return mode == Mode.ALL_REALM_AUTHORITIES || authorityIds.contains(authorityId);
}
}

View File

@@ -0,0 +1,217 @@
/*******************************************************************************
* 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.server;
import java.time.Clock;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
/**
* Deterministic side-effect-free default-deny authorization engine.
*
* <p>Evaluation is linear in the number of supplied grants and uses constant
* auxiliary memory. Callers supply an immutable snapshot of assignments for one
* decision; the engine never reads persistence or invokes external code.</p>
*/
@SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.FieldDeclarationsShouldBeAtStartOfClass" })
public final class AuthorizationEngine {
/** Safe decision codes that reveal no protected-object existence. */
public enum Code {
ALLOWED, NO_MATCHING_GRANT, EXPLICITLY_DENIED, OUTSIDE_REALM,
OUTSIDE_AUTHORITY_SCOPE, INSUFFICIENT_DATA_VIEW, APPROVAL_REQUIRED,
GRANT_EXPIRED, PRINCIPAL_DISABLED, CONDITION_NOT_SATISFIED
}
/**
* One complete authorization input.
*
* @param realmId active realm identity
* @param exposure process authority-exposure policy
* @param principal authenticated or public principal
* @param action requested action
* @param resource exact target reference
* @param relationship established object relationship
* @param dataView requested view
* @param context safe closed-condition context
* @param grants enabled assignments and grants captured for this decision
* @param breakGlassGrantIds grant identities originating from active break-glass records
*/
public record Request(RealmId realmId, AuthorityExposurePolicy exposure, SecurityPrincipal principal,
Permission.Action action, Permission.Resource resource, Permission.Relationship relationship,
Permission.DataView dataView, Permission.Context context, List<Permission.Grant> grants,
java.util.Set<String> breakGlassGrantIds) {
/** Validates and snapshots the authorization input. */
public Request {
Objects.requireNonNull(realmId, "realmId");
Objects.requireNonNull(exposure, "exposure");
Objects.requireNonNull(principal, "principal");
Objects.requireNonNull(action, "action");
Objects.requireNonNull(resource, "resource");
Objects.requireNonNull(relationship, "relationship");
Objects.requireNonNull(dataView, "dataView");
Objects.requireNonNull(context, "context");
grants = List.copyOf(Objects.requireNonNull(grants, "grants"));
breakGlassGrantIds = java.util.Set.copyOf(
Objects.requireNonNull(breakGlassGrantIds, "breakGlassGrantIds"));
}
}
/**
* Finite safe authorization result.
*
* @param code decision code
* @param usedBreakGlass whether the allowing grant came from break-glass authority
*/
public record Decision(Code code, boolean usedBreakGlass) {
/** Validates result invariants. */
public Decision {
Objects.requireNonNull(code, "code");
if (code != Code.ALLOWED && usedBreakGlass) {
throw new IllegalArgumentException("Denied decisions cannot report break-glass use");
}
}
/** @return whether execution is authorized */
public boolean allowed() {
return code == Code.ALLOWED;
}
}
private final Clock clock;
/**
* Creates an engine using an injected authoritative decision clock.
*
* @param clock injected clock
*/
public AuthorizationEngine(Clock clock) {
this.clock = Objects.requireNonNull(clock, "clock");
}
/**
* Evaluates one request with explicit-deny precedence.
*
* @param request complete immutable input
* @return safe finite decision
*/
public Decision authorize(Request request) {
Request exact = Objects.requireNonNull(request, "request");
if (!exact.realmId().equals(exact.resource().scope().realmId())) {
return denied(Code.OUTSIDE_REALM);
}
if (!exact.principal().enabled()) {
return denied(Code.PRINCIPAL_DISABLED);
}
if (exact.resource().scope().authorityId().isPresent()
&& !exact.exposure().allows(exact.resource().scope().authorityId().orElseThrow())) {
return denied(Code.OUTSIDE_AUTHORITY_SCOPE);
}
Instant now = clock.instant();
boolean matchingExpired = false;
boolean matchingView = false;
boolean matchingCondition = false;
boolean approvalMissing = false;
Permission.Grant allowed = null;
for (Permission.Grant grant : exact.grants()) {
if (!basicMatch(exact, grant)) {
continue;
}
if (grant.expiresAt().filter(expiry -> !now.isBefore(expiry)).isPresent()) {
matchingExpired = true;
continue;
}
if (!grant.dataView().permits(exact.dataView())) {
matchingView = true;
continue;
}
if (!conditionsMatch(grant, exact)) {
matchingCondition = true;
approvalMissing |= grant.conditions().contains(Permission.Condition.APPROVAL_REQUIRED)
&& !exact.context().approvalPresent();
continue;
}
if (grant.effect() == Permission.Effect.DENY) {
return denied(Code.EXPLICITLY_DENIED);
}
allowed = grant;
}
if (allowed != null) {
return new Decision(Code.ALLOWED, exact.breakGlassGrantIds().contains(allowed.grantId()));
}
if (matchingExpired) return denied(Code.GRANT_EXPIRED);
if (matchingView) return denied(Code.INSUFFICIENT_DATA_VIEW);
if (approvalMissing) return denied(Code.APPROVAL_REQUIRED);
if (matchingCondition) return denied(Code.CONDITION_NOT_SATISFIED);
return denied(Code.NO_MATCHING_GRANT);
}
private static boolean basicMatch(Request request, Permission.Grant grant) {
if (!grant.enabled() || !grant.principalId().equals(request.principal().principalId())
|| grant.action() != request.action() || grant.resourceType() != request.resource().type()
|| !grant.scope().realmId().equals(request.realmId())) {
return false;
}
if (grant.scope().authorityId().isPresent()
&& !grant.scope().authorityId().equals(request.resource().scope().authorityId())) return false;
if (grant.scope().issuerId().isPresent()
&& !grant.scope().issuerId().equals(request.resource().scope().issuerId())) return false;
if (grant.scope().profileId().isPresent()
&& !grant.scope().profileId().equals(request.resource().scope().profileId())) return false;
return grant.relationship() == Permission.Relationship.ANY
|| request.relationship() == Permission.Relationship.OWN
&& request.resource().ownedBy(request.principal());
}
private static boolean conditionsMatch(Permission.Grant grant, Request request) {
for (Permission.Condition condition : grant.conditions()) {
boolean matches = switch (condition) {
case REASON_REQUIRED -> request.context().reason().isPresent();
case APPROVAL_REQUIRED -> request.context().approvalPresent();
case REQUESTER_APPROVER_SEPARATION -> request.context().safeAttributes()
.getOrDefault("requester", "").isBlank()
|| !request.principal().principalId().equals(
request.context().safeAttributes().get("requester"));
case AUTHORITY_ACTIVE -> request.context().authorityActive();
case PROFILE_MATCH_REQUIRED -> request.resource().scope().profileId().isPresent();
};
if (!matches) return false;
}
return true;
}
private static Decision denied(Code code) {
return new Decision(code, false);
}
}

View File

@@ -0,0 +1,183 @@
/*******************************************************************************
* 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.server;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/** Durable, scoped, mandatory-expiry emergency authorization service. */
@SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.FieldDeclarationsShouldBeAtStartOfClass",
"PMD.UseObjectForClearerAPI", "PMD.AvoidSynchronizedAtMethodLevel" })
public final class BreakGlassService {
/** Break-glass lifecycle states. */
public enum State {
ACTIVE(1), REVOKED(2), EXPIRED(3);
private final int code;
State(int code) { this.code = code; }
/** @return stable persistence code */ public int code() { return code; }
/** Resolves one stable persistence code. */
public static State fromCode(int code) {
return switch (code) { case 1 -> ACTIVE; case 2 -> REVOKED; case 3 -> EXPIRED;
default -> throw new IllegalArgumentException("Unknown break-glass state code"); };
}
}
/**
* Durable break-glass record.
*
* @param breakGlassId stable emergency grant identity
* @param principalId grantee
* @param grant exact scoped permission grant
* @param reason mandatory reason
* @param issuerPrincipalId independent issuer identity
* @param approvalId optional independent approval authority
* @param createdAt creation time
* @param activatedAt activation time
* @param expiresAt mandatory expiry
* @param state lifecycle state
* @param auditCommitment stable safe commitment
*/
public record Record(String breakGlassId, String principalId, Permission.Grant grant, String reason,
String issuerPrincipalId, Optional<String> approvalId, Instant createdAt, Instant activatedAt,
Instant expiresAt, State state, String auditCommitment) {
/** Validates the strict time-limited scoped record. */
public Record {
Permission.requireId(breakGlassId, "break-glass");
Permission.requirePrincipal(principalId);
Objects.requireNonNull(grant, "grant");
if (!grant.principalId().equals(principalId) || grant.effect() != Permission.Effect.ALLOW) {
throw new IllegalArgumentException("Break-glass grant must be an explicit scoped allow");
}
Permission.requireBounded(reason, 2048, "break-glass reason");
Permission.requirePrincipal(issuerPrincipalId);
if (issuerPrincipalId.equals(principalId)) {
throw new IllegalArgumentException("Break-glass issuer and grantee must differ");
}
approvalId = Objects.requireNonNull(approvalId, "approvalId");
Objects.requireNonNull(createdAt, "createdAt");
Objects.requireNonNull(activatedAt, "activatedAt");
Objects.requireNonNull(expiresAt, "expiresAt");
if (activatedAt.isBefore(createdAt) || !expiresAt.isAfter(activatedAt)
|| Duration.between(activatedAt, expiresAt).compareTo(Duration.ofHours(24)) > 0) {
throw new IllegalArgumentException("Break-glass lifetime is invalid");
}
Objects.requireNonNull(state, "state");
if (auditCommitment == null || !auditCommitment.matches("[0-9a-f]{64}")) {
throw new IllegalArgumentException("Break-glass audit commitment is invalid");
}
}
}
/** Active grants plus their identities for authorization and use auditing. */
public record ActiveGrants(List<Permission.Grant> grants, java.util.Set<String> grantIds) {
/** Defensively snapshots active grants. */
public ActiveGrants {
grants = List.copyOf(Objects.requireNonNull(grants, "grants"));
grantIds = java.util.Set.copyOf(Objects.requireNonNull(grantIds, "grantIds"));
}
}
private final ServerControlStore store;
private final Clock clock;
private final SafeAudit audit;
/** Creates a durable break-glass service. */
public BreakGlassService(ServerControlStore store, Clock clock, zeroecho.pki.spi.audit.AuditSink auditSink) {
this.store = Objects.requireNonNull(store, "store");
this.clock = Objects.requireNonNull(clock, "clock");
this.audit = new SafeAudit(clock, auditSink);
}
/** Creates and immediately activates one independently issued emergency grant. */
public synchronized Record activate(String breakGlassId, String principalId, Permission.Grant grant, String reason,
String issuerPrincipalId, Optional<String> approvalId, Instant expiresAt) {
store.requirePrincipal(principalId);
store.requirePrincipal(issuerPrincipalId);
Instant now = clock.instant();
String commitment = ApprovalService.digest(breakGlassId + '|' + principalId + '|' + grant.grantId()
+ '|' + issuerPrincipalId + '|' + now + '|' + expiresAt);
Record record = new Record(breakGlassId, principalId, grant, reason, issuerPrincipalId, approvalId,
now, now, expiresAt, State.ACTIVE, commitment);
store.createBreakGlass(record);
audit.record("BREAK_GLASS_ACTIVATE", issuerPrincipalId, Optional.empty(), Map.of("state", "ACTIVE"));
return record;
}
/** Revokes an active grant. */
public synchronized Record revoke(String breakGlassId, String actorPrincipalId) {
Record current = requireCurrent(breakGlassId);
if (current.state() != State.ACTIVE) throw new IllegalStateException("Break-glass grant is not active");
Record revoked = copy(current, State.REVOKED);
store.replaceBreakGlass(current, revoked);
audit.record("BREAK_GLASS_REVOKE", actorPrincipalId, Optional.empty(), Map.of("state", "REVOKED"));
return revoked;
}
/** Returns a current record, durably applying expiry when observed. */
public synchronized Record requireCurrent(String breakGlassId) {
Record current = store.requireBreakGlass(breakGlassId);
if (current.state() == State.ACTIVE && !clock.instant().isBefore(current.expiresAt())) {
Record expired = copy(current, State.EXPIRED);
store.replaceBreakGlass(current, expired);
audit.record("BREAK_GLASS_EXPIRY", current.principalId(), Optional.empty(), Map.of("state", "EXPIRED"));
return expired;
}
return current;
}
/** Returns current active grants for one principal and enforces expiry after reopen. */
public synchronized ActiveGrants activeFor(String principalId) {
Permission.requirePrincipal(principalId);
List<Record> active = store.breakGlassFor(principalId).stream().map(item -> requireCurrent(item.breakGlassId()))
.filter(item -> item.state() == State.ACTIVE).toList();
return new ActiveGrants(active.stream().map(Record::grant).toList(),
active.stream().map(item -> item.grant().grantId()).collect(java.util.stream.Collectors.toSet()));
}
/** Records use without exposing grant internals or reason. */
public void auditUse(String principalId) {
audit.record("BREAK_GLASS_USE", principalId, Optional.empty(), Map.of("used", "true"));
}
private static Record copy(Record source, State state) {
return new Record(source.breakGlassId(), source.principalId(), source.grant(), source.reason(),
source.issuerPrincipalId(), source.approvalId(), source.createdAt(), source.activatedAt(),
source.expiresAt(), state, source.auditCommitment());
}
}

View File

@@ -0,0 +1,313 @@
/*******************************************************************************
* 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.server;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Instant;
import java.util.HexFormat;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import zeroecho.pki.api.PkiId;
/** Durable disclosure authority and capability-based retrieval decision service. */
@SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.AvoidLiteralsInIfCondition",
"PMD.FieldDeclarationsShouldBeAtStartOfClass", "PMD.AvoidSynchronizedAtMethodLevel" })
public final class DisclosureService {
/** Persisted disclosure states, independent of issuance and publication. */
public enum Policy {
PUBLIC(1), PUBLIC_UNLISTED(2), AUTHENTICATED(3), OWNER_ONLY(4), RESTRICTED(5), NOT_PUBLISHED(6);
private final int code;
Policy(int code) { this.code = code; }
/** @return stable persistence code */ public int code() { return code; }
/** Resolves a stable persistence code. */
public static Policy fromCode(int code) {
for (Policy candidate : values()) if (candidate.code == code) return candidate;
throw new IllegalArgumentException("Unknown disclosure-policy code");
}
}
/** Public-object classes with independent default policies. */
public enum ObjectType {
CA_CERTIFICATE(1), CA_CHAIN(2), LEAF_CERTIFICATE(3), CRL(4), STATUS_OBJECT(5);
private final int code;
ObjectType(int code) { this.code = code; }
/** @return stable persistence code */ public int code() { return code; }
/** Resolves a stable persistence code. */
public static ObjectType fromCode(int code) {
for (ObjectType candidate : values()) if (candidate.code == code) return candidate;
throw new IllegalArgumentException("Unknown disclosure object-type code");
}
}
/**
* Configuration-driven safe defaults.
*
* @param rootCa root certificate default
* @param intermediateCa intermediate certificate default
* @param caChain chain default
* @param crl CRL default
* @param leaf leaf default
* @param sensitiveLeaf sensitive leaf default
*/
public record Defaults(Policy rootCa, Policy intermediateCa, Policy caChain, Policy crl,
Policy leaf, Policy sensitiveLeaf) {
/** Validates non-null defaults and prohibits public sensitive-leaf defaults. */
public Defaults {
Objects.requireNonNull(rootCa, "rootCa");
Objects.requireNonNull(intermediateCa, "intermediateCa");
Objects.requireNonNull(caChain, "caChain");
Objects.requireNonNull(crl, "crl");
Objects.requireNonNull(leaf, "leaf");
Objects.requireNonNull(sensitiveLeaf, "sensitiveLeaf");
if (leaf == Policy.PUBLIC || sensitiveLeaf == Policy.PUBLIC
|| sensitiveLeaf == Policy.PUBLIC_UNLISTED) {
throw new IllegalArgumentException("Leaf defaults must not silently expose certificate data");
}
}
/** @return normative recommended defaults */
public static Defaults recommended() {
return new Defaults(Policy.PUBLIC, Policy.PUBLIC, Policy.PUBLIC, Policy.PUBLIC,
Policy.OWNER_ONLY, Policy.RESTRICTED);
}
}
/** Durable policy bound to exact object and profile/policy commitment. */
public record Record(PkiId objectId, ObjectType objectType, Policy policy, Optional<String> ownerPrincipalId,
String policyCommitment, Instant updatedAt) {
/** Validates the durable disclosure record. */
public Record {
Objects.requireNonNull(objectId, "objectId");
Objects.requireNonNull(objectType, "objectType");
Objects.requireNonNull(policy, "policy");
ownerPrincipalId = Objects.requireNonNull(ownerPrincipalId, "ownerPrincipalId")
.map(Permission::requirePrincipal);
if (policyCommitment == null || !policyCommitment.matches("[0-9a-f]{64}")) {
throw new IllegalArgumentException("Disclosure policy commitment is invalid");
}
Objects.requireNonNull(updatedAt, "updatedAt");
if (objectType == ObjectType.LEAF_CERTIFICATE && policy == Policy.OWNER_ONLY
&& ownerPrincipalId.isEmpty()) {
throw new IllegalArgumentException("Owner-only leaf disclosure requires an owner");
}
}
}
/** Persisted commitment-only capability authority. */
public record Capability(String capabilityId, RealmId realmId, PkiId objectId, String action,
byte[] tokenCommitment, Instant expiresAt, boolean revoked) {
/** Validates and snapshots capability metadata. */
public Capability {
Permission.requireId(capabilityId, "capability");
Objects.requireNonNull(realmId, "realmId");
Objects.requireNonNull(objectId, "objectId");
Permission.requireBounded(action, 128, "capability action");
tokenCommitment = Objects.requireNonNull(tokenCommitment, "tokenCommitment").clone();
if (tokenCommitment.length != 32) throw new IllegalArgumentException("Capability commitment length");
Objects.requireNonNull(expiresAt, "expiresAt");
}
/** Returns a defensive commitment copy. */
@Override public byte[] tokenCommitment() { return tokenCommitment.clone(); }
}
/** One-time raw-token issuance result. */
public record IssuedCapability(Capability capability, byte[] token) {
/** Validates and snapshots the one-time token. */
public IssuedCapability {
Objects.requireNonNull(capability, "capability");
token = Objects.requireNonNull(token, "token").clone();
if (token.length != 32) throw new IllegalArgumentException("Capability token length");
}
/** Returns a defensive one-time token copy. */
@Override public byte[] token() { return token.clone(); }
}
/** Safe retrieval result that reveals no protected-object existence. */
public enum Decision { ALLOWED, DENIED }
private final RealmId realmId;
private final ServerControlStore store;
private final Defaults defaults;
private final Clock clock;
private final SecureRandom random;
private final SafeAudit audit;
/** Creates a durable disclosure service with an injected random source and clock. */
public DisclosureService(RealmId realmId, ServerControlStore store, Defaults defaults, Clock clock,
SecureRandom random, zeroecho.pki.spi.audit.AuditSink auditSink) {
this.realmId = Objects.requireNonNull(realmId, "realmId");
this.store = Objects.requireNonNull(store, "store");
this.defaults = Objects.requireNonNull(defaults, "defaults");
this.clock = Objects.requireNonNull(clock, "clock");
this.random = Objects.requireNonNull(random, "random");
this.audit = new SafeAudit(clock, auditSink);
}
/** Returns the default policy for one object classification. */
public Policy defaultPolicy(ObjectType type, boolean rootCa, boolean sensitive) {
Objects.requireNonNull(type, "type");
return switch (type) {
case CA_CERTIFICATE -> rootCa ? defaults.rootCa() : defaults.intermediateCa();
case CA_CHAIN -> defaults.caChain();
case CRL -> defaults.crl();
case LEAF_CERTIFICATE -> sensitive ? defaults.sensitiveLeaf() : defaults.leaf();
case STATUS_OBJECT -> Policy.RESTRICTED;
};
}
/** Persists the first disclosure record without touching PKI content. */
public synchronized Record register(PkiId objectId, ObjectType type, Policy policy,
Optional<String> ownerPrincipalId,
String profileOrPolicyCommitment, String actorPrincipalId) {
ownerPrincipalId.ifPresent(store::requirePrincipal);
Record record = new Record(objectId, type, policy, ownerPrincipalId,
requireDigest(profileOrPolicyCommitment), clock.instant());
store.createDisclosure(record);
audit.record("DISCLOSURE_REGISTER", actorPrincipalId, Optional.of(objectId), Map.of("policy", policy.name()));
return record;
}
/** Changes only disclosure metadata; increased exposure may require approval. */
public synchronized Record change(PkiId objectId, Policy policy, boolean increasedExposureApprovalRequired,
boolean approvalPresented, String actorPrincipalId) {
Record current = store.requireDisclosure(objectId);
if (increasedExposureApprovalRequired && exposure(policy) < exposure(current.policy())
&& !approvalPresented) {
throw new IllegalStateException("Increased disclosure requires approval");
}
Record updated = new Record(current.objectId(), current.objectType(), policy, current.ownerPrincipalId(),
current.policyCommitment(), clock.instant());
store.replaceDisclosure(current, updated);
audit.record("DISCLOSURE_CHANGE", actorPrincipalId, Optional.of(objectId), Map.of("policy", policy.name()));
return updated;
}
/** Evaluates direct retrieval separately from search authorization. */
public synchronized Decision decide(PkiId objectId, Optional<SecurityPrincipal> principal,
boolean ownerRelationship,
boolean explicitAdministrativePermission, Optional<byte[]> capabilityToken) {
Record record = store.requireDisclosure(objectId);
return switch (record.policy()) {
case PUBLIC -> Decision.ALLOWED;
case PUBLIC_UNLISTED -> capabilityToken.filter(token -> validCapability(record.objectId(), token))
.isPresent() ? Decision.ALLOWED : Decision.DENIED;
case AUTHENTICATED -> principal.filter(SecurityPrincipal::enabled)
.filter(value -> value.type() != SecurityPrincipal.Type.PUBLIC)
.map(ignored -> Decision.ALLOWED).orElse(Decision.DENIED);
case OWNER_ONLY -> ownerRelationship || explicitAdministrativePermission
? Decision.ALLOWED : Decision.DENIED;
case RESTRICTED -> explicitAdministrativePermission ? Decision.ALLOWED : Decision.DENIED;
case NOT_PUBLISHED -> explicitAdministrativePermission || ownerRelationship
? Decision.ALLOWED : Decision.DENIED;
};
}
/** Issues 256 random capability bits and persists only their commitment. */
public synchronized IssuedCapability issueCapability(PkiId objectId, Instant expiresAt,
String actorPrincipalId) {
Record record = store.requireDisclosure(objectId);
if (record.policy() != Policy.PUBLIC_UNLISTED || !expiresAt.isAfter(clock.instant())) {
throw new IllegalStateException("Capability issuance is unavailable for this object");
}
byte[] token = new byte[32];
byte[] identity = new byte[16];
random.nextBytes(token);
random.nextBytes(identity);
String capabilityId = "cap-" + HexFormat.of().formatHex(identity);
Capability capability = new Capability(capabilityId, realmId, objectId, "RETRIEVE",
capabilityCommitment(objectId, expiresAt, token), expiresAt, false);
store.createCapability(capability);
audit.record("CAPABILITY_ISSUE", actorPrincipalId, Optional.of(objectId), Map.of("issued", "true"));
return new IssuedCapability(capability, token);
}
/** Revokes a capability without revealing or recovering its raw token. */
public synchronized Capability revokeCapability(String capabilityId, String actorPrincipalId) {
Capability current = store.requireCapability(capabilityId);
Capability revoked = new Capability(current.capabilityId(), current.realmId(), current.objectId(),
current.action(), current.tokenCommitment(), current.expiresAt(), true);
store.replaceCapability(current, revoked);
audit.record("CAPABILITY_REVOKE", actorPrincipalId, Optional.of(current.objectId()), Map.of("revoked", "true"));
return revoked;
}
private boolean validCapability(PkiId objectId, byte[] token) {
if (token == null || token.length != 32) return false;
for (Capability capability : store.capabilitiesFor(objectId)) {
if (!capability.revoked() && capability.realmId().equals(realmId)
&& "RETRIEVE".equals(capability.action())
&& clock.instant().isBefore(capability.expiresAt())
&& MessageDigest.isEqual(capability.tokenCommitment(),
capabilityCommitment(objectId, capability.expiresAt(), token))) return true;
}
return false;
}
private byte[] capabilityCommitment(PkiId objectId, Instant expiry, byte[] token) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
digest.update(realmId.value().getBytes(StandardCharsets.UTF_8));
digest.update((byte) 0);
digest.update(objectId.value().getBytes(StandardCharsets.UTF_8));
digest.update((byte) 0);
digest.update(ByteBuffer.allocate(Long.BYTES).putLong(expiry.toEpochMilli()).array());
digest.update(token);
return digest.digest();
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException("SHA-256 is unavailable", impossible);
}
}
private static int exposure(Policy policy) {
return switch (policy) {
case PUBLIC -> 0; case PUBLIC_UNLISTED -> 1; case AUTHENTICATED -> 2;
case OWNER_ONLY -> 3; case RESTRICTED -> 4; case NOT_PUBLISHED -> 5;
};
}
private static String requireDigest(String value) {
if (value == null || !value.matches("[0-9a-f]{64}")) {
throw new IllegalArgumentException("Policy commitment is invalid");
}
return value;
}
}

View File

@@ -0,0 +1,232 @@
/*******************************************************************************
* 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.server;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import zeroecho.pki.application.PkiOperation;
/** Immutable security descriptors keyed by existing typed-operation identities. */
@SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.FieldDeclarationsShouldBeAtStartOfClass" })
public final class OperationSecurityDescriptors {
/** Approval treatment for one operation. */
public enum ApprovalCategory { NONE, HIGH_RISK }
/** Future transport eligibility without defining a transport protocol. */
public enum Eligibility { ADMINISTRATIVE, PUBLIC_READ }
/**
* Immutable operation security descriptor.
*
* @param operationId stable typed-operation identity
* @param action required permission
* @param resourceType required resource type
* @param mutating whether the operation may commit state
* @param dataView required data view
* @param approvalCategory approval treatment
* @param eligibility future transport eligibility metadata
*/
public record Descriptor(String operationId, Permission.Action action, Permission.ResourceType resourceType,
boolean mutating, Permission.DataView dataView, ApprovalCategory approvalCategory,
Eligibility eligibility) {
/** Validates one finite descriptor. */
public Descriptor {
Permission.requireBounded(operationId, 256, "operation ID");
Objects.requireNonNull(action, "action");
Objects.requireNonNull(resourceType, "resourceType");
Objects.requireNonNull(dataView, "dataView");
Objects.requireNonNull(approvalCategory, "approvalCategory");
Objects.requireNonNull(eligibility, "eligibility");
}
}
private final Map<String, Descriptor> descriptors;
/** Creates the closed current gateway descriptor registry. */
public OperationSecurityDescriptors() {
this(List.of(
read(PkiOperation.ListAuthorities.NAME, Permission.Action.AUTHORITY_LIST, Permission.ResourceType.AUTHORITY),
read(PkiOperation.InspectAuthority.NAME, Permission.Action.AUTHORITY_READ, Permission.ResourceType.AUTHORITY),
mutate(PkiOperation.CreateAuthority.NAME, Permission.Action.AUTHORITY_CREATE, Permission.ResourceType.AUTHORITY, true),
mutate(PkiOperation.TransitionAuthority.NAME, Permission.Action.AUTHORITY_ACTIVATE, Permission.ResourceType.AUTHORITY, true),
read(PkiOperation.ListProfileVersions.NAME, Permission.Action.PROFILE_READ, Permission.ResourceType.PROFILE),
read(PkiOperation.InspectProfile.NAME, Permission.Action.PROFILE_READ, Permission.ResourceType.PROFILE),
mutate(PkiOperation.ActivateProfile.NAME, Permission.Action.PROFILE_ACTIVATE, Permission.ResourceType.PROFILE, true),
read(PkiOperation.InspectCredential.NAME, Permission.Action.CERTIFICATE_READ_METADATA, Permission.ResourceType.CERTIFICATE),
mutate(PkiOperation.IssueCredential.NAME, Permission.Action.CERTIFICATE_ISSUE, Permission.ResourceType.CERTIFICATE, false),
mutate(PkiOperation.RevokeCredential.NAME, Permission.Action.CERTIFICATE_REVOKE, Permission.ResourceType.REVOCATION, false),
read(PkiOperation.ReadRevocationHistory.NAME, Permission.Action.REVOCATION_HISTORY_READ, Permission.ResourceType.REVOCATION),
mutate(PkiOperation.GenerateStatus.NAME, Permission.Action.CRL_GENERATE, Permission.ResourceType.STATUS_OBJECT, false),
read(PkiOperation.InspectPublication.NAME, Permission.Action.PUBLICATION_READ, Permission.ResourceType.PUBLICATION),
mutate(PkiOperation.ProcessPublication.NAME, Permission.Action.PUBLICATION_PROCESS, Permission.ResourceType.PUBLICATION, false),
read(PkiOperation.ListAlgorithmBindings.NAME, Permission.Action.X509_BINDING_READ, Permission.ResourceType.X509_BINDING),
read(PkiOperation.InspectAlgorithmBinding.NAME, Permission.Action.X509_BINDING_READ, Permission.ResourceType.X509_BINDING)));
}
/** Creates a registry and rejects duplicate operation identities. */
public OperationSecurityDescriptors(List<Descriptor> values) {
Map<String, Descriptor> copy = new LinkedHashMap<>();
for (Descriptor descriptor : values) {
if (copy.putIfAbsent(descriptor.operationId(), descriptor) != null) {
throw new IllegalArgumentException("Duplicate operation security descriptor");
}
}
descriptors = Map.copyOf(copy);
}
/** Resolves one descriptor or fails closed. */
public Descriptor require(PkiOperation operation) {
Objects.requireNonNull(operation, "operation");
Descriptor descriptor = descriptors.get(operation.name());
if (descriptor == null) throw new SecurityException("Operation is not exposed by the server gateway");
validateOperationType(operation, descriptor);
return descriptor;
}
/** @return immutable descriptor map keyed by operation ID */
public Map<String, Descriptor> descriptors() {
return descriptors;
}
/** Computes an exact deterministic safe operation commitment. */
public String commitment(RealmId realmId, PkiOperation operation, Permission.Resource resource) {
Descriptor descriptor = require(operation);
return ApprovalService.digest(realmId.value() + '|' + descriptor.operationId() + '|'
+ canonicalArguments(operation) + '|' + canonicalResource(resource));
}
/** Verifies that caller-supplied security scope matches operation identities. */
public void validateResource(PkiOperation operation, Permission.Resource resource) {
Descriptor descriptor = require(operation);
if (descriptor.resourceType() != resource.type()) {
throw new SecurityException("Operation resource type is invalid");
}
boolean valid = switch (operation) {
case PkiOperation.InspectAuthority value -> matchesAuthorityAndObject(resource, value.caId());
case PkiOperation.TransitionAuthority value -> matchesAuthorityAndObject(resource, value.caId());
case PkiOperation.CreateAuthority ignored -> resource.scope().authorityId().isEmpty()
&& resource.objectId().isEmpty();
case PkiOperation.ListAuthorities ignored -> resource.scope().authorityId().isEmpty();
case PkiOperation.InspectProfile value -> resource.scope().profileId().filter(value.profileId()::equals).isPresent();
case PkiOperation.ListProfileVersions value -> resource.scope().profileId().filter(value.profileId()::equals).isPresent();
case PkiOperation.ActivateProfile value -> resource.scope().profileId().filter(value.profileId()::equals).isPresent();
case PkiOperation.InspectCredential value -> resource.objectId().filter(value.credentialId()::equals).isPresent()
&& resource.scope().authorityId().isPresent();
case PkiOperation.IssueCredential value -> resource.scope().authorityId().filter(value.issuerCaId()::equals).isPresent()
&& resource.scope().profileId().filter(value.profileId()::equals).isPresent();
case PkiOperation.RevokeCredential value -> resource.objectId().filter(value.credentialId()::equals).isPresent()
&& resource.scope().authorityId().isPresent();
case PkiOperation.ReadRevocationHistory value -> resource.objectId().filter(value.credentialId()::equals).isPresent()
&& resource.scope().authorityId().isPresent();
case PkiOperation.GenerateStatus value -> resource.scope().authorityId().filter(value.issuerCaId()::equals).isPresent();
case PkiOperation.InspectPublication value -> resource.objectId().filter(value.publicationId()::equals).isPresent()
&& resource.scope().authorityId().isPresent();
case PkiOperation.ProcessPublication value -> resource.objectId().filter(value.publicationId()::equals).isPresent()
&& resource.scope().authorityId().isPresent();
case PkiOperation.ListAlgorithmBindings ignored -> resource.scope().authorityId().isEmpty();
case PkiOperation.InspectAlgorithmBinding ignored -> resource.scope().authorityId().isEmpty();
default -> false;
};
if (!valid) throw new SecurityException("Operation security scope does not match typed arguments");
}
private static boolean matchesAuthorityAndObject(Permission.Resource resource, zeroecho.pki.api.PkiId id) {
return resource.scope().authorityId().filter(id::equals).isPresent()
&& resource.objectId().filter(id::equals).isPresent();
}
private static String canonicalArguments(PkiOperation operation) {
return switch (operation) {
case PkiOperation.ListAuthorities value -> "limit=" + value.limit();
case PkiOperation.InspectAuthority value -> "ca=" + atom(value.caId().value());
case PkiOperation.CreateAuthority value -> "format=" + atom(value.formatId().value()) + ";subject="
+ atom(value.subjectRef().value()) + ";profile=" + atom(value.profileId())
+ ";keyRef=" + atom(value.keyRef().value());
case PkiOperation.TransitionAuthority value -> "ca=" + atom(value.caId().value()) + ";state="
+ value.state().name() + ";reason=" + atom(value.reason());
case PkiOperation.ListProfileVersions value -> "profile=" + atom(value.profileId());
case PkiOperation.InspectProfile value -> "profile=" + atom(value.profileId()) + ";version=" + value.profileVersion();
case PkiOperation.ActivateProfile value -> "profile=" + atom(value.profileId()) + ";version=" + value.profileVersion();
case PkiOperation.InspectCredential value -> "credential=" + atom(value.credentialId().value());
case PkiOperation.IssueCredential value -> "issuer=" + atom(value.issuerCaId().value()) + ";request="
+ atom(value.requestId().value()) + ";profile=" + atom(value.profileId());
case PkiOperation.RevokeCredential value -> "credential=" + atom(value.credentialId().value())
+ ";reason=" + value.reason().name();
case PkiOperation.ReadRevocationHistory value -> "credential=" + atom(value.credentialId().value())
+ ";limit=" + value.limit();
case PkiOperation.GenerateStatus value -> "issuer=" + atom(value.issuerCaId().value()) + ";type="
+ value.type().name() + ";format=" + atom(value.formatId().value());
case PkiOperation.InspectPublication value -> "publication=" + atom(value.publicationId().value());
case PkiOperation.ProcessPublication value -> "publication=" + atom(value.publicationId().value());
case PkiOperation.ListAlgorithmBindings value -> "origin=" + atom(value.origin().orElse("")) + ";role="
+ atom(value.role().orElse("")) + ";limit=" + value.limit();
case PkiOperation.InspectAlgorithmBinding value -> "binding=" + atom(value.bindingId());
default -> throw new SecurityException("Operation has no canonical security encoding");
};
}
private static String canonicalResource(Permission.Resource resource) {
Permission.Scope scope = resource.scope();
return resource.type().name() + '|' + atom(scope.realmId().value()) + '|'
+ atom(scope.authorityId().map(zeroecho.pki.api.PkiId::value).orElse("")) + '|'
+ atom(scope.issuerId().map(zeroecho.pki.api.PkiId::value).orElse("")) + '|'
+ atom(scope.profileId().orElse("")) + '|'
+ atom(resource.objectId().map(zeroecho.pki.api.PkiId::value).orElse("")) + '|'
+ atom(resource.ownerPrincipalId().orElse(""));
}
private static String atom(String value) {
return ApprovalService.digest(value);
}
private static Descriptor read(String id, Permission.Action action, Permission.ResourceType type) {
return new Descriptor(id, action, type, false, Permission.DataView.METADATA_REDACTED,
ApprovalCategory.NONE, Eligibility.ADMINISTRATIVE);
}
private static Descriptor mutate(String id, Permission.Action action, Permission.ResourceType type,
boolean highRisk) {
return new Descriptor(id, action, type, true, Permission.DataView.METADATA_REDACTED,
highRisk ? ApprovalCategory.HIGH_RISK : ApprovalCategory.NONE, Eligibility.ADMINISTRATIVE);
}
private static void validateOperationType(PkiOperation operation, Descriptor descriptor) {
if (!operation.name().equals(descriptor.operationId())) {
throw new SecurityException("Operation identity and descriptor differ");
}
}
}

View File

@@ -0,0 +1,281 @@
/*******************************************************************************
* 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.server;
import java.time.Instant;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import zeroecho.pki.api.PkiId;
/** Closed permission vocabulary and immutable scoped grant model. */
@SuppressWarnings({ "PMD.ExcessivePublicCount", "PMD.ControlStatementBraces",
"PMD.AvoidLiteralsInIfCondition", "PMD.CommentDefaultAccessModifier" })
public final class Permission {
private Permission() {
}
/** Stable permission actions; semantic separation is part of the public contract. */
public enum Action {
REALM_READ(1), SERVER_HEALTH_READ(2), SERVER_CONFIGURATION_READ(3),
SERVER_CONFIGURATION_UPDATE(4), IDENTITY_PROVIDER_MANAGE(5), PRINCIPAL_MANAGE(6),
ROLE_MANAGE(7), PERMISSION_GRANT(8), AUTHORITY_LIST(20), AUTHORITY_READ(21),
AUTHORITY_CREATE(22), AUTHORITY_IMPORT(23), AUTHORITY_ACTIVATE(24), AUTHORITY_SUSPEND(25),
AUTHORITY_RETIRE(26), ISSUER_CREATE(27), ISSUER_ROTATE(28), ISSUER_RETIRE(29),
CA_CHAIN_DOWNLOAD(30), PROFILE_READ(40), PROFILE_REGISTER(41), PROFILE_VALIDATE(42),
PROFILE_ACTIVATE(43), PROFILE_DEACTIVATE(44), POLICY_READ(45), POLICY_UPDATE(46),
X509_BINDING_READ(47), X509_BINDING_PROVIDER_ENABLE(48), REQUEST_SUBMIT(60),
REQUEST_READ_OWN(61), REQUEST_READ_ANY(62), REQUEST_APPROVE(63), REQUEST_REJECT(64),
REQUEST_CANCEL(65), CERTIFICATE_ISSUE(70), CERTIFICATE_RENEW(71), CERTIFICATE_REKEY(72),
CERTIFICATE_READ_METADATA(73), CERTIFICATE_READ_CONTENT(74), CERTIFICATE_SEARCH(75),
CERTIFICATE_READ_PII(76), CERTIFICATE_DOWNLOAD(77), CERTIFICATE_PUBLICATION_CHANGE(78),
CERTIFICATE_REVOKE(80), CERTIFICATE_HOLD(81), CERTIFICATE_RELEASE_HOLD(82),
REVOCATION_HISTORY_READ(83), CRL_GENERATE(84), CRL_PUBLISH(85), CRL_DOWNLOAD(86),
OCSP_ADMINISTER(87), PUBLICATION_REGISTER(100), PUBLICATION_READ(101),
PUBLICATION_PROCESS(102), PUBLICATION_RETRY(103), PUBLICATION_RECONCILE(104),
AUDIT_READ_REDACTED(120), AUDIT_READ_FULL(121), AUDIT_READ_PII(122), AUDIT_EXPORT(123),
AUDIT_INTEGRITY_VERIFY(124), BACKUP_EXPORT(140), BACKUP_VERIFY(141), RESTORE_EXECUTE(142);
private final int code;
Action(int code) {
this.code = code;
}
/** @return stable persistence code */
public int code() {
return code;
}
/** Resolves a stable persistence code. */
public static Action fromCode(int code) {
for (Action candidate : values()) {
if (candidate.code == code) {
return candidate;
}
}
throw new IllegalArgumentException("Unknown permission action code");
}
}
/** Grant effect. */
public enum Effect {
ALLOW(1), DENY(2);
private final int code;
Effect(int code) { this.code = code; }
/** @return stable code */ public int code() { return code; }
/** Resolves a stable code. */
public static Effect fromCode(int code) {
return switch (code) { case 1 -> ALLOW; case 2 -> DENY;
default -> throw new IllegalArgumentException("Unknown grant effect code"); };
}
}
/** Security resource types, distinct from PKI authority ownership. */
public enum ResourceType {
REALM(1), SERVER_CONFIGURATION(2), PRINCIPAL(3), ROLE(4), GRANT(5), AUTHORITY(10),
ISSUER(11), PROFILE(12), POLICY(13), X509_BINDING(14), REQUEST(20), CERTIFICATE(21),
REVOCATION(22), STATUS_OBJECT(23), PUBLICATION(24), AUDIT(30), BACKUP(31), RESTORE(32),
DISCLOSURE(33), CAPABILITY(34);
private final int code;
ResourceType(int code) { this.code = code; }
/** @return stable code */ public int code() { return code; }
/** Resolves a stable code. */
public static ResourceType fromCode(int code) {
for (ResourceType candidate : values()) if (candidate.code == code) return candidate;
throw new IllegalArgumentException("Unknown resource type code");
}
}
/** Relationship between the requesting principal and the target object. */
public enum Relationship {
OWN(1), ANY(2);
private final int code;
Relationship(int code) { this.code = code; }
/** @return stable code */ public int code() { return code; }
/** Resolves a stable code. */
public static Relationship fromCode(int code) {
return switch (code) { case 1 -> OWN; case 2 -> ANY;
default -> throw new IllegalArgumentException("Unknown relationship code"); };
}
}
/** Explicitly separated disclosure and audit data views. */
public enum DataView {
METADATA_REDACTED(1), METADATA_FULL(2), CONTENT_FULL(3), PII_FULL(4);
private final int code;
DataView(int code) { this.code = code; }
/** @return stable code */ public int code() { return code; }
/** Resolves a stable code. */
public static DataView fromCode(int code) {
return switch (code) { case 1 -> METADATA_REDACTED; case 2 -> METADATA_FULL;
case 3 -> CONTENT_FULL; case 4 -> PII_FULL;
default -> throw new IllegalArgumentException("Unknown data-view code"); };
}
/** Tests view sufficiency without merging content and PII authority. */
public boolean permits(DataView requested) {
Objects.requireNonNull(requested, "requested");
return this == requested || this == METADATA_FULL && requested == METADATA_REDACTED;
}
}
/** Closed non-programmable grant conditions. */
public enum Condition {
REASON_REQUIRED(1), APPROVAL_REQUIRED(2), REQUESTER_APPROVER_SEPARATION(3),
AUTHORITY_ACTIVE(4), PROFILE_MATCH_REQUIRED(5);
private final int code;
Condition(int code) { this.code = code; }
/** @return stable code */ public int code() { return code; }
/** Resolves a stable code. */
public static Condition fromCode(int code) {
for (Condition candidate : values()) if (candidate.code == code) return candidate;
throw new IllegalArgumentException("Unknown condition code");
}
}
/**
* Canonical resource scope supplied independently of object contents.
*
* @param realmId realm identity
* @param authorityId explicit logical authority when applicable
* @param issuerId explicit issuer generation when applicable
* @param profileId explicit profile when applicable
*/
public record Scope(RealmId realmId, Optional<PkiId> authorityId, Optional<PkiId> issuerId,
Optional<String> profileId) {
/** Validates the exact finite scope. */
public Scope {
Objects.requireNonNull(realmId, "realmId");
authorityId = Objects.requireNonNull(authorityId, "authorityId");
issuerId = Objects.requireNonNull(issuerId, "issuerId");
profileId = Objects.requireNonNull(profileId, "profileId").map(Permission::requireProfile);
if (issuerId.isPresent() && authorityId.isEmpty()) {
throw new IllegalArgumentException("Issuer scope requires authority scope");
}
}
}
/**
* Transport-neutral target reference. Possession is never authorization.
*
* @param type resource type
* @param scope exact security scope
* @param objectId optional canonical object identity
* @param ownerPrincipalId optional owning principal identity
*/
public record Resource(ResourceType type, Scope scope, Optional<PkiId> objectId,
Optional<String> ownerPrincipalId) {
/** Validates the finite resource reference. */
public Resource {
Objects.requireNonNull(type, "type");
Objects.requireNonNull(scope, "scope");
objectId = Objects.requireNonNull(objectId, "objectId");
ownerPrincipalId = Objects.requireNonNull(ownerPrincipalId, "ownerPrincipalId")
.map(Permission::requirePrincipal);
}
/** @return whether the principal owns this object */
public boolean ownedBy(SecurityPrincipal principal) {
Objects.requireNonNull(principal, "principal");
return ownerPrincipalId.filter(principal.principalId()::equals).isPresent();
}
}
/**
* Immutable persisted permission grant.
*
* @param grantId stable grant identity
* @param principalId grantee identity
* @param effect allow or deny
* @param action exact action
* @param resourceType exact resource type
* @param scope exact non-wildcard realm and optional subscopes
* @param relationship own or any
* @param dataView maximum exact view
* @param conditions closed conditions
* @param expiresAt optional mandatory upper bound for temporary grants
* @param enabled whether the grant participates in decisions
*/
public record Grant(String grantId, String principalId, Effect effect, Action action,
ResourceType resourceType, Scope scope, Relationship relationship, DataView dataView,
Set<Condition> conditions, Optional<Instant> expiresAt, boolean enabled) {
/** Validates and snapshots the finite grant. */
public Grant {
requireId(grantId, "grant");
requirePrincipal(principalId);
Objects.requireNonNull(effect, "effect");
Objects.requireNonNull(action, "action");
Objects.requireNonNull(resourceType, "resourceType");
Objects.requireNonNull(scope, "scope");
Objects.requireNonNull(relationship, "relationship");
Objects.requireNonNull(dataView, "dataView");
conditions = Set.copyOf(Objects.requireNonNull(conditions, "conditions"));
expiresAt = Objects.requireNonNull(expiresAt, "expiresAt");
}
}
/** Safe finite context used only by closed conditions. */
public record Context(Optional<String> reason, boolean approvalPresent, boolean authorityActive,
Map<String, String> safeAttributes) {
/** Validates the finite condition context. */
public Context {
reason = Objects.requireNonNull(reason, "reason").map(value -> requireBounded(value, 1024, "reason"));
safeAttributes = Map.copyOf(Objects.requireNonNull(safeAttributes, "safeAttributes"));
if (safeAttributes.size() > 32) throw new IllegalArgumentException("Too many context attributes");
}
/** @return empty safe context */
public static Context empty() {
return new Context(Optional.empty(), false, false, Map.of());
}
}
static void requireId(String value, String label) {
if (value == null || !value.matches("[a-zA-Z0-9][a-zA-Z0-9._:-]{0,255}")) {
throw new IllegalArgumentException(label + " identity is not canonical");
}
}
static String requirePrincipal(String value) { requireId(value, "principal"); return value; }
static String requireProfile(String value) { return requireBounded(value, 256, "profile"); }
static String requireBounded(String value, int limit, String label) {
if (value == null || value.isBlank() || value.length() > limit) {
throw new IllegalArgumentException(label + " is invalid");
}
return value;
}
}

View File

@@ -0,0 +1,60 @@
/*******************************************************************************
* 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.server;
import java.util.Objects;
/**
* Canonical identity of one PKI realm.
*
* <p>The identity is deployment-defined and has no filesystem, listener, Java
* type, or store-name interpretation.</p>
*
* @param value canonical realm identifier
*/
public record RealmId(String value) {
/** Validates the canonical realm identifier. */
public RealmId {
Objects.requireNonNull(value, "value");
if (!value.matches("[a-z0-9][a-z0-9._-]{0,127}")) {
throw new IllegalArgumentException("Realm identity is not canonical");
}
}
/** Returns the canonical identifier. */
@Override
public String toString() {
return value;
}
}

View File

@@ -0,0 +1,254 @@
/*******************************************************************************
* 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.server;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import tools.jackson.core.JsonParser;
import tools.jackson.core.JsonToken;
import tools.jackson.core.TokenStreamFactory;
import tools.jackson.core.json.JsonFactory;
/** Strict deterministic loader for versioned built-in role templates. */
@SuppressWarnings({ "PMD.CyclomaticComplexity", "PMD.ControlStatementBraces",
"PMD.FieldDeclarationsShouldBeAtStartOfClass", "PMD.ExceptionAsFlowControl",
"PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace", "PMD.UseEnumCollections" })
public final class RoleTemplateCatalog {
/** Classpath resource holding the normative role templates. */
public static final String RESOURCE = "zeroecho/pki/server/security/role-templates-v1.json";
private static final int MAXIMUM_RESOURCE_BYTES = 131_072;
private static final TokenStreamFactory JSON = JsonFactory.builder().build();
/** Immutable role template that has no authority until scoped assignment. */
public record Template(String templateId, int version, Set<Permission.Action> actions) {
/** Validates the stable template. */
public Template {
Permission.requireId(templateId, "role template");
if (version <= 0) throw new IllegalArgumentException("Role template version must be positive");
actions = Set.copyOf(Objects.requireNonNull(actions, "actions"));
if (actions.isEmpty()) throw new IllegalArgumentException("Role template actions must not be empty");
}
}
/** Persistable scoped role assignment; the template itself grants nothing. */
public record Assignment(String assignmentId, String principalId, String templateId,
int templateVersion, Permission.Scope scope, boolean enabled) {
/** Validates the exact scoped assignment. */
public Assignment {
Permission.requireId(assignmentId, "assignment");
Permission.requirePrincipal(principalId);
Permission.requireId(templateId, "role template");
if (templateVersion <= 0) throw new IllegalArgumentException("Template version must be positive");
Objects.requireNonNull(scope, "scope");
}
}
private final List<Template> templates;
private RoleTemplateCatalog(List<Template> templates) {
this.templates = List.copyOf(templates);
}
/**
* Loads the built-in strict resource through the supplied class loader.
*
* @param loader class loader owning server resources
* @return immutable deterministic catalog
*/
public static RoleTemplateCatalog load(ClassLoader loader) {
Objects.requireNonNull(loader, "loader");
try (InputStream input = Objects.requireNonNull(loader.getResourceAsStream(RESOURCE),
"Built-in role-template resource is missing")) {
byte[] encoded = input.readNBytes(MAXIMUM_RESOURCE_BYTES + 1);
if (encoded.length > MAXIMUM_RESOURCE_BYTES) {
throw new IllegalArgumentException("Role-template resource exceeds its technical bound");
}
return parse(encoded);
} catch (IOException failure) {
throw new IllegalStateException("Role-template resource cannot be read", failure);
}
}
/**
* Parses one complete strict versioned resource.
*
* @param encoded bounded UTF-8 JSON document
* @return immutable deterministic catalog
*/
public static RoleTemplateCatalog parse(byte[] encoded) {
Objects.requireNonNull(encoded, "encoded");
if (encoded.length == 0 || encoded.length > MAXIMUM_RESOURCE_BYTES) {
throw new IllegalArgumentException("Role-template resource size is invalid");
}
try (JsonParser parser = JSON.createParser(encoded)) {
require(parser.nextToken(), JsonToken.START_OBJECT);
Integer schema = null;
List<Template> result = null;
while (parser.nextToken() != JsonToken.END_OBJECT) {
require(parser.currentToken(), JsonToken.PROPERTY_NAME);
String field = parser.currentName();
parser.nextToken();
if ("schemaVersion".equals(field) && schema == null) schema = parser.getIntValue();
else if ("roles".equals(field) && result == null) result = readTemplates(parser);
else throw new IllegalArgumentException("Unknown or duplicate role-template field");
}
if (parser.nextToken() != null || !Integer.valueOf(1).equals(schema) || result == null) {
throw new IllegalArgumentException("Role-template resource framing is invalid");
}
result.sort(Comparator.comparing(Template::templateId));
Set<String> ids = new HashSet<>();
if (result.size() != 15 || result.stream().anyMatch(item -> !ids.add(item.templateId()))) {
throw new IllegalArgumentException("Role-template set is incomplete or ambiguous");
}
return new RoleTemplateCatalog(result);
} catch (IOException | RuntimeException failure) {
if (failure instanceof IllegalArgumentException invalid) throw invalid;
throw new IllegalArgumentException("Role-template resource is malformed");
}
}
/** @return immutable templates ordered by stable ID */
public List<Template> templates() {
return templates;
}
/** Resolves one exact template. */
public Template require(String templateId, int version) {
return templates.stream().filter(item -> item.templateId().equals(templateId) && item.version() == version)
.findFirst().orElseThrow(() -> new IllegalArgumentException("Role template is unavailable"));
}
/**
* Expands one enabled scoped assignment to explicit immutable grants.
*
* @param assignment persisted assignment
* @return deterministic finite grants, or an empty list when disabled
*/
public List<Permission.Grant> instantiate(Assignment assignment) {
Assignment exact = Objects.requireNonNull(assignment, "assignment");
if (!exact.enabled()) return List.of();
Template template = require(exact.templateId(), exact.templateVersion());
return template.actions().stream().sorted(Comparator.comparingInt(Permission.Action::code))
.map(action -> new Permission.Grant(exact.assignmentId() + ":" + action.code(), exact.principalId(),
Permission.Effect.ALLOW, action, resourceType(action), exact.scope(),
relationship(template, action), dataView(action), Set.of(), java.util.Optional.empty(), true))
.toList();
}
private static Permission.ResourceType resourceType(Permission.Action action) {
String name = action.name();
if (name.startsWith("AUTHORITY")) return Permission.ResourceType.AUTHORITY;
if (name.startsWith("ISSUER") || name.startsWith("CA_CHAIN")) return Permission.ResourceType.ISSUER;
if (name.startsWith("PROFILE")) return Permission.ResourceType.PROFILE;
if (name.startsWith("POLICY")) return Permission.ResourceType.POLICY;
if (name.startsWith("X509_BINDING")) return Permission.ResourceType.X509_BINDING;
if (name.startsWith("REQUEST")) return Permission.ResourceType.REQUEST;
if (name.startsWith("CERTIFICATE")) return Permission.ResourceType.CERTIFICATE;
if (name.startsWith("REVOCATION")) return Permission.ResourceType.REVOCATION;
if (name.startsWith("CRL") || name.startsWith("OCSP")) return Permission.ResourceType.STATUS_OBJECT;
if (name.startsWith("PUBLICATION")) return Permission.ResourceType.PUBLICATION;
if (name.startsWith("AUDIT")) return Permission.ResourceType.AUDIT;
if (name.startsWith("BACKUP")) return Permission.ResourceType.BACKUP;
if (name.startsWith("RESTORE")) return Permission.ResourceType.RESTORE;
if (name.startsWith("PRINCIPAL")) return Permission.ResourceType.PRINCIPAL;
if (name.startsWith("ROLE")) return Permission.ResourceType.ROLE;
if (name.startsWith("PERMISSION")) return Permission.ResourceType.GRANT;
if (name.startsWith("SERVER_CONFIGURATION")) return Permission.ResourceType.SERVER_CONFIGURATION;
return Permission.ResourceType.REALM;
}
private static Permission.Relationship relationship(Template template, Permission.Action action) {
boolean requesterCredential = "requester".equals(template.templateId())
&& action.name().startsWith("CERTIFICATE_");
return action.name().endsWith("_OWN") || requesterCredential
? Permission.Relationship.OWN : Permission.Relationship.ANY;
}
private static Permission.DataView dataView(Permission.Action action) {
if (action == Permission.Action.CERTIFICATE_READ_CONTENT || action == Permission.Action.CERTIFICATE_DOWNLOAD
|| action == Permission.Action.CA_CHAIN_DOWNLOAD || action == Permission.Action.CRL_DOWNLOAD) {
return Permission.DataView.CONTENT_FULL;
}
if (action == Permission.Action.CERTIFICATE_READ_PII || action == Permission.Action.AUDIT_READ_PII) {
return Permission.DataView.PII_FULL;
}
if (action == Permission.Action.AUDIT_READ_FULL) return Permission.DataView.METADATA_FULL;
return Permission.DataView.METADATA_REDACTED;
}
private static List<Template> readTemplates(JsonParser parser) throws IOException {
require(parser.currentToken(), JsonToken.START_ARRAY);
List<Template> result = new ArrayList<>();
while (parser.nextToken() != JsonToken.END_ARRAY) {
require(parser.currentToken(), JsonToken.START_OBJECT);
String id = null;
Integer version = null;
Set<Permission.Action> actions = null;
while (parser.nextToken() != JsonToken.END_OBJECT) {
require(parser.currentToken(), JsonToken.PROPERTY_NAME);
String field = parser.currentName();
parser.nextToken();
if ("id".equals(field) && id == null) id = parser.getString();
else if ("version".equals(field) && version == null) version = parser.getIntValue();
else if ("actions".equals(field) && actions == null) actions = readActions(parser);
else throw new IllegalArgumentException("Unknown or duplicate role field");
}
if (id == null || version == null || actions == null) throw new IllegalArgumentException("Role incomplete");
result.add(new Template(id, version, actions));
}
return result;
}
private static Set<Permission.Action> readActions(JsonParser parser) throws IOException {
require(parser.currentToken(), JsonToken.START_ARRAY);
Set<Permission.Action> result = new HashSet<>();
while (parser.nextToken() != JsonToken.END_ARRAY) {
require(parser.currentToken(), JsonToken.VALUE_STRING);
Permission.Action action = Permission.Action.valueOf(parser.getString());
if (!result.add(action)) throw new IllegalArgumentException("Duplicate role action");
}
return result;
}
private static void require(JsonToken actual, JsonToken expected) {
if (actual != expected) throw new IllegalArgumentException("Unexpected role-template token");
}
}

View File

@@ -0,0 +1,63 @@
/*******************************************************************************
* 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.server;
import java.time.Clock;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.audit.AuditEvent;
import zeroecho.pki.api.audit.Principal;
import zeroecho.pki.api.audit.Purpose;
import zeroecho.pki.spi.audit.AuditSink;
/** Package-private safe server audit producer. */
@SuppressWarnings("PMD.CommentDefaultAccessModifier")
final class SafeAudit {
private final Clock clock;
private final AuditSink sink;
SafeAudit(Clock clock, AuditSink sink) {
this.clock = Objects.requireNonNull(clock, "clock");
this.sink = Objects.requireNonNull(sink, "sink");
}
void record(String action, String principalId, Optional<PkiId> objectId, Map<String, String> details) {
sink.record(new AuditEvent(clock.instant(), "SERVER_SECURITY", action,
new Principal("SERVER_PRINCIPAL", principalId), new Purpose("SERVER_CONTROL"),
Objects.requireNonNull(objectId, "objectId"), Optional.empty(), Map.copyOf(details)));
}
}

View File

@@ -0,0 +1,114 @@
/*******************************************************************************
* 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.server;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/**
* Transport-independent authenticated-identity target used by authorization.
* Authentication credentials and secrets are deliberately excluded.
*
* @param principalId canonical stable principal identity
* @param type closed principal type
* @param displayName safe administrative display label
* @param organization optional canonical owner/organization relationship
* @param attributes finite safe relationship attributes
* @param enabled whether authorization assignments may be considered
*/
public record SecurityPrincipal(String principalId, Type type, String displayName,
Optional<String> organization, Map<String, String> attributes, boolean enabled) {
/** Closed principal types. */
public enum Type {
/** Human administrative or requesting identity. */ USER(1),
/** Non-human authenticated service identity. */ SERVICE(2),
/** Protocol-specific account identity. */ ACCOUNT(3),
/** Unauthenticated public identity. */ PUBLIC(4);
private final int code;
Type(int code) {
this.code = code;
}
/** @return stable persistence code */
public int code() {
return code;
}
/** Resolves one stable persistence code. */
public static Type fromCode(int code) {
return switch (code) {
case 1 -> USER;
case 2 -> SERVICE;
case 3 -> ACCOUNT;
case 4 -> PUBLIC;
default -> throw new IllegalArgumentException("Unknown principal type code");
};
}
}
/** Validates and snapshots finite safe principal metadata. */
public SecurityPrincipal {
requireId(principalId);
Objects.requireNonNull(type, "type");
if (displayName == null || displayName.isBlank() || displayName.length() > 256) {
throw new IllegalArgumentException("Principal display name is invalid");
}
organization = Objects.requireNonNull(organization, "organization").map(SecurityPrincipal::requireText);
attributes = Map.copyOf(Objects.requireNonNull(attributes, "attributes"));
if (attributes.size() > 32 || attributes.entrySet().stream()
.anyMatch(entry -> entry.getKey().isBlank() || entry.getKey().length() > 64
|| entry.getValue().isBlank() || entry.getValue().length() > 256)) {
throw new IllegalArgumentException("Principal attributes are not finite canonical metadata");
}
if (type == Type.PUBLIC && (!"public".equals(principalId) || organization.isPresent())) {
throw new IllegalArgumentException("Public principal identity is fixed and unowned");
}
}
private static void requireId(String value) {
if (value == null || !value.matches("[a-zA-Z0-9][a-zA-Z0-9._:@-]{0,255}")) {
throw new IllegalArgumentException("Principal identity is not canonical");
}
}
private static String requireText(String value) {
if (value == null || value.isBlank() || value.length() > 256) {
throw new IllegalArgumentException("Principal relationship value is invalid");
}
return value;
}
}

View File

@@ -0,0 +1,669 @@
/*******************************************************************************
* 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.server;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.OptionalLong;
import java.util.Set;
import zeroecho.core.io.CancellationSignal;
import zeroecho.core.io.RepeatableContent;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.spi.store.MetadataCommitResult;
import zeroecho.pki.spi.store.MetadataCursor;
import zeroecho.pki.spi.store.MetadataKey;
import zeroecho.pki.spi.store.MetadataSnapshot;
import zeroecho.pki.spi.store.MetadataStoreId;
import zeroecho.pki.spi.store.MetadataTransaction;
import zeroecho.pki.spi.store.TransactionalMetadataStore;
/**
* Dedicated durable authority for finite server security and disclosure control
* metadata.
*
* <p>All public operations are synchronized to serialize compare-and-replace
* within one realm process. Cross-process exclusion and durability remain the
* responsibility of the composed {@link TransactionalMetadataStore}. No PKI
* certificate, CRL, CSR, key, secret, or unrestricted exception data is stored.</p>
*/
@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.CyclomaticComplexity",
"PMD.FieldDeclarationsShouldBeAtStartOfClass", "PMD.AvoidSynchronizedAtMethodLevel",
"PMD.ControlStatementBraces", "PMD.PreserveStackTrace", "PMD.CloseResource",
"PMD.UseEnumCollections", "PMD.CommentRequired", "PMD.UncommentedEmptyMethodBody",
"PMD.TooManyMethods" })
public final class ServerControlStore implements AutoCloseable {
/** Stable namespace for the realm record. */ public static final String REALM = "io.zeroecho.server.realm";
/** Stable namespace for principals. */ public static final String PRINCIPAL = "io.zeroecho.server.principal";
/** Stable namespace for scoped role assignments. */ public static final String ASSIGNMENT = "io.zeroecho.server.assignment";
/** Stable namespace for direct permission grants. */ public static final String GRANT = "io.zeroecho.server.grant";
/** Stable namespace for approvals. */ public static final String APPROVAL = "io.zeroecho.server.approval";
/** Stable namespace for break-glass records. */ public static final String BREAK_GLASS = "io.zeroecho.server.break-glass";
/** Stable namespace for disclosure records. */ public static final String DISCLOSURE = "io.zeroecho.server.disclosure";
/** Stable namespace for capability commitments. */ public static final String CAPABILITY = "io.zeroecho.server.capability";
private static final int MAGIC = 0x5a455331;
private static final int SCHEMA = 1;
private static final int MAXIMUM_RECORD_BYTES = 1_048_576;
private static final int MAXIMUM_STRING_BYTES = 16_384;
private static final int MAXIMUM_COLLECTION = 4_096;
private static final int KIND_REALM = 1;
private static final int KIND_PRINCIPAL = 2;
private static final int KIND_ASSIGNMENT = 3;
private static final int KIND_GRANT = 4;
private static final int KIND_APPROVAL = 5;
private static final int KIND_BREAK_GLASS = 6;
private static final int KIND_DISCLOSURE = 7;
private static final int KIND_CAPABILITY = 8;
/**
* Durable realm-control identity and commitments.
*
* @param realmId realm identity
* @param displayName safe administrative display name
* @param exposure authority exposure policy
* @param authorizationCommitment authorization configuration commitment
* @param approvalCommitment approval-policy commitment
* @param disclosureCommitment disclosure-default commitment
* @param controlStoreId exact metadata-store identity
*/
public record RealmRecord(RealmId realmId, String displayName, AuthorityExposurePolicy exposure,
String authorizationCommitment, String approvalCommitment, String disclosureCommitment,
MetadataStoreId controlStoreId) {
/** Validates finite realm control metadata. */
public RealmRecord {
Objects.requireNonNull(realmId, "realmId");
Permission.requireBounded(displayName, 256, "realm display name");
Objects.requireNonNull(exposure, "exposure");
requireDigest(authorizationCommitment);
requireDigest(approvalCommitment);
requireDigest(disclosureCommitment);
Objects.requireNonNull(controlStoreId, "controlStoreId");
}
}
private final TransactionalMetadataStore metadata;
private boolean closed;
/**
* Creates a server-control adapter over one exclusively owned metadata store.
*
* @param metadata lifecycle-owned metadata authority
*/
public ServerControlStore(TransactionalMetadataStore metadata) {
this.metadata = Objects.requireNonNull(metadata, "metadata");
}
/** @return durable server-control metadata-store identity */
public synchronized MetadataStoreId id() {
requireOpen();
return metadata.id();
}
/** Creates or validates the sole realm identity record. */
public synchronized RealmRecord ensureRealm(RealmRecord expected) {
Objects.requireNonNull(expected, "expected");
List<RealmRecord> existing = scan(REALM, KIND_REALM, ServerControlStore::readRealm);
if (existing.isEmpty()) {
create(REALM, expected.realmId().value(), output -> writeRealm(output, expected));
return expected;
}
if (existing.size() != 1 || !existing.getFirst().equals(expected)) {
throw new IllegalStateException("Persisted realm control identity or commitment differs");
}
return existing.getFirst();
}
/** Creates one principal. */
public synchronized void createPrincipal(SecurityPrincipal value) {
create(PRINCIPAL, value.principalId(), output -> writePrincipal(output, value));
}
/** Replaces one exact principal. */
public synchronized void replacePrincipal(SecurityPrincipal current, SecurityPrincipal updated) {
requireSame(current.principalId(), updated.principalId());
replace(PRINCIPAL, current.principalId(), output -> writePrincipal(output, updated));
}
/** Returns one principal or fails without revealing storage details. */
public synchronized SecurityPrincipal requirePrincipal(String principalId) {
return read(PRINCIPAL, principalId, KIND_PRINCIPAL, ServerControlStore::readPrincipal)
.orElseThrow(() -> new IllegalArgumentException("Principal is unavailable"));
}
/** Creates one scoped role assignment. */
public synchronized void createAssignment(RoleTemplateCatalog.Assignment value) {
create(ASSIGNMENT, value.assignmentId(), output -> writeAssignment(output, value));
}
/** Lists assignments for one principal from one stable metadata snapshot. */
public synchronized List<RoleTemplateCatalog.Assignment> assignmentsFor(String principalId) {
return scan(ASSIGNMENT, KIND_ASSIGNMENT, ServerControlStore::readAssignment).stream()
.filter(item -> item.principalId().equals(principalId)).toList();
}
/** Creates one direct grant. */
public synchronized void createGrant(Permission.Grant value) {
create(GRANT, value.grantId(), output -> writeGrant(output, value));
}
/** Replaces one direct grant. */
public synchronized void replaceGrant(Permission.Grant current, Permission.Grant updated) {
requireSame(current.grantId(), updated.grantId());
replace(GRANT, current.grantId(), output -> writeGrant(output, updated));
}
/** Lists direct grants for one principal from one stable snapshot. */
public synchronized List<Permission.Grant> grantsFor(String principalId) {
return scan(GRANT, KIND_GRANT, ServerControlStore::readGrant).stream()
.filter(item -> item.principalId().equals(principalId)).toList();
}
/** Creates one approval request. */
public synchronized void createApproval(ApprovalService.Request value) {
create(APPROVAL, value.approvalId(), output -> writeApproval(output, value));
}
/** Replaces one approval request through compare-and-replace. */
public synchronized void replaceApproval(ApprovalService.Request current, ApprovalService.Request updated) {
requireSame(current.approvalId(), updated.approvalId());
replace(APPROVAL, current.approvalId(), output -> writeApproval(output, updated));
}
/** Returns one approval request. */
public synchronized ApprovalService.Request requireApproval(String approvalId) {
return read(APPROVAL, approvalId, KIND_APPROVAL, ServerControlStore::readApproval)
.orElseThrow(() -> new IllegalArgumentException("Approval is unavailable"));
}
/** Creates one break-glass record. */
public synchronized void createBreakGlass(BreakGlassService.Record value) {
create(BREAK_GLASS, value.breakGlassId(), output -> writeBreakGlass(output, value));
}
/** Replaces one break-glass record. */
public synchronized void replaceBreakGlass(BreakGlassService.Record current, BreakGlassService.Record updated) {
requireSame(current.breakGlassId(), updated.breakGlassId());
replace(BREAK_GLASS, current.breakGlassId(), output -> writeBreakGlass(output, updated));
}
/** Returns one break-glass record. */
public synchronized BreakGlassService.Record requireBreakGlass(String breakGlassId) {
return read(BREAK_GLASS, breakGlassId, KIND_BREAK_GLASS, ServerControlStore::readBreakGlass)
.orElseThrow(() -> new IllegalArgumentException("Break-glass record is unavailable"));
}
/** Lists break-glass records for one principal. */
public synchronized List<BreakGlassService.Record> breakGlassFor(String principalId) {
return scan(BREAK_GLASS, KIND_BREAK_GLASS, ServerControlStore::readBreakGlass).stream()
.filter(item -> item.principalId().equals(principalId)).toList();
}
/** Creates one disclosure record. */
public synchronized void createDisclosure(DisclosureService.Record value) {
create(DISCLOSURE, value.objectId().value(), output -> writeDisclosure(output, value));
}
/** Replaces one disclosure record. */
public synchronized void replaceDisclosure(DisclosureService.Record current, DisclosureService.Record updated) {
requireSame(current.objectId().value(), updated.objectId().value());
replace(DISCLOSURE, current.objectId().value(), output -> writeDisclosure(output, updated));
}
/** Returns one disclosure record. */
public synchronized DisclosureService.Record requireDisclosure(PkiId objectId) {
return read(DISCLOSURE, objectId.value(), KIND_DISCLOSURE, ServerControlStore::readDisclosure)
.orElseThrow(() -> new IllegalArgumentException("Disclosure record is unavailable"));
}
/** Creates one commitment-only capability record. */
public synchronized void createCapability(DisclosureService.Capability value) {
create(CAPABILITY, value.capabilityId(), output -> writeCapability(output, value));
}
/** Replaces one capability record. */
public synchronized void replaceCapability(DisclosureService.Capability current,
DisclosureService.Capability updated) {
requireSame(current.capabilityId(), updated.capabilityId());
replace(CAPABILITY, current.capabilityId(), output -> writeCapability(output, updated));
}
/** Returns one capability record. */
public synchronized DisclosureService.Capability requireCapability(String capabilityId) {
return read(CAPABILITY, capabilityId, KIND_CAPABILITY, ServerControlStore::readCapability)
.orElseThrow(() -> new IllegalArgumentException("Capability is unavailable"));
}
/** Lists capability commitments bound to one object. */
public synchronized List<DisclosureService.Capability> capabilitiesFor(PkiId objectId) {
return scan(CAPABILITY, KIND_CAPABILITY, ServerControlStore::readCapability).stream()
.filter(item -> item.objectId().equals(objectId)).toList();
}
/**
* Strictly decodes and cross-checks every authoritative control record from
* stable metadata snapshots before the realm is exposed.
*/
public synchronized void validateAll() {
scan(REALM, KIND_REALM, ServerControlStore::readRealm);
scan(PRINCIPAL, KIND_PRINCIPAL, ServerControlStore::readPrincipal);
scan(ASSIGNMENT, KIND_ASSIGNMENT, ServerControlStore::readAssignment);
scan(GRANT, KIND_GRANT, ServerControlStore::readGrant);
scan(APPROVAL, KIND_APPROVAL, ServerControlStore::readApproval);
scan(BREAK_GLASS, KIND_BREAK_GLASS, ServerControlStore::readBreakGlass);
scan(DISCLOSURE, KIND_DISCLOSURE, ServerControlStore::readDisclosure);
scan(CAPABILITY, KIND_CAPABILITY, ServerControlStore::readCapability);
}
/** Validates durable relationships against the active strict role catalog. */
public synchronized void validateReferences(RoleTemplateCatalog roles) {
Objects.requireNonNull(roles, "roles");
Set<String> principals = scan(PRINCIPAL, KIND_PRINCIPAL, ServerControlStore::readPrincipal).stream()
.map(SecurityPrincipal::principalId).collect(java.util.stream.Collectors.toUnmodifiableSet());
for (RoleTemplateCatalog.Assignment assignment
: scan(ASSIGNMENT, KIND_ASSIGNMENT, ServerControlStore::readAssignment)) {
requirePrincipalReference(principals, assignment.principalId());
roles.instantiate(assignment);
}
for (Permission.Grant grant : scan(GRANT, KIND_GRANT, ServerControlStore::readGrant)) {
requirePrincipalReference(principals, grant.principalId());
}
for (BreakGlassService.Record record
: scan(BREAK_GLASS, KIND_BREAK_GLASS, ServerControlStore::readBreakGlass)) {
requirePrincipalReference(principals, record.principalId());
requirePrincipalReference(principals, record.issuerPrincipalId());
}
for (ApprovalService.Request request
: scan(APPROVAL, KIND_APPROVAL, ServerControlStore::readApproval)) {
requirePrincipalReference(principals, request.requesterPrincipalId());
request.decisions().forEach(decision -> requirePrincipalReference(principals, decision.principalId()));
}
for (DisclosureService.Record record
: scan(DISCLOSURE, KIND_DISCLOSURE, ServerControlStore::readDisclosure)) {
record.ownerPrincipalId().ifPresent(owner -> requirePrincipalReference(principals, owner));
}
}
/** Opens no sidecar authority and closes the sole metadata authority idempotently. */
@Override
public synchronized void close() throws IOException {
if (!closed) {
closed = true;
metadata.close();
}
}
private <T> Optional<T> read(String namespace, String identity, int kind, Decoder<T> decoder) {
requireOpen();
MetadataKey key = key(namespace, identity);
try (MetadataSnapshot snapshot = metadata.snapshot()) {
Optional<MetadataSnapshot.Record> record = snapshot.get(key);
if (record.isEmpty()) return Optional.empty();
T decoded = decode(record.orElseThrow(), kind, decoder);
requireSame(identity, identityOf(decoded));
return Optional.of(decoded);
} catch (IOException failure) {
throw new IllegalStateException("Server-control metadata read failed");
}
}
private <T> List<T> scan(String namespace, int kind, Decoder<T> decoder) {
requireOpen();
List<T> values = new ArrayList<>();
try (MetadataSnapshot snapshot = metadata.snapshot();
MetadataCursor cursor = snapshot.scan(MetadataSnapshot.KeyRange.all(namespace),
CancellationSignal.NONE)) {
Optional<MetadataSnapshot.Record> next;
while ((next = cursor.next(CancellationSignal.NONE)).isPresent()) {
MetadataSnapshot.Record record = next.orElseThrow();
T decoded = decode(record, kind, decoder);
if (!record.key().equals(key(namespace, identityOf(decoded)))) {
throw new IllegalStateException("Server-control metadata key mismatch");
}
values.add(decoded);
if (values.size() > MAXIMUM_COLLECTION) {
throw new IllegalStateException("Server-control metadata population exceeds technical bound");
}
}
return List.copyOf(values);
} catch (IOException failure) {
throw new IllegalStateException("Server-control metadata scan failed");
}
}
private void create(String namespace, String identity, Encoder encoder) {
mutate(namespace, identity, true, encoder);
}
private void replace(String namespace, String identity, Encoder encoder) {
mutate(namespace, identity, false, encoder);
}
private void mutate(String namespace, String identity, boolean create, Encoder encoder) {
requireOpen();
byte[] value = encode(encoder);
MetadataKey key = key(namespace, identity);
try (MetadataSnapshot snapshot = metadata.snapshot(); MetadataTransaction transaction = metadata.beginTransaction();
RepeatableContent content = new ByteContent(value)) {
Optional<MetadataSnapshot.Record> existing = snapshot.get(key);
if (create) {
if (existing.isPresent()) throw new IllegalStateException("Server-control record already exists");
transaction.create(key, content, CancellationSignal.NONE);
} else {
MetadataSnapshot.Record record = existing
.orElseThrow(() -> new IllegalStateException("Server-control record is unavailable"));
transaction.replace(key, record.recordRevision(), content, CancellationSignal.NONE);
}
MetadataCommitResult result = transaction.commit();
if (result.outcome() != MetadataCommitResult.Outcome.COMMITTED) {
throw new IllegalStateException("Server-control metadata commit was not confirmed");
}
} catch (IOException failure) {
throw new IllegalStateException("Server-control metadata mutation failed");
}
}
private static byte[] encode(Encoder encoder) {
try (ByteArrayOutputStream bytes = new ByteArrayOutputStream(); DataOutputStream output = new DataOutputStream(bytes)) {
output.writeInt(MAGIC);
output.writeInt(SCHEMA);
encoder.write(output);
output.flush();
byte[] result = bytes.toByteArray();
if (result.length > MAXIMUM_RECORD_BYTES) throw new IllegalArgumentException("Control record too large");
return result;
} catch (IOException impossible) {
throw new IllegalStateException("In-memory control encoding failed", impossible);
}
}
private static <T> T decode(RepeatableContent content, int expectedKind, Decoder<T> decoder) throws IOException {
try (InputStream stream = content.openStream()) {
byte[] bytes = stream.readNBytes(MAXIMUM_RECORD_BYTES + 1);
if (bytes.length > MAXIMUM_RECORD_BYTES || stream.read() != -1) {
throw new IllegalArgumentException("Control record exceeds its technical bound");
}
try (DataInputStream input = new DataInputStream(new ByteArrayInputStream(bytes))) {
if (input.readInt() != MAGIC || input.readInt() != SCHEMA || input.readInt() != expectedKind) {
throw new IllegalArgumentException("Control record framing or kind is invalid");
}
T result = decoder.read(input);
if (input.read() != -1) throw new IllegalArgumentException("Trailing control record input");
return result;
}
}
}
private static MetadataKey key(String namespace, String identity) {
return new MetadataKey(namespace, digest(identity));
}
private static String digest(String value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException("SHA-256 is unavailable", impossible);
}
}
private static String identityOf(Object value) {
return switch (value) {
case RealmRecord item -> item.realmId().value();
case SecurityPrincipal item -> item.principalId();
case RoleTemplateCatalog.Assignment item -> item.assignmentId();
case Permission.Grant item -> item.grantId();
case ApprovalService.Request item -> item.approvalId();
case BreakGlassService.Record item -> item.breakGlassId();
case DisclosureService.Record item -> item.objectId().value();
case DisclosureService.Capability item -> item.capabilityId();
default -> throw new IllegalArgumentException("Unsupported control record type");
};
}
private static void requireSame(String first, String second) {
if (!Objects.equals(first, second)) throw new IllegalArgumentException("Control record identity mismatch");
}
private static void requirePrincipalReference(Set<String> principals, String principalId) {
if (!principals.contains(principalId)) {
throw new IllegalStateException("Server-control record references an unavailable principal");
}
}
private void requireOpen() {
if (closed) throw new IllegalStateException("Server-control store is closed");
}
private static void writeRealm(DataOutputStream out, RealmRecord value) throws IOException {
out.writeInt(KIND_REALM); writeString(out, value.realmId().value()); writeString(out, value.displayName());
out.writeInt(value.exposure().mode().code()); out.writeBoolean(value.exposure().authorityCreationPermitted());
writePkiSet(out, value.exposure().authorityIds()); writeString(out, value.authorizationCommitment());
writeString(out, value.approvalCommitment()); writeString(out, value.disclosureCommitment());
writeString(out, value.controlStoreId().value());
}
private static RealmRecord readRealm(DataInputStream in) throws IOException {
RealmId realm = new RealmId(readString(in)); String displayName = readString(in);
AuthorityExposurePolicy.Mode mode = AuthorityExposurePolicy.Mode.fromCode(in.readInt());
boolean creation = in.readBoolean(); Set<PkiId> ids = readPkiSet(in);
return new RealmRecord(realm, displayName, new AuthorityExposurePolicy(mode, ids, creation),
readString(in), readString(in), readString(in), new MetadataStoreId(readString(in)));
}
private static void writePrincipal(DataOutputStream out, SecurityPrincipal value) throws IOException {
out.writeInt(KIND_PRINCIPAL); writeString(out, value.principalId()); out.writeInt(value.type().code());
writeString(out, value.displayName()); writeOptionalString(out, value.organization()); writeStringMap(out, value.attributes());
out.writeBoolean(value.enabled());
}
private static SecurityPrincipal readPrincipal(DataInputStream in) throws IOException {
return new SecurityPrincipal(readString(in), SecurityPrincipal.Type.fromCode(in.readInt()), readString(in),
readOptionalString(in), readStringMap(in), in.readBoolean());
}
private static void writeAssignment(DataOutputStream out, RoleTemplateCatalog.Assignment value) throws IOException {
out.writeInt(KIND_ASSIGNMENT); writeString(out, value.assignmentId()); writeString(out, value.principalId());
writeString(out, value.templateId()); out.writeInt(value.templateVersion()); writeScope(out, value.scope());
out.writeBoolean(value.enabled());
}
private static RoleTemplateCatalog.Assignment readAssignment(DataInputStream in) throws IOException {
return new RoleTemplateCatalog.Assignment(readString(in), readString(in), readString(in), in.readInt(),
readScope(in), in.readBoolean());
}
private static void writeGrant(DataOutputStream out, Permission.Grant value) throws IOException {
out.writeInt(KIND_GRANT); writeGrantBody(out, value);
}
private static Permission.Grant readGrant(DataInputStream in) throws IOException { return readGrantBody(in); }
private static void writeGrantBody(DataOutputStream out, Permission.Grant value) throws IOException {
writeString(out, value.grantId()); writeString(out, value.principalId()); out.writeInt(value.effect().code());
out.writeInt(value.action().code()); out.writeInt(value.resourceType().code()); writeScope(out, value.scope());
out.writeInt(value.relationship().code()); out.writeInt(value.dataView().code());
List<Permission.Condition> conditions = value.conditions().stream().sorted(Comparator.comparingInt(Permission.Condition::code)).toList();
out.writeInt(conditions.size()); for (Permission.Condition item : conditions) out.writeInt(item.code());
writeOptionalInstant(out, value.expiresAt()); out.writeBoolean(value.enabled());
}
private static Permission.Grant readGrantBody(DataInputStream in) throws IOException {
String id = readString(in); String principal = readString(in); Permission.Effect effect = Permission.Effect.fromCode(in.readInt());
Permission.Action action = Permission.Action.fromCode(in.readInt()); Permission.ResourceType type = Permission.ResourceType.fromCode(in.readInt());
Permission.Scope scope = readScope(in); Permission.Relationship relationship = Permission.Relationship.fromCode(in.readInt());
Permission.DataView view = Permission.DataView.fromCode(in.readInt()); int count = readCount(in); Set<Permission.Condition> conditions = new HashSet<>();
for (int index = 0; index < count; index++) if (!conditions.add(Permission.Condition.fromCode(in.readInt()))) throw new IllegalArgumentException("Duplicate grant condition");
return new Permission.Grant(id, principal, effect, action, type, scope, relationship, view, conditions,
readOptionalInstant(in), in.readBoolean());
}
private static void writeApproval(DataOutputStream out, ApprovalService.Request value) throws IOException {
out.writeInt(KIND_APPROVAL); writeString(out, value.approvalId()); writeString(out, value.operationId());
writeString(out, value.operationCommitment()); writeScope(out, value.scope()); writeString(out, value.requesterPrincipalId());
writeInstant(out, value.createdAt()); writeInstant(out, value.expiresAt()); writeApprovalPolicy(out, value.policy());
writeString(out, value.policyCommitment()); out.writeInt(value.state().code()); out.writeInt(value.decisions().size());
for (ApprovalService.Decision decision : value.decisions()) { writeString(out, decision.principalId()); out.writeInt(decision.choice().code());
writeString(out, decision.justification()); writeInstant(out, decision.decidedAt()); }
writeOptionalString(out, value.resultClassification());
}
private static ApprovalService.Request readApproval(DataInputStream in) throws IOException {
String id = readString(in); String operation = readString(in); String commitment = readString(in); Permission.Scope scope = readScope(in);
String requester = readString(in); Instant created = readInstant(in); Instant expires = readInstant(in); ApprovalService.Policy policy = readApprovalPolicy(in);
String policyCommitment = readString(in); ApprovalService.State state = ApprovalService.State.fromCode(in.readInt()); int count = readCount(in);
List<ApprovalService.Decision> decisions = new ArrayList<>();
for (int index = 0; index < count; index++) decisions.add(new ApprovalService.Decision(readString(in), ApprovalService.Choice.fromCode(in.readInt()), readString(in), readInstant(in)));
return new ApprovalService.Request(id, operation, commitment, scope, requester, created, expires, policy,
policyCommitment, state, decisions, readOptionalString(in));
}
private static void writeApprovalPolicy(DataOutputStream out, ApprovalService.Policy value) throws IOException {
writeString(out, value.policyId()); out.writeInt(value.threshold()); writeStringSet(out, value.eligibleApprovers());
writeStringSet(out, value.requiredRoleTemplateIds()); out.writeBoolean(value.requesterSeparation());
out.writeLong(value.lifetime().toSeconds()); out.writeBoolean(value.justificationRequired());
}
private static ApprovalService.Policy readApprovalPolicy(DataInputStream in) throws IOException {
return new ApprovalService.Policy(readString(in), in.readInt(), readStringSet(in), readStringSet(in),
in.readBoolean(), java.time.Duration.ofSeconds(in.readLong()), in.readBoolean());
}
private static void writeBreakGlass(DataOutputStream out, BreakGlassService.Record value) throws IOException {
out.writeInt(KIND_BREAK_GLASS); writeString(out, value.breakGlassId()); writeString(out, value.principalId());
writeGrantBody(out, value.grant()); writeString(out, value.reason()); writeString(out, value.issuerPrincipalId());
writeOptionalString(out, value.approvalId()); writeInstant(out, value.createdAt()); writeInstant(out, value.activatedAt());
writeInstant(out, value.expiresAt()); out.writeInt(value.state().code()); writeString(out, value.auditCommitment());
}
private static BreakGlassService.Record readBreakGlass(DataInputStream in) throws IOException {
return new BreakGlassService.Record(readString(in), readString(in), readGrantBody(in), readString(in),
readString(in), readOptionalString(in), readInstant(in), readInstant(in), readInstant(in),
BreakGlassService.State.fromCode(in.readInt()), readString(in));
}
private static void writeDisclosure(DataOutputStream out, DisclosureService.Record value) throws IOException {
out.writeInt(KIND_DISCLOSURE); writeString(out, value.objectId().value()); out.writeInt(value.objectType().code());
out.writeInt(value.policy().code()); writeOptionalString(out, value.ownerPrincipalId());
writeString(out, value.policyCommitment()); writeInstant(out, value.updatedAt());
}
private static DisclosureService.Record readDisclosure(DataInputStream in) throws IOException {
return new DisclosureService.Record(new PkiId(readString(in)), DisclosureService.ObjectType.fromCode(in.readInt()),
DisclosureService.Policy.fromCode(in.readInt()), readOptionalString(in), readString(in), readInstant(in));
}
private static void writeCapability(DataOutputStream out, DisclosureService.Capability value) throws IOException {
out.writeInt(KIND_CAPABILITY); writeString(out, value.capabilityId()); writeString(out, value.realmId().value());
writeString(out, value.objectId().value()); writeString(out, value.action()); writeBytes(out, value.tokenCommitment());
writeInstant(out, value.expiresAt()); out.writeBoolean(value.revoked());
}
private static DisclosureService.Capability readCapability(DataInputStream in) throws IOException {
return new DisclosureService.Capability(readString(in), new RealmId(readString(in)), new PkiId(readString(in)),
readString(in), readBytes(in, 32), readInstant(in), in.readBoolean());
}
private static void writeScope(DataOutputStream out, Permission.Scope value) throws IOException {
writeString(out, value.realmId().value()); writeOptionalPki(out, value.authorityId());
writeOptionalPki(out, value.issuerId()); writeOptionalString(out, value.profileId());
}
private static Permission.Scope readScope(DataInputStream in) throws IOException {
return new Permission.Scope(new RealmId(readString(in)), readOptionalPki(in), readOptionalPki(in), readOptionalString(in));
}
private static void writeString(DataOutputStream out, String value) throws IOException {
byte[] bytes = Objects.requireNonNull(value, "value").getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAXIMUM_STRING_BYTES) throw new IllegalArgumentException("Control string too long");
out.writeInt(bytes.length); out.write(bytes);
}
private static String readString(DataInputStream in) throws IOException {
int length = in.readInt(); if (length < 0 || length > MAXIMUM_STRING_BYTES) throw new IllegalArgumentException("Control string length invalid");
byte[] bytes = in.readNBytes(length); if (bytes.length != length) throw new IllegalArgumentException("Truncated control string");
String result = new String(bytes, StandardCharsets.UTF_8);
if (!java.util.Arrays.equals(bytes, result.getBytes(StandardCharsets.UTF_8))) throw new IllegalArgumentException("Control string UTF-8 invalid");
return result;
}
private static void writeOptionalString(DataOutputStream out, Optional<String> value) throws IOException {
out.writeBoolean(value.isPresent()); if (value.isPresent()) writeString(out, value.orElseThrow());
}
private static Optional<String> readOptionalString(DataInputStream in) throws IOException { return in.readBoolean() ? Optional.of(readString(in)) : Optional.empty(); }
private static void writeOptionalPki(DataOutputStream out, Optional<PkiId> value) throws IOException { writeOptionalString(out, value.map(PkiId::value)); }
private static Optional<PkiId> readOptionalPki(DataInputStream in) throws IOException { return readOptionalString(in).map(PkiId::new); }
private static void writeInstant(DataOutputStream out, Instant value) throws IOException { out.writeLong(value.toEpochMilli()); }
private static Instant readInstant(DataInputStream in) throws IOException { return Instant.ofEpochMilli(in.readLong()); }
private static void writeOptionalInstant(DataOutputStream out, Optional<Instant> value) throws IOException { out.writeBoolean(value.isPresent()); if (value.isPresent()) writeInstant(out, value.orElseThrow()); }
private static Optional<Instant> readOptionalInstant(DataInputStream in) throws IOException { return in.readBoolean() ? Optional.of(readInstant(in)) : Optional.empty(); }
private static void writePkiSet(DataOutputStream out, Set<PkiId> values) throws IOException { List<PkiId> sorted = values.stream().sorted(Comparator.comparing(PkiId::value)).toList(); out.writeInt(sorted.size()); for (PkiId value : sorted) writeString(out, value.value()); }
private static Set<PkiId> readPkiSet(DataInputStream in) throws IOException { int count = readCount(in); Set<PkiId> result = new HashSet<>(); for (int index = 0; index < count; index++) if (!result.add(new PkiId(readString(in)))) throw new IllegalArgumentException("Duplicate authority identity"); return result; }
private static void writeStringSet(DataOutputStream out, Set<String> values) throws IOException { List<String> sorted = values.stream().sorted().toList(); out.writeInt(sorted.size()); for (String value : sorted) writeString(out, value); }
private static Set<String> readStringSet(DataInputStream in) throws IOException { int count = readCount(in); Set<String> result = new HashSet<>(); for (int index = 0; index < count; index++) if (!result.add(readString(in))) throw new IllegalArgumentException("Duplicate control set value"); return result; }
private static void writeStringMap(DataOutputStream out, Map<String, String> values) throws IOException { List<Map.Entry<String, String>> sorted = values.entrySet().stream().sorted(Map.Entry.comparingByKey()).toList(); out.writeInt(sorted.size()); for (Map.Entry<String, String> entry : sorted) { writeString(out, entry.getKey()); writeString(out, entry.getValue()); } }
private static Map<String, String> readStringMap(DataInputStream in) throws IOException { int count = readCount(in); Map<String, String> result = new LinkedHashMap<>(); for (int index = 0; index < count; index++) if (result.put(readString(in), readString(in)) != null) throw new IllegalArgumentException("Duplicate control map key"); return Map.copyOf(result); }
private static int readCount(DataInputStream in) throws IOException { int count = in.readInt(); if (count < 0 || count > MAXIMUM_COLLECTION) throw new IllegalArgumentException("Control collection size invalid"); return count; }
private static void writeBytes(DataOutputStream out, byte[] value) throws IOException { out.writeInt(value.length); out.write(value); }
private static byte[] readBytes(DataInputStream in, int expected) throws IOException { int length = in.readInt(); if (length != expected) throw new IllegalArgumentException("Control byte value length invalid"); byte[] value = in.readNBytes(length); if (value.length != length) throw new IllegalArgumentException("Truncated control byte value"); return value; }
private static void requireDigest(String value) { if (value == null || !value.matches("[0-9a-f]{64}")) throw new IllegalArgumentException("Control commitment invalid"); }
@FunctionalInterface private interface Encoder { void write(DataOutputStream output) throws IOException; }
@FunctionalInterface private interface Decoder<T> { T read(DataInputStream input) throws IOException; }
private record ByteContent(byte[] value) implements RepeatableContent {
private ByteContent { value = value.clone(); }
@Override public InputStream openStream() { return new ByteArrayInputStream(value); }
@Override public OptionalLong length() { return OptionalLong.of(value.length); }
@Override public String contentId() { return "server-control-record"; }
@Override public void close() { }
}
}

View File

@@ -0,0 +1,300 @@
/*******************************************************************************
* 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.server;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import zeroecho.core.io.CancellationSignal;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.application.PkiOperation;
import zeroecho.pki.application.PkiOperationExecutor;
import zeroecho.pki.application.PkiOperationOutcome;
import zeroecho.pki.application.PkiOperationResult;
import zeroecho.pki.application.PkiOperationValue;
import zeroecho.pki.application.PkiResourceScopeResolver;
/**
* In-process transport-neutral security gateway for existing typed PKI
* operations.
*
* <p>The gateway owns no executor, thread, queue, retry, or PKI business rule. It
* serially validates realm/scope, authorizes, applies approval policy, delegates
* exactly once to the session executor, and preserves the returned outcome.</p>
*/
@SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.FieldDeclarationsShouldBeAtStartOfClass",
"PMD.ExcessiveParameterList", "PMD.CyclomaticComplexity" })
public final class ServerOperationGateway {
/**
* Complete transport-neutral request admission input.
*
* @param realmId exact realm identity
* @param principalId authenticated or public principal identity
* @param operation existing typed operation
* @param resource exact non-bearer resource reference
* @param relationship established object relationship
* @param context safe closed-condition context
* @param approvalId optional durable approval reference
* @param correlationId safe finite request correlation identity
*/
public record Request(RealmId realmId, String principalId, PkiOperation operation,
Permission.Resource resource, Permission.Relationship relationship, Permission.Context context,
Optional<String> approvalId, String correlationId) {
/** Validates the immutable request. */
public Request {
Objects.requireNonNull(realmId, "realmId");
Permission.requirePrincipal(principalId);
Objects.requireNonNull(operation, "operation");
Objects.requireNonNull(resource, "resource");
Objects.requireNonNull(relationship, "relationship");
Objects.requireNonNull(context, "context");
approvalId = Objects.requireNonNull(approvalId, "approvalId");
Permission.requireBounded(correlationId, 256, "correlation ID");
}
}
/** Closed gateway outcome preserving typed backend results. */
public sealed interface Outcome permits Outcome.Executed, Outcome.Denied, Outcome.ApprovalRequired {
/** Successfully admitted operation and exact backend outcome. */
record Executed(PkiOperationOutcome outcome) implements Outcome {
/** Validates the backend result. */ public Executed { Objects.requireNonNull(outcome, "outcome"); }
}
/** Safe denial without protected-object existence information. */
record Denied(AuthorizationEngine.Code code) implements Outcome {
/** Validates the safe code. */ public Denied { Objects.requireNonNull(code, "code"); }
}
/** High-risk operation requires a separately created durable approval. */
record ApprovalRequired(String operationCommitment) implements Outcome {
/** Validates the safe exact-operation commitment. */
public ApprovalRequired {
if (operationCommitment == null || !operationCommitment.matches("[0-9a-f]{64}"))
throw new IllegalArgumentException("Operation commitment is invalid");
}
}
}
private final RealmId realmId;
private final AuthorityExposurePolicy exposure;
private final ServerControlStore control;
private final RoleTemplateCatalog roles;
private final AuthorizationEngine authorization;
private final ApprovalService approvals;
private final BreakGlassService breakGlass;
private final OperationSecurityDescriptors descriptors;
private final PkiOperationExecutor executor;
private final PkiResourceScopeResolver resourceScopes;
private final Map<OperationSecurityDescriptors.ApprovalCategory, ApprovalService.Policy> approvalPolicies;
private final SafeAudit audit;
private final Runnable openCheck;
/** Creates one gateway bound to one realm and one session executor. */
public ServerOperationGateway(RealmId realmId, AuthorityExposurePolicy exposure, ServerControlStore control,
RoleTemplateCatalog roles, AuthorizationEngine authorization, ApprovalService approvals,
BreakGlassService breakGlass, OperationSecurityDescriptors descriptors, PkiOperationExecutor executor,
PkiResourceScopeResolver resourceScopes,
Map<OperationSecurityDescriptors.ApprovalCategory, ApprovalService.Policy> approvalPolicies,
java.time.Clock clock, zeroecho.pki.spi.audit.AuditSink auditSink, Runnable openCheck) {
this.realmId = Objects.requireNonNull(realmId, "realmId");
this.exposure = Objects.requireNonNull(exposure, "exposure");
this.control = Objects.requireNonNull(control, "control");
this.roles = Objects.requireNonNull(roles, "roles");
this.authorization = Objects.requireNonNull(authorization, "authorization");
this.approvals = Objects.requireNonNull(approvals, "approvals");
this.breakGlass = Objects.requireNonNull(breakGlass, "breakGlass");
this.descriptors = Objects.requireNonNull(descriptors, "descriptors");
this.executor = Objects.requireNonNull(executor, "executor");
this.resourceScopes = Objects.requireNonNull(resourceScopes, "resourceScopes");
this.approvalPolicies = Map.copyOf(Objects.requireNonNull(approvalPolicies, "approvalPolicies"));
this.audit = new SafeAudit(clock, auditSink);
this.openCheck = Objects.requireNonNull(openCheck, "openCheck");
}
/**
* Admits and synchronously executes one operation.
*
* @param request complete security request
* @param cancellation cooperative cancellation signal passed unchanged to PKI
* @return safe gateway outcome
*/
public Outcome execute(Request request, CancellationSignal cancellation) {
openCheck.run();
Request exact = Objects.requireNonNull(request, "request");
Objects.requireNonNull(cancellation, "cancellation");
if (!realmId.equals(exact.realmId()) || !realmId.equals(exact.resource().scope().realmId())) {
return denied(exact, AuthorizationEngine.Code.OUTSIDE_REALM);
}
OperationSecurityDescriptors.Descriptor descriptor;
try {
descriptor = descriptors.require(exact.operation());
descriptors.validateResource(exact.operation(), exact.resource());
validateAuthoritativeScope(exact.operation(), exact.resource());
} catch (SecurityException invalid) {
return denied(exact, AuthorizationEngine.Code.NO_MATCHING_GRANT);
}
if (exact.operation() instanceof PkiOperation.CreateAuthority && !exposure.authorityCreationPermitted()) {
return denied(exact, AuthorizationEngine.Code.OUTSIDE_AUTHORITY_SCOPE);
}
SecurityPrincipal principal;
try {
principal = control.requirePrincipal(exact.principalId());
} catch (IllegalArgumentException unavailable) {
return denied(exact, AuthorizationEngine.Code.NO_MATCHING_GRANT);
}
List<Permission.Grant> grants = new ArrayList<>(control.grantsFor(principal.principalId()));
for (RoleTemplateCatalog.Assignment assignment : control.assignmentsFor(principal.principalId())) {
grants.addAll(roles.instantiate(assignment));
}
BreakGlassService.ActiveGrants emergency = breakGlass.activeFor(principal.principalId());
grants.addAll(emergency.grants());
Permission.Action action = action(exact.operation(), descriptor.action());
Permission.Context conditionContext = approvalContext(exact);
AuthorizationEngine.Decision decision = authorization.authorize(new AuthorizationEngine.Request(realmId,
exposure, principal, action, exact.resource(), exact.relationship(), descriptor.dataView(),
conditionContext, grants, emergency.grantIds()));
boolean filterableList = exact.operation() instanceof PkiOperation.ListAuthorities
&& decision.code() == AuthorizationEngine.Code.NO_MATCHING_GRANT;
if (!decision.allowed() && !filterableList) {
return denied(exact, decision.code());
}
if (decision.usedBreakGlass()) breakGlass.auditUse(principal.principalId());
String commitment = descriptors.commitment(realmId, exact.operation(), exact.resource());
Optional<String> claimedApproval = Optional.empty();
if (descriptor.approvalCategory() == OperationSecurityDescriptors.ApprovalCategory.HIGH_RISK) {
ApprovalService.Policy expected = approvalPolicies.get(descriptor.approvalCategory());
if (expected == null) throw new IllegalStateException("High-risk approval policy is not configured");
if (exact.approvalId().isEmpty()) return new Outcome.ApprovalRequired(commitment);
ApprovalService.Request current = approvals.requireCurrent(exact.approvalId().orElseThrow());
if (!current.policyCommitment().equals(expected.commitment())) {
return denied(exact, AuthorizationEngine.Code.APPROVAL_REQUIRED);
}
approvals.claim(current.approvalId(), exact.operation().name(), commitment, exact.resource().scope());
claimedApproval = Optional.of(current.approvalId());
}
PkiOperationOutcome backend = executor.execute(exact.operation(), cancellation);
if (exact.operation() instanceof PkiOperation.ListAuthorities) {
backend = filterAuthorities(backend, principal, grants, emergency.grantIds(), exact.context());
}
if (claimedApproval.isPresent()) {
approvals.complete(claimedApproval.orElseThrow(), classification(backend));
}
audit.record("GATEWAY_RESULT", principal.principalId(), exact.resource().objectId(),
Map.of("operation", exact.operation().name(), "result", classification(backend),
"correlation", exact.correlationId()));
return new Outcome.Executed(backend);
}
private PkiOperationOutcome filterAuthorities(PkiOperationOutcome outcome, SecurityPrincipal principal,
List<Permission.Grant> grants, java.util.Set<String> breakGlassIds, Permission.Context context) {
if (!(outcome instanceof PkiOperationOutcome.Success success)) return outcome;
PkiOperationValue value = success.result().fields().get("authorities");
if (!(value instanceof PkiOperationValue.ListValue list)) return outcome;
List<PkiOperationValue> allowed = list.values().stream().filter(item -> authorityAllowed(item, principal,
grants, breakGlassIds, context)).toList();
Map<String, PkiOperationValue> fields = new LinkedHashMap<>(success.result().fields());
fields.put("count", new PkiOperationValue.IntegerValue(allowed.size()));
fields.put("authorities", new PkiOperationValue.ListValue(allowed));
return new PkiOperationOutcome.Success(new PkiOperationResult(success.result().operationName(), fields));
}
private boolean authorityAllowed(PkiOperationValue value, SecurityPrincipal principal,
List<Permission.Grant> grants, java.util.Set<String> breakGlassIds, Permission.Context context) {
if (!(value instanceof PkiOperationValue.ObjectValue object)
|| !(object.fields().get("caId") instanceof PkiOperationValue.Text id)) return false;
PkiId authorityId = new PkiId(id.value());
if (!exposure.allows(authorityId)) return false;
Permission.Resource resource = new Permission.Resource(Permission.ResourceType.AUTHORITY,
new Permission.Scope(realmId, Optional.of(authorityId), Optional.empty(), Optional.empty()),
Optional.of(authorityId), Optional.empty());
return authorization.authorize(new AuthorizationEngine.Request(realmId, exposure, principal,
Permission.Action.AUTHORITY_LIST, resource, Permission.Relationship.ANY,
Permission.DataView.METADATA_REDACTED, context, grants, breakGlassIds)).allowed();
}
private Permission.Context approvalContext(Request request) {
return new Permission.Context(request.context().reason(), request.approvalId().isPresent(),
request.context().authorityActive(), request.context().safeAttributes());
}
private void validateAuthoritativeScope(PkiOperation operation, Permission.Resource resource) {
Optional<PkiId> actual = switch (operation) {
case PkiOperation.InspectCredential value -> resourceScopes.credentialAuthority(value.credentialId());
case PkiOperation.RevokeCredential value -> resourceScopes.credentialAuthority(value.credentialId());
case PkiOperation.ReadRevocationHistory value -> resourceScopes.credentialAuthority(value.credentialId());
case PkiOperation.InspectPublication value -> resourceScopes.publicationAuthority(value.publicationId());
case PkiOperation.ProcessPublication value -> resourceScopes.publicationAuthority(value.publicationId());
default -> resource.scope().authorityId();
};
if (requiresResolvedAuthority(operation)
&& (actual.isEmpty() || !actual.equals(resource.scope().authorityId()))) {
throw new SecurityException("Authoritative object scope does not match the request");
}
}
private static boolean requiresResolvedAuthority(PkiOperation operation) {
return operation instanceof PkiOperation.InspectCredential
|| operation instanceof PkiOperation.RevokeCredential
|| operation instanceof PkiOperation.ReadRevocationHistory
|| operation instanceof PkiOperation.InspectPublication
|| operation instanceof PkiOperation.ProcessPublication;
}
private Outcome denied(Request request, AuthorizationEngine.Code code) {
audit.record("AUTHORIZATION_DENY", request.principalId(), Optional.empty(),
Map.of("operation", request.operation().name(), "code", code.name(),
"correlation", request.correlationId()));
return new Outcome.Denied(code);
}
private static Permission.Action action(PkiOperation operation, Permission.Action defaultAction) {
if (operation instanceof PkiOperation.TransitionAuthority transition) {
return switch (transition.state()) {
case ACTIVE -> Permission.Action.AUTHORITY_ACTIVATE;
case RETIRED -> Permission.Action.AUTHORITY_RETIRE;
case COMPROMISED, DISABLED -> Permission.Action.AUTHORITY_SUSPEND;
};
}
return defaultAction;
}
private static String classification(PkiOperationOutcome outcome) {
return switch (outcome) {
case PkiOperationOutcome.Success ignored -> "SUCCEEDED";
case PkiOperationOutcome.Failure failure -> failure.classification().name();
};
}
}

View File

@@ -0,0 +1,100 @@
/*******************************************************************************
* 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.server;
import java.nio.file.Path;
import java.util.Map;
import java.util.Objects;
import zeroecho.pki.application.PkiSessionConfiguration;
import zeroecho.pki.server.OperationSecurityDescriptors.ApprovalCategory;
import zeroecho.pki.spi.store.MetadataStoreId;
/**
* Immutable transport-neutral configuration for one server realm.
*
* <p>The realm identity and server-control identity are explicit values and are
* never derived from paths, listeners, display names, or Java types. Provider
* configuration values may contain sensitive references and must not be logged
* or rendered.</p>
*
* @param realmId canonical realm identity
* @param displayName safe finite administrative display name
* @param pkiSessionConfiguration shared PKI session configuration
* @param authorityExposure explicit process authority-exposure policy
* @param authorizationCommitment SHA-256 commitment to authorization configuration
* @param approvalCommitment SHA-256 commitment to approval configuration
* @param disclosureCommitment SHA-256 commitment to disclosure defaults
* @param disclosureDefaults safe object-disclosure defaults
* @param controlLogPath dedicated server-control metadata log
* @param controlStoreId explicit durable server-control identity
* @param approvalPolicies closed approval policies by operation category
*/
public record ServerRealmConfiguration(RealmId realmId, String displayName,
PkiSessionConfiguration pkiSessionConfiguration, AuthorityExposurePolicy authorityExposure,
String authorizationCommitment, String approvalCommitment, String disclosureCommitment,
DisclosureService.Defaults disclosureDefaults, Path controlLogPath, MetadataStoreId controlStoreId,
Map<ApprovalCategory, ApprovalService.Policy> approvalPolicies) {
/** Validates and snapshots the complete configuration before allocation. */
public ServerRealmConfiguration {
Objects.requireNonNull(realmId, "realmId");
Permission.requireBounded(displayName, 256, "realm display name");
Objects.requireNonNull(pkiSessionConfiguration, "pkiSessionConfiguration");
Objects.requireNonNull(authorityExposure, "authorityExposure");
requireDigest(authorizationCommitment, "authorization commitment");
requireDigest(approvalCommitment, "approval commitment");
requireDigest(disclosureCommitment, "disclosure commitment");
Objects.requireNonNull(disclosureDefaults, "disclosureDefaults");
controlLogPath = Objects.requireNonNull(controlLogPath, "controlLogPath").toAbsolutePath().normalize();
if (controlLogPath.getParent() == null) {
throw new IllegalArgumentException("Server-control log requires a parent directory");
}
Objects.requireNonNull(controlStoreId, "controlStoreId");
approvalPolicies = Map.copyOf(Objects.requireNonNull(approvalPolicies, "approvalPolicies"));
ApprovalService.Policy highRisk = approvalPolicies.get(ApprovalCategory.HIGH_RISK);
if (highRisk == null || !highRisk.commitment().equals(approvalCommitment)) {
throw new IllegalArgumentException("High-risk approval policy commitment is inconsistent");
}
if (approvalPolicies.containsKey(ApprovalCategory.NONE)) {
throw new IllegalArgumentException("Ordinary operations cannot have an implicit approval policy");
}
}
private static void requireDigest(String value, String name) {
if (value == null || !value.matches("[0-9a-f]{64}")) {
throw new IllegalArgumentException(name + " is invalid");
}
}
}

View File

@@ -0,0 +1,307 @@
/*******************************************************************************
* 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.server;
import java.io.IOException;
import java.nio.file.Files;
import java.security.SecureRandom;
import java.time.Clock;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.OptionalLong;
import java.util.concurrent.atomic.AtomicReference;
import zeroecho.pki.application.PkiSession;
import zeroecho.pki.application.PkiOperation;
import zeroecho.pki.application.PkiOperationOutcome;
import zeroecho.pki.application.PkiSessionRuntimeDependencies;
import zeroecho.pki.impl.fs.PosixTransactionalMetadataStore;
import zeroecho.pki.spi.audit.AuditSink;
import zeroecho.pki.spi.bootstrap.PkiBootstrap;
/**
* Lifecycle owner for one server realm, one long-lived PKI session, and one
* dedicated durable server-control authority.
*
* <p>The context is transport-neutral and synchronous. It creates no execution
* lane, queue, thread, scheduler, retry loop, HTTP type, or ACME object. A future
* transport can retain one instance for its process lifetime and submit existing
* typed operations through {@link #gateway()}.</p>
*/
@SuppressWarnings({ "PMD.CommentDefaultAccessModifier", "PMD.CloseResource", "PMD.UseProperClassLoader",
"PMD.AvoidCatchingGenericException", "PMD.LinguisticNaming", "PMD.ControlStatementBraces",
"PMD.PreserveStackTrace", "PMD.SignatureDeclareThrowsException", "PMD.CommentRequired",
"PMD.AvoidSynchronizedAtMethodLevel", "PMD.AvoidInstantiatingObjectsInLoops" })
public final class ServerRealmContext implements AutoCloseable {
/** Closed lifecycle states. */
public enum State { OPEN, CLOSING, CLOSED }
private final ServerRealmConfiguration configuration;
private final ServerControlStore control;
private final PkiSession session;
private final RoleTemplateCatalog roles;
private final AuthorizationEngine authorization;
private final ApprovalService approvals;
private final BreakGlassService breakGlass;
private final DisclosureService disclosure;
private final AuditorViews auditorViews;
private final ServerOperationGateway gateway;
private final SafeAudit audit;
private final AtomicReference<State> state = new AtomicReference<>(State.OPEN);
private ServerRealmContext(ServerRealmConfiguration configuration, ServerControlStore control,
PkiSession session, RoleTemplateCatalog roles, AuthorizationEngine authorization,
ApprovalService approvals, BreakGlassService breakGlass, DisclosureService disclosure,
AuditorViews auditorViews, AuditSink auditSink, Clock clock) {
this.configuration = configuration;
this.control = control;
this.session = session;
this.roles = roles;
this.authorization = authorization;
this.approvals = approvals;
this.breakGlass = breakGlass;
this.disclosure = disclosure;
this.auditorViews = auditorViews;
this.audit = new SafeAudit(clock, auditSink);
this.gateway = new ServerOperationGateway(configuration.realmId(), configuration.authorityExposure(),
control, roles, authorization, approvals, breakGlass, new OperationSecurityDescriptors(),
session.operations(), session.resourceScopes(), configuration.approvalPolicies(), clock, auditSink,
this::requireOpen);
}
/**
* Opens one production realm using explicit process-local capabilities.
*
* @param configuration validated immutable realm configuration
* @param dependencies key-access capabilities, never secret values
* @return fully recovered realm context
*/
public static ServerRealmContext open(ServerRealmConfiguration configuration,
PkiSessionRuntimeDependencies dependencies) {
return open(configuration, dependencies, Clock.systemUTC(), new SecureRandom());
}
static ServerRealmContext open(ServerRealmConfiguration configuration,
PkiSessionRuntimeDependencies dependencies, Clock clock, SecureRandom random) {
ServerRealmConfiguration exact = Objects.requireNonNull(configuration, "configuration");
PkiSessionRuntimeDependencies runtime = Objects.requireNonNull(dependencies, "dependencies");
Objects.requireNonNull(clock, "clock");
Objects.requireNonNull(random, "random");
if (Files.isSymbolicLink(exact.controlLogPath())) {
throw new IllegalArgumentException("Server-control log must not be a symbolic link");
}
ServerControlStore control = null;
SharedAuditSink audit = null;
PkiSession session = null;
try {
control = new ServerControlStore(openControl(exact));
ServerControlStore.RealmRecord realm = new ServerControlStore.RealmRecord(exact.realmId(),
exact.displayName(), exact.authorityExposure(), exact.authorizationCommitment(),
exact.approvalCommitment(), exact.disclosureCommitment(), exact.controlStoreId());
control.ensureRealm(realm);
RoleTemplateCatalog roles = RoleTemplateCatalog.load(ServerRealmContext.class.getClassLoader());
control.validateAll();
control.validateReferences(roles);
audit = new SharedAuditSink(PkiBootstrap.openAudit(exact.pkiSessionConfiguration().audit()));
session = PkiSession.open(exact.pkiSessionConfiguration(), runtime.withAuditSink(audit));
validateExposure(exact.authorityExposure(), session);
AuthorizationEngine authorization = new AuthorizationEngine(clock);
ApprovalService approvals = new ApprovalService(control, clock, audit);
BreakGlassService breakGlass = new BreakGlassService(control, clock, audit);
DisclosureService disclosure = new DisclosureService(exact.realmId(), control,
exact.disclosureDefaults(), clock, random, audit);
AuditorViews views = new AuditorViews(clock, audit);
ServerRealmContext result = new ServerRealmContext(exact, control, session, roles, authorization,
approvals, breakGlass, disclosure, views, audit, clock);
result.audit.record("REALM_OPEN", "system", Optional.empty(), Map.of("realm", exact.realmId().value()));
return result;
} catch (RuntimeException | Error primary) {
closeAfterFailure(session, audit, control, primary);
throw primary;
}
}
/** @return exact realm configuration without rendering provider values */
public ServerRealmConfiguration configuration() { requireOpen(); return configuration; }
/** @return the one long-lived PKI session */
public PkiSession session() { requireOpen(); return session; }
/** @return immutable built-in role template catalog */
public RoleTemplateCatalog roleTemplates() { requireOpen(); return roles; }
/** @return deterministic authorization engine */
public AuthorizationEngine authorization() { requireOpen(); return authorization; }
/** @return durable approval service */
public ApprovalService approvals() { requireOpen(); return approvals; }
/** @return durable break-glass service */
public BreakGlassService breakGlass() { requireOpen(); return breakGlass; }
/** @return durable disclosure decision service */
public DisclosureService disclosure() { requireOpen(); return disclosure; }
/** @return explicit auditor projection service */
public AuditorViews auditorViews() { requireOpen(); return auditorViews; }
/** @return authorized typed-operation gateway */
public ServerOperationGateway gateway() { requireOpen(); return gateway; }
/** @return current lifecycle state */
public State state() { return state.get(); }
/** Creates a principal and records only safe administrative audit metadata. */
public void createPrincipal(SecurityPrincipal principal, String actorPrincipalId) {
requireOpen();
control.createPrincipal(principal);
audit.record("PRINCIPAL_CREATE", actorPrincipalId, Optional.empty(),
Map.of("principal", principal.principalId(), "enabled", Boolean.toString(principal.enabled())));
}
/** Changes only the enabled state of an existing principal. */
public SecurityPrincipal setPrincipalEnabled(String principalId, boolean enabled, String actorPrincipalId) {
requireOpen();
SecurityPrincipal current = control.requirePrincipal(principalId);
SecurityPrincipal updated = new SecurityPrincipal(current.principalId(), current.type(),
current.displayName(), current.organization(), current.attributes(), enabled);
control.replacePrincipal(current, updated);
audit.record("PRINCIPAL_STATE", actorPrincipalId, Optional.empty(),
Map.of("principal", principalId, "enabled", Boolean.toString(enabled)));
return updated;
}
/** Creates one scoped role assignment; templates remain non-authoritative alone. */
public void assignRole(RoleTemplateCatalog.Assignment assignment, String actorPrincipalId) {
requireOpen();
roles.instantiate(assignment);
control.requirePrincipal(assignment.principalId());
control.createAssignment(assignment);
audit.record("ROLE_ASSIGNMENT_CREATE", actorPrincipalId, Optional.empty(),
Map.of("assignment", assignment.assignmentId(), "template", assignment.templateId()));
}
/** Creates one explicit scoped direct grant. */
public void grant(Permission.Grant grant, String actorPrincipalId) {
requireOpen();
control.requirePrincipal(grant.principalId());
control.createGrant(grant);
audit.record("PERMISSION_GRANT_CREATE", actorPrincipalId, Optional.empty(),
Map.of("grant", grant.grantId(), "effect", grant.effect().name()));
}
/**
* Rejects new work and closes session then server-control authority. Repeated
* calls are harmless and failure suppression preserves causal order.
*/
@Override
public void close() throws Exception {
if (!state.compareAndSet(State.OPEN, State.CLOSING)) return;
Throwable primary = null;
try {
audit.record("REALM_CLOSE", "system", Optional.empty(), Map.of("realm", configuration.realmId().value()));
} catch (Throwable failure) {
primary = failure;
}
primary = closeOne(session, primary);
primary = closeOne(control, primary);
state.set(State.CLOSED);
rethrow(primary);
}
private void requireOpen() {
if (state.get() != State.OPEN) throw new IllegalStateException("Server realm is not open");
}
private static PosixTransactionalMetadataStore openControl(ServerRealmConfiguration configuration) {
try {
if (Files.exists(configuration.controlLogPath())) {
PosixTransactionalMetadataStore opened = PosixTransactionalMetadataStore.open(
configuration.controlLogPath(), OptionalLong.of(1_048_576));
if (!opened.id().equals(configuration.controlStoreId())) {
opened.close();
throw new IllegalStateException("Server-control store identity differs");
}
return opened;
}
return PosixTransactionalMetadataStore.create(configuration.controlLogPath(),
configuration.controlStoreId(), OptionalLong.of(1_048_576));
} catch (IOException failure) {
throw new IllegalStateException("Server-control metadata authority cannot be opened");
}
}
private static void validateExposure(AuthorityExposurePolicy exposure, PkiSession session) {
if (exposure.mode() == AuthorityExposurePolicy.Mode.EXPLICIT_AUTHORITIES) {
for (zeroecho.pki.api.PkiId authorityId : exposure.authorityIds()) {
PkiOperationOutcome outcome = session.operations().execute(
new PkiOperation.InspectAuthority(authorityId), zeroecho.core.io.CancellationSignal.NONE);
if (!(outcome instanceof PkiOperationOutcome.Success)) {
throw new IllegalStateException("Configured authority exposure contains an unavailable authority");
}
}
}
}
private static void closeAfterFailure(PkiSession session, AuditSink audit, ServerControlStore control,
Throwable primary) {
Throwable result = closeOne(session, primary);
if (session == null) result = closeOne(audit, result);
closeOne(control, result);
}
private static Throwable closeOne(AutoCloseable resource, Throwable primary) {
if (resource == null) return primary;
try {
resource.close();
} catch (Throwable failure) {
if (primary == null) return failure;
primary.addSuppressed(failure);
}
return primary;
}
private static void rethrow(Throwable failure) throws Exception {
if (failure == null) return;
if (failure instanceof Exception exception) throw exception;
if (failure instanceof Error error) throw error;
throw new IllegalStateException("Unexpected realm close failure");
}
private static final class SharedAuditSink implements AuditSink {
private final AuditSink delegate;
private boolean closed;
private SharedAuditSink(AuditSink delegate) { this.delegate = Objects.requireNonNull(delegate, "delegate"); }
@Override public synchronized void record(zeroecho.pki.api.audit.AuditEvent event) {
if (closed) throw new IllegalStateException("Audit sink is closed");
delegate.record(event);
}
@Override public synchronized void close() {
if (!closed) { closed = true; delegate.close(); }
}
}
}

View File

@@ -0,0 +1,20 @@
{
"schemaVersion": 1,
"roles": [
{"id":"platform-operator","version":1,"actions":["REALM_READ","SERVER_HEALTH_READ","SERVER_CONFIGURATION_READ","SERVER_CONFIGURATION_UPDATE"]},
{"id":"security-administrator","version":1,"actions":["REALM_READ","IDENTITY_PROVIDER_MANAGE","PRINCIPAL_MANAGE","ROLE_MANAGE","PERMISSION_GRANT"]},
{"id":"ca-security-officer","version":1,"actions":["AUTHORITY_LIST","AUTHORITY_READ","AUTHORITY_CREATE","AUTHORITY_IMPORT","AUTHORITY_ACTIVATE","AUTHORITY_SUSPEND","AUTHORITY_RETIRE","ISSUER_CREATE","ISSUER_ROTATE","ISSUER_RETIRE","CA_CHAIN_DOWNLOAD"]},
{"id":"profile-policy-manager","version":1,"actions":["PROFILE_READ","PROFILE_REGISTER","PROFILE_VALIDATE","PROFILE_ACTIVATE","PROFILE_DEACTIVATE","POLICY_READ","POLICY_UPDATE","X509_BINDING_READ","X509_BINDING_PROVIDER_ENABLE"]},
{"id":"enrollment-officer","version":1,"actions":["REQUEST_SUBMIT","REQUEST_READ_ANY","CERTIFICATE_ISSUE","CERTIFICATE_READ_METADATA","CERTIFICATE_DOWNLOAD"]},
{"id":"approver","version":1,"actions":["REQUEST_APPROVE","REQUEST_REJECT"]},
{"id":"requester","version":1,"actions":["REQUEST_SUBMIT","REQUEST_READ_OWN","REQUEST_CANCEL","CERTIFICATE_READ_METADATA","CERTIFICATE_DOWNLOAD","CERTIFICATE_RENEW","CERTIFICATE_REKEY"]},
{"id":"revocation-officer","version":1,"actions":["CERTIFICATE_REVOKE","CERTIFICATE_HOLD","CERTIFICATE_RELEASE_HOLD","REVOCATION_HISTORY_READ","CRL_GENERATE","CRL_PUBLISH","CRL_DOWNLOAD"]},
{"id":"publication-operator","version":1,"actions":["PUBLICATION_REGISTER","PUBLICATION_READ","PUBLICATION_PROCESS","PUBLICATION_RETRY","PUBLICATION_RECONCILE"]},
{"id":"backup-operator","version":1,"actions":["BACKUP_EXPORT","BACKUP_VERIFY"]},
{"id":"recovery-officer","version":1,"actions":["RESTORE_EXECUTE"]},
{"id":"auditor","version":1,"actions":["AUDIT_READ_REDACTED","AUDIT_INTEGRITY_VERIFY","REALM_READ","AUTHORITY_LIST","AUTHORITY_READ","PROFILE_READ","CERTIFICATE_READ_METADATA","REVOCATION_HISTORY_READ","PUBLICATION_READ"]},
{"id":"privileged-auditor","version":1,"actions":["AUDIT_READ_REDACTED","AUDIT_READ_FULL","AUDIT_READ_PII","AUDIT_EXPORT","AUDIT_INTEGRITY_VERIFY","CERTIFICATE_READ_METADATA","CERTIFICATE_READ_CONTENT","CERTIFICATE_READ_PII"]},
{"id":"acme-administrator","version":1,"actions":["REALM_READ","AUTHORITY_READ","PROFILE_READ","POLICY_READ"]},
{"id":"public-principal","version":1,"actions":["REALM_READ","CA_CHAIN_DOWNLOAD","CRL_DOWNLOAD"]}
]
}

View File

@@ -0,0 +1,139 @@
/*******************************************************************************
* 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.server;
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.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.Test;
/** Authorization and immutable built-in role-template coverage. */
class AuthorizationAndRoleTest {
@Test
void defaultsToDenyAndExplicitDenyOverridesAllow() {
System.out.println("defaultsToDenyAndExplicitDenyOverridesAllow");
AuthorizationEngine engine = new AuthorizationEngine(ServerTestSupport.CLOCK);
SecurityPrincipal principal = ServerTestSupport.principal("officer");
AuthorizationEngine.Request empty = request(principal, List.of(), Permission.DataView.METADATA_REDACTED);
assertEquals(AuthorizationEngine.Code.NO_MATCHING_GRANT, engine.authorize(empty).code());
Permission.Grant allow = ServerTestSupport.grant("allow", "officer", Permission.Effect.ALLOW,
Permission.Action.AUTHORITY_READ, Permission.ResourceType.AUTHORITY, ServerTestSupport.scope(),
Permission.Relationship.ANY, Permission.DataView.METADATA_FULL);
Permission.Grant deny = ServerTestSupport.grant("deny", "officer", Permission.Effect.DENY,
Permission.Action.AUTHORITY_READ, Permission.ResourceType.AUTHORITY, ServerTestSupport.scope(),
Permission.Relationship.ANY, Permission.DataView.METADATA_FULL);
assertEquals(AuthorizationEngine.Code.ALLOWED,
engine.authorize(request(principal, List.of(allow), Permission.DataView.METADATA_REDACTED)).code());
assertEquals(AuthorizationEngine.Code.EXPLICITLY_DENIED,
engine.authorize(request(principal, List.of(allow, deny), Permission.DataView.METADATA_REDACTED)).code());
System.out.println("...decisions=default-deny,allow,explicit-deny");
System.out.println("...ok");
}
@Test
void enforcesAuthorityOwnershipViewsExpiryAndDisabledPrincipal() {
System.out.println("enforcesAuthorityOwnershipViewsExpiryAndDisabledPrincipal");
AuthorizationEngine engine = new AuthorizationEngine(ServerTestSupport.CLOCK);
SecurityPrincipal principal = ServerTestSupport.principal("requester");
Permission.Resource owned = new Permission.Resource(Permission.ResourceType.CERTIFICATE,
ServerTestSupport.scope(), Optional.of(new zeroecho.pki.api.PkiId("credential-a")),
Optional.of("requester"));
Permission.Grant own = new Permission.Grant("own", "requester", Permission.Effect.ALLOW,
Permission.Action.CERTIFICATE_READ_METADATA, Permission.ResourceType.CERTIFICATE,
ServerTestSupport.scope(), Permission.Relationship.OWN, Permission.DataView.METADATA_REDACTED,
Set.of(), Optional.empty(), true);
AuthorizationEngine.Request accepted = new AuthorizationEngine.Request(ServerTestSupport.REALM,
new AuthorityExposurePolicy(AuthorityExposurePolicy.Mode.ALL_REALM_AUTHORITIES, Set.of(), false),
principal, Permission.Action.CERTIFICATE_READ_METADATA, owned, Permission.Relationship.OWN,
Permission.DataView.METADATA_REDACTED, Permission.Context.empty(), List.of(own), Set.of());
assertTrue(engine.authorize(accepted).allowed());
Permission.Grant expired = new Permission.Grant("expired", "requester", Permission.Effect.ALLOW,
own.action(), own.resourceType(), own.scope(), own.relationship(), own.dataView(), Set.of(),
Optional.of(Instant.parse("2026-08-04T11:59:59Z")), true);
assertEquals(AuthorizationEngine.Code.GRANT_EXPIRED,
engine.authorize(new AuthorizationEngine.Request(accepted.realmId(), accepted.exposure(), principal,
accepted.action(), owned, accepted.relationship(), accepted.dataView(), accepted.context(),
List.of(expired), Set.of())).code());
SecurityPrincipal disabled = new SecurityPrincipal("requester", SecurityPrincipal.Type.USER, "Requester",
Optional.empty(), Map.of(), false);
assertEquals(AuthorizationEngine.Code.PRINCIPAL_DISABLED,
engine.authorize(new AuthorizationEngine.Request(accepted.realmId(), accepted.exposure(), disabled,
accepted.action(), owned, accepted.relationship(), accepted.dataView(), accepted.context(),
List.of(own), Set.of())).code());
System.out.println("...ownership=true, expiry=closed, disabled=closed");
System.out.println("...ok");
}
@Test
void loadsExactTemplatesAndRejectsUnknownSchemaFields() throws Exception {
System.out.println("loadsExactTemplatesAndRejectsUnknownSchemaFields");
RoleTemplateCatalog catalog = RoleTemplateCatalog.load(getClass().getClassLoader());
assertEquals(15, catalog.templates().size());
assertTrue(catalog.templates().stream().anyMatch(item -> item.templateId().equals("platform-operator")));
assertFalse(catalog.templates().stream().anyMatch(item -> item.templateId().contains("owner")));
RoleTemplateCatalog.Assignment requester = new RoleTemplateCatalog.Assignment("requester-role",
"requester", "requester", 1, ServerTestSupport.scope(), true);
assertTrue(catalog.instantiate(requester).stream()
.filter(item -> item.action() == Permission.Action.CERTIFICATE_READ_METADATA)
.allMatch(item -> item.relationship() == Permission.Relationship.OWN));
byte[] source;
try (java.io.InputStream input = getClass().getClassLoader()
.getResourceAsStream(RoleTemplateCatalog.RESOURCE)) {
source = java.util.Objects.requireNonNull(input).readAllBytes();
}
String hostile = new String(source, StandardCharsets.UTF_8).replaceFirst("\\{",
"{\"unknown\":true,");
assertThrows(IllegalArgumentException.class,
() -> RoleTemplateCatalog.parse(hostile.getBytes(StandardCharsets.UTF_8)));
System.out.println("...templates=" + catalog.templates().size());
System.out.println("...ok");
}
private static AuthorizationEngine.Request request(SecurityPrincipal principal, List<Permission.Grant> grants,
Permission.DataView view) {
return new AuthorizationEngine.Request(ServerTestSupport.REALM,
new AuthorityExposurePolicy(AuthorityExposurePolicy.Mode.ALL_REALM_AUTHORITIES, Set.of(), false),
principal, Permission.Action.AUTHORITY_READ, ServerTestSupport.authorityResource(),
Permission.Relationship.ANY, view, Permission.Context.empty(), grants, Set.of());
}
}

View File

@@ -0,0 +1,238 @@
/*******************************************************************************
* 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.server;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Path;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.OptionalLong;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.impl.fs.PosixTransactionalMetadataStore;
/** Durable control, approval, break-glass, audit-view and disclosure tests. */
class ControlApprovalDisclosureTest {
@TempDir Path temporaryDirectory;
@Test
void persistsPrincipalsAssignmentsAndGrantsAcrossRestart() throws Exception {
System.out.println("persistsPrincipalsAssignmentsAndGrantsAcrossRestart");
Path log;
try (ServerTestSupport.OpenedStore opened = ServerTestSupport.open(temporaryDirectory)) {
log = opened.log();
opened.store().createPrincipal(ServerTestSupport.principal("officer"));
opened.store().createAssignment(new RoleTemplateCatalog.Assignment("assignment-a", "officer",
"ca-security-officer", 1, ServerTestSupport.scope(), true));
opened.store().createGrant(ServerTestSupport.grant("grant-a", "officer", Permission.Effect.ALLOW,
Permission.Action.AUTHORITY_READ, Permission.ResourceType.AUTHORITY, ServerTestSupport.scope(),
Permission.Relationship.ANY, Permission.DataView.METADATA_REDACTED));
opened.store().validateAll();
}
try (ServerControlStore reopened = new ServerControlStore(PosixTransactionalMetadataStore.open(log,
OptionalLong.of(1_048_576)))) {
reopened.validateAll();
assertEquals("officer", reopened.requirePrincipal("officer").principalId());
assertEquals(1, reopened.assignmentsFor("officer").size());
assertEquals(1, reopened.grantsFor("officer").size());
System.out.println("...recovered-records=3");
}
System.out.println("...ok");
}
@Test
void enforcesTwoPartyApprovalCommitmentAndExactOnceExecution() throws Exception {
System.out.println("enforcesTwoPartyApprovalCommitmentAndExactOnceExecution");
try (ServerTestSupport.OpenedStore opened = ServerTestSupport.open(temporaryDirectory)) {
opened.store().createPrincipal(ServerTestSupport.principal("requester"));
opened.store().createPrincipal(ServerTestSupport.principal("approver-a"));
opened.store().createPrincipal(ServerTestSupport.principal("approver-b"));
ApprovalService service = new ApprovalService(opened.store(), ServerTestSupport.CLOCK, opened.audit());
ApprovalService.Policy policy = new ApprovalService.Policy("high-risk", 2,
Set.of("approver-a", "approver-b"), Set.of(), true, Duration.ofHours(2), true);
ApprovalService.Request request = service.request("approval-a", "ca.create",
ServerTestSupport.DIGEST, ServerTestSupport.scope(), "requester", policy);
assertThrows(IllegalStateException.class, () -> service.decide(request.approvalId(),
"requester", ApprovalService.Choice.APPROVE, "self"));
service.decide(request.approvalId(), "approver-a",
ApprovalService.Choice.APPROVE, "reviewed-a");
ApprovalService.Request approved = service.decide(request.approvalId(),
"approver-b", ApprovalService.Choice.APPROVE, "reviewed-b");
assertEquals(ApprovalService.State.APPROVED, approved.state());
assertThrows(IllegalStateException.class, () -> service.claim(request.approvalId(), "ca.create",
"abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd",
ServerTestSupport.scope()));
service.claim(request.approvalId(), "ca.create", ServerTestSupport.DIGEST, ServerTestSupport.scope());
ApprovalService.Request executed = service.complete(request.approvalId(), "SUCCEEDED");
assertEquals(ApprovalService.State.EXECUTED, executed.state());
assertThrows(IllegalStateException.class, () -> service.claim(request.approvalId(), "ca.create",
ServerTestSupport.DIGEST, ServerTestSupport.scope()));
ApprovalService.Request rejected = service.request("approval-rejected", "ca.create",
ServerTestSupport.DIGEST, ServerTestSupport.scope(), "requester", policy);
assertEquals(ApprovalService.State.REJECTED, service.decide(rejected.approvalId(), "approver-a",
ApprovalService.Choice.REJECT, "policy mismatch").state());
ApprovalService.Request cancelled = service.request("approval-cancelled", "ca.create",
ServerTestSupport.DIGEST, ServerTestSupport.scope(), "requester", policy);
assertEquals(ApprovalService.State.CANCELLED,
service.cancel(cancelled.approvalId(), "requester").state());
ApprovalService.Request expiring = service.request("approval-expired", "ca.create",
ServerTestSupport.DIGEST, ServerTestSupport.scope(), "requester", policy);
Clock afterExpiry = Clock.fixed(expiring.expiresAt(), ZoneOffset.UTC);
ApprovalService recovered = new ApprovalService(opened.store(), afterExpiry, opened.audit());
assertEquals(ApprovalService.State.EXPIRED, recovered.requireCurrent(expiring.approvalId()).state());
System.out.println("...approvals=" + approved.decisions().size() + ", state=" + executed.state());
}
System.out.println("...ok");
}
@Test
void breakGlassIsScopedAuditedAndExpires() throws Exception {
System.out.println("breakGlassIsScopedAuditedAndExpires");
try (ServerTestSupport.OpenedStore opened = ServerTestSupport.open(temporaryDirectory)) {
opened.store().createPrincipal(ServerTestSupport.principal("officer"));
opened.store().createPrincipal(ServerTestSupport.principal("security-admin"));
Permission.Grant emergency = ServerTestSupport.grant("emergency-grant", "officer",
Permission.Effect.ALLOW, Permission.Action.CERTIFICATE_REVOKE,
Permission.ResourceType.REVOCATION, ServerTestSupport.scope(), Permission.Relationship.ANY,
Permission.DataView.METADATA_REDACTED);
BreakGlassService service = new BreakGlassService(opened.store(), ServerTestSupport.CLOCK, opened.audit());
BreakGlassService.Record active = service.activate("break-a", "officer", emergency,
"incident-response", "security-admin", Optional.empty(),
ServerTestSupport.CLOCK.instant().plus(Duration.ofHours(1)));
assertEquals(BreakGlassService.State.ACTIVE, active.state());
assertEquals(1, service.activeFor("officer").grants().size());
assertThrows(IllegalArgumentException.class, () -> service.activate("break-b", "officer", emergency,
"incident", "officer", Optional.empty(), ServerTestSupport.CLOCK.instant().plusSeconds(60)));
assertTrue(opened.audit().events().stream().anyMatch(event -> event.action().contains("ACTIVATE")));
BreakGlassService expired = new BreakGlassService(opened.store(),
Clock.fixed(active.expiresAt(), ZoneOffset.UTC), opened.audit());
assertTrue(expired.activeFor("officer").grants().isEmpty());
System.out.println("...scope-authority=" + active.grant().scope().authorityId().orElseThrow());
}
System.out.println("...ok");
}
@Test
void disclosureSeparatesIdentityOwnershipPublicationAndCapability() throws Exception {
System.out.println("disclosureSeparatesIdentityOwnershipPublicationAndCapability");
try (ServerTestSupport.OpenedStore opened = ServerTestSupport.open(temporaryDirectory)) {
opened.store().createPrincipal(ServerTestSupport.principal("owner-a"));
SecureRandom deterministic = SecureRandom.getInstance("SHA1PRNG");
deterministic.setSeed(new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 });
DisclosureService service = new DisclosureService(ServerTestSupport.REALM, opened.store(),
DisclosureService.Defaults.recommended(), ServerTestSupport.CLOCK, deterministic, opened.audit());
assertEquals(DisclosureService.Policy.PUBLIC,
service.defaultPolicy(DisclosureService.ObjectType.CA_CERTIFICATE, true, false));
assertEquals(DisclosureService.Policy.PUBLIC,
service.defaultPolicy(DisclosureService.ObjectType.CA_CHAIN, false, false));
assertEquals(DisclosureService.Policy.PUBLIC,
service.defaultPolicy(DisclosureService.ObjectType.CRL, false, false));
assertEquals(DisclosureService.Policy.OWNER_ONLY,
service.defaultPolicy(DisclosureService.ObjectType.LEAF_CERTIFICATE, false, false));
assertEquals(DisclosureService.Policy.RESTRICTED,
service.defaultPolicy(DisclosureService.ObjectType.LEAF_CERTIFICATE, false, true));
PkiId leaf = new PkiId("leaf-a");
service.register(leaf, DisclosureService.ObjectType.LEAF_CERTIFICATE,
DisclosureService.Policy.OWNER_ONLY, Optional.of("owner-a"), ServerTestSupport.DIGEST,
"officer");
assertEquals(DisclosureService.Decision.DENIED,
service.decide(leaf, Optional.empty(), false, false, Optional.empty()));
assertEquals(DisclosureService.Decision.ALLOWED,
service.decide(leaf, Optional.of(ServerTestSupport.principal("owner-a")), true, false,
Optional.empty()));
assertThrows(IllegalStateException.class, () -> service.change(leaf, DisclosureService.Policy.PUBLIC,
true, false, "officer"));
service.change(leaf, DisclosureService.Policy.PUBLIC_UNLISTED, true, true, "officer");
DisclosureService.IssuedCapability issued = service.issueCapability(leaf,
ServerTestSupport.CLOCK.instant().plusSeconds(600), "officer");
assertNotEquals(java.util.HexFormat.of().formatHex(issued.token()),
java.util.HexFormat.of().formatHex(issued.capability().tokenCommitment()));
assertEquals(DisclosureService.Decision.ALLOWED,
service.decide(leaf, Optional.empty(), false, false, Optional.of(issued.token())));
DisclosureService.Capability wrongAction = new DisclosureService.Capability("cap-wrong-action",
ServerTestSupport.REALM, leaf, "INSPECT", issued.capability().tokenCommitment(),
issued.capability().expiresAt(), false);
opened.store().createCapability(wrongAction);
service.revokeCapability(issued.capability().capabilityId(), "officer");
assertEquals(DisclosureService.Decision.DENIED,
service.decide(leaf, Optional.empty(), false, false, Optional.of(issued.token())));
byte[] wrong = issued.token();
wrong[0] ^= 1;
assertEquals(DisclosureService.Decision.DENIED,
service.decide(leaf, Optional.empty(), false, false, Optional.of(wrong)));
assertArrayEquals(issued.capability().tokenCommitment(),
opened.store().requireCapability(issued.capability().capabilityId()).tokenCommitment());
System.out.println("...token-bytes=" + issued.token().length + ", persisted=commitment-only");
}
System.out.println("...ok");
}
@Test
void auditorViewsKeepMetadataContentAndPiiSeparate() {
System.out.println("auditorViewsKeepMetadataContentAndPiiSeparate");
ServerTestSupport.RecordingAudit audit = new ServerTestSupport.RecordingAudit();
AuditorViews views = new AuditorViews(ServerTestSupport.CLOCK, audit);
AuditorViews.Source source = new AuditorViews.Source(new PkiId("credential-a"),
Permission.ResourceType.CERTIFICATE, ServerTestSupport.scope(), "VALID",
Instant.parse("2026-08-04T10:00:00Z"), "ed25519", Optional.of("standard.ed25519"),
ServerTestSupport.DIGEST, true, 2, Optional.of("CN=private"), List.of("private.example"),
Optional.of(new byte[] { 0x30, 0x01, 0x00 }));
AuditorViews.Redacted redacted = views.redacted(source);
assertTrue(redacted.subjectPresent());
assertThrows(SecurityException.class, () -> views.fullContent(source, true, false));
assertThrows(SecurityException.class, () -> views.pii(source, ServerTestSupport.principal("auditor"),
"case-1", true, false));
assertEquals("CN=private", views.pii(source, ServerTestSupport.principal("auditor"),
"case-1", true, true).subject().orElseThrow());
assertFalse(audit.events().isEmpty());
System.out.println("...redacted-san-count=" + redacted.subjectAlternativeNameCount());
System.out.println("...ok");
}
}

View File

@@ -0,0 +1,243 @@
/*******************************************************************************
* 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.server;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import zeroecho.core.io.CancellationSignal;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.application.PkiOperation;
import zeroecho.pki.application.PkiOperationFailure;
import zeroecho.pki.application.PkiOperationOutcome;
import zeroecho.pki.application.PkiOperationResult;
import zeroecho.pki.application.PkiOperationValue;
/** Gateway descriptor, multi-authority and approval enforcement coverage. */
class ServerOperationGatewayTest {
@TempDir Path temporaryDirectory;
@Test
void delegatesAllowedOperationAndDeniesCrossAuthorityWithoutExecution() throws Exception {
System.out.println("delegatesAllowedOperationAndDeniesCrossAuthorityWithoutExecution");
try (ServerTestSupport.OpenedStore opened = ServerTestSupport.open(temporaryDirectory)) {
opened.store().createPrincipal(ServerTestSupport.principal("auditor"));
opened.store().createGrant(ServerTestSupport.grant("read-a", "auditor", Permission.Effect.ALLOW,
Permission.Action.AUTHORITY_READ, Permission.ResourceType.AUTHORITY, ServerTestSupport.scope(),
Permission.Relationship.ANY, Permission.DataView.METADATA_REDACTED));
AtomicInteger calls = new AtomicInteger();
ServerOperationGateway gateway = gateway(opened, calls, Map.of());
ServerOperationGateway.Outcome allowed = gateway.execute(request(new PkiOperation.InspectAuthority(
ServerTestSupport.AUTHORITY), ServerTestSupport.authorityResource(), Optional.empty()),
CancellationSignal.NONE);
assertInstanceOf(ServerOperationGateway.Outcome.Executed.class, allowed);
Permission.Scope otherScope = new Permission.Scope(ServerTestSupport.REALM,
Optional.of(new PkiId("ca-b")), Optional.empty(), Optional.empty());
Permission.Resource forged = new Permission.Resource(Permission.ResourceType.AUTHORITY, otherScope,
Optional.of(ServerTestSupport.AUTHORITY), Optional.empty());
ServerOperationGateway.Outcome denied = gateway.execute(request(new PkiOperation.InspectAuthority(
ServerTestSupport.AUTHORITY), forged, Optional.empty()), CancellationSignal.NONE);
assertInstanceOf(ServerOperationGateway.Outcome.Denied.class, denied);
assertEquals(1, calls.get());
System.out.println("...backend-calls=" + calls.get());
}
System.out.println("...ok");
}
@Test
void requiresExactApprovalForHighRiskOperationAndExecutesOnce() throws Exception {
System.out.println("requiresExactApprovalForHighRiskOperationAndExecutesOnce");
try (ServerTestSupport.OpenedStore opened = ServerTestSupport.open(temporaryDirectory)) {
opened.store().createPrincipal(ServerTestSupport.principal("officer"));
opened.store().createPrincipal(ServerTestSupport.principal("approver"));
Permission.Scope realmScope = new Permission.Scope(ServerTestSupport.REALM, Optional.empty(),
Optional.empty(), Optional.empty());
opened.store().createGrant(ServerTestSupport.grant("create", "officer", Permission.Effect.ALLOW,
Permission.Action.AUTHORITY_CREATE, Permission.ResourceType.AUTHORITY, realmScope,
Permission.Relationship.ANY, Permission.DataView.METADATA_REDACTED));
ApprovalService.Policy policy = new ApprovalService.Policy("high-risk", 1, Set.of("approver"),
Set.of(), true, Duration.ofHours(1), true);
AtomicInteger calls = new AtomicInteger();
ServerOperationGateway gateway = gateway(opened, calls,
Map.of(OperationSecurityDescriptors.ApprovalCategory.HIGH_RISK, policy));
PkiOperation operation = new PkiOperation.CreateAuthority(new zeroecho.pki.api.FormatId("x509"),
new zeroecho.pki.api.SubjectRef("subject-a"), "profile-a", new zeroecho.pki.api.KeyRef("key-a"));
Permission.Resource resource = new Permission.Resource(Permission.ResourceType.AUTHORITY, realmScope,
Optional.empty(), Optional.empty());
ServerOperationGateway.Request initial = request(operation, resource, Optional.empty());
ServerOperationGateway.Outcome required = gateway.execute(initial, CancellationSignal.NONE);
String commitment = assertInstanceOf(ServerOperationGateway.Outcome.ApprovalRequired.class,
required).operationCommitment();
ApprovalService approvals = new ApprovalService(opened.store(), ServerTestSupport.CLOCK, opened.audit());
approvals.request("approval-gateway", operation.name(), commitment, realmScope, "officer", policy);
approvals.decide("approval-gateway", "approver",
ApprovalService.Choice.APPROVE, "reviewed");
ServerOperationGateway.Outcome executed = gateway.execute(request(operation, resource,
Optional.of("approval-gateway")), CancellationSignal.NONE);
assertInstanceOf(ServerOperationGateway.Outcome.Executed.class, executed);
assertThrows(IllegalStateException.class, () -> gateway.execute(request(operation, resource,
Optional.of("approval-gateway")), CancellationSignal.NONE));
assertEquals(1, calls.get());
System.out.println("...approval-state=" + approvals.requireCurrent("approval-gateway").state());
}
System.out.println("...ok");
}
@Test
void preservesRecoveryAndExternalOutcomeClassifications() throws Exception {
System.out.println("preservesRecoveryAndExternalOutcomeClassifications");
try (ServerTestSupport.OpenedStore opened = ServerTestSupport.open(temporaryDirectory)) {
opened.store().createPrincipal(ServerTestSupport.principal("auditor"));
opened.store().createGrant(ServerTestSupport.grant("read", "auditor", Permission.Effect.ALLOW,
Permission.Action.AUTHORITY_READ, Permission.ResourceType.AUTHORITY, ServerTestSupport.scope(),
Permission.Relationship.ANY, Permission.DataView.METADATA_REDACTED));
PkiOperationOutcome recovery = new PkiOperationOutcome.Failure(PkiOperation.InspectAuthority.NAME,
PkiOperationFailure.RECOVERY_REQUIRED, "RECOVERY_REQUIRED");
ServerOperationGateway gateway = gateway(opened, new AtomicInteger(), Map.of(), recovery);
ServerOperationGateway.Outcome outcome = gateway.execute(request(new PkiOperation.InspectAuthority(
ServerTestSupport.AUTHORITY), ServerTestSupport.authorityResource(), Optional.empty()),
CancellationSignal.NONE);
PkiOperationOutcome exact = assertInstanceOf(ServerOperationGateway.Outcome.Executed.class,
outcome).outcome();
assertEquals(PkiOperationFailure.RECOVERY_REQUIRED,
assertInstanceOf(PkiOperationOutcome.Failure.class, exact).classification());
System.out.println("...classification=RECOVERY_REQUIRED");
}
System.out.println("...ok");
}
@Test
void rejectsCredentialWhoseAuthoritativeIssuerDiffersFromClaimedScope() throws Exception {
System.out.println("rejectsCredentialWhoseAuthoritativeIssuerDiffersFromClaimedScope");
try (ServerTestSupport.OpenedStore opened = ServerTestSupport.open(temporaryDirectory)) {
opened.store().createPrincipal(ServerTestSupport.principal("auditor"));
opened.store().createGrant(ServerTestSupport.grant("credential-read", "auditor",
Permission.Effect.ALLOW, Permission.Action.CERTIFICATE_READ_METADATA,
Permission.ResourceType.CERTIFICATE, ServerTestSupport.scope(), Permission.Relationship.ANY,
Permission.DataView.METADATA_REDACTED));
AtomicInteger calls = new AtomicInteger();
zeroecho.pki.application.PkiResourceScopeResolver otherAuthority = new TestScopeResolver() {
@Override public Optional<PkiId> credentialAuthority(PkiId credentialId) {
return Optional.of(new PkiId("ca-b"));
}
};
ServerOperationGateway gateway = gateway(opened, calls, Map.of(),
new PkiOperationOutcome.Success(new PkiOperationResult("credential.inspect", Map.of())),
otherAuthority);
PkiId credentialId = new PkiId("credential-a");
Permission.Resource resource = new Permission.Resource(Permission.ResourceType.CERTIFICATE,
ServerTestSupport.scope(), Optional.of(credentialId), Optional.empty());
ServerOperationGateway.Outcome outcome = gateway.execute(request(
new PkiOperation.InspectCredential(credentialId), resource, Optional.empty()),
CancellationSignal.NONE);
assertInstanceOf(ServerOperationGateway.Outcome.Denied.class, outcome);
assertEquals(0, calls.get());
System.out.println("...cross-authority-backend-calls=" + calls.get());
}
System.out.println("...ok");
}
@Test
void descriptorRegistryRejectsUnknownAndDuplicateOperations() {
System.out.println("descriptorRegistryRejectsUnknownAndDuplicateOperations");
OperationSecurityDescriptors descriptors = new OperationSecurityDescriptors();
assertEquals(16, descriptors.descriptors().size());
assertThrows(SecurityException.class, () -> descriptors.require(new PkiOperation.ValidateConfiguration()));
OperationSecurityDescriptors.Descriptor descriptor = descriptors.descriptors()
.get(PkiOperation.InspectAuthority.NAME);
assertThrows(IllegalArgumentException.class,
() -> new OperationSecurityDescriptors(List.of(descriptor, descriptor)));
System.out.println("...covered-operations=" + descriptors.descriptors().size());
System.out.println("...ok");
}
private static ServerOperationGateway gateway(ServerTestSupport.OpenedStore opened, AtomicInteger calls,
Map<OperationSecurityDescriptors.ApprovalCategory, ApprovalService.Policy> policies) {
PkiOperationOutcome success = new PkiOperationOutcome.Success(new PkiOperationResult("test",
Map.of("safe", new PkiOperationValue.BooleanValue(true))));
return gateway(opened, calls, policies, success, new TestScopeResolver());
}
private static ServerOperationGateway gateway(ServerTestSupport.OpenedStore opened, AtomicInteger calls,
Map<OperationSecurityDescriptors.ApprovalCategory, ApprovalService.Policy> policies,
PkiOperationOutcome result) {
return gateway(opened, calls, policies, result, new TestScopeResolver());
}
private static ServerOperationGateway gateway(ServerTestSupport.OpenedStore opened, AtomicInteger calls,
Map<OperationSecurityDescriptors.ApprovalCategory, ApprovalService.Policy> policies,
PkiOperationOutcome result, zeroecho.pki.application.PkiResourceScopeResolver resourceScopes) {
AuthorizationEngine authorization = new AuthorizationEngine(ServerTestSupport.CLOCK);
ApprovalService approvals = new ApprovalService(opened.store(), ServerTestSupport.CLOCK, opened.audit());
BreakGlassService breakGlass = new BreakGlassService(opened.store(), ServerTestSupport.CLOCK, opened.audit());
return new ServerOperationGateway(ServerTestSupport.REALM,
new AuthorityExposurePolicy(AuthorityExposurePolicy.Mode.ALL_REALM_AUTHORITIES, Set.of(), true),
opened.store(), RoleTemplateCatalog.load(ServerOperationGatewayTest.class.getClassLoader()),
authorization, approvals, breakGlass, new OperationSecurityDescriptors(),
(operation, cancellation) -> { calls.incrementAndGet(); return result; }, resourceScopes, policies,
ServerTestSupport.CLOCK, opened.audit(), () -> { });
}
private static ServerOperationGateway.Request request(PkiOperation operation, Permission.Resource resource,
Optional<String> approval) {
String principalId = operation instanceof PkiOperation.CreateAuthority ? "officer" : "auditor";
return new ServerOperationGateway.Request(ServerTestSupport.REALM, principalId,
operation, resource, Permission.Relationship.ANY, Permission.Context.empty(), approval,
"correlation-a");
}
private static class TestScopeResolver implements zeroecho.pki.application.PkiResourceScopeResolver {
@Override public Optional<PkiId> credentialAuthority(PkiId credentialId) {
return Optional.of(ServerTestSupport.AUTHORITY);
}
@Override public Optional<PkiId> statusObjectAuthority(PkiId statusObjectId) {
return Optional.of(ServerTestSupport.AUTHORITY);
}
@Override public Optional<PkiId> publicationAuthority(PkiId publicationId) {
return Optional.of(ServerTestSupport.AUTHORITY);
}
}
}

View File

@@ -0,0 +1,141 @@
/*******************************************************************************
* 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.server;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.nio.file.Path;
import java.security.SecureRandom;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.application.PkiOperation;
import zeroecho.pki.application.PkiOperationOutcome;
import zeroecho.pki.application.PkiSessionConfiguration;
import zeroecho.pki.application.PkiSessionRuntimeDependencies;
import zeroecho.pki.spi.ProviderConfig;
/** Realm composition, one-session lifecycle and exposure-policy tests. */
class ServerRealmContextTest {
@TempDir Path temporaryDirectory;
@Test
void opensOneLongLivedSessionClosesAndRecoversControlState() throws Exception {
System.out.println("opensOneLongLivedSessionClosesAndRecoversControlState");
ServerRealmConfiguration configuration = configuration(new AuthorityExposurePolicy(
AuthorityExposurePolicy.Mode.ALL_REALM_AUTHORITIES, Set.of(), true));
try (ServerRealmContext context = ServerRealmContext.open(configuration,
PkiSessionRuntimeDependencies.none(), ServerTestSupport.CLOCK, deterministicRandom())) {
assertSame(context.session(), context.session());
PkiOperationOutcome first = context.session().operations().execute(new PkiOperation.ValidateConfiguration(),
zeroecho.core.io.CancellationSignal.NONE);
PkiOperationOutcome second = context.session().operations().execute(new PkiOperation.ValidateConfiguration(),
zeroecho.core.io.CancellationSignal.NONE);
assertEquals(PkiOperationOutcome.Success.class, first.getClass());
assertEquals(PkiOperationOutcome.Success.class, second.getClass());
context.createPrincipal(ServerTestSupport.principal("auditor"), "bootstrap");
assertEquals(ServerRealmContext.State.OPEN, context.state());
System.out.println("...session-reused=true");
}
try (ServerRealmContext reopened = ServerRealmContext.open(configuration,
PkiSessionRuntimeDependencies.none(), ServerTestSupport.CLOCK, deterministicRandom())) {
assertThrows(IllegalStateException.class,
() -> reopened.createPrincipal(ServerTestSupport.principal("auditor"), "bootstrap"));
assertEquals(ServerRealmContext.State.OPEN, reopened.state());
System.out.println("...control-recovered=true");
}
System.out.println("...ok");
}
@Test
void supportsExplicitOneAuthorityAndRejectsOtherAuthority() {
System.out.println("supportsExplicitOneAuthorityAndRejectsOtherAuthority");
AuthorityExposurePolicy exposure = new AuthorityExposurePolicy(
AuthorityExposurePolicy.Mode.EXPLICIT_AUTHORITIES, Set.of(ServerTestSupport.AUTHORITY), false);
assertEquals(true, exposure.allows(ServerTestSupport.AUTHORITY));
assertEquals(false, exposure.allows(new PkiId("ca-b")));
assertThrows(IllegalArgumentException.class, () -> new AuthorityExposurePolicy(
AuthorityExposurePolicy.Mode.EXPLICIT_AUTHORITIES, Set.of(), false));
System.out.println("...visible-authorities=" + exposure.authorityIds().size());
System.out.println("...ok");
}
@Test
void rejectsChangedRealmCommitmentOnReopen() throws Exception {
System.out.println("rejectsChangedRealmCommitmentOnReopen");
ServerRealmConfiguration original = configuration(new AuthorityExposurePolicy(
AuthorityExposurePolicy.Mode.ALL_REALM_AUTHORITIES, Set.of(), true));
try (ServerRealmContext ignored = ServerRealmContext.open(original, PkiSessionRuntimeDependencies.none(),
ServerTestSupport.CLOCK, deterministicRandom())) {
System.out.println("...initial-open=true");
}
String changed = "abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd";
ServerRealmConfiguration incompatible = new ServerRealmConfiguration(original.realmId(),
original.displayName(), original.pkiSessionConfiguration(), original.authorityExposure(), changed,
original.approvalCommitment(), original.disclosureCommitment(), original.disclosureDefaults(),
original.controlLogPath(), original.controlStoreId(), original.approvalPolicies());
assertThrows(IllegalStateException.class, () -> ServerRealmContext.open(incompatible,
PkiSessionRuntimeDependencies.none(), ServerTestSupport.CLOCK, deterministicRandom()));
System.out.println("...changed-commitment=rejected");
System.out.println("...ok");
}
private ServerRealmConfiguration configuration(AuthorityExposurePolicy exposure) {
ApprovalService.Policy approval = new ApprovalService.Policy("high-risk", 1, Set.of("approver"),
Set.of(), true, Duration.ofHours(1), true);
PkiSessionConfiguration pki = new PkiSessionConfiguration(1,
new ProviderConfig("fs", Map.of("root", temporaryDirectory.resolve("pki-store").toString())),
new ProviderConfig("memory", Map.of("size", "128")), Optional.empty(), List.of(), List.of());
return new ServerRealmConfiguration(ServerTestSupport.REALM, "Production", pki, exposure,
ServerTestSupport.DIGEST, approval.commitment(), ServerTestSupport.DIGEST,
DisclosureService.Defaults.recommended(), temporaryDirectory.resolve("control.log"),
ServerTestSupport.STORE_ID,
Map.of(OperationSecurityDescriptors.ApprovalCategory.HIGH_RISK, approval));
}
private static SecureRandom deterministicRandom() throws Exception {
SecureRandom random = SecureRandom.getInstance("SHA1PRNG");
random.setSeed(new byte[] { 8, 7, 6, 5, 4, 3, 2, 1 });
return random;
}
}

View File

@@ -0,0 +1,104 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.server;
import java.io.IOException;
import java.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.OptionalLong;
import java.util.Set;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.audit.AuditEvent;
import zeroecho.pki.impl.fs.PosixTransactionalMetadataStore;
import zeroecho.pki.spi.audit.AuditSink;
import zeroecho.pki.spi.store.MetadataStoreId;
/** Shared deterministic fixtures for the server security tests. */
final class ServerTestSupport {
static final RealmId REALM = new RealmId("production");
static final PkiId AUTHORITY = new PkiId("ca-a");
static final Clock CLOCK = Clock.fixed(Instant.parse("2026-08-04T12:00:00Z"), ZoneOffset.UTC);
static final String DIGEST = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
static final MetadataStoreId STORE_ID = new MetadataStoreId("0123456789abcdef0123456789abcdef");
private ServerTestSupport() { }
static OpenedStore open(Path directory) throws IOException {
Path log = directory.resolve("server-control.log");
PosixTransactionalMetadataStore metadata = PosixTransactionalMetadataStore.create(log, STORE_ID,
OptionalLong.of(1_048_576));
ServerControlStore store = new ServerControlStore(metadata);
store.ensureRealm(new ServerControlStore.RealmRecord(REALM, "Production",
new AuthorityExposurePolicy(AuthorityExposurePolicy.Mode.ALL_REALM_AUTHORITIES, Set.of(), true),
DIGEST, DIGEST, DIGEST, STORE_ID));
return new OpenedStore(log, store, new RecordingAudit());
}
static Permission.Scope scope() {
return new Permission.Scope(REALM, Optional.of(AUTHORITY), Optional.empty(), Optional.empty());
}
static Permission.Resource authorityResource() {
return new Permission.Resource(Permission.ResourceType.AUTHORITY, scope(), Optional.of(AUTHORITY),
Optional.empty());
}
static SecurityPrincipal principal(String id) {
return new SecurityPrincipal(id, SecurityPrincipal.Type.USER, id, Optional.empty(), Map.of(), true);
}
static Permission.Grant grant(String id, String principal, Permission.Effect effect,
Permission.Action action, Permission.ResourceType type, Permission.Scope scope,
Permission.Relationship relationship, Permission.DataView view) {
return new Permission.Grant(id, principal, effect, action, type, scope, relationship, view,
Set.of(), Optional.empty(), true);
}
record OpenedStore(Path log, ServerControlStore store, RecordingAudit audit) implements AutoCloseable {
@Override public void close() throws IOException { store.close(); }
}
static final class RecordingAudit implements AuditSink {
private final List<AuditEvent> events = new ArrayList<>();
@Override public void record(AuditEvent event) { events.add(event); }
List<AuditEvent> events() { return List.copyOf(events); }
}
}