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.Optional;
import java.util.Set; import java.util.Set;
import zeroecho.core.alg.BootstrapAlgorithmIdentities;
import zeroecho.core.alg.ecdsa.EcdsaCurveSpec; import zeroecho.core.alg.ecdsa.EcdsaCurveSpec;
import zeroecho.core.alg.rsa.RsaSigSpec; import zeroecho.core.alg.rsa.RsaSigSpec;
import zeroecho.core.spec.AlgorithmIdentity;
import zeroecho.core.spec.VoidSpec; import zeroecho.core.spec.VoidSpec;
/** /**
@@ -96,6 +98,10 @@ public final class SignatureInteropProfiles {
new SignatureInteropProfile("SHA512withRSA", "RSA", "RSA", new SignatureInteropProfile("SHA512withRSA", "RSA", "RSA",
RsaSigSpec.pkcs1v15(RsaSigSpec.Hash.SHA512), RsaSigSpec.pkcs1v15(RsaSigSpec.Hash.SHA512),
SignatureInteropProfile.SignatureRepresentation.IDENTITY, 0)), 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 Map.entry("SHA256withECDSA", new SignatureInteropProfile("SHA256withECDSA", "ECDSA", "ECDSA", // NOPMD
EcdsaCurveSpec.P256, EcdsaCurveSpec.P256,
SignatureInteropProfile.SignatureRepresentation.ECDSA_DER_EXTERNAL_P1363_INTERNAL, 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, Map.entry("Ed448", new SignatureInteropProfile("Ed448", "Ed448", "Ed448", VoidSpec.INSTANCE,
SignatureInteropProfile.SignatureRepresentation.IDENTITY, 0))); 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() { private SignatureInteropProfiles() {
} }
@@ -129,7 +146,8 @@ public final class SignatureInteropProfiles {
if (algorithmId.isBlank()) { if (algorithmId.isBlank()) {
return Optional.empty(); 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 * @return immutable set of supported standard signature names
*/ */
public static Set<String> algorithmIds() { 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; 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; package zeroecho.pki.api.ca;
import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.FormatId; import zeroecho.pki.api.FormatId;
import zeroecho.pki.api.KeyRef; import zeroecho.pki.api.KeyRef;
import zeroecho.pki.api.SubjectRef; import zeroecho.pki.api.SubjectRef;
import zeroecho.pki.api.attr.AttributeSet; import zeroecho.pki.api.attr.AttributeSet;
import zeroecho.pki.api.content.DurableContentReference;
/** /**
* Command to import an existing root CA credential into PKI inventory. * 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) * @param attributes universal attributes (may be empty but not null)
*/ */
public record CaImportCommand(FormatId formatId, SubjectRef subjectRef, String profileId, KeyRef keyRef, public record CaImportCommand(FormatId formatId, SubjectRef subjectRef, String profileId, KeyRef keyRef,
EncodedObject existingCaCredential, AttributeSet attributes) { DurableContentReference existingCaCredential, AttributeSet attributes) {
/** /**
* Creates a CA import command. * 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; package zeroecho.pki.api.credential;
import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.FormatId; import zeroecho.pki.api.FormatId;
import zeroecho.pki.api.IssuerRef; import zeroecho.pki.api.IssuerRef;
import zeroecho.pki.api.PkiId; import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.SubjectRef; import zeroecho.pki.api.SubjectRef;
import zeroecho.pki.api.Validity; import zeroecho.pki.api.Validity;
import zeroecho.pki.api.attr.AttributeSet; import zeroecho.pki.api.attr.AttributeSet;
import zeroecho.pki.api.content.DurableContentReference;
/** /**
* Issued credential with mandatory core metadata and universal attributes. * 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 * current revocation state and evaluation time are
* external runtime inputs. Security-sensitive callers * external runtime inputs. Security-sensitive callers
* must use {@link EffectiveCredentialStatusResolver}. * must use {@link EffectiveCredentialStatusResolver}.
* @param encoded encoded credential bytes * @param content immutable store-owned credential content
* @param attributes universal attribute set * @param attributes universal attribute set
*/ */
public record Credential(PkiId credentialId, FormatId formatId, IssuerRef issuerRef, SubjectRef subjectRef, public record Credential(PkiId credentialId, FormatId formatId, IssuerRef issuerRef, SubjectRef subjectRef,
Validity validity, String serialOrUniqueId, PkiId publicKeyId, CredentialProfileBinding profileBinding, Validity validity, String serialOrUniqueId, PkiId publicKeyId, CredentialProfileBinding profileBinding,
CredentialStatus status, EncodedObject encoded, AttributeSet attributes) { CredentialStatus status, DurableContentReference content, AttributeSet attributes) {
/** /**
* Creates a credential record. * Creates a credential record.
@@ -110,8 +110,8 @@ public record Credential(PkiId credentialId, FormatId formatId, IssuerRef issuer
if (status == null) { if (status == null) {
throw new IllegalArgumentException("status must not be null"); throw new IllegalArgumentException("status must not be null");
} }
if (encoded == null) { if (content == null) {
throw new IllegalArgumentException("encoded must not be null"); throw new IllegalArgumentException("content must not be null");
} }
if (attributes == null) { if (attributes == null) {
throw new IllegalArgumentException("attributes must not be 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 java.util.List;
import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.content.DurableContentReference;
/** /**
* Bundle of a primary credential and supporting objects. * Bundle of a primary credential and supporting objects.
@@ -49,7 +49,7 @@ import zeroecho.pki.api.EncodedObject;
* @param credential primary credential * @param credential primary credential
* @param supportingObjects supporting artifacts (framework-defined ordering) * @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. * Creates a bundle.

View File

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

View File

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

View File

@@ -33,7 +33,6 @@
******************************************************************************/ ******************************************************************************/
package zeroecho.pki.impl.core; package zeroecho.pki.impl.core;
import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.OutputStream; import java.io.OutputStream;
import java.security.MessageDigest; import java.security.MessageDigest;
@@ -49,24 +48,30 @@ import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
import org.bouncycastle.cert.X509CertificateHolder; import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.operator.ContentSigner; 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.EncodedObject;
import zeroecho.pki.api.Encoding; import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.FormatId; import zeroecho.pki.api.FormatId;
import zeroecho.pki.api.KeyRef; import zeroecho.pki.api.KeyRef;
import zeroecho.pki.api.PkiException; import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.PkiId; import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.content.DurableContentReference;
import zeroecho.pki.api.audit.AccessContext; import zeroecho.pki.api.audit.AccessContext;
import zeroecho.pki.api.audit.AuditEvent; import zeroecho.pki.api.audit.AuditEvent;
import zeroecho.pki.api.audit.Principal; import zeroecho.pki.api.audit.Principal;
import zeroecho.pki.api.audit.Purpose; import zeroecho.pki.api.audit.Purpose;
import zeroecho.pki.impl.core.async.PkiSigningBus; 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.spi.audit.AuditSink;
import zeroecho.pki.util.async.AsyncState; import zeroecho.pki.util.async.AsyncState;
import zeroecho.pki.util.async.AsyncStatus; import zeroecho.pki.util.async.AsyncStatus;
import zeroecho.pki.spi.store.ContentSink;
/** /**
* Internal fail-closed proof gate for CA signing keys. * Internal fail-closed proof gate for CA signing keys.
@@ -85,20 +90,27 @@ final class CaProofGate {
private final PublicKeyInfoResolver publicKeyResolver; private final PublicKeyInfoResolver publicKeyResolver;
private final PkiSigningBus signingBus; private final PkiSigningBus signingBus;
private final AuditSink auditSink; private final AuditSink auditSink;
private final String signatureAlgorithmId; private final AlgorithmIdentity signatureIdentity;
private final Duration signingTtl; private final Duration signingTtl;
/* default */ CaProofGate(PublicKeyInfoResolver publicKeyResolver, PkiSigningBus signingBus, AuditSink auditSink, /* default */ CaProofGate(PublicKeyInfoResolver publicKeyResolver, PkiSigningBus signingBus, AuditSink auditSink,
String signatureAlgorithmId, Duration signingTtl) { 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.publicKeyResolver = Objects.requireNonNull(publicKeyResolver, "publicKeyResolver");
this.signingBus = Objects.requireNonNull(signingBus, "signingBus"); this.signingBus = Objects.requireNonNull(signingBus, "signingBus");
this.auditSink = Objects.requireNonNull(auditSink, "auditSink"); this.auditSink = Objects.requireNonNull(auditSink, "auditSink");
this.signatureAlgorithmId = Objects.requireNonNull(signatureAlgorithmId, "signatureAlgorithmId"); this.signatureIdentity = Objects.requireNonNull(signatureIdentity, "signatureIdentity");
this.signingTtl = Objects.requireNonNull(signingTtl, "signingTtl"); this.signingTtl = Objects.requireNonNull(signingTtl, "signingTtl");
} }
/* default */ ContentSigner signer(ManagedKeyProof proof) { /* 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) { /* default */ SubjectPublicKeyInfo parseRootSpki(EncodedObject spki, FormatId formatId) {
@@ -115,8 +127,24 @@ final class CaProofGate {
} }
try { try {
byte[] embeddedSpki = certificate.getSubjectPublicKeyInfo().getEncoded(); byte[] embeddedSpki = certificate.getSubjectPublicKeyInfo().getEncoded();
return MessageDigest.isEqual(expectedSpki.bytes(), embeddedSpki) && certificate.isSignatureValid( BcX509AlgorithmAdapter adapter = new BcX509AlgorithmAdapter(signingBus.authority().bindings());
new JcaContentVerifierProviderBuilder().build(certificate.getSubjectPublicKeyInfo())); 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) { } catch (Exception ex) {
return false; return false;
} }
@@ -174,7 +202,7 @@ final class CaProofGate {
} }
private byte[] signManagedKeyChallenge(KeyRef keyRef, byte[] challenge) { private byte[] signManagedKeyChallenge(KeyRef keyRef, byte[] challenge) {
ContentSigner contentSigner = new BusBackedContentSigner(signingBus, keyRef, signatureAlgorithmId, signingTtl); ContentSigner contentSigner = new BusBackedContentSigner(signingBus, keyRef, signatureIdentity, signingTtl);
try { try {
contentSigner.getOutputStream().write(challenge); contentSigner.getOutputStream().write(challenge);
} catch (IOException ex) { } catch (IOException ex) {
@@ -186,10 +214,17 @@ final class CaProofGate {
private boolean verifyChallenge(byte[] spkiDer, byte[] challenge, byte[] signature) { private boolean verifyChallenge(byte[] spkiDer, byte[] challenge, byte[] signature) {
try { try {
SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(spkiDer); SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(spkiDer);
ContentVerifier verifier = new JcaContentVerifierProviderBuilder().build(spki) BcX509AlgorithmAdapter adapter = new BcX509AlgorithmAdapter(signingBus.authority().bindings());
.get(new DefaultSignatureAlgorithmIdentifierFinder().find(signatureAlgorithmId)); AlgorithmIdentity keyIdentity = adapter.decode(spki.getAlgorithm(),
verifier.getOutputStream().write(challenge); X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM);
return verifier.verify(signature); 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) { } catch (Exception ex) {
return false; return false;
} }
@@ -231,21 +266,30 @@ final class CaProofGate {
private final PkiSigningBus bus; private final PkiSigningBus bus;
private final KeyRef keyRef; private final KeyRef keyRef;
private final String algorithmId; private final AlgorithmIdentity algorithmIdentity;
private final Duration ttl; 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.bus = bus;
this.keyRef = keyRef; this.keyRef = keyRef;
this.algorithmId = algorithmId; this.algorithmIdentity = algorithmIdentity;
this.ttl = ttl; 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 @Override
public AlgorithmIdentifier getAlgorithmIdentifier() { public AlgorithmIdentifier getAlgorithmIdentifier() {
return new DefaultSignatureAlgorithmIdentifierFinder().find(algorithmId); return new BcX509AlgorithmAdapter(bus.authority().bindings()).encode(algorithmIdentity,
X509AlgorithmRole.SIGNATURE_ALGORITHM);
} }
@Override @Override
@@ -255,24 +299,40 @@ final class CaProofGate {
@Override @Override
public byte[] getSignature() { public byte[] getSignature() {
byte[] tbs = output.toByteArray(); DurableContentReference content;
try { try {
Principal owner = new Principal("SYSTEM", "pki"); output.close();
PkiId opId = bus.newSubmissionId(); content = sink.complete();
EncodedObject payload = new EncodedObject(Encoding.BINARY, tbs); } catch (IOException exception) {
AccessContext accessContext = new AccessContext(owner, new Purpose("X509_SIGN"), Optional.empty(), closeSinkPreserving(exception);
Optional.empty()); throw new PkiException("Signing content staging failed: code=SPOOL_STORAGE_FAILED", exception);
PkiSigningBus.SignContinuation continuation = new PkiSigningBus.SignContinuation(accessContext, }
algorithmId, payload, keyRef, Encoding.BINARY, Optional.empty()); Principal owner = new Principal("SYSTEM", "pki");
try { PkiId opId = bus.newSubmissionId();
bus.submitSign(opId, owner, keyRef, algorithmId, payload, ttl, Optional.of(continuation.encode())); AccessContext accessContext = new AccessContext(owner, new Purpose("X509_SIGN"), Optional.empty(),
} catch (RuntimeException failure) { // NOPMD - delete state if submission partially persisted it Optional.empty());
deletePreservingFailure(opId); String canonicalIdentity = algorithmIdentity.canonicalForm();
throw failure; PkiSigningBus.SignContinuation continuation = new PkiSigningBus.SignContinuation(accessContext,
} canonicalIdentity, content, keyRef, Encoding.BINARY, Optional.empty());
return awaitSignature(opId); boolean submitted = false;
try {
bus.submitSign(opId, owner, keyRef, canonicalIdentity, content, ttl,
Optional.of(continuation.encode()));
submitted = true;
} finally { } 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.Optional;
import java.util.Set; 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.x509.SubjectPublicKeyInfo;
import org.bouncycastle.asn1.x9.X9ObjectIdentifiers;
import zeroecho.pki.api.Encoding; import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.PkiException; 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.SubjectAlternativeName;
import zeroecho.pki.api.request.SubjectRdn; import zeroecho.pki.api.request.SubjectRdn;
import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes; 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; import zeroecho.pki.impl.framework.x509.bc.BcX509ProfileSupport;
/** /**
@@ -83,18 +83,20 @@ import zeroecho.pki.impl.framework.x509.bc.BcX509ProfileSupport;
@SuppressWarnings("PMD.CyclomaticComplexity") @SuppressWarnings("PMD.CyclomaticComplexity")
final class CertificateProfileValidator { final class CertificateProfileValidator {
private static final String EC_FAMILY = "ec";
private CertificateProfileValidator() { private CertificateProfileValidator() {
} }
/* package */ static ValidatedCertificateRequest validate(VerifiedIssuanceCandidate candidate, /* package */ static ValidatedCertificateRequest validate(VerifiedIssuanceCandidate candidate,
CertificateProfile profile, CertificateProfileRef profileReference, Credential issuerCredential, CertificateProfile profile, CertificateProfileRef profileReference, Credential issuerCredential,
Instant evaluationTime) { Instant evaluationTime, X509AuthoritySnapshot authority) {
ParsedCertificationRequest request = candidate.request(); ParsedCertificationRequest request = candidate.request();
LeafCertificatePolicy policy = profile.leafPolicy(); LeafCertificatePolicy policy = profile.leafPolicy();
requireCanonicalRequestAttributes(request); requireCanonicalRequestAttributes(request);
List<SubjectRdn> approvedSubject = validateSubject(request, profile); List<SubjectRdn> approvedSubject = validateSubject(request, profile);
List<SubjectAlternativeName> approvedSans = validateSans(request, policy, approvedSubject.isEmpty()); 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); Validity validity = approvedValidity(candidate, request, profile, issuerCredential, evaluationTime);
boolean sanCritical = approvedSubject.isEmpty() boolean sanCritical = approvedSubject.isEmpty()
|| policy.subjectAlternativeNamePolicy().criticalWithNonemptySubject(); || policy.subjectAlternativeNamePolicy().criticalWithNonemptySubject();
@@ -225,15 +227,24 @@ final class CertificateProfileValidator {
// The public exception deliberately redacts ASN.1 parser details. // The public exception deliberately redacts ASN.1 parser details.
@SuppressWarnings({ "PMD.PreserveStackTrace", "PMD.AvoidRethrowingException" }) @SuppressWarnings({ "PMD.PreserveStackTrace", "PMD.AvoidRethrowingException" })
/* package */ static void requireSubjectKeyAllowed(zeroecho.pki.api.EncodedObject exactPublicKey, /* package */ static void requireSubjectKeyAllowed(zeroecho.pki.api.EncodedObject exactPublicKey,
Set<String> allowedAlgorithms) { Set<String> allowedAlgorithms, X509AuthoritySnapshot authority) {
if (exactPublicKey.encoding() != Encoding.DER) { if (exactPublicKey.encoding() != Encoding.DER) {
throw reject("SUBJECT_KEY_UNSUPPORTED"); throw reject("SUBJECT_KEY_UNSUPPORTED");
} }
byte[] encoded = exactPublicKey.bytes(); byte[] encoded = exactPublicKey.bytes();
try { try {
SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(encoded); SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(encoded);
SubjectKeyAlgorithm algorithm = subjectKeyAlgorithm(spki.getAlgorithm().getAlgorithm()); AlgorithmIdentity identity;
requireSupportedParameters(spki, algorithm); 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())) { if (!allowedAlgorithms.contains(algorithm.profileId())) {
throw reject("SUBJECT_KEY_ALGORITHM_FORBIDDEN"); throw reject("SUBJECT_KEY_ALGORITHM_FORBIDDEN");
} }
@@ -258,35 +269,22 @@ final class CertificateProfileValidator {
} }
} }
private static SubjectKeyAlgorithm subjectKeyAlgorithm(ASN1ObjectIdentifier oid) { private static SubjectKeyAlgorithm subjectKeyAlgorithm(AlgorithmIdentity identity) {
if (PKCSObjectIdentifiers.rsaEncryption.equals(oid)) { if (identity.equals(BootstrapAlgorithmIdentities.RSA_PUBLIC_KEY)) {
return new SubjectKeyAlgorithm("RSA", "RSA"); return new SubjectKeyAlgorithm("RSA", "RSA");
} }
if (X9ObjectIdentifiers.id_ecPublicKey.equals(oid)) { if (EC_FAMILY.equals(identity.family().name())) {
return new SubjectKeyAlgorithm("ECDSA", "EC"); return new SubjectKeyAlgorithm("ECDSA", "EC");
} }
if (EdECObjectIdentifiers.id_Ed25519.equals(oid)) { if (identity.equals(BootstrapAlgorithmIdentities.ED25519_PUBLIC_KEY)) {
return new SubjectKeyAlgorithm("Ed25519", "Ed25519"); return new SubjectKeyAlgorithm("Ed25519", "Ed25519");
} }
if (EdECObjectIdentifiers.id_Ed448.equals(oid)) { if (identity.equals(BootstrapAlgorithmIdentities.ED448_PUBLIC_KEY)) {
return new SubjectKeyAlgorithm("Ed448", "Ed448"); return new SubjectKeyAlgorithm("Ed448", "Ed448");
} }
throw reject("SUBJECT_KEY_ALGORITHM_UNKNOWN"); 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. // The public exception deliberately redacts temporal arithmetic details.
@SuppressWarnings("PMD.PreserveStackTrace") @SuppressWarnings("PMD.PreserveStackTrace")
private static Validity approvedValidity(VerifiedIssuanceCandidate candidate, ParsedCertificationRequest request, 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.ArrayList;
import java.util.List; import java.util.List;
import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.attr.AttributeId; import zeroecho.pki.api.attr.AttributeId;
import zeroecho.pki.api.attr.AttributeSet; import zeroecho.pki.api.attr.AttributeSet;
import zeroecho.pki.api.attr.AttributeValue; import zeroecho.pki.api.attr.AttributeValue;
@@ -54,18 +53,13 @@ final class CredentialSnapshots {
/* default */ static CredentialBundle copy(CredentialBundle source) { /* default */ static CredentialBundle copy(CredentialBundle source) {
Credential credential = copy(source.credential()); Credential credential = copy(source.credential());
List<EncodedObject> supporting = source.supportingObjects().stream().map(CredentialSnapshots::copy).toList(); return new CredentialBundle(credential, List.copyOf(source.supportingObjects()));
return new CredentialBundle(credential, supporting);
} }
/* default */ static Credential copy(Credential source) { /* default */ static Credential copy(Credential source) {
return new Credential(source.credentialId(), source.formatId(), source.issuerRef(), source.subjectRef(), return new Credential(source.credentialId(), source.formatId(), source.issuerRef(), source.subjectRef(),
source.validity(), source.serialOrUniqueId(), source.publicKeyId(), source.profileBinding(), source.validity(), source.serialOrUniqueId(), source.publicKeyId(), source.profileBinding(),
source.status(), copy(source.encoded()), copy(source.attributes())); source.status(), source.content(), copy(source.attributes()));
}
private static EncodedObject copy(EncodedObject source) {
return new EncodedObject(source.encoding(), source.bytes().clone());
} }
private static AttributeSet copy(AttributeSet source) { 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.OperatorCreationException;
import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder; 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.CaService;
import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.Encoding; 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.ActiveCertificateProfile;
import zeroecho.pki.api.profile.CertificateProfileKind; import zeroecho.pki.api.profile.CertificateProfileKind;
import zeroecho.pki.impl.core.async.PkiSigningBus; 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.impl.core.attr.SimpleAttributeSet;
import zeroecho.pki.spi.audit.AuditSink; import zeroecho.pki.spi.audit.AuditSink;
import zeroecho.pki.spi.framework.CredentialFramework; import zeroecho.pki.spi.framework.CredentialFramework;
@@ -167,10 +171,12 @@ public final class DefaultCaService implements CaService {
private final CredentialFramework framework; private final CredentialFramework framework;
private final CredentialIssuerBackend issuerBackend; private final CredentialIssuerBackend issuerBackend;
private final CaProofGate proofGate; private final CaProofGate proofGate;
private final zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot authority;
private final AuditSink auditSink; private final AuditSink auditSink;
private final EffectiveCredentialStatusResolver statusResolver; private final EffectiveCredentialStatusResolver statusResolver;
private final ProfileService profileService; private final ProfileService profileService;
private final Clock clock; private final Clock clock;
private final AlgorithmIdentity signatureIdentity;
/** /**
* Creates a CA service bound to a specific store, credential framework, and * 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"); this.issuerBackend = Objects.requireNonNull(issuerBackend, "issuerBackend");
Objects.requireNonNull(publicKeyResolver, "publicKeyResolver"); Objects.requireNonNull(publicKeyResolver, "publicKeyResolver");
Objects.requireNonNull(signingBus, "signingBus"); Objects.requireNonNull(signingBus, "signingBus");
this.authority = signingBus.authority();
this.auditSink = Objects.requireNonNull(auditSink, "auditSink"); this.auditSink = Objects.requireNonNull(auditSink, "auditSink");
this.statusResolver = Objects.requireNonNull(statusResolver, "statusResolver"); this.statusResolver = Objects.requireNonNull(statusResolver, "statusResolver");
this.profileService = Objects.requireNonNull(profileService, "profileService"); this.profileService = Objects.requireNonNull(profileService, "profileService");
@@ -242,7 +249,12 @@ public final class DefaultCaService implements CaService {
if (signingTtl == null || signingTtl.isZero() || signingTtl.isNegative()) { if (signingTtl == null || signingTtl.isZero() || signingTtl.isNegative()) {
throw new IllegalArgumentException("signingTtl must be positive"); 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 request = CaCertificateProfileValidator.validate(
ValidatedCaCertificateRequest.Operation.CREATE_ROOT, activeProfile, CertificateProfileKind.ROOT_CA, ValidatedCaCertificateRequest.Operation.CREATE_ROOT, activeProfile, CertificateProfileKind.ROOT_CA,
command.formatId(), new PkiId("ca:pending-root"), new PkiId("ca:pending-root"), command.subjectRef(), 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()); SubjectPublicKeyInfo rootPublicKeyInfo = proofGate.parseRootSpki(spki, command.formatId());
CaProofGate.ManagedKeyProof proof = proofGate.proveManagedKey(keyRef, command.formatId(), CREATE_ROOT_REJECTED, CaProofGate.ManagedKeyProof proof = proofGate.proveManagedKey(keyRef, command.formatId(), CREATE_ROOT_REJECTED,
Optional.empty()); Optional.empty());
@@ -342,7 +354,7 @@ public final class DefaultCaService implements CaService {
Credential credential = new Credential(credId, command.formatId(), new IssuerRef(caId), request.subjectRef(), Credential credential = new Credential(credId, command.formatId(), new IssuerRef(caId), request.subjectRef(),
validity, serial.toString(), publicKeyId, new CaProfileBinding(request.profileReference()), validity, serial.toString(), publicKeyId, new CaProfileBinding(request.profileReference()),
CredentialStatus.ISSUED, new EncodedObject(Encoding.DER, certDer), CredentialStatus.ISSUED, CredentialContent.stage(store, certDer),
SimpleAttributeSet.builder().build()); SimpleAttributeSet.builder().build());
CredentialProfileBindings.requireCaBinding(credential.profileBinding(), request.profileReference()); 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"); 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; X509CertificateHolder holder;
try { try {
holder = new X509CertificateHolder(certDer); holder = new X509CertificateHolder(certDer);
@@ -423,10 +436,10 @@ public final class DefaultCaService implements CaService {
ValidatedCaCertificateRequest request = CaCertificateProfileValidator.validate( ValidatedCaCertificateRequest request = CaCertificateProfileValidator.validate(
ValidatedCaCertificateRequest.Operation.IMPORT_ROOT, activeProfile, CertificateProfileKind.ROOT_CA, ValidatedCaCertificateRequest.Operation.IMPORT_ROOT, activeProfile, CertificateProfileKind.ROOT_CA,
command.formatId(), caId, caId, command.subjectRef(), spki, Optional.of(validity), evaluationTime, 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(), Credential credential = new Credential(credId, command.formatId(), new IssuerRef(caId), request.subjectRef(),
validity, serial.toString(), publicKeyId, new CaProfileBinding(request.profileReference()), validity, serial.toString(), publicKeyId, new CaProfileBinding(request.profileReference()),
CredentialStatus.ISSUED, new EncodedObject(Encoding.DER, certDer), CredentialStatus.ISSUED, command.existingCaCredential(),
SimpleAttributeSet.builder().build()); SimpleAttributeSet.builder().build());
CredentialProfileBindings.requireCaBinding(credential.profileBinding(), request.profileReference()); CredentialProfileBindings.requireCaBinding(credential.profileBinding(), request.profileReference());
requireCaCertificateMatches(credential, credential, request, caId, IMPORT_ROOT_REJECTED, requireCaCertificateMatches(credential, credential, request, caId, IMPORT_ROOT_REJECTED,
@@ -511,7 +524,7 @@ public final class DefaultCaService implements CaService {
ValidatedCaCertificateRequest.Operation.CREATE_INTERMEDIATE, activeProfile, ValidatedCaCertificateRequest.Operation.CREATE_INTERMEDIATE, activeProfile,
CertificateProfileKind.INTERMEDIATE_CA, command.formatId(), command.issuerCaId(), caId, approvedSubject, CertificateProfileKind.INTERMEDIATE_CA, command.formatId(), command.issuerCaId(), caId, approvedSubject,
subjectSpki, Optional.empty(), evaluationTime, Optional.of(issuerCredential.validity().notAfter()), subjectSpki, Optional.empty(), evaluationTime, Optional.of(issuerCredential.validity().notAfter()),
CertificateSerialAllocator.allocate()); CertificateSerialAllocator.allocate(), authority);
CaProofGate.ManagedKeyProof subjectProof = proofGate.proveManagedKey(command.keyRef().get(), command.formatId(), CaProofGate.ManagedKeyProof subjectProof = proofGate.proveManagedKey(command.keyRef().get(), command.formatId(),
CREATE_INT_REJECTED, Optional.of(caId)); CREATE_INT_REJECTED, Optional.of(caId));
requireSameManagedKey(subjectSpki, subjectProof.exactPublicKey(), CREATE_INT_REJECTED, command.formatId(), requireSameManagedKey(subjectSpki, subjectProof.exactPublicKey(), CREATE_INT_REJECTED, command.formatId(),
@@ -520,7 +533,7 @@ public final class DefaultCaService implements CaService {
Credential backendCredential; Credential backendCredential;
try { try {
backendCredential = issuerBackend.issueIntermediateCertificate(issue, issuerCredential.encoded(), backendCredential = issuerBackend.issueIntermediateCertificate(issue, issuerCredential.content(),
issuer.issuerKeyRef()); issuer.issuerKeyRef());
} catch (RuntimeException ex) { // NOPMD - reject malformed or mutable framework output } catch (RuntimeException ex) { // NOPMD - reject malformed or mutable framework output
throw proofGate.rejection(CREATE_INT_REJECTED, command.formatId(), Optional.of(caId), 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, ValidatedCaCertificateRequest.Operation.ISSUE_INTERMEDIATE, activeProfile,
CertificateProfileKind.INTERMEDIATE_CA, command.formatId(), command.issuerCaId(), command.subjectCaId(), CertificateProfileKind.INTERMEDIATE_CA, command.formatId(), command.issuerCaId(), command.subjectCaId(),
approvedSubject, subjectSpki, command.requestedValidity(), evaluationTime, 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(), CaProofGate.ManagedKeyProof subjectProof = proofGate.proveManagedKey(subject.issuerKeyRef(), command.formatId(),
ISSUE_INT_REJECTED, Optional.of(subject.caId())); ISSUE_INT_REJECTED, Optional.of(subject.caId()));
requireSameManagedKey(subjectSpki, subjectProof.exactPublicKey(), ISSUE_INT_REJECTED, command.formatId(), requireSameManagedKey(subjectSpki, subjectProof.exactPublicKey(), ISSUE_INT_REJECTED, command.formatId(),
@@ -612,7 +626,7 @@ public final class DefaultCaService implements CaService {
Credential backendCredential; Credential backendCredential;
try { try {
backendCredential = issuerBackend.issueIntermediateCertificate(gated, issuerCredential.encoded(), backendCredential = issuerBackend.issueIntermediateCertificate(gated, issuerCredential.content(),
issuer.issuerKeyRef()); issuer.issuerKeyRef());
} catch (RuntimeException ex) { // NOPMD - reject malformed or mutable framework output } catch (RuntimeException ex) { // NOPMD - reject malformed or mutable framework output
throw proofGate.rejection(ISSUE_INT_REJECTED, command.formatId(), Optional.of(subject.caId()), 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, private void requireIssuerKeyBinding(CaRecord issuer, Credential credential, FormatId formatId, String action,
Optional<PkiId> objectId) { Optional<PkiId> objectId) {
try { try {
if (credential.encoded().encoding() != Encoding.DER) { if (credential.content().encoding() != Encoding.DER) {
throw proofGate.rejection(action, formatId, objectId, "ISSUER_CREDENTIAL_INVALID"); 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, CaProofGate.ManagedKeyProof proof = proofGate.proveManagedKey(issuer.issuerKeyRef(), formatId, action,
objectId); objectId);
if (!MessageDigest.isEqual(proof.exactPublicKey().bytes(), holder.getSubjectPublicKeyInfo().getEncoded())) { if (!MessageDigest.isEqual(proof.exactPublicKey().bytes(), holder.getSubjectPublicKeyInfo().getEncoded())) {
@@ -855,8 +871,13 @@ public final class DefaultCaService implements CaService {
if (!matchesCaCredentialEnvelope(credential, request, subjectCaId)) { if (!matchesCaCredentialEnvelope(credential, request, subjectCaId)) {
throw proofGate.rejection(action, framework.formatId(), Optional.of(subjectCaId), mismatchCode); throw proofGate.rejection(action, framework.formatId(), Optional.of(subjectCaId), mismatchCode);
} }
X509CertificateHolder holder = new X509CertificateHolder(credential.encoded().bytes()); CredentialContent.validateCertificate(store, credential.content(), authority,
X509CertificateHolder issuerHolder = new X509CertificateHolder(issuerCredential.encoded().bytes()); 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(); byte[] actualSpki = holder.getSubjectPublicKeyInfo().getEncoded();
Extension constraintsExtension = holder.getExtension(Extension.basicConstraints); Extension constraintsExtension = holder.getExtension(Extension.basicConstraints);
BasicConstraints constraints = constraintsExtension == null ? null BasicConstraints constraints = constraintsExtension == null ? null
@@ -872,7 +893,7 @@ public final class DefaultCaService implements CaService {
actualSpki) actualSpki)
|| !matchesCaCertificatePolicy(holder, request, constraintsExtension, constraints, || !matchesCaCertificatePolicy(holder, request, constraintsExtension, constraints,
keyUsageExtension, keyUsage) keyUsageExtension, keyUsage)
|| !matchesCaCredentialMetadata(credential, holder, request, actualSpki)) { || !matchesCaCredentialMetadata(credential, holder, request, actualSpki, credentialDer)) {
throw proofGate.rejection(action, framework.formatId(), Optional.of(subjectCaId), mismatchCode); throw proofGate.rejection(action, framework.formatId(), Optional.of(subjectCaId), mismatchCode);
} }
CredentialProfileBindings.requireCaBinding(credential.profileBinding(), request.profileReference()); CredentialProfileBindings.requireCaBinding(credential.profileBinding(), request.profileReference());
@@ -885,7 +906,7 @@ public final class DefaultCaService implements CaService {
private boolean matchesCaCredentialEnvelope(Credential credential, ValidatedCaCertificateRequest request, private boolean matchesCaCredentialEnvelope(Credential credential, ValidatedCaCertificateRequest request,
PkiId subjectCaId) { 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.status() == CredentialStatus.ISSUED
&& credential.subjectRef().equals(request.subjectRef()) && credential.subjectRef().equals(request.subjectRef())
&& credential.issuerRef() && credential.issuerRef()
@@ -915,9 +936,9 @@ public final class DefaultCaService implements CaService {
} }
private static boolean matchesCaCredentialMetadata(Credential credential, X509CertificateHolder holder, 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))) 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.serialOrUniqueId().equals(holder.getSerialNumber().toString())
&& credential.validity().notBefore().getEpochSecond() == holder.getNotBefore().toInstant() && credential.validity().notBefore().getEpochSecond() == holder.getNotBefore().toInstant()
.getEpochSecond() .getEpochSecond()

View File

@@ -78,6 +78,9 @@ import zeroecho.pki.api.request.ParsedCertificationRequest;
import zeroecho.pki.api.request.ProofOfPossessionResult; import zeroecho.pki.api.request.ProofOfPossessionResult;
import zeroecho.pki.api.request.ProofOfPossessionStatus; import zeroecho.pki.api.request.ProofOfPossessionStatus;
import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes; 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.impl.framework.x509.bc.BcX509ProfileSupport;
import zeroecho.pki.spi.audit.AuditSink; import zeroecho.pki.spi.audit.AuditSink;
import zeroecho.pki.spi.framework.CredentialFramework; import zeroecho.pki.spi.framework.CredentialFramework;
@@ -157,6 +160,8 @@ public final class DefaultIssuanceService implements IssuanceService {
private final EffectiveCredentialStatusResolver statusResolver; private final EffectiveCredentialStatusResolver statusResolver;
private final ProfileService profileService; private final ProfileService profileService;
private final Clock clock; 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 * 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.statusResolver = Objects.requireNonNull(statusResolver, "statusResolver");
this.profileService = Objects.requireNonNull(profileService, "profileService"); this.profileService = Objects.requireNonNull(profileService, "profileService");
this.clock = Objects.requireNonNull(clock, "clock"); 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; ValidatedCertificateRequest validated;
try { try {
validated = CertificateProfileValidator.validate(candidate, profile, active.reference(), issuerCred, validated = CertificateProfileValidator.validate(candidate, profile, active.reference(), issuerCred,
evaluationTime); evaluationTime, authority);
} catch (PkiException exception) { } catch (PkiException exception) {
throw rejection(candidate.request(), statusCode(exception)); throw rejection(candidate.request(), statusCode(exception));
} }
@@ -259,7 +269,7 @@ public final class DefaultIssuanceService implements IssuanceService {
CredentialBundle bundle; CredentialBundle bundle;
try { try {
bundle = CredentialSnapshots 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 } catch (RuntimeException ex) { // NOPMD - framework output must cross the snapshot boundary
throw rejection(candidate.request(), "BACKEND_CREDENTIAL_MISMATCH"); throw rejection(candidate.request(), "BACKEND_CREDENTIAL_MISMATCH");
} }
@@ -441,14 +451,19 @@ public final class DefaultIssuanceService implements IssuanceService {
try { try {
CredentialProfileBindings.requireEndEntityBinding(credential.profileBinding(), CredentialProfileBindings.requireEndEntityBinding(credential.profileBinding(),
validated.profileReference()); 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.subjectRef().equals(validated.subjectRef())
|| !credential.issuerRef().equals(new zeroecho.pki.api.IssuerRef(validated.issuerCaId())) || !credential.issuerRef().equals(new zeroecho.pki.api.IssuerRef(validated.issuerCaId()))
|| credential.status() != CredentialStatus.ISSUED) { || credential.status() != CredentialStatus.ISSUED) {
throw rejection(auditRequest, "BACKEND_CREDENTIAL_MISMATCH"); throw rejection(auditRequest, "BACKEND_CREDENTIAL_MISMATCH");
} }
X509CertificateHolder holder = new X509CertificateHolder(credential.encoded().bytes()); CredentialContent.validateCertificate(store, credential.content(), authority,
X509CertificateHolder issuerHolder = new X509CertificateHolder(issuerCredential.encoded().bytes()); 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(); byte[] actualSpki = holder.getSubjectPublicKeyInfo().getEncoded();
if (!MessageDigest.isEqual(validated.exactPublicKey().bytes(), actualSpki) if (!MessageDigest.isEqual(validated.exactPublicKey().bytes(), actualSpki)
|| !holder.getSubject().equals(BcX509ProfileSupport.subject(validated.subjectRdns())) || !holder.getSubject().equals(BcX509ProfileSupport.subject(validated.subjectRdns()))
@@ -457,7 +472,7 @@ public final class DefaultIssuanceService implements IssuanceService {
new JcaContentVerifierProviderBuilder().build(issuerHolder.getSubjectPublicKeyInfo())) new JcaContentVerifierProviderBuilder().build(issuerHolder.getSubjectPublicKeyInfo()))
|| !holder.getSerialNumber().equals(allocatedSerial) || !holder.getSerialNumber().equals(allocatedSerial)
|| !credential.publicKeyId().equals(new PkiId("spki:" + sha256Hex(actualSpki))) || !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.serialOrUniqueId().equals(holder.getSerialNumber().toString())
|| !credential.validity().equals(validated.validity()) || !credential.validity().equals(validated.validity())
|| validated.validity().notBefore().getEpochSecond() != holder.getNotBefore().toInstant() || validated.validity().notBefore().getEpochSecond() != holder.getNotBefore().toInstant()

View File

@@ -112,8 +112,18 @@ public final class DefaultRevocationService implements RevocationService {
@SuppressWarnings("PMD.AvoidCatchingGenericException") @SuppressWarnings("PMD.AvoidCatchingGenericException")
public List<RevocationJournal> search(RevocationQuery query) { public List<RevocationJournal> search(RevocationQuery query) {
Objects.requireNonNull(query, "query"); Objects.requireNonNull(query, "query");
try { try (zeroecho.pki.spi.store.RevocationSnapshot snapshot = store.openRevocationSnapshot();
return store.listRevocationJournals().stream().filter(journal -> matches(journal, query)).toList(); 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) { } catch (RuntimeException failure) {
throw sanitized(failure); throw sanitized(failure);
} }

View File

@@ -33,17 +33,19 @@
******************************************************************************/ ******************************************************************************/
package zeroecho.pki.impl.core; package zeroecho.pki.impl.core;
import java.io.IOException;
import java.math.BigInteger; import java.math.BigInteger;
import java.time.Instant; import java.time.Instant;
import java.util.Arrays; import java.util.Arrays;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.OptionalLong;
import org.bouncycastle.cert.X509CertificateHolder; import org.bouncycastle.cert.X509CertificateHolder;
import zeroecho.core.io.CancellationSignal;
import zeroecho.core.io.RepeatableContent;
import zeroecho.pki.api.Encoding; import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.PkiException; import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.PkiId; import zeroecho.pki.api.PkiId;
@@ -51,6 +53,7 @@ import zeroecho.pki.api.StatusObjectService;
import zeroecho.pki.api.attr.AttributeValue; import zeroecho.pki.api.attr.AttributeValue;
import zeroecho.pki.api.ca.CaRecord; import zeroecho.pki.api.ca.CaRecord;
import zeroecho.pki.api.ca.CaState; import zeroecho.pki.api.ca.CaState;
import zeroecho.pki.api.content.DurableContentReference;
import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.credential.CredentialUse; import zeroecho.pki.api.credential.CredentialUse;
import zeroecho.pki.api.credential.EffectiveCredentialStatus; 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.api.status.StatusObjectType;
import zeroecho.pki.impl.core.attr.SimpleAttributeSet; import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes; 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.impl.framework.x509.bc.BcX509CredentialFramework;
import zeroecho.pki.spi.audit.AuditSink; import zeroecho.pki.spi.audit.AuditSink;
import zeroecho.pki.spi.framework.CredentialFramework; import zeroecho.pki.spi.framework.CredentialFramework;
import zeroecho.pki.spi.framework.CrlEntry; import zeroecho.pki.spi.framework.CrlEntry;
import zeroecho.pki.spi.framework.CrlEntrySource;
import zeroecho.pki.spi.store.PkiStore; import zeroecho.pki.spi.store.PkiStore;
import zeroecho.pki.spi.store.RevocationSnapshot;
/** /**
* Default implementation of {@link StatusObjectService}. * Default implementation of {@link StatusObjectService}.
@@ -126,6 +136,7 @@ public final class DefaultStatusObjectService implements StatusObjectService {
private final CredentialFramework framework; private final CredentialFramework framework;
private final AuditSink auditSink; private final AuditSink auditSink;
private final EffectiveCredentialStatusResolver statusResolver; private final EffectiveCredentialStatusResolver statusResolver;
private final X509AuthoritySnapshot authority;
/** /**
* Creates a status object service bound to the supplied persistence and * 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} * @throws NullPointerException if an argument is {@code null}
*/ */
public DefaultStatusObjectService(PkiStore store, CredentialFramework framework, AuditSink auditSink, public DefaultStatusObjectService(PkiStore store, CredentialFramework framework, AuditSink auditSink,
EffectiveCredentialStatusResolver statusResolver) { EffectiveCredentialStatusResolver statusResolver, X509AuthoritySnapshot authority) {
this.store = Objects.requireNonNull(store, "store"); this.store = Objects.requireNonNull(store, "store");
this.framework = Objects.requireNonNull(framework, "framework"); this.framework = Objects.requireNonNull(framework, "framework");
this.auditSink = Objects.requireNonNull(auditSink, "auditSink"); this.auditSink = Objects.requireNonNull(auditSink, "auditSink");
this.statusResolver = Objects.requireNonNull(statusResolver, "statusResolver"); 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 * generated status object fails
*/ */
@Override @Override
@SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" })
public StatusObject generate(StatusObjectGenerateCommand command) { public StatusObject generate(StatusObjectGenerateCommand command) {
if (command == null) { if (command == null) {
throw new IllegalArgumentException("command must not be null"); throw new IllegalArgumentException("command must not be null");
@@ -206,59 +219,79 @@ public final class DefaultStatusObjectService implements StatusObjectService {
} }
EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation(); EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation();
Credential issuerCred = selectIssuerCredential(ca, command, statusEvaluation); 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(); SimpleAttributeSet.Builder b = SimpleAttributeSet.builder();
b.putAll(command.attributes()); 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())); b.put(BcX509Attributes.ISSUER_KEYREF, new AttributeValue.StringValue(ca.issuerKeyRef().value()));
StatusObjectGenerateCommand wired = new StatusObjectGenerateCommand(command.issuerCaId(), command.type(), StatusObjectGenerateCommand wired = new StatusObjectGenerateCommand(command.issuerCaId(), command.type(),
command.formatId(), b.build()); command.formatId(), b.build());
if (command.type() == StatusObjectType.CRL) { try (CrlEntrySource entries = command.type() == StatusObjectType.CRL
return generateAndPersistCrl(wired, crlEntries); ? openCrlEntries(command.issuerCaId(), statusEvaluation.evaluationTime())
} : new EmptyCrlEntrySource()) {
StatusObject obj = framework.statusObjectGenerator().generate(wired, crlEntries); if (command.type() == StatusObjectType.CRL) {
store.putStatusObject(obj); return generateAndPersistCrl(wired, entries, issuerCred);
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);
} }
return List.copyOf(entries); return generateAndPersistOther();
} catch (RuntimeException exception) { } catch (IOException | RuntimeException exception) {
throw crlGenerationFailure(); throw crlGenerationFailure();
} }
} }
private Optional<CrlEntry> collectCrlEntry(PkiId issuerCaId, Instant evaluationTime, RevocationJournal journal, private StatusObject generateAndPersistCrl(StatusObjectGenerateCommand command, CrlEntrySource entries,
Set<BigInteger> serials) { 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"); Objects.requireNonNull(journal, "journal");
RevocationTransition latest = Objects.requireNonNull(journal.latest(), "latest transition"); RevocationTransition latest = Objects.requireNonNull(journal.latest(), "latest transition");
if (latest.time().isAfter(evaluationTime)) { if (latest.time().isAfter(evaluationTime)) {
@@ -273,13 +306,10 @@ public final class DefaultStatusObjectService implements StatusObjectService {
return Optional.empty(); return Optional.empty();
} }
if (!BcX509CredentialFramework.FORMAT_ID.equals(credential.formatId()) if (!BcX509CredentialFramework.FORMAT_ID.equals(credential.formatId())
|| credential.encoded().encoding() != Encoding.DER) { || credential.content().encoding() != Encoding.DER) {
throw crlGenerationFailure(); throw crlGenerationFailure();
} }
BigInteger serial = certificateSerial(credential); BigInteger serial = certificateSerial(credential);
if (!serials.add(serial)) {
throw crlGenerationFailure();
}
RevocationReason reason = switch (latest.state()) { RevocationReason reason = switch (latest.state()) {
case HELD -> RevocationReason.CERTIFICATE_HOLD; case HELD -> RevocationReason.CERTIFICATE_HOLD;
case PERMANENTLY_REVOKED -> case PERMANENTLY_REVOKED ->
@@ -289,11 +319,131 @@ public final class DefaultStatusObjectService implements StatusObjectService {
return Optional.of(new CrlEntry(serial, latest.time(), reason)); 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 // Parser failures can contain persisted certificate details; the original
// cause is intentionally removed at this public service boundary. // cause is intentionally removed at this public service boundary.
@SuppressWarnings("PMD.PreserveStackTrace") @SuppressWarnings("PMD.PreserveStackTrace")
private static BigInteger certificateSerial(Credential credential) { private BigInteger certificateSerial(Credential credential) {
byte[] der = credential.encoded().bytes(); byte[] der = CredentialContent.materializeForBc(store, credential.content());
BigInteger serial; BigInteger serial;
try { try {
serial = new X509CertificateHolder(der).getSerialNumber(); serial = new X509CertificateHolder(der).getSerialNumber();

View File

@@ -40,6 +40,7 @@ import java.time.Instant;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicBoolean; 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.Level;
import java.util.logging.Logger; 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.EncodedObject;
import zeroecho.pki.api.Encoding; import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.KeyRef; import zeroecho.pki.api.KeyRef;
import zeroecho.pki.api.PkiException; import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.PkiId; 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.audit.Principal;
import zeroecho.pki.api.orch.OrchestrationDurabilityPolicy; import zeroecho.pki.api.orch.OrchestrationDurabilityPolicy;
import zeroecho.pki.api.orch.SigningSubmissionId; import zeroecho.pki.api.orch.SigningSubmissionId;
import zeroecho.pki.api.orch.WorkflowStateRecord; 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.crypto.SignatureWorkflow;
import zeroecho.pki.spi.store.PkiStore; import zeroecho.pki.spi.store.PkiStore;
import zeroecho.pki.spi.store.SignWorkflowStore; 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.AsyncEndpoint;
import zeroecho.pki.util.async.AsyncState; import zeroecho.pki.util.async.AsyncState;
import zeroecho.pki.util.async.AsyncStatus; import zeroecho.pki.util.async.AsyncStatus;
@@ -112,6 +122,7 @@ public final class PkiSigningBus implements AutoCloseable {
private final PkiStore store; private final PkiStore store;
private final DurableAsyncBus<PkiId, Principal, String, EncodedObject> bus; private final DurableAsyncBus<PkiId, Principal, String, EncodedObject> bus;
private final SignatureWorkflow signer; private final SignatureWorkflow signer;
private final X509AuthoritySnapshot authority;
private final SecureRandom random; private final SecureRandom random;
private final String namespace; private final String namespace;
private final OperationCoordinator coordinator; private final OperationCoordinator coordinator;
@@ -122,58 +133,70 @@ public final class PkiSigningBus implements AutoCloseable {
private final OrchestrationDurabilityPolicy durabilityPolicy; private final OrchestrationDurabilityPolicy durabilityPolicy;
/** /**
* Creates a signing bus. * Creates a signing bus in an explicitly composed runtime authority graph.
*
* @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.
* *
* <p> * <p>
* The {@code displaySuffixMaxLen} parameter controls the maximum number of * The authority must already bind every signing identity declared by
* characters appended after {@code '#'} in operation identifiers returned by * {@code signer} to that exact workflow instance for the {@code SIGN}
* {@link #canonicalizeOperationId(PkiId, Principal)}. If * direction. This constructor validates ownership before registering the
* {@code displaySuffixMaxLen} is not positive, the constructor fails. * workflow or activating durable state. It never constructs an internal
* authority, accepts an authority from another runtime, or handles private
* key material.
* </p> * </p>
* *
* @param store persistent store (source of truth for * @param store persistent store
* continuation state) * @param signer exact signing workflow owned by
* @param signer signature workflow * {@code authority}
* @param durableLineStorePath path to append-only line store file * @param durableLineStorePath durable bus path
* @param displaySuffixMaxLen maximum number of characters after {@code '#'} * @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) { public PkiSigningBus(PkiStore store, SignatureWorkflow signer, Path durableLineStorePath,
this(store, signer, durableLineStorePath, displaySuffixMaxLen, OrchestrationDurabilityPolicy.DURABLE_MIN_STATE); 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 * <p>
* continuation state) * The authority must already bind every signing identity declared by
* @param signer signature workflow * {@code signer} to that exact workflow instance for the {@code SIGN}
* @param durableLineStorePath path to append-only line store file * direction. Validation occurs before registration or durable-state
* @param displaySuffixMaxLen maximum number of characters after {@code '#'} * activation. The constructor does not derive an authority from provider
* @param durabilityPolicy durability policy used when persisting workflow * strings, accept cross-runtime workflow ownership, or access private key
* state * 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, public PkiSigningBus(PkiStore store, SignatureWorkflow signer, Path durableLineStorePath, int displaySuffixMaxLen,
OrchestrationDurabilityPolicy durabilityPolicy) { OrchestrationDurabilityPolicy durabilityPolicy, X509AuthoritySnapshot authority) {
Objects.requireNonNull(store, "store"); Objects.requireNonNull(store, "store");
Objects.requireNonNull(signer, "signer"); Objects.requireNonNull(signer, "signer");
Objects.requireNonNull(durableLineStorePath, "durableLineStorePath"); Objects.requireNonNull(durableLineStorePath, "durableLineStorePath");
Objects.requireNonNull(durabilityPolicy, "durabilityPolicy"); Objects.requireNonNull(durabilityPolicy, "durabilityPolicy");
X509AuthoritySnapshot exactAuthority = Objects.requireNonNull(authority, "authority");
validateSigningAuthority(signer, exactAuthority);
this.store = store; this.store = store;
this.signer = signer; this.signer = signer;
this.authority = exactAuthority;
this.random = new SecureRandom(); this.random = new SecureRandom();
this.namespace = store.signingNamespace() + "." + signer.id(); this.namespace = store.signingNamespace() + "." + signer.id();
signer.validateSigningDomain(this.namespace, store.signingHorizon(), store.signingPermittedSkew()); signer.validateSigningDomain(this.namespace, store.signingHorizon(), store.signingPermittedSkew());
@@ -189,13 +212,21 @@ public final class PkiSigningBus implements AutoCloseable {
for (WorkflowStateRecord state : store.listWorkflowStates()) { for (WorkflowStateRecord state : store.listWorkflowStates()) {
if (TYPE_SIGN.equals(state.type()) && state.payload().isPresent()) { if (TYPE_SIGN.equals(state.type()) && state.payload().isPresent()) {
try { 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 } catch (RuntimeException ex) { // NOPMD - malformed persisted state must fail closed
throw new PkiException("Invalid persisted sign continuation: code=CONTINUATION_INVALID"); 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.bus.registerEndpoint(ENDPOINT_SIGNER, endpoint);
this.signerRegistration = signer.register(endpoint::onProviderStatusChanged); this.signerRegistration = signer.register(endpoint::onProviderStatusChanged);
for (SignWorkflowStore.Record record : store.listSignRecords()) { 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 * Builds a canonical, globally unique operation identifier derived from tuple
* (owner, clientOpId). * (owner, clientOpId).
@@ -242,24 +376,29 @@ public final class PkiSigningBus implements AutoCloseable {
* @param owner owner principal * @param owner owner principal
* @param keyRef signing key reference * @param keyRef signing key reference
* @param algorithmId signature algorithm id * @param algorithmId signature algorithm id
* @param payload bytes to sign * @param content durable repeatable content to sign
* @param ttl time-to-live * @param ttl time-to-live
* @param workflowPayload minimal continuation payload * @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) { Duration ttl, Optional<EncodedObject> workflowPayload) {
Objects.requireNonNull(opId, "opId"); Objects.requireNonNull(opId, "opId");
Objects.requireNonNull(owner, "owner"); Objects.requireNonNull(owner, "owner");
Objects.requireNonNull(keyRef, "keyRef"); Objects.requireNonNull(keyRef, "keyRef");
Objects.requireNonNull(algorithmId, "algorithmId"); Objects.requireNonNull(algorithmId, "algorithmId");
Objects.requireNonNull(payload, "payload"); Objects.requireNonNull(content, "content");
Objects.requireNonNull(ttl, "ttl"); Objects.requireNonNull(ttl, "ttl");
Objects.requireNonNull(workflowPayload, "workflowPayload"); Objects.requireNonNull(workflowPayload, "workflowPayload");
if (algorithmId.isBlank()) { if (algorithmId.isBlank()) {
throw new IllegalArgumentException("algorithmId must not be blank"); 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()) { if (ttl.isZero() || ttl.isNegative()) {
throw new IllegalArgumentException("ttl must be positive"); throw new IllegalArgumentException("ttl must be positive");
} }
@@ -272,27 +411,52 @@ public final class PkiSigningBus implements AutoCloseable {
try (OperationCoordinator.Lease ignored = coordinator.acquire(baseOpId)) { try (OperationCoordinator.Lease ignored = coordinator.acquire(baseOpId)) {
SigningSubmissionId parsed = SigningSubmissionId.parse(baseOpId); SigningSubmissionId parsed = SigningSubmissionId.parse(baseOpId);
Instant deadline = parsed.createdAt().plus(ttl); 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) if (!owner.equals(continuation.accessContext.principal()) || !keyRef.equals(continuation.keyRef)
|| !algorithmId.equals(continuation.algorithmId) || !submittedIdentity.equals(continuationIdentity)
|| payload.encoding() != continuation.payload.encoding() || !content.equals(continuation.content())) {
|| !java.util.Arrays.equals(payload.bytes(), continuation.payload.bytes())) {
throw new IllegalArgumentException("Sign continuation does not match the submitted request"); throw new IllegalArgumentException("Sign continuation does not match the submitted request");
} }
continuation = continuation.withAlgorithmId(submittedIdentity.canonicalForm());
String fingerprint = continuation.semanticFingerprint(namespace, deadline); String fingerprint = continuation.semanticFingerprint(namespace, deadline);
EncodedObject persistedRequest = continuation.withSignerOpId(baseOpId).encode(); EncodedObject persistedRequest = continuation.withSignerOpId(baseOpId).encode();
SignWorkflowStore.Record intent = new SignWorkflowStore.Record(baseOpId, namespace, fingerprint, owner, SignWorkflowStore.Record intent = new SignWorkflowStore.Record(baseOpId, namespace, fingerprint, owner,
parsed.createdAt(), deadline, persistedRequest, SignWorkflowStore.State.INTENT, 0L, 0L, parsed.createdAt(), deadline, persistedRequest, SignWorkflowStore.State.INTENT, 0L, 0L,
Optional.empty(), Optional.of("INTENT"), Optional.empty(), Optional.empty()); Optional.empty(), Optional.of("INTENT"), Optional.empty(), Optional.empty());
SignWorkflowStore.CreateResult created = store.createSignIntent(intent); DurableContentOwner contentOwner = DurableContentOwner.signingOperation(baseOpId);
if (created == SignWorkflowStore.CreateResult.CONFLICT) { boolean retained = false;
throw new PkiException("Signing submission identifier conflicts with a different request"); 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); 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. * Returns current status if known.
*/ */
@@ -400,8 +564,15 @@ public final class PkiSigningBus implements AutoCloseable {
if (!isTerminalSignState(state.state())) { if (!isTerminalSignState(state.state())) {
return; 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); state = confirmRetirement(baseOpId, state);
store.deleteWorkflowState(baseOpId); store.deleteWorkflowState(baseOpId);
if (releaseReference.isPresent()) {
releaseRetiredOperationContent(baseOpId, releaseReference.get());
}
} }
AsyncState advisoryState = state.result().isPresent() ? AsyncState.SUCCEEDED : AsyncState.CANCELLED; AsyncState advisoryState = state.result().isPresent() ? AsyncState.SUCCEEDED : AsyncState.CANCELLED;
bus.update(baseOpId, new AsyncStatus(advisoryState, store.signingNow(), Optional.of("RETIRED"), bus.update(baseOpId, new AsyncStatus(advisoryState, store.signingNow(), Optional.of("RETIRED"),
@@ -409,6 +580,17 @@ public final class PkiSigningBus implements AutoCloseable {
bus.retire(baseOpId); 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() { private void reconcileExpiredOperations() {
Instant current = store.signingNow(); Instant current = store.signingNow();
for (SignWorkflowStore.Record candidate : store.listSignRecords()) { for (SignWorkflowStore.Record candidate : store.listSignRecords()) {
@@ -813,6 +995,7 @@ public final class PkiSigningBus implements AutoCloseable {
private final SignatureWorkflow signer; private final SignatureWorkflow signer;
private final OperationCoordinator coordinator; private final OperationCoordinator coordinator;
private final ExternalActionCoordinator externalActions; private final ExternalActionCoordinator externalActions;
private final X509AuthoritySnapshot authority;
private final ConcurrentMap<PkiId, Boolean> pendingAdvisories; private final ConcurrentMap<PkiId, Boolean> pendingAdvisories;
private final AtomicInteger pendingAdvisoryCount; private final AtomicInteger pendingAdvisoryCount;
private final AtomicBoolean closed; 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} * operation and to query its status; must not be {@code null}
*/ */
private SignatureWorkflowEndpoint(PkiStore store, SignatureWorkflow signer, OperationCoordinator coordinator, private SignatureWorkflowEndpoint(PkiStore store, SignatureWorkflow signer, OperationCoordinator coordinator,
ExternalActionCoordinator externalActions) { ExternalActionCoordinator externalActions, X509AuthoritySnapshot authority) {
this.store = store; this.store = store;
this.signer = signer; this.signer = signer;
this.coordinator = coordinator; this.coordinator = coordinator;
this.externalActions = externalActions; this.externalActions = externalActions;
this.authority = authority;
this.pendingAdvisories = new ConcurrentHashMap<>(); this.pendingAdvisories = new ConcurrentHashMap<>();
this.pendingAdvisoryCount = new AtomicInteger(); this.pendingAdvisoryCount = new AtomicInteger();
this.closed = new AtomicBoolean(); this.closed = new AtomicBoolean();
@@ -877,7 +1061,8 @@ public final class PkiSigningBus implements AutoCloseable {
SubmissionCall call = prepared.get(); SubmissionCall call = prepared.get();
PkiId returned; PkiId returned;
try (ExternalActionCoordinator.Reservation ignored = call.reservation()) { 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 } catch (RuntimeException ambiguousFailure) { // NOPMD - provider acceptance is unknown
return; return;
} }
@@ -917,17 +1102,27 @@ public final class PkiSigningBus implements AutoCloseable {
ExternalActionCoordinator.Reservation reservation = reserved.get(); ExternalActionCoordinator.Reservation reservation = reserved.get();
boolean reservationTransferred = false; boolean reservationTransferred = false;
try { 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, SignatureWorkflow.SignRequest request = SignatureWorkflow.SignRequest.create(opId,
claimed.namespace(), claimed.fence(), continuation.accessContext, continuation.keyRef, claimed.namespace(), claimed.fence(), continuation.accessContext, continuation.keyRef,
continuation.algorithmId, continuation.payload, continuation.algorithmId, content, Optional.of(continuation.preferredSignatureEncoding),
Optional.of(continuation.preferredSignatureEncoding), Optional.of(claimed.deadline())); Optional.of(claimed.deadline()));
if (!constantTimeAsciiEquals(claimed.fingerprint(), request.semanticFingerprint())) { if (!constantTimeAsciiEquals(claimed.fingerprint(), request.semanticFingerprint())) {
store.transitionSign(opId, claimed.revision(), claimed.fence(), SignWorkflowStore.State.FAILED, store.transitionSign(opId, claimed.revision(), claimed.fence(), SignWorkflowStore.State.FAILED,
Optional.of("REQUEST_INTEGRITY_FAILURE"), Optional.empty(), Optional.empty()); Optional.of("REQUEST_INTEGRITY_FAILURE"), Optional.empty(), Optional.empty());
return Optional.empty(); return Optional.empty();
} }
SubmissionCall call = new SubmissionCall(claimed, request, reservation); SubmissionCall call = new SubmissionCall(claimed, request, reservation, plan);
reservationTransferred = true; reservationTransferred = true;
return Optional.of(call); return Optional.of(call);
} finally { } 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) { private static boolean constantTimeAsciiEquals(String left, String right) {
byte[] leftBytes = left.getBytes(java.nio.charset.StandardCharsets.US_ASCII); byte[] leftBytes = left.getBytes(java.nio.charset.StandardCharsets.US_ASCII);
byte[] rightBytes = right.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, 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> * <h2>Encoding model</h2>
* <p> * <p>
* Instances are encoded into a compact binary representation through * 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 * The binary format is versioned by {@link #VERSION}. The current version
* persists the algorithm identifier, payload encoding and bytes, key reference, * persists the algorithm identifier, payload encoding and bytes, key reference,
* preferred signature encoding, and the optional downstream signer operation * preferred signature encoding, and the optional downstream signer operation
@@ -1271,7 +1485,7 @@ public final class PkiSigningBus implements AutoCloseable {
* <p> * <p>
* The current binary encoding does not persist the original * The current binary encoding does not persist the original
* {@link zeroecho.pki.api.audit.AccessContext} losslessly. During * {@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 * instead. This is sufficient for the current continuation flow, but callers
* must not assume that {@code decode(encode(x))} preserves the original access * must not assume that {@code decode(encode(x))} preserves the original access
* context exactly. * context exactly.
@@ -1284,11 +1498,13 @@ public final class PkiSigningBus implements AutoCloseable {
*/ */
public static final class SignContinuation { 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 zeroecho.pki.api.audit.AccessContext accessContext;
private final String algorithmId; private final String algorithmId;
private final EncodedObject payload; private final Optional<DurableContentReference> content;
private final ContentCommitment commitment;
private final KeyRef keyRef; private final KeyRef keyRef;
private final Encoding preferredSignatureEncoding; private final Encoding preferredSignatureEncoding;
private final Optional<PkiId> signerOpId; private final Optional<PkiId> signerOpId;
@@ -1300,7 +1516,7 @@ public final class PkiSigningBus implements AutoCloseable {
* sign request; must not be {@code null} * sign request; must not be {@code null}
* @param algorithmId non-blank signature algorithm identifier; * @param algorithmId non-blank signature algorithm identifier;
* must not be {@code null} or blank * 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} * {@code null}
* @param keyRef signing key reference; must not be * @param keyRef signing key reference; must not be
* {@code null} * {@code null}
@@ -1313,10 +1529,13 @@ public final class PkiSigningBus implements AutoCloseable {
* @throws IllegalArgumentException if {@code algorithmId} is blank * @throws IllegalArgumentException if {@code algorithmId} is blank
*/ */
public SignContinuation(zeroecho.pki.api.audit.AccessContext accessContext, String algorithmId, 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.accessContext = Objects.requireNonNull(accessContext, "accessContext");
this.algorithmId = Objects.requireNonNull(algorithmId, "algorithmId"); 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.keyRef = Objects.requireNonNull(keyRef, "keyRef");
this.preferredSignatureEncoding = Objects.requireNonNull(preferredSignatureEncoding, this.preferredSignatureEncoding = Objects.requireNonNull(preferredSignatureEncoding,
"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 * Returns a new continuation with the downstream signer workflow operation
* identifier assigned. * identifier assigned.
@@ -1343,8 +1578,19 @@ public final class PkiSigningBus implements AutoCloseable {
* @throws NullPointerException if {@code opId} is {@code null} * @throws NullPointerException if {@code opId} is {@code null}
*/ */
public SignContinuation withSignerOpId(PkiId opId) { public SignContinuation withSignerOpId(PkiId opId) {
return new SignContinuation(accessContext, algorithmId, payload, keyRef, preferredSignatureEncoding, return new SignContinuation(accessContext, algorithmId, content, commitment, keyRef,
Optional.of(opId)); 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; 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. * 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) { public String semanticFingerprint(String namespace, Instant deadline) {
Objects.requireNonNull(deadline, "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)); Optional.of(preferredSignatureEncoding), Optional.of(deadline));
} }
@@ -1411,7 +1683,7 @@ public final class PkiSigningBus implements AutoCloseable {
* <ul> * <ul>
* <li>format version,</li> * <li>format version,</li>
* <li>algorithm identifier,</li> * <li>algorithm identifier,</li>
* <li>payload encoding and bytes,</li> * <li>store-issued staged-content reference metadata,</li>
* <li>key reference,</li> * <li>key reference,</li>
* <li>preferred signature encoding,</li> * <li>preferred signature encoding,</li>
* <li>presence marker and optional downstream signer workflow operation * <li>presence marker and optional downstream signer workflow operation
@@ -1427,7 +1699,6 @@ public final class PkiSigningBus implements AutoCloseable {
*/ */
public EncodedObject encode() { public EncodedObject encode() {
WipeableByteArrayOutputStream bytes = new WipeableByteArrayOutputStream(); WipeableByteArrayOutputStream bytes = new WipeableByteArrayOutputStream();
byte[] payloadBytes = payload.bytes();
byte[] encoded = null; byte[] encoded = null;
try { try {
try (java.io.DataOutputStream output = new java.io.DataOutputStream(bytes)) { 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(algorithmId);
output.writeUTF(keyRef.value()); output.writeUTF(keyRef.value());
output.writeByte(payload.encoding().ordinal()); output.writeBoolean(content.isPresent());
output.writeInt(payloadBytes.length); output.writeUTF(commitment.storeId());
output.write(payloadBytes); 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.writeByte(preferredSignatureEncoding.ordinal());
output.writeBoolean(signerOpId.isPresent()); output.writeBoolean(signerOpId.isPresent());
if (signerOpId.isPresent()) { if (signerOpId.isPresent()) {
@@ -1459,7 +1734,6 @@ public final class PkiSigningBus implements AutoCloseable {
} catch (java.io.IOException ex) { } catch (java.io.IOException ex) {
throw new PkiException("Failed to encode sign continuation: code=CONTINUATION_ENCODE_FAILED"); throw new PkiException("Failed to encode sign continuation: code=CONTINUATION_ENCODE_FAILED");
} finally { } finally {
java.util.Arrays.fill(payloadBytes, (byte) 0);
if (encoded != null) { if (encoded != null) {
java.util.Arrays.fill(encoded, (byte) 0); java.util.Arrays.fill(encoded, (byte) 0);
} }
@@ -1489,6 +1763,8 @@ public final class PkiSigningBus implements AutoCloseable {
* </p> * </p>
* *
* @param obj binary encoded continuation payload; must not be {@code null} * @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 * @return decoded continuation instance
* @throws NullPointerException if {@code obj} is {@code null} * @throws NullPointerException if {@code obj} is {@code null}
* @throws IllegalArgumentException if {@code obj} does not use * @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 * format version is not supported, or if the
* binary payload is malformed * 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(obj, "obj");
Objects.requireNonNull(stagedContent, "stagedContent");
if (obj.encoding() != Encoding.BINARY) { if (obj.encoding() != Encoding.BINARY) {
throw new IllegalArgumentException("Expected BINARY continuation payload"); throw new IllegalArgumentException("Expected BINARY continuation payload");
} }
byte[] encoded = obj.bytes(); byte[] encoded = obj.bytes();
byte[] payloadBytes = null;
try (java.io.DataInputStream input = new java.io.DataInputStream( try (java.io.DataInputStream input = new java.io.DataInputStream(
new java.io.ByteArrayInputStream(encoded))) { new java.io.ByteArrayInputStream(encoded))) {
int version = input.readUnsignedByte(); int version = input.readUnsignedByte();
@@ -1518,30 +1795,85 @@ public final class PkiSigningBus implements AutoCloseable {
: Optional.empty(); : Optional.empty();
String algId = input.readUTF(); String algId = input.readUTF();
KeyRef key = new KeyRef(input.readUTF()); KeyRef key = new KeyRef(input.readUTF());
Encoding payloadEncoding = Encoding.values()[input.readUnsignedByte()]; boolean liveContent = input.readBoolean();
int payloadLength = input.readInt(); String storeId = input.readUTF();
if (payloadLength <= 0 || payloadLength > 16 * 1024 * 1024) { String contentId = input.readUTF();
throw new PkiException("Invalid sign continuation payload length"); Encoding contentEncoding = Encoding.values()[input.readUnsignedByte()];
} long contentLength = input.readLong();
payloadBytes = input.readNBytes(payloadLength); String sha256 = input.readUTF();
if (payloadBytes.length != payloadLength) { DurableContentReference.Lifecycle lifecycle = DurableContentReference.Lifecycle
throw new PkiException("Truncated sign continuation payload"); .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()]; Encoding preferred = Encoding.values()[input.readUnsignedByte()];
Optional<PkiId> signerId = input.readBoolean() ? Optional.of(new PkiId(input.readUTF())) Optional<PkiId> signerId = input.readBoolean() ? Optional.of(new PkiId(input.readUTF()))
: Optional.empty(); : Optional.empty();
requireCompleteInput(input);
zeroecho.pki.api.audit.AccessContext access = new zeroecho.pki.api.audit.AccessContext(principal, zeroecho.pki.api.audit.AccessContext access = new zeroecho.pki.api.audit.AccessContext(principal,
purpose, objectId, formatId); purpose, objectId, formatId);
return new SignContinuation(access, algId, new EncodedObject(payloadEncoding, payloadBytes), key, return new SignContinuation(access, algId, content, commitment, key, preferred, signerId);
preferred, signerId);
} catch (java.io.IOException | IndexOutOfBoundsException ex) { } catch (java.io.IOException | IndexOutOfBoundsException ex) {
throw new PkiException("Malformed sign continuation: code=CONTINUATION_MALFORMED"); throw new PkiException("Malformed sign continuation: code=CONTINUATION_MALFORMED");
} finally { } finally {
java.util.Arrays.fill(encoded, (byte) 0); 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; package zeroecho.pki.impl.crypto.zeroecholib;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.DataInputStream; import java.io.DataInputStream;
import java.io.DataOutputStream; import java.io.DataOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.OutputStream;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.nio.channels.FileChannel; import java.nio.channels.FileChannel;
import java.nio.channels.FileLock; 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.slhdsa.SlhDsaPublicKeySpec;
import zeroecho.core.alg.sphincsplus.SphincsPlusPublicKeySpec; import zeroecho.core.alg.sphincsplus.SphincsPlusPublicKeySpec;
import zeroecho.core.context.SignatureContext; import zeroecho.core.context.SignatureContext;
import zeroecho.core.io.CancellationSignal;
import zeroecho.core.io.RepeatableContent;
import zeroecho.core.io.TailStrippingInputStream; import zeroecho.core.io.TailStrippingInputStream;
import zeroecho.core.spec.AlgorithmKeySpec; import zeroecho.core.spec.AlgorithmKeySpec;
import zeroecho.core.spec.ContextSpec; 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, private static final OperationStatus UNKNOWN_OPERATION_STATUS = new OperationStatus(State.FAILED, Instant.EPOCH,
Optional.of(DC_UNKNOWN_OPERATION), Optional.empty()); 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 static final long MIN_FENCING_TOKEN = 1L;
private final String id; private final String id;
@@ -488,7 +488,6 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
return opId; return opId;
} }
byte[] payloadBytes = null;
byte[] signatureBytes = null; byte[] signatureBytes = null;
try { try {
KeyRefParts parts = parseKeyRefOrThrow(request.keyRef(), true); KeyRefParts parts = parseKeyRefOrThrow(request.keyRef(), true);
@@ -514,8 +513,9 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
completeSign(request, expiredStatus()); completeSign(request, expiredStatus());
return opId; return opId;
} }
payloadBytes = request.payload().bytes(); request.cancellation().throwIfCancelled();
signatureBytes = signStreaming(request.algorithmId(), prv.key(), pub.key(), payloadBytes); signatureBytes = signStreaming(request.algorithmId(), prv.key(), pub.key(), request.content(),
request.cancellation());
Encoding outEnc = request.preferredSignatureEncoding().orElse(Encoding.BINARY); Encoding outEnc = request.preferredSignatureEncoding().orElse(Encoding.BINARY);
EncodedObject signature = encodeSignatureOrThrow(outEnc, signatureBytes); EncodedObject signature = encodeSignatureOrThrow(outEnc, signatureBytes);
@@ -553,7 +553,6 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
logSafeFailure("SIGN", DC_CRYPTO_FAILURE, ex); logSafeFailure("SIGN", DC_CRYPTO_FAILURE, ex);
return opId; return opId;
} finally { } finally {
clearOwned("sign-payload", payloadBytes);
clearOwned("sign-result-copy", signatureBytes); 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())); putStatus(opId, new OperationStatus(State.RUNNING, now(), Optional.of(DC_SUBMITTED), Optional.empty()));
byte[] signatureBytes = null; byte[] signatureBytes = null;
byte[] payloadBytes = null;
try { try {
if (request.algorithmId() == null || request.algorithmId().isBlank()) { if (request.algorithmId() == null || request.algorithmId().isBlank()) {
throw new InvalidRequestException(DC_INVALID_ALGORITHM_ID); throw new InvalidRequestException(DC_INVALID_ALGORITHM_ID);
@@ -683,8 +681,9 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
putStatus(opId, expiredStatus()); putStatus(opId, expiredStatus());
return opId; return opId;
} }
payloadBytes = request.payload().bytes(); request.cancellation().throwIfCancelled();
boolean ok = verifyStreaming(request.algorithmId(), pub, payloadBytes, signatureBytes); boolean ok = verifyStreaming(request.algorithmId(), pub, request.content(), signatureBytes,
request.cancellation());
Instant completedAt = now(); Instant completedAt = now();
if (deadlineReached(request.deadline(), completedAt)) { if (deadlineReached(request.deadline(), completedAt)) {
@@ -716,7 +715,6 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
logSafeFailure("VERIFY", DC_CRYPTO_FAILURE, ex); logSafeFailure("VERIFY", DC_CRYPTO_FAILURE, ex);
return opId; return opId;
} finally { } finally {
clearOwned("verify-payload", payloadBytes);
clearOwned("verify-signature", signatureBytes); clearOwned("verify-signature", signatureBytes);
} }
} }
@@ -1061,7 +1059,8 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
return a; 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 { throws GeneralSecurityException, IOException {
Optional<SignatureInteropProfile> profile = SignatureInteropProfiles.resolve(algorithmId); 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)) { try (SignatureContext signer = session.createContext(contextAlgorithmId, KeyUsage.SIGN, prv, contextSpec)) {
final byte[][] sigHolder = new byte[1][]; final byte[][] sigHolder = new byte[1][];
try (InputStream in = new TailStrippingInputStream(signer.wrap(new ByteArrayInputStream(msg)), sigLen, try (InputStream source = content.openStream();
8192) { InputStream in = new TailStrippingInputStream(signer.wrap(source), sigLen, 8192) {
@Override @Override
protected void processTail(byte[] tail) throws IOException { protected void processTail(byte[] tail) throws IOException {
sigHolder[0] = (tail == null) ? null : tail.clone(); sigHolder[0] = (tail == null) ? null : tail.clone();
} }
}) { }) {
in.transferTo(OutputStream.nullOutputStream()); consume(in, cancellation);
} }
byte[] internalSignature = sigHolder[0]; 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 { throws GeneralSecurityException, IOException {
Optional<SignatureInteropProfile> profile = SignatureInteropProfiles.resolve(algorithmId); 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)) { try (SignatureContext verifier = session.createContext(contextAlgorithmId, KeyUsage.VERIFY, pub, contextSpec)) {
verifier.setExpectedTag(internalSignature); verifier.setExpectedTag(internalSignature);
try (InputStream in = verifier.wrap(new ByteArrayInputStream(msg))) { try (InputStream source = content.openStream(); InputStream in = verifier.wrap(source)) {
in.transferTo(OutputStream.nullOutputStream()); consume(in, cancellation);
} }
return true; return true;
} catch (Exception mismatch) { } 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 { private EncodedObject encodeSignatureOrThrow(Encoding encoding, byte[] sigBytes) throws InvalidRequestException {
if (encoding == Encoding.BINARY || encoding == Encoding.DER) { if (encoding == Encoding.BINARY || encoding == Encoding.DER) {
return new EncodedObject(encoding, sigBytes); return new EncodedObject(encoding, sigBytes);
@@ -1482,14 +1493,8 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
} }
output.writeUTF(request.keyRef().value()); output.writeUTF(request.keyRef().value());
output.writeUTF(request.algorithmId()); output.writeUTF(request.algorithmId());
output.writeInt(encodingCode(request.payload().encoding())); output.writeUTF(request.content().contentId());
byte[] payload = request.payload().bytes(); output.writeLong(request.content().length().orElse(-1L));
try {
output.writeInt(payload.length);
output.write(payload);
} finally {
clearOwned("persisted-request-payload", payload);
}
output.writeBoolean(request.preferredSignatureEncoding().isPresent()); output.writeBoolean(request.preferredSignatureEncoding().isPresent());
if (request.preferredSignatureEncoding().isPresent()) { if (request.preferredSignatureEncoding().isPresent()) {
output.writeInt(encodingCode(request.preferredSignatureEncoding().orElseThrow())); output.writeInt(encodingCode(request.preferredSignatureEncoding().orElseThrow()));
@@ -1516,25 +1521,38 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
objectId, formatId); objectId, formatId);
KeyRef keyRef = new KeyRef(input.readUTF()); KeyRef keyRef = new KeyRef(input.readUTF());
String algorithmId = input.readUTF(); String algorithmId = input.readUTF();
Encoding payloadEncoding = encodingFromCode(input.readInt()); String contentId = input.readUTF();
int payloadLength = input.readInt(); long contentLength = input.readLong();
if (payloadLength < 1 || payloadLength > 16 * 1024 * 1024) { if (contentId.isBlank() || contentLength < -1L) {
throw new IllegalStateException("Invalid persisted signing payload length"); throw new IllegalStateException("Invalid persisted signing content metadata");
} }
byte[] payload = input.readNBytes(payloadLength); Optional<Encoding> preferred = input.readBoolean() ? Optional.of(encodingFromCode(input.readInt()))
if (payload.length != payloadLength) { : Optional.empty();
throw new IllegalStateException("Truncated persisted signing payload"); 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())) @Override
: Optional.empty(); public java.util.OptionalLong length() {
Optional<Instant> deadline = input.readBoolean() return persistedLength < 0L ? java.util.OptionalLong.empty() : java.util.OptionalLong.of(persistedLength);
? Optional.of(Instant.ofEpochSecond(input.readLong(), input.readInt())) }
: Optional.empty();
return SignRequest.create(submissionId, namespace, 1L, access, keyRef, algorithmId, @Override
new EncodedObject(payloadEncoding, payload), preferred, deadline); public void close() {
} finally { // Metadata owns no live resource.
clearOwned("loaded-request-payload", payload);
} }
} }

View File

@@ -42,6 +42,11 @@ import java.util.Set;
import java.util.logging.Level; import java.util.logging.Level;
import java.util.logging.Logger; 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.spi.KeyringUnlockProvider;
import zeroecho.core.storage.KeyringPassword; import zeroecho.core.storage.KeyringPassword;
import zeroecho.core.storage.KeyringStore; import zeroecho.core.storage.KeyringStore;
@@ -75,7 +80,8 @@ import zeroecho.pki.spi.crypto.SignatureWorkflowRuntimeDependencies;
* performs no value logging. * performs no value logging.
* </p> * </p>
*/ */
public final class ZeroEchoLibSignatureWorkflowProvider implements SignatureWorkflowProvider { public final class ZeroEchoLibSignatureWorkflowProvider
implements SignatureWorkflowProvider, AlgorithmExecutionCapabilityProvider {
/** Stable failure code for a missing explicit keyring unlock provider. */ /** Stable failure code for a missing explicit keyring unlock provider. */
public static final String DC_KEYRING_UNLOCK_PROVIDER_REQUIRED = "KEYRING_UNLOCK_PROVIDER_REQUIRED"; public static final String DC_KEYRING_UNLOCK_PROVIDER_REQUIRED = "KEYRING_UNLOCK_PROVIDER_REQUIRED";
/** Stable failure code for an unlock-provider acquisition failure. */ /** Stable failure code for an unlock-provider acquisition failure. */
@@ -127,6 +133,42 @@ public final class ZeroEchoLibSignatureWorkflowProvider implements SignatureWork
KEY_REQUIRE_SUFFIX); 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. * 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 { try {
PKCS10CertificationRequest csr; PKCS10CertificationRequest csr;
try { try {
BcX509AlgorithmAdapter.requireCanonicalDer(csrDer);
csr = new PKCS10CertificationRequest(csrDer); csr = new PKCS10CertificationRequest(csrDer);
} catch (Exception ex) { } catch (Exception ex) {
throw new PkiException("Invalid PKCS#10 certification request: code=CSR_MALFORMED"); 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.FrameworkAttributeMapper;
import zeroecho.pki.spi.framework.ProofOfPossessionVerifier; import zeroecho.pki.spi.framework.ProofOfPossessionVerifier;
import zeroecho.pki.spi.framework.StatusObjectGenerator; import zeroecho.pki.spi.framework.StatusObjectGenerator;
import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot;
/** /**
* Bouncy Castle backed X.509 implementation of {@link CredentialFramework}. * 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 ProofOfPossessionVerifier popVerifier;
private final StatusObjectGenerator status; private final StatusObjectGenerator status;
private final FrameworkAttributeMapper attributeMapper; private final FrameworkAttributeMapper attributeMapper;
private final X509AuthoritySnapshot authority;
/** /**
* Creates a partially wired X.509 framework instance with default components. * 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. * exposed by the framework facade.
* </p> * </p>
*/ */
public BcX509CredentialFramework() { public BcX509CredentialFramework(X509AuthoritySnapshot authority, BcX509VerificationExecutor executor) {
this(new BcX509CertificationRequestParser(), new BcX509ProofOfPossessionVerifier(), this(new BcX509CertificationRequestParser(), new BcX509ProofOfPossessionVerifier(authority, executor),
new UnsupportedStatusObjectGenerator(), new BcX509FrameworkAttributeMapper()); new UnsupportedStatusObjectGenerator(), new BcX509FrameworkAttributeMapper(), authority);
} }
/** /**
@@ -146,11 +148,12 @@ public final class BcX509CredentialFramework implements CredentialFramework {
* @throws NullPointerException if any component argument is {@code null} * @throws NullPointerException if any component argument is {@code null}
*/ */
private BcX509CredentialFramework(CertificationRequestParser requestParser, ProofOfPossessionVerifier popVerifier, private BcX509CredentialFramework(CertificationRequestParser requestParser, ProofOfPossessionVerifier popVerifier,
StatusObjectGenerator status, FrameworkAttributeMapper attributeMapper) { StatusObjectGenerator status, FrameworkAttributeMapper attributeMapper, X509AuthoritySnapshot authority) {
this.requestParser = Objects.requireNonNull(requestParser, "requestParser"); this.requestParser = Objects.requireNonNull(requestParser, "requestParser");
this.popVerifier = Objects.requireNonNull(popVerifier, "popVerifier"); this.popVerifier = Objects.requireNonNull(popVerifier, "popVerifier");
this.status = Objects.requireNonNull(status, "status"); this.status = Objects.requireNonNull(status, "status");
this.attributeMapper = Objects.requireNonNull(attributeMapper, "attributeMapper"); 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) { public BcX509CredentialFramework wired(BcX509StatusObjectGenerator statusObjectGenerator) {
Objects.requireNonNull(statusObjectGenerator, "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(statusObjectGenerator, "statusObjectGenerator");
Objects.requireNonNull(proofOfPossessionVerifier, "proofOfPossessionVerifier"); Objects.requireNonNull(proofOfPossessionVerifier, "proofOfPossessionVerifier");
return new BcX509CredentialFramework(requestParser, proofOfPossessionVerifier, statusObjectGenerator, 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 * @throws UnsupportedOperationException always
*/ */
@Override @Override
public zeroecho.pki.api.status.StatusObject generate( public zeroecho.pki.impl.framework.x509.X509SignedObjectCompletion generate(
zeroecho.pki.api.status.StatusObjectGenerateCommand command, 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"); throw new UnsupportedOperationException("X.509 status object generator not wired");
} }
} }

View File

@@ -34,12 +34,15 @@
package zeroecho.pki.impl.framework.x509.bc; package zeroecho.pki.impl.framework.x509.bc;
import java.util.Set; import java.util.Set;
import java.util.List;
import java.util.logging.Level; import java.util.logging.Level;
import java.util.logging.Logger; import java.util.logging.Logger;
import zeroecho.pki.spi.ProviderConfig; import zeroecho.pki.spi.ProviderConfig;
import zeroecho.pki.spi.framework.CredentialFramework; import zeroecho.pki.spi.framework.CredentialFramework;
import zeroecho.pki.spi.framework.CredentialFrameworkProvider; 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 * ServiceLoader provider for the Bouncy Castle backed X.509 credential
@@ -162,6 +165,12 @@ public final class BcX509CredentialFrameworkProvider implements CredentialFramew
@Override @Override
public CredentialFramework allocate(ProviderConfig config) { public CredentialFramework allocate(ProviderConfig config) {
validateConfig(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; 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.math.BigInteger;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.time.Duration; import java.time.Duration;
@@ -52,8 +55,9 @@ import org.bouncycastle.asn1.x509.KeyUsage;
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
import org.bouncycastle.cert.X509CertificateHolder; import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.X509v3CertificateBuilder; 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.EncodedObject;
import zeroecho.pki.api.Encoding; import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.IssuerRef; 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.CredentialBundle;
import zeroecho.pki.api.credential.CredentialStatus; import zeroecho.pki.api.credential.CredentialStatus;
import zeroecho.pki.api.credential.EndEntityProfileBinding; import zeroecho.pki.api.credential.EndEntityProfileBinding;
import zeroecho.pki.api.content.DurableContentReference;
import zeroecho.pki.api.profile.LeafKeyUsage; import zeroecho.pki.api.profile.LeafKeyUsage;
import zeroecho.pki.api.request.SubjectAlternativeName; import zeroecho.pki.api.request.SubjectAlternativeName;
import zeroecho.pki.impl.core.ValidatedCaCertificateRequest; 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.async.PkiSigningBus;
import zeroecho.pki.impl.core.attr.SimpleAttributeSet; import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
import zeroecho.pki.spi.framework.CredentialIssuerBackend; import zeroecho.pki.spi.framework.CredentialIssuerBackend;
import zeroecho.pki.spi.store.ContentSink;
/** /**
* Bouncy Castle backed X.509 credential issuance backend. * Bouncy Castle backed X.509 credential issuance backend.
@@ -122,7 +128,7 @@ import zeroecho.pki.spi.framework.CredentialIssuerBackend;
public final class BcX509CredentialIssuerBackend implements CredentialIssuerBackend { public final class BcX509CredentialIssuerBackend implements CredentialIssuerBackend {
private final PkiSigningBus signingBus; private final PkiSigningBus signingBus;
private final String signatureAlgorithmId; private final AlgorithmIdentity signatureIdentity;
private final Duration signingTtl; private final Duration signingTtl;
/** /**
@@ -139,17 +145,29 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
* contract * contract
*/ */
public BcX509CredentialIssuerBackend(PkiSigningBus signingBus, String signatureAlgorithmId, Duration signingTtl) { 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) { if (signingBus == null) {
throw new IllegalArgumentException("signingBus must not be null"); throw new IllegalArgumentException("signingBus must not be null");
} }
if (signatureAlgorithmId == null || signatureAlgorithmId.isBlank()) { if (signatureIdentity == null || signatureIdentity.kind() != AlgorithmIdentity.Kind.SIGNATURE) {
throw new IllegalArgumentException("signatureAlgorithmId must not be null/blank"); throw new IllegalArgumentException("signatureIdentity must be a signature identity");
} }
if (signingTtl == null || signingTtl.isZero() || signingTtl.isNegative()) { if (signingTtl == null || signingTtl.isZero() || signingTtl.isNegative()) {
throw new IllegalArgumentException("signingTtl must be positive"); throw new IllegalArgumentException("signingTtl must be positive");
} }
this.signingBus = signingBus; this.signingBus = signingBus;
this.signatureAlgorithmId = signatureAlgorithmId; this.signatureIdentity = signatureIdentity;
this.signingTtl = signingTtl; this.signingTtl = signingTtl;
} }
@@ -174,13 +192,14 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
* fails, or certificate encoding fails * fails, or certificate encoding fails
*/ */
@Override @Override
public CredentialBundle issueEndEntity(ValidatedCertificateRequest request, EncodedObject issuerCertificate, public CredentialBundle issueEndEntity(ValidatedCertificateRequest request,
DurableContentReference issuerCertificate,
KeyRef issuerKeyRef, BigInteger serial) { KeyRef issuerKeyRef, BigInteger serial) {
if (request == null || issuerCertificate == null || issuerKeyRef == null || serial == null if (request == null || issuerCertificate == null || issuerKeyRef == null || serial == null
|| serial.signum() <= 0 || serial.toByteArray().length > 20) { || serial.signum() <= 0 || serial.toByteArray().length > 20) {
throw new IllegalArgumentException("Invalid validated end-entity issuance input"); throw new IllegalArgumentException("Invalid validated end-entity issuance input");
} }
byte[] issuerDer = issuerCertificate.bytes(); byte[] issuerDer = materialize(issuerCertificate);
X509CertificateHolder issuer; X509CertificateHolder issuer;
try { try {
issuer = parseIssuerCertificateOrThrow(issuerDer); 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); Date.from(request.validity().notBefore()), Date.from(request.validity().notAfter()), subjectDn, spki);
addLeafExtensions(builder, request); addLeafExtensions(builder, request);
ContentSigner signer = new PkiBusContentSigner(signingBus, issuerKeyRef, signatureAlgorithmId, signingTtl); PkiBusContentSigner signer = new PkiBusContentSigner(signingBus, issuerKeyRef, signatureIdentity, signingTtl);
X509CertificateHolder leaf; X509CertificateHolder leaf;
try { try {
leaf = builder.build(signer); leaf = builder.build(signer);
@@ -214,10 +233,12 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
PkiId publicKeyId = new PkiId("spki:" + fingerprintEncoded(request.exactPublicKey())); PkiId publicKeyId = new PkiId("spki:" + fingerprintEncoded(request.exactPublicKey()));
try { try {
DurableContentReference content = stageCertificate(certDer);
validateGeneratedCertificate(content, signer);
Credential credential = new Credential(credId, BcX509CredentialFramework.FORMAT_ID, Credential credential = new Credential(credId, BcX509CredentialFramework.FORMAT_ID,
new IssuerRef(request.issuerCaId()), request.subjectRef(), request.validity(), serial.toString(), new IssuerRef(request.issuerCaId()), request.subjectRef(), request.validity(), serial.toString(),
publicKeyId, new EndEntityProfileBinding(request.profileReference()), CredentialStatus.ISSUED, 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)); return new CredentialBundle(credential, java.util.List.of(issuerCertificate));
} finally { } finally {
java.util.Arrays.fill(certDer, (byte) 0); java.util.Arrays.fill(certDer, (byte) 0);
@@ -302,7 +323,7 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
*/ */
@Override @Override
public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest request, public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest request,
EncodedObject issuerCertificate, KeyRef issuerKeyRef) { DurableContentReference issuerCertificate, KeyRef issuerKeyRef) {
if (request == null || issuerCertificate == null || issuerKeyRef == null) { if (request == null || issuerCertificate == null || issuerKeyRef == null) {
throw new IllegalArgumentException("validated CA issuance inputs must not be 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"); throw new IllegalArgumentException("Unsupported formatId");
} }
byte[] issuerDer = issuerCertificate.bytes(); byte[] issuerDer = materialize(issuerCertificate);
X509CertificateHolder issuer; X509CertificateHolder issuer;
try { try {
issuer = parseIssuerCertificateOrThrow(issuerDer); 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"); 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; X509CertificateHolder certificate;
try { try {
certificate = builder.build(signer); certificate = builder.build(signer);
@@ -361,15 +382,66 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
PkiId credId = new PkiId("x509:" + sha256Hex(certDer)); PkiId credId = new PkiId("x509:" + sha256Hex(certDer));
try { try {
DurableContentReference content = stageCertificate(certDer);
validateGeneratedCertificate(content, signer);
return new Credential(credId, request.formatId(), new IssuerRef(request.issuerCaId()), subjectRef, validity, return new Credential(credId, request.formatId(), new IssuerRef(request.issuerCaId()), subjectRef, validity,
serial.toString(), publicKeyId, new CaProfileBinding(request.profileReference()), serial.toString(), publicKeyId, new CaProfileBinding(request.profileReference()),
CredentialStatus.ISSUED, new EncodedObject(Encoding.DER, certDer), CredentialStatus.ISSUED, content,
SimpleAttributeSet.builder().build()); SimpleAttributeSet.builder().build());
} finally { } finally {
java.util.Arrays.fill(certDer, (byte) 0); 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) { private static X509CertificateHolder parseIssuerCertificateOrThrow(byte[] issuerCertDer) {
try { try {
return new X509CertificateHolder(issuerCertDer); return new X509CertificateHolder(issuerCertDer);

View File

@@ -35,17 +35,20 @@ package zeroecho.pki.impl.framework.x509.bc;
import java.util.Optional; import java.util.Optional;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.operator.ContentVerifierProvider;
import org.bouncycastle.operator.OperatorCreationException; import org.bouncycastle.operator.OperatorCreationException;
import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder;
import org.bouncycastle.pkcs.PKCS10CertificationRequest; 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.attr.AttributeValue;
import zeroecho.pki.api.issuance.VerificationPolicy; import zeroecho.pki.api.issuance.VerificationPolicy;
import zeroecho.pki.api.request.ParsedCertificationRequest; import zeroecho.pki.api.request.ParsedCertificationRequest;
import zeroecho.pki.api.request.ProofOfPossessionResult; import zeroecho.pki.api.request.ProofOfPossessionResult;
import zeroecho.pki.api.request.ProofOfPossessionStatus; 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; import zeroecho.pki.spi.framework.ProofOfPossessionVerifier;
/** /**
@@ -94,21 +97,38 @@ import zeroecho.pki.spi.framework.ProofOfPossessionVerifier;
* authorization checks, or profile compliance checks.</li> * authorization checks, or profile compliance checks.</li>
* <li>The presence of a valid PKCS#10 self-signature does not by itself imply * <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> * that the requester is entitled to receive the requested certificate.</li>
* <li>This verifier accepts signature algorithms supported by its Bouncy Castle * <li>Algorithm identity, X.509 binding, immutable security-floor, capability,
* provider, including RSASSA-PSS. Deployments requiring a narrower or stronger * and configured-policy decisions are taken from one injected
* algorithm policy must enforce it in the issuance policy layer.</li> * {@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 * <li>The CSR payload carried under {@link BcX509Attributes#CSR_DER} may be
* operationally sensitive and must not be logged unsafely.</li> * operationally sensitive and must not be logged unsafely.</li>
* </ul> * </ul>
* *
* <h2>Thread-safety</h2> * <h2>Thread-safety</h2>
* <p> * <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> * </p>
*/ */
public final class BcX509ProofOfPossessionVerifier implements ProofOfPossessionVerifier { 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. * 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 * Otherwise, the method expects the original CSR DER payload to be present in
* {@code request.attributes()} under {@link BcX509Attributes#CSR_DER} as an * {@code request.attributes()} under {@link BcX509Attributes#CSR_DER} as an
* {@link AttributeValue.BytesValue}. It then parses the CSR, creates a Bouncy * {@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. * key information, and validates the PKCS#10 signature.
* </p> * </p>
* *
@@ -179,17 +199,33 @@ public final class BcX509ProofOfPossessionVerifier implements ProofOfPossessionV
try { try {
PKCS10CertificationRequest csr; PKCS10CertificationRequest csr;
try { try {
BcX509AlgorithmAdapter.requireCanonicalDer(csrDer);
csr = new PKCS10CertificationRequest(csrDer); csr = new PKCS10CertificationRequest(csrDer);
} catch (Exception ex) { } catch (Exception ex) {
return new ProofOfPossessionResult(ProofOfPossessionStatus.FAILED, Optional.of("Invalid CSR")); return new ProofOfPossessionResult(ProofOfPossessionStatus.FAILED, Optional.of("Invalid CSR"));
} }
ContentVerifierProvider cvp = new JcaContentVerifierProviderBuilder().setProvider(BC_PROVIDER) BcX509AlgorithmAdapter algorithmAdapter = new BcX509AlgorithmAdapter(authority.bindings());
.build(csr.getSubjectPublicKeyInfo()); try {
boolean ok = csr.isSignatureValid(cvp); AlgorithmIdentity signatureIdentity = algorithmAdapter.decode(csr.getSignatureAlgorithm(),
if (ok) { X509AlgorithmRole.SIGNATURE_ALGORITHM);
return new ProofOfPossessionResult(ProofOfPossessionStatus.VERIFIED, Optional.empty()); 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) { } catch (OperatorCreationException ex) {
return new ProofOfPossessionResult(ProofOfPossessionStatus.FAILED, Optional.of("Verifier unavailable")); return new ProofOfPossessionResult(ProofOfPossessionStatus.FAILED, Optional.of("Verifier unavailable"));
} catch (Exception ex) { } 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; 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.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.Arrays;
import java.util.Date; import java.util.Date;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import java.util.Optional; 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.AuthorityKeyIdentifier;
import org.bouncycastle.asn1.x509.CRLReason; import org.bouncycastle.asn1.x509.CRLReason;
import org.bouncycastle.asn1.x509.Extension; 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.X509CertificateHolder;
import org.bouncycastle.cert.X509v2CRLBuilder;
import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils; 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.Encoding;
import zeroecho.pki.api.FormatId; import zeroecho.pki.api.FormatId;
import zeroecho.pki.api.KeyRef; 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.StatusObject;
import zeroecho.pki.api.status.StatusObjectGenerateCommand; import zeroecho.pki.api.status.StatusObjectGenerateCommand;
import zeroecho.pki.api.status.StatusObjectType; import zeroecho.pki.api.status.StatusObjectType;
import zeroecho.pki.api.content.DurableContentReference;
import zeroecho.pki.impl.core.async.PkiSigningBus; 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.CrlEntry;
import zeroecho.pki.spi.framework.CrlEntrySource;
import zeroecho.pki.spi.framework.StatusObjectGenerator; 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. * 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 { public final class BcX509StatusObjectGenerator implements StatusObjectGenerator {
private static final long EMPTY_CONTENT_LENGTH = 0L;
private final PkiSigningBus signingBus; private final PkiSigningBus signingBus;
private final String signatureAlgorithmId; private final AlgorithmIdentity signatureIdentity;
private final Duration signingTtl; private final Duration signingTtl;
/** /**
@@ -161,17 +182,29 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator
* contract * contract
*/ */
public BcX509StatusObjectGenerator(PkiSigningBus signingBus, String signatureAlgorithmId, Duration signingTtl) { 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) { if (signingBus == null) {
throw new IllegalArgumentException("signingBus must not be null"); throw new IllegalArgumentException("signingBus must not be null");
} }
if (signatureAlgorithmId == null || signatureAlgorithmId.isBlank()) { if (signatureIdentity == null || signatureIdentity.kind() != AlgorithmIdentity.Kind.SIGNATURE) {
throw new IllegalArgumentException("signatureAlgorithmId must not be null/blank"); throw new IllegalArgumentException("signatureIdentity must be a signature identity");
} }
if (signingTtl == null || signingTtl.isZero() || signingTtl.isNegative()) { if (signingTtl == null || signingTtl.isZero() || signingTtl.isNegative()) {
throw new IllegalArgumentException("signingTtl must be positive"); throw new IllegalArgumentException("signingTtl must be positive");
} }
this.signingBus = signingBus; this.signingBus = signingBus;
this.signatureAlgorithmId = signatureAlgorithmId; this.signatureIdentity = signatureIdentity;
this.signingTtl = signingTtl; this.signingTtl = signingTtl;
} }
@@ -212,28 +245,240 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator
* if signing fails, or if CRL encoding fails * if signing fails, or if CRL encoding fails
*/ */
@Override @Override
public StatusObject generate(StatusObjectGenerateCommand command, List<CrlEntry> crlEntries) { public X509SignedObjectCompletion generate(StatusObjectGenerateCommand command, CrlEntrySource crlEntries) {
validateCommandOrThrow(command); validateCommandOrThrow(command);
List<CrlEntry> validatedEntries = validateEntries(crlEntries); if (crlEntries == null) {
throw new IllegalArgumentException("crlEntries must not be null");
}
IssuerMaterial issuerMaterial = extractIssuerMaterialOrThrow(command.attributes()); IssuerMaterial issuerMaterial = extractIssuerMaterialOrThrow(command.attributes());
Instant thisUpdate = Instant.now(); Instant thisUpdate = Instant.now();
Instant nextUpdate = thisUpdate.plus(Duration.ofDays(7)); Instant nextUpdate = thisUpdate.plus(Duration.ofDays(7));
Date thisUpdateDate = Date.from(thisUpdate); DurableContentReference entries = null;
DurableContentReference tbs = null;
X509v2CRLBuilder builder = newCrlBuilder(issuerMaterial.issuerHolder(), thisUpdateDate, nextUpdate); DurableContentReference crl = null;
addRevokedEntries(builder, validatedEntries); boolean accepted = false;
addAuthorityKeyIdentifierOrThrow(builder, issuerMaterial.issuerHolder()); try {
entries = encodeEntries(crlEntries);
X509CRLHolder crl = buildSignedCrlOrThrow(builder, issuerMaterial.keyRef()); PkiBusContentSigner signer = new PkiBusContentSigner(signingBus, issuerMaterial.keyRef(),
byte[] crlDer = encodeCrlOrThrow(crl); signatureIdentity, signingTtl);
byte[] algorithm = signer.getAlgorithmIdentifier().getEncoded();
return toStatusObject(command, crlDer, thisUpdate, nextUpdate); 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) { private void requireValidResult(DurableContentReference crl, DurableContentReference tbs,
Objects.requireNonNull(crlEntries, "crlEntries"); X509ExecutionPlan<SignatureWorkflow> signingPlan,
return List.copyOf(crlEntries); 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); 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. * 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) { 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; package zeroecho.pki.impl.framework.x509.bc;
import java.util.Map;
import java.util.Objects; import java.util.Objects;
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
import org.bouncycastle.asn1.x509.AlgorithmIdentifier; 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 * Internal mapper from X.509 and PKCS#10 signature algorithm identifiers to
* ZeroEcho canonical signature algorithm ids. * ZeroEcho canonical signature algorithm ids.
@@ -67,11 +71,13 @@ import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
* *
* <h2>Null and unsupported handling</h2> * <h2>Null and unsupported handling</h2>
* <ul> * <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 * 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> * 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> * string but still returns {@code null} for blank or unsupported values.</li>
* </ul> * </ul>
* *
@@ -105,27 +111,6 @@ public final class OidAlgorithmMapper {
* reviewable, and easy to maintain. * reviewable, and easy to maintain.
* </p> * </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. * Creates no instances.
*/ */
@@ -137,29 +122,28 @@ public final class OidAlgorithmMapper {
* signature algorithm identifier. * signature algorithm identifier.
* *
* <p> * <p>
* This method extracts the underlying {@link ASN1ObjectIdentifier}, converts it * This method extracts the underlying object identifier, converts it to its
* to its dotted-decimal string form, and performs a lookup in the immutable * dotted-decimal string form, and resolves it through the injected authority.
* mapping table.
* </p> * </p>
* *
* @param sigAlg ASN.1 signature algorithm identifier, possibly {@code null} * @param sigAlg ASN.1 signature algorithm identifier, possibly {@code null}
* @param authority immutable algorithm authority
* @return ZeroEcho canonical signature algorithm identifier, or {@code null} * @return ZeroEcho canonical signature algorithm identifier, or {@code null}
* when the input is {@code null}, structurally incomplete, blank, or * when the input is {@code null}, structurally incomplete, blank, or
* unsupported * unsupported
*/ */
public static String toZeroEchoAlgorithmId(AlgorithmIdentifier sigAlg) { public static String toZeroEchoAlgorithmId(AlgorithmIdentifier sigAlg, X509AuthoritySnapshot authority) {
if (sigAlg == null) { if (sigAlg == null) {
return null; return null;
} }
ASN1ObjectIdentifier oid = sigAlg.getAlgorithm(); try {
if (oid == null) { AlgorithmIdentity identity = new BcX509AlgorithmAdapter(
Objects.requireNonNull(authority, "authority").bindings())
.decode(sigAlg, X509AlgorithmRole.SIGNATURE_ALGORITHM);
return identity.canonicalForm();
} catch (IllegalArgumentException exception) {
return null; 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 * @param oid dotted-decimal signature algorithm OID string; must not be
* {@code null} * {@code null}
* @param authority immutable algorithm authority
* @return ZeroEcho canonical signature algorithm identifier, or {@code null} * @return ZeroEcho canonical signature algorithm identifier, or {@code null}
* when the supplied OID string is blank or unsupported * when the supplied OID string is blank or unsupported
* @throws NullPointerException if {@code oid} is {@code null} * @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"); 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 null;
} }
return OID_TO_ZEROECHO_ALG.get(oid);
} }
} }

View File

@@ -33,27 +33,31 @@
******************************************************************************/ ******************************************************************************/
package zeroecho.pki.impl.framework.x509.bc; package zeroecho.pki.impl.framework.x509.bc;
import java.io.ByteArrayOutputStream; import java.io.IOException;
import java.io.OutputStream; import java.io.OutputStream;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.Arrays;
import java.util.Optional; import java.util.Optional;
import org.bouncycastle.asn1.x509.AlgorithmIdentifier; import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
import org.bouncycastle.operator.ContentSigner; import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.DefaultSignatureAlgorithmIdentifierFinder;
import zeroecho.core.spec.AlgorithmIdentity;
import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.Encoding; import zeroecho.pki.api.Encoding;
import zeroecho.pki.api.KeyRef; import zeroecho.pki.api.KeyRef;
import zeroecho.pki.api.PkiException; import zeroecho.pki.api.PkiException;
import zeroecho.pki.api.PkiId; import zeroecho.pki.api.PkiId;
import zeroecho.pki.api.content.DurableContentReference;
import zeroecho.pki.api.audit.AccessContext; import zeroecho.pki.api.audit.AccessContext;
import zeroecho.pki.api.audit.Principal; import zeroecho.pki.api.audit.Principal;
import zeroecho.pki.api.audit.Purpose; import zeroecho.pki.api.audit.Purpose;
import zeroecho.pki.impl.core.async.PkiSigningBus; import zeroecho.pki.impl.core.async.PkiSigningBus;
import zeroecho.pki.impl.core.async.PkiSigningBus.SignContinuation; 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; 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 * contract to the PKI runtime signing boundary represented by
* {@link PkiSigningBus}. Bouncy Castle writes the to-be-signed bytes into the * {@link PkiSigningBus}. Bouncy Castle writes the to-be-signed bytes into the
* output stream returned by {@link #getOutputStream()}, and when * output stream returned by {@link #getOutputStream()}, and when
* {@link #getSignature()} is invoked this signer submits the collected bytes to * {@link #getSignature()} is invoked this signer atomically completes the
* the PKI signing bus as a delegated sign workflow. * file-backed content and submits its durable reference to the PKI signing bus.
* </p> * </p>
* *
* <p> * <p>
@@ -79,7 +83,8 @@ import zeroecho.pki.util.async.AsyncState;
* *
* <h2>Execution model</h2> * <h2>Execution model</h2>
* <ul> * <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 * <li>A synthetic system owner and purpose are used to submit the signing
* operation.</li> * operation.</li>
* <li>A fresh client operation identifier is generated for each signature * <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> * <h2>Security considerations</h2>
* <ul> * <ul>
* <li>This class never accesses private key material directly.</li> * <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> * operationally sensitive and must not be logged.</li>
* <li>The generated operation identifier is an internal workflow handle and * <li>The generated operation identifier is an internal workflow handle and
* must not be treated as a durable business identifier outside the signing * 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> * <h2>Thread-safety</h2>
* <p> * <p>
* Instances of this class are not thread-safe. Each instance maintains mutable * Instances of this class are not thread-safe. Each instance owns one sequential
* in-memory state through its internal {@link ByteArrayOutputStream} and is * staged-content sink and is intended for one certificate or CRL signing flow.
* intended for one certificate or CRL signing flow.
* </p> * </p>
*/ */
// PMD cannot infer that retaining provider causes would violate the redaction contract. // 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 PkiSigningBus bus;
private final KeyRef keyRef; private final KeyRef keyRef;
private final String algorithmId; private final AlgorithmIdentity algorithmIdentity;
private final X509ExecutionPlan<SignatureWorkflow> executionPlan;
private final Duration ttl; 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 * Creates a signer that routes signature generation through the PKI signing
@@ -136,23 +142,52 @@ public final class PkiBusContentSigner implements ContentSigner {
* contract * contract
*/ */
public PkiBusContentSigner(PkiSigningBus bus, KeyRef keyRef, String algorithmId, Duration ttl) { 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) { if (bus == null) {
throw new IllegalArgumentException("bus must not be null"); throw new IllegalArgumentException("bus must not be null");
} }
if (keyRef == null) { if (keyRef == null) {
throw new IllegalArgumentException("keyRef must not be null"); throw new IllegalArgumentException("keyRef must not be null");
} }
if (algorithmId == null || algorithmId.isBlank()) { if (algorithmIdentity == null || algorithmIdentity.kind() != AlgorithmIdentity.Kind.SIGNATURE) {
throw new IllegalArgumentException("algorithmId must not be null/blank"); throw new IllegalArgumentException("algorithmIdentity must be a signature identity");
} }
if (ttl == null || ttl.isZero() || ttl.isNegative()) { if (ttl == null || ttl.isZero() || ttl.isNegative()) {
throw new IllegalArgumentException("ttl must be positive"); throw new IllegalArgumentException("ttl must be positive");
} }
this.bus = bus; this.bus = bus;
this.keyRef = keyRef; 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.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 @Override
public AlgorithmIdentifier getAlgorithmIdentifier() { 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. * Returns the output stream used to collect the to-be-signed bytes.
* *
* <p> * <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. * {@link #getSignature()} is called.
* </p> * </p>
* *
* @return mutable in-memory output stream receiving the to-be-signed payload * @return sequential staged-content output stream
*/ */
@Override @Override
public OutputStream getOutputStream() { 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 @Override
@SuppressWarnings("PMD.AvoidCatchingGenericException") @SuppressWarnings("PMD.AvoidCatchingGenericException")
public byte[] getSignature() { public byte[] getSignature() {
byte[] tbs = baos.toByteArray();
byte[] consumedResult = null; byte[] consumedResult = null;
PkiId opId = null; PkiId opId = null;
boolean retirementRequired = false; boolean retirementRequired = false;
Throwable primaryFailure = null; Throwable primaryFailure = null;
try { try {
EncodedObject payload = new EncodedObject(Encoding.BINARY, tbs); DurableContentReference content = completeContent();
Principal owner = new Principal("SYSTEM", "pki"); Principal owner = new Principal("SYSTEM", "pki");
opId = bus.newSubmissionId(); opId = bus.newSubmissionId();
AccessContext ac = new AccessContext(owner, new Purpose("X509_SIGN"), Optional.empty(), Optional.empty()); 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()); Optional.empty());
retirementRequired = true; 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); consumedResult = awaitSignature(opId);
return consumedResult.clone(); return consumedResult.clone();
} catch (RuntimeException failure) { } catch (RuntimeException failure) {
@@ -257,15 +301,32 @@ public final class PkiBusContentSigner implements ContentSigner {
retirePreservingFailure(opId, primaryFailure); retirePreservingFailure(opId, primaryFailure);
} }
} finally { } finally {
Arrays.fill(tbs, (byte) 0); closeSink(primaryFailure);
baos.wipe();
if (consumedResult != null) { 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) { private byte[] awaitSignature(PkiId opId) {
Instant deadline = Instant.now().plus(ttl); Instant deadline = Instant.now().plus(ttl);
while (Instant.now().isBefore(deadline)) { 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.ParsedCertificationRequest;
import zeroecho.pki.api.request.ProofOfPossessionResult; import zeroecho.pki.api.request.ProofOfPossessionResult;
import zeroecho.pki.api.request.ProofOfPossessionStatus; 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.pki.spi.crypto.SignatureWorkflow;
import zeroecho.core.io.CancellationSignal;
import zeroecho.core.io.ImmutableByteContent;
import zeroecho.pki.spi.framework.ProofOfPossessionVerifier; 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 static final Principal SYSTEM = new Principal("SYSTEM", "pki");
private final SignatureWorkflow workflow; 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 * @param workflow selected verification workflow
* verification; must not be {@code null} * @param authority matching authority snapshot
* @throws NullPointerException if {@code workflow} is {@code null}
*/ */
public WorkflowProofOfPossessionVerifier(SignatureWorkflow workflow) { public WorkflowProofOfPossessionVerifier(SignatureWorkflow workflow, X509AuthoritySnapshot authority) {
this.workflow = Objects.requireNonNull(workflow, "workflow"); 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(); 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()); Optional<byte[]> tbs = encodeCertificationRequestInfo(csrAsn1.getCertificationRequestInfo());
if (tbs.isEmpty()) { if (tbs.isEmpty()) {
return failed("CSR TBS encoding failed"); return failed("CSR TBS encoding failed");
} }
String algorithmId = OidAlgorithmMapper.toZeroEchoAlgorithmId(csrAsn1.getSignatureAlgorithm()); String algorithmId = signatureIdentity.canonicalForm();
if (algorithmId == null) {
return new ProofOfPossessionResult(ProofOfPossessionStatus.NOT_SUPPORTED,
Optional.of("Unsupported CSR algorithm"));
}
byte[] signature = extractSignatureBytes(csrAsn1.getSignature()); byte[] signature = extractSignatureBytes(csrAsn1.getSignature());
SignatureWorkflow.VerifyRequest verifyRequest = toVerifyRequest(request, algorithmId, tbs.get(), signature, SignatureWorkflow.VerifyRequest verifyRequest = toVerifyRequest(request, algorithmId, tbs.get(), signature,
spkiDer.get()); 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) { private static Optional<PKCS10CertificationRequest> parseCsr(byte[] csrDer) {
try { try {
BcX509AlgorithmAdapter.requireCanonicalDer(csrDer);
return Optional.of(new PKCS10CertificationRequest(csrDer)); return Optional.of(new PKCS10CertificationRequest(csrDer));
} catch (IOException | IllegalArgumentException ex) { } catch (IOException | IllegalArgumentException ex) {
return Optional.empty(); return Optional.empty();
@@ -315,9 +334,10 @@ public final class WorkflowProofOfPossessionVerifier implements ProofOfPossessio
AccessContext accessContext = new AccessContext(SYSTEM, PURPOSE, Optional.of(request.requestId()), AccessContext accessContext = new AccessContext(SYSTEM, PURPOSE, Optional.of(request.requestId()),
Optional.of(request.formatId())); 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(), 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} * @param verifyRequest workflow verification request; must not be {@code null}
* @return mapped proof-of-possession result * @return mapped proof-of-possession result
*/ */
private ProofOfPossessionResult verifyWithWorkflow(SignatureWorkflow.VerifyRequest verifyRequest) { private ProofOfPossessionResult verifyWithWorkflow(SignatureWorkflow.VerifyRequest verifyRequest,
PkiId verifyOperationId = workflow.submitVerify(verifyRequest); X509ExecutionPlan<SignatureWorkflow> executionPlan) {
SignatureWorkflow.OperationStatus status = workflow.status(verifyOperationId); 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) { if (status == null) {
return failed("Verifier returned no status"); 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.List;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.OptionalLong;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicBoolean; 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.RevocationState;
import zeroecho.pki.api.revocation.RevocationTransition; import zeroecho.pki.api.revocation.RevocationTransition;
import zeroecho.pki.api.status.StatusObject; 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;
import zeroecho.pki.impl.ProfileLifecycleFailure.Code; import zeroecho.pki.impl.ProfileLifecycleFailure.Code;
import zeroecho.pki.impl.core.async.PkiSigningBus; import zeroecho.pki.impl.core.async.PkiSigningBus;
import zeroecho.pki.spi.store.PkiStore; import zeroecho.pki.spi.store.PkiStore;
import zeroecho.pki.spi.store.StagedContentStore;
import zeroecho.pki.spi.store.SignWorkflowStore; 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}. * 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<PkiId, RevocationLockEntry> revocationLocks;
private final ConcurrentMap<String, ProfileLockEntry> profileLocks; private final ConcurrentMap<String, ProfileLockEntry> profileLocks;
private final AtomicBoolean durabilityUncertain; private final AtomicBoolean durabilityUncertain;
private final FilesystemStagedContentStore stagedContent;
private final CredentialContentTransaction credentialContentTransactions;
private final StoreOwnership ownership; private final StoreOwnership ownership;
@@ -221,8 +230,12 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
try { try {
ensureVersionFile(); ensureVersionFile();
this.signingNamespace = ensureSigningNamespace(); 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.signingTimeWatermark = new AtomicLong(loadSigningTimeWatermark());
this.historySeq = new AtomicLong(0L); this.historySeq = new AtomicLong(0L);
recoverStagedContent();
LOG.log(Level.INFO, "running in {0}", root); LOG.log(Level.INFO, "running in {0}", root);
this.ownership = acquiredOwnership; 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 * Exports a snapshot of this store as of time {@code at} into
* {@code targetRoot}. * {@code targetRoot}.
@@ -292,16 +400,23 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
public void putCredential(final Credential credential) { public void putCredential(final Credential credential) {
requireStoreUsable(); requireStoreUsable();
Objects.requireNonNull(credential, "credential"); Objects.requireNonNull(credential, "credential");
PkiId id = credential.credentialId(); credentialContentTransactions.put(credential);
Path p = this.paths.credentialPath(id);
writeOnce(p, FsCodec.encode(FsCodec.CREDENTIAL, credential), "CREDENTIAL", FsUtil.safeId(id));
} }
@Override @Override
public Optional<Credential> getCredential(final PkiId credentialId) { public Optional<Credential> getCredential(final PkiId credentialId) {
requireStoreUsable(); requireStoreUsable();
Objects.requireNonNull(credentialId, "credentialId"); 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 @Override
@@ -369,28 +484,29 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
} }
@Override @Override
// Listing failures are intentionally replaced by one stable redacted boundary. // Snapshot failures are intentionally replaced by one stable redacted boundary.
@SuppressWarnings("PMD.PreserveStackTrace") @SuppressWarnings("PMD.PreserveStackTrace")
public List<RevocationJournal> listRevocationJournals() { public RevocationSnapshot openRevocationSnapshot() {
requireStoreUsable(); requireStoreUsable();
Path root = this.paths.root().resolve("revocations").resolve("by-credential"); Path root = this.paths.root().resolve("revocations").resolve("by-credential");
if (!Files.isDirectory(root)) { String snapshotId = UUID.randomUUID().toString();
return List.of(); Path snapshotRoot = paths.revocationSnapshotRoot().resolve(snapshotId);
} long count = 0L;
try (Stream<Path> directories = Files.list(root)) { try {
List<RevocationJournal> journals = new ArrayList<>(); Files.createDirectories(snapshotRoot);
for (Path entityDir : directories.filter(Files::isDirectory) if (Files.isDirectory(root)) {
.sorted(Comparator.comparing(path -> path.getFileName().toString())).toList()) { try (Stream<Path> journals = Files.walk(root)) {
Path journalPath = entityDir.resolve("journal.bin"); java.util.Iterator<Path> iterator = journals
if (Files.exists(journalPath)) { .filter(path -> Files.isRegularFile(path)
RevocationJournal journal = decodeRevocationJournal(journalPath); && "journal.bin".equals(path.getFileName().toString()))
if (!entityDir.getFileName().toString().equals(FsUtil.safeId(journal.credentialId()))) { .iterator();
throw corruptRevocationState(); 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) { } catch (IOException ex) {
throw corruptRevocationState(); throw corruptRevocationState();
} }
@@ -835,9 +951,13 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|| !isRetirableSignState(current.state())) { || !isRetirableSignState(current.state())) {
return Optional.empty(); return Optional.empty();
} }
SignWorkflowStore.Record retired = copySignRecord(current, SignWorkflowStore.State.RETIRED, PkiSigningBus.SignContinuation continuation = PkiSigningBus.SignContinuation.decode(current.request(),
current.revision() + 1L, fence, Optional.empty(), Optional.of("RETIRED"), current.result(), stagedContent);
current.providerUpdatedAt()); 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); writeSignRecord(retired);
return Optional.of(retired); return Optional.of(retired);
} finally { } finally {
@@ -1054,6 +1174,105 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
return Optional.of(journal); 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 // Codec and filesystem failures are external persisted-state boundaries; raw
// causes are deliberately removed from the stable corruption exception. // causes are deliberately removed from the stable corruption exception.
@SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" }) @SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" })
@@ -1156,7 +1375,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
} }
byte[] payload = new byte[input.remaining()]; byte[] payload = new byte[input.remaining()];
input.get(payload); input.get(payload);
return FsCodec.decode(FsCodec.SIGN_WORKFLOW_RECORD, payload); return FsCodec.decode(FsCodec.SIGN_WORKFLOW_RECORD, payload, stagedContent);
} catch (IOException ex) { } catch (IOException ex) {
throw new IllegalStateException("Failed to read authoritative signing record", ex); throw new IllegalStateException("Failed to read authoritative signing record", ex);
} }
@@ -1176,7 +1395,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
Instant futureLimit; Instant futureLimit;
try { try {
parsed = SigningSubmissionId.parse(record.submissionId()); parsed = SigningSubmissionId.parse(record.submissionId());
continuation = PkiSigningBus.SignContinuation.decode(record.request()); continuation = PkiSigningBus.SignContinuation.decode(record.request(), stagedContent);
horizonEnd = record.createdAt().plus(options.signingOperationHorizon()); horizonEnd = record.createdAt().plus(options.signingOperationHorizon());
futureLimit = signingNow().plus(options.signingIdPermittedSkew()); futureLimit = signingNow().plus(options.signingIdPermittedSkew());
} catch (RuntimeException ex) { } catch (RuntimeException ex) {
@@ -1193,6 +1412,8 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
record, "DEADLINE_INVALID"); record, "DEADLINE_INVALID");
requireValidSignRecord(continuation.isBoundTo(record.submissionId(), record.owner()), record, requireValidSignRecord(continuation.isBoundTo(record.submissionId(), record.owner()), record,
"CONTINUATION_IDENTITY_MISMATCH"); "CONTINUATION_IDENTITY_MISMATCH");
requireValidSignRecord(record.state() == SignWorkflowStore.State.RETIRED != continuation.hasLiveContent(),
record, "CONTINUATION_LIFECYCLE_MISMATCH");
byte[] storedRequest = record.request().bytes(); byte[] storedRequest = record.request().bytes();
byte[] canonicalRequest = null; 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 { try {
if (!Files.exists(path)) { if (!Files.exists(path)) {
return Optional.empty(); return Optional.empty();
} }
byte[] data = FsOperations.readAll(path); byte[] data = FsOperations.readAll(path);
return Optional.of(FsCodec.decode(schema, data)); return Optional.of(FsCodec.decode(schema, data, stagedContent));
} catch (IOException e) { } catch (IOException e) {
throw new IllegalStateException("read failed: " + path, 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)) { if (!Files.isDirectory(byIdDir)) {
return List.of(); return List.of();
} }
@@ -1629,7 +1850,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
return Files.list(byIdDir).filter(Files::isRegularFile) return Files.list(byIdDir).filter(Files::isRegularFile)
.sorted(Comparator.comparing(p -> p.getFileName().toString())).map(p -> { .sorted(Comparator.comparing(p -> p.getFileName().toString())).map(p -> {
try { try {
return FsCodec.decode(schema, FsOperations.readAll(p)); return FsCodec.decode(schema, FsOperations.readAll(p), stagedContent);
} catch (IOException e) { } catch (IOException e) {
throw new IllegalStateException("read failed: " + p, 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)) { if (!Files.isDirectory(byIdDir)) {
return List.of(); return List.of();
} }
@@ -1651,7 +1872,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
for (Path entityDir : entityDirs) { for (Path entityDir : entityDirs) {
Path current = entityDir.resolve(FsPaths.CURRENT_FILE); Path current = entityDir.resolve(FsPaths.CURRENT_FILE);
if (Files.exists(current)) { if (Files.exists(current)) {
out.add(FsCodec.decode(schema, FsOperations.readAll(current))); out.add(FsCodec.decode(schema, FsOperations.readAll(current), stagedContent));
} }
} }
return out; 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.Credential;
import zeroecho.pki.api.credential.CredentialProfileBinding; import zeroecho.pki.api.credential.CredentialProfileBinding;
import zeroecho.pki.api.credential.CredentialStatus; import zeroecho.pki.api.credential.CredentialStatus;
import zeroecho.pki.api.content.DurableContentReference;
import zeroecho.pki.api.credential.EndEntityProfileBinding; import zeroecho.pki.api.credential.EndEntityProfileBinding;
import zeroecho.pki.api.orch.OrchestrationDurabilityPolicy; import zeroecho.pki.api.orch.OrchestrationDurabilityPolicy;
import zeroecho.pki.api.orch.WorkflowStateRecord; 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.revocation.RevocationTransition;
import zeroecho.pki.api.status.StatusObject; import zeroecho.pki.api.status.StatusObject;
import zeroecho.pki.api.status.StatusObjectType; import zeroecho.pki.api.status.StatusObjectType;
import zeroecho.pki.spi.store.StagedContentStore;
import zeroecho.pki.impl.core.attr.SimpleAttributeSet; import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
import zeroecho.pki.spi.store.SignWorkflowStore; import zeroecho.pki.spi.store.SignWorkflowStore;
@@ -118,7 +120,7 @@ import zeroecho.pki.spi.store.SignWorkflowStore;
final class FsCodec { final class FsCodec {
/* package */ static final int MAX_COMPONENT_BYTES = 256 * 1024; /* 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 CODEC_MAGIC = 0x5A454346;
private static final int MAX_COLLECTION_ELEMENTS = MAX_COMPONENT_BYTES; 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_POLICY_TRACE = 8;
private static final int TOP_WORKFLOW_STATE = 9; private static final int TOP_WORKFLOW_STATE = 9;
private static final int TOP_SIGN_WORKFLOW_RECORD = 10; 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_PROFILE_VERSION = 11;
private static final int TOP_ACTIVE_PROFILE_REF = 12; 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_SAN = 66;
private static final int TYPE_PROFILE_REF = 72; private static final int TYPE_PROFILE_REF = 72;
private static final int TYPE_PROFILE_BINDING = 73; 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_STRING = 1;
private static final int ATTRIBUTE_BOOLEAN = 2; private static final int ATTRIBUTE_BOOLEAN = 2;
@@ -392,6 +396,8 @@ final class FsCodec {
}, reader -> new Validity(reader.readValue(INSTANT), reader.readValue(INSTANT))); }, reader -> new Validity(reader.readValue(INSTANT), reader.readValue(INSTANT)));
private static final ValueSchema<EncodedObject> ENCODED_OBJECT = valueSchema(TYPE_ENCODED_OBJECT, private static final ValueSchema<EncodedObject> ENCODED_OBJECT = valueSchema(TYPE_ENCODED_OBJECT,
FsCodec::writeEncodedObject, FsCodec::readEncodedObject); 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) -> { private static final ValueSchema<Principal> PRINCIPAL = valueSchema(TYPE_PRINCIPAL, (writer, value) -> {
writer.writeValue(STRING, value.type()); writer.writeValue(STRING, value.type());
writer.writeValue(STRING, value.name()); writer.writeValue(STRING, value.name());
@@ -495,39 +501,48 @@ final class FsCodec {
} }
/* package */ static <T> T decode(final Schema<T> schema, final byte[] encoded) { /* 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(schema, "schema");
Objects.requireNonNull(encoded, "encoded"); Objects.requireNonNull(encoded, "encoded");
try { try {
return decodeCurrentPayload(schema, encoded); return PayloadDecoder.decode(schema, encoded, stagedContent);
} catch (IOException | IllegalArgumentException ex) { } catch (IOException | IllegalArgumentException ex) {
throw new IllegalStateException("Decoding failed: schema=" + schema.name + " code=INVALID_CURRENT_PAYLOAD", throw new IllegalStateException("Decoding failed: schema=" + schema.name + " code=INVALID_CURRENT_PAYLOAD",
ex); ex);
} }
} }
private static <T> T decodeCurrentPayload(Schema<T> schema, byte[] encoded) throws IOException { /** Strict current-schema payload decoder separated from the schema registry. */
ByteArrayInputStream input = new ByteArrayInputStream(encoded); private static final class PayloadDecoder {
Reader reader = new Reader(input); private static <T> T decode(Schema<T> schema, byte[] encoded, StagedContentStore stagedContent)
if (reader.readInt() != CODEC_MAGIC) { throws IOException {
throw new IOException("codec magic mismatch"); 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 { 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 { private static void writeAttributeValue(Writer writer, AttributeValue value) throws IOException {
switch (value) { switch (value) {
case AttributeValue.StringValue stringValue -> { case AttributeValue.StringValue stringValue -> {
@@ -629,7 +673,7 @@ final class FsCodec {
writer.writeValue(PKI_ID, value.publicKeyId()); writer.writeValue(PKI_ID, value.publicKeyId());
writer.writeValue(PROFILE_BINDING, value.profileBinding()); writer.writeValue(PROFILE_BINDING, value.profileBinding());
writer.writeValue(CREDENTIAL_STATUS, value.status()); writer.writeValue(CREDENTIAL_STATUS, value.status());
writer.writeValue(ENCODED_OBJECT, value.encoded()); writer.writeValue(DURABLE_CONTENT, value.content());
writer.writeValue(ATTRIBUTE_SET, value.attributes()); 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), 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(SUBJECT_REF), reader.readValue(VALIDITY), reader.readValue(STRING),
reader.readValue(PKI_ID), reader.readValue(PROFILE_BINDING), reader.readValue(CREDENTIAL_STATUS), 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 { 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(STATUS_OBJECT_TYPE, value.type());
writer.writeValue(INSTANT, value.thisUpdate()); writer.writeValue(INSTANT, value.thisUpdate());
writer.writeValue(OPTIONAL_INSTANT, value.nextUpdate()); writer.writeValue(OPTIONAL_INSTANT, value.nextUpdate());
writer.writeValue(ENCODED_OBJECT, value.encoded()); writer.writeValue(DURABLE_CONTENT, value.content());
writer.writeValue(ATTRIBUTE_SET, value.attributes()); writer.writeValue(ATTRIBUTE_SET, value.attributes());
} }
private static StatusObject readStatusObject(Reader reader) throws IOException { private static StatusObject readStatusObject(Reader reader) throws IOException {
return new StatusObject(reader.readValue(PKI_ID), reader.readValue(FORMAT_ID), reader.readValue(PKI_ID), PkiId statusObjectId = reader.readValue(PKI_ID);
reader.readValue(STATUS_OBJECT_TYPE), reader.readValue(INSTANT), reader.readValue(OPTIONAL_INSTANT), FormatId formatId = reader.readValue(FORMAT_ID);
reader.readValue(ENCODED_OBJECT), reader.readValue(ATTRIBUTE_SET)); 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 { 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 { private static int toInt(long value) throws IOException {
if (value < Integer.MIN_VALUE || value > Integer.MAX_VALUE) { try {
throw new IOException("integer value out of range"); 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 { private static void writePolicyTrace(Writer writer, PolicyTrace value) throws IOException {
@@ -1073,9 +1124,19 @@ final class FsCodec {
private static final class Reader { private static final class Reader {
private final InputStream input; private final InputStream input;
private final StagedContentStore stagedContent;
private Reader(InputStream input) { private Reader(InputStream input, StagedContentStore stagedContent) {
this.input = input; 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 { 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"); return this.root.resolve("SIGNING_TIME_WATERMARK");
} }
/* default */ Path stagedContentRoot() {
return this.root.resolve("staged-content");
}
/* default */ Path lockFile() { /* default */ Path lockFile() {
return this.root.resolve(LOCK_DIR).resolve(STORE_LOCK); return this.root.resolve(LOCK_DIR).resolve(STORE_LOCK);
} }
@@ -175,6 +179,10 @@ final class FsPaths {
return revocationDir(credentialId).resolve("journal.bin"); return revocationDir(credentialId).resolve("journal.bin");
} }
/* default */ Path revocationSnapshotRoot() {
return this.root.resolve("revocation-snapshots");
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Status objects (immutable .bin) // Status objects (immutable .bin)
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------

View File

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

View File

@@ -35,8 +35,8 @@ package zeroecho.pki.spi.framework;
import java.math.BigInteger; import java.math.BigInteger;
import zeroecho.pki.api.EncodedObject;
import zeroecho.pki.api.KeyRef; import zeroecho.pki.api.KeyRef;
import zeroecho.pki.api.content.DurableContentReference;
import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.credential.Credential;
import zeroecho.pki.api.credential.CredentialBundle; import zeroecho.pki.api.credential.CredentialBundle;
import zeroecho.pki.impl.core.ValidatedCaCertificateRequest; import zeroecho.pki.impl.core.ValidatedCaCertificateRequest;
@@ -140,7 +140,7 @@ public interface CredentialIssuerBackend {
* or other framework-specific issuance * or other framework-specific issuance
* processing fails * processing fails
*/ */
CredentialBundle issueEndEntity(ValidatedCertificateRequest request, EncodedObject issuerCertificate, CredentialBundle issueEndEntity(ValidatedCertificateRequest request, DurableContentReference issuerCertificate,
KeyRef issuerKeyRef, BigInteger serial); KeyRef issuerKeyRef, BigInteger serial);
/** /**
@@ -171,6 +171,7 @@ public interface CredentialIssuerBackend {
* or other framework-specific issuance * or other framework-specific issuance
* processing fails * processing fails
*/ */
Credential issueIntermediateCertificate(ValidatedCaCertificateRequest request, EncodedObject issuerCertificate, Credential issueIntermediateCertificate(ValidatedCaCertificateRequest request,
DurableContentReference issuerCertificate,
KeyRef issuerKeyRef); 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; package zeroecho.pki.spi.framework;
import java.util.List;
import zeroecho.pki.api.status.StatusObject;
import zeroecho.pki.api.status.StatusObjectGenerateCommand; 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 * Generates status objects for a credential framework (e.g., CRL/delta CRL/OCSP
@@ -49,11 +47,11 @@ public interface StatusObjectGenerator {
* Generates a status object. * Generates a status object.
* *
* @param command generation command * @param command generation command
* @param crlEntries structured CRL entries; empty for non-CRL objects * @param crlEntries stable streamed CRL entries
* @return generated status object * @return generated status object
* @throws IllegalArgumentException if {@code command} or {@code crlEntries} is * @throws IllegalArgumentException if {@code command} or {@code crlEntries} is
* invalid * invalid
* @throws RuntimeException if generation fails * @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; package zeroecho.pki.spi.publish;
import zeroecho.pki.api.EncodedObject; import zeroecho.core.io.RepeatableContent;
import zeroecho.pki.api.publication.PublicationTarget; import zeroecho.pki.api.publication.PublicationTarget;
/** /**
@@ -50,5 +50,5 @@ public interface Publisher {
* @throws IllegalArgumentException if inputs are null * @throws IllegalArgumentException if inputs are null
* @throws RuntimeException if publishing fails * @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 { 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. * Persists or updates a Certificate Authority (CA) record.
* *
@@ -175,11 +182,12 @@ public interface PkiStore extends SignWorkflowStore {
Optional<RevocationJournal> getRevocationJournal(PkiId credentialId); 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. * 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