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