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.
314 lines
16 KiB
Java
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;
|
|
}
|
|
}
|