feat: refactor SPI/bootstrap to generic configurable providers

- Introduce a universal ConfigurableProvider/ProviderConfig abstraction
  for ServiceLoader-based components and align PKI bootstrapping
  utilities with it.
- Document deterministic provider selection, property-based
  configuration conventions, and security requirements (never log config
  values), including package-level documentation for spi, spi.store and
  spi.bootstrap.

fix: harden audit runtime, fix gzip scanning, add bounds and docs

- Fix FileAuditSink concatenated gzip scan by shielding underlying
  stream
- Use JUnit @TempDir for filesystem-backed tests
- Bound InMemoryAuditSink with deterministic ring buffer
- Add ServiceLoader smoke test and expand DefaultAuditService coverage
- Improve JavaDoc and logging across audit implementation

feat: add deterministic tests for PkiBootstrap with real SPI providers

- add JUnit 5 test suite for PkiBootstrap
- cover SPI selection for filesystem PkiStore and audit sinks
- use @TempDir for filesystem-backed providers
- register test ServiceLoader providers under src/test/resources
- ensure deterministic bootstrap behavior via system properties

Signed-off-by: Leo Galambos <lg@hq.egothor.org>
This commit is contained in:
2025-12-29 02:09:07 +01:00
parent cab1eeefe7
commit 0346c5b30f
33 changed files with 3672 additions and 32 deletions

View File

@@ -6,26 +6,27 @@
<attribute name="gradle_used_by_scope" value="main,test"/> <attribute name="gradle_used_by_scope" value="main,test"/>
</attributes> </attributes>
</classpathentry> </classpathentry>
<classpathentry kind="src" output="pki/bin/main" path="src/main/resources"/> <classpathentry kind="src" output="bin/main" path="src/main/resources">
<attributes>
<attribute name="gradle_scope" value="main"/>
<attribute name="gradle_used_by_scope" value="main,test"/>
</attributes>
</classpathentry>
<classpathentry kind="src" output="bin/test" path="src/test/java"> <classpathentry kind="src" output="bin/test" path="src/test/java">
<attributes> <attributes>
<attribute name="test" value="true"/>
<attribute name="gradle_scope" value="test"/> <attribute name="gradle_scope" value="test"/>
<attribute name="gradle_used_by_scope" value="test"/> <attribute name="gradle_used_by_scope" value="test"/>
<attribute name="test" value="true"/>
</attributes> </attributes>
</classpathentry> </classpathentry>
<classpathentry kind="src" output="bin/test" path="src/test/resources"> <classpathentry kind="src" output="bin/test" path="src/test/resources">
<attributes> <attributes>
<attribute name="test" value="true"/>
<attribute name="gradle_scope" value="test"/> <attribute name="gradle_scope" value="test"/>
<attribute name="gradle_used_by_scope" value="test"/> <attribute name="gradle_used_by_scope" value="test"/>
<attribute name="test" value="true"/>
</attributes> </attributes>
</classpathentry> </classpathentry>
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-21/"> <classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-21/"/>
<attributes>
<attribute name="module" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="con" path="org.eclipse.buildship.core.gradleclasspathcontainer"/> <classpathentry kind="con" path="org.eclipse.buildship.core.gradleclasspathcontainer"/>
<classpathentry kind="output" path="bin/default"/> <classpathentry kind="output" path="bin/default"/>
</classpath> </classpath>

View File

@@ -35,40 +35,80 @@
package zeroecho.pki.api.audit; package zeroecho.pki.api.audit;
import java.time.Instant; import java.time.Instant;
import java.util.Comparator;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import zeroecho.pki.api.FormatId; import zeroecho.pki.api.FormatId;
import zeroecho.pki.api.PkiId; import zeroecho.pki.api.PkiId;
/** /**
* Auditable event emitted by the PKI core. * Auditable event emitted by the PKI core and governance layer.
* *
* <p> * <p>
* Audit events may represent high-level PKI operations (issuance, revocation, * An {@code AuditEvent} is an immutable, structured record describing a
* publication, backup) and attribute access governance outcomes. * security-relevant action or outcome. Typical event categories include
* Implementations must ensure no secrets appear in {@code details}. * high-level PKI operations (issuance, revocation, publication, backup) and
* attribute-level governance decisions (read/export attempts and outcomes).
* </p> * </p>
* *
* @param time event time (server time) * <h2>Security properties</h2>
* @param category non-empty category (e.g., "ISSUANCE", "REVOCATION", * <ul>
* "ATTRIBUTE_ACCESS") * <li><strong>No secrets:</strong> {@link #details()} MUST NOT contain secrets
* @param action non-empty action string (e.g., "ISSUE_END_ENTITY", "REVOKE", * (keys, seeds, shared secrets, plaintext, private material, or other sensitive
* "READ") * cryptographic/internal state). It is intended for non-sensitive metadata only
* @param principal actor responsible for the event * (e.g., decision, policy id, reason codes, counters).</li>
* @param purpose purpose of the operation/access * <li><strong>Minimality:</strong> callers should prefer coarse,
* @param objectId optional subject object id (credential id, request id, etc.) * non-identifying fields and avoid excessive detail. If an identifier is
* @param formatId optional format id related to the object * needed, prefer {@link #objectId()} and a stable, non-secret reference.</li>
* @param details additional non-sensitive key/value details * </ul>
*
* <h2>Determinism and ordering</h2>
* <p>
* The static comparator returned by {@link #auditOrder()} defines a
* deterministic order for presentation and query results. It does not imply
* causality; it is purely a stable sort key.
* </p>
*
* <h2>Validation</h2>
* <p>
* This record enforces basic invariants: time is non-null, {@code category} and
* {@code action} are non-blank, {@code principal} and {@code purpose} are
* non-null, and optional containers/maps are non-null.
* </p>
*
* @param time event time (server time), never {@code null}
* @param category non-blank category (e.g., {@code "ISSUANCE"},
* {@code "REVOCATION"}, {@code "ATTRIBUTE_ACCESS"})
* @param action non-blank action string (e.g., {@code "ISSUE_END_ENTITY"},
* {@code "REVOKE"}, {@code "READ"})
* @param principal actor responsible for the event, never {@code null}
* @param purpose declared purpose of the operation/access, never {@code null}
* @param objectId optional subject object id (credential id, request id,
* attribute id, etc.), never {@code null}
* @param formatId optional format id related to the object (e.g., encoding),
* never {@code null}
* @param details additional non-sensitive key/value details, never
* {@code null}
*/ */
public record AuditEvent(Instant time, String category, String action, Principal principal, Purpose purpose, public record AuditEvent(Instant time, String category, String action, Principal principal, Purpose purpose,
Optional<PkiId> objectId, Optional<FormatId> formatId, Map<String, String> details) { Optional<PkiId> objectId, Optional<FormatId> formatId, Map<String, String> details) {
/** /**
* Creates an audit event. * Creates an audit event and validates record invariants.
* *
* @throws IllegalArgumentException if inputs are invalid or optional * <p>
* containers/maps are null * Note that this constructor does not (and cannot) automatically prove the
* absence of secrets in {@link #details()}. The responsibility to redact and
* constrain details is on the event producer.
* </p>
*
* @throws IllegalArgumentException if {@code time} is {@code null},
* {@code category/action} are blank,
* {@code principal/purpose} are {@code null},
* or any of {@code objectId/formatId/details}
* are {@code null}
*/ */
public AuditEvent { public AuditEvent {
if (time == null) { if (time == null) {
@@ -96,4 +136,78 @@ public record AuditEvent(Instant time, String category, String action, Principal
throw new IllegalArgumentException("details must not be null"); throw new IllegalArgumentException("details must not be null");
} }
} }
/**
* Returns a deterministic comparator for audit events.
*
* <p>
* The comparator orders events by: {@code time}, {@code category},
* {@code action}, {@code principal.type}, {@code principal.name}.
* </p>
*
* <p>
* This ordering is intended for consistent presentation and stable test
* assertions. It is not a security primitive.
* </p>
*
* @return deterministic comparator, never {@code null}
*/
public static Comparator<AuditEvent> auditOrder() {
return Comparator.comparing(AuditEvent::time).thenComparing(AuditEvent::category)
.thenComparing(AuditEvent::action).thenComparing(e -> e.principal().type())
.thenComparing(e -> e.principal().name());
}
/**
* Evaluates whether this event matches a query constraint set.
*
* <p>
* Matching semantics:
* </p>
* <ul>
* <li>If {@code query.category} is present, it must equal
* {@link #category()}.</li>
* <li>If {@code query.action} is present, it must equal {@link #action()}.</li>
* <li>If {@code query.after} is present, event time must be {@code >= after}
* (implemented as {@code !time.isBefore(after)}).</li>
* <li>If {@code query.before} is present, event time must be {@code <= before}
* (implemented as {@code !time.isAfter(before)}).</li>
* <li>If {@code query.objectId} is present, it must equal {@link #objectId()}
* (empty optional does not match).</li>
* <li>If {@code query.principalName} is present, it must equal
* {@link #principal()}.{@code name()}.</li>
* </ul>
*
* <p>
* This method is side-effect free and deterministic.
* </p>
*
* @param query query constraints, must not be {@code null}
* @return {@code true} if this event matches all present constraints;
* {@code false} otherwise
* @throws NullPointerException if {@code query} is {@code null}
*/
public boolean matches(AuditQuery query) {
Objects.requireNonNull(query, "query");
if (query.category().isPresent() && !query.category().get().equals(category())) {
return false;
}
if (query.action().isPresent() && !query.action().get().equals(action())) {
return false;
}
if (query.after().isPresent() && time().isBefore(query.after().get())) {
return false;
}
if (query.before().isPresent() && time().isAfter(query.before().get())) {
return false;
}
if (query.objectId().isPresent() && !query.objectId().get().equals(objectId().orElse(null))) {
return false;
}
if (query.principalName().isPresent() && !query.principalName().get().equals(principal().name())) { // NOPMD
return false;
}
return true;
}
} }

View File

@@ -0,0 +1,285 @@
/*******************************************************************************
* Copyright (C) 2025, 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.impl.audit;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.TreeMap;
import zeroecho.pki.api.FormatId;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.audit.AuditEvent;
import zeroecho.pki.api.audit.Principal;
import zeroecho.pki.api.audit.Purpose;
/**
* Internal codec for deterministic audit event identification and line-based
* persistence encoding.
*
* <h2>Stable id</h2>
* <p>
* {@link #eventId(AuditEvent)} computes SHA-256 over canonical bytes from
* {@link #canonicalBytes(AuditEvent)}. Canonicalization sorts
* {@link AuditEvent#details()} keys to ensure deterministic output.
* </p>
*
* <h2>Security</h2>
* <ul>
* <li>This codec does not log.</li>
* <li>It assumes audit details have already been redacted by the caller (no
* secrets).</li>
* </ul>
*/
final class AuditEventCodec {
private AuditEventCodec() {
}
/**
* Computes a deterministic stable identifier for an audit event.
*
* @param event audit event (must not be null)
* @return stable id
* @throws IllegalArgumentException if {@code event} is null
*/
/* default */ static PkiId eventId(AuditEvent event) {
if (event == null) {
throw new IllegalArgumentException("event must not be null");
}
byte[] bytes = canonicalBytes(event);
byte[] digest = sha256(bytes);
return new PkiId(toHex(digest));
}
/**
* Produces canonical UTF-8 bytes used as input to stable hashing.
*
* @param event audit event (must not be null)
* @return canonical bytes
* @throws IllegalArgumentException if {@code event} is null
*/
@SuppressWarnings("PMD")
/* default */ static byte[] canonicalBytes(AuditEvent event) {
if (event == null) {
throw new IllegalArgumentException("event must not be null");
}
StringBuilder sb = new StringBuilder(256);
sb.append("time=").append(event.time().toString()).append('\n');
sb.append("category=").append(event.category()).append('\n');
sb.append("action=").append(event.action()).append('\n');
sb.append("principal.type=").append(event.principal().type()).append('\n');
sb.append("principal.name=").append(event.principal().name()).append('\n');
sb.append("purpose=").append(event.purpose().value()).append('\n');
Optional<PkiId> objectId = event.objectId();
sb.append("objectId=").append(objectId.isPresent() ? objectId.get().value() : "").append('\n');
Optional<FormatId> formatId = event.formatId();
sb.append("formatId=").append(formatId.isPresent() ? formatId.get().value() : "").append('\n');
TreeMap<String, String> sorted = new TreeMap<String, String>();
sorted.putAll(event.details());
for (Map.Entry<String, String> e : sorted.entrySet()) {
sb.append("d.").append(e.getKey()).append('=').append(e.getValue()).append('\n');
}
return sb.toString().getBytes(StandardCharsets.UTF_8);
}
/**
* Encodes an event into a deterministic single-line representation
* (newline-terminated).
*
* @param event audit event (must not be null)
* @return encoded line
* @throws IllegalArgumentException if {@code event} is null
*/
@SuppressWarnings("PMD")
/* default */ static String encodeLine(AuditEvent event) {
if (event == null) {
throw new IllegalArgumentException("event must not be null");
}
StringBuilder sb = new StringBuilder(256);
sb.append("t=").append(event.time().toString()).append('|');
sb.append("c=").append(escape(event.category())).append('|');
sb.append("a=").append(escape(event.action())).append('|');
sb.append("pt=").append(escape(event.principal().type())).append('|');
sb.append("pn=").append(escape(event.principal().name())).append('|');
sb.append("pu=").append(escape(event.purpose().value())).append('|');
sb.append("oid=").append(escape(event.objectId().isPresent() ? event.objectId().get().value() : ""))
.append('|');
sb.append("fid=").append(escape(event.formatId().isPresent() ? event.formatId().get().value() : ""))
.append('|');
TreeMap<String, String> sorted = new TreeMap<String, String>();
sorted.putAll(event.details());
boolean first = true;
for (Map.Entry<String, String> e : sorted.entrySet()) {
if (!first) {
sb.append(',');
}
first = false;
sb.append(escape(e.getKey())).append('=').append(escape(e.getValue()));
}
sb.append('\n');
return sb.toString();
}
/**
* Decodes a single encoded line created by {@link #encodeLine(AuditEvent)}.
*
* @param line encoded line (must not be null or blank)
* @return decoded event
* @throws IllegalArgumentException if the input is malformed or missing
* required fields
*/
/* default */ static AuditEvent decodeLine(String line) {
if (line == null || line.isBlank()) {
throw new IllegalArgumentException("line must not be null/blank");
}
String trimmed = line.strip();
String[] parts = trimmed.split("\\|", -1);
Instant time = Instant.parse(value(parts, "t"));
String category = unescape(value(parts, "c"));
String action = unescape(value(parts, "a"));
Principal principal = new Principal(unescape(value(parts, "pt")), unescape(value(parts, "pn")));
Purpose purpose = new Purpose(unescape(value(parts, "pu")));
String oid = unescape(value(parts, "oid"));
Optional<PkiId> objectId = oid.isBlank() ? Optional.empty() : Optional.of(new PkiId(oid));
String fid = unescape(value(parts, "fid"));
Optional<FormatId> formatId = fid.isBlank() ? Optional.empty() : Optional.of(new FormatId(fid));
Map<String, String> details = decodeDetails(parts.length > 8 ? parts[8] : "");
return new AuditEvent(time, category, action, principal, purpose, objectId, formatId, Map.copyOf(details));
}
private static Map<String, String> decodeDetails(String detailsPart) {
Map<String, String> details = new TreeMap<>();
if (detailsPart == null || detailsPart.isBlank()) {
return details;
}
String[] kvs = detailsPart.split(",", -1);
for (String kv : kvs) {
int idx = kv.indexOf('=');
if (idx <= 0) {
continue;
}
String k = unescape(kv.substring(0, idx));
String v = unescape(kv.substring(idx + 1));
if (!k.isBlank()) {
details.put(k, v);
}
}
return details;
}
private static String value(String[] parts, String key) {
String prefix = key + "=";
for (String p : parts) {
if (p.startsWith(prefix)) {
return p.substring(prefix.length());
}
}
throw new IllegalArgumentException("Missing required field: " + key);
}
private static String escape(String s) {
String x = s.replace("\\", "\\\\");
x = x.replace("|", "\\|");
x = x.replace("\n", "\\n");
x = x.replace("\r", "\\r");
return x;
}
@SuppressWarnings("PMD")
private static String unescape(String s) {
StringBuilder out = new StringBuilder(s.length());
boolean esc = false;
for (int i = 0; i < s.length(); i++) {
char ch = s.charAt(i);
if (!esc) {
if (ch == '\\') {
esc = true;
} else {
out.append(ch);
}
continue;
}
esc = false;
if (ch == 'n') {
out.append('\n');
} else if (ch == 'r') {
out.append('\r');
} else {
out.append(ch);
}
}
return out.toString();
}
private static byte[] sha256(byte[] input) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
return md.digest(input);
} catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException("SHA-256 not available", ex);
}
}
private static String toHex(byte[] bytes) {
char[] hex = new char[bytes.length * 2];
final char[] alphabet = "0123456789abcdef".toCharArray();
for (int i = 0; i < bytes.length; i++) {
int v = bytes[i] & 0xFF;
hex[i * 2] = alphabet[v >>> 4];
hex[i * 2 + 1] = alphabet[v & 0x0F];
}
return new String(hex);
}
}

