From 0346c5b30f3c58a5df9fb5081266dbc06aac0d1f Mon Sep 17 00:00:00 2001 From: Leo Galambos Date: Mon, 29 Dec 2025 02:09:07 +0100 Subject: [PATCH] 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 --- pki/.classpath | 17 +- .../zeroecho/pki/api/audit/AuditEvent.java | 148 +++++- .../pki/impl/audit/AuditEventCodec.java | 285 ++++++++++++ .../DefaultAttributeGovernanceService.java | 429 ++++++++++++++++++ .../pki/impl/audit/FileAuditSink.java | 365 +++++++++++++++ .../pki/impl/audit/FileAuditSinkFiles.java | 80 ++++ .../pki/impl/audit/FileAuditSinkProvider.java | 68 +++ .../pki/impl/audit/InMemoryAuditSink.java | 190 ++++++++ .../impl/audit/InMemoryAuditSinkProvider.java | 79 ++++ .../pki/impl/audit/SearchableAuditSink.java | 75 +++ .../pki/impl/audit/StdoutAuditSink.java | 96 ++++ .../impl/audit/StdoutAuditSinkProvider.java | 62 +++ .../zeroecho/pki/impl/audit/package-info.java | 72 +++ .../pki/impl/fs/FilesystemPkiStore.java | 2 + .../impl/fs/FilesystemPkiStoreProvider.java | 85 ++++ .../pki/spi/ConfigurableProvider.java | 112 +++++ .../java/zeroecho/pki/spi/ProviderConfig.java | 109 +++++ .../pki/spi/{ => audit}/AuditSink.java | 2 +- .../pki/spi/audit/AuditSinkProvider.java | 75 +++ .../zeroecho/pki/spi/audit/package-info.java | 116 +++++ .../pki/spi/bootstrap/PkiBootstrap.java | 217 +++++++++ .../pki/spi/bootstrap/SpiSelector.java | 142 ++++++ .../spi/bootstrap/SpiSystemProperties.java | 101 +++++ .../pki/spi/bootstrap/package-info.java | 71 +++ .../java/zeroecho/pki/spi/package-info.java | 19 +- .../pki/spi/store/PkiStoreProvider.java | 77 ++++ .../zeroecho/pki/spi/store/package-info.java | 20 +- .../zeroecho.pki.spi.audit.AuditSinkProvider | 3 + .../zeroecho.pki.spi.store.PkiStoreProvider | 1 + .../pki/impl/audit/FileAuditSinkTest.java | 113 +++++ .../pki/impl/audit/InMemoryAuditSinkTest.java | 177 ++++++++ .../pki/impl/audit/StdoutAuditSinkTest.java | 118 +++++ .../pki/spi/bootstrap/PkiBootstrapTest.java | 178 ++++++++ 33 files changed, 3672 insertions(+), 32 deletions(-) create mode 100644 pki/src/main/java/zeroecho/pki/impl/audit/AuditEventCodec.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/audit/DefaultAttributeGovernanceService.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/audit/FileAuditSink.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/audit/FileAuditSinkFiles.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/audit/FileAuditSinkProvider.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/audit/InMemoryAuditSink.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/audit/InMemoryAuditSinkProvider.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/audit/SearchableAuditSink.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/audit/StdoutAuditSink.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/audit/StdoutAuditSinkProvider.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/audit/package-info.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStoreProvider.java create mode 100644 pki/src/main/java/zeroecho/pki/spi/ConfigurableProvider.java create mode 100644 pki/src/main/java/zeroecho/pki/spi/ProviderConfig.java rename pki/src/main/java/zeroecho/pki/spi/{ => audit}/AuditSink.java (98%) create mode 100644 pki/src/main/java/zeroecho/pki/spi/audit/AuditSinkProvider.java create mode 100644 pki/src/main/java/zeroecho/pki/spi/audit/package-info.java create mode 100644 pki/src/main/java/zeroecho/pki/spi/bootstrap/PkiBootstrap.java create mode 100644 pki/src/main/java/zeroecho/pki/spi/bootstrap/SpiSelector.java create mode 100644 pki/src/main/java/zeroecho/pki/spi/bootstrap/SpiSystemProperties.java create mode 100644 pki/src/main/java/zeroecho/pki/spi/bootstrap/package-info.java create mode 100644 pki/src/main/java/zeroecho/pki/spi/store/PkiStoreProvider.java create mode 100644 pki/src/main/resources/META-INF/services/zeroecho.pki.spi.audit.AuditSinkProvider create mode 100644 pki/src/main/resources/META-INF/services/zeroecho.pki.spi.store.PkiStoreProvider create mode 100644 pki/src/test/java/zeroecho/pki/impl/audit/FileAuditSinkTest.java create mode 100644 pki/src/test/java/zeroecho/pki/impl/audit/InMemoryAuditSinkTest.java create mode 100644 pki/src/test/java/zeroecho/pki/impl/audit/StdoutAuditSinkTest.java create mode 100644 pki/src/test/java/zeroecho/pki/spi/bootstrap/PkiBootstrapTest.java diff --git a/pki/.classpath b/pki/.classpath index 000d5a0..8ce7b51 100644 --- a/pki/.classpath +++ b/pki/.classpath @@ -6,26 +6,27 @@ - + + + + + + - + - + - - - - - + diff --git a/pki/src/main/java/zeroecho/pki/api/audit/AuditEvent.java b/pki/src/main/java/zeroecho/pki/api/audit/AuditEvent.java index 1a55561..5fa56e0 100644 --- a/pki/src/main/java/zeroecho/pki/api/audit/AuditEvent.java +++ b/pki/src/main/java/zeroecho/pki/api/audit/AuditEvent.java @@ -35,40 +35,80 @@ package zeroecho.pki.api.audit; import java.time.Instant; +import java.util.Comparator; import java.util.Map; +import java.util.Objects; import java.util.Optional; import zeroecho.pki.api.FormatId; import zeroecho.pki.api.PkiId; /** - * Auditable event emitted by the PKI core. + * Auditable event emitted by the PKI core and governance layer. * *

- * Audit events may represent high-level PKI operations (issuance, revocation, - * publication, backup) and attribute access governance outcomes. - * Implementations must ensure no secrets appear in {@code details}. + * An {@code AuditEvent} is an immutable, structured record describing a + * security-relevant action or outcome. Typical event categories include + * high-level PKI operations (issuance, revocation, publication, backup) and + * attribute-level governance decisions (read/export attempts and outcomes). *

* - * @param time event time (server time) - * @param category non-empty category (e.g., "ISSUANCE", "REVOCATION", - * "ATTRIBUTE_ACCESS") - * @param action non-empty action string (e.g., "ISSUE_END_ENTITY", "REVOKE", - * "READ") - * @param principal actor responsible for the event - * @param purpose purpose of the operation/access - * @param objectId optional subject object id (credential id, request id, etc.) - * @param formatId optional format id related to the object - * @param details additional non-sensitive key/value details + *

Security properties

+ *
    + *
  • No secrets: {@link #details()} MUST NOT contain secrets + * (keys, seeds, shared secrets, plaintext, private material, or other sensitive + * cryptographic/internal state). It is intended for non-sensitive metadata only + * (e.g., decision, policy id, reason codes, counters).
  • + *
  • Minimality: callers should prefer coarse, + * non-identifying fields and avoid excessive detail. If an identifier is + * needed, prefer {@link #objectId()} and a stable, non-secret reference.
  • + *
+ * + *

Determinism and ordering

+ *

+ * 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. + *

+ * + *

Validation

+ *

+ * 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. + *

+ * + * @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, Optional objectId, Optional formatId, Map details) { /** - * Creates an audit event. + * Creates an audit event and validates record invariants. * - * @throws IllegalArgumentException if inputs are invalid or optional - * 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. + *

+ * + * @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 { 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"); } } + + /** + * Returns a deterministic comparator for audit events. + * + *

+ * The comparator orders events by: {@code time}, {@code category}, + * {@code action}, {@code principal.type}, {@code principal.name}. + *

+ * + *

+ * This ordering is intended for consistent presentation and stable test + * assertions. It is not a security primitive. + *

+ * + * @return deterministic comparator, never {@code null} + */ + public static Comparator 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. + * + *

