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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -38,8 +38,10 @@ import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import zeroecho.core.alg.BootstrapAlgorithmIdentities;
|
||||
import zeroecho.core.alg.ecdsa.EcdsaCurveSpec;
|
||||
import zeroecho.core.alg.rsa.RsaSigSpec;
|
||||
import zeroecho.core.spec.AlgorithmIdentity;
|
||||
import zeroecho.core.spec.VoidSpec;
|
||||
|
||||
/**
|
||||
@@ -96,6 +98,10 @@ public final class SignatureInteropProfiles {
|
||||
new SignatureInteropProfile("SHA512withRSA", "RSA", "RSA",
|
||||
RsaSigSpec.pkcs1v15(RsaSigSpec.Hash.SHA512),
|
||||
SignatureInteropProfile.SignatureRepresentation.IDENTITY, 0)),
|
||||
Map.entry("SHA256withRSAandMGF1",
|
||||
new SignatureInteropProfile("SHA256withRSAandMGF1", "RSA", "RSA",
|
||||
RsaSigSpec.pss(RsaSigSpec.Hash.SHA256, 32),
|
||||
SignatureInteropProfile.SignatureRepresentation.IDENTITY, 0)),
|
||||
Map.entry("SHA256withECDSA", new SignatureInteropProfile("SHA256withECDSA", "ECDSA", "ECDSA", // NOPMD
|
||||
EcdsaCurveSpec.P256,
|
||||
SignatureInteropProfile.SignatureRepresentation.ECDSA_DER_EXTERNAL_P1363_INTERNAL,
|
||||
@@ -114,6 +120,17 @@ public final class SignatureInteropProfiles {
|
||||
Map.entry("Ed448", new SignatureInteropProfile("Ed448", "Ed448", "Ed448", VoidSpec.INSTANCE,
|
||||
SignatureInteropProfile.SignatureRepresentation.IDENTITY, 0)));
|
||||
|
||||
private static final Map<String, SignatureInteropProfile> CANONICAL_PROFILES = Map.ofEntries(
|
||||
canonical(BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256, "SHA256withRSA"),
|
||||
canonical(BootstrapAlgorithmIdentities.RSA_PKCS1_SHA384, "SHA384withRSA"),
|
||||
canonical(BootstrapAlgorithmIdentities.RSA_PKCS1_SHA512, "SHA512withRSA"),
|
||||
canonical(BootstrapAlgorithmIdentities.RSA_PSS_SHA256, "SHA256withRSAandMGF1"),
|
||||
canonical(BootstrapAlgorithmIdentities.ECDSA_SHA256, "SHA256withECDSA"),
|
||||
canonical(BootstrapAlgorithmIdentities.ECDSA_SHA384, "SHA384withECDSA"),
|
||||
canonical(BootstrapAlgorithmIdentities.ECDSA_SHA512, "SHA512withECDSA"),
|
||||
canonical(BootstrapAlgorithmIdentities.ED25519_SIGNATURE, "Ed25519"),
|
||||
canonical(BootstrapAlgorithmIdentities.ED448_SIGNATURE, "Ed448"));
|
||||
|
||||
private SignatureInteropProfiles() {
|
||||
}
|
||||
|
||||
@@ -129,7 +146,8 @@ public final class SignatureInteropProfiles {
|
||||
if (algorithmId.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.ofNullable(PROFILES.get(algorithmId));
|
||||
SignatureInteropProfile profile = CANONICAL_PROFILES.get(algorithmId);
|
||||
return Optional.ofNullable(profile == null ? PROFILES.get(algorithmId) : profile);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,7 +157,9 @@ public final class SignatureInteropProfiles {
|
||||
* @return immutable set of supported standard signature names
|
||||
*/
|
||||
public static Set<String> algorithmIds() {
|
||||
return PROFILES.keySet();
|
||||
Set<String> identifiers = new java.util.LinkedHashSet<>(PROFILES.keySet());
|
||||
identifiers.addAll(CANONICAL_PROFILES.keySet());
|
||||
return Set.copyOf(identifiers);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -164,4 +184,8 @@ public final class SignatureInteropProfiles {
|
||||
}
|
||||
return algorithmId;
|
||||
}
|
||||
|
||||
private static Map.Entry<String, SignatureInteropProfile> canonical(AlgorithmIdentity identity, String alias) {
|
||||
return Map.entry(identity.canonicalForm(), PROFILES.get(alias));
|
||||
}
|
||||
}
|
||||
|
||||
73
lib/src/main/java/zeroecho/core/io/CancellationSignal.java
Normal file
73
lib/src/main/java/zeroecho/core/io/CancellationSignal.java
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
86
lib/src/main/java/zeroecho/core/io/ContentDigests.java
Normal file
86
lib/src/main/java/zeroecho/core/io/ContentDigests.java
Normal 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());
|
||||
}
|
||||
}
|
||||
161
lib/src/main/java/zeroecho/core/io/ContentSlice.java
Normal file
161
lib/src/main/java/zeroecho/core/io/ContentSlice.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
107
lib/src/main/java/zeroecho/core/io/ImmutableByteContent.java
Normal file
107
lib/src/main/java/zeroecho/core/io/ImmutableByteContent.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
75
lib/src/main/java/zeroecho/core/io/OneShotContent.java
Normal file
75
lib/src/main/java/zeroecho/core/io/OneShotContent.java
Normal 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;
|
||||
}
|
||||
97
lib/src/main/java/zeroecho/core/io/RepeatableContent.java
Normal file
97
lib/src/main/java/zeroecho/core/io/RepeatableContent.java
Normal 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;
|
||||
}
|
||||
589
lib/src/main/java/zeroecho/core/spec/AlgorithmIdentity.java
Normal file
589
lib/src/main/java/zeroecho/core/spec/AlgorithmIdentity.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
79
lib/src/main/java/zeroecho/core/spec/AlgorithmSuite.java
Normal file
79
lib/src/main/java/zeroecho/core/spec/AlgorithmSuite.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,11 +33,11 @@
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.api.ca;
|
||||
|
||||
import zeroecho.pki.api.EncodedObject;
|
||||
import zeroecho.pki.api.FormatId;
|
||||
import zeroecho.pki.api.KeyRef;
|
||||
import zeroecho.pki.api.SubjectRef;
|
||||
import zeroecho.pki.api.attr.AttributeSet;
|
||||
import zeroecho.pki.api.content.DurableContentReference;
|
||||
|
||||
/**
|
||||
* Command to import an existing root CA credential into PKI inventory.
|
||||
@@ -55,7 +55,7 @@ import zeroecho.pki.api.attr.AttributeSet;
|
||||
* @param attributes universal attributes (may be empty but not null)
|
||||
*/
|
||||
public record CaImportCommand(FormatId formatId, SubjectRef subjectRef, String profileId, KeyRef keyRef,
|
||||
EncodedObject existingCaCredential, AttributeSet attributes) {
|
||||
DurableContentReference existingCaCredential, AttributeSet attributes) {
|
||||
|
||||
/**
|
||||
* Creates a CA import command.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
108
pki/src/main/java/zeroecho/pki/api/content/ResourceLimit.java
Normal file
108
pki/src/main/java/zeroecho/pki/api/content/ResourceLimit.java
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,13 +33,13 @@
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.api.credential;
|
||||
|
||||
import zeroecho.pki.api.EncodedObject;
|
||||
import zeroecho.pki.api.FormatId;
|
||||
import zeroecho.pki.api.IssuerRef;
|
||||
import zeroecho.pki.api.PkiId;
|
||||
import zeroecho.pki.api.SubjectRef;
|
||||
import zeroecho.pki.api.Validity;
|
||||
import zeroecho.pki.api.attr.AttributeSet;
|
||||
import zeroecho.pki.api.content.DurableContentReference;
|
||||
|
||||
/**
|
||||
* Issued credential with mandatory core metadata and universal attributes.
|
||||
@@ -70,12 +70,12 @@ import zeroecho.pki.api.attr.AttributeSet;
|
||||
* current revocation state and evaluation time are
|
||||
* external runtime inputs. Security-sensitive callers
|
||||
* must use {@link EffectiveCredentialStatusResolver}.
|
||||
* @param encoded encoded credential bytes
|
||||
* @param content immutable store-owned credential content
|
||||
* @param attributes universal attribute set
|
||||
*/
|
||||
public record Credential(PkiId credentialId, FormatId formatId, IssuerRef issuerRef, SubjectRef subjectRef,
|
||||
Validity validity, String serialOrUniqueId, PkiId publicKeyId, CredentialProfileBinding profileBinding,
|
||||
CredentialStatus status, EncodedObject encoded, AttributeSet attributes) {
|
||||
CredentialStatus status, DurableContentReference content, AttributeSet attributes) {
|
||||
|
||||
/**
|
||||
* Creates a credential record.
|
||||
@@ -110,8 +110,8 @@ public record Credential(PkiId credentialId, FormatId formatId, IssuerRef issuer
|
||||
if (status == null) {
|
||||
throw new IllegalArgumentException("status must not be null");
|
||||
}
|
||||
if (encoded == null) {
|
||||
throw new IllegalArgumentException("encoded must not be null");
|
||||
if (content == null) {
|
||||
throw new IllegalArgumentException("content must not be null");
|
||||
}
|
||||
if (attributes == null) {
|
||||
throw new IllegalArgumentException("attributes must not be null");
|
||||
|
||||
@@ -35,7 +35,7 @@ package zeroecho.pki.api.credential;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import zeroecho.pki.api.EncodedObject;
|
||||
import zeroecho.pki.api.content.DurableContentReference;
|
||||
|
||||
/**
|
||||
* Bundle of a primary credential and supporting objects.
|
||||
@@ -49,7 +49,7 @@ import zeroecho.pki.api.EncodedObject;
|
||||
* @param credential primary credential
|
||||
* @param supportingObjects supporting artifacts (framework-defined ordering)
|
||||
*/
|
||||
public record CredentialBundle(Credential credential, List<EncodedObject> supportingObjects) {
|
||||
public record CredentialBundle(Credential credential, List<DurableContentReference> supportingObjects) {
|
||||
|
||||
/**
|
||||
* Creates a bundle.
|
||||
|
||||
@@ -36,10 +36,10 @@ package zeroecho.pki.api.status;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
import zeroecho.pki.api.EncodedObject;
|
||||
import zeroecho.pki.api.FormatId;
|
||||
import zeroecho.pki.api.PkiId;
|
||||
import zeroecho.pki.api.attr.AttributeSet;
|
||||
import zeroecho.pki.api.content.DurableContentReference;
|
||||
|
||||
/**
|
||||
* Generated status object used for revocation distribution.
|
||||
@@ -57,12 +57,12 @@ import zeroecho.pki.api.attr.AttributeSet;
|
||||
* @param type status object type
|
||||
* @param thisUpdate time of issuance/publication baseline
|
||||
* @param nextUpdate optional next update timestamp
|
||||
* @param encoded encoded payload
|
||||
* @param content immutable store-owned encoded content reference
|
||||
* @param attributes universal attributes describing the object (must not
|
||||
* contain secrets)
|
||||
*/
|
||||
public record StatusObject(PkiId statusObjectId, FormatId formatId, PkiId issuerCaId, StatusObjectType type,
|
||||
Instant thisUpdate, Optional<Instant> nextUpdate, EncodedObject encoded, AttributeSet attributes) {
|
||||
Instant thisUpdate, Optional<Instant> nextUpdate, DurableContentReference content, AttributeSet attributes) {
|
||||
|
||||
/**
|
||||
* Creates a status object.
|
||||
@@ -89,8 +89,8 @@ public record StatusObject(PkiId statusObjectId, FormatId formatId, PkiId issuer
|
||||
if (nextUpdate == null) {
|
||||
throw new IllegalArgumentException("nextUpdate must not be null");
|
||||
}
|
||||
if (encoded == null) {
|
||||
throw new IllegalArgumentException("encoded must not be null");
|
||||
if (content == null) {
|
||||
throw new IllegalArgumentException("content must not be null");
|
||||
}
|
||||
if (attributes == null) {
|
||||
throw new IllegalArgumentException("attributes must not be null");
|
||||
|
||||
@@ -80,12 +80,12 @@ final class CaCertificateProfileValidator {
|
||||
ActiveCertificateProfile activeProfile, CertificateProfileKind expectedKind, FormatId formatId,
|
||||
PkiId issuerCaId, PkiId subjectCaId, SubjectRef requestedSubject, EncodedObject exactPublicKey,
|
||||
Optional<Validity> requestedValidity, Instant evaluationTime, Optional<Instant> issuerNotAfter,
|
||||
BigInteger serial) {
|
||||
BigInteger serial, zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot authority) {
|
||||
CertificateProfileDefinition definition = activeProfile.definition();
|
||||
requireProfileShape(definition, expectedKind, formatId);
|
||||
List<SubjectRdn> approvedSubject = validateSubject(requestedSubject, definition);
|
||||
return validateApprovedSubject(operation, activeProfile, expectedKind, formatId, issuerCaId, subjectCaId,
|
||||
approvedSubject, exactPublicKey, requestedValidity, evaluationTime, issuerNotAfter, serial);
|
||||
approvedSubject, exactPublicKey, requestedValidity, evaluationTime, issuerNotAfter, serial, authority);
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -97,12 +97,13 @@ final class CaCertificateProfileValidator {
|
||||
ValidatedCaCertificateRequest.Operation operation, ActiveCertificateProfile activeProfile,
|
||||
CertificateProfileKind expectedKind, FormatId formatId, PkiId issuerCaId, PkiId subjectCaId,
|
||||
List<SubjectRdn> approvedSubject, EncodedObject exactPublicKey, Optional<Validity> requestedValidity,
|
||||
Instant evaluationTime, Optional<Instant> issuerNotAfter, BigInteger serial) {
|
||||
Instant evaluationTime, Optional<Instant> issuerNotAfter, BigInteger serial,
|
||||
zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot authority) {
|
||||
CertificateProfileDefinition definition = activeProfile.definition();
|
||||
requireProfileShape(definition, expectedKind, formatId);
|
||||
List<SubjectRdn> subjectSnapshot = requireApprovedSubject(approvedSubject);
|
||||
CertificateProfileValidator.requireSubjectKeyAllowed(exactPublicKey,
|
||||
definition.caPolicy().allowedSubjectKeyAlgorithmIds());
|
||||
definition.caPolicy().allowedSubjectKeyAlgorithmIds(), authority);
|
||||
Validity validity = approvedValidity(requestedValidity, definition, evaluationTime, issuerNotAfter,
|
||||
operation == ValidatedCaCertificateRequest.Operation.IMPORT_ROOT);
|
||||
SubjectRef canonicalSubject = new SubjectRef(BcX509ProfileSupport.subject(subjectSnapshot).toString());
|
||||
|
||||
@@ -33,7 +33,6 @@
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.impl.core;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.security.MessageDigest;
|
||||
@@ -49,24 +48,30 @@ import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
|
||||
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
|
||||
import org.bouncycastle.cert.X509CertificateHolder;
|
||||
import org.bouncycastle.operator.ContentSigner;
|
||||
import org.bouncycastle.operator.ContentVerifier;
|
||||
import org.bouncycastle.operator.DefaultSignatureAlgorithmIdentifierFinder;
|
||||
import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder;
|
||||
|
||||
import zeroecho.core.spec.AlgorithmIdentity;
|
||||
import zeroecho.core.io.ImmutableByteContent;
|
||||
import zeroecho.core.spi.AlgorithmExecutionCapability;
|
||||
import zeroecho.pki.api.EncodedObject;
|
||||
import zeroecho.pki.api.Encoding;
|
||||
import zeroecho.pki.api.FormatId;
|
||||
import zeroecho.pki.api.KeyRef;
|
||||
import zeroecho.pki.api.PkiException;
|
||||
import zeroecho.pki.api.PkiId;
|
||||
import zeroecho.pki.api.content.DurableContentReference;
|
||||
import zeroecho.pki.api.audit.AccessContext;
|
||||
import zeroecho.pki.api.audit.AuditEvent;
|
||||
import zeroecho.pki.api.audit.Principal;
|
||||
import zeroecho.pki.api.audit.Purpose;
|
||||
import zeroecho.pki.impl.core.async.PkiSigningBus;
|
||||
import zeroecho.pki.impl.framework.x509.X509AlgorithmRole;
|
||||
import zeroecho.pki.impl.framework.x509.X509ExecutionPlan;
|
||||
import zeroecho.pki.impl.framework.x509.bc.BcX509VerificationExecutor;
|
||||
import zeroecho.pki.impl.framework.x509.bc.BcX509AlgorithmAdapter;
|
||||
import zeroecho.pki.spi.audit.AuditSink;
|
||||
import zeroecho.pki.util.async.AsyncState;
|
||||
import zeroecho.pki.util.async.AsyncStatus;
|
||||
import zeroecho.pki.spi.store.ContentSink;
|
||||
|
||||
/**
|
||||
* Internal fail-closed proof gate for CA signing keys.
|
||||
@@ -85,20 +90,27 @@ final class CaProofGate {
|
||||
private final PublicKeyInfoResolver publicKeyResolver;
|
||||
private final PkiSigningBus signingBus;
|
||||
private final AuditSink auditSink;
|
||||
private final String signatureAlgorithmId;
|
||||
private final AlgorithmIdentity signatureIdentity;
|
||||
private final Duration signingTtl;
|
||||
|
||||
/* default */ CaProofGate(PublicKeyInfoResolver publicKeyResolver, PkiSigningBus signingBus, AuditSink auditSink,
|
||||
String signatureAlgorithmId, Duration signingTtl) {
|
||||
this(publicKeyResolver, signingBus, auditSink,
|
||||
signingBus.authority().resolveIdentity(Objects.requireNonNull(signatureAlgorithmId,
|
||||
"signatureAlgorithmId")), signingTtl);
|
||||
}
|
||||
|
||||
/* default */ CaProofGate(PublicKeyInfoResolver publicKeyResolver, PkiSigningBus signingBus, AuditSink auditSink,
|
||||
AlgorithmIdentity signatureIdentity, Duration signingTtl) {
|
||||
this.publicKeyResolver = Objects.requireNonNull(publicKeyResolver, "publicKeyResolver");
|
||||
this.signingBus = Objects.requireNonNull(signingBus, "signingBus");
|
||||
this.auditSink = Objects.requireNonNull(auditSink, "auditSink");
|
||||
this.signatureAlgorithmId = Objects.requireNonNull(signatureAlgorithmId, "signatureAlgorithmId");
|
||||
this.signatureIdentity = Objects.requireNonNull(signatureIdentity, "signatureIdentity");
|
||||
this.signingTtl = Objects.requireNonNull(signingTtl, "signingTtl");
|
||||
}
|
||||
|
||||
/* default */ ContentSigner signer(ManagedKeyProof proof) {
|
||||
return new BusBackedContentSigner(signingBus, proof.keyRef(), signatureAlgorithmId, signingTtl);
|
||||
return new BusBackedContentSigner(signingBus, proof.keyRef(), signatureIdentity, signingTtl);
|
||||
}
|
||||
|
||||
/* default */ SubjectPublicKeyInfo parseRootSpki(EncodedObject spki, FormatId formatId) {
|
||||
@@ -115,8 +127,24 @@ final class CaProofGate {
|
||||
}
|
||||
try {
|
||||
byte[] embeddedSpki = certificate.getSubjectPublicKeyInfo().getEncoded();
|
||||
return MessageDigest.isEqual(expectedSpki.bytes(), embeddedSpki) && certificate.isSignatureValid(
|
||||
new JcaContentVerifierProviderBuilder().build(certificate.getSubjectPublicKeyInfo()));
|
||||
BcX509AlgorithmAdapter adapter = new BcX509AlgorithmAdapter(signingBus.authority().bindings());
|
||||
AlgorithmIdentity outer = adapter.decode(certificate.getSignatureAlgorithm(),
|
||||
X509AlgorithmRole.SIGNATURE_ALGORITHM);
|
||||
AlgorithmIdentity inner = adapter.decode(
|
||||
certificate.toASN1Structure().getTBSCertificate().getSignature(),
|
||||
X509AlgorithmRole.SIGNATURE_ALGORITHM);
|
||||
AlgorithmIdentity key = adapter.decode(certificate.getSubjectPublicKeyInfo().getAlgorithm(),
|
||||
X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM);
|
||||
X509ExecutionPlan<BcX509VerificationExecutor> plan = signingBus.authority().plan(outer, key,
|
||||
AlgorithmExecutionCapability.Direction.VERIFY,
|
||||
Optional.of(BcX509VerificationExecutor.IMPLEMENTATION_ID), "root-proof",
|
||||
BcX509VerificationExecutor.class);
|
||||
BcX509VerificationExecutor executor = plan.executor();
|
||||
boolean verified = executor.verify(signingBus.authority(), plan, certificate.getSubjectPublicKeyInfo(),
|
||||
certificate.getSignatureAlgorithm(),
|
||||
new ImmutableByteContent(certificate.toASN1Structure().getTBSCertificate().getEncoded()),
|
||||
certificate.getSignature());
|
||||
return MessageDigest.isEqual(expectedSpki.bytes(), embeddedSpki) && verified && outer.equals(inner);
|
||||
} catch (Exception ex) {
|
||||
return false;
|
||||
}
|
||||
@@ -174,7 +202,7 @@ final class CaProofGate {
|
||||
}
|
||||
|
||||
private byte[] signManagedKeyChallenge(KeyRef keyRef, byte[] challenge) {
|
||||
ContentSigner contentSigner = new BusBackedContentSigner(signingBus, keyRef, signatureAlgorithmId, signingTtl);
|
||||
ContentSigner contentSigner = new BusBackedContentSigner(signingBus, keyRef, signatureIdentity, signingTtl);
|
||||
try {
|
||||
contentSigner.getOutputStream().write(challenge);
|
||||
} catch (IOException ex) {
|
||||
@@ -186,10 +214,17 @@ final class CaProofGate {
|
||||
private boolean verifyChallenge(byte[] spkiDer, byte[] challenge, byte[] signature) {
|
||||
try {
|
||||
SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(spkiDer);
|
||||
ContentVerifier verifier = new JcaContentVerifierProviderBuilder().build(spki)
|
||||
.get(new DefaultSignatureAlgorithmIdentifierFinder().find(signatureAlgorithmId));
|
||||
verifier.getOutputStream().write(challenge);
|
||||
return verifier.verify(signature);
|
||||
BcX509AlgorithmAdapter adapter = new BcX509AlgorithmAdapter(signingBus.authority().bindings());
|
||||
AlgorithmIdentity keyIdentity = adapter.decode(spki.getAlgorithm(),
|
||||
X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM);
|
||||
X509ExecutionPlan<BcX509VerificationExecutor> plan = signingBus.authority().plan(signatureIdentity,
|
||||
keyIdentity, AlgorithmExecutionCapability.Direction.VERIFY,
|
||||
Optional.of(BcX509VerificationExecutor.IMPLEMENTATION_ID), "managed-key-proof",
|
||||
BcX509VerificationExecutor.class);
|
||||
BcX509VerificationExecutor executor = plan.executor();
|
||||
return executor.verify(signingBus.authority(), plan, spki,
|
||||
adapter.encode(signatureIdentity, X509AlgorithmRole.SIGNATURE_ALGORITHM),
|
||||
new ImmutableByteContent(challenge), signature);
|
||||
} catch (Exception ex) {
|
||||
return false;
|
||||
}
|
||||
@@ -231,21 +266,30 @@ final class CaProofGate {
|
||||
|
||||
private final PkiSigningBus bus;
|
||||
private final KeyRef keyRef;
|
||||
private final String algorithmId;
|
||||
private final AlgorithmIdentity algorithmIdentity;
|
||||
private final Duration ttl;
|
||||
private final ByteArrayOutputStream output;
|
||||
private final ContentSink sink;
|
||||
private final OutputStream output;
|
||||
|
||||
private BusBackedContentSigner(PkiSigningBus bus, KeyRef keyRef, String algorithmId, Duration ttl) {
|
||||
private BusBackedContentSigner(PkiSigningBus bus, KeyRef keyRef, AlgorithmIdentity algorithmIdentity,
|
||||
Duration ttl) {
|
||||
this.bus = bus;
|
||||
this.keyRef = keyRef;
|
||||
this.algorithmId = algorithmId;
|
||||
this.algorithmIdentity = algorithmIdentity;
|
||||
this.ttl = ttl;
|
||||
this.output = new ByteArrayOutputStream();
|
||||
this.sink = bus.beginSigningContent(Encoding.BINARY);
|
||||
try {
|
||||
this.output = sink.outputStream();
|
||||
} catch (IOException exception) {
|
||||
closeSinkPreserving(exception);
|
||||
throw new PkiException("Signing content staging failed: code=SPOOL_STORAGE_FAILED", exception);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public AlgorithmIdentifier getAlgorithmIdentifier() {
|
||||
return new DefaultSignatureAlgorithmIdentifierFinder().find(algorithmId);
|
||||
return new BcX509AlgorithmAdapter(bus.authority().bindings()).encode(algorithmIdentity,
|
||||
X509AlgorithmRole.SIGNATURE_ALGORITHM);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -255,24 +299,40 @@ final class CaProofGate {
|
||||
|
||||
@Override
|
||||
public byte[] getSignature() {
|
||||
byte[] tbs = output.toByteArray();
|
||||
DurableContentReference content;
|
||||
try {
|
||||
Principal owner = new Principal("SYSTEM", "pki");
|
||||
PkiId opId = bus.newSubmissionId();
|
||||
EncodedObject payload = new EncodedObject(Encoding.BINARY, tbs);
|
||||
AccessContext accessContext = new AccessContext(owner, new Purpose("X509_SIGN"), Optional.empty(),
|
||||
Optional.empty());
|
||||
PkiSigningBus.SignContinuation continuation = new PkiSigningBus.SignContinuation(accessContext,
|
||||
algorithmId, payload, keyRef, Encoding.BINARY, Optional.empty());
|
||||
try {
|
||||
bus.submitSign(opId, owner, keyRef, algorithmId, payload, ttl, Optional.of(continuation.encode()));
|
||||
} catch (RuntimeException failure) { // NOPMD - delete state if submission partially persisted it
|
||||
deletePreservingFailure(opId);
|
||||
throw failure;
|
||||
}
|
||||
return awaitSignature(opId);
|
||||
output.close();
|
||||
content = sink.complete();
|
||||
} catch (IOException exception) {
|
||||
closeSinkPreserving(exception);
|
||||
throw new PkiException("Signing content staging failed: code=SPOOL_STORAGE_FAILED", exception);
|
||||
}
|
||||
Principal owner = new Principal("SYSTEM", "pki");
|
||||
PkiId opId = bus.newSubmissionId();
|
||||
AccessContext accessContext = new AccessContext(owner, new Purpose("X509_SIGN"), Optional.empty(),
|
||||
Optional.empty());
|
||||
String canonicalIdentity = algorithmIdentity.canonicalForm();
|
||||
PkiSigningBus.SignContinuation continuation = new PkiSigningBus.SignContinuation(accessContext,
|
||||
canonicalIdentity, content, keyRef, Encoding.BINARY, Optional.empty());
|
||||
boolean submitted = false;
|
||||
try {
|
||||
bus.submitSign(opId, owner, keyRef, canonicalIdentity, content, ttl,
|
||||
Optional.of(continuation.encode()));
|
||||
submitted = true;
|
||||
} finally {
|
||||
Arrays.fill(tbs, (byte) 0);
|
||||
if (!submitted) {
|
||||
deletePreservingFailure(opId);
|
||||
bus.releaseContent(content);
|
||||
}
|
||||
}
|
||||
return awaitSignature(opId);
|
||||
}
|
||||
|
||||
private void closeSinkPreserving(IOException primaryFailure) {
|
||||
try {
|
||||
sink.close();
|
||||
} catch (IOException cleanupFailure) {
|
||||
primaryFailure.addSuppressed(cleanupFailure);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,12 +49,7 @@ import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
|
||||
import org.bouncycastle.asn1.DERNull;
|
||||
import org.bouncycastle.asn1.edec.EdECObjectIdentifiers;
|
||||
import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers;
|
||||
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
|
||||
import org.bouncycastle.asn1.x9.X9ObjectIdentifiers;
|
||||
|
||||
import zeroecho.pki.api.Encoding;
|
||||
import zeroecho.pki.api.PkiException;
|
||||
@@ -74,6 +69,11 @@ import zeroecho.pki.api.request.ParsedCertificationRequest;
|
||||
import zeroecho.pki.api.request.SubjectAlternativeName;
|
||||
import zeroecho.pki.api.request.SubjectRdn;
|
||||
import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes;
|
||||
import zeroecho.pki.impl.framework.x509.bc.BcX509AlgorithmAdapter;
|
||||
import zeroecho.pki.impl.framework.x509.X509AlgorithmRole;
|
||||
import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot;
|
||||
import zeroecho.core.alg.BootstrapAlgorithmIdentities;
|
||||
import zeroecho.core.spec.AlgorithmIdentity;
|
||||
import zeroecho.pki.impl.framework.x509.bc.BcX509ProfileSupport;
|
||||
|
||||
/**
|
||||
@@ -83,18 +83,20 @@ import zeroecho.pki.impl.framework.x509.bc.BcX509ProfileSupport;
|
||||
@SuppressWarnings("PMD.CyclomaticComplexity")
|
||||
final class CertificateProfileValidator {
|
||||
|
||||
private static final String EC_FAMILY = "ec";
|
||||
|
||||
private CertificateProfileValidator() {
|
||||
}
|
||||
|
||||
/* package */ static ValidatedCertificateRequest validate(VerifiedIssuanceCandidate candidate,
|
||||
CertificateProfile profile, CertificateProfileRef profileReference, Credential issuerCredential,
|
||||
Instant evaluationTime) {
|
||||
Instant evaluationTime, X509AuthoritySnapshot authority) {
|
||||
ParsedCertificationRequest request = candidate.request();
|
||||
LeafCertificatePolicy policy = profile.leafPolicy();
|
||||
requireCanonicalRequestAttributes(request);
|
||||
List<SubjectRdn> approvedSubject = validateSubject(request, profile);
|
||||
List<SubjectAlternativeName> approvedSans = validateSans(request, policy, approvedSubject.isEmpty());
|
||||
requireSubjectKeyAllowed(candidate.exactPublicKey(), policy.allowedSubjectKeyAlgorithmIds());
|
||||
requireSubjectKeyAllowed(candidate.exactPublicKey(), policy.allowedSubjectKeyAlgorithmIds(), authority);
|
||||
Validity validity = approvedValidity(candidate, request, profile, issuerCredential, evaluationTime);
|
||||
boolean sanCritical = approvedSubject.isEmpty()
|
||||
|| policy.subjectAlternativeNamePolicy().criticalWithNonemptySubject();
|
||||
@@ -225,15 +227,24 @@ final class CertificateProfileValidator {
|
||||
// The public exception deliberately redacts ASN.1 parser details.
|
||||
@SuppressWarnings({ "PMD.PreserveStackTrace", "PMD.AvoidRethrowingException" })
|
||||
/* package */ static void requireSubjectKeyAllowed(zeroecho.pki.api.EncodedObject exactPublicKey,
|
||||
Set<String> allowedAlgorithms) {
|
||||
Set<String> allowedAlgorithms, X509AuthoritySnapshot authority) {
|
||||
if (exactPublicKey.encoding() != Encoding.DER) {
|
||||
throw reject("SUBJECT_KEY_UNSUPPORTED");
|
||||
}
|
||||
byte[] encoded = exactPublicKey.bytes();
|
||||
try {
|
||||
SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(encoded);
|
||||
SubjectKeyAlgorithm algorithm = subjectKeyAlgorithm(spki.getAlgorithm().getAlgorithm());
|
||||
requireSupportedParameters(spki, algorithm);
|
||||
AlgorithmIdentity identity;
|
||||
try {
|
||||
identity = new BcX509AlgorithmAdapter(authority.bindings()).decode(spki.getAlgorithm(),
|
||||
X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM);
|
||||
} catch (IllegalArgumentException invalidBinding) {
|
||||
boolean knownOid = authority.bindings().rules().stream()
|
||||
.filter(rule -> rule.role() == X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM)
|
||||
.anyMatch(rule -> rule.oid().equals(spki.getAlgorithm().getAlgorithm().getId()));
|
||||
throw reject(knownOid ? "SUBJECT_KEY_PARAMETERS_UNSUPPORTED" : "SUBJECT_KEY_ALGORITHM_UNKNOWN");
|
||||
}
|
||||
SubjectKeyAlgorithm algorithm = subjectKeyAlgorithm(identity);
|
||||
if (!allowedAlgorithms.contains(algorithm.profileId())) {
|
||||
throw reject("SUBJECT_KEY_ALGORITHM_FORBIDDEN");
|
||||
}
|
||||
@@ -258,35 +269,22 @@ final class CertificateProfileValidator {
|
||||
}
|
||||
}
|
||||
|
||||
private static SubjectKeyAlgorithm subjectKeyAlgorithm(ASN1ObjectIdentifier oid) {
|
||||
if (PKCSObjectIdentifiers.rsaEncryption.equals(oid)) {
|
||||
private static SubjectKeyAlgorithm subjectKeyAlgorithm(AlgorithmIdentity identity) {
|
||||
if (identity.equals(BootstrapAlgorithmIdentities.RSA_PUBLIC_KEY)) {
|
||||
return new SubjectKeyAlgorithm("RSA", "RSA");
|
||||
}
|
||||
if (X9ObjectIdentifiers.id_ecPublicKey.equals(oid)) {
|
||||
if (EC_FAMILY.equals(identity.family().name())) {
|
||||
return new SubjectKeyAlgorithm("ECDSA", "EC");
|
||||
}
|
||||
if (EdECObjectIdentifiers.id_Ed25519.equals(oid)) {
|
||||
if (identity.equals(BootstrapAlgorithmIdentities.ED25519_PUBLIC_KEY)) {
|
||||
return new SubjectKeyAlgorithm("Ed25519", "Ed25519");
|
||||
}
|
||||
if (EdECObjectIdentifiers.id_Ed448.equals(oid)) {
|
||||
if (identity.equals(BootstrapAlgorithmIdentities.ED448_PUBLIC_KEY)) {
|
||||
return new SubjectKeyAlgorithm("Ed448", "Ed448");
|
||||
}
|
||||
throw reject("SUBJECT_KEY_ALGORITHM_UNKNOWN");
|
||||
}
|
||||
|
||||
private static void requireSupportedParameters(SubjectPublicKeyInfo spki, SubjectKeyAlgorithm algorithm) {
|
||||
org.bouncycastle.asn1.ASN1Encodable parameters = spki.getAlgorithm().getParameters();
|
||||
boolean supported = switch (algorithm.profileId()) {
|
||||
case "RSA" -> DERNull.INSTANCE.equals(parameters);
|
||||
case "ECDSA" -> parameters instanceof ASN1ObjectIdentifier;
|
||||
case "Ed25519", "Ed448" -> parameters == null;
|
||||
default -> false;
|
||||
};
|
||||
if (!supported) {
|
||||
throw reject("SUBJECT_KEY_PARAMETERS_UNSUPPORTED");
|
||||
}
|
||||
}
|
||||
|
||||
// The public exception deliberately redacts temporal arithmetic details.
|
||||
@SuppressWarnings("PMD.PreserveStackTrace")
|
||||
private static Validity approvedValidity(VerifiedIssuanceCandidate candidate, ParsedCertificationRequest request,
|
||||
|
||||
111
pki/src/main/java/zeroecho/pki/impl/core/CredentialContent.java
Normal file
111
pki/src/main/java/zeroecho/pki/impl/core/CredentialContent.java
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,6 @@ package zeroecho.pki.impl.core;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import zeroecho.pki.api.EncodedObject;
|
||||
import zeroecho.pki.api.attr.AttributeId;
|
||||
import zeroecho.pki.api.attr.AttributeSet;
|
||||
import zeroecho.pki.api.attr.AttributeValue;
|
||||
@@ -54,18 +53,13 @@ final class CredentialSnapshots {
|
||||
|
||||
/* default */ static CredentialBundle copy(CredentialBundle source) {
|
||||
Credential credential = copy(source.credential());
|
||||
List<EncodedObject> supporting = source.supportingObjects().stream().map(CredentialSnapshots::copy).toList();
|
||||
return new CredentialBundle(credential, supporting);
|
||||
return new CredentialBundle(credential, List.copyOf(source.supportingObjects()));
|
||||
}
|
||||
|
||||
/* default */ static Credential copy(Credential source) {
|
||||
return new Credential(source.credentialId(), source.formatId(), source.issuerRef(), source.subjectRef(),
|
||||
source.validity(), source.serialOrUniqueId(), source.publicKeyId(), source.profileBinding(),
|
||||
source.status(), copy(source.encoded()), copy(source.attributes()));
|
||||
}
|
||||
|
||||
private static EncodedObject copy(EncodedObject source) {
|
||||
return new EncodedObject(source.encoding(), source.bytes().clone());
|
||||
source.status(), source.content(), copy(source.attributes()));
|
||||
}
|
||||
|
||||
private static AttributeSet copy(AttributeSet source) {
|
||||
|
||||
@@ -60,6 +60,8 @@ import org.bouncycastle.operator.ContentSigner;
|
||||
import org.bouncycastle.operator.OperatorCreationException;
|
||||
import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder;
|
||||
|
||||
import zeroecho.core.spec.AlgorithmIdentity;
|
||||
import zeroecho.core.spi.AlgorithmExecutionCapability;
|
||||
import zeroecho.pki.api.CaService;
|
||||
import zeroecho.pki.api.EncodedObject;
|
||||
import zeroecho.pki.api.Encoding;
|
||||
@@ -91,6 +93,8 @@ import zeroecho.pki.api.credential.EffectiveCredentialStatusResolver;
|
||||
import zeroecho.pki.api.profile.ActiveCertificateProfile;
|
||||
import zeroecho.pki.api.profile.CertificateProfileKind;
|
||||
import zeroecho.pki.impl.core.async.PkiSigningBus;
|
||||
import zeroecho.pki.impl.framework.x509.X509ExecutionPlan;
|
||||
import zeroecho.pki.spi.crypto.SignatureWorkflow;
|
||||
import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
|
||||
import zeroecho.pki.spi.audit.AuditSink;
|
||||
import zeroecho.pki.spi.framework.CredentialFramework;
|
||||
@@ -167,10 +171,12 @@ public final class DefaultCaService implements CaService {
|
||||
private final CredentialFramework framework;
|
||||
private final CredentialIssuerBackend issuerBackend;
|
||||
private final CaProofGate proofGate;
|
||||
private final zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot authority;
|
||||
private final AuditSink auditSink;
|
||||
private final EffectiveCredentialStatusResolver statusResolver;
|
||||
private final ProfileService profileService;
|
||||
private final Clock clock;
|
||||
private final AlgorithmIdentity signatureIdentity;
|
||||
|
||||
/**
|
||||
* Creates a CA service bound to a specific store, credential framework, and
|
||||
@@ -232,6 +238,7 @@ public final class DefaultCaService implements CaService {
|
||||
this.issuerBackend = Objects.requireNonNull(issuerBackend, "issuerBackend");
|
||||
Objects.requireNonNull(publicKeyResolver, "publicKeyResolver");
|
||||
Objects.requireNonNull(signingBus, "signingBus");
|
||||
this.authority = signingBus.authority();
|
||||
this.auditSink = Objects.requireNonNull(auditSink, "auditSink");
|
||||
this.statusResolver = Objects.requireNonNull(statusResolver, "statusResolver");
|
||||
this.profileService = Objects.requireNonNull(profileService, "profileService");
|
||||
@@ -242,7 +249,12 @@ public final class DefaultCaService implements CaService {
|
||||
if (signingTtl == null || signingTtl.isZero() || signingTtl.isNegative()) {
|
||||
throw new IllegalArgumentException("signingTtl must be positive");
|
||||
}
|
||||
this.proofGate = new CaProofGate(publicKeyResolver, signingBus, auditSink, signatureAlgorithmId, signingTtl);
|
||||
AlgorithmIdentity signatureIdentity = signingBus.authority().resolveIdentity(signatureAlgorithmId);
|
||||
X509ExecutionPlan<SignatureWorkflow> plan = signingBus.authority()
|
||||
.planSigning(signatureIdentity.canonicalForm(), SignatureWorkflow.class);
|
||||
signingBus.authority().authorize(plan, plan.executor(), AlgorithmExecutionCapability.Direction.SIGN);
|
||||
this.signatureIdentity = signatureIdentity;
|
||||
this.proofGate = new CaProofGate(publicKeyResolver, signingBus, auditSink, signatureIdentity, signingTtl);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -294,7 +306,7 @@ public final class DefaultCaService implements CaService {
|
||||
ValidatedCaCertificateRequest request = CaCertificateProfileValidator.validate(
|
||||
ValidatedCaCertificateRequest.Operation.CREATE_ROOT, activeProfile, CertificateProfileKind.ROOT_CA,
|
||||
command.formatId(), new PkiId("ca:pending-root"), new PkiId("ca:pending-root"), command.subjectRef(),
|
||||
spki, Optional.empty(), evaluationTime, Optional.empty(), serial);
|
||||
spki, Optional.empty(), evaluationTime, Optional.empty(), serial, authority);
|
||||
SubjectPublicKeyInfo rootPublicKeyInfo = proofGate.parseRootSpki(spki, command.formatId());
|
||||
CaProofGate.ManagedKeyProof proof = proofGate.proveManagedKey(keyRef, command.formatId(), CREATE_ROOT_REJECTED,
|
||||
Optional.empty());
|
||||
@@ -342,7 +354,7 @@ public final class DefaultCaService implements CaService {
|
||||
|
||||
Credential credential = new Credential(credId, command.formatId(), new IssuerRef(caId), request.subjectRef(),
|
||||
validity, serial.toString(), publicKeyId, new CaProfileBinding(request.profileReference()),
|
||||
CredentialStatus.ISSUED, new EncodedObject(Encoding.DER, certDer),
|
||||
CredentialStatus.ISSUED, CredentialContent.stage(store, certDer),
|
||||
SimpleAttributeSet.builder().build());
|
||||
CredentialProfileBindings.requireCaBinding(credential.profileBinding(), request.profileReference());
|
||||
|
||||
@@ -394,7 +406,8 @@ public final class DefaultCaService implements CaService {
|
||||
throw new PkiException("Only DER import supported by this runtime");
|
||||
}
|
||||
|
||||
byte[] certDer = command.existingCaCredential().bytes().clone();
|
||||
CredentialContent.validateCertificate(store, command.existingCaCredential(), authority, Optional.empty());
|
||||
byte[] certDer = CredentialContent.materializeForBc(store, command.existingCaCredential());
|
||||
X509CertificateHolder holder;
|
||||
try {
|
||||
holder = new X509CertificateHolder(certDer);
|
||||
@@ -423,10 +436,10 @@ public final class DefaultCaService implements CaService {
|
||||
ValidatedCaCertificateRequest request = CaCertificateProfileValidator.validate(
|
||||
ValidatedCaCertificateRequest.Operation.IMPORT_ROOT, activeProfile, CertificateProfileKind.ROOT_CA,
|
||||
command.formatId(), caId, caId, command.subjectRef(), spki, Optional.of(validity), evaluationTime,
|
||||
Optional.empty(), serial);
|
||||
Optional.empty(), serial, authority);
|
||||
Credential credential = new Credential(credId, command.formatId(), new IssuerRef(caId), request.subjectRef(),
|
||||
validity, serial.toString(), publicKeyId, new CaProfileBinding(request.profileReference()),
|
||||
CredentialStatus.ISSUED, new EncodedObject(Encoding.DER, certDer),
|
||||
CredentialStatus.ISSUED, command.existingCaCredential(),
|
||||
SimpleAttributeSet.builder().build());
|
||||
CredentialProfileBindings.requireCaBinding(credential.profileBinding(), request.profileReference());
|
||||
requireCaCertificateMatches(credential, credential, request, caId, IMPORT_ROOT_REJECTED,
|
||||
@@ -511,7 +524,7 @@ public final class DefaultCaService implements CaService {
|
||||
ValidatedCaCertificateRequest.Operation.CREATE_INTERMEDIATE, activeProfile,
|
||||
CertificateProfileKind.INTERMEDIATE_CA, command.formatId(), command.issuerCaId(), caId, approvedSubject,
|
||||
subjectSpki, Optional.empty(), evaluationTime, Optional.of(issuerCredential.validity().notAfter()),
|
||||
CertificateSerialAllocator.allocate());
|
||||
CertificateSerialAllocator.allocate(), authority);
|
||||
CaProofGate.ManagedKeyProof subjectProof = proofGate.proveManagedKey(command.keyRef().get(), command.formatId(),
|
||||
CREATE_INT_REJECTED, Optional.of(caId));
|
||||
requireSameManagedKey(subjectSpki, subjectProof.exactPublicKey(), CREATE_INT_REJECTED, command.formatId(),
|
||||
@@ -520,7 +533,7 @@ public final class DefaultCaService implements CaService {
|
||||
|
||||
Credential backendCredential;
|
||||
try {
|
||||
backendCredential = issuerBackend.issueIntermediateCertificate(issue, issuerCredential.encoded(),
|
||||
backendCredential = issuerBackend.issueIntermediateCertificate(issue, issuerCredential.content(),
|
||||
issuer.issuerKeyRef());
|
||||
} catch (RuntimeException ex) { // NOPMD - reject malformed or mutable framework output
|
||||
throw proofGate.rejection(CREATE_INT_REJECTED, command.formatId(), Optional.of(caId),
|
||||
@@ -602,7 +615,8 @@ public final class DefaultCaService implements CaService {
|
||||
ValidatedCaCertificateRequest.Operation.ISSUE_INTERMEDIATE, activeProfile,
|
||||
CertificateProfileKind.INTERMEDIATE_CA, command.formatId(), command.issuerCaId(), command.subjectCaId(),
|
||||
approvedSubject, subjectSpki, command.requestedValidity(), evaluationTime,
|
||||
Optional.of(issuerCredential.validity().notAfter()), CertificateSerialAllocator.allocate());
|
||||
Optional.of(issuerCredential.validity().notAfter()), CertificateSerialAllocator.allocate(),
|
||||
authority);
|
||||
CaProofGate.ManagedKeyProof subjectProof = proofGate.proveManagedKey(subject.issuerKeyRef(), command.formatId(),
|
||||
ISSUE_INT_REJECTED, Optional.of(subject.caId()));
|
||||
requireSameManagedKey(subjectSpki, subjectProof.exactPublicKey(), ISSUE_INT_REJECTED, command.formatId(),
|
||||
@@ -612,7 +626,7 @@ public final class DefaultCaService implements CaService {
|
||||
|
||||
Credential backendCredential;
|
||||
try {
|
||||
backendCredential = issuerBackend.issueIntermediateCertificate(gated, issuerCredential.encoded(),
|
||||
backendCredential = issuerBackend.issueIntermediateCertificate(gated, issuerCredential.content(),
|
||||
issuer.issuerKeyRef());
|
||||
} catch (RuntimeException ex) { // NOPMD - reject malformed or mutable framework output
|
||||
throw proofGate.rejection(ISSUE_INT_REJECTED, command.formatId(), Optional.of(subject.caId()),
|
||||
@@ -833,10 +847,12 @@ public final class DefaultCaService implements CaService {
|
||||
private void requireIssuerKeyBinding(CaRecord issuer, Credential credential, FormatId formatId, String action,
|
||||
Optional<PkiId> objectId) {
|
||||
try {
|
||||
if (credential.encoded().encoding() != Encoding.DER) {
|
||||
if (credential.content().encoding() != Encoding.DER) {
|
||||
throw proofGate.rejection(action, formatId, objectId, "ISSUER_CREDENTIAL_INVALID");
|
||||
}
|
||||
X509CertificateHolder holder = new X509CertificateHolder(credential.encoded().bytes());
|
||||
CredentialContent.validateCertificate(store, credential.content(), authority, Optional.empty());
|
||||
X509CertificateHolder holder = new X509CertificateHolder(CredentialContent.materializeForBc(store,
|
||||
credential.content()));
|
||||
CaProofGate.ManagedKeyProof proof = proofGate.proveManagedKey(issuer.issuerKeyRef(), formatId, action,
|
||||
objectId);
|
||||
if (!MessageDigest.isEqual(proof.exactPublicKey().bytes(), holder.getSubjectPublicKeyInfo().getEncoded())) {
|
||||
@@ -855,8 +871,13 @@ public final class DefaultCaService implements CaService {
|
||||
if (!matchesCaCredentialEnvelope(credential, request, subjectCaId)) {
|
||||
throw proofGate.rejection(action, framework.formatId(), Optional.of(subjectCaId), mismatchCode);
|
||||
}
|
||||
X509CertificateHolder holder = new X509CertificateHolder(credential.encoded().bytes());
|
||||
X509CertificateHolder issuerHolder = new X509CertificateHolder(issuerCredential.encoded().bytes());
|
||||
CredentialContent.validateCertificate(store, credential.content(), authority,
|
||||
Optional.of(signatureIdentity));
|
||||
CredentialContent.validateCertificate(store, issuerCredential.content(), authority, Optional.empty());
|
||||
byte[] credentialDer = CredentialContent.materializeForBc(store, credential.content());
|
||||
X509CertificateHolder holder = new X509CertificateHolder(credentialDer);
|
||||
X509CertificateHolder issuerHolder = new X509CertificateHolder(CredentialContent.materializeForBc(store,
|
||||
issuerCredential.content()));
|
||||
byte[] actualSpki = holder.getSubjectPublicKeyInfo().getEncoded();
|
||||
Extension constraintsExtension = holder.getExtension(Extension.basicConstraints);
|
||||
BasicConstraints constraints = constraintsExtension == null ? null
|
||||
@@ -872,7 +893,7 @@ public final class DefaultCaService implements CaService {
|
||||
actualSpki)
|
||||
|| !matchesCaCertificatePolicy(holder, request, constraintsExtension, constraints,
|
||||
keyUsageExtension, keyUsage)
|
||||
|| !matchesCaCredentialMetadata(credential, holder, request, actualSpki)) {
|
||||
|| !matchesCaCredentialMetadata(credential, holder, request, actualSpki, credentialDer)) {
|
||||
throw proofGate.rejection(action, framework.formatId(), Optional.of(subjectCaId), mismatchCode);
|
||||
}
|
||||
CredentialProfileBindings.requireCaBinding(credential.profileBinding(), request.profileReference());
|
||||
@@ -885,7 +906,7 @@ public final class DefaultCaService implements CaService {
|
||||
|
||||
private boolean matchesCaCredentialEnvelope(Credential credential, ValidatedCaCertificateRequest request,
|
||||
PkiId subjectCaId) {
|
||||
return framework.formatId().equals(credential.formatId()) && credential.encoded().encoding() == Encoding.DER
|
||||
return framework.formatId().equals(credential.formatId()) && credential.content().encoding() == Encoding.DER
|
||||
&& credential.status() == CredentialStatus.ISSUED
|
||||
&& credential.subjectRef().equals(request.subjectRef())
|
||||
&& credential.issuerRef()
|
||||
@@ -915,9 +936,9 @@ public final class DefaultCaService implements CaService {
|
||||
}
|
||||
|
||||
private static boolean matchesCaCredentialMetadata(Credential credential, X509CertificateHolder holder,
|
||||
ValidatedCaCertificateRequest request, byte[] actualSpki) {
|
||||
ValidatedCaCertificateRequest request, byte[] actualSpki, byte[] credentialDer) {
|
||||
return credential.publicKeyId().equals(new PkiId("spki:" + sha256Hex(actualSpki)))
|
||||
&& credential.credentialId().equals(new PkiId("x509:" + sha256Hex(credential.encoded().bytes())))
|
||||
&& credential.credentialId().equals(new PkiId("x509:" + sha256Hex(credentialDer)))
|
||||
&& credential.serialOrUniqueId().equals(holder.getSerialNumber().toString())
|
||||
&& credential.validity().notBefore().getEpochSecond() == holder.getNotBefore().toInstant()
|
||||
.getEpochSecond()
|
||||
|
||||
@@ -78,6 +78,9 @@ import zeroecho.pki.api.request.ParsedCertificationRequest;
|
||||
import zeroecho.pki.api.request.ProofOfPossessionResult;
|
||||
import zeroecho.pki.api.request.ProofOfPossessionStatus;
|
||||
import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes;
|
||||
import zeroecho.pki.impl.framework.x509.bc.BcX509CredentialFramework;
|
||||
import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot;
|
||||
import zeroecho.pki.impl.framework.x509.X509BuiltInDefaults;
|
||||
import zeroecho.pki.impl.framework.x509.bc.BcX509ProfileSupport;
|
||||
import zeroecho.pki.spi.audit.AuditSink;
|
||||
import zeroecho.pki.spi.framework.CredentialFramework;
|
||||
@@ -157,6 +160,8 @@ public final class DefaultIssuanceService implements IssuanceService {
|
||||
private final EffectiveCredentialStatusResolver statusResolver;
|
||||
private final ProfileService profileService;
|
||||
private final Clock clock;
|
||||
private final X509AuthoritySnapshot authority;
|
||||
private final zeroecho.core.spec.AlgorithmIdentity expectedSignature;
|
||||
|
||||
/**
|
||||
* Creates the issuance service bound to the supplied persistence and framework
|
||||
@@ -185,6 +190,11 @@ public final class DefaultIssuanceService implements IssuanceService {
|
||||
this.statusResolver = Objects.requireNonNull(statusResolver, "statusResolver");
|
||||
this.profileService = Objects.requireNonNull(profileService, "profileService");
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
if (!(framework instanceof BcX509CredentialFramework x509Framework)) {
|
||||
throw new IllegalArgumentException("X.509 issuance requires an algorithm authority");
|
||||
}
|
||||
this.authority = x509Framework.authority();
|
||||
this.expectedSignature = authority.resolveDefault(X509BuiltInDefaults.PKI_SIGNATURE_DEFAULT_V1).signature();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -250,7 +260,7 @@ public final class DefaultIssuanceService implements IssuanceService {
|
||||
ValidatedCertificateRequest validated;
|
||||
try {
|
||||
validated = CertificateProfileValidator.validate(candidate, profile, active.reference(), issuerCred,
|
||||
evaluationTime);
|
||||
evaluationTime, authority);
|
||||
} catch (PkiException exception) {
|
||||
throw rejection(candidate.request(), statusCode(exception));
|
||||
}
|
||||
@@ -259,7 +269,7 @@ public final class DefaultIssuanceService implements IssuanceService {
|
||||
CredentialBundle bundle;
|
||||
try {
|
||||
bundle = CredentialSnapshots
|
||||
.copy(issuerBackend.issueEndEntity(validated, issuerCred.encoded(), issuer.issuerKeyRef(), serial));
|
||||
.copy(issuerBackend.issueEndEntity(validated, issuerCred.content(), issuer.issuerKeyRef(), serial));
|
||||
} catch (RuntimeException ex) { // NOPMD - framework output must cross the snapshot boundary
|
||||
throw rejection(candidate.request(), "BACKEND_CREDENTIAL_MISMATCH");
|
||||
}
|
||||
@@ -441,14 +451,19 @@ public final class DefaultIssuanceService implements IssuanceService {
|
||||
try {
|
||||
CredentialProfileBindings.requireEndEntityBinding(credential.profileBinding(),
|
||||
validated.profileReference());
|
||||
if (!framework.formatId().equals(credential.formatId()) || credential.encoded().encoding() != Encoding.DER
|
||||
if (!framework.formatId().equals(credential.formatId()) || credential.content().encoding() != Encoding.DER
|
||||
|| !credential.subjectRef().equals(validated.subjectRef())
|
||||
|| !credential.issuerRef().equals(new zeroecho.pki.api.IssuerRef(validated.issuerCaId()))
|
||||
|| credential.status() != CredentialStatus.ISSUED) {
|
||||
throw rejection(auditRequest, "BACKEND_CREDENTIAL_MISMATCH");
|
||||
}
|
||||
X509CertificateHolder holder = new X509CertificateHolder(credential.encoded().bytes());
|
||||
X509CertificateHolder issuerHolder = new X509CertificateHolder(issuerCredential.encoded().bytes());
|
||||
CredentialContent.validateCertificate(store, credential.content(), authority,
|
||||
Optional.of(expectedSignature));
|
||||
CredentialContent.validateCertificate(store, issuerCredential.content(), authority, Optional.empty());
|
||||
byte[] credentialDer = CredentialContent.materializeForBc(store, credential.content());
|
||||
X509CertificateHolder holder = new X509CertificateHolder(credentialDer);
|
||||
X509CertificateHolder issuerHolder = new X509CertificateHolder(
|
||||
CredentialContent.materializeForBc(store, issuerCredential.content()));
|
||||
byte[] actualSpki = holder.getSubjectPublicKeyInfo().getEncoded();
|
||||
if (!MessageDigest.isEqual(validated.exactPublicKey().bytes(), actualSpki)
|
||||
|| !holder.getSubject().equals(BcX509ProfileSupport.subject(validated.subjectRdns()))
|
||||
@@ -457,7 +472,7 @@ public final class DefaultIssuanceService implements IssuanceService {
|
||||
new JcaContentVerifierProviderBuilder().build(issuerHolder.getSubjectPublicKeyInfo()))
|
||||
|| !holder.getSerialNumber().equals(allocatedSerial)
|
||||
|| !credential.publicKeyId().equals(new PkiId("spki:" + sha256Hex(actualSpki)))
|
||||
|| !credential.credentialId().equals(new PkiId("x509:" + sha256Hex(credential.encoded().bytes())))
|
||||
|| !credential.credentialId().equals(new PkiId("x509:" + sha256Hex(credentialDer)))
|
||||
|| !credential.serialOrUniqueId().equals(holder.getSerialNumber().toString())
|
||||
|| !credential.validity().equals(validated.validity())
|
||||
|| validated.validity().notBefore().getEpochSecond() != holder.getNotBefore().toInstant()
|
||||
|
||||
@@ -112,8 +112,18 @@ public final class DefaultRevocationService implements RevocationService {
|
||||
@SuppressWarnings("PMD.AvoidCatchingGenericException")
|
||||
public List<RevocationJournal> search(RevocationQuery query) {
|
||||
Objects.requireNonNull(query, "query");
|
||||
try {
|
||||
return store.listRevocationJournals().stream().filter(journal -> matches(journal, query)).toList();
|
||||
try (zeroecho.pki.spi.store.RevocationSnapshot snapshot = store.openRevocationSnapshot();
|
||||
zeroecho.pki.spi.store.RevocationSnapshot.Cursor cursor = snapshot.openCursor()) {
|
||||
List<RevocationJournal> matching = new java.util.ArrayList<>();
|
||||
while (cursor.next()) {
|
||||
RevocationJournal journal = cursor.current();
|
||||
if (matches(journal, query)) {
|
||||
matching.add(journal);
|
||||
}
|
||||
}
|
||||
return List.copyOf(matching);
|
||||
} catch (java.io.IOException failure) {
|
||||
throw new PkiException("Revocation snapshot failed: code=STORE_FAILED", failure);
|
||||
} catch (RuntimeException failure) {
|
||||
throw sanitized(failure);
|
||||
}
|
||||
|
||||
@@ -33,17 +33,19 @@
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.impl.core;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.OptionalLong;
|
||||
|
||||
import org.bouncycastle.cert.X509CertificateHolder;
|
||||
|
||||
import zeroecho.core.io.CancellationSignal;
|
||||
import zeroecho.core.io.RepeatableContent;
|
||||
import zeroecho.pki.api.Encoding;
|
||||
import zeroecho.pki.api.PkiException;
|
||||
import zeroecho.pki.api.PkiId;
|
||||
@@ -51,6 +53,7 @@ import zeroecho.pki.api.StatusObjectService;
|
||||
import zeroecho.pki.api.attr.AttributeValue;
|
||||
import zeroecho.pki.api.ca.CaRecord;
|
||||
import zeroecho.pki.api.ca.CaState;
|
||||
import zeroecho.pki.api.content.DurableContentReference;
|
||||
import zeroecho.pki.api.credential.Credential;
|
||||
import zeroecho.pki.api.credential.CredentialUse;
|
||||
import zeroecho.pki.api.credential.EffectiveCredentialStatus;
|
||||
@@ -65,11 +68,18 @@ import zeroecho.pki.api.status.StatusObjectQuery;
|
||||
import zeroecho.pki.api.status.StatusObjectType;
|
||||
import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
|
||||
import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes;
|
||||
import zeroecho.pki.impl.framework.x509.bc.BcX509SignedObjectValidator;
|
||||
import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot;
|
||||
import zeroecho.pki.impl.framework.x509.X509ExecutionPlan;
|
||||
import zeroecho.pki.impl.framework.x509.X509SignedObjectCompletion;
|
||||
import zeroecho.pki.spi.crypto.SignatureWorkflow;
|
||||
import zeroecho.pki.impl.framework.x509.bc.BcX509CredentialFramework;
|
||||
import zeroecho.pki.spi.audit.AuditSink;
|
||||
import zeroecho.pki.spi.framework.CredentialFramework;
|
||||
import zeroecho.pki.spi.framework.CrlEntry;
|
||||
import zeroecho.pki.spi.framework.CrlEntrySource;
|
||||
import zeroecho.pki.spi.store.PkiStore;
|
||||
import zeroecho.pki.spi.store.RevocationSnapshot;
|
||||
|
||||
/**
|
||||
* Default implementation of {@link StatusObjectService}.
|
||||
@@ -126,6 +136,7 @@ public final class DefaultStatusObjectService implements StatusObjectService {
|
||||
private final CredentialFramework framework;
|
||||
private final AuditSink auditSink;
|
||||
private final EffectiveCredentialStatusResolver statusResolver;
|
||||
private final X509AuthoritySnapshot authority;
|
||||
|
||||
/**
|
||||
* Creates a status object service bound to the supplied persistence and
|
||||
@@ -141,11 +152,12 @@ public final class DefaultStatusObjectService implements StatusObjectService {
|
||||
* @throws NullPointerException if an argument is {@code null}
|
||||
*/
|
||||
public DefaultStatusObjectService(PkiStore store, CredentialFramework framework, AuditSink auditSink,
|
||||
EffectiveCredentialStatusResolver statusResolver) {
|
||||
EffectiveCredentialStatusResolver statusResolver, X509AuthoritySnapshot authority) {
|
||||
this.store = Objects.requireNonNull(store, "store");
|
||||
this.framework = Objects.requireNonNull(framework, "framework");
|
||||
this.auditSink = Objects.requireNonNull(auditSink, "auditSink");
|
||||
this.statusResolver = Objects.requireNonNull(statusResolver, "statusResolver");
|
||||
this.authority = Objects.requireNonNull(authority, "authority");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -193,6 +205,7 @@ public final class DefaultStatusObjectService implements StatusObjectService {
|
||||
* generated status object fails
|
||||
*/
|
||||
@Override
|
||||
@SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" })
|
||||
public StatusObject generate(StatusObjectGenerateCommand command) {
|
||||
if (command == null) {
|
||||
throw new IllegalArgumentException("command must not be null");
|
||||
@@ -206,59 +219,79 @@ public final class DefaultStatusObjectService implements StatusObjectService {
|
||||
}
|
||||
EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation();
|
||||
Credential issuerCred = selectIssuerCredential(ca, command, statusEvaluation);
|
||||
List<CrlEntry> crlEntries = command.type() == StatusObjectType.CRL
|
||||
? collectCrlEntries(command.issuerCaId(), statusEvaluation.evaluationTime())
|
||||
: List.of();
|
||||
|
||||
SimpleAttributeSet.Builder b = SimpleAttributeSet.builder();
|
||||
b.putAll(command.attributes());
|
||||
b.put(BcX509Attributes.ISSUER_CERT_DER, new AttributeValue.BytesValue(issuerCred.encoded().bytes()));
|
||||
b.put(BcX509Attributes.ISSUER_CERT_DER,
|
||||
new AttributeValue.BytesValue(CredentialContent.materializeForBc(store, issuerCred.content())));
|
||||
b.put(BcX509Attributes.ISSUER_KEYREF, new AttributeValue.StringValue(ca.issuerKeyRef().value()));
|
||||
|
||||
StatusObjectGenerateCommand wired = new StatusObjectGenerateCommand(command.issuerCaId(), command.type(),
|
||||
command.formatId(), b.build());
|
||||
if (command.type() == StatusObjectType.CRL) {
|
||||
return generateAndPersistCrl(wired, crlEntries);
|
||||
}
|
||||
StatusObject obj = framework.statusObjectGenerator().generate(wired, crlEntries);
|
||||
store.putStatusObject(obj);
|
||||
return obj;
|
||||
}
|
||||
|
||||
// Framework, signing, and store failures may carry provider or persisted
|
||||
// material. CRL generation deliberately replaces the complete boundary with
|
||||
// one fresh cause-free and suppressed-free exception.
|
||||
@SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" })
|
||||
private StatusObject generateAndPersistCrl(StatusObjectGenerateCommand command, List<CrlEntry> entries) {
|
||||
try {
|
||||
StatusObject generated = framework.statusObjectGenerator().generate(command, entries);
|
||||
store.putStatusObject(generated);
|
||||
return generated;
|
||||
} catch (RuntimeException exception) {
|
||||
throw crlGenerationFailure();
|
||||
}
|
||||
}
|
||||
|
||||
// Store and parser failures may contain persisted material; the complete
|
||||
// collection boundary deliberately replaces every cause with one stable code.
|
||||
@SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" })
|
||||
private List<CrlEntry> collectCrlEntries(PkiId issuerCaId, Instant evaluationTime) {
|
||||
try {
|
||||
List<RevocationJournal> journals = Objects.requireNonNull(store.listRevocationJournals(),
|
||||
"revocation journals");
|
||||
List<CrlEntry> entries = new java.util.ArrayList<>();
|
||||
Set<BigInteger> serials = new HashSet<>();
|
||||
for (RevocationJournal journal : journals) {
|
||||
collectCrlEntry(issuerCaId, evaluationTime, journal, serials).ifPresent(entries::add);
|
||||
try (CrlEntrySource entries = command.type() == StatusObjectType.CRL
|
||||
? openCrlEntries(command.issuerCaId(), statusEvaluation.evaluationTime())
|
||||
: new EmptyCrlEntrySource()) {
|
||||
if (command.type() == StatusObjectType.CRL) {
|
||||
return generateAndPersistCrl(wired, entries, issuerCred);
|
||||
}
|
||||
return List.copyOf(entries);
|
||||
} catch (RuntimeException exception) {
|
||||
return generateAndPersistOther();
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
throw crlGenerationFailure();
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<CrlEntry> collectCrlEntry(PkiId issuerCaId, Instant evaluationTime, RevocationJournal journal,
|
||||
Set<BigInteger> serials) {
|
||||
private StatusObject generateAndPersistCrl(StatusObjectGenerateCommand command, CrlEntrySource entries,
|
||||
Credential issuer) {
|
||||
X509SignedObjectCompletion completion = framework.statusObjectGenerator().generate(command, entries);
|
||||
StatusObject generated = authority.requireStatusCompletion(completion);
|
||||
X509ExecutionPlan<SignatureWorkflow> signingPlan = authority.requireStatusSigningPlan(completion);
|
||||
requirePersistableContent(generated.content());
|
||||
boolean accepted = false;
|
||||
try {
|
||||
byte[] issuerDer = CredentialContent.materializeForBc(store, issuer.content());
|
||||
try (RepeatableContent content = store.stagedContent().openContent(generated.content())) {
|
||||
X509CertificateHolder holder = new X509CertificateHolder(issuerDer);
|
||||
new BcX509SignedObjectValidator(authority).validateGeneratedCrl(content, signingPlan,
|
||||
holder.getSubjectPublicKeyInfo(), CancellationSignal.NONE);
|
||||
} finally {
|
||||
Arrays.fill(issuerDer, (byte) 0);
|
||||
}
|
||||
store.putStatusObject(generated);
|
||||
accepted = true;
|
||||
return generated;
|
||||
} catch (IOException exception) {
|
||||
throw new PkiException("Status postcondition validation failed: code=CONTENT_IO_FAILED", exception);
|
||||
} finally {
|
||||
if (!accepted) {
|
||||
releaseRejectedContent(generated.content());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private StatusObject generateAndPersistOther() {
|
||||
throw new PkiException("Unsupported status object type");
|
||||
}
|
||||
|
||||
private void requirePersistableContent(DurableContentReference content) {
|
||||
if (content.lifecycle() != DurableContentReference.Lifecycle.PERSISTED
|
||||
|| !store.stagedContent().contentStoreId().equals(content.storeId())) {
|
||||
throw new PkiException("Status content lifecycle invalid: code=STAGED_CONTENT_FOREIGN_RUNTIME");
|
||||
}
|
||||
}
|
||||
|
||||
private void releaseRejectedContent(DurableContentReference content) {
|
||||
try {
|
||||
store.stagedContent().retireUnownedContent(content);
|
||||
} catch (IOException cleanupFailure) {
|
||||
throw new PkiException("Rejected status content cleanup failed: code=CONTENT_IO_FAILED", cleanupFailure);
|
||||
}
|
||||
}
|
||||
|
||||
private CrlEntrySource openCrlEntries(PkiId issuerCaId, Instant evaluationTime) {
|
||||
return new JournalCrlEntrySource(store.openRevocationSnapshot(), issuerCaId, evaluationTime);
|
||||
}
|
||||
|
||||
private Optional<CrlEntry> collectCrlEntry(PkiId issuerCaId, Instant evaluationTime, RevocationJournal journal) {
|
||||
Objects.requireNonNull(journal, "journal");
|
||||
RevocationTransition latest = Objects.requireNonNull(journal.latest(), "latest transition");
|
||||
if (latest.time().isAfter(evaluationTime)) {
|
||||
@@ -273,13 +306,10 @@ public final class DefaultStatusObjectService implements StatusObjectService {
|
||||
return Optional.empty();
|
||||
}
|
||||
if (!BcX509CredentialFramework.FORMAT_ID.equals(credential.formatId())
|
||||
|| credential.encoded().encoding() != Encoding.DER) {
|
||||
|| credential.content().encoding() != Encoding.DER) {
|
||||
throw crlGenerationFailure();
|
||||
}
|
||||
BigInteger serial = certificateSerial(credential);
|
||||
if (!serials.add(serial)) {
|
||||
throw crlGenerationFailure();
|
||||
}
|
||||
RevocationReason reason = switch (latest.state()) {
|
||||
case HELD -> RevocationReason.CERTIFICATE_HOLD;
|
||||
case PERMANENTLY_REVOKED ->
|
||||
@@ -289,11 +319,131 @@ public final class DefaultStatusObjectService implements StatusObjectService {
|
||||
return Optional.of(new CrlEntry(serial, latest.time(), reason));
|
||||
}
|
||||
|
||||
/** Stable restartable view over one revocation-store snapshot. */
|
||||
private final class JournalCrlEntrySource implements CrlEntrySource {
|
||||
private final RevocationSnapshot snapshot;
|
||||
private final PkiId issuerCaId;
|
||||
private final Instant evaluationTime;
|
||||
|
||||
private JournalCrlEntrySource(RevocationSnapshot snapshot, PkiId issuerCaId, Instant evaluationTime) {
|
||||
this.snapshot = snapshot;
|
||||
this.issuerCaId = issuerCaId;
|
||||
this.evaluationTime = evaluationTime;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Cursor openCursor() throws IOException {
|
||||
return new JournalCrlCursor(snapshot.openCursor(), issuerCaId, evaluationTime);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OptionalLong count() {
|
||||
return OptionalLong.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
snapshot.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Bounded cursor translating authoritative journals into CRL entries. */
|
||||
private final class JournalCrlCursor implements CrlEntrySource.Cursor {
|
||||
private final RevocationSnapshot.Cursor cursor;
|
||||
private final PkiId issuerCaId;
|
||||
private final Instant evaluationTime;
|
||||
private CrlEntry current;
|
||||
private long ordinal = -1L;
|
||||
|
||||
private JournalCrlCursor(RevocationSnapshot.Cursor cursor, PkiId issuerCaId, Instant evaluationTime) {
|
||||
this.cursor = cursor;
|
||||
this.issuerCaId = issuerCaId;
|
||||
this.evaluationTime = evaluationTime;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean next() throws IOException {
|
||||
while (cursor.next()) {
|
||||
Optional<CrlEntry> candidate = collectCrlEntry(issuerCaId, evaluationTime, cursor.current());
|
||||
if (candidate.isPresent()) {
|
||||
current = candidate.orElseThrow();
|
||||
ordinal = Math.addExact(ordinal, 1L);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
current = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CrlEntry current() {
|
||||
if (current == null) {
|
||||
throw new IllegalStateException("CRL entry cursor is not positioned");
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long ordinal() {
|
||||
if (current == null) {
|
||||
throw new IllegalStateException("CRL entry cursor is not positioned");
|
||||
}
|
||||
return ordinal;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
cursor.close();
|
||||
current = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Empty source used for status formats without revocation entries. */
|
||||
private static final class EmptyCrlEntrySource implements CrlEntrySource {
|
||||
@Override
|
||||
public Cursor openCursor() {
|
||||
return new EmptyCrlCursor();
|
||||
}
|
||||
|
||||
@Override
|
||||
public OptionalLong count() {
|
||||
return OptionalLong.of(0L);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
// No resources.
|
||||
}
|
||||
}
|
||||
|
||||
/** Resource-free cursor for an empty status-entry source. */
|
||||
private static final class EmptyCrlCursor implements CrlEntrySource.Cursor {
|
||||
@Override
|
||||
public boolean next() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CrlEntry current() {
|
||||
throw new IllegalStateException("Empty CRL cursor has no entry");
|
||||
}
|
||||
|
||||
@Override
|
||||
public long ordinal() {
|
||||
throw new IllegalStateException("Empty CRL cursor has no ordinal");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
// No resources.
|
||||
}
|
||||
}
|
||||
|
||||
// Parser failures can contain persisted certificate details; the original
|
||||
// cause is intentionally removed at this public service boundary.
|
||||
@SuppressWarnings("PMD.PreserveStackTrace")
|
||||
private static BigInteger certificateSerial(Credential credential) {
|
||||
byte[] der = credential.encoded().bytes();
|
||||
private BigInteger certificateSerial(Credential credential) {
|
||||
byte[] der = CredentialContent.materializeForBc(store, credential.content());
|
||||
BigInteger serial;
|
||||
try {
|
||||
serial = new X509CertificateHolder(der).getSerialNumber();
|
||||
|
||||
@@ -40,6 +40,7 @@ import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
@@ -48,18 +49,27 @@ import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import zeroecho.core.spec.AlgorithmIdentity;
|
||||
import zeroecho.core.spi.AlgorithmExecutionCapability;
|
||||
import zeroecho.core.io.RepeatableContent;
|
||||
import zeroecho.pki.api.EncodedObject;
|
||||
import zeroecho.pki.api.Encoding;
|
||||
import zeroecho.pki.api.KeyRef;
|
||||
import zeroecho.pki.api.PkiException;
|
||||
import zeroecho.pki.api.PkiId;
|
||||
import zeroecho.pki.api.content.DurableContentReference;
|
||||
import zeroecho.pki.api.content.DurableContentOwner;
|
||||
import zeroecho.pki.api.audit.Principal;
|
||||
import zeroecho.pki.api.orch.OrchestrationDurabilityPolicy;
|
||||
import zeroecho.pki.api.orch.SigningSubmissionId;
|
||||
import zeroecho.pki.api.orch.WorkflowStateRecord;
|
||||
import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot;
|
||||
import zeroecho.pki.impl.framework.x509.X509ExecutionPlan;
|
||||
import zeroecho.pki.spi.crypto.SignatureWorkflow;
|
||||
import zeroecho.pki.spi.store.PkiStore;
|
||||
import zeroecho.pki.spi.store.SignWorkflowStore;
|
||||
import zeroecho.pki.spi.store.ContentSink;
|
||||
import zeroecho.pki.spi.store.TemporaryUniqueIndex;
|
||||
import zeroecho.pki.util.async.AsyncEndpoint;
|
||||
import zeroecho.pki.util.async.AsyncState;
|
||||
import zeroecho.pki.util.async.AsyncStatus;
|
||||
@@ -112,6 +122,7 @@ public final class PkiSigningBus implements AutoCloseable {
|
||||
private final PkiStore store;
|
||||
private final DurableAsyncBus<PkiId, Principal, String, EncodedObject> bus;
|
||||
private final SignatureWorkflow signer;
|
||||
private final X509AuthoritySnapshot authority;
|
||||
private final SecureRandom random;
|
||||
private final String namespace;
|
||||
private final OperationCoordinator coordinator;
|
||||
@@ -122,58 +133,70 @@ public final class PkiSigningBus implements AutoCloseable {
|
||||
private final OrchestrationDurabilityPolicy durabilityPolicy;
|
||||
|
||||
/**
|
||||
* Creates a signing bus.
|
||||
*
|
||||
* @param store persistent store (source of truth for
|
||||
* continuation state)
|
||||
* @param signer signature workflow
|
||||
* @param durableLineStorePath path to append-only line store file
|
||||
*/
|
||||
public PkiSigningBus(PkiStore store, SignatureWorkflow signer, Path durableLineStorePath) {
|
||||
this(store, signer, durableLineStorePath, resolveDisplaySuffixMaxLen(Optional.empty()),
|
||||
OrchestrationDurabilityPolicy.DURABLE_MIN_STATE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a signing bus.
|
||||
* Creates a signing bus in an explicitly composed runtime authority graph.
|
||||
*
|
||||
* <p>
|
||||
* The {@code displaySuffixMaxLen} parameter controls the maximum number of
|
||||
* characters appended after {@code '#'} in operation identifiers returned by
|
||||
* {@link #canonicalizeOperationId(PkiId, Principal)}. If
|
||||
* {@code displaySuffixMaxLen} is not positive, the constructor fails.
|
||||
* The authority must already bind every signing identity declared by
|
||||
* {@code signer} to that exact workflow instance for the {@code SIGN}
|
||||
* direction. This constructor validates ownership before registering the
|
||||
* workflow or activating durable state. It never constructs an internal
|
||||
* authority, accepts an authority from another runtime, or handles private
|
||||
* key material.
|
||||
* </p>
|
||||
*
|
||||
* @param store persistent store (source of truth for
|
||||
* continuation state)
|
||||
* @param signer signature workflow
|
||||
* @param durableLineStorePath path to append-only line store file
|
||||
* @param displaySuffixMaxLen maximum number of characters after {@code '#'}
|
||||
* @param store persistent store
|
||||
* @param signer exact signing workflow owned by
|
||||
* {@code authority}
|
||||
* @param durableLineStorePath durable bus path
|
||||
* @param authority shared immutable runtime authority
|
||||
* @throws NullPointerException if any argument is {@code null}
|
||||
* @throws IllegalArgumentException if the authority does not own the exact
|
||||
* workflow for every declared signing
|
||||
* identity and the {@code SIGN} direction
|
||||
*/
|
||||
public PkiSigningBus(PkiStore store, SignatureWorkflow signer, Path durableLineStorePath, int displaySuffixMaxLen) {
|
||||
this(store, signer, durableLineStorePath, displaySuffixMaxLen, OrchestrationDurabilityPolicy.DURABLE_MIN_STATE);
|
||||
public PkiSigningBus(PkiStore store, SignatureWorkflow signer, Path durableLineStorePath,
|
||||
X509AuthoritySnapshot authority) {
|
||||
this(store, signer, durableLineStorePath, resolveDisplaySuffixMaxLen(Optional.empty()),
|
||||
OrchestrationDurabilityPolicy.DURABLE_MIN_STATE, authority);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a signing bus with explicit workflow continuation durability policy.
|
||||
* Creates a signing bus bound to one immutable algorithm authority snapshot.
|
||||
*
|
||||
* @param store persistent store (source of truth for
|
||||
* continuation state)
|
||||
* @param signer signature workflow
|
||||
* @param durableLineStorePath path to append-only line store file
|
||||
* @param displaySuffixMaxLen maximum number of characters after {@code '#'}
|
||||
* @param durabilityPolicy durability policy used when persisting workflow
|
||||
* state
|
||||
* <p>
|
||||
* The authority must already bind every signing identity declared by
|
||||
* {@code signer} to that exact workflow instance for the {@code SIGN}
|
||||
* direction. Validation occurs before registration or durable-state
|
||||
* activation. The constructor does not derive an authority from provider
|
||||
* strings, accept cross-runtime workflow ownership, or access private key
|
||||
* material.
|
||||
* </p>
|
||||
*
|
||||
* @param store persistent store
|
||||
* @param signer exact workflow represented by the snapshot
|
||||
* capability
|
||||
* @param durableLineStorePath durable bus path
|
||||
* @param displaySuffixMaxLen display suffix bound
|
||||
* @param durabilityPolicy continuation durability policy
|
||||
* @param authority immutable runtime authority
|
||||
* @throws NullPointerException if any reference argument is {@code null}
|
||||
* @throws IllegalArgumentException if the display suffix bound is invalid or
|
||||
* the authority does not own the exact
|
||||
* workflow for every declared signing
|
||||
* identity and the {@code SIGN} direction
|
||||
*/
|
||||
public PkiSigningBus(PkiStore store, SignatureWorkflow signer, Path durableLineStorePath, int displaySuffixMaxLen,
|
||||
OrchestrationDurabilityPolicy durabilityPolicy) {
|
||||
OrchestrationDurabilityPolicy durabilityPolicy, X509AuthoritySnapshot authority) {
|
||||
Objects.requireNonNull(store, "store");
|
||||
Objects.requireNonNull(signer, "signer");
|
||||
Objects.requireNonNull(durableLineStorePath, "durableLineStorePath");
|
||||
Objects.requireNonNull(durabilityPolicy, "durabilityPolicy");
|
||||
X509AuthoritySnapshot exactAuthority = Objects.requireNonNull(authority, "authority");
|
||||
validateSigningAuthority(signer, exactAuthority);
|
||||
|
||||
this.store = store;
|
||||
this.signer = signer;
|
||||
this.authority = exactAuthority;
|
||||
this.random = new SecureRandom();
|
||||
this.namespace = store.signingNamespace() + "." + signer.id();
|
||||
signer.validateSigningDomain(this.namespace, store.signingHorizon(), store.signingPermittedSkew());
|
||||
@@ -189,13 +212,21 @@ public final class PkiSigningBus implements AutoCloseable {
|
||||
for (WorkflowStateRecord state : store.listWorkflowStates()) {
|
||||
if (TYPE_SIGN.equals(state.type()) && state.payload().isPresent()) {
|
||||
try {
|
||||
SignContinuation.decode(state.payload().orElseThrow());
|
||||
SignContinuation continuation = SignContinuation.decode(state.payload().orElseThrow(),
|
||||
store.stagedContent());
|
||||
X509ExecutionPlan<SignatureWorkflow> plan = authority.planSigning(continuation.algorithmId,
|
||||
workflowImplementationId(signer), SignatureWorkflow.class);
|
||||
authority.authorize(plan, signer, AlgorithmExecutionCapability.Direction.SIGN);
|
||||
AlgorithmIdentity identity = plan.selection().requested();
|
||||
if (!identity.canonicalForm().equals(continuation.algorithmId)) {
|
||||
throw new IllegalArgumentException("Persisted sign continuation is not canonical");
|
||||
}
|
||||
} catch (RuntimeException ex) { // NOPMD - malformed persisted state must fail closed
|
||||
throw new PkiException("Invalid persisted sign continuation: code=CONTINUATION_INVALID");
|
||||
}
|
||||
}
|
||||
}
|
||||
this.endpoint = new SignatureWorkflowEndpoint(store, signer, coordinator, externalActions);
|
||||
this.endpoint = new SignatureWorkflowEndpoint(store, signer, coordinator, externalActions, authority);
|
||||
this.bus.registerEndpoint(ENDPOINT_SIGNER, endpoint);
|
||||
this.signerRegistration = signer.register(endpoint::onProviderStatusChanged);
|
||||
for (SignWorkflowStore.Record record : store.listSignRecords()) {
|
||||
@@ -206,6 +237,109 @@ public final class PkiSigningBus implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the immutable authority used by this workflow graph.
|
||||
*
|
||||
* @return authority snapshot
|
||||
*/
|
||||
public X509AuthoritySnapshot authority() {
|
||||
return authority;
|
||||
}
|
||||
|
||||
/**
|
||||
* Begins runtime-owned durable staging for one signing input.
|
||||
*
|
||||
* @param encoding content encoding
|
||||
* @return atomic staged-content sink
|
||||
*/
|
||||
public ContentSink beginSigningContent(Encoding encoding) {
|
||||
return beginContent(encoding, DurableContentReference.Lifecycle.OPERATION);
|
||||
}
|
||||
|
||||
/**
|
||||
* Begins atomic runtime-owned content staging.
|
||||
*
|
||||
* <p>
|
||||
* The sink writes to the injected staged-content store and never retains an
|
||||
* aggregate payload in the bus. ZeroEcho core imposes no arbitrary
|
||||
* product-wide aggregate size limit.
|
||||
* </p>
|
||||
*
|
||||
* @param encoding content encoding
|
||||
* @param lifecycle ownership and retirement class
|
||||
* @return atomic streamed sink
|
||||
* @throws PkiException if staging cannot begin
|
||||
*/
|
||||
public ContentSink beginContent(Encoding encoding, DurableContentReference.Lifecycle lifecycle) {
|
||||
try {
|
||||
return store.stagedContent().beginContent(Objects.requireNonNull(encoding, "encoding"),
|
||||
Objects.requireNonNull(lifecycle, "lifecycle"));
|
||||
} catch (java.io.IOException ex) {
|
||||
throw new PkiException("Content staging failed: code=SPOOL_STORAGE_FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens immutable repeatable content owned by this runtime.
|
||||
*
|
||||
* @param reference opaque durable content reference
|
||||
* @return repeatable content
|
||||
* @throws PkiException if the content is missing, incomplete, corrupt, or
|
||||
* belongs to another runtime store
|
||||
*/
|
||||
public RepeatableContent openContent(DurableContentReference reference) {
|
||||
try {
|
||||
return store.stagedContent().openContent(Objects.requireNonNull(reference, "reference"));
|
||||
} catch (java.io.IOException ex) {
|
||||
throw new PkiException("Content open failed: code=CONTENT_INTEGRITY_FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases staged content from this runtime.
|
||||
*
|
||||
* @param reference content reference
|
||||
* @throws PkiException if cleanup fails
|
||||
*/
|
||||
public void releaseContent(DurableContentReference reference) {
|
||||
try {
|
||||
store.stagedContent().retireUnownedContent(Objects.requireNonNull(reference, "reference"));
|
||||
} catch (java.io.IOException ex) {
|
||||
throw new PkiException("Content cleanup failed: code=SPOOL_STORAGE_FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Begins a runtime-owned file-backed uniqueness index.
|
||||
*
|
||||
* @return temporary uniqueness index
|
||||
* @throws PkiException if temporary storage cannot be created
|
||||
*/
|
||||
public TemporaryUniqueIndex beginUniqueIndex() {
|
||||
try {
|
||||
return store.stagedContent().beginUniqueIndex();
|
||||
} catch (java.io.IOException ex) {
|
||||
throw new PkiException("Temporary index failed: code=SPOOL_STORAGE_FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
private static String workflowImplementationId(SignatureWorkflow workflow) {
|
||||
return "workflow." + workflow.id();
|
||||
}
|
||||
|
||||
private static void validateSigningAuthority(SignatureWorkflow workflow, X509AuthoritySnapshot authority) {
|
||||
Set<String> supportedAlgorithms = Set.copyOf(
|
||||
Objects.requireNonNull(workflow.supportedAlgorithms(), "workflow.supportedAlgorithms"));
|
||||
if (supportedAlgorithms.isEmpty()) {
|
||||
throw new IllegalArgumentException("Signature workflow must declare a signing identity");
|
||||
}
|
||||
for (String algorithm : supportedAlgorithms) {
|
||||
X509ExecutionPlan<SignatureWorkflow> plan = authority.planSigning(algorithm,
|
||||
workflowImplementationId(workflow), SignatureWorkflow.class);
|
||||
authority.authorize(plan, workflow, AlgorithmExecutionCapability.Direction.SIGN);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a canonical, globally unique operation identifier derived from tuple
|
||||
* (owner, clientOpId).
|
||||
@@ -242,24 +376,29 @@ public final class PkiSigningBus implements AutoCloseable {
|
||||
* @param owner owner principal
|
||||
* @param keyRef signing key reference
|
||||
* @param algorithmId signature algorithm id
|
||||
* @param payload bytes to sign
|
||||
* @param content durable repeatable content to sign
|
||||
* @param ttl time-to-live
|
||||
* @param workflowPayload minimal continuation payload
|
||||
*/
|
||||
public void submitSign(PkiId opId, Principal owner, KeyRef keyRef, String algorithmId, EncodedObject payload,
|
||||
public void submitSign(PkiId opId, Principal owner, KeyRef keyRef, String algorithmId,
|
||||
DurableContentReference content,
|
||||
Duration ttl, Optional<EncodedObject> workflowPayload) {
|
||||
|
||||
Objects.requireNonNull(opId, "opId");
|
||||
Objects.requireNonNull(owner, "owner");
|
||||
Objects.requireNonNull(keyRef, "keyRef");
|
||||
Objects.requireNonNull(algorithmId, "algorithmId");
|
||||
Objects.requireNonNull(payload, "payload");
|
||||
Objects.requireNonNull(content, "content");
|
||||
Objects.requireNonNull(ttl, "ttl");
|
||||
Objects.requireNonNull(workflowPayload, "workflowPayload");
|
||||
|
||||
if (algorithmId.isBlank()) {
|
||||
throw new IllegalArgumentException("algorithmId must not be blank");
|
||||
}
|
||||
X509ExecutionPlan<SignatureWorkflow> submittedPlan = authority.planSigning(algorithmId,
|
||||
workflowImplementationId(signer), SignatureWorkflow.class);
|
||||
authority.authorize(submittedPlan, signer, AlgorithmExecutionCapability.Direction.SIGN);
|
||||
AlgorithmIdentity submittedIdentity = submittedPlan.selection().requested();
|
||||
if (ttl.isZero() || ttl.isNegative()) {
|
||||
throw new IllegalArgumentException("ttl must be positive");
|
||||
}
|
||||
@@ -272,27 +411,52 @@ public final class PkiSigningBus implements AutoCloseable {
|
||||
try (OperationCoordinator.Lease ignored = coordinator.acquire(baseOpId)) {
|
||||
SigningSubmissionId parsed = SigningSubmissionId.parse(baseOpId);
|
||||
Instant deadline = parsed.createdAt().plus(ttl);
|
||||
SignContinuation continuation = SignContinuation.decode(workflowPayload.get());
|
||||
SignContinuation continuation = SignContinuation.decode(workflowPayload.get(), store.stagedContent());
|
||||
X509ExecutionPlan<SignatureWorkflow> continuationPlan = authority.planSigning(continuation.algorithmId,
|
||||
workflowImplementationId(signer), SignatureWorkflow.class);
|
||||
authority.authorize(continuationPlan, signer, AlgorithmExecutionCapability.Direction.SIGN);
|
||||
AlgorithmIdentity continuationIdentity = continuationPlan.selection().requested();
|
||||
if (!owner.equals(continuation.accessContext.principal()) || !keyRef.equals(continuation.keyRef)
|
||||
|| !algorithmId.equals(continuation.algorithmId)
|
||||
|| payload.encoding() != continuation.payload.encoding()
|
||||
|| !java.util.Arrays.equals(payload.bytes(), continuation.payload.bytes())) {
|
||||
|| !submittedIdentity.equals(continuationIdentity)
|
||||
|| !content.equals(continuation.content())) {
|
||||
throw new IllegalArgumentException("Sign continuation does not match the submitted request");
|
||||
}
|
||||
continuation = continuation.withAlgorithmId(submittedIdentity.canonicalForm());
|
||||
String fingerprint = continuation.semanticFingerprint(namespace, deadline);
|
||||
EncodedObject persistedRequest = continuation.withSignerOpId(baseOpId).encode();
|
||||
SignWorkflowStore.Record intent = new SignWorkflowStore.Record(baseOpId, namespace, fingerprint, owner,
|
||||
parsed.createdAt(), deadline, persistedRequest, SignWorkflowStore.State.INTENT, 0L, 0L,
|
||||
Optional.empty(), Optional.of("INTENT"), Optional.empty(), Optional.empty());
|
||||
SignWorkflowStore.CreateResult created = store.createSignIntent(intent);
|
||||
if (created == SignWorkflowStore.CreateResult.CONFLICT) {
|
||||
throw new PkiException("Signing submission identifier conflicts with a different request");
|
||||
DurableContentOwner contentOwner = DurableContentOwner.signingOperation(baseOpId);
|
||||
boolean retained = false;
|
||||
boolean recordVisible = false;
|
||||
try {
|
||||
retained = store.stagedContent().retainContent(content, contentOwner);
|
||||
SignWorkflowStore.CreateResult created = store.createSignIntent(intent);
|
||||
if (created == SignWorkflowStore.CreateResult.CONFLICT) {
|
||||
throw new PkiException("Signing submission identifier conflicts with a different request");
|
||||
}
|
||||
recordVisible = true;
|
||||
authoritative = store.getSignRecord(baseOpId).orElseThrow();
|
||||
} catch (java.io.IOException exception) {
|
||||
throw new PkiException("Signing content retention failed: code=SPOOL_STORAGE_FAILED", exception);
|
||||
} finally {
|
||||
if (retained && !recordVisible) {
|
||||
rollbackSigningOwner(content, contentOwner);
|
||||
}
|
||||
}
|
||||
authoritative = store.getSignRecord(baseOpId).orElseThrow();
|
||||
}
|
||||
project(authoritative);
|
||||
}
|
||||
|
||||
private void rollbackSigningOwner(DurableContentReference content, DurableContentOwner owner) {
|
||||
try {
|
||||
store.stagedContent().releaseContent(content, owner);
|
||||
} catch (java.io.IOException exception) {
|
||||
throw new PkiException("Signing content rollback failed: code=SPOOL_STORAGE_FAILED", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns current status if known.
|
||||
*/
|
||||
@@ -400,8 +564,15 @@ public final class PkiSigningBus implements AutoCloseable {
|
||||
if (!isTerminalSignState(state.state())) {
|
||||
return;
|
||||
}
|
||||
Optional<DurableContentReference> releaseReference = Optional.empty();
|
||||
if (state.state() != SignWorkflowStore.State.RETIRED) {
|
||||
releaseReference = Optional.of(SignContinuation.decode(state.request(), store.stagedContent()).content());
|
||||
}
|
||||
state = confirmRetirement(baseOpId, state);
|
||||
store.deleteWorkflowState(baseOpId);
|
||||
if (releaseReference.isPresent()) {
|
||||
releaseRetiredOperationContent(baseOpId, releaseReference.get());
|
||||
}
|
||||
}
|
||||
AsyncState advisoryState = state.result().isPresent() ? AsyncState.SUCCEEDED : AsyncState.CANCELLED;
|
||||
bus.update(baseOpId, new AsyncStatus(advisoryState, store.signingNow(), Optional.of("RETIRED"),
|
||||
@@ -409,6 +580,17 @@ public final class PkiSigningBus implements AutoCloseable {
|
||||
bus.retire(baseOpId);
|
||||
}
|
||||
|
||||
private void releaseRetiredOperationContent(PkiId operationId, DurableContentReference reference) {
|
||||
if (reference.lifecycle() != DurableContentReference.Lifecycle.OPERATION) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
store.stagedContent().releaseContent(reference, DurableContentOwner.signingOperation(operationId));
|
||||
} catch (java.io.IOException exception) {
|
||||
throw new PkiException("Signing content retirement failed: code=SPOOL_STORAGE_FAILED", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void reconcileExpiredOperations() {
|
||||
Instant current = store.signingNow();
|
||||
for (SignWorkflowStore.Record candidate : store.listSignRecords()) {
|
||||
@@ -813,6 +995,7 @@ public final class PkiSigningBus implements AutoCloseable {
|
||||
private final SignatureWorkflow signer;
|
||||
private final OperationCoordinator coordinator;
|
||||
private final ExternalActionCoordinator externalActions;
|
||||
private final X509AuthoritySnapshot authority;
|
||||
private final ConcurrentMap<PkiId, Boolean> pendingAdvisories;
|
||||
private final AtomicInteger pendingAdvisoryCount;
|
||||
private final AtomicBoolean closed;
|
||||
@@ -826,11 +1009,12 @@ public final class PkiSigningBus implements AutoCloseable {
|
||||
* operation and to query its status; must not be {@code null}
|
||||
*/
|
||||
private SignatureWorkflowEndpoint(PkiStore store, SignatureWorkflow signer, OperationCoordinator coordinator,
|
||||
ExternalActionCoordinator externalActions) {
|
||||
ExternalActionCoordinator externalActions, X509AuthoritySnapshot authority) {
|
||||
this.store = store;
|
||||
this.signer = signer;
|
||||
this.coordinator = coordinator;
|
||||
this.externalActions = externalActions;
|
||||
this.authority = authority;
|
||||
this.pendingAdvisories = new ConcurrentHashMap<>();
|
||||
this.pendingAdvisoryCount = new AtomicInteger();
|
||||
this.closed = new AtomicBoolean();
|
||||
@@ -877,7 +1061,8 @@ public final class PkiSigningBus implements AutoCloseable {
|
||||
SubmissionCall call = prepared.get();
|
||||
PkiId returned;
|
||||
try (ExternalActionCoordinator.Reservation ignored = call.reservation()) {
|
||||
returned = signer.submitSign(call.request());
|
||||
authority.authorize(call.plan(), signer, AlgorithmExecutionCapability.Direction.SIGN);
|
||||
returned = call.plan().executor().submitSign(call.request());
|
||||
} catch (RuntimeException ambiguousFailure) { // NOPMD - provider acceptance is unknown
|
||||
return;
|
||||
}
|
||||
@@ -917,17 +1102,27 @@ public final class PkiSigningBus implements AutoCloseable {
|
||||
ExternalActionCoordinator.Reservation reservation = reserved.get();
|
||||
boolean reservationTransferred = false;
|
||||
try {
|
||||
SignContinuation continuation = SignContinuation.decode(claimed.request());
|
||||
SignContinuation continuation = SignContinuation.decode(claimed.request(), store.stagedContent());
|
||||
DurableContentOwner contentOwner = DurableContentOwner.signingOperation(opId);
|
||||
requireSigningOwner(continuation.content(), contentOwner);
|
||||
X509ExecutionPlan<SignatureWorkflow> plan = authority.planSigning(continuation.algorithmId,
|
||||
workflowImplementationId(signer), SignatureWorkflow.class);
|
||||
authority.authorize(plan, signer, AlgorithmExecutionCapability.Direction.SIGN);
|
||||
AlgorithmIdentity persistedIdentity = plan.selection().requested();
|
||||
if (!persistedIdentity.canonicalForm().equals(continuation.algorithmId)) {
|
||||
throw new IllegalArgumentException("Persisted sign continuation is not canonical");
|
||||
}
|
||||
RepeatableContent content = openContent(continuation.content());
|
||||
SignatureWorkflow.SignRequest request = SignatureWorkflow.SignRequest.create(opId,
|
||||
claimed.namespace(), claimed.fence(), continuation.accessContext, continuation.keyRef,
|
||||
continuation.algorithmId, continuation.payload,
|
||||
Optional.of(continuation.preferredSignatureEncoding), Optional.of(claimed.deadline()));
|
||||
continuation.algorithmId, content, Optional.of(continuation.preferredSignatureEncoding),
|
||||
Optional.of(claimed.deadline()));
|
||||
if (!constantTimeAsciiEquals(claimed.fingerprint(), request.semanticFingerprint())) {
|
||||
store.transitionSign(opId, claimed.revision(), claimed.fence(), SignWorkflowStore.State.FAILED,
|
||||
Optional.of("REQUEST_INTEGRITY_FAILURE"), Optional.empty(), Optional.empty());
|
||||
return Optional.empty();
|
||||
}
|
||||
SubmissionCall call = new SubmissionCall(claimed, request, reservation);
|
||||
SubmissionCall call = new SubmissionCall(claimed, request, reservation, plan);
|
||||
reservationTransferred = true;
|
||||
return Optional.of(call);
|
||||
} finally {
|
||||
@@ -938,6 +1133,24 @@ public final class PkiSigningBus implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
private void requireSigningOwner(DurableContentReference reference, DurableContentOwner owner) {
|
||||
try {
|
||||
if (!store.stagedContent().contentOwners(reference).contains(owner)) {
|
||||
throw new PkiException("Signing content owner missing: code=STAGED_CONTENT_INCOMPLETE");
|
||||
}
|
||||
} catch (java.io.IOException exception) {
|
||||
throw new PkiException("Signing content ownership failed: code=CONTENT_INTEGRITY_FAILED", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private RepeatableContent openContent(DurableContentReference reference) {
|
||||
try {
|
||||
return store.stagedContent().openContent(reference);
|
||||
} catch (java.io.IOException ex) {
|
||||
throw new PkiException("Signing content unavailable: code=STAGED_CONTENT_MISSING");
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean constantTimeAsciiEquals(String left, String right) {
|
||||
byte[] leftBytes = left.getBytes(java.nio.charset.StandardCharsets.US_ASCII);
|
||||
byte[] rightBytes = right.getBytes(java.nio.charset.StandardCharsets.US_ASCII);
|
||||
@@ -970,7 +1183,7 @@ public final class PkiSigningBus implements AutoCloseable {
|
||||
}
|
||||
|
||||
private record SubmissionCall(SignWorkflowStore.Record record, SignatureWorkflow.SignRequest request,
|
||||
ExternalActionCoordinator.Reservation reservation) {
|
||||
ExternalActionCoordinator.Reservation reservation, X509ExecutionPlan<SignatureWorkflow> plan) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1260,7 +1473,8 @@ public final class PkiSigningBus implements AutoCloseable {
|
||||
* <h2>Encoding model</h2>
|
||||
* <p>
|
||||
* Instances are encoded into a compact binary representation through
|
||||
* {@link #encode()} and reconstructed through {@link #decode(EncodedObject)}.
|
||||
* {@link #encode()} and reconstructed through
|
||||
* {@link #decode(EncodedObject, StagedContentStore)}.
|
||||
* The binary format is versioned by {@link #VERSION}. The current version
|
||||
* persists the algorithm identifier, payload encoding and bytes, key reference,
|
||||
* preferred signature encoding, and the optional downstream signer operation
|
||||
@@ -1271,7 +1485,7 @@ public final class PkiSigningBus implements AutoCloseable {
|
||||
* <p>
|
||||
* The current binary encoding does not persist the original
|
||||
* {@link zeroecho.pki.api.audit.AccessContext} losslessly. During
|
||||
* {@link #decode(EncodedObject)}, a synthetic system access context is created
|
||||
* {@link #decode(EncodedObject, StagedContentStore)}, a synthetic system access context is created
|
||||
* instead. This is sufficient for the current continuation flow, but callers
|
||||
* must not assume that {@code decode(encode(x))} preserves the original access
|
||||
* context exactly.
|
||||
@@ -1284,11 +1498,13 @@ public final class PkiSigningBus implements AutoCloseable {
|
||||
*/
|
||||
public static final class SignContinuation {
|
||||
|
||||
private static final byte VERSION = 2;
|
||||
private static final byte VERSION = 4;
|
||||
private static final long MINIMUM_CONTENT_LENGTH = 0L;
|
||||
|
||||
private final zeroecho.pki.api.audit.AccessContext accessContext;
|
||||
private final String algorithmId;
|
||||
private final EncodedObject payload;
|
||||
private final Optional<DurableContentReference> content;
|
||||
private final ContentCommitment commitment;
|
||||
private final KeyRef keyRef;
|
||||
private final Encoding preferredSignatureEncoding;
|
||||
private final Optional<PkiId> signerOpId;
|
||||
@@ -1300,7 +1516,7 @@ public final class PkiSigningBus implements AutoCloseable {
|
||||
* sign request; must not be {@code null}
|
||||
* @param algorithmId non-blank signature algorithm identifier;
|
||||
* must not be {@code null} or blank
|
||||
* @param payload to-be-signed payload; must not be
|
||||
* @param content durable to-be-signed content reference
|
||||
* {@code null}
|
||||
* @param keyRef signing key reference; must not be
|
||||
* {@code null}
|
||||
@@ -1313,10 +1529,13 @@ public final class PkiSigningBus implements AutoCloseable {
|
||||
* @throws IllegalArgumentException if {@code algorithmId} is blank
|
||||
*/
|
||||
public SignContinuation(zeroecho.pki.api.audit.AccessContext accessContext, String algorithmId,
|
||||
EncodedObject payload, KeyRef keyRef, Encoding preferredSignatureEncoding, Optional<PkiId> signerOpId) {
|
||||
DurableContentReference content, KeyRef keyRef, Encoding preferredSignatureEncoding,
|
||||
Optional<PkiId> signerOpId) {
|
||||
this.accessContext = Objects.requireNonNull(accessContext, "accessContext");
|
||||
this.algorithmId = Objects.requireNonNull(algorithmId, "algorithmId");
|
||||
this.payload = Objects.requireNonNull(payload, "payload");
|
||||
DurableContentReference exactContent = Objects.requireNonNull(content, "content");
|
||||
this.content = Optional.of(exactContent);
|
||||
this.commitment = ContentCommitment.of(exactContent);
|
||||
this.keyRef = Objects.requireNonNull(keyRef, "keyRef");
|
||||
this.preferredSignatureEncoding = Objects.requireNonNull(preferredSignatureEncoding,
|
||||
"preferredSignatureEncoding");
|
||||
@@ -1326,6 +1545,22 @@ public final class PkiSigningBus implements AutoCloseable {
|
||||
}
|
||||
}
|
||||
|
||||
private SignContinuation(zeroecho.pki.api.audit.AccessContext accessContext, String algorithmId,
|
||||
Optional<DurableContentReference> content, ContentCommitment commitment, KeyRef keyRef,
|
||||
Encoding preferredSignatureEncoding, Optional<PkiId> signerOpId) {
|
||||
this.accessContext = Objects.requireNonNull(accessContext, "accessContext");
|
||||
this.algorithmId = Objects.requireNonNull(algorithmId, "algorithmId");
|
||||
this.content = Objects.requireNonNull(content, "content");
|
||||
this.commitment = Objects.requireNonNull(commitment, "commitment");
|
||||
this.keyRef = Objects.requireNonNull(keyRef, "keyRef");
|
||||
this.preferredSignatureEncoding = Objects.requireNonNull(preferredSignatureEncoding,
|
||||
"preferredSignatureEncoding");
|
||||
this.signerOpId = Objects.requireNonNull(signerOpId, "signerOpId");
|
||||
if (algorithmId.isBlank() || content.isPresent() && !commitment.matches(content.get())) {
|
||||
throw new IllegalArgumentException("Invalid sign continuation content commitment");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new continuation with the downstream signer workflow operation
|
||||
* identifier assigned.
|
||||
@@ -1343,8 +1578,19 @@ public final class PkiSigningBus implements AutoCloseable {
|
||||
* @throws NullPointerException if {@code opId} is {@code null}
|
||||
*/
|
||||
public SignContinuation withSignerOpId(PkiId opId) {
|
||||
return new SignContinuation(accessContext, algorithmId, payload, keyRef, preferredSignatureEncoding,
|
||||
Optional.of(opId));
|
||||
return new SignContinuation(accessContext, algorithmId, content, commitment, keyRef,
|
||||
preferredSignatureEncoding, Optional.of(opId));
|
||||
}
|
||||
|
||||
private SignContinuation withAlgorithmId(String canonicalAlgorithmId) {
|
||||
return new SignContinuation(accessContext, canonicalAlgorithmId, content, commitment, keyRef,
|
||||
preferredSignatureEncoding, signerOpId);
|
||||
}
|
||||
|
||||
/** Returns a terminal continuation retaining only immutable content commitment metadata. */
|
||||
public SignContinuation withoutLiveContent() {
|
||||
return new SignContinuation(accessContext, algorithmId, Optional.empty(), commitment, keyRef,
|
||||
preferredSignatureEncoding, signerOpId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1363,6 +1609,31 @@ public final class PkiSigningBus implements AutoCloseable {
|
||||
return signerOpId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the payload-free durable reference used for recovery.
|
||||
*
|
||||
* @return staged content reference; never a live handle or payload
|
||||
*/
|
||||
public DurableContentReference content() {
|
||||
return content.orElseThrow(() -> new IllegalStateException("Retired continuation has no live content"));
|
||||
}
|
||||
|
||||
/** Returns whether this continuation still requires live staged content. */
|
||||
public boolean hasLiveContent() {
|
||||
return content.isPresent();
|
||||
}
|
||||
|
||||
/** Restores the committed reference through its owning store for delayed cleanup. */
|
||||
public DurableContentReference restoreContent(zeroecho.pki.spi.store.StagedContentStore stagedContent)
|
||||
throws java.io.IOException {
|
||||
Objects.requireNonNull(stagedContent, "stagedContent");
|
||||
if (content.isPresent()) {
|
||||
return content.get();
|
||||
}
|
||||
return stagedContent.restoreReference(commitment.storeId(), commitment.contentId(), commitment.encoding(),
|
||||
commitment.length(), commitment.sha256(), commitment.lifecycle());
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the canonical semantic fingerprint for this persisted request.
|
||||
*
|
||||
@@ -1380,7 +1651,8 @@ public final class PkiSigningBus implements AutoCloseable {
|
||||
*/
|
||||
public String semanticFingerprint(String namespace, Instant deadline) {
|
||||
Objects.requireNonNull(deadline, "deadline");
|
||||
return SignatureWorkflow.SignRequest.fingerprint(namespace, accessContext, keyRef, algorithmId, payload,
|
||||
return SignatureWorkflow.SignRequest.fingerprint(namespace, accessContext, keyRef, algorithmId,
|
||||
new ReferenceContent(commitment),
|
||||
Optional.of(preferredSignatureEncoding), Optional.of(deadline));
|
||||
}
|
||||
|
||||
@@ -1411,7 +1683,7 @@ public final class PkiSigningBus implements AutoCloseable {
|
||||
* <ul>
|
||||
* <li>format version,</li>
|
||||
* <li>algorithm identifier,</li>
|
||||
* <li>payload encoding and bytes,</li>
|
||||
* <li>store-issued staged-content reference metadata,</li>
|
||||
* <li>key reference,</li>
|
||||
* <li>preferred signature encoding,</li>
|
||||
* <li>presence marker and optional downstream signer workflow operation
|
||||
@@ -1427,7 +1699,6 @@ public final class PkiSigningBus implements AutoCloseable {
|
||||
*/
|
||||
public EncodedObject encode() {
|
||||
WipeableByteArrayOutputStream bytes = new WipeableByteArrayOutputStream();
|
||||
byte[] payloadBytes = payload.bytes();
|
||||
byte[] encoded = null;
|
||||
try {
|
||||
try (java.io.DataOutputStream output = new java.io.DataOutputStream(bytes)) {
|
||||
@@ -1445,9 +1716,13 @@ public final class PkiSigningBus implements AutoCloseable {
|
||||
}
|
||||
output.writeUTF(algorithmId);
|
||||
output.writeUTF(keyRef.value());
|
||||
output.writeByte(payload.encoding().ordinal());
|
||||
output.writeInt(payloadBytes.length);
|
||||
output.write(payloadBytes);
|
||||
output.writeBoolean(content.isPresent());
|
||||
output.writeUTF(commitment.storeId());
|
||||
output.writeUTF(commitment.contentId());
|
||||
output.writeByte(commitment.encoding().ordinal());
|
||||
output.writeLong(commitment.length());
|
||||
output.writeUTF(commitment.sha256());
|
||||
output.writeByte(commitment.lifecycle().ordinal());
|
||||
output.writeByte(preferredSignatureEncoding.ordinal());
|
||||
output.writeBoolean(signerOpId.isPresent());
|
||||
if (signerOpId.isPresent()) {
|
||||
@@ -1459,7 +1734,6 @@ public final class PkiSigningBus implements AutoCloseable {
|
||||
} catch (java.io.IOException ex) {
|
||||
throw new PkiException("Failed to encode sign continuation: code=CONTINUATION_ENCODE_FAILED");
|
||||
} finally {
|
||||
java.util.Arrays.fill(payloadBytes, (byte) 0);
|
||||
if (encoded != null) {
|
||||
java.util.Arrays.fill(encoded, (byte) 0);
|
||||
}
|
||||
@@ -1489,6 +1763,8 @@ public final class PkiSigningBus implements AutoCloseable {
|
||||
* </p>
|
||||
*
|
||||
* @param obj binary encoded continuation payload; must not be {@code null}
|
||||
* @param stagedContent owning store used to restore and validate the persisted
|
||||
* content reference
|
||||
* @return decoded continuation instance
|
||||
* @throws NullPointerException if {@code obj} is {@code null}
|
||||
* @throws IllegalArgumentException if {@code obj} does not use
|
||||
@@ -1496,13 +1772,14 @@ public final class PkiSigningBus implements AutoCloseable {
|
||||
* format version is not supported, or if the
|
||||
* binary payload is malformed
|
||||
*/
|
||||
public static SignContinuation decode(EncodedObject obj) {
|
||||
public static SignContinuation decode(EncodedObject obj,
|
||||
zeroecho.pki.spi.store.StagedContentStore stagedContent) {
|
||||
Objects.requireNonNull(obj, "obj");
|
||||
Objects.requireNonNull(stagedContent, "stagedContent");
|
||||
if (obj.encoding() != Encoding.BINARY) {
|
||||
throw new IllegalArgumentException("Expected BINARY continuation payload");
|
||||
}
|
||||
byte[] encoded = obj.bytes();
|
||||
byte[] payloadBytes = null;
|
||||
try (java.io.DataInputStream input = new java.io.DataInputStream(
|
||||
new java.io.ByteArrayInputStream(encoded))) {
|
||||
int version = input.readUnsignedByte();
|
||||
@@ -1518,30 +1795,85 @@ public final class PkiSigningBus implements AutoCloseable {
|
||||
: Optional.empty();
|
||||
String algId = input.readUTF();
|
||||
KeyRef key = new KeyRef(input.readUTF());
|
||||
Encoding payloadEncoding = Encoding.values()[input.readUnsignedByte()];
|
||||
int payloadLength = input.readInt();
|
||||
if (payloadLength <= 0 || payloadLength > 16 * 1024 * 1024) {
|
||||
throw new PkiException("Invalid sign continuation payload length");
|
||||
}
|
||||
payloadBytes = input.readNBytes(payloadLength);
|
||||
if (payloadBytes.length != payloadLength) {
|
||||
throw new PkiException("Truncated sign continuation payload");
|
||||
}
|
||||
boolean liveContent = input.readBoolean();
|
||||
String storeId = input.readUTF();
|
||||
String contentId = input.readUTF();
|
||||
Encoding contentEncoding = Encoding.values()[input.readUnsignedByte()];
|
||||
long contentLength = input.readLong();
|
||||
String sha256 = input.readUTF();
|
||||
DurableContentReference.Lifecycle lifecycle = DurableContentReference.Lifecycle
|
||||
.values()[input.readUnsignedByte()];
|
||||
ContentCommitment commitment = new ContentCommitment(storeId, contentId, contentEncoding,
|
||||
contentLength, sha256, lifecycle);
|
||||
Optional<DurableContentReference> content = liveContent
|
||||
? Optional.of(stagedContent.restoreReference(storeId, contentId, contentEncoding,
|
||||
contentLength, sha256, lifecycle))
|
||||
: Optional.empty();
|
||||
Encoding preferred = Encoding.values()[input.readUnsignedByte()];
|
||||
Optional<PkiId> signerId = input.readBoolean() ? Optional.of(new PkiId(input.readUTF()))
|
||||
: Optional.empty();
|
||||
requireCompleteInput(input);
|
||||
zeroecho.pki.api.audit.AccessContext access = new zeroecho.pki.api.audit.AccessContext(principal,
|
||||
purpose, objectId, formatId);
|
||||
return new SignContinuation(access, algId, new EncodedObject(payloadEncoding, payloadBytes), key,
|
||||
preferred, signerId);
|
||||
return new SignContinuation(access, algId, content, commitment, key, preferred, signerId);
|
||||
} catch (java.io.IOException | IndexOutOfBoundsException ex) {
|
||||
throw new PkiException("Malformed sign continuation: code=CONTINUATION_MALFORMED");
|
||||
} finally {
|
||||
java.util.Arrays.fill(encoded, (byte) 0);
|
||||
if (payloadBytes != null) {
|
||||
java.util.Arrays.fill(payloadBytes, (byte) 0);
|
||||
}
|
||||
}
|
||||
|
||||
private record ContentCommitment(String storeId, String contentId, Encoding encoding, long length,
|
||||
String sha256, DurableContentReference.Lifecycle lifecycle) {
|
||||
private ContentCommitment {
|
||||
Objects.requireNonNull(storeId, "storeId");
|
||||
Objects.requireNonNull(contentId, "contentId");
|
||||
Objects.requireNonNull(encoding, "encoding");
|
||||
Objects.requireNonNull(sha256, "sha256");
|
||||
Objects.requireNonNull(lifecycle, "lifecycle");
|
||||
if (length < MINIMUM_CONTENT_LENGTH) {
|
||||
throw new IllegalArgumentException("Content length must not be negative");
|
||||
}
|
||||
}
|
||||
|
||||
private static ContentCommitment of(DurableContentReference reference) {
|
||||
return new ContentCommitment(reference.storeId(), reference.contentId(), reference.encoding(),
|
||||
reference.length(), reference.sha256(), reference.lifecycle());
|
||||
}
|
||||
|
||||
private boolean matches(DurableContentReference reference) {
|
||||
return storeId.equals(reference.storeId()) && contentId.equals(reference.contentId())
|
||||
&& encoding == reference.encoding() && length == reference.length()
|
||||
&& sha256.equals(reference.sha256()) && lifecycle == reference.lifecycle();
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireCompleteInput(java.io.DataInputStream input) throws java.io.IOException {
|
||||
if (input.read() >= 0) {
|
||||
throw new java.io.IOException("Trailing sign continuation data");
|
||||
}
|
||||
}
|
||||
|
||||
private record ReferenceContent(ContentCommitment reference) implements RepeatableContent {
|
||||
@Override
|
||||
public java.io.InputStream openStream() throws java.io.IOException {
|
||||
throw new java.io.IOException("Content reference requires staged-content store");
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.OptionalLong length() {
|
||||
return java.util.OptionalLong.of(reference.length());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String contentId() {
|
||||
return "sha256:" + reference.sha256();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
// Reference metadata owns no live resource.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,13 +33,11 @@
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.impl.crypto.zeroecholib;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataInputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.FileLock;
|
||||
@@ -87,6 +85,8 @@ import zeroecho.core.alg.rsa.RsaPublicKeySpec;
|
||||
import zeroecho.core.alg.slhdsa.SlhDsaPublicKeySpec;
|
||||
import zeroecho.core.alg.sphincsplus.SphincsPlusPublicKeySpec;
|
||||
import zeroecho.core.context.SignatureContext;
|
||||
import zeroecho.core.io.CancellationSignal;
|
||||
import zeroecho.core.io.RepeatableContent;
|
||||
import zeroecho.core.io.TailStrippingInputStream;
|
||||
import zeroecho.core.spec.AlgorithmKeySpec;
|
||||
import zeroecho.core.spec.ContextSpec;
|
||||
@@ -266,7 +266,7 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
|
||||
|
||||
private static final OperationStatus UNKNOWN_OPERATION_STATUS = new OperationStatus(State.FAILED, Instant.EPOCH,
|
||||
Optional.of(DC_UNKNOWN_OPERATION), Optional.empty());
|
||||
private static final int OPERATION_RECORD_VERSION = 3;
|
||||
private static final int OPERATION_RECORD_VERSION = 4;
|
||||
private static final long MIN_FENCING_TOKEN = 1L;
|
||||
|
||||
private final String id;
|
||||
@@ -488,7 +488,6 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
|
||||
return opId;
|
||||
}
|
||||
|
||||
byte[] payloadBytes = null;
|
||||
byte[] signatureBytes = null;
|
||||
try {
|
||||
KeyRefParts parts = parseKeyRefOrThrow(request.keyRef(), true);
|
||||
@@ -514,8 +513,9 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
|
||||
completeSign(request, expiredStatus());
|
||||
return opId;
|
||||
}
|
||||
payloadBytes = request.payload().bytes();
|
||||
signatureBytes = signStreaming(request.algorithmId(), prv.key(), pub.key(), payloadBytes);
|
||||
request.cancellation().throwIfCancelled();
|
||||
signatureBytes = signStreaming(request.algorithmId(), prv.key(), pub.key(), request.content(),
|
||||
request.cancellation());
|
||||
|
||||
Encoding outEnc = request.preferredSignatureEncoding().orElse(Encoding.BINARY);
|
||||
EncodedObject signature = encodeSignatureOrThrow(outEnc, signatureBytes);
|
||||
@@ -553,7 +553,6 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
|
||||
logSafeFailure("SIGN", DC_CRYPTO_FAILURE, ex);
|
||||
return opId;
|
||||
} finally {
|
||||
clearOwned("sign-payload", payloadBytes);
|
||||
clearOwned("sign-result-copy", signatureBytes);
|
||||
}
|
||||
}
|
||||
@@ -670,7 +669,6 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
|
||||
putStatus(opId, new OperationStatus(State.RUNNING, now(), Optional.of(DC_SUBMITTED), Optional.empty()));
|
||||
|
||||
byte[] signatureBytes = null;
|
||||
byte[] payloadBytes = null;
|
||||
try {
|
||||
if (request.algorithmId() == null || request.algorithmId().isBlank()) {
|
||||
throw new InvalidRequestException(DC_INVALID_ALGORITHM_ID);
|
||||
@@ -683,8 +681,9 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
|
||||
putStatus(opId, expiredStatus());
|
||||
return opId;
|
||||
}
|
||||
payloadBytes = request.payload().bytes();
|
||||
boolean ok = verifyStreaming(request.algorithmId(), pub, payloadBytes, signatureBytes);
|
||||
request.cancellation().throwIfCancelled();
|
||||
boolean ok = verifyStreaming(request.algorithmId(), pub, request.content(), signatureBytes,
|
||||
request.cancellation());
|
||||
|
||||
Instant completedAt = now();
|
||||
if (deadlineReached(request.deadline(), completedAt)) {
|
||||
@@ -716,7 +715,6 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
|
||||
logSafeFailure("VERIFY", DC_CRYPTO_FAILURE, ex);
|
||||
return opId;
|
||||
} finally {
|
||||
clearOwned("verify-payload", payloadBytes);
|
||||
clearOwned("verify-signature", signatureBytes);
|
||||
}
|
||||
}
|
||||
@@ -1061,7 +1059,8 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
|
||||
return a;
|
||||
}
|
||||
|
||||
private byte[] signStreaming(String algorithmId, PrivateKey prv, PublicKey pub, byte[] msg)
|
||||
private byte[] signStreaming(String algorithmId, PrivateKey prv, PublicKey pub, RepeatableContent content,
|
||||
CancellationSignal cancellation)
|
||||
throws GeneralSecurityException, IOException {
|
||||
|
||||
Optional<SignatureInteropProfile> profile = SignatureInteropProfiles.resolve(algorithmId);
|
||||
@@ -1075,14 +1074,14 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
|
||||
|
||||
try (SignatureContext signer = session.createContext(contextAlgorithmId, KeyUsage.SIGN, prv, contextSpec)) {
|
||||
final byte[][] sigHolder = new byte[1][];
|
||||
try (InputStream in = new TailStrippingInputStream(signer.wrap(new ByteArrayInputStream(msg)), sigLen,
|
||||
8192) {
|
||||
try (InputStream source = content.openStream();
|
||||
InputStream in = new TailStrippingInputStream(signer.wrap(source), sigLen, 8192) {
|
||||
@Override
|
||||
protected void processTail(byte[] tail) throws IOException {
|
||||
sigHolder[0] = (tail == null) ? null : tail.clone();
|
||||
}
|
||||
}) {
|
||||
in.transferTo(OutputStream.nullOutputStream());
|
||||
consume(in, cancellation);
|
||||
}
|
||||
|
||||
byte[] internalSignature = sigHolder[0];
|
||||
@@ -1101,7 +1100,8 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean verifyStreaming(String algorithmId, PublicKey pub, byte[] msg, byte[] signature)
|
||||
private boolean verifyStreaming(String algorithmId, PublicKey pub, RepeatableContent content, byte[] signature,
|
||||
CancellationSignal cancellation)
|
||||
throws GeneralSecurityException, IOException {
|
||||
|
||||
Optional<SignatureInteropProfile> profile = SignatureInteropProfiles.resolve(algorithmId);
|
||||
@@ -1116,8 +1116,8 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
|
||||
|
||||
try (SignatureContext verifier = session.createContext(contextAlgorithmId, KeyUsage.VERIFY, pub, contextSpec)) {
|
||||
verifier.setExpectedTag(internalSignature);
|
||||
try (InputStream in = verifier.wrap(new ByteArrayInputStream(msg))) {
|
||||
in.transferTo(OutputStream.nullOutputStream());
|
||||
try (InputStream source = content.openStream(); InputStream in = verifier.wrap(source)) {
|
||||
consume(in, cancellation);
|
||||
}
|
||||
return true;
|
||||
} catch (Exception mismatch) {
|
||||
@@ -1129,6 +1129,17 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
|
||||
}
|
||||
}
|
||||
|
||||
private static void consume(InputStream input, CancellationSignal cancellation) throws IOException {
|
||||
byte[] buffer = new byte[16 * 1024];
|
||||
try {
|
||||
while (input.read(buffer) >= 0) {
|
||||
cancellation.throwIfCancelled();
|
||||
}
|
||||
} finally {
|
||||
Arrays.fill(buffer, (byte) 0);
|
||||
}
|
||||
}
|
||||
|
||||
private EncodedObject encodeSignatureOrThrow(Encoding encoding, byte[] sigBytes) throws InvalidRequestException {
|
||||
if (encoding == Encoding.BINARY || encoding == Encoding.DER) {
|
||||
return new EncodedObject(encoding, sigBytes);
|
||||
@@ -1482,14 +1493,8 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
|
||||
}
|
||||
output.writeUTF(request.keyRef().value());
|
||||
output.writeUTF(request.algorithmId());
|
||||
output.writeInt(encodingCode(request.payload().encoding()));
|
||||
byte[] payload = request.payload().bytes();
|
||||
try {
|
||||
output.writeInt(payload.length);
|
||||
output.write(payload);
|
||||
} finally {
|
||||
clearOwned("persisted-request-payload", payload);
|
||||
}
|
||||
output.writeUTF(request.content().contentId());
|
||||
output.writeLong(request.content().length().orElse(-1L));
|
||||
output.writeBoolean(request.preferredSignatureEncoding().isPresent());
|
||||
if (request.preferredSignatureEncoding().isPresent()) {
|
||||
output.writeInt(encodingCode(request.preferredSignatureEncoding().orElseThrow()));
|
||||
@@ -1516,25 +1521,38 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow {
|
||||
objectId, formatId);
|
||||
KeyRef keyRef = new KeyRef(input.readUTF());
|
||||
String algorithmId = input.readUTF();
|
||||
Encoding payloadEncoding = encodingFromCode(input.readInt());
|
||||
int payloadLength = input.readInt();
|
||||
if (payloadLength < 1 || payloadLength > 16 * 1024 * 1024) {
|
||||
throw new IllegalStateException("Invalid persisted signing payload length");
|
||||
String contentId = input.readUTF();
|
||||
long contentLength = input.readLong();
|
||||
if (contentId.isBlank() || contentLength < -1L) {
|
||||
throw new IllegalStateException("Invalid persisted signing content metadata");
|
||||
}
|
||||
byte[] payload = input.readNBytes(payloadLength);
|
||||
if (payload.length != payloadLength) {
|
||||
throw new IllegalStateException("Truncated persisted signing payload");
|
||||
Optional<Encoding> preferred = input.readBoolean() ? Optional.of(encodingFromCode(input.readInt()))
|
||||
: Optional.empty();
|
||||
Optional<Instant> deadline = input.readBoolean()
|
||||
? Optional.of(Instant.ofEpochSecond(input.readLong(), input.readInt()))
|
||||
: Optional.empty();
|
||||
return SignRequest.create(submissionId, namespace, 1L, access, keyRef, algorithmId,
|
||||
new RecoveredContentMetadata(contentId, contentLength), preferred, deadline);
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata-only view retained for terminal request identity after restart.
|
||||
* Recovered non-terminal operations fail closed before this content can execute.
|
||||
*/
|
||||
private record RecoveredContentMetadata(String contentId, long persistedLength) implements RepeatableContent {
|
||||
@Override
|
||||
public InputStream openStream() throws IOException {
|
||||
throw new IOException("Recovered content requires staged-content resolution");
|
||||
}
|
||||
try {
|
||||
Optional<Encoding> preferred = input.readBoolean() ? Optional.of(encodingFromCode(input.readInt()))
|
||||
: Optional.empty();
|
||||
Optional<Instant> deadline = input.readBoolean()
|
||||
? Optional.of(Instant.ofEpochSecond(input.readLong(), input.readInt()))
|
||||
: Optional.empty();
|
||||
return SignRequest.create(submissionId, namespace, 1L, access, keyRef, algorithmId,
|
||||
new EncodedObject(payloadEncoding, payload), preferred, deadline);
|
||||
} finally {
|
||||
clearOwned("loaded-request-payload", payload);
|
||||
|
||||
@Override
|
||||
public java.util.OptionalLong length() {
|
||||
return persistedLength < 0L ? java.util.OptionalLong.empty() : java.util.OptionalLong.of(persistedLength);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
// Metadata owns no live resource.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,11 @@ import java.util.Set;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import zeroecho.core.alg.BootstrapAlgorithmIdentities;
|
||||
import zeroecho.core.spec.AlgorithmIdentity;
|
||||
import zeroecho.core.spec.AlgorithmSuite;
|
||||
import zeroecho.core.spi.AlgorithmExecutionCapability;
|
||||
import zeroecho.core.spi.AlgorithmExecutionCapabilityProvider;
|
||||
import zeroecho.core.spi.KeyringUnlockProvider;
|
||||
import zeroecho.core.storage.KeyringPassword;
|
||||
import zeroecho.core.storage.KeyringStore;
|
||||
@@ -75,7 +80,8 @@ import zeroecho.pki.spi.crypto.SignatureWorkflowRuntimeDependencies;
|
||||
* performs no value logging.
|
||||
* </p>
|
||||
*/
|
||||
public final class ZeroEchoLibSignatureWorkflowProvider implements SignatureWorkflowProvider {
|
||||
public final class ZeroEchoLibSignatureWorkflowProvider
|
||||
implements SignatureWorkflowProvider, AlgorithmExecutionCapabilityProvider {
|
||||
/** Stable failure code for a missing explicit keyring unlock provider. */
|
||||
public static final String DC_KEYRING_UNLOCK_PROVIDER_REQUIRED = "KEYRING_UNLOCK_PROVIDER_REQUIRED";
|
||||
/** Stable failure code for an unlock-provider acquisition failure. */
|
||||
@@ -127,6 +133,42 @@ public final class ZeroEchoLibSignatureWorkflowProvider implements SignatureWork
|
||||
KEY_REQUIRE_SUFFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares the exact classic signature domain implemented by the workflow.
|
||||
*
|
||||
* <p>
|
||||
* This metadata describes the same workflow implementation allocated by this
|
||||
* provider. It does not select an OID, redefine an identity, or claim key
|
||||
* availability.
|
||||
* </p>
|
||||
*
|
||||
* @return immutable execution capability contribution
|
||||
*/
|
||||
@Override
|
||||
public java.util.List<AlgorithmExecutionCapability> capabilities() {
|
||||
Set<AlgorithmIdentity> signatures = Set.of(BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256,
|
||||
BootstrapAlgorithmIdentities.RSA_PKCS1_SHA384, BootstrapAlgorithmIdentities.RSA_PKCS1_SHA512,
|
||||
BootstrapAlgorithmIdentities.RSA_PSS_SHA256, BootstrapAlgorithmIdentities.ECDSA_SHA256,
|
||||
BootstrapAlgorithmIdentities.ECDSA_SHA384, BootstrapAlgorithmIdentities.ECDSA_SHA512,
|
||||
BootstrapAlgorithmIdentities.ED25519_SIGNATURE, BootstrapAlgorithmIdentities.ED448_SIGNATURE);
|
||||
return java.util.List.of(new AlgorithmExecutionCapability() {
|
||||
@Override
|
||||
public String implementationId() {
|
||||
return "zeroecho-lib.signature-workflow";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String domainFingerprint() {
|
||||
return "zeroecho-lib-signature-v1:bootstrap-classic:sign,verify";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supports(AlgorithmIdentity identity, AlgorithmSuite suite, Direction direction) {
|
||||
return signatures.contains(identity) && identity.equals(suite.signature());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates configuration for the ZeroEcho-lib signature workflow provider.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,6 +184,7 @@ public final class BcX509CertificationRequestParser implements CertificationRequ
|
||||
try {
|
||||
PKCS10CertificationRequest csr;
|
||||
try {
|
||||
BcX509AlgorithmAdapter.requireCanonicalDer(csrDer);
|
||||
csr = new PKCS10CertificationRequest(csrDer);
|
||||
} catch (Exception ex) {
|
||||
throw new PkiException("Invalid PKCS#10 certification request: code=CSR_MALFORMED");
|
||||
|
||||
@@ -41,6 +41,7 @@ import zeroecho.pki.spi.framework.CredentialFramework;
|
||||
import zeroecho.pki.spi.framework.FrameworkAttributeMapper;
|
||||
import zeroecho.pki.spi.framework.ProofOfPossessionVerifier;
|
||||
import zeroecho.pki.spi.framework.StatusObjectGenerator;
|
||||
import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot;
|
||||
|
||||
/**
|
||||
* Bouncy Castle backed X.509 implementation of {@link CredentialFramework}.
|
||||
@@ -104,6 +105,7 @@ public final class BcX509CredentialFramework implements CredentialFramework {
|
||||
private final ProofOfPossessionVerifier popVerifier;
|
||||
private final StatusObjectGenerator status;
|
||||
private final FrameworkAttributeMapper attributeMapper;
|
||||
private final X509AuthoritySnapshot authority;
|
||||
|
||||
/**
|
||||
* Creates a partially wired X.509 framework instance with default components.
|
||||
@@ -124,9 +126,9 @@ public final class BcX509CredentialFramework implements CredentialFramework {
|
||||
* exposed by the framework facade.
|
||||
* </p>
|
||||
*/
|
||||
public BcX509CredentialFramework() {
|
||||
this(new BcX509CertificationRequestParser(), new BcX509ProofOfPossessionVerifier(),
|
||||
new UnsupportedStatusObjectGenerator(), new BcX509FrameworkAttributeMapper());
|
||||
public BcX509CredentialFramework(X509AuthoritySnapshot authority, BcX509VerificationExecutor executor) {
|
||||
this(new BcX509CertificationRequestParser(), new BcX509ProofOfPossessionVerifier(authority, executor),
|
||||
new UnsupportedStatusObjectGenerator(), new BcX509FrameworkAttributeMapper(), authority);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,11 +148,12 @@ public final class BcX509CredentialFramework implements CredentialFramework {
|
||||
* @throws NullPointerException if any component argument is {@code null}
|
||||
*/
|
||||
private BcX509CredentialFramework(CertificationRequestParser requestParser, ProofOfPossessionVerifier popVerifier,
|
||||
StatusObjectGenerator status, FrameworkAttributeMapper attributeMapper) {
|
||||
StatusObjectGenerator status, FrameworkAttributeMapper attributeMapper, X509AuthoritySnapshot authority) {
|
||||
this.requestParser = Objects.requireNonNull(requestParser, "requestParser");
|
||||
this.popVerifier = Objects.requireNonNull(popVerifier, "popVerifier");
|
||||
this.status = Objects.requireNonNull(status, "status");
|
||||
this.attributeMapper = Objects.requireNonNull(attributeMapper, "attributeMapper");
|
||||
this.authority = Objects.requireNonNull(authority, "authority");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -170,7 +173,8 @@ public final class BcX509CredentialFramework implements CredentialFramework {
|
||||
*/
|
||||
public BcX509CredentialFramework wired(BcX509StatusObjectGenerator statusObjectGenerator) {
|
||||
Objects.requireNonNull(statusObjectGenerator, "statusObjectGenerator");
|
||||
return new BcX509CredentialFramework(requestParser, popVerifier, statusObjectGenerator, attributeMapper);
|
||||
return new BcX509CredentialFramework(requestParser, popVerifier, statusObjectGenerator, attributeMapper,
|
||||
authority);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -202,7 +206,16 @@ public final class BcX509CredentialFramework implements CredentialFramework {
|
||||
Objects.requireNonNull(statusObjectGenerator, "statusObjectGenerator");
|
||||
Objects.requireNonNull(proofOfPossessionVerifier, "proofOfPossessionVerifier");
|
||||
return new BcX509CredentialFramework(requestParser, proofOfPossessionVerifier, statusObjectGenerator,
|
||||
attributeMapper);
|
||||
attributeMapper, authority);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the immutable algorithm authority owned by this framework graph.
|
||||
*
|
||||
* @return shared authority snapshot
|
||||
*/
|
||||
public X509AuthoritySnapshot authority() {
|
||||
return authority;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -283,9 +296,9 @@ public final class BcX509CredentialFramework implements CredentialFramework {
|
||||
* @throws UnsupportedOperationException always
|
||||
*/
|
||||
@Override
|
||||
public zeroecho.pki.api.status.StatusObject generate(
|
||||
public zeroecho.pki.impl.framework.x509.X509SignedObjectCompletion generate(
|
||||
zeroecho.pki.api.status.StatusObjectGenerateCommand command,
|
||||
java.util.List<zeroecho.pki.spi.framework.CrlEntry> crlEntries) {
|
||||
zeroecho.pki.spi.framework.CrlEntrySource crlEntries) {
|
||||
throw new UnsupportedOperationException("X.509 status object generator not wired");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,12 +34,15 @@
|
||||
package zeroecho.pki.impl.framework.x509.bc;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.List;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import zeroecho.pki.spi.ProviderConfig;
|
||||
import zeroecho.pki.spi.framework.CredentialFramework;
|
||||
import zeroecho.pki.spi.framework.CredentialFrameworkProvider;
|
||||
import zeroecho.pki.impl.framework.x509.X509AlgorithmResolver;
|
||||
import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot;
|
||||
|
||||
/**
|
||||
* ServiceLoader provider for the Bouncy Castle backed X.509 credential
|
||||
@@ -162,6 +165,12 @@ public final class BcX509CredentialFrameworkProvider implements CredentialFramew
|
||||
@Override
|
||||
public CredentialFramework allocate(ProviderConfig config) {
|
||||
validateConfig(config);
|
||||
return new BcX509CredentialFramework();
|
||||
BcX509VerificationExecutor executor = new BcX509VerificationExecutor();
|
||||
X509AlgorithmResolver.Policy policy = (suite, direction) -> true;
|
||||
X509AuthoritySnapshot authority = X509AuthoritySnapshot.compose(List.of(), List.of(executor),
|
||||
List.of(X509AuthoritySnapshot.bindExecutor(BcX509VerificationExecutor.IMPLEMENTATION_ID,
|
||||
zeroecho.core.spi.AlgorithmExecutionCapability.Direction.VERIFY, executor)),
|
||||
policy);
|
||||
return new BcX509CredentialFramework(authority, executor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.impl.framework.x509.bc;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.math.BigInteger;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Duration;
|
||||
@@ -52,8 +55,9 @@ import org.bouncycastle.asn1.x509.KeyUsage;
|
||||
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
|
||||
import org.bouncycastle.cert.X509CertificateHolder;
|
||||
import org.bouncycastle.cert.X509v3CertificateBuilder;
|
||||
import org.bouncycastle.operator.ContentSigner;
|
||||
|
||||
import zeroecho.core.spec.AlgorithmIdentity;
|
||||
import zeroecho.core.io.RepeatableContent;
|
||||
import zeroecho.pki.api.EncodedObject;
|
||||
import zeroecho.pki.api.Encoding;
|
||||
import zeroecho.pki.api.IssuerRef;
|
||||
@@ -67,6 +71,7 @@ import zeroecho.pki.api.credential.Credential;
|
||||
import zeroecho.pki.api.credential.CredentialBundle;
|
||||
import zeroecho.pki.api.credential.CredentialStatus;
|
||||
import zeroecho.pki.api.credential.EndEntityProfileBinding;
|
||||
import zeroecho.pki.api.content.DurableContentReference;
|
||||
import zeroecho.pki.api.profile.LeafKeyUsage;
|
||||
import zeroecho.pki.api.request.SubjectAlternativeName;
|
||||
import zeroecho.pki.impl.core.ValidatedCaCertificateRequest;
|
||||
@@ -74,6 +79,7 @@ import zeroecho.pki.impl.core.ValidatedCertificateRequest;
|
||||
import zeroecho.pki.impl.core.async.PkiSigningBus;
|
||||
import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
|
||||
import zeroecho.pki.spi.framework.CredentialIssuerBackend;
|
||||
import zeroecho.pki.spi.store.ContentSink;
|
||||
|
||||
/**
|
||||
* Bouncy Castle backed X.509 credential issuance backend.
|
||||
@@ -122,7 +128,7 @@ import zeroecho.pki.spi.framework.CredentialIssuerBackend;
|
||||
public final class BcX509CredentialIssuerBackend implements CredentialIssuerBackend {
|
||||
|
||||
private final PkiSigningBus signingBus;
|
||||
private final String signatureAlgorithmId;
|
||||
private final AlgorithmIdentity signatureIdentity;
|
||||
private final Duration signingTtl;
|
||||
|
||||
/**
|
||||
@@ -139,17 +145,29 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
|
||||
* contract
|
||||
*/
|
||||
public BcX509CredentialIssuerBackend(PkiSigningBus signingBus, String signatureAlgorithmId, Duration signingTtl) {
|
||||
this(signingBus, signingBus.authority().resolveIdentity(signatureAlgorithmId), signingTtl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the X.509 issuance backend with an exact signature identity.
|
||||
*
|
||||
* @param signingBus signing bus used to delegate signing
|
||||
* @param signatureIdentity exact provider-independent signature identity
|
||||
* @param signingTtl positive maximum signing TTL
|
||||
*/
|
||||
public BcX509CredentialIssuerBackend(PkiSigningBus signingBus, AlgorithmIdentity signatureIdentity,
|
||||
Duration signingTtl) {
|
||||
if (signingBus == null) {
|
||||
throw new IllegalArgumentException("signingBus must not be null");
|
||||
}
|
||||
if (signatureAlgorithmId == null || signatureAlgorithmId.isBlank()) {
|
||||
throw new IllegalArgumentException("signatureAlgorithmId must not be null/blank");
|
||||
if (signatureIdentity == null || signatureIdentity.kind() != AlgorithmIdentity.Kind.SIGNATURE) {
|
||||
throw new IllegalArgumentException("signatureIdentity must be a signature identity");
|
||||
}
|
||||
if (signingTtl == null || signingTtl.isZero() || signingTtl.isNegative()) {
|
||||
throw new IllegalArgumentException("signingTtl must be positive");
|
||||
}
|
||||
this.signingBus = signingBus;
|
||||
this.signatureAlgorithmId = signatureAlgorithmId;
|
||||
this.signatureIdentity = signatureIdentity;
|
||||
this.signingTtl = signingTtl;
|
||||
}
|
||||
|
||||
@@ -174,13 +192,14 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
|
||||
* fails, or certificate encoding fails
|
||||
*/
|
||||
@Override
|
||||
public CredentialBundle issueEndEntity(ValidatedCertificateRequest request, EncodedObject issuerCertificate,
|
||||
public CredentialBundle issueEndEntity(ValidatedCertificateRequest request,
|
||||
DurableContentReference issuerCertificate,
|
||||
KeyRef issuerKeyRef, BigInteger serial) {
|
||||
if (request == null || issuerCertificate == null || issuerKeyRef == null || serial == null
|
||||
|| serial.signum() <= 0 || serial.toByteArray().length > 20) {
|
||||
throw new IllegalArgumentException("Invalid validated end-entity issuance input");
|
||||
}
|
||||
byte[] issuerDer = issuerCertificate.bytes();
|
||||
byte[] issuerDer = materialize(issuerCertificate);
|
||||
X509CertificateHolder issuer;
|
||||
try {
|
||||
issuer = parseIssuerCertificateOrThrow(issuerDer);
|
||||
@@ -195,7 +214,7 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
|
||||
Date.from(request.validity().notBefore()), Date.from(request.validity().notAfter()), subjectDn, spki);
|
||||
addLeafExtensions(builder, request);
|
||||
|
||||
ContentSigner signer = new PkiBusContentSigner(signingBus, issuerKeyRef, signatureAlgorithmId, signingTtl);
|
||||
PkiBusContentSigner signer = new PkiBusContentSigner(signingBus, issuerKeyRef, signatureIdentity, signingTtl);
|
||||
X509CertificateHolder leaf;
|
||||
try {
|
||||
leaf = builder.build(signer);
|
||||
@@ -214,10 +233,12 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
|
||||
PkiId publicKeyId = new PkiId("spki:" + fingerprintEncoded(request.exactPublicKey()));
|
||||
|
||||
try {
|
||||
DurableContentReference content = stageCertificate(certDer);
|
||||
validateGeneratedCertificate(content, signer);
|
||||
Credential credential = new Credential(credId, BcX509CredentialFramework.FORMAT_ID,
|
||||
new IssuerRef(request.issuerCaId()), request.subjectRef(), request.validity(), serial.toString(),
|
||||
publicKeyId, new EndEntityProfileBinding(request.profileReference()), CredentialStatus.ISSUED,
|
||||
new EncodedObject(Encoding.DER, certDer), SimpleAttributeSet.builder().build());
|
||||
content, SimpleAttributeSet.builder().build());
|
||||
return new CredentialBundle(credential, java.util.List.of(issuerCertificate));
|
||||
} finally {
|
||||
java.util.Arrays.fill(certDer, (byte) 0);
|
||||
@@ -302,7 +323,7 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
|
||||
*/
|
||||
@Override
|
||||
public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest request,
|
||||
EncodedObject issuerCertificate, KeyRef issuerKeyRef) {
|
||||
DurableContentReference issuerCertificate, KeyRef issuerKeyRef) {
|
||||
if (request == null || issuerCertificate == null || issuerKeyRef == null) {
|
||||
throw new IllegalArgumentException("validated CA issuance inputs must not be null");
|
||||
}
|
||||
@@ -310,7 +331,7 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
|
||||
throw new IllegalArgumentException("Unsupported formatId");
|
||||
}
|
||||
|
||||
byte[] issuerDer = issuerCertificate.bytes();
|
||||
byte[] issuerDer = materialize(issuerCertificate);
|
||||
X509CertificateHolder issuer;
|
||||
try {
|
||||
issuer = parseIssuerCertificateOrThrow(issuerDer);
|
||||
@@ -343,7 +364,7 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
|
||||
throw new PkiException("X.509 extension construction failed: code=EXTENSION_BUILD_FAILED");
|
||||
}
|
||||
|
||||
ContentSigner signer = new PkiBusContentSigner(signingBus, issuerKeyRef, signatureAlgorithmId, signingTtl);
|
||||
PkiBusContentSigner signer = new PkiBusContentSigner(signingBus, issuerKeyRef, signatureIdentity, signingTtl);
|
||||
X509CertificateHolder certificate;
|
||||
try {
|
||||
certificate = builder.build(signer);
|
||||
@@ -361,15 +382,66 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
|
||||
PkiId credId = new PkiId("x509:" + sha256Hex(certDer));
|
||||
|
||||
try {
|
||||
DurableContentReference content = stageCertificate(certDer);
|
||||
validateGeneratedCertificate(content, signer);
|
||||
return new Credential(credId, request.formatId(), new IssuerRef(request.issuerCaId()), subjectRef, validity,
|
||||
serial.toString(), publicKeyId, new CaProfileBinding(request.profileReference()),
|
||||
CredentialStatus.ISSUED, new EncodedObject(Encoding.DER, certDer),
|
||||
CredentialStatus.ISSUED, content,
|
||||
SimpleAttributeSet.builder().build());
|
||||
} finally {
|
||||
java.util.Arrays.fill(certDer, (byte) 0);
|
||||
}
|
||||
}
|
||||
|
||||
private DurableContentReference stageCertificate(byte[] certificate) {
|
||||
try (ContentSink sink = signingBus.beginContent(Encoding.DER, DurableContentReference.Lifecycle.PERSISTED);
|
||||
OutputStream output = sink.outputStream()) {
|
||||
output.write(certificate);
|
||||
return sink.complete();
|
||||
} catch (IOException exception) {
|
||||
throw new PkiException("Certificate staging failed: code=SPOOL_STORAGE_FAILED", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void validateGeneratedCertificate(DurableContentReference reference, PkiBusContentSigner signer) {
|
||||
try (RepeatableContent content = signingBus.openContent(reference)) {
|
||||
new BcX509SignedObjectValidator(signingBus.authority()).validateGeneratedCertificate(content,
|
||||
signer.executionPlan(), zeroecho.core.io.CancellationSignal.NONE);
|
||||
} catch (IOException | IllegalArgumentException exception) {
|
||||
signingBus.releaseContent(reference);
|
||||
throw new PkiException("Certificate postcondition failed: code=BACKEND_RESULT_SUBSTITUTION", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] materialize(DurableContentReference reference) {
|
||||
if (reference.length() > Integer.MAX_VALUE) {
|
||||
throw new PkiException("Certificate exceeds BC adapter element domain: code=ADAPTER_ELEMENT_LIMIT_EXCEEDED");
|
||||
}
|
||||
byte[] result = new byte[(int) reference.length()];
|
||||
try {
|
||||
readExact(reference, result);
|
||||
return result;
|
||||
} catch (IOException exception) {
|
||||
throw new PkiException("Certificate content failed: code=CONTENT_IO_FAILED", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void readExact(DurableContentReference reference, byte[] result) throws IOException {
|
||||
try (RepeatableContent content = signingBus.openContent(reference); InputStream input = content.openStream()) {
|
||||
int offset = 0;
|
||||
while (offset != result.length) {
|
||||
int count = input.read(result, offset, result.length - offset);
|
||||
if (count < 0) {
|
||||
throw new IOException("Certificate content is truncated");
|
||||
}
|
||||
offset += count;
|
||||
}
|
||||
if (input.read() >= 0) {
|
||||
throw new IOException("Certificate content has trailing bytes");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static X509CertificateHolder parseIssuerCertificateOrThrow(byte[] issuerCertDer) {
|
||||
try {
|
||||
return new X509CertificateHolder(issuerCertDer);
|
||||
|
||||
@@ -35,17 +35,20 @@ package zeroecho.pki.impl.framework.x509.bc;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||
import org.bouncycastle.operator.ContentVerifierProvider;
|
||||
import org.bouncycastle.operator.OperatorCreationException;
|
||||
import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder;
|
||||
import org.bouncycastle.pkcs.PKCS10CertificationRequest;
|
||||
|
||||
import zeroecho.core.spec.AlgorithmIdentity;
|
||||
import zeroecho.core.io.ImmutableByteContent;
|
||||
import zeroecho.core.spi.AlgorithmExecutionCapability;
|
||||
import zeroecho.pki.api.attr.AttributeValue;
|
||||
import zeroecho.pki.api.issuance.VerificationPolicy;
|
||||
import zeroecho.pki.api.request.ParsedCertificationRequest;
|
||||
import zeroecho.pki.api.request.ProofOfPossessionResult;
|
||||
import zeroecho.pki.api.request.ProofOfPossessionStatus;
|
||||
import zeroecho.pki.impl.framework.x509.X509AlgorithmRole;
|
||||
import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot;
|
||||
import zeroecho.pki.impl.framework.x509.X509ExecutionPlan;
|
||||
import zeroecho.pki.spi.framework.ProofOfPossessionVerifier;
|
||||
|
||||
/**
|
||||
@@ -94,21 +97,38 @@ import zeroecho.pki.spi.framework.ProofOfPossessionVerifier;
|
||||
* authorization checks, or profile compliance checks.</li>
|
||||
* <li>The presence of a valid PKCS#10 self-signature does not by itself imply
|
||||
* that the requester is entitled to receive the requested certificate.</li>
|
||||
* <li>This verifier accepts signature algorithms supported by its Bouncy Castle
|
||||
* provider, including RSASSA-PSS. Deployments requiring a narrower or stronger
|
||||
* algorithm policy must enforce it in the issuance policy layer.</li>
|
||||
* <li>Algorithm identity, X.509 binding, immutable security-floor, capability,
|
||||
* and configured-policy decisions are taken from one injected
|
||||
* {@link X509AuthoritySnapshot}. Bouncy Castle remains only the structural and
|
||||
* cryptographic adapter at this Phase A boundary.</li>
|
||||
* <li>The CSR payload carried under {@link BcX509Attributes#CSR_DER} may be
|
||||
* operationally sensitive and must not be logged unsafely.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <h2>Thread-safety</h2>
|
||||
* <p>
|
||||
* This class is stateless and thread-safe.
|
||||
* This class is immutable and thread-safe when its authority snapshot is shared
|
||||
* as part of one immutable runtime graph.
|
||||
* </p>
|
||||
*/
|
||||
public final class BcX509ProofOfPossessionVerifier implements ProofOfPossessionVerifier {
|
||||
|
||||
private static final BouncyCastleProvider BC_PROVIDER = new BouncyCastleProvider();
|
||||
private final X509AuthoritySnapshot authority;
|
||||
private final BcX509VerificationExecutor executor;
|
||||
|
||||
/**
|
||||
* Creates a verifier backed by one runtime authority and its exact executor.
|
||||
*
|
||||
* @param authority identity, binding, capability, floor, and policy authority;
|
||||
* must not be {@code null}
|
||||
* @param executor process-local verification executor bound into
|
||||
* {@code authority}
|
||||
* @throws NullPointerException if {@code authority} is {@code null}
|
||||
*/
|
||||
public BcX509ProofOfPossessionVerifier(X509AuthoritySnapshot authority, BcX509VerificationExecutor executor) {
|
||||
this.authority = java.util.Objects.requireNonNull(authority, "authority");
|
||||
this.executor = java.util.Objects.requireNonNull(executor, "executor");
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies proof of possession for a parsed PKCS#10 certification request.
|
||||
@@ -123,7 +143,7 @@ public final class BcX509ProofOfPossessionVerifier implements ProofOfPossessionV
|
||||
* Otherwise, the method expects the original CSR DER payload to be present in
|
||||
* {@code request.attributes()} under {@link BcX509Attributes#CSR_DER} as an
|
||||
* {@link AttributeValue.BytesValue}. It then parses the CSR, creates a Bouncy
|
||||
* Castle {@link ContentVerifierProvider} from the CSR's embedded subject public
|
||||
* Castle content verifier from the CSR's embedded subject public
|
||||
* key information, and validates the PKCS#10 signature.
|
||||
* </p>
|
||||
*
|
||||
@@ -179,17 +199,33 @@ public final class BcX509ProofOfPossessionVerifier implements ProofOfPossessionV
|
||||
try {
|
||||
PKCS10CertificationRequest csr;
|
||||
try {
|
||||
BcX509AlgorithmAdapter.requireCanonicalDer(csrDer);
|
||||
csr = new PKCS10CertificationRequest(csrDer);
|
||||
} catch (Exception ex) {
|
||||
return new ProofOfPossessionResult(ProofOfPossessionStatus.FAILED, Optional.of("Invalid CSR"));
|
||||
}
|
||||
ContentVerifierProvider cvp = new JcaContentVerifierProviderBuilder().setProvider(BC_PROVIDER)
|
||||
.build(csr.getSubjectPublicKeyInfo());
|
||||
boolean ok = csr.isSignatureValid(cvp);
|
||||
if (ok) {
|
||||
return new ProofOfPossessionResult(ProofOfPossessionStatus.VERIFIED, Optional.empty());
|
||||
BcX509AlgorithmAdapter algorithmAdapter = new BcX509AlgorithmAdapter(authority.bindings());
|
||||
try {
|
||||
AlgorithmIdentity signatureIdentity = algorithmAdapter.decode(csr.getSignatureAlgorithm(),
|
||||
X509AlgorithmRole.SIGNATURE_ALGORITHM);
|
||||
AlgorithmIdentity keyIdentity = algorithmAdapter.decode(csr.getSubjectPublicKeyInfo().getAlgorithm(),
|
||||
X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM);
|
||||
X509ExecutionPlan<BcX509VerificationExecutor> plan = authority.plan(signatureIdentity, keyIdentity,
|
||||
AlgorithmExecutionCapability.Direction.VERIFY,
|
||||
Optional.of(BcX509VerificationExecutor.IMPLEMENTATION_ID), "csr-proof",
|
||||
BcX509VerificationExecutor.class);
|
||||
byte[] signedBytes = csr.toASN1Structure().getCertificationRequestInfo().getEncoded();
|
||||
boolean valid = executor.verify(authority, plan, csr.getSubjectPublicKeyInfo(),
|
||||
csr.getSignatureAlgorithm(), new ImmutableByteContent(signedBytes), csr.getSignature());
|
||||
if (!valid) {
|
||||
return new ProofOfPossessionResult(ProofOfPossessionStatus.FAILED,
|
||||
Optional.of("CSR signature invalid"));
|
||||
}
|
||||
} catch (IllegalArgumentException unsupported) {
|
||||
return new ProofOfPossessionResult(ProofOfPossessionStatus.FAILED,
|
||||
Optional.of("Unsupported CSR algorithm"));
|
||||
}
|
||||
return new ProofOfPossessionResult(ProofOfPossessionStatus.FAILED, Optional.of("CSR signature invalid"));
|
||||
return new ProofOfPossessionResult(ProofOfPossessionStatus.VERIFIED, Optional.empty());
|
||||
} catch (OperatorCreationException ex) {
|
||||
return new ProofOfPossessionResult(ProofOfPossessionStatus.FAILED, Optional.of("Verifier unavailable"));
|
||||
} catch (Exception ex) {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,27 +33,33 @@
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.impl.framework.x509.bc;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Arrays;
|
||||
import java.util.Date;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.bouncycastle.asn1.x500.X500Name;
|
||||
import org.bouncycastle.asn1.ASN1EncodableVector;
|
||||
import org.bouncycastle.asn1.ASN1Integer;
|
||||
import org.bouncycastle.asn1.DERSequence;
|
||||
import org.bouncycastle.asn1.DERTaggedObject;
|
||||
import org.bouncycastle.asn1.x509.AuthorityKeyIdentifier;
|
||||
import org.bouncycastle.asn1.x509.CRLReason;
|
||||
import org.bouncycastle.asn1.x509.Extension;
|
||||
import org.bouncycastle.cert.X509CRLHolder;
|
||||
import org.bouncycastle.asn1.x509.Extensions;
|
||||
import org.bouncycastle.asn1.x509.Time;
|
||||
import org.bouncycastle.cert.X509CertificateHolder;
|
||||
import org.bouncycastle.cert.X509v2CRLBuilder;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils;
|
||||
import org.bouncycastle.operator.ContentSigner;
|
||||
|
||||
import zeroecho.pki.api.EncodedObject;
|
||||
import zeroecho.core.io.CancellationSignal;
|
||||
import zeroecho.core.io.ContentSlice;
|
||||
import zeroecho.core.io.RepeatableContent;
|
||||
import zeroecho.core.spec.AlgorithmIdentity;
|
||||
import zeroecho.core.spi.AlgorithmExecutionCapability;
|
||||
import zeroecho.pki.api.Encoding;
|
||||
import zeroecho.pki.api.FormatId;
|
||||
import zeroecho.pki.api.KeyRef;
|
||||
@@ -65,9 +71,22 @@ import zeroecho.pki.api.revocation.RevocationReason;
|
||||
import zeroecho.pki.api.status.StatusObject;
|
||||
import zeroecho.pki.api.status.StatusObjectGenerateCommand;
|
||||
import zeroecho.pki.api.status.StatusObjectType;
|
||||
import zeroecho.pki.api.content.DurableContentReference;
|
||||
import zeroecho.pki.impl.core.async.PkiSigningBus;
|
||||
import zeroecho.pki.impl.framework.x509.StreamingDerWriter;
|
||||
import zeroecho.pki.impl.framework.x509.StreamingDerReader;
|
||||
import zeroecho.pki.impl.framework.x509.StreamingDerReader.SignedObjectKind;
|
||||
import zeroecho.pki.impl.framework.x509.StreamingDerReader.SignedObjectLayout;
|
||||
import zeroecho.pki.impl.framework.x509.X509AlgorithmRole;
|
||||
import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot;
|
||||
import zeroecho.pki.impl.framework.x509.X509ExecutionPlan;
|
||||
import zeroecho.pki.impl.framework.x509.X509SignedObjectCompletion;
|
||||
import zeroecho.pki.spi.crypto.SignatureWorkflow;
|
||||
import zeroecho.pki.spi.framework.CrlEntry;
|
||||
import zeroecho.pki.spi.framework.CrlEntrySource;
|
||||
import zeroecho.pki.spi.framework.StatusObjectGenerator;
|
||||
import zeroecho.pki.spi.store.ContentSink;
|
||||
import zeroecho.pki.spi.store.TemporaryUniqueIndex;
|
||||
|
||||
/**
|
||||
* Bouncy Castle backed generator of X.509 status objects.
|
||||
@@ -143,8 +162,10 @@ import zeroecho.pki.spi.framework.StatusObjectGenerator;
|
||||
*/
|
||||
public final class BcX509StatusObjectGenerator implements StatusObjectGenerator {
|
||||
|
||||
private static final long EMPTY_CONTENT_LENGTH = 0L;
|
||||
|
||||
private final PkiSigningBus signingBus;
|
||||
private final String signatureAlgorithmId;
|
||||
private final AlgorithmIdentity signatureIdentity;
|
||||
private final Duration signingTtl;
|
||||
|
||||
/**
|
||||
@@ -161,17 +182,29 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator
|
||||
* contract
|
||||
*/
|
||||
public BcX509StatusObjectGenerator(PkiSigningBus signingBus, String signatureAlgorithmId, Duration signingTtl) {
|
||||
this(signingBus, signingBus.authority().resolveIdentity(signatureAlgorithmId), signingTtl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the generator with an exact signature identity.
|
||||
*
|
||||
* @param signingBus signing bus
|
||||
* @param signatureIdentity exact provider-independent signature identity
|
||||
* @param signingTtl positive signing TTL
|
||||
*/
|
||||
public BcX509StatusObjectGenerator(PkiSigningBus signingBus, AlgorithmIdentity signatureIdentity,
|
||||
Duration signingTtl) {
|
||||
if (signingBus == null) {
|
||||
throw new IllegalArgumentException("signingBus must not be null");
|
||||
}
|
||||
if (signatureAlgorithmId == null || signatureAlgorithmId.isBlank()) {
|
||||
throw new IllegalArgumentException("signatureAlgorithmId must not be null/blank");
|
||||
if (signatureIdentity == null || signatureIdentity.kind() != AlgorithmIdentity.Kind.SIGNATURE) {
|
||||
throw new IllegalArgumentException("signatureIdentity must be a signature identity");
|
||||
}
|
||||
if (signingTtl == null || signingTtl.isZero() || signingTtl.isNegative()) {
|
||||
throw new IllegalArgumentException("signingTtl must be positive");
|
||||
}
|
||||
this.signingBus = signingBus;
|
||||
this.signatureAlgorithmId = signatureAlgorithmId;
|
||||
this.signatureIdentity = signatureIdentity;
|
||||
this.signingTtl = signingTtl;
|
||||
}
|
||||
|
||||
@@ -212,28 +245,240 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator
|
||||
* if signing fails, or if CRL encoding fails
|
||||
*/
|
||||
@Override
|
||||
public StatusObject generate(StatusObjectGenerateCommand command, List<CrlEntry> crlEntries) {
|
||||
public X509SignedObjectCompletion generate(StatusObjectGenerateCommand command, CrlEntrySource crlEntries) {
|
||||
validateCommandOrThrow(command);
|
||||
List<CrlEntry> validatedEntries = validateEntries(crlEntries);
|
||||
if (crlEntries == null) {
|
||||
throw new IllegalArgumentException("crlEntries must not be null");
|
||||
}
|
||||
|
||||
IssuerMaterial issuerMaterial = extractIssuerMaterialOrThrow(command.attributes());
|
||||
Instant thisUpdate = Instant.now();
|
||||
Instant nextUpdate = thisUpdate.plus(Duration.ofDays(7));
|
||||
Date thisUpdateDate = Date.from(thisUpdate);
|
||||
|
||||
X509v2CRLBuilder builder = newCrlBuilder(issuerMaterial.issuerHolder(), thisUpdateDate, nextUpdate);
|
||||
addRevokedEntries(builder, validatedEntries);
|
||||
addAuthorityKeyIdentifierOrThrow(builder, issuerMaterial.issuerHolder());
|
||||
|
||||
X509CRLHolder crl = buildSignedCrlOrThrow(builder, issuerMaterial.keyRef());
|
||||
byte[] crlDer = encodeCrlOrThrow(crl);
|
||||
|
||||
return toStatusObject(command, crlDer, thisUpdate, nextUpdate);
|
||||
DurableContentReference entries = null;
|
||||
DurableContentReference tbs = null;
|
||||
DurableContentReference crl = null;
|
||||
boolean accepted = false;
|
||||
try {
|
||||
entries = encodeEntries(crlEntries);
|
||||
PkiBusContentSigner signer = new PkiBusContentSigner(signingBus, issuerMaterial.keyRef(),
|
||||
signatureIdentity, signingTtl);
|
||||
byte[] algorithm = signer.getAlgorithmIdentifier().getEncoded();
|
||||
tbs = encodeTbs(issuerMaterial.issuerHolder(), thisUpdate, nextUpdate, entries, algorithm);
|
||||
copyToSigner(tbs, signer);
|
||||
byte[] signature = signer.getSignature();
|
||||
crl = encodeOuter(tbs, algorithm, signature);
|
||||
requireValidResult(crl, tbs, signer.executionPlan(), signer.getAlgorithmIdentifier(), signature,
|
||||
issuerMaterial.issuerHolder());
|
||||
StatusObject result = new StatusObject(new PkiId("crl:" + crl.sha256()), command.formatId(),
|
||||
command.issuerCaId(),
|
||||
command.type(), thisUpdate, Optional.of(nextUpdate), crl, command.attributes());
|
||||
X509SignedObjectCompletion completion = signingBus.authority().completeStatusObject(result,
|
||||
signer.executionPlan());
|
||||
accepted = true;
|
||||
return completion;
|
||||
} catch (IOException | ArithmeticException exception) {
|
||||
throw new PkiException("CRL generation failed: code=SPOOL_STORAGE_FAILED", exception);
|
||||
} finally {
|
||||
if (!accepted) {
|
||||
releaseTemporary(crl);
|
||||
}
|
||||
releaseTemporary(tbs);
|
||||
releaseTemporary(entries);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<CrlEntry> validateEntries(List<CrlEntry> crlEntries) {
|
||||
Objects.requireNonNull(crlEntries, "crlEntries");
|
||||
return List.copyOf(crlEntries);
|
||||
private void requireValidResult(DurableContentReference crl, DurableContentReference tbs,
|
||||
X509ExecutionPlan<SignatureWorkflow> signingPlan,
|
||||
org.bouncycastle.asn1.x509.AlgorithmIdentifier algorithm, byte[] signature, X509CertificateHolder issuer)
|
||||
throws IOException {
|
||||
requireAuthorizedSigningPlan(signingBus.authority(), signingPlan, signatureIdentity);
|
||||
try (RepeatableContent complete = signingBus.openContent(crl)) {
|
||||
StreamingDerReader reader = new StreamingDerReader();
|
||||
SignedObjectLayout layout = reader.inspectSignedObject(complete, SignedObjectKind.CRL,
|
||||
CancellationSignal.NONE);
|
||||
byte[] expectedAlgorithm = algorithm.getEncoded();
|
||||
ContentComparison.requireExactSlice(complete, layout.tbsAlgorithmOffset(), layout.tbsAlgorithmLength(),
|
||||
expectedAlgorithm, "CRL TBS binding mismatch");
|
||||
ContentComparison.requireExactSlice(complete, layout.outerAlgorithmOffset(), layout.outerAlgorithmLength(),
|
||||
expectedAlgorithm, "CRL outer binding mismatch");
|
||||
ContentComparison.requireExactSlice(complete, layout.signatureOffset(), layout.signatureLength(), signature,
|
||||
"CRL signature result mismatch");
|
||||
try (RepeatableContent expectedTbs = signingBus.openContent(tbs)) {
|
||||
if (!ContentComparison.contentEquals(complete, layout.tbsOffset(), layout.tbsLength(), expectedTbs)) {
|
||||
throw new PkiException("CRL TBS substitution: code=BACKEND_RESULT_SUBSTITUTION");
|
||||
}
|
||||
}
|
||||
|
||||
BcX509AlgorithmAdapter adapter = new BcX509AlgorithmAdapter(signingBus.authority().bindings());
|
||||
AlgorithmIdentity actualSignature = adapter.decode(algorithm, X509AlgorithmRole.SIGNATURE_ALGORITHM);
|
||||
if (!signingPlan.selection().requested().equals(actualSignature)) {
|
||||
throw new PkiException("CRL binding mismatch: code=STATUS_OBJECT_BINDING_MISMATCH");
|
||||
}
|
||||
AlgorithmIdentity issuerKey = adapter.decode(issuer.getSubjectPublicKeyInfo().getAlgorithm(),
|
||||
X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM);
|
||||
X509ExecutionPlan<BcX509VerificationExecutor> plan = signingBus.authority().plan(actualSignature,
|
||||
issuerKey, AlgorithmExecutionCapability.Direction.VERIFY,
|
||||
Optional.of(BcX509VerificationExecutor.IMPLEMENTATION_ID), "crl-postcondition",
|
||||
BcX509VerificationExecutor.class);
|
||||
try (RepeatableContent signedContent = new ContentSlice(complete, layout.tbsOffset(),
|
||||
layout.tbsLength())) {
|
||||
if (!plan.executor().verify(signingBus.authority(), plan, issuer.getSubjectPublicKeyInfo(), algorithm,
|
||||
signedContent, signature)) {
|
||||
throw new PkiException("CRL signature invalid: code=SIGNATURE_VERIFICATION_FAILED");
|
||||
}
|
||||
}
|
||||
} catch (java.security.GeneralSecurityException | org.bouncycastle.operator.OperatorCreationException ex) {
|
||||
throw new PkiException("CRL verification failed: code=SIGNATURE_VERIFICATION_FAILED", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* The status-object postcondition accepts only the exact live plan minted by
|
||||
* its runtime authority. An equal public fingerprint cannot substitute for
|
||||
* opaque process-local provenance.
|
||||
*/
|
||||
/* default */ static void requireAuthorizedSigningPlan(X509AuthoritySnapshot authority,
|
||||
X509ExecutionPlan<SignatureWorkflow> signingPlan, AlgorithmIdentity expectedIdentity) {
|
||||
authority.authorize(signingPlan, signingPlan.executor(), AlgorithmExecutionCapability.Direction.SIGN);
|
||||
if (!expectedIdentity.equals(signingPlan.selection().requested())) {
|
||||
throw new PkiException("CRL signing-plan substitution: code=BACKEND_RESULT_SUBSTITUTION");
|
||||
}
|
||||
}
|
||||
|
||||
private DurableContentReference encodeEntries(CrlEntrySource source) throws IOException {
|
||||
try (ContentSink sink = signingBus.beginContent(Encoding.DER, DurableContentReference.Lifecycle.TEMPORARY);
|
||||
OutputStream output = sink.outputStream();
|
||||
CrlEntrySource.Cursor cursor = source.openCursor();
|
||||
TemporaryUniqueIndex serials = signingBus.beginUniqueIndex()) {
|
||||
long expectedOrdinal = 0L;
|
||||
while (cursor.next()) {
|
||||
if (cursor.ordinal() != expectedOrdinal) {
|
||||
throw new PkiException("CRL entry order invalid: code=MALFORMED_SIGNED_OBJECT");
|
||||
}
|
||||
CrlEntry entry = cursor.current();
|
||||
if (!serials.add(unsignedSerial(entry))) {
|
||||
throw new PkiException("Duplicate CRL serial: code=MALFORMED_SIGNED_OBJECT");
|
||||
}
|
||||
output.write(encodeEntry(entry));
|
||||
expectedOrdinal = Math.addExact(expectedOrdinal, 1L);
|
||||
}
|
||||
return sink.complete();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] unsignedSerial(CrlEntry entry) {
|
||||
byte[] encoded = entry.serialNumber().toByteArray();
|
||||
if (encoded.length > 1 && encoded[0] == 0) {
|
||||
return Arrays.copyOfRange(encoded, 1, encoded.length);
|
||||
}
|
||||
return encoded;
|
||||
}
|
||||
|
||||
private static byte[] encodeEntry(CrlEntry entry) throws IOException {
|
||||
if (entry == null) {
|
||||
throw new IllegalArgumentException("CRL entry must not be null");
|
||||
}
|
||||
ASN1EncodableVector values = new ASN1EncodableVector(3);
|
||||
values.add(new ASN1Integer(entry.serialNumber()));
|
||||
values.add(new Time(Date.from(entry.transitionTime().truncatedTo(ChronoUnit.SECONDS))));
|
||||
int reason = CrlReasonAdapter.code(entry.reason());
|
||||
if (reason != CRLReason.unspecified) {
|
||||
Extension reasonExtension = Extension.create(Extension.reasonCode, false, CRLReason.lookup(reason));
|
||||
values.add(new Extensions(reasonExtension));
|
||||
}
|
||||
return new DERSequence(values).getEncoded();
|
||||
}
|
||||
|
||||
private DurableContentReference encodeTbs(X509CertificateHolder issuer, Instant thisUpdate, Instant nextUpdate,
|
||||
DurableContentReference entries, byte[] algorithm) throws IOException {
|
||||
byte[] version = new ASN1Integer(1L).getEncoded();
|
||||
byte[] issuerName = issuer.getSubject().getEncoded();
|
||||
byte[] thisUpdateDer = new Time(Date.from(thisUpdate)).getEncoded();
|
||||
byte[] nextUpdateDer = new Time(Date.from(nextUpdate)).getEncoded();
|
||||
byte[] extensions = crlExtensions(issuer);
|
||||
long entriesLength = entries.length() == EMPTY_CONTENT_LENGTH
|
||||
? EMPTY_CONTENT_LENGTH : StreamingDerWriter.encodedLength(entries.length());
|
||||
long valueLength = checkedSum(version.length, algorithm.length, issuerName.length, thisUpdateDer.length,
|
||||
nextUpdateDer.length, entriesLength, extensions.length);
|
||||
try (ContentSink sink = signingBus.beginContent(Encoding.DER, DurableContentReference.Lifecycle.TEMPORARY);
|
||||
OutputStream output = sink.outputStream()) {
|
||||
StreamingDerWriter.writeTagAndLength(output, StreamingDerWriter.SEQUENCE_TAG, valueLength);
|
||||
output.write(version);
|
||||
output.write(algorithm);
|
||||
output.write(issuerName);
|
||||
output.write(thisUpdateDer);
|
||||
output.write(nextUpdateDer);
|
||||
if (entries.length() != EMPTY_CONTENT_LENGTH) {
|
||||
StreamingDerWriter.writeTagAndLength(output, StreamingDerWriter.SEQUENCE_TAG, entries.length());
|
||||
try (RepeatableContent content = signingBus.openContent(entries);
|
||||
InputStream input = content.openStream()) {
|
||||
requireLength(entries.length(),
|
||||
StreamingDerWriter.copy(input, output, CancellationSignal.NONE));
|
||||
}
|
||||
}
|
||||
output.write(extensions);
|
||||
return sink.complete();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] crlExtensions(X509CertificateHolder issuer) {
|
||||
try {
|
||||
AuthorityKeyIdentifier authorityKeyIdentifier = new JcaX509ExtensionUtils()
|
||||
.createAuthorityKeyIdentifier(issuer.getSubjectPublicKeyInfo());
|
||||
Extensions extensions = new Extensions(
|
||||
Extension.create(Extension.authorityKeyIdentifier, false, authorityKeyIdentifier));
|
||||
return new DERTaggedObject(true, 0, extensions).getEncoded();
|
||||
} catch (Exception exception) {
|
||||
throw new PkiException("Failed to build CRL extensions", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void copyToSigner(DurableContentReference tbs, PkiBusContentSigner signer) throws IOException {
|
||||
try (RepeatableContent content = signingBus.openContent(tbs);
|
||||
InputStream input = content.openStream();
|
||||
OutputStream output = signer.getOutputStream()) {
|
||||
requireLength(tbs.length(), StreamingDerWriter.copy(input, output, CancellationSignal.NONE));
|
||||
}
|
||||
}
|
||||
|
||||
private DurableContentReference encodeOuter(DurableContentReference tbs, byte[] algorithm, byte[] signature)
|
||||
throws IOException {
|
||||
long bitStringValueLength = Math.addExact(1L, signature.length);
|
||||
long valueLength = checkedSum(tbs.length(), algorithm.length,
|
||||
StreamingDerWriter.encodedLength(bitStringValueLength));
|
||||
try (ContentSink sink = signingBus.beginContent(Encoding.DER, DurableContentReference.Lifecycle.PERSISTED);
|
||||
OutputStream output = sink.outputStream()) {
|
||||
StreamingDerWriter.writeTagAndLength(output, StreamingDerWriter.SEQUENCE_TAG, valueLength);
|
||||
try (RepeatableContent content = signingBus.openContent(tbs);
|
||||
InputStream input = content.openStream()) {
|
||||
requireLength(tbs.length(), StreamingDerWriter.copy(input, output, CancellationSignal.NONE));
|
||||
}
|
||||
output.write(algorithm);
|
||||
StreamingDerWriter.writeTagAndLength(output, StreamingDerWriter.BIT_STRING_TAG, bitStringValueLength);
|
||||
output.write(0);
|
||||
output.write(signature);
|
||||
return sink.complete();
|
||||
}
|
||||
}
|
||||
|
||||
private static long checkedSum(long... values) {
|
||||
long total = 0L;
|
||||
for (long value : values) {
|
||||
total = Math.addExact(total, value);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private static void requireLength(long expected, long actual) throws IOException {
|
||||
if (expected != actual) {
|
||||
throw new IOException("Staged content length changed");
|
||||
}
|
||||
}
|
||||
|
||||
private void releaseTemporary(DurableContentReference reference) {
|
||||
if (reference != null) {
|
||||
signingBus.releaseContent(reference);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -293,141 +538,6 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator
|
||||
return new IssuerMaterial(issuerHolder, keyRef);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a CRL builder initialized with issuer and update information.
|
||||
*
|
||||
* @param issuerHolder parsed issuer certificate holder; must not be
|
||||
* {@code null}
|
||||
* @param thisUpdate current update timestamp as {@link Date}; must not be
|
||||
* {@code null}
|
||||
* @param nextUpdate next update timestamp as {@link Instant}; must not be
|
||||
* {@code null}
|
||||
* @return initialized CRL builder
|
||||
*/
|
||||
private static X509v2CRLBuilder newCrlBuilder(X509CertificateHolder issuerHolder, Date thisUpdate,
|
||||
Instant nextUpdate) {
|
||||
X500Name issuerDn = issuerHolder.getSubject();
|
||||
X509v2CRLBuilder builder = new X509v2CRLBuilder(issuerDn, thisUpdate);
|
||||
builder.setNextUpdate(Date.from(nextUpdate));
|
||||
return builder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds validated structured revoked-certificate entries to the CRL builder.
|
||||
*
|
||||
* @param builder target CRL builder; must not be {@code null}
|
||||
* @param entries validated structured entries
|
||||
*/
|
||||
private static void addRevokedEntries(X509v2CRLBuilder builder, List<CrlEntry> entries) {
|
||||
for (CrlEntry entry : entries) {
|
||||
Instant encodedTime = entry.transitionTime().truncatedTo(ChronoUnit.SECONDS);
|
||||
builder.addCRLEntry(entry.serialNumber(), Date.from(encodedTime), reasonCode(entry.reason()));
|
||||
}
|
||||
}
|
||||
|
||||
private static int reasonCode(RevocationReason reason) {
|
||||
return switch (reason) {
|
||||
case UNSPECIFIED -> CRLReason.unspecified;
|
||||
case KEY_COMPROMISE -> CRLReason.keyCompromise;
|
||||
case CA_COMPROMISE -> CRLReason.cACompromise;
|
||||
case AFFILIATION_CHANGED -> CRLReason.affiliationChanged;
|
||||
case SUPERSEDED -> CRLReason.superseded;
|
||||
case CESSATION_OF_OPERATION -> CRLReason.cessationOfOperation;
|
||||
case CERTIFICATE_HOLD -> CRLReason.certificateHold;
|
||||
case REMOVE_FROM_CRL -> throw new IllegalArgumentException("REMOVE_FROM_CRL is not an active CRL entry");
|
||||
case PRIVILEGE_WITHDRAWN -> CRLReason.privilegeWithdrawn;
|
||||
case AA_COMPROMISE -> CRLReason.aACompromise;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the authority key identifier extension to the CRL builder.
|
||||
*
|
||||
* @param builder target CRL builder; must not be {@code null}
|
||||
* @param issuerHolder parsed issuer certificate holder; must not be
|
||||
* {@code null}
|
||||
* @throws PkiException if the extension cannot be derived or added
|
||||
*/
|
||||
private static void addAuthorityKeyIdentifierOrThrow(X509v2CRLBuilder builder, X509CertificateHolder issuerHolder) {
|
||||
try {
|
||||
JcaX509ExtensionUtils extensionUtils = new JcaX509ExtensionUtils();
|
||||
AuthorityKeyIdentifier authorityKeyIdentifier = extensionUtils
|
||||
.createAuthorityKeyIdentifier(issuerHolder.getSubjectPublicKeyInfo());
|
||||
builder.addExtension(Extension.authorityKeyIdentifier, false, authorityKeyIdentifier);
|
||||
} catch (Exception ex) {
|
||||
throw new PkiException("Failed to build CRL extensions", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds and signs the CRL using the configured PKI signing boundary.
|
||||
*
|
||||
* @param builder prepared CRL builder; must not be {@code null}
|
||||
* @param issuerKeyRef issuer signing key reference; must not be {@code null}
|
||||
* @return signed CRL holder
|
||||
* @throws PkiException if delegated signing fails
|
||||
*/
|
||||
private X509CRLHolder buildSignedCrlOrThrow(X509v2CRLBuilder builder, KeyRef issuerKeyRef) {
|
||||
ContentSigner signer = new PkiBusContentSigner(signingBus, issuerKeyRef, signatureAlgorithmId, signingTtl);
|
||||
try {
|
||||
return builder.build(signer);
|
||||
} catch (Exception ex) {
|
||||
throw new PkiException("CRL signing failed", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes the signed CRL into DER form.
|
||||
*
|
||||
* @param crl signed CRL holder; must not be {@code null}
|
||||
* @return DER-encoded CRL bytes
|
||||
* @throws PkiException if CRL encoding fails
|
||||
*/
|
||||
private static byte[] encodeCrlOrThrow(X509CRLHolder crl) {
|
||||
try {
|
||||
return crl.getEncoded();
|
||||
} catch (Exception ex) {
|
||||
throw new PkiException("CRL encoding failed", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the PKI status object representation of the generated CRL.
|
||||
*
|
||||
* @param command original generation command; must not be {@code null}
|
||||
* @param crlDer DER-encoded CRL bytes; must not be {@code null}
|
||||
* @param thisUpdate generated {@code thisUpdate} timestamp; must not be
|
||||
* {@code null}
|
||||
* @param nextUpdate generated {@code nextUpdate} timestamp; must not be
|
||||
* {@code null}
|
||||
* @return resulting status object
|
||||
*/
|
||||
private static StatusObject toStatusObject(StatusObjectGenerateCommand command, byte[] crlDer, Instant thisUpdate,
|
||||
Instant nextUpdate) {
|
||||
PkiId id = new PkiId("crl:" + sha256Hex(crlDer));
|
||||
EncodedObject encoded = new EncodedObject(Encoding.DER, crlDer);
|
||||
return new StatusObject(id, command.formatId(), command.issuerCaId(), command.type(), thisUpdate,
|
||||
Optional.of(nextUpdate), encoded, command.attributes());
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the SHA-256 digest of the supplied bytes and returns it as a
|
||||
* lowercase hexadecimal string.
|
||||
*
|
||||
* @param in input bytes; must not be {@code null}
|
||||
* @return hexadecimal SHA-256 digest
|
||||
* @throws IllegalStateException if SHA-256 is unexpectedly unavailable in the
|
||||
* runtime
|
||||
*/
|
||||
private static String sha256Hex(byte[] in) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
return HexFormat.of().formatHex(md.digest(in));
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException("SHA-256 not available", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutable issuer-side material required for CRL generation.
|
||||
*
|
||||
@@ -436,4 +546,93 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator
|
||||
*/
|
||||
private record IssuerMaterial(X509CertificateHolder issuerHolder, KeyRef keyRef) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps one bounded revocation-reason element to the standard CRL code.
|
||||
*/
|
||||
private static final class CrlReasonAdapter {
|
||||
private static int code(RevocationReason reason) {
|
||||
return switch (reason) {
|
||||
case UNSPECIFIED -> CRLReason.unspecified;
|
||||
case KEY_COMPROMISE -> CRLReason.keyCompromise;
|
||||
case CA_COMPROMISE -> CRLReason.cACompromise;
|
||||
case AFFILIATION_CHANGED -> CRLReason.affiliationChanged;
|
||||
case SUPERSEDED -> CRLReason.superseded;
|
||||
case CESSATION_OF_OPERATION -> CRLReason.cessationOfOperation;
|
||||
case CERTIFICATE_HOLD -> CRLReason.certificateHold;
|
||||
case REMOVE_FROM_CRL ->
|
||||
throw new IllegalArgumentException("REMOVE_FROM_CRL is not an active CRL entry");
|
||||
case PRIVILEGE_WITHDRAWN -> CRLReason.privilegeWithdrawn;
|
||||
case AA_COMPROMISE -> CRLReason.aACompromise;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Incremental comparison of immutable signed-object slices.
|
||||
*/
|
||||
private static final class ContentComparison {
|
||||
private static final int BUFFER_LENGTH = 8192;
|
||||
|
||||
private static void requireExactSlice(RepeatableContent content, long offset, long length, byte[] expected,
|
||||
String message) throws IOException {
|
||||
if (length != expected.length) {
|
||||
throw new PkiException(message + ": code=STATUS_OBJECT_BINDING_MISMATCH");
|
||||
}
|
||||
try (InputStream input = new ContentSlice(content, offset, length).openStream()) {
|
||||
byte[] buffer = new byte[BUFFER_LENGTH];
|
||||
int expectedOffset = 0;
|
||||
while (expectedOffset != expected.length) {
|
||||
int count = input.read(buffer, 0, Math.min(buffer.length, expected.length - expectedOffset));
|
||||
if (count < 0 || !Arrays.equals(buffer, 0, count, expected, expectedOffset,
|
||||
expectedOffset + count)) {
|
||||
throw new PkiException(message + ": code=STATUS_OBJECT_BINDING_MISMATCH");
|
||||
}
|
||||
expectedOffset += count;
|
||||
}
|
||||
if (input.read() >= 0) {
|
||||
throw new PkiException(message + ": code=STATUS_OBJECT_BINDING_MISMATCH");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean contentEquals(RepeatableContent content, long offset, long length,
|
||||
RepeatableContent expected) throws IOException {
|
||||
if (expected.length().isEmpty() || length != expected.length().getAsLong()) {
|
||||
return false;
|
||||
}
|
||||
try (InputStream left = new ContentSlice(content, offset, length).openStream();
|
||||
InputStream right = expected.openStream()) {
|
||||
byte[] leftBuffer = new byte[BUFFER_LENGTH];
|
||||
byte[] rightBuffer = new byte[BUFFER_LENGTH];
|
||||
while (true) {
|
||||
int leftCount = readBlock(left, leftBuffer);
|
||||
int rightCount = readBlock(right, rightBuffer);
|
||||
if (leftCount != rightCount) {
|
||||
return false;
|
||||
}
|
||||
if (leftCount < 0) {
|
||||
return true;
|
||||
}
|
||||
if (!Arrays.equals(leftBuffer, 0, leftCount, rightBuffer, 0, rightCount)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static int readBlock(InputStream input, byte[] buffer) throws IOException {
|
||||
int total = 0;
|
||||
while (total != buffer.length) {
|
||||
int count = input.read(buffer, total, buffer.length - total);
|
||||
if (count < 0) {
|
||||
return total == 0 ? -1 : total;
|
||||
}
|
||||
if (count != 0) {
|
||||
total += count;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -33,12 +33,16 @@
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.impl.framework.x509.bc;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
|
||||
import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
|
||||
|
||||
import zeroecho.core.spec.AlgorithmIdentity;
|
||||
import zeroecho.pki.impl.framework.x509.StandardX509Bindings;
|
||||
import zeroecho.pki.impl.framework.x509.X509AlgorithmIdentifier;
|
||||
import zeroecho.pki.impl.framework.x509.X509AlgorithmRole;
|
||||
import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot;
|
||||
|
||||
/**
|
||||
* Internal mapper from X.509 and PKCS#10 signature algorithm identifiers to
|
||||
* ZeroEcho canonical signature algorithm ids.
|
||||
@@ -67,11 +71,13 @@ import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
|
||||
*
|
||||
* <h2>Null and unsupported handling</h2>
|
||||
* <ul>
|
||||
* <li>{@link #toZeroEchoAlgorithmId(AlgorithmIdentifier)} returns {@code null}
|
||||
* <li>{@link #toZeroEchoAlgorithmId(AlgorithmIdentifier, X509AuthoritySnapshot)}
|
||||
* returns {@code null}
|
||||
* when the supplied identifier is {@code null}, when its embedded
|
||||
* {@link ASN1ObjectIdentifier} is {@code null}, when the OID string is blank,
|
||||
* object identifier is {@code null}, when the OID string is blank,
|
||||
* or when the OID is not present in the mapping table.</li>
|
||||
* <li>{@link #toZeroEchoAlgorithmId(String)} requires a non-{@code null} OID
|
||||
* <li>{@link #toZeroEchoAlgorithmId(String, X509AuthoritySnapshot)} requires a
|
||||
* non-{@code null} OID
|
||||
* string but still returns {@code null} for blank or unsupported values.</li>
|
||||
* </ul>
|
||||
*
|
||||
@@ -105,27 +111,6 @@ public final class OidAlgorithmMapper {
|
||||
* reviewable, and easy to maintain.
|
||||
* </p>
|
||||
*/
|
||||
@SuppressWarnings("PMD.AvoidUsingHardCodedIP")
|
||||
private static final Map<String, String> OID_TO_ZEROECHO_ALG = Map.ofEntries(
|
||||
// RSA PKCS#1 v1.5 digests
|
||||
Map.entry("1.2.840.113549.1.1.11", "SHA256withRSA"), // sha256WithRSAEncryption
|
||||
Map.entry("1.2.840.113549.1.1.12", "SHA384withRSA"), // sha384WithRSAEncryption
|
||||
Map.entry("1.2.840.113549.1.1.13", "SHA512withRSA"), // sha512WithRSAEncryption
|
||||
Map.entry("1.2.840.113549.1.1.5", "SHA1withRSA"), // sha1WithRSAEncryption
|
||||
|
||||
// ECDSA digests
|
||||
Map.entry("1.2.840.10045.4.3.2", "SHA256withECDSA"), // ecdsa-with-SHA256
|
||||
Map.entry("1.2.840.10045.4.3.3", "SHA384withECDSA"), // ecdsa-with-SHA384
|
||||
Map.entry("1.2.840.10045.4.3.4", "SHA512withECDSA"), // ecdsa-with-SHA512
|
||||
Map.entry("1.2.840.10045.4.1", "SHA1withECDSA"), // ecdsa-with-SHA1
|
||||
|
||||
// EdDSA
|
||||
Map.entry("1.3.101.112", "Ed25519"), // Ed25519
|
||||
Map.entry("1.3.101.113", "Ed448") // Ed448
|
||||
|
||||
// PQC OIDs vary by provider/standard; extend as needed.
|
||||
);
|
||||
|
||||
/**
|
||||
* Creates no instances.
|
||||
*/
|
||||
@@ -137,29 +122,28 @@ public final class OidAlgorithmMapper {
|
||||
* signature algorithm identifier.
|
||||
*
|
||||
* <p>
|
||||
* This method extracts the underlying {@link ASN1ObjectIdentifier}, converts it
|
||||
* to its dotted-decimal string form, and performs a lookup in the immutable
|
||||
* mapping table.
|
||||
* This method extracts the underlying object identifier, converts it to its
|
||||
* dotted-decimal string form, and resolves it through the injected authority.
|
||||
* </p>
|
||||
*
|
||||
* @param sigAlg ASN.1 signature algorithm identifier, possibly {@code null}
|
||||
* @param authority immutable algorithm authority
|
||||
* @return ZeroEcho canonical signature algorithm identifier, or {@code null}
|
||||
* when the input is {@code null}, structurally incomplete, blank, or
|
||||
* unsupported
|
||||
*/
|
||||
public static String toZeroEchoAlgorithmId(AlgorithmIdentifier sigAlg) {
|
||||
public static String toZeroEchoAlgorithmId(AlgorithmIdentifier sigAlg, X509AuthoritySnapshot authority) {
|
||||
if (sigAlg == null) {
|
||||
return null;
|
||||
}
|
||||
ASN1ObjectIdentifier oid = sigAlg.getAlgorithm();
|
||||
if (oid == null) {
|
||||
try {
|
||||
AlgorithmIdentity identity = new BcX509AlgorithmAdapter(
|
||||
Objects.requireNonNull(authority, "authority").bindings())
|
||||
.decode(sigAlg, X509AlgorithmRole.SIGNATURE_ALGORITHM);
|
||||
return identity.canonicalForm();
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return null;
|
||||
}
|
||||
String id = oid.getId();
|
||||
if (id == null || id.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return OID_TO_ZEROECHO_ALG.get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,15 +158,34 @@ public final class OidAlgorithmMapper {
|
||||
*
|
||||
* @param oid dotted-decimal signature algorithm OID string; must not be
|
||||
* {@code null}
|
||||
* @param authority immutable algorithm authority
|
||||
* @return ZeroEcho canonical signature algorithm identifier, or {@code null}
|
||||
* when the supplied OID string is blank or unsupported
|
||||
* @throws NullPointerException if {@code oid} is {@code null}
|
||||
*/
|
||||
public static String toZeroEchoAlgorithmId(String oid) {
|
||||
public static String toZeroEchoAlgorithmId(String oid, X509AuthoritySnapshot authority) {
|
||||
Objects.requireNonNull(oid, "oid");
|
||||
if (oid.isBlank()) {
|
||||
X509AlgorithmIdentifier identifier = null;
|
||||
if (StandardX509Bindings.OID_RSA_SHA256.equals(oid)
|
||||
|| StandardX509Bindings.OID_RSA_SHA384.equals(oid)
|
||||
|| StandardX509Bindings.OID_RSA_SHA512.equals(oid)) {
|
||||
identifier = X509AlgorithmIdentifier.derNull(oid);
|
||||
} else if (StandardX509Bindings.OID_ECDSA_SHA256.equals(oid)
|
||||
|| StandardX509Bindings.OID_ECDSA_SHA384.equals(oid)
|
||||
|| StandardX509Bindings.OID_ECDSA_SHA512.equals(oid)
|
||||
|| StandardX509Bindings.OID_ED25519.equals(oid)
|
||||
|| StandardX509Bindings.OID_ED448.equals(oid)) {
|
||||
identifier = X509AlgorithmIdentifier.absent(oid);
|
||||
}
|
||||
if (identifier == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Objects.requireNonNull(authority, "authority").bindings()
|
||||
.reverse(identifier, X509AlgorithmRole.SIGNATURE_ALGORITHM)
|
||||
.canonicalForm();
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return null;
|
||||
}
|
||||
return OID_TO_ZEROECHO_ALG.get(oid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,27 +33,31 @@
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.impl.framework.x509.bc;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
|
||||
import org.bouncycastle.operator.ContentSigner;
|
||||
import org.bouncycastle.operator.DefaultSignatureAlgorithmIdentifierFinder;
|
||||
|
||||
import zeroecho.core.spec.AlgorithmIdentity;
|
||||
import zeroecho.pki.api.EncodedObject;
|
||||
import zeroecho.pki.api.Encoding;
|
||||
import zeroecho.pki.api.KeyRef;
|
||||
import zeroecho.pki.api.PkiException;
|
||||
import zeroecho.pki.api.PkiId;
|
||||
import zeroecho.pki.api.content.DurableContentReference;
|
||||
import zeroecho.pki.api.audit.AccessContext;
|
||||
import zeroecho.pki.api.audit.Principal;
|
||||
import zeroecho.pki.api.audit.Purpose;
|
||||
import zeroecho.pki.impl.core.async.PkiSigningBus;
|
||||
import zeroecho.pki.impl.core.async.PkiSigningBus.SignContinuation;
|
||||
import zeroecho.pki.impl.framework.x509.X509AlgorithmRole;
|
||||
import zeroecho.pki.impl.framework.x509.X509ExecutionPlan;
|
||||
import zeroecho.pki.spi.crypto.SignatureWorkflow;
|
||||
import zeroecho.pki.spi.store.ContentSink;
|
||||
import zeroecho.pki.util.async.AsyncState;
|
||||
|
||||
/**
|
||||
@@ -65,8 +69,8 @@ import zeroecho.pki.util.async.AsyncState;
|
||||
* contract to the PKI runtime signing boundary represented by
|
||||
* {@link PkiSigningBus}. Bouncy Castle writes the to-be-signed bytes into the
|
||||
* output stream returned by {@link #getOutputStream()}, and when
|
||||
* {@link #getSignature()} is invoked this signer submits the collected bytes to
|
||||
* the PKI signing bus as a delegated sign workflow.
|
||||
* {@link #getSignature()} is invoked this signer atomically completes the
|
||||
* file-backed content and submits its durable reference to the PKI signing bus.
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
@@ -79,7 +83,8 @@ import zeroecho.pki.util.async.AsyncState;
|
||||
*
|
||||
* <h2>Execution model</h2>
|
||||
* <ul>
|
||||
* <li>The to-be-signed payload is accumulated fully in memory.</li>
|
||||
* <li>The to-be-signed payload is streamed to the runtime-owned staged-content
|
||||
* store with {@code long} accounting.</li>
|
||||
* <li>A synthetic system owner and purpose are used to submit the signing
|
||||
* operation.</li>
|
||||
* <li>A fresh client operation identifier is generated for each signature
|
||||
@@ -95,7 +100,7 @@ import zeroecho.pki.util.async.AsyncState;
|
||||
* <h2>Security considerations</h2>
|
||||
* <ul>
|
||||
* <li>This class never accesses private key material directly.</li>
|
||||
* <li>The buffered to-be-signed payload and the returned signature bytes are
|
||||
* <li>The staged to-be-signed content and returned signature bytes are
|
||||
* operationally sensitive and must not be logged.</li>
|
||||
* <li>The generated operation identifier is an internal workflow handle and
|
||||
* must not be treated as a durable business identifier outside the signing
|
||||
@@ -104,9 +109,8 @@ import zeroecho.pki.util.async.AsyncState;
|
||||
*
|
||||
* <h2>Thread-safety</h2>
|
||||
* <p>
|
||||
* Instances of this class are not thread-safe. Each instance maintains mutable
|
||||
* in-memory state through its internal {@link ByteArrayOutputStream} and is
|
||||
* intended for one certificate or CRL signing flow.
|
||||
* Instances of this class are not thread-safe. Each instance owns one sequential
|
||||
* staged-content sink and is intended for one certificate or CRL signing flow.
|
||||
* </p>
|
||||
*/
|
||||
// PMD cannot infer that retaining provider causes would violate the redaction contract.
|
||||
@@ -115,10 +119,12 @@ public final class PkiBusContentSigner implements ContentSigner {
|
||||
|
||||
private final PkiSigningBus bus;
|
||||
private final KeyRef keyRef;
|
||||
private final String algorithmId;
|
||||
private final AlgorithmIdentity algorithmIdentity;
|
||||
private final X509ExecutionPlan<SignatureWorkflow> executionPlan;
|
||||
private final Duration ttl;
|
||||
|
||||
private final WipeableByteArrayOutputStream baos;
|
||||
private final ContentSink contentSink;
|
||||
private final OutputStream contentOutput;
|
||||
|
||||
/**
|
||||
* Creates a signer that routes signature generation through the PKI signing
|
||||
@@ -136,23 +142,52 @@ public final class PkiBusContentSigner implements ContentSigner {
|
||||
* contract
|
||||
*/
|
||||
public PkiBusContentSigner(PkiSigningBus bus, KeyRef keyRef, String algorithmId, Duration ttl) {
|
||||
this(bus, keyRef, bus.authority().resolveIdentity(algorithmId), ttl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a signer from an exact provider-independent signature identity.
|
||||
*
|
||||
* @param bus signing bus
|
||||
* @param keyRef signing key reference
|
||||
* @param algorithmIdentity exact signature identity
|
||||
* @param ttl positive workflow time-to-live
|
||||
* @throws IllegalArgumentException if an argument violates the contract or no
|
||||
* authoritative signature binding exists
|
||||
*/
|
||||
public PkiBusContentSigner(PkiSigningBus bus, KeyRef keyRef, AlgorithmIdentity algorithmIdentity, Duration ttl) {
|
||||
if (bus == null) {
|
||||
throw new IllegalArgumentException("bus must not be null");
|
||||
}
|
||||
if (keyRef == null) {
|
||||
throw new IllegalArgumentException("keyRef must not be null");
|
||||
}
|
||||
if (algorithmId == null || algorithmId.isBlank()) {
|
||||
throw new IllegalArgumentException("algorithmId must not be null/blank");
|
||||
if (algorithmIdentity == null || algorithmIdentity.kind() != AlgorithmIdentity.Kind.SIGNATURE) {
|
||||
throw new IllegalArgumentException("algorithmIdentity must be a signature identity");
|
||||
}
|
||||
if (ttl == null || ttl.isZero() || ttl.isNegative()) {
|
||||
throw new IllegalArgumentException("ttl must be positive");
|
||||
}
|
||||
this.bus = bus;
|
||||
this.keyRef = keyRef;
|
||||
this.algorithmId = algorithmId;
|
||||
X509ExecutionPlan<SignatureWorkflow> plan = bus.authority()
|
||||
.planSigning(algorithmIdentity.canonicalForm(), SignatureWorkflow.class);
|
||||
bus.authority().authorize(plan, plan.executor(),
|
||||
zeroecho.core.spi.AlgorithmExecutionCapability.Direction.SIGN);
|
||||
this.executionPlan = plan;
|
||||
this.algorithmIdentity = plan.selection().requested();
|
||||
this.ttl = ttl;
|
||||
this.baos = new WipeableByteArrayOutputStream();
|
||||
this.contentSink = bus.beginSigningContent(Encoding.BINARY);
|
||||
try {
|
||||
this.contentOutput = contentSink.outputStream();
|
||||
} catch (IOException ex) {
|
||||
try {
|
||||
contentSink.close();
|
||||
} catch (IOException cleanupFailure) {
|
||||
ex.addSuppressed(cleanupFailure);
|
||||
}
|
||||
throw new PkiException("Signing content staging failed: code=SPOOL_STORAGE_FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -170,22 +205,31 @@ public final class PkiBusContentSigner implements ContentSigner {
|
||||
*/
|
||||
@Override
|
||||
public AlgorithmIdentifier getAlgorithmIdentifier() {
|
||||
return new DefaultSignatureAlgorithmIdentifierFinder().find(algorithmId);
|
||||
return new BcX509AlgorithmAdapter(bus.authority().bindings()).encode(algorithmIdentity,
|
||||
X509AlgorithmRole.SIGNATURE_ALGORITHM);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the output stream used to collect the to-be-signed bytes.
|
||||
*
|
||||
* <p>
|
||||
* Data written to this stream is buffered in memory until
|
||||
* Data written to this stream is staged without aggregate heap buffering until
|
||||
* {@link #getSignature()} is called.
|
||||
* </p>
|
||||
*
|
||||
* @return mutable in-memory output stream receiving the to-be-signed payload
|
||||
* @return sequential staged-content output stream
|
||||
*/
|
||||
@Override
|
||||
public OutputStream getOutputStream() {
|
||||
return baos;
|
||||
return contentOutput;
|
||||
}
|
||||
|
||||
/*
|
||||
* Package-local postconditions receive the exact live plan minted for this
|
||||
* signer. A public semantic fingerprint is intentionally insufficient.
|
||||
*/
|
||||
/* default */ X509ExecutionPlan<SignatureWorkflow> executionPlan() {
|
||||
return executionPlan;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -226,22 +270,22 @@ public final class PkiBusContentSigner implements ContentSigner {
|
||||
@Override
|
||||
@SuppressWarnings("PMD.AvoidCatchingGenericException")
|
||||
public byte[] getSignature() {
|
||||
byte[] tbs = baos.toByteArray();
|
||||
byte[] consumedResult = null;
|
||||
PkiId opId = null;
|
||||
boolean retirementRequired = false;
|
||||
Throwable primaryFailure = null;
|
||||
try {
|
||||
EncodedObject payload = new EncodedObject(Encoding.BINARY, tbs);
|
||||
DurableContentReference content = completeContent();
|
||||
Principal owner = new Principal("SYSTEM", "pki");
|
||||
opId = bus.newSubmissionId();
|
||||
|
||||
AccessContext ac = new AccessContext(owner, new Purpose("X509_SIGN"), Optional.empty(), Optional.empty());
|
||||
SignContinuation cont = new SignContinuation(ac, algorithmId, payload, keyRef, Encoding.BINARY,
|
||||
String canonicalIdentity = algorithmIdentity.canonicalForm();
|
||||
SignContinuation cont = new SignContinuation(ac, canonicalIdentity, content, keyRef, Encoding.BINARY,
|
||||
Optional.empty());
|
||||
|
||||
retirementRequired = true;
|
||||
bus.submitSign(opId, owner, keyRef, algorithmId, payload, ttl, Optional.of(cont.encode()));
|
||||
bus.submitSign(opId, owner, keyRef, canonicalIdentity, content, ttl, Optional.of(cont.encode()));
|
||||
consumedResult = awaitSignature(opId);
|
||||
return consumedResult.clone();
|
||||
} catch (RuntimeException failure) {
|
||||
@@ -257,15 +301,32 @@ public final class PkiBusContentSigner implements ContentSigner {
|
||||
retirePreservingFailure(opId, primaryFailure);
|
||||
}
|
||||
} finally {
|
||||
Arrays.fill(tbs, (byte) 0);
|
||||
baos.wipe();
|
||||
closeSink(primaryFailure);
|
||||
if (consumedResult != null) {
|
||||
Arrays.fill(consumedResult, (byte) 0);
|
||||
java.util.Arrays.fill(consumedResult, (byte) 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private DurableContentReference completeContent() {
|
||||
try {
|
||||
return contentSink.complete();
|
||||
} catch (IOException ex) {
|
||||
throw new PkiException("Signing content staging failed: code=SPOOL_STORAGE_FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
private void closeSink(Throwable primaryFailure) {
|
||||
try {
|
||||
contentSink.close();
|
||||
} catch (IOException cleanupFailure) {
|
||||
if (primaryFailure == null) {
|
||||
throw new PkiException("Signing cleanup failed: code=SIGNING_CLEANUP_FAILED");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] awaitSignature(PkiId opId) {
|
||||
Instant deadline = Instant.now().plus(ttl);
|
||||
while (Instant.now().isBefore(deadline)) {
|
||||
@@ -313,15 +374,4 @@ public final class PkiBusContentSigner implements ContentSigner {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Byte-array output stream whose retained backing storage can be overwritten
|
||||
* after one signing attempt.
|
||||
*/
|
||||
private static final class WipeableByteArrayOutputStream extends ByteArrayOutputStream {
|
||||
|
||||
private void wipe() {
|
||||
Arrays.fill(buf, (byte) 0);
|
||||
reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,12 @@ import zeroecho.pki.api.issuance.VerificationPolicy;
|
||||
import zeroecho.pki.api.request.ParsedCertificationRequest;
|
||||
import zeroecho.pki.api.request.ProofOfPossessionResult;
|
||||
import zeroecho.pki.api.request.ProofOfPossessionStatus;
|
||||
import zeroecho.pki.impl.framework.x509.X509AlgorithmRole;
|
||||
import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot;
|
||||
import zeroecho.pki.impl.framework.x509.X509ExecutionPlan;
|
||||
import zeroecho.pki.spi.crypto.SignatureWorkflow;
|
||||
import zeroecho.core.io.CancellationSignal;
|
||||
import zeroecho.core.io.ImmutableByteContent;
|
||||
import zeroecho.pki.spi.framework.ProofOfPossessionVerifier;
|
||||
|
||||
/**
|
||||
@@ -133,16 +138,17 @@ public final class WorkflowProofOfPossessionVerifier implements ProofOfPossessio
|
||||
private static final Principal SYSTEM = new Principal("SYSTEM", "pki");
|
||||
|
||||
private final SignatureWorkflow workflow;
|
||||
private final X509AuthoritySnapshot authority;
|
||||
|
||||
/**
|
||||
* Creates the workflow-backed proof-of-possession verifier.
|
||||
* Creates a verifier bound to the runtime's immutable authority snapshot.
|
||||
*
|
||||
* @param workflow signature workflow used to perform cryptographic signature
|
||||
* verification; must not be {@code null}
|
||||
* @throws NullPointerException if {@code workflow} is {@code null}
|
||||
* @param workflow selected verification workflow
|
||||
* @param authority matching authority snapshot
|
||||
*/
|
||||
public WorkflowProofOfPossessionVerifier(SignatureWorkflow workflow) {
|
||||
public WorkflowProofOfPossessionVerifier(SignatureWorkflow workflow, X509AuthoritySnapshot authority) {
|
||||
this.workflow = Objects.requireNonNull(workflow, "workflow");
|
||||
this.authority = Objects.requireNonNull(authority, "authority");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -207,22 +213,34 @@ public final class WorkflowProofOfPossessionVerifier implements ProofOfPossessio
|
||||
}
|
||||
|
||||
CertificationRequest csrAsn1 = csr.get().toASN1Structure();
|
||||
BcX509AlgorithmAdapter algorithmAdapter = new BcX509AlgorithmAdapter(authority.bindings());
|
||||
zeroecho.core.spec.AlgorithmIdentity signatureIdentity;
|
||||
X509ExecutionPlan<SignatureWorkflow> executionPlan;
|
||||
try {
|
||||
signatureIdentity = algorithmAdapter.decode(
|
||||
csrAsn1.getSignatureAlgorithm(), X509AlgorithmRole.SIGNATURE_ALGORITHM);
|
||||
zeroecho.core.spec.AlgorithmIdentity keyIdentity = algorithmAdapter.decode(
|
||||
csr.get().getSubjectPublicKeyInfo().getAlgorithm(),
|
||||
X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM);
|
||||
executionPlan = authority.plan(signatureIdentity, keyIdentity,
|
||||
zeroecho.core.spi.AlgorithmExecutionCapability.Direction.VERIFY,
|
||||
Optional.empty(), "csr-proof", SignatureWorkflow.class);
|
||||
} catch (IllegalArgumentException unsupported) {
|
||||
return new ProofOfPossessionResult(ProofOfPossessionStatus.NOT_SUPPORTED,
|
||||
Optional.of("Unsupported CSR algorithm"));
|
||||
}
|
||||
Optional<byte[]> tbs = encodeCertificationRequestInfo(csrAsn1.getCertificationRequestInfo());
|
||||
if (tbs.isEmpty()) {
|
||||
return failed("CSR TBS encoding failed");
|
||||
}
|
||||
|
||||
String algorithmId = OidAlgorithmMapper.toZeroEchoAlgorithmId(csrAsn1.getSignatureAlgorithm());
|
||||
if (algorithmId == null) {
|
||||
return new ProofOfPossessionResult(ProofOfPossessionStatus.NOT_SUPPORTED,
|
||||
Optional.of("Unsupported CSR algorithm"));
|
||||
}
|
||||
String algorithmId = signatureIdentity.canonicalForm();
|
||||
|
||||
byte[] signature = extractSignatureBytes(csrAsn1.getSignature());
|
||||
SignatureWorkflow.VerifyRequest verifyRequest = toVerifyRequest(request, algorithmId, tbs.get(), signature,
|
||||
spkiDer.get());
|
||||
|
||||
return verifyWithWorkflow(verifyRequest);
|
||||
return verifyWithWorkflow(verifyRequest, executionPlan);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -249,6 +267,7 @@ public final class WorkflowProofOfPossessionVerifier implements ProofOfPossessio
|
||||
*/
|
||||
private static Optional<PKCS10CertificationRequest> parseCsr(byte[] csrDer) {
|
||||
try {
|
||||
BcX509AlgorithmAdapter.requireCanonicalDer(csrDer);
|
||||
return Optional.of(new PKCS10CertificationRequest(csrDer));
|
||||
} catch (IOException | IllegalArgumentException ex) {
|
||||
return Optional.empty();
|
||||
@@ -315,9 +334,10 @@ public final class WorkflowProofOfPossessionVerifier implements ProofOfPossessio
|
||||
AccessContext accessContext = new AccessContext(SYSTEM, PURPOSE, Optional.of(request.requestId()),
|
||||
Optional.of(request.formatId()));
|
||||
|
||||
return new SignatureWorkflow.VerifyRequest(accessContext, algorithmId, new EncodedObject(Encoding.DER, tbsDer),
|
||||
return new SignatureWorkflow.VerifyRequest(accessContext, algorithmId, new ImmutableByteContent(tbsDer),
|
||||
new EncodedObject(Encoding.BINARY, signature), Optional.empty(),
|
||||
Optional.of(new EncodedObject(Encoding.DER, spkiDer)), Optional.of(Instant.now().plusSeconds(30)));
|
||||
Optional.of(new EncodedObject(Encoding.DER, spkiDer)), Optional.of(Instant.now().plusSeconds(30)),
|
||||
CancellationSignal.NONE);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -327,9 +347,12 @@ public final class WorkflowProofOfPossessionVerifier implements ProofOfPossessio
|
||||
* @param verifyRequest workflow verification request; must not be {@code null}
|
||||
* @return mapped proof-of-possession result
|
||||
*/
|
||||
private ProofOfPossessionResult verifyWithWorkflow(SignatureWorkflow.VerifyRequest verifyRequest) {
|
||||
PkiId verifyOperationId = workflow.submitVerify(verifyRequest);
|
||||
SignatureWorkflow.OperationStatus status = workflow.status(verifyOperationId);
|
||||
private ProofOfPossessionResult verifyWithWorkflow(SignatureWorkflow.VerifyRequest verifyRequest,
|
||||
X509ExecutionPlan<SignatureWorkflow> executionPlan) {
|
||||
authority.authorize(executionPlan, workflow,
|
||||
zeroecho.core.spi.AlgorithmExecutionCapability.Direction.VERIFY);
|
||||
PkiId verifyOperationId = executionPlan.executor().submitVerify(verifyRequest);
|
||||
SignatureWorkflow.OperationStatus status = executionPlan.executor().status(verifyOperationId);
|
||||
if (status == null) {
|
||||
return failed("Verifier returned no status");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
417
pki/src/main/java/zeroecho/pki/impl/fs/DurableMetadataFiles.java
Normal file
417
pki/src/main/java/zeroecho/pki/impl/fs/DurableMetadataFiles.java
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,8 @@ import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalLong;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
@@ -87,11 +89,16 @@ import zeroecho.pki.api.revocation.RevocationReason;
|
||||
import zeroecho.pki.api.revocation.RevocationState;
|
||||
import zeroecho.pki.api.revocation.RevocationTransition;
|
||||
import zeroecho.pki.api.status.StatusObject;
|
||||
import zeroecho.pki.api.content.DurableContentReference;
|
||||
import zeroecho.pki.api.content.DurableContentOwner;
|
||||
import zeroecho.pki.impl.ProfileLifecycleFailure;
|
||||
import zeroecho.pki.impl.ProfileLifecycleFailure.Code;
|
||||
import zeroecho.pki.impl.core.async.PkiSigningBus;
|
||||
import zeroecho.pki.spi.store.PkiStore;
|
||||
import zeroecho.pki.spi.store.StagedContentStore;
|
||||
import zeroecho.pki.spi.store.SignWorkflowStore;
|
||||
import zeroecho.pki.spi.store.TemporaryUniqueIndex;
|
||||
import zeroecho.pki.spi.store.RevocationSnapshot;
|
||||
|
||||
/**
|
||||
* Filesystem-based reference implementation of {@link PkiStore}.
|
||||
@@ -167,6 +174,8 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
||||
private final ConcurrentMap<PkiId, RevocationLockEntry> revocationLocks;
|
||||
private final ConcurrentMap<String, ProfileLockEntry> profileLocks;
|
||||
private final AtomicBoolean durabilityUncertain;
|
||||
private final FilesystemStagedContentStore stagedContent;
|
||||
private final CredentialContentTransaction credentialContentTransactions;
|
||||
|
||||
private final StoreOwnership ownership;
|
||||
|
||||
@@ -221,8 +230,12 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
||||
try {
|
||||
ensureVersionFile();
|
||||
this.signingNamespace = ensureSigningNamespace();
|
||||
this.stagedContent = new FilesystemStagedContentStore(this.paths.stagedContentRoot(),
|
||||
this.signingNamespace);
|
||||
this.credentialContentTransactions = new CredentialContentTransaction(this.paths, this.stagedContent);
|
||||
this.signingTimeWatermark = new AtomicLong(loadSigningTimeWatermark());
|
||||
this.historySeq = new AtomicLong(0L);
|
||||
recoverStagedContent();
|
||||
|
||||
LOG.log(Level.INFO, "running in {0}", root);
|
||||
this.ownership = acquiredOwnership;
|
||||
@@ -236,6 +249,101 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public StagedContentStore stagedContent() {
|
||||
return stagedContent;
|
||||
}
|
||||
|
||||
private void recoverStagedContent() throws IOException {
|
||||
credentialContentTransactions.recover();
|
||||
try (TemporaryUniqueIndex retained = stagedContent.beginUniqueIndex();
|
||||
TemporaryUniqueIndex retainedOwners = stagedContent.beginOwnerIndex()) {
|
||||
try {
|
||||
addPersistedCredentialReferences(retained, retainedOwners);
|
||||
addPersistedStatusReferences(retained);
|
||||
addPendingSigningReferences(retained, retainedOwners);
|
||||
stagedContent.recoverContent(retained, retainedOwners);
|
||||
} catch (IllegalStateException | PkiException malformedDurableState) {
|
||||
// Recovery cannot prove abandonment while durable metadata is
|
||||
// corrupt. Preserve content so the normal owning subsystem can
|
||||
// report the authoritative corruption without destructive loss.
|
||||
LOG.log(Level.WARNING, "Staged-content reclamation skipped: code=DURABLE_METADATA_INVALID");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void addPersistedCredentialReferences(TemporaryUniqueIndex retained, TemporaryUniqueIndex retainedOwners)
|
||||
throws IOException {
|
||||
Path root = paths.root().resolve("credentials").resolve("by-id");
|
||||
if (!Files.isDirectory(root)) {
|
||||
return;
|
||||
}
|
||||
try (Stream<Path> pathsStream = Files.list(root)) {
|
||||
java.util.Iterator<Path> iterator = pathsStream
|
||||
.filter(path -> Files.isRegularFile(path) && path.getFileName().toString().endsWith(".bin"))
|
||||
.iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Credential credential = FsCodec.decode(FsCodec.CREDENTIAL, FsOperations.readAll(iterator.next()),
|
||||
stagedContent);
|
||||
addRetained(retained, credential.content());
|
||||
DurableContentOwner owner = DurableContentOwner.credentialRecord(credential.credentialId());
|
||||
retainedOwners.add(owner.canonicalForm().getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void addPersistedStatusReferences(TemporaryUniqueIndex retained) throws IOException {
|
||||
Path root = paths.root().resolve("status").resolve("by-id");
|
||||
if (!Files.isDirectory(root)) {
|
||||
return;
|
||||
}
|
||||
try (Stream<Path> pathsStream = Files.list(root)) {
|
||||
java.util.Iterator<Path> iterator = pathsStream.filter(Files::isRegularFile).iterator();
|
||||
while (iterator.hasNext()) {
|
||||
StatusObject status = FsCodec.decode(FsCodec.STATUS_OBJECT, FsOperations.readAll(iterator.next()),
|
||||
stagedContent);
|
||||
addRetained(retained, status.content());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void addPendingSigningReferences(TemporaryUniqueIndex retained, TemporaryUniqueIndex retainedOwners)
|
||||
throws IOException {
|
||||
Path root = paths.signWorkflowRoot();
|
||||
if (!Files.isDirectory(root)) {
|
||||
return;
|
||||
}
|
||||
try (Stream<Path> pathsStream = Files.walk(root)) {
|
||||
java.util.Iterator<Path> iterator = pathsStream
|
||||
.filter(path -> Files.isRegularFile(path)
|
||||
&& FsPaths.CURRENT_FILE.equals(path.getFileName().toString()))
|
||||
.iterator();
|
||||
while (iterator.hasNext()) {
|
||||
SignWorkflowStore.Record record = readSignRecordFile(iterator.next());
|
||||
PkiSigningBus.SignContinuation continuation = PkiSigningBus.SignContinuation.decode(record.request(),
|
||||
stagedContent);
|
||||
if (record.state() == SignWorkflowStore.State.RETIRED) {
|
||||
if (continuation.hasLiveContent()) {
|
||||
throw new IllegalStateException("Retired signing record retains live content");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
DurableContentReference reference = continuation.content();
|
||||
DurableContentOwner owner = DurableContentOwner.signingOperation(record.submissionId());
|
||||
if (!stagedContent.contentOwners(reference).contains(owner)) {
|
||||
throw new IllegalStateException("Signing content owner is missing");
|
||||
}
|
||||
addRetained(retained, reference);
|
||||
retainedOwners.add(owner.canonicalForm().getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void addRetained(TemporaryUniqueIndex retained, DurableContentReference reference)
|
||||
throws IOException {
|
||||
retained.add(reference.contentId().getBytes(StandardCharsets.US_ASCII));
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports a snapshot of this store as of time {@code at} into
|
||||
* {@code targetRoot}.
|
||||
@@ -292,16 +400,23 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
||||
public void putCredential(final Credential credential) {
|
||||
requireStoreUsable();
|
||||
Objects.requireNonNull(credential, "credential");
|
||||
PkiId id = credential.credentialId();
|
||||
Path p = this.paths.credentialPath(id);
|
||||
writeOnce(p, FsCodec.encode(FsCodec.CREDENTIAL, credential), "CREDENTIAL", FsUtil.safeId(id));
|
||||
credentialContentTransactions.put(credential);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Credential> getCredential(final PkiId credentialId) {
|
||||
requireStoreUsable();
|
||||
Objects.requireNonNull(credentialId, "credentialId");
|
||||
return readOptional(this.paths.credentialPath(credentialId), FsCodec.CREDENTIAL);
|
||||
Path path = this.paths.credentialPath(credentialId);
|
||||
if (!Files.exists(path)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
Credential credential = FsCodec.decode(FsCodec.CREDENTIAL, FsOperations.readAll(path), stagedContent);
|
||||
return Optional.of(credentialContentTransactions.validateLoaded(credentialId, credential));
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("Credential read failed", failure);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -369,28 +484,29 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
||||
}
|
||||
|
||||
@Override
|
||||
// Listing failures are intentionally replaced by one stable redacted boundary.
|
||||
// Snapshot failures are intentionally replaced by one stable redacted boundary.
|
||||
@SuppressWarnings("PMD.PreserveStackTrace")
|
||||
public List<RevocationJournal> listRevocationJournals() {
|
||||
public RevocationSnapshot openRevocationSnapshot() {
|
||||
requireStoreUsable();
|
||||
Path root = this.paths.root().resolve("revocations").resolve("by-credential");
|
||||
if (!Files.isDirectory(root)) {
|
||||
return List.of();
|
||||
}
|
||||
try (Stream<Path> directories = Files.list(root)) {
|
||||
List<RevocationJournal> journals = new ArrayList<>();
|
||||
for (Path entityDir : directories.filter(Files::isDirectory)
|
||||
.sorted(Comparator.comparing(path -> path.getFileName().toString())).toList()) {
|
||||
Path journalPath = entityDir.resolve("journal.bin");
|
||||
if (Files.exists(journalPath)) {
|
||||
RevocationJournal journal = decodeRevocationJournal(journalPath);
|
||||
if (!entityDir.getFileName().toString().equals(FsUtil.safeId(journal.credentialId()))) {
|
||||
throw corruptRevocationState();
|
||||
String snapshotId = UUID.randomUUID().toString();
|
||||
Path snapshotRoot = paths.revocationSnapshotRoot().resolve(snapshotId);
|
||||
long count = 0L;
|
||||
try {
|
||||
Files.createDirectories(snapshotRoot);
|
||||
if (Files.isDirectory(root)) {
|
||||
try (Stream<Path> journals = Files.walk(root)) {
|
||||
java.util.Iterator<Path> iterator = journals
|
||||
.filter(path -> Files.isRegularFile(path)
|
||||
&& "journal.bin".equals(path.getFileName().toString()))
|
||||
.iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Files.copy(iterator.next(), snapshotRoot.resolve(Long.toUnsignedString(count) + ".bin"));
|
||||
count = Math.addExact(count, 1L);
|
||||
}
|
||||
journals.add(journal);
|
||||
}
|
||||
}
|
||||
return List.copyOf(journals);
|
||||
return new FilesystemRevocationSnapshot(snapshotId, snapshotRoot, count);
|
||||
} catch (IOException ex) {
|
||||
throw corruptRevocationState();
|
||||
}
|
||||
@@ -835,9 +951,13 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
||||
|| !isRetirableSignState(current.state())) {
|
||||
return Optional.empty();
|
||||
}
|
||||
SignWorkflowStore.Record retired = copySignRecord(current, SignWorkflowStore.State.RETIRED,
|
||||
current.revision() + 1L, fence, Optional.empty(), Optional.of("RETIRED"), current.result(),
|
||||
current.providerUpdatedAt());
|
||||
PkiSigningBus.SignContinuation continuation = PkiSigningBus.SignContinuation.decode(current.request(),
|
||||
stagedContent);
|
||||
EncodedObject retiredRequest = continuation.withoutLiveContent().encode();
|
||||
SignWorkflowStore.Record retired = new SignWorkflowStore.Record(current.submissionId(),
|
||||
current.namespace(), current.fingerprint(), current.owner(), current.createdAt(),
|
||||
current.deadline(), retiredRequest, SignWorkflowStore.State.RETIRED, current.revision() + 1L,
|
||||
fence, Optional.empty(), Optional.of("RETIRED"), current.result(), current.providerUpdatedAt());
|
||||
writeSignRecord(retired);
|
||||
return Optional.of(retired);
|
||||
} finally {
|
||||
@@ -1054,6 +1174,105 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
||||
return Optional.of(journal);
|
||||
}
|
||||
|
||||
/** Stable file-backed revocation snapshot isolated from later store writes. */
|
||||
private static final class FilesystemRevocationSnapshot implements RevocationSnapshot {
|
||||
private final String snapshotId;
|
||||
private final Path root;
|
||||
private final long count;
|
||||
private final AtomicBoolean closed;
|
||||
|
||||
private FilesystemRevocationSnapshot(String snapshotId, Path root, long count) {
|
||||
this.snapshotId = snapshotId;
|
||||
this.root = root;
|
||||
this.count = count;
|
||||
this.closed = new AtomicBoolean();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String snapshotId() {
|
||||
return snapshotId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OptionalLong count() {
|
||||
return OptionalLong.of(count);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Cursor openCursor() {
|
||||
if (closed.get()) {
|
||||
throw new IllegalStateException("Revocation snapshot is closed");
|
||||
}
|
||||
return new FilesystemRevocationCursor(root, count);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
if (!closed.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
if (Files.isDirectory(root)) {
|
||||
try (Stream<Path> files = Files.list(root)) {
|
||||
java.util.Iterator<Path> iterator = files.iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Files.deleteIfExists(iterator.next());
|
||||
}
|
||||
}
|
||||
Files.deleteIfExists(root);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Sequential bounded-memory cursor over one immutable snapshot directory. */
|
||||
private static final class FilesystemRevocationCursor implements RevocationSnapshot.Cursor {
|
||||
private final Path root;
|
||||
private final long count;
|
||||
private long nextOrdinal;
|
||||
private RevocationJournal current;
|
||||
private boolean closed;
|
||||
|
||||
private FilesystemRevocationCursor(Path root, long count) {
|
||||
this.root = root;
|
||||
this.count = count;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean next() throws IOException {
|
||||
if (closed) {
|
||||
throw new IllegalStateException("Revocation cursor is closed");
|
||||
}
|
||||
if (nextOrdinal >= count) {
|
||||
current = null;
|
||||
return false;
|
||||
}
|
||||
current = decodeRevocationJournal(root.resolve(Long.toUnsignedString(nextOrdinal) + ".bin"));
|
||||
nextOrdinal = Math.addExact(nextOrdinal, 1L);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RevocationJournal current() {
|
||||
if (current == null) {
|
||||
throw new IllegalStateException("Revocation cursor is not positioned");
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long ordinal() {
|
||||
if (current == null) {
|
||||
throw new IllegalStateException("Revocation cursor is not positioned");
|
||||
}
|
||||
return nextOrdinal - 1L;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
current = null;
|
||||
closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Codec and filesystem failures are external persisted-state boundaries; raw
|
||||
// causes are deliberately removed from the stable corruption exception.
|
||||
@SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" })
|
||||
@@ -1156,7 +1375,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
||||
}
|
||||
byte[] payload = new byte[input.remaining()];
|
||||
input.get(payload);
|
||||
return FsCodec.decode(FsCodec.SIGN_WORKFLOW_RECORD, payload);
|
||||
return FsCodec.decode(FsCodec.SIGN_WORKFLOW_RECORD, payload, stagedContent);
|
||||
} catch (IOException ex) {
|
||||
throw new IllegalStateException("Failed to read authoritative signing record", ex);
|
||||
}
|
||||
@@ -1176,7 +1395,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
||||
Instant futureLimit;
|
||||
try {
|
||||
parsed = SigningSubmissionId.parse(record.submissionId());
|
||||
continuation = PkiSigningBus.SignContinuation.decode(record.request());
|
||||
continuation = PkiSigningBus.SignContinuation.decode(record.request(), stagedContent);
|
||||
horizonEnd = record.createdAt().plus(options.signingOperationHorizon());
|
||||
futureLimit = signingNow().plus(options.signingIdPermittedSkew());
|
||||
} catch (RuntimeException ex) {
|
||||
@@ -1193,6 +1412,8 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
||||
record, "DEADLINE_INVALID");
|
||||
requireValidSignRecord(continuation.isBoundTo(record.submissionId(), record.owner()), record,
|
||||
"CONTINUATION_IDENTITY_MISMATCH");
|
||||
requireValidSignRecord(record.state() == SignWorkflowStore.State.RETIRED != continuation.hasLiveContent(),
|
||||
record, "CONTINUATION_LIFECYCLE_MISMATCH");
|
||||
|
||||
byte[] storedRequest = record.request().bytes();
|
||||
byte[] canonicalRequest = null;
|
||||
@@ -1609,19 +1830,19 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
||||
}
|
||||
}
|
||||
|
||||
private static <T> Optional<T> readOptional(final Path path, final FsCodec.Schema<T> schema) {
|
||||
private <T> Optional<T> readOptional(final Path path, final FsCodec.Schema<T> schema) {
|
||||
try {
|
||||
if (!Files.exists(path)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
byte[] data = FsOperations.readAll(path);
|
||||
return Optional.of(FsCodec.decode(schema, data));
|
||||
return Optional.of(FsCodec.decode(schema, data, stagedContent));
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("read failed: " + path, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static <T> List<T> listBinaryFiles(final Path byIdDir, final FsCodec.Schema<T> schema) {
|
||||
private <T> List<T> listBinaryFiles(final Path byIdDir, final FsCodec.Schema<T> schema) {
|
||||
if (!Files.isDirectory(byIdDir)) {
|
||||
return List.of();
|
||||
}
|
||||
@@ -1629,7 +1850,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
||||
return Files.list(byIdDir).filter(Files::isRegularFile)
|
||||
.sorted(Comparator.comparing(p -> p.getFileName().toString())).map(p -> {
|
||||
try {
|
||||
return FsCodec.decode(schema, FsOperations.readAll(p));
|
||||
return FsCodec.decode(schema, FsOperations.readAll(p), stagedContent);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("read failed: " + p, e);
|
||||
}
|
||||
@@ -1639,7 +1860,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
||||
}
|
||||
}
|
||||
|
||||
private static <T> List<T> listCurrentRecords(final Path byIdDir, final FsCodec.Schema<T> schema) {
|
||||
private <T> List<T> listCurrentRecords(final Path byIdDir, final FsCodec.Schema<T> schema) {
|
||||
if (!Files.isDirectory(byIdDir)) {
|
||||
return List.of();
|
||||
}
|
||||
@@ -1651,7 +1872,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable {
|
||||
for (Path entityDir : entityDirs) {
|
||||
Path current = entityDir.resolve(FsPaths.CURRENT_FILE);
|
||||
if (Files.exists(current)) {
|
||||
out.add(FsCodec.decode(schema, FsOperations.readAll(current)));
|
||||
out.add(FsCodec.decode(schema, FsOperations.readAll(current), stagedContent));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -68,6 +68,7 @@ import zeroecho.pki.api.credential.CaProfileBinding;
|
||||
import zeroecho.pki.api.credential.Credential;
|
||||
import zeroecho.pki.api.credential.CredentialProfileBinding;
|
||||
import zeroecho.pki.api.credential.CredentialStatus;
|
||||
import zeroecho.pki.api.content.DurableContentReference;
|
||||
import zeroecho.pki.api.credential.EndEntityProfileBinding;
|
||||
import zeroecho.pki.api.orch.OrchestrationDurabilityPolicy;
|
||||
import zeroecho.pki.api.orch.WorkflowStateRecord;
|
||||
@@ -92,6 +93,7 @@ import zeroecho.pki.api.revocation.RevocationState;
|
||||
import zeroecho.pki.api.revocation.RevocationTransition;
|
||||
import zeroecho.pki.api.status.StatusObject;
|
||||
import zeroecho.pki.api.status.StatusObjectType;
|
||||
import zeroecho.pki.spi.store.StagedContentStore;
|
||||
import zeroecho.pki.impl.core.attr.SimpleAttributeSet;
|
||||
import zeroecho.pki.spi.store.SignWorkflowStore;
|
||||
|
||||
@@ -118,7 +120,7 @@ import zeroecho.pki.spi.store.SignWorkflowStore;
|
||||
final class FsCodec {
|
||||
|
||||
/* package */ static final int MAX_COMPONENT_BYTES = 256 * 1024;
|
||||
/* package */ static final int CURRENT_CODEC_VERSION = 2;
|
||||
/* package */ static final int CURRENT_CODEC_VERSION = 3;
|
||||
|
||||
private static final int CODEC_MAGIC = 0x5A454346;
|
||||
private static final int MAX_COLLECTION_ELEMENTS = MAX_COMPONENT_BYTES;
|
||||
@@ -132,6 +134,7 @@ final class FsCodec {
|
||||
private static final int TOP_POLICY_TRACE = 8;
|
||||
private static final int TOP_WORKFLOW_STATE = 9;
|
||||
private static final int TOP_SIGN_WORKFLOW_RECORD = 10;
|
||||
private static final int DURABLE_CONTENT_VERSION = 1;
|
||||
private static final int TOP_PROFILE_VERSION = 11;
|
||||
private static final int TOP_ACTIVE_PROFILE_REF = 12;
|
||||
|
||||
@@ -174,6 +177,7 @@ final class FsCodec {
|
||||
private static final int TYPE_SAN = 66;
|
||||
private static final int TYPE_PROFILE_REF = 72;
|
||||
private static final int TYPE_PROFILE_BINDING = 73;
|
||||
private static final int TYPE_DURABLE_CONTENT = 74;
|
||||
|
||||
private static final int ATTRIBUTE_STRING = 1;
|
||||
private static final int ATTRIBUTE_BOOLEAN = 2;
|
||||
@@ -392,6 +396,8 @@ final class FsCodec {
|
||||
}, reader -> new Validity(reader.readValue(INSTANT), reader.readValue(INSTANT)));
|
||||
private static final ValueSchema<EncodedObject> ENCODED_OBJECT = valueSchema(TYPE_ENCODED_OBJECT,
|
||||
FsCodec::writeEncodedObject, FsCodec::readEncodedObject);
|
||||
private static final ValueSchema<DurableContentReference> DURABLE_CONTENT = valueSchema(TYPE_DURABLE_CONTENT,
|
||||
FsCodec::writeDurableContent, FsCodec::readDurableContent);
|
||||
private static final ValueSchema<Principal> PRINCIPAL = valueSchema(TYPE_PRINCIPAL, (writer, value) -> {
|
||||
writer.writeValue(STRING, value.type());
|
||||
writer.writeValue(STRING, value.name());
|
||||
@@ -495,39 +501,48 @@ final class FsCodec {
|
||||
}
|
||||
|
||||
/* package */ static <T> T decode(final Schema<T> schema, final byte[] encoded) {
|
||||
return decode(schema, encoded, null);
|
||||
}
|
||||
|
||||
/* package */ static <T> T decode(final Schema<T> schema, final byte[] encoded,
|
||||
final StagedContentStore stagedContent) {
|
||||
Objects.requireNonNull(schema, "schema");
|
||||
Objects.requireNonNull(encoded, "encoded");
|
||||
try {
|
||||
return decodeCurrentPayload(schema, encoded);
|
||||
return PayloadDecoder.decode(schema, encoded, stagedContent);
|
||||
} catch (IOException | IllegalArgumentException ex) {
|
||||
throw new IllegalStateException("Decoding failed: schema=" + schema.name + " code=INVALID_CURRENT_PAYLOAD",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static <T> T decodeCurrentPayload(Schema<T> schema, byte[] encoded) throws IOException {
|
||||
ByteArrayInputStream input = new ByteArrayInputStream(encoded);
|
||||
Reader reader = new Reader(input);
|
||||
if (reader.readInt() != CODEC_MAGIC) {
|
||||
throw new IOException("codec magic mismatch");
|
||||
/** Strict current-schema payload decoder separated from the schema registry. */
|
||||
private static final class PayloadDecoder {
|
||||
private static <T> T decode(Schema<T> schema, byte[] encoded, StagedContentStore stagedContent)
|
||||
throws IOException {
|
||||
ByteArrayInputStream input = new ByteArrayInputStream(encoded);
|
||||
Reader reader = new Reader(input, stagedContent);
|
||||
if (reader.readInt() != CODEC_MAGIC) {
|
||||
throw new IOException("codec magic mismatch");
|
||||
}
|
||||
int version = reader.readUnsignedByte();
|
||||
if (version != CURRENT_CODEC_VERSION) {
|
||||
throw new IOException("unsupported codec version");
|
||||
}
|
||||
int typeId = reader.readUnsignedByte();
|
||||
Schema<?> encodedSchema = TOP_LEVEL_SCHEMAS.get(typeId);
|
||||
if (encodedSchema == null) {
|
||||
throw new IOException("unknown top-level type");
|
||||
}
|
||||
if (encodedSchema.typeId != schema.typeId) {
|
||||
throw new IOException("top-level type mismatch");
|
||||
}
|
||||
T decoded = schema.valueSchema.decoder.decode(reader);
|
||||
if (input.available() != 0) {
|
||||
throw new IOException("trailing payload data");
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
int version = reader.readUnsignedByte();
|
||||
if (version != CURRENT_CODEC_VERSION) {
|
||||
throw new IOException("unsupported codec version");
|
||||
}
|
||||
int typeId = reader.readUnsignedByte();
|
||||
Schema<?> encodedSchema = TOP_LEVEL_SCHEMAS.get(typeId);
|
||||
if (encodedSchema == null) {
|
||||
throw new IOException("unknown top-level type");
|
||||
}
|
||||
if (encodedSchema.typeId != schema.typeId) {
|
||||
throw new IOException("top-level type mismatch");
|
||||
}
|
||||
T decoded = schema.valueSchema.decoder.decode(reader);
|
||||
if (input.available() != 0) {
|
||||
throw new IOException("trailing payload data");
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
private static void writeEncodedObject(Writer writer, EncodedObject value) throws IOException {
|
||||
@@ -550,6 +565,35 @@ final class FsCodec {
|
||||
}
|
||||
}
|
||||
|
||||
private static void writeDurableContent(Writer writer, DurableContentReference content) throws IOException {
|
||||
writer.writeUnsignedByte(DURABLE_CONTENT_VERSION);
|
||||
writer.writeValue(STRING, content.storeId());
|
||||
writer.writeValue(STRING, content.contentId());
|
||||
writer.writeValue(ENCODING, content.encoding());
|
||||
writer.writeValue(LONG, content.length());
|
||||
writer.writeValue(STRING, content.sha256());
|
||||
writer.writeValue(STRING, content.lifecycle().name());
|
||||
}
|
||||
|
||||
private static DurableContentReference readDurableContent(Reader reader) throws IOException {
|
||||
int version = reader.readUnsignedByte();
|
||||
if (version != DURABLE_CONTENT_VERSION) {
|
||||
throw new IOException("unsupported durable content reference version");
|
||||
}
|
||||
String storeId = reader.readValue(STRING);
|
||||
String contentId = reader.readValue(STRING);
|
||||
Encoding encoding = reader.readValue(ENCODING);
|
||||
long length = reader.readValue(LONG);
|
||||
String sha256 = reader.readValue(STRING);
|
||||
DurableContentReference.Lifecycle lifecycle;
|
||||
try {
|
||||
lifecycle = DurableContentReference.Lifecycle.valueOf(reader.readValue(STRING));
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new IOException("unknown durable content lifecycle", exception);
|
||||
}
|
||||
return reader.restoreReference(storeId, contentId, encoding, length, sha256, lifecycle);
|
||||
}
|
||||
|
||||
private static void writeAttributeValue(Writer writer, AttributeValue value) throws IOException {
|
||||
switch (value) {
|
||||
case AttributeValue.StringValue stringValue -> {
|
||||
@@ -629,7 +673,7 @@ final class FsCodec {
|
||||
writer.writeValue(PKI_ID, value.publicKeyId());
|
||||
writer.writeValue(PROFILE_BINDING, value.profileBinding());
|
||||
writer.writeValue(CREDENTIAL_STATUS, value.status());
|
||||
writer.writeValue(ENCODED_OBJECT, value.encoded());
|
||||
writer.writeValue(DURABLE_CONTENT, value.content());
|
||||
writer.writeValue(ATTRIBUTE_SET, value.attributes());
|
||||
}
|
||||
|
||||
@@ -637,7 +681,7 @@ final class FsCodec {
|
||||
return new Credential(reader.readValue(PKI_ID), reader.readValue(FORMAT_ID), reader.readValue(ISSUER_REF),
|
||||
reader.readValue(SUBJECT_REF), reader.readValue(VALIDITY), reader.readValue(STRING),
|
||||
reader.readValue(PKI_ID), reader.readValue(PROFILE_BINDING), reader.readValue(CREDENTIAL_STATUS),
|
||||
reader.readValue(ENCODED_OBJECT), reader.readValue(ATTRIBUTE_SET));
|
||||
reader.readValue(DURABLE_CONTENT), reader.readValue(ATTRIBUTE_SET));
|
||||
}
|
||||
|
||||
private static void writeProfileBinding(Writer writer, CredentialProfileBinding value) throws IOException {
|
||||
@@ -731,14 +775,20 @@ final class FsCodec {
|
||||
writer.writeValue(STATUS_OBJECT_TYPE, value.type());
|
||||
writer.writeValue(INSTANT, value.thisUpdate());
|
||||
writer.writeValue(OPTIONAL_INSTANT, value.nextUpdate());
|
||||
writer.writeValue(ENCODED_OBJECT, value.encoded());
|
||||
writer.writeValue(DURABLE_CONTENT, value.content());
|
||||
writer.writeValue(ATTRIBUTE_SET, value.attributes());
|
||||
}
|
||||
|
||||
private static StatusObject readStatusObject(Reader reader) throws IOException {
|
||||
return new StatusObject(reader.readValue(PKI_ID), reader.readValue(FORMAT_ID), reader.readValue(PKI_ID),
|
||||
reader.readValue(STATUS_OBJECT_TYPE), reader.readValue(INSTANT), reader.readValue(OPTIONAL_INSTANT),
|
||||
reader.readValue(ENCODED_OBJECT), reader.readValue(ATTRIBUTE_SET));
|
||||
PkiId statusObjectId = reader.readValue(PKI_ID);
|
||||
FormatId formatId = reader.readValue(FORMAT_ID);
|
||||
PkiId issuerCaId = reader.readValue(PKI_ID);
|
||||
StatusObjectType type = reader.readValue(STATUS_OBJECT_TYPE);
|
||||
Instant thisUpdate = reader.readValue(INSTANT);
|
||||
Optional<Instant> nextUpdate = reader.readValue(OPTIONAL_INSTANT);
|
||||
DurableContentReference content = reader.readValue(DURABLE_CONTENT);
|
||||
return new StatusObject(statusObjectId, formatId, issuerCaId, type, thisUpdate, nextUpdate, content,
|
||||
reader.readValue(ATTRIBUTE_SET));
|
||||
}
|
||||
|
||||
private static void writePublication(Writer writer, PublicationRecord value) throws IOException {
|
||||
@@ -798,10 +848,11 @@ final class FsCodec {
|
||||
}
|
||||
|
||||
private static int toInt(long value) throws IOException {
|
||||
if (value < Integer.MIN_VALUE || value > Integer.MAX_VALUE) {
|
||||
throw new IOException("integer value out of range");
|
||||
try {
|
||||
return Math.toIntExact(value);
|
||||
} catch (ArithmeticException exception) {
|
||||
throw new IOException("integer value out of range", exception);
|
||||
}
|
||||
return (int) value;
|
||||
}
|
||||
|
||||
private static void writePolicyTrace(Writer writer, PolicyTrace value) throws IOException {
|
||||
@@ -1073,9 +1124,19 @@ final class FsCodec {
|
||||
private static final class Reader {
|
||||
|
||||
private final InputStream input;
|
||||
private final StagedContentStore stagedContent;
|
||||
|
||||
private Reader(InputStream input) {
|
||||
private Reader(InputStream input, StagedContentStore stagedContent) {
|
||||
this.input = input;
|
||||
this.stagedContent = stagedContent;
|
||||
}
|
||||
|
||||
private DurableContentReference restoreReference(String storeId, String contentId, Encoding encoding,
|
||||
long length, String sha256, DurableContentReference.Lifecycle lifecycle) throws IOException {
|
||||
if (stagedContent == null) {
|
||||
throw new IOException("durable content reference requires owning staged-content store");
|
||||
}
|
||||
return stagedContent.restoreReference(storeId, contentId, encoding, length, sha256, lifecycle);
|
||||
}
|
||||
|
||||
private <T> T readValue(ValueSchema<T> schema) throws IOException {
|
||||
|
||||
@@ -90,6 +90,10 @@ final class FsPaths {
|
||||
return this.root.resolve("SIGNING_TIME_WATERMARK");
|
||||
}
|
||||
|
||||
/* default */ Path stagedContentRoot() {
|
||||
return this.root.resolve("staged-content");
|
||||
}
|
||||
|
||||
/* default */ Path lockFile() {
|
||||
return this.root.resolve(LOCK_DIR).resolve(STORE_LOCK);
|
||||
}
|
||||
@@ -175,6 +179,10 @@ final class FsPaths {
|
||||
return revocationDir(credentialId).resolve("journal.bin");
|
||||
}
|
||||
|
||||
/* default */ Path revocationSnapshotRoot() {
|
||||
return this.root.resolve("revocation-snapshots");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Status objects (immutable .bin)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -113,6 +113,7 @@ final class FsSnapshotExporter {
|
||||
copyTreeIfExists(sourceRoot.resolve("policy"), targetRoot.resolve("policy"));
|
||||
copyTreeIfExists(sourceRoot.resolve("publications"), targetRoot.resolve("publications"));
|
||||
copyTreeIfExists(sourceRoot.resolve("sign-workflows"), targetRoot.resolve("sign-workflows"));
|
||||
copyTreeIfExists(sourceRoot.resolve("staged-content"), targetRoot.resolve("staged-content"));
|
||||
copyTreeIfExists(sourceRoot.resolve("revocations"), targetRoot.resolve("revocations"));
|
||||
copyImportedProfilesAsOf(profiles, targetRoot.resolve("profiles"));
|
||||
|
||||
|
||||
445
pki/src/main/java/zeroecho/pki/impl/fs/MetadataFrameCodec.java
Normal file
445
pki/src/main/java/zeroecho/pki/impl/fs/MetadataFrameCodec.java
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
471
pki/src/main/java/zeroecho/pki/impl/fs/MetadataStateIndex.java
Normal file
471
pki/src/main/java/zeroecho/pki/impl/fs/MetadataStateIndex.java
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
987
pki/src/main/java/zeroecho/pki/impl/fs/PosixMetadataLog.java
Normal file
987
pki/src/main/java/zeroecho/pki/impl/fs/PosixMetadataLog.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
1050
pki/src/main/java/zeroecho/pki/impl/fs/PosixMetadataLogScanner.java
Normal file
1050
pki/src/main/java/zeroecho/pki/impl/fs/PosixMetadataLogScanner.java
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,8 @@ import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import zeroecho.core.io.CancellationSignal;
|
||||
import zeroecho.core.io.RepeatableContent;
|
||||
import zeroecho.pki.api.EncodedObject;
|
||||
import zeroecho.pki.api.Encoding;
|
||||
import zeroecho.pki.api.KeyRef;
|
||||
@@ -66,7 +68,7 @@ import zeroecho.pki.api.audit.AccessContext;
|
||||
* <h2>Trust boundary</h2>
|
||||
* <ul>
|
||||
* <li><strong>PKI (caller):</strong> works only with {@link KeyRef}, algorithm
|
||||
* id and payload bytes; it must never parse {@code KeyRef} nor access private
|
||||
* id and repeatable content; it must never parse {@code KeyRef} nor access private
|
||||
* key bytes.</li>
|
||||
* <li><strong>Provider (callee):</strong> resolves {@code KeyRef} into an
|
||||
* internal runtime key handle, enforces policy (including multi-hop approvals),
|
||||
@@ -269,13 +271,14 @@ public interface SignatureWorkflow extends Closeable {
|
||||
* {@code null})
|
||||
* @param algorithmId requested signature algorithm id (never
|
||||
* blank)
|
||||
* @param payload payload bytes (never {@code null})
|
||||
* @param content immutable repeatable content
|
||||
* @param preferredSignatureEncoding preferred signature encoding (optional)
|
||||
* @param deadline optional absolute deadline
|
||||
*/
|
||||
record SignRequest(PkiId submissionId, String namespace, String semanticFingerprint, long fencingToken,
|
||||
AccessContext accessContext, KeyRef keyRef, String algorithmId, EncodedObject payload,
|
||||
Optional<Encoding> preferredSignatureEncoding, Optional<Instant> deadline) {
|
||||
AccessContext accessContext, KeyRef keyRef, String algorithmId, RepeatableContent content,
|
||||
Optional<Encoding> preferredSignatureEncoding, Optional<Instant> deadline,
|
||||
CancellationSignal cancellation) {
|
||||
|
||||
private static final long MIN_FENCING_TOKEN = 1L;
|
||||
|
||||
@@ -286,16 +289,17 @@ public interface SignatureWorkflow extends Closeable {
|
||||
Objects.requireNonNull(accessContext, "accessContext");
|
||||
Objects.requireNonNull(keyRef, "keyRef");
|
||||
Objects.requireNonNull(algorithmId, "algorithmId");
|
||||
Objects.requireNonNull(payload, "payload");
|
||||
Objects.requireNonNull(content, "content");
|
||||
Objects.requireNonNull(preferredSignatureEncoding, "preferredSignatureEncoding");
|
||||
Objects.requireNonNull(deadline, "deadline");
|
||||
Objects.requireNonNull(cancellation, "cancellation");
|
||||
if (algorithmId.isBlank()) {
|
||||
throw new IllegalArgumentException("algorithmId must not be blank");
|
||||
}
|
||||
if (fencingToken < MIN_FENCING_TOKEN) {
|
||||
throw new IllegalArgumentException("fencingToken must be positive");
|
||||
}
|
||||
String expected = fingerprint(namespace, accessContext, keyRef, algorithmId, payload,
|
||||
String expected = fingerprint(namespace, accessContext, keyRef, algorithmId, content,
|
||||
preferredSignatureEncoding, deadline);
|
||||
if (!constantTimeFingerprintEquals(expected, semanticFingerprint)) {
|
||||
throw new IllegalArgumentException("semanticFingerprint does not match signing request");
|
||||
@@ -306,12 +310,12 @@ public interface SignatureWorkflow extends Closeable {
|
||||
* Creates a request with its canonical versioned fingerprint.
|
||||
*/
|
||||
public static SignRequest create(PkiId submissionId, String namespace, long fencingToken,
|
||||
AccessContext accessContext, KeyRef keyRef, String algorithmId, EncodedObject payload,
|
||||
AccessContext accessContext, KeyRef keyRef, String algorithmId, RepeatableContent content,
|
||||
Optional<Encoding> preferredSignatureEncoding, Optional<Instant> deadline) {
|
||||
String fingerprint = fingerprint(namespace, accessContext, keyRef, algorithmId, payload,
|
||||
String fingerprint = fingerprint(namespace, accessContext, keyRef, algorithmId, content,
|
||||
preferredSignatureEncoding, deadline);
|
||||
return new SignRequest(submissionId, namespace, fingerprint, fencingToken, accessContext, keyRef,
|
||||
algorithmId, payload, preferredSignatureEncoding, deadline);
|
||||
algorithmId, content, preferredSignatureEncoding, deadline, CancellationSignal.NONE);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -320,17 +324,17 @@ public interface SignatureWorkflow extends Closeable {
|
||||
* constant-size digest state.
|
||||
*/
|
||||
public static String fingerprint(String namespace, AccessContext accessContext, KeyRef keyRef,
|
||||
String algorithmId, EncodedObject payload, Optional<Encoding> preferredSignatureEncoding,
|
||||
String algorithmId, RepeatableContent content, Optional<Encoding> preferredSignatureEncoding,
|
||||
Optional<Instant> deadline) {
|
||||
Objects.requireNonNull(namespace, "namespace");
|
||||
Objects.requireNonNull(accessContext, "accessContext");
|
||||
Objects.requireNonNull(keyRef, "keyRef");
|
||||
Objects.requireNonNull(algorithmId, "algorithmId");
|
||||
Objects.requireNonNull(payload, "payload");
|
||||
Objects.requireNonNull(content, "content");
|
||||
Objects.requireNonNull(preferredSignatureEncoding, "preferredSignatureEncoding");
|
||||
Objects.requireNonNull(deadline, "deadline");
|
||||
try {
|
||||
return fingerprintWithDigest(namespace, accessContext, keyRef, algorithmId, payload,
|
||||
return fingerprintWithDigest(namespace, accessContext, keyRef, algorithmId, content,
|
||||
preferredSignatureEncoding, deadline, MessageDigest.getInstance("SHA-256"), ignored -> {
|
||||
});
|
||||
} catch (NoSuchAlgorithmException ex) {
|
||||
@@ -339,16 +343,15 @@ public interface SignatureWorkflow extends Closeable {
|
||||
}
|
||||
|
||||
/* default */ static String fingerprintWithDigest(String namespace, AccessContext accessContext, KeyRef keyRef,
|
||||
String algorithmId, EncodedObject payload, Optional<Encoding> preferredSignatureEncoding,
|
||||
String algorithmId, RepeatableContent content, Optional<Encoding> preferredSignatureEncoding,
|
||||
Optional<Instant> deadline, MessageDigest digest, Consumer<byte[]> cleanupObserver) {
|
||||
Objects.requireNonNull(digest, "digest");
|
||||
Objects.requireNonNull(cleanupObserver, "cleanupObserver");
|
||||
byte[] payloadBytes = payload.bytes();
|
||||
byte[] digestBytes = null;
|
||||
try {
|
||||
try (DataOutputStream output = new DataOutputStream(
|
||||
new DigestOutputStream(OutputStream.nullOutputStream(), digest))) {
|
||||
output.writeUTF("sign-request-v1");
|
||||
output.writeUTF("sign-request-v2");
|
||||
output.writeUTF(namespace);
|
||||
output.writeUTF(accessContext.principal().type());
|
||||
output.writeUTF(accessContext.principal().name());
|
||||
@@ -357,9 +360,8 @@ public interface SignatureWorkflow extends Closeable {
|
||||
output.writeUTF(accessContext.formatId().map(zeroecho.pki.api.FormatId::value).orElse(""));
|
||||
output.writeUTF(keyRef.value());
|
||||
output.writeUTF(algorithmId);
|
||||
output.writeUTF(payload.encoding().name());
|
||||
output.writeInt(payloadBytes.length);
|
||||
output.write(payloadBytes);
|
||||
output.writeUTF(content.contentId());
|
||||
output.writeLong(content.length().orElse(-1L));
|
||||
output.writeUTF(preferredSignatureEncoding.map(Enum::name).orElse(""));
|
||||
output.writeUTF(deadline.map(Instant::toString).orElse(""));
|
||||
}
|
||||
@@ -368,7 +370,6 @@ public interface SignatureWorkflow extends Closeable {
|
||||
} catch (IOException ex) {
|
||||
throw new IllegalStateException("Unable to compute signing request fingerprint");
|
||||
} finally {
|
||||
clearOwned(payloadBytes, cleanupObserver);
|
||||
clearOwned(digestBytes, cleanupObserver);
|
||||
}
|
||||
}
|
||||
@@ -405,25 +406,26 @@ public interface SignatureWorkflow extends Closeable {
|
||||
*
|
||||
* @param accessContext audit/governance context (never {@code null})
|
||||
* @param algorithmId requested signature algorithm id (never blank)
|
||||
* @param payload signed payload bytes (never {@code null})
|
||||
* @param content immutable repeatable signed content
|
||||
* @param signature signature bytes (never {@code null})
|
||||
* @param publicKeyRef optional public key reference (preferred)
|
||||
* @param publicKeyEncoded optional encoded public key bytes (provider-specific;
|
||||
* may be unsupported)
|
||||
* @param deadline optional absolute deadline
|
||||
*/
|
||||
record VerifyRequest(AccessContext accessContext, String algorithmId, EncodedObject payload,
|
||||
record VerifyRequest(AccessContext accessContext, String algorithmId, RepeatableContent content,
|
||||
EncodedObject signature, Optional<KeyRef> publicKeyRef, Optional<EncodedObject> publicKeyEncoded,
|
||||
Optional<Instant> deadline) {
|
||||
Optional<Instant> deadline, CancellationSignal cancellation) {
|
||||
|
||||
public VerifyRequest {
|
||||
Objects.requireNonNull(accessContext, "accessContext");
|
||||
Objects.requireNonNull(algorithmId, "algorithmId");
|
||||
Objects.requireNonNull(payload, "payload");
|
||||
Objects.requireNonNull(content, "content");
|
||||
Objects.requireNonNull(signature, "signature");
|
||||
Objects.requireNonNull(publicKeyRef, "publicKeyRef");
|
||||
Objects.requireNonNull(publicKeyEncoded, "publicKeyEncoded");
|
||||
Objects.requireNonNull(deadline, "deadline");
|
||||
Objects.requireNonNull(cancellation, "cancellation");
|
||||
if (algorithmId.isBlank()) {
|
||||
throw new IllegalArgumentException("algorithmId must not be blank");
|
||||
}
|
||||
|
||||
@@ -35,8 +35,8 @@ package zeroecho.pki.spi.framework;
|
||||
|
||||
import java.math.BigInteger;
|
||||
|
||||
import zeroecho.pki.api.EncodedObject;
|
||||
import zeroecho.pki.api.KeyRef;
|
||||
import zeroecho.pki.api.content.DurableContentReference;
|
||||
import zeroecho.pki.api.credential.Credential;
|
||||
import zeroecho.pki.api.credential.CredentialBundle;
|
||||
import zeroecho.pki.impl.core.ValidatedCaCertificateRequest;
|
||||
@@ -140,7 +140,7 @@ public interface CredentialIssuerBackend {
|
||||
* or other framework-specific issuance
|
||||
* processing fails
|
||||
*/
|
||||
CredentialBundle issueEndEntity(ValidatedCertificateRequest request, EncodedObject issuerCertificate,
|
||||
CredentialBundle issueEndEntity(ValidatedCertificateRequest request, DurableContentReference issuerCertificate,
|
||||
KeyRef issuerKeyRef, BigInteger serial);
|
||||
|
||||
/**
|
||||
@@ -171,6 +171,7 @@ public interface CredentialIssuerBackend {
|
||||
* or other framework-specific issuance
|
||||
* processing fails
|
||||
*/
|
||||
Credential issueIntermediateCertificate(ValidatedCaCertificateRequest request, EncodedObject issuerCertificate,
|
||||
Credential issueIntermediateCertificate(ValidatedCaCertificateRequest request,
|
||||
DurableContentReference issuerCertificate,
|
||||
KeyRef issuerKeyRef);
|
||||
}
|
||||
|
||||
102
pki/src/main/java/zeroecho/pki/spi/framework/CrlEntrySource.java
Normal file
102
pki/src/main/java/zeroecho/pki/spi/framework/CrlEntrySource.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -33,10 +33,8 @@
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.spi.framework;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import zeroecho.pki.api.status.StatusObject;
|
||||
import zeroecho.pki.api.status.StatusObjectGenerateCommand;
|
||||
import zeroecho.pki.impl.framework.x509.X509SignedObjectCompletion;
|
||||
|
||||
/**
|
||||
* Generates status objects for a credential framework (e.g., CRL/delta CRL/OCSP
|
||||
@@ -49,11 +47,11 @@ public interface StatusObjectGenerator {
|
||||
* Generates a status object.
|
||||
*
|
||||
* @param command generation command
|
||||
* @param crlEntries structured CRL entries; empty for non-CRL objects
|
||||
* @param crlEntries stable streamed CRL entries
|
||||
* @return generated status object
|
||||
* @throws IllegalArgumentException if {@code command} or {@code crlEntries} is
|
||||
* invalid
|
||||
* @throws RuntimeException if generation fails
|
||||
*/
|
||||
StatusObject generate(StatusObjectGenerateCommand command, List<CrlEntry> crlEntries);
|
||||
X509SignedObjectCompletion generate(StatusObjectGenerateCommand command, CrlEntrySource crlEntries);
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
******************************************************************************/
|
||||
package zeroecho.pki.spi.publish;
|
||||
|
||||
import zeroecho.pki.api.EncodedObject;
|
||||
import zeroecho.core.io.RepeatableContent;
|
||||
import zeroecho.pki.api.publication.PublicationTarget;
|
||||
|
||||
/**
|
||||
@@ -50,5 +50,5 @@ public interface Publisher {
|
||||
* @throws IllegalArgumentException if inputs are null
|
||||
* @throws RuntimeException if publishing fails
|
||||
*/
|
||||
void publish(PublicationTarget target, EncodedObject payload);
|
||||
void publish(PublicationTarget target, RepeatableContent payload);
|
||||
}
|
||||
|
||||
92
pki/src/main/java/zeroecho/pki/spi/store/ContentSink.java
Normal file
92
pki/src/main/java/zeroecho/pki/spi/store/ContentSink.java
Normal 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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
46
pki/src/main/java/zeroecho/pki/spi/store/MetadataCursor.java
Normal file
46
pki/src/main/java/zeroecho/pki/spi/store/MetadataCursor.java
Normal 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();
|
||||
}
|
||||
163
pki/src/main/java/zeroecho/pki/spi/store/MetadataKey.java
Normal file
163
pki/src/main/java/zeroecho/pki/spi/store/MetadataKey.java
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
215
pki/src/main/java/zeroecho/pki/spi/store/MetadataSnapshot.java
Normal file
215
pki/src/main/java/zeroecho/pki/spi/store/MetadataSnapshot.java
Normal 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();
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,13 @@ import zeroecho.pki.api.status.StatusObject;
|
||||
*/
|
||||
public interface PkiStore extends SignWorkflowStore {
|
||||
|
||||
/**
|
||||
* Returns the runtime-owned durable streaming-content store.
|
||||
*
|
||||
* @return staged-content store used by this PKI store
|
||||
*/
|
||||
StagedContentStore stagedContent();
|
||||
|
||||
/**
|
||||
* Persists or updates a Certificate Authority (CA) record.
|
||||
*
|
||||
@@ -175,11 +182,12 @@ public interface PkiStore extends SignWorkflowStore {
|
||||
Optional<RevocationJournal> getRevocationJournal(PkiId credentialId);
|
||||
|
||||
/**
|
||||
* Lists authoritative revocation journals.
|
||||
* Opens a stable restartable streaming snapshot of authoritative revocations.
|
||||
*
|
||||
* @return immutable journal list
|
||||
* @return revocation snapshot
|
||||
* @throws IllegalStateException if the snapshot cannot be opened
|
||||
*/
|
||||
List<RevocationJournal> listRevocationJournals();
|
||||
RevocationSnapshot openRevocationSnapshot();
|
||||
|
||||
/**
|
||||
* Persists a status object.
|
||||
|
||||
120
pki/src/main/java/zeroecho/pki/spi/store/RevocationSnapshot.java
Normal file
120
pki/src/main/java/zeroecho/pki/spi/store/RevocationSnapshot.java
Normal 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;
|
||||
}
|
||||
}
|
||||
201
pki/src/main/java/zeroecho/pki/spi/store/StagedContentStore.java
Normal file
201
pki/src/main/java/zeroecho/pki/spi/store/StagedContentStore.java
Normal 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
Reference in New Issue
Block a user