View File

@@ -0,0 +1,429 @@
/*******************************************************************************
* Copyright (C) 2025, 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.impl.audit;
import java.time.Clock;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.logging.Level;
import java.util.logging.Logger;
import zeroecho.pki.api.attr.AttributeAccessPolicy;
import zeroecho.pki.api.attr.AttributeCatalogue;
import zeroecho.pki.api.attr.AttributeDefinition;
import zeroecho.pki.api.attr.AttributeExportTarget;
import zeroecho.pki.api.attr.AttributeId;
import zeroecho.pki.api.attr.AttributeSensitivity;
import zeroecho.pki.api.attr.AttributeSet;
import zeroecho.pki.api.attr.AttributeValue;
import zeroecho.pki.api.attr.AttributeValue.BytesValue;
import zeroecho.pki.api.audit.AccessAction;
import zeroecho.pki.api.audit.AccessContext;
import zeroecho.pki.api.audit.AccessDecision;
import zeroecho.pki.api.audit.AttributeAccessController;
import zeroecho.pki.api.audit.AttributeGovernanceService;
import zeroecho.pki.api.audit.AuditEvent;
import zeroecho.pki.api.audit.AuditService;
/**
* Default enforcement implementation of {@link AttributeGovernanceService}.
*
* <p>
* This component acts as a Policy Enforcement Point (PEP). For attribute
* operations it:
* </p>
* <ol>
* <li>resolves attribute metadata from {@link AttributeCatalogue},</li>
* <li>obtains a decision from {@link AttributeAccessController} (PDP),</li>
* <li>enforces allow/deny deterministically,</li>
* <li>emits audit events via {@link AuditService} (if configured by
* policy).</li>
* </ol>
*
* <h2>Security properties</h2>
* <ul>
* <li><strong>No secrets in audit:</strong> audit {@link AuditEvent#details()}
* contains only metadata. Attribute values are never included.</li>
* <li><strong>Deterministic time:</strong> timestamps are sourced from an
* injected {@link Clock}.</li>
* <li><strong>Export redaction:</strong> non-public attributes are
* deterministically redacted during export.</li>
* </ul>
*
* <h2>Logging</h2>
* <p>
* This class logs operational failures (e.g., PDP throws) and unexpected
* states, but never logs attribute values.
* </p>
*/
public final class DefaultAttributeGovernanceService implements AttributeGovernanceService {
private static final Logger LOGGER = Logger.getLogger(DefaultAttributeGovernanceService.class.getName());
private static final String CATEGORY = "ATTRIBUTE_GOVERNANCE";
private final AttributeAccessController accessController;
private final AuditService auditService;
private final Clock clock;
/**
* Creates a governance service.
*
* @param accessController decision point (PDP)
* @param auditService audit service for recording decisions
* @param clock time source
* @throws IllegalArgumentException if any argument is null
*/
public DefaultAttributeGovernanceService(AttributeAccessController accessController, AuditService auditService,
Clock clock) {
if (accessController == null) {
throw new IllegalArgumentException("accessController must not be null");
}
if (auditService == null) {
throw new IllegalArgumentException("auditService must not be null");
}
if (clock == null) {
throw new IllegalArgumentException("clock must not be null");
}
this.accessController = accessController;
this.auditService = auditService;
this.clock = clock;
LOGGER.log(Level.INFO, "DefaultAttributeGovernanceService initialized.");
}
@Override
public Optional<AttributeValue> read(AttributeCatalogue catalogue, AttributeSet set, AttributeId id,
AccessContext context) {
Objects.requireNonNull(catalogue, "catalogue");
Objects.requireNonNull(set, "set");
Objects.requireNonNull(id, "id");
Objects.requireNonNull(context, "context");
Optional<AttributeDefinition> definition = catalogue.find(id);
if (definition.isEmpty()) {
LOGGER.log(Level.FINE, "Attribute not found in catalogue; denying READ.");
auditIfConfigured(null, id, AccessAction.READ, context, AccessDecision.DENY);
return Optional.empty();
}
AccessDecision decision = decideSafely(definition.get(), AccessAction.READ, context);
auditIfConfigured(definition.get(), id, AccessAction.READ, context, decision);
if (decision != AccessDecision.ALLOW) {
return Optional.empty();
}
return set.get(id);
}
@Override
public AttributeSet write(AttributeCatalogue catalogue, AttributeSet set, AttributeId id, AttributeValue value,
AccessContext context) {
Objects.requireNonNull(catalogue, "catalogue");
Objects.requireNonNull(set, "set");
Objects.requireNonNull(id, "id");
Objects.requireNonNull(value, "value");
Objects.requireNonNull(context, "context");
Optional<AttributeDefinition> definition = catalogue.find(id);
if (definition.isEmpty()) {
LOGGER.log(Level.FINE, "Attribute not found in catalogue; denying WRITE.");
auditIfConfigured(null, id, AccessAction.WRITE, context, AccessDecision.DENY);
return set;
}
AccessDecision decision = decideSafely(definition.get(), AccessAction.WRITE, context);
auditIfConfigured(definition.get(), id, AccessAction.WRITE, context, decision);
if (decision != AccessDecision.ALLOW) {
return set;
}
return AttributeSetCopies.withSingleValue(set, id, value);
}
@Override
public Optional<AttributeValue> export(AttributeCatalogue catalogue, AttributeSet set, AttributeId id,
AccessContext context) {
Objects.requireNonNull(catalogue, "catalogue");
Objects.requireNonNull(set, "set");
Objects.requireNonNull(id, "id");
Objects.requireNonNull(context, "context");
Optional<AttributeDefinition> definition = catalogue.find(id);
if (definition.isEmpty()) {
LOGGER.log(Level.FINE, "Attribute not found in catalogue; denying EXPORT.");
auditIfConfigured(null, id, AccessAction.EXPORT, context, AccessDecision.DENY);
return Optional.empty();
}
AccessDecision decision = decideSafely(definition.get(), AccessAction.EXPORT, context);
if (decision == AccessDecision.ALLOW) {
AttributeAccessPolicy policy = definition.get().accessPolicy();
if (!isExportTargetAllowed(policy, context)) {
decision = AccessDecision.DENY;
}
}
auditIfConfigured(definition.get(), id, AccessAction.EXPORT, context, decision);
if (decision != AccessDecision.ALLOW) {
return Optional.empty();
}
Optional<AttributeValue> value = set.get(id);
if (value.isEmpty()) {
return Optional.empty();
}
return Optional.of(redactForExport(definition.get().sensitivity(), value.get()));
}
@Override
public AttributeSet derive(AttributeCatalogue catalogue, AttributeSet set, AttributeId id, AccessContext context) {
Objects.requireNonNull(catalogue, "catalogue");
Objects.requireNonNull(set, "set");
Objects.requireNonNull(id, "id");
Objects.requireNonNull(context, "context");
Optional<AttributeDefinition> definition = catalogue.find(id);
if (definition.isEmpty()) {
LOGGER.log(Level.FINE, "Attribute not found in catalogue; denying DERIVE.");
auditIfConfigured(null, id, AccessAction.DERIVE, context, AccessDecision.DENY);
return set;
}
AccessDecision decision = decideSafely(definition.get(), AccessAction.DERIVE, context);
auditIfConfigured(definition.get(), id, AccessAction.DERIVE, context, decision);
if (decision != AccessDecision.ALLOW) {
return set;
}
Optional<AttributeValue> existing = set.get(id);
if (existing.isPresent()) {
return set;
}
// Baseline deterministic derivation (no domain logic; does not use secrets).
AttributeValue derived = new AttributeValue.StringValue("derived:" + id.value());
return AttributeSetCopies.withSingleValue(set, id, derived);
}
/**
* Wraps PDP decision with defensive error handling.
*
* @param definition attribute definition
* @param action access action
* @param context access context
* @return PDP decision or DENY on failure
*/
private AccessDecision decideSafely(AttributeDefinition definition, AccessAction action, AccessContext context) {
try {
return accessController.decide(definition, action, context);
} catch (RuntimeException ex) { // NOPMD
// Never log attribute value; only operational message.
LOGGER.log(Level.SEVERE, "Access controller failed; defaulting to DENY.", ex);
return AccessDecision.DENY;
}
}
/**
* Emits an audit record if configured by policy.
*
* <p>
* Only metadata is recorded. No attribute values are included.
* </p>
*
* @param definitionOrNull attribute definition or null if unknown attribute
* @param id attribute id
* @param action access action
* @param context access context
* @param decision access decision
*/
private void auditIfConfigured(AttributeDefinition definitionOrNull, AttributeId id, AccessAction action,
AccessContext context, AccessDecision decision) {
boolean audit = shouldAudit(definitionOrNull, decision);
if (!audit) {
return;
}
Instant now = clock.instant();
Map<String, String> details = new HashMap<>();
details.put("attributeId", id.value());
details.put("decision", decision.name());
details.put("principalType", context.principal().type());
details.put("principalName", context.principal().name());
details.put("purpose", context.purpose().value());
details.put("sensitivity", definitionOrNull != null ? definitionOrNull.sensitivity().name() : "UNKNOWN");
AuditEvent event = new AuditEvent(now, CATEGORY, action.name(), context.principal(), context.purpose(),
context.objectId(), context.formatId(), Map.copyOf(details));
auditService.record(event);
}
private static boolean shouldAudit(AttributeDefinition definitionOrNull, AccessDecision decision) {
if (definitionOrNull == null) {
// Unknown attribute accesses are always audited.
return true;
}
AttributeAccessPolicy policy = definitionOrNull.accessPolicy();
if (decision == AccessDecision.ALLOW) {
return policy.auditOnAllow();
}
return policy.auditOnDeny();
}
private static boolean isExportTargetAllowed(AttributeAccessPolicy policy, AccessContext context) {
if (policy.exportTargets().isEmpty()) {
return false;
}
// Baseline mapping: Purpose.value() -> AttributeExportTarget.name()
// (case-insensitive).
String purpose = context.purpose().value().trim().toUpperCase(Locale.ROOT);
for (AttributeExportTarget allowed : policy.exportTargets()) {
if (allowed.name().equalsIgnoreCase(purpose)) {
return true;
}
}
return false;
}
private static AttributeValue redactForExport(AttributeSensitivity sensitivity, AttributeValue value) {
if (sensitivity == AttributeSensitivity.PUBLIC) {
return value;
}
if (value instanceof AttributeValue.StringValue) {
return new AttributeValue.StringValue("[REDACTED]");
}
if (value instanceof AttributeValue.BooleanValue) {
return new AttributeValue.BooleanValue(false);
}
if (value instanceof AttributeValue.IntegerValue) {
return new AttributeValue.IntegerValue(0L);
}
if (value instanceof AttributeValue.InstantValue) {
return new AttributeValue.InstantValue(Instant.EPOCH);
}
if (value instanceof BytesValue) {
return new BytesValue(new byte[0]);
}
return new AttributeValue.StringValue("[REDACTED]");
}
/**
* Internal helper for producing immutable {@link AttributeSet} copies with
* modifications.
*/
/* default */ static final class AttributeSetCopies {
private AttributeSetCopies() {
}
/**
* Returns a new set with exactly one value stored under the given id (replacing
* existing values).
*
* @param input original set
* @param id attribute id
* @param value attribute value
* @return new immutable attribute set
*/
/* default */ static AttributeSet withSingleValue(AttributeSet input, AttributeId id, AttributeValue value) {
Map<AttributeId, List<AttributeValue>> map = new HashMap<>();
for (AttributeId existingId : input.ids()) {
List<AttributeValue> values = input.getAll(existingId);
map.put(existingId, List.copyOf(values));
}
map.put(id, List.of(value));
return new MapBackedAttributeSet(Map.copyOf(map));
}
}
/**
* Immutable, map-backed {@link AttributeSet} implementation used by this
* reference PEP.
*/
/* default */ static final class MapBackedAttributeSet implements AttributeSet {
private final Map<AttributeId, List<AttributeValue>> values;
/**
* Creates a new attribute set backed by an immutable map.
*
* @param values attribute storage map
*/
/* default */ MapBackedAttributeSet(Map<AttributeId, List<AttributeValue>> values) {
this.values = Objects.requireNonNull(values, "values");
}
@Override
public java.util.Set<AttributeId> ids() {
return values.keySet();
}
@Override
public Optional<AttributeValue> get(AttributeId id) {
Objects.requireNonNull(id, "id");
List<AttributeValue> list = values.get(id);
if (list == null || list.isEmpty()) {
return Optional.empty();
}
return Optional.of(list.get(0));
}
@Override
public List<AttributeValue> getAll(AttributeId id) {
Objects.requireNonNull(id, "id");
List<AttributeValue> list = values.get(id);
if (list == null) {
return List.of();
}
return List.copyOf(list);
}
}
}

