Files
ZeroEcho/pki-server/src/main/java/zeroecho/pki/server/DisclosureService.java
Leo Galambos 8a5cbb61b3 feat(pki-server): add multi-CA security foundation
Add the durable multi-authority realm, scoped default-deny authorization,
approval and break-glass workflows, auditor views and disclosure policy.

Enforce all administration through the transport-neutral secured operation
gateway while preserving immutable PKI authority and future HTTP reuse.
2026-08-04 18:46:00 +02:00

314 lines
16 KiB
Java

/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.server;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Clock;
import java.time.Instant;
import java.util.HexFormat;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import zeroecho.pki.api.PkiId;
/** Durable disclosure authority and capability-based retrieval decision service. */
@SuppressWarnings({ "PMD.ControlStatementBraces", "PMD.AvoidLiteralsInIfCondition",
"PMD.FieldDeclarationsShouldBeAtStartOfClass", "PMD.AvoidSynchronizedAtMethodLevel" })
public final class DisclosureService {
/** Persisted disclosure states, independent of issuance and publication. */
public enum Policy {
PUBLIC(1), PUBLIC_UNLISTED(2), AUTHENTICATED(3), OWNER_ONLY(4), RESTRICTED(5), NOT_PUBLISHED(6);
private final int code;
Policy(int code) { this.code = code; }
/** @return stable persistence code */ public int code() { return code; }
/** Resolves a stable persistence code. */
public static Policy fromCode(int code) {
for (Policy candidate : values()) if (candidate.code == code) return candidate;
throw new IllegalArgumentException("Unknown disclosure-policy code");
}
}
/** Public-object classes with independent default policies. */
public enum ObjectType {
CA_CERTIFICATE(1), CA_CHAIN(2), LEAF_CERTIFICATE(3), CRL(4), STATUS_OBJECT(5);
private final int code;
ObjectType(int code) { this.code = code; }
/** @return stable persistence code */ public int code() { return code; }
/** Resolves a stable persistence code. */
public static ObjectType fromCode(int code) {
for (ObjectType candidate : values()) if (candidate.code == code) return candidate;
throw new IllegalArgumentException("Unknown disclosure object-type code");
}
}
/**
* Configuration-driven safe defaults.
*
* @param rootCa root certificate default
* @param intermediateCa intermediate certificate default
* @param caChain chain default
* @param crl CRL default
* @param leaf leaf default
* @param sensitiveLeaf sensitive leaf default
*/
public record Defaults(Policy rootCa, Policy intermediateCa, Policy caChain, Policy crl,
Policy leaf, Policy sensitiveLeaf) {
/** Validates non-null defaults and prohibits public sensitive-leaf defaults. */
public Defaults {
Objects.requireNonNull(rootCa, "rootCa");
Objects.requireNonNull(intermediateCa, "intermediateCa");
Objects.requireNonNull(caChain, "caChain");
Objects.requireNonNull(crl, "crl");
Objects.requireNonNull(leaf, "leaf");
Objects.requireNonNull(sensitiveLeaf, "sensitiveLeaf");
if (leaf == Policy.PUBLIC || sensitiveLeaf == Policy.PUBLIC
|| sensitiveLeaf == Policy.PUBLIC_UNLISTED) {
throw new IllegalArgumentException("Leaf defaults must not silently expose certificate data");
}
}
/** @return normative recommended defaults */
public static Defaults recommended() {
return new Defaults(Policy.PUBLIC, Policy.PUBLIC, Policy.PUBLIC, Policy.PUBLIC,
Policy.OWNER_ONLY, Policy.RESTRICTED);
}
}
/** Durable policy bound to exact object and profile/policy commitment. */
public record Record(PkiId objectId, ObjectType objectType, Policy policy, Optional<String> ownerPrincipalId,
String policyCommitment, Instant updatedAt) {
/** Validates the durable disclosure record. */
public Record {
Objects.requireNonNull(objectId, "objectId");
Objects.requireNonNull(objectType, "objectType");
Objects.requireNonNull(policy, "policy");
ownerPrincipalId = Objects.requireNonNull(ownerPrincipalId, "ownerPrincipalId")
.map(Permission::requirePrincipal);
if (policyCommitment == null || !policyCommitment.matches("[0-9a-f]{64}")) {
throw new IllegalArgumentException("Disclosure policy commitment is invalid");
}
Objects.requireNonNull(updatedAt, "updatedAt");
if (objectType == ObjectType.LEAF_CERTIFICATE && policy == Policy.OWNER_ONLY
&& ownerPrincipalId.isEmpty()) {
throw new IllegalArgumentException("Owner-only leaf disclosure requires an owner");
}
}
}
/** Persisted commitment-only capability authority. */
public record Capability(String capabilityId, RealmId realmId, PkiId objectId, String action,
byte[] tokenCommitment, Instant expiresAt, boolean revoked) {
/** Validates and snapshots capability metadata. */
public Capability {
Permission.requireId(capabilityId, "capability");
Objects.requireNonNull(realmId, "realmId");
Objects.requireNonNull(objectId, "objectId");
Permission.requireBounded(action, 128, "capability action");
tokenCommitment = Objects.requireNonNull(tokenCommitment, "tokenCommitment").clone();
if (tokenCommitment.length != 32) throw new IllegalArgumentException("Capability commitment length");
Objects.requireNonNull(expiresAt, "expiresAt");
}
/** Returns a defensive commitment copy. */
@Override public byte[] tokenCommitment() { return tokenCommitment.clone(); }
}
/** One-time raw-token issuance result. */
public record IssuedCapability(Capability capability, byte[] token) {
/** Validates and snapshots the one-time token. */
public IssuedCapability {
Objects.requireNonNull(capability, "capability");
token = Objects.requireNonNull(token, "token").clone();
if (token.length != 32) throw new IllegalArgumentException("Capability token length");
}
/** Returns a defensive one-time token copy. */
@Override public byte[] token() { return token.clone(); }
}
/** Safe retrieval result that reveals no protected-object existence. */
public enum Decision { ALLOWED, DENIED }
private final RealmId realmId;
private final ServerControlStore store;
private final Defaults defaults;
private final Clock clock;
private final SecureRandom random;
private final SafeAudit audit;
/** Creates a durable disclosure service with an injected random source and clock. */
public DisclosureService(RealmId realmId, ServerControlStore store, Defaults defaults, Clock clock,
SecureRandom random, zeroecho.pki.spi.audit.AuditSink auditSink) {
this.realmId = Objects.requireNonNull(realmId, "realmId");
this.store = Objects.requireNonNull(store, "store");
this.defaults = Objects.requireNonNull(defaults, "defaults");
this.clock = Objects.requireNonNull(clock, "clock");
this.random = Objects.requireNonNull(random, "random");
this.audit = new SafeAudit(clock, auditSink);
}
/** Returns the default policy for one object classification. */
public Policy defaultPolicy(ObjectType type, boolean rootCa, boolean sensitive) {
Objects.requireNonNull(type, "type");
return switch (type) {
case CA_CERTIFICATE -> rootCa ? defaults.rootCa() : defaults.intermediateCa();
case CA_CHAIN -> defaults.caChain();
case CRL -> defaults.crl();
case LEAF_CERTIFICATE -> sensitive ? defaults.sensitiveLeaf() : defaults.leaf();
case STATUS_OBJECT -> Policy.RESTRICTED;
};
}
/** Persists the first disclosure record without touching PKI content. */
public synchronized Record register(PkiId objectId, ObjectType type, Policy policy,
Optional<String> ownerPrincipalId,
String profileOrPolicyCommitment, String actorPrincipalId) {
ownerPrincipalId.ifPresent(store::requirePrincipal);
Record record = new Record(objectId, type, policy, ownerPrincipalId,
requireDigest(profileOrPolicyCommitment), clock.instant());
store.createDisclosure(record);
audit.record("DISCLOSURE_REGISTER", actorPrincipalId, Optional.of(objectId), Map.of("policy", policy.name()));
return record;
}
/** Changes only disclosure metadata; increased exposure may require approval. */
public synchronized Record change(PkiId objectId, Policy policy, boolean increasedExposureApprovalRequired,
boolean approvalPresented, String actorPrincipalId) {
Record current = store.requireDisclosure(objectId);
if (increasedExposureApprovalRequired && exposure(policy) < exposure(current.policy())
&& !approvalPresented) {
throw new IllegalStateException("Increased disclosure requires approval");
}
Record updated = new Record(current.objectId(), current.objectType(), policy, current.ownerPrincipalId(),
current.policyCommitment(), clock.instant());
store.replaceDisclosure(current, updated);
audit.record("DISCLOSURE_CHANGE", actorPrincipalId, Optional.of(objectId), Map.of("policy", policy.name()));
return updated;
}
/** Evaluates direct retrieval separately from search authorization. */
public synchronized Decision decide(PkiId objectId, Optional<SecurityPrincipal> principal,
boolean ownerRelationship,
boolean explicitAdministrativePermission, Optional<byte[]> capabilityToken) {
Record record = store.requireDisclosure(objectId);
return switch (record.policy()) {
case PUBLIC -> Decision.ALLOWED;
case PUBLIC_UNLISTED -> capabilityToken.filter(token -> validCapability(record.objectId(), token))
.isPresent() ? Decision.ALLOWED : Decision.DENIED;
case AUTHENTICATED -> principal.filter(SecurityPrincipal::enabled)
.filter(value -> value.type() != SecurityPrincipal.Type.PUBLIC)
.map(ignored -> Decision.ALLOWED).orElse(Decision.DENIED);
case OWNER_ONLY -> ownerRelationship || explicitAdministrativePermission
? Decision.ALLOWED : Decision.DENIED;
case RESTRICTED -> explicitAdministrativePermission ? Decision.ALLOWED : Decision.DENIED;
case NOT_PUBLISHED -> explicitAdministrativePermission || ownerRelationship
? Decision.ALLOWED : Decision.DENIED;
};
}
/** Issues 256 random capability bits and persists only their commitment. */
public synchronized IssuedCapability issueCapability(PkiId objectId, Instant expiresAt,
String actorPrincipalId) {
Record record = store.requireDisclosure(objectId);
if (record.policy() != Policy.PUBLIC_UNLISTED || !expiresAt.isAfter(clock.instant())) {
throw new IllegalStateException("Capability issuance is unavailable for this object");
}
byte[] token = new byte[32];
byte[] identity = new byte[16];
random.nextBytes(token);
random.nextBytes(identity);
String capabilityId = "cap-" + HexFormat.of().formatHex(identity);
Capability capability = new Capability(capabilityId, realmId, objectId, "RETRIEVE",
capabilityCommitment(objectId, expiresAt, token), expiresAt, false);
store.createCapability(capability);
audit.record("CAPABILITY_ISSUE", actorPrincipalId, Optional.of(objectId), Map.of("issued", "true"));
return new IssuedCapability(capability, token);
}
/** Revokes a capability without revealing or recovering its raw token. */
public synchronized Capability revokeCapability(String capabilityId, String actorPrincipalId) {
Capability current = store.requireCapability(capabilityId);
Capability revoked = new Capability(current.capabilityId(), current.realmId(), current.objectId(),
current.action(), current.tokenCommitment(), current.expiresAt(), true);
store.replaceCapability(current, revoked);
audit.record("CAPABILITY_REVOKE", actorPrincipalId, Optional.of(current.objectId()), Map.of("revoked", "true"));
return revoked;
}
private boolean validCapability(PkiId objectId, byte[] token) {
if (token == null || token.length != 32) return false;
for (Capability capability : store.capabilitiesFor(objectId)) {
if (!capability.revoked() && capability.realmId().equals(realmId)
&& "RETRIEVE".equals(capability.action())
&& clock.instant().isBefore(capability.expiresAt())
&& MessageDigest.isEqual(capability.tokenCommitment(),
capabilityCommitment(objectId, capability.expiresAt(), token))) return true;
}
return false;
}
private byte[] capabilityCommitment(PkiId objectId, Instant expiry, byte[] token) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
digest.update(realmId.value().getBytes(StandardCharsets.UTF_8));
digest.update((byte) 0);
digest.update(objectId.value().getBytes(StandardCharsets.UTF_8));
digest.update((byte) 0);
digest.update(ByteBuffer.allocate(Long.BYTES).putLong(expiry.toEpochMilli()).array());
digest.update(token);
return digest.digest();
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException("SHA-256 is unavailable", impossible);
}
}
private static int exposure(Policy policy) {
return switch (policy) {
case PUBLIC -> 0; case PUBLIC_UNLISTED -> 1; case AUTHENTICATED -> 2;
case OWNER_ONLY -> 3; case RESTRICTED -> 4; case NOT_PUBLISHED -> 5;
};
}
private static String requireDigest(String value) {
if (value == null || !value.matches("[0-9a-f]{64}")) {
throw new IllegalArgumentException("Policy commitment is invalid");
}
return value;
}
}