+ * Matching semantics: + *

+ *
    + *
  • If {@code query.category} is present, it must equal + * {@link #category()}.
  • + *
  • If {@code query.action} is present, it must equal {@link #action()}.
  • + *
  • If {@code query.after} is present, event time must be {@code >= after} + * (implemented as {@code !time.isBefore(after)}).
  • + *
  • If {@code query.before} is present, event time must be {@code <= before} + * (implemented as {@code !time.isAfter(before)}).
  • + *
  • If {@code query.objectId} is present, it must equal {@link #objectId()} + * (empty optional does not match).
  • + *
  • If {@code query.principalName} is present, it must equal + * {@link #principal()}.{@code name()}.
  • + *
+ * + *

+ * This method is side-effect free and deterministic. + *

+ * + * @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; + } } diff --git a/pki/src/main/java/zeroecho/pki/impl/audit/AuditEventCodec.java b/pki/src/main/java/zeroecho/pki/impl/audit/AuditEventCodec.java new file mode 100644 index 0000000..8e3f7bf --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/audit/AuditEventCodec.java @@ -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. + * + *

Stable id

+ *

+ * {@link #eventId(AuditEvent)} computes SHA-256 over canonical bytes from + * {@link #canonicalBytes(AuditEvent)}. Canonicalization sorts + * {@link AuditEvent#details()} keys to ensure deterministic output. + *

+ * + *

Security

+ *
    + *
  • This codec does not log.
  • + *
  • It assumes audit details have already been redacted by the caller (no + * secrets).
  • + *
+ */ +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 objectId = event.objectId(); + sb.append("objectId=").append(objectId.isPresent() ? objectId.get().value() : "").append('\n'); + + Optional formatId = event.formatId(); + sb.append("formatId=").append(formatId.isPresent() ? formatId.get().value() : "").append('\n'); + + TreeMap sorted = new TreeMap(); + sorted.putAll(event.details()); + for (Map.Entry 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 sorted = new TreeMap(); + sorted.putAll(event.details()); + + boolean first = true; + for (Map.Entry 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 objectId = oid.isBlank() ? Optional.empty() : Optional.of(new PkiId(oid)); + + String fid = unescape(value(parts, "fid")); + Optional formatId = fid.isBlank() ? Optional.empty() : Optional.of(new FormatId(fid)); + + Map details = decodeDetails(parts.length > 8 ? parts[8] : ""); + return new AuditEvent(time, category, action, principal, purpose, objectId, formatId, Map.copyOf(details)); + } + + private static Map decodeDetails(String detailsPart) { + Map 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); + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/audit/DefaultAttributeGovernanceService.java b/pki/src/main/java/zeroecho/pki/impl/audit/DefaultAttributeGovernanceService.java new file mode 100644 index 0000000..868b981 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/audit/DefaultAttributeGovernanceService.java @@ -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}. + * + *

+ * This component acts as a Policy Enforcement Point (PEP). For attribute + * operations it: + *

+ *
    + *
  1. resolves attribute metadata from {@link AttributeCatalogue},
  2. + *
  3. obtains a decision from {@link AttributeAccessController} (PDP),
  4. + *
  5. enforces allow/deny deterministically,
  6. + *
  7. emits audit events via {@link AuditService} (if configured by + * policy).
  8. + *
+ * + *

Security properties

+ *
    + *
  • No secrets in audit: audit {@link AuditEvent#details()} + * contains only metadata. Attribute values are never included.
  • + *
  • Deterministic time: timestamps are sourced from an + * injected {@link Clock}.
  • + *
  • Export redaction: non-public attributes are + * deterministically redacted during export.
  • + *
+ * + *

Logging

+ *

+ * This class logs operational failures (e.g., PDP throws) and unexpected + * states, but never logs attribute values. + *

+ */ +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 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 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 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 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 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 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 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 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. + * + *

+ * Only metadata is recorded. No attribute values are included. + *

+ * + * @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 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> map = new HashMap<>(); + for (AttributeId existingId : input.ids()) { + List 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> values; + + /** + * Creates a new attribute set backed by an immutable map. + * + * @param values attribute storage map + */ + /* default */ MapBackedAttributeSet(Map> values) { + this.values = Objects.requireNonNull(values, "values"); + } + + @Override + public java.util.Set ids() { + return values.keySet(); + } + + @Override + public Optional get(AttributeId id) { + Objects.requireNonNull(id, "id"); + List list = values.get(id); + if (list == null || list.isEmpty()) { + return Optional.empty(); + } + return Optional.of(list.get(0)); + } + + @Override + public List getAll(AttributeId id) { + Objects.requireNonNull(id, "id"); + List list = values.get(id); + if (list == null) { + return List.of(); + } + return List.copyOf(list); + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/audit/FileAuditSink.java b/pki/src/main/java/zeroecho/pki/impl/audit/FileAuditSink.java new file mode 100644 index 0000000..a3f254a --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/audit/FileAuditSink.java @@ -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. + * + *

ServiceLoader compatibility

+ *

+ * This sink supports {@link java.util.ServiceLoader} by providing a public + * no-arg constructor. + *

+ * + *

Storage layout

+ * baseDir/
+ *   YYYY/
+ *     MM/
+ *       YYYY-MM-DD.audit.log.gz
+ * 
+ * + *

Encoding

+ *

+ * 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. + *

+ * + *

Security properties

+ *
    + *
  • Best-effort permissions: directories 0700, files 0600 on POSIX.
  • + *
  • No event payload is logged. Corrupted records are skipped without logging + * the record content.
  • + *
+ */ +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 search(AuditQuery query) { + if (query == null) { + throw new IllegalArgumentException("query must not be null"); + } + + List 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 out = new ArrayList<>(); + for (Path f : files) { + scanFile(f, query, out, null); + } + + out.sort(AuditEvent.auditOrder()); + return List.copyOf(out); + } + + @Override + public Optional getById(PkiId eventId) { + if (eventId == null) { + throw new IllegalArgumentException("eventId must not be null"); + } + + List files = FileAuditSinkFiles.listDailyFiles(baseDir); + for (Path f : files) { + List 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 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 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 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 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 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. + * + *

+ * {@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. + *

+ */ + private static final class CloseShieldInputStream extends FilterInputStream { + + private CloseShieldInputStream(InputStream in) { + super(in); + } + + @Override + public void close() throws IOException { + // Intentionally do nothing. + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/audit/FileAuditSinkFiles.java b/pki/src/main/java/zeroecho/pki/impl/audit/FileAuditSinkFiles.java new file mode 100644 index 0000000..e001fe9 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/audit/FileAuditSinkFiles.java @@ -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 listDailyFiles(Path baseDir) { + if (baseDir == null) { + throw new IllegalArgumentException("baseDir must not be null"); + } + if (!Files.isDirectory(baseDir)) { + return List.of(); + } + + List 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); + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/audit/FileAuditSinkProvider.java b/pki/src/main/java/zeroecho/pki/impl/audit/FileAuditSinkProvider.java new file mode 100644 index 0000000..8861c81 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/audit/FileAuditSinkProvider.java @@ -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 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); + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/audit/InMemoryAuditSink.java b/pki/src/main/java/zeroecho/pki/impl/audit/InMemoryAuditSink.java new file mode 100644 index 0000000..7309467 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/audit/InMemoryAuditSink.java @@ -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. + * + *

Memory bound

+ *

+ * 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. + *

+ * + *

Configuration

+ *
    + *
  • System property {@code zeroecho.pki.audit.inmemory.maxEvents} (positive + * integer)
  • + *
  • Default: 10_000
  • + *
+ * + *

Security properties

+ *
    + *
  • Does not log event payloads.
  • + *
  • Stores events as received; callers must ensure + * {@link AuditEvent#details()} contains no secrets.
  • + *
+ * + *

Thread-safety

+ *

+ * This implementation is synchronized and safe for concurrent use. + *

+ */ +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 events; + + private boolean overflowLogged; + + /** + * Public no-arg constructor required for {@link java.util.ServiceLoader}. + * + *

+ * Defaults to 10_000 events. + *

+ */ + 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 snapshot() { // NOPMD + return List.copyOf(events); + } + + @Override + public synchronized List search(AuditQuery query) { // NOPMD + if (query == null) { + throw new IllegalArgumentException("query must not be null"); + } + + List 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 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 + "]"; + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/audit/InMemoryAuditSinkProvider.java b/pki/src/main/java/zeroecho/pki/impl/audit/InMemoryAuditSinkProvider.java new file mode 100644 index 0000000..bfa2ad4 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/audit/InMemoryAuditSinkProvider.java @@ -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 supportedKeys() { + return Set.of("size"); + } + + @Override + public AuditSink allocate(ProviderConfig config) { + + Optional 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; + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/audit/SearchableAuditSink.java b/pki/src/main/java/zeroecho/pki/impl/audit/SearchableAuditSink.java new file mode 100644 index 0000000..dee475f --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/audit/SearchableAuditSink.java @@ -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. + * + *

+ * 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)}. + *

+ */ +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 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 getById(PkiId eventId); +} diff --git a/pki/src/main/java/zeroecho/pki/impl/audit/StdoutAuditSink.java b/pki/src/main/java/zeroecho/pki/impl/audit/StdoutAuditSink.java new file mode 100644 index 0000000..879f973 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/audit/StdoutAuditSink.java @@ -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. + * + *

Security properties

+ *
    + *
  • Does not print {@link AuditEvent#details()} to reduce the risk of secret + * disclosure.
  • + *
  • Only prints high-level metadata (time, category, action, principal, + * purpose).
  • + *
+ * + *

Determinism

+ *

+ * Timestamp is rendered in ISO-8601 UTC, and the output line layout is stable. + *

+ * + *

+ * This sink is suitable for development and demonstrations. It is not intended + * as a production persistence backend. + *

+ */ +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', ' '); + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/audit/StdoutAuditSinkProvider.java b/pki/src/main/java/zeroecho/pki/impl/audit/StdoutAuditSinkProvider.java new file mode 100644 index 0000000..0ab3828 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/audit/StdoutAuditSinkProvider.java @@ -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 supportedKeys() { + return Collections.emptySet(); + } + + @Override + public AuditSink allocate(ProviderConfig config) { + return new StdoutAuditSink(); + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/audit/package-info.java b/pki/src/main/java/zeroecho/pki/impl/audit/package-info.java new file mode 100644 index 0000000..ba5c293 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/audit/package-info.java @@ -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. + * + *

Scope

+ *

+ * 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}. + *

+ * + *

Security properties

+ *
    + *
  • No secrets in logs: JUL logs never include audit payload + * maps, attribute values, keys, seeds, plaintext, or other sensitive + * material.
  • + *
  • No secrets by default: reference sinks avoid printing + * {@link zeroecho.pki.api.audit.AuditEvent#details()} unless explicitly + * required by a custom sink implementation.
  • + *
  • Fail-fast recording: sink failures are propagated to the + * caller to avoid silent audit loss.
  • + *
+ * + *

Determinism

+ *
    + *
  • Search results are deterministically sorted.
  • + *
  • Stable event identifiers are derived from canonicalized content + * (SHA-256).
  • + *
  • File enumeration is deterministic (lexicographic order).
  • + *
+ * + *

Persistence

+ *

+ * {@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. + *

+ */ +package zeroecho.pki.impl.audit; diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java index 7873ecc..cb0d400 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java @@ -147,6 +147,8 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { ensureVersionFile(); this.historySeq = new AtomicLong(0L); + LOG.log(Level.INFO, "running in {0}", root); + } catch (IOException e) { throw new IllegalStateException("failed to open filesystem store at " + root, e); } diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStoreProvider.java b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStoreProvider.java new file mode 100644 index 0000000..9aea6b3 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStoreProvider.java @@ -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}. + * + *

+ * Supported configuration keys: + *

+ *
    + *
  • {@code root} - store root directory (required)
  • + *
+ */ +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 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); + } +} diff --git a/pki/src/main/java/zeroecho/pki/spi/ConfigurableProvider.java b/pki/src/main/java/zeroecho/pki/spi/ConfigurableProvider.java new file mode 100644 index 0000000..23c7271 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/spi/ConfigurableProvider.java @@ -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. + * + *

+ * {@link java.util.ServiceLoader} instantiates providers using a public no-arg + * constructor. However, the produced 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. + *

+ * + *

Provider identity

+ *

+ * {@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. + *

+ * + *

Configuration

+ *

+ * 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. + *

+ * + *

Security

+ *

+ * 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 keys only. + *

+ * + * @param the type produced by the provider (e.g. {@code PkiStore}, + * {@code AuditSink}) + */ +public interface ConfigurableProvider { + + /** + * 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. + * + *

+ * Keys are informational (diagnostics/validation/help). Unknown keys must be + * ignored by implementations for forward compatibility. + *

+ * + * @return supported configuration keys (never {@code null}) + */ + Set supportedKeys(); + + /** + * Allocates a new instance using the provided configuration. + * + *

+ * Implementations must validate required keys and throw + * {@link IllegalArgumentException} if configuration is incomplete or invalid. + *

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

+ * 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. + *

+ * + *

+ * 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. + *

+ * + *

+ * 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. + *

+ * + * @param backendId selected backend id (never blank) + * @param properties backend properties (never {@code null}) + */ +public record ProviderConfig(String backendId, Map 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 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; + } +} diff --git a/pki/src/main/java/zeroecho/pki/spi/AuditSink.java b/pki/src/main/java/zeroecho/pki/spi/audit/AuditSink.java similarity index 98% rename from pki/src/main/java/zeroecho/pki/spi/AuditSink.java rename to pki/src/main/java/zeroecho/pki/spi/audit/AuditSink.java index 2d88d06..3a6d46a 100644 --- a/pki/src/main/java/zeroecho/pki/spi/AuditSink.java +++ b/pki/src/main/java/zeroecho/pki/spi/audit/AuditSink.java @@ -32,7 +32,7 @@ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE * POSSIBILITY OF SUCH DAMAGE. ******************************************************************************/ -package zeroecho.pki.spi; +package zeroecho.pki.spi.audit; import zeroecho.pki.api.audit.AuditEvent; diff --git a/pki/src/main/java/zeroecho/pki/spi/audit/AuditSinkProvider.java b/pki/src/main/java/zeroecho/pki/spi/audit/AuditSinkProvider.java new file mode 100644 index 0000000..52e97de --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/spi/audit/AuditSinkProvider.java @@ -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. + * + *

+ * {@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. + *

+ * + *

+ * Implementations must provide a public no-arg constructor to be discoverable + * via {@link java.util.ServiceLoader}. + *

+ * + *

+ * Security note: providers and bootstraps must never log configuration values + * because they can contain sensitive material (passwords, tokens, endpoints). + *

+ */ +public interface AuditSinkProvider extends ConfigurableProvider { + /** + * Opens a new {@link AuditSink} instance using the provided configuration. + * + *

+ * Implementations must validate required keys and throw + * {@link IllegalArgumentException} if configuration is incomplete or invalid. + *

+ * + * @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); +} diff --git a/pki/src/main/java/zeroecho/pki/spi/audit/package-info.java b/pki/src/main/java/zeroecho/pki/spi/audit/package-info.java new file mode 100644 index 0000000..9677219 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/spi/audit/package-info.java @@ -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. + * + *

+ * 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}. + *

+ * + *

Purpose and scope

+ *

+ * 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. + *

+ * + *

+ * The SPI is split into two roles: + *

+ *
    + *
  • {@link zeroecho.pki.spi.audit.AuditSink} – a runtime component that + * receives {@link zeroecho.pki.api.audit.AuditEvent audit events} and persists + * them.
  • + *
  • {@link zeroecho.pki.spi.audit.AuditSinkProvider} – a + * {@link java.util.ServiceLoader}-discoverable factory responsible for creating + * and configuring {@code AuditSink} instances.
  • + *
+ * + *

Instantiation model

+ *

+ * {@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). + *

+ * + *

+ * 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. + *

+ * + *

Security and compliance requirements

+ *

+ * Implementations of this SPI are security-critical. The following rules are + * mandatory: + *

+ *
    + *
  • Audit sinks must never persist secrets, private keys, shared secrets, + * passwords, tokens, or other sensitive cryptographic material.
  • + *
  • Providers and sinks must apply appropriate redaction and minimization + * policies to all persisted data.
  • + *
  • Configuration values supplied via {@code ProviderConfig} must never be + * written to logs or audit outputs, as they may contain sensitive information. + *
  • + *
+ * + *

Thread-safety and lifecycle

+ *

+ * Unless explicitly documented otherwise by a concrete implementation, + * {@code AuditSink} instances are expected to be thread-safe and usable by + * multiple concurrent PKI operations. + *

+ * + *

+ * 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. + *

+ * + *

Intended usage

+ *

+ * 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. + *

+ */ +package zeroecho.pki.spi.audit; diff --git a/pki/src/main/java/zeroecho/pki/spi/bootstrap/PkiBootstrap.java b/pki/src/main/java/zeroecho/pki/spi/bootstrap/PkiBootstrap.java new file mode 100644 index 0000000..5cccaab --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/spi/bootstrap/PkiBootstrap.java @@ -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. + * + *

+ * 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.). + *

+ * + *

System property conventions

+ * + *
    + *
  • Select store provider: {@code -Dzeroecho.pki.store=<id>}
  • + *
  • Configure store provider: + * {@code -Dzeroecho.pki.store.<key>=<value>}
  • + *
+ * + *

+ * Security note: configuration values may be sensitive and must not be logged. + * This bootstrap logs only provider ids and configuration keys. + *

+ */ +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}. + * + *

+ * Provider selection is deterministic and fail-fast: + *

+ *
    + *
  • If {@code -Dzeroecho.pki.store=<id>} is specified, the provider + * with the matching id is selected.
  • + *
  • If no id is specified and exactly one provider exists, that provider is + * selected.
  • + *
  • If multiple providers exist and no id is specified, bootstrap fails as + * configuration is ambiguous.
  • + *
+ * + *

+ * 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. + *

+ * + *

+ * 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. + *

+ * + *

+ * 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. + *

+ * + * @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 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. + * + *

+ * This method is safe: it does not log configuration values. + *

+ * + * @param provider provider (never {@code null}) + */ + public static void logSupportedKeys(ConfigurableProvider 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}. + * + *

+ * Selection and configuration follow the same conventions as + * {@link #openStore()}, using {@code -Dzeroecho.pki.audit=<id>} and + * {@code zeroecho.pki.audit.} prefixed properties. + *

+ * + * @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 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); + + } +} diff --git a/pki/src/main/java/zeroecho/pki/spi/bootstrap/SpiSelector.java b/pki/src/main/java/zeroecho/pki/spi/bootstrap/SpiSelector.java new file mode 100644 index 0000000..c212e99 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/spi/bootstrap/SpiSelector.java @@ -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. + * + *

+ * This class centralizes deterministic provider-selection rules: + *

+ *
    + *
  • If an id is requested and exactly one provider matches it, that provider + * is selected.
  • + *
  • If no id is requested and exactly one provider exists, it is + * selected.
  • + *
  • Otherwise selection fails fast with a descriptive exception.
  • + *
+ * + *

+ * 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. + *

+ * + *

+ * 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. + *

+ */ +public final class SpiSelector { + + private SpiSelector() { + throw new AssertionError("No instances."); + } + + /** + * Provider id accessor used by the selector. + * + * @param provider type + */ + @FunctionalInterface + public interface ProviderId { + + /** + * 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 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 select(Class service, String requestedId, ProviderId idAccessor) { + Objects.requireNonNull(service, "service"); + Objects.requireNonNull(idAccessor, "idAccessor"); + + ServiceLoader loader = ServiceLoader.load(service); + List 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 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."); + } +} diff --git a/pki/src/main/java/zeroecho/pki/spi/bootstrap/SpiSystemProperties.java b/pki/src/main/java/zeroecho/pki/spi/bootstrap/SpiSystemProperties.java new file mode 100644 index 0000000..7507436 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/spi/bootstrap/SpiSystemProperties.java @@ -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. + * + *

+ * Convention: + *

+ *
    + *
  • Select provider: {@code -Dzeroecho.pki.<component>=<id>}
  • + *
  • Provider config: + * {@code -Dzeroecho.pki.<component>.<key>=<value>}
  • + *
+ * + *

+ * Values may be sensitive and must not be logged. Callers may log keys only. + *

+ */ +public final class SpiSystemProperties { + + private SpiSystemProperties() { + throw new AssertionError("No instances."); + } + + /** + * Reads all string system properties with the given prefix. + * + *

+ * 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. + *

+ * + * @param prefix property prefix including trailing dot, e.g. + * {@code "zeroecho.pki.store."} + * @return map of subKey -> value (never {@code null}) + */ + public static Map readPrefixed(String prefix) { + Objects.requireNonNull(prefix, "prefix"); + + Map out = new HashMap<>(); + Properties props = System.getProperties(); + for (Map.Entry 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; + } +} diff --git a/pki/src/main/java/zeroecho/pki/spi/bootstrap/package-info.java b/pki/src/main/java/zeroecho/pki/spi/bootstrap/package-info.java new file mode 100644 index 0000000..eb2f5e5 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/spi/bootstrap/package-info.java @@ -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. + * + *

+ * This package centralizes deterministic provider selection and a minimal + * configuration convention for multiple SPIs (store, audit, publication, and + * future components). + *

+ * + *

+ * Selection is fail-fast: if multiple providers exist for a service and no + * explicit provider id is requested, bootstrap rejects the configuration as + * ambiguous. + *

+ * + *

+ * The conventions are intentionally minimal: + *

+ *
    + *
  • Select provider: {@code -Dzeroecho.pki.<component>=<id>}
  • + *
  • Provider properties: + * {@code -Dzeroecho.pki.<component>.<key>=<value>}
  • + *
+ * + *

+ * 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. + *

+ * + *

+ * Security note: configuration values may contain sensitive material and must + * not be logged. Bootstraps should log provider ids and configuration keys + * only. + *

+ */ +package zeroecho.pki.spi.bootstrap; diff --git a/pki/src/main/java/zeroecho/pki/spi/package-info.java b/pki/src/main/java/zeroecho/pki/spi/package-info.java index 3a5670f..b40305f 100644 --- a/pki/src/main/java/zeroecho/pki/spi/package-info.java +++ b/pki/src/main/java/zeroecho/pki/spi/package-info.java @@ -36,9 +36,22 @@ * Service Provider Interfaces (SPIs) for the PKI module. * *

- * SPIs are used to plug in persistence, audit sinks, publishing targets, and - * credential framework implementations. Public API types remain - * framework-agnostic. + * SPIs are used to plug in persistence backends, audit sinks, publishing + * targets, and framework integrations while keeping the public PKI API + * framework-agnostic. Implementations are typically discovered via + * {@link java.util.ServiceLoader}. + *

+ * + *

+ * 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. + *

+ * + *

+ * Backend configuration is passed as string properties through + * {@link zeroecho.pki.spi.ProviderConfig}. Configuration values may be + * sensitive and must not be logged. *

*/ package zeroecho.pki.spi; diff --git a/pki/src/main/java/zeroecho/pki/spi/store/PkiStoreProvider.java b/pki/src/main/java/zeroecho/pki/spi/store/PkiStoreProvider.java new file mode 100644 index 0000000..0b55b9b --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/spi/store/PkiStoreProvider.java @@ -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. + * + *

+ * 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. + *

+ * + *

+ * Implementations must be discoverable via {@link java.util.ServiceLoader} and + * therefore must provide a public no-arg constructor. + *

+ * + *

+ * Security note: configuration values may contain sensitive material. Providers + * must not log configuration values. + *

+ */ +public interface PkiStoreProvider extends ConfigurableProvider { + /** + * Opens a new {@link PkiStore} instance using the provided configuration. + * + *

+ * Implementations must validate required keys and throw + * {@link IllegalArgumentException} if configuration is incomplete or invalid. + *

+ * + * @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); +} diff --git a/pki/src/main/java/zeroecho/pki/spi/store/package-info.java b/pki/src/main/java/zeroecho/pki/spi/store/package-info.java index 80f19c8..70c65e5 100644 --- a/pki/src/main/java/zeroecho/pki/spi/store/package-info.java +++ b/pki/src/main/java/zeroecho/pki/spi/store/package-info.java @@ -36,9 +36,23 @@ * Persistence SPI for PKI state. * *

- * Implementations persist CA entities, credentials, requests, revocations, - * status objects, profiles, policy traces, publication records, and optionally - * audit events. Storage must be deterministic and suitable for backup/restore. + * This package defines the authoritative persistence contract for PKI-managed + * state (CAs, credentials, requests, revocations, status objects, profiles, + * publications, and policy traces). Storage must be deterministic and suitable + * for backup/restore. + *

+ * + *

+ * 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}. + *

+ * + *

+ * 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. *

*/ package zeroecho.pki.spi.store; diff --git a/pki/src/main/resources/META-INF/services/zeroecho.pki.spi.audit.AuditSinkProvider b/pki/src/main/resources/META-INF/services/zeroecho.pki.spi.audit.AuditSinkProvider new file mode 100644 index 0000000..db62c4b --- /dev/null +++ b/pki/src/main/resources/META-INF/services/zeroecho.pki.spi.audit.AuditSinkProvider @@ -0,0 +1,3 @@ +zeroecho.pki.impl.audit.FileAuditSinkProvider +zeroecho.pki.impl.audit.InMemoryAuditSinkProvider +zeroecho.pki.impl.audit.StdoutAuditSinkProvider diff --git a/pki/src/main/resources/META-INF/services/zeroecho.pki.spi.store.PkiStoreProvider b/pki/src/main/resources/META-INF/services/zeroecho.pki.spi.store.PkiStoreProvider new file mode 100644 index 0000000..98b4f82 --- /dev/null +++ b/pki/src/main/resources/META-INF/services/zeroecho.pki.spi.store.PkiStoreProvider @@ -0,0 +1 @@ +zeroecho.pki.impl.fs.FilesystemPkiStoreProvider diff --git a/pki/src/test/java/zeroecho/pki/impl/audit/FileAuditSinkTest.java b/pki/src/test/java/zeroecho/pki/impl/audit/FileAuditSinkTest.java new file mode 100644 index 0000000..b5ac7e6 --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/impl/audit/FileAuditSinkTest.java @@ -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 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 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) + "..."; + } +} diff --git a/pki/src/test/java/zeroecho/pki/impl/audit/InMemoryAuditSinkTest.java b/pki/src/test/java/zeroecho/pki/impl/audit/InMemoryAuditSinkTest.java new file mode 100644 index 0000000..0c99e60 --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/impl/audit/InMemoryAuditSinkTest.java @@ -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 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 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 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 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) + "..."; + } +} diff --git a/pki/src/test/java/zeroecho/pki/impl/audit/StdoutAuditSinkTest.java b/pki/src/test/java/zeroecho/pki/impl/audit/StdoutAuditSinkTest.java new file mode 100644 index 0000000..878c3ae --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/impl/audit/StdoutAuditSinkTest.java @@ -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) + "..."; + } +} diff --git a/pki/src/test/java/zeroecho/pki/spi/bootstrap/PkiBootstrapTest.java b/pki/src/test/java/zeroecho/pki/spi/bootstrap/PkiBootstrapTest.java new file mode 100644 index 0000000..c3c1b2c --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/spi/bootstrap/PkiBootstrapTest.java @@ -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}. + * + *

+ * The tests validate provider selection and configuration conventions based on + * {@link java.util.ServiceLoader} providers and {@link System#getProperties()}. + *

+ * + *

+ * 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). + *

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