View File

@@ -0,0 +1,365 @@
/*******************************************************************************
* Copyright (C) 2025, 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.impl.audit;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PushbackInputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.OpenOption;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.PosixFilePermission;
import java.nio.file.attribute.PosixFilePermissions;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.audit.AuditEvent;
import zeroecho.pki.api.audit.AuditQuery;
import zeroecho.pki.spi.audit.AuditSink;
/**
* Persistent {@link AuditSink} provider storing audit events as daily gzip
* files.
*
* <h2>ServiceLoader compatibility</h2>
* <p>
* This sink supports {@link java.util.ServiceLoader} by providing a public
* no-arg constructor.
* </p>
*
* <h2>Storage layout</h2> <pre>
* baseDir/
* YYYY/
* MM/
* YYYY-MM-DD.audit.log.gz
* </pre>
*
* <h2>Encoding</h2>
* <p>
* Each event is encoded as a single UTF-8 line and appended as its own gzip
* member. Members are concatenated to enable append-only writes.
* </p>
*
* <h2>Security properties</h2>
* <ul>
* <li>Best-effort permissions: directories 0700, files 0600 on POSIX.</li>
* <li>No event payload is logged. Corrupted records are skipped without logging
* the record content.</li>
* </ul>
*/
public final class FileAuditSink implements AuditSink, SearchableAuditSink {
private static final Logger LOG = Logger.getLogger(FileAuditSink.class.getName());
private final Path baseDir;
/**
* Creates the sink with an explicit base directory.
*
* @param baseDir base directory
*/
public FileAuditSink(Path baseDir) {
this.baseDir = baseDir;
LOG.log(Level.INFO, "FileAuditSink enabled; baseDir name={0}", baseDir);
}
@Override
public void record(AuditEvent event) {
if (event == null) {
throw new IllegalArgumentException("event must not be null");
}
Path file = dailyFile(baseDir, event.time());
ensureSecurePath(file.getParent(), file);
String line = AuditEventCodec.encodeLine(event);
byte[] bytes = line.getBytes(StandardCharsets.UTF_8);
OpenOption[] opts = { StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.APPEND };
try (OutputStream os = new BufferedOutputStream(Files.newOutputStream(file, opts));
GZIPOutputStream gzip = new GZIPOutputStream(os, true)) {
gzip.write(bytes);
} catch (IOException ex) {
if (LOG.isLoggable(Level.SEVERE)) {
LOG.log(Level.SEVERE, "Failed to write audit record to file: " + file.getFileName(), ex);
}
throw new IllegalStateException("Failed to write audit event.", ex);
}
}
@Override
public List<AuditEvent> search(AuditQuery query) {
if (query == null) {
throw new IllegalArgumentException("query must not be null");
}
List<Path> files = FileAuditSinkFiles.listDailyFiles(baseDir);
if (files.isEmpty()) {
return List.of();
}
if (LOG.isLoggable(Level.FINE)) {
LOG.log(Level.FINE, "FileAuditSink search scanning {0} file(s).", files.size());
}
List<AuditEvent> out = new ArrayList<>();
for (Path f : files) {
scanFile(f, query, out, null);
}
out.sort(AuditEvent.auditOrder());
return List.copyOf(out);
}
@Override
public Optional<AuditEvent> getById(PkiId eventId) {
if (eventId == null) {
throw new IllegalArgumentException("eventId must not be null");
}
List<Path> files = FileAuditSinkFiles.listDailyFiles(baseDir);
for (Path f : files) {
List<AuditEvent> tmp = new ArrayList<>(); // NOPMD
scanFile(f, null, tmp, eventId);
if (!tmp.isEmpty()) {
return Optional.of(tmp.get(0));
}
}
return Optional.empty();
}
private void scanFile(Path file, AuditQuery queryOrNull, List<AuditEvent> out, PkiId idOrNull) {
if (!Files.isRegularFile(file)) {
return;
}
try (InputStream fis = new BufferedInputStream(Files.newInputStream(file));
PushbackInputStream pb = new PushbackInputStream(fis, 1)) {
while (true) {
int peek = pb.read();
if (peek == -1) {
break;
}
pb.unread(peek);
try (GZIPInputStream gzip = new GZIPInputStream(new CloseShieldInputStream(pb))) { // NOPMD
readLinesFromMember(gzip, queryOrNull, out, idOrNull);
}
if (idOrNull != null && !out.isEmpty()) {
return;
}
}
} catch (IOException ex) {
if (LOG.isLoggable(Level.SEVERE)) {
LOG.log(Level.SEVERE, "Failed to read audit file: " + file.getFileName(), ex);
}
throw new IllegalStateException("Failed to read audit file: " + file, ex);
}
}
private void readLinesFromMember(InputStream gzip, AuditQuery queryOrNull, List<AuditEvent> out, PkiId idOrNull)
throws IOException {
StringBuilder sb = new StringBuilder(256);
int b;
while ((b = gzip.read()) != -1) {
if (b == '\n') { // NOPMD
processLine(sb.toString(), queryOrNull, out, idOrNull);
sb.setLength(0);
if (idOrNull != null && !out.isEmpty()) {
return;
}
} else {
sb.append((char) b);
}
}
if (sb.length() > 0) {
processLine(sb.toString(), queryOrNull, out, idOrNull);
}
}
private void processLine(String line, AuditQuery queryOrNull, List<AuditEvent> out, PkiId idOrNull) {
if (line == null || line.isBlank()) {
return;
}
AuditEvent e;
try {
e = AuditEventCodec.decodeLine(line);
} catch (RuntimeException ex) { // NOPMD
LOG.log(Level.WARNING, "Corrupted audit record encountered; skipping.", ex);
return;
}
if (idOrNull != null) {
if (AuditEventCodec.eventId(e).equals(idOrNull)) {
out.add(e);
}
return;
}
if (queryOrNull == null) {
out.add(e);
return;
}
if (e.matches(queryOrNull)) {
out.add(e);
}
}
private static Path dailyFile(Path baseDir, Instant time) {
LocalDate date = LocalDate.ofInstant(time, ZoneOffset.UTC);
String yyyy = String.format("%04d", date.getYear());
String mm = String.format("%02d", date.getMonthValue());
String filename = String.format("%04d-%02d-%02d.audit.log.gz", date.getYear(), date.getMonthValue(),
date.getDayOfMonth());
return baseDir.resolve(yyyy).resolve(mm).resolve(filename);
}
private void ensureSecurePath(Path dir, Path file) {
try {
Files.createDirectories(dir);
} catch (IOException ex) {
LOG.log(Level.SEVERE, "Failed to create audit directory.", ex);
throw new IllegalStateException("Failed to create audit directory: " + dir, ex);
}
applyDirectoryPermissions(dir);
if (!Files.exists(file)) {
try {
Files.createFile(file);
} catch (FileAlreadyExistsException ignored) {
// acceptable race
} catch (IOException ex) {
if (LOG.isLoggable(Level.SEVERE)) {
LOG.log(Level.SEVERE, "Failed to create audit file: " + file.getFileName(), ex);
}
throw new IllegalStateException("Failed to create audit file: " + file, ex);
}
}
applyFilePermissions(file);
}
private void applyDirectoryPermissions(Path dir) {
if (supportsPosix()) {
try {
Set<PosixFilePermission> dirPerms = PosixFilePermissions.fromString("rwx------");
Files.setPosixFilePermissions(dir, dirPerms);
} catch (IOException ex) {
LOG.log(Level.WARNING, "Failed to apply POSIX permissions to audit directory.", ex);
}
return;
}
boolean r = dir.toFile().setReadable(true, true);
boolean w = dir.toFile().setWritable(true, true);
boolean x = dir.toFile().setExecutable(true, true);
if (!r || !w || !x) {
LOG.log(Level.WARNING, "Failed to apply basic permissions to audit directory (non-POSIX FS).");
}
}
private void applyFilePermissions(Path file) {
if (supportsPosix()) {
try {
Set<PosixFilePermission> filePerms = PosixFilePermissions.fromString("rw-------");
Files.setPosixFilePermissions(file, filePerms);
} catch (IOException ex) {
LOG.log(Level.WARNING, "Failed to apply POSIX permissions to audit file.", ex);
}
return;
}
boolean r = file.toFile().setReadable(true, true);
boolean w = file.toFile().setWritable(true, true);
boolean x = file.toFile().setExecutable(false, true);
if (!r || !w || !x) {
LOG.log(Level.WARNING, "Failed to apply basic permissions to audit file (non-POSIX FS).");
}
}
private static boolean supportsPosix() {
return FileSystems.getDefault().supportedFileAttributeViews().contains("posix");
}
/**
* InputStream wrapper that prevents accidental closing of the underlying
* stream.
*
* <p>
* {@link java.util.zip.GZIPInputStream#close()} closes the wrapped stream. When
* scanning concatenated gzip members, we must keep the outer stream open and
* only close the inflater state of each member.
* </p>
*/
private static final class CloseShieldInputStream extends FilterInputStream {
private CloseShieldInputStream(InputStream in) {
super(in);
}
@Override
public void close() throws IOException {
// Intentionally do nothing.
}
}
}

