wip(pki): checkpoint Phase A metadata foundation

Checkpoint the current pre-release Phase A work before production
persistence integration continues.

Includes the consolidated transactional metadata SPI, POSIX append-only
metadata log, recovery epochs, mutation codec, state reducer, internal
transaction engine, transactional adapter, staged-content foundations,
and the related current lib/pki changes.

Validated baseline:
- lib tests pass
- focused metadata tests pass
- PMD passes with zero findings
- JavaDoc passes
- app compilation passes
- pki retains exactly 31 independently classified failures:
  2 credential snapshot/model cases and 29 revocation fixture/reference
  cases

This is a work-in-progress safety checkpoint, not a release-ready
milestone.
This commit is contained in:
2026-08-01 16:45:14 +02:00
parent 369c626ed8
commit 5420c19d08
147 changed files with 26788 additions and 1071 deletions

View File

@@ -0,0 +1,226 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.core.alg;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import zeroecho.core.spec.AlgorithmIdentity;
import zeroecho.core.spec.AlgorithmIdentityCatalog;
import zeroecho.core.spec.AlgorithmSuite;
/**
* Immutable bootstrap identities required by current ZeroEcho PKI behavior.
*
* <p>
* These identities are mandatory defaults, not a permanent maximum algorithm
* set. Trusted installed extensions may add identities in other namespaces.
* Provider aliases are accepted only by {@link #fromCompatibilityAlias(String)}
* and never become canonical identity data.
* </p>
*/
public final class BootstrapAlgorithmIdentities {
private static final AlgorithmIdentity.Family SHA2_256 = family("sha2-256");
private static final AlgorithmIdentity.Family SHA2_384 = family("sha2-384");
private static final AlgorithmIdentity.Family SHA2_512 = family("sha2-512");
private static final AlgorithmIdentity.Family MGF1_FAMILY = family("mgf1");
private static final AlgorithmIdentity.Family RSA_PKCS1 = family("rsa-pkcs1-v1_5");
private static final AlgorithmIdentity.Family RSA_PSS = family("rsa-pss");
private static final AlgorithmIdentity.Family ECDSA = family("ecdsa");
private static final AlgorithmIdentity.Family ED25519_FAMILY = family("ed25519");
private static final AlgorithmIdentity.Family ED448_FAMILY = family("ed448");
private static final AlgorithmIdentity.Family RSA_KEY = family("rsa");
private static final AlgorithmIdentity.Family EC_KEY = family("ec");
/** SHA-256 digest identity. */
public static final AlgorithmIdentity SHA256 = identity(AlgorithmIdentity.Kind.DIGEST, SHA2_256,
AlgorithmIdentity.NoParameters.INSTANCE);
/** SHA-384 digest identity. */
public static final AlgorithmIdentity SHA384 = identity(AlgorithmIdentity.Kind.DIGEST, SHA2_384,
AlgorithmIdentity.NoParameters.INSTANCE);
/** SHA-512 digest identity. */
public static final AlgorithmIdentity SHA512 = identity(AlgorithmIdentity.Kind.DIGEST, SHA2_512,
AlgorithmIdentity.NoParameters.INSTANCE);
/** MGF1 mask-generation identity. */
public static final AlgorithmIdentity MGF1 = identity(AlgorithmIdentity.Kind.MASK_GENERATION, MGF1_FAMILY,
AlgorithmIdentity.NoParameters.INSTANCE);
/** RSA PKCS#1 v1.5 with SHA-256. */
public static final AlgorithmIdentity RSA_PKCS1_SHA256 = digestSignature(RSA_PKCS1, SHA256);
/** RSA PKCS#1 v1.5 with SHA-384. */
public static final AlgorithmIdentity RSA_PKCS1_SHA384 = digestSignature(RSA_PKCS1, SHA384);
/** RSA PKCS#1 v1.5 with SHA-512. */
public static final AlgorithmIdentity RSA_PKCS1_SHA512 = digestSignature(RSA_PKCS1, SHA512);
/** RSA-PSS SHA-256/MGF1-SHA-256/salt-32/trailer-1 bootstrap identity. */
public static final AlgorithmIdentity RSA_PSS_SHA256 = rsaPss(SHA256, SHA256, 32);
/** ECDSA with SHA-256, independent of the EC curve. */
public static final AlgorithmIdentity ECDSA_SHA256 = digestSignature(ECDSA, SHA256);
/** ECDSA with SHA-384, independent of the EC curve. */
public static final AlgorithmIdentity ECDSA_SHA384 = digestSignature(ECDSA, SHA384);
/** ECDSA with SHA-512, independent of the EC curve. */
public static final AlgorithmIdentity ECDSA_SHA512 = digestSignature(ECDSA, SHA512);
/** Ed25519 signature identity. */
public static final AlgorithmIdentity ED25519_SIGNATURE = identity(AlgorithmIdentity.Kind.SIGNATURE,
ED25519_FAMILY, AlgorithmIdentity.NoParameters.INSTANCE);
/** Ed448 signature identity. */
public static final AlgorithmIdentity ED448_SIGNATURE = identity(AlgorithmIdentity.Kind.SIGNATURE, ED448_FAMILY,
AlgorithmIdentity.NoParameters.INSTANCE);
/** RSA public-key identity. */
public static final AlgorithmIdentity RSA_PUBLIC_KEY = identity(AlgorithmIdentity.Kind.PUBLIC_KEY, RSA_KEY,
AlgorithmIdentity.NoParameters.INSTANCE);
/** EC P-256 public-key identity. */
public static final AlgorithmIdentity EC_P256_PUBLIC_KEY = namedKey(EC_KEY, "p-256");
/** EC P-384 public-key identity. */
public static final AlgorithmIdentity EC_P384_PUBLIC_KEY = namedKey(EC_KEY, "p-384");
/** EC P-521 public-key identity. */
public static final AlgorithmIdentity EC_P521_PUBLIC_KEY = namedKey(EC_KEY, "p-521");
/** Ed25519 public-key identity. */
public static final AlgorithmIdentity ED25519_PUBLIC_KEY = identity(AlgorithmIdentity.Kind.PUBLIC_KEY,
ED25519_FAMILY, AlgorithmIdentity.NoParameters.INSTANCE);
/** Ed448 public-key identity. */
public static final AlgorithmIdentity ED448_PUBLIC_KEY = identity(AlgorithmIdentity.Kind.PUBLIC_KEY, ED448_FAMILY,
AlgorithmIdentity.NoParameters.INSTANCE);
/** Current ECDSA P-256 signing suite. */
public static final AlgorithmSuite ECDSA_SHA256_P256 = new AlgorithmSuite(ECDSA_SHA256, EC_P256_PUBLIC_KEY);
/** Current ECDSA P-384 signing suite. */
public static final AlgorithmSuite ECDSA_SHA384_P384 = new AlgorithmSuite(ECDSA_SHA384, EC_P384_PUBLIC_KEY);
/** Current ECDSA P-521 signing suite. */
public static final AlgorithmSuite ECDSA_SHA512_P521 = new AlgorithmSuite(ECDSA_SHA512, EC_P521_PUBLIC_KEY);
/** Current immutable PKI signing default. */
public static final AlgorithmSuite PKI_SIGNATURE_DEFAULT_V1 = new AlgorithmSuite(RSA_PKCS1_SHA256, RSA_PUBLIC_KEY);
private static final List<AlgorithmIdentity> IDENTITIES = List.of(SHA256, SHA384, SHA512, MGF1, RSA_PKCS1_SHA256,
RSA_PKCS1_SHA384, RSA_PKCS1_SHA512, RSA_PSS_SHA256, ECDSA_SHA256, ECDSA_SHA384, ECDSA_SHA512,
ED25519_SIGNATURE, ED448_SIGNATURE, RSA_PUBLIC_KEY, EC_P256_PUBLIC_KEY, EC_P384_PUBLIC_KEY,
EC_P521_PUBLIC_KEY, ED25519_PUBLIC_KEY, ED448_PUBLIC_KEY);
private static final AlgorithmIdentityCatalog CATALOG = AlgorithmIdentityCatalog.builtIn(IDENTITIES);
private static final Map<String, AlgorithmIdentity> ALIASES = Map.ofEntries(
Map.entry("SHA256withRSA", RSA_PKCS1_SHA256),
Map.entry("SHA384withRSA", RSA_PKCS1_SHA384),
Map.entry("SHA512withRSA", RSA_PKCS1_SHA512),
Map.entry("SHA256withRSAandMGF1", RSA_PSS_SHA256),
Map.entry("SHA256withECDSA", ECDSA_SHA256),
Map.entry("SHA384withECDSA", ECDSA_SHA384),
Map.entry("SHA512withECDSA", ECDSA_SHA512),
Map.entry("Ed25519", ED25519_SIGNATURE),
Map.entry("Ed448", ED448_SIGNATURE));
private BootstrapAlgorithmIdentities() {
}
/**
* Returns the immutable bootstrap identity catalog.
*
* @return built-in catalog
*/
public static AlgorithmIdentityCatalog catalog() {
return CATALOG;
}
/**
* Creates an exact RSA-PSS identity without requiring a central enum entry.
*
* @param hash message digest
* @param mgfHash MGF1 digest
* @param saltLength salt length in bytes
* @return exact RSA-PSS identity
* @throws IllegalArgumentException if the tuple is contradictory
*/
public static AlgorithmIdentity rsaPss(AlgorithmIdentity hash, AlgorithmIdentity mgfHash, int saltLength) {
return identity(AlgorithmIdentity.Kind.SIGNATURE, RSA_PSS,
new AlgorithmIdentity.RsaPssParameters(hash, MGF1, mgfHash, saltLength, 1));
}
/**
* Resolves a finite legacy provider alias at the compatibility boundary.
*
* <p>
* SHA-1 and unknown aliases are rejected. The returned identity, rather than
* the alias, is authoritative.
* </p>
*
* @param alias legacy provider spelling
* @return exact bootstrap identity, or empty when unknown or forbidden
*/
public static Optional<AlgorithmIdentity> fromCompatibilityAlias(String alias) {
Objects.requireNonNull(alias, "alias");
return Optional.ofNullable(ALIASES.get(alias));
}
/**
* Returns the immutable finite built-in compatibility aliases.
*
* @return alias-to-exact-identity map
*/
public static Map<String, AlgorithmIdentity> compatibilityAliases() {
return ALIASES;
}
/**
* Resolves either a canonical identity or an approved compatibility alias.
*
* @param value canonical identity or finite legacy alias
* @return exact identity, or empty when unsupported
*/
public static Optional<AlgorithmIdentity> resolve(String value) {
Objects.requireNonNull(value, "value");
Optional<AlgorithmIdentity> canonical = CATALOG.resolve(value);
return canonical.isPresent() ? canonical : fromCompatibilityAlias(value);
}
private static AlgorithmIdentity digestSignature(AlgorithmIdentity.Family family, AlgorithmIdentity digest) {
return identity(AlgorithmIdentity.Kind.SIGNATURE, family, new AlgorithmIdentity.DigestParameters(digest));
}
private static AlgorithmIdentity namedKey(AlgorithmIdentity.Family family, String name) {
return identity(AlgorithmIdentity.Kind.PUBLIC_KEY, family,
new AlgorithmIdentity.NamedParameters(new AlgorithmIdentity.Family("zeroecho", name)));
}
private static AlgorithmIdentity identity(AlgorithmIdentity.Kind kind, AlgorithmIdentity.Family family,
AlgorithmIdentity.Parameters parameters) {
return new AlgorithmIdentity(kind, family, parameters);
}
private static AlgorithmIdentity.Family family(String name) {
return new AlgorithmIdentity.Family("zeroecho", name);
}
}

View File

@@ -38,8 +38,10 @@ import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import zeroecho.core.alg.BootstrapAlgorithmIdentities;
import zeroecho.core.alg.ecdsa.EcdsaCurveSpec;
import zeroecho.core.alg.rsa.RsaSigSpec;
import zeroecho.core.spec.AlgorithmIdentity;
import zeroecho.core.spec.VoidSpec;
/**
@@ -96,6 +98,10 @@ public final class SignatureInteropProfiles {
new SignatureInteropProfile("SHA512withRSA", "RSA", "RSA",
RsaSigSpec.pkcs1v15(RsaSigSpec.Hash.SHA512),
SignatureInteropProfile.SignatureRepresentation.IDENTITY, 0)),
Map.entry("SHA256withRSAandMGF1",
new SignatureInteropProfile("SHA256withRSAandMGF1", "RSA", "RSA",
RsaSigSpec.pss(RsaSigSpec.Hash.SHA256, 32),
SignatureInteropProfile.SignatureRepresentation.IDENTITY, 0)),
Map.entry("SHA256withECDSA", new SignatureInteropProfile("SHA256withECDSA", "ECDSA", "ECDSA", // NOPMD
EcdsaCurveSpec.P256,
SignatureInteropProfile.SignatureRepresentation.ECDSA_DER_EXTERNAL_P1363_INTERNAL,
@@ -114,6 +120,17 @@ public final class SignatureInteropProfiles {
Map.entry("Ed448", new SignatureInteropProfile("Ed448", "Ed448", "Ed448", VoidSpec.INSTANCE,
SignatureInteropProfile.SignatureRepresentation.IDENTITY, 0)));
private static final Map<String, SignatureInteropProfile> CANONICAL_PROFILES = Map.ofEntries(
canonical(BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256, "SHA256withRSA"),
canonical(BootstrapAlgorithmIdentities.RSA_PKCS1_SHA384, "SHA384withRSA"),
canonical(BootstrapAlgorithmIdentities.RSA_PKCS1_SHA512, "SHA512withRSA"),
canonical(BootstrapAlgorithmIdentities.RSA_PSS_SHA256, "SHA256withRSAandMGF1"),
canonical(BootstrapAlgorithmIdentities.ECDSA_SHA256, "SHA256withECDSA"),
canonical(BootstrapAlgorithmIdentities.ECDSA_SHA384, "SHA384withECDSA"),
canonical(BootstrapAlgorithmIdentities.ECDSA_SHA512, "SHA512withECDSA"),
canonical(BootstrapAlgorithmIdentities.ED25519_SIGNATURE, "Ed25519"),
canonical(BootstrapAlgorithmIdentities.ED448_SIGNATURE, "Ed448"));
private SignatureInteropProfiles() {
}
@@ -129,7 +146,8 @@ public final class SignatureInteropProfiles {
if (algorithmId.isBlank()) {
return Optional.empty();
}
return Optional.ofNullable(PROFILES.get(algorithmId));
SignatureInteropProfile profile = CANONICAL_PROFILES.get(algorithmId);
return Optional.ofNullable(profile == null ? PROFILES.get(algorithmId) : profile);
}
/**
@@ -139,7 +157,9 @@ public final class SignatureInteropProfiles {
* @return immutable set of supported standard signature names
*/
public static Set<String> algorithmIds() {
return PROFILES.keySet();
Set<String> identifiers = new java.util.LinkedHashSet<>(PROFILES.keySet());
identifiers.addAll(CANONICAL_PROFILES.keySet());
return Set.copyOf(identifiers);
}
/**
@@ -164,4 +184,8 @@ public final class SignatureInteropProfiles {
}
return algorithmId;
}
private static Map.Entry<String, SignatureInteropProfile> canonical(AlgorithmIdentity identity, String alias) {
return Map.entry(identity.canonicalForm(), PROFILES.get(alias));
}
}

View File

@@ -0,0 +1,73 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.core.io;
import java.io.InterruptedIOException;
/**
* Provider-independent cancellation signal for streaming operations.
*
* <p>
* The signal carries no operation content, key material or executor state.
* Implementations should be immutable views over runtime-owned cancellation
* state. Streaming readers and writers are expected to call
* {@link #throwIfCancelled()} between bounded I/O operations.
* </p>
*/
@FunctionalInterface
public interface CancellationSignal {
/**
* A signal that never requests cancellation.
*/
CancellationSignal NONE = () -> false;
/**
* Reports whether cancellation was requested.
*
* @return {@code true} when the operation should stop
*/
boolean isCancelled();
/**
* Fails the current streaming operation when cancellation was requested.
*
* @throws InterruptedIOException when cancellation was requested
*/
default void throwIfCancelled() throws InterruptedIOException {
if (isCancelled()) {
throw new InterruptedIOException("Streaming operation cancelled");
}
}
}

View File

@@ -0,0 +1,86 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.core.io;
import java.io.IOException;
import java.io.InputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Objects;
/**
* Streaming integrity helpers for repeatable content.
*/
public final class ContentDigests {
private static final int BUFFER_BYTES = 16 * 1024;
private ContentDigests() {
throw new AssertionError("No instances");
}
/**
* Computes a SHA-256 fingerprint without materializing the aggregate content.
*
* @param content repeatable content
* @param cancellation runtime cancellation signal
* @return lowercase hexadecimal SHA-256 fingerprint
* @throws IOException if the content cannot be read or cancellation is
* requested
*/
public static String sha256(RepeatableContent content, CancellationSignal cancellation) throws IOException {
Objects.requireNonNull(content, "content");
Objects.requireNonNull(cancellation, "cancellation");
MessageDigest digest;
try {
digest = MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException("SHA-256 is unavailable", ex);
}
byte[] buffer = new byte[BUFFER_BYTES];
try (InputStream input = content.openStream()) {
int read;
while ((read = input.read(buffer)) >= 0) {
cancellation.throwIfCancelled();
if (read > 0) {
digest.update(buffer, 0, read);
}
}
} finally {
java.util.Arrays.fill(buffer, (byte) 0);
}
return HexFormat.of().formatHex(digest.digest());
}
}

View File

@@ -0,0 +1,161 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.core.io;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Objects;
import java.util.OptionalLong;
/**
* Immutable repeatable bounded view over another repeatable content source.
*
* <p>
* A slice opens a fresh source pass and skips incrementally; it never copies the
* represented bytes. Closing the slice does not close its source because source
* ownership remains with the creator.
* </p>
*/
public final class ContentSlice implements RepeatableContent {
private static final long EMPTY_LENGTH = 0L;
private final RepeatableContent source;
private final long offset;
private final long length;
private final String contentId;
/**
* Creates a repeatable slice.
*
* @param source repeatable source
* @param offset non-negative source offset
* @param length non-negative slice length
* @throws IllegalArgumentException if a range is negative or exceeds a known
* source length
*/
public ContentSlice(RepeatableContent source, long offset, long length) {
this.source = Objects.requireNonNull(source, "source");
if (offset < 0L || length < 0L) {
throw new IllegalArgumentException("Content slice range must not be negative");
}
long end = Math.addExact(offset, length);
OptionalLong sourceLength = source.length();
if (sourceLength.isPresent() && end > sourceLength.getAsLong()) {
throw new IllegalArgumentException("Content slice exceeds source");
}
this.offset = offset;
this.length = length;
this.contentId = source.contentId() + "#slice:" + offset + ':' + length;
}
@Override
public InputStream openStream() throws IOException {
InputStream input = source.openStream();
try {
skipExactly(input, offset);
return new LimitedInputStream(input, length);
} catch (IOException failure) {
input.close();
throw failure;
}
}
@Override
public OptionalLong length() {
return OptionalLong.of(length);
}
@Override
public String contentId() {
return contentId;
}
@Override
public void close() {
// Source ownership remains with the creator.
}
private static void skipExactly(InputStream input, long count) throws IOException {
long remaining = count;
while (remaining != EMPTY_LENGTH) {
long skipped = input.skip(remaining);
if (skipped > EMPTY_LENGTH) {
remaining -= skipped;
} else if (input.read() < 0) {
throw new IOException("Content slice source is truncated");
} else {
remaining--;
}
}
}
/** Exact-length stream view that fails when its underlying source truncates. */
private static final class LimitedInputStream extends FilterInputStream {
private long remaining;
private LimitedInputStream(InputStream input, long remaining) {
super(input);
this.remaining = remaining;
}
@Override
public int read() throws IOException {
if (remaining == EMPTY_LENGTH) {
return -1;
}
int value = super.read();
if (value < 0) {
throw new IOException("Content slice source is truncated");
}
remaining--;
return value;
}
@Override
public int read(byte[] bytes, int offset, int count) throws IOException {
Objects.checkFromIndexSize(offset, count, bytes.length);
if (remaining == EMPTY_LENGTH) {
return -1;
}
int requested = (int) Math.min(remaining, count);
int read = super.read(bytes, offset, requested);
if (read < 0) {
throw new IOException("Content slice source is truncated");
}
remaining -= read;
return read;
}
}
}

View File

@@ -0,0 +1,107 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.core.io;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Objects;
import java.util.OptionalLong;
/**
* Explicit small-value adapter from immutable bytes to repeatable content.
*
* <p>
* This adapter intentionally materializes its individual value. It is suitable
* for bounded signatures, public-key fields and external small-object inputs. It
* must not be used as the authoritative representation of aggregate CRLs, TBS
* objects or streamed entry sequences.
* </p>
*/
public final class ImmutableByteContent implements RepeatableContent {
private final byte[] bytes;
private final String contentId;
/**
* Creates an owned immutable byte value.
*
* @param bytes individual value, possibly empty
* @throws NullPointerException if {@code bytes} is {@code null}
*/
public ImmutableByteContent(byte[] bytes) {
byte[] source = Objects.requireNonNull(bytes, "bytes");
this.bytes = source.clone();
this.contentId = "sha256:" + digest(this.bytes);
}
@Override
public InputStream openStream() {
return new ByteArrayInputStream(bytes);
}
@Override
public OptionalLong length() {
return OptionalLong.of(bytes.length);
}
@Override
public String contentId() {
return contentId;
}
/**
* Returns a defensive copy for an explicitly bounded provider adapter.
*
* @return newly allocated bytes
*/
public byte[] copyBytes() {
return bytes.clone();
}
@Override
public void close() {
// Immutable caller-visible values own no external resources.
}
private static String digest(byte[] value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value));
} catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException("SHA-256 is unavailable", ex);
}
}
}

View File

@@ -0,0 +1,75 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.core.io;
import java.io.IOException;
import java.io.InputStream;
import java.util.OptionalLong;
/**
* Provider-independent one-shot streaming input.
*
* <p>
* A one-shot input is not repeatable and therefore must be staged before signing
* recovery, canonical comparison, postcondition validation or publication that
* needs another pass. The returned stream is owned by the caller. Implementations
* must reject a second call to {@link #openStream()}.
* </p>
*/
public interface OneShotContent extends AutoCloseable {
/**
* Opens the only sequential reader.
*
* @return content stream
* @throws IOException if the source cannot be opened
* @throws IllegalStateException if the source was already opened
*/
InputStream openStream() throws IOException;
/**
* Returns the known source length when available.
*
* @return non-negative length or empty
*/
OptionalLong length();
/**
* Releases the source.
*
* @throws IOException if cleanup fails
*/
@Override
void close() throws IOException;
}

View File

@@ -0,0 +1,97 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.core.io;
import java.io.IOException;
import java.io.InputStream;
import java.util.OptionalLong;
/**
* Immutable, provider-independent source of repeatable operation content.
*
* <p>
* Every call to {@link #openStream()} returns a new sequential reader positioned
* at the first byte. Implementations may be backed by files, object storage,
* databases or explicitly small immutable byte values. Callers own and must close
* each returned stream. Closing the content releases its implementation-owned
* resources but does not close streams already returned unless the implementation
* documents a stronger local rule.
* </p>
*
* <p>
* The contract does not impose an aggregate content-size limit. Completion remains
* subject to available storage, I/O, technical representability and explicitly
* injected deployment policy. Content never carries key material or cryptographic
* provider authority.
* </p>
*/
public interface RepeatableContent extends AutoCloseable {
/**
* Opens a new sequential reader.
*
* @return newly opened content stream
* @throws IOException if the immutable content cannot be opened or its
* integrity cannot be established
*/
InputStream openStream() throws IOException;
/**
* Returns the known aggregate length when cheaply and authoritatively
* available.
*
* @return non-negative length, or empty when the length is unknown
*/
OptionalLong length();
/**
* Returns a stable, non-secret identifier for integrity and durable provenance.
*
* <p>
* The identifier is metadata, not authorization, and must not expose a
* temporary physical path.
* </p>
*
* @return stable non-blank identifier
*/
String contentId();
/**
* Releases implementation-owned resources.
*
* @throws IOException if cleanup fails
*/
@Override
void close() throws IOException;
}

View File

@@ -0,0 +1,589 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.core.spec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Collection;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Pattern;
/**
* Provider-independent identity of one exact cryptographic operation or key
* type.
*
* <p>
* An identity contains no provider, implementation class, key material, or
* X.509 representation. Families are namespaced so trusted installed
* extensions can add typed parameter models without modifying a central enum.
* The parameter object is responsible for family-specific validation and a
* deterministic canonical component.
* </p>
*
* <p>
* Instances are immutable. Equality and hashing use the complete canonical
* semantics, including the role, family, and parameters.
* </p>
*/
public final class AlgorithmIdentity {
private static final Pattern COMPONENT = Pattern.compile("[a-z][a-z0-9._-]{0,63}");
private static final AlgorithmIdentityCodec BUILTIN_CODEC = new BuiltInCodec();
private static final String NO_PARAMETERS = "none";
private static final int RSA_PSS_COMPONENT_COUNT = 5;
private static final int REQUIRED_TRAILER_FIELD = 1;
private static final int UTF8_ONE_BYTE_LIMIT = 0x7f;
private static final int UTF8_TWO_BYTE_LIMIT = 0x7ff;
private final Kind kind;
private final Family family;
private final AlgorithmIdentityCodec codec;
private final byte[] parameterSnapshot;
private final String canonicalForm;
/**
* Semantic role represented by an identity.
*/
public enum Kind {
/** Message digest. */
DIGEST,
/** Mask-generation function. */
MASK_GENERATION,
/** Signature scheme, independent of a particular key parameter set. */
SIGNATURE,
/** Public-key algorithm and its exact key parameter set. */
PUBLIC_KEY,
/** Key-encapsulation mechanism. */
KEM,
/** Key-agreement mechanism. */
AGREEMENT
}
/**
* Stable namespaced algorithm family name.
*
* @param namespace namespace owned by the built-in catalog or trusted
* extension
* @param name family name within that namespace
*/
public record Family(String namespace, String name) {
/**
* Creates a validated family name.
*
* @throws IllegalArgumentException if either component is not a lowercase
* canonical identifier
*/
public Family {
namespace = requireComponent(namespace, "namespace");
name = requireComponent(name, "name");
}
/**
* Returns the deterministic family representation.
*
* @return namespace and family separated by {@code /}
*/
public String canonicalForm() {
return namespace + "/" + name;
}
}
/**
* Typed, immutable family parameters.
*
* <p>
* Implementations supplied by trusted code must validate their complete
* family-specific semantics during construction. The canonical component is
* persistent identity data and therefore must never depend on a provider,
* locale, insertion order, or display alias.
* </p>
*/
public interface Parameters {
/**
* Returns the deterministic parameter representation.
*
* @return non-blank lowercase canonical component
*/
String canonicalForm();
/**
* Returns an independently owned immutable copy.
*
* <p>
* Trusted extension implementations must not return mutable caller-owned
* state. Immutable records may return {@code this}.
* </p>
*
* @return immutable owned parameters
*/
Parameters immutableCopy();
}
/**
* Parameters for an unparameterized family.
*/
public enum NoParameters implements Parameters {
/** Singleton empty-parameter value. */
INSTANCE;
@Override
public String canonicalForm() {
return "none";
}
@Override
public Parameters immutableCopy() {
return this;
}
}
/**
* Digest-qualified signature parameters.
*
* @param digest exact digest identity
*/
public record DigestParameters(AlgorithmIdentity digest) implements Parameters {
/**
* Creates digest-qualified parameters.
*
* @throws IllegalArgumentException if {@code digest} is not a digest
* identity
*/
public DigestParameters {
Objects.requireNonNull(digest, "digest");
if (digest.kind() != Kind.DIGEST) {
throw new IllegalArgumentException("digest must have DIGEST kind");
}
}
@Override
public String canonicalForm() {
return "digest=" + digest.family().namespace() + "." + digest.family().name() + "."
+ digest.parameters().canonicalForm();
}
@Override
public Parameters immutableCopy() {
return this;
}
}
/**
* Exact RSA-PSS parameters.
*
* @param hash message digest identity
* @param mask mask-generation identity
* @param maskHash mask-generation digest identity
* @param saltLength non-negative salt length in bytes
* @param trailerField trailer field; PKCS#1 currently defines value {@code 1}
*/
public record RsaPssParameters(AlgorithmIdentity hash, AlgorithmIdentity mask, AlgorithmIdentity maskHash,
int saltLength, int trailerField) implements Parameters {
/**
* Creates a validated exact RSA-PSS parameter tuple.
*
* @throws IllegalArgumentException if roles or numeric parameters are
* contradictory
*/
public RsaPssParameters {
Objects.requireNonNull(hash, "hash");
Objects.requireNonNull(mask, "mask");
Objects.requireNonNull(maskHash, "maskHash");
if (hash.kind() != Kind.DIGEST || maskHash.kind() != Kind.DIGEST) {
throw new IllegalArgumentException("RSA-PSS hashes must have DIGEST kind");
}
if (mask.kind() != Kind.MASK_GENERATION) {
throw new IllegalArgumentException("RSA-PSS mask must have MASK_GENERATION kind");
}
if (saltLength < 0) {
throw new IllegalArgumentException("RSA-PSS salt length must not be negative");
}
if (trailerField != REQUIRED_TRAILER_FIELD) {
throw new IllegalArgumentException("RSA-PSS trailer field must be 1");
}
}
@Override
public String canonicalForm() {
return "hash=" + shortName(hash) + ",mask=" + shortName(mask) + ",maskhash=" + shortName(maskHash)
+ ",salt=" + saltLength + ",trailer=" + trailerField;
}
@Override
public Parameters immutableCopy() {
return this;
}
}
/**
* Exact named parameter set, such as an elliptic-curve name.
*
* @param parameterSet stable namespaced parameter-set identifier
*/
public record NamedParameters(Family parameterSet) implements Parameters {
/**
* Creates named parameters.
*
* @throws NullPointerException if {@code parameterSet} is {@code null}
*/
public NamedParameters {
Objects.requireNonNull(parameterSet, "parameterSet");
}
@Override
public String canonicalForm() {
return "set=" + parameterSet.namespace() + "." + parameterSet.name();
}
@Override
public Parameters immutableCopy() {
return this;
}
}
/**
* Creates one exact algorithm identity.
*
* @param kind semantic role
* @param family stable namespaced family
* @param parameters validated typed parameters
* @throws NullPointerException if an argument is {@code null}
* @throws IllegalArgumentException if the parameter canonical form is not
* deterministic syntax
*/
public AlgorithmIdentity(Kind kind, Family family, Parameters parameters) {
this(kind, family, parameters, BUILTIN_CODEC);
}
/**
* Creates one exact algorithm identity using a trusted typed codec.
*
* @param kind semantic role
* @param family stable namespaced family
* @param parameters validated typed parameters
* @param codec immutable canonical parameter codec
*/
public AlgorithmIdentity(Kind kind, Family family, Parameters parameters, AlgorithmIdentityCodec codec) {
this.kind = Objects.requireNonNull(kind, "kind");
this.family = Objects.requireNonNull(family, "family");
this.codec = Objects.requireNonNull(codec, "codec");
requireComponent(codec.id(), "codec.id");
byte[] encoded = codec.encode(Objects.requireNonNull(parameters, "parameters"));
this.parameterSnapshot = Objects.requireNonNull(encoded, "encoded parameters").clone();
Parameters decoded = Objects.requireNonNull(codec.decode(parameterSnapshot.clone()), "decoded parameters");
byte[] roundTrip = codec.encode(decoded);
if (!java.util.Arrays.equals(parameterSnapshot, roundTrip)) {
throw new IllegalArgumentException("Algorithm parameter codec is not canonical");
}
this.canonicalForm = "zealg:2:" + field(kind.name().toLowerCase(Locale.ROOT)) + field(family.namespace())
+ field(family.name()) + field(codec.id())
+ field(Base64.getUrlEncoder().withoutPadding().encodeToString(parameterSnapshot));
}
/**
* Returns the semantic identity kind.
*
* @return identity kind
*/
public Kind kind() {
return kind;
}
/**
* Returns the stable family.
*
* @return namespaced family
*/
public Family family() {
return family;
}
/**
* Returns the immutable typed parameters.
*
* @return family parameters
*/
public Parameters parameters() {
return codec.decode(parameterSnapshot.clone());
}
/**
* Parses a complete version-two canonical identity with installed codecs.
*
* @param canonicalForm canonical identity
* @param codecs trusted installed codecs
* @return exact decoded identity
* @throws IllegalArgumentException if syntax, version, codec, or canonical
* round-trip validation fails
*/
public static AlgorithmIdentity parse(String canonicalForm, Collection<AlgorithmIdentityCodec> codecs) {
Objects.requireNonNull(canonicalForm, "canonicalForm");
Objects.requireNonNull(codecs, "codecs");
if (!canonicalForm.startsWith("zealg:2:")) {
throw new IllegalArgumentException("Unsupported canonical algorithm identity version");
}
Map<String, AlgorithmIdentityCodec> byId = new HashMap<>();
byId.put(BUILTIN_CODEC.id(), BUILTIN_CODEC);
for (AlgorithmIdentityCodec candidate : codecs) {
AlgorithmIdentityCodec previous = byId.putIfAbsent(candidate.id(), candidate);
if (previous != null && !previous.id().equals(candidate.id())) {
throw new IllegalArgumentException("Algorithm identity codec collision");
}
}
Cursor cursor = new Cursor(canonicalForm, "zealg:2:".length());
Kind parsedKind;
try {
parsedKind = Kind.valueOf(cursor.field().toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException exception) {
throw new IllegalArgumentException("Unknown algorithm identity kind", exception);
}
Family parsedFamily = new Family(cursor.field(), cursor.field());
String codecId = cursor.field();
String encodedParameters = cursor.field();
cursor.requireEnd();
AlgorithmIdentityCodec selected = byId.get(codecId);
if (selected == null) {
throw new IllegalArgumentException("Unknown algorithm identity codec");
}
byte[] bytes;
try {
bytes = Base64.getUrlDecoder().decode(encodedParameters);
} catch (IllegalArgumentException malformed) {
throw new IllegalArgumentException("Malformed canonical algorithm parameters", malformed);
}
AlgorithmIdentity identity = new AlgorithmIdentity(parsedKind, parsedFamily, selected.decode(bytes), selected);
if (!canonicalForm.equals(identity.canonicalForm())) {
throw new IllegalArgumentException("Non-canonical algorithm identity");
}
return identity;
}
/**
* Returns the deterministic provider-independent representation.
*
* @return complete canonical identity
*/
public String canonicalForm() {
return canonicalForm;
}
@Override
public boolean equals(Object other) {
return other instanceof AlgorithmIdentity identity && canonicalForm.equals(identity.canonicalForm);
}
@Override
public int hashCode() {
return canonicalForm.hashCode();
}
@Override
public String toString() {
return canonicalForm;
}
private static String requireComponent(String value, String field) {
Objects.requireNonNull(value, field);
if (!COMPONENT.matcher(value).matches()) {
throw new IllegalArgumentException(field + " must be a lowercase canonical identifier");
}
return value;
}
private static String shortName(AlgorithmIdentity identity) {
return identity.family.namespace() + "." + identity.family.name() + "."
+ identity.parameters().canonicalForm();
}
private static String field(String value) {
byte[] utf8 = value.getBytes(StandardCharsets.UTF_8);
return utf8.length + ":" + value;
}
/** Strict cursor for the length-prefixed canonical representation. */
private static final class Cursor {
private final String value;
private int offset;
private Cursor(String value, int offset) {
this.value = value;
this.offset = offset;
}
private String field() {
int separator = value.indexOf(':', offset);
if (separator < 0 || separator == offset) {
throw new IllegalArgumentException("Malformed canonical algorithm identity");
}
int length;
try {
length = Integer.parseInt(value.substring(offset, separator));
} catch (NumberFormatException exception) {
throw new IllegalArgumentException("Malformed canonical algorithm identity length", exception);
}
if (length < 0) {
throw new IllegalArgumentException("Negative canonical algorithm identity length");
}
int start = separator + 1;
int index = start;
int bytes = 0;
while (index < value.length() && bytes < length) {
int codePoint = value.codePointAt(index);
bytes += utf8Length(codePoint);
index += Character.charCount(codePoint);
}
if (bytes != length) {
throw new IllegalArgumentException("Truncated canonical algorithm identity field");
}
offset = index;
return value.substring(start, index);
}
private void requireEnd() {
if (offset != value.length()) {
throw new IllegalArgumentException("Trailing canonical algorithm identity data");
}
}
private static int utf8Length(int codePoint) {
if (codePoint <= UTF8_ONE_BYTE_LIMIT) {
return 1;
}
if (codePoint <= UTF8_TWO_BYTE_LIMIT) {
return 2;
}
return codePoint <= 0xffff ? 3 : 4;
}
}
/** Canonical codec for the built-in closed parameter records. */
private static final class BuiltInCodec implements AlgorithmIdentityCodec {
@Override
public String id() {
return "zeroecho.builtin";
}
@Override
public byte[] encode(Parameters parameters) {
if (!isKnown(parameters)) {
throw new IllegalArgumentException("Built-in codec cannot encode extension parameters");
}
return parameters.canonicalForm().getBytes(StandardCharsets.US_ASCII);
}
@Override
public Parameters decode(byte[] encoded) {
String value = new String(encoded.clone(), StandardCharsets.US_ASCII);
if (!java.util.Arrays.equals(encoded, value.getBytes(StandardCharsets.US_ASCII))) {
throw new IllegalArgumentException("Built-in parameters are not ASCII");
}
if (NO_PARAMETERS.equals(value)) {
return NoParameters.INSTANCE;
}
if (value.startsWith("digest=")) {
return new DigestParameters(parseShort(value.substring(7), Kind.DIGEST));
}
if (value.startsWith("set=")) {
return new NamedParameters(parseFamily(value.substring(4)));
}
if (value.startsWith("hash=")) {
String[] components = value.split(",");
if (components.length != RSA_PSS_COMPONENT_COUNT) {
throw new IllegalArgumentException("Malformed RSA-PSS parameters");
}
AlgorithmIdentity hash = parseShort(requirePair(components[0], "hash"), Kind.DIGEST);
AlgorithmIdentity mask = parseShort(requirePair(components[1], "mask"), Kind.MASK_GENERATION);
AlgorithmIdentity maskHash = parseShort(requirePair(components[2], "maskhash"), Kind.DIGEST);
int salt = parseInteger(requirePair(components[3], "salt"));
int trailer = parseInteger(requirePair(components[4], "trailer"));
return new RsaPssParameters(hash, mask, maskHash, salt, trailer);
}
throw new IllegalArgumentException("Unknown built-in algorithm parameters");
}
private static boolean isKnown(Parameters parameters) {
return parameters instanceof NoParameters || parameters instanceof DigestParameters
|| parameters instanceof RsaPssParameters || parameters instanceof NamedParameters;
}
private static AlgorithmIdentity parseShort(String value, Kind kind) {
int first = value.indexOf('.');
int second = value.indexOf('.', first + 1);
if (first <= 0 || second <= first + 1) {
throw new IllegalArgumentException("Malformed nested algorithm identity");
}
Family family = new Family(value.substring(0, first), value.substring(first + 1, second));
String parameters = value.substring(second + 1);
return new AlgorithmIdentity(kind, family, decodeStatic(parameters));
}
private static Parameters decodeStatic(String value) {
return new BuiltInCodec().decode(value.getBytes(StandardCharsets.US_ASCII));
}
private static Family parseFamily(String value) {
int separator = value.indexOf('.');
if (separator <= 0 || separator == value.length() - 1) {
throw new IllegalArgumentException("Malformed named parameter set");
}
return new Family(value.substring(0, separator), value.substring(separator + 1));
}
private static String requirePair(String value, String name) {
String prefix = name + "=";
if (!value.startsWith(prefix)) {
throw new IllegalArgumentException("Malformed RSA-PSS parameters");
}
return value.substring(prefix.length());
}
private static int parseInteger(String value) {
try {
return Integer.parseInt(value);
} catch (NumberFormatException exception) {
throw new IllegalArgumentException("Malformed integer algorithm parameter", exception);
}
}
}
}

View File

@@ -0,0 +1,184 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.core.spec;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/**
* Deeply immutable snapshot of installed algorithm identities.
*
* <p>
* Built-in identities reserve the {@code zeroecho} namespace. Trusted installed
* code may contribute identities under another namespace. Duplicate canonical
* representations fail closed; registration order never supplies precedence.
* Administrative configuration is not a registration mechanism.
* </p>
*/
public final class AlgorithmIdentityCatalog {
/** Namespace reserved for immutable built-in identities. */
public static final String BUILTIN_NAMESPACE = "zeroecho";
private final Map<String, AlgorithmIdentity> identities;
private AlgorithmIdentityCatalog(Map<String, AlgorithmIdentity> identities) {
this.identities = Map.copyOf(identities);
}
/**
* Creates the immutable built-in catalog.
*
* @param identities built-in identities
* @return immutable catalog
* @throws IllegalArgumentException if an identity is outside the reserved
* namespace or collides
*/
public static AlgorithmIdentityCatalog builtIn(Collection<AlgorithmIdentity> identities) {
return create(identities, true);
}
/**
* Creates a trusted extension catalog.
*
* @param identities extension identities
* @return immutable catalog
* @throws IllegalArgumentException if an extension uses the built-in namespace
* or contains a collision
*/
public static AlgorithmIdentityCatalog extension(Collection<AlgorithmIdentity> identities) {
return create(identities, false);
}
/**
* Returns a new additive snapshot containing this catalog and all extensions.
*
* @param extensions trusted installed extension catalogs
* @return immutable merged snapshot
* @throws IllegalArgumentException if any canonical identity collides
*/
public AlgorithmIdentityCatalog merge(List<AlgorithmIdentityCatalog> extensions) {
Objects.requireNonNull(extensions, "extensions");
Map<String, AlgorithmIdentity> merged = new LinkedHashMap<>(identities);
for (AlgorithmIdentityCatalog extension : extensions) {
Objects.requireNonNull(extension, "extension");
for (AlgorithmIdentity identity : extension.identities.values()) {
if (BUILTIN_NAMESPACE.equals(identity.family().namespace())
&& identities.values().stream().noneMatch(builtIn -> builtIn.kind() == identity.kind()
&& builtIn.family().equals(identity.family()))) {
throw new IllegalArgumentException("Extension identity uses unknown reserved family");
}
AlgorithmIdentity previous = merged.putIfAbsent(identity.canonicalForm(), identity);
if (previous != null && !previous.equals(identity)) {
throw new IllegalArgumentException("Algorithm identity collision");
}
if (previous != null) {
throw new IllegalArgumentException("Duplicate algorithm identity");
}
}
}
return new AlgorithmIdentityCatalog(merged);
}
/**
* Adds exact parameter combinations to this authority snapshot.
*
* <p>
* A trusted extension may add a tuple within an existing reserved family, but
* cannot introduce a new family under the built-in namespace.
* </p>
*
* @param additions exact additive identities
* @return new immutable catalog
*/
public AlgorithmIdentityCatalog add(Collection<AlgorithmIdentity> additions) {
Objects.requireNonNull(additions, "additions");
Map<String, AlgorithmIdentity> merged = new LinkedHashMap<>(identities);
for (AlgorithmIdentity identity : additions) {
Objects.requireNonNull(identity, "identity");
if (BUILTIN_NAMESPACE.equals(identity.family().namespace())
&& identities.values().stream().noneMatch(builtIn -> builtIn.kind() == identity.kind()
&& builtIn.family().equals(identity.family()))) {
throw new IllegalArgumentException("Extension identity uses unknown reserved family");
}
if (merged.putIfAbsent(identity.canonicalForm(), identity) != null) {
throw new IllegalArgumentException("Duplicate algorithm identity");
}
}
return new AlgorithmIdentityCatalog(merged);
}
/**
* Resolves a canonical identity without provider alias fallback.
*
* @param canonicalForm complete canonical representation
* @return registered exact identity, or empty when unknown
*/
public Optional<AlgorithmIdentity> resolve(String canonicalForm) {
Objects.requireNonNull(canonicalForm, "canonicalForm");
return Optional.ofNullable(identities.get(canonicalForm));
}
/**
* Returns identities in deterministic canonical order.
*
* @return immutable identity list
*/
public List<AlgorithmIdentity> identities() {
return identities.values().stream().sorted((left, right) -> left.canonicalForm()
.compareTo(right.canonicalForm())).toList();
}
private static AlgorithmIdentityCatalog create(Collection<AlgorithmIdentity> source, boolean builtIn) {
Objects.requireNonNull(source, "identities");
Map<String, AlgorithmIdentity> result = new LinkedHashMap<>();
for (AlgorithmIdentity identity : source) {
Objects.requireNonNull(identity, "identity");
boolean reserved = BUILTIN_NAMESPACE.equals(identity.family().namespace());
if (builtIn != reserved) {
throw new IllegalArgumentException(
builtIn ? "Built-in identity must use reserved namespace"
: "Extension identity must not use reserved namespace");
}
if (result.putIfAbsent(identity.canonicalForm(), identity) != null) {
throw new IllegalArgumentException("Duplicate algorithm identity");
}
}
return new AlgorithmIdentityCatalog(result);
}
}

View File

@@ -0,0 +1,70 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.core.spec;
/**
* Trusted-code canonical codec for one typed algorithm-parameter schema.
*
* <p>
* Encoding takes an immediate immutable snapshot. Decoding must return fresh
* values or intrinsically immutable values and must reject malformed,
* incomplete, or contradictory input. Codec identifiers are stable semantic
* identity and cannot be redefined by catalog ordering or configuration.
* </p>
*/
public interface AlgorithmIdentityCodec {
/**
* Returns the stable namespaced codec identifier.
*
* @return canonical codec identifier
*/
String id();
/**
* Encodes complete typed parameters.
*
* @param parameters typed parameters
* @return independently owned canonical bytes
*/
byte[] encode(AlgorithmIdentity.Parameters parameters);
/**
* Decodes complete canonical bytes.
*
* @param encoded canonical bytes
* @return fresh validated typed parameters
*/
AlgorithmIdentity.Parameters decode(byte[] encoded);
}

View File

@@ -0,0 +1,79 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.core.spec;
import java.util.Objects;
/**
* Immutable composition of a signature scheme and a compatible public-key
* identity.
*
* <p>
* The suite keeps signature and key semantics separate. In particular, an
* ECDSA signature identity contains its digest while the public-key identity
* contains the named curve. Compatibility is evaluated by capability and
* policy, not inferred from an X.509 signature OID.
* </p>
*
* @param signature exact signature-scheme identity
* @param publicKey exact public-key identity
*/
public record AlgorithmSuite(AlgorithmIdentity signature, AlgorithmIdentity publicKey) {
/**
* Creates a validated signature suite.
*
* @throws NullPointerException if an identity is {@code null}
* @throws IllegalArgumentException if an identity has the wrong semantic kind
*/
public AlgorithmSuite {
Objects.requireNonNull(signature, "signature");
Objects.requireNonNull(publicKey, "publicKey");
if (signature.kind() != AlgorithmIdentity.Kind.SIGNATURE) {
throw new IllegalArgumentException("signature must have SIGNATURE kind");
}
if (publicKey.kind() != AlgorithmIdentity.Kind.PUBLIC_KEY) {
throw new IllegalArgumentException("publicKey must have PUBLIC_KEY kind");
}
}
/**
* Returns the deterministic suite representation.
*
* @return signature and key canonical identities in a length-independent form
*/
public String canonicalForm() {
return "zesuite:1:" + signature.canonicalForm() + "|" + publicKey.canonicalForm();
}
}

View File

@@ -0,0 +1,141 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.core.spi;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.ServiceLoader;
import java.util.Set;
import zeroecho.core.spec.AlgorithmIdentity;
import zeroecho.core.spec.AlgorithmSuite;
/**
* Immutable deterministic snapshot of installed execution capabilities.
*
* <p>
* Multiple implementations may support one semantic identity. Callers must
* select an implementation explicitly when more than one remains after policy;
* classpath or provider order never supplies precedence.
* </p>
*/
public final class AlgorithmExecutionCapabilities {
private final List<AlgorithmExecutionCapability> capabilities;
/**
* Creates a validated immutable snapshot.
*
* @param capabilities installed trusted-code capabilities
* @throws IllegalArgumentException if implementation identifiers collide or a
* fingerprint is blank
*/
public AlgorithmExecutionCapabilities(List<AlgorithmExecutionCapability> capabilities) {
Objects.requireNonNull(capabilities, "capabilities");
List<AlgorithmExecutionCapability> copy = new ArrayList<>(capabilities);
copy.sort(Comparator.comparing(AlgorithmExecutionCapability::implementationId));
Set<String> identifiers = new HashSet<>();
for (AlgorithmExecutionCapability capability : copy) {
Objects.requireNonNull(capability, "capability");
if (capability.implementationId() == null || capability.implementationId().isBlank()
|| !identifiers.add(capability.implementationId())) {
throw new IllegalArgumentException("Execution capability identifier collision");
}
if (capability.domainFingerprint() == null || capability.domainFingerprint().isBlank()) {
throw new IllegalArgumentException("Execution capability fingerprint must not be blank");
}
}
this.capabilities = List.copyOf(copy);
}
/**
* Discovers installed providers using the existing ServiceLoader convention.
*
* @return deterministic immutable capability snapshot
*/
public static AlgorithmExecutionCapabilities installed() {
List<AlgorithmExecutionCapabilityProvider> providers = new ArrayList<>();
ServiceLoader.load(AlgorithmExecutionCapabilityProvider.class).forEach(providers::add);
return fromProviders(providers);
}
/**
* Builds one deterministic snapshot from already selected trusted providers.
*
* @param providers provider instances belonging to the runtime graph
* @return immutable capability snapshot
*/
public static AlgorithmExecutionCapabilities fromProviders(
Collection<? extends AlgorithmExecutionCapabilityProvider> providers) {
Objects.requireNonNull(providers, "providers");
List<AlgorithmExecutionCapability> discovered = new ArrayList<>();
List<AlgorithmExecutionCapabilityProvider> ordered = new ArrayList<>(providers);
ordered.sort(Comparator.comparing(provider -> provider.getClass().getName()));
for (AlgorithmExecutionCapabilityProvider provider : ordered) {
List<AlgorithmExecutionCapability> contribution = Objects.requireNonNull(provider.capabilities(),
"provider capabilities");
discovered.addAll(contribution);
}
return new AlgorithmExecutionCapabilities(discovered);
}
/**
* Finds all installed implementations supporting an exact tuple.
*
* @param identity exact operation identity
* @param suite complete suite
* @param direction operation direction
* @return deterministic immutable matching list
*/
public List<AlgorithmExecutionCapability> supporting(AlgorithmIdentity identity, AlgorithmSuite suite,
AlgorithmExecutionCapability.Direction direction) {
Objects.requireNonNull(identity, "identity");
Objects.requireNonNull(suite, "suite");
Objects.requireNonNull(direction, "direction");
return capabilities.stream().filter(capability -> capability.supports(identity, suite, direction)).toList();
}
/**
* Returns the immutable installed snapshot.
*
* @return capabilities sorted by implementation identifier
*/
public List<AlgorithmExecutionCapability> all() {
return capabilities;
}
}

View File

@@ -0,0 +1,84 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.core.spi;
import zeroecho.core.spec.AlgorithmIdentity;
import zeroecho.core.spec.AlgorithmSuite;
/**
* Trusted-code declaration of an installed cryptographic execution domain.
*
* <p>
* A capability describes implementation availability; it never defines
* algorithm identity, X.509 semantics, defaults, or policy. Implementations must
* provide a stable semantic fingerprint for deterministic conflict diagnostics.
* Administrative configuration cannot provide implementation classes.
* </p>
*/
public interface AlgorithmExecutionCapability {
/**
* Supported execution direction.
*/
enum Direction {
/** Signature generation. */
SIGN,
/** Signature verification. */
VERIFY
}
/**
* Returns a stable installed implementation identifier.
*
* @return namespaced provider implementation identifier
*/
String implementationId();
/**
* Returns a deterministic description of the supported typed domain.
*
* @return stable non-secret domain fingerprint
*/
String domainFingerprint();
/**
* Tests whether this implementation supports an exact identity and suite.
*
* @param identity requested exact operation identity
* @param suite complete key and signature suite
* @param direction requested direction
* @return {@code true} only for tuples implemented exactly
*/
boolean supports(AlgorithmIdentity identity, AlgorithmSuite suite, Direction direction);
}

View File

@@ -0,0 +1,56 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.core.spi;
import java.util.List;
/**
* Service-provider contract for trusted installed execution capabilities.
*
* <p>
* Providers are deployment code discovered using the existing ServiceLoader
* convention. Configuration may select an installed capability but cannot name
* or load an implementation class.
* </p>
*/
@FunctionalInterface
public interface AlgorithmExecutionCapabilityProvider {
/**
* Returns an immutable capability contribution.
*
* @return installed capabilities; never {@code null}
*/
List<AlgorithmExecutionCapability> capabilities();
}

View File

@@ -0,0 +1,150 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.core.spec;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import org.junit.jupiter.api.Test;
import zeroecho.core.alg.BootstrapAlgorithmIdentities;
import zeroecho.core.spi.AlgorithmExecutionCapabilities;
import zeroecho.core.spi.AlgorithmExecutionCapability;
/**
* Phase A regression tests for provider-independent identity and capability
* contracts.
*/
public final class AlgorithmIdentityPhaseATest {
@Test
void exactIdentityAlgebraAndCanonicalRoundTrip() {
System.out.println("exactIdentityAlgebraAndCanonicalRoundTrip");
AlgorithmIdentity first = BootstrapAlgorithmIdentities.rsaPss(BootstrapAlgorithmIdentities.SHA384,
BootstrapAlgorithmIdentities.SHA512, 40);
AlgorithmIdentity second = BootstrapAlgorithmIdentities.rsaPss(BootstrapAlgorithmIdentities.SHA384,
BootstrapAlgorithmIdentities.SHA512, 40);
AlgorithmIdentity different = BootstrapAlgorithmIdentities.rsaPss(BootstrapAlgorithmIdentities.SHA384,
BootstrapAlgorithmIdentities.SHA384, 40);
AlgorithmIdentityCatalog extension = AlgorithmIdentityCatalog.extension(List.of(
new AlgorithmIdentity(AlgorithmIdentity.Kind.SIGNATURE,
new AlgorithmIdentity.Family("example", "signature"),
new AlgorithmIdentity.DigestParameters(BootstrapAlgorithmIdentities.SHA384))));
AlgorithmIdentityCatalog merged = BootstrapAlgorithmIdentities.catalog().merge(List.of(extension));
assertEquals(first, second);
assertEquals(first.hashCode(), second.hashCode());
assertNotEquals(first, different);
assertEquals(BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256,
merged.resolve(BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256.canonicalForm()).orElseThrow());
assertFalse(first.canonicalForm().contains("BC"));
assertFalse(first.canonicalForm().contains("Sun"));
System.out.println("...canonical=" + abbreviate(first.canonicalForm()));
System.out.println("...ok");
}
@Test
void roleAndParameterContradictionsFailClosed() {
System.out.println("roleAndParameterContradictionsFailClosed");
assertThrows(IllegalArgumentException.class,
() -> new AlgorithmSuite(BootstrapAlgorithmIdentities.SHA256,
BootstrapAlgorithmIdentities.RSA_PUBLIC_KEY));
assertThrows(IllegalArgumentException.class,
() -> new AlgorithmIdentity.RsaPssParameters(BootstrapAlgorithmIdentities.RSA_PUBLIC_KEY,
BootstrapAlgorithmIdentities.MGF1, BootstrapAlgorithmIdentities.SHA256, 32, 1));
assertThrows(IllegalArgumentException.class,
() -> BootstrapAlgorithmIdentities.rsaPss(BootstrapAlgorithmIdentities.SHA256,
BootstrapAlgorithmIdentities.SHA256, -1));
assertThrows(IllegalArgumentException.class,
() -> AlgorithmIdentityCatalog.extension(List.of(BootstrapAlgorithmIdentities.SHA256)));
assertTrue(BootstrapAlgorithmIdentities.fromCompatibilityAlias("SHA1withRSA").isEmpty());
assertTrue(BootstrapAlgorithmIdentities.fromCompatibilityAlias("provider-specific").isEmpty());
System.out.println("...rejections=6");
System.out.println("...ok");
}
@Test
void parameterizedCapabilityDomainIsProviderMetadataOnly() {
System.out.println("parameterizedCapabilityDomainIsProviderMetadataOnly");
AlgorithmIdentity pss = BootstrapAlgorithmIdentities.rsaPss(BootstrapAlgorithmIdentities.SHA384,
BootstrapAlgorithmIdentities.SHA512, 40);
AlgorithmSuite suite = new AlgorithmSuite(pss, BootstrapAlgorithmIdentities.RSA_PUBLIC_KEY);
AlgorithmExecutionCapability capability = new TestPssCapability();
AlgorithmExecutionCapabilities capabilities = new AlgorithmExecutionCapabilities(List.of(capability));
assertEquals(1,
capabilities.supporting(pss, suite, AlgorithmExecutionCapability.Direction.VERIFY).size());
assertTrue(capabilities.supporting(pss, suite, AlgorithmExecutionCapability.Direction.SIGN).isEmpty());
assertEquals(pss, suite.signature());
System.out.println("...implementation=" + capability.implementationId());
System.out.println("...ok");
}
private static String abbreviate(String value) {
return value.length() <= 30 ? value : value.substring(0, 27) + "...";
}
/**
* Typed test-only parameter domain proving that a central enum is unnecessary.
*/
private static final class TestPssCapability implements AlgorithmExecutionCapability {
@Override
public String implementationId() {
return "test.rsa-pss-verify";
}
@Override
public String domainFingerprint() {
return "rsa-pss|sha384|mgf1-sha512|salt=0..64|verify";
}
@Override
public boolean supports(AlgorithmIdentity identity, AlgorithmSuite suite, Direction direction) {
if (!(identity.parameters() instanceof AlgorithmIdentity.RsaPssParameters parameters)) {
return false;
}
return identity.equals(suite.signature())
&& BootstrapAlgorithmIdentities.RSA_PUBLIC_KEY.equals(suite.publicKey())
&& BootstrapAlgorithmIdentities.SHA384.equals(parameters.hash())
&& BootstrapAlgorithmIdentities.SHA512.equals(parameters.maskHash())
&& parameters.saltLength() <= 64 && direction == Direction.VERIFY;
}
}
}

View File

@@ -33,11 +33,11 @@
******************************************************************************/
package zeroecho.pki.api.ca;
import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.FormatId;
import zeroecho.pki.api.KeyRef;
import zeroecho.pki.api.SubjectRef;
import zeroecho.pki.api.attr.AttributeSet;
import zeroecho.pki.api.content.DurableContentReference;
/**
* Command to import an existing root CA credential into PKI inventory.
@@ -55,7 +55,7 @@ import zeroecho.pki.api.attr.AttributeSet;
* @param attributes universal attributes (may be empty but not null)
*/
public record CaImportCommand(FormatId formatId, SubjectRef subjectRef, String profileId, KeyRef keyRef,
EncodedObject existingCaCredential, AttributeSet attributes) {
DurableContentReference existingCaCredential, AttributeSet attributes) {
/**
* Creates a CA import command.

View File

@@ -0,0 +1,74 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.api.content;
import java.util.Objects;
/**
* Immutable deployment-owned resource policy for streaming PKI operations.
*
* <p>
* ZeroEcho provides no built-in operational values. A deployment explicitly
* supplies every dimension as either unrestricted by deployment or positively
* limited. Checks are incremental and do not alter algorithm, certificate or
* binding semantics.
* </p>
*
* @param acceptedSourceBytes accepted input bytes
* @param generatedObjectBytes generated signed-object bytes
* @param entryCount streamed entry count
* @param stagedContentBytes durable staged-content bytes
* @param temporaryStorageBytes temporary spool bytes
* @param publicationBytes bytes supplied to a publisher
* @param openStagedObjects concurrently open staged objects
*/
public record DeploymentResourcePolicy(ResourceLimit acceptedSourceBytes, ResourceLimit generatedObjectBytes,
ResourceLimit entryCount, ResourceLimit stagedContentBytes, ResourceLimit temporaryStorageBytes,
ResourceLimit publicationBytes, ResourceLimit openStagedObjects) {
/**
* Creates a deployment resource policy.
*
* @throws NullPointerException if a dimension is {@code null}
*/
public DeploymentResourcePolicy {
Objects.requireNonNull(acceptedSourceBytes, "acceptedSourceBytes");
Objects.requireNonNull(generatedObjectBytes, "generatedObjectBytes");
Objects.requireNonNull(entryCount, "entryCount");
Objects.requireNonNull(stagedContentBytes, "stagedContentBytes");
Objects.requireNonNull(temporaryStorageBytes, "temporaryStorageBytes");
Objects.requireNonNull(publicationBytes, "publicationBytes");
Objects.requireNonNull(openStagedObjects, "openStagedObjects");
}
}

View File

@@ -0,0 +1,119 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.api.content;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.orch.SigningSubmissionId;
/**
* Typed immutable identity of a durable business owner of staged content.
*
* <p>
* Owner identities contain no path, content, key material, provider name, or
* implementation class. The staged-content store is the sole authority that
* persists retain and release transitions. Constructing this value does not
* itself retain content.
* </p>
*
* @param category closed durable-owner category
* @param identifier canonical category-specific identifier
*/
public record DurableContentOwner(Category category, String identifier) {
private static final int MAX_IDENTIFIER_BYTES = 512;
/** Creates and validates one typed owner identity. */
public DurableContentOwner {
Objects.requireNonNull(category, "category");
String exact = Objects.requireNonNull(identifier, "identifier");
if (exact.isBlank() || !exact.equals(exact.strip())
|| exact.getBytes(StandardCharsets.UTF_8).length > MAX_IDENTIFIER_BYTES) {
throw new IllegalArgumentException("Durable content owner identifier is not canonical");
}
for (int index = 0; index < exact.length(); index++) {
char value = exact.charAt(index);
if (value < 0x21 || value > 0x7e || value == '/' || value == '\\') {
throw new IllegalArgumentException("Durable content owner identifier is not canonical");
}
}
if (category == Category.SIGNING_OPERATION) {
SigningSubmissionId.parse(new PkiId(exact));
}
}
/**
* Creates the canonical owner for one signing operation.
*
* @param operationId canonical signing submission identifier
* @return signing-operation owner
* @throws IllegalArgumentException if the identifier is not a canonical
* signing submission identifier
*/
public static DurableContentOwner signingOperation(PkiId operationId) {
Objects.requireNonNull(operationId, "operationId");
SigningSubmissionId parsed = SigningSubmissionId.parse(operationId);
return new DurableContentOwner(Category.SIGNING_OPERATION, parsed.id().value());
}
/**
* Creates the canonical owner for one persisted credential record.
*
* @param credentialId canonical credential identifier
* @return credential-record owner
* @throws IllegalArgumentException if the identifier cannot be represented as
* a canonical durable owner identifier
*/
public static DurableContentOwner credentialRecord(PkiId credentialId) {
Objects.requireNonNull(credentialId, "credentialId");
return new DurableContentOwner(Category.CREDENTIAL_RECORD, credentialId.value());
}
/** Returns a deterministic persistence token containing no path or payload. */
public String canonicalForm() {
return category.name() + ":" + identifier;
}
/** Closed durable-owner categories reserved by the Phase A lifecycle. */
public enum Category {
/** Pending or recoverable signing operation. */
SIGNING_OPERATION,
/** Persisted credential record; integration is deferred. */
CREDENTIAL_RECORD,
/** Persisted status-object record; integration is deferred. */
STATUS_OBJECT_RECORD
}
}

View File

@@ -0,0 +1,117 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.api.content;
import zeroecho.pki.api.Encoding;
/**
* Stable, payload-free reference to immutable staged content.
*
* <p>
* The reference is durable provenance rather than a live file handle. It carries
* no physical path, payload, key material or runtime authorization token. It may
* be resolved only through the staged-content store whose immutable identifier
* matches {@link #storeId()}.
* </p>
*
* A durable content reference is authoritative only when issued and validated
* by its owning staged-content store. This read-only interface deliberately has
* no public construction factory: callers cannot turn arbitrary metadata into
* an authoritative reference. Persistence decoders must restore references
* through the owning store, which rejects foreign stores and metadata mismatch.
* Sealing and reference issuance do not imply durable business-object ownership.
*
* <p>
* Implementations are immutable and expose no physical path, temporary name,
* payload, key material, open handle, or mutable lifecycle state. Earlier
* pre-release persistence forms are not accepted or migrated. Phase B key
* isolation is outside this contract.
* </p>
*/
public interface DurableContentReference {
/**
* Returns the canonical logical identity of the owning store.
*
* @return path-independent store identity
*/
String storeId();
/**
* Returns the opaque canonical content identity issued by the store.
*
* @return path-independent content identity
*/
String contentId();
/**
* Returns the semantic transport encoding.
*
* @return content encoding
*/
Encoding encoding();
/**
* Returns the exact checked byte length.
*
* @return non-negative content length
*/
long length();
/**
* Returns the canonical lowercase SHA-256 integrity value.
*
* @return integrity value; callers must not treat it as issuance authority
*/
String sha256();
/**
* Returns the immutable content purpose classification.
*
* @return lifecycle classification; not durable ownership state
*/
Lifecycle lifecycle();
/**
* Durable ownership classification.
*/
enum Lifecycle {
/** Content retained while a durable operation is pending. */
OPERATION,
/** Content owned by an immutable persisted PKI object. */
PERSISTED,
/** Content eligible for release after its immediate operation. */
TEMPORARY
}
}

View File

@@ -0,0 +1,108 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.api.content;
/**
* Explicit deployment-owned resource constraint.
*
* <p>
* This type distinguishes absence of a deployment limit from a positive finite
* limit without nullable numbers or sentinel values. It never changes PKI,
* algorithm or X.509 binding semantics.
* </p>
*/
public sealed interface ResourceLimit permits ResourceLimit.UnrestrictedByDeployment, ResourceLimit.LimitedTo {
/** Smallest valid observed aggregate value. */
long EMPTY = 0L;
/**
* Checks an incrementally observed non-negative value.
*
* @param observed observed aggregate count or byte length
* @throws IllegalArgumentException if {@code observed} is negative
* @throws ResourceLimitExceededException if the configured limit is exceeded
*/
void requireAllows(long observed);
/**
* Explicit absence of a deployment-owned limit.
*/
record UnrestrictedByDeployment() implements ResourceLimit {
@Override
public void requireAllows(long observed) {
if (observed < EMPTY) {
throw new IllegalArgumentException("Observed resource value must not be negative");
}
}
}
/**
* Positive finite deployment-owned limit.
*
* @param maximum inclusive maximum
*/
record LimitedTo(long maximum) implements ResourceLimit {
/**
* Creates a finite limit.
*
* @throws IllegalArgumentException if {@code maximum} is not positive
*/
public LimitedTo {
if (maximum <= EMPTY) {
throw new IllegalArgumentException("Deployment resource limit must be positive");
}
}
@Override
public void requireAllows(long observed) {
if (observed < EMPTY) {
throw new IllegalArgumentException("Observed resource value must not be negative");
}
if (observed > maximum) {
throw new ResourceLimitExceededException();
}
}
}
/**
* Stable non-sensitive failure for deployment resource rejection.
*/
final class ResourceLimitExceededException extends IllegalStateException {
private static final long serialVersionUID = 5780535874172354935L;
private ResourceLimitExceededException() {
super("Deployment resource limit exceeded: code=DEPLOYMENT_RESOURCE_LIMIT_EXCEEDED");
}
}
}

View File

@@ -33,13 +33,13 @@
******************************************************************************/
package zeroecho.pki.api.credential;
import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.FormatId;
import zeroecho.pki.api.IssuerRef;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.SubjectRef;
import zeroecho.pki.api.Validity;
import zeroecho.pki.api.attr.AttributeSet;
import zeroecho.pki.api.content.DurableContentReference;
/**
* Issued credential with mandatory core metadata and universal attributes.
@@ -70,12 +70,12 @@ import zeroecho.pki.api.attr.AttributeSet;
* current revocation state and evaluation time are
* external runtime inputs. Security-sensitive callers
* must use {@link EffectiveCredentialStatusResolver}.
* @param encoded encoded credential bytes
* @param content immutable store-owned credential content
* @param attributes universal attribute set
*/
public record Credential(PkiId credentialId, FormatId formatId, IssuerRef issuerRef, SubjectRef subjectRef,
Validity validity, String serialOrUniqueId, PkiId publicKeyId, CredentialProfileBinding profileBinding,
CredentialStatus status, EncodedObject encoded, AttributeSet attributes) {
CredentialStatus status, DurableContentReference content, AttributeSet attributes) {
/**
* Creates a credential record.
@@ -110,8 +110,8 @@ public record Credential(PkiId credentialId, FormatId formatId, IssuerRef issuer
if (status == null) {
throw new IllegalArgumentException("status must not be null");
}
if (encoded == null) {
throw new IllegalArgumentException("encoded must not be null");
if (content == null) {
throw new IllegalArgumentException("content must not be null");
}
if (attributes == null) {
throw new IllegalArgumentException("attributes must not be null");

View File

@@ -35,7 +35,7 @@ package zeroecho.pki.api.credential;
import java.util.List;
import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.content.DurableContentReference;
/**
* Bundle of a primary credential and supporting objects.
@@ -49,7 +49,7 @@ import zeroecho.pki.api.EncodedObject;
* @param credential primary credential
* @param supportingObjects supporting artifacts (framework-defined ordering)
*/
public record CredentialBundle(Credential credential, List<EncodedObject> supportingObjects) {
public record CredentialBundle(Credential credential, List<DurableContentReference> supportingObjects) {
/**
* Creates a bundle.

View File

@@ -36,10 +36,10 @@ package zeroecho.pki.api.status;
import java.time.Instant;
import java.util.Optional;
import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.FormatId;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.attr.AttributeSet;
import zeroecho.pki.api.content.DurableContentReference;
/**
* Generated status object used for revocation distribution.
@@ -57,12 +57,12 @@ import zeroecho.pki.api.attr.AttributeSet;
* @param type status object type
* @param thisUpdate time of issuance/publication baseline
* @param nextUpdate optional next update timestamp
* @param encoded encoded payload
* @param content immutable store-owned encoded content reference
* @param attributes universal attributes describing the object (must not
* contain secrets)
*/
public record StatusObject(PkiId statusObjectId, FormatId formatId, PkiId issuerCaId, StatusObjectType type,
Instant thisUpdate, Optional<Instant> nextUpdate, EncodedObject encoded, AttributeSet attributes) {
Instant thisUpdate, Optional<Instant> nextUpdate, DurableContentReference content, AttributeSet attributes) {
/**
* Creates a status object.
@@ -89,8 +89,8 @@ public record StatusObject(PkiId statusObjectId, FormatId formatId, PkiId issuer
if (nextUpdate == null) {
throw new IllegalArgumentException("nextUpdate must not be null");
}
if (encoded == null) {
throw new IllegalArgumentException("encoded must not be null");
if (content == null) {
throw new IllegalArgumentException("content must not be null");
}
if (attributes == null) {
throw new IllegalArgumentException("attributes must not be null");

View File

@@ -80,12 +80,12 @@ final class CaCertificateProfileValidator {
ActiveCertificateProfile activeProfile, CertificateProfileKind expectedKind, FormatId formatId,
PkiId issuerCaId, PkiId subjectCaId, SubjectRef requestedSubject, EncodedObject exactPublicKey,
Optional<Validity> requestedValidity, Instant evaluationTime, Optional<Instant> issuerNotAfter,
BigInteger serial) {
BigInteger serial, zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot authority) {
CertificateProfileDefinition definition = activeProfile.definition();
requireProfileShape(definition, expectedKind, formatId);
List<SubjectRdn> approvedSubject = validateSubject(requestedSubject, definition);
return validateApprovedSubject(operation, activeProfile, expectedKind, formatId, issuerCaId, subjectCaId,
approvedSubject, exactPublicKey, requestedValidity, evaluationTime, issuerNotAfter, serial);
approvedSubject, exactPublicKey, requestedValidity, evaluationTime, issuerNotAfter, serial, authority);
}
/*
@@ -97,12 +97,13 @@ final class CaCertificateProfileValidator {
ValidatedCaCertificateRequest.Operation operation, ActiveCertificateProfile activeProfile,
CertificateProfileKind expectedKind, FormatId formatId, PkiId issuerCaId, PkiId subjectCaId,
List<SubjectRdn> approvedSubject, EncodedObject exactPublicKey, Optional<Validity> requestedValidity,
Instant evaluationTime, Optional<Instant> issuerNotAfter, BigInteger serial) {
Instant evaluationTime, Optional<Instant> issuerNotAfter, BigInteger serial,
zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot authority) {
CertificateProfileDefinition definition = activeProfile.definition();
requireProfileShape(definition, expectedKind, formatId);
List<SubjectRdn> subjectSnapshot = requireApprovedSubject(approvedSubject);
CertificateProfileValidator.requireSubjectKeyAllowed(exactPublicKey,
definition.caPolicy().allowedSubjectKeyAlgorithmIds());
definition.caPolicy().allowedSubjectKeyAlgorithmIds(), authority);
Validity validity = approvedValidity(requestedValidity, definition, evaluationTime, issuerNotAfter,
operation == ValidatedCaCertificateRequest.Operation.IMPORT_ROOT);
SubjectRef canonicalSubject = new SubjectRef(BcX509ProfileSupport.subject(subjectSnapshot).toString());

View File

@@ -33,7 +33,6 @@
******************************************************************************/
package zeroecho.pki.impl.core;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.security.MessageDigest;
@@ -49,24 +48,30 @@ import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.ContentVerifier;
import org.bouncycastle.operator.DefaultSignatureAlgorithmIdentifierFinder;
import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder;
import zeroecho.core.spec.AlgorithmIdentity;
import zeroecho.core.io.ImmutableByteContent;
import zeroecho.core.spi.AlgorithmExecutionCapability;
import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.FormatId;
import zeroecho.pki.api.KeyRef;
import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.content.DurableContentReference;
import zeroecho.pki.api.audit.AccessContext;
import zeroecho.pki.api.audit.AuditEvent;
import zeroecho.pki.api.audit.Principal;
import zeroecho.pki.api.audit.Purpose;
import zeroecho.pki.impl.core.async.PkiSigningBus;
import zeroecho.pki.impl.framework.x509.X509AlgorithmRole;
import zeroecho.pki.impl.framework.x509.X509ExecutionPlan;
import zeroecho.pki.impl.framework.x509.bc.BcX509VerificationExecutor;
import zeroecho.pki.impl.framework.x509.bc.BcX509AlgorithmAdapter;
import zeroecho.pki.spi.audit.AuditSink;
import zeroecho.pki.util.async.AsyncState;
import zeroecho.pki.util.async.AsyncStatus;
import zeroecho.pki.spi.store.ContentSink;
/**
* Internal fail-closed proof gate for CA signing keys.
@@ -85,20 +90,27 @@ final class CaProofGate {
private final PublicKeyInfoResolver publicKeyResolver;
private final PkiSigningBus signingBus;
private final AuditSink auditSink;
private final String signatureAlgorithmId;
private final AlgorithmIdentity signatureIdentity;
private final Duration signingTtl;
/* default */ CaProofGate(PublicKeyInfoResolver publicKeyResolver, PkiSigningBus signingBus, AuditSink auditSink,
String signatureAlgorithmId, Duration signingTtl) {
this(publicKeyResolver, signingBus, auditSink,
signingBus.authority().resolveIdentity(Objects.requireNonNull(signatureAlgorithmId,
"signatureAlgorithmId")), signingTtl);
}
/* default */ CaProofGate(PublicKeyInfoResolver publicKeyResolver, PkiSigningBus signingBus, AuditSink auditSink,
AlgorithmIdentity signatureIdentity, Duration signingTtl) {
this.publicKeyResolver = Objects.requireNonNull(publicKeyResolver, "publicKeyResolver");
this.signingBus = Objects.requireNonNull(signingBus, "signingBus");
this.auditSink = Objects.requireNonNull(auditSink, "auditSink");
this.signatureAlgorithmId = Objects.requireNonNull(signatureAlgorithmId, "signatureAlgorithmId");
this.signatureIdentity = Objects.requireNonNull(signatureIdentity, "signatureIdentity");
this.signingTtl = Objects.requireNonNull(signingTtl, "signingTtl");
}
/* default */ ContentSigner signer(ManagedKeyProof proof) {
return new BusBackedContentSigner(signingBus, proof.keyRef(), signatureAlgorithmId, signingTtl);
return new BusBackedContentSigner(signingBus, proof.keyRef(), signatureIdentity, signingTtl);
}
/* default */ SubjectPublicKeyInfo parseRootSpki(EncodedObject spki, FormatId formatId) {
@@ -115,8 +127,24 @@ final class CaProofGate {
}
try {
byte[] embeddedSpki = certificate.getSubjectPublicKeyInfo().getEncoded();
return MessageDigest.isEqual(expectedSpki.bytes(), embeddedSpki) && certificate.isSignatureValid(
new JcaContentVerifierProviderBuilder().build(certificate.getSubjectPublicKeyInfo()));
BcX509AlgorithmAdapter adapter = new BcX509AlgorithmAdapter(signingBus.authority().bindings());
AlgorithmIdentity outer = adapter.decode(certificate.getSignatureAlgorithm(),
X509AlgorithmRole.SIGNATURE_ALGORITHM);
AlgorithmIdentity inner = adapter.decode(
certificate.toASN1Structure().getTBSCertificate().getSignature(),
X509AlgorithmRole.SIGNATURE_ALGORITHM);
AlgorithmIdentity key = adapter.decode(certificate.getSubjectPublicKeyInfo().getAlgorithm(),
X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM);
X509ExecutionPlan<BcX509VerificationExecutor> plan = signingBus.authority().plan(outer, key,
AlgorithmExecutionCapability.Direction.VERIFY,
Optional.of(BcX509VerificationExecutor.IMPLEMENTATION_ID), "root-proof",
BcX509VerificationExecutor.class);
BcX509VerificationExecutor executor = plan.executor();
boolean verified = executor.verify(signingBus.authority(), plan, certificate.getSubjectPublicKeyInfo(),
certificate.getSignatureAlgorithm(),
new ImmutableByteContent(certificate.toASN1Structure().getTBSCertificate().getEncoded()),
certificate.getSignature());
return MessageDigest.isEqual(expectedSpki.bytes(), embeddedSpki) && verified && outer.equals(inner);
} catch (Exception ex) {
return false;
}
@@ -174,7 +202,7 @@ final class CaProofGate {
}
private byte[] signManagedKeyChallenge(KeyRef keyRef, byte[] challenge) {
ContentSigner contentSigner = new BusBackedContentSigner(signingBus, keyRef, signatureAlgorithmId, signingTtl);
ContentSigner contentSigner = new BusBackedContentSigner(signingBus, keyRef, signatureIdentity, signingTtl);
try {
contentSigner.getOutputStream().write(challenge);
} catch (IOException ex) {
@@ -186,10 +214,17 @@ final class CaProofGate {
private boolean verifyChallenge(byte[] spkiDer, byte[] challenge, byte[] signature) {
try {
SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(spkiDer);
ContentVerifier verifier = new JcaContentVerifierProviderBuilder().build(spki)
.get(new DefaultSignatureAlgorithmIdentifierFinder().find(signatureAlgorithmId));
verifier.getOutputStream().write(challenge);
return verifier.verify(signature);
BcX509AlgorithmAdapter adapter = new BcX509AlgorithmAdapter(signingBus.authority().bindings());
AlgorithmIdentity keyIdentity = adapter.decode(spki.getAlgorithm(),
X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM);
X509ExecutionPlan<BcX509VerificationExecutor> plan = signingBus.authority().plan(signatureIdentity,
keyIdentity, AlgorithmExecutionCapability.Direction.VERIFY,
Optional.of(BcX509VerificationExecutor.IMPLEMENTATION_ID), "managed-key-proof",
BcX509VerificationExecutor.class);
BcX509VerificationExecutor executor = plan.executor();
return executor.verify(signingBus.authority(), plan, spki,
adapter.encode(signatureIdentity, X509AlgorithmRole.SIGNATURE_ALGORITHM),
new ImmutableByteContent(challenge), signature);
} catch (Exception ex) {
return false;
}
@@ -231,21 +266,30 @@ final class CaProofGate {
private final PkiSigningBus bus;
private final KeyRef keyRef;
private final String algorithmId;
private final AlgorithmIdentity algorithmIdentity;
private final Duration ttl;
private final ByteArrayOutputStream output;
private final ContentSink sink;
private final OutputStream output;
private BusBackedContentSigner(PkiSigningBus bus, KeyRef keyRef, String algorithmId, Duration ttl) {
private BusBackedContentSigner(PkiSigningBus bus, KeyRef keyRef, AlgorithmIdentity algorithmIdentity,
Duration ttl) {
this.bus = bus;
this.keyRef = keyRef;
this.algorithmId = algorithmId;
this.algorithmIdentity = algorithmIdentity;
this.ttl = ttl;
this.output = new ByteArrayOutputStream();
this.sink = bus.beginSigningContent(Encoding.BINARY);
try {
this.output = sink.outputStream();
} catch (IOException exception) {
closeSinkPreserving(exception);
throw new PkiException("Signing content staging failed: code=SPOOL_STORAGE_FAILED", exception);
}
}
@Override
public AlgorithmIdentifier getAlgorithmIdentifier() {
return new DefaultSignatureAlgorithmIdentifierFinder().find(algorithmId);
return new BcX509AlgorithmAdapter(bus.authority().bindings()).encode(algorithmIdentity,
X509AlgorithmRole.SIGNATURE_ALGORITHM);
}
@Override
@@ -255,24 +299,40 @@ final class CaProofGate {
@Override
public byte[] getSignature() {
byte[] tbs = output.toByteArray();
DurableContentReference content;
try {
Principal owner = new Principal("SYSTEM", "pki");
PkiId opId = bus.newSubmissionId();
EncodedObject payload = new EncodedObject(Encoding.BINARY, tbs);
AccessContext accessContext = new AccessContext(owner, new Purpose("X509_SIGN"), Optional.empty(),
Optional.empty());
PkiSigningBus.SignContinuation continuation = new PkiSigningBus.SignContinuation(accessContext,
algorithmId, payload, keyRef, Encoding.BINARY, Optional.empty());
try {
bus.submitSign(opId, owner, keyRef, algorithmId, payload, ttl, Optional.of(continuation.encode()));
} catch (RuntimeException failure) { // NOPMD - delete state if submission partially persisted it
deletePreservingFailure(opId);
throw failure;
}
return awaitSignature(opId);
output.close();
content = sink.complete();
} catch (IOException exception) {
closeSinkPreserving(exception);
throw new PkiException("Signing content staging failed: code=SPOOL_STORAGE_FAILED", exception);
}
Principal owner = new Principal("SYSTEM", "pki");
PkiId opId = bus.newSubmissionId();
AccessContext accessContext = new AccessContext(owner, new Purpose("X509_SIGN"), Optional.empty(),
Optional.empty());
String canonicalIdentity = algorithmIdentity.canonicalForm();
PkiSigningBus.SignContinuation continuation = new PkiSigningBus.SignContinuation(accessContext,
canonicalIdentity, content, keyRef, Encoding.BINARY, Optional.empty());
boolean submitted = false;
try {
bus.submitSign(opId, owner, keyRef, canonicalIdentity, content, ttl,
Optional.of(continuation.encode()));
submitted = true;
} finally {
Arrays.fill(tbs, (byte) 0);
if (!submitted) {
deletePreservingFailure(opId);
bus.releaseContent(content);
}
}
return awaitSignature(opId);
}
private void closeSinkPreserving(IOException primaryFailure) {
try {
sink.close();
} catch (IOException cleanupFailure) {
primaryFailure.addSuppressed(cleanupFailure);
}
}

View File

@@ -49,12 +49,7 @@ import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
import org.bouncycastle.asn1.DERNull;
import org.bouncycastle.asn1.edec.EdECObjectIdentifiers;
import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers;
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
import org.bouncycastle.asn1.x9.X9ObjectIdentifiers;
import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.PkiException;
@@ -74,6 +69,11 @@ import zeroecho.pki.api.request.ParsedCertificationRequest;
import zeroecho.pki.api.request.SubjectAlternativeName;
import zeroecho.pki.api.request.SubjectRdn;
import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes;
import zeroecho.pki.impl.framework.x509.bc.BcX509AlgorithmAdapter;
import zeroecho.pki.impl.framework.x509.X509AlgorithmRole;
import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot;
import zeroecho.core.alg.BootstrapAlgorithmIdentities;
import zeroecho.core.spec.AlgorithmIdentity;
import zeroecho.pki.impl.framework.x509.bc.BcX509ProfileSupport;
/**
@@ -83,18 +83,20 @@ import zeroecho.pki.impl.framework.x509.bc.BcX509ProfileSupport;
@SuppressWarnings("PMD.CyclomaticComplexity")
final class CertificateProfileValidator {
private static final String EC_FAMILY = "ec";
private CertificateProfileValidator() {
}
/* package */ static ValidatedCertificateRequest validate(VerifiedIssuanceCandidate candidate,
CertificateProfile profile, CertificateProfileRef profileReference, Credential issuerCredential,
Instant evaluationTime) {
Instant evaluationTime, X509AuthoritySnapshot authority) {
ParsedCertificationRequest request = candidate.request();
LeafCertificatePolicy policy = profile.leafPolicy();
requireCanonicalRequestAttributes(request);
List<SubjectRdn> approvedSubject = validateSubject(request, profile);
List<SubjectAlternativeName> approvedSans = validateSans(request, policy, approvedSubject.isEmpty());
requireSubjectKeyAllowed(candidate.exactPublicKey(), policy.allowedSubjectKeyAlgorithmIds());
requireSubjectKeyAllowed(candidate.exactPublicKey(), policy.allowedSubjectKeyAlgorithmIds(), authority);
Validity validity = approvedValidity(candidate, request, profile, issuerCredential, evaluationTime);
boolean sanCritical = approvedSubject.isEmpty()
|| policy.subjectAlternativeNamePolicy().criticalWithNonemptySubject();
@@ -225,15 +227,24 @@ final class CertificateProfileValidator {
// The public exception deliberately redacts ASN.1 parser details.
@SuppressWarnings({ "PMD.PreserveStackTrace", "PMD.AvoidRethrowingException" })
/* package */ static void requireSubjectKeyAllowed(zeroecho.pki.api.EncodedObject exactPublicKey,
Set<String> allowedAlgorithms) {
Set<String> allowedAlgorithms, X509AuthoritySnapshot authority) {
if (exactPublicKey.encoding() != Encoding.DER) {
throw reject("SUBJECT_KEY_UNSUPPORTED");
}
byte[] encoded = exactPublicKey.bytes();
try {
SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(encoded);
SubjectKeyAlgorithm algorithm = subjectKeyAlgorithm(spki.getAlgorithm().getAlgorithm());
requireSupportedParameters(spki, algorithm);
AlgorithmIdentity identity;
try {
identity = new BcX509AlgorithmAdapter(authority.bindings()).decode(spki.getAlgorithm(),
X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM);
} catch (IllegalArgumentException invalidBinding) {
boolean knownOid = authority.bindings().rules().stream()
.filter(rule -> rule.role() == X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM)
.anyMatch(rule -> rule.oid().equals(spki.getAlgorithm().getAlgorithm().getId()));
throw reject(knownOid ? "SUBJECT_KEY_PARAMETERS_UNSUPPORTED" : "SUBJECT_KEY_ALGORITHM_UNKNOWN");
}
SubjectKeyAlgorithm algorithm = subjectKeyAlgorithm(identity);
if (!allowedAlgorithms.contains(algorithm.profileId())) {
throw reject("SUBJECT_KEY_ALGORITHM_FORBIDDEN");
}
@@ -258,35 +269,22 @@ final class CertificateProfileValidator {
}
}
private static SubjectKeyAlgorithm subjectKeyAlgorithm(ASN1ObjectIdentifier oid) {
if (PKCSObjectIdentifiers.rsaEncryption.equals(oid)) {
private static SubjectKeyAlgorithm subjectKeyAlgorithm(AlgorithmIdentity identity) {
if (identity.equals(BootstrapAlgorithmIdentities.RSA_PUBLIC_KEY)) {
return new SubjectKeyAlgorithm("RSA", "RSA");
}
if (X9ObjectIdentifiers.id_ecPublicKey.equals(oid)) {
if (EC_FAMILY.equals(identity.family().name())) {
return new SubjectKeyAlgorithm("ECDSA", "EC");
}
if (EdECObjectIdentifiers.id_Ed25519.equals(oid)) {
if (identity.equals(BootstrapAlgorithmIdentities.ED25519_PUBLIC_KEY)) {
return new SubjectKeyAlgorithm("Ed25519", "Ed25519");
}
if (EdECObjectIdentifiers.id_Ed448.equals(oid)) {
if (identity.equals(BootstrapAlgorithmIdentities.ED448_PUBLIC_KEY)) {
return new SubjectKeyAlgorithm("Ed448", "Ed448");
}
throw reject("SUBJECT_KEY_ALGORITHM_UNKNOWN");
}
private static void requireSupportedParameters(SubjectPublicKeyInfo spki, SubjectKeyAlgorithm algorithm) {
org.bouncycastle.asn1.ASN1Encodable parameters = spki.getAlgorithm().getParameters();
boolean supported = switch (algorithm.profileId()) {
case "RSA" -> DERNull.INSTANCE.equals(parameters);
case "ECDSA" -> parameters instanceof ASN1ObjectIdentifier;
case "Ed25519", "Ed448" -> parameters == null;
default -> false;
};
if (!supported) {
throw reject("SUBJECT_KEY_PARAMETERS_UNSUPPORTED");
}
}
// The public exception deliberately redacts temporal arithmetic details.
@SuppressWarnings("PMD.PreserveStackTrace")
private static Validity approvedValidity(VerifiedIssuanceCandidate candidate, ParsedCertificationRequest request,

View File

@@ -0,0 +1,111 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.impl.core;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Optional;
import zeroecho.core.io.CancellationSignal;
import zeroecho.core.io.RepeatableContent;
import zeroecho.core.spec.AlgorithmIdentity;
import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.content.DurableContentReference;
import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot;
import zeroecho.pki.impl.framework.x509.bc.BcX509SignedObjectValidator;
import zeroecho.pki.spi.store.ContentSink;
import zeroecho.pki.spi.store.PkiStore;
/**
* Internal individual-certificate adapter around store-owned content.
*/
final class CredentialContent {
private CredentialContent() {
}
/* default */ static DurableContentReference stage(PkiStore store, byte[] encoded) {
try (ContentSink sink = store.stagedContent().beginContent(Encoding.DER, DurableContentReference.Lifecycle.PERSISTED);
OutputStream output = sink.outputStream()) {
output.write(encoded);
return sink.complete();
} catch (IOException exception) {
throw new PkiException("Credential staging failed: code=SPOOL_STORAGE_FAILED", exception);
}
}
/* default */ static byte[] materializeForBc(PkiStore store, DurableContentReference reference) {
if (reference.length() > Integer.MAX_VALUE) {
throw new PkiException("Credential exceeds BC adapter element domain: code=ADAPTER_ELEMENT_LIMIT_EXCEEDED");
}
byte[] result = new byte[(int) reference.length()];
try {
readExact(store, reference, result);
return result;
} catch (IOException exception) {
java.util.Arrays.fill(result, (byte) 0);
throw new PkiException("Credential content failed: code=CONTENT_IO_FAILED", exception);
}
}
/* default */ static BcX509SignedObjectValidator.CertificateBindings validateCertificate(PkiStore store,
DurableContentReference reference, X509AuthoritySnapshot authority,
Optional<AlgorithmIdentity> expectedSignature) {
try (RepeatableContent content = store.stagedContent().openContent(reference)) {
return new BcX509SignedObjectValidator(authority).validateCertificate(content, expectedSignature,
CancellationSignal.NONE);
} catch (IOException | IllegalArgumentException exception) {
throw new PkiException("Certificate validation failed: code=NON_CANONICAL_DER", exception);
}
}
private static void readExact(PkiStore store, DurableContentReference reference, byte[] result)
throws IOException {
try (RepeatableContent content = store.stagedContent().openContent(reference);
InputStream input = content.openStream()) {
int offset = 0;
while (offset != result.length) {
int count = input.read(result, offset, result.length - offset);
if (count < 0) {
throw new IOException("Credential content is truncated");
}
offset += count;
}
if (input.read() >= 0) {
throw new IOException("Credential content length changed");
}
}
}
}

View File

@@ -36,7 +36,6 @@ package zeroecho.pki.impl.core;
import java.util.ArrayList;
import java.util.List;
import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.attr.AttributeId;
import zeroecho.pki.api.attr.AttributeSet;
import zeroecho.pki.api.attr.AttributeValue;
@@ -54,18 +53,13 @@ final class CredentialSnapshots {
/* default */ static CredentialBundle copy(CredentialBundle source) {
Credential credential = copy(source.credential());
List<EncodedObject> supporting = source.supportingObjects().stream().map(CredentialSnapshots::copy).toList();
return new CredentialBundle(credential, supporting);
return new CredentialBundle(credential, List.copyOf(source.supportingObjects()));
}
/* default */ static Credential copy(Credential source) {
return new Credential(source.credentialId(), source.formatId(), source.issuerRef(), source.subjectRef(),
source.validity(), source.serialOrUniqueId(), source.publicKeyId(), source.profileBinding(),
source.status(), copy(source.encoded()), copy(source.attributes()));
}
private static EncodedObject copy(EncodedObject source) {
return new EncodedObject(source.encoding(), source.bytes().clone());
source.status(), source.content(), copy(source.attributes()));
}
private static AttributeSet copy(AttributeSet source) {

View File

@@ -60,6 +60,8 @@ import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.OperatorCreationException;
import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder;
import zeroecho.core.spec.AlgorithmIdentity;
import zeroecho.core.spi.AlgorithmExecutionCapability;
import zeroecho.pki.api.CaService;
import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.Encoding;
@@ -91,6 +93,8 @@ import zeroecho.pki.api.credential.EffectiveCredentialStatusResolver;
import zeroecho.pki.api.profile.ActiveCertificateProfile;
import zeroecho.pki.api.profile.CertificateProfileKind;
import zeroecho.pki.impl.core.async.PkiSigningBus;
import zeroecho.pki.impl.framework.x509.X509ExecutionPlan;
import zeroecho.pki.spi.crypto.SignatureWorkflow;
import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
import zeroecho.pki.spi.audit.AuditSink;
import zeroecho.pki.spi.framework.CredentialFramework;
@@ -167,10 +171,12 @@ public final class DefaultCaService implements CaService {
private final CredentialFramework framework;
private final CredentialIssuerBackend issuerBackend;
private final CaProofGate proofGate;
private final zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot authority;
private final AuditSink auditSink;
private final EffectiveCredentialStatusResolver statusResolver;
private final ProfileService profileService;
private final Clock clock;
private final AlgorithmIdentity signatureIdentity;
/**
* Creates a CA service bound to a specific store, credential framework, and
@@ -232,6 +238,7 @@ public final class DefaultCaService implements CaService {
this.issuerBackend = Objects.requireNonNull(issuerBackend, "issuerBackend");
Objects.requireNonNull(publicKeyResolver, "publicKeyResolver");
Objects.requireNonNull(signingBus, "signingBus");
this.authority = signingBus.authority();
this.auditSink = Objects.requireNonNull(auditSink, "auditSink");
this.statusResolver = Objects.requireNonNull(statusResolver, "statusResolver");
this.profileService = Objects.requireNonNull(profileService, "profileService");
@@ -242,7 +249,12 @@ public final class DefaultCaService implements CaService {
if (signingTtl == null || signingTtl.isZero() || signingTtl.isNegative()) {
throw new IllegalArgumentException("signingTtl must be positive");
}
this.proofGate = new CaProofGate(publicKeyResolver, signingBus, auditSink, signatureAlgorithmId, signingTtl);
AlgorithmIdentity signatureIdentity = signingBus.authority().resolveIdentity(signatureAlgorithmId);
X509ExecutionPlan<SignatureWorkflow> plan = signingBus.authority()
.planSigning(signatureIdentity.canonicalForm(), SignatureWorkflow.class);
signingBus.authority().authorize(plan, plan.executor(), AlgorithmExecutionCapability.Direction.SIGN);
this.signatureIdentity = signatureIdentity;
this.proofGate = new CaProofGate(publicKeyResolver, signingBus, auditSink, signatureIdentity, signingTtl);
}
/**
@@ -294,7 +306,7 @@ public final class DefaultCaService implements CaService {
ValidatedCaCertificateRequest request = CaCertificateProfileValidator.validate(
ValidatedCaCertificateRequest.Operation.CREATE_ROOT, activeProfile, CertificateProfileKind.ROOT_CA,
command.formatId(), new PkiId("ca:pending-root"), new PkiId("ca:pending-root"), command.subjectRef(),
spki, Optional.empty(), evaluationTime, Optional.empty(), serial);
spki, Optional.empty(), evaluationTime, Optional.empty(), serial, authority);
SubjectPublicKeyInfo rootPublicKeyInfo = proofGate.parseRootSpki(spki, command.formatId());
CaProofGate.ManagedKeyProof proof = proofGate.proveManagedKey(keyRef, command.formatId(), CREATE_ROOT_REJECTED,
Optional.empty());
@@ -342,7 +354,7 @@ public final class DefaultCaService implements CaService {
Credential credential = new Credential(credId, command.formatId(), new IssuerRef(caId), request.subjectRef(),
validity, serial.toString(), publicKeyId, new CaProfileBinding(request.profileReference()),
CredentialStatus.ISSUED, new EncodedObject(Encoding.DER, certDer),
CredentialStatus.ISSUED, CredentialContent.stage(store, certDer),
SimpleAttributeSet.builder().build());
CredentialProfileBindings.requireCaBinding(credential.profileBinding(), request.profileReference());
@@ -394,7 +406,8 @@ public final class DefaultCaService implements CaService {
throw new PkiException("Only DER import supported by this runtime");
}
byte[] certDer = command.existingCaCredential().bytes().clone();
CredentialContent.validateCertificate(store, command.existingCaCredential(), authority, Optional.empty());
byte[] certDer = CredentialContent.materializeForBc(store, command.existingCaCredential());
X509CertificateHolder holder;
try {
holder = new X509CertificateHolder(certDer);
@@ -423,10 +436,10 @@ public final class DefaultCaService implements CaService {
ValidatedCaCertificateRequest request = CaCertificateProfileValidator.validate(
ValidatedCaCertificateRequest.Operation.IMPORT_ROOT, activeProfile, CertificateProfileKind.ROOT_CA,
command.formatId(), caId, caId, command.subjectRef(), spki, Optional.of(validity), evaluationTime,
Optional.empty(), serial);
Optional.empty(), serial, authority);
Credential credential = new Credential(credId, command.formatId(), new IssuerRef(caId), request.subjectRef(),
validity, serial.toString(), publicKeyId, new CaProfileBinding(request.profileReference()),
CredentialStatus.ISSUED, new EncodedObject(Encoding.DER, certDer),
CredentialStatus.ISSUED, command.existingCaCredential(),
SimpleAttributeSet.builder().build());
CredentialProfileBindings.requireCaBinding(credential.profileBinding(), request.profileReference());
requireCaCertificateMatches(credential, credential, request, caId, IMPORT_ROOT_REJECTED,
@@ -511,7 +524,7 @@ public final class DefaultCaService implements CaService {
ValidatedCaCertificateRequest.Operation.CREATE_INTERMEDIATE, activeProfile,
CertificateProfileKind.INTERMEDIATE_CA, command.formatId(), command.issuerCaId(), caId, approvedSubject,
subjectSpki, Optional.empty(), evaluationTime, Optional.of(issuerCredential.validity().notAfter()),
CertificateSerialAllocator.allocate());
CertificateSerialAllocator.allocate(), authority);
CaProofGate.ManagedKeyProof subjectProof = proofGate.proveManagedKey(command.keyRef().get(), command.formatId(),
CREATE_INT_REJECTED, Optional.of(caId));
requireSameManagedKey(subjectSpki, subjectProof.exactPublicKey(), CREATE_INT_REJECTED, command.formatId(),
@@ -520,7 +533,7 @@ public final class DefaultCaService implements CaService {
Credential backendCredential;
try {
backendCredential = issuerBackend.issueIntermediateCertificate(issue, issuerCredential.encoded(),
backendCredential = issuerBackend.issueIntermediateCertificate(issue, issuerCredential.content(),
issuer.issuerKeyRef());
} catch (RuntimeException ex) { // NOPMD - reject malformed or mutable framework output
throw proofGate.rejection(CREATE_INT_REJECTED, command.formatId(), Optional.of(caId),
@@ -602,7 +615,8 @@ public final class DefaultCaService implements CaService {
ValidatedCaCertificateRequest.Operation.ISSUE_INTERMEDIATE, activeProfile,
CertificateProfileKind.INTERMEDIATE_CA, command.formatId(), command.issuerCaId(), command.subjectCaId(),
approvedSubject, subjectSpki, command.requestedValidity(), evaluationTime,
Optional.of(issuerCredential.validity().notAfter()), CertificateSerialAllocator.allocate());
Optional.of(issuerCredential.validity().notAfter()), CertificateSerialAllocator.allocate(),
authority);
CaProofGate.ManagedKeyProof subjectProof = proofGate.proveManagedKey(subject.issuerKeyRef(), command.formatId(),
ISSUE_INT_REJECTED, Optional.of(subject.caId()));
requireSameManagedKey(subjectSpki, subjectProof.exactPublicKey(), ISSUE_INT_REJECTED, command.formatId(),
@@ -612,7 +626,7 @@ public final class DefaultCaService implements CaService {
Credential backendCredential;
try {
backendCredential = issuerBackend.issueIntermediateCertificate(gated, issuerCredential.encoded(),
backendCredential = issuerBackend.issueIntermediateCertificate(gated, issuerCredential.content(),
issuer.issuerKeyRef());
} catch (RuntimeException ex) { // NOPMD - reject malformed or mutable framework output
throw proofGate.rejection(ISSUE_INT_REJECTED, command.formatId(), Optional.of(subject.caId()),
@@ -833,10 +847,12 @@ public final class DefaultCaService implements CaService {
private void requireIssuerKeyBinding(CaRecord issuer, Credential credential, FormatId formatId, String action,
Optional<PkiId> objectId) {
try {
if (credential.encoded().encoding() != Encoding.DER) {
if (credential.content().encoding() != Encoding.DER) {
throw proofGate.rejection(action, formatId, objectId, "ISSUER_CREDENTIAL_INVALID");
}
X509CertificateHolder holder = new X509CertificateHolder(credential.encoded().bytes());
CredentialContent.validateCertificate(store, credential.content(), authority, Optional.empty());
X509CertificateHolder holder = new X509CertificateHolder(CredentialContent.materializeForBc(store,
credential.content()));
CaProofGate.ManagedKeyProof proof = proofGate.proveManagedKey(issuer.issuerKeyRef(), formatId, action,
objectId);
if (!MessageDigest.isEqual(proof.exactPublicKey().bytes(), holder.getSubjectPublicKeyInfo().getEncoded())) {
@@ -855,8 +871,13 @@ public final class DefaultCaService implements CaService {
if (!matchesCaCredentialEnvelope(credential, request, subjectCaId)) {
throw proofGate.rejection(action, framework.formatId(), Optional.of(subjectCaId), mismatchCode);
}
X509CertificateHolder holder = new X509CertificateHolder(credential.encoded().bytes());
X509CertificateHolder issuerHolder = new X509CertificateHolder(issuerCredential.encoded().bytes());
CredentialContent.validateCertificate(store, credential.content(), authority,
Optional.of(signatureIdentity));
CredentialContent.validateCertificate(store, issuerCredential.content(), authority, Optional.empty());
byte[] credentialDer = CredentialContent.materializeForBc(store, credential.content());
X509CertificateHolder holder = new X509CertificateHolder(credentialDer);
X509CertificateHolder issuerHolder = new X509CertificateHolder(CredentialContent.materializeForBc(store,
issuerCredential.content()));
byte[] actualSpki = holder.getSubjectPublicKeyInfo().getEncoded();
Extension constraintsExtension = holder.getExtension(Extension.basicConstraints);
BasicConstraints constraints = constraintsExtension == null ? null
@@ -872,7 +893,7 @@ public final class DefaultCaService implements CaService {
actualSpki)
|| !matchesCaCertificatePolicy(holder, request, constraintsExtension, constraints,
keyUsageExtension, keyUsage)
|| !matchesCaCredentialMetadata(credential, holder, request, actualSpki)) {
|| !matchesCaCredentialMetadata(credential, holder, request, actualSpki, credentialDer)) {
throw proofGate.rejection(action, framework.formatId(), Optional.of(subjectCaId), mismatchCode);
}
CredentialProfileBindings.requireCaBinding(credential.profileBinding(), request.profileReference());
@@ -885,7 +906,7 @@ public final class DefaultCaService implements CaService {
private boolean matchesCaCredentialEnvelope(Credential credential, ValidatedCaCertificateRequest request,
PkiId subjectCaId) {
return framework.formatId().equals(credential.formatId()) && credential.encoded().encoding() == Encoding.DER
return framework.formatId().equals(credential.formatId()) && credential.content().encoding() == Encoding.DER
&& credential.status() == CredentialStatus.ISSUED
&& credential.subjectRef().equals(request.subjectRef())
&& credential.issuerRef()
@@ -915,9 +936,9 @@ public final class DefaultCaService implements CaService {
}
private static boolean matchesCaCredentialMetadata(Credential credential, X509CertificateHolder holder,
ValidatedCaCertificateRequest request, byte[] actualSpki) {
ValidatedCaCertificateRequest request, byte[] actualSpki, byte[] credentialDer) {
return credential.publicKeyId().equals(new PkiId("spki:" + sha256Hex(actualSpki)))
&& credential.credentialId().equals(new PkiId("x509:" + sha256Hex(credential.encoded().bytes())))
&& credential.credentialId().equals(new PkiId("x509:" + sha256Hex(credentialDer)))
&& credential.serialOrUniqueId().equals(holder.getSerialNumber().toString())
&& credential.validity().notBefore().getEpochSecond() == holder.getNotBefore().toInstant()
.getEpochSecond()

View File

@@ -78,6 +78,9 @@ import zeroecho.pki.api.request.ParsedCertificationRequest;
import zeroecho.pki.api.request.ProofOfPossessionResult;
import zeroecho.pki.api.request.ProofOfPossessionStatus;
import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes;
import zeroecho.pki.impl.framework.x509.bc.BcX509CredentialFramework;
import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot;
import zeroecho.pki.impl.framework.x509.X509BuiltInDefaults;
import zeroecho.pki.impl.framework.x509.bc.BcX509ProfileSupport;
import zeroecho.pki.spi.audit.AuditSink;
import zeroecho.pki.spi.framework.CredentialFramework;
@@ -157,6 +160,8 @@ public final class DefaultIssuanceService implements IssuanceService {
private final EffectiveCredentialStatusResolver statusResolver;
private final ProfileService profileService;
private final Clock clock;
private final X509AuthoritySnapshot authority;
private final zeroecho.core.spec.AlgorithmIdentity expectedSignature;
/**
* Creates the issuance service bound to the supplied persistence and framework
@@ -185,6 +190,11 @@ public final class DefaultIssuanceService implements IssuanceService {
this.statusResolver = Objects.requireNonNull(statusResolver, "statusResolver");
this.profileService = Objects.requireNonNull(profileService, "profileService");
this.clock = Objects.requireNonNull(clock, "clock");
if (!(framework instanceof BcX509CredentialFramework x509Framework)) {
throw new IllegalArgumentException("X.509 issuance requires an algorithm authority");
}
this.authority = x509Framework.authority();
this.expectedSignature = authority.resolveDefault(X509BuiltInDefaults.PKI_SIGNATURE_DEFAULT_V1).signature();
}
/**
@@ -250,7 +260,7 @@ public final class DefaultIssuanceService implements IssuanceService {
ValidatedCertificateRequest validated;
try {
validated = CertificateProfileValidator.validate(candidate, profile, active.reference(), issuerCred,
evaluationTime);
evaluationTime, authority);
} catch (PkiException exception) {
throw rejection(candidate.request(), statusCode(exception));
}
@@ -259,7 +269,7 @@ public final class DefaultIssuanceService implements IssuanceService {
CredentialBundle bundle;
try {
bundle = CredentialSnapshots
.copy(issuerBackend.issueEndEntity(validated, issuerCred.encoded(), issuer.issuerKeyRef(), serial));
.copy(issuerBackend.issueEndEntity(validated, issuerCred.content(), issuer.issuerKeyRef(), serial));
} catch (RuntimeException ex) { // NOPMD - framework output must cross the snapshot boundary
throw rejection(candidate.request(), "BACKEND_CREDENTIAL_MISMATCH");
}
@@ -441,14 +451,19 @@ public final class DefaultIssuanceService implements IssuanceService {
try {
CredentialProfileBindings.requireEndEntityBinding(credential.profileBinding(),
validated.profileReference());
if (!framework.formatId().equals(credential.formatId()) || credential.encoded().encoding() != Encoding.DER
if (!framework.formatId().equals(credential.formatId()) || credential.content().encoding() != Encoding.DER
|| !credential.subjectRef().equals(validated.subjectRef())
|| !credential.issuerRef().equals(new zeroecho.pki.api.IssuerRef(validated.issuerCaId()))
|| credential.status() != CredentialStatus.ISSUED) {
throw rejection(auditRequest, "BACKEND_CREDENTIAL_MISMATCH");
}
X509CertificateHolder holder = new X509CertificateHolder(credential.encoded().bytes());
X509CertificateHolder issuerHolder = new X509CertificateHolder(issuerCredential.encoded().bytes());
CredentialContent.validateCertificate(store, credential.content(), authority,
Optional.of(expectedSignature));
CredentialContent.validateCertificate(store, issuerCredential.content(), authority, Optional.empty());
byte[] credentialDer = CredentialContent.materializeForBc(store, credential.content());
X509CertificateHolder holder = new X509CertificateHolder(credentialDer);
X509CertificateHolder issuerHolder = new X509CertificateHolder(
CredentialContent.materializeForBc(store, issuerCredential.content()));
byte[] actualSpki = holder.getSubjectPublicKeyInfo().getEncoded();
if (!MessageDigest.isEqual(validated.exactPublicKey().bytes(), actualSpki)
|| !holder.getSubject().equals(BcX509ProfileSupport.subject(validated.subjectRdns()))
@@ -457,7 +472,7 @@ public final class DefaultIssuanceService implements IssuanceService {
new JcaContentVerifierProviderBuilder().build(issuerHolder.getSubjectPublicKeyInfo()))
|| !holder.getSerialNumber().equals(allocatedSerial)
|| !credential.publicKeyId().equals(new PkiId("spki:" + sha256Hex(actualSpki)))
|| !credential.credentialId().equals(new PkiId("x509:" + sha256Hex(credential.encoded().bytes())))
|| !credential.credentialId().equals(new PkiId("x509:" + sha256Hex(credentialDer)))
|| !credential.serialOrUniqueId().equals(holder.getSerialNumber().toString())
|| !credential.validity().equals(validated.validity())
|| validated.validity().notBefore().getEpochSecond() != holder.getNotBefore().toInstant()

View File

@@ -112,8 +112,18 @@ public final class DefaultRevocationService implements RevocationService {
@SuppressWarnings("PMD.AvoidCatchingGenericException")
public List<RevocationJournal> search(RevocationQuery query) {
Objects.requireNonNull(query, "query");
try {
return store.listRevocationJournals().stream().filter(journal -> matches(journal, query)).toList();
try (zeroecho.pki.spi.store.RevocationSnapshot snapshot = store.openRevocationSnapshot();
zeroecho.pki.spi.store.RevocationSnapshot.Cursor cursor = snapshot.openCursor()) {
List<RevocationJournal> matching = new java.util.ArrayList<>();
while (cursor.next()) {
RevocationJournal journal = cursor.current();
if (matches(journal, query)) {
matching.add(journal);
}
}
return List.copyOf(matching);
} catch (java.io.IOException failure) {
throw new PkiException("Revocation snapshot failed: code=STORE_FAILED", failure);
} catch (RuntimeException failure) {
throw sanitized(failure);
}

View File

@@ -33,17 +33,19 @@
******************************************************************************/
package zeroecho.pki.impl.core;
import java.io.IOException;
import java.math.BigInteger;
import java.time.Instant;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.OptionalLong;
import org.bouncycastle.cert.X509CertificateHolder;
import zeroecho.core.io.CancellationSignal;
import zeroecho.core.io.RepeatableContent;
import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.PkiId;
@@ -51,6 +53,7 @@ import zeroecho.pki.api.StatusObjectService;
import zeroecho.pki.api.attr.AttributeValue;
import zeroecho.pki.api.ca.CaRecord;
import zeroecho.pki.api.ca.CaState;
import zeroecho.pki.api.content.DurableContentReference;
import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.credential.CredentialUse;
import zeroecho.pki.api.credential.EffectiveCredentialStatus;
@@ -65,11 +68,18 @@ import zeroecho.pki.api.status.StatusObjectQuery;
import zeroecho.pki.api.status.StatusObjectType;
import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes;
import zeroecho.pki.impl.framework.x509.bc.BcX509SignedObjectValidator;
import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot;
import zeroecho.pki.impl.framework.x509.X509ExecutionPlan;
import zeroecho.pki.impl.framework.x509.X509SignedObjectCompletion;
import zeroecho.pki.spi.crypto.SignatureWorkflow;
import zeroecho.pki.impl.framework.x509.bc.BcX509CredentialFramework;
import zeroecho.pki.spi.audit.AuditSink;
import zeroecho.pki.spi.framework.CredentialFramework;
import zeroecho.pki.spi.framework.CrlEntry;
import zeroecho.pki.spi.framework.CrlEntrySource;
import zeroecho.pki.spi.store.PkiStore;
import zeroecho.pki.spi.store.RevocationSnapshot;
/**
* Default implementation of {@link StatusObjectService}.
@@ -126,6 +136,7 @@ public final class DefaultStatusObjectService implements StatusObjectService {
private final CredentialFramework framework;
private final AuditSink auditSink;
private final EffectiveCredentialStatusResolver statusResolver;
private final X509AuthoritySnapshot authority;
/**
* Creates a status object service bound to the supplied persistence and
@@ -141,11 +152,12 @@ public final class DefaultStatusObjectService implements StatusObjectService {
* @throws NullPointerException if an argument is {@code null}
*/
public DefaultStatusObjectService(PkiStore store, CredentialFramework framework, AuditSink auditSink,
EffectiveCredentialStatusResolver statusResolver) {
EffectiveCredentialStatusResolver statusResolver, X509AuthoritySnapshot authority) {
this.store = Objects.requireNonNull(store, "store");
this.framework = Objects.requireNonNull(framework, "framework");
this.auditSink = Objects.requireNonNull(auditSink, "auditSink");
this.statusResolver = Objects.requireNonNull(statusResolver, "statusResolver");
this.authority = Objects.requireNonNull(authority, "authority");
}
/**
@@ -193,6 +205,7 @@ public final class DefaultStatusObjectService implements StatusObjectService {
* generated status object fails
*/
@Override
@SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" })
public StatusObject generate(StatusObjectGenerateCommand command) {
if (command == null) {
throw new IllegalArgumentException("command must not be null");
@@ -206,59 +219,79 @@ public final class DefaultStatusObjectService implements StatusObjectService {
}
EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation();
Credential issuerCred = selectIssuerCredential(ca, command, statusEvaluation);
List<CrlEntry> crlEntries = command.type() == StatusObjectType.CRL
? collectCrlEntries(command.issuerCaId(), statusEvaluation.evaluationTime())
: List.of();
SimpleAttributeSet.Builder b = SimpleAttributeSet.builder();
b.putAll(command.attributes());
b.put(BcX509Attributes.ISSUER_CERT_DER, new AttributeValue.BytesValue(issuerCred.encoded().bytes()));
b.put(BcX509Attributes.ISSUER_CERT_DER,
new AttributeValue.BytesValue(CredentialContent.materializeForBc(store, issuerCred.content())));
b.put(BcX509Attributes.ISSUER_KEYREF, new AttributeValue.StringValue(ca.issuerKeyRef().value()));
StatusObjectGenerateCommand wired = new StatusObjectGenerateCommand(command.issuerCaId(), command.type(),
command.formatId(), b.build());
if (command.type() == StatusObjectType.CRL) {
return generateAndPersistCrl(wired, crlEntries);
}
StatusObject obj = framework.statusObjectGenerator().generate(wired, crlEntries);
store.putStatusObject(obj);
return obj;
}
// Framework, signing, and store failures may carry provider or persisted
// material. CRL generation deliberately replaces the complete boundary with
// one fresh cause-free and suppressed-free exception.
@SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" })
private StatusObject generateAndPersistCrl(StatusObjectGenerateCommand command, List<CrlEntry> entries) {
try {
StatusObject generated = framework.statusObjectGenerator().generate(command, entries);
store.putStatusObject(generated);
return generated;
} catch (RuntimeException exception) {
throw crlGenerationFailure();
}
}
// Store and parser failures may contain persisted material; the complete
// collection boundary deliberately replaces every cause with one stable code.
@SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" })
private List<CrlEntry> collectCrlEntries(PkiId issuerCaId, Instant evaluationTime) {
try {
List<RevocationJournal> journals = Objects.requireNonNull(store.listRevocationJournals(),
"revocation journals");
List<CrlEntry> entries = new java.util.ArrayList<>();
Set<BigInteger> serials = new HashSet<>();
for (RevocationJournal journal : journals) {
collectCrlEntry(issuerCaId, evaluationTime, journal, serials).ifPresent(entries::add);
try (CrlEntrySource entries = command.type() == StatusObjectType.CRL
? openCrlEntries(command.issuerCaId(), statusEvaluation.evaluationTime())
: new EmptyCrlEntrySource()) {
if (command.type() == StatusObjectType.CRL) {
return generateAndPersistCrl(wired, entries, issuerCred);
}
return List.copyOf(entries);
} catch (RuntimeException exception) {
return generateAndPersistOther();
} catch (IOException | RuntimeException exception) {
throw crlGenerationFailure();
}
}
private Optional<CrlEntry> collectCrlEntry(PkiId issuerCaId, Instant evaluationTime, RevocationJournal journal,
Set<BigInteger> serials) {
private StatusObject generateAndPersistCrl(StatusObjectGenerateCommand command, CrlEntrySource entries,
Credential issuer) {
X509SignedObjectCompletion completion = framework.statusObjectGenerator().generate(command, entries);
StatusObject generated = authority.requireStatusCompletion(completion);
X509ExecutionPlan<SignatureWorkflow> signingPlan = authority.requireStatusSigningPlan(completion);
requirePersistableContent(generated.content());
boolean accepted = false;
try {
byte[] issuerDer = CredentialContent.materializeForBc(store, issuer.content());
try (RepeatableContent content = store.stagedContent().openContent(generated.content())) {
X509CertificateHolder holder = new X509CertificateHolder(issuerDer);
new BcX509SignedObjectValidator(authority).validateGeneratedCrl(content, signingPlan,
holder.getSubjectPublicKeyInfo(), CancellationSignal.NONE);
} finally {
Arrays.fill(issuerDer, (byte) 0);
}
store.putStatusObject(generated);
accepted = true;
return generated;
} catch (IOException exception) {
throw new PkiException("Status postcondition validation failed: code=CONTENT_IO_FAILED", exception);
} finally {
if (!accepted) {
releaseRejectedContent(generated.content());
}
}
}
private StatusObject generateAndPersistOther() {
throw new PkiException("Unsupported status object type");
}
private void requirePersistableContent(DurableContentReference content) {
if (content.lifecycle() != DurableContentReference.Lifecycle.PERSISTED
|| !store.stagedContent().contentStoreId().equals(content.storeId())) {
throw new PkiException("Status content lifecycle invalid: code=STAGED_CONTENT_FOREIGN_RUNTIME");
}
}
private void releaseRejectedContent(DurableContentReference content) {
try {
store.stagedContent().retireUnownedContent(content);
} catch (IOException cleanupFailure) {
throw new PkiException("Rejected status content cleanup failed: code=CONTENT_IO_FAILED", cleanupFailure);
}
}
private CrlEntrySource openCrlEntries(PkiId issuerCaId, Instant evaluationTime) {
return new JournalCrlEntrySource(store.openRevocationSnapshot(), issuerCaId, evaluationTime);
}
private Optional<CrlEntry> collectCrlEntry(PkiId issuerCaId, Instant evaluationTime, RevocationJournal journal) {
Objects.requireNonNull(journal, "journal");
RevocationTransition latest = Objects.requireNonNull(journal.latest(), "latest transition");
if (latest.time().isAfter(evaluationTime)) {
@@ -273,13 +306,10 @@ public final class DefaultStatusObjectService implements StatusObjectService {
return Optional.empty();
}
if (!BcX509CredentialFramework.FORMAT_ID.equals(credential.formatId())
|| credential.encoded().encoding() != Encoding.DER) {
|| credential.content().encoding() != Encoding.DER) {
throw crlGenerationFailure();
}
BigInteger serial = certificateSerial(credential);
if (!serials.add(serial)) {
throw crlGenerationFailure();
}
RevocationReason reason = switch (latest.state()) {
case HELD -> RevocationReason.CERTIFICATE_HOLD;
case PERMANENTLY_REVOKED ->
@@ -289,11 +319,131 @@ public final class DefaultStatusObjectService implements StatusObjectService {
return Optional.of(new CrlEntry(serial, latest.time(), reason));
}
/** Stable restartable view over one revocation-store snapshot. */
private final class JournalCrlEntrySource implements CrlEntrySource {
private final RevocationSnapshot snapshot;
private final PkiId issuerCaId;
private final Instant evaluationTime;
private JournalCrlEntrySource(RevocationSnapshot snapshot, PkiId issuerCaId, Instant evaluationTime) {
this.snapshot = snapshot;
this.issuerCaId = issuerCaId;
this.evaluationTime = evaluationTime;
}
@Override
public Cursor openCursor() throws IOException {
return new JournalCrlCursor(snapshot.openCursor(), issuerCaId, evaluationTime);
}
@Override
public OptionalLong count() {
return OptionalLong.empty();
}
@Override
public void close() throws IOException {
snapshot.close();
}
}
/** Bounded cursor translating authoritative journals into CRL entries. */
private final class JournalCrlCursor implements CrlEntrySource.Cursor {
private final RevocationSnapshot.Cursor cursor;
private final PkiId issuerCaId;
private final Instant evaluationTime;
private CrlEntry current;
private long ordinal = -1L;
private JournalCrlCursor(RevocationSnapshot.Cursor cursor, PkiId issuerCaId, Instant evaluationTime) {
this.cursor = cursor;
this.issuerCaId = issuerCaId;
this.evaluationTime = evaluationTime;
}
@Override
public boolean next() throws IOException {
while (cursor.next()) {
Optional<CrlEntry> candidate = collectCrlEntry(issuerCaId, evaluationTime, cursor.current());
if (candidate.isPresent()) {
current = candidate.orElseThrow();
ordinal = Math.addExact(ordinal, 1L);
return true;
}
}
current = null;
return false;
}
@Override
public CrlEntry current() {
if (current == null) {
throw new IllegalStateException("CRL entry cursor is not positioned");
}
return current;
}
@Override
public long ordinal() {
if (current == null) {
throw new IllegalStateException("CRL entry cursor is not positioned");
}
return ordinal;
}
@Override
public void close() throws IOException {
cursor.close();
current = null;
}
}
/** Empty source used for status formats without revocation entries. */
private static final class EmptyCrlEntrySource implements CrlEntrySource {
@Override
public Cursor openCursor() {
return new EmptyCrlCursor();
}
@Override
public OptionalLong count() {
return OptionalLong.of(0L);
}
@Override
public void close() {
// No resources.
}
}
/** Resource-free cursor for an empty status-entry source. */
private static final class EmptyCrlCursor implements CrlEntrySource.Cursor {
@Override
public boolean next() {
return false;
}
@Override
public CrlEntry current() {
throw new IllegalStateException("Empty CRL cursor has no entry");
}
@Override
public long ordinal() {
throw new IllegalStateException("Empty CRL cursor has no ordinal");
}
@Override
public void close() {
// No resources.
}
}
// Parser failures can contain persisted certificate details; the original
// cause is intentionally removed at this public service boundary.
@SuppressWarnings("PMD.PreserveStackTrace")
private static BigInteger certificateSerial(Credential credential) {
byte[] der = credential.encoded().bytes();
private BigInteger certificateSerial(Credential credential) {
byte[] der = CredentialContent.materializeForBc(store, credential.content());
BigInteger serial;
try {
serial = new X509CertificateHolder(der).getSerialNumber();

View File

@@ -40,6 +40,7 @@ import java.time.Instant;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -48,18 +49,27 @@ import java.util.concurrent.locks.ReentrantLock;
import java.util.logging.Level;
import java.util.logging.Logger;
import zeroecho.core.spec.AlgorithmIdentity;
import zeroecho.core.spi.AlgorithmExecutionCapability;
import zeroecho.core.io.RepeatableContent;
import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.KeyRef;
import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.content.DurableContentReference;
import zeroecho.pki.api.content.DurableContentOwner;
import zeroecho.pki.api.audit.Principal;
import zeroecho.pki.api.orch.OrchestrationDurabilityPolicy;
import zeroecho.pki.api.orch.SigningSubmissionId;
import zeroecho.pki.api.orch.WorkflowStateRecord;
import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot;
import zeroecho.pki.impl.framework.x509.X509ExecutionPlan;
import zeroecho.pki.spi.crypto.SignatureWorkflow;
import zeroecho.pki.spi.store.PkiStore;
import zeroecho.pki.spi.store.SignWorkflowStore;
import zeroecho.pki.spi.store.ContentSink;
import zeroecho.pki.spi.store.TemporaryUniqueIndex;
import zeroecho.pki.util.async.AsyncEndpoint;
import zeroecho.pki.util.async.AsyncState;
import zeroecho.pki.util.async.AsyncStatus;
@@ -112,6 +122,7 @@ public final class PkiSigningBus implements AutoCloseable {
private final PkiStore store;
private final DurableAsyncBus<PkiId, Principal, String, EncodedObject> bus;
private final SignatureWorkflow signer;
private final X509AuthoritySnapshot authority;
private final SecureRandom random;
private final String namespace;
private final OperationCoordinator coordinator;
@@ -122,58 +133,70 @@ public final class PkiSigningBus implements AutoCloseable {
private final OrchestrationDurabilityPolicy durabilityPolicy;
/**
* Creates a signing bus.
*
* @param store persistent store (source of truth for
* continuation state)
* @param signer signature workflow
* @param durableLineStorePath path to append-only line store file
*/
public PkiSigningBus(PkiStore store, SignatureWorkflow signer, Path durableLineStorePath) {
this(store, signer, durableLineStorePath, resolveDisplaySuffixMaxLen(Optional.empty()),
OrchestrationDurabilityPolicy.DURABLE_MIN_STATE);
}
/**
* Creates a signing bus.
* Creates a signing bus in an explicitly composed runtime authority graph.
*
* <p>
* The {@code displaySuffixMaxLen} parameter controls the maximum number of
* characters appended after {@code '#'} in operation identifiers returned by
* {@link #canonicalizeOperationId(PkiId, Principal)}. If
* {@code displaySuffixMaxLen} is not positive, the constructor fails.
* The authority must already bind every signing identity declared by
* {@code signer} to that exact workflow instance for the {@code SIGN}
* direction. This constructor validates ownership before registering the
* workflow or activating durable state. It never constructs an internal
* authority, accepts an authority from another runtime, or handles private
* key material.
* </p>
*
* @param store persistent store (source of truth for
* continuation state)
* @param signer signature workflow
* @param durableLineStorePath path to append-only line store file
* @param displaySuffixMaxLen maximum number of characters after {@code '#'}
* @param store persistent store
* @param signer exact signing workflow owned by
* {@code authority}
* @param durableLineStorePath durable bus path
* @param authority shared immutable runtime authority
* @throws NullPointerException if any argument is {@code null}
* @throws IllegalArgumentException if the authority does not own the exact
* workflow for every declared signing
* identity and the {@code SIGN} direction
*/
public PkiSigningBus(PkiStore store, SignatureWorkflow signer, Path durableLineStorePath, int displaySuffixMaxLen) {
this(store, signer, durableLineStorePath, displaySuffixMaxLen, OrchestrationDurabilityPolicy.DURABLE_MIN_STATE);
public PkiSigningBus(PkiStore store, SignatureWorkflow signer, Path durableLineStorePath,
X509AuthoritySnapshot authority) {
this(store, signer, durableLineStorePath, resolveDisplaySuffixMaxLen(Optional.empty()),
OrchestrationDurabilityPolicy.DURABLE_MIN_STATE, authority);
}
/**
* Creates a signing bus with explicit workflow continuation durability policy.
* Creates a signing bus bound to one immutable algorithm authority snapshot.
*
* @param store persistent store (source of truth for
* continuation state)
* @param signer signature workflow
* @param durableLineStorePath path to append-only line store file
* @param displaySuffixMaxLen maximum number of characters after {@code '#'}
* @param durabilityPolicy durability policy used when persisting workflow
* state
* <p>
* The authority must already bind every signing identity declared by
* {@code signer} to that exact workflow instance for the {@code SIGN}
* direction. Validation occurs before registration or durable-state
* activation. The constructor does not derive an authority from provider
* strings, accept cross-runtime workflow ownership, or access private key
* material.
* </p>
*
* @param store persistent store
* @param signer exact workflow represented by the snapshot
* capability
* @param durableLineStorePath durable bus path
* @param displaySuffixMaxLen display suffix bound
* @param durabilityPolicy continuation durability policy
* @param authority immutable runtime authority
* @throws NullPointerException if any reference argument is {@code null}
* @throws IllegalArgumentException if the display suffix bound is invalid or
* the authority does not own the exact
* workflow for every declared signing
* identity and the {@code SIGN} direction
*/
public PkiSigningBus(PkiStore store, SignatureWorkflow signer, Path durableLineStorePath, int displaySuffixMaxLen,
OrchestrationDurabilityPolicy durabilityPolicy) {
OrchestrationDurabilityPolicy durabilityPolicy, X509AuthoritySnapshot authority) {
Objects.requireNonNull(store, "store");
Objects.requireNonNull(signer, "signer");
Objects.requireNonNull(durableLineStorePath, "durableLineStorePath");
Objects.requireNonNull(durabilityPolicy, "durabilityPolicy");
X509AuthoritySnapshot exactAuthority = Objects.requireNonNull(authority, "authority");
validateSigningAuthority(signer, exactAuthority);
this.store = store;
this.signer = signer;
this.authority = exactAuthority;
this.random = new SecureRandom();
this.namespace = store.signingNamespace() + "." + signer.id();
signer.validateSigningDomain(this.namespace, store.signingHorizon(), store.signingPermittedSkew());
@@ -189,13 +212,21 @@ public final class PkiSigningBus implements AutoCloseable {
for (WorkflowStateRecord state : store.listWorkflowStates()) {
if (TYPE_SIGN.equals(state.type()) && state.payload().isPresent()) {
try {
SignContinuation.decode(state.payload().orElseThrow());
SignContinuation continuation = SignContinuation.decode(state.payload().orElseThrow(),
store.stagedContent());
X509ExecutionPlan<SignatureWorkflow> plan = authority.planSigning(continuation.algorithmId,
workflowImplementationId(signer), SignatureWorkflow.class);
authority.authorize(plan, signer, AlgorithmExecutionCapability.Direction.SIGN);
AlgorithmIdentity identity = plan.selection().requested();
if (!identity.canonicalForm().equals(continuation.algorithmId)) {
throw new IllegalArgumentException("Persisted sign continuation is not canonical");
}
} catch (RuntimeException ex) { // NOPMD - malformed persisted state must fail closed
throw new PkiException("Invalid persisted sign continuation: code=CONTINUATION_INVALID");
}
}
}
this.endpoint = new SignatureWorkflowEndpoint(store, signer, coordinator, externalActions);
this.endpoint = new SignatureWorkflowEndpoint(store, signer, coordinator, externalActions, authority);
this.bus.registerEndpoint(ENDPOINT_SIGNER, endpoint);
this.signerRegistration = signer.register(endpoint::onProviderStatusChanged);
for (SignWorkflowStore.Record record : store.listSignRecords()) {
@@ -206,6 +237,109 @@ public final class PkiSigningBus implements AutoCloseable {
}
}
/**
* Returns the immutable authority used by this workflow graph.
*
* @return authority snapshot
*/
public X509AuthoritySnapshot authority() {
return authority;
}
/**
* Begins runtime-owned durable staging for one signing input.
*
* @param encoding content encoding
* @return atomic staged-content sink
*/
public ContentSink beginSigningContent(Encoding encoding) {
return beginContent(encoding, DurableContentReference.Lifecycle.OPERATION);
}
/**
* Begins atomic runtime-owned content staging.
*
* <p>
* The sink writes to the injected staged-content store and never retains an
* aggregate payload in the bus. ZeroEcho core imposes no arbitrary
* product-wide aggregate size limit.
* </p>
*
* @param encoding content encoding
* @param lifecycle ownership and retirement class
* @return atomic streamed sink
* @throws PkiException if staging cannot begin
*/
public ContentSink beginContent(Encoding encoding, DurableContentReference.Lifecycle lifecycle) {
try {
return store.stagedContent().beginContent(Objects.requireNonNull(encoding, "encoding"),
Objects.requireNonNull(lifecycle, "lifecycle"));
} catch (java.io.IOException ex) {
throw new PkiException("Content staging failed: code=SPOOL_STORAGE_FAILED");
}
}
/**
* Opens immutable repeatable content owned by this runtime.
*
* @param reference opaque durable content reference
* @return repeatable content
* @throws PkiException if the content is missing, incomplete, corrupt, or
* belongs to another runtime store
*/
public RepeatableContent openContent(DurableContentReference reference) {
try {
return store.stagedContent().openContent(Objects.requireNonNull(reference, "reference"));
} catch (java.io.IOException ex) {
throw new PkiException("Content open failed: code=CONTENT_INTEGRITY_FAILED");
}
}
/**
* Releases staged content from this runtime.
*
* @param reference content reference
* @throws PkiException if cleanup fails
*/
public void releaseContent(DurableContentReference reference) {
try {
store.stagedContent().retireUnownedContent(Objects.requireNonNull(reference, "reference"));
} catch (java.io.IOException ex) {
throw new PkiException("Content cleanup failed: code=SPOOL_STORAGE_FAILED");
}
}
/**
* Begins a runtime-owned file-backed uniqueness index.
*
* @return temporary uniqueness index
* @throws PkiException if temporary storage cannot be created
*/
public TemporaryUniqueIndex beginUniqueIndex() {
try {
return store.stagedContent().beginUniqueIndex();
} catch (java.io.IOException ex) {
throw new PkiException("Temporary index failed: code=SPOOL_STORAGE_FAILED");
}
}
private static String workflowImplementationId(SignatureWorkflow workflow) {
return "workflow." + workflow.id();
}
private static void validateSigningAuthority(SignatureWorkflow workflow, X509AuthoritySnapshot authority) {
Set<String> supportedAlgorithms = Set.copyOf(
Objects.requireNonNull(workflow.supportedAlgorithms(), "workflow.supportedAlgorithms"));
if (supportedAlgorithms.isEmpty()) {
throw new IllegalArgumentException("Signature workflow must declare a signing identity");
}
for (String algorithm : supportedAlgorithms) {
X509ExecutionPlan<SignatureWorkflow> plan = authority.planSigning(algorithm,
workflowImplementationId(workflow), SignatureWorkflow.class);
authority.authorize(plan, workflow, AlgorithmExecutionCapability.Direction.SIGN);
}
}
/**
* Builds a canonical, globally unique operation identifier derived from tuple
* (owner, clientOpId).
@@ -242,24 +376,29 @@ public final class PkiSigningBus implements AutoCloseable {
* @param owner owner principal
* @param keyRef signing key reference
* @param algorithmId signature algorithm id
* @param payload bytes to sign
* @param content durable repeatable content to sign
* @param ttl time-to-live
* @param workflowPayload minimal continuation payload
*/
public void submitSign(PkiId opId, Principal owner, KeyRef keyRef, String algorithmId, EncodedObject payload,
public void submitSign(PkiId opId, Principal owner, KeyRef keyRef, String algorithmId,
DurableContentReference content,
Duration ttl, Optional<EncodedObject> workflowPayload) {
Objects.requireNonNull(opId, "opId");
Objects.requireNonNull(owner, "owner");
Objects.requireNonNull(keyRef, "keyRef");
Objects.requireNonNull(algorithmId, "algorithmId");
Objects.requireNonNull(payload, "payload");
Objects.requireNonNull(content, "content");
Objects.requireNonNull(ttl, "ttl");
Objects.requireNonNull(workflowPayload, "workflowPayload");
if (algorithmId.isBlank()) {
throw new IllegalArgumentException("algorithmId must not be blank");
}
X509ExecutionPlan<SignatureWorkflow> submittedPlan = authority.planSigning(algorithmId,
workflowImplementationId(signer), SignatureWorkflow.class);
authority.authorize(submittedPlan, signer, AlgorithmExecutionCapability.Direction.SIGN);
AlgorithmIdentity submittedIdentity = submittedPlan.selection().requested();
if (ttl.isZero() || ttl.isNegative()) {
throw new IllegalArgumentException("ttl must be positive");
}
@@ -272,27 +411,52 @@ public final class PkiSigningBus implements AutoCloseable {
try (OperationCoordinator.Lease ignored = coordinator.acquire(baseOpId)) {
SigningSubmissionId parsed = SigningSubmissionId.parse(baseOpId);
Instant deadline = parsed.createdAt().plus(ttl);
SignContinuation continuation = SignContinuation.decode(workflowPayload.get());
SignContinuation continuation = SignContinuation.decode(workflowPayload.get(), store.stagedContent());
X509ExecutionPlan<SignatureWorkflow> continuationPlan = authority.planSigning(continuation.algorithmId,
workflowImplementationId(signer), SignatureWorkflow.class);
authority.authorize(continuationPlan, signer, AlgorithmExecutionCapability.Direction.SIGN);
AlgorithmIdentity continuationIdentity = continuationPlan.selection().requested();
if (!owner.equals(continuation.accessContext.principal()) || !keyRef.equals(continuation.keyRef)
|| !algorithmId.equals(continuation.algorithmId)
|| payload.encoding() != continuation.payload.encoding()
|| !java.util.Arrays.equals(payload.bytes(), continuation.payload.bytes())) {
|| !submittedIdentity.equals(continuationIdentity)
|| !content.equals(continuation.content())) {
throw new IllegalArgumentException("Sign continuation does not match the submitted request");
}
continuation = continuation.withAlgorithmId(submittedIdentity.canonicalForm());
String fingerprint = continuation.semanticFingerprint(namespace, deadline);
EncodedObject persistedRequest = continuation.withSignerOpId(baseOpId).encode();
SignWorkflowStore.Record intent = new SignWorkflowStore.Record(baseOpId, namespace, fingerprint, owner,
parsed.createdAt(), deadline, persistedRequest, SignWorkflowStore.State.INTENT, 0L, 0L,
Optional.empty(), Optional.of("INTENT"), Optional.empty(), Optional.empty());
SignWorkflowStore.CreateResult created = store.createSignIntent(intent);
if (created == SignWorkflowStore.CreateResult.CONFLICT) {
throw new PkiException("Signing submission identifier conflicts with a different request");
DurableContentOwner contentOwner = DurableContentOwner.signingOperation(baseOpId);
boolean retained = false;
boolean recordVisible = false;
try {
retained = store.stagedContent().retainContent(content, contentOwner);
SignWorkflowStore.CreateResult created = store.createSignIntent(intent);
if (created == SignWorkflowStore.CreateResult.CONFLICT) {
throw new PkiException("Signing submission identifier conflicts with a different request");
}
recordVisible = true;
authoritative = store.getSignRecord(baseOpId).orElseThrow();
} catch (java.io.IOException exception) {
throw new PkiException("Signing content retention failed: code=SPOOL_STORAGE_FAILED", exception);
} finally {
if (retained && !recordVisible) {
rollbackSigningOwner(content, contentOwner);
}
}
authoritative = store.getSignRecord(baseOpId).orElseThrow();
}
project(authoritative);
}
private void rollbackSigningOwner(DurableContentReference content, DurableContentOwner owner) {
try {
store.stagedContent().releaseContent(content, owner);
} catch (java.io.IOException exception) {
throw new PkiException("Signing content rollback failed: code=SPOOL_STORAGE_FAILED", exception);
}
}
/**
* Returns current status if known.
*/
@@ -400,8 +564,15 @@ public final class PkiSigningBus implements AutoCloseable {
if (!isTerminalSignState(state.state())) {
return;
}
Optional<DurableContentReference> releaseReference = Optional.empty();
if (state.state() != SignWorkflowStore.State.RETIRED) {
releaseReference = Optional.of(SignContinuation.decode(state.request(), store.stagedContent()).content());
}
state = confirmRetirement(baseOpId, state);
store.deleteWorkflowState(baseOpId);
if (releaseReference.isPresent()) {
releaseRetiredOperationContent(baseOpId, releaseReference.get());
}
}
AsyncState advisoryState = state.result().isPresent() ? AsyncState.SUCCEEDED : AsyncState.CANCELLED;
bus.update(baseOpId, new AsyncStatus(advisoryState, store.signingNow(), Optional.of("RETIRED"),
@@ -409,6 +580,17 @@ public final class PkiSigningBus implements AutoCloseable {
bus.retire(baseOpId);
}
private void releaseRetiredOperationContent(PkiId operationId, DurableContentReference reference) {
if (reference.lifecycle() != DurableContentReference.Lifecycle.OPERATION) {
return;
}
try {
store.stagedContent().releaseContent(reference, DurableContentOwner.signingOperation(operationId));
} catch (java.io.IOException exception) {
throw new PkiException("Signing content retirement failed: code=SPOOL_STORAGE_FAILED", exception);
}
}
private void reconcileExpiredOperations() {
Instant current = store.signingNow();
for (SignWorkflowStore.Record candidate : store.listSignRecords()) {
@@ -813,6 +995,7 @@ public final class PkiSigningBus implements AutoCloseable {
private final SignatureWorkflow signer;
private final OperationCoordinator coordinator;
private final ExternalActionCoordinator externalActions;
private final X509AuthoritySnapshot authority;
private final ConcurrentMap<PkiId, Boolean> pendingAdvisories;
private final AtomicInteger pendingAdvisoryCount;
private final AtomicBoolean closed;
@@ -826,11 +1009,12 @@ public final class PkiSigningBus implements AutoCloseable {
* operation and to query its status; must not be {@code null}
*/
private SignatureWorkflowEndpoint(PkiStore store, SignatureWorkflow signer, OperationCoordinator coordinator,
ExternalActionCoordinator externalActions) {
ExternalActionCoordinator externalActions, X509AuthoritySnapshot authority) {
this.store = store;
this.signer = signer;
this.coordinator = coordinator;
this.externalActions = externalActions;
this.authority = authority;
this.pendingAdvisories = new ConcurrentHashMap<>();
this.pendingAdvisoryCount = new AtomicInteger();
this.closed = new AtomicBoolean();
@@ -877,7 +1061,8 @@ public final class PkiSigningBus implements AutoCloseable {
SubmissionCall call = prepared.get();
PkiId returned;
try (ExternalActionCoordinator.Reservation ignored = call.reservation()) {
returned = signer.submitSign(call.request());
authority.authorize(call.plan(), signer, AlgorithmExecutionCapability.Direction.SIGN);
returned = call.plan().executor().submitSign(call.request());
} catch (RuntimeException ambiguousFailure) { // NOPMD - provider acceptance is unknown
return;
}
@@ -917,17 +1102,27 @@ public final class PkiSigningBus implements AutoCloseable {
ExternalActionCoordinator.Reservation reservation = reserved.get();
boolean reservationTransferred = false;
try {
SignContinuation continuation = SignContinuation.decode(claimed.request());
SignContinuation continuation = SignContinuation.decode(claimed.request(), store.stagedContent());
DurableContentOwner contentOwner = DurableContentOwner.signingOperation(opId);
requireSigningOwner(continuation.content(), contentOwner);
X509ExecutionPlan<SignatureWorkflow> plan = authority.planSigning(continuation.algorithmId,
workflowImplementationId(signer), SignatureWorkflow.class);
authority.authorize(plan, signer, AlgorithmExecutionCapability.Direction.SIGN);
AlgorithmIdentity persistedIdentity = plan.selection().requested();
if (!persistedIdentity.canonicalForm().equals(continuation.algorithmId)) {
throw new IllegalArgumentException("Persisted sign continuation is not canonical");
}
RepeatableContent content = openContent(continuation.content());
SignatureWorkflow.SignRequest request = SignatureWorkflow.SignRequest.create(opId,
claimed.namespace(), claimed.fence(), continuation.accessContext, continuation.keyRef,
continuation.algorithmId, continuation.payload,
Optional.of(continuation.preferredSignatureEncoding), Optional.of(claimed.deadline()));
continuation.algorithmId, content, Optional.of(continuation.preferredSignatureEncoding),
Optional.of(claimed.deadline()));
if (!constantTimeAsciiEquals(claimed.fingerprint(), request.semanticFingerprint())) {
store.transitionSign(opId, claimed.revision(), claimed.fence(), SignWorkflowStore.State.FAILED,
Optional.of("REQUEST_INTEGRITY_FAILURE"), Optional.empty(), Optional.empty());
return Optional.empty();
}
SubmissionCall call = new SubmissionCall(claimed, request, reservation);
SubmissionCall call = new SubmissionCall(claimed, request, reservation, plan);
reservationTransferred = true;
return Optional.of(call);
} finally {
@@ -938,6 +1133,24 @@ public final class PkiSigningBus implements AutoCloseable {
}
}
private void requireSigningOwner(DurableContentReference reference, DurableContentOwner owner) {
try {
if (!store.stagedContent().contentOwners(reference).contains(owner)) {
throw new PkiException("Signing content owner missing: code=STAGED_CONTENT_INCOMPLETE");
}
} catch (java.io.IOException exception) {
throw new PkiException("Signing content ownership failed: code=CONTENT_INTEGRITY_FAILED", exception);
}
}
private RepeatableContent openContent(DurableContentReference reference) {
try {
return store.stagedContent().openContent(reference);
} catch (java.io.IOException ex) {
throw new PkiException("Signing content unavailable: code=STAGED_CONTENT_MISSING");
}
}
private static boolean constantTimeAsciiEquals(String left, String right) {
byte[] leftBytes = left.getBytes(java.nio.charset.StandardCharsets.US_ASCII);
byte[] rightBytes = right.getBytes(java.nio.charset.StandardCharsets.US_ASCII);
@@ -970,7 +1183,7 @@ public final class PkiSigningBus implements AutoCloseable {
}
private record SubmissionCall(SignWorkflowStore.Record record, SignatureWorkflow.SignRequest request,
ExternalActionCoordinator.Reservation reservation) {
ExternalActionCoordinator.Reservation reservation, X509ExecutionPlan<SignatureWorkflow> plan) {
}
/**
@@ -1260,7 +1473,8 @@ public final class PkiSigningBus implements AutoCloseable {
* <h2>Encoding model</h2>
* <p>
* Instances are encoded into a compact binary representation through
* {@link #encode()} and reconstructed through {@link #decode(EncodedObject)}.
* {@link #encode()} and reconstructed through
* {@link #decode(EncodedObject, StagedContentStore)}.
* The binary format is versioned by {@link #VERSION}. The current version
* persists the algorithm identifier, payload encoding and bytes, key reference,
* preferred signature encoding, and the optional downstream signer operation
@@ -1271,7 +1485,7 @@ public final class PkiSigningBus implements AutoCloseable {
* <p>
* The current binary encoding does not persist the original
* {@link zeroecho.pki.api.audit.AccessContext} losslessly. During
* {@link #decode(EncodedObject)}, a synthetic system access context is created
* {@link #decode(EncodedObject, StagedContentStore)}, a synthetic system access context is created
* instead. This is sufficient for the current continuation flow, but callers
* must not assume that {@code decode(encode(x))} preserves the original access
* context exactly.
@@ -1284,11 +1498,13 @@ public final class PkiSigningBus implements AutoCloseable {
*/
public static final class SignContinuation {
private static final byte VERSION = 2;
private static final byte VERSION = 4;
private static final long MINIMUM_CONTENT_LENGTH = 0L;
private final zeroecho.pki.api.audit.AccessContext accessContext;
private final String algorithmId;
private final EncodedObject payload;
private final Optional<DurableContentReference> content;
private final ContentCommitment commitment;
private final KeyRef keyRef;
private final Encoding preferredSignatureEncoding;
private final Optional<PkiId> signerOpId;
@@ -1300,7 +1516,7 @@ public final class PkiSigningBus implements AutoCloseable {
* sign request; must not be {@code null}
* @param algorithmId non-blank signature algorithm identifier;
* must not be {@code null} or blank
* @param payload to-be-signed payload; must not be
* @param content durable to-be-signed content reference
* {@code null}
* @param keyRef signing key reference; must not be
* {@code null}
@@ -1313,10 +1529,13 @@ public final class PkiSigningBus implements AutoCloseable {
* @throws IllegalArgumentException if {@code algorithmId} is blank
*/
public SignContinuation(zeroecho.pki.api.audit.AccessContext accessContext, String algorithmId,
EncodedObject payload, KeyRef keyRef, Encoding preferredSignatureEncoding, Optional<PkiId> signerOpId) {
DurableContentReference content, KeyRef keyRef, Encoding preferredSignatureEncoding,
Optional<PkiId> signerOpId) {
this.accessContext = Objects.requireNonNull(accessContext, "accessContext");
this.algorithmId = Objects.requireNonNull(algorithmId, "algorithmId");
this.payload = Objects.requireNonNull(payload, "payload");
DurableContentReference exactContent = Objects.requireNonNull(content, "content");
this.content = Optional.of(exactContent);
this.commitment = ContentCommitment.of(exactContent);
this.keyRef = Objects.requireNonNull(keyRef, "keyRef");
this.preferredSignatureEncoding = Objects.requireNonNull(preferredSignatureEncoding,
"preferredSignatureEncoding");
@@ -1326,6 +1545,22 @@ public final class PkiSigningBus implements AutoCloseable {
}
}
private SignContinuation(zeroecho.pki.api.audit.AccessContext accessContext, String algorithmId,
Optional<DurableContentReference> content, ContentCommitment commitment, KeyRef keyRef,
Encoding preferredSignatureEncoding, Optional<PkiId> signerOpId) {
this.accessContext = Objects.requireNonNull(accessContext, "accessContext");
this.algorithmId = Objects.requireNonNull(algorithmId, "algorithmId");
this.content = Objects.requireNonNull(content, "content");
this.commitment = Objects.requireNonNull(commitment, "commitment");
this.keyRef = Objects.requireNonNull(keyRef, "keyRef");
this.preferredSignatureEncoding = Objects.requireNonNull(preferredSignatureEncoding,
"preferredSignatureEncoding");
this.signerOpId = Objects.requireNonNull(signerOpId, "signerOpId");
if (algorithmId.isBlank() || content.isPresent() && !commitment.matches(content.get())) {
throw new IllegalArgumentException("Invalid sign continuation content commitment");
}
}
/**
* Returns a new continuation with the downstream signer workflow operation
* identifier assigned.
@@ -1343,8 +1578,19 @@ public final class PkiSigningBus implements AutoCloseable {
* @throws NullPointerException if {@code opId} is {@code null}
*/
public SignContinuation withSignerOpId(PkiId opId) {
return new SignContinuation(accessContext, algorithmId, payload, keyRef, preferredSignatureEncoding,
Optional.of(opId));
return new SignContinuation(accessContext, algorithmId, content, commitment, keyRef,
preferredSignatureEncoding, Optional.of(opId));
}
private SignContinuation withAlgorithmId(String canonicalAlgorithmId) {
return new SignContinuation(accessContext, canonicalAlgorithmId, content, commitment, keyRef,
preferredSignatureEncoding, signerOpId);
}
/** Returns a terminal continuation retaining only immutable content commitment metadata. */
public SignContinuation withoutLiveContent() {
return new SignContinuation(accessContext, algorithmId, Optional.empty(), commitment, keyRef,
preferredSignatureEncoding, signerOpId);
}
/**
@@ -1363,6 +1609,31 @@ public final class PkiSigningBus implements AutoCloseable {
return signerOpId;
}
/**
* Returns the payload-free durable reference used for recovery.
*
* @return staged content reference; never a live handle or payload
*/
public DurableContentReference content() {
return content.orElseThrow(() -> new IllegalStateException("Retired continuation has no live content"));
}
/** Returns whether this continuation still requires live staged content. */
public boolean hasLiveContent() {
return content.isPresent();
}
/** Restores the committed reference through its owning store for delayed cleanup. */
public DurableContentReference restoreContent(zeroecho.pki.spi.store.StagedContentStore stagedContent)
throws java.io.IOException {
Objects.requireNonNull(stagedContent, "stagedContent");
if (content.isPresent()) {
return content.get();
}
return stagedContent.restoreReference(commitment.storeId(), commitment.contentId(), commitment.encoding(),
commitment.length(), commitment.sha256(), commitment.lifecycle());
}
/**
* Computes the canonical semantic fingerprint for this persisted request.
*
@@ -1380,7 +1651,8 @@ public final class PkiSigningBus implements AutoCloseable {
*/
public String semanticFingerprint(String namespace, Instant deadline) {
Objects.requireNonNull(deadline, "deadline");
return SignatureWorkflow.SignRequest.fingerprint(namespace, accessContext, keyRef, algorithmId, payload,
return SignatureWorkflow.SignRequest.fingerprint(namespace, accessContext, keyRef, algorithmId,
new ReferenceContent(commitment),
Optional.of(preferredSignatureEncoding), Optional.of(deadline));
}
@@ -1411,7 +1683,7 @@ public final class PkiSigningBus implements AutoCloseable {
* <ul>
* <li>format version,</li>
* <li>algorithm identifier,</li>
* <li>payload encoding and bytes,</li>
* <li>store-issued staged-content reference metadata,</li>
* <li>key reference,</li>
* <li>preferred signature encoding,</li>
* <li>presence marker and optional downstream signer workflow operation
@@ -1427,7 +1699,6 @@ public final class PkiSigningBus implements AutoCloseable {
*/
public EncodedObject encode() {
WipeableByteArrayOutputStream bytes = new WipeableByteArrayOutputStream();
byte[] payloadBytes = payload.bytes();
byte[] encoded = null;
try {
try (java.io.DataOutputStream output = new java.io.DataOutputStream(bytes)) {
@@ -1445,9 +1716,13 @@ public final class PkiSigningBus implements AutoCloseable {
}
output.writeUTF(algorithmId);
output.writeUTF(keyRef.value());
output.writeByte(payload.encoding().ordinal());
output.writeInt(payloadBytes.length);
output.write(payloadBytes);
output.writeBoolean(content.isPresent());
output.writeUTF(commitment.storeId());
output.writeUTF(commitment.contentId());
output.writeByte(commitment.encoding().ordinal());
output.writeLong(commitment.length());
output.writeUTF(commitment.sha256());
output.writeByte(commitment.lifecycle().ordinal());
output.writeByte(preferredSignatureEncoding.ordinal());
output.writeBoolean(signerOpId.isPresent());
if (signerOpId.isPresent()) {
@@ -1459,7 +1734,6 @@ public final class PkiSigningBus implements AutoCloseable {
} catch (java.io.IOException ex) {
throw new PkiException("Failed to encode sign continuation: code=CONTINUATION_ENCODE_FAILED");
} finally {
java.util.Arrays.fill(payloadBytes, (byte) 0);
if (encoded != null) {
java.util.Arrays.fill(encoded, (byte) 0);
}
@@ -1489,6 +1763,8 @@ public final class PkiSigningBus implements AutoCloseable {
* </p>
*
* @param obj binary encoded continuation payload; must not be {@code null}
* @param stagedContent owning store used to restore and validate the persisted
* content reference
* @return decoded continuation instance
* @throws NullPointerException if {@code obj} is {@code null}
* @throws IllegalArgumentException if {@code obj} does not use
@@ -1496,13 +1772,14 @@ public final class PkiSigningBus implements AutoCloseable {
* format version is not supported, or if the
* binary payload is malformed
*/
public static SignContinuation decode(EncodedObject obj) {
public static SignContinuation decode(EncodedObject obj,
zeroecho.pki.spi.store.StagedContentStore stagedContent) {
Objects.requireNonNull(obj, "obj");
Objects.requireNonNull(stagedContent, "stagedContent");
if (obj.encoding() != Encoding.BINARY) {
throw new IllegalArgumentException("Expected BINARY continuation payload");
}
byte[] encoded = obj.bytes();
byte[] payloadBytes = null;
try (java.io.DataInputStream input = new java.io.DataInputStream(
new java.io.ByteArrayInputStream(encoded))) {
int version = input.readUnsignedByte();
@@ -1518,30 +1795,85 @@ public final class PkiSigningBus implements AutoCloseable {
: Optional.empty();
String algId = input.readUTF();
KeyRef key = new KeyRef(input.readUTF());
Encoding payloadEncoding = Encoding.values()[input.readUnsignedByte()];
int payloadLength = input.readInt();
if (payloadLength <= 0 || payloadLength > 16 * 1024 * 1024) {
throw new PkiException("Invalid sign continuation payload length");
}
payloadBytes = input.readNBytes(payloadLength);
if (payloadBytes.length != payloadLength) {
throw new PkiException("Truncated sign continuation payload");
}
boolean liveContent = input.readBoolean();
String storeId = input.readUTF();
String contentId = input.readUTF();
Encoding contentEncoding = Encoding.values()[input.readUnsignedByte()];
long contentLength = input.readLong();
String sha256 = input.readUTF();
DurableContentReference.Lifecycle lifecycle = DurableContentReference.Lifecycle
.values()[input.readUnsignedByte()];
ContentCommitment commitment = new ContentCommitment(storeId, contentId, contentEncoding,
contentLength, sha256, lifecycle);
Optional<DurableContentReference> content = liveContent
? Optional.of(stagedContent.restoreReference(storeId, contentId, contentEncoding,
contentLength, sha256, lifecycle))
: Optional.empty();
Encoding preferred = Encoding.values()[input.readUnsignedByte()];
Optional<PkiId> signerId = input.readBoolean() ? Optional.of(new PkiId(input.readUTF()))
: Optional.empty();
requireCompleteInput(input);
zeroecho.pki.api.audit.AccessContext access = new zeroecho.pki.api.audit.AccessContext(principal,
purpose, objectId, formatId);
return new SignContinuation(access, algId, new EncodedObject(payloadEncoding, payloadBytes), key,
preferred, signerId);
return new SignContinuation(access, algId, content, commitment, key, preferred, signerId);
} catch (java.io.IOException | IndexOutOfBoundsException ex) {
throw new PkiException("Malformed sign continuation: code=CONTINUATION_MALFORMED");
} finally {
java.util.Arrays.fill(encoded, (byte) 0);
if (payloadBytes != null) {
java.util.Arrays.fill(payloadBytes, (byte) 0);
}
}
private record ContentCommitment(String storeId, String contentId, Encoding encoding, long length,
String sha256, DurableContentReference.Lifecycle lifecycle) {
private ContentCommitment {
Objects.requireNonNull(storeId, "storeId");
Objects.requireNonNull(contentId, "contentId");
Objects.requireNonNull(encoding, "encoding");
Objects.requireNonNull(sha256, "sha256");
Objects.requireNonNull(lifecycle, "lifecycle");
if (length < MINIMUM_CONTENT_LENGTH) {
throw new IllegalArgumentException("Content length must not be negative");
}
}
private static ContentCommitment of(DurableContentReference reference) {
return new ContentCommitment(reference.storeId(), reference.contentId(), reference.encoding(),
reference.length(), reference.sha256(), reference.lifecycle());
}
private boolean matches(DurableContentReference reference) {
return storeId.equals(reference.storeId()) && contentId.equals(reference.contentId())
&& encoding == reference.encoding() && length == reference.length()
&& sha256.equals(reference.sha256()) && lifecycle == reference.lifecycle();
}
}
private static void requireCompleteInput(java.io.DataInputStream input) throws java.io.IOException {
if (input.read() >= 0) {
throw new java.io.IOException("Trailing sign continuation data");
}
}
private record ReferenceContent(ContentCommitment reference) implements RepeatableContent {
@Override
public java.io.InputStream openStream() throws java.io.IOException {
throw new java.io.IOException("Content reference requires staged-content store");
}
@Override
public java.util.OptionalLong length() {
return java.util.OptionalLong.of(reference.length());
}
@Override
public String contentId() {
return "sha256:" + reference.sha256();
}
@Override
public void close() {
// Reference metadata owns no live resource.
}
}
/**

View File

@@ -33,13 +33,11 @@
******************************************************************************/
package zeroecho.pki.impl.crypto.zeroecholib;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
@@ -87,6 +85,8 @@ import zeroecho.core.alg.rsa.RsaPublicKeySpec;
import zeroecho.core.alg.slhdsa.SlhDsaPublicKeySpec;
import zeroecho.core.alg.sphincsplus.SphincsPlusPublicKeySpec;
import zeroecho.core.context.SignatureContext;
import zeroecho.core.io.CancellationSignal;
import zeroecho.core.io.RepeatableContent;
import zeroecho.core.io.TailStrippingInputStream;
import zeroecho.core.spec.AlgorithmKeySpec;
import zeroecho.core.spec.ContextSpec;
@@ -266,7 +266,7 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
private static final OperationStatus UNKNOWN_OPERATION_STATUS = new OperationStatus(State.FAILED, Instant.EPOCH,
Optional.of(DC_UNKNOWN_OPERATION), Optional.empty());
private static final int OPERATION_RECORD_VERSION = 3;
private static final int OPERATION_RECORD_VERSION = 4;
private static final long MIN_FENCING_TOKEN = 1L;
private final String id;
@@ -488,7 +488,6 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
return opId;
}
byte[] payloadBytes = null;
byte[] signatureBytes = null;
try {
KeyRefParts parts = parseKeyRefOrThrow(request.keyRef(), true);
@@ -514,8 +513,9 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
completeSign(request, expiredStatus());
return opId;
}
payloadBytes = request.payload().bytes();
signatureBytes = signStreaming(request.algorithmId(), prv.key(), pub.key(), payloadBytes);
request.cancellation().throwIfCancelled();
signatureBytes = signStreaming(request.algorithmId(), prv.key(), pub.key(), request.content(),
request.cancellation());
Encoding outEnc = request.preferredSignatureEncoding().orElse(Encoding.BINARY);
EncodedObject signature = encodeSignatureOrThrow(outEnc, signatureBytes);
@@ -553,7 +553,6 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
logSafeFailure("SIGN", DC_CRYPTO_FAILURE, ex);
return opId;
} finally {
clearOwned("sign-payload", payloadBytes);
clearOwned("sign-result-copy", signatureBytes);
}
}
@@ -670,7 +669,6 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
putStatus(opId, new OperationStatus(State.RUNNING, now(), Optional.of(DC_SUBMITTED), Optional.empty()));
byte[] signatureBytes = null;
byte[] payloadBytes = null;
try {
if (request.algorithmId() == null || request.algorithmId().isBlank()) {
throw new InvalidRequestException(DC_INVALID_ALGORITHM_ID);
@@ -683,8 +681,9 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
putStatus(opId, expiredStatus());
return opId;
}
payloadBytes = request.payload().bytes();
boolean ok = verifyStreaming(request.algorithmId(), pub, payloadBytes, signatureBytes);
request.cancellation().throwIfCancelled();
boolean ok = verifyStreaming(request.algorithmId(), pub, request.content(), signatureBytes,
request.cancellation());
Instant completedAt = now();
if (deadlineReached(request.deadline(), completedAt)) {
@@ -716,7 +715,6 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
logSafeFailure("VERIFY", DC_CRYPTO_FAILURE, ex);
return opId;
} finally {
clearOwned("verify-payload", payloadBytes);
clearOwned("verify-signature", signatureBytes);
}
}
@@ -1061,7 +1059,8 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
return a;
}
private byte[] signStreaming(String algorithmId, PrivateKey prv, PublicKey pub, byte[] msg)
private byte[] signStreaming(String algorithmId, PrivateKey prv, PublicKey pub, RepeatableContent content,
CancellationSignal cancellation)
throws GeneralSecurityException, IOException {
Optional<SignatureInteropProfile> profile = SignatureInteropProfiles.resolve(algorithmId);
@@ -1075,14 +1074,14 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
try (SignatureContext signer = session.createContext(contextAlgorithmId, KeyUsage.SIGN, prv, contextSpec)) {
final byte[][] sigHolder = new byte[1][];
try (InputStream in = new TailStrippingInputStream(signer.wrap(new ByteArrayInputStream(msg)), sigLen,
8192) {
try (InputStream source = content.openStream();
InputStream in = new TailStrippingInputStream(signer.wrap(source), sigLen, 8192) {
@Override
protected void processTail(byte[] tail) throws IOException {
sigHolder[0] = (tail == null) ? null : tail.clone();
}
}) {
in.transferTo(OutputStream.nullOutputStream());
consume(in, cancellation);
}
byte[] internalSignature = sigHolder[0];
@@ -1101,7 +1100,8 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
}
}
private boolean verifyStreaming(String algorithmId, PublicKey pub, byte[] msg, byte[] signature)
private boolean verifyStreaming(String algorithmId, PublicKey pub, RepeatableContent content, byte[] signature,
CancellationSignal cancellation)
throws GeneralSecurityException, IOException {
Optional<SignatureInteropProfile> profile = SignatureInteropProfiles.resolve(algorithmId);
@@ -1116,8 +1116,8 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
try (SignatureContext verifier = session.createContext(contextAlgorithmId, KeyUsage.VERIFY, pub, contextSpec)) {
verifier.setExpectedTag(internalSignature);
try (InputStream in = verifier.wrap(new ByteArrayInputStream(msg))) {
in.transferTo(OutputStream.nullOutputStream());
try (InputStream source = content.openStream(); InputStream in = verifier.wrap(source)) {
consume(in, cancellation);
}
return true;
} catch (Exception mismatch) {
@@ -1129,6 +1129,17 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
}
}
private static void consume(InputStream input, CancellationSignal cancellation) throws IOException {
byte[] buffer = new byte[16 * 1024];
try {
while (input.read(buffer) >= 0) {
cancellation.throwIfCancelled();
}
} finally {
Arrays.fill(buffer, (byte) 0);
}
}
private EncodedObject encodeSignatureOrThrow(Encoding encoding, byte[] sigBytes) throws InvalidRequestException {
if (encoding == Encoding.BINARY || encoding == Encoding.DER) {
return new EncodedObject(encoding, sigBytes);
@@ -1482,14 +1493,8 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
}
output.writeUTF(request.keyRef().value());
output.writeUTF(request.algorithmId());
output.writeInt(encodingCode(request.payload().encoding()));
byte[] payload = request.payload().bytes();
try {
output.writeInt(payload.length);
output.write(payload);
} finally {
clearOwned("persisted-request-payload", payload);
}
output.writeUTF(request.content().contentId());
output.writeLong(request.content().length().orElse(-1L));
output.writeBoolean(request.preferredSignatureEncoding().isPresent());
if (request.preferredSignatureEncoding().isPresent()) {
output.writeInt(encodingCode(request.preferredSignatureEncoding().orElseThrow()));
@@ -1516,25 +1521,38 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
objectId, formatId);
KeyRef keyRef = new KeyRef(input.readUTF());
String algorithmId = input.readUTF();
Encoding payloadEncoding = encodingFromCode(input.readInt());
int payloadLength = input.readInt();
if (payloadLength < 1 || payloadLength > 16 * 1024 * 1024) {
throw new IllegalStateException("Invalid persisted signing payload length");
String contentId = input.readUTF();
long contentLength = input.readLong();
if (contentId.isBlank() || contentLength < -1L) {
throw new IllegalStateException("Invalid persisted signing content metadata");
}
byte[] payload = input.readNBytes(payloadLength);
if (payload.length != payloadLength) {
throw new IllegalStateException("Truncated persisted signing payload");
Optional<Encoding> preferred = input.readBoolean() ? Optional.of(encodingFromCode(input.readInt()))
: Optional.empty();
Optional<Instant> deadline = input.readBoolean()
? Optional.of(Instant.ofEpochSecond(input.readLong(), input.readInt()))
: Optional.empty();
return SignRequest.create(submissionId, namespace, 1L, access, keyRef, algorithmId,
new RecoveredContentMetadata(contentId, contentLength), preferred, deadline);
}
/**
* Metadata-only view retained for terminal request identity after restart.
* Recovered non-terminal operations fail closed before this content can execute.
*/
private record RecoveredContentMetadata(String contentId, long persistedLength) implements RepeatableContent {
@Override
public InputStream openStream() throws IOException {
throw new IOException("Recovered content requires staged-content resolution");
}
try {
Optional<Encoding> preferred = input.readBoolean() ? Optional.of(encodingFromCode(input.readInt()))
: Optional.empty();
Optional<Instant> deadline = input.readBoolean()
? Optional.of(Instant.ofEpochSecond(input.readLong(), input.readInt()))
: Optional.empty();
return SignRequest.create(submissionId, namespace, 1L, access, keyRef, algorithmId,
new EncodedObject(payloadEncoding, payload), preferred, deadline);
} finally {
clearOwned("loaded-request-payload", payload);
@Override
public java.util.OptionalLong length() {
return persistedLength < 0L ? java.util.OptionalLong.empty() : java.util.OptionalLong.of(persistedLength);
}
@Override
public void close() {
// Metadata owns no live resource.
}
}

View File

@@ -42,6 +42,11 @@ import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
import zeroecho.core.alg.BootstrapAlgorithmIdentities;
import zeroecho.core.spec.AlgorithmIdentity;
import zeroecho.core.spec.AlgorithmSuite;
import zeroecho.core.spi.AlgorithmExecutionCapability;
import zeroecho.core.spi.AlgorithmExecutionCapabilityProvider;
import zeroecho.core.spi.KeyringUnlockProvider;
import zeroecho.core.storage.KeyringPassword;
import zeroecho.core.storage.KeyringStore;
@@ -75,7 +80,8 @@ import zeroecho.pki.spi.crypto.SignatureWorkflowRuntimeDependencies;
* performs no value logging.
* </p>
*/
public final class ZeroEchoLibSignatureWorkflowProvider implements SignatureWorkflowProvider {
public final class ZeroEchoLibSignatureWorkflowProvider
implements SignatureWorkflowProvider, AlgorithmExecutionCapabilityProvider {
/** Stable failure code for a missing explicit keyring unlock provider. */
public static final String DC_KEYRING_UNLOCK_PROVIDER_REQUIRED = "KEYRING_UNLOCK_PROVIDER_REQUIRED";
/** Stable failure code for an unlock-provider acquisition failure. */
@@ -127,6 +133,42 @@ public final class ZeroEchoLibSignatureWorkflowProvider implements SignatureWork
KEY_REQUIRE_SUFFIX);
}
/**
* Declares the exact classic signature domain implemented by the workflow.
*
* <p>
* This metadata describes the same workflow implementation allocated by this
* provider. It does not select an OID, redefine an identity, or claim key
* availability.
* </p>
*
* @return immutable execution capability contribution
*/
@Override
public java.util.List<AlgorithmExecutionCapability> capabilities() {
Set<AlgorithmIdentity> signatures = Set.of(BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256,
BootstrapAlgorithmIdentities.RSA_PKCS1_SHA384, BootstrapAlgorithmIdentities.RSA_PKCS1_SHA512,
BootstrapAlgorithmIdentities.RSA_PSS_SHA256, BootstrapAlgorithmIdentities.ECDSA_SHA256,
BootstrapAlgorithmIdentities.ECDSA_SHA384, BootstrapAlgorithmIdentities.ECDSA_SHA512,
BootstrapAlgorithmIdentities.ED25519_SIGNATURE, BootstrapAlgorithmIdentities.ED448_SIGNATURE);
return java.util.List.of(new AlgorithmExecutionCapability() {
@Override
public String implementationId() {
return "zeroecho-lib.signature-workflow";
}
@Override
public String domainFingerprint() {
return "zeroecho-lib-signature-v1:bootstrap-classic:sign,verify";
}
@Override
public boolean supports(AlgorithmIdentity identity, AlgorithmSuite suite, Direction direction) {
return signatures.contains(identity) && identity.equals(suite.signature());
}
});
}
/**
* Validates configuration for the ZeroEcho-lib signature workflow provider.
*

View File

@@ -0,0 +1,619 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.impl.framework.x509;
import java.io.ByteArrayOutputStream;
import java.math.BigInteger;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import zeroecho.core.alg.BootstrapAlgorithmIdentities;
import zeroecho.core.spec.AlgorithmIdentity;
/**
* Immutable authoritative standard X.509 bootstrap bindings.
*
* <p>
* Standard OIDs and parameter semantics are code-owned and cannot be overridden
* by configuration or extension ordering. RSA-PSS and EC SubjectPublicKeyInfo
* are represented by typed parameterized rules.
* </p>
*/
public final class StandardX509Bindings {
/** RSA PKCS#1 SHA-256 signature OID. */
public static final String OID_RSA_SHA256 = "1.2.840.113549.1.1.11";
/** RSA PKCS#1 SHA-384 signature OID. */
public static final String OID_RSA_SHA384 = "1.2.840.113549.1.1.12";
/** RSA PKCS#1 SHA-512 signature OID. */
public static final String OID_RSA_SHA512 = "1.2.840.113549.1.1.13";
/** RSA-PSS signature OID. */
public static final String OID_RSA_PSS = "1.2.840.113549.1.1.10";
/** ECDSA SHA-256 signature OID. */
public static final String OID_ECDSA_SHA256 = "1.2.840.10045.4.3.2";
/** ECDSA SHA-384 signature OID. */
public static final String OID_ECDSA_SHA384 = "1.2.840.10045.4.3.3";
/** ECDSA SHA-512 signature OID. */
public static final String OID_ECDSA_SHA512 = "1.2.840.10045.4.3.4";
/** Ed25519 OID. */
public static final String OID_ED25519 = String.join(".", "1", "3", "101", "112");
/** Ed448 OID. */
public static final String OID_ED448 = String.join(".", "1", "3", "101", "113");
/** RSA public-key OID. */
public static final String OID_RSA_PUBLIC_KEY = "1.2.840.113549.1.1.1";
/** EC public-key OID. */
public static final String OID_EC_PUBLIC_KEY = "1.2.840.10045.2.1";
/** P-256 named-curve OID. */
public static final String OID_P256 = "1.2.840.10045.3.1.7";
/** P-384 named-curve OID. */
public static final String OID_P384 = "1.3.132.0.34";
/** P-521 named-curve OID. */
public static final String OID_P521 = "1.3.132.0.35";
private static final X509BindingCatalog CATALOG = createCatalog(X509ComponentCatalog.builtIn());
private StandardX509Bindings() {
}
private static X509BindingCatalog createCatalog(X509ComponentCatalog components) {
Objects.requireNonNull(components, "components");
return X509BindingCatalog.builtIn(List.of(
fixed("zeroecho.signature.rsa-pkcs1-sha256", X509AlgorithmRole.SIGNATURE_ALGORITHM,
BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256, X509AlgorithmIdentifier.derNull(OID_RSA_SHA256),
X509BindingRule.SignatureEncoding.OPAQUE, X509BindingRule.PublicKeyEncoding.NOT_APPLICABLE),
fixed("zeroecho.signature.rsa-pkcs1-sha384", X509AlgorithmRole.SIGNATURE_ALGORITHM,
BootstrapAlgorithmIdentities.RSA_PKCS1_SHA384, X509AlgorithmIdentifier.derNull(OID_RSA_SHA384),
X509BindingRule.SignatureEncoding.OPAQUE, X509BindingRule.PublicKeyEncoding.NOT_APPLICABLE),
fixed("zeroecho.signature.rsa-pkcs1-sha512", X509AlgorithmRole.SIGNATURE_ALGORITHM,
BootstrapAlgorithmIdentities.RSA_PKCS1_SHA512, X509AlgorithmIdentifier.derNull(OID_RSA_SHA512),
X509BindingRule.SignatureEncoding.OPAQUE, X509BindingRule.PublicKeyEncoding.NOT_APPLICABLE),
new RsaPssRule(components),
fixed("zeroecho.signature.ecdsa-sha256", X509AlgorithmRole.SIGNATURE_ALGORITHM,
BootstrapAlgorithmIdentities.ECDSA_SHA256, X509AlgorithmIdentifier.absent(OID_ECDSA_SHA256),
X509BindingRule.SignatureEncoding.ECDSA_DER,
X509BindingRule.PublicKeyEncoding.NOT_APPLICABLE),
fixed("zeroecho.signature.ecdsa-sha384", X509AlgorithmRole.SIGNATURE_ALGORITHM,
BootstrapAlgorithmIdentities.ECDSA_SHA384, X509AlgorithmIdentifier.absent(OID_ECDSA_SHA384),
X509BindingRule.SignatureEncoding.ECDSA_DER,
X509BindingRule.PublicKeyEncoding.NOT_APPLICABLE),
fixed("zeroecho.signature.ecdsa-sha512", X509AlgorithmRole.SIGNATURE_ALGORITHM,
BootstrapAlgorithmIdentities.ECDSA_SHA512, X509AlgorithmIdentifier.absent(OID_ECDSA_SHA512),
X509BindingRule.SignatureEncoding.ECDSA_DER,
X509BindingRule.PublicKeyEncoding.NOT_APPLICABLE),
fixed("zeroecho.signature.ed25519", X509AlgorithmRole.SIGNATURE_ALGORITHM,
BootstrapAlgorithmIdentities.ED25519_SIGNATURE, X509AlgorithmIdentifier.absent(OID_ED25519),
X509BindingRule.SignatureEncoding.OPAQUE, X509BindingRule.PublicKeyEncoding.NOT_APPLICABLE),
fixed("zeroecho.signature.ed448", X509AlgorithmRole.SIGNATURE_ALGORITHM,
BootstrapAlgorithmIdentities.ED448_SIGNATURE, X509AlgorithmIdentifier.absent(OID_ED448),
X509BindingRule.SignatureEncoding.OPAQUE, X509BindingRule.PublicKeyEncoding.NOT_APPLICABLE),
fixed("zeroecho.spki.rsa", X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM,
BootstrapAlgorithmIdentities.RSA_PUBLIC_KEY, X509AlgorithmIdentifier.derNull(OID_RSA_PUBLIC_KEY),
X509BindingRule.SignatureEncoding.NOT_APPLICABLE, X509BindingRule.PublicKeyEncoding.RSA_PKCS1_DER),
new EcPublicKeyRule(components),
fixed("zeroecho.spki.ed25519", X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM,
BootstrapAlgorithmIdentities.ED25519_PUBLIC_KEY, X509AlgorithmIdentifier.absent(OID_ED25519),
X509BindingRule.SignatureEncoding.NOT_APPLICABLE, X509BindingRule.PublicKeyEncoding.RAW),
fixed("zeroecho.spki.ed448", X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM,
BootstrapAlgorithmIdentities.ED448_PUBLIC_KEY, X509AlgorithmIdentifier.absent(OID_ED448),
X509BindingRule.SignatureEncoding.NOT_APPLICABLE, X509BindingRule.PublicKeyEncoding.RAW)));
}
/**
* Returns the immutable built-in standard catalog.
*
* @return authoritative binding snapshot
*/
public static X509BindingCatalog catalog() {
return CATALOG;
}
/**
* Creates the built-in rules against an immutable component authority.
*
* @param components exact component catalog used by parameterized rules
* @return immutable built-in binding catalog
*/
public static X509BindingCatalog catalog(X509ComponentCatalog components) {
return createCatalog(components);
}
private static X509BindingRule fixed(String id, X509AlgorithmRole role, AlgorithmIdentity identity,
X509AlgorithmIdentifier identifier, X509BindingRule.SignatureEncoding signatureEncoding,
X509BindingRule.PublicKeyEncoding publicKeyEncoding) {
return new FixedRule(id, role, identity, identifier, signatureEncoding, publicKeyEncoding);
}
private record FixedRule(String id, X509AlgorithmRole role, AlgorithmIdentity identity,
X509AlgorithmIdentifier identifier, SignatureEncoding signatureEncoding,
PublicKeyEncoding publicKeyEncoding) implements X509BindingRule {
private FixedRule {
Objects.requireNonNull(id, "id");
Objects.requireNonNull(role, "role");
Objects.requireNonNull(identity, "identity");
Objects.requireNonNull(identifier, "identifier");
Objects.requireNonNull(signatureEncoding, "signatureEncoding");
Objects.requireNonNull(publicKeyEncoding, "publicKeyEncoding");
}
@Override
public String oid() {
return identifier.oid();
}
@Override
public String semanticFingerprint() {
return id + "|" + role + "|" + identity.canonicalForm() + "|" + identifier.canonicalForm() + "|"
+ signatureEncoding + "|" + publicKeyEncoding;
}
@Override
public Optional<X509AlgorithmIdentifier> encode(AlgorithmIdentity candidate) {
return identity.equals(candidate) ? Optional.of(identifier) : Optional.empty();
}
@Override
public Optional<AlgorithmIdentity> decode(X509AlgorithmIdentifier candidate) {
if (!identifier.oid().equals(candidate.oid())) {
return Optional.empty();
}
if (!identifier.equals(candidate)) {
throw new IllegalArgumentException("Non-canonical X.509 algorithm parameters");
}
return Optional.of(identity);
}
}
/** Parameterized authority for RSA-PSS signature identifiers. */
private static final class RsaPssRule implements X509BindingRule {
private final X509ComponentCatalog components;
private RsaPssRule(X509ComponentCatalog components) {
this.components = components;
}
@Override
public String id() {
return "zeroecho.signature.rsa-pss";
}
@Override
public X509AlgorithmRole role() {
return X509AlgorithmRole.SIGNATURE_ALGORITHM;
}
@Override
public String oid() {
return OID_RSA_PSS;
}
@Override
public String semanticFingerprint() {
return "rsa-pss-v1|sha2-256,sha2-384,sha2-512|mgf1|salt-nonnegative|trailer-1|canonical-explicit";
}
@Override
public SignatureEncoding signatureEncoding() {
return SignatureEncoding.OPAQUE;
}
@Override
public PublicKeyEncoding publicKeyEncoding() {
return PublicKeyEncoding.NOT_APPLICABLE;
}
@Override
public Optional<X509AlgorithmIdentifier> encode(AlgorithmIdentity identity) {
if (identity.kind() != AlgorithmIdentity.Kind.SIGNATURE
|| !"zeroecho/rsa-pss".equals(identity.family().canonicalForm())) {
return Optional.empty();
}
if (!(identity.parameters() instanceof AlgorithmIdentity.RsaPssParameters parameters)) {
throw new IllegalArgumentException("RSA-PSS identity has invalid typed parameters");
}
byte[] der = encodePss(parameters, components);
return Optional.of(X509AlgorithmIdentifier.exact(OID_RSA_PSS, der));
}
@Override
public Optional<AlgorithmIdentity> decode(X509AlgorithmIdentifier identifier) {
if (!OID_RSA_PSS.equals(identifier.oid())) {
return Optional.empty();
}
if (identifier.parameterForm() != X509AlgorithmIdentifier.ParameterForm.EXACT_DER) {
throw new IllegalArgumentException("RSA-PSS parameters must be explicit");
}
AlgorithmIdentity.RsaPssParameters parameters = decodePss(identifier.parameters(), components);
return Optional.of(BootstrapAlgorithmIdentities.rsaPss(parameters.hash(), parameters.maskHash(),
parameters.saltLength()));
}
}
/** Parameterized authority for named-curve EC public-key identifiers. */
private static final class EcPublicKeyRule implements X509BindingRule {
private final X509ComponentCatalog components;
private EcPublicKeyRule(X509ComponentCatalog components) {
this.components = components;
}
@Override
public String id() {
return "zeroecho.spki.ec-named-curve";
}
@Override
public X509AlgorithmRole role() {
return X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM;
}
@Override
public String oid() {
return OID_EC_PUBLIC_KEY;
}
@Override
public String semanticFingerprint() {
return "ec-spki-v1|named-only|p-256,p-384,p-521|sec1-point";
}
@Override
public SignatureEncoding signatureEncoding() {
return SignatureEncoding.NOT_APPLICABLE;
}
@Override
public PublicKeyEncoding publicKeyEncoding() {
return PublicKeyEncoding.EC_POINT;
}
@Override
public Optional<X509AlgorithmIdentifier> encode(AlgorithmIdentity identity) {
if (identity.kind() != AlgorithmIdentity.Kind.PUBLIC_KEY
|| !"zeroecho/ec".equals(identity.family().canonicalForm())) {
return Optional.empty();
}
String curveOid = components.oid(X509ComponentCatalog.Kind.NAMED_CURVE, identity);
return Optional.of(X509AlgorithmIdentifier.exact(OID_EC_PUBLIC_KEY, StrictDer.oid(curveOid)));
}
@Override
public Optional<AlgorithmIdentity> decode(X509AlgorithmIdentifier identifier) {
if (!OID_EC_PUBLIC_KEY.equals(identifier.oid())) {
return Optional.empty();
}
if (identifier.parameterForm() != X509AlgorithmIdentifier.ParameterForm.EXACT_DER) {
throw new IllegalArgumentException("EC SubjectPublicKeyInfo requires named-curve parameters");
}
String curve = StrictDer.decodeOid(identifier.parameters());
return Optional.of(components.identity(X509ComponentCatalog.Kind.NAMED_CURVE, curve));
}
}
private static byte[] encodePss(AlgorithmIdentity.RsaPssParameters parameters,
X509ComponentCatalog components) {
String hashOid = requireDigestOid(parameters.hash(), components);
String maskHashOid = requireDigestOid(parameters.maskHash(), components);
if (!BootstrapAlgorithmIdentities.MGF1.equals(parameters.mask())) {
throw new IllegalArgumentException("RSA-PSS requires MGF1");
}
byte[] hashAlgorithm = StrictDer.sequence(StrictDer.oid(hashOid), StrictDer.nullValue());
byte[] maskHashAlgorithm = StrictDer.sequence(StrictDer.oid(maskHashOid), StrictDer.nullValue());
byte[] maskAlgorithm = StrictDer.sequence(
StrictDer.oid(components.oid(X509ComponentCatalog.Kind.MASK_GENERATION, parameters.mask())),
maskHashAlgorithm);
return StrictDer.sequence(StrictDer.explicit(0, hashAlgorithm), StrictDer.explicit(1, maskAlgorithm),
StrictDer.explicit(2, StrictDer.integer(parameters.saltLength())));
}
private static AlgorithmIdentity.RsaPssParameters decodePss(byte[] encoded, X509ComponentCatalog components) {
StrictDer.Reader sequence = StrictDer.reader(encoded).readConstructed(0x30);
byte[] hashAlgorithm = sequence.readConstructed(0xa0).readOnlyValue(0x30);
byte[] maskAlgorithm = sequence.readConstructed(0xa1).readOnlyValue(0x30);
int saltLength = sequence.readConstructed(0xa2).readOnlyInteger();
if (sequence.hasRemaining()) {
throw new IllegalArgumentException("RSA-PSS DEFAULT trailer must be omitted");
}
sequence.requireEnd();
AlgorithmIdentity hash = decodeDigestAlgorithm(hashAlgorithm, components);
StrictDer.Reader mask = StrictDer.readerContent(maskAlgorithm);
String maskOid = mask.readOid();
if (!BootstrapAlgorithmIdentities.MGF1.equals(
components.identity(X509ComponentCatalog.Kind.MASK_GENERATION, maskOid))) {
throw new IllegalArgumentException("RSA-PSS mask algorithm must be MGF1");
}
byte[] maskHashAlgorithm = mask.readOnlyValue(0x30);
mask.requireEnd();
AlgorithmIdentity maskHash = decodeDigestAlgorithm(maskHashAlgorithm, components);
AlgorithmIdentity.RsaPssParameters parameters = new AlgorithmIdentity.RsaPssParameters(hash,
BootstrapAlgorithmIdentities.MGF1, maskHash, saltLength, 1);
if (!Arrays.equals(encoded, encodePss(parameters, components))) {
throw new IllegalArgumentException("RSA-PSS parameters are not canonical");
}
return parameters;
}
private static AlgorithmIdentity decodeDigestAlgorithm(byte[] content, X509ComponentCatalog components) {
StrictDer.Reader reader = StrictDer.readerContent(content);
String oid = reader.readOid();
reader.readNull();
reader.requireEnd();
return components.identity(X509ComponentCatalog.Kind.DIGEST, oid);
}
private static String requireDigestOid(AlgorithmIdentity digest, X509ComponentCatalog components) {
return components.oid(X509ComponentCatalog.Kind.DIGEST, digest);
}
/**
* Minimal strict DER support for the fixed standard parameter structures.
*/
private enum StrictDer {
;
private static final int MINIMUM_OID_COMPONENTS = 2;
private static final int SHORT_LENGTH_BOUND = 128;
private static byte[] sequence(byte[]... values) {
return tagged(0x30, concatenate(values));
}
private static byte[] explicit(int tag, byte[] value) {
return tagged(0xa0 + tag, value);
}
private static byte[] nullValue() {
return new byte[] { 0x05, 0x00 };
}
private static byte[] integer(int value) {
if (value < 0) {
throw new IllegalArgumentException("DER integer must not be negative");
}
return tagged(0x02, BigInteger.valueOf(value).toByteArray());
}
private static byte[] oid(String dotted) {
String[] components = dotted.split("\\.");
if (components.length < MINIMUM_OID_COMPONENTS) {
throw new IllegalArgumentException("Invalid OID");
}
int first = Integer.parseInt(components[0]);
int second = Integer.parseInt(components[1]);
ByteArrayOutputStream content = new ByteArrayOutputStream();
writeBase128(content, 40L * first + second);
for (int index = 2; index < components.length; index++) {
writeBase128(content, Long.parseLong(components[index]));
}
return tagged(0x06, content.toByteArray());
}
private static String decodeOid(byte[] der) {
Reader reader = reader(der);
String oid = reader.readOid();
reader.requireEnd();
if (!Arrays.equals(der, oid(oid))) {
throw new IllegalArgumentException("OID is not canonical DER");
}
return oid;
}
private static byte[] tagged(int tag, byte[] content) {
ByteArrayOutputStream output = new ByteArrayOutputStream(content.length + 6);
output.write(tag);
writeLength(output, content.length);
output.writeBytes(content);
return output.toByteArray();
}
private static byte[] concatenate(byte[][] values) {
ByteArrayOutputStream output = new ByteArrayOutputStream();
for (byte[] value : values) {
output.writeBytes(value);
}
return output.toByteArray();
}
private static void writeLength(ByteArrayOutputStream output, int length) {
if (length < SHORT_LENGTH_BOUND) {
output.write(length);
return;
}
int octets = 0;
int current = length;
while (current != 0) {
octets++;
current >>>= 8;
}
output.write(0x80 | octets);
for (int shift = (octets - 1) * 8; shift >= 0; shift -= 8) {
output.write(length >>> shift);
}
}
private static void writeBase128(ByteArrayOutputStream output, long value) {
if (value < 0) {
throw new IllegalArgumentException("OID component must not be negative");
}
int groups = 1;
long current = value;
while ((current >>>= 7) != 0) {
groups++;
}
for (int group = groups - 1; group >= 0; group--) {
int octet = (int) ((value >>> (group * 7)) & 0x7f);
output.write(group == 0 ? octet : octet | 0x80);
}
}
private static Reader reader(byte[] encoded) {
return new Reader(encoded.clone(), 0, encoded.length);
}
private static Reader readerContent(byte[] content) {
return new Reader(content.clone(), 0, content.length);
}
/** Bounded cursor over one independently owned DER byte sequence. */
private static final class Reader {
private final byte[] data;
private final int end;
private int offset;
private Reader(byte[] data, int offset, int end) {
this.data = data;
this.offset = offset;
this.end = end;
}
private Reader readConstructed(int expectedTag) {
byte[] value = readValue(expectedTag);
return new Reader(value, 0, value.length);
}
private byte[] readOnlyValue(int expectedTag) {
byte[] value = readValue(expectedTag);
requireEnd();
return value;
}
private int readOnlyInteger() {
byte[] value = readValue(0x02);
requireEnd();
if (value.length == 0 || value.length > 5 || (value[0] & 0x80) != 0) {
throw new IllegalArgumentException("Invalid non-negative DER integer");
}
BigInteger integer = new BigInteger(value);
if (!Arrays.equals(value, integer.toByteArray()) || integer.bitLength() > 31) {
throw new IllegalArgumentException("Non-canonical or excessive DER integer");
}
return integer.intValue();
}
private String readOid() {
byte[] value = readValue(0x06);
if (value.length == 0) {
throw new IllegalArgumentException("Empty DER OID");
}
StringBuilder dotted = new StringBuilder();
long component = 0;
boolean first = true;
boolean continued = false;
for (byte octetValue : value) {
if (component > (Long.MAX_VALUE >>> 7)) {
throw new IllegalArgumentException("DER OID component overflow");
}
int octet = octetValue & 0xff;
component = (component << 7) | (octet & 0x7f);
continued = (octet & 0x80) != 0;
if (!continued) {
if (first) {
int firstArc = component < 40 ? 0 : component < 80 ? 1 : 2;
dotted.append(firstArc).append('.').append(component - 40L * firstArc);
first = false;
} else {
dotted.append('.').append(component);
}
component = 0;
}
}
if (continued || first) {
throw new IllegalArgumentException("Truncated DER OID");
}
return dotted.toString();
}
private void readNull() {
if (readValue(0x05).length != 0) {
throw new IllegalArgumentException("Invalid DER NULL");
}
}
private byte[] readValue(int expectedTag) {
if (offset >= end || (data[offset++] & 0xff) != expectedTag) {
throw new IllegalArgumentException("Unexpected DER tag");
}
int length = readLength();
if (length > end - offset) {
throw new IllegalArgumentException("Truncated DER value");
}
byte[] value = Arrays.copyOfRange(data, offset, offset + length);
offset += length;
return value;
}
private int readLength() {
if (offset >= end) {
throw new IllegalArgumentException("Truncated DER length");
}
int first = data[offset++] & 0xff;
if (first < SHORT_LENGTH_BOUND) {
return first;
}
int octets = first & 0x7f;
if (octets == 0 || octets > 4 || octets > end - offset) {
throw new IllegalArgumentException("Invalid DER length");
}
int length = 0;
for (int index = 0; index < octets; index++) {
if (length > (Integer.MAX_VALUE >>> 8)) {
throw new IllegalArgumentException("DER length overflow");
}
length = (length << 8) | (data[offset++] & 0xff);
}
if (length < SHORT_LENGTH_BOUND) {
throw new IllegalArgumentException("Non-canonical DER length");
}
return length;
}
private void requireEnd() {
if (offset != end) {
throw new IllegalArgumentException("Trailing DER data");
}
}
private boolean hasRemaining() {
return offset != end;
}
}
}
}

View File

@@ -0,0 +1,681 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.impl.framework.x509;
import java.io.IOException;
import java.io.InputStream;
import java.util.Objects;
import zeroecho.core.io.CancellationSignal;
import zeroecho.core.io.RepeatableContent;
/**
* Focused incremental canonical-DER structural validator.
*
* <p>
* Validation consumes exactly one object, rejects indefinite and non-minimal
* lengths, and checks canonical primitive forms without materializing aggregate
* content. Aggregate offsets and lengths use {@code long}. The fixed structural
* depth is an X.509 adapter capability, not an aggregate byte or cardinality
* limit.
* </p>
*
* <p>
* Canonical SET ordering uses two monotonic comparison readers per active,
* fixed-bounded structure depth. Each reader moves forward only, so validation
* is {@code O(B)} for encoded size {@code B} with auxiliary heap bounded by the
* fixed depth and comparison-buffer size.
* </p>
*/
public final class StreamingDerReader {
private static final int BUFFER_BYTES = 16 * 1024;
private static final int MAXIMUM_X509_STRUCTURE_DEPTH = 64;
private static final int HIGH_TAG_NUMBER = 0x1f;
private static final int CONTINUATION_BIT = 0x80;
private static final long SHORT_LENGTH_LIMIT = 128L;
/**
* Validates one complete canonical DER object.
*
* @param content repeatable original content
* @param cancellation cancellation signal
* @return exact encoded length
* @throws IOException if input fails, is malformed, non-canonical, truncated,
* or has trailing data
*/
public long validate(RepeatableContent content, CancellationSignal cancellation) throws IOException {
Objects.requireNonNull(content, "content");
Objects.requireNonNull(cancellation, "cancellation");
try (CountedInput input = new CountedInput(content.openStream());
OrderingContext ordering = new OrderingContext(content)) {
Header root = HeaderReader.read(input);
readValue(input, root, 0, cancellation, ordering);
if (input.read() >= 0) {
throw new IOException("Trailing DER data: code=TRAILING_DER_DATA");
}
long length = input.position();
if (content.length().isPresent() && length != content.length().getAsLong()) {
throw new IOException("DER length mismatch: code=NON_CANONICAL_DER");
}
return length;
}
}
/**
* Validates and locates the signed portions of one certificate or CRL.
*
* @param content original repeatable DER
* @param kind signed-object grammar
* @param cancellation cancellation signal
* @return immutable long-offset layout
* @throws IOException if structure or canonicality is invalid
*/
public SignedObjectLayout inspectSignedObject(RepeatableContent content, SignedObjectKind kind,
CancellationSignal cancellation) throws IOException {
validate(content, cancellation);
try (CountedInput input = new CountedInput(content.openStream())) {
Header outer = HeaderReader.read(input);
requireSequence(outer);
long outerEnd = Math.addExact(input.position(), outer.length());
Header tbs = HeaderReader.read(input);
requireSequence(tbs);
long tbsTotalLength = tbs.encodedLength();
AlgorithmAndKey inner = inspectTbs(content, kind, tbs, cancellation);
skip(input, tbs.length(), cancellation);
Header outerAlgorithm = HeaderReader.read(input);
requireSequence(outerAlgorithm);
skip(input, outerAlgorithm.length(), cancellation);
Header signature = HeaderReader.read(input);
if (!signature.universal() || signature.tagNumber() != 3 || signature.constructed()
|| signature.length() < 1L) {
throw new IOException("Malformed signed-object signature BIT STRING");
}
int unused = input.readRequired();
if (unused != 0) {
throw new IOException("Signed-object signature has unused bits");
}
long signatureOffset = input.position();
skip(input, signature.length() - 1L, cancellation);
if (input.position() != outerEnd || input.read() >= 0) {
throw new IOException("Signed object has trailing data");
}
return new SignedObjectLayout(tbs.start(), tbsTotalLength, inner.algorithmOffset(),
inner.algorithmLength(), outerAlgorithm.start(), outerAlgorithm.encodedLength(),
inner.spkiOffset(), inner.spkiLength(), signatureOffset, signature.length() - 1L);
}
}
private static AlgorithmAndKey inspectTbs(RepeatableContent content, SignedObjectKind kind, Header tbs,
CancellationSignal cancellation) throws IOException {
try (CountedInput input = new CountedInput(content.openStream())) {
skip(input, tbs.valueOffset(), cancellation);
Header child = HeaderReader.read(input);
if (kind == SignedObjectKind.CERTIFICATE) {
if (child.contextSpecific(0)) {
skip(input, child.length(), cancellation);
child = HeaderReader.read(input);
}
requireUniversal(child, 2);
skip(input, child.length(), cancellation);
child = HeaderReader.read(input);
} else if (child.universal() && child.tagNumber() == 2) {
skip(input, child.length(), cancellation);
child = HeaderReader.read(input);
}
requireSequence(child);
long algorithmOffset = child.start();
long algorithmLength = child.encodedLength();
if (kind == SignedObjectKind.CRL) {
return new AlgorithmAndKey(algorithmOffset, algorithmLength, -1L, 0L);
}
skip(input, child.length(), cancellation);
for (int index = 0; index < 3; index++) {
Header field = HeaderReader.read(input);
skip(input, field.length(), cancellation);
}
Header spki = HeaderReader.read(input);
requireSequence(spki);
return new AlgorithmAndKey(algorithmOffset, algorithmLength, spki.start(), spki.encodedLength());
}
}
private static void requireSequence(Header header) throws IOException {
requireUniversal(header, 16);
if (!header.constructed()) {
throw new IOException("Expected constructed DER SEQUENCE");
}
}
private static void requireUniversal(Header header, int tagNumber) throws IOException {
if (!header.universal() || header.tagNumber() != tagNumber) {
throw new IOException("Unexpected signed-object DER field");
}
}
private static void readValue(CountedInput input, Header header, int depth, CancellationSignal cancellation,
OrderingContext ordering) throws IOException {
if (depth >= MAXIMUM_X509_STRUCTURE_DEPTH) {
throw new IOException("DER nesting exceeds X.509 adapter capability");
}
long end = Math.addExact(input.position(), header.length());
if (header.constructed()) {
requireConstructedForm(header);
SetOrdering setOrdering = header.universal() && header.tagNumber() == 17
? ordering.begin(depth, header.valueOffset(), cancellation)
: null;
while (input.position() < end) {
cancellation.throwIfCancelled();
Header child = HeaderReader.read(input);
long childEnd = Math.addExact(input.position(), child.length());
if (childEnd > end) {
throw new IOException("DER child exceeds parent: code=MALFORMED_SIGNED_OBJECT");
}
readValue(input, child, depth + 1, cancellation, ordering);
if (setOrdering != null && setOrdering.accept(child.encodedLength(), cancellation) > 0) {
throw new IOException("Non-canonical DER SET ordering: code=NON_CANONICAL_DER");
}
}
if (setOrdering != null) {
setOrdering.finish(cancellation);
}
} else {
requirePrimitiveForm(header);
PrimitiveReader.read(input, header, cancellation);
}
if (input.position() != end) {
throw new IOException("DER value length mismatch: code=MALFORMED_SIGNED_OBJECT");
}
}
private static void requireConstructedForm(Header header) throws IOException {
if (header.universal() && header.tagNumber() != 16 && header.tagNumber() != 17) {
throw new IOException("Constructed primitive is not canonical DER");
}
}
private static void requirePrimitiveForm(Header header) throws IOException {
if (header.universal() && (header.tagNumber() == 16 || header.tagNumber() == 17)) {
throw new IOException("Primitive container is not canonical DER");
}
}
private static void skip(CountedInput input, long length, CancellationSignal cancellation) throws IOException {
byte[] buffer = new byte[BUFFER_BYTES];
long remaining = length;
while (remaining != 0L) {
cancellation.throwIfCancelled();
int read = input.read(buffer, 0, (int) Math.min(buffer.length, remaining));
if (read < 0) {
throw new IOException("Truncated DER value");
}
if (read == 0) {
input.readRequired();
remaining--;
} else {
remaining -= read;
}
}
}
/**
* Supported signed-object grammar.
*/
public enum SignedObjectKind {
/** X.509 Certificate. */
CERTIFICATE,
/** X.509 CertificateList (CRL). */
CRL
}
/**
* Exact offsets into the validated original DER.
*
* @param tbsOffset complete TBS TLV offset
* @param tbsLength complete TBS TLV length
* @param tbsAlgorithmOffset TBS AlgorithmIdentifier TLV offset
* @param tbsAlgorithmLength TBS AlgorithmIdentifier TLV length
* @param outerAlgorithmOffset outer AlgorithmIdentifier TLV offset
* @param outerAlgorithmLength outer AlgorithmIdentifier TLV length
* @param subjectPublicKeyInfoOffset SPKI TLV offset, or {@code -1} for CRLs
* @param subjectPublicKeyInfoLength SPKI TLV length, or zero for CRLs
* @param signatureOffset signature octets offset
* @param signatureLength signature octets length
*/
public record SignedObjectLayout(long tbsOffset, long tbsLength, long tbsAlgorithmOffset,
long tbsAlgorithmLength, long outerAlgorithmOffset, long outerAlgorithmLength,
long subjectPublicKeyInfoOffset, long subjectPublicKeyInfoLength, long signatureOffset,
long signatureLength) {
}
private record AlgorithmAndKey(long algorithmOffset, long algorithmLength, long spkiOffset, long spkiLength) {
}
private record Header(int firstTag, int tagNumber, long length, long start, long valueOffset) {
private boolean constructed() {
return (firstTag & 0x20) != 0;
}
private boolean universal() {
return (firstTag & 0xc0) == 0;
}
private boolean contextSpecific(int expectedTag) {
return (firstTag & 0xc0) == 0x80 && tagNumber == expectedTag;
}
private long encodedLength() {
return Math.addExact(valueOffset - start, length);
}
}
/** Canonical DER tag and length decoder. */
private static final class HeaderReader {
private static Header read(CountedInput input) throws IOException {
long start = input.position();
int firstTag = input.readRequired();
if (firstTag == 0) {
throw new IOException("DER end-of-contents is forbidden");
}
int tagNumber = readTagNumber(input, firstTag);
long length = readLength(input);
return new Header(firstTag, tagNumber, length, start, input.position());
}
private static int readTagNumber(CountedInput input, int firstTag) throws IOException {
int tagNumber = firstTag & HIGH_TAG_NUMBER;
if (tagNumber == HIGH_TAG_NUMBER) {
int octet = input.readRequired();
if ((octet & 0x7f) == 0) {
throw new IOException("Non-minimal DER high tag");
}
while ((octet & CONTINUATION_BIT) != 0) {
octet = input.readRequired();
}
tagNumber = -1;
}
return tagNumber;
}
private static long readLength(CountedInput input) throws IOException {
int firstLength = input.readRequired();
if (firstLength < CONTINUATION_BIT) {
return firstLength;
}
int octets = firstLength & 0x7f;
if (octets == 0) {
throw new IOException("Indefinite DER length: code=NON_CANONICAL_DER");
}
if (octets > Long.BYTES) {
throw new IOException("DER length is not representable");
}
int first = input.readRequired();
if (first == 0) {
throw new IOException("Non-minimal DER length: code=NON_CANONICAL_DER");
}
long length = first;
for (int index = 1; index < octets; index++) {
if (length > (Long.MAX_VALUE >>> Byte.SIZE)) {
throw new IOException("DER length overflow: code=CONTENT_LENGTH_OVERFLOW");
}
length = (length << Byte.SIZE) | input.readRequired();
}
if (length < SHORT_LENGTH_LIMIT) {
throw new IOException("Non-minimal DER length: code=NON_CANONICAL_DER");
}
return length;
}
}
/** Fixed-depth owner of monotonic comparison streams for DER SET values. */
private static final class OrderingContext implements AutoCloseable {
private final RepeatableContent content;
private final SetOrdering[] levels = new SetOrdering[MAXIMUM_X509_STRUCTURE_DEPTH];
private OrderingContext(RepeatableContent content) {
this.content = content;
}
private SetOrdering begin(int depth, long valueOffset, CancellationSignal cancellation) throws IOException {
SetOrdering ordering = levels[depth];
if (ordering == null) {
ordering = new SetOrdering(content);
levels[depth] = ordering;
}
ordering.begin(valueOffset, cancellation);
return ordering;
}
@Override
public void close() throws IOException {
IOException failure = null;
for (SetOrdering ordering : levels) {
if (ordering == null) {
continue;
}
try {
ordering.closeStreams();
} catch (IOException exception) {
if (failure == null) {
failure = exception;
} else {
failure.addSuppressed(exception);
}
}
}
if (failure != null) {
throw failure;
}
}
}
/** Adjacent-child comparator whose two source passes only move forward. */
private static final class SetOrdering {
private static final long NO_PREVIOUS_VALUE = -1L;
private final CountedInput left;
private final CountedInput right;
private final byte[] leftBuffer = new byte[BUFFER_BYTES];
private final byte[] rightBuffer = new byte[BUFFER_BYTES];
private long previousLength = NO_PREVIOUS_VALUE;
private SetOrdering(RepeatableContent content) throws IOException {
left = new CountedInput(content.openStream());
try {
right = new CountedInput(content.openStream());
} catch (IOException exception) {
left.close();
throw exception;
}
}
private void begin(long valueOffset, CancellationSignal cancellation) throws IOException {
advanceTo(left, valueOffset, leftBuffer, cancellation);
advanceTo(right, valueOffset, rightBuffer, cancellation);
previousLength = NO_PREVIOUS_VALUE;
}
private int accept(long currentLength, CancellationSignal cancellation) throws IOException {
if (previousLength == NO_PREVIOUS_VALUE) {
skipWithBuffer(right, currentLength, rightBuffer, cancellation);
previousLength = currentLength;
return 0;
}
int comparison = compare(previousLength, currentLength, cancellation);
previousLength = currentLength;
return comparison;
}
private void finish(CancellationSignal cancellation) throws IOException {
if (previousLength != NO_PREVIOUS_VALUE) {
skipWithBuffer(left, previousLength, leftBuffer, cancellation);
}
}
private int compare(long leftLength, long rightLength, CancellationSignal cancellation) throws IOException {
long common = Math.min(leftLength, rightLength);
int comparison = 0;
long remaining = common;
while (remaining != 0L) {
cancellation.throwIfCancelled();
int count = (int) Math.min(BUFFER_BYTES, remaining);
readExactly(left, leftBuffer, count);
readExactly(right, rightBuffer, count);
if (comparison == 0) {
comparison = compareBuffers(leftBuffer, rightBuffer, count);
}
remaining -= count;
}
skipWithBuffer(left, leftLength - common, leftBuffer, cancellation);
skipWithBuffer(right, rightLength - common, rightBuffer, cancellation);
return comparison == 0 ? Long.compare(leftLength, rightLength) : comparison;
}
private void closeStreams() throws IOException {
IOException failure = null;
try {
left.close();
} catch (IOException exception) {
failure = exception;
}
try {
right.close();
} catch (IOException exception) {
if (failure == null) {
failure = exception;
} else {
failure.addSuppressed(exception);
}
}
if (failure != null) {
throw failure;
}
}
private static void advanceTo(CountedInput input, long position, byte[] buffer,
CancellationSignal cancellation) throws IOException {
if (input.position() > position) {
throw new IOException("DER SET comparison stream moved backward");
}
skipWithBuffer(input, position - input.position(), buffer, cancellation);
}
private static void skipWithBuffer(InputStream input, long length, byte[] buffer,
CancellationSignal cancellation) throws IOException {
long remaining = length;
while (remaining != 0L) {
cancellation.throwIfCancelled();
int count = (int) Math.min(buffer.length, remaining);
readExactly(input, buffer, count);
remaining -= count;
}
}
private static void readExactly(InputStream input, byte[] buffer, int length) throws IOException {
int offset = 0;
while (offset != length) {
int count = input.read(buffer, offset, length - offset);
if (count < 0) {
throw new IOException("Truncated DER value");
}
if (count == 0) {
int value = input.read();
if (value < 0) {
throw new IOException("Truncated DER value");
}
buffer[offset] = (byte) value;
offset++;
} else {
offset += count;
}
}
}
private static int compareBuffers(byte[] left, byte[] right, int length) {
for (int index = 0; index < length; index++) {
int comparison = Integer.compare(Byte.toUnsignedInt(left[index]), Byte.toUnsignedInt(right[index]));
if (comparison != 0) {
return comparison;
}
}
return 0;
}
}
/**
* Input wrapper retaining an overflow-checked long byte position.
*/
private static final class CountedInput extends InputStream {
private final InputStream delegate;
private long position;
private CountedInput(InputStream delegate) {
super();
this.delegate = delegate;
}
@Override
public int read() throws IOException {
int value = delegate.read();
if (value >= 0) {
position = Math.addExact(position, 1L);
}
return value;
}
@Override
public int read(byte[] bytes, int offset, int length) throws IOException {
int count = delegate.read(bytes, offset, length);
if (count > 0) {
position = Math.addExact(position, count);
}
return count;
}
@Override
public void close() throws IOException {
delegate.close();
}
private int readRequired() throws IOException {
int value = read();
if (value < 0) {
throw new IOException("Truncated DER object");
}
return value;
}
private long position() {
return position;
}
}
/**
* Canonical validation for primitive DER values.
*/
private static final class PrimitiveReader {
private static final long EMPTY_LENGTH = 0L;
private static final long SINGLE_OCTET_LENGTH = 1L;
private static void read(CountedInput input, Header header, CancellationSignal cancellation)
throws IOException {
if (!header.universal()) {
skip(input, header.length(), cancellation);
return;
}
switch (header.tagNumber()) {
case 1 -> readBoolean(input, header.length());
case 2 -> readInteger(input, header.length(), cancellation);
case 3 -> readBitString(input, header.length(), cancellation);
case 5 -> readNull(header.length());
case 6 -> readOid(input, header.length(), cancellation);
default -> skip(input, header.length(), cancellation);
}
}
private static void readBoolean(CountedInput input, long length) throws IOException {
if (length != SINGLE_OCTET_LENGTH) {
throw new IOException("Malformed DER BOOLEAN");
}
int value = input.readRequired();
if (value != 0 && value != 0xff) {
throw new IOException("Non-canonical DER BOOLEAN");
}
}
private static void readInteger(CountedInput input, long length, CancellationSignal cancellation)
throws IOException {
if (length == EMPTY_LENGTH) {
throw new IOException("Malformed DER INTEGER");
}
int first = input.readRequired();
if (length > SINGLE_OCTET_LENGTH) {
int second = input.readRequired();
if ((first == 0 && (second & CONTINUATION_BIT) == 0)
|| (first == 0xff && (second & CONTINUATION_BIT) != 0)) {
throw new IOException("Non-canonical DER INTEGER");
}
skip(input, length - 2L, cancellation);
}
}
private static void readBitString(CountedInput input, long length, CancellationSignal cancellation)
throws IOException {
if (length == EMPTY_LENGTH) {
throw new IOException("Malformed DER BIT STRING");
}
int unused = input.readRequired();
if (unused > 7 || (length == SINGLE_OCTET_LENGTH && unused != 0)) {
throw new IOException("Malformed DER BIT STRING");
}
long octets = length - SINGLE_OCTET_LENGTH;
if (octets == EMPTY_LENGTH) {
return;
}
int last = 0;
for (long index = 0L; index < octets; index++) {
cancellation.throwIfCancelled();
last = input.readRequired();
}
if (unused != 0 && (last & ((1 << unused) - 1)) != 0) {
throw new IOException("Non-canonical DER BIT STRING");
}
}
private static void readNull(long length) throws IOException {
if (length != EMPTY_LENGTH) {
throw new IOException("Malformed DER NULL");
}
}
private static void readOid(CountedInput input, long length, CancellationSignal cancellation)
throws IOException {
if (length == EMPTY_LENGTH) {
throw new IOException("Malformed DER OID");
}
boolean atComponentStart = true;
for (long index = 0L; index < length; index++) {
cancellation.throwIfCancelled();
int octet = input.readRequired();
if (atComponentStart && octet == CONTINUATION_BIT) {
throw new IOException("Non-canonical DER OID");
}
atComponentStart = (octet & CONTINUATION_BIT) == 0;
}
if (!atComponentStart) {
throw new IOException("Truncated DER OID");
}
}
}
}

View File

@@ -0,0 +1,155 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.impl.framework.x509;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import zeroecho.core.io.CancellationSignal;
/**
* Focused canonical DER streaming primitives used by signed-object adapters.
*
* <p>
* This is deliberately not a general ASN.1 framework. It writes already
* validated current X.509 child encodings into definite-length containers while
* accounting in {@code long}. ZeroEcho core imposes no arbitrary product-wide
* aggregate CRL-size or revocation-entry limit; completion remains subject to
* storage, I/O, technical representability, cancellation, and deployment
* policy.
* </p>
*/
public final class StreamingDerWriter {
/** DER universal SEQUENCE tag. */
public static final int SEQUENCE_TAG = 0x30;
/** DER universal BIT STRING tag. */
public static final int BIT_STRING_TAG = 0x03;
private static final int BUFFER_SIZE = 16 * 1024;
private static final long EMPTY_LENGTH = 0L;
private static final long SHORT_FORM_LIMIT = 128L;
private StreamingDerWriter() {
}
/**
* Returns the encoded size of one tag-length-value object.
*
* @param valueLength non-negative value length
* @return complete encoded length
* @throws IllegalArgumentException if the length is negative
* @throws ArithmeticException if the result overflows {@code long}
*/
public static long encodedLength(long valueLength) {
if (valueLength < EMPTY_LENGTH) {
throw new IllegalArgumentException("DER value length must not be negative");
}
return Math.addExact(Math.addExact(1L, lengthOctets(valueLength)), valueLength);
}
/**
* Writes one canonical DER tag and definite length.
*
* @param output target stream
* @param tag one-octet tag
* @param valueLength non-negative value length
* @throws IOException if writing fails
* @throws IllegalArgumentException if the tag or length is invalid
*/
public static void writeTagAndLength(OutputStream output, int tag, long valueLength) throws IOException {
if (output == null) {
throw new IllegalArgumentException("output must not be null");
}
if (tag < 0 || tag > 0xff) {
throw new IllegalArgumentException("DER tag must fit one octet");
}
if (valueLength < EMPTY_LENGTH) {
throw new IllegalArgumentException("DER value length must not be negative");
}
output.write(tag);
if (valueLength < SHORT_FORM_LIMIT) {
output.write((int) valueLength);
return;
}
int octets = significantOctets(valueLength);
output.write(0x80 | octets);
for (int shift = (octets - 1) * Byte.SIZE; shift >= 0; shift -= Byte.SIZE) {
output.write((int) (valueLength >>> shift) & 0xff);
}
}
/**
* Copies content incrementally and returns the exact byte count.
*
* @param input source
* @param output destination
* @param cancellation cancellation signal
* @return copied byte count
* @throws IOException if reading or writing fails
* @throws ArithmeticException if the count overflows
*/
public static long copy(InputStream input, OutputStream output, CancellationSignal cancellation)
throws IOException {
if (input == null || output == null || cancellation == null) {
throw new IllegalArgumentException("DER copy arguments must not be null");
}
byte[] buffer = new byte[BUFFER_SIZE];
long count = 0L;
int read;
while ((read = input.read(buffer)) >= 0) {
cancellation.throwIfCancelled();
if (read != 0) {
output.write(buffer, 0, read);
count = Math.addExact(count, read);
}
}
return count;
}
private static long lengthOctets(long valueLength) {
return valueLength < 128L ? 1L : Math.addExact(1L, significantOctets(valueLength));
}
private static int significantOctets(long valueLength) {
int octets = 0;
long remaining = valueLength;
while (remaining != 0L) {
octets++;
remaining >>>= Byte.SIZE;
}
return octets;
}
}

View File

@@ -0,0 +1,167 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.impl.framework.x509;
import java.util.Arrays;
import java.util.HexFormat;
import java.util.Objects;
import java.util.regex.Pattern;
/**
* Provider-neutral canonical X.509 {@code AlgorithmIdentifier} representation.
*
* <p>
* Parameters distinguish absent, DER NULL, and exact canonical structured DER.
* Parameter bytes are defensively copied. This value contains no Bouncy Castle
* object and is immutable.
* </p>
*/
public final class X509AlgorithmIdentifier {
private static final Pattern OID = Pattern.compile("[0-2](?:\\.[0-9]+)+");
private static final byte[] DER_NULL = { 0x05, 0x00 };
/**
* Exact parameter representation.
*/
public enum ParameterForm {
/** Parameters are omitted. */
ABSENT,
/** Parameters are canonical DER NULL. */
DER_NULL,
/** Parameters are exact canonical structured DER. */
EXACT_DER
}
private final String oid;
private final ParameterForm parameterForm;
private final byte[] parameters;
private X509AlgorithmIdentifier(String oid, ParameterForm parameterForm, byte[] parameters) {
Objects.requireNonNull(oid, "oid");
if (!OID.matcher(oid).matches()) {
throw new IllegalArgumentException("Invalid dotted-decimal OID");
}
this.oid = oid;
this.parameterForm = Objects.requireNonNull(parameterForm, "parameterForm");
this.parameters = Objects.requireNonNull(parameters, "parameters").clone();
if (parameterForm == ParameterForm.ABSENT && parameters.length != 0) {
throw new IllegalArgumentException("Absent parameters must have no DER");
}
if (parameterForm == ParameterForm.DER_NULL && !Arrays.equals(DER_NULL, parameters)) {
throw new IllegalArgumentException("DER NULL parameters must be canonical");
}
if (parameterForm == ParameterForm.EXACT_DER && parameters.length == 0) {
throw new IllegalArgumentException("Exact parameters must not be empty");
}
}
/**
* Creates an identifier with absent parameters.
*
* @param oid dotted-decimal OID
* @return immutable identifier
*/
public static X509AlgorithmIdentifier absent(String oid) {
return new X509AlgorithmIdentifier(oid, ParameterForm.ABSENT, new byte[0]);
}
/**
* Creates an identifier with canonical DER NULL parameters.
*
* @param oid dotted-decimal OID
* @return immutable identifier
*/
public static X509AlgorithmIdentifier derNull(String oid) {
return new X509AlgorithmIdentifier(oid, ParameterForm.DER_NULL, DER_NULL);
}
/**
* Creates an identifier with exact canonical structured parameters.
*
* @param oid dotted-decimal OID
* @param parameters complete DER parameter value
* @return immutable identifier
*/
public static X509AlgorithmIdentifier exact(String oid, byte[] parameters) {
return new X509AlgorithmIdentifier(oid, ParameterForm.EXACT_DER, parameters);
}
/**
* Returns the OID.
*
* @return dotted-decimal OID
*/
public String oid() {
return oid;
}
/**
* Returns the exact parameter form.
*
* @return parameter form
*/
public ParameterForm parameterForm() {
return parameterForm;
}
/**
* Returns independently owned parameter DER.
*
* @return defensive copy, empty for absent parameters
*/
public byte[] parameters() {
return parameters.clone();
}
/**
* Returns the deterministic reverse-lookup representation.
*
* @return OID, form, and exact DER
*/
public String canonicalForm() {
return oid + "|" + parameterForm + "|" + HexFormat.of().formatHex(parameters);
}
@Override
public boolean equals(Object other) {
return other instanceof X509AlgorithmIdentifier identifier && oid.equals(identifier.oid)
&& parameterForm == identifier.parameterForm && Arrays.equals(parameters, identifier.parameters);
}
@Override
public int hashCode() {
return 31 * (31 * oid.hashCode() + parameterForm.hashCode()) + Arrays.hashCode(parameters);
}
}

View File

@@ -0,0 +1,243 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.impl.framework.x509;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import zeroecho.core.spec.AlgorithmIdentity;
import zeroecho.core.spec.AlgorithmSuite;
import zeroecho.core.spi.AlgorithmExecutionCapabilities;
import zeroecho.core.spi.AlgorithmExecutionCapability;
/**
* Provider-independent intersection of exact identity, binding, installed
* execution capability, immutable security floor, configured policy, and key
* compatibility.
*
* <p>
* The resolver returns an immutable effective selection and never falls back to
* a default or provider alias. It contains no key material and performs no
* cryptographic execution.
* </p>
*/
public final class X509AlgorithmResolver {
private static final int UNIQUE_MATCH_COUNT = 1;
private final X509BindingCatalog bindings;
private final AlgorithmExecutionCapabilities capabilities;
private final Policy policy;
/**
* Stable resolution failure categories.
*/
public enum Failure {
/** No authoritative X.509 binding exists. */
NO_BINDING,
/** No installed implementation supports the tuple. */
NO_CAPABILITY,
/** More than one implementation remains without explicit selection. */
AMBIGUOUS_IMPLEMENTATION,
/** Explicit implementation is unavailable for the tuple. */
UNKNOWN_IMPLEMENTATION,
/** Semantic capability has no process-local executor binding. */
NO_EXECUTOR,
/** Non-overridable security floor rejected the identity. */
SECURITY_FLOOR,
/** Configured policy rejected the suite. */
POLICY,
/** Signature and key identities are incompatible. */
INCOMPATIBLE_KEY
}
/**
* Policy decision over exact identity data.
*/
@FunctionalInterface
public interface Policy {
/**
* Tests whether configured policy permits the exact operation.
*
* @param suite complete suite
* @param direction execution direction
* @return {@code true} when permitted
*/
boolean permits(AlgorithmSuite suite, AlgorithmExecutionCapability.Direction direction);
/**
* Returns stable non-secret policy semantics for snapshot provenance.
*
* @return deterministic policy fingerprint
*/
default String semanticFingerprint() {
return getClass().getName();
}
}
/**
* Immutable effective selection.
*
* @param requested exact requested identity
* @param suite compatible exact suite
* @param binding exact X.509 representation
* @param implementation selected execution metadata
* @param direction authorized execution direction
* @param provenance stable default identifier or {@code explicit}
* @param authorityFingerprint authority snapshot fingerprint
*/
public record Selection(AlgorithmIdentity requested, AlgorithmSuite suite, X509AlgorithmIdentifier binding,
AlgorithmExecutionCapability implementation, AlgorithmExecutionCapability.Direction direction,
String provenance, String authorityFingerprint) {
/**
* Creates an immutable selection.
*
* @throws NullPointerException if an argument is {@code null}
*/
public Selection {
Objects.requireNonNull(requested, "requested");
Objects.requireNonNull(suite, "suite");
Objects.requireNonNull(binding, "binding");
Objects.requireNonNull(implementation, "implementation");
Objects.requireNonNull(provenance, "provenance");
Objects.requireNonNull(authorityFingerprint, "authorityFingerprint");
}
}
/**
* Resolution exception with a stable non-sensitive category.
*/
public static final class ResolutionException extends IllegalArgumentException {
private static final long serialVersionUID = 1L;
private final Failure failure;
/* default */ ResolutionException(Failure failure) {
super("X.509 algorithm resolution failed: " + failure);
this.failure = failure;
}
private ResolutionException(Failure failure, IllegalArgumentException cause) {
super("X.509 algorithm resolution failed: " + failure, cause);
this.failure = failure;
}
/**
* Returns the stable failure category.
*
* @return resolution failure
*/
public Failure failure() {
return failure;
}
}
/**
* Creates an immutable resolver snapshot.
*
* @param bindings authoritative binding snapshot
* @param capabilities installed execution snapshot
* @param policy configured restrictive policy
*/
public X509AlgorithmResolver(X509BindingCatalog bindings, AlgorithmExecutionCapabilities capabilities,
Policy policy) {
this.bindings = Objects.requireNonNull(bindings, "bindings");
this.capabilities = Objects.requireNonNull(capabilities, "capabilities");
this.policy = Objects.requireNonNull(policy, "policy");
}
/**
* Resolves an exact explicit selection.
*
* @param requested exact signature identity
* @param key exact public-key identity
* @param direction execution direction
* @param implementation optional explicit implementation identifier
* @param provenance stable default identifier or {@code explicit}
* @return immutable effective selection
* @throws ResolutionException for a precise fail-closed category
*/
public Selection resolve(AlgorithmIdentity requested, AlgorithmIdentity key,
AlgorithmExecutionCapability.Direction direction, Optional<String> implementation, String provenance,
String authorityFingerprint) {
Objects.requireNonNull(requested, "requested");
Objects.requireNonNull(key, "key");
Objects.requireNonNull(direction, "direction");
Objects.requireNonNull(implementation, "implementation");
Objects.requireNonNull(provenance, "provenance");
Objects.requireNonNull(authorityFingerprint, "authorityFingerprint");
try {
X509SecurityFloor.requirePermitted(requested);
} catch (IllegalArgumentException rejected) {
throw new ResolutionException(Failure.SECURITY_FLOOR, rejected);
}
AlgorithmSuite suite;
try {
suite = X509SuiteCompatibility.requireCompatible(requested, key);
} catch (IllegalArgumentException incompatible) {
throw new ResolutionException(Failure.INCOMPATIBLE_KEY, incompatible);
}
X509AlgorithmIdentifier binding;
try {
binding = bindings.resolve(requested, X509AlgorithmRole.SIGNATURE_ALGORITHM);
} catch (IllegalArgumentException missing) {
throw new ResolutionException(Failure.NO_BINDING, missing);
}
if (!policy.permits(suite, direction)) {
throw new ResolutionException(Failure.POLICY);
}
List<AlgorithmExecutionCapability> matches = capabilities.supporting(requested, suite, direction);
AlgorithmExecutionCapability selected = select(matches, implementation);
return new Selection(requested, suite, binding, selected, direction, provenance, authorityFingerprint);
}
private static AlgorithmExecutionCapability select(List<AlgorithmExecutionCapability> matches,
Optional<String> requestedImplementation) {
if (requestedImplementation.isPresent()) {
return matches.stream()
.filter(capability -> requestedImplementation.get().equals(capability.implementationId()))
.findFirst().orElseThrow(() -> new ResolutionException(Failure.UNKNOWN_IMPLEMENTATION));
}
if (matches.isEmpty()) {
throw new ResolutionException(Failure.NO_CAPABILITY);
}
if (matches.size() != UNIQUE_MATCH_COUNT) {
throw new ResolutionException(Failure.AMBIGUOUS_IMPLEMENTATION);
}
return matches.get(0);
}
}

View File

@@ -0,0 +1,44 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.impl.framework.x509;
/**
* Closed semantic role of an algorithm representation in X.509.
*/
public enum X509AlgorithmRole {
/** Signature algorithm on a certificate, CRL, or certification request. */
SIGNATURE_ALGORITHM,
/** Public-key algorithm in SubjectPublicKeyInfo. */
SUBJECT_PUBLIC_KEY_ALGORITHM
}

View File

@@ -0,0 +1,635 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.impl.framework.x509;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.ServiceLoader;
import java.util.Set;
import zeroecho.core.alg.BootstrapAlgorithmIdentities;
import zeroecho.core.spec.AlgorithmIdentity;
import zeroecho.core.spec.AlgorithmIdentityCatalog;
import zeroecho.core.spec.AlgorithmIdentityCodec;
import zeroecho.core.spec.AlgorithmSuite;
import zeroecho.core.spi.AlgorithmExecutionCapabilities;
import zeroecho.core.spi.AlgorithmExecutionCapability;
import zeroecho.pki.api.status.StatusObject;
import zeroecho.pki.spi.crypto.SignatureWorkflow;
import zeroecho.core.spi.AlgorithmExecutionCapabilityProvider;
/**
* One immutable internally consistent runtime authority snapshot.
*
* <p>
* The snapshot composes identities, X.509 components, binding rules, installed
* execution capabilities, aliases, immutable defaults, security floor, and
* configured policy once. It owns the effective resolver and a stable semantic
* fingerprint. It contains no key material and is not persisted in Phase A.
* </p>
*/
public final class X509AuthoritySnapshot {
private final AlgorithmIdentityCatalog identities;
private final X509ComponentCatalog components;
private final X509BindingCatalog bindings;
private final AlgorithmExecutionCapabilities capabilities;
private final List<AlgorithmIdentityCodec> codecs;
private final Map<String, AlgorithmIdentity> aliases;
private final Map<String, AlgorithmSuite> defaults;
private final X509AlgorithmResolver.Policy policy;
private final X509AlgorithmResolver resolver;
private final String semanticFingerprint;
private final Map<ExecutorKey, Object> executors;
private final Object provenanceToken;
/**
* Creates a consistent authority snapshot.
*
* @param identities exact identity catalog
* @param components parameter component catalog
* @param bindings role-specific binding catalog
* @param capabilities installed execution capabilities
* @param policy configured restrictive policy
*/
public X509AuthoritySnapshot(AlgorithmIdentityCatalog identities, X509ComponentCatalog components,
X509BindingCatalog bindings, AlgorithmExecutionCapabilities capabilities,
X509AlgorithmResolver.Policy policy) {
this(identities, components, bindings, capabilities, List.of(),
BootstrapAlgorithmIdentities.compatibilityAliases(), X509BuiltInDefaults.snapshot(), policy,
List.of());
}
private X509AuthoritySnapshot(AlgorithmIdentityCatalog identities, X509ComponentCatalog components,
X509BindingCatalog bindings, AlgorithmExecutionCapabilities capabilities,
Collection<AlgorithmIdentityCodec> codecs, Map<String, AlgorithmIdentity> aliases,
Map<String, AlgorithmSuite> defaults, X509AlgorithmResolver.Policy policy,
Collection<ExecutorBinding> executorBindings) {
this.identities = Objects.requireNonNull(identities, "identities");
this.components = Objects.requireNonNull(components, "components");
this.bindings = Objects.requireNonNull(bindings, "bindings");
this.capabilities = Objects.requireNonNull(capabilities, "capabilities");
this.codecs = immutableCodecs(codecs);
this.aliases = immutableAliases(aliases, identities);
this.defaults = immutableDefaults(defaults);
this.policy = Objects.requireNonNull(policy, "policy");
this.resolver = new X509AlgorithmResolver(bindings, capabilities, policy);
this.executors = immutableExecutors(executorBindings, capabilities);
this.provenanceToken = new Object();
this.semanticFingerprint = fingerprint();
}
/**
* Creates the installed runtime snapshot with an explicit policy.
*
* @param policy configured restrictive policy
* @return installed snapshot
*/
public static X509AuthoritySnapshot installed(X509AlgorithmResolver.Policy policy) {
List<X509BindingRuleProvider> bindingProviders = ServiceLoader.load(X509BindingRuleProvider.class).stream()
.map(ServiceLoader.Provider::get).sorted(Comparator.comparing(provider -> provider.getClass().getName()))
.toList();
List<AlgorithmExecutionCapabilityProvider> capabilityProviders = ServiceLoader
.load(AlgorithmExecutionCapabilityProvider.class).stream().map(ServiceLoader.Provider::get)
.sorted(Comparator.comparing(provider -> provider.getClass().getName())).toList();
return compose(bindingProviders, capabilityProviders, policy);
}
/**
* Composes one runtime graph from explicitly selected trusted providers.
*
* @param bindingProviders binding and identity contributors
* @param capabilityProviders execution contributors used by this runtime
* @param policy restrictive policy
* @return immutable authority snapshot
*/
public static X509AuthoritySnapshot compose(List<X509BindingRuleProvider> bindingProviders,
List<AlgorithmExecutionCapabilityProvider> capabilityProviders, X509AlgorithmResolver.Policy policy) {
return compose(bindingProviders, capabilityProviders, List.of(), policy);
}
/**
* Composes one runtime graph with exact process-local executor bindings.
*
* @param bindingProviders binding and identity contributors
* @param capabilityProviders semantic execution contributors
* @param executorBindings actual process-local executors
* @param policy restrictive policy
* @return immutable authority snapshot
*/
public static X509AuthoritySnapshot compose(List<X509BindingRuleProvider> bindingProviders,
List<AlgorithmExecutionCapabilityProvider> capabilityProviders,
List<ExecutorBinding> executorBindings, X509AlgorithmResolver.Policy policy) {
Objects.requireNonNull(bindingProviders, "bindingProviders");
Objects.requireNonNull(capabilityProviders, "capabilityProviders");
List<X509BindingRuleProvider> ordered = bindingProviders.stream()
.sorted(Comparator.comparing(provider -> provider.getClass().getName())).toList();
AlgorithmIdentityCatalog identities = BootstrapAlgorithmIdentities.catalog();
List<X509ComponentCatalog> componentExtensions = new ArrayList<>();
List<X509BindingCatalog> bindingExtensions = new ArrayList<>();
List<AlgorithmIdentityCodec> codecs = new ArrayList<>();
Map<String, AlgorithmIdentity> aliases = new LinkedHashMap<>(
BootstrapAlgorithmIdentities.compatibilityAliases());
Map<String, AlgorithmSuite> defaults = new LinkedHashMap<>(X509BuiltInDefaults.snapshot());
for (X509BindingRuleProvider provider : ordered) {
Collection<AlgorithmIdentity> contributedIdentities = List.copyOf(provider.identities());
if (!contributedIdentities.isEmpty()) {
identities = identities.add(contributedIdentities);
}
List<X509ComponentCatalog.Component> contributedComponents = List.copyOf(provider.components());
if (!contributedComponents.isEmpty()) {
componentExtensions.add(X509ComponentCatalog.extension(contributedComponents));
}
codecs.addAll(List.copyOf(provider.codecs()));
mergeAliases(aliases, provider.aliases());
mergeDefaults(defaults, provider.defaults());
}
X509ComponentCatalog components = X509ComponentCatalog.builtIn().merge(componentExtensions);
X509BindingCatalog builtInBindings = StandardX509Bindings.catalog(components);
for (X509BindingRuleProvider provider : ordered) {
List<X509BindingRule> rules = List.copyOf(provider.rules());
if (!rules.isEmpty()) {
bindingExtensions.add(X509BindingCatalog.extension(rules));
}
}
X509BindingCatalog bindings = builtInBindings.merge(bindingExtensions);
AlgorithmExecutionCapabilities capabilities = AlgorithmExecutionCapabilities
.fromProviders(capabilityProviders);
return new X509AuthoritySnapshot(identities, components, bindings, capabilities, codecs, aliases, defaults,
policy, executorBindings);
}
/**
* Resolves a canonical identity or finite built-in compatibility alias.
*
* @param value canonical identity or approved legacy alias
* @return exact identity
*/
public AlgorithmIdentity resolveIdentity(String value) {
Objects.requireNonNull(value, "value");
Optional<AlgorithmIdentity> canonical = identities.resolve(value);
if (canonical.isEmpty() && value.startsWith("zealg:2:")) {
AlgorithmIdentity parsed = AlgorithmIdentity.parse(value, codecs);
canonical = identities.resolve(parsed.canonicalForm());
}
return canonical.or(() -> Optional.ofNullable(aliases.get(value)))
.orElseThrow(() -> new IllegalArgumentException("Unknown algorithm identity"));
}
/**
* Resolves an immutable built-in default without extension override.
*
* @param identifier versioned default identifier
* @return exact default suite
*/
public AlgorithmSuite resolveDefault(String identifier) {
AlgorithmSuite suite = defaults.get(Objects.requireNonNull(identifier, "identifier"));
if (suite == null) {
throw new IllegalArgumentException("Unknown default identifier");
}
return suite;
}
/**
* Resolves one effective operation and binds it to this snapshot fingerprint.
*
* @param signature exact signature identity
* @param key exact public-key identity
* @param direction execution direction
* @param implementation optional implementation selection
* @param provenance explicit or default provenance
* @return effective immutable selection
*/
public X509AlgorithmResolver.Selection resolve(AlgorithmIdentity signature, AlgorithmIdentity key,
AlgorithmExecutionCapability.Direction direction, Optional<String> implementation, String provenance) {
return resolver.resolve(signature, key, direction, implementation, provenance, semanticFingerprint);
}
/**
* Resolves and authorizes one process-local execution plan.
*
* @param signature exact signature identity
* @param key exact public-key identity
* @param direction operation direction
* @param implementation optional exact implementation identifier
* @param provenance explicit or default provenance
* @param executorType required runtime executor type
* @param <E> executor type
* @return unforgeable process-local plan
*/
public <E> X509ExecutionPlan<E> plan(AlgorithmIdentity signature, AlgorithmIdentity key,
AlgorithmExecutionCapability.Direction direction, Optional<String> implementation, String provenance,
Class<E> executorType) {
X509AlgorithmResolver.Selection selection = resolve(signature, key, direction, implementation, provenance);
ExecutorKey executorKey = new ExecutorKey(selection.implementation().implementationId(), direction);
Object executor = executors.get(executorKey);
if (executor == null || !executorType.isInstance(executor)) {
throw new X509AlgorithmResolver.ResolutionException(X509AlgorithmResolver.Failure.NO_EXECUTOR);
}
return new X509ExecutionPlan<>(selection, executorType.cast(executor), provenanceToken);
}
/**
* Resolves a signing plan for a legacy upper API that carries only the
* signature identity.
*
* @param value canonical identity or approved finite alias
* @param implementation exact implementation identifier
* @param executorType required executor type
* @param <E> executor type
* @return exact process-local signing plan
*/
public <E> X509ExecutionPlan<E> planSigning(String value, String implementation, Class<E> executorType) {
AlgorithmIdentity signature = resolveIdentity(value);
AlgorithmIdentity key = bootstrapKeyFor(signature);
return plan(signature, key, AlgorithmExecutionCapability.Direction.SIGN, Optional.of(implementation),
"explicit", executorType);
}
/**
* Resolves a signing plan when exactly one implementation is available.
*
* @param value canonical identity or approved finite alias
* @param executorType required executor type
* @param <E> executor type
* @return exact process-local signing plan
*/
public <E> X509ExecutionPlan<E> planSigning(String value, Class<E> executorType) {
AlgorithmIdentity signature = resolveIdentity(value);
AlgorithmIdentity key = bootstrapKeyFor(signature);
return plan(signature, key, AlgorithmExecutionCapability.Direction.SIGN, Optional.empty(), "explicit",
executorType);
}
/**
* Validates an execution plan immediately before invoking its executor.
*
* @param plan plan minted by this snapshot
* @param expectedExecutor exact executor reference expected by the boundary
* @param direction required operation direction
* @throws IllegalArgumentException if provenance, executor, implementation, or
* direction differs
*/
public void authorize(X509ExecutionPlan<?> plan, Object expectedExecutor,
AlgorithmExecutionCapability.Direction direction) {
Objects.requireNonNull(plan, "plan");
Objects.requireNonNull(expectedExecutor, "expectedExecutor");
Objects.requireNonNull(direction, "direction");
X509AlgorithmResolver.Selection selection = plan.selection();
ExecutorKey key = new ExecutorKey(selection.implementation().implementationId(), direction);
if (!plan.isOwnedBy(provenanceToken) || !sameInstance(plan.executor(), expectedExecutor)
|| selection.direction() != direction || !sameInstance(executors.get(key), expectedExecutor)
|| !semanticFingerprint.equals(selection.authorityFingerprint())) {
throw new IllegalArgumentException("X.509 execution plan authority mismatch");
}
}
/**
* Mints the live completion of an X.509 status-object SIGN operation.
*
* @param statusObject immutable generated status object
* @param signingPlan exact live SIGN plan used to produce its content
* @return non-forgeable process-local completion
* @throws IllegalArgumentException if the plan is foreign or not an authorized
* SIGN plan
*/
public X509SignedObjectCompletion completeStatusObject(StatusObject statusObject,
X509ExecutionPlan<SignatureWorkflow> signingPlan) {
Objects.requireNonNull(statusObject, "statusObject");
authorize(signingPlan, signingPlan.executor(), AlgorithmExecutionCapability.Direction.SIGN);
return new X509SignedObjectCompletion(statusObject, signingPlan, provenanceToken);
}
/**
* Validates and unwraps a status completion minted by this live authority.
*
* @param completion signed-object completion
* @return immutable completed status object
* @throws IllegalArgumentException if provenance or executor binding differs
*/
public StatusObject requireStatusCompletion(X509SignedObjectCompletion completion) {
Objects.requireNonNull(completion, "completion");
if (!completion.isOwnedBy(provenanceToken)) {
throw new IllegalArgumentException("X.509 status completion authority mismatch");
}
X509ExecutionPlan<SignatureWorkflow> plan = completion.signingPlan();
authorize(plan, plan.executor(), AlgorithmExecutionCapability.Direction.SIGN);
return completion.statusObject();
}
/**
* Returns the exact live SIGN plan after validating status completion
* provenance.
*
* @param completion status completion minted by this authority
* @return exact authorized SIGN plan
* @throws IllegalArgumentException if the completion belongs to another live
* runtime
*/
public X509ExecutionPlan<SignatureWorkflow> requireStatusSigningPlan(X509SignedObjectCompletion completion) {
requireStatusCompletion(completion);
return completion.signingPlan();
}
/**
* Validates a persisted signature identity against the complete runtime
* authority intersection.
*
* @param value canonical identity or approved compatibility alias
* @param direction execution direction
* @return exact canonical signature identity
*/
public AlgorithmIdentity requireExecutableSignature(String value,
AlgorithmExecutionCapability.Direction direction) {
AlgorithmIdentity signature = resolveIdentity(value);
X509SecurityFloor.requirePermitted(signature);
bindings.resolve(signature, X509AlgorithmRole.SIGNATURE_ALGORITHM);
List<AlgorithmIdentity> keys = identities.identities().stream()
.filter(identity -> identity.kind() == AlgorithmIdentity.Kind.PUBLIC_KEY).toList();
for (AlgorithmIdentity key : keys) {
try {
AlgorithmSuite suite = X509SuiteCompatibility.requireCompatible(signature, key);
if (policy.permits(suite, direction)
&& !capabilities.supporting(signature, suite, direction).isEmpty()) {
return signature;
}
} catch (IllegalArgumentException incompatible) {
continue;
}
}
throw new IllegalArgumentException("Signature identity is unavailable for execution");
}
/**
* Rejects a selection produced by a semantically different authority snapshot.
*
* @param selection effective selection
*/
public void requireAuthority(X509AlgorithmResolver.Selection selection) {
Objects.requireNonNull(selection, "selection");
if (!semanticFingerprint.equals(selection.authorityFingerprint())) {
throw new IllegalArgumentException("X.509 authority snapshot mismatch");
}
}
/**
* Creates one exact process-local executor binding.
*
* @param implementationId semantic capability implementation identifier
* @param direction supported execution direction
* @param executor actual runtime executor
* @return immutable binding contribution
*/
public static ExecutorBinding bindExecutor(String implementationId,
AlgorithmExecutionCapability.Direction direction, Object executor) {
return new ExecutorBinding(implementationId, direction, executor);
}
/**
* Returns the owned effective resolver.
*
* @return immutable resolver
*/
public X509AlgorithmResolver resolver() {
return resolver;
}
/**
* Returns the binding catalog snapshot.
*
* @return immutable bindings
*/
public X509BindingCatalog bindings() {
return bindings;
}
/**
* Returns the component catalog snapshot.
*
* @return immutable components
*/
public X509ComponentCatalog components() {
return components;
}
/**
* Returns the stable snapshot fingerprint.
*
* @return SHA-256 hexadecimal semantic fingerprint
*/
public String semanticFingerprint() {
return semanticFingerprint;
}
private String fingerprint() {
StringBuilder semantic = new StringBuilder();
addFields(semantic, "identity", identities.identities().stream().map(AlgorithmIdentity::canonicalForm).toList());
addFields(semantic, "codec", codecs.stream().map(AlgorithmIdentityCodec::id).toList());
addFields(semantic, "component", List.of(components.semanticFingerprint()));
addFields(semantic, "binding",
bindings.rules().stream().map(X509BindingRule::semanticFingerprint).sorted().toList());
addFields(semantic, "capability", capabilities.all().stream()
.map(capability -> capability.implementationId() + ":" + capability.domainFingerprint()).toList());
addFields(semantic, "alias", aliases.entrySet().stream().sorted(Map.Entry.comparingByKey())
.map(entry -> entry.getKey() + ":" + entry.getValue().canonicalForm()).toList());
addFields(semantic, "default", defaults.entrySet().stream().sorted(Map.Entry.comparingByKey())
.map(entry -> entry.getKey() + ":" + entry.getValue().canonicalForm()).toList());
addFields(semantic, "floor", List.of(X509SecurityFloor.semanticFingerprint()));
addFields(semantic, "policy", List.of(policy.semanticFingerprint()));
try {
return HexFormat.of().formatHex(
MessageDigest.getInstance("SHA-256").digest(semantic.toString().getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 unavailable", exception);
}
}
private static List<AlgorithmIdentityCodec> immutableCodecs(Collection<AlgorithmIdentityCodec> source) {
Map<String, AlgorithmIdentityCodec> byId = new LinkedHashMap<>();
source.stream().sorted(Comparator.comparing(AlgorithmIdentityCodec::id)).forEach(codec -> {
if (byId.putIfAbsent(codec.id(), codec) != null) {
throw new IllegalArgumentException("Algorithm identity codec collision");
}
});
return List.copyOf(byId.values());
}
private static Map<String, AlgorithmIdentity> immutableAliases(Map<String, AlgorithmIdentity> source,
AlgorithmIdentityCatalog identities) {
Map<String, AlgorithmIdentity> copy = new LinkedHashMap<>();
source.entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry -> {
AlgorithmIdentity identity = Objects.requireNonNull(entry.getValue(), "alias identity");
if (entry.getKey().isBlank() || identities.resolve(identity.canonicalForm()).isEmpty()) {
throw new IllegalArgumentException("Invalid algorithm alias contribution");
}
X509SecurityFloor.requirePermitted(identity);
copy.put(entry.getKey(), identity);
});
return Map.copyOf(copy);
}
private static Map<String, AlgorithmSuite> immutableDefaults(Map<String, AlgorithmSuite> source) {
Map<String, AlgorithmSuite> copy = new LinkedHashMap<>();
source.entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry -> {
if (entry.getKey().isBlank()) {
throw new IllegalArgumentException("Invalid default identifier");
}
X509SecurityFloor.requirePermitted(entry.getValue().signature());
copy.put(entry.getKey(), Objects.requireNonNull(entry.getValue(), "default suite"));
});
return Map.copyOf(copy);
}
private static Map<ExecutorKey, Object> immutableExecutors(Collection<ExecutorBinding> source,
AlgorithmExecutionCapabilities capabilities) {
Map<ExecutorKey, Object> copy = new LinkedHashMap<>();
Set<String> known = capabilities.all().stream().map(AlgorithmExecutionCapability::implementationId)
.collect(java.util.stream.Collectors.toUnmodifiableSet());
source.forEach(binding -> {
if (!known.contains(binding.implementationId())) {
throw new IllegalArgumentException("Executor binding has no semantic capability");
}
ExecutorKey key = new ExecutorKey(binding.implementationId(), binding.direction());
if (copy.putIfAbsent(key, binding.executor()) != null) {
throw new IllegalArgumentException("Duplicate execution binding");
}
});
return Map.copyOf(copy);
}
private static boolean sameInstance(Object first, Object second) {
Map<Object, Boolean> identity = new IdentityHashMap<>();
identity.put(first, Boolean.TRUE);
return identity.containsKey(second);
}
private static AlgorithmIdentity bootstrapKeyFor(AlgorithmIdentity signature) {
if (signature.equals(BootstrapAlgorithmIdentities.ECDSA_SHA256)) {
return BootstrapAlgorithmIdentities.EC_P256_PUBLIC_KEY;
}
if (signature.equals(BootstrapAlgorithmIdentities.ECDSA_SHA384)) {
return BootstrapAlgorithmIdentities.EC_P384_PUBLIC_KEY;
}
if (signature.equals(BootstrapAlgorithmIdentities.ECDSA_SHA512)) {
return BootstrapAlgorithmIdentities.EC_P521_PUBLIC_KEY;
}
if (signature.equals(BootstrapAlgorithmIdentities.ED25519_SIGNATURE)) {
return BootstrapAlgorithmIdentities.ED25519_PUBLIC_KEY;
}
if (signature.equals(BootstrapAlgorithmIdentities.ED448_SIGNATURE)) {
return BootstrapAlgorithmIdentities.ED448_PUBLIC_KEY;
}
if ("rsa-pkcs1-v1_5".equals(signature.family().name())
|| "rsa-pss".equals(signature.family().name())) {
return BootstrapAlgorithmIdentities.RSA_PUBLIC_KEY;
}
throw new IllegalArgumentException("Exact public-key identity is required");
}
private static void mergeAliases(Map<String, AlgorithmIdentity> target, Map<String, AlgorithmIdentity> addition) {
addition.forEach((alias, identity) -> {
if (target.putIfAbsent(alias, identity) != null) {
throw new IllegalArgumentException("Algorithm alias collision");
}
});
}
private static void mergeDefaults(Map<String, AlgorithmSuite> target, Map<String, AlgorithmSuite> addition) {
addition.forEach((identifier, suite) -> {
if (identifier.startsWith("zeroecho.") || target.putIfAbsent(identifier, suite) != null) {
throw new IllegalArgumentException("Default identifier collision");
}
});
}
private static void addFields(StringBuilder target, String category, List<String> values) {
List<String> sorted = values.stream().sorted().toList();
appendField(target, category);
for (String value : sorted) {
appendField(target, value);
}
}
/**
* Immutable process-local executor contribution.
*/
public static final class ExecutorBinding {
private final String implementationId;
private final AlgorithmExecutionCapability.Direction direction;
private final Object executor;
private ExecutorBinding(String implementationId, AlgorithmExecutionCapability.Direction direction,
Object executor) {
this.implementationId = Objects.requireNonNull(implementationId, "implementationId");
this.direction = Objects.requireNonNull(direction, "direction");
this.executor = Objects.requireNonNull(executor, "executor");
if (implementationId.isBlank()) {
throw new IllegalArgumentException("implementationId must not be blank");
}
}
/* default */ String implementationId() {
return implementationId;
}
/* default */ AlgorithmExecutionCapability.Direction direction() {
return direction;
}
/* default */ Object executor() {
return executor;
}
}
private record ExecutorKey(String implementationId, AlgorithmExecutionCapability.Direction direction) {
}
private static void appendField(StringBuilder target, String value) {
int length = value.getBytes(StandardCharsets.UTF_8).length;
target.append(length).append(':').append(value);
}
}

View File

@@ -0,0 +1,216 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.impl.framework.x509;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import zeroecho.core.spec.AlgorithmIdentity;
/**
* Deeply immutable snapshot of authoritative X.509 binding rules.
*
* <p>
* One rule exclusively owns each role and OID parameter domain. The current
* implementation requires one authoritative rule for a role/OID pair; that rule
* may itself be parameterized. This permits RSA-PSS and named-curve EC while
* rejecting order-dependent overlapping contributions.
* </p>
*/
public final class X509BindingCatalog {
/** Prefix reserved for built-in binding identifiers. */
public static final String BUILTIN_PREFIX = "zeroecho.";
private static final Set<String> FORBIDDEN_SIGNATURE_OIDS = Set.of("1.2.840.113549.1.1.5",
"1.2.840.10045.4.1");
private final List<X509BindingRule> rules;
private final Map<String, X509BindingRule> byRoleAndOid;
private X509BindingCatalog(List<X509BindingRule> rules) {
this.rules = List.copyOf(rules);
Map<String, X509BindingRule> reverse = new HashMap<>();
for (X509BindingRule rule : rules) {
reverse.put(key(rule.role(), rule.oid()), rule);
}
this.byRoleAndOid = Map.copyOf(reverse);
}
/**
* Creates the immutable built-in catalog.
*
* @param rules fixed standard rules
* @return built-in catalog
*/
public static X509BindingCatalog builtIn(List<X509BindingRule> rules) {
return create(rules, true);
}
/**
* Creates a trusted additive extension catalog.
*
* @param rules extension rules
* @return extension catalog
*/
public static X509BindingCatalog extension(List<X509BindingRule> rules) {
return create(rules, false);
}
/**
* Produces a new immutable additive snapshot.
*
* @param extensions installed extension catalogs
* @return merged catalog
* @throws IllegalArgumentException if a rule identifier or role/OID domain
* collides
*/
public X509BindingCatalog merge(List<X509BindingCatalog> extensions) {
Objects.requireNonNull(extensions, "extensions");
List<X509BindingRule> merged = new ArrayList<>(rules);
for (X509BindingCatalog extension : extensions) {
Objects.requireNonNull(extension, "extension");
merged.addAll(extension.rules);
}
return validate(merged);
}
/**
* Resolves one exact identity for a closed role.
*
* @param identity exact identity
* @param role X.509 role
* @return canonical X.509 representation
* @throws IllegalArgumentException if no rule or more than one rule resolves
*/
public X509AlgorithmIdentifier resolve(AlgorithmIdentity identity, X509AlgorithmRole role) {
Objects.requireNonNull(identity, "identity");
Objects.requireNonNull(role, "role");
if (role == X509AlgorithmRole.SIGNATURE_ALGORITHM) {
X509SecurityFloor.requirePermitted(identity);
}
X509AlgorithmIdentifier resolved = null;
for (X509BindingRule rule : rules) {
if (rule.role() != role) {
continue;
}
Optional<X509AlgorithmIdentifier> candidate = rule.encode(identity);
if (candidate.isPresent()) {
if (resolved != null) {
throw new IllegalArgumentException("Ambiguous X.509 identity binding");
}
resolved = candidate.get();
}
}
if (resolved == null) {
throw new IllegalArgumentException("No authoritative X.509 binding");
}
return resolved;
}
/**
* Reverse-resolves an exact role-specific representation.
*
* @param identifier canonical X.509 representation
* @param role X.509 role
* @return exact identity
* @throws IllegalArgumentException if the representation is unknown or invalid
*/
public AlgorithmIdentity reverse(X509AlgorithmIdentifier identifier, X509AlgorithmRole role) {
Objects.requireNonNull(identifier, "identifier");
Objects.requireNonNull(role, "role");
X509BindingRule rule = byRoleAndOid.get(key(role, identifier.oid()));
if (rule == null) {
throw new IllegalArgumentException("Unknown X.509 algorithm identifier");
}
AlgorithmIdentity identity = rule.decode(identifier)
.orElseThrow(() -> new IllegalArgumentException("Invalid X.509 parameters"));
if (role == X509AlgorithmRole.SIGNATURE_ALGORITHM) {
X509SecurityFloor.requirePermitted(identity);
}
return identity;
}
/**
* Returns deterministic immutable binding rules.
*
* @return rule snapshot
*/
public List<X509BindingRule> rules() {
return rules;
}
private static X509BindingCatalog create(List<X509BindingRule> rules, boolean builtIn) {
Objects.requireNonNull(rules, "rules");
for (X509BindingRule rule : rules) {
if (rule.role() == X509AlgorithmRole.SIGNATURE_ALGORITHM
&& FORBIDDEN_SIGNATURE_OIDS.contains(rule.oid())) {
throw new IllegalArgumentException("SHA-1 X.509 signature binding is forbidden");
}
Objects.requireNonNull(rule, "rule");
if (builtIn != rule.id().startsWith(BUILTIN_PREFIX)) {
throw new IllegalArgumentException(
builtIn ? "Built-in rule must use reserved identifier"
: "Extension rule must not use reserved identifier");
}
}
return validate(rules);
}
private static X509BindingCatalog validate(List<X509BindingRule> rules) {
Set<String> ids = new HashSet<>();
Set<String> domains = new HashSet<>();
for (X509BindingRule rule : rules) {
if (!ids.add(rule.id())) {
throw new IllegalArgumentException("Duplicate X.509 binding rule identifier");
}
if (!domains.add(key(rule.role(), rule.oid()))) {
throw new IllegalArgumentException("Overlapping X.509 binding rule domain");
}
if (rule.semanticFingerprint() == null || rule.semanticFingerprint().isBlank()) {
throw new IllegalArgumentException("Binding rule fingerprint must not be blank");
}
}
return new X509BindingCatalog(rules);
}
private static String key(X509AlgorithmRole role, String oid) {
return role + "|" + oid;
}
}

View File

@@ -0,0 +1,137 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.impl.framework.x509;
import java.util.Optional;
import zeroecho.core.spec.AlgorithmIdentity;
/**
* Immutable trusted-code rule between exact ZeroEcho identities and X.509
* representations.
*
* <p>
* A rule may own one exact binding or a typed non-ambiguous parameter domain,
* such as RSA-PSS parameters or EC named curves. It is not administrative
* configuration and cannot override an existing catalog rule.
* </p>
*/
public interface X509BindingRule {
/**
* Signature byte representation owned by a signature rule.
*/
enum SignatureEncoding {
/** Algorithm-defined opaque signature bytes. */
OPAQUE,
/** ASN.1 DER {@code SEQUENCE { r, s }}. */
ECDSA_DER,
/** Not applicable to a public-key-only rule. */
NOT_APPLICABLE
}
/**
* Subject-public-key bit-string representation.
*/
enum PublicKeyEncoding {
/** Algorithm-defined PKCS#1 RSA public-key structure. */
RSA_PKCS1_DER,
/** SEC1 encoded elliptic-curve point. */
EC_POINT,
/** Algorithm-defined raw public-key bytes. */
RAW,
/** Not applicable to a signature-only rule. */
NOT_APPLICABLE
}
/**
* Returns the stable namespaced rule identifier.
*
* @return immutable rule identifier
*/
String id();
/**
* Returns the closed X.509 role.
*
* @return role owned by this rule
*/
X509AlgorithmRole role();
/**
* Returns the OID domain owned by this rule.
*
* @return dotted-decimal OID
*/
String oid();
/**
* Returns an immutable semantic fingerprint used for conflict diagnostics.
*
* @return provider-independent rule fingerprint
*/
String semanticFingerprint();
/**
* Returns the exact signature-byte encoding rule.
*
* @return signature encoding or {@link SignatureEncoding#NOT_APPLICABLE}
*/
SignatureEncoding signatureEncoding();
/**
* Returns the exact subject-public-key encoding rule.
*
* @return key encoding or {@link PublicKeyEncoding#NOT_APPLICABLE}
*/
PublicKeyEncoding publicKeyEncoding();
/**
* Resolves an exact identity to its canonical X.509 representation.
*
* @param identity exact provider-independent identity
* @return canonical representation, or empty outside this rule's domain
*/
Optional<X509AlgorithmIdentifier> encode(AlgorithmIdentity identity);
/**
* Reverse-resolves an exact X.509 representation.
*
* @param identifier canonical structural representation
* @return exact role-specific identity, or empty outside this rule's domain
* @throws IllegalArgumentException when the OID is owned by this rule but its
* parameters are malformed or forbidden
*/
Optional<AlgorithmIdentity> decode(X509AlgorithmIdentifier identifier);
}

View File

@@ -0,0 +1,109 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.impl.framework.x509;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import zeroecho.core.spec.AlgorithmIdentity;
import zeroecho.core.spec.AlgorithmIdentityCodec;
import zeroecho.core.spec.AlgorithmSuite;
/**
* Trusted installed-code contribution of additive X.509 binding rules.
*
* <p>
* This interface is not an administrative configuration surface. Catalog
* composition rejects every collision with built-in or previously installed
* semantics.
* </p>
*/
public interface X509BindingRuleProvider {
/**
* Returns trusted parameter codecs required by contributed identities.
*
* @return immutable codec contribution
*/
default Collection<AlgorithmIdentityCodec> codecs() {
return List.of();
}
/**
* Returns exact identities introduced by this installed extension.
*
* @return immutable identity contribution
*/
default Collection<AlgorithmIdentity> identities() {
return List.of();
}
/**
* Returns component bindings used by parameterized rules.
*
* @return immutable component contribution
*/
default List<X509ComponentCatalog.Component> components() {
return List.of();
}
/**
* Returns immutable additive binding rules.
*
* @return trusted rules; never {@code null}
*/
default List<X509BindingRule> rules() {
return List.of();
}
/**
* Returns finite compatibility aliases. Aliases are never canonical identity
* data.
*
* @return immutable alias contribution
*/
default Map<String, AlgorithmIdentity> aliases() {
return Map.of();
}
/**
* Returns additive explicitly versioned defaults.
*
* @return immutable default contribution
*/
default Map<String, AlgorithmSuite> defaults() {
return Map.of();
}
}

View File

@@ -0,0 +1,89 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.impl.framework.x509;
import java.util.Map;
import java.util.Objects;
import zeroecho.core.alg.BootstrapAlgorithmIdentities;
import zeroecho.core.spec.AlgorithmSuite;
/**
* Immutable code-owned PKI defaults.
*
* <p>
* Default identifiers and meanings cannot be registered, redirected, removed,
* or shadowed by extensions or administrative configuration. A future
* recommendation must use a new versioned identifier.
* </p>
*/
public final class X509BuiltInDefaults {
/**
* Historical and current version-one certificate, CRL, and CA-proof signing
* default.
*/
public static final String PKI_SIGNATURE_DEFAULT_V1 = "zeroecho.default.pki-signature.v1";
private static final Map<String, AlgorithmSuite> DEFAULTS = Map.of(PKI_SIGNATURE_DEFAULT_V1,
BootstrapAlgorithmIdentities.PKI_SIGNATURE_DEFAULT_V1);
private X509BuiltInDefaults() {
}
/**
* Resolves one immutable built-in default.
*
* @param identifier stable versioned default identifier
* @return exact suite
* @throws IllegalArgumentException if the identifier is unknown
*/
public static AlgorithmSuite resolve(String identifier) {
Objects.requireNonNull(identifier, "identifier");
AlgorithmSuite suite = DEFAULTS.get(identifier);
if (suite == null) {
throw new IllegalArgumentException("Unknown built-in default identifier");
}
return suite;
}
/**
* Returns a semantic fingerprint of all immutable defaults.
*
* @return deterministic immutable identifier-to-suite map
*/
public static Map<String, AlgorithmSuite> snapshot() {
return DEFAULTS;
}
}

View File

@@ -0,0 +1,253 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.impl.framework.x509;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import zeroecho.core.alg.BootstrapAlgorithmIdentities;
import zeroecho.core.spec.AlgorithmIdentity;
/**
* Immutable additive catalog of X.509 component identities used inside
* parameterized binding rules.
*
* <p>
* Digest, mask-generation, and named-curve OIDs are structural components rather
* than complete signature or SPKI bindings. Identity and OID collisions fail
* closed and the SHA-1 component OID is prohibited structurally.
* </p>
*/
public final class X509ComponentCatalog {
private static final Set<String> FORBIDDEN_DIGEST_OIDS = Set.of("1.3.14.3.2.26");
private static final X509ComponentCatalog BUILT_INS = create(List.of(
new Component("zeroecho.digest.sha256", Kind.DIGEST, BootstrapAlgorithmIdentities.SHA256,
"2.16.840.1.101.3.4.2.1", true),
new Component("zeroecho.digest.sha384", Kind.DIGEST, BootstrapAlgorithmIdentities.SHA384,
"2.16.840.1.101.3.4.2.2", true),
new Component("zeroecho.digest.sha512", Kind.DIGEST, BootstrapAlgorithmIdentities.SHA512,
"2.16.840.1.101.3.4.2.3", true),
new Component("zeroecho.mask.mgf1", Kind.MASK_GENERATION, BootstrapAlgorithmIdentities.MGF1,
"1.2.840.113549.1.1.8", true),
new Component("zeroecho.curve.p256", Kind.NAMED_CURVE,
BootstrapAlgorithmIdentities.EC_P256_PUBLIC_KEY, "1.2.840.10045.3.1.7", true),
new Component("zeroecho.curve.p384", Kind.NAMED_CURVE,
BootstrapAlgorithmIdentities.EC_P384_PUBLIC_KEY, "1.3.132.0.34", true),
new Component("zeroecho.curve.p521", Kind.NAMED_CURVE,
BootstrapAlgorithmIdentities.EC_P521_PUBLIC_KEY, "1.3.132.0.35", true)));
private final List<Component> components;
private final Map<String, Component> byIdentity;
private final Map<String, Component> byOid;
/**
* Closed component role.
*/
public enum Kind {
/** Digest AlgorithmIdentifier component. */
DIGEST,
/** Mask-generation AlgorithmIdentifier component. */
MASK_GENERATION,
/** Named-curve OBJECT IDENTIFIER component. */
NAMED_CURVE
}
/**
* Immutable component binding.
*
* @param id stable namespaced contribution identifier
* @param kind closed component kind
* @param identity exact provider-independent identity
* @param oid standard dotted-decimal OID
* @param builtIn whether the entry is non-overridable built-in authority
*/
public record Component(String id, Kind kind, AlgorithmIdentity identity, String oid, boolean builtIn) {
/**
* Creates a component.
*
* @throws NullPointerException if a required field is {@code null}
*/
public Component {
Objects.requireNonNull(id, "id");
Objects.requireNonNull(kind, "kind");
Objects.requireNonNull(identity, "identity");
Objects.requireNonNull(oid, "oid");
}
}
private X509ComponentCatalog(List<Component> components) {
this.components = List.copyOf(components);
Map<String, Component> identityIndex = new HashMap<>();
Map<String, Component> oidIndex = new HashMap<>();
for (Component component : components) {
identityIndex.put(key(component.kind(), component.identity().canonicalForm()), component);
oidIndex.put(key(component.kind(), component.oid()), component);
}
this.byIdentity = Map.copyOf(identityIndex);
this.byOid = Map.copyOf(oidIndex);
}
/**
* Returns immutable built-in components.
*
* @return built-in catalog
*/
public static X509ComponentCatalog builtIn() {
return BUILT_INS;
}
/**
* Creates a trusted additive extension catalog.
*
* @param components extension components
* @return validated extension catalog
*/
public static X509ComponentCatalog extension(List<Component> components) {
for (Component component : components) {
if (component.builtIn() || component.id().startsWith("zeroecho.")) {
throw new IllegalArgumentException("Extension component uses reserved authority");
}
}
return create(components);
}
/**
* Produces a new immutable additive snapshot.
*
* @param extensions extension catalogs
* @return merged catalog
*/
public X509ComponentCatalog merge(List<X509ComponentCatalog> extensions) {
Objects.requireNonNull(extensions, "extensions");
List<Component> merged = new ArrayList<>(components);
for (X509ComponentCatalog extension : extensions) {
merged.addAll(Objects.requireNonNull(extension, "extension").components);
}
return create(merged);
}
/**
* Resolves the OID of an exact component identity.
*
* @param kind component kind
* @param identity exact identity
* @return component OID
*/
public String oid(Kind kind, AlgorithmIdentity identity) {
Objects.requireNonNull(kind, "kind");
Objects.requireNonNull(identity, "identity");
X509SecurityFloor.requirePermitted(identity);
Component component = byIdentity.get(key(kind, identity.canonicalForm()));
if (component == null) {
throw new IllegalArgumentException("Unknown X.509 component identity");
}
return component.oid();
}
/**
* Reverse-resolves an exact component OID.
*
* @param kind component kind
* @param oid dotted-decimal OID
* @return exact component identity
*/
public AlgorithmIdentity identity(Kind kind, String oid) {
Objects.requireNonNull(kind, "kind");
Objects.requireNonNull(oid, "oid");
Component component = byOid.get(key(kind, oid));
if (component == null) {
throw new IllegalArgumentException("Unknown X.509 component OID");
}
X509SecurityFloor.requirePermitted(component.identity());
return component.identity();
}
/**
* Returns a deterministic semantic fingerprint.
*
* @return immutable canonical component inventory
*/
public String semanticFingerprint() {
return String.join("|", components.stream().sorted(Comparator.comparing(Component::id))
.map(component -> component.id() + ":" + component.kind() + ":"
+ component.identity().canonicalForm() + ":" + component.oid())
.toList());
}
/**
* Resolves a component when present.
*
* @param kind component kind
* @param oid OID
* @return component or empty
*/
public Optional<Component> find(Kind kind, String oid) {
return Optional.ofNullable(byOid.get(key(kind, oid)));
}
private static X509ComponentCatalog create(List<Component> source) {
Objects.requireNonNull(source, "components");
Set<String> ids = new HashSet<>();
Set<String> identities = new HashSet<>();
Set<String> oids = new HashSet<>();
List<Component> copy = new ArrayList<>(source);
for (Component component : copy) {
Objects.requireNonNull(component, "component");
if (!ids.add(component.id())
|| !identities.add(key(component.kind(), component.identity().canonicalForm()))
|| !oids.add(key(component.kind(), component.oid()))) {
throw new IllegalArgumentException("X.509 component catalog collision");
}
if (component.kind() == Kind.DIGEST && FORBIDDEN_DIGEST_OIDS.contains(component.oid())) {
throw new IllegalArgumentException("SHA-1 X.509 component is forbidden");
}
X509SecurityFloor.requirePermitted(component.identity());
}
copy.sort(Comparator.comparing(Component::id));
return new X509ComponentCatalog(copy);
}
private static String key(Kind kind, String value) {
return kind + "|" + value;
}
}

View File

@@ -0,0 +1,97 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.impl.framework.x509;
import java.util.Objects;
/**
* Immutable process-local authorization to execute one resolved X.509
* operation.
*
* <p>
* Only a plan minted and subsequently authorized by its owning
* {@link X509AuthoritySnapshot} is executable. The plan binds the public
* semantic selection to the exact runtime executor and to a hidden,
* process-local authority token. It contains no key material and is not
* serializable.
* </p>
*
* @param <E> exact runtime executor type
*/
public final class X509ExecutionPlan<E> {
private final X509AlgorithmResolver.Selection selection;
private final E executor;
private final Object authorityToken;
/* default */ X509ExecutionPlan(X509AlgorithmResolver.Selection selection, E executor, Object authorityToken) {
this.selection = Objects.requireNonNull(selection, "selection");
this.executor = Objects.requireNonNull(executor, "executor");
this.authorityToken = Objects.requireNonNull(authorityToken, "authorityToken");
}
/**
* Returns the immutable semantic selection.
*
* @return exact identity, suite, binding, direction, implementation, and
* provenance
*/
public X509AlgorithmResolver.Selection selection() {
return selection;
}
/**
* Returns the exact runtime executor bound by the authority.
*
* <p>
* An execution boundary must call
* {@link X509AuthoritySnapshot#authorize(X509ExecutionPlan, Object, zeroecho.core.spi.AlgorithmExecutionCapability.Direction)}
* immediately before invoking this object.
* </p>
*
* @return process-local executor
*/
public E executor() {
return executor;
}
/*
* Package-private boolean proof deliberately reveals no token value. A
* same-package caller may reconstruct the public semantic fields but cannot
* reproduce the owning snapshot's private provenance object.
*/
/* default */ boolean isOwnedBy(Object candidateToken) {
return candidateToken.equals(authorityToken);
}
}

View File

@@ -0,0 +1,79 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.impl.framework.x509;
import java.util.Locale;
import java.util.Objects;
import zeroecho.core.spec.AlgorithmIdentity;
/**
* Non-overridable PKI algorithm security floor.
*
* <p>
* SHA-1 signatures, including RSA-PSS hash or MGF SHA-1, are rejected before
* provider capability or configured policy is considered.
* </p>
*/
public final class X509SecurityFloor {
private static final String FINGERPRINT = "pki-floor-v1:no-sha1-signature-or-component";
private X509SecurityFloor() {
}
/**
* Returns immutable security-floor semantics for authority provenance.
*
* @return stable floor fingerprint
*/
public static String semanticFingerprint() {
return FINGERPRINT;
}
/**
* Enforces the immutable security floor.
*
* @param identity requested exact identity
* @throws IllegalArgumentException if the identity contains forbidden SHA-1
* semantics
*/
public static void requirePermitted(AlgorithmIdentity identity) {
Objects.requireNonNull(identity, "identity");
String canonical = identity.canonicalForm().toLowerCase(Locale.ROOT);
if (canonical.contains("sha1") || canonical.contains("sha-1")) {
throw new IllegalArgumentException("SHA-1 is forbidden for PKI signatures");
}
}
}

View File

@@ -0,0 +1,79 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.impl.framework.x509;
import java.util.Objects;
import zeroecho.pki.api.status.StatusObject;
import zeroecho.pki.spi.crypto.SignatureWorkflow;
/**
* Non-forgeable live completion of one X.509 signed status-object operation.
*
* <p>
* Only the owning {@link X509AuthoritySnapshot} can construct this value. It
* binds the immutable status metadata to the exact live SIGN plan and opaque
* runtime provenance. The completion is not persisted and contains no key
* material or signed-object payload.
* </p>
*/
public final class X509SignedObjectCompletion {
private final StatusObject statusObject;
private final X509ExecutionPlan<SignatureWorkflow> signingPlan;
private final Object authorityToken;
/* default */ X509SignedObjectCompletion(StatusObject statusObject,
X509ExecutionPlan<SignatureWorkflow> signingPlan, Object authorityToken) {
this.statusObject = Objects.requireNonNull(statusObject, "statusObject");
this.signingPlan = Objects.requireNonNull(signingPlan, "signingPlan");
this.authorityToken = Objects.requireNonNull(authorityToken, "authorityToken");
}
/**
* Returns the completed immutable status-object metadata.
*
* @return completed status object
*/
public StatusObject statusObject() {
return statusObject;
}
/* default */ X509ExecutionPlan<SignatureWorkflow> signingPlan() {
return signingPlan;
}
/* default */ boolean isOwnedBy(Object candidate) {
return authorityToken.equals(candidate);
}
}

View File

@@ -0,0 +1,81 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.impl.framework.x509;
import java.util.Objects;
import zeroecho.core.spec.AlgorithmIdentity;
import zeroecho.core.spec.AlgorithmSuite;
/**
* Contextual compatibility validation for current classic X.509 suites.
*
* <p>
* Signature OIDs and SPKI identities are resolved independently and combined
* only here. An ECDSA signature OID therefore never implies an EC curve.
* Installed capabilities and policy may further restrict compatible suites.
* </p>
*/
public final class X509SuiteCompatibility {
private X509SuiteCompatibility() {
}
/**
* Combines compatible signature and key identities.
*
* @param signature exact signature identity
* @param publicKey exact SPKI identity
* @return complete suite
* @throws IllegalArgumentException if family roles are incompatible
*/
public static AlgorithmSuite requireCompatible(AlgorithmIdentity signature, AlgorithmIdentity publicKey) {
Objects.requireNonNull(signature, "signature");
Objects.requireNonNull(publicKey, "publicKey");
AlgorithmSuite suite = new AlgorithmSuite(signature, publicKey);
String signatureFamily = signature.family().name();
String keyFamily = publicKey.family().name();
boolean compatible = switch (signatureFamily) {
case "rsa-pkcs1-v1_5", "rsa-pss" -> "rsa".equals(keyFamily);
case "ecdsa" -> "ec".equals(keyFamily);
case "ed25519" -> "ed25519".equals(keyFamily);
case "ed448" -> "ed448".equals(keyFamily);
default -> false;
};
if (!compatible) {
throw new IllegalArgumentException("Incompatible signature and public-key identities");
}
return suite;
}
}

View File

@@ -0,0 +1,168 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.impl.framework.x509.bc;
import java.io.IOException;
import java.util.Objects;
import java.util.Arrays;
import org.bouncycastle.asn1.ASN1Encoding;
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
import org.bouncycastle.asn1.ASN1Primitive;
import org.bouncycastle.asn1.DERNull;
import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
import zeroecho.core.spec.AlgorithmIdentity;
import zeroecho.pki.impl.framework.x509.X509AlgorithmIdentifier;
import zeroecho.pki.impl.framework.x509.X509AlgorithmRole;
import zeroecho.pki.impl.framework.x509.X509BindingCatalog;
/**
* Bouncy Castle edge adapter for the provider-neutral X.509 binding authority.
*
* <p>
* This adapter owns no OID table, default, or alias. It performs only structural
* conversion and delegates semantic resolution to an immutable
* {@link X509BindingCatalog}.
* </p>
*/
public final class BcX509AlgorithmAdapter {
private final X509BindingCatalog catalog;
/**
* Creates an adapter for one immutable catalog snapshot.
*
* @param catalog authoritative provider-neutral binding catalog
*/
public BcX509AlgorithmAdapter(X509BindingCatalog catalog) {
this.catalog = Objects.requireNonNull(catalog, "catalog");
}
/**
* Resolves an exact identity and converts it to Bouncy Castle representation.
*
* @param identity exact identity
* @param role closed X.509 role
* @return exact BC algorithm identifier
*/
public AlgorithmIdentifier encode(AlgorithmIdentity identity, X509AlgorithmRole role) {
return toBc(catalog.resolve(identity, role));
}
/**
* Converts and reverse-resolves a BC algorithm identifier.
*
* @param identifier BC structural representation
* @param role closed X.509 role
* @return exact provider-independent identity
* @throws IllegalArgumentException if encoding or parameters are invalid
*/
public AlgorithmIdentity decode(AlgorithmIdentifier identifier, X509AlgorithmRole role) {
return catalog.reverse(fromBc(identifier), role);
}
/**
* Converts a provider-neutral identifier to Bouncy Castle form.
*
* @param identifier provider-neutral value
* @return BC value
*/
public static AlgorithmIdentifier toBc(X509AlgorithmIdentifier identifier) {
Objects.requireNonNull(identifier, "identifier");
ASN1ObjectIdentifier oid = new ASN1ObjectIdentifier(identifier.oid());
return switch (identifier.parameterForm()) {
case ABSENT -> new AlgorithmIdentifier(oid);
case DER_NULL -> new AlgorithmIdentifier(oid, DERNull.INSTANCE);
case EXACT_DER -> {
try {
ASN1Primitive parameters = ASN1Primitive.fromByteArray(identifier.parameters());
yield new AlgorithmIdentifier(oid, parameters);
} catch (IOException | IllegalArgumentException exception) {
throw new IllegalArgumentException("Invalid canonical X.509 parameter DER", exception);
}
}
};
}
/**
* Converts a BC identifier to an exact provider-neutral representation.
*
* @param identifier BC value
* @return provider-neutral value
* @throws IllegalArgumentException if parameter DER is malformed
*/
public static X509AlgorithmIdentifier fromBc(AlgorithmIdentifier identifier) {
Objects.requireNonNull(identifier, "identifier");
String oid = identifier.getAlgorithm().getId();
if (identifier.getParameters() == null) {
return X509AlgorithmIdentifier.absent(oid);
}
if (DERNull.INSTANCE.equals(identifier.getParameters().toASN1Primitive())) {
return X509AlgorithmIdentifier.derNull(oid);
}
try {
return X509AlgorithmIdentifier.exact(oid,
identifier.getParameters().toASN1Primitive().getEncoded(ASN1Encoding.DER));
} catch (IOException exception) {
throw new IllegalArgumentException("Cannot encode X.509 algorithm parameters", exception);
}
}
/**
* Requires one complete canonical DER object before a BC structure parser is
* invoked.
*
* @param encoded complete DER object
* @throws IllegalArgumentException for trailing data, BER forms, or
* non-canonical DER
*/
/* default */ static void requireCanonicalDer(byte[] encoded) {
Objects.requireNonNull(encoded, "encoded");
ASN1Primitive primitive;
try {
primitive = ASN1Primitive.fromByteArray(encoded);
} catch (IOException exception) {
throw new IllegalArgumentException("Invalid canonical DER object", exception);
}
try {
byte[] canonical = primitive.getEncoded(ASN1Encoding.DER);
if (!Arrays.equals(encoded, canonical)) {
throw new IllegalArgumentException("Input is not one canonical DER object");
}
} catch (IOException exception) {
throw new IllegalArgumentException("Invalid canonical DER object", exception);
}
}
}

View File

@@ -184,6 +184,7 @@ public final class BcX509CertificationRequestParser implements CertificationRequ
try {
PKCS10CertificationRequest csr;
try {
BcX509AlgorithmAdapter.requireCanonicalDer(csrDer);
csr = new PKCS10CertificationRequest(csrDer);
} catch (Exception ex) {
throw new PkiException("Invalid PKCS#10 certification request: code=CSR_MALFORMED");

View File

@@ -41,6 +41,7 @@ import zeroecho.pki.spi.framework.CredentialFramework;
import zeroecho.pki.spi.framework.FrameworkAttributeMapper;
import zeroecho.pki.spi.framework.ProofOfPossessionVerifier;
import zeroecho.pki.spi.framework.StatusObjectGenerator;
import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot;
/**
* Bouncy Castle backed X.509 implementation of {@link CredentialFramework}.
@@ -104,6 +105,7 @@ public final class BcX509CredentialFramework implements CredentialFramework {
private final ProofOfPossessionVerifier popVerifier;
private final StatusObjectGenerator status;
private final FrameworkAttributeMapper attributeMapper;
private final X509AuthoritySnapshot authority;
/**
* Creates a partially wired X.509 framework instance with default components.
@@ -124,9 +126,9 @@ public final class BcX509CredentialFramework implements CredentialFramework {
* exposed by the framework facade.
* </p>
*/
public BcX509CredentialFramework() {
this(new BcX509CertificationRequestParser(), new BcX509ProofOfPossessionVerifier(),
new UnsupportedStatusObjectGenerator(), new BcX509FrameworkAttributeMapper());
public BcX509CredentialFramework(X509AuthoritySnapshot authority, BcX509VerificationExecutor executor) {
this(new BcX509CertificationRequestParser(), new BcX509ProofOfPossessionVerifier(authority, executor),
new UnsupportedStatusObjectGenerator(), new BcX509FrameworkAttributeMapper(), authority);
}
/**
@@ -146,11 +148,12 @@ public final class BcX509CredentialFramework implements CredentialFramework {
* @throws NullPointerException if any component argument is {@code null}
*/
private BcX509CredentialFramework(CertificationRequestParser requestParser, ProofOfPossessionVerifier popVerifier,
StatusObjectGenerator status, FrameworkAttributeMapper attributeMapper) {
StatusObjectGenerator status, FrameworkAttributeMapper attributeMapper, X509AuthoritySnapshot authority) {
this.requestParser = Objects.requireNonNull(requestParser, "requestParser");
this.popVerifier = Objects.requireNonNull(popVerifier, "popVerifier");
this.status = Objects.requireNonNull(status, "status");
this.attributeMapper = Objects.requireNonNull(attributeMapper, "attributeMapper");
this.authority = Objects.requireNonNull(authority, "authority");
}
/**
@@ -170,7 +173,8 @@ public final class BcX509CredentialFramework implements CredentialFramework {
*/
public BcX509CredentialFramework wired(BcX509StatusObjectGenerator statusObjectGenerator) {
Objects.requireNonNull(statusObjectGenerator, "statusObjectGenerator");
return new BcX509CredentialFramework(requestParser, popVerifier, statusObjectGenerator, attributeMapper);
return new BcX509CredentialFramework(requestParser, popVerifier, statusObjectGenerator, attributeMapper,
authority);
}
/**
@@ -202,7 +206,16 @@ public final class BcX509CredentialFramework implements CredentialFramework {
Objects.requireNonNull(statusObjectGenerator, "statusObjectGenerator");
Objects.requireNonNull(proofOfPossessionVerifier, "proofOfPossessionVerifier");
return new BcX509CredentialFramework(requestParser, proofOfPossessionVerifier, statusObjectGenerator,
attributeMapper);
attributeMapper, authority);
}
/**
* Returns the immutable algorithm authority owned by this framework graph.
*
* @return shared authority snapshot
*/
public X509AuthoritySnapshot authority() {
return authority;
}
/**
@@ -283,9 +296,9 @@ public final class BcX509CredentialFramework implements CredentialFramework {
* @throws UnsupportedOperationException always
*/
@Override
public zeroecho.pki.api.status.StatusObject generate(
public zeroecho.pki.impl.framework.x509.X509SignedObjectCompletion generate(
zeroecho.pki.api.status.StatusObjectGenerateCommand command,
java.util.List<zeroecho.pki.spi.framework.CrlEntry> crlEntries) {
zeroecho.pki.spi.framework.CrlEntrySource crlEntries) {
throw new UnsupportedOperationException("X.509 status object generator not wired");
}
}

View File

@@ -34,12 +34,15 @@
package zeroecho.pki.impl.framework.x509.bc;
import java.util.Set;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
import zeroecho.pki.spi.ProviderConfig;
import zeroecho.pki.spi.framework.CredentialFramework;
import zeroecho.pki.spi.framework.CredentialFrameworkProvider;
import zeroecho.pki.impl.framework.x509.X509AlgorithmResolver;
import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot;
/**
* ServiceLoader provider for the Bouncy Castle backed X.509 credential
@@ -162,6 +165,12 @@ public final class BcX509CredentialFrameworkProvider implements CredentialFramew
@Override
public CredentialFramework allocate(ProviderConfig config) {
validateConfig(config);
return new BcX509CredentialFramework();
BcX509VerificationExecutor executor = new BcX509VerificationExecutor();
X509AlgorithmResolver.Policy policy = (suite, direction) -> true;
X509AuthoritySnapshot authority = X509AuthoritySnapshot.compose(List.of(), List.of(executor),
List.of(X509AuthoritySnapshot.bindExecutor(BcX509VerificationExecutor.IMPLEMENTATION_ID,
zeroecho.core.spi.AlgorithmExecutionCapability.Direction.VERIFY, executor)),
policy);
return new BcX509CredentialFramework(authority, executor);
}
}

View File

@@ -33,6 +33,9 @@
******************************************************************************/
package zeroecho.pki.impl.framework.x509.bc;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.math.BigInteger;
import java.security.MessageDigest;
import java.time.Duration;
@@ -52,8 +55,9 @@ import org.bouncycastle.asn1.x509.KeyUsage;
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.X509v3CertificateBuilder;
import org.bouncycastle.operator.ContentSigner;
import zeroecho.core.spec.AlgorithmIdentity;
import zeroecho.core.io.RepeatableContent;
import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.IssuerRef;
@@ -67,6 +71,7 @@ import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.credential.CredentialBundle;
import zeroecho.pki.api.credential.CredentialStatus;
import zeroecho.pki.api.credential.EndEntityProfileBinding;
import zeroecho.pki.api.content.DurableContentReference;
import zeroecho.pki.api.profile.LeafKeyUsage;
import zeroecho.pki.api.request.SubjectAlternativeName;
import zeroecho.pki.impl.core.ValidatedCaCertificateRequest;
@@ -74,6 +79,7 @@ import zeroecho.pki.impl.core.ValidatedCertificateRequest;
import zeroecho.pki.impl.core.async.PkiSigningBus;
import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
import zeroecho.pki.spi.framework.CredentialIssuerBackend;
import zeroecho.pki.spi.store.ContentSink;
/**
* Bouncy Castle backed X.509 credential issuance backend.
@@ -122,7 +128,7 @@ import zeroecho.pki.spi.framework.CredentialIssuerBackend;
public final class BcX509CredentialIssuerBackend implements CredentialIssuerBackend {
private final PkiSigningBus signingBus;
private final String signatureAlgorithmId;
private final AlgorithmIdentity signatureIdentity;
private final Duration signingTtl;
/**
@@ -139,17 +145,29 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
* contract
*/
public BcX509CredentialIssuerBackend(PkiSigningBus signingBus, String signatureAlgorithmId, Duration signingTtl) {
this(signingBus, signingBus.authority().resolveIdentity(signatureAlgorithmId), signingTtl);
}
/**
* Creates the X.509 issuance backend with an exact signature identity.
*
* @param signingBus signing bus used to delegate signing
* @param signatureIdentity exact provider-independent signature identity
* @param signingTtl positive maximum signing TTL
*/
public BcX509CredentialIssuerBackend(PkiSigningBus signingBus, AlgorithmIdentity signatureIdentity,
Duration signingTtl) {
if (signingBus == null) {
throw new IllegalArgumentException("signingBus must not be null");
}
if (signatureAlgorithmId == null || signatureAlgorithmId.isBlank()) {
throw new IllegalArgumentException("signatureAlgorithmId must not be null/blank");
if (signatureIdentity == null || signatureIdentity.kind() != AlgorithmIdentity.Kind.SIGNATURE) {
throw new IllegalArgumentException("signatureIdentity must be a signature identity");
}
if (signingTtl == null || signingTtl.isZero() || signingTtl.isNegative()) {
throw new IllegalArgumentException("signingTtl must be positive");
}
this.signingBus = signingBus;
this.signatureAlgorithmId = signatureAlgorithmId;
this.signatureIdentity = signatureIdentity;
this.signingTtl = signingTtl;
}
@@ -174,13 +192,14 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
* fails, or certificate encoding fails
*/
@Override
public CredentialBundle issueEndEntity(ValidatedCertificateRequest request, EncodedObject issuerCertificate,
public CredentialBundle issueEndEntity(ValidatedCertificateRequest request,
DurableContentReference issuerCertificate,
KeyRef issuerKeyRef, BigInteger serial) {
if (request == null || issuerCertificate == null || issuerKeyRef == null || serial == null
|| serial.signum() <= 0 || serial.toByteArray().length > 20) {
throw new IllegalArgumentException("Invalid validated end-entity issuance input");
}
byte[] issuerDer = issuerCertificate.bytes();
byte[] issuerDer = materialize(issuerCertificate);
X509CertificateHolder issuer;
try {
issuer = parseIssuerCertificateOrThrow(issuerDer);
@@ -195,7 +214,7 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
Date.from(request.validity().notBefore()), Date.from(request.validity().notAfter()), subjectDn, spki);
addLeafExtensions(builder, request);
ContentSigner signer = new PkiBusContentSigner(signingBus, issuerKeyRef, signatureAlgorithmId, signingTtl);
PkiBusContentSigner signer = new PkiBusContentSigner(signingBus, issuerKeyRef, signatureIdentity, signingTtl);
X509CertificateHolder leaf;
try {
leaf = builder.build(signer);
@@ -214,10 +233,12 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
PkiId publicKeyId = new PkiId("spki:" + fingerprintEncoded(request.exactPublicKey()));
try {
DurableContentReference content = stageCertificate(certDer);
validateGeneratedCertificate(content, signer);
Credential credential = new Credential(credId, BcX509CredentialFramework.FORMAT_ID,
new IssuerRef(request.issuerCaId()), request.subjectRef(), request.validity(), serial.toString(),
publicKeyId, new EndEntityProfileBinding(request.profileReference()), CredentialStatus.ISSUED,
new EncodedObject(Encoding.DER, certDer), SimpleAttributeSet.builder().build());
content, SimpleAttributeSet.builder().build());
return new CredentialBundle(credential, java.util.List.of(issuerCertificate));
} finally {
java.util.Arrays.fill(certDer, (byte) 0);
@@ -302,7 +323,7 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
*/
@Override
public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest request,
EncodedObject issuerCertificate, KeyRef issuerKeyRef) {
DurableContentReference issuerCertificate, KeyRef issuerKeyRef) {
if (request == null || issuerCertificate == null || issuerKeyRef == null) {
throw new IllegalArgumentException("validated CA issuance inputs must not be null");
}
@@ -310,7 +331,7 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
throw new IllegalArgumentException("Unsupported formatId");
}
byte[] issuerDer = issuerCertificate.bytes();
byte[] issuerDer = materialize(issuerCertificate);
X509CertificateHolder issuer;
try {
issuer = parseIssuerCertificateOrThrow(issuerDer);
@@ -343,7 +364,7 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
throw new PkiException("X.509 extension construction failed: code=EXTENSION_BUILD_FAILED");
}
ContentSigner signer = new PkiBusContentSigner(signingBus, issuerKeyRef, signatureAlgorithmId, signingTtl);
PkiBusContentSigner signer = new PkiBusContentSigner(signingBus, issuerKeyRef, signatureIdentity, signingTtl);
X509CertificateHolder certificate;
try {
certificate = builder.build(signer);
@@ -361,15 +382,66 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
PkiId credId = new PkiId("x509:" + sha256Hex(certDer));
try {
DurableContentReference content = stageCertificate(certDer);
validateGeneratedCertificate(content, signer);
return new Credential(credId, request.formatId(), new IssuerRef(request.issuerCaId()), subjectRef, validity,
serial.toString(), publicKeyId, new CaProfileBinding(request.profileReference()),
CredentialStatus.ISSUED, new EncodedObject(Encoding.DER, certDer),
CredentialStatus.ISSUED, content,
SimpleAttributeSet.builder().build());
} finally {
java.util.Arrays.fill(certDer, (byte) 0);
}
}
private DurableContentReference stageCertificate(byte[] certificate) {
try (ContentSink sink = signingBus.beginContent(Encoding.DER, DurableContentReference.Lifecycle.PERSISTED);
OutputStream output = sink.outputStream()) {
output.write(certificate);
return sink.complete();
} catch (IOException exception) {
throw new PkiException("Certificate staging failed: code=SPOOL_STORAGE_FAILED", exception);
}
}
private void validateGeneratedCertificate(DurableContentReference reference, PkiBusContentSigner signer) {
try (RepeatableContent content = signingBus.openContent(reference)) {
new BcX509SignedObjectValidator(signingBus.authority()).validateGeneratedCertificate(content,
signer.executionPlan(), zeroecho.core.io.CancellationSignal.NONE);
} catch (IOException | IllegalArgumentException exception) {
signingBus.releaseContent(reference);
throw new PkiException("Certificate postcondition failed: code=BACKEND_RESULT_SUBSTITUTION", exception);
}
}
private byte[] materialize(DurableContentReference reference) {
if (reference.length() > Integer.MAX_VALUE) {
throw new PkiException("Certificate exceeds BC adapter element domain: code=ADAPTER_ELEMENT_LIMIT_EXCEEDED");
}
byte[] result = new byte[(int) reference.length()];
try {
readExact(reference, result);
return result;
} catch (IOException exception) {
throw new PkiException("Certificate content failed: code=CONTENT_IO_FAILED", exception);
}
}
private void readExact(DurableContentReference reference, byte[] result) throws IOException {
try (RepeatableContent content = signingBus.openContent(reference); InputStream input = content.openStream()) {
int offset = 0;
while (offset != result.length) {
int count = input.read(result, offset, result.length - offset);
if (count < 0) {
throw new IOException("Certificate content is truncated");
}
offset += count;
}
if (input.read() >= 0) {
throw new IOException("Certificate content has trailing bytes");
}
}
}
private static X509CertificateHolder parseIssuerCertificateOrThrow(byte[] issuerCertDer) {
try {
return new X509CertificateHolder(issuerCertDer);

View File

@@ -35,17 +35,20 @@ package zeroecho.pki.impl.framework.x509.bc;
import java.util.Optional;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.operator.ContentVerifierProvider;
import org.bouncycastle.operator.OperatorCreationException;
import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder;
import org.bouncycastle.pkcs.PKCS10CertificationRequest;
import zeroecho.core.spec.AlgorithmIdentity;
import zeroecho.core.io.ImmutableByteContent;
import zeroecho.core.spi.AlgorithmExecutionCapability;
import zeroecho.pki.api.attr.AttributeValue;
import zeroecho.pki.api.issuance.VerificationPolicy;
import zeroecho.pki.api.request.ParsedCertificationRequest;
import zeroecho.pki.api.request.ProofOfPossessionResult;
import zeroecho.pki.api.request.ProofOfPossessionStatus;
import zeroecho.pki.impl.framework.x509.X509AlgorithmRole;
import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot;
import zeroecho.pki.impl.framework.x509.X509ExecutionPlan;
import zeroecho.pki.spi.framework.ProofOfPossessionVerifier;
/**
@@ -94,21 +97,38 @@ import zeroecho.pki.spi.framework.ProofOfPossessionVerifier;
* authorization checks, or profile compliance checks.</li>
* <li>The presence of a valid PKCS#10 self-signature does not by itself imply
* that the requester is entitled to receive the requested certificate.</li>
* <li>This verifier accepts signature algorithms supported by its Bouncy Castle
* provider, including RSASSA-PSS. Deployments requiring a narrower or stronger
* algorithm policy must enforce it in the issuance policy layer.</li>
* <li>Algorithm identity, X.509 binding, immutable security-floor, capability,
* and configured-policy decisions are taken from one injected
* {@link X509AuthoritySnapshot}. Bouncy Castle remains only the structural and
* cryptographic adapter at this Phase A boundary.</li>
* <li>The CSR payload carried under {@link BcX509Attributes#CSR_DER} may be
* operationally sensitive and must not be logged unsafely.</li>
* </ul>
*
* <h2>Thread-safety</h2>
* <p>
* This class is stateless and thread-safe.
* This class is immutable and thread-safe when its authority snapshot is shared
* as part of one immutable runtime graph.
* </p>
*/
public final class BcX509ProofOfPossessionVerifier implements ProofOfPossessionVerifier {
private static final BouncyCastleProvider BC_PROVIDER = new BouncyCastleProvider();
private final X509AuthoritySnapshot authority;
private final BcX509VerificationExecutor executor;
/**
* Creates a verifier backed by one runtime authority and its exact executor.
*
* @param authority identity, binding, capability, floor, and policy authority;
* must not be {@code null}
* @param executor process-local verification executor bound into
* {@code authority}
* @throws NullPointerException if {@code authority} is {@code null}
*/
public BcX509ProofOfPossessionVerifier(X509AuthoritySnapshot authority, BcX509VerificationExecutor executor) {
this.authority = java.util.Objects.requireNonNull(authority, "authority");
this.executor = java.util.Objects.requireNonNull(executor, "executor");
}
/**
* Verifies proof of possession for a parsed PKCS#10 certification request.
@@ -123,7 +143,7 @@ public final class BcX509ProofOfPossessionVerifier implements ProofOfPossessionV
* Otherwise, the method expects the original CSR DER payload to be present in
* {@code request.attributes()} under {@link BcX509Attributes#CSR_DER} as an
* {@link AttributeValue.BytesValue}. It then parses the CSR, creates a Bouncy
* Castle {@link ContentVerifierProvider} from the CSR's embedded subject public
* Castle content verifier from the CSR's embedded subject public
* key information, and validates the PKCS#10 signature.
* </p>
*
@@ -179,17 +199,33 @@ public final class BcX509ProofOfPossessionVerifier implements ProofOfPossessionV
try {
PKCS10CertificationRequest csr;
try {
BcX509AlgorithmAdapter.requireCanonicalDer(csrDer);
csr = new PKCS10CertificationRequest(csrDer);
} catch (Exception ex) {
return new ProofOfPossessionResult(ProofOfPossessionStatus.FAILED, Optional.of("Invalid CSR"));
}
ContentVerifierProvider cvp = new JcaContentVerifierProviderBuilder().setProvider(BC_PROVIDER)
.build(csr.getSubjectPublicKeyInfo());
boolean ok = csr.isSignatureValid(cvp);
if (ok) {
return new ProofOfPossessionResult(ProofOfPossessionStatus.VERIFIED, Optional.empty());
BcX509AlgorithmAdapter algorithmAdapter = new BcX509AlgorithmAdapter(authority.bindings());
try {
AlgorithmIdentity signatureIdentity = algorithmAdapter.decode(csr.getSignatureAlgorithm(),
X509AlgorithmRole.SIGNATURE_ALGORITHM);
AlgorithmIdentity keyIdentity = algorithmAdapter.decode(csr.getSubjectPublicKeyInfo().getAlgorithm(),
X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM);
X509ExecutionPlan<BcX509VerificationExecutor> plan = authority.plan(signatureIdentity, keyIdentity,
AlgorithmExecutionCapability.Direction.VERIFY,
Optional.of(BcX509VerificationExecutor.IMPLEMENTATION_ID), "csr-proof",
BcX509VerificationExecutor.class);
byte[] signedBytes = csr.toASN1Structure().getCertificationRequestInfo().getEncoded();
boolean valid = executor.verify(authority, plan, csr.getSubjectPublicKeyInfo(),
csr.getSignatureAlgorithm(), new ImmutableByteContent(signedBytes), csr.getSignature());
if (!valid) {
return new ProofOfPossessionResult(ProofOfPossessionStatus.FAILED,
Optional.of("CSR signature invalid"));
}
} catch (IllegalArgumentException unsupported) {
return new ProofOfPossessionResult(ProofOfPossessionStatus.FAILED,
Optional.of("Unsupported CSR algorithm"));
}
return new ProofOfPossessionResult(ProofOfPossessionStatus.FAILED, Optional.of("CSR signature invalid"));
return new ProofOfPossessionResult(ProofOfPossessionStatus.VERIFIED, Optional.empty());
} catch (OperatorCreationException ex) {
return new ProofOfPossessionResult(ProofOfPossessionStatus.FAILED, Optional.of("Verifier unavailable"));
} catch (Exception ex) {

View File

@@ -0,0 +1,305 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.impl.framework.x509.bc;
import java.io.IOException;
import java.io.InputStream;
import java.security.GeneralSecurityException;
import java.util.Objects;
import java.util.Optional;
import org.bouncycastle.asn1.ASN1Primitive;
import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
import org.bouncycastle.operator.OperatorCreationException;
import zeroecho.core.io.CancellationSignal;
import zeroecho.core.io.ContentSlice;
import zeroecho.core.io.RepeatableContent;
import zeroecho.core.spec.AlgorithmIdentity;
import zeroecho.core.spi.AlgorithmExecutionCapability;
import zeroecho.pki.impl.framework.x509.StreamingDerReader;
import zeroecho.pki.impl.framework.x509.X509AlgorithmRole;
import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot;
import zeroecho.pki.impl.framework.x509.X509ExecutionPlan;
import zeroecho.pki.impl.framework.x509.X509SecurityFloor;
import zeroecho.pki.impl.framework.x509.X509SuiteCompatibility;
import zeroecho.pki.spi.crypto.SignatureWorkflow;
/**
* Bouncy Castle edge inspection of a canonically validated signed X.509 object.
*
* <p>
* The original repeatable content is validated incrementally before any BC
* normalization. Only the individually bounded algorithm and SPKI values are
* materialized. Exact outer and TBS algorithm encodings, authoritative binding
* identities, and signature/key compatibility are then required. This adapter
* contains no key material and does not perform Phase B execution.
* </p>
*/
public final class BcX509SignedObjectValidator {
private static final int COMPARE_BUFFER_BYTES = 4096;
private final X509AuthoritySnapshot authority;
private final StreamingDerReader reader;
/**
* Creates a validator owned by one immutable authority snapshot.
*
* @param authority runtime binding and security authority
*/
public BcX509SignedObjectValidator(X509AuthoritySnapshot authority) {
this.authority = Objects.requireNonNull(authority, "authority");
this.reader = new StreamingDerReader();
}
/**
* Validates one complete canonical certificate and its exact bindings.
*
* @param content original repeatable certificate DER
* @param expectedSignature optional exact signature identity selected for
* generation
* @param cancellation operation cancellation signal
* @return exact signature and subject-key identities
* @throws IOException if DER, structure, binding, or compatibility is invalid
*/
public CertificateBindings validateCertificate(RepeatableContent content,
Optional<AlgorithmIdentity> expectedSignature, CancellationSignal cancellation) throws IOException {
Objects.requireNonNull(content, "content");
Objects.requireNonNull(expectedSignature, "expectedSignature");
Objects.requireNonNull(cancellation, "cancellation");
StreamingDerReader.SignedObjectLayout layout = reader.inspectSignedObject(content,
StreamingDerReader.SignedObjectKind.CERTIFICATE, cancellation);
ContentSlice tbsAlgorithm = new ContentSlice(content, layout.tbsAlgorithmOffset(),
layout.tbsAlgorithmLength());
ContentSlice outerAlgorithm = new ContentSlice(content, layout.outerAlgorithmOffset(),
layout.outerAlgorithmLength());
if (!equalContent(tbsAlgorithm, outerAlgorithm, cancellation)) {
throw new IOException("Certificate algorithms differ: code=OUTER_TBS_ALGORITHM_MISMATCH");
}
AlgorithmIdentity signature = decodeAlgorithm(tbsAlgorithm, X509AlgorithmRole.SIGNATURE_ALGORITHM);
X509SecurityFloor.requirePermitted(signature);
if (expectedSignature.isPresent() && !expectedSignature.get().equals(signature)) {
throw new IOException(
"Certificate signature differs from execution plan: code=EXPECTED_SIGNATURE_IDENTITY_MISMATCH");
}
ContentSlice spki = new ContentSlice(content, layout.subjectPublicKeyInfoOffset(),
layout.subjectPublicKeyInfoLength());
AlgorithmIdentity key = decodeSpki(spki);
X509SuiteCompatibility.requireCompatible(signature, key);
return new CertificateBindings(signature, key, layout);
}
/**
* Validates a generated certificate against its exact live signing plan.
*
* @param content generated canonical certificate content
* @param plan non-forgeable execution plan used by the signer
* @param cancellation cancellation signal
* @return exact signature and subject-key identities
* @throws IOException if DER or binding postconditions fail
* @throws IllegalArgumentException if the plan belongs to another runtime
*/
public CertificateBindings validateGeneratedCertificate(RepeatableContent content,
X509ExecutionPlan<SignatureWorkflow> plan, CancellationSignal cancellation) throws IOException {
Objects.requireNonNull(plan, "plan");
authority.authorize(plan, plan.executor(), AlgorithmExecutionCapability.Direction.SIGN);
return validateCertificate(content, Optional.of(plan.selection().requested()), cancellation);
}
/**
* Validates a generated CRL against its exact live signing plan and issuer
* public key.
*
* @param content original complete CRL DER
* @param plan exact non-forgeable SIGN plan used for generation
* @param issuerPublicKey authorized issuer SPKI
* @param cancellation operation cancellation signal
* @throws IOException if canonical DER, binding, suite, or signature
* validation fails
* @throws IllegalArgumentException if the plan belongs to another runtime
*/
public void validateGeneratedCrl(RepeatableContent content, X509ExecutionPlan<SignatureWorkflow> plan,
SubjectPublicKeyInfo issuerPublicKey, CancellationSignal cancellation) throws IOException {
Objects.requireNonNull(content, "content");
Objects.requireNonNull(plan, "plan");
Objects.requireNonNull(issuerPublicKey, "issuerPublicKey");
Objects.requireNonNull(cancellation, "cancellation");
authority.authorize(plan, plan.executor(), AlgorithmExecutionCapability.Direction.SIGN);
StreamingDerReader.SignedObjectLayout layout = reader.inspectSignedObject(content,
StreamingDerReader.SignedObjectKind.CRL, cancellation);
ContentSlice tbsAlgorithm = new ContentSlice(content, layout.tbsAlgorithmOffset(),
layout.tbsAlgorithmLength());
ContentSlice outerAlgorithm = new ContentSlice(content, layout.outerAlgorithmOffset(),
layout.outerAlgorithmLength());
if (!equalContent(tbsAlgorithm, outerAlgorithm, cancellation)) {
throw new IOException("CRL algorithms differ: code=OUTER_TBS_ALGORITHM_MISMATCH");
}
AlgorithmIdentity signatureIdentity = decodeAlgorithm(outerAlgorithm,
X509AlgorithmRole.SIGNATURE_ALGORITHM);
if (!plan.selection().requested().equals(signatureIdentity)) {
throw new IOException("CRL signature differs from execution plan: code=STATUS_OBJECT_BINDING_MISMATCH");
}
AlgorithmIdentity issuerKey = new BcX509AlgorithmAdapter(authority.bindings()).decode(
issuerPublicKey.getAlgorithm(), X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM);
X509SuiteCompatibility.requireCompatible(signatureIdentity, issuerKey);
AlgorithmIdentifier identifier = decodeIdentifier(outerAlgorithm);
byte[] signature = materializeElement(new ContentSlice(content, layout.signatureOffset(),
layout.signatureLength()));
X509ExecutionPlan<BcX509VerificationExecutor> verification = authority.plan(signatureIdentity, issuerKey,
AlgorithmExecutionCapability.Direction.VERIFY,
Optional.of(BcX509VerificationExecutor.IMPLEMENTATION_ID), "status-postcondition",
BcX509VerificationExecutor.class);
try (RepeatableContent tbs = new ContentSlice(content, layout.tbsOffset(), layout.tbsLength())) {
if (!verification.executor().verify(authority, verification, issuerPublicKey, identifier, tbs,
signature)) {
throw new IOException("CRL signature invalid: code=SIGNATURE_VERIFICATION_FAILED");
}
} catch (GeneralSecurityException | OperatorCreationException exception) {
throw new IOException("CRL signature invalid: code=SIGNATURE_VERIFICATION_FAILED", exception);
} finally {
java.util.Arrays.fill(signature, (byte) 0);
}
}
private static AlgorithmIdentifier decodeIdentifier(RepeatableContent content) throws IOException {
byte[] encoded = materializeElement(content);
try {
return AlgorithmIdentifier.getInstance(ASN1Primitive.fromByteArray(encoded));
} catch (IllegalArgumentException exception) {
throw new IOException("Malformed AlgorithmIdentifier: code=UNKNOWN_SIGNATURE_BINDING", exception);
} finally {
java.util.Arrays.fill(encoded, (byte) 0);
}
}
private AlgorithmIdentity decodeAlgorithm(RepeatableContent content, X509AlgorithmRole role) throws IOException {
byte[] encoded = materializeElement(content);
try {
AlgorithmIdentifier identifier = AlgorithmIdentifier.getInstance(ASN1Primitive.fromByteArray(encoded));
return new BcX509AlgorithmAdapter(authority.bindings()).decode(identifier, role);
} catch (IllegalArgumentException exception) {
throw new IOException("Unknown X.509 algorithm binding: code=UNKNOWN_SIGNATURE_BINDING", exception);
} finally {
java.util.Arrays.fill(encoded, (byte) 0);
}
}
private AlgorithmIdentity decodeSpki(RepeatableContent content) throws IOException {
byte[] encoded = materializeElement(content);
try {
SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(ASN1Primitive.fromByteArray(encoded));
return new BcX509AlgorithmAdapter(authority.bindings()).decode(spki.getAlgorithm(),
X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM);
} catch (IllegalArgumentException exception) {
throw new IOException("Unknown SPKI binding: code=UNKNOWN_SPKI_BINDING", exception);
} finally {
java.util.Arrays.fill(encoded, (byte) 0);
}
}
private static byte[] materializeElement(RepeatableContent content) throws IOException {
long length = content.length().orElseThrow();
if (length > Integer.MAX_VALUE) {
throw new IOException("X.509 element exceeds adapter domain: code=ADAPTER_ELEMENT_LIMIT_EXCEEDED");
}
byte[] encoded = new byte[(int) length];
try (InputStream input = content.openStream()) {
int offset = 0;
while (offset < encoded.length) {
int count = input.read(encoded, offset, encoded.length - offset);
if (count < 0) {
throw new IOException("Truncated X.509 element");
}
offset += count;
}
if (input.read() >= 0) {
throw new IOException("Trailing X.509 element data");
}
}
return encoded;
}
private static boolean equalContent(RepeatableContent left, RepeatableContent right,
CancellationSignal cancellation) throws IOException {
if (left.length().orElseThrow() != right.length().orElseThrow()) {
return false;
}
byte[] leftBuffer = new byte[COMPARE_BUFFER_BYTES];
byte[] rightBuffer = new byte[COMPARE_BUFFER_BYTES];
try (InputStream leftInput = left.openStream(); InputStream rightInput = right.openStream()) {
while (true) {
cancellation.throwIfCancelled();
int leftCount = leftInput.read(leftBuffer);
int rightCount = rightInput.read(rightBuffer);
if (leftCount != rightCount) {
return false;
}
if (leftCount < 0) {
return true;
}
for (int index = 0; index < leftCount; index++) {
if (leftBuffer[index] != rightBuffer[index]) {
return false;
}
}
}
} finally {
java.util.Arrays.fill(leftBuffer, (byte) 0);
java.util.Arrays.fill(rightBuffer, (byte) 0);
}
}
/**
* Exact role-specific identities extracted from validated original DER.
*
* @param signature exact certificate signature identity
* @param subjectPublicKey exact subject SPKI identity
* @param layout immutable original-content offsets
*/
public record CertificateBindings(AlgorithmIdentity signature, AlgorithmIdentity subjectPublicKey,
StreamingDerReader.SignedObjectLayout layout) {
/**
* Creates immutable binding results.
*
* @throws NullPointerException if a value is {@code null}
*/
public CertificateBindings {
Objects.requireNonNull(signature, "signature");
Objects.requireNonNull(subjectPublicKey, "subjectPublicKey");
Objects.requireNonNull(layout, "layout");
}
}
}

View File

@@ -33,27 +33,33 @@
******************************************************************************/
package zeroecho.pki.impl.framework.x509.bc;
import java.security.MessageDigest;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Arrays;
import java.util.Date;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.ASN1EncodableVector;
import org.bouncycastle.asn1.ASN1Integer;
import org.bouncycastle.asn1.DERSequence;
import org.bouncycastle.asn1.DERTaggedObject;
import org.bouncycastle.asn1.x509.AuthorityKeyIdentifier;
import org.bouncycastle.asn1.x509.CRLReason;
import org.bouncycastle.asn1.x509.Extension;
import org.bouncycastle.cert.X509CRLHolder;
import org.bouncycastle.asn1.x509.Extensions;
import org.bouncycastle.asn1.x509.Time;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.X509v2CRLBuilder;
import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils;
import org.bouncycastle.operator.ContentSigner;
import zeroecho.pki.api.EncodedObject;
import zeroecho.core.io.CancellationSignal;
import zeroecho.core.io.ContentSlice;
import zeroecho.core.io.RepeatableContent;
import zeroecho.core.spec.AlgorithmIdentity;
import zeroecho.core.spi.AlgorithmExecutionCapability;
import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.FormatId;
import zeroecho.pki.api.KeyRef;
@@ -65,9 +71,22 @@ import zeroecho.pki.api.revocation.RevocationReason;
import zeroecho.pki.api.status.StatusObject;
import zeroecho.pki.api.status.StatusObjectGenerateCommand;
import zeroecho.pki.api.status.StatusObjectType;
import zeroecho.pki.api.content.DurableContentReference;
import zeroecho.pki.impl.core.async.PkiSigningBus;
import zeroecho.pki.impl.framework.x509.StreamingDerWriter;
import zeroecho.pki.impl.framework.x509.StreamingDerReader;
import zeroecho.pki.impl.framework.x509.StreamingDerReader.SignedObjectKind;
import zeroecho.pki.impl.framework.x509.StreamingDerReader.SignedObjectLayout;
import zeroecho.pki.impl.framework.x509.X509AlgorithmRole;
import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot;
import zeroecho.pki.impl.framework.x509.X509ExecutionPlan;
import zeroecho.pki.impl.framework.x509.X509SignedObjectCompletion;
import zeroecho.pki.spi.crypto.SignatureWorkflow;
import zeroecho.pki.spi.framework.CrlEntry;
import zeroecho.pki.spi.framework.CrlEntrySource;
import zeroecho.pki.spi.framework.StatusObjectGenerator;
import zeroecho.pki.spi.store.ContentSink;
import zeroecho.pki.spi.store.TemporaryUniqueIndex;
/**
* Bouncy Castle backed generator of X.509 status objects.
@@ -143,8 +162,10 @@ import zeroecho.pki.spi.framework.StatusObjectGenerator;
*/
public final class BcX509StatusObjectGenerator implements StatusObjectGenerator {
private static final long EMPTY_CONTENT_LENGTH = 0L;
private final PkiSigningBus signingBus;
private final String signatureAlgorithmId;
private final AlgorithmIdentity signatureIdentity;
private final Duration signingTtl;
/**
@@ -161,17 +182,29 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator
* contract
*/
public BcX509StatusObjectGenerator(PkiSigningBus signingBus, String signatureAlgorithmId, Duration signingTtl) {
this(signingBus, signingBus.authority().resolveIdentity(signatureAlgorithmId), signingTtl);
}
/**
* Creates the generator with an exact signature identity.
*
* @param signingBus signing bus
* @param signatureIdentity exact provider-independent signature identity
* @param signingTtl positive signing TTL
*/
public BcX509StatusObjectGenerator(PkiSigningBus signingBus, AlgorithmIdentity signatureIdentity,
Duration signingTtl) {
if (signingBus == null) {
throw new IllegalArgumentException("signingBus must not be null");
}
if (signatureAlgorithmId == null || signatureAlgorithmId.isBlank()) {
throw new IllegalArgumentException("signatureAlgorithmId must not be null/blank");
if (signatureIdentity == null || signatureIdentity.kind() != AlgorithmIdentity.Kind.SIGNATURE) {
throw new IllegalArgumentException("signatureIdentity must be a signature identity");
}
if (signingTtl == null || signingTtl.isZero() || signingTtl.isNegative()) {
throw new IllegalArgumentException("signingTtl must be positive");
}
this.signingBus = signingBus;
this.signatureAlgorithmId = signatureAlgorithmId;
this.signatureIdentity = signatureIdentity;
this.signingTtl = signingTtl;
}
@@ -212,28 +245,240 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator
* if signing fails, or if CRL encoding fails
*/
@Override
public StatusObject generate(StatusObjectGenerateCommand command, List<CrlEntry> crlEntries) {
public X509SignedObjectCompletion generate(StatusObjectGenerateCommand command, CrlEntrySource crlEntries) {
validateCommandOrThrow(command);
List<CrlEntry> validatedEntries = validateEntries(crlEntries);
if (crlEntries == null) {
throw new IllegalArgumentException("crlEntries must not be null");
}
IssuerMaterial issuerMaterial = extractIssuerMaterialOrThrow(command.attributes());
Instant thisUpdate = Instant.now();
Instant nextUpdate = thisUpdate.plus(Duration.ofDays(7));
Date thisUpdateDate = Date.from(thisUpdate);
X509v2CRLBuilder builder = newCrlBuilder(issuerMaterial.issuerHolder(), thisUpdateDate, nextUpdate);
addRevokedEntries(builder, validatedEntries);
addAuthorityKeyIdentifierOrThrow(builder, issuerMaterial.issuerHolder());
X509CRLHolder crl = buildSignedCrlOrThrow(builder, issuerMaterial.keyRef());
byte[] crlDer = encodeCrlOrThrow(crl);
return toStatusObject(command, crlDer, thisUpdate, nextUpdate);
DurableContentReference entries = null;
DurableContentReference tbs = null;
DurableContentReference crl = null;
boolean accepted = false;
try {
entries = encodeEntries(crlEntries);
PkiBusContentSigner signer = new PkiBusContentSigner(signingBus, issuerMaterial.keyRef(),
signatureIdentity, signingTtl);
byte[] algorithm = signer.getAlgorithmIdentifier().getEncoded();
tbs = encodeTbs(issuerMaterial.issuerHolder(), thisUpdate, nextUpdate, entries, algorithm);
copyToSigner(tbs, signer);
byte[] signature = signer.getSignature();
crl = encodeOuter(tbs, algorithm, signature);
requireValidResult(crl, tbs, signer.executionPlan(), signer.getAlgorithmIdentifier(), signature,
issuerMaterial.issuerHolder());
StatusObject result = new StatusObject(new PkiId("crl:" + crl.sha256()), command.formatId(),
command.issuerCaId(),
command.type(), thisUpdate, Optional.of(nextUpdate), crl, command.attributes());
X509SignedObjectCompletion completion = signingBus.authority().completeStatusObject(result,
signer.executionPlan());
accepted = true;
return completion;
} catch (IOException | ArithmeticException exception) {
throw new PkiException("CRL generation failed: code=SPOOL_STORAGE_FAILED", exception);
} finally {
if (!accepted) {
releaseTemporary(crl);
}
releaseTemporary(tbs);
releaseTemporary(entries);
}
}
private static List<CrlEntry> validateEntries(List<CrlEntry> crlEntries) {
Objects.requireNonNull(crlEntries, "crlEntries");
return List.copyOf(crlEntries);
private void requireValidResult(DurableContentReference crl, DurableContentReference tbs,
X509ExecutionPlan<SignatureWorkflow> signingPlan,
org.bouncycastle.asn1.x509.AlgorithmIdentifier algorithm, byte[] signature, X509CertificateHolder issuer)
throws IOException {
requireAuthorizedSigningPlan(signingBus.authority(), signingPlan, signatureIdentity);
try (RepeatableContent complete = signingBus.openContent(crl)) {
StreamingDerReader reader = new StreamingDerReader();
SignedObjectLayout layout = reader.inspectSignedObject(complete, SignedObjectKind.CRL,
CancellationSignal.NONE);
byte[] expectedAlgorithm = algorithm.getEncoded();
ContentComparison.requireExactSlice(complete, layout.tbsAlgorithmOffset(), layout.tbsAlgorithmLength(),
expectedAlgorithm, "CRL TBS binding mismatch");
ContentComparison.requireExactSlice(complete, layout.outerAlgorithmOffset(), layout.outerAlgorithmLength(),
expectedAlgorithm, "CRL outer binding mismatch");
ContentComparison.requireExactSlice(complete, layout.signatureOffset(), layout.signatureLength(), signature,
"CRL signature result mismatch");
try (RepeatableContent expectedTbs = signingBus.openContent(tbs)) {
if (!ContentComparison.contentEquals(complete, layout.tbsOffset(), layout.tbsLength(), expectedTbs)) {
throw new PkiException("CRL TBS substitution: code=BACKEND_RESULT_SUBSTITUTION");
}
}
BcX509AlgorithmAdapter adapter = new BcX509AlgorithmAdapter(signingBus.authority().bindings());
AlgorithmIdentity actualSignature = adapter.decode(algorithm, X509AlgorithmRole.SIGNATURE_ALGORITHM);
if (!signingPlan.selection().requested().equals(actualSignature)) {
throw new PkiException("CRL binding mismatch: code=STATUS_OBJECT_BINDING_MISMATCH");
}
AlgorithmIdentity issuerKey = adapter.decode(issuer.getSubjectPublicKeyInfo().getAlgorithm(),
X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM);
X509ExecutionPlan<BcX509VerificationExecutor> plan = signingBus.authority().plan(actualSignature,
issuerKey, AlgorithmExecutionCapability.Direction.VERIFY,
Optional.of(BcX509VerificationExecutor.IMPLEMENTATION_ID), "crl-postcondition",
BcX509VerificationExecutor.class);
try (RepeatableContent signedContent = new ContentSlice(complete, layout.tbsOffset(),
layout.tbsLength())) {
if (!plan.executor().verify(signingBus.authority(), plan, issuer.getSubjectPublicKeyInfo(), algorithm,
signedContent, signature)) {
throw new PkiException("CRL signature invalid: code=SIGNATURE_VERIFICATION_FAILED");
}
}
} catch (java.security.GeneralSecurityException | org.bouncycastle.operator.OperatorCreationException ex) {
throw new PkiException("CRL verification failed: code=SIGNATURE_VERIFICATION_FAILED", ex);
}
}
/*
* The status-object postcondition accepts only the exact live plan minted by
* its runtime authority. An equal public fingerprint cannot substitute for
* opaque process-local provenance.
*/
/* default */ static void requireAuthorizedSigningPlan(X509AuthoritySnapshot authority,
X509ExecutionPlan<SignatureWorkflow> signingPlan, AlgorithmIdentity expectedIdentity) {
authority.authorize(signingPlan, signingPlan.executor(), AlgorithmExecutionCapability.Direction.SIGN);
if (!expectedIdentity.equals(signingPlan.selection().requested())) {
throw new PkiException("CRL signing-plan substitution: code=BACKEND_RESULT_SUBSTITUTION");
}
}
private DurableContentReference encodeEntries(CrlEntrySource source) throws IOException {
try (ContentSink sink = signingBus.beginContent(Encoding.DER, DurableContentReference.Lifecycle.TEMPORARY);
OutputStream output = sink.outputStream();
CrlEntrySource.Cursor cursor = source.openCursor();
TemporaryUniqueIndex serials = signingBus.beginUniqueIndex()) {
long expectedOrdinal = 0L;
while (cursor.next()) {
if (cursor.ordinal() != expectedOrdinal) {
throw new PkiException("CRL entry order invalid: code=MALFORMED_SIGNED_OBJECT");
}
CrlEntry entry = cursor.current();
if (!serials.add(unsignedSerial(entry))) {
throw new PkiException("Duplicate CRL serial: code=MALFORMED_SIGNED_OBJECT");
}
output.write(encodeEntry(entry));
expectedOrdinal = Math.addExact(expectedOrdinal, 1L);
}
return sink.complete();
}
}
private static byte[] unsignedSerial(CrlEntry entry) {
byte[] encoded = entry.serialNumber().toByteArray();
if (encoded.length > 1 && encoded[0] == 0) {
return Arrays.copyOfRange(encoded, 1, encoded.length);
}
return encoded;
}
private static byte[] encodeEntry(CrlEntry entry) throws IOException {
if (entry == null) {
throw new IllegalArgumentException("CRL entry must not be null");
}
ASN1EncodableVector values = new ASN1EncodableVector(3);
values.add(new ASN1Integer(entry.serialNumber()));
values.add(new Time(Date.from(entry.transitionTime().truncatedTo(ChronoUnit.SECONDS))));
int reason = CrlReasonAdapter.code(entry.reason());
if (reason != CRLReason.unspecified) {
Extension reasonExtension = Extension.create(Extension.reasonCode, false, CRLReason.lookup(reason));
values.add(new Extensions(reasonExtension));
}
return new DERSequence(values).getEncoded();
}
private DurableContentReference encodeTbs(X509CertificateHolder issuer, Instant thisUpdate, Instant nextUpdate,
DurableContentReference entries, byte[] algorithm) throws IOException {
byte[] version = new ASN1Integer(1L).getEncoded();
byte[] issuerName = issuer.getSubject().getEncoded();
byte[] thisUpdateDer = new Time(Date.from(thisUpdate)).getEncoded();
byte[] nextUpdateDer = new Time(Date.from(nextUpdate)).getEncoded();
byte[] extensions = crlExtensions(issuer);
long entriesLength = entries.length() == EMPTY_CONTENT_LENGTH
? EMPTY_CONTENT_LENGTH : StreamingDerWriter.encodedLength(entries.length());
long valueLength = checkedSum(version.length, algorithm.length, issuerName.length, thisUpdateDer.length,
nextUpdateDer.length, entriesLength, extensions.length);
try (ContentSink sink = signingBus.beginContent(Encoding.DER, DurableContentReference.Lifecycle.TEMPORARY);
OutputStream output = sink.outputStream()) {
StreamingDerWriter.writeTagAndLength(output, StreamingDerWriter.SEQUENCE_TAG, valueLength);
output.write(version);
output.write(algorithm);
output.write(issuerName);
output.write(thisUpdateDer);
output.write(nextUpdateDer);
if (entries.length() != EMPTY_CONTENT_LENGTH) {
StreamingDerWriter.writeTagAndLength(output, StreamingDerWriter.SEQUENCE_TAG, entries.length());
try (RepeatableContent content = signingBus.openContent(entries);
InputStream input = content.openStream()) {
requireLength(entries.length(),
StreamingDerWriter.copy(input, output, CancellationSignal.NONE));
}
}
output.write(extensions);
return sink.complete();
}
}
private static byte[] crlExtensions(X509CertificateHolder issuer) {
try {
AuthorityKeyIdentifier authorityKeyIdentifier = new JcaX509ExtensionUtils()
.createAuthorityKeyIdentifier(issuer.getSubjectPublicKeyInfo());
Extensions extensions = new Extensions(
Extension.create(Extension.authorityKeyIdentifier, false, authorityKeyIdentifier));
return new DERTaggedObject(true, 0, extensions).getEncoded();
} catch (Exception exception) {
throw new PkiException("Failed to build CRL extensions", exception);
}
}
private void copyToSigner(DurableContentReference tbs, PkiBusContentSigner signer) throws IOException {
try (RepeatableContent content = signingBus.openContent(tbs);
InputStream input = content.openStream();
OutputStream output = signer.getOutputStream()) {
requireLength(tbs.length(), StreamingDerWriter.copy(input, output, CancellationSignal.NONE));
}
}
private DurableContentReference encodeOuter(DurableContentReference tbs, byte[] algorithm, byte[] signature)
throws IOException {
long bitStringValueLength = Math.addExact(1L, signature.length);
long valueLength = checkedSum(tbs.length(), algorithm.length,
StreamingDerWriter.encodedLength(bitStringValueLength));
try (ContentSink sink = signingBus.beginContent(Encoding.DER, DurableContentReference.Lifecycle.PERSISTED);
OutputStream output = sink.outputStream()) {
StreamingDerWriter.writeTagAndLength(output, StreamingDerWriter.SEQUENCE_TAG, valueLength);
try (RepeatableContent content = signingBus.openContent(tbs);
InputStream input = content.openStream()) {
requireLength(tbs.length(), StreamingDerWriter.copy(input, output, CancellationSignal.NONE));
}
output.write(algorithm);
StreamingDerWriter.writeTagAndLength(output, StreamingDerWriter.BIT_STRING_TAG, bitStringValueLength);
output.write(0);
output.write(signature);
return sink.complete();
}
}
private static long checkedSum(long... values) {
long total = 0L;
for (long value : values) {
total = Math.addExact(total, value);
}
return total;
}
private static void requireLength(long expected, long actual) throws IOException {
if (expected != actual) {
throw new IOException("Staged content length changed");
}
}
private void releaseTemporary(DurableContentReference reference) {
if (reference != null) {
signingBus.releaseContent(reference);
}
}
/**
@@ -293,141 +538,6 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator
return new IssuerMaterial(issuerHolder, keyRef);
}
/**
* Creates a CRL builder initialized with issuer and update information.
*
* @param issuerHolder parsed issuer certificate holder; must not be
* {@code null}
* @param thisUpdate current update timestamp as {@link Date}; must not be
* {@code null}
* @param nextUpdate next update timestamp as {@link Instant}; must not be
* {@code null}
* @return initialized CRL builder
*/
private static X509v2CRLBuilder newCrlBuilder(X509CertificateHolder issuerHolder, Date thisUpdate,
Instant nextUpdate) {
X500Name issuerDn = issuerHolder.getSubject();
X509v2CRLBuilder builder = new X509v2CRLBuilder(issuerDn, thisUpdate);
builder.setNextUpdate(Date.from(nextUpdate));
return builder;
}
/**
* Adds validated structured revoked-certificate entries to the CRL builder.
*
* @param builder target CRL builder; must not be {@code null}
* @param entries validated structured entries
*/
private static void addRevokedEntries(X509v2CRLBuilder builder, List<CrlEntry> entries) {
for (CrlEntry entry : entries) {
Instant encodedTime = entry.transitionTime().truncatedTo(ChronoUnit.SECONDS);
builder.addCRLEntry(entry.serialNumber(), Date.from(encodedTime), reasonCode(entry.reason()));
}
}
private static int reasonCode(RevocationReason reason) {
return switch (reason) {
case UNSPECIFIED -> CRLReason.unspecified;
case KEY_COMPROMISE -> CRLReason.keyCompromise;
case CA_COMPROMISE -> CRLReason.cACompromise;
case AFFILIATION_CHANGED -> CRLReason.affiliationChanged;
case SUPERSEDED -> CRLReason.superseded;
case CESSATION_OF_OPERATION -> CRLReason.cessationOfOperation;
case CERTIFICATE_HOLD -> CRLReason.certificateHold;
case REMOVE_FROM_CRL -> throw new IllegalArgumentException("REMOVE_FROM_CRL is not an active CRL entry");
case PRIVILEGE_WITHDRAWN -> CRLReason.privilegeWithdrawn;
case AA_COMPROMISE -> CRLReason.aACompromise;
};
}
/**
* Adds the authority key identifier extension to the CRL builder.
*
* @param builder target CRL builder; must not be {@code null}
* @param issuerHolder parsed issuer certificate holder; must not be
* {@code null}
* @throws PkiException if the extension cannot be derived or added
*/
private static void addAuthorityKeyIdentifierOrThrow(X509v2CRLBuilder builder, X509CertificateHolder issuerHolder) {
try {
JcaX509ExtensionUtils extensionUtils = new JcaX509ExtensionUtils();
AuthorityKeyIdentifier authorityKeyIdentifier = extensionUtils
.createAuthorityKeyIdentifier(issuerHolder.getSubjectPublicKeyInfo());
builder.addExtension(Extension.authorityKeyIdentifier, false, authorityKeyIdentifier);
} catch (Exception ex) {
throw new PkiException("Failed to build CRL extensions", ex);
}
}
/**
* Builds and signs the CRL using the configured PKI signing boundary.
*
* @param builder prepared CRL builder; must not be {@code null}
* @param issuerKeyRef issuer signing key reference; must not be {@code null}
* @return signed CRL holder
* @throws PkiException if delegated signing fails
*/
private X509CRLHolder buildSignedCrlOrThrow(X509v2CRLBuilder builder, KeyRef issuerKeyRef) {
ContentSigner signer = new PkiBusContentSigner(signingBus, issuerKeyRef, signatureAlgorithmId, signingTtl);
try {
return builder.build(signer);
} catch (Exception ex) {
throw new PkiException("CRL signing failed", ex);
}
}
/**
* Encodes the signed CRL into DER form.
*
* @param crl signed CRL holder; must not be {@code null}
* @return DER-encoded CRL bytes
* @throws PkiException if CRL encoding fails
*/
private static byte[] encodeCrlOrThrow(X509CRLHolder crl) {
try {
return crl.getEncoded();
} catch (Exception ex) {
throw new PkiException("CRL encoding failed", ex);
}
}
/**
* Creates the PKI status object representation of the generated CRL.
*
* @param command original generation command; must not be {@code null}
* @param crlDer DER-encoded CRL bytes; must not be {@code null}
* @param thisUpdate generated {@code thisUpdate} timestamp; must not be
* {@code null}
* @param nextUpdate generated {@code nextUpdate} timestamp; must not be
* {@code null}
* @return resulting status object
*/
private static StatusObject toStatusObject(StatusObjectGenerateCommand command, byte[] crlDer, Instant thisUpdate,
Instant nextUpdate) {
PkiId id = new PkiId("crl:" + sha256Hex(crlDer));
EncodedObject encoded = new EncodedObject(Encoding.DER, crlDer);
return new StatusObject(id, command.formatId(), command.issuerCaId(), command.type(), thisUpdate,
Optional.of(nextUpdate), encoded, command.attributes());
}
/**
* Computes the SHA-256 digest of the supplied bytes and returns it as a
* lowercase hexadecimal string.
*
* @param in input bytes; must not be {@code null}
* @return hexadecimal SHA-256 digest
* @throws IllegalStateException if SHA-256 is unexpectedly unavailable in the
* runtime
*/
private static String sha256Hex(byte[] in) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(md.digest(in));
} catch (Exception ex) {
throw new IllegalStateException("SHA-256 not available", ex);
}
}
/**
* Immutable issuer-side material required for CRL generation.
*
@@ -436,4 +546,93 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator
*/
private record IssuerMaterial(X509CertificateHolder issuerHolder, KeyRef keyRef) {
}
/**
* Maps one bounded revocation-reason element to the standard CRL code.
*/
private static final class CrlReasonAdapter {
private static int code(RevocationReason reason) {
return switch (reason) {
case UNSPECIFIED -> CRLReason.unspecified;
case KEY_COMPROMISE -> CRLReason.keyCompromise;
case CA_COMPROMISE -> CRLReason.cACompromise;
case AFFILIATION_CHANGED -> CRLReason.affiliationChanged;
case SUPERSEDED -> CRLReason.superseded;
case CESSATION_OF_OPERATION -> CRLReason.cessationOfOperation;
case CERTIFICATE_HOLD -> CRLReason.certificateHold;
case REMOVE_FROM_CRL ->
throw new IllegalArgumentException("REMOVE_FROM_CRL is not an active CRL entry");
case PRIVILEGE_WITHDRAWN -> CRLReason.privilegeWithdrawn;
case AA_COMPROMISE -> CRLReason.aACompromise;
};
}
}
/**
* Incremental comparison of immutable signed-object slices.
*/
private static final class ContentComparison {
private static final int BUFFER_LENGTH = 8192;
private static void requireExactSlice(RepeatableContent content, long offset, long length, byte[] expected,
String message) throws IOException {
if (length != expected.length) {
throw new PkiException(message + ": code=STATUS_OBJECT_BINDING_MISMATCH");
}
try (InputStream input = new ContentSlice(content, offset, length).openStream()) {
byte[] buffer = new byte[BUFFER_LENGTH];
int expectedOffset = 0;
while (expectedOffset != expected.length) {
int count = input.read(buffer, 0, Math.min(buffer.length, expected.length - expectedOffset));
if (count < 0 || !Arrays.equals(buffer, 0, count, expected, expectedOffset,
expectedOffset + count)) {
throw new PkiException(message + ": code=STATUS_OBJECT_BINDING_MISMATCH");
}
expectedOffset += count;
}
if (input.read() >= 0) {
throw new PkiException(message + ": code=STATUS_OBJECT_BINDING_MISMATCH");
}
}
}
private static boolean contentEquals(RepeatableContent content, long offset, long length,
RepeatableContent expected) throws IOException {
if (expected.length().isEmpty() || length != expected.length().getAsLong()) {
return false;
}
try (InputStream left = new ContentSlice(content, offset, length).openStream();
InputStream right = expected.openStream()) {
byte[] leftBuffer = new byte[BUFFER_LENGTH];
byte[] rightBuffer = new byte[BUFFER_LENGTH];
while (true) {
int leftCount = readBlock(left, leftBuffer);
int rightCount = readBlock(right, rightBuffer);
if (leftCount != rightCount) {
return false;
}
if (leftCount < 0) {
return true;
}
if (!Arrays.equals(leftBuffer, 0, leftCount, rightBuffer, 0, rightCount)) {
return false;
}
}
}
}
private static int readBlock(InputStream input, byte[] buffer) throws IOException {
int total = 0;
while (total != buffer.length) {
int count = input.read(buffer, total, buffer.length - total);
if (count < 0) {
return total == 0 ? -1 : total;
}
if (count != 0) {
total += count;
}
}
return total;
}
}
}

View File

@@ -0,0 +1,151 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.impl.framework.x509.bc;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.security.GeneralSecurityException;
import java.util.List;
import java.util.Set;
import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.operator.ContentVerifier;
import org.bouncycastle.operator.OperatorCreationException;
import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder;
import zeroecho.core.alg.BootstrapAlgorithmIdentities;
import zeroecho.core.io.CancellationSignal;
import zeroecho.core.io.RepeatableContent;
import zeroecho.core.spec.AlgorithmIdentity;
import zeroecho.core.spec.AlgorithmSuite;
import zeroecho.core.spi.AlgorithmExecutionCapability;
import zeroecho.core.spi.AlgorithmExecutionCapabilityProvider;
import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot;
import zeroecho.pki.impl.framework.x509.X509ExecutionPlan;
import zeroecho.pki.impl.framework.x509.X509AlgorithmRole;
/**
* Stateless Bouncy Castle verification executor at the X.509 adapter edge.
*
* <p>
* This executor never selects identity, OID, parameters, policy, or provider
* precedence. It accepts only an execution plan minted by the same authority
* snapshot and performs verification after immediate provenance validation.
* </p>
*/
public final class BcX509VerificationExecutor implements AlgorithmExecutionCapabilityProvider {
/** Stable semantic implementation identifier. */
public static final String IMPLEMENTATION_ID = "bc.x509-verification";
private static final BouncyCastleProvider BC_PROVIDER = new BouncyCastleProvider();
private static final Set<AlgorithmIdentity> SIGNATURES = Set.of(
BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256, BootstrapAlgorithmIdentities.RSA_PKCS1_SHA384,
BootstrapAlgorithmIdentities.RSA_PKCS1_SHA512, BootstrapAlgorithmIdentities.RSA_PSS_SHA256,
BootstrapAlgorithmIdentities.ECDSA_SHA256, BootstrapAlgorithmIdentities.ECDSA_SHA384,
BootstrapAlgorithmIdentities.ECDSA_SHA512, BootstrapAlgorithmIdentities.ED25519_SIGNATURE,
BootstrapAlgorithmIdentities.ED448_SIGNATURE);
@Override
public List<AlgorithmExecutionCapability> capabilities() {
return List.of(new AlgorithmExecutionCapability() {
@Override
public String implementationId() {
return IMPLEMENTATION_ID;
}
@Override
public String domainFingerprint() {
return "bc-x509-verification-v1:bootstrap-classic";
}
@Override
public boolean supports(AlgorithmIdentity identity, AlgorithmSuite suite, Direction direction) {
return direction == Direction.VERIFY && SIGNATURES.contains(identity)
&& identity.equals(suite.signature());
}
});
}
/**
* Verifies one signature using an already-authorized exact plan.
*
* @param authority owning authority snapshot
* @param plan exact verification plan
* @param publicKeyInfo exact subject public key
* @param algorithmIdentifier exact signature AlgorithmIdentifier
* @param signedContent exact repeatable signed content
* @param signature signature bytes
* @return whether the signature is valid
* @throws IllegalArgumentException if the supplied signature or public-key
* algorithm does not exactly match the
* authorized plan
* @throws GeneralSecurityException if key or provider processing fails
* @throws IOException if the signed bytes cannot be supplied
* @throws OperatorCreationException if verifier construction fails
*/
public boolean verify(X509AuthoritySnapshot authority, X509ExecutionPlan<BcX509VerificationExecutor> plan,
SubjectPublicKeyInfo publicKeyInfo, AlgorithmIdentifier algorithmIdentifier, RepeatableContent signedContent,
byte[] signature) throws GeneralSecurityException, IOException, OperatorCreationException {
authority.authorize(plan, this, AlgorithmExecutionCapability.Direction.VERIFY);
BcX509AlgorithmAdapter adapter = new BcX509AlgorithmAdapter(authority.bindings());
AlgorithmIdentity signatureIdentity = adapter.decode(algorithmIdentifier,
X509AlgorithmRole.SIGNATURE_ALGORITHM);
if (!plan.selection().requested().equals(signatureIdentity)
|| !plan.selection().binding().equals(BcX509AlgorithmAdapter.fromBc(algorithmIdentifier))) {
throw new IllegalArgumentException("Signature AlgorithmIdentifier does not match execution plan");
}
AlgorithmIdentity publicKeyIdentity = adapter.decode(publicKeyInfo.getAlgorithm(),
X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM);
if (!plan.selection().suite().publicKey().equals(publicKeyIdentity)) {
throw new IllegalArgumentException("SubjectPublicKeyInfo does not match execution plan");
}
ContentVerifier verifier = new JcaContentVerifierProviderBuilder().setProvider(BC_PROVIDER)
.build(publicKeyInfo).get(algorithmIdentifier);
try (InputStream input = signedContent.openStream(); OutputStream output = verifier.getOutputStream()) {
byte[] buffer = new byte[16 * 1024];
int read;
while ((read = input.read(buffer)) >= 0) {
CancellationSignal.NONE.throwIfCancelled();
if (read != 0) {
output.write(buffer, 0, read);
}
}
}
return verifier.verify(signature);
}
}

View File

@@ -33,12 +33,16 @@
******************************************************************************/
package zeroecho.pki.impl.framework.x509.bc;
import java.util.Map;
import java.util.Objects;
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
import zeroecho.core.spec.AlgorithmIdentity;
import zeroecho.pki.impl.framework.x509.StandardX509Bindings;
import zeroecho.pki.impl.framework.x509.X509AlgorithmIdentifier;
import zeroecho.pki.impl.framework.x509.X509AlgorithmRole;
import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot;
/**
* Internal mapper from X.509 and PKCS#10 signature algorithm identifiers to
* ZeroEcho canonical signature algorithm ids.
@@ -67,11 +71,13 @@ import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
*
* <h2>Null and unsupported handling</h2>
* <ul>
* <li>{@link #toZeroEchoAlgorithmId(AlgorithmIdentifier)} returns {@code null}
* <li>{@link #toZeroEchoAlgorithmId(AlgorithmIdentifier, X509AuthoritySnapshot)}
* returns {@code null}
* when the supplied identifier is {@code null}, when its embedded
* {@link ASN1ObjectIdentifier} is {@code null}, when the OID string is blank,
* object identifier is {@code null}, when the OID string is blank,
* or when the OID is not present in the mapping table.</li>
* <li>{@link #toZeroEchoAlgorithmId(String)} requires a non-{@code null} OID
* <li>{@link #toZeroEchoAlgorithmId(String, X509AuthoritySnapshot)} requires a
* non-{@code null} OID
* string but still returns {@code null} for blank or unsupported values.</li>
* </ul>
*
@@ -105,27 +111,6 @@ public final class OidAlgorithmMapper {
* reviewable, and easy to maintain.
* </p>
*/
@SuppressWarnings("PMD.AvoidUsingHardCodedIP")
private static final Map<String, String> OID_TO_ZEROECHO_ALG = Map.ofEntries(
// RSA PKCS#1 v1.5 digests
Map.entry("1.2.840.113549.1.1.11", "SHA256withRSA"), // sha256WithRSAEncryption
Map.entry("1.2.840.113549.1.1.12", "SHA384withRSA"), // sha384WithRSAEncryption
Map.entry("1.2.840.113549.1.1.13", "SHA512withRSA"), // sha512WithRSAEncryption
Map.entry("1.2.840.113549.1.1.5", "SHA1withRSA"), // sha1WithRSAEncryption
// ECDSA digests
Map.entry("1.2.840.10045.4.3.2", "SHA256withECDSA"), // ecdsa-with-SHA256
Map.entry("1.2.840.10045.4.3.3", "SHA384withECDSA"), // ecdsa-with-SHA384
Map.entry("1.2.840.10045.4.3.4", "SHA512withECDSA"), // ecdsa-with-SHA512
Map.entry("1.2.840.10045.4.1", "SHA1withECDSA"), // ecdsa-with-SHA1
// EdDSA
Map.entry("1.3.101.112", "Ed25519"), // Ed25519
Map.entry("1.3.101.113", "Ed448") // Ed448
// PQC OIDs vary by provider/standard; extend as needed.
);
/**
* Creates no instances.
*/
@@ -137,29 +122,28 @@ public final class OidAlgorithmMapper {
* signature algorithm identifier.
*
* <p>
* This method extracts the underlying {@link ASN1ObjectIdentifier}, converts it
* to its dotted-decimal string form, and performs a lookup in the immutable
* mapping table.
* This method extracts the underlying object identifier, converts it to its
* dotted-decimal string form, and resolves it through the injected authority.
* </p>
*
* @param sigAlg ASN.1 signature algorithm identifier, possibly {@code null}
* @param authority immutable algorithm authority
* @return ZeroEcho canonical signature algorithm identifier, or {@code null}
* when the input is {@code null}, structurally incomplete, blank, or
* unsupported
*/
public static String toZeroEchoAlgorithmId(AlgorithmIdentifier sigAlg) {
public static String toZeroEchoAlgorithmId(AlgorithmIdentifier sigAlg, X509AuthoritySnapshot authority) {
if (sigAlg == null) {
return null;
}
ASN1ObjectIdentifier oid = sigAlg.getAlgorithm();
if (oid == null) {
try {
AlgorithmIdentity identity = new BcX509AlgorithmAdapter(
Objects.requireNonNull(authority, "authority").bindings())
.decode(sigAlg, X509AlgorithmRole.SIGNATURE_ALGORITHM);
return identity.canonicalForm();
} catch (IllegalArgumentException exception) {
return null;
}
String id = oid.getId();
if (id == null || id.isBlank()) {
return null;
}
return OID_TO_ZEROECHO_ALG.get(id);
}
/**
@@ -174,15 +158,34 @@ public final class OidAlgorithmMapper {
*
* @param oid dotted-decimal signature algorithm OID string; must not be
* {@code null}
* @param authority immutable algorithm authority
* @return ZeroEcho canonical signature algorithm identifier, or {@code null}
* when the supplied OID string is blank or unsupported
* @throws NullPointerException if {@code oid} is {@code null}
*/
public static String toZeroEchoAlgorithmId(String oid) {
public static String toZeroEchoAlgorithmId(String oid, X509AuthoritySnapshot authority) {
Objects.requireNonNull(oid, "oid");
if (oid.isBlank()) {
X509AlgorithmIdentifier identifier = null;
if (StandardX509Bindings.OID_RSA_SHA256.equals(oid)
|| StandardX509Bindings.OID_RSA_SHA384.equals(oid)
|| StandardX509Bindings.OID_RSA_SHA512.equals(oid)) {
identifier = X509AlgorithmIdentifier.derNull(oid);
} else if (StandardX509Bindings.OID_ECDSA_SHA256.equals(oid)
|| StandardX509Bindings.OID_ECDSA_SHA384.equals(oid)
|| StandardX509Bindings.OID_ECDSA_SHA512.equals(oid)
|| StandardX509Bindings.OID_ED25519.equals(oid)
|| StandardX509Bindings.OID_ED448.equals(oid)) {
identifier = X509AlgorithmIdentifier.absent(oid);
}
if (identifier == null) {
return null;
}
try {
return Objects.requireNonNull(authority, "authority").bindings()
.reverse(identifier, X509AlgorithmRole.SIGNATURE_ALGORITHM)
.canonicalForm();
} catch (IllegalArgumentException exception) {
return null;
}
return OID_TO_ZEROECHO_ALG.get(oid);
}
}

View File

@@ -33,27 +33,31 @@
******************************************************************************/
package zeroecho.pki.impl.framework.x509.bc;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import java.util.Optional;
import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.DefaultSignatureAlgorithmIdentifierFinder;
import zeroecho.core.spec.AlgorithmIdentity;
import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.KeyRef;
import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.content.DurableContentReference;
import zeroecho.pki.api.audit.AccessContext;
import zeroecho.pki.api.audit.Principal;
import zeroecho.pki.api.audit.Purpose;
import zeroecho.pki.impl.core.async.PkiSigningBus;
import zeroecho.pki.impl.core.async.PkiSigningBus.SignContinuation;
import zeroecho.pki.impl.framework.x509.X509AlgorithmRole;
import zeroecho.pki.impl.framework.x509.X509ExecutionPlan;
import zeroecho.pki.spi.crypto.SignatureWorkflow;
import zeroecho.pki.spi.store.ContentSink;
import zeroecho.pki.util.async.AsyncState;
/**
@@ -65,8 +69,8 @@ import zeroecho.pki.util.async.AsyncState;
* contract to the PKI runtime signing boundary represented by
* {@link PkiSigningBus}. Bouncy Castle writes the to-be-signed bytes into the
* output stream returned by {@link #getOutputStream()}, and when
* {@link #getSignature()} is invoked this signer submits the collected bytes to
* the PKI signing bus as a delegated sign workflow.
* {@link #getSignature()} is invoked this signer atomically completes the
* file-backed content and submits its durable reference to the PKI signing bus.
* </p>
*
* <p>
@@ -79,7 +83,8 @@ import zeroecho.pki.util.async.AsyncState;
*
* <h2>Execution model</h2>
* <ul>
* <li>The to-be-signed payload is accumulated fully in memory.</li>
* <li>The to-be-signed payload is streamed to the runtime-owned staged-content
* store with {@code long} accounting.</li>
* <li>A synthetic system owner and purpose are used to submit the signing
* operation.</li>
* <li>A fresh client operation identifier is generated for each signature
@@ -95,7 +100,7 @@ import zeroecho.pki.util.async.AsyncState;
* <h2>Security considerations</h2>
* <ul>
* <li>This class never accesses private key material directly.</li>
* <li>The buffered to-be-signed payload and the returned signature bytes are
* <li>The staged to-be-signed content and returned signature bytes are
* operationally sensitive and must not be logged.</li>
* <li>The generated operation identifier is an internal workflow handle and
* must not be treated as a durable business identifier outside the signing
@@ -104,9 +109,8 @@ import zeroecho.pki.util.async.AsyncState;
*
* <h2>Thread-safety</h2>
* <p>
* Instances of this class are not thread-safe. Each instance maintains mutable
* in-memory state through its internal {@link ByteArrayOutputStream} and is
* intended for one certificate or CRL signing flow.
* Instances of this class are not thread-safe. Each instance owns one sequential
* staged-content sink and is intended for one certificate or CRL signing flow.
* </p>
*/
// PMD cannot infer that retaining provider causes would violate the redaction contract.
@@ -115,10 +119,12 @@ public final class PkiBusContentSigner implements ContentSigner {
private final PkiSigningBus bus;
private final KeyRef keyRef;
private final String algorithmId;
private final AlgorithmIdentity algorithmIdentity;
private final X509ExecutionPlan<SignatureWorkflow> executionPlan;
private final Duration ttl;
private final WipeableByteArrayOutputStream baos;
private final ContentSink contentSink;
private final OutputStream contentOutput;
/**
* Creates a signer that routes signature generation through the PKI signing
@@ -136,23 +142,52 @@ public final class PkiBusContentSigner implements ContentSigner {
* contract
*/
public PkiBusContentSigner(PkiSigningBus bus, KeyRef keyRef, String algorithmId, Duration ttl) {
this(bus, keyRef, bus.authority().resolveIdentity(algorithmId), ttl);
}
/**
* Creates a signer from an exact provider-independent signature identity.
*
* @param bus signing bus
* @param keyRef signing key reference
* @param algorithmIdentity exact signature identity
* @param ttl positive workflow time-to-live
* @throws IllegalArgumentException if an argument violates the contract or no
* authoritative signature binding exists
*/
public PkiBusContentSigner(PkiSigningBus bus, KeyRef keyRef, AlgorithmIdentity algorithmIdentity, Duration ttl) {
if (bus == null) {
throw new IllegalArgumentException("bus must not be null");
}
if (keyRef == null) {
throw new IllegalArgumentException("keyRef must not be null");
}
if (algorithmId == null || algorithmId.isBlank()) {
throw new IllegalArgumentException("algorithmId must not be null/blank");
if (algorithmIdentity == null || algorithmIdentity.kind() != AlgorithmIdentity.Kind.SIGNATURE) {
throw new IllegalArgumentException("algorithmIdentity must be a signature identity");
}
if (ttl == null || ttl.isZero() || ttl.isNegative()) {
throw new IllegalArgumentException("ttl must be positive");
}
this.bus = bus;
this.keyRef = keyRef;
this.algorithmId = algorithmId;
X509ExecutionPlan<SignatureWorkflow> plan = bus.authority()
.planSigning(algorithmIdentity.canonicalForm(), SignatureWorkflow.class);
bus.authority().authorize(plan, plan.executor(),
zeroecho.core.spi.AlgorithmExecutionCapability.Direction.SIGN);
this.executionPlan = plan;
this.algorithmIdentity = plan.selection().requested();
this.ttl = ttl;
this.baos = new WipeableByteArrayOutputStream();
this.contentSink = bus.beginSigningContent(Encoding.BINARY);
try {
this.contentOutput = contentSink.outputStream();
} catch (IOException ex) {
try {
contentSink.close();
} catch (IOException cleanupFailure) {
ex.addSuppressed(cleanupFailure);
}
throw new PkiException("Signing content staging failed: code=SPOOL_STORAGE_FAILED");
}
}
/**
@@ -170,22 +205,31 @@ public final class PkiBusContentSigner implements ContentSigner {
*/
@Override
public AlgorithmIdentifier getAlgorithmIdentifier() {
return new DefaultSignatureAlgorithmIdentifierFinder().find(algorithmId);
return new BcX509AlgorithmAdapter(bus.authority().bindings()).encode(algorithmIdentity,
X509AlgorithmRole.SIGNATURE_ALGORITHM);
}
/**
* Returns the output stream used to collect the to-be-signed bytes.
*
* <p>
* Data written to this stream is buffered in memory until
* Data written to this stream is staged without aggregate heap buffering until
* {@link #getSignature()} is called.
* </p>
*
* @return mutable in-memory output stream receiving the to-be-signed payload
* @return sequential staged-content output stream
*/
@Override
public OutputStream getOutputStream() {
return baos;
return contentOutput;
}
/*
* Package-local postconditions receive the exact live plan minted for this
* signer. A public semantic fingerprint is intentionally insufficient.
*/
/* default */ X509ExecutionPlan<SignatureWorkflow> executionPlan() {
return executionPlan;
}
/**
@@ -226,22 +270,22 @@ public final class PkiBusContentSigner implements ContentSigner {
@Override
@SuppressWarnings("PMD.AvoidCatchingGenericException")
public byte[] getSignature() {
byte[] tbs = baos.toByteArray();
byte[] consumedResult = null;
PkiId opId = null;
boolean retirementRequired = false;
Throwable primaryFailure = null;
try {
EncodedObject payload = new EncodedObject(Encoding.BINARY, tbs);
DurableContentReference content = completeContent();
Principal owner = new Principal("SYSTEM", "pki");
opId = bus.newSubmissionId();
AccessContext ac = new AccessContext(owner, new Purpose("X509_SIGN"), Optional.empty(), Optional.empty());
SignContinuation cont = new SignContinuation(ac, algorithmId, payload, keyRef, Encoding.BINARY,
String canonicalIdentity = algorithmIdentity.canonicalForm();
SignContinuation cont = new SignContinuation(ac, canonicalIdentity, content, keyRef, Encoding.BINARY,
Optional.empty());
retirementRequired = true;
bus.submitSign(opId, owner, keyRef, algorithmId, payload, ttl, Optional.of(cont.encode()));
bus.submitSign(opId, owner, keyRef, canonicalIdentity, content, ttl, Optional.of(cont.encode()));
consumedResult = awaitSignature(opId);
return consumedResult.clone();
} catch (RuntimeException failure) {
@@ -257,15 +301,32 @@ public final class PkiBusContentSigner implements ContentSigner {
retirePreservingFailure(opId, primaryFailure);
}
} finally {
Arrays.fill(tbs, (byte) 0);
baos.wipe();
closeSink(primaryFailure);
if (consumedResult != null) {
Arrays.fill(consumedResult, (byte) 0);
java.util.Arrays.fill(consumedResult, (byte) 0);
}
}
}
}
private DurableContentReference completeContent() {
try {
return contentSink.complete();
} catch (IOException ex) {
throw new PkiException("Signing content staging failed: code=SPOOL_STORAGE_FAILED");
}
}
private void closeSink(Throwable primaryFailure) {
try {
contentSink.close();
} catch (IOException cleanupFailure) {
if (primaryFailure == null) {
throw new PkiException("Signing cleanup failed: code=SIGNING_CLEANUP_FAILED");
}
}
}
private byte[] awaitSignature(PkiId opId) {
Instant deadline = Instant.now().plus(ttl);
while (Instant.now().isBefore(deadline)) {
@@ -313,15 +374,4 @@ public final class PkiBusContentSigner implements ContentSigner {
}
}
/**
* Byte-array output stream whose retained backing storage can be overwritten
* after one signing attempt.
*/
private static final class WipeableByteArrayOutputStream extends ByteArrayOutputStream {
private void wipe() {
Arrays.fill(buf, (byte) 0);
reset();
}
}
}

View File

@@ -54,7 +54,12 @@ import zeroecho.pki.api.issuance.VerificationPolicy;
import zeroecho.pki.api.request.ParsedCertificationRequest;
import zeroecho.pki.api.request.ProofOfPossessionResult;
import zeroecho.pki.api.request.ProofOfPossessionStatus;
import zeroecho.pki.impl.framework.x509.X509AlgorithmRole;
import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot;
import zeroecho.pki.impl.framework.x509.X509ExecutionPlan;
import zeroecho.pki.spi.crypto.SignatureWorkflow;
import zeroecho.core.io.CancellationSignal;
import zeroecho.core.io.ImmutableByteContent;
import zeroecho.pki.spi.framework.ProofOfPossessionVerifier;
/**
@@ -133,16 +138,17 @@ public final class WorkflowProofOfPossessionVerifier implements ProofOfPossessio
private static final Principal SYSTEM = new Principal("SYSTEM", "pki");
private final SignatureWorkflow workflow;
private final X509AuthoritySnapshot authority;
/**
* Creates the workflow-backed proof-of-possession verifier.
* Creates a verifier bound to the runtime's immutable authority snapshot.
*
* @param workflow signature workflow used to perform cryptographic signature
* verification; must not be {@code null}
* @throws NullPointerException if {@code workflow} is {@code null}
* @param workflow selected verification workflow
* @param authority matching authority snapshot
*/
public WorkflowProofOfPossessionVerifier(SignatureWorkflow workflow) {
public WorkflowProofOfPossessionVerifier(SignatureWorkflow workflow, X509AuthoritySnapshot authority) {
this.workflow = Objects.requireNonNull(workflow, "workflow");
this.authority = Objects.requireNonNull(authority, "authority");
}
/**
@@ -207,22 +213,34 @@ public final class WorkflowProofOfPossessionVerifier implements ProofOfPossessio
}
CertificationRequest csrAsn1 = csr.get().toASN1Structure();
BcX509AlgorithmAdapter algorithmAdapter = new BcX509AlgorithmAdapter(authority.bindings());
zeroecho.core.spec.AlgorithmIdentity signatureIdentity;
X509ExecutionPlan<SignatureWorkflow> executionPlan;
try {
signatureIdentity = algorithmAdapter.decode(
csrAsn1.getSignatureAlgorithm(), X509AlgorithmRole.SIGNATURE_ALGORITHM);
zeroecho.core.spec.AlgorithmIdentity keyIdentity = algorithmAdapter.decode(
csr.get().getSubjectPublicKeyInfo().getAlgorithm(),
X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM);
executionPlan = authority.plan(signatureIdentity, keyIdentity,
zeroecho.core.spi.AlgorithmExecutionCapability.Direction.VERIFY,
Optional.empty(), "csr-proof", SignatureWorkflow.class);
} catch (IllegalArgumentException unsupported) {
return new ProofOfPossessionResult(ProofOfPossessionStatus.NOT_SUPPORTED,
Optional.of("Unsupported CSR algorithm"));
}
Optional<byte[]> tbs = encodeCertificationRequestInfo(csrAsn1.getCertificationRequestInfo());
if (tbs.isEmpty()) {
return failed("CSR TBS encoding failed");
}
String algorithmId = OidAlgorithmMapper.toZeroEchoAlgorithmId(csrAsn1.getSignatureAlgorithm());
if (algorithmId == null) {
return new ProofOfPossessionResult(ProofOfPossessionStatus.NOT_SUPPORTED,
Optional.of("Unsupported CSR algorithm"));
}
String algorithmId = signatureIdentity.canonicalForm();
byte[] signature = extractSignatureBytes(csrAsn1.getSignature());
SignatureWorkflow.VerifyRequest verifyRequest = toVerifyRequest(request, algorithmId, tbs.get(), signature,
spkiDer.get());
return verifyWithWorkflow(verifyRequest);
return verifyWithWorkflow(verifyRequest, executionPlan);
}
/**
@@ -249,6 +267,7 @@ public final class WorkflowProofOfPossessionVerifier implements ProofOfPossessio
*/
private static Optional<PKCS10CertificationRequest> parseCsr(byte[] csrDer) {
try {
BcX509AlgorithmAdapter.requireCanonicalDer(csrDer);
return Optional.of(new PKCS10CertificationRequest(csrDer));
} catch (IOException | IllegalArgumentException ex) {
return Optional.empty();
@@ -315,9 +334,10 @@ public final class WorkflowProofOfPossessionVerifier implements ProofOfPossessio
AccessContext accessContext = new AccessContext(SYSTEM, PURPOSE, Optional.of(request.requestId()),
Optional.of(request.formatId()));
return new SignatureWorkflow.VerifyRequest(accessContext, algorithmId, new EncodedObject(Encoding.DER, tbsDer),
return new SignatureWorkflow.VerifyRequest(accessContext, algorithmId, new ImmutableByteContent(tbsDer),
new EncodedObject(Encoding.BINARY, signature), Optional.empty(),
Optional.of(new EncodedObject(Encoding.DER, spkiDer)), Optional.of(Instant.now().plusSeconds(30)));
Optional.of(new EncodedObject(Encoding.DER, spkiDer)), Optional.of(Instant.now().plusSeconds(30)),
CancellationSignal.NONE);
}
/**
@@ -327,9 +347,12 @@ public final class WorkflowProofOfPossessionVerifier implements ProofOfPossessio
* @param verifyRequest workflow verification request; must not be {@code null}
* @return mapped proof-of-possession result
*/
private ProofOfPossessionResult verifyWithWorkflow(SignatureWorkflow.VerifyRequest verifyRequest) {
PkiId verifyOperationId = workflow.submitVerify(verifyRequest);
SignatureWorkflow.OperationStatus status = workflow.status(verifyOperationId);
private ProofOfPossessionResult verifyWithWorkflow(SignatureWorkflow.VerifyRequest verifyRequest,
X509ExecutionPlan<SignatureWorkflow> executionPlan) {
authority.authorize(executionPlan, workflow,
zeroecho.core.spi.AlgorithmExecutionCapability.Direction.VERIFY);
PkiId verifyOperationId = executionPlan.executor().submitVerify(verifyRequest);
SignatureWorkflow.OperationStatus status = executionPlan.executor().status(verifyOperationId);
if (status == null) {
return failed("Verifier returned no status");
}

View File

@@ -0,0 +1,563 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.impl.fs;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Stream;
import zeroecho.core.io.RepeatableContent;
import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.content.DurableContentOwner;
import zeroecho.pki.api.content.DurableContentReference;
import zeroecho.pki.api.credential.Credential;
/** Coordinates the credential-record/content-owner write-once handoff. */
@SuppressWarnings("PMD.CyclomaticComplexity")
final class CredentialContentTransaction {
private static final int MAGIC = 0x5A454348;
private static final int VERSION = 1;
private static final int MAX_TEXT_BYTES = 4 * 1024;
private static final int BUFFER_BYTES = 16 * 1024;
private static final int MAX_INTENT_BYTES = 64 * 1024;
private static final int LOCK_COUNT = 64;
private static final String INTENT_SUFFIX = ".intent";
private static final String PRIVATE_SUFFIX = ".credential.pending";
private static final byte[] INTENT_KEY_DOMAIN =
"zeroecho:pki:credential-handoff-intent:v1".getBytes(StandardCharsets.US_ASCII);
private final FsPaths paths;
private final FilesystemStagedContentStore stagedContent;
private final Path intentRoot;
private final ReentrantLock[] locks;
/* default */ CredentialContentTransaction(FsPaths paths, FilesystemStagedContentStore stagedContent) {
this.paths = Objects.requireNonNull(paths, "paths");
this.stagedContent = Objects.requireNonNull(stagedContent, "stagedContent");
this.intentRoot = paths.root().resolve("credential-handoffs");
this.locks = new ReentrantLock[LOCK_COUNT];
for (int index = 0; index < locks.length; index++) {
locks[index] = new ReentrantLock();
}
}
/* default */ void put(Credential credential) {
Objects.requireNonNull(credential, "credential");
PkiId credentialId = credential.credentialId();
ReentrantLock lock = lock(credentialId);
lock.lock();
try {
putLocked(credential);
} finally {
lock.unlock();
}
}
@SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.ExceptionAsFlowControl" })
private void putLocked(Credential credential) {
PkiId credentialId = credential.credentialId();
Path target = paths.credentialPath(credentialId);
Path privateRecord = privateRecordPath(credentialId);
Path intentPath = intentPath(credentialId);
DurableContentOwner owner = DurableContentOwner.credentialRecord(credentialId);
boolean intentWritten = false;
boolean ownerAdded = false;
boolean published = false;
try {
requirePersistedReference(credential.content());
validateIntegrity(credential.content());
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
throw duplicate(credentialId, null);
}
if (Files.exists(intentPath, LinkOption.NOFOLLOW_LINKS)
|| Files.exists(privateRecord, LinkOption.NOFOLLOW_LINKS)) {
throw new IllegalStateException("Credential content handoff is already pending");
}
Set<DurableContentOwner> owners = stagedContent.contentOwners(credential.content());
if (owners.contains(owner)) {
throw new IllegalStateException("Credential content owner exists without a credential record");
}
Intent prepared = Intent.from(credential, State.PREPARED);
intentWritten = true;
DurableMetadataFiles.create(intentPath, MAX_INTENT_BYTES, output -> writeIntent(output, prepared));
ownerAdded = stagedContent.retainContent(credential.content(), owner);
if (!ownerAdded) {
throw new IllegalStateException("Credential content owner was not newly retained");
}
FsOperations.writeNewAtomicStrict(privateRecord, FsCodec.encode(FsCodec.CREDENTIAL, credential));
publish(privateRecord, target);
published = true;
DurableMetadataFiles.replace(intentPath, MAX_INTENT_BYTES,
output -> writeIntent(output, prepared.withState(State.COMMITTED)));
cleanup(privateRecord, intentPath);
} catch (FileAlreadyExistsException failure) {
rollbackBeforePublication(credential.content(), owner, privateRecord, intentPath, intentWritten,
ownerAdded, failure);
throw duplicate(credentialId, failure);
} catch (PublishedException failure) {
throw new IllegalStateException("Credential publication durability is unconfirmed", failure);
} catch (IOException failure) {
if (!published) {
rollbackBeforePublication(credential.content(), owner, privateRecord, intentPath, intentWritten,
ownerAdded, failure);
}
throw new IllegalStateException("Credential content handoff failed", failure);
} catch (RuntimeException failure) {
if (!published) {
rollbackBeforePublication(credential.content(), owner, privateRecord, intentPath, intentWritten,
ownerAdded, failure);
}
throw failure;
}
}
/* default */ Credential validateLoaded(PkiId expectedId, Credential credential) throws IOException {
Objects.requireNonNull(expectedId, "expectedId");
Objects.requireNonNull(credential, "credential");
if (!expectedId.equals(credential.credentialId())) {
throw new IOException("Credential record identifier mismatch");
}
requirePersistedReference(credential.content());
DurableContentOwner owner = DurableContentOwner.credentialRecord(expectedId);
if (!stagedContent.contentOwners(credential.content()).contains(owner)) {
throw new IOException("Credential content owner is missing");
}
validateIntegrity(credential.content());
return credential;
}
/* default */ void recover() throws IOException {
recoverIntents();
cleanupOrphanedPrivateRecords();
validatePublishedRecords();
}
private void recoverIntents() throws IOException {
if (!Files.isDirectory(intentRoot, LinkOption.NOFOLLOW_LINKS)) {
return;
}
List<Path> intents = DurableMetadataFiles.list(intentRoot, INTENT_SUFFIX);
for (Path intentPath : intents) {
recoverIntent(intentPath);
}
}
private void recoverIntent(Path intentPath) throws IOException {
if (!isRegularFile(intentPath)) {
throw new IOException("Credential handoff intent is not a regular file");
}
Intent intent = DurableMetadataFiles.read(intentPath, MAX_INTENT_BYTES,
CredentialContentTransaction::readIntent);
if (!intentPath(intent.credentialId()).equals(intentPath)) {
throw new IOException("Credential handoff intent filename mismatch");
}
Path target = paths.credentialPath(intent.credentialId());
Path privateRecord = privateRecordPath(intent.credentialId());
if (isRegularFile(target)) {
Credential credential = FsCodec.decode(FsCodec.CREDENTIAL, FsOperations.readAll(target), stagedContent);
requireIntentMatch(intent, credential);
validateLoaded(intent.credentialId(), credential);
cleanup(privateRecord, intentPath);
return;
}
releasePreparedOwner(intent);
cleanup(privateRecord, intentPath);
}
private void releasePreparedOwner(Intent intent) throws IOException {
Path metadata = paths.stagedContentRoot().resolve(intent.contentId() + ".meta");
if (!isRegularFile(metadata)) {
return;
}
DurableContentReference reference = intent.restore(stagedContent);
DurableContentOwner owner = intent.owner();
if (stagedContent.contentOwners(reference).contains(owner)) {
stagedContent.releaseContent(reference, owner);
}
}
private void cleanupOrphanedPrivateRecords() throws IOException {
Path root = paths.root().resolve("credentials").resolve("by-id");
if (!Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) {
return;
}
try (Stream<Path> stream = Files.list(root)) {
java.util.Iterator<Path> iterator = stream
.filter(path -> path.getFileName().toString().endsWith(PRIVATE_SUFFIX)).iterator();
while (iterator.hasNext()) {
Files.deleteIfExists(iterator.next());
}
}
}
private void validatePublishedRecords() throws IOException {
Path root = paths.root().resolve("credentials").resolve("by-id");
if (!Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS)) {
return;
}
List<Path> records;
try (Stream<Path> stream = Files.list(root)) {
records = stream.filter(path -> path.getFileName().toString().endsWith(".bin"))
.sorted(Comparator.comparing(path -> path.getFileName().toString())).toList();
}
for (Path record : records) {
if (!isRegularFile(record)) {
throw new IOException("Credential record is not a regular file");
}
Credential credential = FsCodec.decode(FsCodec.CREDENTIAL, FsOperations.readAll(record), stagedContent);
if (!paths.credentialPath(credential.credentialId()).equals(record)) {
throw new IOException("Credential record filename mismatch");
}
validateLoaded(credential.credentialId(), credential);
}
}
private void validateIntegrity(DurableContentReference reference) throws IOException {
try (RepeatableContent content = stagedContent.openContent(reference);
InputStream input = content.openStream()) {
byte[] buffer = new byte[BUFFER_BYTES];
try {
long readLength = 0L;
int read;
while ((read = input.read(buffer)) >= 0) {
readLength = Math.addExact(readLength, read);
}
if (readLength != reference.length()) {
throw new IOException("Credential content length changed while reading");
}
} finally {
Arrays.fill(buffer, (byte) 0);
}
}
}
private void requirePersistedReference(DurableContentReference reference) {
Objects.requireNonNull(reference, "credential.content");
if (!stagedContent.contentStoreId().equals(reference.storeId())) {
throw new IllegalArgumentException("Credential content belongs to another store");
}
if (reference.lifecycle() != DurableContentReference.Lifecycle.PERSISTED) {
throw new IllegalArgumentException("Credential content must use the PERSISTED lifecycle");
}
}
private static void requireIntentMatch(Intent intent, Credential credential) throws IOException {
if (!intent.credentialId().equals(credential.credentialId())
|| !intent.matches(credential.content())
|| !intent.owner().equals(DurableContentOwner.credentialRecord(credential.credentialId()))) {
throw new IOException("Credential handoff intent does not match the published record");
}
}
private void rollbackBeforePublication(DurableContentReference reference, DurableContentOwner owner,
Path privateRecord, Path intentPath, boolean intentWritten, boolean ownerAdded, Throwable failure) {
try {
if (intentWritten) {
Files.deleteIfExists(privateRecord);
}
if (ownerAdded) {
stagedContent.releaseContent(reference, owner);
}
if (intentWritten) {
DurableMetadataFiles.delete(intentPath);
}
} catch (IOException rollbackFailure) {
failure.addSuppressed(rollbackFailure);
}
}
private static void cleanup(Path privateRecord, Path intentPath) throws IOException {
Files.deleteIfExists(privateRecord);
DurableMetadataFiles.delete(intentPath);
}
private Path intentPath(PkiId credentialId) {
return intentRoot.resolve(intentFileName(credentialId));
}
/* default */ static String intentFileName(PkiId credentialId) {
Objects.requireNonNull(credentialId, "credentialId");
MessageDigest digest = sha256();
byte[] identifier = credentialId.value().getBytes(StandardCharsets.UTF_8);
updateLength(digest, INTENT_KEY_DOMAIN.length);
digest.update(INTENT_KEY_DOMAIN);
updateLength(digest, identifier.length);
digest.update(identifier);
return HexFormat.of().formatHex(digest.digest()) + INTENT_SUFFIX;
}
private Path privateRecordPath(PkiId credentialId) {
return paths.credentialPath(credentialId).resolveSibling(
paths.credentialPath(credentialId).getFileName().toString() + PRIVATE_SUFFIX);
}
private ReentrantLock lock(PkiId credentialId) {
return locks[Math.floorMod(credentialId.hashCode(), locks.length)];
}
// The directory-force cause is replaced by a path-free publication-state marker.
@SuppressWarnings("PMD.PreserveStackTrace")
private static void publish(Path source, Path target) throws IOException {
boolean moved = false;
try {
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE);
moved = true;
try (FileChannel directory = FileChannel.open(target.getParent(), StandardOpenOption.READ)) {
directory.force(true);
} catch (IOException failure) {
throw new PublishedException();
}
} catch (AtomicMoveNotSupportedException failure) {
throw new IOException("Atomic credential publication is unavailable", failure);
} finally {
if (!moved) {
Files.deleteIfExists(source);
}
}
}
private static IllegalStateException duplicate(PkiId credentialId, Throwable cause) {
String message = "CREDENTIAL is write-once; already exists: " + FsUtil.safeId(credentialId);
return cause == null ? new IllegalStateException(message) : new IllegalStateException(message, cause);
}
private static boolean isRegularFile(Path path) {
return Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(path);
}
/* default */ static byte[] encode(Intent intent) throws IOException {
Objects.requireNonNull(intent, "intent");
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (DataOutputStream output = new DataOutputStream(bytes)) {
writeIntent(output, intent);
}
return bytes.toByteArray();
}
/* default */ static Intent decode(byte[] encoded) throws IOException {
Objects.requireNonNull(encoded, "encoded");
ByteArrayInputStream bytes = new ByteArrayInputStream(encoded);
try (DataInputStream input = new DataInputStream(bytes)) {
Intent intent = readIntent(input);
if (bytes.available() != 0) {
throw new IOException("Trailing credential handoff intent data");
}
return intent;
}
}
private static void writeIntent(DataOutputStream output, Intent intent) throws IOException {
output.writeInt(MAGIC);
output.writeByte(VERSION);
output.writeByte(intent.state().code);
writeText(output, intent.credentialId().value());
writeText(output, intent.owner().category().name());
writeText(output, intent.owner().identifier());
writeText(output, intent.storeId());
writeText(output, intent.contentId());
writeText(output, intent.encoding().name());
output.writeLong(intent.length());
writeText(output, intent.sha256());
writeText(output, intent.lifecycle().name());
}
private static Intent readIntent(DataInputStream input) throws IOException {
try {
if (input.readInt() != MAGIC || input.readUnsignedByte() != VERSION) {
throw new IOException("Unsupported credential handoff intent");
}
State state = State.fromCode(input.readUnsignedByte());
DurableContentOwner.Category category;
Encoding encoding;
DurableContentReference.Lifecycle lifecycle;
try {
PkiId credentialId = new PkiId(readText(input));
category = DurableContentOwner.Category.valueOf(readText(input));
DurableContentOwner owner = new DurableContentOwner(category, readText(input));
String storeId = readText(input);
String contentId = readText(input);
encoding = Encoding.valueOf(readText(input));
long length = input.readLong();
String sha256 = readText(input);
lifecycle = DurableContentReference.Lifecycle.valueOf(readText(input));
return new Intent(state, credentialId, owner, storeId, contentId, encoding, length, sha256,
lifecycle);
} catch (IllegalArgumentException failure) {
throw new IOException("Malformed credential handoff intent", failure);
}
} catch (EOFException failure) {
throw new IOException("Truncated credential handoff intent", failure);
}
}
private static MessageDigest sha256() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException failure) {
throw new IllegalStateException("SHA-256 is unavailable", failure);
}
}
private static void updateLength(MessageDigest digest, int length) {
digest.update((byte) (length >>> 24));
digest.update((byte) (length >>> 16));
digest.update((byte) (length >>> 8));
digest.update((byte) length);
}
private static void writeText(DataOutputStream output, String value) throws IOException {
byte[] encoded = Objects.requireNonNull(value, "value").getBytes(StandardCharsets.UTF_8);
if (encoded.length > MAX_TEXT_BYTES) {
throw new IOException("Credential handoff intent text is too long");
}
output.writeShort(encoded.length);
output.write(encoded);
}
private static String readText(DataInputStream input) throws IOException {
int length = input.readUnsignedShort();
if (length > MAX_TEXT_BYTES) {
throw new IOException("Credential handoff intent text is too long");
}
byte[] encoded = input.readNBytes(length);
if (encoded.length != length) {
throw new EOFException("Credential handoff intent text is truncated");
}
String value = new String(encoded, StandardCharsets.UTF_8);
if (!Arrays.equals(encoded, value.getBytes(StandardCharsets.UTF_8))) {
throw new IOException("Credential handoff intent text is not canonical UTF-8");
}
return value;
}
/** Closed persisted handoff states. */
/* default */ enum State {
PREPARED(1),
COMMITTED(2);
private final int code;
State(int code) {
this.code = code;
}
private static State fromCode(int code) throws IOException {
for (State state : values()) {
if (state.code == code) {
return state;
}
}
throw new IOException("Unknown credential handoff state");
}
}
/** Strict path-free and payload-free persisted handoff description. */
/* default */ record Intent(State state, PkiId credentialId, DurableContentOwner owner, String storeId, String contentId,
Encoding encoding, long length, String sha256, DurableContentReference.Lifecycle lifecycle) {
Intent {
Objects.requireNonNull(state, "state");
Objects.requireNonNull(credentialId, "credentialId");
Objects.requireNonNull(owner, "owner");
Objects.requireNonNull(storeId, "storeId");
Objects.requireNonNull(contentId, "contentId");
Objects.requireNonNull(encoding, "encoding");
Objects.requireNonNull(sha256, "sha256");
Objects.requireNonNull(lifecycle, "lifecycle");
if (!owner.equals(DurableContentOwner.credentialRecord(credentialId))
|| lifecycle != DurableContentReference.Lifecycle.PERSISTED || length < 0L
|| !storeId.matches("[0-9a-f]{32}")
|| !contentId.matches("[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}")
|| !sha256.matches("[0-9a-f]{64}")) {
throw new IllegalArgumentException("Credential handoff intent is inconsistent");
}
}
/* default */ static Intent from(Credential credential, State state) {
DurableContentReference reference = credential.content();
return new Intent(state, credential.credentialId(),
DurableContentOwner.credentialRecord(credential.credentialId()), reference.storeId(),
reference.contentId(), reference.encoding(), reference.length(), reference.sha256(),
reference.lifecycle());
}
/* default */ Intent withState(State next) {
return new Intent(next, credentialId, owner, storeId, contentId, encoding, length, sha256, lifecycle);
}
/* default */ boolean matches(DurableContentReference reference) {
return storeId.equals(reference.storeId()) && contentId.equals(reference.contentId())
&& encoding == reference.encoding() && length == reference.length()
&& sha256.equals(reference.sha256()) && lifecycle == reference.lifecycle();
}
/* default */ DurableContentReference restore(FilesystemStagedContentStore store) throws IOException {
return store.restoreReference(storeId, contentId, encoding, length, sha256, lifecycle);
}
}
/** Marks a completed namespace move with unconfirmed directory durability. */
private static final class PublishedException extends IOException {
private static final long serialVersionUID = -8788329245365479704L;
private PublishedException() {
super("Credential record is published but directory durability is unconfirmed");
}
}
}

View File

@@ -0,0 +1,417 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.impl.fs;
import java.io.BufferedInputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.FilterInputStream;
import java.io.FilterOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
import java.nio.channels.SeekableByteChannel;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.NoSuchFileException;
import java.nio.file.OpenOption;
import java.nio.file.Path;
import java.nio.file.SecureDirectoryStream;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.FileAttribute;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
/** Strict durable I/O for bounded filesystem metadata records. */
final class DurableMetadataFiles {
private static final int FINAL_TEMPORARY_CREATE_ATTEMPT = 15;
private static final long ZERO_BYTES = 0L;
private static final Set<java.nio.file.attribute.PosixFilePermission> OWNER_ONLY =
PosixFilePermissions.fromString("rw-------");
private static final ThreadLocal<FaultInjector> FAULTS = new ThreadLocal<>();
private DurableMetadataFiles() {
}
/* default */ static void create(Path target, long maximumBytes, Encoder encoder) throws IOException {
write(target, maximumBytes, encoder, false);
}
/* default */ static void replace(Path target, long maximumBytes, Encoder encoder) throws IOException {
write(target, maximumBytes, encoder, true);
}
/* default */ static <T> T read(Path target, long maximumBytes, Decoder<T> decoder) throws IOException {
Objects.requireNonNull(decoder, "decoder");
requireMaximum(maximumBytes);
Path parent = requireParent(target);
try (SecureDirectoryStream<Path> directory = openSecureDirectory(parent);
SeekableByteChannel channel = openRead(directory, target.getFileName());
InputStream raw = Channels.newInputStream(channel);
DataInputStream input = new DataInputStream(
new BufferedInputStream(new BoundedInputStream(raw, maximumBytes)))) {
trip(FaultPoint.DECODE);
T value = decoder.decode(input);
if (input.read() >= 0) {
throw new IOException("Trailing durable metadata data");
}
return value;
}
}
/* default */ static List<Path> list(Path parent, String suffix) throws IOException {
Objects.requireNonNull(suffix, "suffix");
List<Path> paths = new ArrayList<>();
try (SecureDirectoryStream<Path> directory = openSecureDirectory(parent)) {
for (Path relative : directory) {
Path name = relative.getFileName();
if (name != null && name.toString().endsWith(suffix)) {
paths.add(parent.resolve(name));
}
}
}
paths.sort(Comparator.comparing(path -> path.getFileName().toString()));
return List.copyOf(paths);
}
/* default */ static boolean delete(Path target) throws IOException {
Path parent = requireParent(target);
try (SecureDirectoryStream<Path> directory = openSecureDirectory(parent)) {
try {
directory.deleteFile(target.getFileName());
} catch (NoSuchFileException missing) {
return false;
}
}
try {
trip(FaultPoint.DIRECTORY_FORCE_AFTER_DELETE);
forceDirectory(parent);
return true;
} catch (IOException failure) {
throw new UncertainAfterMutationException(Mutation.DELETE, failure);
}
}
/* default */ static void installFault(FaultInjector injector) {
FAULTS.set(Objects.requireNonNull(injector, "injector"));
}
/* default */ static void clearFault() {
FAULTS.remove();
}
private static void write(Path target, long maximumBytes, Encoder encoder, boolean replace) throws IOException {
Objects.requireNonNull(encoder, "encoder");
requireMaximum(maximumBytes);
Path parent = requireParent(target);
Files.createDirectories(parent);
Path temporary = createTemporary(parent);
try {
writeAndForce(temporary, maximumBytes, encoder);
} catch (IOException failure) {
cleanupAfterFailure(temporary, failure);
throw failure;
}
try {
trip(FaultPoint.MOVE);
move(temporary, target, replace);
} catch (IOException failure) {
cleanupAfterFailure(temporary, failure);
throw failure;
}
try {
trip(FaultPoint.DIRECTORY_FORCE_AFTER_MOVE);
forceDirectory(parent);
} catch (IOException postMove) {
throw new UncertainAfterMutationException(Mutation.MOVE, postMove);
}
}
private static void move(Path source, Path target, boolean replace) throws IOException {
if (replace) {
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} else {
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE);
}
}
private static void cleanupAfterFailure(Path temporary, IOException failure) {
try {
trip(FaultPoint.CLEANUP);
cleanupTemporary(temporary);
} catch (IOException cleanupFailure) {
failure.addSuppressed(cleanupFailure);
}
}
private static Path createTemporary(Path parent) throws IOException {
for (int attempt = 0; attempt < 16; attempt++) {
trip(FaultPoint.TEMP_CREATE);
Path candidate = parent.resolve(".metadata-" + UUID.randomUUID() + ".tmp");
try (SecureDirectoryStream<Path> directory = openSecureDirectory(parent);
SeekableByteChannel ignored = directory.newByteChannel(candidate.getFileName(),
openOptions(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE), fileAttributes(parent))) {
return candidate;
} catch (java.nio.file.FileAlreadyExistsException collision) {
if (attempt == FINAL_TEMPORARY_CREATE_ATTEMPT) {
throw new IOException("Unable to create durable metadata temporary file", collision);
}
}
}
throw new IOException("Unable to create durable metadata temporary file");
}
private static void writeAndForce(Path path, long maximumBytes, Encoder encoder) throws IOException {
try (SecureDirectoryStream<Path> directory = openSecureDirectory(path.getParent());
SeekableByteChannel channel = directory.newByteChannel(path.getFileName(),
openOptions(StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING))) {
if (!(channel instanceof FileChannel)) {
throw new IOException("Durable metadata file forcing is unavailable");
}
try (DataOutputStream output = new DataOutputStream(
new BoundedOutputStream(Channels.newOutputStream(channel), maximumBytes))) {
trip(FaultPoint.WRITE);
encoder.encode(output);
output.flush();
trip(FaultPoint.FILE_FORCE);
((FileChannel) channel).force(true);
}
}
}
private static SeekableByteChannel openRead(SecureDirectoryStream<Path> directory, Path name) throws IOException {
return directory.newByteChannel(name, openOptions(StandardOpenOption.READ));
}
private static SecureDirectoryStream<Path> openSecureDirectory(Path directory) throws IOException {
trip(FaultPoint.SECURE_OPEN);
DirectoryStream<Path> opened = Files.newDirectoryStream(directory);
if (opened instanceof SecureDirectoryStream<?>) {
return (SecureDirectoryStream<Path>) opened;
}
opened.close();
throw new IOException("Secure durable metadata directory access is unavailable");
}
private static Set<OpenOption> openOptions(StandardOpenOption... options) {
Set<OpenOption> selected = new java.util.HashSet<>();
selected.addAll(EnumSet.copyOf(List.of(options)));
selected.add(LinkOption.NOFOLLOW_LINKS);
return Set.copyOf(selected);
}
private static FileAttribute<?>[] fileAttributes(Path parent) throws IOException {
if (Files.getFileStore(parent).supportsFileAttributeView("posix")) {
return new FileAttribute<?>[] { PosixFilePermissions.asFileAttribute(OWNER_ONLY) };
}
return new FileAttribute<?>[0];
}
private static void cleanupTemporary(Path temporary) throws IOException {
try (SecureDirectoryStream<Path> directory = openSecureDirectory(temporary.getParent())) {
try {
directory.deleteFile(temporary.getFileName());
} catch (NoSuchFileException missing) {
return;
}
}
forceDirectory(temporary.getParent());
}
private static void forceDirectory(Path directory) throws IOException {
try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) {
channel.force(true);
}
}
private static Path requireParent(Path target) throws IOException {
Objects.requireNonNull(target, "target");
Path parent = target.getParent();
if (parent == null || target.getFileName() == null) {
throw new IOException("Durable metadata target has no parent");
}
return parent;
}
private static void requireMaximum(long maximumBytes) {
if (maximumBytes <= ZERO_BYTES) {
throw new IllegalArgumentException("maximumBytes must be positive");
}
}
private static void trip(FaultPoint point) throws IOException {
FaultInjector injector = FAULTS.get();
if (injector != null) {
injector.fail(point);
}
}
/** Package-private streaming encoder used only by filesystem metadata persistence. */
/* default */
@FunctionalInterface
interface Encoder {
/** Writes one complete bounded metadata record. */
void encode(DataOutputStream output) throws IOException;
}
/** Package-private incremental decoder used only by filesystem metadata persistence. */
/* default */
@FunctionalInterface
interface Decoder<T> {
/** Decodes one complete bounded metadata record. */
T decode(DataInputStream input) throws IOException;
}
/** Package-private deterministic fault injection points. */
/* default */ enum FaultPoint {
TEMP_CREATE, WRITE, FILE_FORCE, MOVE, DIRECTORY_FORCE_AFTER_MOVE, DIRECTORY_FORCE_AFTER_DELETE, SECURE_OPEN,
DECODE, CLEANUP
}
/** Mutation whose namespace durability is uncertain. */
/* default */ enum Mutation {
MOVE, DELETE
}
/** Path-free marker for a completed mutation with uncertain directory force. */
/* default */ static final class UncertainAfterMutationException extends IOException {
private static final long serialVersionUID = 6317105201345803018L;
private final Mutation mutation;
private UncertainAfterMutationException(Mutation mutation, IOException cause) {
super("Durable metadata mutation completed but durability is uncertain: " + mutation.name(), cause);
this.mutation = mutation;
}
/* default */ Mutation mutation() {
return mutation;
}
}
/**
* Package-private deterministic fault seam for focused filesystem tests;
* it is not production API.
*/
/* default */
@FunctionalInterface
interface FaultInjector {
/** Fails the selected operation point when requested by a focused test. */
void fail(FaultPoint point) throws IOException;
}
/** Input wrapper rejecting records larger than their finite limit. */
private static final class BoundedInputStream extends FilterInputStream {
private long remaining;
private BoundedInputStream(InputStream input, long maximumBytes) {
super(input);
this.remaining = maximumBytes;
}
@Override
public int read() throws IOException {
if (remaining == ZERO_BYTES) {
int extra = super.read();
if (extra >= 0) {
throw new IOException("Durable metadata exceeds its size limit");
}
return -1;
}
int value = super.read();
if (value >= 0) {
remaining--;
}
return value;
}
@Override
public int read(byte[] bytes, int offset, int length) throws IOException {
Objects.checkFromIndexSize(offset, length, bytes.length);
if (length == 0) {
return 0;
}
if (remaining == ZERO_BYTES) {
return read();
}
int permitted = (int) Math.min(remaining, length);
int read = super.read(bytes, offset, permitted);
if (read > 0) {
remaining -= read;
}
return read;
}
}
/** Output wrapper rejecting records larger than their finite limit. */
private static final class BoundedOutputStream extends FilterOutputStream {
private long remaining;
private BoundedOutputStream(OutputStream output, long maximumBytes) {
super(output);
this.remaining = maximumBytes;
}
@Override
public void write(int value) throws IOException {
requireCapacity(1);
out.write(value);
remaining--;
}
@Override
public void write(byte[] bytes, int offset, int length) throws IOException {
Objects.checkFromIndexSize(offset, length, bytes.length);
requireCapacity(length);
out.write(bytes, offset, length);
remaining -= length;
}
private void requireCapacity(int length) throws IOException {
if (length > remaining) {
throw new IOException("Durable metadata exceeds its size limit");
}
}
}
}

View File

@@ -58,6 +58,8 @@ import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.OptionalLong;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -87,11 +89,16 @@ import zeroecho.pki.api.revocation.RevocationReason;
import zeroecho.pki.api.revocation.RevocationState;
import zeroecho.pki.api.revocation.RevocationTransition;
import zeroecho.pki.api.status.StatusObject;
import zeroecho.pki.api.content.DurableContentReference;
import zeroecho.pki.api.content.DurableContentOwner;
import zeroecho.pki.impl.ProfileLifecycleFailure;
import zeroecho.pki.impl.ProfileLifecycleFailure.Code;
import zeroecho.pki.impl.core.async.PkiSigningBus;
import zeroecho.pki.spi.store.PkiStore;
import zeroecho.pki.spi.store.StagedContentStore;
import zeroecho.pki.spi.store.SignWorkflowStore;
import zeroecho.pki.spi.store.TemporaryUniqueIndex;
import zeroecho.pki.spi.store.RevocationSnapshot;
/**
* Filesystem-based reference implementation of {@link PkiStore}.
@@ -167,6 +174,8 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
private final ConcurrentMap<PkiId, RevocationLockEntry> revocationLocks;
private final ConcurrentMap<String, ProfileLockEntry> profileLocks;
private final AtomicBoolean durabilityUncertain;
private final FilesystemStagedContentStore stagedContent;
private final CredentialContentTransaction credentialContentTransactions;
private final StoreOwnership ownership;
@@ -221,8 +230,12 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
try {
ensureVersionFile();
this.signingNamespace = ensureSigningNamespace();
this.stagedContent = new FilesystemStagedContentStore(this.paths.stagedContentRoot(),
this.signingNamespace);
this.credentialContentTransactions = new CredentialContentTransaction(this.paths, this.stagedContent);
this.signingTimeWatermark = new AtomicLong(loadSigningTimeWatermark());
this.historySeq = new AtomicLong(0L);
recoverStagedContent();
LOG.log(Level.INFO, "running in {0}", root);
this.ownership = acquiredOwnership;
@@ -236,6 +249,101 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
}
}
@Override
public StagedContentStore stagedContent() {
return stagedContent;
}
private void recoverStagedContent() throws IOException {
credentialContentTransactions.recover();
try (TemporaryUniqueIndex retained = stagedContent.beginUniqueIndex();
TemporaryUniqueIndex retainedOwners = stagedContent.beginOwnerIndex()) {
try {
addPersistedCredentialReferences(retained, retainedOwners);
addPersistedStatusReferences(retained);
addPendingSigningReferences(retained, retainedOwners);
stagedContent.recoverContent(retained, retainedOwners);
} catch (IllegalStateException | PkiException malformedDurableState) {
// Recovery cannot prove abandonment while durable metadata is
// corrupt. Preserve content so the normal owning subsystem can
// report the authoritative corruption without destructive loss.
LOG.log(Level.WARNING, "Staged-content reclamation skipped: code=DURABLE_METADATA_INVALID");
}
}
}
private void addPersistedCredentialReferences(TemporaryUniqueIndex retained, TemporaryUniqueIndex retainedOwners)
throws IOException {
Path root = paths.root().resolve("credentials").resolve("by-id");
if (!Files.isDirectory(root)) {
return;
}
try (Stream<Path> pathsStream = Files.list(root)) {
java.util.Iterator<Path> iterator = pathsStream
.filter(path -> Files.isRegularFile(path) && path.getFileName().toString().endsWith(".bin"))
.iterator();
while (iterator.hasNext()) {
Credential credential = FsCodec.decode(FsCodec.CREDENTIAL, FsOperations.readAll(iterator.next()),
stagedContent);
addRetained(retained, credential.content());
DurableContentOwner owner = DurableContentOwner.credentialRecord(credential.credentialId());
retainedOwners.add(owner.canonicalForm().getBytes(StandardCharsets.UTF_8));
}
}
}
private void addPersistedStatusReferences(TemporaryUniqueIndex retained) throws IOException {
Path root = paths.root().resolve("status").resolve("by-id");
if (!Files.isDirectory(root)) {
return;
}
try (Stream<Path> pathsStream = Files.list(root)) {
java.util.Iterator<Path> iterator = pathsStream.filter(Files::isRegularFile).iterator();
while (iterator.hasNext()) {
StatusObject status = FsCodec.decode(FsCodec.STATUS_OBJECT, FsOperations.readAll(iterator.next()),
stagedContent);
addRetained(retained, status.content());
}
}
}
private void addPendingSigningReferences(TemporaryUniqueIndex retained, TemporaryUniqueIndex retainedOwners)
throws IOException {
Path root = paths.signWorkflowRoot();
if (!Files.isDirectory(root)) {
return;
}
try (Stream<Path> pathsStream = Files.walk(root)) {
java.util.Iterator<Path> iterator = pathsStream
.filter(path -> Files.isRegularFile(path)
&& FsPaths.CURRENT_FILE.equals(path.getFileName().toString()))
.iterator();
while (iterator.hasNext()) {
SignWorkflowStore.Record record = readSignRecordFile(iterator.next());
PkiSigningBus.SignContinuation continuation = PkiSigningBus.SignContinuation.decode(record.request(),
stagedContent);
if (record.state() == SignWorkflowStore.State.RETIRED) {
if (continuation.hasLiveContent()) {
throw new IllegalStateException("Retired signing record retains live content");
}
continue;
}
DurableContentReference reference = continuation.content();
DurableContentOwner owner = DurableContentOwner.signingOperation(record.submissionId());
if (!stagedContent.contentOwners(reference).contains(owner)) {
throw new IllegalStateException("Signing content owner is missing");
}
addRetained(retained, reference);
retainedOwners.add(owner.canonicalForm().getBytes(StandardCharsets.UTF_8));
}
}
}
private static void addRetained(TemporaryUniqueIndex retained, DurableContentReference reference)
throws IOException {
retained.add(reference.contentId().getBytes(StandardCharsets.US_ASCII));
}
/**
* Exports a snapshot of this store as of time {@code at} into
* {@code targetRoot}.
@@ -292,16 +400,23 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
public void putCredential(final Credential credential) {
requireStoreUsable();
Objects.requireNonNull(credential, "credential");
PkiId id = credential.credentialId();
Path p = this.paths.credentialPath(id);
writeOnce(p, FsCodec.encode(FsCodec.CREDENTIAL, credential), "CREDENTIAL", FsUtil.safeId(id));
credentialContentTransactions.put(credential);
}
@Override
public Optional<Credential> getCredential(final PkiId credentialId) {
requireStoreUsable();
Objects.requireNonNull(credentialId, "credentialId");
return readOptional(this.paths.credentialPath(credentialId), FsCodec.CREDENTIAL);
Path path = this.paths.credentialPath(credentialId);
if (!Files.exists(path)) {
return Optional.empty();
}
try {
Credential credential = FsCodec.decode(FsCodec.CREDENTIAL, FsOperations.readAll(path), stagedContent);
return Optional.of(credentialContentTransactions.validateLoaded(credentialId, credential));
} catch (IOException failure) {
throw new IllegalStateException("Credential read failed", failure);
}
}
@Override
@@ -369,28 +484,29 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
}
@Override
// Listing failures are intentionally replaced by one stable redacted boundary.
// Snapshot failures are intentionally replaced by one stable redacted boundary.
@SuppressWarnings("PMD.PreserveStackTrace")
public List<RevocationJournal> listRevocationJournals() {
public RevocationSnapshot openRevocationSnapshot() {
requireStoreUsable();
Path root = this.paths.root().resolve("revocations").resolve("by-credential");
if (!Files.isDirectory(root)) {
return List.of();
}
try (Stream<Path> directories = Files.list(root)) {
List<RevocationJournal> journals = new ArrayList<>();
for (Path entityDir : directories.filter(Files::isDirectory)
.sorted(Comparator.comparing(path -> path.getFileName().toString())).toList()) {
Path journalPath = entityDir.resolve("journal.bin");
if (Files.exists(journalPath)) {
RevocationJournal journal = decodeRevocationJournal(journalPath);
if (!entityDir.getFileName().toString().equals(FsUtil.safeId(journal.credentialId()))) {
throw corruptRevocationState();
String snapshotId = UUID.randomUUID().toString();
Path snapshotRoot = paths.revocationSnapshotRoot().resolve(snapshotId);
long count = 0L;
try {
Files.createDirectories(snapshotRoot);
if (Files.isDirectory(root)) {
try (Stream<Path> journals = Files.walk(root)) {
java.util.Iterator<Path> iterator = journals
.filter(path -> Files.isRegularFile(path)
&& "journal.bin".equals(path.getFileName().toString()))
.iterator();
while (iterator.hasNext()) {
Files.copy(iterator.next(), snapshotRoot.resolve(Long.toUnsignedString(count) + ".bin"));
count = Math.addExact(count, 1L);
}
journals.add(journal);
}
}
return List.copyOf(journals);
return new FilesystemRevocationSnapshot(snapshotId, snapshotRoot, count);
} catch (IOException ex) {
throw corruptRevocationState();
}
@@ -835,9 +951,13 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|| !isRetirableSignState(current.state())) {
return Optional.empty();
}
SignWorkflowStore.Record retired = copySignRecord(current, SignWorkflowStore.State.RETIRED,
current.revision() + 1L, fence, Optional.empty(), Optional.of("RETIRED"), current.result(),
current.providerUpdatedAt());
PkiSigningBus.SignContinuation continuation = PkiSigningBus.SignContinuation.decode(current.request(),
stagedContent);
EncodedObject retiredRequest = continuation.withoutLiveContent().encode();
SignWorkflowStore.Record retired = new SignWorkflowStore.Record(current.submissionId(),
current.namespace(), current.fingerprint(), current.owner(), current.createdAt(),
current.deadline(), retiredRequest, SignWorkflowStore.State.RETIRED, current.revision() + 1L,
fence, Optional.empty(), Optional.of("RETIRED"), current.result(), current.providerUpdatedAt());
writeSignRecord(retired);
return Optional.of(retired);
} finally {
@@ -1054,6 +1174,105 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
return Optional.of(journal);
}
/** Stable file-backed revocation snapshot isolated from later store writes. */
private static final class FilesystemRevocationSnapshot implements RevocationSnapshot {
private final String snapshotId;
private final Path root;
private final long count;
private final AtomicBoolean closed;
private FilesystemRevocationSnapshot(String snapshotId, Path root, long count) {
this.snapshotId = snapshotId;
this.root = root;
this.count = count;
this.closed = new AtomicBoolean();
}
@Override
public String snapshotId() {
return snapshotId;
}
@Override
public OptionalLong count() {
return OptionalLong.of(count);
}
@Override
public Cursor openCursor() {
if (closed.get()) {
throw new IllegalStateException("Revocation snapshot is closed");
}
return new FilesystemRevocationCursor(root, count);
}
@Override
public void close() throws IOException {
if (!closed.compareAndSet(false, true)) {
return;
}
if (Files.isDirectory(root)) {
try (Stream<Path> files = Files.list(root)) {
java.util.Iterator<Path> iterator = files.iterator();
while (iterator.hasNext()) {
Files.deleteIfExists(iterator.next());
}
}
Files.deleteIfExists(root);
}
}
}
/** Sequential bounded-memory cursor over one immutable snapshot directory. */
private static final class FilesystemRevocationCursor implements RevocationSnapshot.Cursor {
private final Path root;
private final long count;
private long nextOrdinal;
private RevocationJournal current;
private boolean closed;
private FilesystemRevocationCursor(Path root, long count) {
this.root = root;
this.count = count;
}
@Override
public boolean next() throws IOException {
if (closed) {
throw new IllegalStateException("Revocation cursor is closed");
}
if (nextOrdinal >= count) {
current = null;
return false;
}
current = decodeRevocationJournal(root.resolve(Long.toUnsignedString(nextOrdinal) + ".bin"));
nextOrdinal = Math.addExact(nextOrdinal, 1L);
return true;
}
@Override
public RevocationJournal current() {
if (current == null) {
throw new IllegalStateException("Revocation cursor is not positioned");
}
return current;
}
@Override
public long ordinal() {
if (current == null) {
throw new IllegalStateException("Revocation cursor is not positioned");
}
return nextOrdinal - 1L;
}
@Override
public void close() {
current = null;
closed = true;
}
}
// Codec and filesystem failures are external persisted-state boundaries; raw
// causes are deliberately removed from the stable corruption exception.
@SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" })
@@ -1156,7 +1375,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
}
byte[] payload = new byte[input.remaining()];
input.get(payload);
return FsCodec.decode(FsCodec.SIGN_WORKFLOW_RECORD, payload);
return FsCodec.decode(FsCodec.SIGN_WORKFLOW_RECORD, payload, stagedContent);
} catch (IOException ex) {
throw new IllegalStateException("Failed to read authoritative signing record", ex);
}
@@ -1176,7 +1395,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
Instant futureLimit;
try {
parsed = SigningSubmissionId.parse(record.submissionId());
continuation = PkiSigningBus.SignContinuation.decode(record.request());
continuation = PkiSigningBus.SignContinuation.decode(record.request(), stagedContent);
horizonEnd = record.createdAt().plus(options.signingOperationHorizon());
futureLimit = signingNow().plus(options.signingIdPermittedSkew());
} catch (RuntimeException ex) {
@@ -1193,6 +1412,8 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
record, "DEADLINE_INVALID");
requireValidSignRecord(continuation.isBoundTo(record.submissionId(), record.owner()), record,
"CONTINUATION_IDENTITY_MISMATCH");
requireValidSignRecord(record.state() == SignWorkflowStore.State.RETIRED != continuation.hasLiveContent(),
record, "CONTINUATION_LIFECYCLE_MISMATCH");
byte[] storedRequest = record.request().bytes();
byte[] canonicalRequest = null;
@@ -1609,19 +1830,19 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
}
}
private static <T> Optional<T> readOptional(final Path path, final FsCodec.Schema<T> schema) {
private <T> Optional<T> readOptional(final Path path, final FsCodec.Schema<T> schema) {
try {
if (!Files.exists(path)) {
return Optional.empty();
}
byte[] data = FsOperations.readAll(path);
return Optional.of(FsCodec.decode(schema, data));
return Optional.of(FsCodec.decode(schema, data, stagedContent));
} catch (IOException e) {
throw new IllegalStateException("read failed: " + path, e);
}
}
private static <T> List<T> listBinaryFiles(final Path byIdDir, final FsCodec.Schema<T> schema) {
private <T> List<T> listBinaryFiles(final Path byIdDir, final FsCodec.Schema<T> schema) {
if (!Files.isDirectory(byIdDir)) {
return List.of();
}
@@ -1629,7 +1850,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
return Files.list(byIdDir).filter(Files::isRegularFile)
.sorted(Comparator.comparing(p -> p.getFileName().toString())).map(p -> {
try {
return FsCodec.decode(schema, FsOperations.readAll(p));
return FsCodec.decode(schema, FsOperations.readAll(p), stagedContent);
} catch (IOException e) {
throw new IllegalStateException("read failed: " + p, e);
}
@@ -1639,7 +1860,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
}
}
private static <T> List<T> listCurrentRecords(final Path byIdDir, final FsCodec.Schema<T> schema) {
private <T> List<T> listCurrentRecords(final Path byIdDir, final FsCodec.Schema<T> schema) {
if (!Files.isDirectory(byIdDir)) {
return List.of();
}
@@ -1651,7 +1872,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
for (Path entityDir : entityDirs) {
Path current = entityDir.resolve(FsPaths.CURRENT_FILE);
if (Files.exists(current)) {
out.add(FsCodec.decode(schema, FsOperations.readAll(current)));
out.add(FsCodec.decode(schema, FsOperations.readAll(current), stagedContent));
}
}
return out;

View File

@@ -0,0 +1,909 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.impl.fs;
import java.io.FilterOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.PosixFilePermission;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.charset.StandardCharsets;
import java.security.DigestOutputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.EnumSet;
import java.util.LinkedHashSet;
import java.util.HexFormat;
import java.util.Objects;
import java.util.OptionalLong;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.ReentrantLock;
import zeroecho.core.io.RepeatableContent;
import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.content.DurableContentReference;
import zeroecho.pki.api.content.DurableContentOwner;
import zeroecho.pki.spi.store.ContentSink;
import zeroecho.pki.spi.store.StagedContentStore;
import zeroecho.pki.spi.store.TemporaryUniqueIndex;
/**
* Filesystem-backed staged-content store with atomic completion and streaming
* integrity verification.
*
* <p>
* Partial files are never exposed through a durable reference. Aggregate lengths
* use {@code long}; content is never buffered in aggregate memory. Callers own
* lifecycle release. The implementation creates restrictive files when POSIX
* permissions are available.
* </p>
*/
public final class FilesystemStagedContentStore implements StagedContentStore {
private static final Set<PosixFilePermission> OWNER_ONLY = EnumSet.of(PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE);
private static final Set<PosixFilePermission> OWNER_DIRECTORY = EnumSet.of(PosixFilePermission.OWNER_READ,
PosixFilePermission.OWNER_WRITE, PosixFilePermission.OWNER_EXECUTE);
private static final int BUFFER_BYTES = 16 * 1024;
private static final int METADATA_VERSION = 1;
private static final int OWNER_METADATA_VERSION = 2;
private static final int MAX_OWNER_RECORD_BYTES = 1024 * 1024;
private static final int MAX_OWNER_COUNT = 2048;
private static final int OWNER_LOCK_COUNT = 64;
private static final long MINIMUM_CONTENT_LENGTH = 0L;
private final Path root;
private final String storeId;
private final ReentrantLock[] ownerLocks;
/**
* Creates a staged-content store.
*
* @param root private storage directory
* @param storeId stable runtime/store identifier
* @throws IOException if the directory cannot be created
* @throws IllegalArgumentException if {@code storeId} is blank
*/
public FilesystemStagedContentStore(Path root, String storeId) throws IOException {
this.root = Objects.requireNonNull(root, "root").toAbsolutePath().normalize();
this.storeId = requireStoreIdentifier(storeId);
this.ownerLocks = new ReentrantLock[OWNER_LOCK_COUNT];
for (int index = 0; index < ownerLocks.length; index++) {
ownerLocks[index] = new ReentrantLock();
}
Files.createDirectories(this.root);
if (Files.isSymbolicLink(this.root) || !Files.isDirectory(this.root, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Staged-content root must be a real directory");
}
cleanupAbandonedTemporaryState();
}
@Override
public String contentStoreId() {
return storeId;
}
@Override
public ContentSink beginContent(Encoding encoding, DurableContentReference.Lifecycle lifecycle) throws IOException {
Objects.requireNonNull(encoding, "encoding");
Objects.requireNonNull(lifecycle, "lifecycle");
String contentId = UUID.randomUUID().toString();
Path incomplete = root.resolve(contentId + ".incomplete");
Path complete = root.resolve(contentId + ".content");
Files.createFile(incomplete);
restrict(incomplete);
return new FileSink(contentId, incomplete, complete, encoding, lifecycle);
}
@Override
public RepeatableContent openContent(DurableContentReference reference) throws IOException {
DurableContentReference exact = requireOwned(reference);
DurableContentReference persisted = readMetadata(exact.contentId());
if (!persisted.equals(exact)) {
throw new IOException("Staged content metadata mismatch: code=CONTENT_INTEGRITY_FAILED");
}
Path path = completePath(exact);
if (!isRegularFile(path)) {
throw new IOException("Staged content missing: code=STAGED_CONTENT_MISSING");
}
long actualLength = Files.size(path);
if (actualLength != exact.length()) {
throw new IOException("Staged content integrity failed: code=CONTENT_INTEGRITY_FAILED");
}
return new FileContent(path, exact);
}
@Override
public DurableContentReference restoreReference(String persistedStoreId, String contentId, Encoding encoding,
long length, String sha256, DurableContentReference.Lifecycle lifecycle) throws IOException {
if (!storeId.equals(requireStoreIdentifier(persistedStoreId))) {
throw new IllegalArgumentException("Staged content belongs to another store");
}
requireContentIdentifier(contentId);
StoreReference supplied = new StoreReference(storeId, contentId, encoding, length, sha256, lifecycle);
DurableContentReference persisted = readMetadata(contentId);
if (!persisted.equals(supplied)) {
throw new IOException("Staged content metadata mismatch: code=CONTENT_INTEGRITY_FAILED");
}
return persisted;
}
@Override
public boolean retainContent(DurableContentReference reference, DurableContentOwner owner) throws IOException {
DurableContentReference exact = requireOwned(reference);
Objects.requireNonNull(owner, "owner");
ReentrantLock lock = ownerLock(exact.contentId());
lock.lock();
try {
requireExactMetadata(exact);
Set<DurableContentOwner> owners = readOwners(exact.contentId());
if (!owners.add(owner)) {
return false;
}
writeOwners(exact.contentId(), owners);
return true;
} finally {
lock.unlock();
}
}
@Override
public boolean releaseContent(DurableContentReference reference, DurableContentOwner owner) throws IOException {
DurableContentReference exact = requireOwned(reference);
Objects.requireNonNull(owner, "owner");
ReentrantLock lock = ownerLock(exact.contentId());
lock.lock();
try {
if (!isRegularFile(metadataPath(exact.contentId()))) {
return false;
}
requireExactMetadata(exact);
Set<DurableContentOwner> owners = readOwners(exact.contentId());
if (!owners.remove(owner)) {
return false;
}
writeOwners(exact.contentId(), owners);
if (owners.isEmpty()) {
retireFiles(exact);
}
return true;
} finally {
lock.unlock();
}
}
@Override
public Set<DurableContentOwner> contentOwners(DurableContentReference reference) throws IOException {
DurableContentReference exact = requireOwned(reference);
ReentrantLock lock = ownerLock(exact.contentId());
lock.lock();
try {
requireExactMetadata(exact);
return Set.copyOf(readOwners(exact.contentId()));
} finally {
lock.unlock();
}
}
@Override
public void retireUnownedContent(DurableContentReference reference) throws IOException {
DurableContentReference exact = requireOwned(reference);
ReentrantLock lock = ownerLock(exact.contentId());
lock.lock();
try {
requireExactMetadata(exact);
if (!readOwners(exact.contentId()).isEmpty()) {
throw new IOException("Staged content remains durably owned");
}
retireFiles(exact);
} finally {
lock.unlock();
}
}
@Override
public void recoverContent(TemporaryUniqueIndex retained, TemporaryUniqueIndex retainedOwners) throws IOException {
StoreIo.recoverContent(this, retained, retainedOwners);
}
@Override
public TemporaryUniqueIndex beginUniqueIndex() throws IOException {
Path directory = root.resolve(UUID.randomUUID() + ".unique-index");
Files.createDirectory(directory);
restrictDirectory(directory);
return FilesystemTemporaryUniqueIndex.general(directory);
}
/* default */ TemporaryUniqueIndex beginOwnerIndex() throws IOException {
Path directory = root.resolve(UUID.randomUUID() + ".unique-index");
Files.createDirectory(directory);
restrictDirectory(directory);
return FilesystemTemporaryUniqueIndex.owners(directory);
}
/* default */ static String ownerIndexKey(DurableContentOwner owner) {
Objects.requireNonNull(owner, "owner");
return FilesystemTemporaryUniqueIndex.ownerKey(owner);
}
private void cleanupAbandonedTemporaryState() throws IOException {
StoreIo.cleanupAbandonedTemporaryState(this);
}
private void cleanupOrphanedCompletedFiles() throws IOException {
StoreIo.cleanupOrphanedCompletedFiles(this);
}
private void cleanupOrphanedOwnerFiles() throws IOException {
StoreIo.cleanupOrphanedOwnerFiles(this);
}
private DurableContentReference requireOwned(DurableContentReference reference) {
return StoreIo.requireOwned(this, reference);
}
private Path completePath(DurableContentReference reference) {
return resolveOwned(reference.contentId() + ".content");
}
private Path metadataPath(String contentId) {
requireContentIdentifier(contentId);
return resolveOwned(contentId + ".meta");
}
private DurableContentReference readMetadata(String contentId) throws IOException {
return StoreIo.readMetadata(this, contentId);
}
private void requireExactMetadata(DurableContentReference exact) throws IOException {
StoreIo.requireExactMetadata(this, exact);
}
private Set<DurableContentOwner> readOwners(String contentId) throws IOException {
return StoreIo.readOwners(this, contentId);
}
private void writeOwners(String contentId, Set<DurableContentOwner> owners) throws IOException {
StoreIo.writeOwners(this, contentId, owners);
}
private void retireFiles(DurableContentReference reference) throws IOException {
Files.deleteIfExists(metadataPath(reference.contentId()));
Files.deleteIfExists(completePath(reference));
DurableMetadataFiles.delete(ownerPath(reference.contentId()));
}
private Path ownerPath(String contentId) {
requireContentIdentifier(contentId);
return resolveOwned(contentId + ".owners");
}
private ReentrantLock ownerLock(String contentId) {
requireContentIdentifier(contentId);
return ownerLocks[Math.floorMod(contentId.hashCode(), ownerLocks.length)];
}
private void writeMetadata(DurableContentReference reference) throws IOException {
StoreIo.writeMetadata(this, reference);
}
private static MessageDigest sha256() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException("SHA-256 is unavailable", ex);
}
}
private static void restrict(Path path) throws IOException {
if (Files.getFileStore(path).supportsFileAttributeView("posix")) {
Files.setPosixFilePermissions(path, OWNER_ONLY);
}
}
private static void restrictDirectory(Path path) throws IOException {
if (Files.getFileStore(path).supportsFileAttributeView("posix")) {
Files.setPosixFilePermissions(path, OWNER_DIRECTORY);
}
}
/** Atomic file-backed sink with explicit terminal lifecycle. */
private final class FileSink implements ContentSink {
private final String contentId;
private final Path incomplete;
private final Path complete;
private final Encoding encoding;
private final DurableContentReference.Lifecycle lifecycle;
private final AtomicBoolean terminal;
private final ReentrantLock lock;
private CountingOutputStream output;
private FileSink(String contentId, Path incomplete, Path complete, Encoding encoding,
DurableContentReference.Lifecycle lifecycle) {
this.contentId = contentId;
this.incomplete = incomplete;
this.complete = complete;
this.encoding = encoding;
this.lifecycle = lifecycle;
this.terminal = new AtomicBoolean();
this.lock = new ReentrantLock();
}
@Override
public OutputStream outputStream() throws IOException {
lock.lock();
try {
requireOpen();
if (output == null) {
output = openCountingOutput(incomplete);
}
return output;
} finally {
lock.unlock();
}
}
@Override
public long length() {
lock.lock();
try {
return output == null ? 0L : output.length();
} finally {
lock.unlock();
}
}
@Override
public DurableContentReference complete() throws IOException {
lock.lock();
try {
requireOpen();
if (output == null) {
output = openCountingOutput(incomplete);
}
output.close();
try (FileChannel channel = FileChannel.open(incomplete, StandardOpenOption.READ)) {
channel.force(true);
}
try {
Files.move(incomplete, complete, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException ex) {
Files.move(incomplete, complete);
}
DurableContentReference reference = new StoreReference(storeId, contentId, encoding,
output.length(), output.digestHex(), lifecycle);
try {
writeMetadata(reference);
terminal.set(true);
return reference;
} catch (IOException failure) {
terminal.set(true);
Files.deleteIfExists(complete);
Files.deleteIfExists(resolveOwned(contentId + ".meta.incomplete"));
Files.deleteIfExists(metadataPath(contentId));
throw failure;
}
} finally {
lock.unlock();
}
}
@Override
public void abort() throws IOException {
lock.lock();
try {
if (terminal.compareAndSet(false, true)) {
if (output != null) {
output.close();
}
Files.deleteIfExists(incomplete);
}
} finally {
lock.unlock();
}
}
@Override
public void close() throws IOException {
lock.lock();
try {
if (!terminal.get()) {
terminal.set(true);
if (output != null) {
output.close();
}
Files.deleteIfExists(incomplete);
}
} finally {
lock.unlock();
}
}
private void requireOpen() {
if (terminal.get()) {
throw new IllegalStateException("Content sink is no longer open");
}
}
}
private static CountingOutputStream openCountingOutput(Path path) throws IOException {
return new CountingOutputStream(Files.newOutputStream(path, StandardOpenOption.WRITE), sha256());
}
/** Overflow-checked streaming digest and length adapter. */
private static final class CountingOutputStream extends FilterOutputStream {
private final DigestOutputStream digestOutput;
private long length;
private CountingOutputStream(OutputStream output, MessageDigest digest) {
super(new DigestOutputStream(output, digest));
this.digestOutput = (DigestOutputStream) out;
}
@Override
public void write(int value) throws IOException {
out.write(value);
length = Math.addExact(length, 1L);
}
@Override
public void write(byte[] bytes, int offset, int count) throws IOException {
Objects.checkFromIndexSize(offset, count, bytes.length);
long next = Math.addExact(length, count);
out.write(bytes, offset, count);
length = next;
}
private long length() {
return length;
}
private String digestHex() {
return HexFormat.of().formatHex(digestOutput.getMessageDigest().digest());
}
}
/** Repeatable immutable view over one completed staged file. */
private static final class FileContent implements RepeatableContent {
private final Path path;
private final DurableContentReference reference;
private FileContent(Path path, DurableContentReference reference) {
this.path = path;
this.reference = reference;
}
@Override
public InputStream openStream() throws IOException {
BasicFileAttributes attributes = Files.readAttributes(path, BasicFileAttributes.class,
LinkOption.NOFOLLOW_LINKS);
if (!attributes.isRegularFile() || attributes.size() != reference.length()) {
throw new IOException("Staged content integrity failed: code=CONTENT_INTEGRITY_FAILED");
}
return new VerifiedChannelInputStream(path, reference);
}
@Override
public OptionalLong length() {
return OptionalLong.of(reference.length());
}
@Override
public String contentId() {
return "sha256:" + reference.sha256();
}
@Override
public void close() {
// The durable store owns the underlying file lifecycle.
}
}
private static void requireDigest(FileChannel channel, DurableContentReference reference) throws IOException {
MessageDigest digest = sha256();
ByteBuffer buffer = ByteBuffer.allocate(BUFFER_BYTES);
long length = 0L;
while (channel.read(buffer) >= 0) {
buffer.flip();
int count = buffer.remaining();
if (count != 0) {
digest.update(buffer);
length = Math.addExact(length, count);
}
buffer.clear();
}
String actual = HexFormat.of().formatHex(digest.digest());
if (length != reference.length() || !MessageDigest.isEqual(actual.getBytes(StandardCharsets.US_ASCII),
reference.sha256().getBytes(StandardCharsets.US_ASCII))) {
throw new IOException("Staged content integrity failed: code=CONTENT_INTEGRITY_FAILED");
}
}
/** Read stream retaining the verified file identity through its open descriptor. */
private static final class VerifiedChannelInputStream extends InputStream {
private final FileChannel channel;
private final InputStream delegate;
private VerifiedChannelInputStream(Path path, DurableContentReference reference) throws IOException {
super();
channel = FileChannel.open(path, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS);
boolean initialized = false;
try {
requireDigest(channel, reference);
channel.position(0L);
delegate = Channels.newInputStream(channel);
initialized = true;
} finally {
if (!initialized) {
closeFailedChannel(channel);
}
}
}
@Override
public int read() throws IOException {
return delegate.read();
}
@Override
public int read(byte[] bytes, int offset, int length) throws IOException {
return delegate.read(bytes, offset, length);
}
@Override
public void close() throws IOException {
channel.close();
}
private static void closeFailedChannel(FileChannel failedChannel) throws IOException {
try (FileChannel ignored = failedChannel) {
// Transfer to try-with-resources solely for failed construction cleanup.
}
}
}
private Path resolveOwned(String fileName) {
Path resolved = root.resolve(fileName).normalize();
if (!root.equals(resolved.getParent())) {
throw new IllegalArgumentException("Staged content identifier escapes its store");
}
return resolved;
}
private static boolean isRegularFile(Path path) {
return Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(path);
}
private static String requireStoreIdentifier(String value) {
String exact = Objects.requireNonNull(value, "storeId");
if (!exact.matches("[0-9a-f]{32}")) {
throw new IllegalArgumentException("Staged content store identifier is not canonical");
}
return exact;
}
private static void requireContentIdentifier(String contentId) {
requireCanonicalUuid(contentId, true);
}
/** Store-branded immutable reference; arbitrary interface implementations are rejected. */
/** Mechanical home for filesystem metadata branches kept outside the store coordinator. */
private static final class StoreIo {
private static void recoverContent(FilesystemStagedContentStore store, TemporaryUniqueIndex retained,
TemporaryUniqueIndex retainedOwners) throws IOException {
Objects.requireNonNull(retained, "retained");
Objects.requireNonNull(retainedOwners, "retainedOwners");
retained.validateNamespace();
retainedOwners.validateNamespace();
try (java.util.stream.Stream<Path> paths = Files.list(store.root)) {
java.util.Iterator<Path> iterator = paths
.filter(path -> path.getFileName().toString().endsWith(".meta")).iterator();
while (iterator.hasNext()) {
Path metadata = iterator.next();
String name = metadata.getFileName().toString();
String contentId = name.substring(0, name.length() - ".meta".length());
recoverContent(store, retained, retainedOwners, contentId);
}
}
store.cleanupOrphanedCompletedFiles();
store.cleanupOrphanedOwnerFiles();
}
private static void recoverContent(FilesystemStagedContentStore store, TemporaryUniqueIndex retained,
TemporaryUniqueIndex retainedOwners, String contentId) throws IOException {
ReentrantLock lock = store.ownerLock(contentId);
lock.lock();
try {
DurableContentReference reference = store.readMetadata(contentId);
Set<DurableContentOwner> owners = store.readOwners(contentId);
owners.removeIf(owner -> !containsOwner(retainedOwners, owner));
if (!owners.isEmpty()) {
store.writeOwners(contentId, owners);
}
boolean referenced = retained.contains(contentId.getBytes(StandardCharsets.US_ASCII));
boolean keep = reference.lifecycle() != DurableContentReference.Lifecycle.TEMPORARY
&& (referenced || !owners.isEmpty());
if (!keep || !isRegularFile(store.completePath(reference))) {
store.retireFiles(reference);
}
} finally {
lock.unlock();
}
}
private static void cleanupAbandonedTemporaryState(FilesystemStagedContentStore store) throws IOException {
try (java.util.stream.Stream<Path> paths = Files.list(store.root)) {
java.util.Iterator<Path> iterator = paths.iterator();
while (iterator.hasNext()) {
Path path = iterator.next();
String name = path.getFileName().toString();
if (name.endsWith(".incomplete")) {
Files.deleteIfExists(path);
} else if (name.endsWith(".unique-index")
&& Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
deleteIndex(path);
}
}
}
}
private static void cleanupOrphanedCompletedFiles(FilesystemStagedContentStore store) throws IOException {
try (java.util.stream.Stream<Path> paths = Files.list(store.root)) {
java.util.Iterator<Path> iterator = paths
.filter(path -> path.getFileName().toString().endsWith(".content")).iterator();
while (iterator.hasNext()) {
Path content = iterator.next();
String name = content.getFileName().toString();
String contentId = name.substring(0, name.length() - ".content".length());
if (!isRegularFile(store.metadataPath(contentId))) {
Files.deleteIfExists(content);
}
}
}
}
private static void cleanupOrphanedOwnerFiles(FilesystemStagedContentStore store) throws IOException {
try (java.util.stream.Stream<Path> paths = Files.list(store.root)) {
java.util.Iterator<Path> iterator = paths
.filter(path -> path.getFileName().toString().endsWith(".owners")).iterator();
while (iterator.hasNext()) {
Path owners = iterator.next();
String name = owners.getFileName().toString();
String contentId = name.substring(0, name.length() - ".owners".length());
if (!isRegularFile(store.metadataPath(contentId))) {
DurableMetadataFiles.delete(owners);
}
}
}
}
private static void deleteIndex(Path directory) throws IOException {
try (java.util.stream.Stream<Path> paths = Files.list(directory)) {
java.util.Iterator<Path> iterator = paths.iterator();
while (iterator.hasNext()) {
Files.deleteIfExists(iterator.next());
}
}
Files.deleteIfExists(directory);
}
private static DurableContentReference requireOwned(FilesystemStagedContentStore store,
DurableContentReference reference) {
DurableContentReference exact = Objects.requireNonNull(reference, "reference");
if (!store.storeId.equals(exact.storeId())) {
throw new IllegalArgumentException("Staged content belongs to another store");
}
if (!(exact instanceof StoreReference)) {
throw new IllegalArgumentException("Staged content reference was not issued by this store");
}
return exact;
}
private static DurableContentReference readMetadata(FilesystemStagedContentStore store, String contentId)
throws IOException {
Path metadata = store.metadataPath(contentId);
if (!isRegularFile(metadata)) {
throw new IOException("Staged content metadata missing: code=STAGED_CONTENT_INCOMPLETE");
}
try (DataInputStream input = new DataInputStream(Channels.newInputStream(
FileChannel.open(metadata, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)))) {
int version = input.readUnsignedByte();
if (version != METADATA_VERSION) {
throw new IOException("Unsupported staged content metadata");
}
Encoding encoding = Encoding.valueOf(input.readUTF());
DurableContentReference.Lifecycle lifecycle = DurableContentReference.Lifecycle.valueOf(
input.readUTF());
long length = input.readLong();
String digest = input.readUTF();
if (input.read() >= 0) {
throw new IOException("Trailing staged content metadata");
}
return new StoreReference(store.storeId, contentId, encoding, length, digest, lifecycle);
} catch (IllegalArgumentException exception) {
throw new IOException("Malformed staged content metadata", exception);
}
}
private static void requireExactMetadata(FilesystemStagedContentStore store,
DurableContentReference exact) throws IOException {
DurableContentReference persisted = store.readMetadata(exact.contentId());
if (!persisted.equals(exact)) {
throw new IOException("Staged content metadata mismatch: code=CONTENT_INTEGRITY_FAILED");
}
if (!isRegularFile(store.completePath(exact))
|| Files.size(store.completePath(exact)) != exact.length()) {
throw new IOException("Staged content integrity failed: code=CONTENT_INTEGRITY_FAILED");
}
}
private static Set<DurableContentOwner> readOwners(FilesystemStagedContentStore store, String contentId)
throws IOException {
Path ownersPath = store.ownerPath(contentId);
try {
return DurableMetadataFiles.read(ownersPath, MAX_OWNER_RECORD_BYTES,
input -> decodeOwners(input, contentId));
} catch (java.nio.file.NoSuchFileException missing) {
return new LinkedHashSet<>();
} catch (IllegalArgumentException exception) {
throw new IOException("Malformed staged content owner metadata", exception);
}
}
private static Set<DurableContentOwner> decodeOwners(DataInputStream input, String contentId)
throws IOException {
Set<DurableContentOwner> owners = new LinkedHashSet<>();
if (input.readUnsignedByte() != OWNER_METADATA_VERSION) {
throw new IOException("Unsupported staged content owner metadata");
}
if (!contentId.equals(input.readUTF())) {
throw new IOException("Staged content owner metadata identity mismatch");
}
int count = input.readInt();
if (count < 0 || count > MAX_OWNER_COUNT) {
throw new IOException("Invalid staged content owner count");
}
for (int index = 0; index < count; index++) {
DurableContentOwner owner = parseOwner(input.readUTF());
if (!owners.add(owner)) {
throw new IOException("Duplicate staged content owner");
}
}
return owners;
}
private static void writeOwners(FilesystemStagedContentStore store, String contentId,
Set<DurableContentOwner> owners) throws IOException {
if (owners.size() > MAX_OWNER_COUNT) {
throw new IOException("Staged content owner count exceeds metadata capability");
}
Path complete = store.ownerPath(contentId);
java.util.List<String> encoded = owners.stream().map(DurableContentOwner::canonicalForm).sorted()
.toList();
DurableMetadataFiles.replace(complete, MAX_OWNER_RECORD_BYTES, output -> {
output.writeByte(OWNER_METADATA_VERSION);
output.writeUTF(contentId);
output.writeInt(encoded.size());
for (String value : encoded) {
output.writeUTF(value);
}
});
}
private static DurableContentOwner parseOwner(String encoded) {
int separator = encoded.indexOf(':');
if (separator <= 0 || separator == encoded.length() - 1) {
throw new IllegalArgumentException("Malformed durable content owner");
}
DurableContentOwner.Category category = DurableContentOwner.Category.valueOf(
encoded.substring(0, separator));
return new DurableContentOwner(category, encoded.substring(separator + 1));
}
private static boolean containsOwner(TemporaryUniqueIndex retainedOwners, DurableContentOwner owner) {
try {
return retainedOwners.contains(owner.canonicalForm().getBytes(StandardCharsets.UTF_8));
} catch (IOException exception) {
throw new java.io.UncheckedIOException(exception);
}
}
private static void writeMetadata(FilesystemStagedContentStore store, DurableContentReference reference)
throws IOException {
Path incomplete = store.resolveOwned(reference.contentId() + ".meta.incomplete");
Path complete = store.metadataPath(reference.contentId());
Files.createFile(incomplete);
restrict(incomplete);
try (DataOutputStream output = new DataOutputStream(Files.newOutputStream(incomplete))) {
output.writeByte(METADATA_VERSION);
output.writeUTF(reference.encoding().name());
output.writeUTF(reference.lifecycle().name());
output.writeLong(reference.length());
output.writeUTF(reference.sha256());
}
try (FileChannel channel = FileChannel.open(incomplete, StandardOpenOption.READ)) {
channel.force(true);
}
try {
Files.move(incomplete, complete, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException exception) {
Files.move(incomplete, complete);
}
}
}
private record StoreReference(String storeId, String contentId, Encoding encoding, long length, String sha256,
DurableContentReference.Lifecycle lifecycle) implements DurableContentReference {
private StoreReference {
requireStoreIdentifier(storeId);
requireContentIdentifier(contentId);
Objects.requireNonNull(encoding, "encoding");
Objects.requireNonNull(lifecycle, "lifecycle");
if (length < MINIMUM_CONTENT_LENGTH) {
throw new IllegalArgumentException("Content length must not be negative");
}
String exactDigest = Objects.requireNonNull(sha256, "sha256");
if (!exactDigest.matches("[0-9a-f]{64}")) {
throw new IllegalArgumentException("Content integrity value must be canonical SHA-256");
}
}
}
private static void requireCanonicalUuid(String value, boolean requireRandomVersion) {
UUID parsed;
try {
parsed = UUID.fromString(value);
} catch (IllegalArgumentException exception) {
throw new IllegalArgumentException("Staged content identifier is not canonical UUID text", exception);
}
if (!parsed.toString().equals(value) || parsed.variant() != 2 || requireRandomVersion && parsed.version() != 4) {
throw new IllegalArgumentException("Staged content identifier is not store-issued");
}
}
}

View File

@@ -0,0 +1,305 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.impl.fs;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.HexFormat;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.ReentrantLock;
import zeroecho.pki.api.content.DurableContentOwner;
import zeroecho.pki.spi.store.TemporaryUniqueIndex;
/** File-backed exact set using fixed-length, self-verifying physical keys. */
final class FilesystemTemporaryUniqueIndex implements TemporaryUniqueIndex {
private static final int MAX_VALUE_BYTES = 640;
private static final int RECORD_VERSION = 1;
private static final int MAX_RECORD_BYTES = 1 + Integer.BYTES + MAX_VALUE_BYTES;
private static final int SHA256_INDEX_KEY_HEX_CHARACTERS = 64;
private static final byte[] GENERAL_DOMAIN =
"zeroecho:pki:temporary-unique-index:v2".getBytes(StandardCharsets.US_ASCII);
private static final byte[] OWNER_DOMAIN =
"zeroecho:pki:durable-content-owner-index:v1".getBytes(StandardCharsets.US_ASCII);
private final Path directory;
private final byte[] domain;
private final AtomicBoolean closed = new AtomicBoolean();
private final ReentrantLock lock = new ReentrantLock();
private FilesystemTemporaryUniqueIndex(Path directory, byte[] domain) {
this.directory = Objects.requireNonNull(directory, "directory");
this.domain = domain.clone();
}
/* default */ static FilesystemTemporaryUniqueIndex general(Path directory) {
return new FilesystemTemporaryUniqueIndex(directory, GENERAL_DOMAIN);
}
/* default */ static FilesystemTemporaryUniqueIndex owners(Path directory) {
return new FilesystemTemporaryUniqueIndex(directory, OWNER_DOMAIN);
}
/* default */ static String ownerKey(DurableContentOwner owner) {
Objects.requireNonNull(owner, "owner");
return key(OWNER_DOMAIN, owner.canonicalForm().getBytes(StandardCharsets.UTF_8));
}
@Override
public boolean add(byte[] value) throws IOException {
requireValue(value);
Path entry = directory.resolve(key(domain, value));
lock.lock();
try {
try {
requireRecord(entry, value);
return false;
} catch (NoSuchFileException missing) {
writeRecord(entry, value);
return true;
}
} finally {
lock.unlock();
}
}
@Override
public boolean contains(byte[] value) throws IOException {
requireValue(value);
Path entry = directory.resolve(key(domain, value));
lock.lock();
try {
try {
requireRecord(entry, value);
return true;
} catch (NoSuchFileException missing) {
return false;
}
} finally {
lock.unlock();
}
}
@Override
public boolean remove(byte[] value) throws IOException {
requireValue(value);
Path entry = directory.resolve(key(domain, value));
lock.lock();
try {
try {
requireRecord(entry, value);
} catch (NoSuchFileException missing) {
return false;
}
DurableMetadataFiles.delete(entry);
return true;
} finally {
lock.unlock();
}
}
@Override
public void validateNamespace() throws IOException {
requireOpen();
lock.lock();
try {
for (Path entry : DurableMetadataFiles.list(directory, "")) {
requireCommittedName(entry);
byte[] value = readRecord(entry);
try {
requireCanonicalOwner(value);
String actualKey = entry.getFileName().toString();
if (!actualKey.equals(key(domain, value))) {
throw integrityFailure();
}
} finally {
Arrays.fill(value, (byte) 0);
}
}
} finally {
lock.unlock();
}
}
@Override
public void close() throws IOException {
lock.lock();
try {
if (!closed.compareAndSet(false, true)) {
return;
}
for (Path entry : DurableMetadataFiles.list(directory, "")) {
DurableMetadataFiles.delete(entry);
}
Files.deleteIfExists(directory);
} finally {
lock.unlock();
}
}
private void requireValue(byte[] value) throws IOException {
Objects.requireNonNull(value, "value");
requireOpen();
if (value.length == 0 || value.length > MAX_VALUE_BYTES) {
throw new IOException("Index element exceeds filesystem adapter capability");
}
}
private void requireOpen() {
if (closed.get()) {
throw new IllegalStateException("Temporary uniqueness index is closed");
}
}
private static void writeRecord(Path entry, byte[] value) throws IOException {
DurableMetadataFiles.create(entry, MAX_RECORD_BYTES, output -> {
output.writeByte(RECORD_VERSION);
output.writeInt(value.length);
output.write(value);
});
}
private static void requireRecord(Path entry, byte[] expected) throws IOException {
byte[] actual = readRecord(entry);
try {
if (!MessageDigest.isEqual(actual, expected)) {
throw integrityFailure();
}
} finally {
Arrays.fill(actual, (byte) 0);
}
}
private static byte[] readRecord(Path entry) throws IOException {
return DurableMetadataFiles.read(entry, MAX_RECORD_BYTES, input -> {
if (input.readUnsignedByte() != RECORD_VERSION) {
throw new IOException("Unsupported uniqueness index record");
}
int length = input.readInt();
if (length <= 0 || length > MAX_VALUE_BYTES) {
throw new IOException("Invalid uniqueness index record length");
}
byte[] actual = input.readNBytes(length);
if (actual.length != length || input.read() >= 0) {
Arrays.fill(actual, (byte) 0);
throw integrityFailure();
}
return actual;
});
}
private static void requireCommittedName(Path entry) throws IOException {
String name = entry.getFileName().toString();
if (name.length() != SHA256_INDEX_KEY_HEX_CHARACTERS) {
throw integrityFailure();
}
for (int index = 0; index < name.length(); index++) {
char current = name.charAt(index);
if (!((current >= '0' && current <= '9') || (current >= 'a' && current <= 'f'))) {
throw integrityFailure();
}
}
}
private void requireCanonicalOwner(byte[] value) throws IOException {
if (!Arrays.equals(domain, OWNER_DOMAIN)) {
return;
}
String encoded;
try {
encoded = StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT).decode(ByteBuffer.wrap(value)).toString();
} catch (CharacterCodingException exception) {
throw integrityFailure(exception);
}
int separator = encoded.indexOf(':');
try {
if (separator <= 0 || separator == encoded.length() - 1) {
throw integrityFailure();
}
DurableContentOwner.Category category = DurableContentOwner.Category.valueOf(
encoded.substring(0, separator));
DurableContentOwner owner = new DurableContentOwner(category, encoded.substring(separator + 1));
if (!MessageDigest.isEqual(value, owner.canonicalForm().getBytes(StandardCharsets.UTF_8))) {
throw integrityFailure();
}
} catch (IllegalArgumentException exception) {
throw integrityFailure(exception);
}
}
private static IOException integrityFailure() {
return new IOException("Uniqueness index collision or substitution: code=CONTENT_INTEGRITY_FAILED");
}
private static IOException integrityFailure(Throwable cause) {
return new IOException("Uniqueness index collision or substitution: code=CONTENT_INTEGRITY_FAILED", cause);
}
private static String key(byte[] domain, byte[] value) {
MessageDigest digest = sha256();
updateLength(digest, domain.length);
digest.update(domain);
updateLength(digest, value.length);
digest.update(value);
return HexFormat.of().formatHex(digest.digest());
}
private static void updateLength(MessageDigest digest, int length) {
digest.update((byte) (length >>> 24));
digest.update((byte) (length >>> 16));
digest.update((byte) (length >>> 8));
digest.update((byte) length);
}
private static MessageDigest sha256() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 is unavailable", exception);
}
}
}

View File

@@ -68,6 +68,7 @@ import zeroecho.pki.api.credential.CaProfileBinding;
import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.credential.CredentialProfileBinding;
import zeroecho.pki.api.credential.CredentialStatus;
import zeroecho.pki.api.content.DurableContentReference;
import zeroecho.pki.api.credential.EndEntityProfileBinding;
import zeroecho.pki.api.orch.OrchestrationDurabilityPolicy;
import zeroecho.pki.api.orch.WorkflowStateRecord;
@@ -92,6 +93,7 @@ import zeroecho.pki.api.revocation.RevocationState;
import zeroecho.pki.api.revocation.RevocationTransition;
import zeroecho.pki.api.status.StatusObject;
import zeroecho.pki.api.status.StatusObjectType;
import zeroecho.pki.spi.store.StagedContentStore;
import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
import zeroecho.pki.spi.store.SignWorkflowStore;
@@ -118,7 +120,7 @@ import zeroecho.pki.spi.store.SignWorkflowStore;
final class FsCodec {
/* package */ static final int MAX_COMPONENT_BYTES = 256 * 1024;
/* package */ static final int CURRENT_CODEC_VERSION = 2;
/* package */ static final int CURRENT_CODEC_VERSION = 3;
private static final int CODEC_MAGIC = 0x5A454346;
private static final int MAX_COLLECTION_ELEMENTS = MAX_COMPONENT_BYTES;
@@ -132,6 +134,7 @@ final class FsCodec {
private static final int TOP_POLICY_TRACE = 8;
private static final int TOP_WORKFLOW_STATE = 9;
private static final int TOP_SIGN_WORKFLOW_RECORD = 10;
private static final int DURABLE_CONTENT_VERSION = 1;
private static final int TOP_PROFILE_VERSION = 11;
private static final int TOP_ACTIVE_PROFILE_REF = 12;
@@ -174,6 +177,7 @@ final class FsCodec {
private static final int TYPE_SAN = 66;
private static final int TYPE_PROFILE_REF = 72;
private static final int TYPE_PROFILE_BINDING = 73;
private static final int TYPE_DURABLE_CONTENT = 74;
private static final int ATTRIBUTE_STRING = 1;
private static final int ATTRIBUTE_BOOLEAN = 2;
@@ -392,6 +396,8 @@ final class FsCodec {
}, reader -> new Validity(reader.readValue(INSTANT), reader.readValue(INSTANT)));
private static final ValueSchema<EncodedObject> ENCODED_OBJECT = valueSchema(TYPE_ENCODED_OBJECT,
FsCodec::writeEncodedObject, FsCodec::readEncodedObject);
private static final ValueSchema<DurableContentReference> DURABLE_CONTENT = valueSchema(TYPE_DURABLE_CONTENT,
FsCodec::writeDurableContent, FsCodec::readDurableContent);
private static final ValueSchema<Principal> PRINCIPAL = valueSchema(TYPE_PRINCIPAL, (writer, value) -> {
writer.writeValue(STRING, value.type());
writer.writeValue(STRING, value.name());
@@ -495,39 +501,48 @@ final class FsCodec {
}
/* package */ static <T> T decode(final Schema<T> schema, final byte[] encoded) {
return decode(schema, encoded, null);
}
/* package */ static <T> T decode(final Schema<T> schema, final byte[] encoded,
final StagedContentStore stagedContent) {
Objects.requireNonNull(schema, "schema");
Objects.requireNonNull(encoded, "encoded");
try {
return decodeCurrentPayload(schema, encoded);
return PayloadDecoder.decode(schema, encoded, stagedContent);
} catch (IOException | IllegalArgumentException ex) {
throw new IllegalStateException("Decoding failed: schema=" + schema.name + " code=INVALID_CURRENT_PAYLOAD",
ex);
}
}
private static <T> T decodeCurrentPayload(Schema<T> schema, byte[] encoded) throws IOException {
ByteArrayInputStream input = new ByteArrayInputStream(encoded);
Reader reader = new Reader(input);
if (reader.readInt() != CODEC_MAGIC) {
throw new IOException("codec magic mismatch");
/** Strict current-schema payload decoder separated from the schema registry. */
private static final class PayloadDecoder {
private static <T> T decode(Schema<T> schema, byte[] encoded, StagedContentStore stagedContent)
throws IOException {
ByteArrayInputStream input = new ByteArrayInputStream(encoded);
Reader reader = new Reader(input, stagedContent);
if (reader.readInt() != CODEC_MAGIC) {
throw new IOException("codec magic mismatch");
}
int version = reader.readUnsignedByte();
if (version != CURRENT_CODEC_VERSION) {
throw new IOException("unsupported codec version");
}
int typeId = reader.readUnsignedByte();
Schema<?> encodedSchema = TOP_LEVEL_SCHEMAS.get(typeId);
if (encodedSchema == null) {
throw new IOException("unknown top-level type");
}
if (encodedSchema.typeId != schema.typeId) {
throw new IOException("top-level type mismatch");
}
T decoded = schema.valueSchema.decoder.decode(reader);
if (input.available() != 0) {
throw new IOException("trailing payload data");
}
return decoded;
}
int version = reader.readUnsignedByte();
if (version != CURRENT_CODEC_VERSION) {
throw new IOException("unsupported codec version");
}
int typeId = reader.readUnsignedByte();
Schema<?> encodedSchema = TOP_LEVEL_SCHEMAS.get(typeId);
if (encodedSchema == null) {
throw new IOException("unknown top-level type");
}
if (encodedSchema.typeId != schema.typeId) {
throw new IOException("top-level type mismatch");
}
T decoded = schema.valueSchema.decoder.decode(reader);
if (input.available() != 0) {
throw new IOException("trailing payload data");
}
return decoded;
}
private static void writeEncodedObject(Writer writer, EncodedObject value) throws IOException {
@@ -550,6 +565,35 @@ final class FsCodec {
}
}
private static void writeDurableContent(Writer writer, DurableContentReference content) throws IOException {
writer.writeUnsignedByte(DURABLE_CONTENT_VERSION);
writer.writeValue(STRING, content.storeId());
writer.writeValue(STRING, content.contentId());
writer.writeValue(ENCODING, content.encoding());
writer.writeValue(LONG, content.length());
writer.writeValue(STRING, content.sha256());
writer.writeValue(STRING, content.lifecycle().name());
}
private static DurableContentReference readDurableContent(Reader reader) throws IOException {
int version = reader.readUnsignedByte();
if (version != DURABLE_CONTENT_VERSION) {
throw new IOException("unsupported durable content reference version");
}
String storeId = reader.readValue(STRING);
String contentId = reader.readValue(STRING);
Encoding encoding = reader.readValue(ENCODING);
long length = reader.readValue(LONG);
String sha256 = reader.readValue(STRING);
DurableContentReference.Lifecycle lifecycle;
try {
lifecycle = DurableContentReference.Lifecycle.valueOf(reader.readValue(STRING));
} catch (IllegalArgumentException exception) {
throw new IOException("unknown durable content lifecycle", exception);
}
return reader.restoreReference(storeId, contentId, encoding, length, sha256, lifecycle);
}
private static void writeAttributeValue(Writer writer, AttributeValue value) throws IOException {
switch (value) {
case AttributeValue.StringValue stringValue -> {
@@ -629,7 +673,7 @@ final class FsCodec {
writer.writeValue(PKI_ID, value.publicKeyId());
writer.writeValue(PROFILE_BINDING, value.profileBinding());
writer.writeValue(CREDENTIAL_STATUS, value.status());
writer.writeValue(ENCODED_OBJECT, value.encoded());
writer.writeValue(DURABLE_CONTENT, value.content());
writer.writeValue(ATTRIBUTE_SET, value.attributes());
}
@@ -637,7 +681,7 @@ final class FsCodec {
return new Credential(reader.readValue(PKI_ID), reader.readValue(FORMAT_ID), reader.readValue(ISSUER_REF),
reader.readValue(SUBJECT_REF), reader.readValue(VALIDITY), reader.readValue(STRING),
reader.readValue(PKI_ID), reader.readValue(PROFILE_BINDING), reader.readValue(CREDENTIAL_STATUS),
reader.readValue(ENCODED_OBJECT), reader.readValue(ATTRIBUTE_SET));
reader.readValue(DURABLE_CONTENT), reader.readValue(ATTRIBUTE_SET));
}
private static void writeProfileBinding(Writer writer, CredentialProfileBinding value) throws IOException {
@@ -731,14 +775,20 @@ final class FsCodec {
writer.writeValue(STATUS_OBJECT_TYPE, value.type());
writer.writeValue(INSTANT, value.thisUpdate());
writer.writeValue(OPTIONAL_INSTANT, value.nextUpdate());
writer.writeValue(ENCODED_OBJECT, value.encoded());
writer.writeValue(DURABLE_CONTENT, value.content());
writer.writeValue(ATTRIBUTE_SET, value.attributes());
}
private static StatusObject readStatusObject(Reader reader) throws IOException {
return new StatusObject(reader.readValue(PKI_ID), reader.readValue(FORMAT_ID), reader.readValue(PKI_ID),
reader.readValue(STATUS_OBJECT_TYPE), reader.readValue(INSTANT), reader.readValue(OPTIONAL_INSTANT),
reader.readValue(ENCODED_OBJECT), reader.readValue(ATTRIBUTE_SET));
PkiId statusObjectId = reader.readValue(PKI_ID);
FormatId formatId = reader.readValue(FORMAT_ID);
PkiId issuerCaId = reader.readValue(PKI_ID);
StatusObjectType type = reader.readValue(STATUS_OBJECT_TYPE);
Instant thisUpdate = reader.readValue(INSTANT);
Optional<Instant> nextUpdate = reader.readValue(OPTIONAL_INSTANT);
DurableContentReference content = reader.readValue(DURABLE_CONTENT);
return new StatusObject(statusObjectId, formatId, issuerCaId, type, thisUpdate, nextUpdate, content,
reader.readValue(ATTRIBUTE_SET));
}
private static void writePublication(Writer writer, PublicationRecord value) throws IOException {
@@ -798,10 +848,11 @@ final class FsCodec {
}
private static int toInt(long value) throws IOException {
if (value < Integer.MIN_VALUE || value > Integer.MAX_VALUE) {
throw new IOException("integer value out of range");
try {
return Math.toIntExact(value);
} catch (ArithmeticException exception) {
throw new IOException("integer value out of range", exception);
}
return (int) value;
}
private static void writePolicyTrace(Writer writer, PolicyTrace value) throws IOException {
@@ -1073,9 +1124,19 @@ final class FsCodec {
private static final class Reader {
private final InputStream input;
private final StagedContentStore stagedContent;
private Reader(InputStream input) {
private Reader(InputStream input, StagedContentStore stagedContent) {
this.input = input;
this.stagedContent = stagedContent;
}
private DurableContentReference restoreReference(String storeId, String contentId, Encoding encoding,
long length, String sha256, DurableContentReference.Lifecycle lifecycle) throws IOException {
if (stagedContent == null) {
throw new IOException("durable content reference requires owning staged-content store");
}
return stagedContent.restoreReference(storeId, contentId, encoding, length, sha256, lifecycle);
}
private <T> T readValue(ValueSchema<T> schema) throws IOException {

View File

@@ -90,6 +90,10 @@ final class FsPaths {
return this.root.resolve("SIGNING_TIME_WATERMARK");
}
/* default */ Path stagedContentRoot() {
return this.root.resolve("staged-content");
}
/* default */ Path lockFile() {
return this.root.resolve(LOCK_DIR).resolve(STORE_LOCK);
}
@@ -175,6 +179,10 @@ final class FsPaths {
return revocationDir(credentialId).resolve("journal.bin");
}
/* default */ Path revocationSnapshotRoot() {
return this.root.resolve("revocation-snapshots");
}
// -------------------------------------------------------------------------
// Status objects (immutable .bin)
// -------------------------------------------------------------------------

View File

@@ -113,6 +113,7 @@ final class FsSnapshotExporter {
copyTreeIfExists(sourceRoot.resolve("policy"), targetRoot.resolve("policy"));
copyTreeIfExists(sourceRoot.resolve("publications"), targetRoot.resolve("publications"));
copyTreeIfExists(sourceRoot.resolve("sign-workflows"), targetRoot.resolve("sign-workflows"));
copyTreeIfExists(sourceRoot.resolve("staged-content"), targetRoot.resolve("staged-content"));
copyTreeIfExists(sourceRoot.resolve("revocations"), targetRoot.resolve("revocations"));
copyImportedProfilesAsOf(profiles, targetRoot.resolve("profiles"));

View File

@@ -0,0 +1,445 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.impl.fs;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.channels.SeekableByteChannel;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.HexFormat;
import java.util.Objects;
import java.util.OptionalLong;
import zeroecho.core.io.CancellationSignal;
import zeroecho.core.io.RepeatableContent;
/** Strict streaming codec for one structural metadata-log frame. */
final class MetadataFrameCodec {
private static final int FRAME_MAGIC = 0x5A454D46;
private static final short SCHEMA_VERSION = 1;
private static final byte RESERVED_FLAGS = 0;
private static final int TRANSACTION_TOKEN_BYTES = 16;
private static final int HEADER_FIELDS_BYTES = 40;
private static final int SHA_256_BYTES = 32;
private static final int FIXED_HEADER_BYTES = HEADER_FIELDS_BYTES + SHA_256_BYTES;
private static final int TRANSFER_BUFFER_BYTES = 16 * 1024;
private static final long MINIMUM_FRAME_OFFSET = 0L;
private static final long MINIMUM_PAYLOAD_LENGTH = 0L;
private static final long MINIMUM_SEQUENCE_NUMBER = 0L;
private static final HexFormat LOWERCASE_HEX = HexFormat.of();
private final OptionalLong maximumPayloadLength;
/* default */ MetadataFrameCodec(OptionalLong maximumPayloadLength) {
this.maximumPayloadLength = Objects.requireNonNull(maximumPayloadLength, "maximumPayloadLength");
if (maximumPayloadLength.isPresent() && maximumPayloadLength.getAsLong() < 0L) {
throw new IllegalArgumentException("Maximum metadata-frame payload length must be non-negative");
}
}
/* default */ MetadataFrameCodec() {
this(OptionalLong.empty());
}
/*
* The authenticated header is validated before payloadLength is used for
* positioning or allocation. A tampered length is therefore corruption, not
* an apparently incomplete tail selected by unauthenticated input.
*/
/* default */ ReadResult read(SeekableByteChannel channel, long frameOffset) throws IOException {
Objects.requireNonNull(channel, "channel");
if (frameOffset < MINIMUM_FRAME_OFFSET) {
throw new IllegalArgumentException("Metadata-frame offset must be non-negative");
}
ByteBuffer header = ByteBuffer.allocate(FIXED_HEADER_BYTES).order(ByteOrder.BIG_ENDIAN);
channel.position(frameOffset);
int headerBytes = readAvailable(channel, header);
if (headerBytes == 0) {
return ReadResult.endOfInput();
}
if (headerBytes < FIXED_HEADER_BYTES) {
return ReadResult.incompleteTail();
}
return decodeCompleteHeader(channel, frameOffset, header.array());
}
private ReadResult decodeCompleteHeader(
SeekableByteChannel channel, long frameOffset, byte[] encodedHeader) throws IOException {
ByteBuffer fields = ByteBuffer.wrap(encodedHeader).order(ByteOrder.BIG_ENDIAN);
if (fields.getInt() != FRAME_MAGIC) {
return ReadResult.corruptFrame();
}
byte[] suppliedHeaderDigest = Arrays.copyOfRange(
encodedHeader, HEADER_FIELDS_BYTES, FIXED_HEADER_BYTES);
MessageDigest headerDigest = newSha256();
headerDigest.update(encodedHeader, 0, HEADER_FIELDS_BYTES);
if (!MessageDigest.isEqual(headerDigest.digest(), suppliedHeaderDigest)) {
return ReadResult.corruptFrame();
}
short version = fields.getShort();
byte typeCode = fields.get();
byte flags = fields.get();
byte[] transactionTokenBytes = new byte[TRANSACTION_TOKEN_BYTES];
fields.get(transactionTokenBytes);
long sequence = fields.getLong();
long payloadLength = fields.getLong();
FrameType frameType = FrameType.fromWireCode(typeCode);
if (headerFieldsAreInvalid(version, frameType, flags, sequence, payloadLength)) {
return ReadResult.corruptFrame();
}
final long payloadOffset;
final long payloadDigestOffset;
final long frameEndOffset;
try {
// Checked arithmetic prevents a validly authenticated hostile length
// from wrapping frame boundaries into an earlier part of the log.
payloadOffset = Math.addExact(frameOffset, FIXED_HEADER_BYTES);
payloadDigestOffset = Math.addExact(payloadOffset, payloadLength);
frameEndOffset = Math.addExact(payloadDigestOffset, SHA_256_BYTES);
} catch (ArithmeticException overflow) {
return ReadResult.corruptFrame();
}
ReadClassification payloadClassification = classifyPayloadAndDigest(channel, payloadLength);
if (payloadClassification != ReadClassification.COMPLETE_FRAME) {
return ReadResult.forClassification(payloadClassification);
}
FrameMetadata metadata = new FrameMetadata(
frameType,
LOWERCASE_HEX.formatHex(transactionTokenBytes),
sequence,
frameOffset,
payloadOffset,
payloadLength,
payloadDigestOffset,
frameEndOffset);
return ReadResult.completeFrame(metadata);
}
private boolean headerFieldsAreInvalid(
short version, FrameType frameType, byte flags, long sequence, long payloadLength) {
return version != SCHEMA_VERSION || frameType == null || flags != RESERVED_FLAGS
|| sequence < MINIMUM_SEQUENCE_NUMBER || payloadLength < MINIMUM_PAYLOAD_LENGTH
|| exceedsTechnicalLimit(payloadLength);
}
private static ReadClassification classifyPayloadAndDigest(
SeekableByteChannel channel, long payloadLength) throws IOException {
MessageDigest payloadDigest = newSha256();
byte[] transferBuffer = new byte[TRANSFER_BUFFER_BYTES];
long remaining = payloadLength;
try {
while (remaining > 0L) {
int requested = (int) Math.min((long) transferBuffer.length, remaining);
ByteBuffer destination = ByteBuffer.wrap(transferBuffer, 0, requested);
int read = readAvailable(channel, destination);
if (read < requested) {
return ReadClassification.INCOMPLETE_TAIL;
}
payloadDigest.update(transferBuffer, 0, read);
remaining -= read;
}
ByteBuffer footer = ByteBuffer.allocate(SHA_256_BYTES);
int footerBytes = readAvailable(channel, footer);
if (footerBytes < SHA_256_BYTES) {
return ReadClassification.INCOMPLETE_TAIL;
}
if (!MessageDigest.isEqual(payloadDigest.digest(), footer.array())) {
return ReadClassification.CORRUPT_FRAME;
}
} finally {
Arrays.fill(transferBuffer, (byte) 0);
}
return ReadClassification.COMPLETE_FRAME;
}
/*
* Failed writes may leave a partial frame at the channel's current position.
* Truncation and rollback belong to the later log writer, not this codec.
* Payload processing uses one fixed buffer, so auxiliary memory is O(1).
*/
/* default */ FrameMetadata write(
SeekableByteChannel channel,
FrameType frameType,
String transactionToken,
long sequence,
long declaredPayloadLength,
RepeatableContent content,
CancellationSignal cancellation) throws IOException {
Objects.requireNonNull(channel, "channel");
Objects.requireNonNull(frameType, "frameType");
Objects.requireNonNull(content, "content");
Objects.requireNonNull(cancellation, "cancellation");
byte[] transactionTokenBytes = decodeTransactionToken(transactionToken);
requirePayloadLength(declaredPayloadLength);
OptionalLong knownLength = content.length();
if (knownLength.isPresent() && knownLength.getAsLong() != declaredPayloadLength) {
throw new IOException("Declared metadata-frame payload length does not match content length");
}
long frameOffset = channel.position();
final long payloadOffset;
final long payloadDigestOffset;
final long frameEndOffset;
try {
payloadOffset = Math.addExact(frameOffset, FIXED_HEADER_BYTES);
payloadDigestOffset = Math.addExact(payloadOffset, declaredPayloadLength);
frameEndOffset = Math.addExact(payloadDigestOffset, SHA_256_BYTES);
} catch (ArithmeticException overflow) {
throw new IOException("Metadata-frame boundaries exceed the supported long range", overflow);
}
ByteBuffer header = encodeHeader(frameType, transactionTokenBytes, sequence, declaredPayloadLength);
cancellation.throwIfCancelled();
writeFully(channel, header);
MessageDigest payloadDigest = newSha256();
byte[] transferBuffer = new byte[TRANSFER_BUFFER_BYTES];
long remaining = declaredPayloadLength;
cancellation.throwIfCancelled();
try (InputStream input = content.openStream()) {
while (remaining > 0L) {
cancellation.throwIfCancelled();
int requested = (int) Math.min((long) transferBuffer.length, remaining);
int read = input.read(transferBuffer, 0, requested);
if (read < 0) {
throw new IOException("Metadata-frame payload is shorter than its declared length");
}
if (read == 0) {
continue;
}
payloadDigest.update(transferBuffer, 0, read);
writeFully(channel, ByteBuffer.wrap(transferBuffer, 0, read));
remaining -= read;
}
cancellation.throwIfCancelled();
if (input.read() >= 0) {
throw new IOException("Metadata-frame payload is longer than its declared length");
}
writeFully(channel, ByteBuffer.wrap(payloadDigest.digest()));
} finally {
Arrays.fill(transferBuffer, (byte) 0);
}
return new FrameMetadata(
frameType,
LOWERCASE_HEX.formatHex(transactionTokenBytes),
sequence,
frameOffset,
payloadOffset,
declaredPayloadLength,
payloadDigestOffset,
frameEndOffset);
}
private boolean exceedsTechnicalLimit(long payloadLength) {
return maximumPayloadLength.isPresent() && payloadLength > maximumPayloadLength.getAsLong();
}
private void requirePayloadLength(long payloadLength) {
if (payloadLength < MINIMUM_PAYLOAD_LENGTH) {
throw new IllegalArgumentException("Metadata-frame payload length must be non-negative");
}
if (exceedsTechnicalLimit(payloadLength)) {
throw new IllegalArgumentException("Metadata-frame payload exceeds the adapter technical limit");
}
}
private static ByteBuffer encodeHeader(
FrameType frameType, byte[] transactionToken, long sequence, long payloadLength) {
if (sequence < MINIMUM_SEQUENCE_NUMBER) {
throw new IllegalArgumentException("Metadata-frame sequence must be non-negative");
}
ByteBuffer header = ByteBuffer.allocate(FIXED_HEADER_BYTES).order(ByteOrder.BIG_ENDIAN);
header.putInt(FRAME_MAGIC);
header.putShort(SCHEMA_VERSION);
header.put(frameType.wireCode());
header.put(RESERVED_FLAGS);
header.put(transactionToken);
header.putLong(sequence);
header.putLong(payloadLength);
MessageDigest digest = newSha256();
digest.update(header.array(), 0, HEADER_FIELDS_BYTES);
header.put(digest.digest());
header.flip();
return header;
}
private static byte[] decodeTransactionToken(String transactionToken) {
Objects.requireNonNull(transactionToken, "transactionToken");
if (transactionToken.length() != TRANSACTION_TOKEN_BYTES * 2) {
throw new IllegalArgumentException("Metadata transaction token must be 32 lowercase hexadecimal characters");
}
for (int index = 0; index < transactionToken.length(); index++) {
char current = transactionToken.charAt(index);
if (!((current >= '0' && current <= '9') || (current >= 'a' && current <= 'f'))) {
throw new IllegalArgumentException(
"Metadata transaction token must be 32 lowercase hexadecimal characters");
}
}
return LOWERCASE_HEX.parseHex(transactionToken);
}
private static int readAvailable(SeekableByteChannel channel, ByteBuffer destination) throws IOException {
int total = 0;
while (destination.hasRemaining()) {
int read = channel.read(destination);
if (read < 0) {
break;
}
if (read == 0) {
throw new IOException("Metadata-frame channel made no read progress");
}
total += read;
}
return total;
}
private static void writeFully(SeekableByteChannel channel, ByteBuffer source) throws IOException {
while (source.hasRemaining()) {
if (channel.write(source) == 0) {
throw new IOException("Metadata-frame channel made no write progress");
}
}
}
private static MessageDigest newSha256() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException failure) {
throw new IllegalStateException("SHA-256 is unavailable", failure);
}
}
/**
* Package-local wire kinds keep structural frame codes outside the public SPI
* while preserving the closed current-version type set.
*/
/* default */ enum FrameType {
STORE_HEADER(1),
TRANSACTION_ISSUED(2),
MUTATION_CREATE(3),
MUTATION_REPLACE(4),
MUTATION_DELETE(5),
TERMINAL_COMMITTED(6),
TERMINAL_NOT_COMMITTED(7),
RECOVERY_RESTART(8);
private final byte wireCode;
FrameType(int wireCode) {
this.wireCode = (byte) wireCode;
}
private byte wireCode() {
return wireCode;
}
private static FrameType fromWireCode(byte wireCode) {
for (FrameType candidate : values()) {
if (candidate.wireCode == wireCode) {
return candidate;
}
}
return null;
}
}
/**
* Package-local classifications keep recovery-facing wire state outside the
* public SPI while preserving the exhaustive structural outcomes.
*/
/* default */ enum ReadClassification {
END_OF_INPUT,
INCOMPLETE_TAIL,
COMPLETE_FRAME,
CORRUPT_FRAME
}
/* default */ record FrameMetadata(
FrameType frameType,
String transactionToken,
long sequence,
long frameOffset,
long payloadOffset,
long payloadLength,
long payloadDigestOffset,
long frameEndOffset) {
FrameMetadata {
Objects.requireNonNull(frameType, "frameType");
Objects.requireNonNull(transactionToken, "transactionToken");
}
}
/* default */ record ReadResult(ReadClassification classification, FrameMetadata metadata) {
ReadResult {
Objects.requireNonNull(classification, "classification");
if ((classification == ReadClassification.COMPLETE_FRAME) != (metadata != null)) {
throw new IllegalArgumentException("Complete metadata-frame classification requires metadata only");
}
}
private static ReadResult endOfInput() {
return new ReadResult(ReadClassification.END_OF_INPUT, null);
}
private static ReadResult incompleteTail() {
return new ReadResult(ReadClassification.INCOMPLETE_TAIL, null);
}
private static ReadResult corruptFrame() {
return new ReadResult(ReadClassification.CORRUPT_FRAME, null);
}
private static ReadResult forClassification(ReadClassification classification) {
return new ReadResult(classification, null);
}
private static ReadResult completeFrame(FrameMetadata metadata) {
return new ReadResult(ReadClassification.COMPLETE_FRAME, metadata);
}
}
}

View File

@@ -0,0 +1,591 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.impl.fs;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.channels.SeekableByteChannel;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
import java.util.OptionalLong;
import zeroecho.core.io.CancellationSignal;
import zeroecho.core.io.RepeatableContent;
import zeroecho.pki.spi.store.MetadataCommitResult;
import zeroecho.pki.spi.store.MetadataKey;
import zeroecho.pki.spi.store.MetadataStoreException;
/** Strict current-version codec for opaque metadata mutation frame payloads. */
final class MetadataMutationPayloadCodec {
private static final short SCHEMA_VERSION = 1;
private static final byte RESERVED_FLAGS = 0;
private static final int COMMON_BYTES = Short.BYTES + Byte.BYTES + Byte.BYTES
+ Short.BYTES + Short.BYTES;
private static final int VALUE_SCALAR_BYTES = Long.BYTES;
private static final int EXPECTED_AND_VALUE_SCALAR_BYTES = Long.BYTES + Long.BYTES;
private static final int MAXIMUM_IDENTITY_BYTES = MetadataKey.MAXIMUM_NAMESPACE_UTF8_BYTES
+ MetadataKey.MAXIMUM_KEY_UTF8_BYTES;
private static final int TRANSFER_BUFFER_BYTES = 8192;
private static final long NO_REMAINING_VALUE_BYTES = 0L;
private static final long MINIMUM_REVISION = 0L;
private static final long MINIMUM_LENGTH = 0L;
private MetadataMutationPayloadCodec() {
}
/* default */ static RepeatableContent create(
MetadataKey key, RepeatableContent value, CancellationSignal cancellation)
throws MetadataStoreException {
return PayloadEncoder.create(key, value, cancellation);
}
/* default */ static RepeatableContent replace(
MetadataKey key,
long expectedRevision,
RepeatableContent value,
CancellationSignal cancellation) throws MetadataStoreException {
return PayloadEncoder.replace(key, expectedRevision, value, cancellation);
}
/* default */ static RepeatableContent delete(MetadataKey key, long expectedRevision) {
return PayloadEncoder.delete(key, expectedRevision);
}
/*
* Decoding reads only the bounded descriptor prefix. Value bytes remain in
* the already validated frame region and no channel authority escapes.
*/
/* default */ static Descriptor decode(
SeekableByteChannel channel, MetadataFrameCodec.FrameMetadata frame) throws IOException {
return PayloadDecoder.decode(channel, frame);
}
/** Builds bounded mutation prefixes without consuming caller content. */
private static final class PayloadEncoder {
private static RepeatableContent create(
MetadataKey key, RepeatableContent value, CancellationSignal cancellation)
throws MetadataStoreException {
return valuePayload(MutationKind.CREATE, key, OptionalLong.empty(), value, cancellation);
}
private static RepeatableContent replace(
MetadataKey key,
long expectedRevision,
RepeatableContent value,
CancellationSignal cancellation) throws MetadataStoreException {
requireRevision(expectedRevision);
return valuePayload(
MutationKind.REPLACE, key, OptionalLong.of(expectedRevision), value, cancellation);
}
private static RepeatableContent delete(MetadataKey key, long expectedRevision) {
Objects.requireNonNull(key, "key");
requireRevision(expectedRevision);
byte[] prefix = prefix(
MutationKind.DELETE, key, OptionalLong.of(expectedRevision), OptionalLong.empty());
return new CompositePayload(prefix, null, 0L, CancellationSignal.NONE);
}
private static RepeatableContent valuePayload(
MutationKind kind,
MetadataKey key,
OptionalLong expectedRevision,
RepeatableContent value,
CancellationSignal cancellation) throws MetadataStoreException {
Objects.requireNonNull(key, "key");
Objects.requireNonNull(value, "value");
Objects.requireNonNull(cancellation, "cancellation");
OptionalLong knownLength = value.length();
if (knownLength.isEmpty()) {
throw new MetadataStoreException(
MetadataCommitResult.FailureCategory.UNSUPPORTED_CAPABILITY,
"Metadata mutation value requires a known length");
}
long valueLength = knownLength.getAsLong();
if (valueLength < MINIMUM_LENGTH) {
throw new MetadataStoreException(
MetadataCommitResult.FailureCategory.INTEGRITY_FAILURE,
"Metadata mutation value length is negative");
}
byte[] prefix = prefix(kind, key, expectedRevision, OptionalLong.of(valueLength));
try {
Math.addExact((long) prefix.length, valueLength);
} catch (ArithmeticException failure) {
throw new MetadataStoreException(
MetadataCommitResult.FailureCategory.LIMIT_EXCEEDED,
"Metadata mutation payload length is not representable",
failure);
}
return new CompositePayload(prefix, value, valueLength, cancellation);
}
private static byte[] prefix(
MutationKind kind,
MetadataKey key,
OptionalLong expectedRevision,
OptionalLong valueLength) {
byte[] namespace = key.namespace().getBytes(StandardCharsets.UTF_8);
byte[] logicalKey = key.key().getBytes(StandardCharsets.UTF_8);
SchemaRules.requireIdentityLengths(namespace.length, logicalKey.length);
int scalarLength = SchemaRules.scalarLength(kind);
int prefixLength = Math.addExact(
Math.addExact(COMMON_BYTES, scalarLength),
Math.addExact(namespace.length, logicalKey.length));
ByteBuffer prefix = ByteBuffer.allocate(prefixLength).order(ByteOrder.BIG_ENDIAN);
prefix.putShort(SCHEMA_VERSION).put(kind.code).put(RESERVED_FLAGS);
prefix.putShort((short) namespace.length).putShort((short) logicalKey.length);
if (expectedRevision.isPresent()) {
prefix.putLong(expectedRevision.getAsLong());
}
if (valueLength.isPresent()) {
prefix.putLong(valueLength.getAsLong());
}
prefix.put(namespace).put(logicalKey);
return prefix.array();
}
private static void requireRevision(long revision) {
if (revision < MINIMUM_REVISION) {
throw new IllegalArgumentException(
"Metadata mutation expected revision must be non-negative");
}
}
}
/** Validates bounded mutation descriptors without reading value bytes. */
private static final class PayloadDecoder {
private static Descriptor decode(
SeekableByteChannel channel, MetadataFrameCodec.FrameMetadata frame) throws IOException {
Objects.requireNonNull(channel, "channel");
Objects.requireNonNull(frame, "frame");
requireMutationFrame(frame.frameType());
requirePayloadRegion(frame);
ByteBuffer common = readExact(channel, frame.payloadOffset(), COMMON_BYTES, frame);
short version = common.getShort();
MutationKind kind = MutationKind.fromCode(common.get());
byte flags = common.get();
int namespaceLength = Short.toUnsignedInt(common.getShort());
int keyLength = Short.toUnsignedInt(common.getShort());
validateCommonFields(version, flags, namespaceLength, keyLength);
requireFrameKind(frame.frameType(), kind);
int scalarLength = SchemaRules.scalarLength(kind);
long scalarOffset = checkedAdd(frame.payloadOffset(), COMMON_BYTES);
ByteBuffer scalars = readExact(channel, scalarOffset, scalarLength, frame);
OptionalLong expectedRevision = expectedRevision(kind, scalars);
OptionalLong valueLength = valueLength(kind, scalars);
long identityOffset = checkedAdd(scalarOffset, scalarLength);
int identityLength = Math.addExact(namespaceLength, keyLength);
MetadataKey metadataKey = decodeIdentity(
channel, frame, identityOffset, identityLength, namespaceLength, keyLength);
long valueOffset = checkedAdd(identityOffset, identityLength);
validatePayloadBoundary(frame, valueOffset, valueLength);
return new Descriptor(
kind,
metadataKey,
expectedRevision,
valueLength.isPresent() ? OptionalLong.of(valueOffset) : OptionalLong.empty(),
valueLength);
}
private static void validateCommonFields(
short version, byte flags, int namespaceLength, int keyLength)
throws MetadataStoreException {
if (version != SCHEMA_VERSION || flags != RESERVED_FLAGS) {
throw integrity("Metadata mutation payload schema is unsupported");
}
try {
SchemaRules.requireIdentityLengths(namespaceLength, keyLength);
} catch (IllegalArgumentException failure) {
throw integrity("Metadata mutation identity length exceeds its canonical limit", failure);
}
}
private static MetadataKey decodeIdentity(
SeekableByteChannel channel,
MetadataFrameCodec.FrameMetadata frame,
long identityOffset,
int identityLength,
int namespaceLength,
int keyLength) throws IOException {
ByteBuffer identity = readExact(channel, identityOffset, identityLength, frame);
String namespace = decodeUtf8(identity, namespaceLength);
String logicalKey = decodeUtf8(identity, keyLength);
try {
return new MetadataKey(namespace, logicalKey);
} catch (IllegalArgumentException failure) {
throw integrity("Metadata mutation identity is not canonical", failure);
}
}
private static OptionalLong expectedRevision(MutationKind kind, ByteBuffer scalars)
throws MetadataStoreException {
if (kind == MutationKind.CREATE) {
return OptionalLong.empty();
}
long revision = scalars.getLong();
if (revision < MINIMUM_REVISION) {
throw integrity("Metadata mutation expected revision is negative");
}
return OptionalLong.of(revision);
}
private static OptionalLong valueLength(MutationKind kind, ByteBuffer scalars)
throws MetadataStoreException {
if (kind == MutationKind.DELETE) {
return OptionalLong.empty();
}
long length = scalars.getLong();
if (length < MINIMUM_LENGTH) {
throw integrity("Metadata mutation value length is negative");
}
return OptionalLong.of(length);
}
private static void validatePayloadBoundary(
MetadataFrameCodec.FrameMetadata frame,
long valueOffset,
OptionalLong valueLength) throws MetadataStoreException {
if (valueOffset < frame.payloadOffset()) {
throw integrity("Metadata mutation value begins before its validated payload region");
}
long expectedEnd = valueOffset;
if (valueLength.isPresent()) {
long length = valueLength.getAsLong();
if (length < MINIMUM_LENGTH) {
throw integrity("Metadata mutation value length is negative");
}
expectedEnd = checkedAdd(expectedEnd, length);
}
if (expectedEnd != frame.payloadDigestOffset()) {
throw integrity("Metadata mutation payload has trailing or missing data");
}
}
private static void requirePayloadRegion(MetadataFrameCodec.FrameMetadata frame)
throws MetadataStoreException {
if (frame.payloadOffset() < MINIMUM_LENGTH || frame.payloadLength() < MINIMUM_LENGTH) {
throw integrity("Metadata mutation frame payload region is negative");
}
long expectedEnd = checkedAdd(frame.payloadOffset(), frame.payloadLength());
if (expectedEnd != frame.payloadDigestOffset()) {
throw integrity("Metadata mutation frame payload region is inconsistent");
}
}
private static ByteBuffer readExact(
SeekableByteChannel channel,
long offset,
int length,
MetadataFrameCodec.FrameMetadata frame) throws IOException {
long end = checkedAdd(offset, length);
if (offset < frame.payloadOffset() || end > frame.payloadDigestOffset()) {
throw integrity("Metadata mutation descriptor exceeds its validated payload region");
}
ByteBuffer result = ByteBuffer.allocate(length).order(ByteOrder.BIG_ENDIAN);
channel.position(offset);
while (result.hasRemaining()) {
int count = channel.read(result);
if (count < 0) {
throw integrity("Metadata mutation descriptor is truncated");
}
if (count == 0) {
throw integrity("Metadata mutation descriptor channel made no read progress");
}
}
result.flip();
return result;
}
private static String decodeUtf8(ByteBuffer source, int length)
throws MetadataStoreException {
ByteBuffer bytes = source.slice();
bytes.limit(length);
source.position(source.position() + length);
try {
return StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(bytes)
.toString();
} catch (CharacterCodingException failure) {
throw integrity("Metadata mutation identity is not valid UTF-8", failure);
}
}
private static void requireMutationFrame(MetadataFrameCodec.FrameType type) {
if (type != MetadataFrameCodec.FrameType.MUTATION_CREATE
&& type != MetadataFrameCodec.FrameType.MUTATION_REPLACE
&& type != MetadataFrameCodec.FrameType.MUTATION_DELETE) {
throw new IllegalArgumentException("Frame does not contain a metadata mutation");
}
}
private static void requireFrameKind(
MetadataFrameCodec.FrameType frameType, MutationKind kind)
throws MetadataStoreException {
if (kind.frameType != frameType) {
throw integrity("Metadata mutation kind does not match its frame type");
}
}
private static long checkedAdd(long first, long second) throws MetadataStoreException {
try {
return Math.addExact(first, second);
} catch (ArithmeticException failure) {
throw integrity("Metadata mutation payload boundary overflows", failure);
}
}
}
/** Centralizes the schema limits shared by encoding and decoding. */
private static final class SchemaRules {
private static void requireIdentityLengths(int namespaceLength, int keyLength) {
if (namespaceLength > MetadataKey.MAXIMUM_NAMESPACE_UTF8_BYTES
|| keyLength > MetadataKey.MAXIMUM_KEY_UTF8_BYTES
|| Math.addExact(namespaceLength, keyLength) > MAXIMUM_IDENTITY_BYTES) {
throw new IllegalArgumentException(
"Metadata mutation identity length exceeds its canonical limit");
}
}
private static int scalarLength(MutationKind kind) {
return kind == MutationKind.REPLACE
? EXPECTED_AND_VALUE_SCALAR_BYTES
: VALUE_SCALAR_BYTES;
}
}
private static MetadataStoreException integrity(String message) {
return new MetadataStoreException(MetadataCommitResult.FailureCategory.INTEGRITY_FAILURE, message);
}
private static MetadataStoreException integrity(String message, Throwable cause) {
return new MetadataStoreException(
MetadataCommitResult.FailureCategory.INTEGRITY_FAILURE, message, cause);
}
/** Stable mutation kinds; codes never depend on Java enum ordinals. */
/* default */ enum MutationKind {
CREATE((byte) 1, MetadataFrameCodec.FrameType.MUTATION_CREATE),
REPLACE((byte) 2, MetadataFrameCodec.FrameType.MUTATION_REPLACE),
DELETE((byte) 3, MetadataFrameCodec.FrameType.MUTATION_DELETE);
private final byte code;
private final MetadataFrameCodec.FrameType frameType;
MutationKind(byte code, MetadataFrameCodec.FrameType frameType) {
this.code = code;
this.frameType = frameType;
}
private static MutationKind fromCode(byte code) throws MetadataStoreException {
for (MutationKind candidate : values()) {
if (candidate.code == code) {
return candidate;
}
}
throw integrity("Metadata mutation kind is unsupported");
}
}
/** Bounded descriptor of a decoded mutation; it carries no channel or value bytes. */
/* default */ record Descriptor(
MutationKind kind,
MetadataKey key,
OptionalLong expectedRevision,
OptionalLong valueOffset,
OptionalLong valueLength) {
Descriptor {
Objects.requireNonNull(kind, "kind");
Objects.requireNonNull(key, "key");
Objects.requireNonNull(expectedRevision, "expectedRevision");
Objects.requireNonNull(valueOffset, "valueOffset");
Objects.requireNonNull(valueLength, "valueLength");
}
}
/** Composite control prefix plus an exactly bounded caller-owned value stream. */
private static final class CompositePayload implements RepeatableContent {
private final byte[] prefix;
private final RepeatableContent value;
private final long valueLength;
private final CancellationSignal cancellation;
private CompositePayload(
byte[] prefix,
RepeatableContent value,
long valueLength,
CancellationSignal cancellation) {
this.prefix = prefix.clone();
this.value = value;
this.valueLength = valueLength;
this.cancellation = cancellation;
}
@Override
public InputStream openStream() throws IOException {
cancellation.throwIfCancelled();
InputStream valueStream = value == null ? null : value.openStream();
return new ExactCompositeInputStream(prefix, valueStream, valueLength, cancellation);
}
@Override
public OptionalLong length() {
return OptionalLong.of(Math.addExact((long) prefix.length, valueLength));
}
@Override
public String contentId() {
return "zeroecho-metadata-mutation-payload-v1";
}
@Override
public void close() {
// The composite owns opened streams, never the caller's content object.
}
}
/** Bounded stream that validates declared value length without buffering it. */
private static final class ExactCompositeInputStream extends InputStream {
private final ByteArrayInputStream prefix;
private final InputStream value;
private final CancellationSignal cancellation;
private long remaining;
private boolean exactLengthVerified;
private boolean closed;
private ExactCompositeInputStream(
byte[] prefix,
InputStream value,
long valueLength,
CancellationSignal cancellation) {
super();
this.prefix = new ByteArrayInputStream(prefix);
this.value = value;
this.remaining = valueLength;
this.cancellation = cancellation;
}
@Override
public int read() throws IOException {
byte[] one = new byte[1];
int count = read(one, 0, one.length);
return count < 0 ? -1 : Byte.toUnsignedInt(one[0]);
}
@Override
public int read(byte[] target, int offset, int length) throws IOException {
Objects.checkFromIndexSize(offset, length, target.length);
requireOpen();
cancellation.throwIfCancelled();
if (length == 0) {
return 0;
}
int prefixCount = prefix.read(target, offset, length);
if (prefixCount >= 0) {
return prefixCount;
}
return readValue(target, offset, length);
}
private int readValue(byte[] target, int offset, int length) throws IOException {
if (value == null) {
return -1;
}
if (remaining == NO_REMAINING_VALUE_BYTES) {
verifyNoExcess();
return -1;
}
int requested = (int) Math.min((long) Math.min(length, TRANSFER_BUFFER_BYTES), remaining);
int count = value.read(target, offset, requested);
if (count < 0) {
throw new IOException("Metadata mutation value is shorter than declared");
}
if (count == 0) {
throw closeAfterNoProgress();
}
remaining -= count;
return count;
}
private MetadataStoreException closeAfterNoProgress() {
MetadataStoreException failure = new MetadataStoreException(
MetadataCommitResult.FailureCategory.STORAGE_FAILURE,
"Metadata mutation value stream made no read progress");
closed = true;
try {
prefix.close();
} catch (IOException closeFailure) {
failure.addSuppressed(closeFailure);
}
try {
value.close();
} catch (IOException closeFailure) {
failure.addSuppressed(closeFailure);
}
return failure;
}
private void verifyNoExcess() throws IOException {
if (!exactLengthVerified) {
cancellation.throwIfCancelled();
if (value.read() >= 0) {
throw new IOException("Metadata mutation value is longer than declared");
}
exactLengthVerified = true;
}
}
private void requireOpen() {
if (closed) {
throw new IllegalStateException("Metadata mutation payload stream is closed");
}
}
@Override
public void close() throws IOException {
if (!closed) {
closed = true;
prefix.close();
if (value != null) {
value.close();
}
}
}
}
}

View File

@@ -0,0 +1,471 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.impl.fs;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.NavigableMap;
import java.util.Objects;
import java.util.Optional;
import java.util.OptionalLong;
import java.util.Set;
import java.util.TreeMap;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import zeroecho.pki.spi.store.MetadataCommitResult;
import zeroecho.pki.spi.store.MetadataKey;
import zeroecho.pki.spi.store.MetadataStoreException;
/** Atomic current-record index reconstructed from committed mutation descriptors. */
final class MetadataStateIndex {
private static final long INITIAL_STORE_REVISION = 0L;
private static final long MINIMUM_VALUE_POSITION = 0L;
private final ReentrantReadWriteLock stateLock = new ReentrantReadWriteLock();
private final Lock readLock = stateLock.readLock();
private final Lock writeLock = stateLock.writeLock();
private NavigableMap<MetadataKey, CurrentRecord> current = new TreeMap<>();
private Object stateToken = new StateToken();
private long storeRevision = INITIAL_STORE_REVISION;
/* default */ static RecoveryBuilder recoveryBuilder() {
return new RecoveryBuilder();
}
/* default */ long storeRevision() {
readLock.lock();
try {
return storeRevision;
} finally {
readLock.unlock();
}
}
/* default */ Optional<CurrentRecord> lookup(MetadataKey key) {
Objects.requireNonNull(key, "key");
readLock.lock();
try {
return Optional.ofNullable(current.get(key));
} finally {
readLock.unlock();
}
}
/* default */ List<CurrentRecord> records() {
readLock.lock();
try {
return List.copyOf(current.values());
} finally {
readLock.unlock();
}
}
/*
* Copy-then-publish keeps every conflict and validation failure invisible.
* CREATE and REPLACE record revisions are the authoritative committed store
* revision, never an independently incremented per-record counter.
*/
/* default */ void applyCommitted(
long committedStoreRevision,
List<MetadataMutationPayloadCodec.Descriptor> mutations) throws MetadataStoreException {
publish(prepare(committedStoreRevision, mutations));
}
/* default */ PreparedUpdate prepare(
long committedStoreRevision,
List<MetadataMutationPayloadCodec.Descriptor> mutations) throws MetadataStoreException {
List<ValidatedMutation> validated = validateAndDetach(mutations);
readLock.lock();
try {
requireNextRevision(committedStoreRevision);
rejectDuplicateKeys(validated);
NavigableMap<MetadataKey, CurrentRecord> candidate = new TreeMap<>(current);
for (ValidatedMutation mutation : validated) {
apply(candidate, committedStoreRevision, mutation);
}
return new PreparedUpdate(
stateToken,
storeRevision,
committedStoreRevision,
Collections.unmodifiableNavigableMap(candidate));
} catch (ArithmeticException | IllegalArgumentException
| NullPointerException | IndexOutOfBoundsException failure) {
throw integrity("Committed metadata transaction contains a malformed descriptor", failure);
} finally {
readLock.unlock();
}
}
/* default */ void publish(PreparedUpdate update) throws MetadataStoreException {
Objects.requireNonNull(update, "update");
writeLock.lock();
try {
if (!stateToken.equals(update.baseToken()) || storeRevision != update.baseRevision()) {
throw integrity("Prepared metadata state no longer has its exact base revision");
}
current = update.candidate();
storeRevision = update.targetRevision();
stateToken = new StateToken();
} finally {
writeLock.unlock();
}
}
private static List<ValidatedMutation> validateAndDetach(
List<MetadataMutationPayloadCodec.Descriptor> mutations) throws MetadataStoreException {
try {
List<MetadataMutationPayloadCodec.Descriptor> detached = List.copyOf(
Objects.requireNonNull(mutations, "mutations"));
List<ValidatedMutation> validated = new ArrayList<>(detached.size());
for (MetadataMutationPayloadCodec.Descriptor descriptor : detached) {
validated.add(validateDescriptor(descriptor));
}
return List.copyOf(validated);
} catch (ArithmeticException | IllegalArgumentException
| NullPointerException | IndexOutOfBoundsException failure) {
throw integrity("Committed metadata transaction contains a malformed descriptor", failure);
}
}
private static ValidatedMutation validateDescriptor(
MetadataMutationPayloadCodec.Descriptor descriptor) throws MetadataStoreException {
MetadataMutationPayloadCodec.Descriptor checked = Objects.requireNonNull(descriptor, "descriptor");
MetadataMutationPayloadCodec.MutationKind kind = Objects.requireNonNull(checked.kind(), "kind");
MetadataKey key = Objects.requireNonNull(checked.key(), "key");
OptionalLong expectedRevision = Objects.requireNonNull(
checked.expectedRevision(), "expectedRevision");
OptionalLong valueOffset = Objects.requireNonNull(checked.valueOffset(), "valueOffset");
OptionalLong valueLength = Objects.requireNonNull(checked.valueLength(), "valueLength");
return switch (kind) {
case CREATE -> validateCreate(key, expectedRevision, valueOffset, valueLength);
case REPLACE -> validateReplace(key, expectedRevision, valueOffset, valueLength);
case DELETE -> validateDelete(key, expectedRevision, valueOffset, valueLength);
};
}
private static ValidatedMutation validateCreate(
MetadataKey key,
OptionalLong expectedRevision,
OptionalLong valueOffset,
OptionalLong valueLength) throws MetadataStoreException {
if (expectedRevision.isPresent()) {
throw integrity("Metadata create descriptor unexpectedly carries an expected revision");
}
ValueRegion region = requireValueRegion(valueOffset, valueLength);
return new ValidatedMutation(
MetadataMutationPayloadCodec.MutationKind.CREATE,
key,
OptionalLong.empty(),
region);
}
private static ValidatedMutation validateReplace(
MetadataKey key,
OptionalLong expectedRevision,
OptionalLong valueOffset,
OptionalLong valueLength) throws MetadataStoreException {
requireExpectedRevision(expectedRevision);
ValueRegion region = requireValueRegion(valueOffset, valueLength);
return new ValidatedMutation(
MetadataMutationPayloadCodec.MutationKind.REPLACE,
key,
expectedRevision,
region);
}
private static ValidatedMutation validateDelete(
MetadataKey key,
OptionalLong expectedRevision,
OptionalLong valueOffset,
OptionalLong valueLength) throws MetadataStoreException {
requireExpectedRevision(expectedRevision);
if (valueOffset.isPresent() || valueLength.isPresent()) {
throw integrity("Metadata delete descriptor unexpectedly carries a value region");
}
return new ValidatedMutation(
MetadataMutationPayloadCodec.MutationKind.DELETE,
key,
expectedRevision,
null);
}
private static void requireExpectedRevision(OptionalLong expectedRevision)
throws MetadataStoreException {
if (expectedRevision.isEmpty() || expectedRevision.getAsLong() < INITIAL_STORE_REVISION) {
throw integrity("Metadata mutation expected revision is missing or negative");
}
}
private static ValueRegion requireValueRegion(
OptionalLong valueOffset,
OptionalLong valueLength) throws MetadataStoreException {
if (valueOffset.isEmpty() || valueLength.isEmpty()) {
throw integrity("Metadata value mutation has no value region");
}
long offset = valueOffset.getAsLong();
long length = valueLength.getAsLong();
if (offset < MINIMUM_VALUE_POSITION || length < MINIMUM_VALUE_POSITION) {
throw integrity("Metadata record value region must be non-negative");
}
try {
Math.addExact(offset, length);
} catch (ArithmeticException failure) {
throw integrity("Metadata record value region overflows", failure);
}
return new ValueRegion(offset, length);
}
private void requireNextRevision(long committedStoreRevision) throws MetadataStoreException {
if (storeRevision == Long.MAX_VALUE) {
throw new MetadataStoreException(
MetadataCommitResult.FailureCategory.LIMIT_EXCEEDED,
"Metadata store revision is exhausted");
}
long expected = storeRevision + 1L;
if (committedStoreRevision != expected) {
throw new MetadataStoreException(
MetadataCommitResult.FailureCategory.INTEGRITY_FAILURE,
"Committed metadata store revision is not contiguous");
}
}
private static void rejectDuplicateKeys(
List<ValidatedMutation> mutations) throws MetadataStoreException {
Set<MetadataKey> keys = new HashSet<>();
for (ValidatedMutation mutation : mutations) {
if (!keys.add(mutation.key())) {
throw conflict("Metadata transaction mutates one key more than once");
}
}
}
private static void apply(
Map<MetadataKey, CurrentRecord> candidate,
long committedStoreRevision,
ValidatedMutation mutation) throws MetadataStoreException {
switch (mutation.kind()) {
case CREATE -> create(candidate, committedStoreRevision, mutation);
case REPLACE -> replace(candidate, committedStoreRevision, mutation);
case DELETE -> delete(candidate, mutation);
}
}
private static void create(
Map<MetadataKey, CurrentRecord> candidate,
long committedStoreRevision,
ValidatedMutation mutation) throws MetadataStoreException {
if (candidate.containsKey(mutation.key())) {
throw conflict("Metadata create precondition failed");
}
candidate.put(mutation.key(), record(committedStoreRevision, mutation));
}
private static void replace(
Map<MetadataKey, CurrentRecord> candidate,
long committedStoreRevision,
ValidatedMutation mutation) throws MetadataStoreException {
CurrentRecord existing = candidate.get(mutation.key());
requireExpected(existing, mutation);
candidate.put(mutation.key(), record(committedStoreRevision, mutation));
}
private static void delete(
Map<MetadataKey, CurrentRecord> candidate,
ValidatedMutation mutation) throws MetadataStoreException {
CurrentRecord existing = candidate.get(mutation.key());
requireExpected(existing, mutation);
candidate.remove(mutation.key());
}
private static void requireExpected(
CurrentRecord existing,
ValidatedMutation mutation) throws MetadataStoreException {
if (existing == null
|| mutation.expectedRevision().isEmpty()
|| existing.revision() != mutation.expectedRevision().getAsLong()) {
throw conflict("Metadata expected-revision precondition failed");
}
}
private static CurrentRecord record(
long committedStoreRevision,
ValidatedMutation mutation) {
ValueRegion region = mutation.valueRegion();
return new CurrentRecord(
mutation.key(),
committedStoreRevision,
region.offset(),
region.length());
}
private static MetadataStoreException conflict(String message) {
return new MetadataStoreException(MetadataCommitResult.FailureCategory.CONFLICT, message);
}
private static MetadataStoreException integrity(String message) {
return new MetadataStoreException(MetadataCommitResult.FailureCategory.INTEGRITY_FAILURE, message);
}
private static MetadataStoreException integrity(String message, Throwable cause) {
return new MetadataStoreException(
MetadataCommitResult.FailureCategory.INTEGRITY_FAILURE, message, cause);
}
/** Replay-only unpublished reducer avoiding one full state copy per commit. */
/* default */ static final class RecoveryBuilder {
private final NavigableMap<MetadataKey, CurrentRecord> records = new TreeMap<>();
private long revision = INITIAL_STORE_REVISION;
private long discardedDescriptors;
private boolean finished;
/* default */ void applyCommitted(
long committedRevision,
List<MetadataMutationPayloadCodec.Descriptor> descriptors)
throws MetadataStoreException {
requireUnfinished();
List<ValidatedMutation> validated = validateAndDetach(descriptors);
if (revision == Long.MAX_VALUE || committedRevision != revision + 1L) {
throw integrity("Recovered metadata store revision is not contiguous");
}
rejectDuplicateKeys(validated);
Map<MetadataKey, Optional<CurrentRecord>> delta = new HashMap<>();
for (ValidatedMutation mutation : validated) {
validateRecoveryMutation(delta, committedRevision, mutation);
}
for (Map.Entry<MetadataKey, Optional<CurrentRecord>> entry : delta.entrySet()) {
if (entry.getValue().isPresent()) {
records.put(entry.getKey(), entry.getValue().orElseThrow());
} else {
records.remove(entry.getKey());
}
}
revision = committedRevision;
discardedDescriptors = Math.addExact(discardedDescriptors, validated.size());
}
/* default */ MetadataStateIndex finish() {
requireUnfinished();
finished = true;
MetadataStateIndex result = new MetadataStateIndex();
result.current = records;
result.storeRevision = revision;
result.stateToken = new StateToken();
return result;
}
/* default */ long fullMapCopyCount() {
return 0L;
}
/* default */ long finalInstallCount() {
return finished ? 1L : 0L;
}
/* default */ long discardedDescriptorCount() {
return discardedDescriptors;
}
private void requireUnfinished() {
if (finished) {
throw new IllegalStateException("Metadata recovery builder is finished");
}
}
private void validateRecoveryMutation(
Map<MetadataKey, Optional<CurrentRecord>> delta,
long committedRevision,
ValidatedMutation mutation) throws MetadataStoreException {
CurrentRecord existing = records.get(mutation.key());
switch (mutation.kind()) {
case CREATE -> {
if (existing != null) {
throw conflict("Recovered metadata create precondition failed");
}
delta.put(mutation.key(), Optional.of(record(committedRevision, mutation)));
}
case REPLACE -> {
requireExpected(existing, mutation);
delta.put(mutation.key(), Optional.of(record(committedRevision, mutation)));
}
case DELETE -> {
requireExpected(existing, mutation);
delta.put(mutation.key(), Optional.empty());
}
}
}
}
private record ValidatedMutation(
MetadataMutationPayloadCodec.MutationKind kind,
MetadataKey key,
OptionalLong expectedRevision,
ValueRegion valueRegion) {
}
private record ValueRegion(long offset, long length) {
}
/** Identity-semantic base token changed after every successful publication. */
private static final class StateToken {
}
/** Isolated immutable state candidate bound to one exact current-state identity. */
/* default */ record PreparedUpdate(
Object baseToken,
long baseRevision,
long targetRevision,
NavigableMap<MetadataKey, CurrentRecord> candidate) {
PreparedUpdate {
Objects.requireNonNull(baseToken, "baseToken");
Objects.requireNonNull(candidate, "candidate");
}
}
/** Immutable current value location and its authoritative committed revision. */
/* default */ record CurrentRecord(MetadataKey key, long revision, long valueOffset, long valueLength) {
CurrentRecord {
Objects.requireNonNull(key, "key");
if (revision <= INITIAL_STORE_REVISION) {
throw new IllegalArgumentException("Metadata record revision must be positive");
}
if (valueOffset < MINIMUM_VALUE_POSITION || valueLength < MINIMUM_VALUE_POSITION) {
throw new IllegalArgumentException("Metadata record value region must be non-negative");
}
}
}
}

View File

@@ -0,0 +1,306 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.impl.fs;
import java.io.IOException;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
/** Shared store lifecycle and exact-instance resource authority. */
final class PosixMetadataAdapterLifecycle {
private final ReentrantLock lock = new ReentrantLock();
private final Condition operationsFinished = lock.newCondition();
private final Set<ManagedResource> resources =
Collections.newSetFromMap(new IdentityHashMap<>());
private State state = State.OPEN;
private boolean closing;
private int activeOperations;
/* default */ <T extends ManagedResource> T openManaged(CheckedFactory<T> factory)
throws IOException {
Objects.requireNonNull(factory, "factory");
lock.lock();
try {
requireOpenLocked();
T result = Objects.requireNonNull(factory.create(), "managed resource");
resources.add(result);
return result;
} finally {
lock.unlock();
}
}
/* default */ <T> T read(CheckedFactory<T> operation) throws IOException {
Objects.requireNonNull(operation, "operation");
lock.lock();
try {
requireOpenLocked();
return operation.create();
} finally {
lock.unlock();
}
}
/* default */ void verifyOpen() {
lock.lock();
try {
requireOpenLocked();
} finally {
lock.unlock();
}
}
/* default */ OperationReservation beginOperation() {
lock.lock();
try {
requireOpenLocked();
activeOperations++;
return new OperationReservation();
} finally {
lock.unlock();
}
}
/* default */ OperationReservation beginCleanupOperation() {
lock.lock();
try {
requireNotClosingLocked();
activeOperations++;
return new OperationReservation();
} finally {
lock.unlock();
}
}
/* default */ int activeOperationCount() {
lock.lock();
try {
return activeOperations;
} finally {
lock.unlock();
}
}
/* default */ void awaitClosing() {
lock.lock();
try {
while (!closing && state != State.CLOSED) {
operationsFinished.awaitUninterruptibly();
}
} finally {
lock.unlock();
}
}
/* default */ void unregister(ManagedResource resource) {
lock.lock();
try {
resources.remove(resource);
} finally {
lock.unlock();
}
}
/* default */ void recoveryRequired() {
lock.lock();
try {
if (state == State.OPEN) {
state = State.RECOVERY_REQUIRED;
}
} finally {
lock.unlock();
}
}
/* default */ void close(PosixMetadataStoreEngine engine) throws IOException {
List<ManagedResource> detached;
lock.lock();
try {
if (state == State.CLOSED) {
return;
}
closing = true;
operationsFinished.signalAll();
while (activeOperations != 0) {
operationsFinished.awaitUninterruptibly();
}
detached = List.copyOf(resources);
resources.clear();
state = State.CLOSED;
} finally {
lock.unlock();
}
IOException failure = closeManaged(detached);
try {
engine.close();
} catch (IOException cleanup) {
failure = append(failure, cleanup);
}
if (failure != null) {
throw failure;
}
}
private static IOException closeManaged(List<ManagedResource> resources) {
IOException failure = null;
for (ManagedResource resource : resources) {
failure = append(failure, resource.forceClose());
}
return failure;
}
/* default */ static IOException append(IOException first, IOException later) {
if (later == null) {
return first;
}
if (first == null) {
return later;
}
first.addSuppressed(later);
return first;
}
private void requireOpenLocked() {
requireNotClosingLocked();
if (state == State.RECOVERY_REQUIRED) {
throw new IllegalStateException("POSIX transactional metadata store requires recovery");
}
}
private void requireNotClosingLocked() {
if (state == State.CLOSED || closing) {
throw new IllegalStateException("POSIX transactional metadata store is closed");
}
}
/** One-shot store operation authority consumed under the lifecycle lock. */
/* default */
final class OperationReservation {
private boolean consumed;
/* default */ boolean finish(Runnable accepted, Runnable rejected) {
Objects.requireNonNull(accepted, "accepted");
Objects.requireNonNull(rejected, "rejected");
lock.lock();
try {
requireUnconsumed();
boolean accept = state == State.OPEN && !closing;
if (accept) {
accepted.run();
} else {
rejected.run();
}
return accept;
} finally {
consumeLocked();
lock.unlock();
}
}
/* default */ void cancel(Runnable cleanup) {
Objects.requireNonNull(cleanup, "cleanup");
lock.lock();
try {
requireUnconsumed();
cleanup.run();
} finally {
consumeLocked();
lock.unlock();
}
}
/* default */ void finishTerminal(Runnable completion) {
Objects.requireNonNull(completion, "completion");
lock.lock();
try {
requireUnconsumed();
completion.run();
} finally {
consumeLocked();
lock.unlock();
}
}
private void requireUnconsumed() {
if (consumed) {
throw new IllegalStateException("Metadata operation reservation is already consumed");
}
}
private void consumeLocked() {
if (!consumed) {
consumed = true;
activeOperations--;
operationsFinished.signalAll();
}
}
}
/** Internal resource release avoids imposing another public close contract. */
/* default */
@FunctionalInterface
interface ManagedResource {
/**
* Releases the resource without requiring caller thread ownership.
*
* @return checked cleanup failure, or {@code null}
*/
IOException forceClose();
}
/** Checked lifecycle operation performed under the short store lock. */
/* default */
@FunctionalInterface
interface CheckedFactory<T> {
/**
* Performs one checked operation.
*
* @return operation result
* @throws IOException when the operation fails
*/
T create() throws IOException;
}
/** Closed adapter lifecycle states distinct from an in-progress close. */
private enum State {
OPEN,
RECOVERY_REQUIRED,
CLOSED
}
}

View File

@@ -0,0 +1,987 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.impl.fs;
import java.io.IOException;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.channels.OverlappingFileLockException;
import java.nio.channels.SeekableByteChannel;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.OpenOption;
import java.nio.file.Path;
import java.nio.file.SecureDirectoryStream;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.BasicFileAttributeView;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.FileAttribute;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.HashMap;
import java.util.HashSet;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.locks.ReentrantLock;
import java.util.logging.Logger;
import zeroecho.core.io.CancellationSignal;
import zeroecho.core.io.ImmutableByteContent;
import zeroecho.core.io.RepeatableContent;
import zeroecho.pki.spi.store.MetadataCommitResult;
import zeroecho.pki.spi.store.MetadataStoreException;
import zeroecho.pki.spi.store.MetadataStoreId;
import zeroecho.pki.spi.store.MetadataTransactionId;
/** Exclusive-writer POSIX append-only metadata-log writer. */
final class PosixMetadataLog implements AutoCloseable {
private static final Logger LOGGER = Logger.getLogger(PosixMetadataLog.class.getName());
private static final String CAPABILITY_WARNING =
"POSIX metadata-log durability capabilities are limited; continuing in documented best-effort mode";
private static final String TAIL_REPAIR_WARNING =
"POSIX metadata log had an incomplete final tail; the validated boundary was durably restored";
private static final String ABANDONED_RESTART_INFO =
"POSIX metadata log recovery durably retired incomplete transactions";
private static final long FRAME_HEADER_BYTES = 72L;
private static final long FRAME_DIGEST_BYTES = 32L;
private static final Set<String> RECOGNIZED_LOCAL_FILE_SYSTEMS =
Set.of("apfs", "btrfs", "ext2", "ext3", "ext4", "tmpfs", "ufs", "xfs", "zfs");
private static final Set<java.nio.file.attribute.PosixFilePermission> OWNER_ONLY =
PosixFilePermissions.fromString("rw-------");
private static final long STORE_HEADER_SEQUENCE = 0L;
private static final long ISSUANCE_SEQUENCE = 0L;
private static final long FIRST_TRANSACTION_SEQUENCE = 1L;
private static final long MINIMUM_PAYLOAD_LENGTH = 0L;
private final FileChannel channel;
private final FileLock writerLock;
private final MetadataStoreId storeId;
private final PosixMetadataLogScanner.TransactionCounter transactionCounter;
private final MetadataFrameCodec codec = new MetadataFrameCodec();
private final FaultInjector faultInjector;
private final ReentrantLock operationLock = new ReentrantLock();
private final Map<MetadataTransactionId, ActiveTransaction> active = new HashMap<>();
private MetadataTransactionId activeBatch;
private long recoveryEpoch;
private State state = State.OPEN;
private boolean capabilityWarningEmitted;
private PosixMetadataLog(
FileChannel channel,
FileLock writerLock,
MetadataStoreId storeId,
PosixMetadataLogScanner.TransactionCounter transactionCounter,
long recoveryEpoch,
FaultInjector faultInjector) {
this.channel = channel;
this.writerLock = writerLock;
this.storeId = storeId;
this.transactionCounter = transactionCounter;
this.recoveryEpoch = recoveryEpoch;
this.faultInjector = faultInjector;
}
/* default */ static PosixMetadataLog create(Path logPath, MetadataStoreId storeId) throws IOException {
return create(logPath, storeId, DefaultCapabilityProfile.INSTANCE, FaultInjector.NONE);
}
/* default */ static PosixMetadataLog open(Path logPath) throws IOException {
return open(logPath, DefaultCapabilityProfile.INSTANCE, FaultInjector.NONE);
}
/* default */ static PosixMetadataLog create(
Path logPath,
MetadataStoreId storeId,
CapabilityProfile capabilities,
FaultInjector faults) throws IOException {
return Lifecycle.create(logPath, storeId, capabilities, faults);
}
/* default */ static PosixMetadataLog open(
Path logPath,
CapabilityProfile capabilities,
FaultInjector faults) throws IOException {
return Lifecycle.open(logPath, capabilities, faults);
}
/* default */ static EngineOpen openEngine(
Path logPath,
CapabilityProfile capabilities,
FaultInjector faults) throws IOException {
return Lifecycle.openEngine(logPath, capabilities, faults);
}
/* default */ MetadataStoreId storeId() {
return storeId;
}
/* default */ static long checkedWritableRecoveryEpoch(long currentEpoch)
throws MetadataStoreException {
return Lifecycle.checkedWritableRecoveryEpoch(currentEpoch);
}
/* default */ List<MutationLocation> predictMutationLocations(List<Long> payloadLengths)
throws IOException {
operationLock.lock();
try {
requireOperational();
List<Long> detached = List.copyOf(payloadLengths);
List<MutationLocation> locations = new ArrayList<>(detached.size());
long frameOffset = channel.size();
for (Long boxedLength : detached) {
long payloadLength = Objects.requireNonNull(boxedLength, "payloadLength");
if (payloadLength < MINIMUM_PAYLOAD_LENGTH) {
throw new IllegalArgumentException("Mutation payload length must be non-negative");
}
long payloadOffset = Math.addExact(frameOffset, FRAME_HEADER_BYTES);
long frameEnd = Math.addExact(
Math.addExact(payloadOffset, payloadLength), FRAME_DIGEST_BYTES);
locations.add(new MutationLocation(payloadOffset, frameEnd));
frameOffset = frameEnd;
}
return List.copyOf(locations);
} finally {
operationLock.unlock();
}
}
/* default */ MetadataMutationPayloadCodec.Descriptor decodeMutation(
MetadataFrameCodec.FrameMetadata frame) throws IOException {
operationLock.lock();
try {
requireOpenAuthority();
return MetadataMutationPayloadCodec.decode(channel, frame);
} finally {
operationLock.unlock();
}
}
/* default */ MetadataTransactionId issue() throws IOException {
operationLock.lock();
try {
requireOperational();
MetadataTransactionId transactionId = transactionCounter.issue(storeId);
try {
trip(FaultPoint.ISSUANCE_APPEND);
MetadataFrameCodec.FrameMetadata frame = appendBounded(
MetadataFrameCodec.FrameType.TRANSACTION_ISSUED,
transactionId.token(), ISSUANCE_SEQUENCE,
PosixMetadataLogScanner.encodeIssuance(transactionId));
byte[] chain = PosixMetadataLogScanner.advanceChain(
channel, frame, PosixMetadataLogScanner.initialChainState());
forceFile();
active.put(transactionId, new ActiveTransaction(chain));
return transactionId;
} catch (IOException failure) {
state = State.RECOVERY_REQUIRED;
throw failure;
}
} finally {
operationLock.unlock();
}
}
/* default */ MetadataFrameCodec.FrameMetadata appendMutation(
MetadataTransactionId transactionId,
MetadataFrameCodec.FrameType type,
long payloadLength,
RepeatableContent payload,
CancellationSignal cancellation) throws IOException {
operationLock.lock();
try {
requireOperational();
ActiveTransaction transaction = requireActive(transactionId);
requireMutationType(type);
requireBatch(transactionId);
try {
trip(FaultPoint.MUTATION_APPEND);
channel.position(channel.size());
MetadataFrameCodec.FrameMetadata frame = codec.write(
channel, type, transactionId.token(), transaction.nextSequence,
payloadLength, payload, cancellation);
byte[] next = PosixMetadataLogScanner.advanceChain(
channel, frame, transaction.chainState);
transaction.chainState = next;
transaction.nextSequence++;
return frame;
} catch (IOException failure) {
state = State.RECOVERY_REQUIRED;
throw failure;
}
} finally {
operationLock.unlock();
}
}
/* default */ PosixMetadataLogScanner.Terminal commit(
MetadataTransactionId transactionId, long revision) throws IOException {
operationLock.lock();
try {
requireOperational();
ActiveTransaction transaction = requireActive(transactionId);
requireTerminalBatch(transactionId, transaction);
byte[] payload = PosixMetadataLogScanner.encodeCommitted(
transactionId, revision, transaction.chainState);
PosixMetadataLogScanner.Terminal result = new PosixMetadataLogScanner.Terminal(
PosixMetadataLogScanner.TerminalKind.COMMITTED,
java.util.OptionalLong.of(revision), java.util.OptionalInt.empty());
return appendTerminal(transactionId, transaction, MetadataFrameCodec.FrameType.TERMINAL_COMMITTED,
payload, result);
} finally {
operationLock.unlock();
}
}
/* default */ PosixMetadataLogScanner.Terminal reject(
MetadataTransactionId transactionId, int failureCode) throws IOException {
operationLock.lock();
try {
requireOperational();
ActiveTransaction transaction = requireActive(transactionId);
if (transaction.nextSequence != FIRST_TRANSACTION_SEQUENCE || activeBatch != null) {
throw new IllegalArgumentException(
"Rejected POSIX metadata transaction must contain no mutation frames");
}
byte[] payload = PosixMetadataLogScanner.encodeRejected(
transactionId, failureCode, transaction.chainState);
PosixMetadataLogScanner.Terminal result = new PosixMetadataLogScanner.Terminal(
PosixMetadataLogScanner.TerminalKind.NOT_COMMITTED,
java.util.OptionalLong.empty(), java.util.OptionalInt.of(failureCode));
return appendTerminal(transactionId, transaction,
MetadataFrameCodec.FrameType.TERMINAL_NOT_COMMITTED, payload, result);
} finally {
operationLock.unlock();
}
}
/* default */ int activeTransactionCount() throws IOException {
operationLock.lock();
try {
requireOpenAuthority();
return active.size();
} finally {
operationLock.unlock();
}
}
/* default */ PosixMetadataLogScanner.RecoveryResult scan() throws IOException {
operationLock.lock();
try {
requireOpenAuthority();
return PosixMetadataLogScanner.scan(channel);
} finally {
operationLock.unlock();
}
}
private PosixMetadataLogScanner.Terminal appendTerminal(
MetadataTransactionId transactionId,
ActiveTransaction transaction,
MetadataFrameCodec.FrameType type,
byte[] payload,
PosixMetadataLogScanner.Terminal result) throws IOException {
try {
trip(FaultPoint.TERMINAL_APPEND);
appendBounded(type, transactionId.token(), transaction.nextSequence, payload);
forceFile();
} catch (IOException failure) {
state = State.RECOVERY_REQUIRED;
throw new OutcomeUnknownException(failure);
}
active.remove(transactionId);
activeBatch = null;
try {
trip(FaultPoint.POST_FORCE_UNCERTAINTY);
} catch (IOException hidden) {
state = State.RECOVERY_REQUIRED;
throw new OutcomeUnknownException(hidden);
}
return result;
}
private void appendStoreHeader() throws IOException {
appendBounded(
MetadataFrameCodec.FrameType.STORE_HEADER,
PosixMetadataLogScanner.zeroToken(),
STORE_HEADER_SEQUENCE,
PosixMetadataLogScanner.encodeStoreHeader(storeId));
forceFile();
}
private void appendRecoveryRestart(long abandonedOpenCount, long truncatedByteCount)
throws IOException {
long nextEpoch = checkedWritableRecoveryEpoch(recoveryEpoch);
trip(FaultPoint.RECOVERY_RESTART_APPEND);
appendBounded(
MetadataFrameCodec.FrameType.RECOVERY_RESTART,
PosixMetadataLogScanner.zeroToken(),
nextEpoch,
PosixMetadataLogScanner.encodeRestart(
nextEpoch, abandonedOpenCount, truncatedByteCount));
forceFile();
recoveryEpoch = nextEpoch;
}
private MetadataFrameCodec.FrameMetadata appendBounded(
MetadataFrameCodec.FrameType type, String token, long sequence, byte[] payload) throws IOException {
channel.position(channel.size());
try (ImmutableByteContent content = new ImmutableByteContent(payload)) {
return codec.write(channel, type, token, sequence, payload.length, content, CancellationSignal.NONE);
}
}
private void forceFile() throws IOException {
trip(FaultPoint.FILE_FORCE);
channel.force(true);
}
private ActiveTransaction requireActive(MetadataTransactionId transactionId) {
requireAuthority(transactionId);
ActiveTransaction transaction = active.get(transactionId);
if (transaction == null) {
throw new IllegalArgumentException("POSIX metadata transaction is not active in this log instance");
}
return transaction;
}
private void requireBatch(MetadataTransactionId transactionId) {
if (activeBatch == null) {
activeBatch = transactionId;
} else if (!activeBatch.equals(transactionId)) {
throw new IllegalArgumentException("POSIX metadata mutation batches cannot interleave");
}
}
private void requireTerminalBatch(
MetadataTransactionId transactionId, ActiveTransaction transaction) {
if (transaction.nextSequence == FIRST_TRANSACTION_SEQUENCE) {
if (activeBatch != null) {
throw new IllegalArgumentException(
"Empty terminal cannot interleave with a metadata mutation batch");
}
} else if (!transactionId.equals(activeBatch)) {
throw new IllegalArgumentException("POSIX metadata terminal does not own the active batch");
}
}
private void requireAuthority(MetadataTransactionId transactionId) {
Objects.requireNonNull(transactionId, "transactionId");
if (!storeId.equals(transactionId.storeId())) {
throw new IllegalArgumentException("POSIX metadata transaction belongs to another store");
}
}
private static void requireMutationType(MetadataFrameCodec.FrameType type) {
Objects.requireNonNull(type, "type");
if (type != MetadataFrameCodec.FrameType.MUTATION_CREATE
&& type != MetadataFrameCodec.FrameType.MUTATION_REPLACE
&& type != MetadataFrameCodec.FrameType.MUTATION_DELETE) {
throw new IllegalArgumentException("POSIX metadata log requires an opaque mutation frame type");
}
}
private void requireOperational() throws IOException {
requireOpenAuthority();
if (state == State.RECOVERY_REQUIRED) {
throw new IOException("POSIX metadata log requires close and recovery");
}
}
private void requireOpenAuthority() throws IOException {
if (state == State.CLOSED || !channel.isOpen()) {
throw new IllegalStateException("POSIX metadata log is closed");
}
// The retained lock is the sole writer authority for this log instance.
if (!writerLock.isValid()) {
state = State.RECOVERY_REQUIRED;
throw new IOException("POSIX metadata log writer authority is invalid");
}
}
private void warnIfLimited(boolean limited) {
if (limited && !capabilityWarningEmitted) {
LOGGER.warning(CAPABILITY_WARNING);
capabilityWarningEmitted = true;
}
}
private void trip(FaultPoint point) throws IOException {
faultInjector.fail(point);
}
@Override
public void close() throws IOException {
operationLock.lock();
try {
if (state == State.CLOSED) {
return;
}
IOException failure = null;
try {
if (writerLock.isValid()) {
writerLock.release();
}
} catch (IOException releaseFailure) {
failure = releaseFailure;
}
try {
channel.close();
} catch (IOException closeFailure) {
failure = appendFailure(failure, closeFailure);
}
active.clear();
activeBatch = null;
state = State.CLOSED;
if (failure != null) {
throw failure;
}
} finally {
operationLock.unlock();
}
}
private static IOException appendFailure(IOException first, IOException later) {
if (first == null) {
return later;
}
first.addSuppressed(later);
return first;
}
/** Isolates create/reopen branching from the retained writer authority. */
private static final class Lifecycle {
private static PosixMetadataLog create(
Path logPath,
MetadataStoreId storeId,
CapabilityProfile capabilities,
FaultInjector faults) throws IOException {
Objects.requireNonNull(storeId, "storeId");
Objects.requireNonNull(capabilities, "capabilities");
Objects.requireNonNull(faults, "faults");
Path parent = SecureFiles.requireParent(logPath);
boolean posix = capabilities.posixAvailable(parent);
boolean local = capabilities.localFileSystem(parent);
Resources resources = Resources.acquire(logPath, true, posix);
try {
PosixMetadataLog log = new PosixMetadataLog(
resources.channel, resources.writerLock, storeId,
PosixMetadataLogScanner.TransactionCounter.first(), 0L, faults);
log.warnIfLimited(!posix || !local);
log.appendStoreHeader();
forceParent(capabilities, resources, log);
resources.closeAnchors();
return log;
} catch (IOException failure) {
resources.closeAfterFailure(failure);
throw failure;
}
}
private static PosixMetadataLog open(
Path logPath,
CapabilityProfile capabilities,
FaultInjector faults) throws IOException {
Objects.requireNonNull(capabilities, "capabilities");
Objects.requireNonNull(faults, "faults");
Path parent = SecureFiles.requireParent(logPath);
boolean posix = capabilities.posixAvailable(parent);
boolean local = capabilities.localFileSystem(parent);
Resources resources = Resources.acquire(logPath, false, posix);
try {
PosixMetadataLogScanner.RecoveryResult recovered =
PosixMetadataLogScanner.scan(resources.channel);
long removed = Math.subtractExact(
recovered.physicalEnd(), recovered.lastCompleteFrameBoundary());
recovered = repair(resources.channel, recovered, faults);
PosixMetadataLog log = recoveredLog(resources, recovered, faults);
log.warnIfLimited(!posix || !local);
log.appendRecoveryRestart(recovered.openTransactionCount(), removed);
reportRestart(recovered.openTransactionCount(), removed);
resources.closeAnchors();
return log;
} catch (IOException failure) {
resources.closeAfterFailure(failure);
throw failure;
}
}
private static EngineOpen openEngine(
Path logPath,
CapabilityProfile capabilities,
FaultInjector faults) throws IOException {
Objects.requireNonNull(capabilities, "capabilities");
Objects.requireNonNull(faults, "faults");
Path parent = SecureFiles.requireParent(logPath);
boolean posix = capabilities.posixAvailable(parent);
boolean local = capabilities.localFileSystem(parent);
Resources resources = Resources.acquire(logPath, false, posix);
try {
PosixMetadataLogScanner.ReplayResult recovered =
PosixMetadataLogScanner.replay(resources.channel);
long removed = Math.subtractExact(
recovered.physicalEnd(), recovered.lastCompleteFrameBoundary());
recovered = repair(resources.channel, recovered, faults);
PosixMetadataLog log = recoveredLog(resources, recovered, faults);
log.warnIfLimited(!posix || !local);
resources.channel.position(recovered.lastCompleteFrameBoundary());
log.appendRecoveryRestart(recovered.openTransactionCount(), removed);
reportRestart(recovered.openTransactionCount(), removed);
resources.closeAnchors();
return new EngineOpen(
log,
recovered.stateIndex(),
mergeRestartOutcomes(recovered.outcomes(), recovered.restartOutcomes()));
} catch (IOException failure) {
resources.closeAfterFailure(failure);
throw failure;
}
}
private static PosixMetadataLog recoveredLog(
Resources resources,
PosixMetadataLogScanner.RecoveryResult recovered,
FaultInjector faults) {
return new PosixMetadataLog(
resources.channel, resources.writerLock, recovered.storeId(),
PosixMetadataLogScanner.TransactionCounter.recovered(
recovered.nextTransactionToken(), recovered.transactionTokensExhausted()),
recovered.recoveryEpoch(), faults);
}
private static PosixMetadataLog recoveredLog(
Resources resources,
PosixMetadataLogScanner.ReplayResult recovered,
FaultInjector faults) {
return new PosixMetadataLog(
resources.channel, resources.writerLock, recovered.storeId(),
PosixMetadataLogScanner.TransactionCounter.recovered(
recovered.nextTransactionToken(), recovered.transactionTokensExhausted()),
recovered.recoveryEpoch(), faults);
}
private static PosixMetadataLogScanner.RecoveryResult repair(
FileChannel channel,
PosixMetadataLogScanner.RecoveryResult recovered,
FaultInjector faults) throws IOException {
if (recovered.tail() == PosixMetadataLogScanner.Tail.INCOMPLETE_TAIL) {
return TailRepair.repair(channel, recovered, faults);
}
return recovered;
}
private static PosixMetadataLogScanner.ReplayResult repair(
FileChannel channel,
PosixMetadataLogScanner.ReplayResult recovered,
FaultInjector faults) throws IOException {
if (recovered.tail() == PosixMetadataLogScanner.Tail.INCOMPLETE_TAIL) {
return TailRepair.repair(channel, recovered, faults);
}
return recovered;
}
private static Map<MetadataTransactionId, PosixMetadataLogScanner.Terminal>
mergeRestartOutcomes(
Map<MetadataTransactionId, PosixMetadataLogScanner.Terminal> outcomes,
Map<MetadataTransactionId, PosixMetadataLogScanner.Terminal> restartOutcomes) {
Map<MetadataTransactionId, PosixMetadataLogScanner.Terminal> merged =
new HashMap<>(outcomes);
merged.putAll(restartOutcomes);
return Map.copyOf(merged);
}
private static void forceParent(
CapabilityProfile capabilities,
Resources resources,
PosixMetadataLog log) throws IOException {
try {
capabilities.forceParent(resources.directoryChannel);
} catch (IOException | UnsupportedOperationException unsupported) {
log.warnIfLimited(true);
}
}
private static void reportRestart(long abandonedOpenCount, long truncatedByteCount) {
if (truncatedByteCount > MINIMUM_PAYLOAD_LENGTH) {
LOGGER.warning(TAIL_REPAIR_WARNING);
} else if (abandonedOpenCount > MINIMUM_PAYLOAD_LENGTH) {
LOGGER.info(ABANDONED_RESTART_INFO);
}
}
private static long checkedWritableRecoveryEpoch(long currentEpoch)
throws MetadataStoreException {
if (currentEpoch == Long.MAX_VALUE) {
throw new MetadataStoreException(
MetadataCommitResult.FailureCategory.LIMIT_EXCEEDED,
"POSIX metadata recovery epoch is exhausted");
}
return PosixMetadataLogScanner.checkedNextRecoveryEpoch(currentEpoch);
}
}
/** O(1) repair of one scanner-authenticated incomplete final tail. */
private static final class TailRepair {
private static PosixMetadataLogScanner.RecoveryResult repair(
FileChannel channel,
PosixMetadataLogScanner.RecoveryResult before,
FaultInjector faults) throws IOException {
truncateAndVerify(channel, before.lastCompleteFrameBoundary(), faults);
return new PosixMetadataLogScanner.RecoveryResult(
before.storeId(),
before.lastCompleteFrameBoundary(),
before.lastCompleteFrameBoundary(),
PosixMetadataLogScanner.Tail.CLEAN_END,
before.transactions(),
before.nextTransactionToken(),
before.transactionTokensExhausted(),
before.recoveryEpoch(),
before.openTransactionCount());
}
private static PosixMetadataLogScanner.ReplayResult repair(
FileChannel channel,
PosixMetadataLogScanner.ReplayResult before,
FaultInjector faults) throws IOException {
truncateAndVerify(channel, before.lastCompleteFrameBoundary(), faults);
return new PosixMetadataLogScanner.ReplayResult(
before.storeId(),
before.lastCompleteFrameBoundary(),
before.lastCompleteFrameBoundary(),
PosixMetadataLogScanner.Tail.CLEAN_END,
before.nextTransactionToken(),
before.transactionTokensExhausted(),
before.stateIndex(),
before.outcomes(),
before.restartOutcomes(),
before.recoveryEpoch(),
before.openTransactionCount(),
before.recoveryFullMapCopies(),
before.recoveryFinalInstalls(),
before.discardedDescriptorCount());
}
private static void truncateAndVerify(
FileChannel channel, long boundary, FaultInjector faults) throws IOException {
faults.fail(FaultPoint.TAIL_TRUNCATE);
channel.truncate(boundary);
faults.fail(FaultPoint.TAIL_FORCE);
channel.force(true);
faults.fail(FaultPoint.TAIL_VERIFY);
if (channel.size() != boundary) {
throw new MetadataStoreException(
MetadataCommitResult.FailureCategory.INTEGRITY_FAILURE,
"POSIX metadata log tail repair verification failed");
}
channel.position(boundary);
}
}
/** Live lifecycle of one retained POSIX log authority. */
private enum State {
OPEN,
RECOVERY_REQUIRED,
CLOSED
}
/** O(1) live control state for one transaction. */
private static final class ActiveTransaction {
private byte[] chainState;
private long nextSequence = FIRST_TRANSACTION_SEQUENCE;
private ActiveTransaction(byte[] chainState) {
this.chainState = chainState;
}
}
/** Caller-visible uncertainty requiring strict scanner classification. */
/* default */ static final class OutcomeUnknownException extends IOException {
private static final long serialVersionUID = -4335785072683425908L;
private OutcomeUnknownException(IOException cause) {
super("POSIX metadata transaction terminal outcome requires recovery", cause);
}
}
/** Deterministic package-private fault boundaries. */
/* default */ enum FaultPoint {
ISSUANCE_APPEND,
MUTATION_APPEND,
TERMINAL_APPEND,
FILE_FORCE,
POST_FORCE_UNCERTAINTY,
TAIL_TRUNCATE,
TAIL_FORCE,
TAIL_VERIFY,
RECOVERY_RESTART_APPEND
}
/** Package-private deterministic fault injector; it is not a production extension point. */
/* default */
@FunctionalInterface
interface FaultInjector {
FaultInjector NONE = point -> { };
/** Fails one selected append or durability boundary. */
void fail(FaultPoint point) throws IOException;
}
/** Predicted immutable frame location used before one serialized append batch. */
/* default */ record MutationLocation(long payloadOffset, long frameEndOffset) {
}
/** One-scan engine opening result retaining the reconstructed current state. */
/* default */ record EngineOpen(
PosixMetadataLog log,
MetadataStateIndex stateIndex,
Map<MetadataTransactionId, PosixMetadataLogScanner.Terminal> outcomes) {
EngineOpen {
Objects.requireNonNull(log, "log");
Objects.requireNonNull(stateIndex, "stateIndex");
outcomes = Map.copyOf(outcomes);
}
}
/** Package-private capability seam used to verify fail-closed initialization behavior. */
/* default */
interface CapabilityProfile {
/** Reports whether owner-only POSIX creation attributes are supported. */
boolean posixAvailable(Path parent) throws IOException;
/** Reports whether the filesystem is recognized as local. */
boolean localFileSystem(Path parent) throws IOException;
/** Forces the descriptor-relative parent-directory channel. */
void forceParent(FileChannel parentDirectory) throws IOException;
}
/** Default capability observations for the active Java filesystem provider. */
private enum DefaultCapabilityProfile implements CapabilityProfile {
INSTANCE;
@Override
public boolean posixAvailable(Path parent) throws IOException {
return Files.getFileStore(parent).supportsFileAttributeView("posix");
}
@Override
public boolean localFileSystem(Path parent) throws IOException {
String type = Files.getFileStore(parent).type().toLowerCase(Locale.ROOT);
return RECOGNIZED_LOCAL_FILE_SYSTEMS.contains(type);
}
@Override
public void forceParent(FileChannel parentDirectory) throws IOException {
parentDirectory.force(true);
}
}
/** Secure descriptor-relative opening for the trusted POSIX directory profile. */
private static final class SecureFiles {
private static Path requireParent(Path logPath) throws IOException {
Objects.requireNonNull(logPath, "logPath");
Path parent = logPath.getParent();
if (parent == null || logPath.getFileName() == null
|| !Files.isDirectory(parent, LinkOption.NOFOLLOW_LINKS)
|| Files.isSymbolicLink(parent)) {
throw new MetadataStoreException(
MetadataCommitResult.FailureCategory.INTEGRITY_FAILURE,
"POSIX metadata log target has no trusted regular parent directory");
}
return parent;
}
private static SecureDirectoryStream<Path> openDirectory(Path parent) throws IOException {
return requireSecureDirectory(Files.newDirectoryStream(parent));
}
private static SecureDirectoryStream<Path> requireSecureDirectory(DirectoryStream<Path> opened)
throws IOException {
if (opened instanceof SecureDirectoryStream<?>) {
@SuppressWarnings("unchecked")
SecureDirectoryStream<Path> secure = (SecureDirectoryStream<Path>) opened;
return secure;
}
opened.close();
throw new MetadataStoreException(
MetadataCommitResult.FailureCategory.UNSUPPORTED_CAPABILITY,
"Secure POSIX metadata directory access is unavailable");
}
private static FileChannel fileChannel(SeekableByteChannel selected) throws IOException {
if (selected instanceof FileChannel) {
return (FileChannel) selected;
}
selected.close();
throw new MetadataStoreException(
MetadataCommitResult.FailureCategory.UNSUPPORTED_CAPABILITY,
"POSIX metadata file forcing is unavailable");
}
private static Set<OpenOption> logOptions(boolean create) {
Set<OpenOption> options = new HashSet<>();
options.add(StandardOpenOption.READ);
options.add(StandardOpenOption.WRITE);
options.add(LinkOption.NOFOLLOW_LINKS);
if (create) {
options.add(StandardOpenOption.CREATE_NEW);
}
return Set.copyOf(options);
}
private static FileAttribute<?>[] attributes(boolean posix) {
if (posix) {
return new FileAttribute<?>[] { PosixFilePermissions.asFileAttribute(OWNER_ONLY) };
}
return new FileAttribute<?>[0];
}
private static void requireRegularEntry(
SecureDirectoryStream<Path> directory, Path name, boolean create) throws IOException {
if (create) {
return;
}
BasicFileAttributeView view = directory.getFileAttributeView(
name, BasicFileAttributeView.class, LinkOption.NOFOLLOW_LINKS);
BasicFileAttributes attributes = view.readAttributes();
if (!attributes.isRegularFile() || attributes.isSymbolicLink()) {
throw new MetadataStoreException(
MetadataCommitResult.FailureCategory.INTEGRITY_FAILURE,
"POSIX metadata log entry is not a regular file");
}
}
}
/** Owns initialization anchors and transfers only the retained log authority. */
private static final class Resources {
private SecureDirectoryStream<Path> directory;
private FileChannel directoryChannel;
private FileChannel channel;
private FileLock writerLock;
private static Resources acquire(Path logPath, boolean create, boolean posix) throws IOException {
Resources resources = new Resources();
try {
Path parent = SecureFiles.requireParent(logPath);
resources.directory = SecureFiles.openDirectory(parent);
resources.directoryChannel = SecureFiles.fileChannel(resources.directory.newByteChannel(
Path.of("."), Set.of(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)));
Path name = logPath.getFileName();
SecureFiles.requireRegularEntry(resources.directory, name, create);
resources.channel = SecureFiles.fileChannel(resources.directory.newByteChannel(
name, SecureFiles.logOptions(create), SecureFiles.attributes(posix)));
resources.writerLock = resources.acquireWriterLock();
return resources;
} catch (IOException failure) {
resources.closeAfterFailure(failure);
throw failure;
}
}
private FileLock acquireWriterLock() throws IOException {
final FileLock lock;
try {
lock = channel.tryLock();
} catch (OverlappingFileLockException contention) {
throw new MetadataStoreException(
MetadataCommitResult.FailureCategory.STORAGE_FAILURE,
"POSIX metadata log exclusive writer authority is unavailable", contention);
}
if (lock == null) {
throw new MetadataStoreException(
MetadataCommitResult.FailureCategory.STORAGE_FAILURE,
"POSIX metadata log exclusive writer authority is unavailable");
}
return lock;
}
private void closeAnchors() throws IOException {
IOException failure = null;
try {
if (directoryChannel != null) {
directoryChannel.close();
}
} catch (IOException closeFailure) {
failure = closeFailure;
}
try {
if (directory != null) {
directory.close();
}
} catch (IOException closeFailure) {
failure = appendFailure(failure, closeFailure);
}
directoryChannel = null;
directory = null;
if (failure != null) {
throw failure;
}
}
private void closeAfterFailure(IOException primary) {
IOException cleanup = closeAll();
if (cleanup != null) {
primary.addSuppressed(cleanup);
}
}
private IOException closeAll() {
IOException failure = null;
try {
if (writerLock != null && writerLock.isValid()) {
writerLock.release();
}
} catch (IOException closeFailure) {
failure = closeFailure;
}
try {
if (channel != null) {
channel.close();
}
} catch (IOException closeFailure) {
failure = appendFailure(failure, closeFailure);
}
try {
closeAnchors();
} catch (IOException closeFailure) {
failure = appendFailure(failure, closeFailure);
}
return failure;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,559 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.impl.fs;
import java.io.IOException;
import java.io.InputStream;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.NavigableMap;
import java.util.Objects;
import java.util.Optional;
import java.util.OptionalLong;
import java.util.Set;
import java.util.TreeMap;
import java.util.concurrent.locks.ReentrantLock;
import java.util.logging.Logger;
import zeroecho.core.io.CancellationSignal;
import zeroecho.pki.spi.store.MetadataCursor;
import zeroecho.pki.spi.store.MetadataKey;
import zeroecho.pki.spi.store.MetadataSnapshot;
import zeroecho.pki.spi.store.MetadataStoreId;
/** Stable snapshot, lazy cursor, and bounded log-slice content lifecycles. */
final class PosixMetadataSnapshotSupport {
private static final Logger LOGGER = Logger.getLogger(PosixMetadataSnapshotSupport.class.getName());
private static final String CLEANUP_WARNING =
"POSIX metadata snapshot resources could not be fully retired";
private final PosixMetadataStoreEngine engine;
private final PosixMetadataAdapterLifecycle lifecycle;
private final MetadataStoreId storeId;
private final SliceOpener sliceOpener;
/* default */ PosixMetadataSnapshotSupport(
PosixMetadataStoreEngine engine,
PosixMetadataAdapterLifecycle lifecycle,
MetadataStoreId storeId) {
this(engine, lifecycle, storeId, engine::openValueSlice);
}
/* default */ PosixMetadataSnapshotSupport(
PosixMetadataStoreEngine engine,
PosixMetadataAdapterLifecycle lifecycle,
MetadataStoreId storeId,
SliceOpener sliceOpener) {
this.engine = Objects.requireNonNull(engine, "engine");
this.lifecycle = Objects.requireNonNull(lifecycle, "lifecycle");
this.storeId = Objects.requireNonNull(storeId, "storeId");
this.sliceOpener = Objects.requireNonNull(sliceOpener, "sliceOpener");
}
/* default */ MetadataSnapshot open() throws IOException {
return lifecycle.openManaged(() -> new SnapshotImpl(engine.snapshotState()));
}
/** Finite immutable snapshot metadata; no payload byte is copied. */
private final class SnapshotImpl
implements MetadataSnapshot, PosixMetadataAdapterLifecycle.ManagedResource {
private final long revision;
private final NavigableMap<MetadataKey, RecordMetadata> records;
private final ReentrantLock lock = new ReentrantLock();
private final Set<RecordImpl> recordChildren =
Collections.newSetFromMap(new IdentityHashMap<>());
private final Set<CursorImpl> cursorChildren =
Collections.newSetFromMap(new IdentityHashMap<>());
private boolean closed;
private SnapshotImpl(PosixMetadataStoreEngine.SnapshotState captured) {
super();
revision = captured.revision();
NavigableMap<MetadataKey, RecordMetadata> detached = new TreeMap<>();
captured.records().forEach(record ->
detached.put(record.key(), new RecordMetadata(record)));
records = Collections.unmodifiableNavigableMap(detached);
}
@Override
public MetadataStoreId storeId() {
requireOpen();
return storeId;
}
@Override
public long revision() {
requireOpen();
return revision;
}
@Override
public Optional<MetadataSnapshot.Record> get(MetadataKey key) {
Objects.requireNonNull(key, "key");
lock.lock();
try {
requireOpenLocked();
RecordMetadata metadata = records.get(key);
return metadata == null
? Optional.empty()
: Optional.of(createRecordLocked(metadata));
} finally {
lock.unlock();
}
}
@Override
public MetadataCursor scan(KeyRange range, CancellationSignal cancellation)
throws IOException {
Objects.requireNonNull(range, "range");
Objects.requireNonNull(cancellation, "cancellation").throwIfCancelled();
lock.lock();
try {
requireOpenLocked();
CursorImpl cursor = new CursorImpl(range, records.entrySet().iterator());
cursorChildren.add(cursor);
return cursor;
} finally {
lock.unlock();
}
}
@Override
public void close() {
IOException failure = forceClose();
lifecycle.unregister(this);
if (failure != null) {
warnCleanupFailure();
}
}
@Override
public IOException forceClose() {
List<CursorImpl> cursors;
List<RecordImpl> children;
lock.lock();
try {
if (closed) {
return null;
}
closed = true;
cursors = List.copyOf(cursorChildren);
children = List.copyOf(recordChildren);
cursorChildren.clear();
recordChildren.clear();
} finally {
lock.unlock();
}
IOException failure = closeOwnedCursors(cursors);
return PosixMetadataAdapterLifecycle.append(failure, closeOwnedRecords(children));
}
private IOException closeOwnedCursors(List<CursorImpl> owned) {
IOException failure = null;
Iterator<CursorImpl> iterator = owned.iterator();
while (iterator.hasNext()) {
failure = PosixMetadataAdapterLifecycle.append(
failure, iterator.next().forceClose());
}
return failure;
}
private IOException closeOwnedRecords(List<RecordImpl> owned) {
IOException failure = null;
Iterator<RecordImpl> iterator = owned.iterator();
while (iterator.hasNext()) {
failure = PosixMetadataAdapterLifecycle.append(
failure, iterator.next().forceClose());
}
return failure;
}
private RecordImpl createRecordLocked(RecordMetadata metadata) {
RecordImpl record = new RecordImpl(metadata);
recordChildren.add(record);
return record;
}
private InputStream openStream(RecordImpl record) throws IOException {
InputStream slice = lifecycle.read(() -> sliceOpener.open(
record.metadata.valueOffset(), record.metadata.valueLength()));
lock.lock();
try {
requireOpenLocked();
return record.register(slice);
} catch (IllegalStateException failure) {
try {
slice.close();
} catch (IOException cleanup) {
failure.addSuppressed(cleanup);
}
throw failure;
} finally {
lock.unlock();
}
}
private void requireOpen() {
lock.lock();
try {
requireOpenLocked();
} finally {
lock.unlock();
}
}
private void requireOpenLocked() {
if (closed) {
throw new IllegalStateException("Metadata snapshot is closed");
}
}
/** Cursor retains only one map iterator and one current record. */
private final class CursorImpl implements MetadataCursor {
private final KeyRange range;
private final Iterator<Map.Entry<MetadataKey, RecordMetadata>> iterator;
private RecordImpl current;
private boolean cursorClosed;
private CursorImpl(
KeyRange range,
Iterator<Map.Entry<MetadataKey, RecordMetadata>> iterator) {
super();
this.range = range;
this.iterator = iterator;
}
@Override
public Optional<MetadataSnapshot.Record> next(CancellationSignal cancellation)
throws IOException {
CancellationSignal checked = Objects.requireNonNull(cancellation, "cancellation");
checked.throwIfCancelled();
lock.lock();
try {
requireOpenLocked();
requireCursorOpen();
closeCurrent();
} finally {
lock.unlock();
}
while (true) {
checked.throwIfCancelled();
lock.lock();
try {
requireOpenLocked();
requireCursorOpen();
if (!iterator.hasNext()) {
return Optional.empty();
}
Map.Entry<MetadataKey, RecordMetadata> candidate = iterator.next();
if (range.contains(candidate.getKey())) {
current = createRecordLocked(candidate.getValue());
return Optional.of(current);
}
} finally {
lock.unlock();
}
}
}
@Override
public void close() {
IOException failure = forceClose();
lock.lock();
try {
cursorChildren.remove(this);
} finally {
lock.unlock();
}
if (failure != null) {
warnCleanupFailure();
}
}
private IOException forceClose() {
IOException failure = null;
lock.lock();
try {
if (!cursorClosed) {
cursorClosed = true;
failure = closeCurrentFailure();
}
} finally {
lock.unlock();
}
return failure;
}
private void closeCurrent() throws IOException {
if (current != null) {
current.close();
current = null;
}
}
private IOException closeCurrentFailure() {
try {
closeCurrent();
return null;
} catch (IOException failure) {
return failure;
}
}
private void requireCursorOpen() {
if (cursorClosed) {
throw new IllegalStateException("Metadata cursor is closed");
}
}
}
/** Snapshot record whose streams are closed at the snapshot boundary. */
private final class RecordImpl implements MetadataSnapshot.Record {
private final RecordMetadata metadata;
private final Set<OwnedInputStream> streams =
Collections.newSetFromMap(new IdentityHashMap<>());
private boolean recordClosed;
private RecordImpl(RecordMetadata metadata) {
super();
this.metadata = metadata;
}
@Override
public MetadataKey key() {
requireRecordOpen();
return metadata.key();
}
@Override
public long recordRevision() {
requireRecordOpen();
return metadata.revision();
}
@Override
public long commitRevision() {
requireRecordOpen();
return metadata.revision();
}
@Override
public Optional<Integrity> integrity() {
requireRecordOpen();
return Optional.empty();
}
@Override
public InputStream openStream() throws IOException {
requireRecordOpen();
return SnapshotImpl.this.openStream(this);
}
@Override
public OptionalLong length() {
requireRecordOpen();
return OptionalLong.of(metadata.valueLength());
}
@Override
public String contentId() {
requireRecordOpen();
return "zeroecho-posix-metadata-record-v1:" + storeId.value()
+ ':' + metadata.key().canonical() + ':' + metadata.revision();
}
@Override
public void close() throws IOException {
List<OwnedInputStream> detached;
lock.lock();
try {
if (recordClosed) {
return;
}
recordClosed = true;
detached = List.copyOf(streams);
streams.clear();
recordChildren.remove(this);
} finally {
lock.unlock();
}
IOException failure = closeStreams(detached);
if (failure != null) {
throw failure;
}
}
private OwnedInputStream register(InputStream slice) {
requireRecordOpen();
OwnedInputStream result = new OwnedInputStream(this, slice);
streams.add(result);
return result;
}
private void unregister(OwnedInputStream stream) {
lock.lock();
try {
streams.remove(stream);
} finally {
lock.unlock();
}
}
private IOException forceClose() {
try {
close();
return null;
} catch (IOException failure) {
return failure;
}
}
private void requireRecordOpen() {
lock.lock();
try {
requireOpenLocked();
if (recordClosed) {
throw new IllegalStateException("Metadata snapshot record is closed");
}
} finally {
lock.unlock();
}
}
}
private static IOException closeStreams(List<OwnedInputStream> streams) {
IOException failure = null;
Iterator<OwnedInputStream> iterator = streams.iterator();
while (iterator.hasNext()) {
failure = PosixMetadataAdapterLifecycle.append(
failure, iterator.next().closeFailure());
}
return failure;
}
/** Bounded reader invalidated with its owning record. */
private final class OwnedInputStream extends InputStream {
private final RecordImpl owner;
private final InputStream delegate;
private boolean streamClosed;
private OwnedInputStream(RecordImpl owner, InputStream delegate) {
super();
this.owner = owner;
this.delegate = delegate;
}
@Override
public int read() throws IOException {
requireStreamOpen();
return delegate.read();
}
@Override
public int read(byte[] target, int offset, int length) throws IOException {
requireStreamOpen();
return delegate.read(target, offset, length);
}
@Override
public void close() throws IOException {
if (streamClosed) {
return;
}
streamClosed = true;
try {
delegate.close();
} finally {
owner.unregister(this);
}
}
private void closeDelegate() throws IOException {
if (!streamClosed) {
streamClosed = true;
delegate.close();
}
}
private IOException closeFailure() {
try {
closeDelegate();
return null;
} catch (IOException failure) {
return failure;
}
}
private void requireStreamOpen() {
if (streamClosed) {
throw new IllegalStateException("Metadata record stream is closed");
}
owner.requireRecordOpen();
}
}
}
private static void warnCleanupFailure() {
try {
LOGGER.warning(CLEANUP_WARNING);
} catch (IllegalStateException ignored) {
// A non-fatal logging-handler failure cannot break logical close.
}
}
/** Narrow record-slice seam used to verify child-resource cleanup. */
/* default */
@FunctionalInterface
interface SliceOpener {
/**
* Opens one bounded durable-value region.
*
* @param offset absolute value offset
* @param length exact value length
* @return bounded stream
* @throws IOException when the value region cannot be opened
*/
InputStream open(long offset, long length) throws IOException;
}
/** Finite immutable durable value location. */
private record RecordMetadata(
MetadataKey key, long revision, long valueOffset, long valueLength) {
private RecordMetadata(MetadataStateIndex.CurrentRecord record) {
this(record.key(), record.revision(), record.valueOffset(), record.valueLength());
}
}
}

View File

@@ -0,0 +1,820 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.impl.fs;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.OptionalInt;
import java.util.OptionalLong;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.locks.ReentrantLock;
import java.util.logging.Logger;
import zeroecho.core.io.CancellationSignal;
import zeroecho.core.io.RepeatableContent;
import zeroecho.pki.spi.store.MetadataCommitResult;
import zeroecho.pki.spi.store.MetadataKey;
import zeroecho.pki.spi.store.MetadataStoreException;
import zeroecho.pki.spi.store.MetadataStoreId;
import zeroecho.pki.spi.store.MetadataTransactionId;
/** Internal serialized transaction engine over the durable POSIX metadata log. */
final class PosixMetadataStoreEngine implements AutoCloseable {
private static final int CONFLICT_FAILURE_CODE = 1;
private static final long MINIMUM_VALUE_BOUNDARY = 0L;
private static final Logger LOGGER = Logger.getLogger(PosixMetadataStoreEngine.class.getName());
private static final String CLEANUP_WARNING =
"POSIX metadata transaction resources could not be fully retired after a known outcome";
private final PosixMetadataLog log;
private final Path logPath;
private final MetadataStateIndex stateIndex;
private final FaultInjector faultInjector;
private final ReentrantLock transactionLock = new ReentrantLock();
private final Map<MetadataTransactionId, CommitResult> outcomes;
private State state = State.OPEN;
private PosixMetadataStoreEngine(
Path logPath,
PosixMetadataLog log,
MetadataStateIndex stateIndex,
FaultInjector faultInjector,
Map<MetadataTransactionId, CommitResult> outcomes) {
this.logPath = Objects.requireNonNull(logPath, "logPath")
.toAbsolutePath().normalize();
this.log = log;
this.stateIndex = stateIndex;
this.faultInjector = faultInjector;
this.outcomes = new HashMap<>(outcomes);
}
/* default */ static PosixMetadataStoreEngine create(Path path, MetadataStoreId storeId)
throws IOException {
return create(path, storeId, PosixMetadataLog.FaultInjector.NONE, FaultInjector.NONE);
}
/* default */ static PosixMetadataStoreEngine create(
Path path,
MetadataStoreId storeId,
PosixMetadataLog.FaultInjector logFaults,
FaultInjector engineFaults) throws IOException {
PosixMetadataLog log = PosixMetadataLog.create(
path, storeId, defaultCapabilities(), logFaults);
return new PosixMetadataStoreEngine(
path, log, new MetadataStateIndex(), engineFaults, Map.of());
}
/* default */ static PosixMetadataStoreEngine open(Path path) throws IOException {
return open(path, PosixMetadataLog.FaultInjector.NONE, FaultInjector.NONE);
}
/* default */ static PosixMetadataStoreEngine open(
Path path,
PosixMetadataLog.FaultInjector logFaults,
FaultInjector engineFaults) throws IOException {
PosixMetadataLog.EngineOpen opened =
PosixMetadataLog.openEngine(path, defaultCapabilities(), logFaults);
return new PosixMetadataStoreEngine(
path,
opened.log(),
opened.stateIndex(),
engineFaults,
recoveredOutcomes(opened.outcomes()));
}
/* default */ MetadataStoreId storeId() {
transactionLock.lock();
try {
requireOperational();
return log.storeId();
} finally {
transactionLock.unlock();
}
}
/* default */ MetadataTransactionId issue() throws IOException {
transactionLock.lock();
try {
requireOperational();
return log.issue();
} finally {
transactionLock.unlock();
}
}
/* default */ CommitResult commit(
MetadataTransactionId transactionId,
List<PreparedMutation> mutations) throws IOException {
transactionLock.lock();
try {
requireOperational();
List<PreparedMutation> detached = List.copyOf(mutations);
return commitOwned(transactionId, detached);
} finally {
transactionLock.unlock();
}
}
private CommitResult commitOwned(
MetadataTransactionId transactionId,
List<PreparedMutation> mutations) throws IOException {
try (PreparedResources resources = new PreparedResources(mutations)) {
CommitResult result = commitBatch(transactionId, mutations);
outcomes.put(transactionId, result);
resources.outcomeEstablished();
return result;
}
}
private CommitResult commitBatch(
MetadataTransactionId transactionId,
List<PreparedMutation> detached) throws IOException {
long revision = nextRevision();
List<MetadataMutationPayloadCodec.Descriptor> predicted = predict(detached);
Preparation preparation = Preparation.attempt(stateIndex, revision, predicted);
if (preparation.failure() != null) {
MetadataStoreException failure = preparation.failure();
if (failure.category() != MetadataCommitResult.FailureCategory.CONFLICT) {
throw new MetadataStoreException(
failure.category(),
"Metadata state preparation failed",
failure);
}
try {
log.reject(transactionId, CONFLICT_FAILURE_CODE);
return CommitResult.notCommitted(CONFLICT_FAILURE_CODE);
} catch (IOException uncertainty) {
enterRecoveryRequired();
throw uncertainty;
}
}
appendAndVerify(transactionId, detached, predicted);
try {
log.commit(transactionId, revision);
faultInjector.fail(FaultPoint.POST_FORCE_PUBLICATION);
stateIndex.publish(preparation.update());
return CommitResult.committed(revision);
} catch (IOException failure) {
enterRecoveryRequired();
throw new OutcomeUnknownException(failure);
}
}
private static void closePrepared(List<PreparedMutation> mutations) throws IOException {
IOException failure = null;
for (PreparedMutation mutation : mutations) {
try {
mutation.closeOwned();
} catch (IOException cleanup) {
failure = appendCleanupFailure(failure, cleanup);
}
}
if (failure != null) {
throw failure;
}
}
private static IOException appendCleanupFailure(IOException first, IOException later) {
if (first == null) {
return later;
}
first.addSuppressed(later);
return first;
}
/* default */ long storeRevision() throws IOException {
transactionLock.lock();
try {
requireOperational();
return stateIndex.storeRevision();
} finally {
transactionLock.unlock();
}
}
/* default */ Optional<MetadataStateIndex.CurrentRecord> lookup(MetadataKey key)
throws IOException {
transactionLock.lock();
try {
requireOperational();
return stateIndex.lookup(key);
} finally {
transactionLock.unlock();
}
}
/* default */ SnapshotState snapshotState() throws IOException {
transactionLock.lock();
try {
requireOperational();
return new SnapshotState(stateIndex.storeRevision(), stateIndex.records());
} finally {
transactionLock.unlock();
}
}
/* default */ InputStream openValueSlice(long offset, long length) throws IOException {
transactionLock.lock();
try {
requireOperational();
requireValueRegion(offset, length);
final long end;
try {
end = Math.addExact(offset, length);
} catch (ArithmeticException failure) {
throw integrity("Metadata record value boundary overflows", failure);
}
FileChannel channel = FileChannel.open(
logPath,
Set.of(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS));
if (end > channel.size()) {
MetadataStoreException failure =
integrity("Metadata record value exceeds the durable log boundary");
closeFailedSlice(channel, failure);
throw failure;
}
return new ValueSliceInputStream(channel, offset, length);
} finally {
transactionLock.unlock();
}
}
private static void closeFailedSlice(FileChannel channel, IOException failure) {
try {
channel.close();
} catch (IOException cleanup) {
failure.addSuppressed(cleanup);
}
}
private static void requireValueRegion(long offset, long length)
throws MetadataStoreException {
if (offset < MINIMUM_VALUE_BOUNDARY || length < MINIMUM_VALUE_BOUNDARY) {
throw integrity("Metadata record value region is negative");
}
}
/* default */ CommitResult resolve(MetadataTransactionId transactionId) throws IOException {
Objects.requireNonNull(transactionId, "transactionId");
transactionLock.lock();
try {
requireOperational();
if (!log.storeId().equals(transactionId.storeId())) {
throw new MetadataStoreException(
MetadataCommitResult.FailureCategory.FOREIGN_TRANSACTION,
"Metadata transaction belongs to another store");
}
CommitResult result = outcomes.get(transactionId);
if (result == null) {
throw new MetadataStoreException(
MetadataCommitResult.FailureCategory.TRANSACTION_NOT_ISSUED,
"Metadata transaction has no retained terminal outcome");
}
return result;
} finally {
transactionLock.unlock();
}
}
private long nextRevision() throws MetadataStoreException {
try {
return Math.addExact(stateIndex.storeRevision(), 1L);
} catch (ArithmeticException failure) {
throw new MetadataStoreException(
MetadataCommitResult.FailureCategory.LIMIT_EXCEEDED,
"POSIX metadata store revision is exhausted",
failure);
}
}
private List<MetadataMutationPayloadCodec.Descriptor> predict(
List<PreparedMutation> mutations) throws IOException {
List<Long> payloadLengths = new ArrayList<>(mutations.size());
for (PreparedMutation mutation : mutations) {
payloadLengths.add(mutation.payloadLength());
}
List<PosixMetadataLog.MutationLocation> locations =
log.predictMutationLocations(payloadLengths);
List<MetadataMutationPayloadCodec.Descriptor> descriptors =
new ArrayList<>(mutations.size());
for (int index = 0; index < mutations.size(); index++) {
descriptors.add(mutations.get(index).descriptor(locations.get(index).payloadOffset()));
}
return List.copyOf(descriptors);
}
private void appendAndVerify(
MetadataTransactionId transactionId,
List<PreparedMutation> mutations,
List<MetadataMutationPayloadCodec.Descriptor> predicted) throws IOException {
for (int index = 0; index < mutations.size(); index++) {
PreparedMutation mutation = mutations.get(index);
final MetadataFrameCodec.FrameMetadata frame;
try {
frame = log.appendMutation(
transactionId,
frameType(mutation.kind()),
mutation.payloadLength(),
mutation.payload(),
mutation.cancellation());
} catch (IOException failure) {
enterRecoveryRequired();
throw failure;
}
MetadataMutationPayloadCodec.Descriptor actual;
try {
actual = log.decodeMutation(frame);
} catch (IOException failure) {
enterRecoveryRequired();
throw failure;
}
if (!actual.equals(predicted.get(index))) {
enterRecoveryRequired();
throw integrity("Prepared metadata mutation differs from its durable frame");
}
}
}
private void requireOperational() {
if (state == State.CLOSED) {
throw new IllegalStateException("POSIX metadata store engine is closed");
}
if (state == State.RECOVERY_REQUIRED) {
throw new IllegalStateException("POSIX metadata store engine requires recovery");
}
}
private static MetadataFrameCodec.FrameType frameType(
MetadataMutationPayloadCodec.MutationKind kind) {
return switch (kind) {
case CREATE -> MetadataFrameCodec.FrameType.MUTATION_CREATE;
case REPLACE -> MetadataFrameCodec.FrameType.MUTATION_REPLACE;
case DELETE -> MetadataFrameCodec.FrameType.MUTATION_DELETE;
};
}
private void enterRecoveryRequired() {
state = State.RECOVERY_REQUIRED;
}
@Override
public void close() throws IOException {
transactionLock.lock();
try {
if (state == State.CLOSED) {
return;
}
state = State.CLOSED;
log.close();
} finally {
transactionLock.unlock();
}
}
private static PosixMetadataLog.CapabilityProfile defaultCapabilities() {
return new PosixMetadataLog.CapabilityProfile() {
@Override
public boolean posixAvailable(Path parent) throws IOException {
return Files.getFileStore(parent).supportsFileAttributeView("posix");
}
@Override
public boolean localFileSystem(Path parent) {
return true;
}
@Override
public void forceParent(FileChannel parentDirectory) throws IOException {
parentDirectory.force(true);
}
};
}
private static MetadataStoreException integrity(String message) {
return new MetadataStoreException(
MetadataCommitResult.FailureCategory.INTEGRITY_FAILURE, message);
}
private static MetadataStoreException integrity(String message, Throwable cause) {
return new MetadataStoreException(
MetadataCommitResult.FailureCategory.INTEGRITY_FAILURE, message, cause);
}
private static Map<MetadataTransactionId, CommitResult> recoveredOutcomes(
Map<MetadataTransactionId, PosixMetadataLogScanner.Terminal> recovered) {
Map<MetadataTransactionId, CommitResult> results = new HashMap<>();
for (Map.Entry<MetadataTransactionId, PosixMetadataLogScanner.Terminal> entry
: recovered.entrySet()) {
results.put(entry.getKey(), CommitResult.fromTerminal(entry.getValue()));
}
return Map.copyOf(results);
}
/** Immutable finite current-state metadata captured under engine serialization. */
/* default */ record SnapshotState(
long revision, List<MetadataStateIndex.CurrentRecord> records) {
SnapshotState {
if (revision < MINIMUM_VALUE_BOUNDARY) {
throw new IllegalArgumentException("Snapshot revision must not be negative");
}
records = List.copyOf(records);
}
}
/** Exactly bounded reader over one immutable append-only value region. */
private static final class ValueSliceInputStream extends InputStream {
private final FileChannel channel;
private long position;
private long remaining;
private boolean closed;
private ValueSliceInputStream(FileChannel channel, long position, long remaining) {
super();
this.channel = channel;
this.position = position;
this.remaining = remaining;
}
@Override
public int read() throws IOException {
byte[] single = new byte[1];
int count = read(single, 0, single.length);
return count < 0 ? -1 : Byte.toUnsignedInt(single[0]);
}
@Override
public int read(byte[] target, int offset, int length) throws IOException {
Objects.checkFromIndexSize(offset, length, target.length);
requireOpen();
if (length == 0) {
return 0;
}
if (remaining == MINIMUM_VALUE_BOUNDARY) {
return -1;
}
int requested = (int) Math.min((long) length, remaining);
ByteBuffer buffer = ByteBuffer.wrap(target, offset, requested);
int count = channel.read(buffer, position);
if (count < 0) {
throw integrity("Metadata record value is truncated");
}
if (count == 0) {
throw integrity("Metadata record value channel made no read progress");
}
position = Math.addExact(position, count);
remaining -= count;
return count;
}
@Override
public void close() throws IOException {
if (closed) {
return;
}
closed = true;
channel.close();
}
private void requireOpen() {
if (closed) {
throw new IllegalStateException("Metadata record value stream is closed");
}
}
}
/** Engine lifecycle forbidding semantic use after uncertainty or close. */
private enum State {
OPEN,
RECOVERY_REQUIRED,
CLOSED
}
/** Scope guard that preserves a primary commit failure and suppresses cleanup failure. */
private static final class PreparedResources implements AutoCloseable {
private final List<PreparedMutation> mutations;
private boolean outcomeEstablished;
private PreparedResources(List<PreparedMutation> mutations) {
this.mutations = List.copyOf(mutations);
}
private void outcomeEstablished() {
outcomeEstablished = true;
}
@Override
public void close() throws IOException {
try {
closePrepared(mutations);
} catch (IOException cleanup) {
if (!outcomeEstablished) {
throw cleanup;
}
warnCleanupFailure();
}
}
private static void warnCleanupFailure() {
CleanupBoundary.warn();
}
}
/** Same-thread boundary that converts non-fatal caller cleanup failures to checked state. */
private static final class CleanupBoundary {
private static IOException close(RepeatableContent content) {
CompletableFuture<Void> completion = CompletableFuture.completedFuture(null)
.thenRun(() -> closeUnchecked(content));
Throwable failure = completionFailure(completion);
if (failure == null) {
return null;
}
if (failure instanceof Error fatal) {
throw fatal;
}
if (failure instanceof IOException ioFailure) {
return ioFailure;
}
if (failure instanceof UncheckedIOException unchecked) {
return unchecked.getCause();
}
return new MetadataStoreException(
MetadataCommitResult.FailureCategory.STORAGE_FAILURE,
"Metadata transaction resource cleanup failed",
failure);
}
private static void warn() {
CompletableFuture<Void> completion = CompletableFuture.completedFuture(null)
.thenRun(() -> LOGGER.warning(CLEANUP_WARNING));
Throwable failure = completionFailure(completion);
if (failure instanceof Error fatal) {
throw fatal;
}
// Non-fatal logging failures cannot replace an authoritative result.
}
private static void closeUnchecked(RepeatableContent content) {
try {
content.close();
} catch (IOException failure) {
throw new UncheckedIOException(failure);
}
}
private static Throwable completionFailure(CompletableFuture<Void> completion) {
try {
completion.join();
return null;
} catch (CompletionException failed) {
return failed.getCause();
}
}
}
/** Captures checked preparation without using exceptions as transaction flow. */
private record Preparation(
MetadataStateIndex.PreparedUpdate update, MetadataStoreException failure) {
private static Preparation attempt(
MetadataStateIndex index,
long revision,
List<MetadataMutationPayloadCodec.Descriptor> descriptors) {
try {
return new Preparation(index.prepare(revision, descriptors), null);
} catch (MetadataStoreException failure) {
return new Preparation(null, failure);
}
}
}
/** Closed internal result; it is not the provider-neutral store result. */
/* default */ record CommitResult(
boolean committed,
OptionalLong revision,
OptionalInt failureCode,
Optional<PosixMetadataLogScanner.FailureReason> failureReason) {
CommitResult {
Objects.requireNonNull(revision, "revision");
Objects.requireNonNull(failureCode, "failureCode");
Objects.requireNonNull(failureReason, "failureReason");
}
private static CommitResult committed(long revision) {
return new CommitResult(
true, OptionalLong.of(revision), OptionalInt.empty(), Optional.empty());
}
private static CommitResult notCommitted(int failureCode) {
return new CommitResult(
false,
OptionalLong.empty(),
OptionalInt.of(failureCode),
PosixMetadataLogScanner.FailureReason.fromCode(failureCode));
}
private static CommitResult fromTerminal(PosixMetadataLogScanner.Terminal terminal) {
if (terminal.kind() == PosixMetadataLogScanner.TerminalKind.COMMITTED) {
return committed(terminal.committedRevision().orElseThrow());
}
return notCommitted(terminal.failureCode().orElseThrow());
}
}
/** Store-owned semantic mutation plus its exact known-length encoded payload. */
/* default */ record PreparedMutation(
MetadataMutationPayloadCodec.MutationKind kind,
MetadataKey key,
OptionalLong expectedRevision,
long valueLength,
long payloadLength,
RepeatableContent payload,
CancellationSignal cancellation,
RepeatableContent ownedValue,
AtomicBoolean closed) {
PreparedMutation {
Objects.requireNonNull(kind, "kind");
Objects.requireNonNull(key, "key");
Objects.requireNonNull(expectedRevision, "expectedRevision");
Objects.requireNonNull(payload, "payload");
Objects.requireNonNull(cancellation, "cancellation");
Objects.requireNonNull(closed, "closed");
if (valueLength < 0L || payloadLength < valueLength) {
throw new IllegalArgumentException("Prepared mutation lengths are invalid");
}
}
/* default */ static PreparedMutation create(
MetadataKey key,
RepeatableContent storeOwnedValue,
CancellationSignal cancellation) throws MetadataStoreException {
long valueLength = storeOwnedValue.length().orElseThrow(
() -> new IllegalArgumentException("Prepared mutation value length is unknown"));
RepeatableContent payload = MetadataMutationPayloadCodec.create(
key, storeOwnedValue, cancellation);
return prepared(
MetadataMutationPayloadCodec.MutationKind.CREATE,
key,
OptionalLong.empty(),
valueLength,
payload,
cancellation,
storeOwnedValue);
}
/* default */ static PreparedMutation replace(
MetadataKey key,
long expectedRevision,
RepeatableContent storeOwnedValue,
CancellationSignal cancellation) throws MetadataStoreException {
long valueLength = storeOwnedValue.length().orElseThrow(
() -> new IllegalArgumentException("Prepared mutation value length is unknown"));
RepeatableContent payload = MetadataMutationPayloadCodec.replace(
key, expectedRevision, storeOwnedValue, cancellation);
return prepared(
MetadataMutationPayloadCodec.MutationKind.REPLACE,
key,
OptionalLong.of(expectedRevision),
valueLength,
payload,
cancellation,
storeOwnedValue);
}
/* default */ static PreparedMutation delete(MetadataKey key, long expectedRevision) {
RepeatableContent payload = MetadataMutationPayloadCodec.delete(key, expectedRevision);
return prepared(
MetadataMutationPayloadCodec.MutationKind.DELETE,
key,
OptionalLong.of(expectedRevision),
0L,
payload,
CancellationSignal.NONE,
null);
}
private static PreparedMutation prepared(
MetadataMutationPayloadCodec.MutationKind kind,
MetadataKey key,
OptionalLong expectedRevision,
long valueLength,
RepeatableContent payload,
CancellationSignal cancellation,
RepeatableContent ownedValue) {
long payloadLength = payload.length().orElseThrow();
return new PreparedMutation(
kind,
key,
expectedRevision,
valueLength,
payloadLength,
payload,
cancellation,
ownedValue,
new AtomicBoolean());
}
private void closeOwned() throws IOException {
if (!closed.compareAndSet(false, true)) {
return;
}
IOException failure = null;
IOException payloadFailure = CleanupBoundary.close(payload);
if (payloadFailure != null) {
failure = appendCleanupFailure(failure, payloadFailure);
}
if (ownedValue != null) {
IOException valueFailure = CleanupBoundary.close(ownedValue);
if (valueFailure != null) {
failure = appendCleanupFailure(failure, valueFailure);
}
}
if (failure != null) {
throw failure;
}
}
private MetadataMutationPayloadCodec.Descriptor descriptor(long payloadOffset) {
if (kind == MetadataMutationPayloadCodec.MutationKind.DELETE) {
return new MetadataMutationPayloadCodec.Descriptor(
kind, key, expectedRevision, OptionalLong.empty(), OptionalLong.empty());
}
long controlBytes = Math.subtractExact(payloadLength, valueLength);
long valueOffset = Math.addExact(payloadOffset, controlBytes);
return new MetadataMutationPayloadCodec.Descriptor(
kind,
key,
expectedRevision,
OptionalLong.of(valueOffset),
OptionalLong.of(valueLength));
}
}
/** Deterministic fault boundary after a forced terminal and before publication. */
/* default */ enum FaultPoint {
POST_FORCE_PUBLICATION
}
/** Package-private fault seam for engine-only publication uncertainty. */
/* default */
@FunctionalInterface
interface FaultInjector {
FaultInjector NONE = point -> { };
/** Fails the selected deterministic engine boundary. */
void fail(FaultPoint point) throws IOException;
}
/** Outcome uncertainty requiring close and authoritative replay. */
/* default */ static final class OutcomeUnknownException extends IOException {
private static final long serialVersionUID = -5475791935837085729L;
private OutcomeUnknownException(IOException cause) {
super("POSIX metadata engine outcome requires recovery", cause);
}
}
}

View File

@@ -0,0 +1,692 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.impl.fs;
import java.io.IOException;
import java.io.InputStream;
import java.io.InterruptedIOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.nio.file.attribute.FileAttribute;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.OptionalLong;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.ReentrantLock;
import zeroecho.core.io.CancellationSignal;
import zeroecho.core.io.RepeatableContent;
import zeroecho.pki.spi.store.MetadataCommitResult;
import zeroecho.pki.spi.store.MetadataKey;
import zeroecho.pki.spi.store.MetadataStoreException;
import zeroecho.pki.spi.store.MetadataTransaction;
import zeroecho.pki.spi.store.MetadataTransactionId;
/** Thread-confined transaction admission, staging, and outcome mapping. */
final class PosixMetadataTransactionSupport {
private static final int TRANSFER_BUFFER_BYTES = 8192;
private static final long MINIMUM_CONTENT_LENGTH = 0L;
private final PosixMetadataStoreEngine engine;
private final PosixMetadataAdapterLifecycle lifecycle;
private final Path stagingDirectory;
private final OptionalLong maximumRecordBytes;
private final StagingOperations stagingOperations;
/* default */ PosixMetadataTransactionSupport(
PosixMetadataStoreEngine engine,
PosixMetadataAdapterLifecycle lifecycle,
Path stagingDirectory,
OptionalLong maximumRecordBytes,
StagingOperations stagingOperations) {
this.engine = Objects.requireNonNull(engine, "engine");
this.lifecycle = Objects.requireNonNull(lifecycle, "lifecycle");
this.stagingDirectory = Objects.requireNonNull(stagingDirectory, "stagingDirectory");
this.maximumRecordBytes = Objects.requireNonNull(
maximumRecordBytes, "maximumRecordBytes");
this.stagingOperations = Objects.requireNonNull(stagingOperations, "stagingOperations");
}
/* default */ MetadataTransaction begin() throws IOException {
return lifecycle.openManaged(() -> new TransactionImpl(engine.issue()));
}
/* default */ MetadataCommitResult resolve(MetadataTransactionId transactionId)
throws IOException {
Objects.requireNonNull(transactionId, "transactionId");
return lifecycle.read(() -> map(transactionId, engine.resolve(transactionId)));
}
/* default */ void acknowledge(MetadataTransactionId transactionId) throws IOException {
MetadataCommitResult retained = resolve(transactionId);
if (retained.outcome() == MetadataCommitResult.Outcome.UNKNOWN) {
throw new MetadataStoreException(
MetadataCommitResult.FailureCategory.STORAGE_FAILURE,
"Unknown metadata transaction outcome cannot be acknowledged");
}
}
private SpoolContent stage(
RepeatableContent source, CancellationSignal cancellation) throws IOException {
OptionalLong declared = requireKnownLength(source);
long length = declared.orElseThrow();
requireTechnicalLimit(length);
throwIfCancelled(cancellation);
Path spool = stagingOperations.create(stagingDirectory);
try (SpoolContent owned = new SpoolContent(spool, length, stagingOperations)) {
copyExact(source, cancellation, spool, length);
return owned.transfer();
}
}
private static OptionalLong requireKnownLength(RepeatableContent source)
throws MetadataStoreException {
Objects.requireNonNull(source, "content");
OptionalLong declared;
try {
declared = Objects.requireNonNull(source.length(), "content length");
} catch (IllegalArgumentException | IllegalStateException failure) {
throw storage("Metadata content length could not be established", failure);
}
if (declared.isEmpty()) {
throw new MetadataStoreException(
MetadataCommitResult.FailureCategory.UNSUPPORTED_CAPABILITY,
"POSIX metadata adapter requires known content length");
}
if (declared.getAsLong() < MINIMUM_CONTENT_LENGTH) {
throw new MetadataStoreException(
MetadataCommitResult.FailureCategory.INTEGRITY_FAILURE,
"Metadata content length is negative");
}
return declared;
}
private void requireTechnicalLimit(long length) throws MetadataStoreException {
if (maximumRecordBytes.isPresent() && length > maximumRecordBytes.getAsLong()) {
throw new MetadataStoreException(
MetadataCommitResult.FailureCategory.LIMIT_EXCEEDED,
"Metadata content exceeds the adapter technical limit");
}
}
private void copyExact(
RepeatableContent source,
CancellationSignal cancellation,
Path spool,
long declaredLength) throws IOException {
try (InputStream input = source.openStream();
FileChannel output = stagingOperations.openWrite(spool)) {
copyDeclared(input, output, cancellation, declaredLength);
throwIfCancelled(cancellation);
if (input.read() >= 0) {
throw storage("Metadata content is longer than its declared length");
}
} catch (InterruptedIOException failure) {
throw cancelled(failure);
} catch (MetadataStoreException failure) {
throw failure;
} catch (IOException failure) {
throw storage("Metadata content staging failed", failure);
}
}
private void copyDeclared(
InputStream input,
FileChannel output,
CancellationSignal cancellation,
long declaredLength) throws IOException {
byte[] transfer = new byte[TRANSFER_BUFFER_BYTES];
long copied = MINIMUM_CONTENT_LENGTH;
while (copied < declaredLength) {
throwIfCancelled(cancellation);
int requested = (int) Math.min((long) transfer.length, declaredLength - copied);
int count = input.read(transfer, 0, requested);
requireReadProgress(count);
try {
copied = Math.addExact(copied, count);
} catch (ArithmeticException failure) {
throw new MetadataStoreException(
MetadataCommitResult.FailureCategory.LIMIT_EXCEEDED,
"Metadata content length is not representable",
failure);
}
requireTechnicalLimit(copied);
writeFully(output, ByteBuffer.wrap(transfer, 0, count));
}
}
private static void requireReadProgress(int count) throws MetadataStoreException {
if (count < 0) {
throw storage("Metadata content is shorter than its declared length");
}
if (count == 0) {
throw storage("Metadata content stream made no read progress");
}
}
private static void writeFully(FileChannel channel, ByteBuffer source) throws IOException {
while (source.hasRemaining()) {
if (channel.write(source) == 0) {
throw storage("Metadata staging channel made no write progress");
}
}
}
private static void throwIfCancelled(CancellationSignal cancellation)
throws MetadataStoreException {
Objects.requireNonNull(cancellation, "cancellation");
try {
cancellation.throwIfCancelled();
} catch (InterruptedIOException failure) {
throw cancelled(failure);
}
}
private static MetadataCommitResult map(
MetadataTransactionId transactionId,
PosixMetadataStoreEngine.CommitResult internal) {
if (internal.committed()) {
return new MetadataCommitResult(
transactionId,
MetadataCommitResult.Outcome.COMMITTED,
internal.revision(),
Optional.empty());
}
MetadataCommitResult.FailureCategory category = internal.failureReason()
.map(PosixMetadataTransactionSupport::mapFailure)
.orElse(MetadataCommitResult.FailureCategory.STORAGE_FAILURE);
return new MetadataCommitResult(
transactionId,
MetadataCommitResult.Outcome.NOT_COMMITTED,
OptionalLong.empty(),
Optional.of(category));
}
private static MetadataCommitResult.FailureCategory mapFailure(
PosixMetadataLogScanner.FailureReason reason) {
return switch (reason) {
case TRANSACTION_CONFLICT -> MetadataCommitResult.FailureCategory.CONFLICT;
case ABANDONED_BY_RECOVERY ->
MetadataCommitResult.FailureCategory.ABANDONED_BY_RECOVERY;
};
}
private static MetadataCommitResult unknown(MetadataTransactionId transactionId) {
return new MetadataCommitResult(
transactionId,
MetadataCommitResult.Outcome.UNKNOWN,
OptionalLong.empty(),
Optional.of(MetadataCommitResult.FailureCategory.STORAGE_FAILURE));
}
private static MetadataStoreException cancelled(InterruptedIOException failure) {
return new MetadataStoreException(
MetadataCommitResult.FailureCategory.CANCELLED,
"Metadata content staging was cancelled",
failure);
}
private static MetadataStoreException storage(String message) {
return new MetadataStoreException(
MetadataCommitResult.FailureCategory.STORAGE_FAILURE, message);
}
private static MetadataStoreException storage(String message, Throwable failure) {
return new MetadataStoreException(
MetadataCommitResult.FailureCategory.STORAGE_FAILURE, message, failure);
}
/** Store-issued transaction whose owner lock provides exact thread identity. */
private final class TransactionImpl
implements MetadataTransaction, PosixMetadataAdapterLifecycle.ManagedResource {
private final MetadataTransactionId transactionId;
private final ReentrantLock owner = ownerLock();
private final Map<MetadataKey, StagedMutation> staged = new LinkedHashMap<>();
private final Set<MetadataKey> reserved = new java.util.HashSet<>();
private TransactionState state = TransactionState.ACTIVE;
private TransactionImpl(MetadataTransactionId transactionId) {
super();
this.transactionId = transactionId;
}
@Override
public MetadataTransactionId id() {
return transactionId;
}
@Override
public void create(
MetadataKey key,
RepeatableContent content,
CancellationSignal cancellation) throws IOException {
admit(MetadataMutationPayloadCodec.MutationKind.CREATE,
key, MINIMUM_CONTENT_LENGTH, content, cancellation);
}
@Override
public void replace(
MetadataKey key,
long expectedRevision,
RepeatableContent content,
CancellationSignal cancellation) throws IOException {
requireRevision(expectedRevision);
admit(MetadataMutationPayloadCodec.MutationKind.REPLACE,
key, expectedRevision, content, cancellation);
}
private void admit(
MetadataMutationPayloadCodec.MutationKind kind,
MetadataKey key,
long expectedRevision,
RepeatableContent content,
CancellationSignal cancellation) throws IOException {
Objects.requireNonNull(key, "key");
PosixMetadataAdapterLifecycle.OperationReservation operation = reserve(key);
boolean operationConsumed = false;
try (SpoolContent stagedContent = stage(content, cancellation)) {
boolean accepted;
try {
accepted = operation.finish(
() -> install(
kind, key, expectedRevision, stagedContent),
() -> reserved.remove(key));
} finally {
operationConsumed = true;
}
requireAccepted(accepted);
} finally {
if (!operationConsumed) {
operation.cancel(() -> reserved.remove(key));
}
}
}
private void requireAccepted(boolean accepted) {
if (!accepted) {
throw new IllegalStateException("POSIX transactional metadata store is closed");
}
}
private PosixMetadataAdapterLifecycle.OperationReservation reserve(MetadataKey key) {
requireOwner();
PosixMetadataAdapterLifecycle.OperationReservation operation =
lifecycle.beginOperation();
boolean reservedHere = false;
try {
requireActive();
requireNewKey(key);
reservedHere = reserved.add(key);
return operation;
} finally {
if (!reservedHere) {
operation.cancel(() -> reserved.remove(key));
}
}
}
private void install(
MetadataMutationPayloadCodec.MutationKind kind,
MetadataKey key,
long expectedRevision,
SpoolContent stagedContent) {
requireOwnerAndActive();
reserved.remove(key);
staged.put(key, new StagedMutation(
kind, key, expectedRevision, stagedContent.transfer()));
}
@Override
public void delete(MetadataKey key, long expectedRevision) {
Objects.requireNonNull(key, "key");
requireRevision(expectedRevision);
requireOwner();
PosixMetadataAdapterLifecycle.OperationReservation operation =
lifecycle.beginOperation();
StagedMutation mutation = new StagedMutation(
MetadataMutationPayloadCodec.MutationKind.DELETE,
key,
expectedRevision,
null);
boolean consumed = false;
try {
requireActive();
requireNewKey(key);
try {
boolean accepted = operation.finish(
() -> staged.put(key, mutation),
() -> { });
if (!accepted) {
throw new IllegalStateException(
"POSIX transactional metadata store is closed");
}
} finally {
consumed = true;
}
} finally {
if (!consumed) {
operation.cancel(() -> { });
}
}
}
@Override
public MetadataCommitResult commit() throws IOException {
requireOwner();
PosixMetadataAdapterLifecycle.OperationReservation operation =
lifecycle.beginOperation();
MetadataCommitResult result = null;
IOException failure = null;
boolean engineOwnsResources = false;
boolean terminalAttempt = false;
try {
requireActive();
if (!reserved.isEmpty()) {
throw new IllegalStateException(
"Metadata transaction has an admission in progress");
}
state = TransactionState.COMMITTING;
terminalAttempt = true;
List<StagedMutation> mutations = List.copyOf(staged.values());
List<PosixMetadataStoreEngine.PreparedMutation> prepared = prepare(mutations);
engineOwnsResources = true;
result = map(transactionId, engine.commit(transactionId, prepared));
} catch (MetadataStoreException checked) {
failure = checked;
} catch (IOException uncertainty) {
result = unknown(transactionId);
lifecycle.recoveryRequired();
} finally {
if (!engineOwnsResources) {
failure = PosixMetadataAdapterLifecycle.append(failure, retireStaged());
}
if (terminalAttempt) {
operation.finishTerminal(this::terminalize);
} else {
operation.cancel(() -> { });
}
}
if (failure != null) {
throw failure;
}
return Objects.requireNonNull(result, "commit result");
}
private List<PosixMetadataStoreEngine.PreparedMutation> prepare(
List<StagedMutation> mutations) throws MetadataStoreException {
List<PosixMetadataStoreEngine.PreparedMutation> prepared =
new ArrayList<>(mutations.size());
for (StagedMutation mutation : mutations) {
prepared.add(mutation.prepare());
}
return List.copyOf(prepared);
}
private void terminalize() {
state = TransactionState.TERMINAL;
staged.clear();
reserved.clear();
lifecycle.unregister(this);
}
@Override
public void abort() throws IOException {
requireOwner();
PosixMetadataAdapterLifecycle.OperationReservation operation =
lifecycle.beginCleanupOperation();
IOException failure;
boolean terminalAttempt = false;
try {
requireActive();
state = TransactionState.TERMINAL;
terminalAttempt = true;
failure = retireStaged();
} finally {
if (terminalAttempt) {
operation.finishTerminal(this::terminalize);
} else {
operation.cancel(() -> { });
}
}
if (failure != null) {
throw failure;
}
}
@Override
public void close() throws IOException {
requireOwner();
if (state != TransactionState.TERMINAL) {
abort();
}
}
@Override
public IOException forceClose() {
if (state == TransactionState.TERMINAL) {
return null;
}
state = TransactionState.TERMINAL;
reserved.clear();
return retireStaged();
}
private IOException retireStaged() {
IOException failure = null;
for (StagedMutation mutation : staged.values()) {
try {
mutation.retire();
} catch (IOException cleanup) {
failure = PosixMetadataAdapterLifecycle.append(failure, cleanup);
}
}
staged.clear();
return failure;
}
private void requireOwnerAndActive() {
requireOwner();
requireActive();
}
private void requireActive() {
if (state != TransactionState.ACTIVE) {
throw new IllegalStateException("Metadata transaction is terminal");
}
}
private void requireOwner() {
if (!owner.isHeldByCurrentThread()) {
throw new IllegalStateException("Metadata transaction is thread-confined");
}
}
private void requireNewKey(MetadataKey key) {
if (staged.containsKey(key) || reserved.contains(key)) {
throw new IllegalArgumentException(
"Metadata transaction cannot mutate one key more than once");
}
}
}
private static ReentrantLock ownerLock() {
ReentrantLock result = new ReentrantLock();
result.lock();
return result;
}
private static void requireRevision(long revision) {
if (revision < MINIMUM_CONTENT_LENGTH) {
throw new IllegalArgumentException("Expected metadata revision must not be negative");
}
}
/** Store-owned staged mutation containing no caller content authority. */
private record StagedMutation(
MetadataMutationPayloadCodec.MutationKind kind,
MetadataKey key,
long expectedRevision,
SpoolContent content) {
private PosixMetadataStoreEngine.PreparedMutation prepare() throws MetadataStoreException {
return switch (kind) {
case CREATE -> PosixMetadataStoreEngine.PreparedMutation.create(
key, content, CancellationSignal.NONE);
case REPLACE -> PosixMetadataStoreEngine.PreparedMutation.replace(
key, expectedRevision, content, CancellationSignal.NONE);
case DELETE -> PosixMetadataStoreEngine.PreparedMutation.delete(key, expectedRevision);
};
}
private void retire() throws IOException {
if (content != null) {
content.close();
}
}
}
/** Repeatable store-owned spool removed exactly once after ownership ends. */
private static final class SpoolContent implements RepeatableContent {
private final Path path;
private final long length;
private final StagingOperations operations;
private final AtomicBoolean closed = new AtomicBoolean();
private boolean transferred;
private SpoolContent(Path path, long length, StagingOperations operations) {
super();
this.path = path;
this.length = length;
this.operations = operations;
}
@Override
public InputStream openStream() throws IOException {
if (closed.get()) {
throw new IllegalStateException("Staged metadata content is closed");
}
return java.nio.channels.Channels.newInputStream(operations.openRead(path));
}
@Override
public OptionalLong length() {
return OptionalLong.of(length);
}
@Override
public String contentId() {
return "zeroecho-posix-metadata-staged-v1";
}
private SpoolContent transfer() {
if (transferred || closed.get()) {
throw new IllegalStateException("Staged metadata content ownership is unavailable");
}
transferred = true;
return new SpoolContent(path, length, operations);
}
@Override
public void close() throws IOException {
if (!transferred && closed.compareAndSet(false, true)) {
operations.delete(path);
}
}
}
/** Package-private deterministic staging seam; never part of the SPI. */
/* default */ interface StagingOperations {
/** Creates one unpredictable exclusive store-owned spool. */
Path create(Path parent) throws IOException;
/** Opens the spool for bounded transfer. */
FileChannel openWrite(Path path) throws IOException;
/** Opens a fresh repeatable spool reader. */
FileChannel openRead(Path path) throws IOException;
/** Removes one no-longer-authoritative spool. */
void delete(Path path) throws IOException;
}
/** Real POSIX staging operations. */
/* default */ enum DefaultStagingOperations implements StagingOperations {
/** Singleton production implementation. */
INSTANCE;
private static final FileAttribute<?>[] OWNER_ONLY = {
PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------"))
};
@Override
public Path create(Path parent) throws IOException {
return Files.createTempFile(parent, ".zeroecho-metadata-", ".stage", OWNER_ONLY);
}
@Override
public FileChannel openWrite(Path path) throws IOException {
return FileChannel.open(
path,
Set.of(StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING,
LinkOption.NOFOLLOW_LINKS));
}
@Override
public FileChannel openRead(Path path) throws IOException {
return FileChannel.open(
path,
Set.of(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS));
}
@Override
public void delete(Path path) throws IOException {
Files.deleteIfExists(path);
}
}
/** Single-use transaction lifecycle. */
private enum TransactionState {
ACTIVE,
COMMITTING,
TERMINAL
}
}

View File

@@ -0,0 +1,255 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.impl.fs;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Objects;
import java.util.OptionalLong;
import java.util.Set;
import zeroecho.pki.spi.store.MetadataCommitResult;
import zeroecho.pki.spi.store.MetadataSnapshot;
import zeroecho.pki.spi.store.MetadataStoreCapabilities;
import zeroecho.pki.spi.store.MetadataStoreId;
import zeroecho.pki.spi.store.MetadataTransaction;
import zeroecho.pki.spi.store.MetadataTransactionId;
import zeroecho.pki.spi.store.TransactionalMetadataStore;
/**
* POSIX exclusive-writer implementation of the transactional metadata-store SPI.
*
* <p>Content admission is synchronous and known-length only. Transactions stage
* payloads with bounded heap, while snapshots copy finite metadata and read
* repeatable bounded slices from the append-only log.</p>
*
* <p>Instances are thread-safe. Transactions are thread-confined and single-use.
* Closing a snapshot invalidates all records and cursors it issued.</p>
*/
public final class PosixTransactionalMetadataStore implements TransactionalMetadataStore {
private final PosixMetadataStoreEngine engine;
private final MetadataStoreId storeId;
private final MetadataStoreCapabilities capabilities;
private final PosixMetadataAdapterLifecycle lifecycle;
private final PosixMetadataTransactionSupport transactions;
private final PosixMetadataSnapshotSupport snapshots;
private PosixTransactionalMetadataStore(
Path logPath,
PosixMetadataStoreEngine engine,
OptionalLong maximumRecordBytes,
PosixMetadataTransactionSupport.StagingOperations stagingOperations) {
this(logPath, engine, maximumRecordBytes, stagingOperations, engine::openValueSlice);
}
private PosixTransactionalMetadataStore(
Path logPath,
PosixMetadataStoreEngine engine,
OptionalLong maximumRecordBytes,
PosixMetadataTransactionSupport.StagingOperations stagingOperations,
PosixMetadataSnapshotSupport.SliceOpener sliceOpener) {
this.engine = Objects.requireNonNull(engine, "engine");
this.storeId = engine.storeId();
Path normalized = Objects.requireNonNull(logPath, "logPath")
.toAbsolutePath().normalize();
Path stagingDirectory = Objects.requireNonNull(normalized.getParent(), "log parent");
this.capabilities = capabilities(maximumRecordBytes);
this.lifecycle = new PosixMetadataAdapterLifecycle();
this.transactions = new PosixMetadataTransactionSupport(
engine, lifecycle, stagingDirectory, maximumRecordBytes, stagingOperations);
this.snapshots = new PosixMetadataSnapshotSupport(
engine, lifecycle, storeId, sliceOpener);
}
/**
* Creates a new append-only store without an adapter-specific record limit.
*
* @param logPath new log path in a trusted POSIX directory
* @param storeId durable store identity
* @return opened metadata store
* @throws IOException when exclusive initialization or durability fails
*/
public static PosixTransactionalMetadataStore create(
Path logPath, MetadataStoreId storeId) throws IOException {
return create(logPath, storeId, OptionalLong.empty());
}
/**
* Creates a new append-only store.
*
* @param logPath new log path in a trusted POSIX directory
* @param storeId durable store identity
* @param maximumRecordBytes optional positive adapter technical limit
* @return opened metadata store
* @throws IOException when exclusive initialization or durability fails
*/
public static PosixTransactionalMetadataStore create(
Path logPath,
MetadataStoreId storeId,
OptionalLong maximumRecordBytes) throws IOException {
MetadataStoreCapabilities validated = capabilities(maximumRecordBytes);
PosixMetadataStoreEngine engine = PosixMetadataStoreEngine.create(logPath, storeId);
return new PosixTransactionalMetadataStore(
logPath,
engine,
validated.maximumIndividualRecordBytes(),
PosixMetadataTransactionSupport.DefaultStagingOperations.INSTANCE);
}
/**
* Opens an existing append-only store without an adapter-specific record limit.
*
* @param logPath existing log path in a trusted POSIX directory
* @return opened metadata store
* @throws IOException when locking, recovery, or durability fails
*/
public static PosixTransactionalMetadataStore open(Path logPath) throws IOException {
return open(logPath, OptionalLong.empty());
}
/**
* Opens an existing append-only store.
*
* @param logPath existing log path in a trusted POSIX directory
* @param maximumRecordBytes optional positive adapter technical limit
* @return opened metadata store
* @throws IOException when locking, recovery, or durability fails
*/
public static PosixTransactionalMetadataStore open(
Path logPath, OptionalLong maximumRecordBytes) throws IOException {
MetadataStoreCapabilities validated = capabilities(maximumRecordBytes);
PosixMetadataStoreEngine engine = PosixMetadataStoreEngine.open(logPath);
return new PosixTransactionalMetadataStore(
logPath,
engine,
validated.maximumIndividualRecordBytes(),
PosixMetadataTransactionSupport.DefaultStagingOperations.INSTANCE);
}
/* default */ static PosixTransactionalMetadataStore createForTest(
Path logPath,
MetadataStoreId storeId,
OptionalLong maximumRecordBytes,
PosixMetadataTransactionSupport.StagingOperations stagingOperations,
PosixMetadataLog.FaultInjector logFaults,
PosixMetadataStoreEngine.FaultInjector engineFaults) throws IOException {
MetadataStoreCapabilities validated = capabilities(maximumRecordBytes);
PosixMetadataStoreEngine engine = PosixMetadataStoreEngine.create(
logPath, storeId, logFaults, engineFaults);
return new PosixTransactionalMetadataStore(
logPath,
engine,
validated.maximumIndividualRecordBytes(),
stagingOperations);
}
/* default */ static PosixTransactionalMetadataStore createForTest(
Path logPath,
MetadataStoreId storeId,
OptionalLong maximumRecordBytes,
PosixMetadataTransactionSupport.StagingOperations stagingOperations,
PosixMetadataLog.FaultInjector logFaults,
PosixMetadataStoreEngine.FaultInjector engineFaults,
PosixMetadataSnapshotSupport.SliceOpener sliceOpener) throws IOException {
MetadataStoreCapabilities validated = capabilities(maximumRecordBytes);
PosixMetadataStoreEngine engine = PosixMetadataStoreEngine.create(
logPath, storeId, logFaults, engineFaults);
return new PosixTransactionalMetadataStore(
logPath,
engine,
validated.maximumIndividualRecordBytes(),
stagingOperations,
sliceOpener);
}
/* default */ int activeOperationsForTest() {
return lifecycle.activeOperationCount();
}
/* default */ void awaitClosingForTest() {
lifecycle.awaitClosing();
}
/** {@inheritDoc} */
@Override
public MetadataStoreId id() {
lifecycle.verifyOpen();
return storeId;
}
/** {@inheritDoc} */
@Override
public MetadataStoreCapabilities capabilities() {
lifecycle.verifyOpen();
return capabilities;
}
/** {@inheritDoc} */
@Override
public MetadataTransaction beginTransaction() throws IOException {
return transactions.begin();
}
/** {@inheritDoc} */
@Override
public MetadataCommitResult resolve(MetadataTransactionId transactionId) throws IOException {
return transactions.resolve(transactionId);
}
/** {@inheritDoc} */
@Override
public void acknowledge(MetadataTransactionId transactionId) throws IOException {
transactions.acknowledge(transactionId);
}
/** {@inheritDoc} */
@Override
public MetadataSnapshot snapshot() throws IOException {
return snapshots.open();
}
/** {@inheritDoc} */
@Override
public void close() throws IOException {
lifecycle.close(engine);
}
private static MetadataStoreCapabilities capabilities(OptionalLong maximumRecordBytes) {
return new MetadataStoreCapabilities(
MetadataStoreCapabilities.WriterModel.EXCLUSIVE_WRITER,
MetadataStoreCapabilities.ContentLengthModel.KNOWN_LENGTH_ONLY,
MetadataStoreCapabilities.OutcomeRetention.INDEFINITE,
Set.of(MetadataStoreCapabilities.OptionalFeature.CROSS_PROCESS_COORDINATION),
Objects.requireNonNull(maximumRecordBytes, "maximumRecordBytes"));
}
}

View File

@@ -47,6 +47,8 @@ import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
import zeroecho.core.io.CancellationSignal;
import zeroecho.core.io.RepeatableContent;
import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.KeyRef;
@@ -66,7 +68,7 @@ import zeroecho.pki.api.audit.AccessContext;
* <h2>Trust boundary</h2>
* <ul>
* <li><strong>PKI (caller):</strong> works only with {@link KeyRef}, algorithm
* id and payload bytes; it must never parse {@code KeyRef} nor access private
* id and repeatable content; it must never parse {@code KeyRef} nor access private
* key bytes.</li>
* <li><strong>Provider (callee):</strong> resolves {@code KeyRef} into an
* internal runtime key handle, enforces policy (including multi-hop approvals),
@@ -269,13 +271,14 @@ public interface SignatureWorkflow extends Closeable {
* {@code null})
* @param algorithmId requested signature algorithm id (never
* blank)
* @param payload payload bytes (never {@code null})
* @param content immutable repeatable content
* @param preferredSignatureEncoding preferred signature encoding (optional)
* @param deadline optional absolute deadline
*/
record SignRequest(PkiId submissionId, String namespace, String semanticFingerprint, long fencingToken,
AccessContext accessContext, KeyRef keyRef, String algorithmId, EncodedObject payload,
Optional<Encoding> preferredSignatureEncoding, Optional<Instant> deadline) {
AccessContext accessContext, KeyRef keyRef, String algorithmId, RepeatableContent content,
Optional<Encoding> preferredSignatureEncoding, Optional<Instant> deadline,
CancellationSignal cancellation) {
private static final long MIN_FENCING_TOKEN = 1L;
@@ -286,16 +289,17 @@ public interface SignatureWorkflow extends Closeable {
Objects.requireNonNull(accessContext, "accessContext");
Objects.requireNonNull(keyRef, "keyRef");
Objects.requireNonNull(algorithmId, "algorithmId");
Objects.requireNonNull(payload, "payload");
Objects.requireNonNull(content, "content");
Objects.requireNonNull(preferredSignatureEncoding, "preferredSignatureEncoding");
Objects.requireNonNull(deadline, "deadline");
Objects.requireNonNull(cancellation, "cancellation");
if (algorithmId.isBlank()) {
throw new IllegalArgumentException("algorithmId must not be blank");
}
if (fencingToken < MIN_FENCING_TOKEN) {
throw new IllegalArgumentException("fencingToken must be positive");
}
String expected = fingerprint(namespace, accessContext, keyRef, algorithmId, payload,
String expected = fingerprint(namespace, accessContext, keyRef, algorithmId, content,
preferredSignatureEncoding, deadline);
if (!constantTimeFingerprintEquals(expected, semanticFingerprint)) {
throw new IllegalArgumentException("semanticFingerprint does not match signing request");
@@ -306,12 +310,12 @@ public interface SignatureWorkflow extends Closeable {
* Creates a request with its canonical versioned fingerprint.
*/
public static SignRequest create(PkiId submissionId, String namespace, long fencingToken,
AccessContext accessContext, KeyRef keyRef, String algorithmId, EncodedObject payload,
AccessContext accessContext, KeyRef keyRef, String algorithmId, RepeatableContent content,
Optional<Encoding> preferredSignatureEncoding, Optional<Instant> deadline) {
String fingerprint = fingerprint(namespace, accessContext, keyRef, algorithmId, payload,
String fingerprint = fingerprint(namespace, accessContext, keyRef, algorithmId, content,
preferredSignatureEncoding, deadline);
return new SignRequest(submissionId, namespace, fingerprint, fencingToken, accessContext, keyRef,
algorithmId, payload, preferredSignatureEncoding, deadline);
algorithmId, content, preferredSignatureEncoding, deadline, CancellationSignal.NONE);
}
/**
@@ -320,17 +324,17 @@ public interface SignatureWorkflow extends Closeable {
* constant-size digest state.
*/
public static String fingerprint(String namespace, AccessContext accessContext, KeyRef keyRef,
String algorithmId, EncodedObject payload, Optional<Encoding> preferredSignatureEncoding,
String algorithmId, RepeatableContent content, Optional<Encoding> preferredSignatureEncoding,
Optional<Instant> deadline) {
Objects.requireNonNull(namespace, "namespace");
Objects.requireNonNull(accessContext, "accessContext");
Objects.requireNonNull(keyRef, "keyRef");
Objects.requireNonNull(algorithmId, "algorithmId");
Objects.requireNonNull(payload, "payload");
Objects.requireNonNull(content, "content");
Objects.requireNonNull(preferredSignatureEncoding, "preferredSignatureEncoding");
Objects.requireNonNull(deadline, "deadline");
try {
return fingerprintWithDigest(namespace, accessContext, keyRef, algorithmId, payload,
return fingerprintWithDigest(namespace, accessContext, keyRef, algorithmId, content,
preferredSignatureEncoding, deadline, MessageDigest.getInstance("SHA-256"), ignored -> {
});
} catch (NoSuchAlgorithmException ex) {
@@ -339,16 +343,15 @@ public interface SignatureWorkflow extends Closeable {
}
/* default */ static String fingerprintWithDigest(String namespace, AccessContext accessContext, KeyRef keyRef,
String algorithmId, EncodedObject payload, Optional<Encoding> preferredSignatureEncoding,
String algorithmId, RepeatableContent content, Optional<Encoding> preferredSignatureEncoding,
Optional<Instant> deadline, MessageDigest digest, Consumer<byte[]> cleanupObserver) {
Objects.requireNonNull(digest, "digest");
Objects.requireNonNull(cleanupObserver, "cleanupObserver");
byte[] payloadBytes = payload.bytes();
byte[] digestBytes = null;
try {
try (DataOutputStream output = new DataOutputStream(
new DigestOutputStream(OutputStream.nullOutputStream(), digest))) {
output.writeUTF("sign-request-v1");
output.writeUTF("sign-request-v2");
output.writeUTF(namespace);
output.writeUTF(accessContext.principal().type());
output.writeUTF(accessContext.principal().name());
@@ -357,9 +360,8 @@ public interface SignatureWorkflow extends Closeable {
output.writeUTF(accessContext.formatId().map(zeroecho.pki.api.FormatId::value).orElse(""));
output.writeUTF(keyRef.value());
output.writeUTF(algorithmId);
output.writeUTF(payload.encoding().name());
output.writeInt(payloadBytes.length);
output.write(payloadBytes);
output.writeUTF(content.contentId());
output.writeLong(content.length().orElse(-1L));
output.writeUTF(preferredSignatureEncoding.map(Enum::name).orElse(""));
output.writeUTF(deadline.map(Instant::toString).orElse(""));
}
@@ -368,7 +370,6 @@ public interface SignatureWorkflow extends Closeable {
} catch (IOException ex) {
throw new IllegalStateException("Unable to compute signing request fingerprint");
} finally {
clearOwned(payloadBytes, cleanupObserver);
clearOwned(digestBytes, cleanupObserver);
}
}
@@ -405,25 +406,26 @@ public interface SignatureWorkflow extends Closeable {
*
* @param accessContext audit/governance context (never {@code null})
* @param algorithmId requested signature algorithm id (never blank)
* @param payload signed payload bytes (never {@code null})
* @param content immutable repeatable signed content
* @param signature signature bytes (never {@code null})
* @param publicKeyRef optional public key reference (preferred)
* @param publicKeyEncoded optional encoded public key bytes (provider-specific;
* may be unsupported)
* @param deadline optional absolute deadline
*/
record VerifyRequest(AccessContext accessContext, String algorithmId, EncodedObject payload,
record VerifyRequest(AccessContext accessContext, String algorithmId, RepeatableContent content,
EncodedObject signature, Optional<KeyRef> publicKeyRef, Optional<EncodedObject> publicKeyEncoded,
Optional<Instant> deadline) {
Optional<Instant> deadline, CancellationSignal cancellation) {
public VerifyRequest {
Objects.requireNonNull(accessContext, "accessContext");
Objects.requireNonNull(algorithmId, "algorithmId");
Objects.requireNonNull(payload, "payload");
Objects.requireNonNull(content, "content");
Objects.requireNonNull(signature, "signature");
Objects.requireNonNull(publicKeyRef, "publicKeyRef");
Objects.requireNonNull(publicKeyEncoded, "publicKeyEncoded");
Objects.requireNonNull(deadline, "deadline");
Objects.requireNonNull(cancellation, "cancellation");
if (algorithmId.isBlank()) {
throw new IllegalArgumentException("algorithmId must not be blank");
}

View File

@@ -35,8 +35,8 @@ package zeroecho.pki.spi.framework;
import java.math.BigInteger;
import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.KeyRef;
import zeroecho.pki.api.content.DurableContentReference;
import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.credential.CredentialBundle;
import zeroecho.pki.impl.core.ValidatedCaCertificateRequest;
@@ -140,7 +140,7 @@ public interface CredentialIssuerBackend {
* or other framework-specific issuance
* processing fails
*/
CredentialBundle issueEndEntity(ValidatedCertificateRequest request, EncodedObject issuerCertificate,
CredentialBundle issueEndEntity(ValidatedCertificateRequest request, DurableContentReference issuerCertificate,
KeyRef issuerKeyRef, BigInteger serial);
/**
@@ -171,6 +171,7 @@ public interface CredentialIssuerBackend {
* or other framework-specific issuance
* processing fails
*/
Credential issueIntermediateCertificate(ValidatedCaCertificateRequest request, EncodedObject issuerCertificate,
Credential issueIntermediateCertificate(ValidatedCaCertificateRequest request,
DurableContentReference issuerCertificate,
KeyRef issuerKeyRef);
}

View File

@@ -0,0 +1,102 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.spi.framework;
import java.io.IOException;
import java.util.OptionalLong;
/**
* Stable restartable source of CRL entries.
*
* <p>
* The source yields one immutable entry at a time and never exposes an aggregate
* list. ZeroEcho core does not impose an arbitrary product-wide limit on
* aggregate CRL size or revocation-entry cardinality.
* </p>
*/
public interface CrlEntrySource extends AutoCloseable {
/**
* Opens a new cursor over the same logical snapshot.
*
* @return cursor
* @throws IOException if the pass cannot be opened
*/
Cursor openCursor() throws IOException;
/**
* Returns the entry count when cheaply known.
*
* @return count or empty
*/
OptionalLong count();
/**
* Releases the stable snapshot.
*
* @throws IOException if cleanup fails
*/
@Override
void close() throws IOException;
/**
* One-pass entry cursor.
*/
interface Cursor extends AutoCloseable {
/**
* Advances the cursor.
*
* @return {@code true} when an entry is available
* @throws IOException if reading fails
*/
boolean next() throws IOException;
/**
* Returns the current entry.
*
* @return current entry
*/
CrlEntry current();
/**
* Returns the current zero-based ordinal.
*
* @return ordinal
*/
long ordinal();
@Override
void close() throws IOException;
}
}

View File

@@ -33,10 +33,8 @@
******************************************************************************/
package zeroecho.pki.spi.framework;
import java.util.List;
import zeroecho.pki.api.status.StatusObject;
import zeroecho.pki.api.status.StatusObjectGenerateCommand;
import zeroecho.pki.impl.framework.x509.X509SignedObjectCompletion;
/**
* Generates status objects for a credential framework (e.g., CRL/delta CRL/OCSP
@@ -49,11 +47,11 @@ public interface StatusObjectGenerator {
* Generates a status object.
*
* @param command generation command
* @param crlEntries structured CRL entries; empty for non-CRL objects
* @param crlEntries stable streamed CRL entries
* @return generated status object
* @throws IllegalArgumentException if {@code command} or {@code crlEntries} is
* invalid
* @throws RuntimeException if generation fails
*/
StatusObject generate(StatusObjectGenerateCommand command, List<CrlEntry> crlEntries);
X509SignedObjectCompletion generate(StatusObjectGenerateCommand command, CrlEntrySource crlEntries);
}

View File

@@ -33,7 +33,7 @@
******************************************************************************/
package zeroecho.pki.spi.publish;
import zeroecho.pki.api.EncodedObject;
import zeroecho.core.io.RepeatableContent;
import zeroecho.pki.api.publication.PublicationTarget;
/**
@@ -50,5 +50,5 @@ public interface Publisher {
* @throws IllegalArgumentException if inputs are null
* @throws RuntimeException if publishing fails
*/
void publish(PublicationTarget target, EncodedObject payload);
void publish(PublicationTarget target, RepeatableContent payload);
}

View File

@@ -0,0 +1,92 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.spi.store;
import java.io.IOException;
import java.io.OutputStream;
import zeroecho.pki.api.content.DurableContentReference;
/**
* Atomic sequential sink for staged operation content.
*
* <p>
* A sink is thread-confined. Completion atomically makes the resulting immutable
* content visible. Closing or aborting before completion removes partial content.
* Aggregate byte accounting uses {@code long}; the sink never falls back to
* aggregate heap buffering.
* </p>
*/
public interface ContentSink extends AutoCloseable {
/**
* Returns the sequential output stream owned by this sink.
*
* @return output stream
* @throws IOException if writing cannot begin
* @throws IllegalStateException if the sink is already completed or aborted
*/
OutputStream outputStream() throws IOException;
/**
* Returns the bytes accepted so far.
*
* @return non-negative byte count
*/
long length();
/**
* Atomically completes the content.
*
* @return immutable durable reference
* @throws IOException if data or metadata cannot be committed
* @throws IllegalStateException if the sink is not open
*/
DurableContentReference complete() throws IOException;
/**
* Aborts and removes partial content.
*
* @throws IOException if cleanup fails
*/
void abort() throws IOException;
/**
* Aborts an incomplete sink. Closing a completed sink is a no-op.
*
* @throws IOException if cleanup fails
*/
@Override
void close() throws IOException;
}

View File

@@ -0,0 +1,67 @@
package zeroecho.pki.spi.store;
import java.util.Objects;
import java.util.Optional;
import java.util.OptionalLong;
/**
* Immutable immediate or resolved metadata-transaction result.
*
* <p>A {@link Outcome#COMMITTED} outcome is the only immediate result that
* authorizes durable metadata success. {@link Outcome#UNKNOWN} is uncertainty
* about an already fixed attempt; it implies neither commit nor rollback and
* must be resolved through the owning store.</p>
*
* @param transactionId store-issued transaction identity
* @param outcome semantic outcome
* @param committedRevision committed store revision when known
* @param failureCategory stable safe failure category when applicable
*/
public record MetadataCommitResult(MetadataTransactionId transactionId, Outcome outcome,
OptionalLong committedRevision, Optional<FailureCategory> failureCategory) {
/** Closed commit-outcome model. */
public enum Outcome {
/** Every mutation was durably committed and atomically visible. */ COMMITTED,
/** No mutation from the transaction became committed. */ NOT_COMMITTED,
/** The immediate caller cannot yet determine the fixed durable outcome. */ UNKNOWN
}
/** Stable non-sensitive metadata failure categories. */
public enum FailureCategory {
/** Create or expected-revision precondition failed. */ CONFLICT,
/** Transaction identity belongs to another store. */ FOREIGN_TRANSACTION,
/** Transaction identity was never issued by this store. */ TRANSACTION_NOT_ISSUED,
/**
* The transaction was durably issued but had no authoritative terminal
* outcome when recovery closed its preceding recovery epoch.
*
* <p>This category is a definitive non-commit. It is neither an
* uncertain outcome nor a storage failure.</p>
*/
ABANDONED_BY_RECOVERY,
/** Durable storage or content I/O failed. */ STORAGE_FAILURE,
/** Adapter technical representability limit was exceeded. */ LIMIT_EXCEEDED,
/** Adapter lacks an optional requested capability. */ UNSUPPORTED_CAPABILITY,
/** Stored metadata failed integrity validation. */ INTEGRITY_FAILURE,
/** Cooperative cancellation was requested. */ CANCELLED
}
/** Validates result invariants and checked revision semantics. */
public MetadataCommitResult {
Objects.requireNonNull(transactionId, "transactionId");
Objects.requireNonNull(outcome, "outcome");
Objects.requireNonNull(committedRevision, "committedRevision");
Objects.requireNonNull(failureCategory, "failureCategory");
if (committedRevision.isPresent() && committedRevision.getAsLong() < 0L) {
throw new IllegalArgumentException("Committed revision must not be negative");
}
if (outcome == Outcome.COMMITTED
&& (committedRevision.isEmpty() || failureCategory.isPresent())) {
throw new IllegalArgumentException(
"COMMITTED requires one revision and no failure category");
}
if (outcome != Outcome.COMMITTED && committedRevision.isPresent()) {
throw new IllegalArgumentException("Only COMMITTED may carry a revision");
}
}
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright (c) 2025 ZeroEcho
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package zeroecho.pki.spi.store;
import java.io.IOException;
import java.util.Optional;
import zeroecho.core.io.CancellationSignal;
/**
* A closeable, snapshot-consistent streaming metadata cursor.
*
* <p>Records are returned in deterministic {@link MetadataKey} order without
* requiring complete namespace materialization. A cursor is bound to its
* issuing snapshot and cannot be used after close.
*/
public interface MetadataCursor extends AutoCloseable {
/**
* Advances the cursor by at most one record.
*
* @param cancellation cooperative cancellation signal
* @return the next record, or empty when exhausted
* @throws IOException when record traversal fails
* @throws MetadataStoreException when the cursor is closed or foreign
*/
Optional<MetadataSnapshot.Record> next(CancellationSignal cancellation) throws IOException;
/**
* Closes this cursor idempotently.
*/
@Override
void close();
}

View File

@@ -0,0 +1,163 @@
package zeroecho.pki.spi.store;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;
import java.util.Optional;
/**
* Immutable provider-neutral identity of one metadata record.
*
* <p>The namespace is canonical and extension-owned. The {@code zeroecho}
* root and its children are reserved. The key is exact, case-sensitive visible
* ASCII and has no filesystem interpretation.</p>
*
* @param namespace canonical extension namespace
* @param key exact logical key
*/
public record MetadataKey(String namespace, String key) implements Comparable<MetadataKey> {
private static final int MINIMUM_KEY_BYTE = 0x21;
private static final int MAXIMUM_KEY_BYTE = 0x7e;
/** Maximum canonical namespace length in UTF-8 bytes. */
public static final int MAXIMUM_NAMESPACE_UTF8_BYTES = 255;
/** Maximum exact logical-key length in UTF-8 bytes. */
public static final int MAXIMUM_KEY_UTF8_BYTES = 4096;
private static final String RESERVED_ROOT = "zeroecho";
private static final String RESERVED_PREFIX = RESERVED_ROOT + ".";
/** Validates both identity components. */
public MetadataKey {
validateNamespace(namespace);
validateKey(key, false);
}
/* package */ static void validateNamespace(String value) {
Objects.requireNonNull(value, "namespace");
if (!value.matches("[a-z0-9]+(?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9]+(?:[a-z0-9-]*[a-z0-9])?)*")) {
throw new IllegalArgumentException("Metadata namespace is not canonical");
}
if (RESERVED_ROOT.equals(value) || value.startsWith(RESERVED_PREFIX)) {
throw new IllegalArgumentException("The zeroecho namespace domain is reserved");
}
requireUtf8Limit(value, MAXIMUM_NAMESPACE_UTF8_BYTES, "Metadata namespace");
}
/* package */ static void validateKey(String value, boolean emptyAllowed) {
Objects.requireNonNull(value, "key");
if (!emptyAllowed && value.isEmpty()) {
throw new IllegalArgumentException("Metadata key must not be empty");
}
requireUtf8Limit(value, MAXIMUM_KEY_UTF8_BYTES, "Metadata key");
for (int index = 0; index < value.length(); index++) {
char current = value.charAt(index);
if (current < MINIMUM_KEY_BYTE
|| current > MAXIMUM_KEY_BYTE
|| current == ':'
|| current == '/'
|| current == '\\') {
throw new IllegalArgumentException("Metadata key is not canonical visible ASCII");
}
}
}
/* package */ static void validateKeyComponent(String value) {
validateKey(value, false);
}
/*
* Package-private because ranges share the exact unsigned canonical-key
* ordering without widening this implementation detail into public API.
*/
/* package */ static int compareKeyComponents(String first, String second) {
return Arrays.compareUnsigned(first.getBytes(StandardCharsets.UTF_8),
second.getBytes(StandardCharsets.UTF_8));
}
/*
* An ordering boundary may contain a byte forbidden in a stored key. It is
* compared only and never accepted as semantic record identity.
*/
/* package */ static void validateOrderingBoundary(String value) {
Objects.requireNonNull(value, "ordering boundary");
if (value.isEmpty()) {
throw new IllegalArgumentException("Ordering boundary must not be empty");
}
for (int index = 0; index < value.length(); index++) {
char current = value.charAt(index);
if (current < MINIMUM_KEY_BYTE || current > MAXIMUM_KEY_BYTE) {
throw new IllegalArgumentException(
"Ordering boundary is not canonical visible ASCII");
}
}
}
/** {@inheritDoc} */
@Override
public int compareTo(MetadataKey other) {
Objects.requireNonNull(other, "other");
int namespaceOrder = namespace.compareTo(other.namespace);
if (namespaceOrder == 0) {
return compareKeyComponents(key, other.key);
}
return namespaceOrder;
}
/**
* Parses one complete canonical extension-owned identity.
*
* @param encoded canonical namespace and key separated by one colon
* @return parsed metadata key
* @throws NullPointerException if {@code encoded} is {@code null}
* @throws IllegalArgumentException if the representation is malformed
*/
public static MetadataKey parse(String encoded) {
Objects.requireNonNull(encoded, "encoded");
int separator = encoded.indexOf(':');
if (separator <= 0 || separator != encoded.lastIndexOf(':')
|| separator == encoded.length() - 1) {
throw new IllegalArgumentException("Metadata key representation is not canonical");
}
MetadataKey result = new MetadataKey(
encoded.substring(0, separator), encoded.substring(separator + 1));
if (!result.canonical().equals(encoded)) {
throw new IllegalArgumentException("Metadata key representation is not canonical");
}
return result;
}
/**
* Returns the complete canonical representation.
*
* @return namespace and exact key separated by one colon
*/
public String canonical() {
return namespace + ':' + key;
}
/*
* Package-private because only KeyRange constructs ordering boundaries.
* The scan is O(p) time and the returned boundary uses O(p) memory.
*/
/* package */ static Optional<String> prefixSuccessor(String prefix) {
byte[] value = prefix.getBytes(StandardCharsets.UTF_8);
int successorIndex = -1;
for (int index = value.length - 1; index >= 0; index--) {
if (Byte.toUnsignedInt(value[index]) < MAXIMUM_KEY_BYTE) {
value[index]++;
successorIndex = index;
break;
}
}
if (successorIndex < 0) {
return Optional.empty();
}
byte[] boundary = Arrays.copyOf(value, successorIndex + 1);
return Optional.of(new String(boundary, StandardCharsets.UTF_8));
}
private static void requireUtf8Limit(String value, int maximumBytes, String component) {
if (value.getBytes(StandardCharsets.UTF_8).length > maximumBytes) {
throw new IllegalArgumentException(component + " exceeds its canonical UTF-8 limit");
}
}
}

View File

@@ -0,0 +1,215 @@
/*
* Copyright (c) 2025 ZeroEcho
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package zeroecho.pki.spi.store;
import java.io.IOException;
import java.util.Objects;
import java.util.Optional;
import zeroecho.core.io.CancellationSignal;
import zeroecho.core.io.RepeatableContent;
/**
* An immutable, store-issued view of one committed metadata revision.
*
* <p>Reads and scans never observe later commits. The snapshot and all live
* objects it issues remain bound to the exact issuing store instance.
*/
public interface MetadataSnapshot extends AutoCloseable {
/**
* A repeatable record view owned by a snapshot.
*/
interface Record extends RepeatableContent {
/**
* Returns the semantic record identity.
*
* @return record key
*/
MetadataKey key();
/**
* Returns the non-negative record revision.
*
* @return record revision
*/
long recordRevision();
/**
* Returns the committed store revision containing this record revision.
*
* @return committed store revision
*/
long commitRevision();
/**
* Returns stable adapter-supplied integrity metadata when available.
*
* @return optional integrity metadata
*/
Optional<Integrity> integrity();
}
/**
* Stable content-integrity metadata without payload or storage details.
*
* @param algorithm canonical integrity algorithm identity
* @param value canonical integrity value
*/
record Integrity(String algorithm, String value) {
/**
* Creates integrity metadata.
*
* @param algorithm canonical integrity algorithm identity
* @param value canonical integrity value
*/
public Integrity {
Objects.requireNonNull(algorithm, "algorithm");
Objects.requireNonNull(value, "value");
if (algorithm.isBlank() || value.isBlank()) {
throw new IllegalArgumentException("Integrity fields must not be blank");
}
}
}
/**
* An ordered range within one semantic namespace.
*
* @param namespace canonical extension namespace
* @param lowerInclusive optional inclusive key-component lower bound
* @param upperExclusive optional exclusive key-component upper bound
*/
record KeyRange(
String namespace,
Optional<String> lowerInclusive,
Optional<String> upperExclusive) {
/**
* Creates a validated range.
*
* @param namespace canonical extension namespace
* @param lowerInclusive optional inclusive lower bound
* @param upperExclusive optional exclusive upper bound
*/
public KeyRange {
MetadataKey.validateNamespace(namespace);
Objects.requireNonNull(lowerInclusive, "lowerInclusive");
Objects.requireNonNull(upperExclusive, "upperExclusive");
lowerInclusive.ifPresent(MetadataKey::validateKeyComponent);
upperExclusive.ifPresent(MetadataKey::validateOrderingBoundary);
if (lowerInclusive.isPresent()
&& upperExclusive.isPresent()
&& MetadataKey.compareKeyComponents(
lowerInclusive.orElseThrow(),
upperExclusive.orElseThrow()) >= 0) {
throw new IllegalArgumentException("Range lower bound must precede upper bound");
}
}
/**
* Returns a range containing the complete namespace.
*
* @param namespace canonical extension namespace
* @return complete-namespace range
*/
public static KeyRange all(String namespace) {
return new KeyRange(namespace, Optional.empty(), Optional.empty());
}
/**
* Returns the total ordered range for a canonical key prefix.
*
* @param namespace canonical extension namespace
* @param prefix canonical key prefix, possibly empty
* @return prefix range with an unbounded upper limit when no successor exists
*/
public static KeyRange prefix(String namespace, String prefix) {
MetadataKey.validateNamespace(namespace);
Objects.requireNonNull(prefix, "prefix");
if (prefix.isEmpty()) {
return all(namespace);
}
MetadataKey.validateKeyComponent(prefix);
return new KeyRange(
namespace,
Optional.of(prefix),
MetadataKey.prefixSuccessor(prefix));
}
/**
* Tests whether a key belongs to this range.
*
* @param candidate key to test
* @return {@code true} when the candidate belongs to this range
*/
public boolean contains(MetadataKey candidate) {
Objects.requireNonNull(candidate, "candidate");
if (!namespace.equals(candidate.namespace())) {
return false;
}
String component = candidate.key();
boolean aboveLower = lowerInclusive.isEmpty()
|| MetadataKey.compareKeyComponents(
component,
lowerInclusive.orElseThrow()) >= 0;
boolean belowUpper = upperExclusive.isEmpty()
|| MetadataKey.compareKeyComponents(
component,
upperExclusive.orElseThrow()) < 0;
return aboveLower && belowUpper;
}
}
/**
* Returns the issuing store identity.
*
* @return store identity
*/
MetadataStoreId storeId();
/**
* Returns the captured non-negative committed store revision.
*
* @return snapshot revision
*/
long revision();
/**
* Reads one record from this stable view.
*
* @param key semantic record identity
* @return the stable record, or empty when absent
* @throws IOException when content metadata cannot be opened
* @throws MetadataStoreException when this snapshot is closed or foreign
*/
Optional<Record> get(MetadataKey key) throws IOException;
/**
* Opens a lazy deterministic scan over this stable view.
*
* @param range ordered semantic range
* @param cancellation cooperative cancellation signal
* @return store-issued cursor
* @throws IOException when the scan cannot be opened
* @throws MetadataStoreException when this snapshot is closed or foreign
*/
MetadataCursor scan(KeyRange range, CancellationSignal cancellation) throws IOException;
/**
* Closes this snapshot idempotently and invalidates its cursors.
*/
@Override
void close();
}

View File

@@ -0,0 +1,63 @@
package zeroecho.pki.spi.store;
import java.util.Objects;
import java.util.OptionalLong;
import java.util.Set;
/**
* Immutable adapter capability declaration.
*
* <p>Atomic transactions, conflict detection, outcome resolution, stable
* snapshots, deterministic scans and synchronous content detachment are
* intrinsic store requirements. Only optional adapter properties appear here.</p>
*
* @param writerModel active-writer model
* @param contentLengthModel accepted content-length forms
* @param outcomeRetention authoritative outcome-retention model
* @param optionalFeatures optional adapter facilities
* @param maximumIndividualRecordBytes optional positive technical record limit
*/
public record MetadataStoreCapabilities(WriterModel writerModel,
ContentLengthModel contentLengthModel, OutcomeRetention outcomeRetention,
Set<OptionalFeature> optionalFeatures, OptionalLong maximumIndividualRecordBytes) {
/** Adapter writer-concurrency model. */
public enum WriterModel {
/** At most one writer transaction may be active. */ EXCLUSIVE_WRITER,
/** Concurrent writers have serializable transaction outcomes. */ SERIALIZABLE_MULTI_WRITER
}
/** Accepted repeatable-content length forms. */
public enum ContentLengthModel {
/** Every admitted value must declare its exact length. */ KNOWN_LENGTH_ONLY,
/** Known and initially unknown lengths can be staged safely. */ KNOWN_OR_UNKNOWN_LENGTH
}
/** Authoritative transaction-outcome retention model. */
public enum OutcomeRetention {
/** Known terminal outcomes may be released after acknowledgement. */ UNTIL_ACKNOWLEDGED,
/** Known terminal outcomes remain resolvable indefinitely. */ INDEFINITE
}
/** Optional facilities that do not weaken required store semantics. */
public enum OptionalFeature {
/** Store authority coordinates cooperating processes. */ CROSS_PROCESS_COORDINATION,
/** Adapter supports explicit checkpoints. */ CHECKPOINT,
/** Adapter supports history compaction. */ COMPACTION
}
/** Validates and defensively copies capability values. */
public MetadataStoreCapabilities {
Objects.requireNonNull(writerModel, "writerModel");
Objects.requireNonNull(contentLengthModel, "contentLengthModel");
Objects.requireNonNull(outcomeRetention, "outcomeRetention");
optionalFeatures = Set.copyOf(Objects.requireNonNull(optionalFeatures,
"optionalFeatures"));
Objects.requireNonNull(maximumIndividualRecordBytes,
"maximumIndividualRecordBytes");
if (maximumIndividualRecordBytes.isPresent()
&& maximumIndividualRecordBytes.getAsLong() <= 0L) {
throw new IllegalArgumentException(
"Adapter technical record limit must be positive");
}
}
}

View File

@@ -0,0 +1,44 @@
package zeroecho.pki.spi.store;
import java.io.IOException;
import java.util.Objects;
/** Checked metadata-store failure carrying a stable, non-sensitive category. */
public class MetadataStoreException extends IOException {
private static final long serialVersionUID = -8014654957838033436L;
private final MetadataCommitResult.FailureCategory category;
/**
* Creates a categorized failure.
*
* @param category stable safe category
* @param safeMessage non-sensitive diagnostic message
*/
public MetadataStoreException(MetadataCommitResult.FailureCategory category,
String safeMessage) {
super(Objects.requireNonNull(safeMessage, "safeMessage"));
this.category = Objects.requireNonNull(category, "category");
}
/**
* Creates a categorized failure retaining its cause.
*
* @param category stable safe category
* @param safeMessage non-sensitive diagnostic message
* @param cause original cause
*/
public MetadataStoreException(MetadataCommitResult.FailureCategory category,
String safeMessage, Throwable cause) {
super(Objects.requireNonNull(safeMessage, "safeMessage"), cause);
this.category = Objects.requireNonNull(category, "category");
}
/**
* Returns the stable safe category.
*
* @return failure category
*/
public MetadataCommitResult.FailureCategory category() {
return category;
}
}

View File

@@ -0,0 +1,27 @@
package zeroecho.pki.spi.store;
import java.util.Objects;
/**
* Stable provider-neutral metadata-store identity.
*
* <p>Possession of an equal value does not grant live store authority.</p>
*
* @param value 32 lowercase hexadecimal characters
*/
public record MetadataStoreId(String value) {
/** Validates the canonical identity. */
public MetadataStoreId {
Objects.requireNonNull(value, "value");
if (!value.matches("[0-9a-f]{32}")) {
throw new IllegalArgumentException(
"Metadata store identifier must be 32 lowercase hexadecimal characters");
}
}
/** Returns the canonical identity. */
@Override
public String toString() {
return value;
}
}

View File

@@ -0,0 +1,105 @@
/*
* Copyright (c) 2025 ZeroEcho
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package zeroecho.pki.spi.store;
import java.io.IOException;
import zeroecho.core.io.CancellationSignal;
import zeroecho.core.io.RepeatableContent;
/**
* A single-use, store-issued metadata transaction.
*
* <p>Implementations synchronously detach supplied content before a mutation
* method returns. Transactions are thread-confined and reject operations
* after commit, abort, or close. A revision conflict terminates the complete
* transaction without exposing a subset of its mutations.
*/
public interface MetadataTransaction extends AutoCloseable {
/**
* Returns the store-issued transaction identity.
*
* @return transaction identity
*/
MetadataTransactionId id();
/**
* Adds a create-if-absent mutation.
*
* @param key semantic record identity
* @param content repeatable finite control metadata
* @param cancellation cooperative cancellation signal
* @throws IOException when content staging fails
* @throws MetadataStoreException when the mutation is invalid or unsupported
*/
void create(
MetadataKey key,
RepeatableContent content,
CancellationSignal cancellation) throws IOException;
/**
* Adds a compare-and-replace mutation.
*
* @param key semantic record identity
* @param expectedRevision required current non-negative record revision
* @param content repeatable finite control metadata
* @param cancellation cooperative cancellation signal
* @throws IOException when content staging fails
* @throws MetadataStoreException when the mutation is invalid or unsupported
*/
void replace(
MetadataKey key,
long expectedRevision,
RepeatableContent content,
CancellationSignal cancellation) throws IOException;
/**
* Adds a compare-and-delete mutation.
*
* @param key semantic record identity
* @param expectedRevision required current non-negative record revision
*/
void delete(MetadataKey key, long expectedRevision);
/**
* Attempts one atomic durable commit.
*
* <p>A {@link MetadataCommitResult.Outcome#COMMITTED} outcome is the only
* immediate result that authorizes a caller to report durable metadata
* success. {@link MetadataCommitResult.Outcome#UNKNOWN} is uncertainty
* about an already fixed attempt; it does not permit delayed execution.
*
* @return authoritative or immediately observable commit result
* @throws IOException when the store cannot complete the operation
* @throws MetadataStoreException when transaction authority or lifecycle is invalid
*/
MetadataCommitResult commit() throws IOException;
/**
* Aborts the transaction and releases detached staging resources.
*
* @throws IOException when resource retirement fails
*/
void abort() throws IOException;
/**
* Closes the transaction idempotently, aborting an uncommitted transaction.
*
* @throws IOException when resource retirement fails
*/
@Override
void close() throws IOException;
}

View File

@@ -0,0 +1,53 @@
package zeroecho.pki.spi.store;
import java.util.Objects;
/**
* Canonical durable transaction identity issued by one metadata store.
*
* <p>Constructing an equal value does not prove that the owning store issued it.</p>
*
* @param storeId durable owning-store identity
* @param token 32 lowercase hexadecimal transaction token
*/
public record MetadataTransactionId(MetadataStoreId storeId, String token) {
private static final String PREFIX = "v1:";
/** Validates the canonical identity components. */
public MetadataTransactionId {
Objects.requireNonNull(storeId, "storeId");
Objects.requireNonNull(token, "token");
if (!token.matches("[0-9a-f]{32}")) {
throw new IllegalArgumentException(
"Metadata transaction token must be 32 lowercase hexadecimal characters");
}
}
/**
* Parses one complete current canonical representation.
*
* @param encoded canonical representation
* @return parsed identity
* @throws NullPointerException if {@code encoded} is {@code null}
* @throws IllegalArgumentException if the representation is malformed
*/
public static MetadataTransactionId parse(String encoded) {
Objects.requireNonNull(encoded, "encoded");
String[] parts = encoded.split(":", -1);
if (parts.length != 3 || !"v1".equals(parts[0])) {
throw new IllegalArgumentException("Metadata transaction identifier is not canonical");
}
MetadataTransactionId result = new MetadataTransactionId(
new MetadataStoreId(parts[1]), parts[2]);
if (!result.toString().equals(encoded)) {
throw new IllegalArgumentException("Metadata transaction identifier is not canonical");
}
return result;
}
/** Returns the complete current canonical representation. */
@Override
public String toString() {
return PREFIX + storeId.value() + ":" + token;
}
}

View File

@@ -76,6 +76,13 @@ import zeroecho.pki.api.status.StatusObject;
*/
public interface PkiStore extends SignWorkflowStore {
/**
* Returns the runtime-owned durable streaming-content store.
*
* @return staged-content store used by this PKI store
*/
StagedContentStore stagedContent();
/**
* Persists or updates a Certificate Authority (CA) record.
*
@@ -175,11 +182,12 @@ public interface PkiStore extends SignWorkflowStore {
Optional<RevocationJournal> getRevocationJournal(PkiId credentialId);
/**
* Lists authoritative revocation journals.
* Opens a stable restartable streaming snapshot of authoritative revocations.
*
* @return immutable journal list
* @return revocation snapshot
* @throws IllegalStateException if the snapshot cannot be opened
*/
List<RevocationJournal> listRevocationJournals();
RevocationSnapshot openRevocationSnapshot();
/**
* Persists a status object.

View File

@@ -0,0 +1,120 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.spi.store;
import java.io.IOException;
import java.util.OptionalLong;
import zeroecho.pki.api.revocation.RevocationJournal;
/**
* Stable, restartable streaming view of authoritative revocation journals.
*
* <p>
* A snapshot never exposes a complete collection. Each cursor yields one
* immutable journal at a time and uses {@code long} ordinal accounting. ZeroEcho
* core imposes no product-wide entry-count ceiling.
* </p>
*/
public interface RevocationSnapshot extends AutoCloseable {
/**
* Returns stable non-secret snapshot provenance.
*
* @return non-blank snapshot identifier
*/
String snapshotId();
/**
* Returns the journal count when the store can provide it without
* materialization.
*
* @return count or empty
*/
OptionalLong count();
/**
* Opens a new pass over the same logical snapshot.
*
* @return closeable cursor
* @throws IOException if the cursor cannot be opened
*/
Cursor openCursor() throws IOException;
/**
* Releases snapshot resources.
*
* @throws IOException if cleanup fails
*/
@Override
void close() throws IOException;
/**
* One-pass journal cursor.
*/
interface Cursor extends AutoCloseable {
/**
* Advances to the next journal.
*
* @return {@code true} when {@link #current()} is available
* @throws IOException if store reading fails
*/
boolean next() throws IOException;
/**
* Returns the current journal.
*
* @return immutable current journal
* @throws IllegalStateException if the cursor is not positioned on a value
*/
RevocationJournal current();
/**
* Returns the zero-based current ordinal.
*
* @return non-negative ordinal
* @throws IllegalStateException if the cursor is not positioned on a value
*/
long ordinal();
/**
* Closes the cursor.
*
* @throws IOException if cleanup fails
*/
@Override
void close() throws IOException;
}
}

View File

@@ -0,0 +1,201 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.pki.spi.store;
import java.io.IOException;
import zeroecho.core.io.CancellationSignal;
import zeroecho.core.io.OneShotContent;
import zeroecho.core.io.RepeatableContent;
import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.content.DurableContentReference;
import zeroecho.pki.api.content.DurableContentOwner;
/**
* Durable, runtime-owned staging boundary for signed-object content.
*
* <p>
* ZeroEcho core does not impose an arbitrary product-wide limit on aggregate CRL
* size or revocation-entry cardinality. Implementations stream to durable storage
* and use {@code long} accounting. Completion remains subject to available
* storage, I/O, technical representability and explicitly injected deployment
* policy.
* </p>
*/
public interface StagedContentStore {
/**
* Returns the stable logical store identifier.
*
* @return non-blank identifier
*/
String contentStoreId();
/**
* Begins an atomic staged write.
*
* @param encoding content encoding
* @param lifecycle ownership classification
* @return new incomplete sink
* @throws IOException if staging cannot begin
*/
ContentSink beginContent(Encoding encoding, DurableContentReference.Lifecycle lifecycle) throws IOException;
/**
* Stages a one-shot input incrementally.
*
* @param input one-shot source
* @param encoding content encoding
* @param lifecycle ownership classification
* @param cancellation cancellation signal
* @return durable reference
* @throws IOException if reading, writing, cancellation or completion fails
*/
default DurableContentReference stage(OneShotContent input, Encoding encoding,
DurableContentReference.Lifecycle lifecycle, CancellationSignal cancellation) throws IOException {
try (OneShotContent source = input; ContentSink sink = beginContent(encoding, lifecycle);
java.io.InputStream stream = source.openStream(); java.io.OutputStream output = sink.outputStream()) {
byte[] buffer = new byte[16 * 1024];
try {
int read;
while ((read = stream.read(buffer)) >= 0) {
cancellation.throwIfCancelled();
if (read > 0) {
output.write(buffer, 0, read);
}
}
return sink.complete();
} finally {
java.util.Arrays.fill(buffer, (byte) 0);
}
}
}
/**
* Resolves a completed reference to immutable repeatable content.
*
* @param reference durable reference owned by this store
* @return repeatable content
* @throws IOException if content is missing or fails integrity
* @throws IllegalArgumentException if the reference belongs to another store
*/
RepeatableContent openContent(DurableContentReference reference) throws IOException;
/**
* Restores one persisted reference through this owning store.
*
* <p>
* The supplied fields are untrusted persistence data. The store validates the
* current schema values against its sealed immutable metadata and returns its
* own authoritative reference only on an exact match. This method does not
* create content and does not establish durable business-object ownership.
* Earlier pre-release formats are rejected rather than migrated.
* </p>
*
* @param storeId owning-store identity from the persisted record
* @param contentId logical content identity from the persisted record
* @param encoding persisted transport encoding
* @param length persisted exact byte length
* @param sha256 persisted canonical SHA-256 integrity value
* @param lifecycle persisted purpose classification
* @return store-issued immutable reference after exact metadata validation
* @throws IOException if metadata is missing, malformed, or inconsistent
* @throws IllegalArgumentException if the store identity is foreign or an
* identifier is malformed
*/
DurableContentReference restoreReference(String storeId, String contentId, Encoding encoding, long length,
String sha256, DurableContentReference.Lifecycle lifecycle) throws IOException;
/**
* Durably retains sealed content for one typed business owner.
*
* @param reference store-issued sealed reference
* @param owner exact durable owner
* @return {@code true} when a new association was written; {@code false} when
* the same association already existed
* @throws IOException if content or ownership metadata cannot be validated or
* persisted
*/
boolean retainContent(DurableContentReference reference, DurableContentOwner owner) throws IOException;
/**
* Durably releases one exact owner and retires content after its last owner.
*
* @param reference store-issued sealed reference
* @param owner exact durable owner
* @return {@code true} when the association existed and was removed
* @throws IOException if ownership metadata cannot be validated or persisted
*/
boolean releaseContent(DurableContentReference reference, DurableContentOwner owner) throws IOException;
/**
* Returns the immutable exact owner set for sealed content.
*
* @param reference store-issued sealed reference
* @return immutable owner set
* @throws IOException if ownership metadata is corrupt
*/
java.util.Set<DurableContentOwner> contentOwners(DurableContentReference reference) throws IOException;
/**
* Retires sealed content that has no durable owner.
*
* @param reference store-issued sealed reference
* @throws IOException if content is retained or deletion fails
*/
void retireUnownedContent(DurableContentReference reference) throws IOException;
/**
* Removes completed content not referenced by durable runtime state.
*
* <p>
* The supplied disk-backed index permits recovery without retaining all
* durable references in heap. Temporary content is always abandoned across a
* restart. Operation and persisted content survive only when present in the
* retained index.
* </p>
*
* @param retained completed content identifiers referenced by durable state
* @throws IOException if recovery or cleanup fails
*/
void recoverContent(TemporaryUniqueIndex retained, TemporaryUniqueIndex retainedOwners) throws IOException;
/**
* Begins one file-backed uniqueness index for bounded individual values.
*
* @return temporary index
* @throws IOException if temporary storage cannot be created
*/
TemporaryUniqueIndex beginUniqueIndex() throws IOException;
}

Some files were not shown because too many files have changed in this diff Show More