/******************************************************************************* * 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; } }