View File

@@ -0,0 +1,80 @@
/*******************************************************************************
* Copyright (C) 2025, 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.impl.audit;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/**
* Internal utilities for enumerating persisted audit files.
*/
final class FileAuditSinkFiles {
private FileAuditSinkFiles() {
}
/**
* Lists all {@code *.audit.log.gz} files under {@code baseDir}
* deterministically.
*
* @param baseDir base directory (must not be null)
* @return immutable, lexicographically sorted list of paths
* @throws IllegalArgumentException if {@code baseDir} is null
* @throws IllegalStateException if filesystem traversal fails
*/
/* default */ static List<Path> listDailyFiles(Path baseDir) {
if (baseDir == null) {
throw new IllegalArgumentException("baseDir must not be null");
}
if (!Files.isDirectory(baseDir)) {
return List.of();
}
List<Path> out = new ArrayList<>();
try {
Files.walk(baseDir).filter(Files::isRegularFile)
.filter(p -> p.getFileName().toString().endsWith(".audit.log.gz")).forEach(out::add);
} catch (IOException ex) {
throw new IllegalStateException("Failed to list audit files.", ex);
}
out.sort(Comparator.naturalOrder());
return List.copyOf(out);
}
}

View File

@@ -0,0 +1,68 @@
/*******************************************************************************
* Copyright (C) 2025, 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.impl.audit;
import java.nio.file.Path;
import java.util.Objects;
import java.util.Set;
import zeroecho.pki.spi.ProviderConfig;
import zeroecho.pki.spi.audit.AuditSink;
import zeroecho.pki.spi.audit.AuditSinkProvider;
/**
*
*/
public class FileAuditSinkProvider implements AuditSinkProvider {
@Override
public String id() {
return "file";
}
@Override
public Set<String> supportedKeys() {
return Set.of("root");
}
@Override
public AuditSink allocate(ProviderConfig config) {
Objects.requireNonNull(config, "config");
String rootString = config.require("root");
Path root = Path.of(rootString);
return new FileAuditSink(root);
}
}

View File

@@ -0,0 +1,190 @@
/*******************************************************************************
* Copyright (C) 2025, 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.impl.audit;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
import java.util.Optional;
import java.util.logging.Level;
import java.util.logging.Logger;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.audit.AuditEvent;
import zeroecho.pki.api.audit.AuditQuery;
import zeroecho.pki.spi.audit.AuditSink;
/**
* In-memory {@link AuditSink} provider intended for deterministic tests and
* local debugging.
*
* <h2>Memory bound</h2>
* <p>
* This sink is bounded by {@code maxEvents}. When the capacity is exceeded, the
* oldest event is dropped (ring-buffer policy). This prevents unbounded memory
* growth in long-running processes.
* </p>
*
* <h3>Configuration</h3>
* <ul>
* <li>System property {@code zeroecho.pki.audit.inmemory.maxEvents} (positive
* integer)</li>
* <li>Default: 10_000</li>
* </ul>
*
* <h2>Security properties</h2>
* <ul>
* <li>Does not log event payloads.</li>
* <li>Stores events as received; callers must ensure
* {@link AuditEvent#details()} contains no secrets.</li>
* </ul>
*
* <h2>Thread-safety</h2>
* <p>
* This implementation is synchronized and safe for concurrent use.
* </p>
*/
public final class InMemoryAuditSink implements AuditSink, SearchableAuditSink {
private static final Logger LOG = Logger.getLogger(InMemoryAuditSink.class.getName());
/* default */ static final int DEFAULT_MAX_EVENTS = 10_000;
private final int maxEvents;
private final Deque<AuditEvent> events;
private boolean overflowLogged;
/**
* Public no-arg constructor required for {@link java.util.ServiceLoader}.
*
* <p>
* Defaults to 10_000 events.
* </p>
*/
public InMemoryAuditSink() {
this(DEFAULT_MAX_EVENTS);
}
/**
* Creates a sink with an explicit maximum number of events.
*
* @param maxEvents maximum number of retained events (must be positive)
* @throws IllegalArgumentException if {@code maxEvents <= 0}
*/
public InMemoryAuditSink(int maxEvents) {
if (maxEvents <= 0) {
throw new IllegalArgumentException("maxEvents must be positive");
}
this.maxEvents = maxEvents;
this.events = new ArrayDeque<>(Math.min(maxEvents, 1024));
this.overflowLogged = false;
LOG.log(Level.INFO, "InMemoryAuditSink initialized; maxEvents={0}", maxEvents);
}
@Override
public synchronized void record(AuditEvent event) { // NOPMD
if (event == null) {
throw new IllegalArgumentException("event must not be null");
}
if (events.size() >= maxEvents) {
events.removeFirst();
if (!overflowLogged) {
overflowLogged = true;
LOG.log(Level.WARNING, "InMemoryAuditSink capacity exceeded; dropping oldest events (maxEvents={0})",
maxEvents);
}
}
events.addLast(event);
}
/**
* Returns an immutable snapshot of all currently retained events in insertion
* order (oldest to newest).
*
* @return immutable snapshot
*/
public synchronized List<AuditEvent> snapshot() { // NOPMD
return List.copyOf(events);
}
@Override
public synchronized List<AuditEvent> search(AuditQuery query) { // NOPMD
if (query == null) {
throw new IllegalArgumentException("query must not be null");
}
List<AuditEvent> out = new ArrayList<>();
for (AuditEvent e : events) {
if (e.matches(query)) {
out.add(e);
}
}
out.sort(AuditEvent.auditOrder());
return List.copyOf(out);
}
@Override
public synchronized Optional<AuditEvent> getById(PkiId eventId) { // NOPMD
if (eventId == null) {
throw new IllegalArgumentException("eventId must not be null");
}
for (AuditEvent e : events) {
if (AuditEventCodec.eventId(e).equals(eventId)) {
return Optional.of(e);
}
}
return Optional.empty();
}
/**
* Returns the configured maximum number of retained events.
*
* @return maximum retained events
*/
public int maxEvents() {
return maxEvents;
}
@Override
public synchronized String toString() { // NOPMD
return "InMemoryAuditSink[size=" + events.size() + ", maxEvents=" + maxEvents + "]";
}
}

View File

@@ -0,0 +1,79 @@
/*******************************************************************************
* Copyright (C) 2025, 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.impl.audit;
import java.util.Optional;
import java.util.Set;
import zeroecho.pki.spi.ProviderConfig;
import zeroecho.pki.spi.audit.AuditSink;
import zeroecho.pki.spi.audit.AuditSinkProvider;
/**
*
*/
public class InMemoryAuditSinkProvider implements AuditSinkProvider {
@Override
public String id() {
return "memory";
}
@Override
public Set<String> supportedKeys() {
return Set.of("size");
}
@Override
public AuditSink allocate(ProviderConfig config) {
Optional<String> sizeStr = config.get("size");
if (sizeStr.isPresent()) {
return new InMemoryAuditSink(parseIntOrDefault(sizeStr.get(), InMemoryAuditSink.DEFAULT_MAX_EVENTS));
} else {
return new InMemoryAuditSink();
}
}
private static int parseIntOrDefault(String value, int defaultValue) {
if (value == null) {
return defaultValue;
}
try {
return Integer.parseInt(value);
} catch (NumberFormatException ex) {
return defaultValue;
}
}
}

