diff --git a/pki-server/build.gradle b/pki-server/build.gradle new file mode 100644 index 0000000..874e872 --- /dev/null +++ b/pki-server/build.gradle @@ -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") +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/ApprovalService.java b/pki-server/src/main/java/zeroecho/pki/server/ApprovalService.java new file mode 100644 index 0000000..3891aae --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/ApprovalService.java @@ -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 eligibleApprovers, + Set 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 approvers = eligibleApprovers.stream().sorted().toList(); + List 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 decisions, + Optional 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 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 decisions, + Optional 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"); + } + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/AuditorViews.java b/pki-server/src/main/java/zeroecho/pki/server/AuditorViews.java new file mode 100644 index 0000000..5f32b57 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/AuditorViews.java @@ -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 bindingId, String commitment, + boolean subjectPresent, int subjectAlternativeNameCount, Optional fullSubject, + java.util.List fullSubjectAlternativeNames, Optional 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 bindingId, + String commitment, boolean subjectPresent, int subjectAlternativeNameCount) { } + + /** Full non-PII metadata projection. */ + public record FullMetadata(Redacted redacted, Map 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 subject, java.util.List 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()); + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/AuthorityExposurePolicy.java b/pki-server/src/main/java/zeroecho/pki/server/AuthorityExposurePolicy.java new file mode 100644 index 0000000..8ef77c1 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/AuthorityExposurePolicy.java @@ -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 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); + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/AuthorizationEngine.java b/pki-server/src/main/java/zeroecho/pki/server/AuthorizationEngine.java new file mode 100644 index 0000000..48bfa95 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/AuthorizationEngine.java @@ -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. + * + *

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.

+ */ +@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 grants, + java.util.Set 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); + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/BreakGlassService.java b/pki-server/src/main/java/zeroecho/pki/server/BreakGlassService.java new file mode 100644 index 0000000..10d11c9 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/BreakGlassService.java @@ -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 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 grants, java.util.Set 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 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 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()); + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/DisclosureService.java b/pki-server/src/main/java/zeroecho/pki/server/DisclosureService.java new file mode 100644 index 0000000..e281291 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/DisclosureService.java @@ -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 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 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 principal, + boolean ownerRelationship, + boolean explicitAdministrativePermission, Optional 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; + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/OperationSecurityDescriptors.java b/pki-server/src/main/java/zeroecho/pki/server/OperationSecurityDescriptors.java new file mode 100644 index 0000000..eeafc2f --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/OperationSecurityDescriptors.java @@ -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 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 values) { + Map 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 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"); + } + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/Permission.java b/pki-server/src/main/java/zeroecho/pki/server/Permission.java new file mode 100644 index 0000000..0b18f0a --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/Permission.java @@ -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 authorityId, Optional issuerId, + Optional 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 objectId, + Optional 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 conditions, Optional 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 reason, boolean approvalPresent, boolean authorityActive, + Map 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; + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/RealmId.java b/pki-server/src/main/java/zeroecho/pki/server/RealmId.java new file mode 100644 index 0000000..87e6e25 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/RealmId.java @@ -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. + * + *

The identity is deployment-defined and has no filesystem, listener, Java + * type, or store-name interpretation.

+ * + * @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; + } +} diff --git a/pki-server/src/main/java/zeroecho/pki/server/RoleTemplateCatalog.java b/pki-server/src/main/java/zeroecho/pki/server/RoleTemplateCatalog.java new file mode 100644 index 0000000..63b8a15 --- /dev/null +++ b/pki-server/src/main/java/zeroecho/pki/server/RoleTemplateCatalog.java @@ -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 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