View File

@@ -0,0 +1,75 @@
/*******************************************************************************
* Copyright (C) 2025, 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.impl.audit;
import java.util.List;
import java.util.Optional;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.audit.AuditEvent;
import zeroecho.pki.api.audit.AuditQuery;
/**
* Internal query extension for audit sinks.
*
* <p>
* The public SPI {@code zeroecho.pki.spi.AuditSink} is record-only. Sinks may
* also implement this internal interface to support
* {@link zeroecho.pki.api.audit.AuditService#search(AuditQuery)} and
* {@link zeroecho.pki.api.audit.AuditService#get(PkiId)}.
* </p>
*/
interface SearchableAuditSink {
/**
* Searches persisted events by query constraints.
*
* @param query query constraints (must not be null)
* @return immutable list of matching events (never null)
* @throws IllegalArgumentException if {@code query} is null
* @throws RuntimeException on storage failure
*/
List<AuditEvent> search(AuditQuery query);
/**
* Retrieves a persisted event by stable id.
*
* @param eventId stable event id (must not be null)
* @return event if present
* @throws IllegalArgumentException if {@code eventId} is null
* @throws RuntimeException on storage failure
*/
Optional<AuditEvent> getById(PkiId eventId);
}

View File

@@ -0,0 +1,96 @@
/*******************************************************************************
* Copyright (C) 2025, 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.impl.audit;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.Objects;
import java.util.logging.Logger;
import zeroecho.pki.api.audit.AuditEvent;
import zeroecho.pki.spi.audit.AuditSink;
/**
* Reference {@link AuditSink} provider writing a deterministic summary line to
* standard output.
*
* <h2>Security properties</h2>
* <ul>
* <li>Does not print {@link AuditEvent#details()} to reduce the risk of secret
* disclosure.</li>
* <li>Only prints high-level metadata (time, category, action, principal,
* purpose).</li>
* </ul>
*
* <h2>Determinism</h2>
* <p>
* Timestamp is rendered in ISO-8601 UTC, and the output line layout is stable.
* </p>
*
* <p>
* This sink is suitable for development and demonstrations. It is not intended
* as a production persistence backend.
* </p>
*/
public final class StdoutAuditSink implements AuditSink {
private static final DateTimeFormatter TS = DateTimeFormatter.ISO_INSTANT.withZone(ZoneOffset.UTC);
private static final Logger LOG = Logger.getLogger(StdoutAuditSink.class.getName());
/**
* Public no-arg constructor required for {@link java.util.ServiceLoader}.
*/
public StdoutAuditSink() {
LOG.info("initialized");
}
@Override
public void record(AuditEvent event) {
Objects.requireNonNull(event, "event");
String line = TS.format(event.time()) + " category=" + safe(event.category()) + " action="
+ safe(event.action()) + " principalType=" + safe(event.principal().type()) + " principalName="
+ safe(event.principal().name()) + " purpose=" + safe(event.purpose().value());
System.out.println(line);
}
private static String safe(String s) {
if (s == null) {
return "";
}
return s.replace('\n', ' ').replace('\r', ' ');
}
}

View File

@@ -0,0 +1,62 @@
/*******************************************************************************
* Copyright (C) 2025, 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.impl.audit;
import java.util.Collections;
import java.util.Set;
import zeroecho.pki.spi.ProviderConfig;
import zeroecho.pki.spi.audit.AuditSink;
import zeroecho.pki.spi.audit.AuditSinkProvider;
/**
*
*/
public class StdoutAuditSinkProvider implements AuditSinkProvider {
@Override
public String id() {
return "stdout";
}
@Override
public Set<String> supportedKeys() {
return Collections.emptySet();
}
@Override
public AuditSink allocate(ProviderConfig config) {
return new StdoutAuditSink();
}
}

View File

@@ -0,0 +1,72 @@
/*******************************************************************************
* Copyright (C) 2025, 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.
******************************************************************************/
/**
* Audit logging reference implementations for ZeroEcho PKI.
*
* <h2>Scope</h2>
* <p>
* This package provides concrete implementations of the audit runtime, based on
* the public API {@code zeroecho.pki.api.audit.*} and the SPI
* {@code zeroecho.pki.spi.AuditSink}.
* </p>
*
* <h2>Security properties</h2>
* <ul>
* <li><strong>No secrets in logs:</strong> JUL logs never include audit payload
* maps, attribute values, keys, seeds, plaintext, or other sensitive
* material.</li>
* <li><strong>No secrets by default:</strong> reference sinks avoid printing
* {@link zeroecho.pki.api.audit.AuditEvent#details()} unless explicitly
* required by a custom sink implementation.</li>
* <li><strong>Fail-fast recording:</strong> sink failures are propagated to the
* caller to avoid silent audit loss.</li>
* </ul>
*
* <h2>Determinism</h2>
* <ul>
* <li>Search results are deterministically sorted.</li>
* <li>Stable event identifiers are derived from canonicalized content
* (SHA-256).</li>
* <li>File enumeration is deterministic (lexicographic order).</li>
* </ul>
*
* <h2>Persistence</h2>
* <p>
* {@link zeroecho.pki.impl.audit.FileAuditSink} provides a baseline persistent
* store using daily gzip files and sequential scanning. It is suitable as an
* initial production-grade implementation without introducing a database.
* </p>
*/
package zeroecho.pki.impl.audit;

View File

@@ -147,6 +147,8 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
ensureVersionFile(); ensureVersionFile();
this.historySeq = new AtomicLong(0L); this.historySeq = new AtomicLong(0L);
LOG.log(Level.INFO, "running in {0}", root);
} catch (IOException e) { } catch (IOException e) {
throw new IllegalStateException("failed to open filesystem store at " + root, e); throw new IllegalStateException("failed to open filesystem store at " + root, e);
} }

View File

@@ -0,0 +1,85 @@
/*******************************************************************************
* Copyright (C) 2025, 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.impl.fs;
import java.nio.file.Path;
import java.util.Objects;
import java.util.Set;
import zeroecho.pki.spi.ProviderConfig;
import zeroecho.pki.spi.audit.AuditSinkProvider;
import zeroecho.pki.spi.store.PkiStore;
import zeroecho.pki.spi.store.PkiStoreProvider;
/**
* {@link AuditSinkProvider} for the filesystem-backed {@link PkiStore}.
*
* <p>
* Supported configuration keys:
* </p>
* <ul>
* <li>{@code root} - store root directory (required)</li>
* </ul>
*/
public final class FilesystemPkiStoreProvider implements PkiStoreProvider {
/**
* Public no-arg constructor required by {@link java.util.ServiceLoader}.
*/
public FilesystemPkiStoreProvider() { // NOPMD
// no-op
}
@Override
public String id() {
return "fs";
}
@Override
public Set<String> supportedKeys() {
return Set.of("root");
}
@Override
public PkiStore allocate(ProviderConfig config) {
Objects.requireNonNull(config, "config");
String rootString = config.require("root");
Path root = Path.of(rootString);
FsPkiStoreOptions options = FsPkiStoreOptions.defaults();
return new FilesystemPkiStore(root, options);
}
}

View File

@@ -0,0 +1,112 @@
/*******************************************************************************
* Copyright (C) 2025, 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.spi;
import java.util.Set;
/**
* ServiceLoader provider for configurable implementations.
*
* <p>
* {@link java.util.ServiceLoader} instantiates providers using a public no-arg
* constructor. However, the <em>produced</em> instances (stores, audit sinks,
* publishers, framework adapters) typically require backend-specific
* configuration (filesystem root, JDBC URL, remote endpoints, credentials,
* etc.). This SPI standardizes that configuration handoff.
* </p>
*
* <h2>Provider identity</h2>
* <p>
* {@link #id()} must return a stable identifier that is unique among providers
* of the same service type. Identifiers are used for deterministic selection
* (e.g. via system properties) and must not change across releases once
* published.
* </p>
*
* <h2>Configuration</h2>
* <p>
* Configuration is provided as string properties through
* {@link ProviderConfig}. Providers must validate required keys and reject
* invalid configurations with {@link IllegalArgumentException}. Unknown keys
* must be ignored for forward compatibility. {@link #supportedKeys()} is
* informational and may be used for diagnostics or help output.
* </p>
*
* <h2>Security</h2>
* <p>
* Configuration values may contain sensitive material (passwords, tokens,
* endpoints). Providers and bootstraps must never log configuration values. If
* logging is required, log provider id and configuration <em>keys only</em>.
* </p>
*
* @param <T> the type produced by the provider (e.g. {@code PkiStore},
* {@code AuditSink})
*/
public interface ConfigurableProvider<T> {
/**
* Returns a stable backend identifier (e.g. {@code "fs"}, {@code "jdbc"}).
*
* @return backend identifier (never {@code null}, never blank)
*/
String id();
/**
* Returns the configuration keys understood by this backend.
*
* <p>
* Keys are informational (diagnostics/validation/help). Unknown keys must be
* ignored by implementations for forward compatibility.
* </p>
*
* @return supported configuration keys (never {@code null})
*/
Set<String> supportedKeys();
/**
* Allocates a new instance using the provided configuration.
*
* <p>
* Implementations must validate required keys and throw
* {@link IllegalArgumentException} if configuration is incomplete or invalid.
* </p>
*
* @param config configuration (never {@code null})
* @return new instance (never {@code null})
* @throws IllegalArgumentException if required configuration is missing/invalid
* @throws RuntimeException if opening fails
*/
T allocate(ProviderConfig config);
}

View File

@@ -0,0 +1,109 @@
/*******************************************************************************
* Copyright (C) 2025, 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.spi;
import java.util.Collections;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/**
* Backend configuration for {@link ConfigurableProvider} instances.
*
* <p>
* Values are stringly-typed by design to keep the SPI generic and stable across
* different backends (filesystem, JDBC, remote services). Providers interpret
* keys relevant to their backend and must ignore unknown keys for forward
* compatibility.
* </p>
*
* <p>
* The {@code backendId} is the selected provider identifier
* ({@link ConfigurableProvider#id()}), used for diagnostics and selection
* correlation. It is not a URL, driver name, nor any secret.
* </p>
*
* <p>
* This record is immutable and safe to share. Configuration values may be
* sensitive and must not be logged. If diagnostics are needed, log keys only.
* </p>
*
* @param backendId selected backend id (never blank)
* @param properties backend properties (never {@code null})
*/
public record ProviderConfig(String backendId, Map<String, String> properties) {
/**
* Canonical constructor with validation and defensive wrapping.
*
* @param backendId backend id (never blank)
* @param properties properties (never {@code null})
*/
public ProviderConfig {
Objects.requireNonNull(backendId, "backendId");
Objects.requireNonNull(properties, "properties");
if (backendId.isBlank()) {
throw new IllegalArgumentException("backendId must not be blank");
}
properties = Collections.unmodifiableMap(properties);
}
/**
* Returns an optional value for the given key.
*
* @param key key (never {@code null})
* @return optional value (never {@code null})
*/
public Optional<String> get(String key) {
Objects.requireNonNull(key, "key");
return Optional.ofNullable(properties.get(key));
}
/**
* Returns a required non-blank value for the given key.
*
* @param key key (never {@code null})
* @return value (never {@code null}, never blank)
* @throws IllegalArgumentException if missing or blank
*/
public String require(String key) {
Objects.requireNonNull(key, "key");
String v = properties.get(key);
if (v == null || v.isBlank()) {
throw new IllegalArgumentException("Missing required config key: " + key);
}
return v;
}
}

View File

@@ -32,7 +32,7 @@
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE. * POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/ ******************************************************************************/
package zeroecho.pki.spi; package zeroecho.pki.spi.audit;
import zeroecho.pki.api.audit.AuditEvent; import zeroecho.pki.api.audit.AuditEvent;

View File

@@ -0,0 +1,75 @@
/*******************************************************************************
* Copyright (C) 2025, 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.spi.audit;
import zeroecho.pki.spi.ConfigurableProvider;
import zeroecho.pki.spi.ProviderConfig;
/**
* ServiceLoader provider for {@link AuditSink} implementations.
*
* <p>
* {@link java.util.ServiceLoader} cannot construct {@link AuditSink} instances
* directly because stores typically require backend-specific configuration
* (filesystem root, etc.). This SPI provides a uniform instantiation mechanism.
* </p>
*
* <p>
* Implementations must provide a public no-arg constructor to be discoverable
* via {@link java.util.ServiceLoader}.
* </p>
*
* <p>
* Security note: providers and bootstraps must never log configuration values
* because they can contain sensitive material (passwords, tokens, endpoints).
* </p>
*/
public interface AuditSinkProvider extends ConfigurableProvider<AuditSink> {
/**
* Opens a new {@link AuditSink} instance using the provided configuration.
*
* <p>
* Implementations must validate required keys and throw
* {@link IllegalArgumentException} if configuration is incomplete or invalid.
* </p>
*
* @param config configuration (never {@code null})
* @return opened store (never {@code null})
* @throws IllegalArgumentException if required configuration is missing/invalid
* @throws RuntimeException if opening fails
*/
@Override
AuditSink allocate(ProviderConfig config);
}

View File

@@ -0,0 +1,116 @@
/*******************************************************************************
* Copyright (C) 2025, 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.
******************************************************************************/
/**
* Service Provider Interfaces (SPI) for PKI audit event persistence.
*
* <p>
* This package defines the SPI contracts that allow the PKI subsystem to
* delegate audit event persistence to pluggable, backend-specific
* implementations discovered via {@link java.util.ServiceLoader}.
* </p>
*
* <h2>Purpose and scope</h2>
* <p>
* Audit functionality in the PKI layer is intentionally decoupled from any
* concrete storage mechanism. Implementations may persist audit events to
* filesystems, databases, remote services, or in-memory buffers, depending on
* deployment requirements.
* </p>
*
* <p>
* The SPI is split into two roles:
* </p>
* <ul>
* <li>{@link zeroecho.pki.spi.audit.AuditSink} a runtime component that
* receives {@link zeroecho.pki.api.audit.AuditEvent audit events} and persists
* them.</li>
* <li>{@link zeroecho.pki.spi.audit.AuditSinkProvider} a
* {@link java.util.ServiceLoader}-discoverable factory responsible for creating
* and configuring {@code AuditSink} instances.</li>
* </ul>
*
* <h2>Instantiation model</h2>
* <p>
* {@link java.util.ServiceLoader} is used only to discover
* {@code AuditSinkProvider} implementations. Direct discovery of
* {@code AuditSink} instances is intentionally avoided because most audit
* backends require configuration parameters (such as paths, connection strings,
* credentials, or limits).
* </p>
*
* <p>
* Providers must expose a public no-argument constructor and create
* {@code AuditSink} instances exclusively through the
* {@link zeroecho.pki.spi.audit.AuditSinkProvider#allocate allocate} method,
* using a {@link zeroecho.pki.spi.ProviderConfig} supplied by the bootstrap or
* runtime environment.
* </p>
*
* <h2>Security and compliance requirements</h2>
* <p>
* Implementations of this SPI are security-critical. The following rules are
* mandatory:
* </p>
* <ul>
* <li>Audit sinks must never persist secrets, private keys, shared secrets,
* passwords, tokens, or other sensitive cryptographic material.</li>
* <li>Providers and sinks must apply appropriate redaction and minimization
* policies to all persisted data.</li>
* <li>Configuration values supplied via {@code ProviderConfig} must never be
* written to logs or audit outputs, as they may contain sensitive information.
* </li>
* </ul>
*
* <h2>Thread-safety and lifecycle</h2>
* <p>
* Unless explicitly documented otherwise by a concrete implementation,
* {@code AuditSink} instances are expected to be thread-safe and usable by
* multiple concurrent PKI operations.
* </p>
*
* <p>
* Lifecycle management (creation, activation, shutdown) is controlled by the
* PKI bootstrap and runtime layers; this SPI does not define explicit close or
* shutdown semantics unless required by a specific implementation.
* </p>
*
* <h2>Intended usage</h2>
* <p>
* This package is intended for infrastructure-level integration and must not be
* used directly by application code. Consumers should interact with audit
* functionality exclusively through the public PKI API.
* </p>
*/
package zeroecho.pki.spi.audit;

View File

@@ -0,0 +1,217 @@
/*******************************************************************************
* Copyright (C) 2025, 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.spi.bootstrap;
import java.nio.file.Path;
import java.util.Map;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;
import zeroecho.pki.spi.ConfigurableProvider;
import zeroecho.pki.spi.ProviderConfig;
import zeroecho.pki.spi.audit.AuditSink;
import zeroecho.pki.spi.audit.AuditSinkProvider;
import zeroecho.pki.spi.store.PkiStore;
import zeroecho.pki.spi.store.PkiStoreProvider;
/**
* PKI bootstrap utilities for ServiceLoader-based components.
*
* <p>
* This class provides deterministic selection and instantiation rules for
* components discovered via {@link java.util.ServiceLoader}. It is designed to
* scale as more SPIs are introduced (audit, publish, framework integrations,
* etc.).
* </p>
*
* <h2>System property conventions</h2>
*
* <ul>
* <li>Select store provider: {@code -Dzeroecho.pki.store=&lt;id&gt;}</li>
* <li>Configure store provider:
* {@code -Dzeroecho.pki.store.&lt;key&gt;=&lt;value&gt;}</li>
* </ul>
*
* <p>
* Security note: configuration values may be sensitive and must not be logged.
* This bootstrap logs only provider ids and configuration keys.
* </p>
*/
public final class PkiBootstrap {
private static final Logger LOG = Logger.getLogger(PkiBootstrap.class.getName());
private static final String PROP_STORE_BACKEND = "zeroecho.pki.store";
private static final String PROP_STORE_PREFIX = "zeroecho.pki.store.";
private static final String PROP_AUDIT_BACKEND = "zeroecho.pki.audit";
private static final String PROP_AUDIT_PREFIX = "zeroecho.pki.audit.";
private PkiBootstrap() {
throw new AssertionError("No instances.");
}
/**
* Opens a {@link PkiStore} using {@link PkiStoreProvider} discovered via
* {@link java.util.ServiceLoader}.
*
* <p>
* Provider selection is deterministic and fail-fast:
* </p>
* <ul>
* <li>If {@code -Dzeroecho.pki.store=&lt;id&gt;} is specified, the provider
* with the matching id is selected.</li>
* <li>If no id is specified and exactly one provider exists, that provider is
* selected.</li>
* <li>If multiple providers exist and no id is specified, bootstrap fails as
* configuration is ambiguous.</li>
* </ul>
*
* <p>
* Configuration properties are read from {@link System#getProperties()} using
* the prefix {@code zeroecho.pki.store.}. Values are treated as sensitive and
* are never logged; only keys may be logged.
* </p>
*
* <p>
* Defaulting rules implemented by this bootstrap (policy, not SPI requirement):
* for {@code fs} provider, if {@code root} is not specified, defaults to
* {@code "pki-store"} relative to the working directory.
* </p>
*
* <p>
* This method is suitable for CLI and server deployments. In DI containers
* (Spring, Micronaut, etc.), invoke it from managed components and close the
* returned store using the container lifecycle.
* </p>
*
* @return opened store (never {@code null})
* @throws IllegalStateException if provider selection is ambiguous or no
* provider is available
* @throws IllegalArgumentException if required configuration is missing/invalid
* @throws RuntimeException if allocation fails
*/
public static PkiStore openStore() {
String requestedId = System.getProperty(PROP_STORE_BACKEND);
PkiStoreProvider provider = SpiSelector.select(PkiStoreProvider.class, requestedId,
new SpiSelector.ProviderId<>() {
@Override
public String id(PkiStoreProvider p) {
return p.id();
}
});
Map<String, String> props = SpiSystemProperties.readPrefixed(PROP_STORE_PREFIX);
if ("fs".equals(provider.id()) && !props.containsKey("root")) {
props.put("root", Path.of("pki-store").toString());
}
ProviderConfig config = new ProviderConfig(provider.id(), props);
if (LOG.isLoggable(Level.INFO)) {
LOG.info("Selected store provider: " + provider.id() + " (keys: " + props.keySet() + ")");
}
return provider.allocate(config);
}
/**
* Logs provider help information (supported keys) for diagnostics.
*
* <p>
* This method is safe: it does not log configuration values.
* </p>
*
* @param provider provider (never {@code null})
*/
public static <T> void logSupportedKeys(ConfigurableProvider<T> provider) {
Objects.requireNonNull(provider, "provider");
if (LOG.isLoggable(Level.FINE)) {
LOG.fine("Provider '" + provider.id() + "' supports keys: " + provider.supportedKeys());
}
}
/**
* Opens an {@link AuditSink} using {@link AuditSinkProvider} discovered via
* {@link java.util.ServiceLoader}.
*
* <p>
* Selection and configuration follow the same conventions as
* {@link #openStore()}, using {@code -Dzeroecho.pki.audit=&lt;id&gt;} and
* {@code zeroecho.pki.audit.} prefixed properties.
* </p>
*
* @return opened audit sink (never {@code null})
* @throws IllegalStateException if provider selection is ambiguous or no
* provider is available
* @throws IllegalArgumentException if required configuration is missing/invalid
* @throws RuntimeException if allocation fails
*/
public static AuditSink openAudit() {
String requestedId = System.getProperty(PROP_AUDIT_BACKEND);
if (requestedId == null) {
requestedId = "stdout";
}
AuditSinkProvider provider = SpiSelector.select(AuditSinkProvider.class, requestedId,
new SpiSelector.ProviderId<>() {
@Override
public String id(AuditSinkProvider p) {
return p.id();
}
});
Map<String, String> props = SpiSystemProperties.readPrefixed(PROP_AUDIT_PREFIX);
if ("file".equals(provider.id()) && !props.containsKey("root")) {
props.put("root", Path.of("pki-store").toString());
}
ProviderConfig config = new ProviderConfig(provider.id(), props);
if (LOG.isLoggable(Level.INFO)) {
LOG.info("Selected store provider: " + provider.id() + " (keys: " + props.keySet() + ")");
}
return provider.allocate(config);
}
}

View File

@@ -0,0 +1,142 @@
/*******************************************************************************
* Copyright (C) 2025, 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.spi.bootstrap;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.ServiceLoader;
/**
* Utility for selecting ServiceLoader providers by a stable identifier.
*
* <p>
* This class centralizes deterministic provider-selection rules:
* </p>
* <ul>
* <li>If an id is requested and exactly one provider matches it, that provider
* is selected.</li>
* <li>If no id is requested and exactly one provider exists, it is
* selected.</li>
* <li>Otherwise selection fails fast with a descriptive exception.</li>
* </ul>
*
* <p>
* This utility is intended to be used by multiple bootstraps (store, audit,
* publish, framework components). It is deliberately small and has no logging
* side-effects.
* </p>
*
* <p>
* Note: provider iteration order from {@link java.util.ServiceLoader} must not
* be relied upon. Determinism is achieved by explicit id selection or by
* requiring a single available provider.
* </p>
*/
public final class SpiSelector {
private SpiSelector() {
throw new AssertionError("No instances.");
}
/**
* Provider id accessor used by the selector.
*
* @param <T> provider type
*/
@FunctionalInterface
public interface ProviderId<T> {
/**
* Returns the stable id for a provider instance.
*
* @param provider provider instance (never {@code null})
* @return stable id (never {@code null})
*/
String id(T provider);
}
/**
* Selects a provider for the given service type.
*
* @param <T> provider type
* @param service service class (never {@code null})
* @param requestedId requested id (may be {@code null})
* @param idAccessor id accessor (never {@code null})
* @return selected provider (never {@code null})
* @throws IllegalStateException if no providers exist, selection is ambiguous,
* or id not found
*/
public static <T> T select(Class<T> service, String requestedId, ProviderId<T> idAccessor) {
Objects.requireNonNull(service, "service");
Objects.requireNonNull(idAccessor, "idAccessor");
ServiceLoader<T> loader = ServiceLoader.load(service);
List<T> providers = new ArrayList<>();
for (T p : loader) {
providers.add(p);
}
if (providers.isEmpty()) {
throw new IllegalStateException("No providers found for service: " + service.getName());
}
if (requestedId != null) {
List<T> matches = new ArrayList<>();
for (T p : providers) {
String id = idAccessor.id(p);
if (requestedId.equals(id)) {
matches.add(p);
}
}
if (matches.isEmpty()) {
throw new IllegalStateException(
"No provider found for service " + service.getName() + " with id '" + requestedId + "'.");
}
if (matches.size() > 1) { // NOPMD
throw new IllegalStateException("Multiple providers found for service " + service.getName()
+ " with id '" + requestedId + "'.");
}
return matches.get(0);
}
if (providers.size() == 1) { // NOPMD
return providers.get(0);
}
throw new IllegalStateException(
"Multiple providers found for service " + service.getName() + "; specify explicit selection.");
}
}

View File

@@ -0,0 +1,101 @@
/*******************************************************************************
* Copyright (C) 2025, 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.spi.bootstrap;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Properties;
/**
* Reads component configuration from system properties using a prefix
* convention.
*
* <p>
* Convention:
* </p>
* <ul>
* <li>Select provider: {@code -Dzeroecho.pki.&lt;component&gt;=&lt;id&gt;}</li>
* <li>Provider config:
* {@code -Dzeroecho.pki.&lt;component&gt;.&lt;key&gt;=&lt;value&gt;}</li>
* </ul>
*
* <p>
* Values may be sensitive and must not be logged. Callers may log keys only.
* </p>
*/
public final class SpiSystemProperties {
private SpiSystemProperties() {
throw new AssertionError("No instances.");
}
/**
* Reads all string system properties with the given prefix.
*
* <p>
* The returned map is a snapshot of {@link System#getProperties()} at the time
* of invocation. Values are returned as-is (no trimming/normalization) to keep
* behavior explicit.
* </p>
*
* @param prefix property prefix including trailing dot, e.g.
* {@code "zeroecho.pki.store."}
* @return map of subKey -&gt; value (never {@code null})
*/
public static Map<String, String> readPrefixed(String prefix) {
Objects.requireNonNull(prefix, "prefix");
Map<String, String> out = new HashMap<>();
Properties props = System.getProperties();
for (Map.Entry<Object, Object> e : props.entrySet()) {
Object k = e.getKey();
Object v = e.getValue();
if (!(k instanceof String) || !(v instanceof String)) {
continue;
}
String key = (String) k;
if (!key.startsWith(prefix)) {
continue;
}
String subKey = key.substring(prefix.length());
if (subKey.isBlank()) {
continue;
}
out.put(subKey, (String) v);
}
return out;
}
}

View File

@@ -0,0 +1,71 @@
/*******************************************************************************
* Copyright (C) 2025, 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.
******************************************************************************/
/**
* Bootstrap utilities for ServiceLoader-based components in the PKI module.
*
* <p>
* This package centralizes deterministic provider selection and a minimal
* configuration convention for multiple SPIs (store, audit, publication, and
* future components).
* </p>
*
* <p>
* Selection is fail-fast: if multiple providers exist for a service and no
* explicit provider id is requested, bootstrap rejects the configuration as
* ambiguous.
* </p>
*
* <p>
* The conventions are intentionally minimal:
* </p>
* <ul>
* <li>Select provider: {@code -Dzeroecho.pki.&lt;component&gt;=&lt;id&gt;}</li>
* <li>Provider properties:
* {@code -Dzeroecho.pki.&lt;component&gt;.&lt;key&gt;=&lt;value&gt;}</li>
* </ul>
*
* <p>
* These utilities can be used both in CLI and server deployments. In DI
* containers (Spring, Micronaut, etc.), you may invoke the bootstrap from
* managed beans while using container lifecycle to close resources.
* </p>
*
* <p>
* Security note: configuration values may contain sensitive material and must
* not be logged. Bootstraps should log provider ids and configuration keys
* only.
* </p>
*/
package zeroecho.pki.spi.bootstrap;

View File

@@ -36,9 +36,22 @@
* Service Provider Interfaces (SPIs) for the PKI module. * Service Provider Interfaces (SPIs) for the PKI module.
* *
* <p> * <p>
* SPIs are used to plug in persistence, audit sinks, publishing targets, and * SPIs are used to plug in persistence backends, audit sinks, publishing
* credential framework implementations. Public API types remain * targets, and framework integrations while keeping the public PKI API
* framework-agnostic. * framework-agnostic. Implementations are typically discovered via
* {@link java.util.ServiceLoader}.
* </p>
*
* <p>
* The SPI layer is suitable both for CLI and server deployments. In DI
* containers (Spring, Micronaut, etc.), ServiceLoader discovery can be invoked
* from managed beans, while lifecycle is controlled by the container.
* </p>
*
* <p>
* Backend configuration is passed as string properties through
* {@link zeroecho.pki.spi.ProviderConfig}. Configuration values may be
* sensitive and must not be logged.
* </p> * </p>
*/ */
package zeroecho.pki.spi; package zeroecho.pki.spi;

View File

@@ -0,0 +1,77 @@
/*******************************************************************************
* Copyright (C) 2025, 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.spi.store;
import zeroecho.pki.spi.ConfigurableProvider;
import zeroecho.pki.spi.ProviderConfig;
/**
* {@link java.util.ServiceLoader} provider for {@link PkiStore}
* implementations.
*
* <p>
* This interface is a PKI-specific specialization of
* {@link ConfigurableProvider} for the persistence backend. See
* {@link ConfigurableProvider} and {@link ProviderConfig} for the generic
* provider and configuration contract.
* </p>
*
* <p>
* Implementations must be discoverable via {@link java.util.ServiceLoader} and
* therefore must provide a public no-arg constructor.
* </p>
*
* <p>
* Security note: configuration values may contain sensitive material. Providers
* must not log configuration values.
* </p>
*/
public interface PkiStoreProvider extends ConfigurableProvider<PkiStore> {
/**
* Opens a new {@link PkiStore} instance using the provided configuration.
*
* <p>
* Implementations must validate required keys and throw
* {@link IllegalArgumentException} if configuration is incomplete or invalid.
* </p>
*
* @param config configuration (never {@code null})
* @return opened store (never {@code null})
* @throws IllegalArgumentException if required configuration is missing/invalid
* @throws RuntimeException if opening fails
*/
@Override
PkiStore allocate(ProviderConfig config);
}

View File

@@ -36,9 +36,23 @@
* Persistence SPI for PKI state. * Persistence SPI for PKI state.
* *
* <p> * <p>
* Implementations persist CA entities, credentials, requests, revocations, * This package defines the authoritative persistence contract for PKI-managed
* status objects, profiles, policy traces, publication records, and optionally * state (CAs, credentials, requests, revocations, status objects, profiles,
* audit events. Storage must be deterministic and suitable for backup/restore. * publications, and policy traces). Storage must be deterministic and suitable
* for backup/restore.
* </p>
*
* <p>
* Concrete persistence backends are instantiated via
* {@link java.util.ServiceLoader} using
* {@link zeroecho.pki.spi.store.PkiStoreProvider}. Backend configuration is
* provided through {@link zeroecho.pki.spi.ProviderConfig}.
* </p>
*
* <p>
* Security note: private key material must not be persisted. Configuration
* values may be sensitive (passwords/tokens/endpoints) and must not be logged
* by providers or bootstraps.
* </p> * </p>
*/ */
package zeroecho.pki.spi.store; package zeroecho.pki.spi.store;

View File

@@ -0,0 +1,3 @@
zeroecho.pki.impl.audit.FileAuditSinkProvider
zeroecho.pki.impl.audit.InMemoryAuditSinkProvider
zeroecho.pki.impl.audit.StdoutAuditSinkProvider

View File

@@ -0,0 +1 @@
zeroecho.pki.impl.fs.FilesystemPkiStoreProvider

View File

@@ -0,0 +1,113 @@
/*******************************************************************************
* Copyright (C) 2025, 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.impl.audit;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.nio.file.Path;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import zeroecho.pki.api.FormatId;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.audit.AuditEvent;
import zeroecho.pki.api.audit.AuditQuery;
import zeroecho.pki.api.audit.Principal;
import zeroecho.pki.api.audit.Purpose;
public final class FileAuditSinkTest {
@TempDir
public Path tempDir;
@Test
public void recordThenSearchAndGetById_roundTripsDeterministically() throws Exception {
System.out.println("recordThenSearchAndGetById_roundTripsDeterministically");
// Use JUnit-managed temp directory.
Path dir = tempDir.resolve("audit");
System.out.println("...dir=" + dir.toAbsolutePath());
FileAuditSink sink = new FileAuditSink(dir);
Principal principal = new Principal("USER", "alice");
Purpose purpose = new Purpose("LDAP");
AuditEvent e1 = new AuditEvent(Instant.parse("2025-01-01T00:00:00Z"), "CAT", "READ", principal, purpose,
Optional.of(new PkiId("obj-1")), Optional.of(new FormatId("x509")), Map.of("decision", "ALLOW"));
AuditEvent e2 = new AuditEvent(Instant.parse("2025-01-01T00:00:01Z"), "CAT", "WRITE", principal, purpose,
Optional.of(new PkiId("obj-1")), Optional.of(new FormatId("x509")), Map.of("decision", "DENY"));
// Insert in reverse order to validate deterministic query ordering.
sink.record(e2);
sink.record(e1);
AuditQuery q = new AuditQuery(Optional.of("CAT"), Optional.empty(), Optional.empty(), Optional.empty(),
Optional.of(new PkiId("obj-1")), Optional.of("alice"));
List<AuditEvent> found = sink.search(q);
System.out.println("...foundSize=" + found.size());
assertEquals(2, found.size());
assertEquals("READ", found.get(0).action());
assertEquals("WRITE", found.get(1).action());
PkiId id = AuditEventCodec.eventId(e2);
System.out.println("...eventId=" + summarize(id.value(), 16));
Optional<AuditEvent> loaded = sink.getById(id);
assertTrue(loaded.isPresent());
assertEquals("WRITE", loaded.get().action());
System.out.println("...ok");
}
private static String summarize(String s, int prefixLen) {
if (s == null) {
return "";
}
if (s.length() <= prefixLen) {
return s;
}
return s.substring(0, prefixLen) + "...";
}
}

View File

@@ -0,0 +1,177 @@
/*******************************************************************************
* Copyright (C) 2025, 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.impl.audit;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.audit.AuditEvent;
import zeroecho.pki.api.audit.AuditQuery;
import zeroecho.pki.api.audit.Principal;
import zeroecho.pki.api.audit.Purpose;
public final class InMemoryAuditSinkTest {
@Test
public void recordAndSnapshot_preservesInsertionOrder() {
System.out.println("recordAndSnapshot_preservesInsertionOrder");
InMemoryAuditSink sink = new InMemoryAuditSink();
AuditEvent e1 = new AuditEvent(Instant.parse("2025-01-01T00:00:01Z"), "CAT", "A",
new Principal("USER", "alice"), new Purpose("LDAP"), Optional.empty(), Optional.empty(),
Map.of("k", "v"));
AuditEvent e2 = new AuditEvent(Instant.parse("2025-01-01T00:00:02Z"), "CAT", "B",
new Principal("USER", "alice"), new Purpose("LDAP"), Optional.empty(), Optional.empty(),
Map.of("k", "v"));
sink.record(e1);
sink.record(e2);
List<AuditEvent> snap = sink.snapshot();
System.out.println("...snapshotSize=" + snap.size());
assertEquals(2, snap.size());
assertEquals("A", snap.get(0).action());
assertEquals("B", snap.get(1).action());
System.out.println("...ok");
}
@Test
public void search_returnsDeterministicallySortedResults() {
System.out.println("search_returnsDeterministicallySortedResults");
InMemoryAuditSink sink = new InMemoryAuditSink();
Principal principal = new Principal("USER", "alice");
Purpose purpose = new Purpose("LDAP");
AuditEvent late = new AuditEvent(Instant.parse("2025-01-01T00:00:02Z"), "CAT", "WRITE", principal, purpose,
Optional.empty(), Optional.empty(), Map.of("k", "v"));
AuditEvent early = new AuditEvent(Instant.parse("2025-01-01T00:00:01Z"), "CAT", "READ", principal, purpose,
Optional.empty(), Optional.empty(), Map.of("k", "v"));
// Insert in reverse order to validate deterministic sort.
sink.record(late);
sink.record(early);
AuditQuery query = new AuditQuery(Optional.of("CAT"), Optional.empty(), Optional.empty(), Optional.empty(),
Optional.empty(), Optional.of("alice"));
List<AuditEvent> found = sink.search(query);
System.out.println("...foundSize=" + found.size());
assertEquals(2, found.size());
assertEquals("READ", found.get(0).action());
assertEquals("WRITE", found.get(1).action());
System.out.println("...ok");
}
@Test
public void getById_findsEventByStableId() {
System.out.println("getById_findsEventByStableId");
InMemoryAuditSink sink = new InMemoryAuditSink();
AuditEvent e = new AuditEvent(Instant.parse("2025-01-01T00:00:00Z"), "CAT", "READ",
new Principal("USER", "alice"), new Purpose("LDAP"), Optional.empty(), Optional.empty(),
Map.of("k", "v"));
sink.record(e);
PkiId id = AuditEventCodec.eventId(e);
System.out.println("...eventId=" + summarize(id.value(), 16));
Optional<AuditEvent> loaded = sink.getById(id);
assertTrue(loaded.isPresent());
assertEquals("READ", loaded.get().action());
System.out.println("...ok");
}
@Test
public void record_overCapacity_dropsOldestDeterministically() {
System.out.println("record_overCapacity_dropsOldestDeterministically");
InMemoryAuditSink sink = new InMemoryAuditSink(2);
AuditEvent e1 = new AuditEvent(Instant.parse("2025-01-01T00:00:01Z"), "CAT", "A",
new Principal("USER", "alice"), new Purpose("LDAP"), Optional.empty(), Optional.empty(),
Map.of("k", "v"));
AuditEvent e2 = new AuditEvent(Instant.parse("2025-01-01T00:00:02Z"), "CAT", "B",
new Principal("USER", "alice"), new Purpose("LDAP"), Optional.empty(), Optional.empty(),
Map.of("k", "v"));
AuditEvent e3 = new AuditEvent(Instant.parse("2025-01-01T00:00:03Z"), "CAT", "C",
new Principal("USER", "alice"), new Purpose("LDAP"), Optional.empty(), Optional.empty(),
Map.of("k", "v"));
sink.record(e1);
sink.record(e2);
sink.record(e3);
List<AuditEvent> snap = sink.snapshot();
System.out.println("...snapshotSize=" + snap.size());
assertEquals(2, snap.size());
assertEquals("B", snap.get(0).action());
assertEquals("C", snap.get(1).action());
System.out.println("...ok");
}
private static String summarize(String s, int prefixLen) {
if (s == null) {
return "";
}
if (s.length() <= prefixLen) {
return s;
}
return s.substring(0, prefixLen) + "...";
}
}

View File

@@ -0,0 +1,118 @@
/*******************************************************************************
* Copyright (C) 2025, 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.impl.audit;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayOutputStream;
import java.io.PrintStream;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import zeroecho.pki.api.audit.AuditEvent;
import zeroecho.pki.api.audit.Principal;
import zeroecho.pki.api.audit.Purpose;
public final class StdoutAuditSinkTest {
@Test
public void record_printsDeterministicSummaryWithoutDetails() {
System.out.println("record_printsDeterministicSummaryWithoutDetails");
PrintStream originalOut = System.out;
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
PrintStream capture = new PrintStream(buffer, true, StandardCharsets.UTF_8);
try {
System.setOut(capture);
StdoutAuditSink sink = new StdoutAuditSink();
AuditEvent event = new AuditEvent(Instant.parse("2025-01-01T00:00:00Z"), "CAT", "READ",
new Principal("USER", "alice"), new Purpose("LDAP"), Optional.empty(), Optional.empty(),
Map.of("secret", "SHOULD_NOT_APPEAR"));
sink.record(event);
} finally {
System.setOut(originalOut);
}
String out = buffer.toString(StandardCharsets.UTF_8);
System.out.println("...stdout=" + summarize(out, 120));
assertTrue(out.contains("2025-01-01T00:00:00Z"));
assertTrue(out.contains("category=CAT"));
assertTrue(out.contains("action=READ"));
assertTrue(out.contains("principalType=USER"));
assertTrue(out.contains("principalName=alice"));
assertTrue(out.contains("purpose=LDAP"));
// Must not contain details payload.
assertTrue(!out.contains("secret"));
assertEquals(1, countLines(out));
System.out.println("...ok");
}
private static int countLines(String s) {
int lines = 0;
for (int i = 0; i < s.length(); i++) {
if (s.charAt(i) == '\n') {
lines++;
}
}
// If output doesn't end with newline, still treat it as one line if non-empty.
if (!s.isEmpty() && s.charAt(s.length() - 1) != '\n') {
lines++;
}
return lines;
}
private static String summarize(String s, int limit) {
String trimmed = s.replace("\r", "");
if (trimmed.length() <= limit) {
return trimmed;
}
return trimmed.substring(0, limit) + "...";
}
}

View File

@@ -0,0 +1,178 @@
/*******************************************************************************
* Copyright (C) 2025, 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.spi.bootstrap;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import java.nio.file.Path;
import java.util.Properties;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import zeroecho.pki.spi.audit.AuditSink;
import zeroecho.pki.spi.store.PkiStore;
/**
* JUnit 5 tests for {@link PkiBootstrap}.
*
* <p>
* The tests validate provider selection and configuration conventions based on
* {@link java.util.ServiceLoader} providers and {@link System#getProperties()}.
* </p>
*
* <p>
* Security note: tests must not print configuration values that might be
* sensitive. Here we only print safe information (provider ids, class names,
* and key presence).
* </p>
*/
public final class PkiBootstrapTest {
@TempDir
private Path tempDir;
private Properties savedProperties;
@BeforeEach
public void beforeEach() {
System.out.println("beforeEach");
this.savedProperties = (Properties) System.getProperties().clone();
clearPrefix("zeroecho.pki.store.");
clearPrefix("zeroecho.pki.audit.");
System.clearProperty("zeroecho.pki.store");
System.clearProperty("zeroecho.pki.audit");
System.out.println("...tempDir=" + this.tempDir);
System.out.println("...ok");
}
@AfterEach
public void afterEach() {
System.out.println("afterEach");
System.setProperties(this.savedProperties);
System.out.println("...ok");
}
@Test
public void openStore_fs_usesTempRoot() {
System.out.println("openStore_fs_usesTempRoot");
System.setProperty("zeroecho.pki.store", "fs");
System.setProperty("zeroecho.pki.store.root", this.tempDir.resolve("store").toString());
PkiStore store = PkiBootstrap.openStore();
assertNotNull(store);
String storeClassName = store.getClass().getName();
System.out.println("...storeClass=" + storeClassName);
// Do not rely on implementation type at compile time; compare by name.
assertEquals("zeroecho.pki.impl.fs.FilesystemPkiStore", storeClassName);
System.out.println("...ok");
}
@Test
public void openAudit_defaultIsStdout() {
System.out.println("openAudit_defaultIsStdout");
// Per bootstrap: if no -Dzeroecho.pki.audit is set, default is "stdout".
// This must work even if multiple audit providers exist.
// :contentReference[oaicite:1]{index=1}
AuditSink sink = PkiBootstrap.openAudit();
assertNotNull(sink);
String sinkClassName = sink.getClass().getName();
System.out.println("...auditClass=" + sinkClassName);
assertEquals("zeroecho.pki.impl.audit.StdoutAuditSink", sinkClassName);
System.out.println("...ok");
}
@Test
public void openAudit_file_usesTempRoot() {
System.out.println("openAudit_file_usesTempRoot");
System.setProperty("zeroecho.pki.audit", "file");
System.setProperty("zeroecho.pki.audit.root", this.tempDir.resolve("audit").toString());
AuditSink sink = PkiBootstrap.openAudit();
assertNotNull(sink);
String sinkClassName = sink.getClass().getName();
System.out.println("...auditClass=" + sinkClassName);
assertEquals("zeroecho.pki.impl.audit.FileAuditSink", sinkClassName);
System.out.println("...ok");
}
@Test
public void openAudit_memory_acceptsSize() {
System.out.println("openAudit_memory_acceptsSize");
System.setProperty("zeroecho.pki.audit", "memory");
System.setProperty("zeroecho.pki.audit.size", "123");
AuditSink sink = PkiBootstrap.openAudit();
assertNotNull(sink);
String sinkClassName = sink.getClass().getName();
System.out.println("...auditClass=" + sinkClassName);
assertEquals("zeroecho.pki.impl.audit.InMemoryAuditSink", sinkClassName);
System.out.println("...ok");
}
private static void clearPrefix(String prefix) {
Properties props = System.getProperties();
for (Object keyObj : props.keySet().toArray()) {
if (keyObj instanceof String) {
String key = (String) keyObj;
if (key.startsWith(prefix)) {
System.clearProperty(key);
}
}
}
}
}