From 5420c19d08b106a797999bd79c14342fb6284582 Mon Sep 17 00:00:00 2001 From: Leo Galambos Date: Sat, 1 Aug 2026 16:45:14 +0200 Subject: [PATCH] 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. --- .../alg/BootstrapAlgorithmIdentities.java | 226 ++++ .../common/sig/SignatureInteropProfiles.java | 28 +- .../zeroecho/core/io/CancellationSignal.java | 73 ++ .../java/zeroecho/core/io/ContentDigests.java | 86 ++ .../java/zeroecho/core/io/ContentSlice.java | 161 +++ .../core/io/ImmutableByteContent.java | 107 ++ .../java/zeroecho/core/io/OneShotContent.java | 75 ++ .../zeroecho/core/io/RepeatableContent.java | 97 ++ .../zeroecho/core/spec/AlgorithmIdentity.java | 589 +++++++++ .../core/spec/AlgorithmIdentityCatalog.java | 184 +++ .../core/spec/AlgorithmIdentityCodec.java | 70 ++ .../zeroecho/core/spec/AlgorithmSuite.java | 79 ++ .../spi/AlgorithmExecutionCapabilities.java | 141 +++ .../spi/AlgorithmExecutionCapability.java | 84 ++ .../AlgorithmExecutionCapabilityProvider.java | 56 + .../spec/AlgorithmIdentityPhaseATest.java | 150 +++ .../zeroecho/pki/api/ca/CaImportCommand.java | 4 +- .../api/content/DeploymentResourcePolicy.java | 74 ++ .../pki/api/content/DurableContentOwner.java | 119 ++ .../api/content/DurableContentReference.java | 117 ++ .../pki/api/content/ResourceLimit.java | 108 ++ .../pki/api/credential/Credential.java | 10 +- .../pki/api/credential/CredentialBundle.java | 4 +- .../zeroecho/pki/api/status/StatusObject.java | 10 +- .../core/CaCertificateProfileValidator.java | 9 +- .../zeroecho/pki/impl/core/CaProofGate.java | 132 ++- .../core/CertificateProfileValidator.java | 54 +- .../pki/impl/core/CredentialContent.java | 111 ++ .../pki/impl/core/CredentialSnapshots.java | 10 +- .../pki/impl/core/DefaultCaService.java | 57 +- .../pki/impl/core/DefaultIssuanceService.java | 27 +- .../impl/core/DefaultRevocationService.java | 14 +- .../impl/core/DefaultStatusObjectService.java | 250 +++- .../pki/impl/core/async/PkiSigningBus.java | 502 ++++++-- .../ZeroEchoLibSignatureWorkflow.java | 104 +- .../ZeroEchoLibSignatureWorkflowProvider.java | 44 +- .../framework/x509/StandardX509Bindings.java | 619 ++++++++++ .../framework/x509/StreamingDerReader.java | 681 +++++++++++ .../framework/x509/StreamingDerWriter.java | 155 +++ .../x509/X509AlgorithmIdentifier.java | 167 +++ .../framework/x509/X509AlgorithmResolver.java | 243 ++++ .../framework/x509/X509AlgorithmRole.java | 44 + .../framework/x509/X509AuthoritySnapshot.java | 635 ++++++++++ .../framework/x509/X509BindingCatalog.java | 216 ++++ .../impl/framework/x509/X509BindingRule.java | 137 +++ .../x509/X509BindingRuleProvider.java | 109 ++ .../framework/x509/X509BuiltInDefaults.java | 89 ++ .../framework/x509/X509ComponentCatalog.java | 253 ++++ .../framework/x509/X509ExecutionPlan.java | 97 ++ .../framework/x509/X509SecurityFloor.java | 79 ++ .../x509/X509SignedObjectCompletion.java | 79 ++ .../x509/X509SuiteCompatibility.java | 81 ++ .../x509/bc/BcX509AlgorithmAdapter.java | 168 +++ .../bc/BcX509CertificationRequestParser.java | 1 + .../x509/bc/BcX509CredentialFramework.java | 29 +- .../bc/BcX509CredentialFrameworkProvider.java | 11 +- .../bc/BcX509CredentialIssuerBackend.java | 98 +- .../bc/BcX509ProofOfPossessionVerifier.java | 66 +- .../x509/bc/BcX509SignedObjectValidator.java | 305 +++++ .../x509/bc/BcX509StatusObjectGenerator.java | 525 ++++++--- .../x509/bc/BcX509VerificationExecutor.java | 151 +++ .../framework/x509/bc/OidAlgorithmMapper.java | 83 +- .../x509/bc/PkiBusContentSigner.java | 126 +- .../bc/WorkflowProofOfPossessionVerifier.java | 55 +- .../impl/fs/CredentialContentTransaction.java | 563 +++++++++ .../pki/impl/fs/DurableMetadataFiles.java | 417 +++++++ .../pki/impl/fs/FilesystemPkiStore.java | 283 ++++- .../impl/fs/FilesystemStagedContentStore.java | 909 ++++++++++++++ .../fs/FilesystemTemporaryUniqueIndex.java | 305 +++++ .../java/zeroecho/pki/impl/fs/FsCodec.java | 129 +- .../java/zeroecho/pki/impl/fs/FsPaths.java | 8 + .../pki/impl/fs/FsSnapshotExporter.java | 1 + .../pki/impl/fs/MetadataFrameCodec.java | 445 +++++++ .../impl/fs/MetadataMutationPayloadCodec.java | 591 ++++++++++ .../pki/impl/fs/MetadataStateIndex.java | 471 ++++++++ .../fs/PosixMetadataAdapterLifecycle.java | 306 +++++ .../pki/impl/fs/PosixMetadataLog.java | 987 ++++++++++++++++ .../pki/impl/fs/PosixMetadataLogScanner.java | 1050 +++++++++++++++++ .../impl/fs/PosixMetadataSnapshotSupport.java | 559 +++++++++ .../pki/impl/fs/PosixMetadataStoreEngine.java | 820 +++++++++++++ .../fs/PosixMetadataTransactionSupport.java | 692 +++++++++++ .../fs/PosixTransactionalMetadataStore.java | 255 ++++ .../pki/spi/crypto/SignatureWorkflow.java | 48 +- .../framework/CredentialIssuerBackend.java | 7 +- .../pki/spi/framework/CrlEntrySource.java | 102 ++ .../spi/framework/StatusObjectGenerator.java | 8 +- .../zeroecho/pki/spi/publish/Publisher.java | 4 +- .../zeroecho/pki/spi/store/ContentSink.java | 92 ++ .../pki/spi/store/MetadataCommitResult.java | 67 ++ .../pki/spi/store/MetadataCursor.java | 46 + .../zeroecho/pki/spi/store/MetadataKey.java | 163 +++ .../pki/spi/store/MetadataSnapshot.java | 215 ++++ .../spi/store/MetadataStoreCapabilities.java | 63 + .../pki/spi/store/MetadataStoreException.java | 44 + .../pki/spi/store/MetadataStoreId.java | 27 + .../pki/spi/store/MetadataTransaction.java | 105 ++ .../pki/spi/store/MetadataTransactionId.java | 53 + .../java/zeroecho/pki/spi/store/PkiStore.java | 14 +- .../pki/spi/store/RevocationSnapshot.java | 120 ++ .../pki/spi/store/StagedContentStore.java | 201 ++++ .../pki/spi/store/TemporaryUniqueIndex.java | 95 ++ .../spi/store/TransactionalMetadataStore.java | 93 ++ ...e.spi.AlgorithmExecutionCapabilityProvider | 1 + .../e2e/CaProfileIssuanceEnforcementTest.java | 162 +-- .../pki/e2e/H7EndEntityAcceptanceE2eTest.java | 52 +- .../e2e/H7EndEntityCsrRejectionE2eTest.java | 4 +- .../java/zeroecho/pki/e2e/PkiCoreE2eTest.java | 11 +- .../zeroecho/pki/e2e/PkiProofGateE2eTest.java | 189 +-- .../DefaultStatusObjectServiceCrlTest.java | 156 ++- .../impl/core/H7ProfileEnforcementTest.java | 21 +- ...EffectiveCredentialStatusResolverTest.java | 27 +- .../core/async/PkiSigningBusFailureTest.java | 186 ++- .../PkiSigningBusOperatorApprovalTest.java | 22 +- .../async/PkiSigningBusResilienceTest.java | 14 +- .../ZeroEchoLibKeyRefParsingTest.java | 8 +- ...hoLibSignatureWorkflowPersistenceTest.java | 20 +- ...gnatureWorkflowVerifyEncodedEcdsaTest.java | 7 +- ...LibSignatureWorkflowVerifyEncodedTest.java | 7 +- .../x509/StreamingDerReaderTest.java | 207 ++++ .../framework/x509/X509BindingPhaseATest.java | 422 +++++++ .../bc/PkiBusContentSignerCleanupTest.java | 42 +- ...WorkflowProofOfPossessionVerifierTest.java | 11 +- .../pki/impl/fs/DurableMetadataFilesTest.java | 118 ++ ...ystemCredentialContentTransactionTest.java | 239 ++++ .../pki/impl/fs/FilesystemPkiStoreTest.java | 51 +- .../fs/FilesystemRevocationJournalTest.java | 16 +- .../fs/FilesystemSignWorkflowStoreTest.java | 112 +- .../fs/FilesystemStagedContentStoreTest.java | 411 +++++++ .../zeroecho/pki/impl/fs/FsCodecTest.java | 29 +- .../pki/impl/fs/MetadataFrameCodecTest.java | 669 +++++++++++ .../fs/MetadataMutationPayloadCodecTest.java | 469 ++++++++ .../pki/impl/fs/MetadataStateIndexTest.java | 220 ++++ .../impl/fs/PosixMetadataLogScannerTest.java | 579 +++++++++ .../pki/impl/fs/PosixMetadataLogTest.java | 409 +++++++ .../impl/fs/PosixMetadataStoreEngineTest.java | 633 ++++++++++ .../PosixTransactionalMetadataStoreTest.java | 919 +++++++++++++++ .../pki/spi/bootstrap/PkiBootstrapTest.java | 3 +- .../spi/crypto/SignRequestCleanupTest.java | 21 +- .../pki/spi/store/CommitOutcomeTest.java | 54 + .../InMemoryTransactionalMetadataStore.java | 746 ++++++++++++ .../pki/spi/store/MetadataKeyTest.java | 116 ++ ...ransactionalMetadataStoreContractTest.java | 565 +++++++++ .../DurableDelayedSignatureWorkflow.java | 13 +- ...ableOperatorApprovalSignatureWorkflow.java | 13 +- .../testkit/InMemorySignatureWorkflow.java | 28 +- .../OperatorApprovalSignatureWorkflow.java | 5 +- .../zeroecho/pki/testkit/PkiTestRuntime.java | 226 +++- 147 files changed, 26788 insertions(+), 1071 deletions(-) create mode 100644 lib/src/main/java/zeroecho/core/alg/BootstrapAlgorithmIdentities.java create mode 100644 lib/src/main/java/zeroecho/core/io/CancellationSignal.java create mode 100644 lib/src/main/java/zeroecho/core/io/ContentDigests.java create mode 100644 lib/src/main/java/zeroecho/core/io/ContentSlice.java create mode 100644 lib/src/main/java/zeroecho/core/io/ImmutableByteContent.java create mode 100644 lib/src/main/java/zeroecho/core/io/OneShotContent.java create mode 100644 lib/src/main/java/zeroecho/core/io/RepeatableContent.java create mode 100644 lib/src/main/java/zeroecho/core/spec/AlgorithmIdentity.java create mode 100644 lib/src/main/java/zeroecho/core/spec/AlgorithmIdentityCatalog.java create mode 100644 lib/src/main/java/zeroecho/core/spec/AlgorithmIdentityCodec.java create mode 100644 lib/src/main/java/zeroecho/core/spec/AlgorithmSuite.java create mode 100644 lib/src/main/java/zeroecho/core/spi/AlgorithmExecutionCapabilities.java create mode 100644 lib/src/main/java/zeroecho/core/spi/AlgorithmExecutionCapability.java create mode 100644 lib/src/main/java/zeroecho/core/spi/AlgorithmExecutionCapabilityProvider.java create mode 100644 lib/src/test/java/zeroecho/core/spec/AlgorithmIdentityPhaseATest.java create mode 100644 pki/src/main/java/zeroecho/pki/api/content/DeploymentResourcePolicy.java create mode 100644 pki/src/main/java/zeroecho/pki/api/content/DurableContentOwner.java create mode 100644 pki/src/main/java/zeroecho/pki/api/content/DurableContentReference.java create mode 100644 pki/src/main/java/zeroecho/pki/api/content/ResourceLimit.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/core/CredentialContent.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/framework/x509/StandardX509Bindings.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/framework/x509/StreamingDerReader.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/framework/x509/StreamingDerWriter.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/framework/x509/X509AlgorithmIdentifier.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/framework/x509/X509AlgorithmResolver.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/framework/x509/X509AlgorithmRole.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/framework/x509/X509AuthoritySnapshot.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/framework/x509/X509BindingCatalog.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/framework/x509/X509BindingRule.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/framework/x509/X509BindingRuleProvider.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/framework/x509/X509BuiltInDefaults.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/framework/x509/X509ComponentCatalog.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/framework/x509/X509ExecutionPlan.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/framework/x509/X509SecurityFloor.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/framework/x509/X509SignedObjectCompletion.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/framework/x509/X509SuiteCompatibility.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509AlgorithmAdapter.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509SignedObjectValidator.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509VerificationExecutor.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/fs/CredentialContentTransaction.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/fs/DurableMetadataFiles.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/fs/FilesystemStagedContentStore.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/fs/FilesystemTemporaryUniqueIndex.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/fs/MetadataFrameCodec.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/fs/MetadataMutationPayloadCodec.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/fs/MetadataStateIndex.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/fs/PosixMetadataAdapterLifecycle.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/fs/PosixMetadataLog.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/fs/PosixMetadataLogScanner.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/fs/PosixMetadataSnapshotSupport.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/fs/PosixMetadataStoreEngine.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/fs/PosixMetadataTransactionSupport.java create mode 100644 pki/src/main/java/zeroecho/pki/impl/fs/PosixTransactionalMetadataStore.java create mode 100644 pki/src/main/java/zeroecho/pki/spi/framework/CrlEntrySource.java create mode 100644 pki/src/main/java/zeroecho/pki/spi/store/ContentSink.java create mode 100644 pki/src/main/java/zeroecho/pki/spi/store/MetadataCommitResult.java create mode 100644 pki/src/main/java/zeroecho/pki/spi/store/MetadataCursor.java create mode 100644 pki/src/main/java/zeroecho/pki/spi/store/MetadataKey.java create mode 100644 pki/src/main/java/zeroecho/pki/spi/store/MetadataSnapshot.java create mode 100644 pki/src/main/java/zeroecho/pki/spi/store/MetadataStoreCapabilities.java create mode 100644 pki/src/main/java/zeroecho/pki/spi/store/MetadataStoreException.java create mode 100644 pki/src/main/java/zeroecho/pki/spi/store/MetadataStoreId.java create mode 100644 pki/src/main/java/zeroecho/pki/spi/store/MetadataTransaction.java create mode 100644 pki/src/main/java/zeroecho/pki/spi/store/MetadataTransactionId.java create mode 100644 pki/src/main/java/zeroecho/pki/spi/store/RevocationSnapshot.java create mode 100644 pki/src/main/java/zeroecho/pki/spi/store/StagedContentStore.java create mode 100644 pki/src/main/java/zeroecho/pki/spi/store/TemporaryUniqueIndex.java create mode 100644 pki/src/main/java/zeroecho/pki/spi/store/TransactionalMetadataStore.java create mode 100644 pki/src/main/resources/META-INF/services/zeroecho.core.spi.AlgorithmExecutionCapabilityProvider create mode 100644 pki/src/test/java/zeroecho/pki/impl/framework/x509/StreamingDerReaderTest.java create mode 100644 pki/src/test/java/zeroecho/pki/impl/framework/x509/X509BindingPhaseATest.java create mode 100644 pki/src/test/java/zeroecho/pki/impl/fs/DurableMetadataFilesTest.java create mode 100644 pki/src/test/java/zeroecho/pki/impl/fs/FilesystemCredentialContentTransactionTest.java create mode 100644 pki/src/test/java/zeroecho/pki/impl/fs/FilesystemStagedContentStoreTest.java create mode 100644 pki/src/test/java/zeroecho/pki/impl/fs/MetadataFrameCodecTest.java create mode 100644 pki/src/test/java/zeroecho/pki/impl/fs/MetadataMutationPayloadCodecTest.java create mode 100644 pki/src/test/java/zeroecho/pki/impl/fs/MetadataStateIndexTest.java create mode 100644 pki/src/test/java/zeroecho/pki/impl/fs/PosixMetadataLogScannerTest.java create mode 100644 pki/src/test/java/zeroecho/pki/impl/fs/PosixMetadataLogTest.java create mode 100644 pki/src/test/java/zeroecho/pki/impl/fs/PosixMetadataStoreEngineTest.java create mode 100644 pki/src/test/java/zeroecho/pki/impl/fs/PosixTransactionalMetadataStoreTest.java create mode 100644 pki/src/test/java/zeroecho/pki/spi/store/CommitOutcomeTest.java create mode 100644 pki/src/test/java/zeroecho/pki/spi/store/InMemoryTransactionalMetadataStore.java create mode 100644 pki/src/test/java/zeroecho/pki/spi/store/MetadataKeyTest.java create mode 100644 pki/src/test/java/zeroecho/pki/spi/store/TransactionalMetadataStoreContractTest.java diff --git a/lib/src/main/java/zeroecho/core/alg/BootstrapAlgorithmIdentities.java b/lib/src/main/java/zeroecho/core/alg/BootstrapAlgorithmIdentities.java new file mode 100644 index 0000000..2d92151 --- /dev/null +++ b/lib/src/main/java/zeroecho/core/alg/BootstrapAlgorithmIdentities.java @@ -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. + * + *

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

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

+ * SHA-1 and unknown aliases are rejected. The returned identity, rather than + * the alias, is authoritative. + *

+ * + * @param alias legacy provider spelling + * @return exact bootstrap identity, or empty when unknown or forbidden + */ + public static Optional 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 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 resolve(String value) { + Objects.requireNonNull(value, "value"); + Optional 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); + } +} diff --git a/lib/src/main/java/zeroecho/core/alg/common/sig/SignatureInteropProfiles.java b/lib/src/main/java/zeroecho/core/alg/common/sig/SignatureInteropProfiles.java index 6a770f6..b6cd0a9 100644 --- a/lib/src/main/java/zeroecho/core/alg/common/sig/SignatureInteropProfiles.java +++ b/lib/src/main/java/zeroecho/core/alg/common/sig/SignatureInteropProfiles.java @@ -38,8 +38,10 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; +import zeroecho.core.alg.BootstrapAlgorithmIdentities; import zeroecho.core.alg.ecdsa.EcdsaCurveSpec; import zeroecho.core.alg.rsa.RsaSigSpec; +import zeroecho.core.spec.AlgorithmIdentity; import zeroecho.core.spec.VoidSpec; /** @@ -96,6 +98,10 @@ public final class SignatureInteropProfiles { new SignatureInteropProfile("SHA512withRSA", "RSA", "RSA", RsaSigSpec.pkcs1v15(RsaSigSpec.Hash.SHA512), SignatureInteropProfile.SignatureRepresentation.IDENTITY, 0)), + Map.entry("SHA256withRSAandMGF1", + new SignatureInteropProfile("SHA256withRSAandMGF1", "RSA", "RSA", + RsaSigSpec.pss(RsaSigSpec.Hash.SHA256, 32), + SignatureInteropProfile.SignatureRepresentation.IDENTITY, 0)), Map.entry("SHA256withECDSA", new SignatureInteropProfile("SHA256withECDSA", "ECDSA", "ECDSA", // NOPMD EcdsaCurveSpec.P256, SignatureInteropProfile.SignatureRepresentation.ECDSA_DER_EXTERNAL_P1363_INTERNAL, @@ -114,6 +120,17 @@ public final class SignatureInteropProfiles { Map.entry("Ed448", new SignatureInteropProfile("Ed448", "Ed448", "Ed448", VoidSpec.INSTANCE, SignatureInteropProfile.SignatureRepresentation.IDENTITY, 0))); + private static final Map CANONICAL_PROFILES = Map.ofEntries( + canonical(BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256, "SHA256withRSA"), + canonical(BootstrapAlgorithmIdentities.RSA_PKCS1_SHA384, "SHA384withRSA"), + canonical(BootstrapAlgorithmIdentities.RSA_PKCS1_SHA512, "SHA512withRSA"), + canonical(BootstrapAlgorithmIdentities.RSA_PSS_SHA256, "SHA256withRSAandMGF1"), + canonical(BootstrapAlgorithmIdentities.ECDSA_SHA256, "SHA256withECDSA"), + canonical(BootstrapAlgorithmIdentities.ECDSA_SHA384, "SHA384withECDSA"), + canonical(BootstrapAlgorithmIdentities.ECDSA_SHA512, "SHA512withECDSA"), + canonical(BootstrapAlgorithmIdentities.ED25519_SIGNATURE, "Ed25519"), + canonical(BootstrapAlgorithmIdentities.ED448_SIGNATURE, "Ed448")); + private SignatureInteropProfiles() { } @@ -129,7 +146,8 @@ public final class SignatureInteropProfiles { if (algorithmId.isBlank()) { return Optional.empty(); } - return Optional.ofNullable(PROFILES.get(algorithmId)); + SignatureInteropProfile profile = CANONICAL_PROFILES.get(algorithmId); + return Optional.ofNullable(profile == null ? PROFILES.get(algorithmId) : profile); } /** @@ -139,7 +157,9 @@ public final class SignatureInteropProfiles { * @return immutable set of supported standard signature names */ public static Set algorithmIds() { - return PROFILES.keySet(); + Set identifiers = new java.util.LinkedHashSet<>(PROFILES.keySet()); + identifiers.addAll(CANONICAL_PROFILES.keySet()); + return Set.copyOf(identifiers); } /** @@ -164,4 +184,8 @@ public final class SignatureInteropProfiles { } return algorithmId; } + + private static Map.Entry canonical(AlgorithmIdentity identity, String alias) { + return Map.entry(identity.canonicalForm(), PROFILES.get(alias)); + } } diff --git a/lib/src/main/java/zeroecho/core/io/CancellationSignal.java b/lib/src/main/java/zeroecho/core/io/CancellationSignal.java new file mode 100644 index 0000000..8754112 --- /dev/null +++ b/lib/src/main/java/zeroecho/core/io/CancellationSignal.java @@ -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. + * + *

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

+ */ +@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"); + } + } +} diff --git a/lib/src/main/java/zeroecho/core/io/ContentDigests.java b/lib/src/main/java/zeroecho/core/io/ContentDigests.java new file mode 100644 index 0000000..54696b6 --- /dev/null +++ b/lib/src/main/java/zeroecho/core/io/ContentDigests.java @@ -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()); + } +} diff --git a/lib/src/main/java/zeroecho/core/io/ContentSlice.java b/lib/src/main/java/zeroecho/core/io/ContentSlice.java new file mode 100644 index 0000000..61e844d --- /dev/null +++ b/lib/src/main/java/zeroecho/core/io/ContentSlice.java @@ -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. + * + *

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

+ */ +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; + } + } +} diff --git a/lib/src/main/java/zeroecho/core/io/ImmutableByteContent.java b/lib/src/main/java/zeroecho/core/io/ImmutableByteContent.java new file mode 100644 index 0000000..dc9165b --- /dev/null +++ b/lib/src/main/java/zeroecho/core/io/ImmutableByteContent.java @@ -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. + * + *

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

+ */ +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); + } + } +} diff --git a/lib/src/main/java/zeroecho/core/io/OneShotContent.java b/lib/src/main/java/zeroecho/core/io/OneShotContent.java new file mode 100644 index 0000000..e80d9c7 --- /dev/null +++ b/lib/src/main/java/zeroecho/core/io/OneShotContent.java @@ -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. + * + *

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

+ */ +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; +} diff --git a/lib/src/main/java/zeroecho/core/io/RepeatableContent.java b/lib/src/main/java/zeroecho/core/io/RepeatableContent.java new file mode 100644 index 0000000..e392d44 --- /dev/null +++ b/lib/src/main/java/zeroecho/core/io/RepeatableContent.java @@ -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. + * + *

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

+ * + *

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

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

+ * The identifier is metadata, not authorization, and must not expose a + * temporary physical path. + *

+ * + * @return stable non-blank identifier + */ + String contentId(); + + /** + * Releases implementation-owned resources. + * + * @throws IOException if cleanup fails + */ + @Override + void close() throws IOException; +} diff --git a/lib/src/main/java/zeroecho/core/spec/AlgorithmIdentity.java b/lib/src/main/java/zeroecho/core/spec/AlgorithmIdentity.java new file mode 100644 index 0000000..c9a26ca --- /dev/null +++ b/lib/src/main/java/zeroecho/core/spec/AlgorithmIdentity.java @@ -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. + * + *

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

+ * + *

+ * Instances are immutable. Equality and hashing use the complete canonical + * semantics, including the role, family, and parameters. + *

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

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

+ */ + public interface Parameters { + + /** + * Returns the deterministic parameter representation. + * + * @return non-blank lowercase canonical component + */ + String canonicalForm(); + + /** + * Returns an independently owned immutable copy. + * + *

+ * Trusted extension implementations must not return mutable caller-owned + * state. Immutable records may return {@code this}. + *

+ * + * @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 codecs) { + Objects.requireNonNull(canonicalForm, "canonicalForm"); + Objects.requireNonNull(codecs, "codecs"); + if (!canonicalForm.startsWith("zealg:2:")) { + throw new IllegalArgumentException("Unsupported canonical algorithm identity version"); + } + Map 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); + } + } + } +} diff --git a/lib/src/main/java/zeroecho/core/spec/AlgorithmIdentityCatalog.java b/lib/src/main/java/zeroecho/core/spec/AlgorithmIdentityCatalog.java new file mode 100644 index 0000000..b231496 --- /dev/null +++ b/lib/src/main/java/zeroecho/core/spec/AlgorithmIdentityCatalog.java @@ -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. + * + *

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

+ */ +public final class AlgorithmIdentityCatalog { + + /** Namespace reserved for immutable built-in identities. */ + public static final String BUILTIN_NAMESPACE = "zeroecho"; + + private final Map identities; + + private AlgorithmIdentityCatalog(Map 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 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 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 extensions) { + Objects.requireNonNull(extensions, "extensions"); + Map 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. + * + *

+ * A trusted extension may add a tuple within an existing reserved family, but + * cannot introduce a new family under the built-in namespace. + *

+ * + * @param additions exact additive identities + * @return new immutable catalog + */ + public AlgorithmIdentityCatalog add(Collection additions) { + Objects.requireNonNull(additions, "additions"); + Map 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 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 identities() { + return identities.values().stream().sorted((left, right) -> left.canonicalForm() + .compareTo(right.canonicalForm())).toList(); + } + + private static AlgorithmIdentityCatalog create(Collection source, boolean builtIn) { + Objects.requireNonNull(source, "identities"); + Map 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); + } +} diff --git a/lib/src/main/java/zeroecho/core/spec/AlgorithmIdentityCodec.java b/lib/src/main/java/zeroecho/core/spec/AlgorithmIdentityCodec.java new file mode 100644 index 0000000..e178c03 --- /dev/null +++ b/lib/src/main/java/zeroecho/core/spec/AlgorithmIdentityCodec.java @@ -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. + * + *

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

+ */ +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); +} diff --git a/lib/src/main/java/zeroecho/core/spec/AlgorithmSuite.java b/lib/src/main/java/zeroecho/core/spec/AlgorithmSuite.java new file mode 100644 index 0000000..fa4d8dd --- /dev/null +++ b/lib/src/main/java/zeroecho/core/spec/AlgorithmSuite.java @@ -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. + * + *

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

+ * + * @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(); + } +} diff --git a/lib/src/main/java/zeroecho/core/spi/AlgorithmExecutionCapabilities.java b/lib/src/main/java/zeroecho/core/spi/AlgorithmExecutionCapabilities.java new file mode 100644 index 0000000..3ba003b --- /dev/null +++ b/lib/src/main/java/zeroecho/core/spi/AlgorithmExecutionCapabilities.java @@ -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. + * + *

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

+ */ +public final class AlgorithmExecutionCapabilities { + + private final List 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 capabilities) { + Objects.requireNonNull(capabilities, "capabilities"); + List copy = new ArrayList<>(capabilities); + copy.sort(Comparator.comparing(AlgorithmExecutionCapability::implementationId)); + Set 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 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 providers) { + Objects.requireNonNull(providers, "providers"); + List discovered = new ArrayList<>(); + List ordered = new ArrayList<>(providers); + ordered.sort(Comparator.comparing(provider -> provider.getClass().getName())); + for (AlgorithmExecutionCapabilityProvider provider : ordered) { + List 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 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 all() { + return capabilities; + } +} diff --git a/lib/src/main/java/zeroecho/core/spi/AlgorithmExecutionCapability.java b/lib/src/main/java/zeroecho/core/spi/AlgorithmExecutionCapability.java new file mode 100644 index 0000000..3a031ff --- /dev/null +++ b/lib/src/main/java/zeroecho/core/spi/AlgorithmExecutionCapability.java @@ -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. + * + *

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

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

+ * Providers are deployment code discovered using the existing ServiceLoader + * convention. Configuration may select an installed capability but cannot name + * or load an implementation class. + *

+ */ +@FunctionalInterface +public interface AlgorithmExecutionCapabilityProvider { + + /** + * Returns an immutable capability contribution. + * + * @return installed capabilities; never {@code null} + */ + List capabilities(); +} diff --git a/lib/src/test/java/zeroecho/core/spec/AlgorithmIdentityPhaseATest.java b/lib/src/test/java/zeroecho/core/spec/AlgorithmIdentityPhaseATest.java new file mode 100644 index 0000000..2696362 --- /dev/null +++ b/lib/src/test/java/zeroecho/core/spec/AlgorithmIdentityPhaseATest.java @@ -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; + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/api/ca/CaImportCommand.java b/pki/src/main/java/zeroecho/pki/api/ca/CaImportCommand.java index 7920189..22dbf5c 100644 --- a/pki/src/main/java/zeroecho/pki/api/ca/CaImportCommand.java +++ b/pki/src/main/java/zeroecho/pki/api/ca/CaImportCommand.java @@ -33,11 +33,11 @@ ******************************************************************************/ package zeroecho.pki.api.ca; -import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.FormatId; import zeroecho.pki.api.KeyRef; import zeroecho.pki.api.SubjectRef; import zeroecho.pki.api.attr.AttributeSet; +import zeroecho.pki.api.content.DurableContentReference; /** * Command to import an existing root CA credential into PKI inventory. @@ -55,7 +55,7 @@ import zeroecho.pki.api.attr.AttributeSet; * @param attributes universal attributes (may be empty but not null) */ public record CaImportCommand(FormatId formatId, SubjectRef subjectRef, String profileId, KeyRef keyRef, - EncodedObject existingCaCredential, AttributeSet attributes) { + DurableContentReference existingCaCredential, AttributeSet attributes) { /** * Creates a CA import command. diff --git a/pki/src/main/java/zeroecho/pki/api/content/DeploymentResourcePolicy.java b/pki/src/main/java/zeroecho/pki/api/content/DeploymentResourcePolicy.java new file mode 100644 index 0000000..fa2acb0 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/content/DeploymentResourcePolicy.java @@ -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. + * + *

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

+ * + * @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"); + } +} diff --git a/pki/src/main/java/zeroecho/pki/api/content/DurableContentOwner.java b/pki/src/main/java/zeroecho/pki/api/content/DurableContentOwner.java new file mode 100644 index 0000000..5aecfa0 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/content/DurableContentOwner.java @@ -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. + * + *

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

+ * + * @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 + } +} diff --git a/pki/src/main/java/zeroecho/pki/api/content/DurableContentReference.java b/pki/src/main/java/zeroecho/pki/api/content/DurableContentReference.java new file mode 100644 index 0000000..26cdfe9 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/content/DurableContentReference.java @@ -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. + * + *

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

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

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

+ */ +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 + } +} diff --git a/pki/src/main/java/zeroecho/pki/api/content/ResourceLimit.java b/pki/src/main/java/zeroecho/pki/api/content/ResourceLimit.java new file mode 100644 index 0000000..2c4d4b1 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/api/content/ResourceLimit.java @@ -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. + * + *

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

+ */ +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"); + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/api/credential/Credential.java b/pki/src/main/java/zeroecho/pki/api/credential/Credential.java index fab9f7b..3fb8ad9 100644 --- a/pki/src/main/java/zeroecho/pki/api/credential/Credential.java +++ b/pki/src/main/java/zeroecho/pki/api/credential/Credential.java @@ -33,13 +33,13 @@ ******************************************************************************/ package zeroecho.pki.api.credential; -import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.FormatId; import zeroecho.pki.api.IssuerRef; import zeroecho.pki.api.PkiId; import zeroecho.pki.api.SubjectRef; import zeroecho.pki.api.Validity; import zeroecho.pki.api.attr.AttributeSet; +import zeroecho.pki.api.content.DurableContentReference; /** * Issued credential with mandatory core metadata and universal attributes. @@ -70,12 +70,12 @@ import zeroecho.pki.api.attr.AttributeSet; * current revocation state and evaluation time are * external runtime inputs. Security-sensitive callers * must use {@link EffectiveCredentialStatusResolver}. - * @param encoded encoded credential bytes + * @param content immutable store-owned credential content * @param attributes universal attribute set */ public record Credential(PkiId credentialId, FormatId formatId, IssuerRef issuerRef, SubjectRef subjectRef, Validity validity, String serialOrUniqueId, PkiId publicKeyId, CredentialProfileBinding profileBinding, - CredentialStatus status, EncodedObject encoded, AttributeSet attributes) { + CredentialStatus status, DurableContentReference content, AttributeSet attributes) { /** * Creates a credential record. @@ -110,8 +110,8 @@ public record Credential(PkiId credentialId, FormatId formatId, IssuerRef issuer if (status == null) { throw new IllegalArgumentException("status must not be null"); } - if (encoded == null) { - throw new IllegalArgumentException("encoded must not be null"); + if (content == null) { + throw new IllegalArgumentException("content must not be null"); } if (attributes == null) { throw new IllegalArgumentException("attributes must not be null"); diff --git a/pki/src/main/java/zeroecho/pki/api/credential/CredentialBundle.java b/pki/src/main/java/zeroecho/pki/api/credential/CredentialBundle.java index cd86bfe..493e9b4 100644 --- a/pki/src/main/java/zeroecho/pki/api/credential/CredentialBundle.java +++ b/pki/src/main/java/zeroecho/pki/api/credential/CredentialBundle.java @@ -35,7 +35,7 @@ package zeroecho.pki.api.credential; import java.util.List; -import zeroecho.pki.api.EncodedObject; +import zeroecho.pki.api.content.DurableContentReference; /** * Bundle of a primary credential and supporting objects. @@ -49,7 +49,7 @@ import zeroecho.pki.api.EncodedObject; * @param credential primary credential * @param supportingObjects supporting artifacts (framework-defined ordering) */ -public record CredentialBundle(Credential credential, List supportingObjects) { +public record CredentialBundle(Credential credential, List supportingObjects) { /** * Creates a bundle. diff --git a/pki/src/main/java/zeroecho/pki/api/status/StatusObject.java b/pki/src/main/java/zeroecho/pki/api/status/StatusObject.java index c54f353..4388a6c 100644 --- a/pki/src/main/java/zeroecho/pki/api/status/StatusObject.java +++ b/pki/src/main/java/zeroecho/pki/api/status/StatusObject.java @@ -36,10 +36,10 @@ package zeroecho.pki.api.status; import java.time.Instant; import java.util.Optional; -import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.FormatId; import zeroecho.pki.api.PkiId; import zeroecho.pki.api.attr.AttributeSet; +import zeroecho.pki.api.content.DurableContentReference; /** * Generated status object used for revocation distribution. @@ -57,12 +57,12 @@ import zeroecho.pki.api.attr.AttributeSet; * @param type status object type * @param thisUpdate time of issuance/publication baseline * @param nextUpdate optional next update timestamp - * @param encoded encoded payload + * @param content immutable store-owned encoded content reference * @param attributes universal attributes describing the object (must not * contain secrets) */ public record StatusObject(PkiId statusObjectId, FormatId formatId, PkiId issuerCaId, StatusObjectType type, - Instant thisUpdate, Optional nextUpdate, EncodedObject encoded, AttributeSet attributes) { + Instant thisUpdate, Optional nextUpdate, DurableContentReference content, AttributeSet attributes) { /** * Creates a status object. @@ -89,8 +89,8 @@ public record StatusObject(PkiId statusObjectId, FormatId formatId, PkiId issuer if (nextUpdate == null) { throw new IllegalArgumentException("nextUpdate must not be null"); } - if (encoded == null) { - throw new IllegalArgumentException("encoded must not be null"); + if (content == null) { + throw new IllegalArgumentException("content must not be null"); } if (attributes == null) { throw new IllegalArgumentException("attributes must not be null"); diff --git a/pki/src/main/java/zeroecho/pki/impl/core/CaCertificateProfileValidator.java b/pki/src/main/java/zeroecho/pki/impl/core/CaCertificateProfileValidator.java index 2debf62..1b2bbd3 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/CaCertificateProfileValidator.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/CaCertificateProfileValidator.java @@ -80,12 +80,12 @@ final class CaCertificateProfileValidator { ActiveCertificateProfile activeProfile, CertificateProfileKind expectedKind, FormatId formatId, PkiId issuerCaId, PkiId subjectCaId, SubjectRef requestedSubject, EncodedObject exactPublicKey, Optional requestedValidity, Instant evaluationTime, Optional issuerNotAfter, - BigInteger serial) { + BigInteger serial, zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot authority) { CertificateProfileDefinition definition = activeProfile.definition(); requireProfileShape(definition, expectedKind, formatId); List approvedSubject = validateSubject(requestedSubject, definition); return validateApprovedSubject(operation, activeProfile, expectedKind, formatId, issuerCaId, subjectCaId, - approvedSubject, exactPublicKey, requestedValidity, evaluationTime, issuerNotAfter, serial); + approvedSubject, exactPublicKey, requestedValidity, evaluationTime, issuerNotAfter, serial, authority); } /* @@ -97,12 +97,13 @@ final class CaCertificateProfileValidator { ValidatedCaCertificateRequest.Operation operation, ActiveCertificateProfile activeProfile, CertificateProfileKind expectedKind, FormatId formatId, PkiId issuerCaId, PkiId subjectCaId, List approvedSubject, EncodedObject exactPublicKey, Optional requestedValidity, - Instant evaluationTime, Optional issuerNotAfter, BigInteger serial) { + Instant evaluationTime, Optional issuerNotAfter, BigInteger serial, + zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot authority) { CertificateProfileDefinition definition = activeProfile.definition(); requireProfileShape(definition, expectedKind, formatId); List subjectSnapshot = requireApprovedSubject(approvedSubject); CertificateProfileValidator.requireSubjectKeyAllowed(exactPublicKey, - definition.caPolicy().allowedSubjectKeyAlgorithmIds()); + definition.caPolicy().allowedSubjectKeyAlgorithmIds(), authority); Validity validity = approvedValidity(requestedValidity, definition, evaluationTime, issuerNotAfter, operation == ValidatedCaCertificateRequest.Operation.IMPORT_ROOT); SubjectRef canonicalSubject = new SubjectRef(BcX509ProfileSupport.subject(subjectSnapshot).toString()); diff --git a/pki/src/main/java/zeroecho/pki/impl/core/CaProofGate.java b/pki/src/main/java/zeroecho/pki/impl/core/CaProofGate.java index 78c5a2d..b1535c9 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/CaProofGate.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/CaProofGate.java @@ -33,7 +33,6 @@ ******************************************************************************/ package zeroecho.pki.impl.core; -import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; import java.security.MessageDigest; @@ -49,24 +48,30 @@ import org.bouncycastle.asn1.x509.AlgorithmIdentifier; import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; import org.bouncycastle.cert.X509CertificateHolder; import org.bouncycastle.operator.ContentSigner; -import org.bouncycastle.operator.ContentVerifier; -import org.bouncycastle.operator.DefaultSignatureAlgorithmIdentifierFinder; -import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder; +import zeroecho.core.spec.AlgorithmIdentity; +import zeroecho.core.io.ImmutableByteContent; +import zeroecho.core.spi.AlgorithmExecutionCapability; import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.Encoding; import zeroecho.pki.api.FormatId; import zeroecho.pki.api.KeyRef; import zeroecho.pki.api.PkiException; import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.content.DurableContentReference; import zeroecho.pki.api.audit.AccessContext; import zeroecho.pki.api.audit.AuditEvent; import zeroecho.pki.api.audit.Principal; import zeroecho.pki.api.audit.Purpose; import zeroecho.pki.impl.core.async.PkiSigningBus; +import zeroecho.pki.impl.framework.x509.X509AlgorithmRole; +import zeroecho.pki.impl.framework.x509.X509ExecutionPlan; +import zeroecho.pki.impl.framework.x509.bc.BcX509VerificationExecutor; +import zeroecho.pki.impl.framework.x509.bc.BcX509AlgorithmAdapter; import zeroecho.pki.spi.audit.AuditSink; import zeroecho.pki.util.async.AsyncState; import zeroecho.pki.util.async.AsyncStatus; +import zeroecho.pki.spi.store.ContentSink; /** * Internal fail-closed proof gate for CA signing keys. @@ -85,20 +90,27 @@ final class CaProofGate { private final PublicKeyInfoResolver publicKeyResolver; private final PkiSigningBus signingBus; private final AuditSink auditSink; - private final String signatureAlgorithmId; + private final AlgorithmIdentity signatureIdentity; private final Duration signingTtl; /* default */ CaProofGate(PublicKeyInfoResolver publicKeyResolver, PkiSigningBus signingBus, AuditSink auditSink, String signatureAlgorithmId, Duration signingTtl) { + this(publicKeyResolver, signingBus, auditSink, + signingBus.authority().resolveIdentity(Objects.requireNonNull(signatureAlgorithmId, + "signatureAlgorithmId")), signingTtl); + } + + /* default */ CaProofGate(PublicKeyInfoResolver publicKeyResolver, PkiSigningBus signingBus, AuditSink auditSink, + AlgorithmIdentity signatureIdentity, Duration signingTtl) { this.publicKeyResolver = Objects.requireNonNull(publicKeyResolver, "publicKeyResolver"); this.signingBus = Objects.requireNonNull(signingBus, "signingBus"); this.auditSink = Objects.requireNonNull(auditSink, "auditSink"); - this.signatureAlgorithmId = Objects.requireNonNull(signatureAlgorithmId, "signatureAlgorithmId"); + this.signatureIdentity = Objects.requireNonNull(signatureIdentity, "signatureIdentity"); this.signingTtl = Objects.requireNonNull(signingTtl, "signingTtl"); } /* default */ ContentSigner signer(ManagedKeyProof proof) { - return new BusBackedContentSigner(signingBus, proof.keyRef(), signatureAlgorithmId, signingTtl); + return new BusBackedContentSigner(signingBus, proof.keyRef(), signatureIdentity, signingTtl); } /* default */ SubjectPublicKeyInfo parseRootSpki(EncodedObject spki, FormatId formatId) { @@ -115,8 +127,24 @@ final class CaProofGate { } try { byte[] embeddedSpki = certificate.getSubjectPublicKeyInfo().getEncoded(); - return MessageDigest.isEqual(expectedSpki.bytes(), embeddedSpki) && certificate.isSignatureValid( - new JcaContentVerifierProviderBuilder().build(certificate.getSubjectPublicKeyInfo())); + BcX509AlgorithmAdapter adapter = new BcX509AlgorithmAdapter(signingBus.authority().bindings()); + AlgorithmIdentity outer = adapter.decode(certificate.getSignatureAlgorithm(), + X509AlgorithmRole.SIGNATURE_ALGORITHM); + AlgorithmIdentity inner = adapter.decode( + certificate.toASN1Structure().getTBSCertificate().getSignature(), + X509AlgorithmRole.SIGNATURE_ALGORITHM); + AlgorithmIdentity key = adapter.decode(certificate.getSubjectPublicKeyInfo().getAlgorithm(), + X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM); + X509ExecutionPlan plan = signingBus.authority().plan(outer, key, + AlgorithmExecutionCapability.Direction.VERIFY, + Optional.of(BcX509VerificationExecutor.IMPLEMENTATION_ID), "root-proof", + BcX509VerificationExecutor.class); + BcX509VerificationExecutor executor = plan.executor(); + boolean verified = executor.verify(signingBus.authority(), plan, certificate.getSubjectPublicKeyInfo(), + certificate.getSignatureAlgorithm(), + new ImmutableByteContent(certificate.toASN1Structure().getTBSCertificate().getEncoded()), + certificate.getSignature()); + return MessageDigest.isEqual(expectedSpki.bytes(), embeddedSpki) && verified && outer.equals(inner); } catch (Exception ex) { return false; } @@ -174,7 +202,7 @@ final class CaProofGate { } private byte[] signManagedKeyChallenge(KeyRef keyRef, byte[] challenge) { - ContentSigner contentSigner = new BusBackedContentSigner(signingBus, keyRef, signatureAlgorithmId, signingTtl); + ContentSigner contentSigner = new BusBackedContentSigner(signingBus, keyRef, signatureIdentity, signingTtl); try { contentSigner.getOutputStream().write(challenge); } catch (IOException ex) { @@ -186,10 +214,17 @@ final class CaProofGate { private boolean verifyChallenge(byte[] spkiDer, byte[] challenge, byte[] signature) { try { SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(spkiDer); - ContentVerifier verifier = new JcaContentVerifierProviderBuilder().build(spki) - .get(new DefaultSignatureAlgorithmIdentifierFinder().find(signatureAlgorithmId)); - verifier.getOutputStream().write(challenge); - return verifier.verify(signature); + BcX509AlgorithmAdapter adapter = new BcX509AlgorithmAdapter(signingBus.authority().bindings()); + AlgorithmIdentity keyIdentity = adapter.decode(spki.getAlgorithm(), + X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM); + X509ExecutionPlan plan = signingBus.authority().plan(signatureIdentity, + keyIdentity, AlgorithmExecutionCapability.Direction.VERIFY, + Optional.of(BcX509VerificationExecutor.IMPLEMENTATION_ID), "managed-key-proof", + BcX509VerificationExecutor.class); + BcX509VerificationExecutor executor = plan.executor(); + return executor.verify(signingBus.authority(), plan, spki, + adapter.encode(signatureIdentity, X509AlgorithmRole.SIGNATURE_ALGORITHM), + new ImmutableByteContent(challenge), signature); } catch (Exception ex) { return false; } @@ -231,21 +266,30 @@ final class CaProofGate { private final PkiSigningBus bus; private final KeyRef keyRef; - private final String algorithmId; + private final AlgorithmIdentity algorithmIdentity; private final Duration ttl; - private final ByteArrayOutputStream output; + private final ContentSink sink; + private final OutputStream output; - private BusBackedContentSigner(PkiSigningBus bus, KeyRef keyRef, String algorithmId, Duration ttl) { + private BusBackedContentSigner(PkiSigningBus bus, KeyRef keyRef, AlgorithmIdentity algorithmIdentity, + Duration ttl) { this.bus = bus; this.keyRef = keyRef; - this.algorithmId = algorithmId; + this.algorithmIdentity = algorithmIdentity; this.ttl = ttl; - this.output = new ByteArrayOutputStream(); + this.sink = bus.beginSigningContent(Encoding.BINARY); + try { + this.output = sink.outputStream(); + } catch (IOException exception) { + closeSinkPreserving(exception); + throw new PkiException("Signing content staging failed: code=SPOOL_STORAGE_FAILED", exception); + } } @Override public AlgorithmIdentifier getAlgorithmIdentifier() { - return new DefaultSignatureAlgorithmIdentifierFinder().find(algorithmId); + return new BcX509AlgorithmAdapter(bus.authority().bindings()).encode(algorithmIdentity, + X509AlgorithmRole.SIGNATURE_ALGORITHM); } @Override @@ -255,24 +299,40 @@ final class CaProofGate { @Override public byte[] getSignature() { - byte[] tbs = output.toByteArray(); + DurableContentReference content; try { - Principal owner = new Principal("SYSTEM", "pki"); - PkiId opId = bus.newSubmissionId(); - EncodedObject payload = new EncodedObject(Encoding.BINARY, tbs); - AccessContext accessContext = new AccessContext(owner, new Purpose("X509_SIGN"), Optional.empty(), - Optional.empty()); - PkiSigningBus.SignContinuation continuation = new PkiSigningBus.SignContinuation(accessContext, - algorithmId, payload, keyRef, Encoding.BINARY, Optional.empty()); - try { - bus.submitSign(opId, owner, keyRef, algorithmId, payload, ttl, Optional.of(continuation.encode())); - } catch (RuntimeException failure) { // NOPMD - delete state if submission partially persisted it - deletePreservingFailure(opId); - throw failure; - } - return awaitSignature(opId); + output.close(); + content = sink.complete(); + } catch (IOException exception) { + closeSinkPreserving(exception); + throw new PkiException("Signing content staging failed: code=SPOOL_STORAGE_FAILED", exception); + } + Principal owner = new Principal("SYSTEM", "pki"); + PkiId opId = bus.newSubmissionId(); + AccessContext accessContext = new AccessContext(owner, new Purpose("X509_SIGN"), Optional.empty(), + Optional.empty()); + String canonicalIdentity = algorithmIdentity.canonicalForm(); + PkiSigningBus.SignContinuation continuation = new PkiSigningBus.SignContinuation(accessContext, + canonicalIdentity, content, keyRef, Encoding.BINARY, Optional.empty()); + boolean submitted = false; + try { + bus.submitSign(opId, owner, keyRef, canonicalIdentity, content, ttl, + Optional.of(continuation.encode())); + submitted = true; } finally { - Arrays.fill(tbs, (byte) 0); + if (!submitted) { + deletePreservingFailure(opId); + bus.releaseContent(content); + } + } + return awaitSignature(opId); + } + + private void closeSinkPreserving(IOException primaryFailure) { + try { + sink.close(); + } catch (IOException cleanupFailure) { + primaryFailure.addSuppressed(cleanupFailure); } } diff --git a/pki/src/main/java/zeroecho/pki/impl/core/CertificateProfileValidator.java b/pki/src/main/java/zeroecho/pki/impl/core/CertificateProfileValidator.java index 006b249..88d6f1a 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/CertificateProfileValidator.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/CertificateProfileValidator.java @@ -49,12 +49,7 @@ import java.util.Map; import java.util.Optional; import java.util.Set; -import org.bouncycastle.asn1.ASN1ObjectIdentifier; -import org.bouncycastle.asn1.DERNull; -import org.bouncycastle.asn1.edec.EdECObjectIdentifiers; -import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers; import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; -import org.bouncycastle.asn1.x9.X9ObjectIdentifiers; import zeroecho.pki.api.Encoding; import zeroecho.pki.api.PkiException; @@ -74,6 +69,11 @@ import zeroecho.pki.api.request.ParsedCertificationRequest; import zeroecho.pki.api.request.SubjectAlternativeName; import zeroecho.pki.api.request.SubjectRdn; import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes; +import zeroecho.pki.impl.framework.x509.bc.BcX509AlgorithmAdapter; +import zeroecho.pki.impl.framework.x509.X509AlgorithmRole; +import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot; +import zeroecho.core.alg.BootstrapAlgorithmIdentities; +import zeroecho.core.spec.AlgorithmIdentity; import zeroecho.pki.impl.framework.x509.bc.BcX509ProfileSupport; /** @@ -83,18 +83,20 @@ import zeroecho.pki.impl.framework.x509.bc.BcX509ProfileSupport; @SuppressWarnings("PMD.CyclomaticComplexity") final class CertificateProfileValidator { + private static final String EC_FAMILY = "ec"; + private CertificateProfileValidator() { } /* package */ static ValidatedCertificateRequest validate(VerifiedIssuanceCandidate candidate, CertificateProfile profile, CertificateProfileRef profileReference, Credential issuerCredential, - Instant evaluationTime) { + Instant evaluationTime, X509AuthoritySnapshot authority) { ParsedCertificationRequest request = candidate.request(); LeafCertificatePolicy policy = profile.leafPolicy(); requireCanonicalRequestAttributes(request); List approvedSubject = validateSubject(request, profile); List approvedSans = validateSans(request, policy, approvedSubject.isEmpty()); - requireSubjectKeyAllowed(candidate.exactPublicKey(), policy.allowedSubjectKeyAlgorithmIds()); + requireSubjectKeyAllowed(candidate.exactPublicKey(), policy.allowedSubjectKeyAlgorithmIds(), authority); Validity validity = approvedValidity(candidate, request, profile, issuerCredential, evaluationTime); boolean sanCritical = approvedSubject.isEmpty() || policy.subjectAlternativeNamePolicy().criticalWithNonemptySubject(); @@ -225,15 +227,24 @@ final class CertificateProfileValidator { // The public exception deliberately redacts ASN.1 parser details. @SuppressWarnings({ "PMD.PreserveStackTrace", "PMD.AvoidRethrowingException" }) /* package */ static void requireSubjectKeyAllowed(zeroecho.pki.api.EncodedObject exactPublicKey, - Set allowedAlgorithms) { + Set allowedAlgorithms, X509AuthoritySnapshot authority) { if (exactPublicKey.encoding() != Encoding.DER) { throw reject("SUBJECT_KEY_UNSUPPORTED"); } byte[] encoded = exactPublicKey.bytes(); try { SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(encoded); - SubjectKeyAlgorithm algorithm = subjectKeyAlgorithm(spki.getAlgorithm().getAlgorithm()); - requireSupportedParameters(spki, algorithm); + AlgorithmIdentity identity; + try { + identity = new BcX509AlgorithmAdapter(authority.bindings()).decode(spki.getAlgorithm(), + X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM); + } catch (IllegalArgumentException invalidBinding) { + boolean knownOid = authority.bindings().rules().stream() + .filter(rule -> rule.role() == X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM) + .anyMatch(rule -> rule.oid().equals(spki.getAlgorithm().getAlgorithm().getId())); + throw reject(knownOid ? "SUBJECT_KEY_PARAMETERS_UNSUPPORTED" : "SUBJECT_KEY_ALGORITHM_UNKNOWN"); + } + SubjectKeyAlgorithm algorithm = subjectKeyAlgorithm(identity); if (!allowedAlgorithms.contains(algorithm.profileId())) { throw reject("SUBJECT_KEY_ALGORITHM_FORBIDDEN"); } @@ -258,35 +269,22 @@ final class CertificateProfileValidator { } } - private static SubjectKeyAlgorithm subjectKeyAlgorithm(ASN1ObjectIdentifier oid) { - if (PKCSObjectIdentifiers.rsaEncryption.equals(oid)) { + private static SubjectKeyAlgorithm subjectKeyAlgorithm(AlgorithmIdentity identity) { + if (identity.equals(BootstrapAlgorithmIdentities.RSA_PUBLIC_KEY)) { return new SubjectKeyAlgorithm("RSA", "RSA"); } - if (X9ObjectIdentifiers.id_ecPublicKey.equals(oid)) { + if (EC_FAMILY.equals(identity.family().name())) { return new SubjectKeyAlgorithm("ECDSA", "EC"); } - if (EdECObjectIdentifiers.id_Ed25519.equals(oid)) { + if (identity.equals(BootstrapAlgorithmIdentities.ED25519_PUBLIC_KEY)) { return new SubjectKeyAlgorithm("Ed25519", "Ed25519"); } - if (EdECObjectIdentifiers.id_Ed448.equals(oid)) { + if (identity.equals(BootstrapAlgorithmIdentities.ED448_PUBLIC_KEY)) { return new SubjectKeyAlgorithm("Ed448", "Ed448"); } throw reject("SUBJECT_KEY_ALGORITHM_UNKNOWN"); } - private static void requireSupportedParameters(SubjectPublicKeyInfo spki, SubjectKeyAlgorithm algorithm) { - org.bouncycastle.asn1.ASN1Encodable parameters = spki.getAlgorithm().getParameters(); - boolean supported = switch (algorithm.profileId()) { - case "RSA" -> DERNull.INSTANCE.equals(parameters); - case "ECDSA" -> parameters instanceof ASN1ObjectIdentifier; - case "Ed25519", "Ed448" -> parameters == null; - default -> false; - }; - if (!supported) { - throw reject("SUBJECT_KEY_PARAMETERS_UNSUPPORTED"); - } - } - // The public exception deliberately redacts temporal arithmetic details. @SuppressWarnings("PMD.PreserveStackTrace") private static Validity approvedValidity(VerifiedIssuanceCandidate candidate, ParsedCertificationRequest request, diff --git a/pki/src/main/java/zeroecho/pki/impl/core/CredentialContent.java b/pki/src/main/java/zeroecho/pki/impl/core/CredentialContent.java new file mode 100644 index 0000000..d31ee34 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/core/CredentialContent.java @@ -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 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"); + } + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/core/CredentialSnapshots.java b/pki/src/main/java/zeroecho/pki/impl/core/CredentialSnapshots.java index c5eaa34..9b23049 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/CredentialSnapshots.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/CredentialSnapshots.java @@ -36,7 +36,6 @@ package zeroecho.pki.impl.core; import java.util.ArrayList; import java.util.List; -import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.attr.AttributeId; import zeroecho.pki.api.attr.AttributeSet; import zeroecho.pki.api.attr.AttributeValue; @@ -54,18 +53,13 @@ final class CredentialSnapshots { /* default */ static CredentialBundle copy(CredentialBundle source) { Credential credential = copy(source.credential()); - List supporting = source.supportingObjects().stream().map(CredentialSnapshots::copy).toList(); - return new CredentialBundle(credential, supporting); + return new CredentialBundle(credential, List.copyOf(source.supportingObjects())); } /* default */ static Credential copy(Credential source) { return new Credential(source.credentialId(), source.formatId(), source.issuerRef(), source.subjectRef(), source.validity(), source.serialOrUniqueId(), source.publicKeyId(), source.profileBinding(), - source.status(), copy(source.encoded()), copy(source.attributes())); - } - - private static EncodedObject copy(EncodedObject source) { - return new EncodedObject(source.encoding(), source.bytes().clone()); + source.status(), source.content(), copy(source.attributes())); } private static AttributeSet copy(AttributeSet source) { diff --git a/pki/src/main/java/zeroecho/pki/impl/core/DefaultCaService.java b/pki/src/main/java/zeroecho/pki/impl/core/DefaultCaService.java index 7297892..7bcbecf 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/DefaultCaService.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/DefaultCaService.java @@ -60,6 +60,8 @@ import org.bouncycastle.operator.ContentSigner; import org.bouncycastle.operator.OperatorCreationException; import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder; +import zeroecho.core.spec.AlgorithmIdentity; +import zeroecho.core.spi.AlgorithmExecutionCapability; import zeroecho.pki.api.CaService; import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.Encoding; @@ -91,6 +93,8 @@ import zeroecho.pki.api.credential.EffectiveCredentialStatusResolver; import zeroecho.pki.api.profile.ActiveCertificateProfile; import zeroecho.pki.api.profile.CertificateProfileKind; import zeroecho.pki.impl.core.async.PkiSigningBus; +import zeroecho.pki.impl.framework.x509.X509ExecutionPlan; +import zeroecho.pki.spi.crypto.SignatureWorkflow; import zeroecho.pki.impl.core.attr.SimpleAttributeSet; import zeroecho.pki.spi.audit.AuditSink; import zeroecho.pki.spi.framework.CredentialFramework; @@ -167,10 +171,12 @@ public final class DefaultCaService implements CaService { private final CredentialFramework framework; private final CredentialIssuerBackend issuerBackend; private final CaProofGate proofGate; + private final zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot authority; private final AuditSink auditSink; private final EffectiveCredentialStatusResolver statusResolver; private final ProfileService profileService; private final Clock clock; + private final AlgorithmIdentity signatureIdentity; /** * Creates a CA service bound to a specific store, credential framework, and @@ -232,6 +238,7 @@ public final class DefaultCaService implements CaService { this.issuerBackend = Objects.requireNonNull(issuerBackend, "issuerBackend"); Objects.requireNonNull(publicKeyResolver, "publicKeyResolver"); Objects.requireNonNull(signingBus, "signingBus"); + this.authority = signingBus.authority(); this.auditSink = Objects.requireNonNull(auditSink, "auditSink"); this.statusResolver = Objects.requireNonNull(statusResolver, "statusResolver"); this.profileService = Objects.requireNonNull(profileService, "profileService"); @@ -242,7 +249,12 @@ public final class DefaultCaService implements CaService { if (signingTtl == null || signingTtl.isZero() || signingTtl.isNegative()) { throw new IllegalArgumentException("signingTtl must be positive"); } - this.proofGate = new CaProofGate(publicKeyResolver, signingBus, auditSink, signatureAlgorithmId, signingTtl); + AlgorithmIdentity signatureIdentity = signingBus.authority().resolveIdentity(signatureAlgorithmId); + X509ExecutionPlan plan = signingBus.authority() + .planSigning(signatureIdentity.canonicalForm(), SignatureWorkflow.class); + signingBus.authority().authorize(plan, plan.executor(), AlgorithmExecutionCapability.Direction.SIGN); + this.signatureIdentity = signatureIdentity; + this.proofGate = new CaProofGate(publicKeyResolver, signingBus, auditSink, signatureIdentity, signingTtl); } /** @@ -294,7 +306,7 @@ public final class DefaultCaService implements CaService { ValidatedCaCertificateRequest request = CaCertificateProfileValidator.validate( ValidatedCaCertificateRequest.Operation.CREATE_ROOT, activeProfile, CertificateProfileKind.ROOT_CA, command.formatId(), new PkiId("ca:pending-root"), new PkiId("ca:pending-root"), command.subjectRef(), - spki, Optional.empty(), evaluationTime, Optional.empty(), serial); + spki, Optional.empty(), evaluationTime, Optional.empty(), serial, authority); SubjectPublicKeyInfo rootPublicKeyInfo = proofGate.parseRootSpki(spki, command.formatId()); CaProofGate.ManagedKeyProof proof = proofGate.proveManagedKey(keyRef, command.formatId(), CREATE_ROOT_REJECTED, Optional.empty()); @@ -342,7 +354,7 @@ public final class DefaultCaService implements CaService { Credential credential = new Credential(credId, command.formatId(), new IssuerRef(caId), request.subjectRef(), validity, serial.toString(), publicKeyId, new CaProfileBinding(request.profileReference()), - CredentialStatus.ISSUED, new EncodedObject(Encoding.DER, certDer), + CredentialStatus.ISSUED, CredentialContent.stage(store, certDer), SimpleAttributeSet.builder().build()); CredentialProfileBindings.requireCaBinding(credential.profileBinding(), request.profileReference()); @@ -394,7 +406,8 @@ public final class DefaultCaService implements CaService { throw new PkiException("Only DER import supported by this runtime"); } - byte[] certDer = command.existingCaCredential().bytes().clone(); + CredentialContent.validateCertificate(store, command.existingCaCredential(), authority, Optional.empty()); + byte[] certDer = CredentialContent.materializeForBc(store, command.existingCaCredential()); X509CertificateHolder holder; try { holder = new X509CertificateHolder(certDer); @@ -423,10 +436,10 @@ public final class DefaultCaService implements CaService { ValidatedCaCertificateRequest request = CaCertificateProfileValidator.validate( ValidatedCaCertificateRequest.Operation.IMPORT_ROOT, activeProfile, CertificateProfileKind.ROOT_CA, command.formatId(), caId, caId, command.subjectRef(), spki, Optional.of(validity), evaluationTime, - Optional.empty(), serial); + Optional.empty(), serial, authority); Credential credential = new Credential(credId, command.formatId(), new IssuerRef(caId), request.subjectRef(), validity, serial.toString(), publicKeyId, new CaProfileBinding(request.profileReference()), - CredentialStatus.ISSUED, new EncodedObject(Encoding.DER, certDer), + CredentialStatus.ISSUED, command.existingCaCredential(), SimpleAttributeSet.builder().build()); CredentialProfileBindings.requireCaBinding(credential.profileBinding(), request.profileReference()); requireCaCertificateMatches(credential, credential, request, caId, IMPORT_ROOT_REJECTED, @@ -511,7 +524,7 @@ public final class DefaultCaService implements CaService { ValidatedCaCertificateRequest.Operation.CREATE_INTERMEDIATE, activeProfile, CertificateProfileKind.INTERMEDIATE_CA, command.formatId(), command.issuerCaId(), caId, approvedSubject, subjectSpki, Optional.empty(), evaluationTime, Optional.of(issuerCredential.validity().notAfter()), - CertificateSerialAllocator.allocate()); + CertificateSerialAllocator.allocate(), authority); CaProofGate.ManagedKeyProof subjectProof = proofGate.proveManagedKey(command.keyRef().get(), command.formatId(), CREATE_INT_REJECTED, Optional.of(caId)); requireSameManagedKey(subjectSpki, subjectProof.exactPublicKey(), CREATE_INT_REJECTED, command.formatId(), @@ -520,7 +533,7 @@ public final class DefaultCaService implements CaService { Credential backendCredential; try { - backendCredential = issuerBackend.issueIntermediateCertificate(issue, issuerCredential.encoded(), + backendCredential = issuerBackend.issueIntermediateCertificate(issue, issuerCredential.content(), issuer.issuerKeyRef()); } catch (RuntimeException ex) { // NOPMD - reject malformed or mutable framework output throw proofGate.rejection(CREATE_INT_REJECTED, command.formatId(), Optional.of(caId), @@ -602,7 +615,8 @@ public final class DefaultCaService implements CaService { ValidatedCaCertificateRequest.Operation.ISSUE_INTERMEDIATE, activeProfile, CertificateProfileKind.INTERMEDIATE_CA, command.formatId(), command.issuerCaId(), command.subjectCaId(), approvedSubject, subjectSpki, command.requestedValidity(), evaluationTime, - Optional.of(issuerCredential.validity().notAfter()), CertificateSerialAllocator.allocate()); + Optional.of(issuerCredential.validity().notAfter()), CertificateSerialAllocator.allocate(), + authority); CaProofGate.ManagedKeyProof subjectProof = proofGate.proveManagedKey(subject.issuerKeyRef(), command.formatId(), ISSUE_INT_REJECTED, Optional.of(subject.caId())); requireSameManagedKey(subjectSpki, subjectProof.exactPublicKey(), ISSUE_INT_REJECTED, command.formatId(), @@ -612,7 +626,7 @@ public final class DefaultCaService implements CaService { Credential backendCredential; try { - backendCredential = issuerBackend.issueIntermediateCertificate(gated, issuerCredential.encoded(), + backendCredential = issuerBackend.issueIntermediateCertificate(gated, issuerCredential.content(), issuer.issuerKeyRef()); } catch (RuntimeException ex) { // NOPMD - reject malformed or mutable framework output throw proofGate.rejection(ISSUE_INT_REJECTED, command.formatId(), Optional.of(subject.caId()), @@ -833,10 +847,12 @@ public final class DefaultCaService implements CaService { private void requireIssuerKeyBinding(CaRecord issuer, Credential credential, FormatId formatId, String action, Optional objectId) { try { - if (credential.encoded().encoding() != Encoding.DER) { + if (credential.content().encoding() != Encoding.DER) { throw proofGate.rejection(action, formatId, objectId, "ISSUER_CREDENTIAL_INVALID"); } - X509CertificateHolder holder = new X509CertificateHolder(credential.encoded().bytes()); + CredentialContent.validateCertificate(store, credential.content(), authority, Optional.empty()); + X509CertificateHolder holder = new X509CertificateHolder(CredentialContent.materializeForBc(store, + credential.content())); CaProofGate.ManagedKeyProof proof = proofGate.proveManagedKey(issuer.issuerKeyRef(), formatId, action, objectId); if (!MessageDigest.isEqual(proof.exactPublicKey().bytes(), holder.getSubjectPublicKeyInfo().getEncoded())) { @@ -855,8 +871,13 @@ public final class DefaultCaService implements CaService { if (!matchesCaCredentialEnvelope(credential, request, subjectCaId)) { throw proofGate.rejection(action, framework.formatId(), Optional.of(subjectCaId), mismatchCode); } - X509CertificateHolder holder = new X509CertificateHolder(credential.encoded().bytes()); - X509CertificateHolder issuerHolder = new X509CertificateHolder(issuerCredential.encoded().bytes()); + CredentialContent.validateCertificate(store, credential.content(), authority, + Optional.of(signatureIdentity)); + CredentialContent.validateCertificate(store, issuerCredential.content(), authority, Optional.empty()); + byte[] credentialDer = CredentialContent.materializeForBc(store, credential.content()); + X509CertificateHolder holder = new X509CertificateHolder(credentialDer); + X509CertificateHolder issuerHolder = new X509CertificateHolder(CredentialContent.materializeForBc(store, + issuerCredential.content())); byte[] actualSpki = holder.getSubjectPublicKeyInfo().getEncoded(); Extension constraintsExtension = holder.getExtension(Extension.basicConstraints); BasicConstraints constraints = constraintsExtension == null ? null @@ -872,7 +893,7 @@ public final class DefaultCaService implements CaService { actualSpki) || !matchesCaCertificatePolicy(holder, request, constraintsExtension, constraints, keyUsageExtension, keyUsage) - || !matchesCaCredentialMetadata(credential, holder, request, actualSpki)) { + || !matchesCaCredentialMetadata(credential, holder, request, actualSpki, credentialDer)) { throw proofGate.rejection(action, framework.formatId(), Optional.of(subjectCaId), mismatchCode); } CredentialProfileBindings.requireCaBinding(credential.profileBinding(), request.profileReference()); @@ -885,7 +906,7 @@ public final class DefaultCaService implements CaService { private boolean matchesCaCredentialEnvelope(Credential credential, ValidatedCaCertificateRequest request, PkiId subjectCaId) { - return framework.formatId().equals(credential.formatId()) && credential.encoded().encoding() == Encoding.DER + return framework.formatId().equals(credential.formatId()) && credential.content().encoding() == Encoding.DER && credential.status() == CredentialStatus.ISSUED && credential.subjectRef().equals(request.subjectRef()) && credential.issuerRef() @@ -915,9 +936,9 @@ public final class DefaultCaService implements CaService { } private static boolean matchesCaCredentialMetadata(Credential credential, X509CertificateHolder holder, - ValidatedCaCertificateRequest request, byte[] actualSpki) { + ValidatedCaCertificateRequest request, byte[] actualSpki, byte[] credentialDer) { return credential.publicKeyId().equals(new PkiId("spki:" + sha256Hex(actualSpki))) - && credential.credentialId().equals(new PkiId("x509:" + sha256Hex(credential.encoded().bytes()))) + && credential.credentialId().equals(new PkiId("x509:" + sha256Hex(credentialDer))) && credential.serialOrUniqueId().equals(holder.getSerialNumber().toString()) && credential.validity().notBefore().getEpochSecond() == holder.getNotBefore().toInstant() .getEpochSecond() diff --git a/pki/src/main/java/zeroecho/pki/impl/core/DefaultIssuanceService.java b/pki/src/main/java/zeroecho/pki/impl/core/DefaultIssuanceService.java index 8d94b2e..3878015 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/DefaultIssuanceService.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/DefaultIssuanceService.java @@ -78,6 +78,9 @@ import zeroecho.pki.api.request.ParsedCertificationRequest; import zeroecho.pki.api.request.ProofOfPossessionResult; import zeroecho.pki.api.request.ProofOfPossessionStatus; import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes; +import zeroecho.pki.impl.framework.x509.bc.BcX509CredentialFramework; +import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot; +import zeroecho.pki.impl.framework.x509.X509BuiltInDefaults; import zeroecho.pki.impl.framework.x509.bc.BcX509ProfileSupport; import zeroecho.pki.spi.audit.AuditSink; import zeroecho.pki.spi.framework.CredentialFramework; @@ -157,6 +160,8 @@ public final class DefaultIssuanceService implements IssuanceService { private final EffectiveCredentialStatusResolver statusResolver; private final ProfileService profileService; private final Clock clock; + private final X509AuthoritySnapshot authority; + private final zeroecho.core.spec.AlgorithmIdentity expectedSignature; /** * Creates the issuance service bound to the supplied persistence and framework @@ -185,6 +190,11 @@ public final class DefaultIssuanceService implements IssuanceService { this.statusResolver = Objects.requireNonNull(statusResolver, "statusResolver"); this.profileService = Objects.requireNonNull(profileService, "profileService"); this.clock = Objects.requireNonNull(clock, "clock"); + if (!(framework instanceof BcX509CredentialFramework x509Framework)) { + throw new IllegalArgumentException("X.509 issuance requires an algorithm authority"); + } + this.authority = x509Framework.authority(); + this.expectedSignature = authority.resolveDefault(X509BuiltInDefaults.PKI_SIGNATURE_DEFAULT_V1).signature(); } /** @@ -250,7 +260,7 @@ public final class DefaultIssuanceService implements IssuanceService { ValidatedCertificateRequest validated; try { validated = CertificateProfileValidator.validate(candidate, profile, active.reference(), issuerCred, - evaluationTime); + evaluationTime, authority); } catch (PkiException exception) { throw rejection(candidate.request(), statusCode(exception)); } @@ -259,7 +269,7 @@ public final class DefaultIssuanceService implements IssuanceService { CredentialBundle bundle; try { bundle = CredentialSnapshots - .copy(issuerBackend.issueEndEntity(validated, issuerCred.encoded(), issuer.issuerKeyRef(), serial)); + .copy(issuerBackend.issueEndEntity(validated, issuerCred.content(), issuer.issuerKeyRef(), serial)); } catch (RuntimeException ex) { // NOPMD - framework output must cross the snapshot boundary throw rejection(candidate.request(), "BACKEND_CREDENTIAL_MISMATCH"); } @@ -441,14 +451,19 @@ public final class DefaultIssuanceService implements IssuanceService { try { CredentialProfileBindings.requireEndEntityBinding(credential.profileBinding(), validated.profileReference()); - if (!framework.formatId().equals(credential.formatId()) || credential.encoded().encoding() != Encoding.DER + if (!framework.formatId().equals(credential.formatId()) || credential.content().encoding() != Encoding.DER || !credential.subjectRef().equals(validated.subjectRef()) || !credential.issuerRef().equals(new zeroecho.pki.api.IssuerRef(validated.issuerCaId())) || credential.status() != CredentialStatus.ISSUED) { throw rejection(auditRequest, "BACKEND_CREDENTIAL_MISMATCH"); } - X509CertificateHolder holder = new X509CertificateHolder(credential.encoded().bytes()); - X509CertificateHolder issuerHolder = new X509CertificateHolder(issuerCredential.encoded().bytes()); + CredentialContent.validateCertificate(store, credential.content(), authority, + Optional.of(expectedSignature)); + CredentialContent.validateCertificate(store, issuerCredential.content(), authority, Optional.empty()); + byte[] credentialDer = CredentialContent.materializeForBc(store, credential.content()); + X509CertificateHolder holder = new X509CertificateHolder(credentialDer); + X509CertificateHolder issuerHolder = new X509CertificateHolder( + CredentialContent.materializeForBc(store, issuerCredential.content())); byte[] actualSpki = holder.getSubjectPublicKeyInfo().getEncoded(); if (!MessageDigest.isEqual(validated.exactPublicKey().bytes(), actualSpki) || !holder.getSubject().equals(BcX509ProfileSupport.subject(validated.subjectRdns())) @@ -457,7 +472,7 @@ public final class DefaultIssuanceService implements IssuanceService { new JcaContentVerifierProviderBuilder().build(issuerHolder.getSubjectPublicKeyInfo())) || !holder.getSerialNumber().equals(allocatedSerial) || !credential.publicKeyId().equals(new PkiId("spki:" + sha256Hex(actualSpki))) - || !credential.credentialId().equals(new PkiId("x509:" + sha256Hex(credential.encoded().bytes()))) + || !credential.credentialId().equals(new PkiId("x509:" + sha256Hex(credentialDer))) || !credential.serialOrUniqueId().equals(holder.getSerialNumber().toString()) || !credential.validity().equals(validated.validity()) || validated.validity().notBefore().getEpochSecond() != holder.getNotBefore().toInstant() diff --git a/pki/src/main/java/zeroecho/pki/impl/core/DefaultRevocationService.java b/pki/src/main/java/zeroecho/pki/impl/core/DefaultRevocationService.java index 937d23c..ad2ad7d 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/DefaultRevocationService.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/DefaultRevocationService.java @@ -112,8 +112,18 @@ public final class DefaultRevocationService implements RevocationService { @SuppressWarnings("PMD.AvoidCatchingGenericException") public List search(RevocationQuery query) { Objects.requireNonNull(query, "query"); - try { - return store.listRevocationJournals().stream().filter(journal -> matches(journal, query)).toList(); + try (zeroecho.pki.spi.store.RevocationSnapshot snapshot = store.openRevocationSnapshot(); + zeroecho.pki.spi.store.RevocationSnapshot.Cursor cursor = snapshot.openCursor()) { + List matching = new java.util.ArrayList<>(); + while (cursor.next()) { + RevocationJournal journal = cursor.current(); + if (matches(journal, query)) { + matching.add(journal); + } + } + return List.copyOf(matching); + } catch (java.io.IOException failure) { + throw new PkiException("Revocation snapshot failed: code=STORE_FAILED", failure); } catch (RuntimeException failure) { throw sanitized(failure); } diff --git a/pki/src/main/java/zeroecho/pki/impl/core/DefaultStatusObjectService.java b/pki/src/main/java/zeroecho/pki/impl/core/DefaultStatusObjectService.java index 032856b..78ac0fc 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/DefaultStatusObjectService.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/DefaultStatusObjectService.java @@ -33,17 +33,19 @@ ******************************************************************************/ package zeroecho.pki.impl.core; +import java.io.IOException; import java.math.BigInteger; import java.time.Instant; import java.util.Arrays; -import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.Optional; -import java.util.Set; +import java.util.OptionalLong; import org.bouncycastle.cert.X509CertificateHolder; +import zeroecho.core.io.CancellationSignal; +import zeroecho.core.io.RepeatableContent; import zeroecho.pki.api.Encoding; import zeroecho.pki.api.PkiException; import zeroecho.pki.api.PkiId; @@ -51,6 +53,7 @@ import zeroecho.pki.api.StatusObjectService; import zeroecho.pki.api.attr.AttributeValue; import zeroecho.pki.api.ca.CaRecord; import zeroecho.pki.api.ca.CaState; +import zeroecho.pki.api.content.DurableContentReference; import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.credential.CredentialUse; import zeroecho.pki.api.credential.EffectiveCredentialStatus; @@ -65,11 +68,18 @@ import zeroecho.pki.api.status.StatusObjectQuery; import zeroecho.pki.api.status.StatusObjectType; import zeroecho.pki.impl.core.attr.SimpleAttributeSet; import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes; +import zeroecho.pki.impl.framework.x509.bc.BcX509SignedObjectValidator; +import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot; +import zeroecho.pki.impl.framework.x509.X509ExecutionPlan; +import zeroecho.pki.impl.framework.x509.X509SignedObjectCompletion; +import zeroecho.pki.spi.crypto.SignatureWorkflow; import zeroecho.pki.impl.framework.x509.bc.BcX509CredentialFramework; import zeroecho.pki.spi.audit.AuditSink; import zeroecho.pki.spi.framework.CredentialFramework; import zeroecho.pki.spi.framework.CrlEntry; +import zeroecho.pki.spi.framework.CrlEntrySource; import zeroecho.pki.spi.store.PkiStore; +import zeroecho.pki.spi.store.RevocationSnapshot; /** * Default implementation of {@link StatusObjectService}. @@ -126,6 +136,7 @@ public final class DefaultStatusObjectService implements StatusObjectService { private final CredentialFramework framework; private final AuditSink auditSink; private final EffectiveCredentialStatusResolver statusResolver; + private final X509AuthoritySnapshot authority; /** * Creates a status object service bound to the supplied persistence and @@ -141,11 +152,12 @@ public final class DefaultStatusObjectService implements StatusObjectService { * @throws NullPointerException if an argument is {@code null} */ public DefaultStatusObjectService(PkiStore store, CredentialFramework framework, AuditSink auditSink, - EffectiveCredentialStatusResolver statusResolver) { + EffectiveCredentialStatusResolver statusResolver, X509AuthoritySnapshot authority) { this.store = Objects.requireNonNull(store, "store"); this.framework = Objects.requireNonNull(framework, "framework"); this.auditSink = Objects.requireNonNull(auditSink, "auditSink"); this.statusResolver = Objects.requireNonNull(statusResolver, "statusResolver"); + this.authority = Objects.requireNonNull(authority, "authority"); } /** @@ -193,6 +205,7 @@ public final class DefaultStatusObjectService implements StatusObjectService { * generated status object fails */ @Override + @SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" }) public StatusObject generate(StatusObjectGenerateCommand command) { if (command == null) { throw new IllegalArgumentException("command must not be null"); @@ -206,59 +219,79 @@ public final class DefaultStatusObjectService implements StatusObjectService { } EffectiveCredentialStatusResolver.Evaluation statusEvaluation = statusResolver.beginEvaluation(); Credential issuerCred = selectIssuerCredential(ca, command, statusEvaluation); - List crlEntries = command.type() == StatusObjectType.CRL - ? collectCrlEntries(command.issuerCaId(), statusEvaluation.evaluationTime()) - : List.of(); SimpleAttributeSet.Builder b = SimpleAttributeSet.builder(); b.putAll(command.attributes()); - b.put(BcX509Attributes.ISSUER_CERT_DER, new AttributeValue.BytesValue(issuerCred.encoded().bytes())); + b.put(BcX509Attributes.ISSUER_CERT_DER, + new AttributeValue.BytesValue(CredentialContent.materializeForBc(store, issuerCred.content()))); b.put(BcX509Attributes.ISSUER_KEYREF, new AttributeValue.StringValue(ca.issuerKeyRef().value())); StatusObjectGenerateCommand wired = new StatusObjectGenerateCommand(command.issuerCaId(), command.type(), command.formatId(), b.build()); - if (command.type() == StatusObjectType.CRL) { - return generateAndPersistCrl(wired, crlEntries); - } - StatusObject obj = framework.statusObjectGenerator().generate(wired, crlEntries); - store.putStatusObject(obj); - return obj; - } - - // Framework, signing, and store failures may carry provider or persisted - // material. CRL generation deliberately replaces the complete boundary with - // one fresh cause-free and suppressed-free exception. - @SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" }) - private StatusObject generateAndPersistCrl(StatusObjectGenerateCommand command, List 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 collectCrlEntries(PkiId issuerCaId, Instant evaluationTime) { - try { - List journals = Objects.requireNonNull(store.listRevocationJournals(), - "revocation journals"); - List entries = new java.util.ArrayList<>(); - Set serials = new HashSet<>(); - for (RevocationJournal journal : journals) { - collectCrlEntry(issuerCaId, evaluationTime, journal, serials).ifPresent(entries::add); + try (CrlEntrySource entries = command.type() == StatusObjectType.CRL + ? openCrlEntries(command.issuerCaId(), statusEvaluation.evaluationTime()) + : new EmptyCrlEntrySource()) { + if (command.type() == StatusObjectType.CRL) { + return generateAndPersistCrl(wired, entries, issuerCred); } - return List.copyOf(entries); - } catch (RuntimeException exception) { + return generateAndPersistOther(); + } catch (IOException | RuntimeException exception) { throw crlGenerationFailure(); } } - private Optional collectCrlEntry(PkiId issuerCaId, Instant evaluationTime, RevocationJournal journal, - Set serials) { + private StatusObject generateAndPersistCrl(StatusObjectGenerateCommand command, CrlEntrySource entries, + Credential issuer) { + X509SignedObjectCompletion completion = framework.statusObjectGenerator().generate(command, entries); + StatusObject generated = authority.requireStatusCompletion(completion); + X509ExecutionPlan 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 collectCrlEntry(PkiId issuerCaId, Instant evaluationTime, RevocationJournal journal) { Objects.requireNonNull(journal, "journal"); RevocationTransition latest = Objects.requireNonNull(journal.latest(), "latest transition"); if (latest.time().isAfter(evaluationTime)) { @@ -273,13 +306,10 @@ public final class DefaultStatusObjectService implements StatusObjectService { return Optional.empty(); } if (!BcX509CredentialFramework.FORMAT_ID.equals(credential.formatId()) - || credential.encoded().encoding() != Encoding.DER) { + || credential.content().encoding() != Encoding.DER) { throw crlGenerationFailure(); } BigInteger serial = certificateSerial(credential); - if (!serials.add(serial)) { - throw crlGenerationFailure(); - } RevocationReason reason = switch (latest.state()) { case HELD -> RevocationReason.CERTIFICATE_HOLD; case PERMANENTLY_REVOKED -> @@ -289,11 +319,131 @@ public final class DefaultStatusObjectService implements StatusObjectService { return Optional.of(new CrlEntry(serial, latest.time(), reason)); } + /** Stable restartable view over one revocation-store snapshot. */ + private final class JournalCrlEntrySource implements CrlEntrySource { + private final RevocationSnapshot snapshot; + private final PkiId issuerCaId; + private final Instant evaluationTime; + + private JournalCrlEntrySource(RevocationSnapshot snapshot, PkiId issuerCaId, Instant evaluationTime) { + this.snapshot = snapshot; + this.issuerCaId = issuerCaId; + this.evaluationTime = evaluationTime; + } + + @Override + public Cursor openCursor() throws IOException { + return new JournalCrlCursor(snapshot.openCursor(), issuerCaId, evaluationTime); + } + + @Override + public OptionalLong count() { + return OptionalLong.empty(); + } + + @Override + public void close() throws IOException { + snapshot.close(); + } + } + + /** Bounded cursor translating authoritative journals into CRL entries. */ + private final class JournalCrlCursor implements CrlEntrySource.Cursor { + private final RevocationSnapshot.Cursor cursor; + private final PkiId issuerCaId; + private final Instant evaluationTime; + private CrlEntry current; + private long ordinal = -1L; + + private JournalCrlCursor(RevocationSnapshot.Cursor cursor, PkiId issuerCaId, Instant evaluationTime) { + this.cursor = cursor; + this.issuerCaId = issuerCaId; + this.evaluationTime = evaluationTime; + } + + @Override + public boolean next() throws IOException { + while (cursor.next()) { + Optional candidate = collectCrlEntry(issuerCaId, evaluationTime, cursor.current()); + if (candidate.isPresent()) { + current = candidate.orElseThrow(); + ordinal = Math.addExact(ordinal, 1L); + return true; + } + } + current = null; + return false; + } + + @Override + public CrlEntry current() { + if (current == null) { + throw new IllegalStateException("CRL entry cursor is not positioned"); + } + return current; + } + + @Override + public long ordinal() { + if (current == null) { + throw new IllegalStateException("CRL entry cursor is not positioned"); + } + return ordinal; + } + + @Override + public void close() throws IOException { + cursor.close(); + current = null; + } + } + + /** Empty source used for status formats without revocation entries. */ + private static final class EmptyCrlEntrySource implements CrlEntrySource { + @Override + public Cursor openCursor() { + return new EmptyCrlCursor(); + } + + @Override + public OptionalLong count() { + return OptionalLong.of(0L); + } + + @Override + public void close() { + // No resources. + } + } + + /** Resource-free cursor for an empty status-entry source. */ + private static final class EmptyCrlCursor implements CrlEntrySource.Cursor { + @Override + public boolean next() { + return false; + } + + @Override + public CrlEntry current() { + throw new IllegalStateException("Empty CRL cursor has no entry"); + } + + @Override + public long ordinal() { + throw new IllegalStateException("Empty CRL cursor has no ordinal"); + } + + @Override + public void close() { + // No resources. + } + } + // Parser failures can contain persisted certificate details; the original // cause is intentionally removed at this public service boundary. @SuppressWarnings("PMD.PreserveStackTrace") - private static BigInteger certificateSerial(Credential credential) { - byte[] der = credential.encoded().bytes(); + private BigInteger certificateSerial(Credential credential) { + byte[] der = CredentialContent.materializeForBc(store, credential.content()); BigInteger serial; try { serial = new X509CertificateHolder(der).getSerialNumber(); diff --git a/pki/src/main/java/zeroecho/pki/impl/core/async/PkiSigningBus.java b/pki/src/main/java/zeroecho/pki/impl/core/async/PkiSigningBus.java index ab0e6fc..2ab7c07 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/async/PkiSigningBus.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/async/PkiSigningBus.java @@ -40,6 +40,7 @@ import java.time.Instant; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.atomic.AtomicBoolean; @@ -48,18 +49,27 @@ import java.util.concurrent.locks.ReentrantLock; import java.util.logging.Level; import java.util.logging.Logger; +import zeroecho.core.spec.AlgorithmIdentity; +import zeroecho.core.spi.AlgorithmExecutionCapability; +import zeroecho.core.io.RepeatableContent; import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.Encoding; import zeroecho.pki.api.KeyRef; import zeroecho.pki.api.PkiException; import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.content.DurableContentReference; +import zeroecho.pki.api.content.DurableContentOwner; import zeroecho.pki.api.audit.Principal; import zeroecho.pki.api.orch.OrchestrationDurabilityPolicy; import zeroecho.pki.api.orch.SigningSubmissionId; import zeroecho.pki.api.orch.WorkflowStateRecord; +import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot; +import zeroecho.pki.impl.framework.x509.X509ExecutionPlan; import zeroecho.pki.spi.crypto.SignatureWorkflow; import zeroecho.pki.spi.store.PkiStore; import zeroecho.pki.spi.store.SignWorkflowStore; +import zeroecho.pki.spi.store.ContentSink; +import zeroecho.pki.spi.store.TemporaryUniqueIndex; import zeroecho.pki.util.async.AsyncEndpoint; import zeroecho.pki.util.async.AsyncState; import zeroecho.pki.util.async.AsyncStatus; @@ -112,6 +122,7 @@ public final class PkiSigningBus implements AutoCloseable { private final PkiStore store; private final DurableAsyncBus bus; private final SignatureWorkflow signer; + private final X509AuthoritySnapshot authority; private final SecureRandom random; private final String namespace; private final OperationCoordinator coordinator; @@ -122,58 +133,70 @@ public final class PkiSigningBus implements AutoCloseable { private final OrchestrationDurabilityPolicy durabilityPolicy; /** - * Creates a signing bus. - * - * @param store persistent store (source of truth for - * continuation state) - * @param signer signature workflow - * @param durableLineStorePath path to append-only line store file - */ - public PkiSigningBus(PkiStore store, SignatureWorkflow signer, Path durableLineStorePath) { - this(store, signer, durableLineStorePath, resolveDisplaySuffixMaxLen(Optional.empty()), - OrchestrationDurabilityPolicy.DURABLE_MIN_STATE); - } - - /** - * Creates a signing bus. + * Creates a signing bus in an explicitly composed runtime authority graph. * *

- * The {@code displaySuffixMaxLen} parameter controls the maximum number of - * characters appended after {@code '#'} in operation identifiers returned by - * {@link #canonicalizeOperationId(PkiId, Principal)}. If - * {@code displaySuffixMaxLen} is not positive, the constructor fails. + * The authority must already bind every signing identity declared by + * {@code signer} to that exact workflow instance for the {@code SIGN} + * direction. This constructor validates ownership before registering the + * workflow or activating durable state. It never constructs an internal + * authority, accepts an authority from another runtime, or handles private + * key material. *

* - * @param store persistent store (source of truth for - * continuation state) - * @param signer signature workflow - * @param durableLineStorePath path to append-only line store file - * @param displaySuffixMaxLen maximum number of characters after {@code '#'} + * @param store persistent store + * @param signer exact signing workflow owned by + * {@code authority} + * @param durableLineStorePath durable bus path + * @param authority shared immutable runtime authority + * @throws NullPointerException if any argument is {@code null} + * @throws IllegalArgumentException if the authority does not own the exact + * workflow for every declared signing + * identity and the {@code SIGN} direction */ - public PkiSigningBus(PkiStore store, SignatureWorkflow signer, Path durableLineStorePath, int displaySuffixMaxLen) { - this(store, signer, durableLineStorePath, displaySuffixMaxLen, OrchestrationDurabilityPolicy.DURABLE_MIN_STATE); + public PkiSigningBus(PkiStore store, SignatureWorkflow signer, Path durableLineStorePath, + X509AuthoritySnapshot authority) { + this(store, signer, durableLineStorePath, resolveDisplaySuffixMaxLen(Optional.empty()), + OrchestrationDurabilityPolicy.DURABLE_MIN_STATE, authority); } /** - * Creates a signing bus with explicit workflow continuation durability policy. + * Creates a signing bus bound to one immutable algorithm authority snapshot. * - * @param store persistent store (source of truth for - * continuation state) - * @param signer signature workflow - * @param durableLineStorePath path to append-only line store file - * @param displaySuffixMaxLen maximum number of characters after {@code '#'} - * @param durabilityPolicy durability policy used when persisting workflow - * state + *

+ * The authority must already bind every signing identity declared by + * {@code signer} to that exact workflow instance for the {@code SIGN} + * direction. Validation occurs before registration or durable-state + * activation. The constructor does not derive an authority from provider + * strings, accept cross-runtime workflow ownership, or access private key + * material. + *

+ * + * @param store persistent store + * @param signer exact workflow represented by the snapshot + * capability + * @param durableLineStorePath durable bus path + * @param displaySuffixMaxLen display suffix bound + * @param durabilityPolicy continuation durability policy + * @param authority immutable runtime authority + * @throws NullPointerException if any reference argument is {@code null} + * @throws IllegalArgumentException if the display suffix bound is invalid or + * the authority does not own the exact + * workflow for every declared signing + * identity and the {@code SIGN} direction */ public PkiSigningBus(PkiStore store, SignatureWorkflow signer, Path durableLineStorePath, int displaySuffixMaxLen, - OrchestrationDurabilityPolicy durabilityPolicy) { + OrchestrationDurabilityPolicy durabilityPolicy, X509AuthoritySnapshot authority) { Objects.requireNonNull(store, "store"); Objects.requireNonNull(signer, "signer"); Objects.requireNonNull(durableLineStorePath, "durableLineStorePath"); Objects.requireNonNull(durabilityPolicy, "durabilityPolicy"); + X509AuthoritySnapshot exactAuthority = Objects.requireNonNull(authority, "authority"); + validateSigningAuthority(signer, exactAuthority); this.store = store; this.signer = signer; + this.authority = exactAuthority; this.random = new SecureRandom(); this.namespace = store.signingNamespace() + "." + signer.id(); signer.validateSigningDomain(this.namespace, store.signingHorizon(), store.signingPermittedSkew()); @@ -189,13 +212,21 @@ public final class PkiSigningBus implements AutoCloseable { for (WorkflowStateRecord state : store.listWorkflowStates()) { if (TYPE_SIGN.equals(state.type()) && state.payload().isPresent()) { try { - SignContinuation.decode(state.payload().orElseThrow()); + SignContinuation continuation = SignContinuation.decode(state.payload().orElseThrow(), + store.stagedContent()); + X509ExecutionPlan plan = authority.planSigning(continuation.algorithmId, + workflowImplementationId(signer), SignatureWorkflow.class); + authority.authorize(plan, signer, AlgorithmExecutionCapability.Direction.SIGN); + AlgorithmIdentity identity = plan.selection().requested(); + if (!identity.canonicalForm().equals(continuation.algorithmId)) { + throw new IllegalArgumentException("Persisted sign continuation is not canonical"); + } } catch (RuntimeException ex) { // NOPMD - malformed persisted state must fail closed throw new PkiException("Invalid persisted sign continuation: code=CONTINUATION_INVALID"); } } } - this.endpoint = new SignatureWorkflowEndpoint(store, signer, coordinator, externalActions); + this.endpoint = new SignatureWorkflowEndpoint(store, signer, coordinator, externalActions, authority); this.bus.registerEndpoint(ENDPOINT_SIGNER, endpoint); this.signerRegistration = signer.register(endpoint::onProviderStatusChanged); for (SignWorkflowStore.Record record : store.listSignRecords()) { @@ -206,6 +237,109 @@ public final class PkiSigningBus implements AutoCloseable { } } + /** + * Returns the immutable authority used by this workflow graph. + * + * @return authority snapshot + */ + public X509AuthoritySnapshot authority() { + return authority; + } + + /** + * Begins runtime-owned durable staging for one signing input. + * + * @param encoding content encoding + * @return atomic staged-content sink + */ + public ContentSink beginSigningContent(Encoding encoding) { + return beginContent(encoding, DurableContentReference.Lifecycle.OPERATION); + } + + /** + * Begins atomic runtime-owned content staging. + * + *

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

+ * + * @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 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 plan = authority.planSigning(algorithm, + workflowImplementationId(workflow), SignatureWorkflow.class); + authority.authorize(plan, workflow, AlgorithmExecutionCapability.Direction.SIGN); + } + } + /** * Builds a canonical, globally unique operation identifier derived from tuple * (owner, clientOpId). @@ -242,24 +376,29 @@ public final class PkiSigningBus implements AutoCloseable { * @param owner owner principal * @param keyRef signing key reference * @param algorithmId signature algorithm id - * @param payload bytes to sign + * @param content durable repeatable content to sign * @param ttl time-to-live * @param workflowPayload minimal continuation payload */ - public void submitSign(PkiId opId, Principal owner, KeyRef keyRef, String algorithmId, EncodedObject payload, + public void submitSign(PkiId opId, Principal owner, KeyRef keyRef, String algorithmId, + DurableContentReference content, Duration ttl, Optional workflowPayload) { Objects.requireNonNull(opId, "opId"); Objects.requireNonNull(owner, "owner"); Objects.requireNonNull(keyRef, "keyRef"); Objects.requireNonNull(algorithmId, "algorithmId"); - Objects.requireNonNull(payload, "payload"); + Objects.requireNonNull(content, "content"); Objects.requireNonNull(ttl, "ttl"); Objects.requireNonNull(workflowPayload, "workflowPayload"); if (algorithmId.isBlank()) { throw new IllegalArgumentException("algorithmId must not be blank"); } + X509ExecutionPlan submittedPlan = authority.planSigning(algorithmId, + workflowImplementationId(signer), SignatureWorkflow.class); + authority.authorize(submittedPlan, signer, AlgorithmExecutionCapability.Direction.SIGN); + AlgorithmIdentity submittedIdentity = submittedPlan.selection().requested(); if (ttl.isZero() || ttl.isNegative()) { throw new IllegalArgumentException("ttl must be positive"); } @@ -272,27 +411,52 @@ public final class PkiSigningBus implements AutoCloseable { try (OperationCoordinator.Lease ignored = coordinator.acquire(baseOpId)) { SigningSubmissionId parsed = SigningSubmissionId.parse(baseOpId); Instant deadline = parsed.createdAt().plus(ttl); - SignContinuation continuation = SignContinuation.decode(workflowPayload.get()); + SignContinuation continuation = SignContinuation.decode(workflowPayload.get(), store.stagedContent()); + X509ExecutionPlan continuationPlan = authority.planSigning(continuation.algorithmId, + workflowImplementationId(signer), SignatureWorkflow.class); + authority.authorize(continuationPlan, signer, AlgorithmExecutionCapability.Direction.SIGN); + AlgorithmIdentity continuationIdentity = continuationPlan.selection().requested(); if (!owner.equals(continuation.accessContext.principal()) || !keyRef.equals(continuation.keyRef) - || !algorithmId.equals(continuation.algorithmId) - || payload.encoding() != continuation.payload.encoding() - || !java.util.Arrays.equals(payload.bytes(), continuation.payload.bytes())) { + || !submittedIdentity.equals(continuationIdentity) + || !content.equals(continuation.content())) { throw new IllegalArgumentException("Sign continuation does not match the submitted request"); } + continuation = continuation.withAlgorithmId(submittedIdentity.canonicalForm()); String fingerprint = continuation.semanticFingerprint(namespace, deadline); EncodedObject persistedRequest = continuation.withSignerOpId(baseOpId).encode(); SignWorkflowStore.Record intent = new SignWorkflowStore.Record(baseOpId, namespace, fingerprint, owner, parsed.createdAt(), deadline, persistedRequest, SignWorkflowStore.State.INTENT, 0L, 0L, Optional.empty(), Optional.of("INTENT"), Optional.empty(), Optional.empty()); - SignWorkflowStore.CreateResult created = store.createSignIntent(intent); - if (created == SignWorkflowStore.CreateResult.CONFLICT) { - throw new PkiException("Signing submission identifier conflicts with a different request"); + DurableContentOwner contentOwner = DurableContentOwner.signingOperation(baseOpId); + boolean retained = false; + boolean recordVisible = false; + try { + retained = store.stagedContent().retainContent(content, contentOwner); + SignWorkflowStore.CreateResult created = store.createSignIntent(intent); + if (created == SignWorkflowStore.CreateResult.CONFLICT) { + throw new PkiException("Signing submission identifier conflicts with a different request"); + } + recordVisible = true; + authoritative = store.getSignRecord(baseOpId).orElseThrow(); + } catch (java.io.IOException exception) { + throw new PkiException("Signing content retention failed: code=SPOOL_STORAGE_FAILED", exception); + } finally { + if (retained && !recordVisible) { + rollbackSigningOwner(content, contentOwner); + } } - authoritative = store.getSignRecord(baseOpId).orElseThrow(); } project(authoritative); } + private void rollbackSigningOwner(DurableContentReference content, DurableContentOwner owner) { + try { + store.stagedContent().releaseContent(content, owner); + } catch (java.io.IOException exception) { + throw new PkiException("Signing content rollback failed: code=SPOOL_STORAGE_FAILED", exception); + } + } + /** * Returns current status if known. */ @@ -400,8 +564,15 @@ public final class PkiSigningBus implements AutoCloseable { if (!isTerminalSignState(state.state())) { return; } + Optional releaseReference = Optional.empty(); + if (state.state() != SignWorkflowStore.State.RETIRED) { + releaseReference = Optional.of(SignContinuation.decode(state.request(), store.stagedContent()).content()); + } state = confirmRetirement(baseOpId, state); store.deleteWorkflowState(baseOpId); + if (releaseReference.isPresent()) { + releaseRetiredOperationContent(baseOpId, releaseReference.get()); + } } AsyncState advisoryState = state.result().isPresent() ? AsyncState.SUCCEEDED : AsyncState.CANCELLED; bus.update(baseOpId, new AsyncStatus(advisoryState, store.signingNow(), Optional.of("RETIRED"), @@ -409,6 +580,17 @@ public final class PkiSigningBus implements AutoCloseable { bus.retire(baseOpId); } + private void releaseRetiredOperationContent(PkiId operationId, DurableContentReference reference) { + if (reference.lifecycle() != DurableContentReference.Lifecycle.OPERATION) { + return; + } + try { + store.stagedContent().releaseContent(reference, DurableContentOwner.signingOperation(operationId)); + } catch (java.io.IOException exception) { + throw new PkiException("Signing content retirement failed: code=SPOOL_STORAGE_FAILED", exception); + } + } + private void reconcileExpiredOperations() { Instant current = store.signingNow(); for (SignWorkflowStore.Record candidate : store.listSignRecords()) { @@ -813,6 +995,7 @@ public final class PkiSigningBus implements AutoCloseable { private final SignatureWorkflow signer; private final OperationCoordinator coordinator; private final ExternalActionCoordinator externalActions; + private final X509AuthoritySnapshot authority; private final ConcurrentMap pendingAdvisories; private final AtomicInteger pendingAdvisoryCount; private final AtomicBoolean closed; @@ -826,11 +1009,12 @@ public final class PkiSigningBus implements AutoCloseable { * operation and to query its status; must not be {@code null} */ private SignatureWorkflowEndpoint(PkiStore store, SignatureWorkflow signer, OperationCoordinator coordinator, - ExternalActionCoordinator externalActions) { + ExternalActionCoordinator externalActions, X509AuthoritySnapshot authority) { this.store = store; this.signer = signer; this.coordinator = coordinator; this.externalActions = externalActions; + this.authority = authority; this.pendingAdvisories = new ConcurrentHashMap<>(); this.pendingAdvisoryCount = new AtomicInteger(); this.closed = new AtomicBoolean(); @@ -877,7 +1061,8 @@ public final class PkiSigningBus implements AutoCloseable { SubmissionCall call = prepared.get(); PkiId returned; try (ExternalActionCoordinator.Reservation ignored = call.reservation()) { - returned = signer.submitSign(call.request()); + authority.authorize(call.plan(), signer, AlgorithmExecutionCapability.Direction.SIGN); + returned = call.plan().executor().submitSign(call.request()); } catch (RuntimeException ambiguousFailure) { // NOPMD - provider acceptance is unknown return; } @@ -917,17 +1102,27 @@ public final class PkiSigningBus implements AutoCloseable { ExternalActionCoordinator.Reservation reservation = reserved.get(); boolean reservationTransferred = false; try { - SignContinuation continuation = SignContinuation.decode(claimed.request()); + SignContinuation continuation = SignContinuation.decode(claimed.request(), store.stagedContent()); + DurableContentOwner contentOwner = DurableContentOwner.signingOperation(opId); + requireSigningOwner(continuation.content(), contentOwner); + X509ExecutionPlan plan = authority.planSigning(continuation.algorithmId, + workflowImplementationId(signer), SignatureWorkflow.class); + authority.authorize(plan, signer, AlgorithmExecutionCapability.Direction.SIGN); + AlgorithmIdentity persistedIdentity = plan.selection().requested(); + if (!persistedIdentity.canonicalForm().equals(continuation.algorithmId)) { + throw new IllegalArgumentException("Persisted sign continuation is not canonical"); + } + RepeatableContent content = openContent(continuation.content()); SignatureWorkflow.SignRequest request = SignatureWorkflow.SignRequest.create(opId, claimed.namespace(), claimed.fence(), continuation.accessContext, continuation.keyRef, - continuation.algorithmId, continuation.payload, - Optional.of(continuation.preferredSignatureEncoding), Optional.of(claimed.deadline())); + continuation.algorithmId, content, Optional.of(continuation.preferredSignatureEncoding), + Optional.of(claimed.deadline())); if (!constantTimeAsciiEquals(claimed.fingerprint(), request.semanticFingerprint())) { store.transitionSign(opId, claimed.revision(), claimed.fence(), SignWorkflowStore.State.FAILED, Optional.of("REQUEST_INTEGRITY_FAILURE"), Optional.empty(), Optional.empty()); return Optional.empty(); } - SubmissionCall call = new SubmissionCall(claimed, request, reservation); + SubmissionCall call = new SubmissionCall(claimed, request, reservation, plan); reservationTransferred = true; return Optional.of(call); } finally { @@ -938,6 +1133,24 @@ public final class PkiSigningBus implements AutoCloseable { } } + private void requireSigningOwner(DurableContentReference reference, DurableContentOwner owner) { + try { + if (!store.stagedContent().contentOwners(reference).contains(owner)) { + throw new PkiException("Signing content owner missing: code=STAGED_CONTENT_INCOMPLETE"); + } + } catch (java.io.IOException exception) { + throw new PkiException("Signing content ownership failed: code=CONTENT_INTEGRITY_FAILED", exception); + } + } + + private RepeatableContent openContent(DurableContentReference reference) { + try { + return store.stagedContent().openContent(reference); + } catch (java.io.IOException ex) { + throw new PkiException("Signing content unavailable: code=STAGED_CONTENT_MISSING"); + } + } + private static boolean constantTimeAsciiEquals(String left, String right) { byte[] leftBytes = left.getBytes(java.nio.charset.StandardCharsets.US_ASCII); byte[] rightBytes = right.getBytes(java.nio.charset.StandardCharsets.US_ASCII); @@ -970,7 +1183,7 @@ public final class PkiSigningBus implements AutoCloseable { } private record SubmissionCall(SignWorkflowStore.Record record, SignatureWorkflow.SignRequest request, - ExternalActionCoordinator.Reservation reservation) { + ExternalActionCoordinator.Reservation reservation, X509ExecutionPlan plan) { } /** @@ -1260,7 +1473,8 @@ public final class PkiSigningBus implements AutoCloseable { *

Encoding model

*

* Instances are encoded into a compact binary representation through - * {@link #encode()} and reconstructed through {@link #decode(EncodedObject)}. + * {@link #encode()} and reconstructed through + * {@link #decode(EncodedObject, StagedContentStore)}. * The binary format is versioned by {@link #VERSION}. The current version * persists the algorithm identifier, payload encoding and bytes, key reference, * preferred signature encoding, and the optional downstream signer operation @@ -1271,7 +1485,7 @@ public final class PkiSigningBus implements AutoCloseable { *

* The current binary encoding does not persist the original * {@link zeroecho.pki.api.audit.AccessContext} losslessly. During - * {@link #decode(EncodedObject)}, a synthetic system access context is created + * {@link #decode(EncodedObject, StagedContentStore)}, a synthetic system access context is created * instead. This is sufficient for the current continuation flow, but callers * must not assume that {@code decode(encode(x))} preserves the original access * context exactly. @@ -1284,11 +1498,13 @@ public final class PkiSigningBus implements AutoCloseable { */ public static final class SignContinuation { - private static final byte VERSION = 2; + private static final byte VERSION = 4; + private static final long MINIMUM_CONTENT_LENGTH = 0L; private final zeroecho.pki.api.audit.AccessContext accessContext; private final String algorithmId; - private final EncodedObject payload; + private final Optional content; + private final ContentCommitment commitment; private final KeyRef keyRef; private final Encoding preferredSignatureEncoding; private final Optional signerOpId; @@ -1300,7 +1516,7 @@ public final class PkiSigningBus implements AutoCloseable { * sign request; must not be {@code null} * @param algorithmId non-blank signature algorithm identifier; * must not be {@code null} or blank - * @param payload to-be-signed payload; must not be + * @param content durable to-be-signed content reference * {@code null} * @param keyRef signing key reference; must not be * {@code null} @@ -1313,10 +1529,13 @@ public final class PkiSigningBus implements AutoCloseable { * @throws IllegalArgumentException if {@code algorithmId} is blank */ public SignContinuation(zeroecho.pki.api.audit.AccessContext accessContext, String algorithmId, - EncodedObject payload, KeyRef keyRef, Encoding preferredSignatureEncoding, Optional signerOpId) { + DurableContentReference content, KeyRef keyRef, Encoding preferredSignatureEncoding, + Optional signerOpId) { this.accessContext = Objects.requireNonNull(accessContext, "accessContext"); this.algorithmId = Objects.requireNonNull(algorithmId, "algorithmId"); - this.payload = Objects.requireNonNull(payload, "payload"); + DurableContentReference exactContent = Objects.requireNonNull(content, "content"); + this.content = Optional.of(exactContent); + this.commitment = ContentCommitment.of(exactContent); this.keyRef = Objects.requireNonNull(keyRef, "keyRef"); this.preferredSignatureEncoding = Objects.requireNonNull(preferredSignatureEncoding, "preferredSignatureEncoding"); @@ -1326,6 +1545,22 @@ public final class PkiSigningBus implements AutoCloseable { } } + private SignContinuation(zeroecho.pki.api.audit.AccessContext accessContext, String algorithmId, + Optional content, ContentCommitment commitment, KeyRef keyRef, + Encoding preferredSignatureEncoding, Optional signerOpId) { + this.accessContext = Objects.requireNonNull(accessContext, "accessContext"); + this.algorithmId = Objects.requireNonNull(algorithmId, "algorithmId"); + this.content = Objects.requireNonNull(content, "content"); + this.commitment = Objects.requireNonNull(commitment, "commitment"); + this.keyRef = Objects.requireNonNull(keyRef, "keyRef"); + this.preferredSignatureEncoding = Objects.requireNonNull(preferredSignatureEncoding, + "preferredSignatureEncoding"); + this.signerOpId = Objects.requireNonNull(signerOpId, "signerOpId"); + if (algorithmId.isBlank() || content.isPresent() && !commitment.matches(content.get())) { + throw new IllegalArgumentException("Invalid sign continuation content commitment"); + } + } + /** * Returns a new continuation with the downstream signer workflow operation * identifier assigned. @@ -1343,8 +1578,19 @@ public final class PkiSigningBus implements AutoCloseable { * @throws NullPointerException if {@code opId} is {@code null} */ public SignContinuation withSignerOpId(PkiId opId) { - return new SignContinuation(accessContext, algorithmId, payload, keyRef, preferredSignatureEncoding, - Optional.of(opId)); + return new SignContinuation(accessContext, algorithmId, content, commitment, keyRef, + preferredSignatureEncoding, Optional.of(opId)); + } + + private SignContinuation withAlgorithmId(String canonicalAlgorithmId) { + return new SignContinuation(accessContext, canonicalAlgorithmId, content, commitment, keyRef, + preferredSignatureEncoding, signerOpId); + } + + /** Returns a terminal continuation retaining only immutable content commitment metadata. */ + public SignContinuation withoutLiveContent() { + return new SignContinuation(accessContext, algorithmId, Optional.empty(), commitment, keyRef, + preferredSignatureEncoding, signerOpId); } /** @@ -1363,6 +1609,31 @@ public final class PkiSigningBus implements AutoCloseable { return signerOpId; } + /** + * Returns the payload-free durable reference used for recovery. + * + * @return staged content reference; never a live handle or payload + */ + public DurableContentReference content() { + return content.orElseThrow(() -> new IllegalStateException("Retired continuation has no live content")); + } + + /** Returns whether this continuation still requires live staged content. */ + public boolean hasLiveContent() { + return content.isPresent(); + } + + /** Restores the committed reference through its owning store for delayed cleanup. */ + public DurableContentReference restoreContent(zeroecho.pki.spi.store.StagedContentStore stagedContent) + throws java.io.IOException { + Objects.requireNonNull(stagedContent, "stagedContent"); + if (content.isPresent()) { + return content.get(); + } + return stagedContent.restoreReference(commitment.storeId(), commitment.contentId(), commitment.encoding(), + commitment.length(), commitment.sha256(), commitment.lifecycle()); + } + /** * Computes the canonical semantic fingerprint for this persisted request. * @@ -1380,7 +1651,8 @@ public final class PkiSigningBus implements AutoCloseable { */ public String semanticFingerprint(String namespace, Instant deadline) { Objects.requireNonNull(deadline, "deadline"); - return SignatureWorkflow.SignRequest.fingerprint(namespace, accessContext, keyRef, algorithmId, payload, + return SignatureWorkflow.SignRequest.fingerprint(namespace, accessContext, keyRef, algorithmId, + new ReferenceContent(commitment), Optional.of(preferredSignatureEncoding), Optional.of(deadline)); } @@ -1411,7 +1683,7 @@ public final class PkiSigningBus implements AutoCloseable { *

    *
  • format version,
  • *
  • algorithm identifier,
  • - *
  • payload encoding and bytes,
  • + *
  • store-issued staged-content reference metadata,
  • *
  • key reference,
  • *
  • preferred signature encoding,
  • *
  • presence marker and optional downstream signer workflow operation @@ -1427,7 +1699,6 @@ public final class PkiSigningBus implements AutoCloseable { */ public EncodedObject encode() { WipeableByteArrayOutputStream bytes = new WipeableByteArrayOutputStream(); - byte[] payloadBytes = payload.bytes(); byte[] encoded = null; try { try (java.io.DataOutputStream output = new java.io.DataOutputStream(bytes)) { @@ -1445,9 +1716,13 @@ public final class PkiSigningBus implements AutoCloseable { } output.writeUTF(algorithmId); output.writeUTF(keyRef.value()); - output.writeByte(payload.encoding().ordinal()); - output.writeInt(payloadBytes.length); - output.write(payloadBytes); + output.writeBoolean(content.isPresent()); + output.writeUTF(commitment.storeId()); + output.writeUTF(commitment.contentId()); + output.writeByte(commitment.encoding().ordinal()); + output.writeLong(commitment.length()); + output.writeUTF(commitment.sha256()); + output.writeByte(commitment.lifecycle().ordinal()); output.writeByte(preferredSignatureEncoding.ordinal()); output.writeBoolean(signerOpId.isPresent()); if (signerOpId.isPresent()) { @@ -1459,7 +1734,6 @@ public final class PkiSigningBus implements AutoCloseable { } catch (java.io.IOException ex) { throw new PkiException("Failed to encode sign continuation: code=CONTINUATION_ENCODE_FAILED"); } finally { - java.util.Arrays.fill(payloadBytes, (byte) 0); if (encoded != null) { java.util.Arrays.fill(encoded, (byte) 0); } @@ -1489,6 +1763,8 @@ public final class PkiSigningBus implements AutoCloseable { *

    * * @param obj binary encoded continuation payload; must not be {@code null} + * @param stagedContent owning store used to restore and validate the persisted + * content reference * @return decoded continuation instance * @throws NullPointerException if {@code obj} is {@code null} * @throws IllegalArgumentException if {@code obj} does not use @@ -1496,13 +1772,14 @@ public final class PkiSigningBus implements AutoCloseable { * format version is not supported, or if the * binary payload is malformed */ - public static SignContinuation decode(EncodedObject obj) { + public static SignContinuation decode(EncodedObject obj, + zeroecho.pki.spi.store.StagedContentStore stagedContent) { Objects.requireNonNull(obj, "obj"); + Objects.requireNonNull(stagedContent, "stagedContent"); if (obj.encoding() != Encoding.BINARY) { throw new IllegalArgumentException("Expected BINARY continuation payload"); } byte[] encoded = obj.bytes(); - byte[] payloadBytes = null; try (java.io.DataInputStream input = new java.io.DataInputStream( new java.io.ByteArrayInputStream(encoded))) { int version = input.readUnsignedByte(); @@ -1518,30 +1795,85 @@ public final class PkiSigningBus implements AutoCloseable { : Optional.empty(); String algId = input.readUTF(); KeyRef key = new KeyRef(input.readUTF()); - Encoding payloadEncoding = Encoding.values()[input.readUnsignedByte()]; - int payloadLength = input.readInt(); - if (payloadLength <= 0 || payloadLength > 16 * 1024 * 1024) { - throw new PkiException("Invalid sign continuation payload length"); - } - payloadBytes = input.readNBytes(payloadLength); - if (payloadBytes.length != payloadLength) { - throw new PkiException("Truncated sign continuation payload"); - } + boolean liveContent = input.readBoolean(); + String storeId = input.readUTF(); + String contentId = input.readUTF(); + Encoding contentEncoding = Encoding.values()[input.readUnsignedByte()]; + long contentLength = input.readLong(); + String sha256 = input.readUTF(); + DurableContentReference.Lifecycle lifecycle = DurableContentReference.Lifecycle + .values()[input.readUnsignedByte()]; + ContentCommitment commitment = new ContentCommitment(storeId, contentId, contentEncoding, + contentLength, sha256, lifecycle); + Optional content = liveContent + ? Optional.of(stagedContent.restoreReference(storeId, contentId, contentEncoding, + contentLength, sha256, lifecycle)) + : Optional.empty(); Encoding preferred = Encoding.values()[input.readUnsignedByte()]; Optional signerId = input.readBoolean() ? Optional.of(new PkiId(input.readUTF())) : Optional.empty(); + requireCompleteInput(input); zeroecho.pki.api.audit.AccessContext access = new zeroecho.pki.api.audit.AccessContext(principal, purpose, objectId, formatId); - return new SignContinuation(access, algId, new EncodedObject(payloadEncoding, payloadBytes), key, - preferred, signerId); + return new SignContinuation(access, algId, content, commitment, key, preferred, signerId); } catch (java.io.IOException | IndexOutOfBoundsException ex) { throw new PkiException("Malformed sign continuation: code=CONTINUATION_MALFORMED"); } finally { java.util.Arrays.fill(encoded, (byte) 0); - if (payloadBytes != null) { - java.util.Arrays.fill(payloadBytes, (byte) 0); + } + } + + private record ContentCommitment(String storeId, String contentId, Encoding encoding, long length, + String sha256, DurableContentReference.Lifecycle lifecycle) { + private ContentCommitment { + Objects.requireNonNull(storeId, "storeId"); + Objects.requireNonNull(contentId, "contentId"); + Objects.requireNonNull(encoding, "encoding"); + Objects.requireNonNull(sha256, "sha256"); + Objects.requireNonNull(lifecycle, "lifecycle"); + if (length < MINIMUM_CONTENT_LENGTH) { + throw new IllegalArgumentException("Content length must not be negative"); } } + + private static ContentCommitment of(DurableContentReference reference) { + return new ContentCommitment(reference.storeId(), reference.contentId(), reference.encoding(), + reference.length(), reference.sha256(), reference.lifecycle()); + } + + private boolean matches(DurableContentReference reference) { + return storeId.equals(reference.storeId()) && contentId.equals(reference.contentId()) + && encoding == reference.encoding() && length == reference.length() + && sha256.equals(reference.sha256()) && lifecycle == reference.lifecycle(); + } + } + + private static void requireCompleteInput(java.io.DataInputStream input) throws java.io.IOException { + if (input.read() >= 0) { + throw new java.io.IOException("Trailing sign continuation data"); + } + } + + private record ReferenceContent(ContentCommitment reference) implements RepeatableContent { + @Override + public java.io.InputStream openStream() throws java.io.IOException { + throw new java.io.IOException("Content reference requires staged-content store"); + } + + @Override + public java.util.OptionalLong length() { + return java.util.OptionalLong.of(reference.length()); + } + + @Override + public String contentId() { + return "sha256:" + reference.sha256(); + } + + @Override + public void close() { + // Reference metadata owns no live resource. + } } /** diff --git a/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflow.java b/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflow.java index 167f6c9..76a0f81 100644 --- a/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflow.java +++ b/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflow.java @@ -33,13 +33,11 @@ ******************************************************************************/ package zeroecho.pki.impl.crypto.zeroecholib; -import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.IOException; import java.io.InputStream; -import java.io.OutputStream; import java.nio.ByteBuffer; import java.nio.channels.FileChannel; import java.nio.channels.FileLock; @@ -87,6 +85,8 @@ import zeroecho.core.alg.rsa.RsaPublicKeySpec; import zeroecho.core.alg.slhdsa.SlhDsaPublicKeySpec; import zeroecho.core.alg.sphincsplus.SphincsPlusPublicKeySpec; import zeroecho.core.context.SignatureContext; +import zeroecho.core.io.CancellationSignal; +import zeroecho.core.io.RepeatableContent; import zeroecho.core.io.TailStrippingInputStream; import zeroecho.core.spec.AlgorithmKeySpec; import zeroecho.core.spec.ContextSpec; @@ -266,7 +266,7 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow { private static final OperationStatus UNKNOWN_OPERATION_STATUS = new OperationStatus(State.FAILED, Instant.EPOCH, Optional.of(DC_UNKNOWN_OPERATION), Optional.empty()); - private static final int OPERATION_RECORD_VERSION = 3; + private static final int OPERATION_RECORD_VERSION = 4; private static final long MIN_FENCING_TOKEN = 1L; private final String id; @@ -488,7 +488,6 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow { return opId; } - byte[] payloadBytes = null; byte[] signatureBytes = null; try { KeyRefParts parts = parseKeyRefOrThrow(request.keyRef(), true); @@ -514,8 +513,9 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow { completeSign(request, expiredStatus()); return opId; } - payloadBytes = request.payload().bytes(); - signatureBytes = signStreaming(request.algorithmId(), prv.key(), pub.key(), payloadBytes); + request.cancellation().throwIfCancelled(); + signatureBytes = signStreaming(request.algorithmId(), prv.key(), pub.key(), request.content(), + request.cancellation()); Encoding outEnc = request.preferredSignatureEncoding().orElse(Encoding.BINARY); EncodedObject signature = encodeSignatureOrThrow(outEnc, signatureBytes); @@ -553,7 +553,6 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow { logSafeFailure("SIGN", DC_CRYPTO_FAILURE, ex); return opId; } finally { - clearOwned("sign-payload", payloadBytes); clearOwned("sign-result-copy", signatureBytes); } } @@ -670,7 +669,6 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow { putStatus(opId, new OperationStatus(State.RUNNING, now(), Optional.of(DC_SUBMITTED), Optional.empty())); byte[] signatureBytes = null; - byte[] payloadBytes = null; try { if (request.algorithmId() == null || request.algorithmId().isBlank()) { throw new InvalidRequestException(DC_INVALID_ALGORITHM_ID); @@ -683,8 +681,9 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow { putStatus(opId, expiredStatus()); return opId; } - payloadBytes = request.payload().bytes(); - boolean ok = verifyStreaming(request.algorithmId(), pub, payloadBytes, signatureBytes); + request.cancellation().throwIfCancelled(); + boolean ok = verifyStreaming(request.algorithmId(), pub, request.content(), signatureBytes, + request.cancellation()); Instant completedAt = now(); if (deadlineReached(request.deadline(), completedAt)) { @@ -716,7 +715,6 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow { logSafeFailure("VERIFY", DC_CRYPTO_FAILURE, ex); return opId; } finally { - clearOwned("verify-payload", payloadBytes); clearOwned("verify-signature", signatureBytes); } } @@ -1061,7 +1059,8 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow { return a; } - private byte[] signStreaming(String algorithmId, PrivateKey prv, PublicKey pub, byte[] msg) + private byte[] signStreaming(String algorithmId, PrivateKey prv, PublicKey pub, RepeatableContent content, + CancellationSignal cancellation) throws GeneralSecurityException, IOException { Optional profile = SignatureInteropProfiles.resolve(algorithmId); @@ -1075,14 +1074,14 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow { try (SignatureContext signer = session.createContext(contextAlgorithmId, KeyUsage.SIGN, prv, contextSpec)) { final byte[][] sigHolder = new byte[1][]; - try (InputStream in = new TailStrippingInputStream(signer.wrap(new ByteArrayInputStream(msg)), sigLen, - 8192) { + try (InputStream source = content.openStream(); + InputStream in = new TailStrippingInputStream(signer.wrap(source), sigLen, 8192) { @Override protected void processTail(byte[] tail) throws IOException { sigHolder[0] = (tail == null) ? null : tail.clone(); } }) { - in.transferTo(OutputStream.nullOutputStream()); + consume(in, cancellation); } byte[] internalSignature = sigHolder[0]; @@ -1101,7 +1100,8 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow { } } - private boolean verifyStreaming(String algorithmId, PublicKey pub, byte[] msg, byte[] signature) + private boolean verifyStreaming(String algorithmId, PublicKey pub, RepeatableContent content, byte[] signature, + CancellationSignal cancellation) throws GeneralSecurityException, IOException { Optional profile = SignatureInteropProfiles.resolve(algorithmId); @@ -1116,8 +1116,8 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow { try (SignatureContext verifier = session.createContext(contextAlgorithmId, KeyUsage.VERIFY, pub, contextSpec)) { verifier.setExpectedTag(internalSignature); - try (InputStream in = verifier.wrap(new ByteArrayInputStream(msg))) { - in.transferTo(OutputStream.nullOutputStream()); + try (InputStream source = content.openStream(); InputStream in = verifier.wrap(source)) { + consume(in, cancellation); } return true; } catch (Exception mismatch) { @@ -1129,6 +1129,17 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow { } } + private static void consume(InputStream input, CancellationSignal cancellation) throws IOException { + byte[] buffer = new byte[16 * 1024]; + try { + while (input.read(buffer) >= 0) { + cancellation.throwIfCancelled(); + } + } finally { + Arrays.fill(buffer, (byte) 0); + } + } + private EncodedObject encodeSignatureOrThrow(Encoding encoding, byte[] sigBytes) throws InvalidRequestException { if (encoding == Encoding.BINARY || encoding == Encoding.DER) { return new EncodedObject(encoding, sigBytes); @@ -1482,14 +1493,8 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow { } output.writeUTF(request.keyRef().value()); output.writeUTF(request.algorithmId()); - output.writeInt(encodingCode(request.payload().encoding())); - byte[] payload = request.payload().bytes(); - try { - output.writeInt(payload.length); - output.write(payload); - } finally { - clearOwned("persisted-request-payload", payload); - } + output.writeUTF(request.content().contentId()); + output.writeLong(request.content().length().orElse(-1L)); output.writeBoolean(request.preferredSignatureEncoding().isPresent()); if (request.preferredSignatureEncoding().isPresent()) { output.writeInt(encodingCode(request.preferredSignatureEncoding().orElseThrow())); @@ -1516,25 +1521,38 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow { objectId, formatId); KeyRef keyRef = new KeyRef(input.readUTF()); String algorithmId = input.readUTF(); - Encoding payloadEncoding = encodingFromCode(input.readInt()); - int payloadLength = input.readInt(); - if (payloadLength < 1 || payloadLength > 16 * 1024 * 1024) { - throw new IllegalStateException("Invalid persisted signing payload length"); + String contentId = input.readUTF(); + long contentLength = input.readLong(); + if (contentId.isBlank() || contentLength < -1L) { + throw new IllegalStateException("Invalid persisted signing content metadata"); } - byte[] payload = input.readNBytes(payloadLength); - if (payload.length != payloadLength) { - throw new IllegalStateException("Truncated persisted signing payload"); + Optional preferred = input.readBoolean() ? Optional.of(encodingFromCode(input.readInt())) + : Optional.empty(); + Optional 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 preferred = input.readBoolean() ? Optional.of(encodingFromCode(input.readInt())) - : Optional.empty(); - Optional deadline = input.readBoolean() - ? Optional.of(Instant.ofEpochSecond(input.readLong(), input.readInt())) - : Optional.empty(); - return SignRequest.create(submissionId, namespace, 1L, access, keyRef, algorithmId, - new EncodedObject(payloadEncoding, payload), preferred, deadline); - } finally { - clearOwned("loaded-request-payload", payload); + + @Override + public java.util.OptionalLong length() { + return persistedLength < 0L ? java.util.OptionalLong.empty() : java.util.OptionalLong.of(persistedLength); + } + + @Override + public void close() { + // Metadata owns no live resource. } } diff --git a/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowProvider.java b/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowProvider.java index bba310a..7760325 100644 --- a/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowProvider.java +++ b/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowProvider.java @@ -42,6 +42,11 @@ import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; +import zeroecho.core.alg.BootstrapAlgorithmIdentities; +import zeroecho.core.spec.AlgorithmIdentity; +import zeroecho.core.spec.AlgorithmSuite; +import zeroecho.core.spi.AlgorithmExecutionCapability; +import zeroecho.core.spi.AlgorithmExecutionCapabilityProvider; import zeroecho.core.spi.KeyringUnlockProvider; import zeroecho.core.storage.KeyringPassword; import zeroecho.core.storage.KeyringStore; @@ -75,7 +80,8 @@ import zeroecho.pki.spi.crypto.SignatureWorkflowRuntimeDependencies; * performs no value logging. *

    */ -public final class ZeroEchoLibSignatureWorkflowProvider implements SignatureWorkflowProvider { +public final class ZeroEchoLibSignatureWorkflowProvider + implements SignatureWorkflowProvider, AlgorithmExecutionCapabilityProvider { /** Stable failure code for a missing explicit keyring unlock provider. */ public static final String DC_KEYRING_UNLOCK_PROVIDER_REQUIRED = "KEYRING_UNLOCK_PROVIDER_REQUIRED"; /** Stable failure code for an unlock-provider acquisition failure. */ @@ -127,6 +133,42 @@ public final class ZeroEchoLibSignatureWorkflowProvider implements SignatureWork KEY_REQUIRE_SUFFIX); } + /** + * Declares the exact classic signature domain implemented by the workflow. + * + *

    + * This metadata describes the same workflow implementation allocated by this + * provider. It does not select an OID, redefine an identity, or claim key + * availability. + *

    + * + * @return immutable execution capability contribution + */ + @Override + public java.util.List capabilities() { + Set 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. * diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/StandardX509Bindings.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/StandardX509Bindings.java new file mode 100644 index 0000000..eaf0d9f --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/StandardX509Bindings.java @@ -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. + * + *

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

    + */ +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 encode(AlgorithmIdentity candidate) { + return identity.equals(candidate) ? Optional.of(identifier) : Optional.empty(); + } + + @Override + public Optional 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 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 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 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 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; + } + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/StreamingDerReader.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/StreamingDerReader.java new file mode 100644 index 0000000..229c38c --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/StreamingDerReader.java @@ -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. + * + *

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

    + * + *

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

    + */ +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"); + } + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/StreamingDerWriter.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/StreamingDerWriter.java new file mode 100644 index 0000000..692ed05 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/StreamingDerWriter.java @@ -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. + * + *

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

    + */ +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; + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509AlgorithmIdentifier.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509AlgorithmIdentifier.java new file mode 100644 index 0000000..70268a5 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509AlgorithmIdentifier.java @@ -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. + * + *

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

    + */ +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); + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509AlgorithmResolver.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509AlgorithmResolver.java new file mode 100644 index 0000000..12d87f1 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509AlgorithmResolver.java @@ -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. + * + *

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

    + */ +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 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 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 matches, + Optional 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); + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509AlgorithmRole.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509AlgorithmRole.java new file mode 100644 index 0000000..7e30ec7 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509AlgorithmRole.java @@ -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 +} diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509AuthoritySnapshot.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509AuthoritySnapshot.java new file mode 100644 index 0000000..4186d1f --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509AuthoritySnapshot.java @@ -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. + * + *

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

    + */ +public final class X509AuthoritySnapshot { + + private final AlgorithmIdentityCatalog identities; + private final X509ComponentCatalog components; + private final X509BindingCatalog bindings; + private final AlgorithmExecutionCapabilities capabilities; + private final List codecs; + private final Map aliases; + private final Map defaults; + private final X509AlgorithmResolver.Policy policy; + private final X509AlgorithmResolver resolver; + private final String semanticFingerprint; + private final Map 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 codecs, Map aliases, + Map defaults, X509AlgorithmResolver.Policy policy, + Collection 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 bindingProviders = ServiceLoader.load(X509BindingRuleProvider.class).stream() + .map(ServiceLoader.Provider::get).sorted(Comparator.comparing(provider -> provider.getClass().getName())) + .toList(); + List 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 bindingProviders, + List 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 bindingProviders, + List capabilityProviders, + List executorBindings, X509AlgorithmResolver.Policy policy) { + Objects.requireNonNull(bindingProviders, "bindingProviders"); + Objects.requireNonNull(capabilityProviders, "capabilityProviders"); + List ordered = bindingProviders.stream() + .sorted(Comparator.comparing(provider -> provider.getClass().getName())).toList(); + + AlgorithmIdentityCatalog identities = BootstrapAlgorithmIdentities.catalog(); + List componentExtensions = new ArrayList<>(); + List bindingExtensions = new ArrayList<>(); + List codecs = new ArrayList<>(); + Map aliases = new LinkedHashMap<>( + BootstrapAlgorithmIdentities.compatibilityAliases()); + Map defaults = new LinkedHashMap<>(X509BuiltInDefaults.snapshot()); + for (X509BindingRuleProvider provider : ordered) { + Collection contributedIdentities = List.copyOf(provider.identities()); + if (!contributedIdentities.isEmpty()) { + identities = identities.add(contributedIdentities); + } + List 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 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 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 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 executor type + * @return unforgeable process-local plan + */ + public X509ExecutionPlan plan(AlgorithmIdentity signature, AlgorithmIdentity key, + AlgorithmExecutionCapability.Direction direction, Optional implementation, String provenance, + Class 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 executor type + * @return exact process-local signing plan + */ + public X509ExecutionPlan planSigning(String value, String implementation, Class 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 executor type + * @return exact process-local signing plan + */ + public X509ExecutionPlan planSigning(String value, Class 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 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 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 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 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 immutableCodecs(Collection source) { + Map 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 immutableAliases(Map source, + AlgorithmIdentityCatalog identities) { + Map 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 immutableDefaults(Map source) { + Map 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 immutableExecutors(Collection source, + AlgorithmExecutionCapabilities capabilities) { + Map copy = new LinkedHashMap<>(); + Set 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 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 target, Map addition) { + addition.forEach((alias, identity) -> { + if (target.putIfAbsent(alias, identity) != null) { + throw new IllegalArgumentException("Algorithm alias collision"); + } + }); + } + + private static void mergeDefaults(Map target, Map 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 values) { + List 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); + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509BindingCatalog.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509BindingCatalog.java new file mode 100644 index 0000000..fd3a3dd --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509BindingCatalog.java @@ -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. + * + *

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

    + */ +public final class X509BindingCatalog { + + /** Prefix reserved for built-in binding identifiers. */ + public static final String BUILTIN_PREFIX = "zeroecho."; + private static final Set FORBIDDEN_SIGNATURE_OIDS = Set.of("1.2.840.113549.1.1.5", + "1.2.840.10045.4.1"); + + private final List rules; + private final Map byRoleAndOid; + + private X509BindingCatalog(List rules) { + this.rules = List.copyOf(rules); + Map 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 rules) { + return create(rules, true); + } + + /** + * Creates a trusted additive extension catalog. + * + * @param rules extension rules + * @return extension catalog + */ + public static X509BindingCatalog extension(List 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 extensions) { + Objects.requireNonNull(extensions, "extensions"); + List 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 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 rules() { + return rules; + } + + private static X509BindingCatalog create(List 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 rules) { + Set ids = new HashSet<>(); + Set 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; + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509BindingRule.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509BindingRule.java new file mode 100644 index 0000000..70803b6 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509BindingRule.java @@ -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. + * + *

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

    + */ +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 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 decode(X509AlgorithmIdentifier identifier); +} diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509BindingRuleProvider.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509BindingRuleProvider.java new file mode 100644 index 0000000..0e0dd56 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509BindingRuleProvider.java @@ -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. + * + *

    + * This interface is not an administrative configuration surface. Catalog + * composition rejects every collision with built-in or previously installed + * semantics. + *

    + */ +public interface X509BindingRuleProvider { + + /** + * Returns trusted parameter codecs required by contributed identities. + * + * @return immutable codec contribution + */ + default Collection codecs() { + return List.of(); + } + + /** + * Returns exact identities introduced by this installed extension. + * + * @return immutable identity contribution + */ + default Collection identities() { + return List.of(); + } + + /** + * Returns component bindings used by parameterized rules. + * + * @return immutable component contribution + */ + default List components() { + return List.of(); + } + + /** + * Returns immutable additive binding rules. + * + * @return trusted rules; never {@code null} + */ + default List rules() { + return List.of(); + } + + /** + * Returns finite compatibility aliases. Aliases are never canonical identity + * data. + * + * @return immutable alias contribution + */ + default Map aliases() { + return Map.of(); + } + + /** + * Returns additive explicitly versioned defaults. + * + * @return immutable default contribution + */ + default Map defaults() { + return Map.of(); + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509BuiltInDefaults.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509BuiltInDefaults.java new file mode 100644 index 0000000..99f6365 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509BuiltInDefaults.java @@ -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. + * + *

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

    + */ +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 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 snapshot() { + return DEFAULTS; + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509ComponentCatalog.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509ComponentCatalog.java new file mode 100644 index 0000000..58d5b7f --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509ComponentCatalog.java @@ -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. + * + *

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

    + */ +public final class X509ComponentCatalog { + + private static final Set 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 components; + private final Map byIdentity; + private final Map 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 components) { + this.components = List.copyOf(components); + Map identityIndex = new HashMap<>(); + Map 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 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 extensions) { + Objects.requireNonNull(extensions, "extensions"); + List 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 find(Kind kind, String oid) { + return Optional.ofNullable(byOid.get(key(kind, oid))); + } + + private static X509ComponentCatalog create(List source) { + Objects.requireNonNull(source, "components"); + Set ids = new HashSet<>(); + Set identities = new HashSet<>(); + Set oids = new HashSet<>(); + List 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; + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509ExecutionPlan.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509ExecutionPlan.java new file mode 100644 index 0000000..ea29032 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509ExecutionPlan.java @@ -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. + * + *

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

    + * + * @param exact runtime executor type + */ +public final class X509ExecutionPlan { + + 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. + * + *

    + * An execution boundary must call + * {@link X509AuthoritySnapshot#authorize(X509ExecutionPlan, Object, zeroecho.core.spi.AlgorithmExecutionCapability.Direction)} + * immediately before invoking this object. + *

    + * + * @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); + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509SecurityFloor.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509SecurityFloor.java new file mode 100644 index 0000000..977806c --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509SecurityFloor.java @@ -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. + * + *

    + * SHA-1 signatures, including RSA-PSS hash or MGF SHA-1, are rejected before + * provider capability or configured policy is considered. + *

    + */ +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"); + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509SignedObjectCompletion.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509SignedObjectCompletion.java new file mode 100644 index 0000000..edffcb5 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509SignedObjectCompletion.java @@ -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. + * + *

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

    + */ +public final class X509SignedObjectCompletion { + private final StatusObject statusObject; + private final X509ExecutionPlan signingPlan; + private final Object authorityToken; + + /* default */ X509SignedObjectCompletion(StatusObject statusObject, + X509ExecutionPlan 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 signingPlan() { + return signingPlan; + } + + /* default */ boolean isOwnedBy(Object candidate) { + return authorityToken.equals(candidate); + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509SuiteCompatibility.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509SuiteCompatibility.java new file mode 100644 index 0000000..861866f --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509SuiteCompatibility.java @@ -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. + * + *

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

    + */ +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; + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509AlgorithmAdapter.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509AlgorithmAdapter.java new file mode 100644 index 0000000..72a5ef8 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509AlgorithmAdapter.java @@ -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. + * + *

    + * This adapter owns no OID table, default, or alias. It performs only structural + * conversion and delegates semantic resolution to an immutable + * {@link X509BindingCatalog}. + *

    + */ +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); + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CertificationRequestParser.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CertificationRequestParser.java index db024fb..7637d75 100644 --- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CertificationRequestParser.java +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CertificationRequestParser.java @@ -184,6 +184,7 @@ public final class BcX509CertificationRequestParser implements CertificationRequ try { PKCS10CertificationRequest csr; try { + BcX509AlgorithmAdapter.requireCanonicalDer(csrDer); csr = new PKCS10CertificationRequest(csrDer); } catch (Exception ex) { throw new PkiException("Invalid PKCS#10 certification request: code=CSR_MALFORMED"); diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialFramework.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialFramework.java index 723d8b2..c215316 100644 --- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialFramework.java +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialFramework.java @@ -41,6 +41,7 @@ import zeroecho.pki.spi.framework.CredentialFramework; import zeroecho.pki.spi.framework.FrameworkAttributeMapper; import zeroecho.pki.spi.framework.ProofOfPossessionVerifier; import zeroecho.pki.spi.framework.StatusObjectGenerator; +import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot; /** * Bouncy Castle backed X.509 implementation of {@link CredentialFramework}. @@ -104,6 +105,7 @@ public final class BcX509CredentialFramework implements CredentialFramework { private final ProofOfPossessionVerifier popVerifier; private final StatusObjectGenerator status; private final FrameworkAttributeMapper attributeMapper; + private final X509AuthoritySnapshot authority; /** * Creates a partially wired X.509 framework instance with default components. @@ -124,9 +126,9 @@ public final class BcX509CredentialFramework implements CredentialFramework { * exposed by the framework facade. *

    */ - public BcX509CredentialFramework() { - this(new BcX509CertificationRequestParser(), new BcX509ProofOfPossessionVerifier(), - new UnsupportedStatusObjectGenerator(), new BcX509FrameworkAttributeMapper()); + public BcX509CredentialFramework(X509AuthoritySnapshot authority, BcX509VerificationExecutor executor) { + this(new BcX509CertificationRequestParser(), new BcX509ProofOfPossessionVerifier(authority, executor), + new UnsupportedStatusObjectGenerator(), new BcX509FrameworkAttributeMapper(), authority); } /** @@ -146,11 +148,12 @@ public final class BcX509CredentialFramework implements CredentialFramework { * @throws NullPointerException if any component argument is {@code null} */ private BcX509CredentialFramework(CertificationRequestParser requestParser, ProofOfPossessionVerifier popVerifier, - StatusObjectGenerator status, FrameworkAttributeMapper attributeMapper) { + StatusObjectGenerator status, FrameworkAttributeMapper attributeMapper, X509AuthoritySnapshot authority) { this.requestParser = Objects.requireNonNull(requestParser, "requestParser"); this.popVerifier = Objects.requireNonNull(popVerifier, "popVerifier"); this.status = Objects.requireNonNull(status, "status"); this.attributeMapper = Objects.requireNonNull(attributeMapper, "attributeMapper"); + this.authority = Objects.requireNonNull(authority, "authority"); } /** @@ -170,7 +173,8 @@ public final class BcX509CredentialFramework implements CredentialFramework { */ public BcX509CredentialFramework wired(BcX509StatusObjectGenerator statusObjectGenerator) { Objects.requireNonNull(statusObjectGenerator, "statusObjectGenerator"); - return new BcX509CredentialFramework(requestParser, popVerifier, statusObjectGenerator, attributeMapper); + return new BcX509CredentialFramework(requestParser, popVerifier, statusObjectGenerator, attributeMapper, + authority); } /** @@ -202,7 +206,16 @@ public final class BcX509CredentialFramework implements CredentialFramework { Objects.requireNonNull(statusObjectGenerator, "statusObjectGenerator"); Objects.requireNonNull(proofOfPossessionVerifier, "proofOfPossessionVerifier"); return new BcX509CredentialFramework(requestParser, proofOfPossessionVerifier, statusObjectGenerator, - attributeMapper); + attributeMapper, authority); + } + + /** + * Returns the immutable algorithm authority owned by this framework graph. + * + * @return shared authority snapshot + */ + public X509AuthoritySnapshot authority() { + return authority; } /** @@ -283,9 +296,9 @@ public final class BcX509CredentialFramework implements CredentialFramework { * @throws UnsupportedOperationException always */ @Override - public zeroecho.pki.api.status.StatusObject generate( + public zeroecho.pki.impl.framework.x509.X509SignedObjectCompletion generate( zeroecho.pki.api.status.StatusObjectGenerateCommand command, - java.util.List crlEntries) { + zeroecho.pki.spi.framework.CrlEntrySource crlEntries) { throw new UnsupportedOperationException("X.509 status object generator not wired"); } } diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialFrameworkProvider.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialFrameworkProvider.java index ee28c9d..d21594e 100644 --- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialFrameworkProvider.java +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialFrameworkProvider.java @@ -34,12 +34,15 @@ package zeroecho.pki.impl.framework.x509.bc; import java.util.Set; +import java.util.List; import java.util.logging.Level; import java.util.logging.Logger; import zeroecho.pki.spi.ProviderConfig; import zeroecho.pki.spi.framework.CredentialFramework; import zeroecho.pki.spi.framework.CredentialFrameworkProvider; +import zeroecho.pki.impl.framework.x509.X509AlgorithmResolver; +import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot; /** * ServiceLoader provider for the Bouncy Castle backed X.509 credential @@ -162,6 +165,12 @@ public final class BcX509CredentialFrameworkProvider implements CredentialFramew @Override public CredentialFramework allocate(ProviderConfig config) { validateConfig(config); - return new BcX509CredentialFramework(); + BcX509VerificationExecutor executor = new BcX509VerificationExecutor(); + X509AlgorithmResolver.Policy policy = (suite, direction) -> true; + X509AuthoritySnapshot authority = X509AuthoritySnapshot.compose(List.of(), List.of(executor), + List.of(X509AuthoritySnapshot.bindExecutor(BcX509VerificationExecutor.IMPLEMENTATION_ID, + zeroecho.core.spi.AlgorithmExecutionCapability.Direction.VERIFY, executor)), + policy); + return new BcX509CredentialFramework(authority, executor); } } diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialIssuerBackend.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialIssuerBackend.java index 99d7a0f..67f0993 100644 --- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialIssuerBackend.java +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialIssuerBackend.java @@ -33,6 +33,9 @@ ******************************************************************************/ package zeroecho.pki.impl.framework.x509.bc; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; import java.math.BigInteger; import java.security.MessageDigest; import java.time.Duration; @@ -52,8 +55,9 @@ import org.bouncycastle.asn1.x509.KeyUsage; import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; import org.bouncycastle.cert.X509CertificateHolder; import org.bouncycastle.cert.X509v3CertificateBuilder; -import org.bouncycastle.operator.ContentSigner; +import zeroecho.core.spec.AlgorithmIdentity; +import zeroecho.core.io.RepeatableContent; import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.Encoding; import zeroecho.pki.api.IssuerRef; @@ -67,6 +71,7 @@ import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.credential.CredentialBundle; import zeroecho.pki.api.credential.CredentialStatus; import zeroecho.pki.api.credential.EndEntityProfileBinding; +import zeroecho.pki.api.content.DurableContentReference; import zeroecho.pki.api.profile.LeafKeyUsage; import zeroecho.pki.api.request.SubjectAlternativeName; import zeroecho.pki.impl.core.ValidatedCaCertificateRequest; @@ -74,6 +79,7 @@ import zeroecho.pki.impl.core.ValidatedCertificateRequest; import zeroecho.pki.impl.core.async.PkiSigningBus; import zeroecho.pki.impl.core.attr.SimpleAttributeSet; import zeroecho.pki.spi.framework.CredentialIssuerBackend; +import zeroecho.pki.spi.store.ContentSink; /** * Bouncy Castle backed X.509 credential issuance backend. @@ -122,7 +128,7 @@ import zeroecho.pki.spi.framework.CredentialIssuerBackend; public final class BcX509CredentialIssuerBackend implements CredentialIssuerBackend { private final PkiSigningBus signingBus; - private final String signatureAlgorithmId; + private final AlgorithmIdentity signatureIdentity; private final Duration signingTtl; /** @@ -139,17 +145,29 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack * contract */ public BcX509CredentialIssuerBackend(PkiSigningBus signingBus, String signatureAlgorithmId, Duration signingTtl) { + this(signingBus, signingBus.authority().resolveIdentity(signatureAlgorithmId), signingTtl); + } + + /** + * Creates the X.509 issuance backend with an exact signature identity. + * + * @param signingBus signing bus used to delegate signing + * @param signatureIdentity exact provider-independent signature identity + * @param signingTtl positive maximum signing TTL + */ + public BcX509CredentialIssuerBackend(PkiSigningBus signingBus, AlgorithmIdentity signatureIdentity, + Duration signingTtl) { if (signingBus == null) { throw new IllegalArgumentException("signingBus must not be null"); } - if (signatureAlgorithmId == null || signatureAlgorithmId.isBlank()) { - throw new IllegalArgumentException("signatureAlgorithmId must not be null/blank"); + if (signatureIdentity == null || signatureIdentity.kind() != AlgorithmIdentity.Kind.SIGNATURE) { + throw new IllegalArgumentException("signatureIdentity must be a signature identity"); } if (signingTtl == null || signingTtl.isZero() || signingTtl.isNegative()) { throw new IllegalArgumentException("signingTtl must be positive"); } this.signingBus = signingBus; - this.signatureAlgorithmId = signatureAlgorithmId; + this.signatureIdentity = signatureIdentity; this.signingTtl = signingTtl; } @@ -174,13 +192,14 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack * fails, or certificate encoding fails */ @Override - public CredentialBundle issueEndEntity(ValidatedCertificateRequest request, EncodedObject issuerCertificate, + public CredentialBundle issueEndEntity(ValidatedCertificateRequest request, + DurableContentReference issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { if (request == null || issuerCertificate == null || issuerKeyRef == null || serial == null || serial.signum() <= 0 || serial.toByteArray().length > 20) { throw new IllegalArgumentException("Invalid validated end-entity issuance input"); } - byte[] issuerDer = issuerCertificate.bytes(); + byte[] issuerDer = materialize(issuerCertificate); X509CertificateHolder issuer; try { issuer = parseIssuerCertificateOrThrow(issuerDer); @@ -195,7 +214,7 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack Date.from(request.validity().notBefore()), Date.from(request.validity().notAfter()), subjectDn, spki); addLeafExtensions(builder, request); - ContentSigner signer = new PkiBusContentSigner(signingBus, issuerKeyRef, signatureAlgorithmId, signingTtl); + PkiBusContentSigner signer = new PkiBusContentSigner(signingBus, issuerKeyRef, signatureIdentity, signingTtl); X509CertificateHolder leaf; try { leaf = builder.build(signer); @@ -214,10 +233,12 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack PkiId publicKeyId = new PkiId("spki:" + fingerprintEncoded(request.exactPublicKey())); try { + DurableContentReference content = stageCertificate(certDer); + validateGeneratedCertificate(content, signer); Credential credential = new Credential(credId, BcX509CredentialFramework.FORMAT_ID, new IssuerRef(request.issuerCaId()), request.subjectRef(), request.validity(), serial.toString(), publicKeyId, new EndEntityProfileBinding(request.profileReference()), CredentialStatus.ISSUED, - new EncodedObject(Encoding.DER, certDer), SimpleAttributeSet.builder().build()); + content, SimpleAttributeSet.builder().build()); return new CredentialBundle(credential, java.util.List.of(issuerCertificate)); } finally { java.util.Arrays.fill(certDer, (byte) 0); @@ -302,7 +323,7 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack */ @Override public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest request, - EncodedObject issuerCertificate, KeyRef issuerKeyRef) { + DurableContentReference issuerCertificate, KeyRef issuerKeyRef) { if (request == null || issuerCertificate == null || issuerKeyRef == null) { throw new IllegalArgumentException("validated CA issuance inputs must not be null"); } @@ -310,7 +331,7 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack throw new IllegalArgumentException("Unsupported formatId"); } - byte[] issuerDer = issuerCertificate.bytes(); + byte[] issuerDer = materialize(issuerCertificate); X509CertificateHolder issuer; try { issuer = parseIssuerCertificateOrThrow(issuerDer); @@ -343,7 +364,7 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack throw new PkiException("X.509 extension construction failed: code=EXTENSION_BUILD_FAILED"); } - ContentSigner signer = new PkiBusContentSigner(signingBus, issuerKeyRef, signatureAlgorithmId, signingTtl); + PkiBusContentSigner signer = new PkiBusContentSigner(signingBus, issuerKeyRef, signatureIdentity, signingTtl); X509CertificateHolder certificate; try { certificate = builder.build(signer); @@ -361,15 +382,66 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack PkiId credId = new PkiId("x509:" + sha256Hex(certDer)); try { + DurableContentReference content = stageCertificate(certDer); + validateGeneratedCertificate(content, signer); return new Credential(credId, request.formatId(), new IssuerRef(request.issuerCaId()), subjectRef, validity, serial.toString(), publicKeyId, new CaProfileBinding(request.profileReference()), - CredentialStatus.ISSUED, new EncodedObject(Encoding.DER, certDer), + CredentialStatus.ISSUED, content, SimpleAttributeSet.builder().build()); } finally { java.util.Arrays.fill(certDer, (byte) 0); } } + private DurableContentReference stageCertificate(byte[] certificate) { + try (ContentSink sink = signingBus.beginContent(Encoding.DER, DurableContentReference.Lifecycle.PERSISTED); + OutputStream output = sink.outputStream()) { + output.write(certificate); + return sink.complete(); + } catch (IOException exception) { + throw new PkiException("Certificate staging failed: code=SPOOL_STORAGE_FAILED", exception); + } + } + + private void validateGeneratedCertificate(DurableContentReference reference, PkiBusContentSigner signer) { + try (RepeatableContent content = signingBus.openContent(reference)) { + new BcX509SignedObjectValidator(signingBus.authority()).validateGeneratedCertificate(content, + signer.executionPlan(), zeroecho.core.io.CancellationSignal.NONE); + } catch (IOException | IllegalArgumentException exception) { + signingBus.releaseContent(reference); + throw new PkiException("Certificate postcondition failed: code=BACKEND_RESULT_SUBSTITUTION", exception); + } + } + + private byte[] materialize(DurableContentReference reference) { + if (reference.length() > Integer.MAX_VALUE) { + throw new PkiException("Certificate exceeds BC adapter element domain: code=ADAPTER_ELEMENT_LIMIT_EXCEEDED"); + } + byte[] result = new byte[(int) reference.length()]; + try { + readExact(reference, result); + return result; + } catch (IOException exception) { + throw new PkiException("Certificate content failed: code=CONTENT_IO_FAILED", exception); + } + } + + private void readExact(DurableContentReference reference, byte[] result) throws IOException { + try (RepeatableContent content = signingBus.openContent(reference); InputStream input = content.openStream()) { + int offset = 0; + while (offset != result.length) { + int count = input.read(result, offset, result.length - offset); + if (count < 0) { + throw new IOException("Certificate content is truncated"); + } + offset += count; + } + if (input.read() >= 0) { + throw new IOException("Certificate content has trailing bytes"); + } + } + } + private static X509CertificateHolder parseIssuerCertificateOrThrow(byte[] issuerCertDer) { try { return new X509CertificateHolder(issuerCertDer); diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509ProofOfPossessionVerifier.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509ProofOfPossessionVerifier.java index 07f627a..713bca8 100644 --- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509ProofOfPossessionVerifier.java +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509ProofOfPossessionVerifier.java @@ -35,17 +35,20 @@ package zeroecho.pki.impl.framework.x509.bc; import java.util.Optional; -import org.bouncycastle.jce.provider.BouncyCastleProvider; -import org.bouncycastle.operator.ContentVerifierProvider; import org.bouncycastle.operator.OperatorCreationException; -import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder; import org.bouncycastle.pkcs.PKCS10CertificationRequest; +import zeroecho.core.spec.AlgorithmIdentity; +import zeroecho.core.io.ImmutableByteContent; +import zeroecho.core.spi.AlgorithmExecutionCapability; import zeroecho.pki.api.attr.AttributeValue; import zeroecho.pki.api.issuance.VerificationPolicy; import zeroecho.pki.api.request.ParsedCertificationRequest; import zeroecho.pki.api.request.ProofOfPossessionResult; import zeroecho.pki.api.request.ProofOfPossessionStatus; +import zeroecho.pki.impl.framework.x509.X509AlgorithmRole; +import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot; +import zeroecho.pki.impl.framework.x509.X509ExecutionPlan; import zeroecho.pki.spi.framework.ProofOfPossessionVerifier; /** @@ -94,21 +97,38 @@ import zeroecho.pki.spi.framework.ProofOfPossessionVerifier; * authorization checks, or profile compliance checks.
  • *
  • The presence of a valid PKCS#10 self-signature does not by itself imply * that the requester is entitled to receive the requested certificate.
  • - *
  • This verifier accepts signature algorithms supported by its Bouncy Castle - * provider, including RSASSA-PSS. Deployments requiring a narrower or stronger - * algorithm policy must enforce it in the issuance policy layer.
  • + *
  • Algorithm identity, X.509 binding, immutable security-floor, capability, + * and configured-policy decisions are taken from one injected + * {@link X509AuthoritySnapshot}. Bouncy Castle remains only the structural and + * cryptographic adapter at this Phase A boundary.
  • *
  • The CSR payload carried under {@link BcX509Attributes#CSR_DER} may be * operationally sensitive and must not be logged unsafely.
  • *
* *

Thread-safety

*

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

*/ public final class BcX509ProofOfPossessionVerifier implements ProofOfPossessionVerifier { - private static final BouncyCastleProvider BC_PROVIDER = new BouncyCastleProvider(); + private final X509AuthoritySnapshot authority; + private final BcX509VerificationExecutor executor; + + /** + * Creates a verifier backed by one runtime authority and its exact executor. + * + * @param authority identity, binding, capability, floor, and policy authority; + * must not be {@code null} + * @param executor process-local verification executor bound into + * {@code authority} + * @throws NullPointerException if {@code authority} is {@code null} + */ + public BcX509ProofOfPossessionVerifier(X509AuthoritySnapshot authority, BcX509VerificationExecutor executor) { + this.authority = java.util.Objects.requireNonNull(authority, "authority"); + this.executor = java.util.Objects.requireNonNull(executor, "executor"); + } /** * Verifies proof of possession for a parsed PKCS#10 certification request. @@ -123,7 +143,7 @@ public final class BcX509ProofOfPossessionVerifier implements ProofOfPossessionV * Otherwise, the method expects the original CSR DER payload to be present in * {@code request.attributes()} under {@link BcX509Attributes#CSR_DER} as an * {@link AttributeValue.BytesValue}. It then parses the CSR, creates a Bouncy - * Castle {@link ContentVerifierProvider} from the CSR's embedded subject public + * Castle content verifier from the CSR's embedded subject public * key information, and validates the PKCS#10 signature. *

* @@ -179,17 +199,33 @@ public final class BcX509ProofOfPossessionVerifier implements ProofOfPossessionV try { PKCS10CertificationRequest csr; try { + BcX509AlgorithmAdapter.requireCanonicalDer(csrDer); csr = new PKCS10CertificationRequest(csrDer); } catch (Exception ex) { return new ProofOfPossessionResult(ProofOfPossessionStatus.FAILED, Optional.of("Invalid CSR")); } - ContentVerifierProvider cvp = new JcaContentVerifierProviderBuilder().setProvider(BC_PROVIDER) - .build(csr.getSubjectPublicKeyInfo()); - boolean ok = csr.isSignatureValid(cvp); - if (ok) { - return new ProofOfPossessionResult(ProofOfPossessionStatus.VERIFIED, Optional.empty()); + BcX509AlgorithmAdapter algorithmAdapter = new BcX509AlgorithmAdapter(authority.bindings()); + try { + AlgorithmIdentity signatureIdentity = algorithmAdapter.decode(csr.getSignatureAlgorithm(), + X509AlgorithmRole.SIGNATURE_ALGORITHM); + AlgorithmIdentity keyIdentity = algorithmAdapter.decode(csr.getSubjectPublicKeyInfo().getAlgorithm(), + X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM); + X509ExecutionPlan plan = authority.plan(signatureIdentity, keyIdentity, + AlgorithmExecutionCapability.Direction.VERIFY, + Optional.of(BcX509VerificationExecutor.IMPLEMENTATION_ID), "csr-proof", + BcX509VerificationExecutor.class); + byte[] signedBytes = csr.toASN1Structure().getCertificationRequestInfo().getEncoded(); + boolean valid = executor.verify(authority, plan, csr.getSubjectPublicKeyInfo(), + csr.getSignatureAlgorithm(), new ImmutableByteContent(signedBytes), csr.getSignature()); + if (!valid) { + return new ProofOfPossessionResult(ProofOfPossessionStatus.FAILED, + Optional.of("CSR signature invalid")); + } + } catch (IllegalArgumentException unsupported) { + return new ProofOfPossessionResult(ProofOfPossessionStatus.FAILED, + Optional.of("Unsupported CSR algorithm")); } - return new ProofOfPossessionResult(ProofOfPossessionStatus.FAILED, Optional.of("CSR signature invalid")); + return new ProofOfPossessionResult(ProofOfPossessionStatus.VERIFIED, Optional.empty()); } catch (OperatorCreationException ex) { return new ProofOfPossessionResult(ProofOfPossessionStatus.FAILED, Optional.of("Verifier unavailable")); } catch (Exception ex) { diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509SignedObjectValidator.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509SignedObjectValidator.java new file mode 100644 index 0000000..3292ede --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509SignedObjectValidator.java @@ -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. + * + *

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

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

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

+ */ +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 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 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 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); + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/OidAlgorithmMapper.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/OidAlgorithmMapper.java index de943d8..a14592a 100644 --- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/OidAlgorithmMapper.java +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/OidAlgorithmMapper.java @@ -33,12 +33,16 @@ ******************************************************************************/ package zeroecho.pki.impl.framework.x509.bc; -import java.util.Map; import java.util.Objects; -import org.bouncycastle.asn1.ASN1ObjectIdentifier; import org.bouncycastle.asn1.x509.AlgorithmIdentifier; +import zeroecho.core.spec.AlgorithmIdentity; +import zeroecho.pki.impl.framework.x509.StandardX509Bindings; +import zeroecho.pki.impl.framework.x509.X509AlgorithmIdentifier; +import zeroecho.pki.impl.framework.x509.X509AlgorithmRole; +import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot; + /** * Internal mapper from X.509 and PKCS#10 signature algorithm identifiers to * ZeroEcho canonical signature algorithm ids. @@ -67,11 +71,13 @@ import org.bouncycastle.asn1.x509.AlgorithmIdentifier; * *

Null and unsupported handling

*
    - *
  • {@link #toZeroEchoAlgorithmId(AlgorithmIdentifier)} returns {@code null} + *
  • {@link #toZeroEchoAlgorithmId(AlgorithmIdentifier, X509AuthoritySnapshot)} + * returns {@code null} * when the supplied identifier is {@code null}, when its embedded - * {@link ASN1ObjectIdentifier} is {@code null}, when the OID string is blank, + * object identifier is {@code null}, when the OID string is blank, * or when the OID is not present in the mapping table.
  • - *
  • {@link #toZeroEchoAlgorithmId(String)} requires a non-{@code null} OID + *
  • {@link #toZeroEchoAlgorithmId(String, X509AuthoritySnapshot)} requires a + * non-{@code null} OID * string but still returns {@code null} for blank or unsupported values.
  • *
* @@ -105,27 +111,6 @@ public final class OidAlgorithmMapper { * reviewable, and easy to maintain. *

*/ - @SuppressWarnings("PMD.AvoidUsingHardCodedIP") - private static final Map OID_TO_ZEROECHO_ALG = Map.ofEntries( - // RSA PKCS#1 v1.5 digests - Map.entry("1.2.840.113549.1.1.11", "SHA256withRSA"), // sha256WithRSAEncryption - Map.entry("1.2.840.113549.1.1.12", "SHA384withRSA"), // sha384WithRSAEncryption - Map.entry("1.2.840.113549.1.1.13", "SHA512withRSA"), // sha512WithRSAEncryption - Map.entry("1.2.840.113549.1.1.5", "SHA1withRSA"), // sha1WithRSAEncryption - - // ECDSA digests - Map.entry("1.2.840.10045.4.3.2", "SHA256withECDSA"), // ecdsa-with-SHA256 - Map.entry("1.2.840.10045.4.3.3", "SHA384withECDSA"), // ecdsa-with-SHA384 - Map.entry("1.2.840.10045.4.3.4", "SHA512withECDSA"), // ecdsa-with-SHA512 - Map.entry("1.2.840.10045.4.1", "SHA1withECDSA"), // ecdsa-with-SHA1 - - // EdDSA - Map.entry("1.3.101.112", "Ed25519"), // Ed25519 - Map.entry("1.3.101.113", "Ed448") // Ed448 - - // PQC OIDs vary by provider/standard; extend as needed. - ); - /** * Creates no instances. */ @@ -137,29 +122,28 @@ public final class OidAlgorithmMapper { * signature algorithm identifier. * *

- * This method extracts the underlying {@link ASN1ObjectIdentifier}, converts it - * to its dotted-decimal string form, and performs a lookup in the immutable - * mapping table. + * This method extracts the underlying object identifier, converts it to its + * dotted-decimal string form, and resolves it through the injected authority. *

* * @param sigAlg ASN.1 signature algorithm identifier, possibly {@code null} + * @param authority immutable algorithm authority * @return ZeroEcho canonical signature algorithm identifier, or {@code null} * when the input is {@code null}, structurally incomplete, blank, or * unsupported */ - public static String toZeroEchoAlgorithmId(AlgorithmIdentifier sigAlg) { + public static String toZeroEchoAlgorithmId(AlgorithmIdentifier sigAlg, X509AuthoritySnapshot authority) { if (sigAlg == null) { return null; } - ASN1ObjectIdentifier oid = sigAlg.getAlgorithm(); - if (oid == null) { + try { + AlgorithmIdentity identity = new BcX509AlgorithmAdapter( + Objects.requireNonNull(authority, "authority").bindings()) + .decode(sigAlg, X509AlgorithmRole.SIGNATURE_ALGORITHM); + return identity.canonicalForm(); + } catch (IllegalArgumentException exception) { return null; } - String id = oid.getId(); - if (id == null || id.isBlank()) { - return null; - } - return OID_TO_ZEROECHO_ALG.get(id); } /** @@ -174,15 +158,34 @@ public final class OidAlgorithmMapper { * * @param oid dotted-decimal signature algorithm OID string; must not be * {@code null} + * @param authority immutable algorithm authority * @return ZeroEcho canonical signature algorithm identifier, or {@code null} * when the supplied OID string is blank or unsupported * @throws NullPointerException if {@code oid} is {@code null} */ - public static String toZeroEchoAlgorithmId(String oid) { + public static String toZeroEchoAlgorithmId(String oid, X509AuthoritySnapshot authority) { Objects.requireNonNull(oid, "oid"); - if (oid.isBlank()) { + X509AlgorithmIdentifier identifier = null; + if (StandardX509Bindings.OID_RSA_SHA256.equals(oid) + || StandardX509Bindings.OID_RSA_SHA384.equals(oid) + || StandardX509Bindings.OID_RSA_SHA512.equals(oid)) { + identifier = X509AlgorithmIdentifier.derNull(oid); + } else if (StandardX509Bindings.OID_ECDSA_SHA256.equals(oid) + || StandardX509Bindings.OID_ECDSA_SHA384.equals(oid) + || StandardX509Bindings.OID_ECDSA_SHA512.equals(oid) + || StandardX509Bindings.OID_ED25519.equals(oid) + || StandardX509Bindings.OID_ED448.equals(oid)) { + identifier = X509AlgorithmIdentifier.absent(oid); + } + if (identifier == null) { + return null; + } + try { + return Objects.requireNonNull(authority, "authority").bindings() + .reverse(identifier, X509AlgorithmRole.SIGNATURE_ALGORITHM) + .canonicalForm(); + } catch (IllegalArgumentException exception) { return null; } - return OID_TO_ZEROECHO_ALG.get(oid); } } diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/PkiBusContentSigner.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/PkiBusContentSigner.java index bd98b87..f69d953 100644 --- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/PkiBusContentSigner.java +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/PkiBusContentSigner.java @@ -33,27 +33,31 @@ ******************************************************************************/ package zeroecho.pki.impl.framework.x509.bc; -import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.io.OutputStream; import java.time.Duration; import java.time.Instant; -import java.util.Arrays; import java.util.Optional; import org.bouncycastle.asn1.x509.AlgorithmIdentifier; import org.bouncycastle.operator.ContentSigner; -import org.bouncycastle.operator.DefaultSignatureAlgorithmIdentifierFinder; +import zeroecho.core.spec.AlgorithmIdentity; import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.Encoding; import zeroecho.pki.api.KeyRef; import zeroecho.pki.api.PkiException; import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.content.DurableContentReference; import zeroecho.pki.api.audit.AccessContext; import zeroecho.pki.api.audit.Principal; import zeroecho.pki.api.audit.Purpose; import zeroecho.pki.impl.core.async.PkiSigningBus; import zeroecho.pki.impl.core.async.PkiSigningBus.SignContinuation; +import zeroecho.pki.impl.framework.x509.X509AlgorithmRole; +import zeroecho.pki.impl.framework.x509.X509ExecutionPlan; +import zeroecho.pki.spi.crypto.SignatureWorkflow; +import zeroecho.pki.spi.store.ContentSink; import zeroecho.pki.util.async.AsyncState; /** @@ -65,8 +69,8 @@ import zeroecho.pki.util.async.AsyncState; * contract to the PKI runtime signing boundary represented by * {@link PkiSigningBus}. Bouncy Castle writes the to-be-signed bytes into the * output stream returned by {@link #getOutputStream()}, and when - * {@link #getSignature()} is invoked this signer submits the collected bytes to - * the PKI signing bus as a delegated sign workflow. + * {@link #getSignature()} is invoked this signer atomically completes the + * file-backed content and submits its durable reference to the PKI signing bus. *

* *

@@ -79,7 +83,8 @@ import zeroecho.pki.util.async.AsyncState; * *

Execution model

*
    - *
  • The to-be-signed payload is accumulated fully in memory.
  • + *
  • The to-be-signed payload is streamed to the runtime-owned staged-content + * store with {@code long} accounting.
  • *
  • A synthetic system owner and purpose are used to submit the signing * operation.
  • *
  • A fresh client operation identifier is generated for each signature @@ -95,7 +100,7 @@ import zeroecho.pki.util.async.AsyncState; *

    Security considerations

    *
      *
    • This class never accesses private key material directly.
    • - *
    • The buffered to-be-signed payload and the returned signature bytes are + *
    • The staged to-be-signed content and returned signature bytes are * operationally sensitive and must not be logged.
    • *
    • The generated operation identifier is an internal workflow handle and * must not be treated as a durable business identifier outside the signing @@ -104,9 +109,8 @@ import zeroecho.pki.util.async.AsyncState; * *

      Thread-safety

      *

      - * Instances of this class are not thread-safe. Each instance maintains mutable - * in-memory state through its internal {@link ByteArrayOutputStream} and is - * intended for one certificate or CRL signing flow. + * Instances of this class are not thread-safe. Each instance owns one sequential + * staged-content sink and is intended for one certificate or CRL signing flow. *

      */ // PMD cannot infer that retaining provider causes would violate the redaction contract. @@ -115,10 +119,12 @@ public final class PkiBusContentSigner implements ContentSigner { private final PkiSigningBus bus; private final KeyRef keyRef; - private final String algorithmId; + private final AlgorithmIdentity algorithmIdentity; + private final X509ExecutionPlan executionPlan; private final Duration ttl; - private final WipeableByteArrayOutputStream baos; + private final ContentSink contentSink; + private final OutputStream contentOutput; /** * Creates a signer that routes signature generation through the PKI signing @@ -136,23 +142,52 @@ public final class PkiBusContentSigner implements ContentSigner { * contract */ public PkiBusContentSigner(PkiSigningBus bus, KeyRef keyRef, String algorithmId, Duration ttl) { + this(bus, keyRef, bus.authority().resolveIdentity(algorithmId), ttl); + } + + /** + * Creates a signer from an exact provider-independent signature identity. + * + * @param bus signing bus + * @param keyRef signing key reference + * @param algorithmIdentity exact signature identity + * @param ttl positive workflow time-to-live + * @throws IllegalArgumentException if an argument violates the contract or no + * authoritative signature binding exists + */ + public PkiBusContentSigner(PkiSigningBus bus, KeyRef keyRef, AlgorithmIdentity algorithmIdentity, Duration ttl) { if (bus == null) { throw new IllegalArgumentException("bus must not be null"); } if (keyRef == null) { throw new IllegalArgumentException("keyRef must not be null"); } - if (algorithmId == null || algorithmId.isBlank()) { - throw new IllegalArgumentException("algorithmId must not be null/blank"); + if (algorithmIdentity == null || algorithmIdentity.kind() != AlgorithmIdentity.Kind.SIGNATURE) { + throw new IllegalArgumentException("algorithmIdentity must be a signature identity"); } if (ttl == null || ttl.isZero() || ttl.isNegative()) { throw new IllegalArgumentException("ttl must be positive"); } this.bus = bus; this.keyRef = keyRef; - this.algorithmId = algorithmId; + X509ExecutionPlan plan = bus.authority() + .planSigning(algorithmIdentity.canonicalForm(), SignatureWorkflow.class); + bus.authority().authorize(plan, plan.executor(), + zeroecho.core.spi.AlgorithmExecutionCapability.Direction.SIGN); + this.executionPlan = plan; + this.algorithmIdentity = plan.selection().requested(); this.ttl = ttl; - this.baos = new WipeableByteArrayOutputStream(); + this.contentSink = bus.beginSigningContent(Encoding.BINARY); + try { + this.contentOutput = contentSink.outputStream(); + } catch (IOException ex) { + try { + contentSink.close(); + } catch (IOException cleanupFailure) { + ex.addSuppressed(cleanupFailure); + } + throw new PkiException("Signing content staging failed: code=SPOOL_STORAGE_FAILED"); + } } /** @@ -170,22 +205,31 @@ public final class PkiBusContentSigner implements ContentSigner { */ @Override public AlgorithmIdentifier getAlgorithmIdentifier() { - return new DefaultSignatureAlgorithmIdentifierFinder().find(algorithmId); + return new BcX509AlgorithmAdapter(bus.authority().bindings()).encode(algorithmIdentity, + X509AlgorithmRole.SIGNATURE_ALGORITHM); } /** * Returns the output stream used to collect the to-be-signed bytes. * *

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

      * - * @return mutable in-memory output stream receiving the to-be-signed payload + * @return sequential staged-content output stream */ @Override public OutputStream getOutputStream() { - return baos; + return contentOutput; + } + + /* + * Package-local postconditions receive the exact live plan minted for this + * signer. A public semantic fingerprint is intentionally insufficient. + */ + /* default */ X509ExecutionPlan executionPlan() { + return executionPlan; } /** @@ -226,22 +270,22 @@ public final class PkiBusContentSigner implements ContentSigner { @Override @SuppressWarnings("PMD.AvoidCatchingGenericException") public byte[] getSignature() { - byte[] tbs = baos.toByteArray(); byte[] consumedResult = null; PkiId opId = null; boolean retirementRequired = false; Throwable primaryFailure = null; try { - EncodedObject payload = new EncodedObject(Encoding.BINARY, tbs); + DurableContentReference content = completeContent(); Principal owner = new Principal("SYSTEM", "pki"); opId = bus.newSubmissionId(); AccessContext ac = new AccessContext(owner, new Purpose("X509_SIGN"), Optional.empty(), Optional.empty()); - SignContinuation cont = new SignContinuation(ac, algorithmId, payload, keyRef, Encoding.BINARY, + String canonicalIdentity = algorithmIdentity.canonicalForm(); + SignContinuation cont = new SignContinuation(ac, canonicalIdentity, content, keyRef, Encoding.BINARY, Optional.empty()); retirementRequired = true; - bus.submitSign(opId, owner, keyRef, algorithmId, payload, ttl, Optional.of(cont.encode())); + bus.submitSign(opId, owner, keyRef, canonicalIdentity, content, ttl, Optional.of(cont.encode())); consumedResult = awaitSignature(opId); return consumedResult.clone(); } catch (RuntimeException failure) { @@ -257,15 +301,32 @@ public final class PkiBusContentSigner implements ContentSigner { retirePreservingFailure(opId, primaryFailure); } } finally { - Arrays.fill(tbs, (byte) 0); - baos.wipe(); + closeSink(primaryFailure); if (consumedResult != null) { - Arrays.fill(consumedResult, (byte) 0); + java.util.Arrays.fill(consumedResult, (byte) 0); } } } } + private DurableContentReference completeContent() { + try { + return contentSink.complete(); + } catch (IOException ex) { + throw new PkiException("Signing content staging failed: code=SPOOL_STORAGE_FAILED"); + } + } + + private void closeSink(Throwable primaryFailure) { + try { + contentSink.close(); + } catch (IOException cleanupFailure) { + if (primaryFailure == null) { + throw new PkiException("Signing cleanup failed: code=SIGNING_CLEANUP_FAILED"); + } + } + } + private byte[] awaitSignature(PkiId opId) { Instant deadline = Instant.now().plus(ttl); while (Instant.now().isBefore(deadline)) { @@ -313,15 +374,4 @@ public final class PkiBusContentSigner implements ContentSigner { } } - /** - * Byte-array output stream whose retained backing storage can be overwritten - * after one signing attempt. - */ - private static final class WipeableByteArrayOutputStream extends ByteArrayOutputStream { - - private void wipe() { - Arrays.fill(buf, (byte) 0); - reset(); - } - } } diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/WorkflowProofOfPossessionVerifier.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/WorkflowProofOfPossessionVerifier.java index 31d0e89..4088320 100644 --- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/WorkflowProofOfPossessionVerifier.java +++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/WorkflowProofOfPossessionVerifier.java @@ -54,7 +54,12 @@ import zeroecho.pki.api.issuance.VerificationPolicy; import zeroecho.pki.api.request.ParsedCertificationRequest; import zeroecho.pki.api.request.ProofOfPossessionResult; import zeroecho.pki.api.request.ProofOfPossessionStatus; +import zeroecho.pki.impl.framework.x509.X509AlgorithmRole; +import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot; +import zeroecho.pki.impl.framework.x509.X509ExecutionPlan; import zeroecho.pki.spi.crypto.SignatureWorkflow; +import zeroecho.core.io.CancellationSignal; +import zeroecho.core.io.ImmutableByteContent; import zeroecho.pki.spi.framework.ProofOfPossessionVerifier; /** @@ -133,16 +138,17 @@ public final class WorkflowProofOfPossessionVerifier implements ProofOfPossessio private static final Principal SYSTEM = new Principal("SYSTEM", "pki"); private final SignatureWorkflow workflow; + private final X509AuthoritySnapshot authority; /** - * Creates the workflow-backed proof-of-possession verifier. + * Creates a verifier bound to the runtime's immutable authority snapshot. * - * @param workflow signature workflow used to perform cryptographic signature - * verification; must not be {@code null} - * @throws NullPointerException if {@code workflow} is {@code null} + * @param workflow selected verification workflow + * @param authority matching authority snapshot */ - public WorkflowProofOfPossessionVerifier(SignatureWorkflow workflow) { + public WorkflowProofOfPossessionVerifier(SignatureWorkflow workflow, X509AuthoritySnapshot authority) { this.workflow = Objects.requireNonNull(workflow, "workflow"); + this.authority = Objects.requireNonNull(authority, "authority"); } /** @@ -207,22 +213,34 @@ public final class WorkflowProofOfPossessionVerifier implements ProofOfPossessio } CertificationRequest csrAsn1 = csr.get().toASN1Structure(); + BcX509AlgorithmAdapter algorithmAdapter = new BcX509AlgorithmAdapter(authority.bindings()); + zeroecho.core.spec.AlgorithmIdentity signatureIdentity; + X509ExecutionPlan 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 tbs = encodeCertificationRequestInfo(csrAsn1.getCertificationRequestInfo()); if (tbs.isEmpty()) { return failed("CSR TBS encoding failed"); } - String algorithmId = OidAlgorithmMapper.toZeroEchoAlgorithmId(csrAsn1.getSignatureAlgorithm()); - if (algorithmId == null) { - return new ProofOfPossessionResult(ProofOfPossessionStatus.NOT_SUPPORTED, - Optional.of("Unsupported CSR algorithm")); - } + String algorithmId = signatureIdentity.canonicalForm(); byte[] signature = extractSignatureBytes(csrAsn1.getSignature()); SignatureWorkflow.VerifyRequest verifyRequest = toVerifyRequest(request, algorithmId, tbs.get(), signature, spkiDer.get()); - return verifyWithWorkflow(verifyRequest); + return verifyWithWorkflow(verifyRequest, executionPlan); } /** @@ -249,6 +267,7 @@ public final class WorkflowProofOfPossessionVerifier implements ProofOfPossessio */ private static Optional parseCsr(byte[] csrDer) { try { + BcX509AlgorithmAdapter.requireCanonicalDer(csrDer); return Optional.of(new PKCS10CertificationRequest(csrDer)); } catch (IOException | IllegalArgumentException ex) { return Optional.empty(); @@ -315,9 +334,10 @@ public final class WorkflowProofOfPossessionVerifier implements ProofOfPossessio AccessContext accessContext = new AccessContext(SYSTEM, PURPOSE, Optional.of(request.requestId()), Optional.of(request.formatId())); - return new SignatureWorkflow.VerifyRequest(accessContext, algorithmId, new EncodedObject(Encoding.DER, tbsDer), + return new SignatureWorkflow.VerifyRequest(accessContext, algorithmId, new ImmutableByteContent(tbsDer), new EncodedObject(Encoding.BINARY, signature), Optional.empty(), - Optional.of(new EncodedObject(Encoding.DER, spkiDer)), Optional.of(Instant.now().plusSeconds(30))); + Optional.of(new EncodedObject(Encoding.DER, spkiDer)), Optional.of(Instant.now().plusSeconds(30)), + CancellationSignal.NONE); } /** @@ -327,9 +347,12 @@ public final class WorkflowProofOfPossessionVerifier implements ProofOfPossessio * @param verifyRequest workflow verification request; must not be {@code null} * @return mapped proof-of-possession result */ - private ProofOfPossessionResult verifyWithWorkflow(SignatureWorkflow.VerifyRequest verifyRequest) { - PkiId verifyOperationId = workflow.submitVerify(verifyRequest); - SignatureWorkflow.OperationStatus status = workflow.status(verifyOperationId); + private ProofOfPossessionResult verifyWithWorkflow(SignatureWorkflow.VerifyRequest verifyRequest, + X509ExecutionPlan executionPlan) { + authority.authorize(executionPlan, workflow, + zeroecho.core.spi.AlgorithmExecutionCapability.Direction.VERIFY); + PkiId verifyOperationId = executionPlan.executor().submitVerify(verifyRequest); + SignatureWorkflow.OperationStatus status = executionPlan.executor().status(verifyOperationId); if (status == null) { return failed("Verifier returned no status"); } diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/CredentialContentTransaction.java b/pki/src/main/java/zeroecho/pki/impl/fs/CredentialContentTransaction.java new file mode 100644 index 0000000..41551d9 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/fs/CredentialContentTransaction.java @@ -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 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 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 stream = Files.list(root)) { + java.util.Iterator 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 records; + try (Stream 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"); + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/DurableMetadataFiles.java b/pki/src/main/java/zeroecho/pki/impl/fs/DurableMetadataFiles.java new file mode 100644 index 0000000..a0982ce --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/fs/DurableMetadataFiles.java @@ -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 OWNER_ONLY = + PosixFilePermissions.fromString("rw-------"); + private static final ThreadLocal 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 read(Path target, long maximumBytes, Decoder decoder) throws IOException { + Objects.requireNonNull(decoder, "decoder"); + requireMaximum(maximumBytes); + Path parent = requireParent(target); + try (SecureDirectoryStream 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 list(Path parent, String suffix) throws IOException { + Objects.requireNonNull(suffix, "suffix"); + List paths = new ArrayList<>(); + try (SecureDirectoryStream 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 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 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 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 directory, Path name) throws IOException { + return directory.newByteChannel(name, openOptions(StandardOpenOption.READ)); + } + + private static SecureDirectoryStream openSecureDirectory(Path directory) throws IOException { + trip(FaultPoint.SECURE_OPEN); + DirectoryStream opened = Files.newDirectoryStream(directory); + if (opened instanceof SecureDirectoryStream) { + return (SecureDirectoryStream) opened; + } + opened.close(); + throw new IOException("Secure durable metadata directory access is unavailable"); + } + + private static Set openOptions(StandardOpenOption... options) { + Set 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 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 { + /** 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"); + } + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java index 49d6af2..ca6d3f1 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java @@ -58,6 +58,8 @@ import java.util.HexFormat; import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.OptionalLong; +import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.atomic.AtomicBoolean; @@ -87,11 +89,16 @@ import zeroecho.pki.api.revocation.RevocationReason; import zeroecho.pki.api.revocation.RevocationState; import zeroecho.pki.api.revocation.RevocationTransition; import zeroecho.pki.api.status.StatusObject; +import zeroecho.pki.api.content.DurableContentReference; +import zeroecho.pki.api.content.DurableContentOwner; import zeroecho.pki.impl.ProfileLifecycleFailure; import zeroecho.pki.impl.ProfileLifecycleFailure.Code; import zeroecho.pki.impl.core.async.PkiSigningBus; import zeroecho.pki.spi.store.PkiStore; +import zeroecho.pki.spi.store.StagedContentStore; import zeroecho.pki.spi.store.SignWorkflowStore; +import zeroecho.pki.spi.store.TemporaryUniqueIndex; +import zeroecho.pki.spi.store.RevocationSnapshot; /** * Filesystem-based reference implementation of {@link PkiStore}. @@ -167,6 +174,8 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { private final ConcurrentMap revocationLocks; private final ConcurrentMap profileLocks; private final AtomicBoolean durabilityUncertain; + private final FilesystemStagedContentStore stagedContent; + private final CredentialContentTransaction credentialContentTransactions; private final StoreOwnership ownership; @@ -221,8 +230,12 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { try { ensureVersionFile(); this.signingNamespace = ensureSigningNamespace(); + this.stagedContent = new FilesystemStagedContentStore(this.paths.stagedContentRoot(), + this.signingNamespace); + this.credentialContentTransactions = new CredentialContentTransaction(this.paths, this.stagedContent); this.signingTimeWatermark = new AtomicLong(loadSigningTimeWatermark()); this.historySeq = new AtomicLong(0L); + recoverStagedContent(); LOG.log(Level.INFO, "running in {0}", root); this.ownership = acquiredOwnership; @@ -236,6 +249,101 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { } } + @Override + public StagedContentStore stagedContent() { + return stagedContent; + } + + private void recoverStagedContent() throws IOException { + credentialContentTransactions.recover(); + try (TemporaryUniqueIndex retained = stagedContent.beginUniqueIndex(); + TemporaryUniqueIndex retainedOwners = stagedContent.beginOwnerIndex()) { + try { + addPersistedCredentialReferences(retained, retainedOwners); + addPersistedStatusReferences(retained); + addPendingSigningReferences(retained, retainedOwners); + stagedContent.recoverContent(retained, retainedOwners); + } catch (IllegalStateException | PkiException malformedDurableState) { + // Recovery cannot prove abandonment while durable metadata is + // corrupt. Preserve content so the normal owning subsystem can + // report the authoritative corruption without destructive loss. + LOG.log(Level.WARNING, "Staged-content reclamation skipped: code=DURABLE_METADATA_INVALID"); + } + } + } + + private void addPersistedCredentialReferences(TemporaryUniqueIndex retained, TemporaryUniqueIndex retainedOwners) + throws IOException { + Path root = paths.root().resolve("credentials").resolve("by-id"); + if (!Files.isDirectory(root)) { + return; + } + try (Stream pathsStream = Files.list(root)) { + java.util.Iterator 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 pathsStream = Files.list(root)) { + java.util.Iterator 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 pathsStream = Files.walk(root)) { + java.util.Iterator iterator = pathsStream + .filter(path -> Files.isRegularFile(path) + && FsPaths.CURRENT_FILE.equals(path.getFileName().toString())) + .iterator(); + while (iterator.hasNext()) { + SignWorkflowStore.Record record = readSignRecordFile(iterator.next()); + PkiSigningBus.SignContinuation continuation = PkiSigningBus.SignContinuation.decode(record.request(), + stagedContent); + if (record.state() == SignWorkflowStore.State.RETIRED) { + if (continuation.hasLiveContent()) { + throw new IllegalStateException("Retired signing record retains live content"); + } + continue; + } + DurableContentReference reference = continuation.content(); + DurableContentOwner owner = DurableContentOwner.signingOperation(record.submissionId()); + if (!stagedContent.contentOwners(reference).contains(owner)) { + throw new IllegalStateException("Signing content owner is missing"); + } + addRetained(retained, reference); + retainedOwners.add(owner.canonicalForm().getBytes(StandardCharsets.UTF_8)); + } + } + } + + private static void addRetained(TemporaryUniqueIndex retained, DurableContentReference reference) + throws IOException { + retained.add(reference.contentId().getBytes(StandardCharsets.US_ASCII)); + } + /** * Exports a snapshot of this store as of time {@code at} into * {@code targetRoot}. @@ -292,16 +400,23 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { public void putCredential(final Credential credential) { requireStoreUsable(); Objects.requireNonNull(credential, "credential"); - PkiId id = credential.credentialId(); - Path p = this.paths.credentialPath(id); - writeOnce(p, FsCodec.encode(FsCodec.CREDENTIAL, credential), "CREDENTIAL", FsUtil.safeId(id)); + credentialContentTransactions.put(credential); } @Override public Optional getCredential(final PkiId credentialId) { requireStoreUsable(); Objects.requireNonNull(credentialId, "credentialId"); - return readOptional(this.paths.credentialPath(credentialId), FsCodec.CREDENTIAL); + Path path = this.paths.credentialPath(credentialId); + if (!Files.exists(path)) { + return Optional.empty(); + } + try { + Credential credential = FsCodec.decode(FsCodec.CREDENTIAL, FsOperations.readAll(path), stagedContent); + return Optional.of(credentialContentTransactions.validateLoaded(credentialId, credential)); + } catch (IOException failure) { + throw new IllegalStateException("Credential read failed", failure); + } } @Override @@ -369,28 +484,29 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { } @Override - // Listing failures are intentionally replaced by one stable redacted boundary. + // Snapshot failures are intentionally replaced by one stable redacted boundary. @SuppressWarnings("PMD.PreserveStackTrace") - public List listRevocationJournals() { + public RevocationSnapshot openRevocationSnapshot() { requireStoreUsable(); Path root = this.paths.root().resolve("revocations").resolve("by-credential"); - if (!Files.isDirectory(root)) { - return List.of(); - } - try (Stream directories = Files.list(root)) { - List journals = new ArrayList<>(); - for (Path entityDir : directories.filter(Files::isDirectory) - .sorted(Comparator.comparing(path -> path.getFileName().toString())).toList()) { - Path journalPath = entityDir.resolve("journal.bin"); - if (Files.exists(journalPath)) { - RevocationJournal journal = decodeRevocationJournal(journalPath); - if (!entityDir.getFileName().toString().equals(FsUtil.safeId(journal.credentialId()))) { - throw corruptRevocationState(); + String snapshotId = UUID.randomUUID().toString(); + Path snapshotRoot = paths.revocationSnapshotRoot().resolve(snapshotId); + long count = 0L; + try { + Files.createDirectories(snapshotRoot); + if (Files.isDirectory(root)) { + try (Stream journals = Files.walk(root)) { + java.util.Iterator iterator = journals + .filter(path -> Files.isRegularFile(path) + && "journal.bin".equals(path.getFileName().toString())) + .iterator(); + while (iterator.hasNext()) { + Files.copy(iterator.next(), snapshotRoot.resolve(Long.toUnsignedString(count) + ".bin")); + count = Math.addExact(count, 1L); } - journals.add(journal); } } - return List.copyOf(journals); + return new FilesystemRevocationSnapshot(snapshotId, snapshotRoot, count); } catch (IOException ex) { throw corruptRevocationState(); } @@ -835,9 +951,13 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { || !isRetirableSignState(current.state())) { return Optional.empty(); } - SignWorkflowStore.Record retired = copySignRecord(current, SignWorkflowStore.State.RETIRED, - current.revision() + 1L, fence, Optional.empty(), Optional.of("RETIRED"), current.result(), - current.providerUpdatedAt()); + PkiSigningBus.SignContinuation continuation = PkiSigningBus.SignContinuation.decode(current.request(), + stagedContent); + EncodedObject retiredRequest = continuation.withoutLiveContent().encode(); + SignWorkflowStore.Record retired = new SignWorkflowStore.Record(current.submissionId(), + current.namespace(), current.fingerprint(), current.owner(), current.createdAt(), + current.deadline(), retiredRequest, SignWorkflowStore.State.RETIRED, current.revision() + 1L, + fence, Optional.empty(), Optional.of("RETIRED"), current.result(), current.providerUpdatedAt()); writeSignRecord(retired); return Optional.of(retired); } finally { @@ -1054,6 +1174,105 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { return Optional.of(journal); } + /** Stable file-backed revocation snapshot isolated from later store writes. */ + private static final class FilesystemRevocationSnapshot implements RevocationSnapshot { + private final String snapshotId; + private final Path root; + private final long count; + private final AtomicBoolean closed; + + private FilesystemRevocationSnapshot(String snapshotId, Path root, long count) { + this.snapshotId = snapshotId; + this.root = root; + this.count = count; + this.closed = new AtomicBoolean(); + } + + @Override + public String snapshotId() { + return snapshotId; + } + + @Override + public OptionalLong count() { + return OptionalLong.of(count); + } + + @Override + public Cursor openCursor() { + if (closed.get()) { + throw new IllegalStateException("Revocation snapshot is closed"); + } + return new FilesystemRevocationCursor(root, count); + } + + @Override + public void close() throws IOException { + if (!closed.compareAndSet(false, true)) { + return; + } + if (Files.isDirectory(root)) { + try (Stream files = Files.list(root)) { + java.util.Iterator iterator = files.iterator(); + while (iterator.hasNext()) { + Files.deleteIfExists(iterator.next()); + } + } + Files.deleteIfExists(root); + } + } + } + + /** Sequential bounded-memory cursor over one immutable snapshot directory. */ + private static final class FilesystemRevocationCursor implements RevocationSnapshot.Cursor { + private final Path root; + private final long count; + private long nextOrdinal; + private RevocationJournal current; + private boolean closed; + + private FilesystemRevocationCursor(Path root, long count) { + this.root = root; + this.count = count; + } + + @Override + public boolean next() throws IOException { + if (closed) { + throw new IllegalStateException("Revocation cursor is closed"); + } + if (nextOrdinal >= count) { + current = null; + return false; + } + current = decodeRevocationJournal(root.resolve(Long.toUnsignedString(nextOrdinal) + ".bin")); + nextOrdinal = Math.addExact(nextOrdinal, 1L); + return true; + } + + @Override + public RevocationJournal current() { + if (current == null) { + throw new IllegalStateException("Revocation cursor is not positioned"); + } + return current; + } + + @Override + public long ordinal() { + if (current == null) { + throw new IllegalStateException("Revocation cursor is not positioned"); + } + return nextOrdinal - 1L; + } + + @Override + public void close() { + current = null; + closed = true; + } + } + // Codec and filesystem failures are external persisted-state boundaries; raw // causes are deliberately removed from the stable corruption exception. @SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.PreserveStackTrace" }) @@ -1156,7 +1375,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { } byte[] payload = new byte[input.remaining()]; input.get(payload); - return FsCodec.decode(FsCodec.SIGN_WORKFLOW_RECORD, payload); + return FsCodec.decode(FsCodec.SIGN_WORKFLOW_RECORD, payload, stagedContent); } catch (IOException ex) { throw new IllegalStateException("Failed to read authoritative signing record", ex); } @@ -1176,7 +1395,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { Instant futureLimit; try { parsed = SigningSubmissionId.parse(record.submissionId()); - continuation = PkiSigningBus.SignContinuation.decode(record.request()); + continuation = PkiSigningBus.SignContinuation.decode(record.request(), stagedContent); horizonEnd = record.createdAt().plus(options.signingOperationHorizon()); futureLimit = signingNow().plus(options.signingIdPermittedSkew()); } catch (RuntimeException ex) { @@ -1193,6 +1412,8 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { record, "DEADLINE_INVALID"); requireValidSignRecord(continuation.isBoundTo(record.submissionId(), record.owner()), record, "CONTINUATION_IDENTITY_MISMATCH"); + requireValidSignRecord(record.state() == SignWorkflowStore.State.RETIRED != continuation.hasLiveContent(), + record, "CONTINUATION_LIFECYCLE_MISMATCH"); byte[] storedRequest = record.request().bytes(); byte[] canonicalRequest = null; @@ -1609,19 +1830,19 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { } } - private static Optional readOptional(final Path path, final FsCodec.Schema schema) { + private Optional readOptional(final Path path, final FsCodec.Schema schema) { try { if (!Files.exists(path)) { return Optional.empty(); } byte[] data = FsOperations.readAll(path); - return Optional.of(FsCodec.decode(schema, data)); + return Optional.of(FsCodec.decode(schema, data, stagedContent)); } catch (IOException e) { throw new IllegalStateException("read failed: " + path, e); } } - private static List listBinaryFiles(final Path byIdDir, final FsCodec.Schema schema) { + private List listBinaryFiles(final Path byIdDir, final FsCodec.Schema schema) { if (!Files.isDirectory(byIdDir)) { return List.of(); } @@ -1629,7 +1850,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { return Files.list(byIdDir).filter(Files::isRegularFile) .sorted(Comparator.comparing(p -> p.getFileName().toString())).map(p -> { try { - return FsCodec.decode(schema, FsOperations.readAll(p)); + return FsCodec.decode(schema, FsOperations.readAll(p), stagedContent); } catch (IOException e) { throw new IllegalStateException("read failed: " + p, e); } @@ -1639,7 +1860,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { } } - private static List listCurrentRecords(final Path byIdDir, final FsCodec.Schema schema) { + private List listCurrentRecords(final Path byIdDir, final FsCodec.Schema schema) { if (!Files.isDirectory(byIdDir)) { return List.of(); } @@ -1651,7 +1872,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { for (Path entityDir : entityDirs) { Path current = entityDir.resolve(FsPaths.CURRENT_FILE); if (Files.exists(current)) { - out.add(FsCodec.decode(schema, FsOperations.readAll(current))); + out.add(FsCodec.decode(schema, FsOperations.readAll(current), stagedContent)); } } return out; diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemStagedContentStore.java b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemStagedContentStore.java new file mode 100644 index 0000000..8baabfd --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemStagedContentStore.java @@ -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. + * + *

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

      + */ +public final class FilesystemStagedContentStore implements StagedContentStore { + + private static final Set OWNER_ONLY = EnumSet.of(PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE); + private static final Set 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 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 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 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 readOwners(String contentId) throws IOException { + return StoreIo.readOwners(this, contentId); + } + + private void writeOwners(String contentId, Set 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 paths = Files.list(store.root)) { + java.util.Iterator 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 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 paths = Files.list(store.root)) { + java.util.Iterator 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 paths = Files.list(store.root)) { + java.util.Iterator 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 paths = Files.list(store.root)) { + java.util.Iterator 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 paths = Files.list(directory)) { + java.util.Iterator 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 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 decodeOwners(DataInputStream input, String contentId) + throws IOException { + Set 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 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 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"); + } + } + +} diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemTemporaryUniqueIndex.java b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemTemporaryUniqueIndex.java new file mode 100644 index 0000000..fcd8b0a --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemTemporaryUniqueIndex.java @@ -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); + } + } + +} diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java index f0bacaa..feca88e 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsCodec.java @@ -68,6 +68,7 @@ import zeroecho.pki.api.credential.CaProfileBinding; import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.credential.CredentialProfileBinding; import zeroecho.pki.api.credential.CredentialStatus; +import zeroecho.pki.api.content.DurableContentReference; import zeroecho.pki.api.credential.EndEntityProfileBinding; import zeroecho.pki.api.orch.OrchestrationDurabilityPolicy; import zeroecho.pki.api.orch.WorkflowStateRecord; @@ -92,6 +93,7 @@ import zeroecho.pki.api.revocation.RevocationState; import zeroecho.pki.api.revocation.RevocationTransition; import zeroecho.pki.api.status.StatusObject; import zeroecho.pki.api.status.StatusObjectType; +import zeroecho.pki.spi.store.StagedContentStore; import zeroecho.pki.impl.core.attr.SimpleAttributeSet; import zeroecho.pki.spi.store.SignWorkflowStore; @@ -118,7 +120,7 @@ import zeroecho.pki.spi.store.SignWorkflowStore; final class FsCodec { /* package */ static final int MAX_COMPONENT_BYTES = 256 * 1024; - /* package */ static final int CURRENT_CODEC_VERSION = 2; + /* package */ static final int CURRENT_CODEC_VERSION = 3; private static final int CODEC_MAGIC = 0x5A454346; private static final int MAX_COLLECTION_ELEMENTS = MAX_COMPONENT_BYTES; @@ -132,6 +134,7 @@ final class FsCodec { private static final int TOP_POLICY_TRACE = 8; private static final int TOP_WORKFLOW_STATE = 9; private static final int TOP_SIGN_WORKFLOW_RECORD = 10; + private static final int DURABLE_CONTENT_VERSION = 1; private static final int TOP_PROFILE_VERSION = 11; private static final int TOP_ACTIVE_PROFILE_REF = 12; @@ -174,6 +177,7 @@ final class FsCodec { private static final int TYPE_SAN = 66; private static final int TYPE_PROFILE_REF = 72; private static final int TYPE_PROFILE_BINDING = 73; + private static final int TYPE_DURABLE_CONTENT = 74; private static final int ATTRIBUTE_STRING = 1; private static final int ATTRIBUTE_BOOLEAN = 2; @@ -392,6 +396,8 @@ final class FsCodec { }, reader -> new Validity(reader.readValue(INSTANT), reader.readValue(INSTANT))); private static final ValueSchema ENCODED_OBJECT = valueSchema(TYPE_ENCODED_OBJECT, FsCodec::writeEncodedObject, FsCodec::readEncodedObject); + private static final ValueSchema DURABLE_CONTENT = valueSchema(TYPE_DURABLE_CONTENT, + FsCodec::writeDurableContent, FsCodec::readDurableContent); private static final ValueSchema PRINCIPAL = valueSchema(TYPE_PRINCIPAL, (writer, value) -> { writer.writeValue(STRING, value.type()); writer.writeValue(STRING, value.name()); @@ -495,39 +501,48 @@ final class FsCodec { } /* package */ static T decode(final Schema schema, final byte[] encoded) { + return decode(schema, encoded, null); + } + + /* package */ static T decode(final Schema schema, final byte[] encoded, + final StagedContentStore stagedContent) { Objects.requireNonNull(schema, "schema"); Objects.requireNonNull(encoded, "encoded"); try { - return decodeCurrentPayload(schema, encoded); + return PayloadDecoder.decode(schema, encoded, stagedContent); } catch (IOException | IllegalArgumentException ex) { throw new IllegalStateException("Decoding failed: schema=" + schema.name + " code=INVALID_CURRENT_PAYLOAD", ex); } } - private static T decodeCurrentPayload(Schema schema, byte[] encoded) throws IOException { - ByteArrayInputStream input = new ByteArrayInputStream(encoded); - Reader reader = new Reader(input); - if (reader.readInt() != CODEC_MAGIC) { - throw new IOException("codec magic mismatch"); + /** Strict current-schema payload decoder separated from the schema registry. */ + private static final class PayloadDecoder { + private static T decode(Schema schema, byte[] encoded, StagedContentStore stagedContent) + throws IOException { + ByteArrayInputStream input = new ByteArrayInputStream(encoded); + Reader reader = new Reader(input, stagedContent); + if (reader.readInt() != CODEC_MAGIC) { + throw new IOException("codec magic mismatch"); + } + int version = reader.readUnsignedByte(); + if (version != CURRENT_CODEC_VERSION) { + throw new IOException("unsupported codec version"); + } + int typeId = reader.readUnsignedByte(); + Schema encodedSchema = TOP_LEVEL_SCHEMAS.get(typeId); + if (encodedSchema == null) { + throw new IOException("unknown top-level type"); + } + if (encodedSchema.typeId != schema.typeId) { + throw new IOException("top-level type mismatch"); + } + T decoded = schema.valueSchema.decoder.decode(reader); + if (input.available() != 0) { + throw new IOException("trailing payload data"); + } + return decoded; } - int version = reader.readUnsignedByte(); - if (version != CURRENT_CODEC_VERSION) { - throw new IOException("unsupported codec version"); - } - int typeId = reader.readUnsignedByte(); - Schema encodedSchema = TOP_LEVEL_SCHEMAS.get(typeId); - if (encodedSchema == null) { - throw new IOException("unknown top-level type"); - } - if (encodedSchema.typeId != schema.typeId) { - throw new IOException("top-level type mismatch"); - } - T decoded = schema.valueSchema.decoder.decode(reader); - if (input.available() != 0) { - throw new IOException("trailing payload data"); - } - return decoded; } private static void writeEncodedObject(Writer writer, EncodedObject value) throws IOException { @@ -550,6 +565,35 @@ final class FsCodec { } } + private static void writeDurableContent(Writer writer, DurableContentReference content) throws IOException { + writer.writeUnsignedByte(DURABLE_CONTENT_VERSION); + writer.writeValue(STRING, content.storeId()); + writer.writeValue(STRING, content.contentId()); + writer.writeValue(ENCODING, content.encoding()); + writer.writeValue(LONG, content.length()); + writer.writeValue(STRING, content.sha256()); + writer.writeValue(STRING, content.lifecycle().name()); + } + + private static DurableContentReference readDurableContent(Reader reader) throws IOException { + int version = reader.readUnsignedByte(); + if (version != DURABLE_CONTENT_VERSION) { + throw new IOException("unsupported durable content reference version"); + } + String storeId = reader.readValue(STRING); + String contentId = reader.readValue(STRING); + Encoding encoding = reader.readValue(ENCODING); + long length = reader.readValue(LONG); + String sha256 = reader.readValue(STRING); + DurableContentReference.Lifecycle lifecycle; + try { + lifecycle = DurableContentReference.Lifecycle.valueOf(reader.readValue(STRING)); + } catch (IllegalArgumentException exception) { + throw new IOException("unknown durable content lifecycle", exception); + } + return reader.restoreReference(storeId, contentId, encoding, length, sha256, lifecycle); + } + private static void writeAttributeValue(Writer writer, AttributeValue value) throws IOException { switch (value) { case AttributeValue.StringValue stringValue -> { @@ -629,7 +673,7 @@ final class FsCodec { writer.writeValue(PKI_ID, value.publicKeyId()); writer.writeValue(PROFILE_BINDING, value.profileBinding()); writer.writeValue(CREDENTIAL_STATUS, value.status()); - writer.writeValue(ENCODED_OBJECT, value.encoded()); + writer.writeValue(DURABLE_CONTENT, value.content()); writer.writeValue(ATTRIBUTE_SET, value.attributes()); } @@ -637,7 +681,7 @@ final class FsCodec { return new Credential(reader.readValue(PKI_ID), reader.readValue(FORMAT_ID), reader.readValue(ISSUER_REF), reader.readValue(SUBJECT_REF), reader.readValue(VALIDITY), reader.readValue(STRING), reader.readValue(PKI_ID), reader.readValue(PROFILE_BINDING), reader.readValue(CREDENTIAL_STATUS), - reader.readValue(ENCODED_OBJECT), reader.readValue(ATTRIBUTE_SET)); + reader.readValue(DURABLE_CONTENT), reader.readValue(ATTRIBUTE_SET)); } private static void writeProfileBinding(Writer writer, CredentialProfileBinding value) throws IOException { @@ -731,14 +775,20 @@ final class FsCodec { writer.writeValue(STATUS_OBJECT_TYPE, value.type()); writer.writeValue(INSTANT, value.thisUpdate()); writer.writeValue(OPTIONAL_INSTANT, value.nextUpdate()); - writer.writeValue(ENCODED_OBJECT, value.encoded()); + writer.writeValue(DURABLE_CONTENT, value.content()); writer.writeValue(ATTRIBUTE_SET, value.attributes()); } private static StatusObject readStatusObject(Reader reader) throws IOException { - return new StatusObject(reader.readValue(PKI_ID), reader.readValue(FORMAT_ID), reader.readValue(PKI_ID), - reader.readValue(STATUS_OBJECT_TYPE), reader.readValue(INSTANT), reader.readValue(OPTIONAL_INSTANT), - reader.readValue(ENCODED_OBJECT), reader.readValue(ATTRIBUTE_SET)); + PkiId statusObjectId = reader.readValue(PKI_ID); + FormatId formatId = reader.readValue(FORMAT_ID); + PkiId issuerCaId = reader.readValue(PKI_ID); + StatusObjectType type = reader.readValue(STATUS_OBJECT_TYPE); + Instant thisUpdate = reader.readValue(INSTANT); + Optional nextUpdate = reader.readValue(OPTIONAL_INSTANT); + DurableContentReference content = reader.readValue(DURABLE_CONTENT); + return new StatusObject(statusObjectId, formatId, issuerCaId, type, thisUpdate, nextUpdate, content, + reader.readValue(ATTRIBUTE_SET)); } private static void writePublication(Writer writer, PublicationRecord value) throws IOException { @@ -798,10 +848,11 @@ final class FsCodec { } private static int toInt(long value) throws IOException { - if (value < Integer.MIN_VALUE || value > Integer.MAX_VALUE) { - throw new IOException("integer value out of range"); + try { + return Math.toIntExact(value); + } catch (ArithmeticException exception) { + throw new IOException("integer value out of range", exception); } - return (int) value; } private static void writePolicyTrace(Writer writer, PolicyTrace value) throws IOException { @@ -1073,9 +1124,19 @@ final class FsCodec { private static final class Reader { private final InputStream input; + private final StagedContentStore stagedContent; - private Reader(InputStream input) { + private Reader(InputStream input, StagedContentStore stagedContent) { this.input = input; + this.stagedContent = stagedContent; + } + + private DurableContentReference restoreReference(String storeId, String contentId, Encoding encoding, + long length, String sha256, DurableContentReference.Lifecycle lifecycle) throws IOException { + if (stagedContent == null) { + throw new IOException("durable content reference requires owning staged-content store"); + } + return stagedContent.restoreReference(storeId, contentId, encoding, length, sha256, lifecycle); } private T readValue(ValueSchema schema) throws IOException { diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java index 958e630..87247b9 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsPaths.java @@ -90,6 +90,10 @@ final class FsPaths { return this.root.resolve("SIGNING_TIME_WATERMARK"); } + /* default */ Path stagedContentRoot() { + return this.root.resolve("staged-content"); + } + /* default */ Path lockFile() { return this.root.resolve(LOCK_DIR).resolve(STORE_LOCK); } @@ -175,6 +179,10 @@ final class FsPaths { return revocationDir(credentialId).resolve("journal.bin"); } + /* default */ Path revocationSnapshotRoot() { + return this.root.resolve("revocation-snapshots"); + } + // ------------------------------------------------------------------------- // Status objects (immutable .bin) // ------------------------------------------------------------------------- diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java b/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java index e1c4d56..57aa73b 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FsSnapshotExporter.java @@ -113,6 +113,7 @@ final class FsSnapshotExporter { copyTreeIfExists(sourceRoot.resolve("policy"), targetRoot.resolve("policy")); copyTreeIfExists(sourceRoot.resolve("publications"), targetRoot.resolve("publications")); copyTreeIfExists(sourceRoot.resolve("sign-workflows"), targetRoot.resolve("sign-workflows")); + copyTreeIfExists(sourceRoot.resolve("staged-content"), targetRoot.resolve("staged-content")); copyTreeIfExists(sourceRoot.resolve("revocations"), targetRoot.resolve("revocations")); copyImportedProfilesAsOf(profiles, targetRoot.resolve("profiles")); diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/MetadataFrameCodec.java b/pki/src/main/java/zeroecho/pki/impl/fs/MetadataFrameCodec.java new file mode 100644 index 0000000..29a29a7 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/fs/MetadataFrameCodec.java @@ -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); + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/MetadataMutationPayloadCodec.java b/pki/src/main/java/zeroecho/pki/impl/fs/MetadataMutationPayloadCodec.java new file mode 100644 index 0000000..fc34cc9 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/fs/MetadataMutationPayloadCodec.java @@ -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(); + } + } + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/MetadataStateIndex.java b/pki/src/main/java/zeroecho/pki/impl/fs/MetadataStateIndex.java new file mode 100644 index 0000000..e4aa134 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/fs/MetadataStateIndex.java @@ -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 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 lookup(MetadataKey key) { + Objects.requireNonNull(key, "key"); + readLock.lock(); + try { + return Optional.ofNullable(current.get(key)); + } finally { + readLock.unlock(); + } + } + + /* default */ List 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 mutations) throws MetadataStoreException { + publish(prepare(committedStoreRevision, mutations)); + } + + /* default */ PreparedUpdate prepare( + long committedStoreRevision, + List mutations) throws MetadataStoreException { + List validated = validateAndDetach(mutations); + readLock.lock(); + try { + requireNextRevision(committedStoreRevision); + rejectDuplicateKeys(validated); + NavigableMap 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 validateAndDetach( + List mutations) throws MetadataStoreException { + try { + List detached = List.copyOf( + Objects.requireNonNull(mutations, "mutations")); + List 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 mutations) throws MetadataStoreException { + Set 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 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 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 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 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 records = new TreeMap<>(); + private long revision = INITIAL_STORE_REVISION; + private long discardedDescriptors; + private boolean finished; + + /* default */ void applyCommitted( + long committedRevision, + List descriptors) + throws MetadataStoreException { + requireUnfinished(); + List validated = validateAndDetach(descriptors); + if (revision == Long.MAX_VALUE || committedRevision != revision + 1L) { + throw integrity("Recovered metadata store revision is not contiguous"); + } + rejectDuplicateKeys(validated); + Map> delta = new HashMap<>(); + for (ValidatedMutation mutation : validated) { + validateRecoveryMutation(delta, committedRevision, mutation); + } + for (Map.Entry> 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> 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 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"); + } + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/PosixMetadataAdapterLifecycle.java b/pki/src/main/java/zeroecho/pki/impl/fs/PosixMetadataAdapterLifecycle.java new file mode 100644 index 0000000..c416647 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/fs/PosixMetadataAdapterLifecycle.java @@ -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 resources = + Collections.newSetFromMap(new IdentityHashMap<>()); + private State state = State.OPEN; + private boolean closing; + private int activeOperations; + + /* default */ T openManaged(CheckedFactory 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 read(CheckedFactory 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 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 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 { + /** + * 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 + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/PosixMetadataLog.java b/pki/src/main/java/zeroecho/pki/impl/fs/PosixMetadataLog.java new file mode 100644 index 0000000..f815af7 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/fs/PosixMetadataLog.java @@ -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 RECOGNIZED_LOCAL_FILE_SYSTEMS = + Set.of("apfs", "btrfs", "ext2", "ext3", "ext4", "tmpfs", "ufs", "xfs", "zfs"); + private static final Set 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 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 predictMutationLocations(List payloadLengths) + throws IOException { + operationLock.lock(); + try { + requireOperational(); + List detached = List.copyOf(payloadLengths); + List 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 + mergeRestartOutcomes( + Map outcomes, + Map restartOutcomes) { + Map 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 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 openDirectory(Path parent) throws IOException { + return requireSecureDirectory(Files.newDirectoryStream(parent)); + } + + private static SecureDirectoryStream requireSecureDirectory(DirectoryStream opened) + throws IOException { + if (opened instanceof SecureDirectoryStream) { + @SuppressWarnings("unchecked") + SecureDirectoryStream secure = (SecureDirectoryStream) 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 logOptions(boolean create) { + Set 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 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 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; + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/PosixMetadataLogScanner.java b/pki/src/main/java/zeroecho/pki/impl/fs/PosixMetadataLogScanner.java new file mode 100644 index 0000000..ed30b41 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/fs/PosixMetadataLogScanner.java @@ -0,0 +1,1050 @@ +/******************************************************************************* + * 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.ByteOrder; +import java.nio.channels.SeekableByteChannel; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.ArrayList; +import java.util.List; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.OptionalLong; +import zeroecho.pki.spi.store.MetadataCommitResult; +import zeroecho.pki.spi.store.MetadataStoreException; +import zeroecho.pki.spi.store.MetadataStoreId; +import zeroecho.pki.spi.store.MetadataTransactionId; + +/** Strict sequential recovery scanner for one POSIX append-only metadata log. */ +final class PosixMetadataLogScanner { + + private static final short SEMANTIC_VERSION = 1; + private static final short RESERVED_SHORT = 0; + private static final int ID_BYTES = 16; + private static final int DIGEST_BYTES = 32; + private static final int STORE_HEADER_BYTES = 2 + 2 + ID_BYTES; + private static final int ISSUANCE_BYTES = 2 + 2 + ID_BYTES + ID_BYTES; + private static final int COMMITTED_BYTES = 2 + 2 + ID_BYTES + ID_BYTES + Long.BYTES + DIGEST_BYTES; + private static final int REJECTED_BYTES = 2 + 2 + ID_BYTES + ID_BYTES + Integer.BYTES + DIGEST_BYTES; + private static final int RESTART_BYTES = Short.BYTES + Long.BYTES + Long.BYTES + + Long.BYTES + Short.BYTES; + 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_REVISION = 0L; + private static final long MINIMUM_COUNT = 0L; + private static final long NO_MUTATIONS = 0L; + private static final int MINIMUM_FAILURE_CODE = 1; + private static final String ZERO_TOKEN = "00000000000000000000000000000000"; + private static final HexFormat LOWERCASE_HEX = HexFormat.of(); + private static final byte[] CHAIN_INITIAL_DOMAIN = + "zeroecho:pki:posix-metadata-log-chain-initial:v1\0".getBytes(StandardCharsets.UTF_8); + private static final byte[] CHAIN_STEP_DOMAIN = + "zeroecho:pki:posix-metadata-log-chain-step:v1\0".getBytes(StandardCharsets.UTF_8); + + private PosixMetadataLogScanner() { + } + + /* + * The scanner is O(log bytes) and keeps O(issued transactions) finite control + * state. MetadataFrameCodec supplies fixed-buffer payload validation; this + * scanner reads only exact bounded semantic payloads and 32-byte digests. + */ + /* default */ static RecoveryResult scan(SeekableByteChannel channel) throws IOException { + return new ScanEngine(channel).scan(); + } + + /* default */ static ReplayResult replay(SeekableByteChannel channel) throws IOException { + return new ReplayEngine(channel).replay(); + } + + /* default */ static byte[] encodeStoreHeader(MetadataStoreId storeId) { + return Semantic.encodeStoreHeader(storeId); + } + + /* default */ static byte[] encodeIssuance(MetadataTransactionId transactionId) { + return Semantic.encodeIssuance(transactionId); + } + + /* default */ static byte[] encodeCommitted( + MetadataTransactionId transactionId, long revision, byte[] chainState) { + return Semantic.encodeCommitted(transactionId, revision, chainState); + } + + /* default */ static byte[] encodeRejected( + MetadataTransactionId transactionId, int failureCode, byte[] chainState) { + return Semantic.encodeRejected(transactionId, failureCode, chainState); + } + + /* default */ static byte[] encodeRestart( + long epoch, long abandonedOpenCount, long truncatedByteCount) { + return Semantic.encodeRestart(epoch, abandonedOpenCount, truncatedByteCount); + } + + /* default */ static byte[] initialChainState() { + return TransactionDigest.initial(); + } + + /* default */ static byte[] advanceChain( + SeekableByteChannel channel, + MetadataFrameCodec.FrameMetadata frame, + byte[] previousState) throws IOException { + return TransactionDigest.advance(channel, frame, previousState); + } + + /* default */ static String zeroToken() { + return ZERO_TOKEN; + } + + private static void requireIssuanceAuthority( + MetadataStoreId storeId, + MetadataFrameCodec.FrameMetadata frame, + MetadataTransactionId transactionId) throws IOException { + if (!storeId.equals(transactionId.storeId()) || !frame.transactionToken().equals(transactionId.token())) { + throw integrity("POSIX metadata transaction issuance authority is inconsistent"); + } + } + + private static void requireStoreHeaderShape(MetadataFrameCodec.FrameMetadata header) + throws MetadataStoreException { + if (header.frameType() != MetadataFrameCodec.FrameType.STORE_HEADER + || header.sequence() != STORE_HEADER_SEQUENCE + || !ZERO_TOKEN.equals(header.transactionToken())) { + throw integrity("POSIX metadata log store header is structurally invalid"); + } + } + + private static boolean isTransactionPrefix(MetadataFrameCodec.FrameType type) { + return type == MetadataFrameCodec.FrameType.TRANSACTION_ISSUED || isMutation(type); + } + + private static boolean isMutation(MetadataFrameCodec.FrameType type) { + return type == MetadataFrameCodec.FrameType.MUTATION_CREATE + || type == MetadataFrameCodec.FrameType.MUTATION_REPLACE + || type == MetadataFrameCodec.FrameType.MUTATION_DELETE; + } + + private static byte descriptorCode(MetadataFrameCodec.FrameType type) { + return switch (type) { + case TRANSACTION_ISSUED -> 1; + case MUTATION_CREATE -> 2; + case MUTATION_REPLACE -> 3; + case MUTATION_DELETE -> 4; + default -> throw new IllegalArgumentException("Frame type is not part of a POSIX transaction prefix"); + }; + } + + private static MetadataTransactionId transactionId(MetadataStoreId storeId, String token) { + return new MetadataTransactionId(storeId, token); + } + + private static void requireDigest(byte[] digest, String message) { + if (digest == null || digest.length != DIGEST_BYTES) { + throw new IllegalArgumentException(message); + } + } + + private static int readFully(SeekableByteChannel channel, ByteBuffer target) throws IOException { + int total = 0; + while (target.hasRemaining()) { + int read = channel.read(target); + if (read < 0) { + break; + } + if (read == 0) { + throw new IOException("POSIX metadata log channel made no read progress"); + } + total += read; + } + return total; + } + + private static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException failure) { + throw new IllegalStateException("SHA-256 is unavailable", failure); + } + } + + 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); + } + + /* default */ static long checkedNextRecoveryEpoch(long currentEpoch) + throws MetadataStoreException { + try { + return Math.addExact(currentEpoch, 1L); + } catch (ArithmeticException failure) { + throw integrity("POSIX metadata recovery epoch is exhausted", failure); + } + } + + /** Physical state of the bytes following the last complete validated frame. */ + /* default */ enum Tail { + CLEAN_END, + INCOMPLETE_TAIL + } + + /** Semantic terminal result reconstructed without applying opaque mutations. */ + /* default */ record Terminal(TerminalKind kind, OptionalLong committedRevision, OptionalInt failureCode) { + Terminal { + Objects.requireNonNull(kind, "kind"); + Objects.requireNonNull(committedRevision, "committedRevision"); + Objects.requireNonNull(failureCode, "failureCode"); + } + } + + /** Closed terminal kinds understood by the current log version. */ + /* default */ enum TerminalKind { + COMMITTED, + NOT_COMMITTED + } + + /** Stable internal reasons for synthesized or locally authored rejections. */ + /* default */ enum FailureReason { + TRANSACTION_CONFLICT(1), + ABANDONED_BY_RECOVERY(2); + + private final int code; + + FailureReason(int code) { + this.code = code; + } + + /* default */ int code() { + return code; + } + + /* default */ static Optional fromCode(int code) { + for (FailureReason reason : values()) { + if (reason.code == code) { + return Optional.of(reason); + } + } + return Optional.empty(); + } + } + + /** Exact semantic facts carried by one store-scoped recovery restart. */ + private record Restart(long epoch, long abandonedOpenCount, long truncatedByteCount) { + } + + /** Immutable finite transaction facts reconstructed by recovery. */ + /* default */ record RecoveredTransaction( + MetadataTransactionId transactionId, long mutationCount, Optional terminal) { + RecoveredTransaction { + Objects.requireNonNull(transactionId, "transactionId"); + Objects.requireNonNull(terminal, "terminal"); + if (mutationCount < MINIMUM_COUNT) { + throw new IllegalArgumentException("Recovered mutation count must be non-negative"); + } + } + } + + /** Complete immutable result of one recovery scan. */ + /* default */ record RecoveryResult( + MetadataStoreId storeId, + long lastCompleteFrameBoundary, + long physicalEnd, + Tail tail, + Map transactions, + Optional nextTransactionToken, + boolean transactionTokensExhausted, + long recoveryEpoch, + long openTransactionCount) { + RecoveryResult { + Objects.requireNonNull(storeId, "storeId"); + Objects.requireNonNull(tail, "tail"); + Objects.requireNonNull(transactions, "transactions"); + Objects.requireNonNull(nextTransactionToken, "nextTransactionToken"); + if (lastCompleteFrameBoundary < MINIMUM_COUNT || physicalEnd < MINIMUM_COUNT) { + throw new IllegalArgumentException("Recovery boundaries must be non-negative"); + } + if (lastCompleteFrameBoundary > physicalEnd) { + throw new IllegalArgumentException("Last complete frame boundary exceeds physical end"); + } + if (tail == Tail.CLEAN_END && lastCompleteFrameBoundary != physicalEnd) { + throw new IllegalArgumentException("Clean recovery boundary must equal physical end"); + } + if (tail == Tail.INCOMPLETE_TAIL && lastCompleteFrameBoundary >= physicalEnd) { + throw new IllegalArgumentException("Incomplete recovery boundary must precede physical end"); + } + if (transactionTokensExhausted && nextTransactionToken.isPresent()) { + throw new IllegalArgumentException("Exhausted transaction tokens cannot expose a next token"); + } + if (!transactionTokensExhausted && nextTransactionToken.isEmpty()) { + throw new IllegalArgumentException("Available transaction tokens require a next token"); + } + if (!transactionTokensExhausted) { + TransactionCounter.recovered(nextTransactionToken, false); + } + if (recoveryEpoch < MINIMUM_COUNT || openTransactionCount < MINIMUM_COUNT) { + throw new IllegalArgumentException("Recovery epoch and open count must be non-negative"); + } + transactions = Map.copyOf(transactions); + } + } + + /** Current-state replay result used only by the internal store engine. */ + /* default */ record ReplayResult( + MetadataStoreId storeId, + long lastCompleteFrameBoundary, + long physicalEnd, + Tail tail, + Optional nextTransactionToken, + boolean transactionTokensExhausted, + MetadataStateIndex stateIndex, + Map outcomes, + Map restartOutcomes, + long recoveryEpoch, + long openTransactionCount, + long recoveryFullMapCopies, + long recoveryFinalInstalls, + long discardedDescriptorCount) { + ReplayResult { + Objects.requireNonNull(storeId, "storeId"); + Objects.requireNonNull(tail, "tail"); + Objects.requireNonNull(nextTransactionToken, "nextTransactionToken"); + Objects.requireNonNull(stateIndex, "stateIndex"); + Objects.requireNonNull(outcomes, "outcomes"); + Objects.requireNonNull(restartOutcomes, "restartOutcomes"); + if (recoveryEpoch < MINIMUM_COUNT || openTransactionCount < MINIMUM_COUNT + || recoveryFullMapCopies < MINIMUM_COUNT || recoveryFinalInstalls < MINIMUM_COUNT + || discardedDescriptorCount < MINIMUM_COUNT) { + throw new IllegalArgumentException("Replay counters must be non-negative"); + } + outcomes = Map.copyOf(outcomes); + restartOutcomes = Map.copyOf(restartOutcomes); + if (restartOutcomes.size() != openTransactionCount) { + throw new IllegalArgumentException("Replay restart outcomes must match open transactions"); + } + } + } + + /** Unsigned 128-bit scalar counter; zero is reserved and maximum is issued once. */ + /* default */ static final class TransactionCounter { + private static final long ALL_ONES = -1L; + private static final long ZERO_LIMB = 0L; + private static final long FIRST_LOW_LIMB = 1L; + + private long high; + private long low; + private boolean exhausted; + + private TransactionCounter(long high, long low, boolean exhausted) { + this.high = high; + this.low = low; + this.exhausted = exhausted; + } + + /* default */ static TransactionCounter first() { + return new TransactionCounter(ZERO_LIMB, FIRST_LOW_LIMB, false); + } + + /* default */ static TransactionCounter startingAt(long high, long low) { + if (high == ZERO_LIMB && low == ZERO_LIMB) { + throw new IllegalArgumentException("Zero POSIX metadata transaction token is reserved"); + } + return new TransactionCounter(high, low, false); + } + + /* default */ static TransactionCounter recovered( + Optional nextTransactionToken, boolean exhausted) { + Objects.requireNonNull(nextTransactionToken, "nextTransactionToken"); + if (exhausted) { + return new TransactionCounter(ALL_ONES, ALL_ONES, true); + } + byte[] encoded = LOWERCASE_HEX.parseHex(nextTransactionToken.orElseThrow()); + ByteBuffer token = ByteBuffer.wrap(encoded).order(ByteOrder.BIG_ENDIAN); + return startingAt(token.getLong(), token.getLong()); + } + + /* default */ MetadataTransactionId issue(MetadataStoreId storeId) { + Objects.requireNonNull(storeId, "storeId"); + if (exhausted) { + throw new IllegalStateException("POSIX metadata transaction tokens are exhausted"); + } + String token = encode(high, low); + advance(); + return new MetadataTransactionId(storeId, token); + } + + private void observeIssued(String token) throws MetadataStoreException { + if (exhausted || !encode(high, low).equals(token)) { + throw integrity("POSIX metadata transaction token sequence is not contiguous"); + } + advance(); + } + + private Optional nextToken() { + return exhausted ? Optional.empty() : Optional.of(encode(high, low)); + } + + private boolean exhausted() { + return exhausted; + } + + private void advance() { + if (high == ALL_ONES && low == ALL_ONES) { + exhausted = true; + } else if (low == ALL_ONES) { + high++; + low = ZERO_LIMB; + } else { + low++; + } + } + + private static String encode(long high, long low) { + ByteBuffer token = ByteBuffer.allocate(ID_BYTES).order(ByteOrder.BIG_ENDIAN); + token.putLong(high).putLong(low); + return LOWERCASE_HEX.formatHex(token.array()); + } + } + + /** Sequential scanner that owns recovery control state for one invocation. */ + private static final class ScanEngine { + private final SeekableByteChannel channel; + private final MetadataFrameCodec codec = new MetadataFrameCodec(); + private final long physicalEnd; + private final Map states = new LinkedHashMap<>(); + private final TransactionCounter transactionCounter = TransactionCounter.first(); + private MetadataStoreId storeId; + private MetadataTransactionId batchTransaction; + private long recoveryEpoch; + + private ScanEngine(SeekableByteChannel channel) throws IOException { + this.channel = Objects.requireNonNull(channel, "channel"); + physicalEnd = channel.size(); + } + + private RecoveryResult scan() throws IOException { + MetadataFrameCodec.ReadResult first = codec.read(channel, 0L); + if (first.classification() != MetadataFrameCodec.ReadClassification.COMPLETE_FRAME) { + throw integrity("POSIX metadata log has no complete store header"); + } + MetadataFrameCodec.FrameMetadata header = first.metadata(); + requireStoreHeaderShape(header); + storeId = Semantic.decodeStoreHeader(channel, header); + long offset = header.frameEndOffset(); + while (true) { + MetadataFrameCodec.ReadResult read = codec.read(channel, offset); + if (read.classification() == MetadataFrameCodec.ReadClassification.END_OF_INPUT) { + return recoveryResult(offset, Tail.CLEAN_END); + } + if (read.classification() == MetadataFrameCodec.ReadClassification.INCOMPLETE_TAIL) { + return recoveryResult(offset, Tail.INCOMPLETE_TAIL); + } + if (read.classification() == MetadataFrameCodec.ReadClassification.CORRUPT_FRAME) { + throw integrity("POSIX metadata log contains a corrupt frame"); + } + MetadataFrameCodec.FrameMetadata frame = read.metadata(); + processFrame(frame); + offset = frame.frameEndOffset(); + } + } + + private void processFrame(MetadataFrameCodec.FrameMetadata frame) throws IOException { + if (frame.frameType() == MetadataFrameCodec.FrameType.STORE_HEADER) { + throw integrity("POSIX metadata log contains a duplicate store header"); + } + if (frame.frameType() == MetadataFrameCodec.FrameType.TRANSACTION_ISSUED) { + processIssuance(frame); + return; + } + if (frame.frameType() == MetadataFrameCodec.FrameType.RECOVERY_RESTART) { + processRestart(frame); + return; + } + MetadataTransactionId transactionId = transactionId(storeId, frame.transactionToken()); + TransactionState state = states.get(transactionId); + requireActiveState(state, frame); + if (isMutation(frame.frameType())) { + if (batchTransaction == null) { + batchTransaction = transactionId; + } else if (!batchTransaction.equals(transactionId)) { + throw integrity("POSIX metadata mutation batches are interleaved"); + } + state.chainState = advanceChain(channel, frame, state.chainState); + state.mutationCount++; + state.nextSequence++; + } else if (frame.frameType() == MetadataFrameCodec.FrameType.TERMINAL_COMMITTED) { + requireTerminalBatch(transactionId, state.mutationCount); + state.terminal = Semantic.decodeCommitted(channel, frame, transactionId, state.chainState); + batchTransaction = null; + } else if (frame.frameType() == MetadataFrameCodec.FrameType.TERMINAL_NOT_COMMITTED) { + if (state.mutationCount != NO_MUTATIONS || batchTransaction != null) { + throw integrity("Rejected POSIX metadata transaction contains mutation frames"); + } + state.terminal = Semantic.decodeRejected(channel, frame, transactionId, state.chainState); + } else { + throw integrity("POSIX metadata log contains an unsupported frame type"); + } + } + + private void requireTerminalBatch(MetadataTransactionId transactionId, long mutationCount) + throws MetadataStoreException { + if (mutationCount == NO_MUTATIONS) { + if (batchTransaction != null) { + throw integrity("Empty committed transaction overlaps a mutation batch"); + } + } else if (!transactionId.equals(batchTransaction)) { + throw integrity("Committed POSIX metadata terminal does not own its mutation batch"); + } + } + + private void requireActiveState( + TransactionState state, MetadataFrameCodec.FrameMetadata frame) throws MetadataStoreException { + if (state == null) { + throw integrity("POSIX metadata log frame names an unknown transaction"); + } + if (state.terminal != null) { + throw integrity("POSIX metadata log contains a frame after a terminal outcome"); + } + if (frame.sequence() != state.nextSequence) { + throw integrity("POSIX metadata log transaction sequence is not contiguous"); + } + } + + private void processIssuance(MetadataFrameCodec.FrameMetadata frame) throws IOException { + if (frame.sequence() != ISSUANCE_SEQUENCE) { + throw integrity("POSIX metadata transaction issuance sequence is invalid"); + } + MetadataTransactionId transactionId = Semantic.decodeIssuance(channel, frame); + requireIssuanceAuthority(storeId, frame, transactionId); + transactionCounter.observeIssued(transactionId.token()); + TransactionState state = new TransactionState(); + state.chainState = advanceChain(channel, frame, state.chainState); + states.put(transactionId, state); + } + + private void processRestart(MetadataFrameCodec.FrameMetadata frame) throws IOException { + Restart restart = Semantic.decodeRestart(channel, frame); + long expectedEpoch = checkedNextRecoveryEpoch(recoveryEpoch); + if (!zeroToken().equals(frame.transactionToken()) + || frame.sequence() != expectedEpoch + || restart.epoch() != expectedEpoch + || restart.abandonedOpenCount() != openTransactionCount()) { + throw integrity("POSIX metadata recovery restart is inconsistent"); + } + for (TransactionState state : states.values()) { + if (state.terminal == null) { + state.terminal = abandonedTerminal(); + } + } + batchTransaction = null; + recoveryEpoch = expectedEpoch; + } + + private long openTransactionCount() { + long count = 0L; + for (TransactionState state : states.values()) { + if (state.terminal == null) { + count++; + } + } + return count; + } + + private RecoveryResult recoveryResult(long boundary, Tail tail) { + Map recovered = new LinkedHashMap<>(); + for (Map.Entry entry : states.entrySet()) { + recovered.put(entry.getKey(), recoveredTransaction(entry)); + } + return new RecoveryResult( + storeId, boundary, physicalEnd, tail, recovered, + transactionCounter.nextToken(), transactionCounter.exhausted(), + recoveryEpoch, openTransactionCount()); + } + + private static RecoveredTransaction recoveredTransaction( + Map.Entry entry) { + TransactionState state = entry.getValue(); + return new RecoveredTransaction( + entry.getKey(), state.mutationCount, Optional.ofNullable(state.terminal)); + } + } + + /** Streaming semantic replay retaining only active transactions and one batch. */ + private static final class ReplayEngine { + private final SeekableByteChannel channel; + private final MetadataFrameCodec codec = new MetadataFrameCodec(); + private final long physicalEnd; + private final Map active = new LinkedHashMap<>(); + private final TransactionCounter transactionCounter = TransactionCounter.first(); + private final MetadataStateIndex.RecoveryBuilder recoveryBuilder = + MetadataStateIndex.recoveryBuilder(); + private final Map outcomes = new LinkedHashMap<>(); + private final List batch = new ArrayList<>(); + private MetadataStoreId storeId; + private MetadataTransactionId batchTransaction; + private long recoveryEpoch; + + private ReplayEngine(SeekableByteChannel channel) throws IOException { + this.channel = Objects.requireNonNull(channel, "channel"); + physicalEnd = channel.size(); + } + + private ReplayResult replay() throws IOException { + MetadataFrameCodec.ReadResult first = codec.read(channel, 0L); + if (first.classification() != MetadataFrameCodec.ReadClassification.COMPLETE_FRAME) { + throw integrity("POSIX metadata log has no complete store header"); + } + MetadataFrameCodec.FrameMetadata header = first.metadata(); + requireStoreHeaderShape(header); + storeId = Semantic.decodeStoreHeader(channel, header); + long offset = header.frameEndOffset(); + while (true) { + MetadataFrameCodec.ReadResult read = codec.read(channel, offset); + if (read.classification() == MetadataFrameCodec.ReadClassification.END_OF_INPUT) { + return result(offset, Tail.CLEAN_END); + } + if (read.classification() == MetadataFrameCodec.ReadClassification.INCOMPLETE_TAIL) { + return result(offset, Tail.INCOMPLETE_TAIL); + } + if (read.classification() == MetadataFrameCodec.ReadClassification.CORRUPT_FRAME) { + throw integrity("POSIX metadata log contains a corrupt frame"); + } + process(read.metadata()); + offset = read.metadata().frameEndOffset(); + } + } + + private void process(MetadataFrameCodec.FrameMetadata frame) throws IOException { + if (frame.frameType() == MetadataFrameCodec.FrameType.STORE_HEADER) { + throw integrity("POSIX metadata log contains a duplicate store header"); + } + if (frame.frameType() == MetadataFrameCodec.FrameType.TRANSACTION_ISSUED) { + processIssuance(frame); + return; + } + if (frame.frameType() == MetadataFrameCodec.FrameType.RECOVERY_RESTART) { + processRestart(frame); + return; + } + MetadataTransactionId transactionId = transactionId(storeId, frame.transactionToken()); + TransactionState state = requireActive(transactionId, frame); + if (isMutation(frame.frameType())) { + processMutation(transactionId, state, frame); + } else if (frame.frameType() == MetadataFrameCodec.FrameType.TERMINAL_COMMITTED) { + processCommitted(transactionId, state, frame); + } else if (frame.frameType() == MetadataFrameCodec.FrameType.TERMINAL_NOT_COMMITTED) { + processRejected(transactionId, state, frame); + } else { + throw integrity("POSIX metadata log contains an unsupported frame type"); + } + } + + private void processIssuance(MetadataFrameCodec.FrameMetadata frame) throws IOException { + if (frame.sequence() != ISSUANCE_SEQUENCE) { + throw integrity("POSIX metadata transaction issuance sequence is invalid"); + } + MetadataTransactionId transactionId = Semantic.decodeIssuance(channel, frame); + requireIssuanceAuthority(storeId, frame, transactionId); + transactionCounter.observeIssued(transactionId.token()); + TransactionState state = new TransactionState(); + state.chainState = advanceChain(channel, frame, state.chainState); + active.put(transactionId, state); + } + + private void processRestart(MetadataFrameCodec.FrameMetadata frame) throws IOException { + Restart restart = Semantic.decodeRestart(channel, frame); + long expectedEpoch = checkedNextRecoveryEpoch(recoveryEpoch); + if (!zeroToken().equals(frame.transactionToken()) + || frame.sequence() != expectedEpoch + || restart.epoch() != expectedEpoch + || restart.abandonedOpenCount() != active.size()) { + throw integrity("POSIX metadata recovery restart is inconsistent"); + } + for (MetadataTransactionId transactionId : active.keySet()) { + outcomes.put(transactionId, abandonedTerminal()); + } + active.clear(); + batch.clear(); + batchTransaction = null; + recoveryEpoch = expectedEpoch; + } + + private TransactionState requireActive( + MetadataTransactionId transactionId, + MetadataFrameCodec.FrameMetadata frame) throws MetadataStoreException { + TransactionState state = active.get(transactionId); + if (state == null || frame.sequence() != state.nextSequence) { + throw integrity("POSIX metadata log transaction state is not contiguous"); + } + return state; + } + + private void processMutation( + MetadataTransactionId transactionId, + TransactionState state, + MetadataFrameCodec.FrameMetadata frame) throws IOException { + if (batchTransaction == null) { + batchTransaction = transactionId; + } else if (!batchTransaction.equals(transactionId)) { + throw integrity("POSIX metadata mutation batches are interleaved"); + } + batch.add(MetadataMutationPayloadCodec.decode(channel, frame)); + state.chainState = advanceChain(channel, frame, state.chainState); + state.mutationCount++; + state.nextSequence++; + } + + private void processCommitted( + MetadataTransactionId transactionId, + TransactionState state, + MetadataFrameCodec.FrameMetadata frame) throws IOException { + Terminal terminal = Semantic.decodeCommitted(channel, frame, transactionId, state.chainState); + requireBatchOwner(transactionId, state.mutationCount); + try { + recoveryBuilder.applyCommitted( + terminal.committedRevision().orElseThrow(), List.copyOf(batch)); + } catch (MetadataStoreException failure) { + throw integrity("Committed POSIX metadata transaction cannot be reduced", failure); + } + outcomes.put(transactionId, terminal); + finish(transactionId); + } + + private void processRejected( + MetadataTransactionId transactionId, + TransactionState state, + MetadataFrameCodec.FrameMetadata frame) throws IOException { + Terminal terminal = Semantic.decodeRejected(channel, frame, transactionId, state.chainState); + if (state.mutationCount != NO_MUTATIONS || batchTransaction != null) { + throw integrity("Rejected POSIX metadata transaction contains mutation frames"); + } + outcomes.put(transactionId, terminal); + finish(transactionId); + } + + private void requireBatchOwner(MetadataTransactionId transactionId, long mutationCount) + throws MetadataStoreException { + if (mutationCount == NO_MUTATIONS) { + if (batchTransaction != null || !batch.isEmpty()) { + throw integrity("Empty committed transaction overlaps a mutation batch"); + } + } else if (!transactionId.equals(batchTransaction) || batch.size() != mutationCount) { + throw integrity("Committed POSIX metadata mutation batch is inconsistent"); + } + } + + private void finish(MetadataTransactionId transactionId) { + active.remove(transactionId); + batch.clear(); + batchTransaction = null; + } + + private ReplayResult result(long boundary, Tail tail) { + MetadataStateIndex stateIndex = recoveryBuilder.finish(); + Map restartOutcomes = new LinkedHashMap<>(); + for (MetadataTransactionId transactionId : active.keySet()) { + restartOutcomes.put(transactionId, abandonedTerminal()); + } + return new ReplayResult( + storeId, + boundary, + physicalEnd, + tail, + transactionCounter.nextToken(), + transactionCounter.exhausted(), + stateIndex, + outcomes, + restartOutcomes, + recoveryEpoch, + active.size(), + recoveryBuilder.fullMapCopyCount(), + recoveryBuilder.finalInstallCount(), + recoveryBuilder.discardedDescriptorCount()); + } + } + + private static Terminal abandonedTerminal() { + return new Terminal( + TerminalKind.NOT_COMMITTED, + OptionalLong.empty(), + OptionalInt.of(FailureReason.ABANDONED_BY_RECOVERY.code())); + } + + /** Domain-separated digest mechanics for one transaction prefix. */ + private static final class TransactionDigest { + private static byte[] initial() { + MessageDigest digest = sha256(); + digest.update(CHAIN_INITIAL_DOMAIN); + return digest.digest(); + } + + private static byte[] advance( + SeekableByteChannel channel, + MetadataFrameCodec.FrameMetadata frame, + byte[] previousState) throws IOException { + requireDigest(previousState, "Previous POSIX metadata transaction chain state is invalid"); + if (!isTransactionPrefix(frame.frameType())) { + throw new IllegalArgumentException( + "Terminal and store-header frames do not advance a transaction chain"); + } + byte[] payloadDigest = readPayloadDigest(channel, frame); + ByteBuffer descriptor = ByteBuffer.allocate(1 + ID_BYTES + Long.BYTES + Long.BYTES + DIGEST_BYTES) + .order(ByteOrder.BIG_ENDIAN); + descriptor.put(descriptorCode(frame.frameType())); + descriptor.put(LOWERCASE_HEX.parseHex(frame.transactionToken())); + descriptor.putLong(frame.sequence()); + descriptor.putLong(frame.payloadLength()); + descriptor.put(payloadDigest); + MessageDigest digest = sha256(); + digest.update(CHAIN_STEP_DOMAIN); + digest.update(previousState); + digest.update(descriptor.array()); + return digest.digest(); + } + + private static byte[] readPayloadDigest( + SeekableByteChannel channel, MetadataFrameCodec.FrameMetadata frame) throws IOException { + ByteBuffer digest = ByteBuffer.allocate(DIGEST_BYTES); + channel.position(frame.payloadDigestOffset()); + if (readFully(channel, digest) != DIGEST_BYTES) { + throw integrity("POSIX metadata frame payload digest is incomplete"); + } + channel.position(frame.frameEndOffset()); + return digest.array(); + } + } + + /** Scanner-confined O(1) state for one issued transaction. */ + private static final class TransactionState { + private byte[] chainState = initialChainState(); + private long nextSequence = FIRST_TRANSACTION_SEQUENCE; + private long mutationCount; + private Terminal terminal; + } + + /** Exact bounded semantic payload schemas; no opaque mutation is decoded here. */ + private static final class Semantic { + + private static byte[] encodeStoreHeader(MetadataStoreId storeId) { + ByteBuffer payload = buffer(STORE_HEADER_BYTES); + schema(payload); + payload.put(hex(storeId.value())); + return payload.array(); + } + + private static byte[] encodeIssuance(MetadataTransactionId transactionId) { + ByteBuffer payload = buffer(ISSUANCE_BYTES); + schema(payload); + transaction(payload, transactionId); + return payload.array(); + } + + private static byte[] encodeCommitted( + MetadataTransactionId transactionId, long revision, byte[] chainState) { + if (revision < MINIMUM_REVISION) { + throw new IllegalArgumentException("Committed POSIX metadata revision must be non-negative"); + } + requireDigest(chainState, "POSIX metadata terminal chain state is invalid"); + ByteBuffer payload = buffer(COMMITTED_BYTES); + schema(payload); + transaction(payload, transactionId); + payload.putLong(revision).put(chainState); + return payload.array(); + } + + private static byte[] encodeRejected( + MetadataTransactionId transactionId, int failureCode, byte[] chainState) { + if (failureCode < MINIMUM_FAILURE_CODE) { + throw new IllegalArgumentException("POSIX metadata failure code must be positive"); + } + requireDigest(chainState, "POSIX metadata terminal chain state is invalid"); + ByteBuffer payload = buffer(REJECTED_BYTES); + schema(payload); + transaction(payload, transactionId); + payload.putInt(failureCode).put(chainState); + return payload.array(); + } + + private static byte[] encodeRestart( + long epoch, long abandonedOpenCount, long truncatedByteCount) { + if (epoch < MINIMUM_COUNT || abandonedOpenCount < MINIMUM_COUNT + || truncatedByteCount < MINIMUM_COUNT) { + throw new IllegalArgumentException("POSIX metadata restart values must be non-negative"); + } + ByteBuffer payload = buffer(RESTART_BYTES); + payload.putShort(SEMANTIC_VERSION); + payload.putLong(epoch); + payload.putLong(abandonedOpenCount); + payload.putLong(truncatedByteCount); + payload.putShort(RESERVED_SHORT); + return payload.array(); + } + + private static MetadataStoreId decodeStoreHeader( + SeekableByteChannel channel, MetadataFrameCodec.FrameMetadata frame) throws IOException { + ByteBuffer payload = payload(channel, frame, STORE_HEADER_BYTES); + requireSchema(payload); + return new MetadataStoreId(readHex(payload)); + } + + private static MetadataTransactionId decodeIssuance( + SeekableByteChannel channel, MetadataFrameCodec.FrameMetadata frame) throws IOException { + ByteBuffer payload = payload(channel, frame, ISSUANCE_BYTES); + requireSchema(payload); + return readTransaction(payload); + } + + private static Terminal decodeCommitted( + SeekableByteChannel channel, + MetadataFrameCodec.FrameMetadata frame, + MetadataTransactionId expected, + byte[] expectedChain) throws IOException { + ByteBuffer payload = payload(channel, frame, COMMITTED_BYTES); + requireSchema(payload); + requireTransaction(payload, expected); + long revision = payload.getLong(); + if (revision < MINIMUM_REVISION) { + throw integrity("Committed POSIX metadata revision is negative"); + } + requireChain(payload, expectedChain); + return new Terminal(TerminalKind.COMMITTED, OptionalLong.of(revision), OptionalInt.empty()); + } + + private static Terminal decodeRejected( + SeekableByteChannel channel, + MetadataFrameCodec.FrameMetadata frame, + MetadataTransactionId expected, + byte[] expectedChain) throws IOException { + ByteBuffer payload = payload(channel, frame, REJECTED_BYTES); + requireSchema(payload); + requireTransaction(payload, expected); + int failureCode = payload.getInt(); + if (failureCode < MINIMUM_FAILURE_CODE) { + throw integrity("Rejected POSIX metadata failure code is invalid"); + } + requireChain(payload, expectedChain); + return new Terminal(TerminalKind.NOT_COMMITTED, OptionalLong.empty(), OptionalInt.of(failureCode)); + } + + private static Restart decodeRestart( + SeekableByteChannel channel, MetadataFrameCodec.FrameMetadata frame) + throws IOException { + ByteBuffer payload = payload(channel, frame, RESTART_BYTES); + short version = payload.getShort(); + long epoch = payload.getLong(); + long abandonedOpenCount = payload.getLong(); + long truncatedByteCount = payload.getLong(); + short reserved = payload.getShort(); + if (version != SEMANTIC_VERSION || reserved != RESERVED_SHORT + || epoch < MINIMUM_COUNT || abandonedOpenCount < MINIMUM_COUNT + || truncatedByteCount < MINIMUM_COUNT || payload.hasRemaining()) { + throw integrity("POSIX metadata recovery restart payload is invalid"); + } + return new Restart(epoch, abandonedOpenCount, truncatedByteCount); + } + + private static ByteBuffer payload( + SeekableByteChannel channel, + MetadataFrameCodec.FrameMetadata frame, + int exactLength) throws IOException { + if (frame.payloadLength() != exactLength) { + throw integrity("POSIX metadata semantic payload length is invalid"); + } + ByteBuffer payload = buffer(exactLength); + channel.position(frame.payloadOffset()); + if (readFully(channel, payload) != exactLength) { + throw integrity("POSIX metadata semantic payload is incomplete"); + } + payload.flip(); + return payload; + } + + private static void requireSchema(ByteBuffer payload) throws MetadataStoreException { + if (payload.getShort() != SEMANTIC_VERSION || payload.getShort() != RESERVED_SHORT) { + throw integrity("POSIX metadata semantic payload schema is unsupported"); + } + } + + private static void requireTransaction(ByteBuffer payload, MetadataTransactionId expected) + throws MetadataStoreException { + if (!expected.equals(readTransaction(payload))) { + throw integrity("POSIX metadata terminal transaction identity is inconsistent"); + } + } + + private static void requireChain(ByteBuffer payload, byte[] expected) throws MetadataStoreException { + byte[] observed = new byte[DIGEST_BYTES]; + payload.get(observed); + if (!MessageDigest.isEqual(expected, observed)) { + throw integrity("POSIX metadata terminal chain digest is inconsistent"); + } + } + + private static MetadataTransactionId readTransaction(ByteBuffer payload) { + return new MetadataTransactionId(new MetadataStoreId(readHex(payload)), readHex(payload)); + } + + private static String readHex(ByteBuffer payload) { + byte[] value = new byte[ID_BYTES]; + payload.get(value); + return LOWERCASE_HEX.formatHex(value); + } + + private static void schema(ByteBuffer payload) { + payload.putShort(SEMANTIC_VERSION).putShort(RESERVED_SHORT); + } + + private static void transaction(ByteBuffer payload, MetadataTransactionId transactionId) { + payload.put(hex(transactionId.storeId().value())).put(hex(transactionId.token())); + } + + private static byte[] hex(String value) { + return LOWERCASE_HEX.parseHex(value); + } + + private static ByteBuffer buffer(int size) { + return ByteBuffer.allocate(size).order(ByteOrder.BIG_ENDIAN); + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/PosixMetadataSnapshotSupport.java b/pki/src/main/java/zeroecho/pki/impl/fs/PosixMetadataSnapshotSupport.java new file mode 100644 index 0000000..c435180 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/fs/PosixMetadataSnapshotSupport.java @@ -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 records; + private final ReentrantLock lock = new ReentrantLock(); + private final Set recordChildren = + Collections.newSetFromMap(new IdentityHashMap<>()); + private final Set cursorChildren = + Collections.newSetFromMap(new IdentityHashMap<>()); + private boolean closed; + + private SnapshotImpl(PosixMetadataStoreEngine.SnapshotState captured) { + super(); + revision = captured.revision(); + NavigableMap 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 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 cursors; + List 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 owned) { + IOException failure = null; + Iterator iterator = owned.iterator(); + while (iterator.hasNext()) { + failure = PosixMetadataAdapterLifecycle.append( + failure, iterator.next().forceClose()); + } + return failure; + } + + private IOException closeOwnedRecords(List owned) { + IOException failure = null; + Iterator 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> iterator; + private RecordImpl current; + private boolean cursorClosed; + + private CursorImpl( + KeyRange range, + Iterator> iterator) { + super(); + this.range = range; + this.iterator = iterator; + } + + @Override + public Optional 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 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 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() { + 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 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 streams) { + IOException failure = null; + Iterator 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()); + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/PosixMetadataStoreEngine.java b/pki/src/main/java/zeroecho/pki/impl/fs/PosixMetadataStoreEngine.java new file mode 100644 index 0000000..71cbab5 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/fs/PosixMetadataStoreEngine.java @@ -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 outcomes; + private State state = State.OPEN; + + private PosixMetadataStoreEngine( + Path logPath, + PosixMetadataLog log, + MetadataStateIndex stateIndex, + FaultInjector faultInjector, + Map 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 mutations) throws IOException { + transactionLock.lock(); + try { + requireOperational(); + List detached = List.copyOf(mutations); + return commitOwned(transactionId, detached); + } finally { + transactionLock.unlock(); + } + } + + private CommitResult commitOwned( + MetadataTransactionId transactionId, + List 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 detached) throws IOException { + long revision = nextRevision(); + List 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 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 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 predict( + List mutations) throws IOException { + List payloadLengths = new ArrayList<>(mutations.size()); + for (PreparedMutation mutation : mutations) { + payloadLengths.add(mutation.payloadLength()); + } + List locations = + log.predictMutationLocations(payloadLengths); + List 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 mutations, + List 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 recoveredOutcomes( + Map recovered) { + Map results = new HashMap<>(); + for (Map.Entry 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 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 mutations; + private boolean outcomeEstablished; + + private PreparedResources(List 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 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 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 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 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 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); + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/PosixMetadataTransactionSupport.java b/pki/src/main/java/zeroecho/pki/impl/fs/PosixMetadataTransactionSupport.java new file mode 100644 index 0000000..10c6835 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/fs/PosixMetadataTransactionSupport.java @@ -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 staged = new LinkedHashMap<>(); + private final Set 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 mutations = List.copyOf(staged.values()); + List 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 prepare( + List mutations) throws MetadataStoreException { + List 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 + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/fs/PosixTransactionalMetadataStore.java b/pki/src/main/java/zeroecho/pki/impl/fs/PosixTransactionalMetadataStore.java new file mode 100644 index 0000000..2c88379 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/fs/PosixTransactionalMetadataStore.java @@ -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. + * + *

      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.

      + * + *

      Instances are thread-safe. Transactions are thread-confined and single-use. + * Closing a snapshot invalidates all records and cursors it issued.

      + */ +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")); + } +} diff --git a/pki/src/main/java/zeroecho/pki/spi/crypto/SignatureWorkflow.java b/pki/src/main/java/zeroecho/pki/spi/crypto/SignatureWorkflow.java index 38db48f..e2e8e50 100644 --- a/pki/src/main/java/zeroecho/pki/spi/crypto/SignatureWorkflow.java +++ b/pki/src/main/java/zeroecho/pki/spi/crypto/SignatureWorkflow.java @@ -47,6 +47,8 @@ import java.util.Optional; import java.util.Set; import java.util.function.Consumer; +import zeroecho.core.io.CancellationSignal; +import zeroecho.core.io.RepeatableContent; import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.Encoding; import zeroecho.pki.api.KeyRef; @@ -66,7 +68,7 @@ import zeroecho.pki.api.audit.AccessContext; *

      Trust boundary

      *
        *
      • PKI (caller): 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.
      • *
      • Provider (callee): resolves {@code KeyRef} into an * internal runtime key handle, enforces policy (including multi-hop approvals), @@ -269,13 +271,14 @@ public interface SignatureWorkflow extends Closeable { * {@code null}) * @param algorithmId requested signature algorithm id (never * blank) - * @param payload payload bytes (never {@code null}) + * @param content immutable repeatable content * @param preferredSignatureEncoding preferred signature encoding (optional) * @param deadline optional absolute deadline */ record SignRequest(PkiId submissionId, String namespace, String semanticFingerprint, long fencingToken, - AccessContext accessContext, KeyRef keyRef, String algorithmId, EncodedObject payload, - Optional preferredSignatureEncoding, Optional deadline) { + AccessContext accessContext, KeyRef keyRef, String algorithmId, RepeatableContent content, + Optional preferredSignatureEncoding, Optional deadline, + CancellationSignal cancellation) { private static final long MIN_FENCING_TOKEN = 1L; @@ -286,16 +289,17 @@ public interface SignatureWorkflow extends Closeable { Objects.requireNonNull(accessContext, "accessContext"); Objects.requireNonNull(keyRef, "keyRef"); Objects.requireNonNull(algorithmId, "algorithmId"); - Objects.requireNonNull(payload, "payload"); + Objects.requireNonNull(content, "content"); Objects.requireNonNull(preferredSignatureEncoding, "preferredSignatureEncoding"); Objects.requireNonNull(deadline, "deadline"); + Objects.requireNonNull(cancellation, "cancellation"); if (algorithmId.isBlank()) { throw new IllegalArgumentException("algorithmId must not be blank"); } if (fencingToken < MIN_FENCING_TOKEN) { throw new IllegalArgumentException("fencingToken must be positive"); } - String expected = fingerprint(namespace, accessContext, keyRef, algorithmId, payload, + String expected = fingerprint(namespace, accessContext, keyRef, algorithmId, content, preferredSignatureEncoding, deadline); if (!constantTimeFingerprintEquals(expected, semanticFingerprint)) { throw new IllegalArgumentException("semanticFingerprint does not match signing request"); @@ -306,12 +310,12 @@ public interface SignatureWorkflow extends Closeable { * Creates a request with its canonical versioned fingerprint. */ public static SignRequest create(PkiId submissionId, String namespace, long fencingToken, - AccessContext accessContext, KeyRef keyRef, String algorithmId, EncodedObject payload, + AccessContext accessContext, KeyRef keyRef, String algorithmId, RepeatableContent content, Optional preferredSignatureEncoding, Optional deadline) { - String fingerprint = fingerprint(namespace, accessContext, keyRef, algorithmId, payload, + String fingerprint = fingerprint(namespace, accessContext, keyRef, algorithmId, content, preferredSignatureEncoding, deadline); return new SignRequest(submissionId, namespace, fingerprint, fencingToken, accessContext, keyRef, - algorithmId, payload, preferredSignatureEncoding, deadline); + algorithmId, content, preferredSignatureEncoding, deadline, CancellationSignal.NONE); } /** @@ -320,17 +324,17 @@ public interface SignatureWorkflow extends Closeable { * constant-size digest state. */ public static String fingerprint(String namespace, AccessContext accessContext, KeyRef keyRef, - String algorithmId, EncodedObject payload, Optional preferredSignatureEncoding, + String algorithmId, RepeatableContent content, Optional preferredSignatureEncoding, Optional deadline) { Objects.requireNonNull(namespace, "namespace"); Objects.requireNonNull(accessContext, "accessContext"); Objects.requireNonNull(keyRef, "keyRef"); Objects.requireNonNull(algorithmId, "algorithmId"); - Objects.requireNonNull(payload, "payload"); + Objects.requireNonNull(content, "content"); Objects.requireNonNull(preferredSignatureEncoding, "preferredSignatureEncoding"); Objects.requireNonNull(deadline, "deadline"); try { - return fingerprintWithDigest(namespace, accessContext, keyRef, algorithmId, payload, + return fingerprintWithDigest(namespace, accessContext, keyRef, algorithmId, content, preferredSignatureEncoding, deadline, MessageDigest.getInstance("SHA-256"), ignored -> { }); } catch (NoSuchAlgorithmException ex) { @@ -339,16 +343,15 @@ public interface SignatureWorkflow extends Closeable { } /* default */ static String fingerprintWithDigest(String namespace, AccessContext accessContext, KeyRef keyRef, - String algorithmId, EncodedObject payload, Optional preferredSignatureEncoding, + String algorithmId, RepeatableContent content, Optional preferredSignatureEncoding, Optional deadline, MessageDigest digest, Consumer cleanupObserver) { Objects.requireNonNull(digest, "digest"); Objects.requireNonNull(cleanupObserver, "cleanupObserver"); - byte[] payloadBytes = payload.bytes(); byte[] digestBytes = null; try { try (DataOutputStream output = new DataOutputStream( new DigestOutputStream(OutputStream.nullOutputStream(), digest))) { - output.writeUTF("sign-request-v1"); + output.writeUTF("sign-request-v2"); output.writeUTF(namespace); output.writeUTF(accessContext.principal().type()); output.writeUTF(accessContext.principal().name()); @@ -357,9 +360,8 @@ public interface SignatureWorkflow extends Closeable { output.writeUTF(accessContext.formatId().map(zeroecho.pki.api.FormatId::value).orElse("")); output.writeUTF(keyRef.value()); output.writeUTF(algorithmId); - output.writeUTF(payload.encoding().name()); - output.writeInt(payloadBytes.length); - output.write(payloadBytes); + output.writeUTF(content.contentId()); + output.writeLong(content.length().orElse(-1L)); output.writeUTF(preferredSignatureEncoding.map(Enum::name).orElse("")); output.writeUTF(deadline.map(Instant::toString).orElse("")); } @@ -368,7 +370,6 @@ public interface SignatureWorkflow extends Closeable { } catch (IOException ex) { throw new IllegalStateException("Unable to compute signing request fingerprint"); } finally { - clearOwned(payloadBytes, cleanupObserver); clearOwned(digestBytes, cleanupObserver); } } @@ -405,25 +406,26 @@ public interface SignatureWorkflow extends Closeable { * * @param accessContext audit/governance context (never {@code null}) * @param algorithmId requested signature algorithm id (never blank) - * @param payload signed payload bytes (never {@code null}) + * @param content immutable repeatable signed content * @param signature signature bytes (never {@code null}) * @param publicKeyRef optional public key reference (preferred) * @param publicKeyEncoded optional encoded public key bytes (provider-specific; * may be unsupported) * @param deadline optional absolute deadline */ - record VerifyRequest(AccessContext accessContext, String algorithmId, EncodedObject payload, + record VerifyRequest(AccessContext accessContext, String algorithmId, RepeatableContent content, EncodedObject signature, Optional publicKeyRef, Optional publicKeyEncoded, - Optional deadline) { + Optional deadline, CancellationSignal cancellation) { public VerifyRequest { Objects.requireNonNull(accessContext, "accessContext"); Objects.requireNonNull(algorithmId, "algorithmId"); - Objects.requireNonNull(payload, "payload"); + Objects.requireNonNull(content, "content"); Objects.requireNonNull(signature, "signature"); Objects.requireNonNull(publicKeyRef, "publicKeyRef"); Objects.requireNonNull(publicKeyEncoded, "publicKeyEncoded"); Objects.requireNonNull(deadline, "deadline"); + Objects.requireNonNull(cancellation, "cancellation"); if (algorithmId.isBlank()) { throw new IllegalArgumentException("algorithmId must not be blank"); } diff --git a/pki/src/main/java/zeroecho/pki/spi/framework/CredentialIssuerBackend.java b/pki/src/main/java/zeroecho/pki/spi/framework/CredentialIssuerBackend.java index 115870e..53a9320 100644 --- a/pki/src/main/java/zeroecho/pki/spi/framework/CredentialIssuerBackend.java +++ b/pki/src/main/java/zeroecho/pki/spi/framework/CredentialIssuerBackend.java @@ -35,8 +35,8 @@ package zeroecho.pki.spi.framework; import java.math.BigInteger; -import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.KeyRef; +import zeroecho.pki.api.content.DurableContentReference; import zeroecho.pki.api.credential.Credential; import zeroecho.pki.api.credential.CredentialBundle; import zeroecho.pki.impl.core.ValidatedCaCertificateRequest; @@ -140,7 +140,7 @@ public interface CredentialIssuerBackend { * or other framework-specific issuance * processing fails */ - CredentialBundle issueEndEntity(ValidatedCertificateRequest request, EncodedObject issuerCertificate, + CredentialBundle issueEndEntity(ValidatedCertificateRequest request, DurableContentReference issuerCertificate, KeyRef issuerKeyRef, BigInteger serial); /** @@ -171,6 +171,7 @@ public interface CredentialIssuerBackend { * or other framework-specific issuance * processing fails */ - Credential issueIntermediateCertificate(ValidatedCaCertificateRequest request, EncodedObject issuerCertificate, + Credential issueIntermediateCertificate(ValidatedCaCertificateRequest request, + DurableContentReference issuerCertificate, KeyRef issuerKeyRef); } diff --git a/pki/src/main/java/zeroecho/pki/spi/framework/CrlEntrySource.java b/pki/src/main/java/zeroecho/pki/spi/framework/CrlEntrySource.java new file mode 100644 index 0000000..43d1022 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/spi/framework/CrlEntrySource.java @@ -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. + * + *

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

        + */ +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; + } +} diff --git a/pki/src/main/java/zeroecho/pki/spi/framework/StatusObjectGenerator.java b/pki/src/main/java/zeroecho/pki/spi/framework/StatusObjectGenerator.java index fe320d0..d968957 100644 --- a/pki/src/main/java/zeroecho/pki/spi/framework/StatusObjectGenerator.java +++ b/pki/src/main/java/zeroecho/pki/spi/framework/StatusObjectGenerator.java @@ -33,10 +33,8 @@ ******************************************************************************/ package zeroecho.pki.spi.framework; -import java.util.List; - -import zeroecho.pki.api.status.StatusObject; import zeroecho.pki.api.status.StatusObjectGenerateCommand; +import zeroecho.pki.impl.framework.x509.X509SignedObjectCompletion; /** * Generates status objects for a credential framework (e.g., CRL/delta CRL/OCSP @@ -49,11 +47,11 @@ public interface StatusObjectGenerator { * Generates a status object. * * @param command generation command - * @param crlEntries structured CRL entries; empty for non-CRL objects + * @param crlEntries stable streamed CRL entries * @return generated status object * @throws IllegalArgumentException if {@code command} or {@code crlEntries} is * invalid * @throws RuntimeException if generation fails */ - StatusObject generate(StatusObjectGenerateCommand command, List crlEntries); + X509SignedObjectCompletion generate(StatusObjectGenerateCommand command, CrlEntrySource crlEntries); } diff --git a/pki/src/main/java/zeroecho/pki/spi/publish/Publisher.java b/pki/src/main/java/zeroecho/pki/spi/publish/Publisher.java index f1298f9..bbf6b22 100644 --- a/pki/src/main/java/zeroecho/pki/spi/publish/Publisher.java +++ b/pki/src/main/java/zeroecho/pki/spi/publish/Publisher.java @@ -33,7 +33,7 @@ ******************************************************************************/ package zeroecho.pki.spi.publish; -import zeroecho.pki.api.EncodedObject; +import zeroecho.core.io.RepeatableContent; import zeroecho.pki.api.publication.PublicationTarget; /** @@ -50,5 +50,5 @@ public interface Publisher { * @throws IllegalArgumentException if inputs are null * @throws RuntimeException if publishing fails */ - void publish(PublicationTarget target, EncodedObject payload); + void publish(PublicationTarget target, RepeatableContent payload); } diff --git a/pki/src/main/java/zeroecho/pki/spi/store/ContentSink.java b/pki/src/main/java/zeroecho/pki/spi/store/ContentSink.java new file mode 100644 index 0000000..5a790cc --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/spi/store/ContentSink.java @@ -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. + * + *

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

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

        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.

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

        This category is a definitive non-commit. It is neither an + * uncertain outcome nor a storage failure.

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

        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 next(CancellationSignal cancellation) throws IOException; + + /** + * Closes this cursor idempotently. + */ + @Override + void close(); +} diff --git a/pki/src/main/java/zeroecho/pki/spi/store/MetadataKey.java b/pki/src/main/java/zeroecho/pki/spi/store/MetadataKey.java new file mode 100644 index 0000000..78333b5 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/spi/store/MetadataKey.java @@ -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. + * + *

        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.

        + * + * @param namespace canonical extension namespace + * @param key exact logical key + */ +public record MetadataKey(String namespace, String key) implements Comparable { + 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 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"); + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/spi/store/MetadataSnapshot.java b/pki/src/main/java/zeroecho/pki/spi/store/MetadataSnapshot.java new file mode 100644 index 0000000..53f9a95 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/spi/store/MetadataSnapshot.java @@ -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. + * + *

        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(); + } + + /** + * 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 lowerInclusive, + Optional 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 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(); +} diff --git a/pki/src/main/java/zeroecho/pki/spi/store/MetadataStoreCapabilities.java b/pki/src/main/java/zeroecho/pki/spi/store/MetadataStoreCapabilities.java new file mode 100644 index 0000000..60b06d7 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/spi/store/MetadataStoreCapabilities.java @@ -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. + * + *

        Atomic transactions, conflict detection, outcome resolution, stable + * snapshots, deterministic scans and synchronous content detachment are + * intrinsic store requirements. Only optional adapter properties appear here.

        + * + * @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 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"); + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/spi/store/MetadataStoreException.java b/pki/src/main/java/zeroecho/pki/spi/store/MetadataStoreException.java new file mode 100644 index 0000000..3ff74e3 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/spi/store/MetadataStoreException.java @@ -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; + } +} diff --git a/pki/src/main/java/zeroecho/pki/spi/store/MetadataStoreId.java b/pki/src/main/java/zeroecho/pki/spi/store/MetadataStoreId.java new file mode 100644 index 0000000..7d1c7a8 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/spi/store/MetadataStoreId.java @@ -0,0 +1,27 @@ +package zeroecho.pki.spi.store; + +import java.util.Objects; + +/** + * Stable provider-neutral metadata-store identity. + * + *

        Possession of an equal value does not grant live store authority.

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

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

        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; +} diff --git a/pki/src/main/java/zeroecho/pki/spi/store/MetadataTransactionId.java b/pki/src/main/java/zeroecho/pki/spi/store/MetadataTransactionId.java new file mode 100644 index 0000000..8120eba --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/spi/store/MetadataTransactionId.java @@ -0,0 +1,53 @@ +package zeroecho.pki.spi.store; + +import java.util.Objects; + +/** + * Canonical durable transaction identity issued by one metadata store. + * + *

        Constructing an equal value does not prove that the owning store issued it.

        + * + * @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; + } +} diff --git a/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java b/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java index b1cd843..baf1ea2 100644 --- a/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java +++ b/pki/src/main/java/zeroecho/pki/spi/store/PkiStore.java @@ -76,6 +76,13 @@ import zeroecho.pki.api.status.StatusObject; */ public interface PkiStore extends SignWorkflowStore { + /** + * Returns the runtime-owned durable streaming-content store. + * + * @return staged-content store used by this PKI store + */ + StagedContentStore stagedContent(); + /** * Persists or updates a Certificate Authority (CA) record. * @@ -175,11 +182,12 @@ public interface PkiStore extends SignWorkflowStore { Optional 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 listRevocationJournals(); + RevocationSnapshot openRevocationSnapshot(); /** * Persists a status object. diff --git a/pki/src/main/java/zeroecho/pki/spi/store/RevocationSnapshot.java b/pki/src/main/java/zeroecho/pki/spi/store/RevocationSnapshot.java new file mode 100644 index 0000000..73fe041 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/spi/store/RevocationSnapshot.java @@ -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. + * + *

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

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

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

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

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

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

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

        + * + * @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; +} diff --git a/pki/src/main/java/zeroecho/pki/spi/store/TemporaryUniqueIndex.java b/pki/src/main/java/zeroecho/pki/spi/store/TemporaryUniqueIndex.java new file mode 100644 index 0000000..1cae919 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/spi/store/TemporaryUniqueIndex.java @@ -0,0 +1,95 @@ +/******************************************************************************* + * 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; + +/** + * Runtime-owned file-backed uniqueness index for bounded individual values. + * + *

        + * Implementations retain no aggregate in heap. The index exists only for one + * operation and is removed deterministically on close. + *

        + */ +public interface TemporaryUniqueIndex extends AutoCloseable { + + /** + * Adds one immutable bounded value. + * + * @param value exact value + * @return {@code true} when newly added, {@code false} when already present + * @throws IOException if the index cannot be updated + */ + boolean add(byte[] value) throws IOException; + + /** + * Tests whether an exact bounded value is present. + * + * @param value exact value + * @return {@code true} when present + * @throws IOException if the index cannot be read + */ + boolean contains(byte[] value) throws IOException; + + /** + * Removes one exact bounded value after validating its physical record. + * + * @param value exact value + * @return {@code true} when removed, {@code false} when absent + * @throws IOException if the namespace or record is invalid, or removal fails + */ + boolean remove(byte[] value) throws IOException; + + /** + * Validates every committed entry before the namespace is trusted. + * + *

        + * Validation rejects unknown files, non-regular entries, malformed records, + * and records whose complete semantic value does not derive their physical + * key. The index-local lock serializes validation with mutation. + *

        + * + * @throws IOException if any namespace entry is invalid + */ + void validateNamespace() throws IOException; + + /** + * Removes all index storage. + * + * @throws IOException if cleanup fails + */ + @Override + void close() throws IOException; +} diff --git a/pki/src/main/java/zeroecho/pki/spi/store/TransactionalMetadataStore.java b/pki/src/main/java/zeroecho/pki/spi/store/TransactionalMetadataStore.java new file mode 100644 index 0000000..ba5ec38 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/spi/store/TransactionalMetadataStore.java @@ -0,0 +1,93 @@ +/* + * 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; + +/** + * Provider-neutral authority for finite transactional control metadata. + * + *

        The store supports atomic mutations across namespaces, retained commit + * outcomes, stable snapshots, and streaming iteration. It does not store + * aggregate certificate, CRL, or staged-content payloads and exposes no + * filesystem, database, or PKI-domain concepts. + */ +public interface TransactionalMetadataStore extends AutoCloseable { + + /** + * Returns this durable store authority identity. + * + * @return store identity + */ + MetadataStoreId id(); + + /** + * Returns immutable adapter capabilities. + * + * @return capability descriptor + */ + MetadataStoreCapabilities capabilities(); + + /** + * Begins a single-use store-issued transaction. + * + * @return live transaction bound to this store instance + * @throws IOException when durable transaction issuance fails + * @throws MetadataStoreException when required capabilities or lifecycle are invalid + */ + MetadataTransaction beginTransaction() throws IOException; + + /** + * Resolves a transaction outcome retained by this store. + * + *

        {@link MetadataCommitResult.Outcome#UNKNOWN} does not imply commit or + * rollback and must be resolved through the owning store. A syntactically + * valid but unissued identity fails closed rather than being treated as + * authoritative {@code NOT_COMMITTED} evidence. + * + * @param transactionId store-issued transaction identity + * @return retained authoritative result, or still-unknown observation + * @throws IOException when resolution storage cannot be read + * @throws MetadataStoreException for foreign, malformed, or unissued identities + */ + MetadataCommitResult resolve(MetadataTransactionId transactionId) throws IOException; + + /** + * Acknowledges a terminal retained outcome when supported. + * + * @param transactionId store-issued transaction identity + * @throws IOException when outcome retention cannot be updated + * @throws MetadataStoreException for foreign, unissued, or non-terminal identities + */ + void acknowledge(MetadataTransactionId transactionId) throws IOException; + + /** + * Opens a stable snapshot of the latest committed store revision. + * + * @return live snapshot bound to this store instance + * @throws IOException when the snapshot cannot be established + * @throws MetadataStoreException when the store is closed + */ + MetadataSnapshot snapshot() throws IOException; + + /** + * Closes the store idempotently. + * + * @throws IOException when store-owned resources cannot be retired + */ + @Override + void close() throws IOException; +} diff --git a/pki/src/main/resources/META-INF/services/zeroecho.core.spi.AlgorithmExecutionCapabilityProvider b/pki/src/main/resources/META-INF/services/zeroecho.core.spi.AlgorithmExecutionCapabilityProvider new file mode 100644 index 0000000..5e3e6ce --- /dev/null +++ b/pki/src/main/resources/META-INF/services/zeroecho.core.spi.AlgorithmExecutionCapabilityProvider @@ -0,0 +1 @@ +zeroecho.pki.impl.crypto.zeroecholib.ZeroEchoLibSignatureWorkflowProvider diff --git a/pki/src/test/java/zeroecho/pki/e2e/CaProfileIssuanceEnforcementTest.java b/pki/src/test/java/zeroecho/pki/e2e/CaProfileIssuanceEnforcementTest.java index 014220d..326218d 100644 --- a/pki/src/test/java/zeroecho/pki/e2e/CaProfileIssuanceEnforcementTest.java +++ b/pki/src/test/java/zeroecho/pki/e2e/CaProfileIssuanceEnforcementTest.java @@ -131,8 +131,8 @@ final class CaProfileIssuanceEnforcementTest { CaRecord intermediate = runtime.caService().getCa(intermediateId); Credential credential = intermediate.caCredentials().get(0); - X509CertificateHolder holder = new X509CertificateHolder(credential.encoded().bytes()); - X509CertificateHolder additionalHolder = new X509CertificateHolder(additional.encoded().bytes()); + X509CertificateHolder holder = new X509CertificateHolder(runtime.credentialBytes(credential)); + X509CertificateHolder additionalHolder = new X509CertificateHolder(runtime.credentialBytes(additional)); assertEquals("Fixed Organization", holder.getSubject().getRDNs(BCStyle.O)[0].getFirst().getValue().toString()); assertEquals("Fixed Organization", @@ -170,8 +170,8 @@ final class CaProfileIssuanceEnforcementTest { rootProfile = ((CaProfileBinding) runtime.caService().getCa(rootId).caCredentials().get(0).profileBinding()) .reference(); intermediateProfile = ((CaProfileBinding) additional.profileBinding()).reference(); - assertCaCertificate(runtime.caService().getCa(rootId).caCredentials().get(0), 1); - assertCaCertificate(additional, 0); + assertCaCertificate(runtime, runtime.caService().getCa(rootId).caCredentials().get(0), 1); + assertCaCertificate(runtime, additional, 0); } try (PkiTestRuntime reopened = PkiTestRuntime.create(store, directory.resolve("reopened-bus.log"), Map.of(rootRef, rootKey, intermediateRef, intermediateKey))) { @@ -261,7 +261,7 @@ final class CaProfileIssuanceEnforcementTest { caA = createRoot(runtime, keyRefA, "CN=Version Switch Root A", profileId); Credential issuedA = onlyCredential(runtime.caService(), caA); - assertCaProfileCredential("A", issuedA, versionOne, 1); + assertCaProfileCredential(runtime, "A", issuedA, versionOne, 1); credentialA = issuedA.credentialId(); ImportedCertificateProfileVersion storedOne = runtime.profileService().getImportedVersion(profileId, 1) @@ -277,18 +277,18 @@ final class CaProfileIssuanceEnforcementTest { caB = createRoot(runtime, keyRefB, "CN=Version Switch Root B", profileId); Credential issuedB = onlyCredential(runtime.caService(), caB); - assertCaProfileCredential("B", issuedB, versionOne, 1); + assertCaProfileCredential(runtime, "B", issuedB, versionOne, 1); credentialB = issuedB.credentialId(); assertEquals(versionTwo, runtime.profileService().activateProfile(profileId, 2)); caC = createRoot(runtime, keyRefC, "CN=Version Switch Root C", profileId); Credential issuedC = onlyCredential(runtime.caService(), caC); - assertCaProfileCredential("C", issuedC, versionTwo, 2); + assertCaProfileCredential(runtime, "C", issuedC, versionTwo, 2); credentialC = issuedC.credentialId(); - assertCaProfileCredential("A-reread", runtime.store().getCredential(credentialA).orElseThrow(), versionOne, + assertCaProfileCredential(runtime, "A-reread", runtime.store().getCredential(credentialA).orElseThrow(), versionOne, 1); - assertCaProfileCredential("B-reread", runtime.store().getCredential(credentialB).orElseThrow(), versionOne, + assertCaProfileCredential(runtime, "B-reread", runtime.store().getCredential(credentialB).orElseThrow(), versionOne, 1); ImportedCertificateProfileVersion unchanged = runtime.profileService().getImportedVersion(profileId, 1) .orElseThrow(); @@ -298,11 +298,11 @@ final class CaProfileIssuanceEnforcementTest { try (PkiTestRuntime reopened = PkiTestRuntime.create(store, directory.resolve("reopened-bus.log"), keys)) { assertEquals(versionTwo, reopened.profileService().getActiveReference(profileId).orElseThrow()); - assertCaProfileCredential("A-restart", reopened.store().getCredential(credentialA).orElseThrow(), + assertCaProfileCredential(reopened, "A-restart", reopened.store().getCredential(credentialA).orElseThrow(), versionOne, 1); - assertCaProfileCredential("B-restart", reopened.store().getCredential(credentialB).orElseThrow(), + assertCaProfileCredential(reopened, "B-restart", reopened.store().getCredential(credentialB).orElseThrow(), versionOne, 1); - assertCaProfileCredential("C-restart", reopened.store().getCredential(credentialC).orElseThrow(), + assertCaProfileCredential(reopened, "C-restart", reopened.store().getCredential(credentialC).orElseThrow(), versionTwo, 2); ImportedCertificateProfileVersion unchanged = reopened.profileService().getImportedVersion(profileId, 1) .orElseThrow(); @@ -348,7 +348,7 @@ final class CaProfileIssuanceEnforcementTest { runtime.profileService().activateProfile(rootProfileId, 1); rootId = createRoot(runtime, rootKeyRef, "CN=Version Switch Issuer Root", rootProfileId); Credential rootCredential = onlyCredential(runtime.caService(), rootId); - assertCaProfileCredential("issuer", rootCredential, rootProfile, 2); + assertCaProfileCredential(runtime, "issuer", rootCredential, rootProfile, 2); versionOne = runtime.profileService().importProfile(versionOneDocument); assertEquals(intermediateProfileId, versionOne.profileId()); @@ -358,9 +358,9 @@ final class CaProfileIssuanceEnforcementTest { caA = createIntermediate(runtime, rootId, keyRefA, "CN=Version Switch Intermediate A", intermediateProfileId); Credential issuedA = onlyCredential(runtime.caService(), caA); - assertCaProfileCredential("A", issuedA, versionOne, 0); + assertCaProfileCredential(runtime, "A", issuedA, versionOne, 0); credentialA = issuedA.credentialId(); - assertCaProfileCredential("issuer-after-A", onlyCredential(runtime.caService(), rootId), rootProfile, 2); + assertCaProfileCredential(runtime, "issuer-after-A", onlyCredential(runtime.caService(), rootId), rootProfile, 2); ImportedCertificateProfileVersion storedOne = runtime.profileService() .getImportedVersion(intermediateProfileId, 1).orElseThrow(); @@ -376,21 +376,21 @@ final class CaProfileIssuanceEnforcementTest { caB = createIntermediate(runtime, rootId, keyRefB, "CN=Version Switch Intermediate B", intermediateProfileId); Credential issuedB = onlyCredential(runtime.caService(), caB); - assertCaProfileCredential("B", issuedB, versionOne, 0); + assertCaProfileCredential(runtime, "B", issuedB, versionOne, 0); credentialB = issuedB.credentialId(); assertEquals(versionTwo, runtime.profileService().activateProfile(intermediateProfileId, 2)); caC = createIntermediate(runtime, rootId, keyRefC, "CN=Version Switch Intermediate C", intermediateProfileId); Credential issuedC = onlyCredential(runtime.caService(), caC); - assertCaProfileCredential("C", issuedC, versionTwo, 1); + assertCaProfileCredential(runtime, "C", issuedC, versionTwo, 1); credentialC = issuedC.credentialId(); - assertCaProfileCredential("A-reread", runtime.store().getCredential(credentialA).orElseThrow(), versionOne, + assertCaProfileCredential(runtime, "A-reread", runtime.store().getCredential(credentialA).orElseThrow(), versionOne, 0); - assertCaProfileCredential("B-reread", runtime.store().getCredential(credentialB).orElseThrow(), versionOne, + assertCaProfileCredential(runtime, "B-reread", runtime.store().getCredential(credentialB).orElseThrow(), versionOne, 0); - assertCaProfileCredential("issuer-reread", onlyCredential(runtime.caService(), rootId), rootProfile, 2); + assertCaProfileCredential(runtime, "issuer-reread", onlyCredential(runtime.caService(), rootId), rootProfile, 2); ImportedCertificateProfileVersion unchanged = runtime.profileService() .getImportedVersion(intermediateProfileId, 1).orElseThrow(); assertArrayEquals(persistedVersionOne, unchanged.canonicalJson()); @@ -399,13 +399,14 @@ final class CaProfileIssuanceEnforcementTest { try (PkiTestRuntime reopened = PkiTestRuntime.create(store, directory.resolve("reopened-bus.log"), keys)) { assertEquals(versionTwo, reopened.profileService().getActiveReference(intermediateProfileId).orElseThrow()); - assertCaProfileCredential("A-restart", reopened.store().getCredential(credentialA).orElseThrow(), + assertCaProfileCredential(reopened, "A-restart", reopened.store().getCredential(credentialA).orElseThrow(), versionOne, 0); - assertCaProfileCredential("B-restart", reopened.store().getCredential(credentialB).orElseThrow(), + assertCaProfileCredential(reopened, "B-restart", reopened.store().getCredential(credentialB).orElseThrow(), versionOne, 0); - assertCaProfileCredential("C-restart", reopened.store().getCredential(credentialC).orElseThrow(), + assertCaProfileCredential(reopened, "C-restart", reopened.store().getCredential(credentialC).orElseThrow(), versionTwo, 1); - assertCaProfileCredential("issuer-restart", onlyCredential(reopened.caService(), rootId), rootProfile, 2); + assertCaProfileCredential(reopened, "issuer-restart", onlyCredential(reopened.caService(), rootId), + rootProfile, 2); ImportedCertificateProfileVersion unchanged = reopened.profileService() .getImportedVersion(intermediateProfileId, 1).orElseThrow(); assertArrayEquals(persistedVersionOne, unchanged.canonicalJson()); @@ -431,7 +432,7 @@ final class CaProfileIssuanceEnforcementTest { Credential original = root.caCredentials().get(0); Credential mutated = new Credential(original.credentialId(), original.formatId(), original.issuerRef(), original.subjectRef(), original.validity(), original.serialOrUniqueId(), original.publicKeyId(), - new CaProfileBinding(wrongFormat), original.status(), original.encoded(), original.attributes()); + new CaProfileBinding(wrongFormat), original.status(), original.content(), original.attributes()); runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(), root.subjectRef(), List.of(mutated))); @@ -490,7 +491,7 @@ final class CaProfileIssuanceEnforcementTest { service.issueIntermediateCertificate(new IntermediateCertIssueCommand(runtime.framework().formatId(), rootId, intermediateId, "intermediate-ca", Optional.empty(), new SimpleAttributeSet())); profiles.assertAndReset("intermediate-ca"); - rootCertificate = service.getCa(rootId).caCredentials().get(0).encoded().bytes(); + rootCertificate = runtime.credentialBytes(service.getCa(rootId).caCredentials().get(0)); } try (PkiTestRuntime target = PkiTestRuntime.create(directory.resolve("import"), directory.resolve("import-bus.log"), Map.of(rootRef, rootKey))) { @@ -498,7 +499,7 @@ final class CaProfileIssuanceEnforcementTest { CountingProfileService profiles = new CountingProfileService(target.profileService()); CaService service = target.caService(profiles); service.importRoot(new CaImportCommand(target.framework().formatId(), new SubjectRef("CN=Lookup Root"), - "root-ca", rootRef, new EncodedObject(Encoding.DER, rootCertificate), new SimpleAttributeSet())); + "root-ca", rootRef, target.stageCredential(rootCertificate), new SimpleAttributeSet())); profiles.assertAndReset("root-ca"); } } @@ -557,13 +558,13 @@ final class CaProfileIssuanceEnforcementTest { directory.resolve("source-bus.log"), Map.of(rootRef, rootKey))) { PkiId rootId = source.caService().createRoot(new CaCreateCommand(source.framework().formatId(), new SubjectRef("CN=Imported Root"), "root-ca", Optional.of(rootRef), new SimpleAttributeSet())); - encoded = source.caService().getCa(rootId).caCredentials().get(0).encoded().bytes(); + encoded = source.credentialBytes(source.caService().getCa(rootId).caCredentials().get(0)); } try (PkiTestRuntime target = PkiTestRuntime.create(directory.resolve("target"), directory.resolve("target-bus.log"), Map.of(rootRef, rootKey))) { PkiId imported = target.caService() .importRoot(new CaImportCommand(target.framework().formatId(), new SubjectRef("CN=Imported Root"), - "root-ca", rootRef, new EncodedObject(Encoding.DER, encoded), new SimpleAttributeSet())); + "root-ca", rootRef, target.stageCredential(encoded), new SimpleAttributeSet())); Credential credential = target.caService().getCa(imported).caCredentials().get(0); assertEquals(target.profileService().getActiveReference("root-ca").orElseThrow(), ((CaProfileBinding) credential.profileBinding()).reference()); @@ -583,7 +584,7 @@ final class CaProfileIssuanceEnforcementTest { new SubjectRef("CN=Import Mutation Root"), "root-ca", Optional.of(rootRef), new SimpleAttributeSet())); Credential sourceCredential = source.caService().getCa(rootId).caCredentials().get(0); - encoded = mutation.mutate(sourceCredential, rootKey); + encoded = mutation.mutate(source, sourceCredential, rootKey); } try (PkiTestRuntime target = PkiTestRuntime.create(directory.resolve("target"), directory.resolve("target-bus.log"), Map.of(rootRef, rootKey))) { @@ -610,14 +611,14 @@ final class CaProfileIssuanceEnforcementTest { CredentialIssuerBackend backend = new CredentialIssuerBackend() { @Override public CredentialBundle issueEndEntity(ValidatedCertificateRequest request, - EncodedObject issuerCertificate, KeyRef issuerKeyRef, java.math.BigInteger serial) { + zeroecho.pki.api.content.DurableContentReference issuerCertificate, KeyRef issuerKeyRef, java.math.BigInteger serial) { return delegate.issueEndEntity(request, issuerCertificate, issuerKeyRef, serial); } @Override public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest request, - EncodedObject issuerCertificate, KeyRef issuerKeyRef) { - return mutation.apply( + zeroecho.pki.api.content.DurableContentReference issuerCertificate, KeyRef issuerKeyRef) { + return mutation.apply(runtime, delegate.issueIntermediateCertificate(request, issuerCertificate, issuerKeyRef), rootKey); } }; @@ -630,8 +631,9 @@ final class CaProfileIssuanceEnforcementTest { } } - private static void assertCaCertificate(Credential credential, int pathLength) throws Exception { - X509CertificateHolder holder = new X509CertificateHolder(credential.encoded().bytes()); + private static void assertCaCertificate(PkiTestRuntime runtime, Credential credential, int pathLength) + throws Exception { + X509CertificateHolder holder = new X509CertificateHolder(runtime.credentialBytes(credential)); BasicConstraints constraints = BasicConstraints .getInstance(holder.getExtension(Extension.basicConstraints).getParsedValue()); KeyUsage usage = KeyUsage.getInstance(holder.getExtension(Extension.keyUsage).getParsedValue()); @@ -642,11 +644,11 @@ final class CaProfileIssuanceEnforcementTest { assertEquals(2, holder.getExtensions().getExtensionOIDs().length); } - private static void assertCaProfileCredential(String label, Credential credential, + private static void assertCaProfileCredential(PkiTestRuntime runtime, String label, Credential credential, CertificateProfileRef expectedProfile, int pathLength) throws Exception { assertTrue(credential.profileBinding() instanceof CaProfileBinding); assertEquals(expectedProfile, ((CaProfileBinding) credential.profileBinding()).reference()); - assertCaCertificate(credential, pathLength); + assertCaCertificate(runtime, credential, pathLength); System.out.println("..." + label + " profileVersion=" + expectedProfile.profileVersion() + " hash=" + abbreviatedHash(expectedProfile) + " credentialId=" + credential.credentialId().value() + " pathLength=" + pathLength); @@ -761,79 +763,79 @@ final class CaProfileIssuanceEnforcementTest { private enum MetadataMutation { PROFILE { @Override - Credential apply(Credential value, KeyPair issuerKey) { + Credential apply(PkiTestRuntime runtime, Credential value, KeyPair issuerKey) { return copy(value, value.serialOrUniqueId(), value.validity(), new CaProfileBinding(new CertificateProfileRef("other", 1, new byte[32]))); } }, SERIAL { @Override - Credential apply(Credential value, KeyPair issuerKey) { + Credential apply(PkiTestRuntime runtime, Credential value, KeyPair issuerKey) { return copy(value, value.serialOrUniqueId() + "1", value.validity(), value.profileBinding()); } }, VALIDITY { @Override - Credential apply(Credential value, KeyPair issuerKey) { + Credential apply(PkiTestRuntime runtime, Credential value, KeyPair issuerKey) { return copy(value, value.serialOrUniqueId(), new zeroecho.pki.api.Validity(value.validity().notBefore(), value.validity().notAfter().minusSeconds(1)), value.profileBinding()); } }, SUBJECT { @Override - Credential apply(Credential value, KeyPair issuerKey) { + Credential apply(PkiTestRuntime runtime, Credential value, KeyPair issuerKey) { return new Credential(value.credentialId(), value.formatId(), value.issuerRef(), new SubjectRef("CN=Substituted"), value.validity(), value.serialOrUniqueId(), - value.publicKeyId(), value.profileBinding(), value.status(), value.encoded(), + value.publicKeyId(), value.profileBinding(), value.status(), value.content(), value.attributes()); } }, ATTRIBUTES { @Override - Credential apply(Credential value, KeyPair issuerKey) { + Credential apply(PkiTestRuntime runtime, Credential value, KeyPair issuerKey) { zeroecho.pki.api.attr.AttributeSet attributes = SimpleAttributeSet.builder() .put(new zeroecho.pki.api.attr.AttributeId("test.unexpected"), new zeroecho.pki.api.attr.AttributeValue.StringValue("unexpected")) .build(); return new Credential(value.credentialId(), value.formatId(), value.issuerRef(), value.subjectRef(), value.validity(), value.serialOrUniqueId(), value.publicKeyId(), value.profileBinding(), - value.status(), value.encoded(), attributes); + value.status(), value.content(), attributes); } }, EXTRA_EXTENSION { @Override - Credential apply(Credential value, KeyPair issuerKey) { - return rebuild(value, issuerKey, null, true); + Credential apply(PkiTestRuntime runtime, Credential value, KeyPair issuerKey) { + return rebuild(runtime, value, issuerKey, null, true); } }, SUBJECT_DER { @Override - Credential apply(Credential value, KeyPair issuerKey) { + Credential apply(PkiTestRuntime runtime, Credential value, KeyPair issuerKey) { X500Name alternate = new X500Name( new RDN[] { new RDN(BCStyle.CN, new DERPrintableString("Mutation Intermediate")) }); - return rebuild(value, issuerKey, alternate, false); + return rebuild(runtime, value, issuerKey, alternate, false); } }; - abstract Credential apply(Credential value, KeyPair issuerKey); + abstract Credential apply(PkiTestRuntime runtime, Credential value, KeyPair issuerKey); private static Credential copy(Credential value, String serial, zeroecho.pki.api.Validity validity, zeroecho.pki.api.credential.CredentialProfileBinding binding) { return new Credential(value.credentialId(), value.formatId(), value.issuerRef(), value.subjectRef(), - validity, serial, value.publicKeyId(), binding, value.status(), value.encoded(), + validity, serial, value.publicKeyId(), binding, value.status(), value.content(), value.attributes()); } - private static Credential rebuild(Credential value, KeyPair issuerKey, X500Name alternateSubject, + private static Credential rebuild(PkiTestRuntime runtime, Credential value, KeyPair issuerKey, X500Name alternateSubject, boolean extraExtension) { - return rebuild(value, issuerKey, alternateSubject, extraExtension, null, null, null); + return rebuild(runtime, value, issuerKey, alternateSubject, extraExtension, null, null, null); } - private static Credential rebuild(Credential value, KeyPair issuerKey, X500Name alternateSubject, + private static Credential rebuild(PkiTestRuntime runtime, Credential value, KeyPair issuerKey, X500Name alternateSubject, boolean extraExtension, BasicConstraints alternateConstraints, KeyUsage alternateKeyUsage, Date alternateNotAfter) { try { - X509CertificateHolder original = new X509CertificateHolder(value.encoded().bytes()); + X509CertificateHolder original = new X509CertificateHolder(runtime.credentialBytes(value)); X500Name subject = alternateSubject == null ? original.getSubject() : alternateSubject; X509v3CertificateBuilder builder = new X509v3CertificateBuilder(original.getIssuer(), original.getSerialNumber(), Date.from(original.getNotBefore().toInstant()), @@ -856,7 +858,7 @@ final class CaProfileIssuanceEnforcementTest { .build(new JcaContentSignerBuilder("SHA256withRSA").build(issuerKey.getPrivate())).getEncoded(); return new Credential(new PkiId("x509:" + sha256(encoded)), value.formatId(), value.issuerRef(), value.subjectRef(), value.validity(), value.serialOrUniqueId(), value.publicKeyId(), - value.profileBinding(), value.status(), new EncodedObject(Encoding.DER, encoded), + value.profileBinding(), value.status(), runtime.stageCredential(encoded), value.attributes()); } catch (Exception exception) { throw new IllegalStateException("test certificate mutation failed", exception); @@ -896,8 +898,9 @@ final class CaProfileIssuanceEnforcementTest { } @Override - byte[] mutate(Credential credential, KeyPair issuerKey) { - return MetadataMutation.rebuild(credential, issuerKey, null, true).encoded().bytes(); + byte[] mutate(PkiTestRuntime runtime, Credential credential, KeyPair issuerKey) { + Credential rebuilt = MetadataMutation.rebuild(runtime, credential, issuerKey, null, true); + return readCredential(runtime, rebuilt); } }, BASIC_CONSTRAINTS { @@ -907,10 +910,10 @@ final class CaProfileIssuanceEnforcementTest { } @Override - byte[] mutate(Credential credential, KeyPair issuerKey) { - return MetadataMutation - .rebuild(credential, issuerKey, null, false, new BasicConstraints(false), null, null).encoded() - .bytes(); + byte[] mutate(PkiTestRuntime runtime, Credential credential, KeyPair issuerKey) { + Credential rebuilt = MetadataMutation.rebuild(runtime, credential, issuerKey, null, false, + new BasicConstraints(false), null, null); + return readCredential(runtime, rebuilt); } }, KEY_USAGE { @@ -920,9 +923,10 @@ final class CaProfileIssuanceEnforcementTest { } @Override - byte[] mutate(Credential credential, KeyPair issuerKey) { - return MetadataMutation.rebuild(credential, issuerKey, null, false, null, - new KeyUsage(KeyUsage.digitalSignature), null).encoded().bytes(); + byte[] mutate(PkiTestRuntime runtime, Credential credential, KeyPair issuerKey) { + Credential rebuilt = MetadataMutation.rebuild(runtime, credential, issuerKey, null, false, null, + new KeyUsage(KeyUsage.digitalSignature), null); + return readCredential(runtime, rebuilt); } }, VALIDITY { @@ -932,23 +936,41 @@ final class CaProfileIssuanceEnforcementTest { } @Override - byte[] mutate(Credential credential, KeyPair issuerKey) { + byte[] mutate(PkiTestRuntime runtime, Credential credential, KeyPair issuerKey) { Date extended = Date.from(credential.validity().notAfter().plusSeconds(1)); - return MetadataMutation.rebuild(credential, issuerKey, null, false, null, null, extended).encoded() - .bytes(); + Credential rebuilt = MetadataMutation.rebuild(runtime, credential, issuerKey, null, false, null, + null, extended); + return readCredential(runtime, rebuilt); } }; abstract CaImportCommand command(PkiTestRuntime runtime, KeyRef keyRef, byte[] encoded); - byte[] mutate(Credential credential, KeyPair issuerKey) { - return credential.encoded().bytes(); + byte[] mutate(PkiTestRuntime runtime, Credential credential, KeyPair issuerKey) { + return readCredential(runtime, credential); } private static CaImportCommand importCommand(PkiTestRuntime runtime, KeyRef keyRef, byte[] encoded, SubjectRef subject, String profileId) { return new CaImportCommand(runtime.framework().formatId(), subject, profileId, keyRef, - new EncodedObject(Encoding.DER, encoded), new SimpleAttributeSet()); + stageCredential(runtime, encoded), new SimpleAttributeSet()); + } + + private static byte[] readCredential(PkiTestRuntime runtime, Credential credential) { + try { + return runtime.credentialBytes(credential); + } catch (java.io.IOException exception) { + throw new IllegalStateException("test credential read failed", exception); + } + } + + private static zeroecho.pki.api.content.DurableContentReference stageCredential(PkiTestRuntime runtime, + byte[] encoded) { + try { + return runtime.stageCredential(encoded); + } catch (java.io.IOException exception) { + throw new IllegalStateException("test credential staging failed", exception); + } } } } diff --git a/pki/src/test/java/zeroecho/pki/e2e/H7EndEntityAcceptanceE2eTest.java b/pki/src/test/java/zeroecho/pki/e2e/H7EndEntityAcceptanceE2eTest.java index 712178d..905e361 100644 --- a/pki/src/test/java/zeroecho/pki/e2e/H7EndEntityAcceptanceE2eTest.java +++ b/pki/src/test/java/zeroecho/pki/e2e/H7EndEntityAcceptanceE2eTest.java @@ -167,7 +167,7 @@ final class H7EndEntityAcceptanceE2eTest { .credential(); EndEntityProfileBinding binding = (EndEntityProfileBinding) issued.profileBinding(); assertEquals(imported, binding.reference()); - X509CertificateHolder holder = new X509CertificateHolder(issued.encoded().bytes()); + X509CertificateHolder holder = new X509CertificateHolder(runtime.credentialBytes(issued)); GeneralName[] names = GeneralNames .fromExtensions(holder.getExtensions(), Extension.subjectAlternativeName).getNames(); assertEquals(1, names.length); @@ -268,7 +268,7 @@ final class H7EndEntityAcceptanceE2eTest { BigInteger allocatedSerial = allocatedSerials.get(allocatedSerials.size() - 1); PersistedExpectation expectation = new PersistedExpectation(issuanceCase, allocatedSerial, credential.validity(), credential.publicKeyId()); - assertExactLeaf(credential, rootKey, leafKey, expectation); + assertExactLeaf(runtime, credential, rootKey, leafKey, expectation); durableCredentials.put(credential.credentialId(), expectation); } } @@ -285,7 +285,7 @@ final class H7EndEntityAcceptanceE2eTest { try (PkiTestRuntime reopened = PkiTestRuntime.create(tempDir, busFile, Map.of(rootKeyRef, rootKey))) { for (Map.Entry entry : durableCredentials.entrySet()) { Credential persisted = reopened.store().getCredential(entry.getKey()).orElseThrow(); - assertExactLeaf(persisted, rootKey, leafKey, entry.getValue()); + assertExactLeaf(reopened, persisted, rootKey, leafKey, entry.getValue()); } } } @@ -450,27 +450,28 @@ final class H7EndEntityAcceptanceE2eTest { return new CredentialIssuerBackend() { @Override public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, - EncodedObject issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { + zeroecho.pki.api.content.DurableContentReference issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { allocatedSerials.add(serial); return delegate.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial); } @Override public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest issuance, - EncodedObject issuerCertificate, KeyRef issuerKeyRef) { + zeroecho.pki.api.content.DurableContentReference issuerCertificate, KeyRef issuerKeyRef) { return delegate.issueIntermediateCertificate(issuance, issuerCertificate, issuerKeyRef); } }; } - private static void assertExactLeaf(Credential credential, KeyPair rootKey, KeyPair leafKey, + private static void assertExactLeaf(PkiTestRuntime runtime, Credential credential, KeyPair rootKey, + KeyPair leafKey, PersistedExpectation expectation) throws Exception { - assertEquals(Encoding.DER, credential.encoded().encoding()); + assertEquals(Encoding.DER, credential.content().encoding()); EndEntityProfileBinding binding = assertInstanceOf(EndEntityProfileBinding.class, credential.profileBinding()); assertEquals(expectation.issuanceCase().profileId(), binding.reference().profileId()); assertEquals(expectation.validity(), credential.validity()); assertEquals(expectation.publicKeyId(), credential.publicKeyId()); - X509CertificateHolder holder = new X509CertificateHolder(credential.encoded().bytes()); + X509CertificateHolder holder = new X509CertificateHolder(runtime.credentialBytes(credential)); assertEquals(expectation.issuanceCase().subject(), holder.getSubject()); SubjectRef expectedSubjectRef = expectation.issuanceCase().subject().getRDNs().length == 0 ? new SubjectRef("x509:empty-subject") @@ -548,14 +549,14 @@ final class H7EndEntityAcceptanceE2eTest { return new CredentialIssuerBackend() { @Override public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, - EncodedObject issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { + zeroecho.pki.api.content.DurableContentReference issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { backendCalls.incrementAndGet(); return delegate.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial); } @Override public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest issuance, - EncodedObject issuerCertificate, KeyRef issuerKeyRef) { + zeroecho.pki.api.content.DurableContentReference issuerCertificate, KeyRef issuerKeyRef) { return delegate.issueIntermediateCertificate(issuance, issuerCertificate, issuerKeyRef); } }; @@ -568,8 +569,8 @@ final class H7EndEntityAcceptanceE2eTest { AtomicReference baselineCredential = new AtomicReference<>(); AtomicReference deliveredSerial = new AtomicReference<>(); AtomicInteger backendCalls = new AtomicInteger(); - CredentialIssuerBackend backend = mutatingBackend(runtime.issuerBackend(), rootKey, substituteKey, mutation, - baselineCredential, maliciousCredential, deliveredSerial, backendCalls); + CredentialIssuerBackend backend = mutatingBackend(runtime, runtime.issuerBackend(), rootKey, substituteKey, + mutation, baselineCredential, maliciousCredential, deliveredSerial, backendCalls); int auditBefore = runtime.auditSink().snapshot().size(); PkiException rejection = assertThrows(PkiException.class, @@ -590,35 +591,36 @@ final class H7EndEntityAcceptanceE2eTest { assertFalse(runtime.auditSink().snapshot().toString().contains(REDACTION_SENTINEL), mutation.name()); } - private static CredentialIssuerBackend mutatingBackend(CredentialIssuerBackend delegate, KeyPair rootKey, - KeyPair substituteKey, LeafMutation mutation, AtomicReference baselineCredential, - AtomicReference maliciousCredential, AtomicReference deliveredSerial, - AtomicInteger backendCalls) { + private static CredentialIssuerBackend mutatingBackend(PkiTestRuntime runtime, + CredentialIssuerBackend delegate, KeyPair rootKey, KeyPair substituteKey, LeafMutation mutation, + AtomicReference baselineCredential, AtomicReference maliciousCredential, + AtomicReference deliveredSerial, AtomicInteger backendCalls) { return new CredentialIssuerBackend() { @Override public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, - EncodedObject issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { + zeroecho.pki.api.content.DurableContentReference issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { backendCalls.incrementAndGet(); deliveredSerial.set(serial); CredentialBundle baseline = delegate.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial); baselineCredential.set(baseline.credential()); - Credential mutated = mutateCredential(baseline.credential(), rootKey, substituteKey, mutation); + Credential mutated = mutateCredential(runtime, baseline.credential(), rootKey, substituteKey, + mutation); maliciousCredential.set(mutated); return new CredentialBundle(mutated, baseline.supportingObjects()); } @Override public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest issuance, - EncodedObject issuerCertificate, KeyRef issuerKeyRef) { + zeroecho.pki.api.content.DurableContentReference issuerCertificate, KeyRef issuerKeyRef) { return delegate.issueIntermediateCertificate(issuance, issuerCertificate, issuerKeyRef); } }; } - private static Credential mutateCredential(Credential credential, KeyPair rootKey, KeyPair substituteKey, - LeafMutation mutation) { + private static Credential mutateCredential(PkiTestRuntime runtime, Credential credential, KeyPair rootKey, + KeyPair substituteKey, LeafMutation mutation) { try { - X509CertificateHolder original = new X509CertificateHolder(credential.encoded().bytes()); + X509CertificateHolder original = new X509CertificateHolder(runtime.credentialBytes(credential)); X500Name issuer = mutation == LeafMutation.ISSUER ? new X500Name("CN=Wrong H7 Issuer") : original.getIssuer(); X500Name subject = mutatedSubject(original.getSubject(), mutation); @@ -681,8 +683,12 @@ final class H7EndEntityAcceptanceE2eTest { CredentialStatus status = mutation == LeafMutation.STATUS_METADATA ? CredentialStatus.REVOKED : credential.status(); Encoding encoding = mutation == LeafMutation.ENCODING_METADATA ? Encoding.PEM : Encoding.DER; + zeroecho.pki.api.content.DurableContentReference content = runtime.stageCredential(der); + if (encoding != Encoding.DER) { + content = zeroecho.pki.testkit.PkiTestRuntime.untrustedReference(content, encoding); + } return new Credential(credentialId, formatId, issuerRef, subjectRef, metadataValidity, metadataSerial, - publicKeyId, profileBinding, status, new EncodedObject(encoding, der), credential.attributes()); + publicKeyId, profileBinding, status, content, credential.attributes()); } catch (Exception exception) { throw new IllegalStateException("Failed to build controlled H7 mutation " + mutation.name()); } diff --git a/pki/src/test/java/zeroecho/pki/e2e/H7EndEntityCsrRejectionE2eTest.java b/pki/src/test/java/zeroecho/pki/e2e/H7EndEntityCsrRejectionE2eTest.java index ffa24cd..7f595e2 100644 --- a/pki/src/test/java/zeroecho/pki/e2e/H7EndEntityCsrRejectionE2eTest.java +++ b/pki/src/test/java/zeroecho/pki/e2e/H7EndEntityCsrRejectionE2eTest.java @@ -412,14 +412,14 @@ final class H7EndEntityCsrRejectionE2eTest { return new CredentialIssuerBackend() { @Override public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, - EncodedObject issuerCertificate, KeyRef issuerKeyRef, java.math.BigInteger serial) { + zeroecho.pki.api.content.DurableContentReference issuerCertificate, KeyRef issuerKeyRef, java.math.BigInteger serial) { backendCalls.incrementAndGet(); return delegate.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial); } @Override public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest issuance, - EncodedObject issuerCertificate, KeyRef issuerKeyRef) { + zeroecho.pki.api.content.DurableContentReference issuerCertificate, KeyRef issuerKeyRef) { return delegate.issueIntermediateCertificate(issuance, issuerCertificate, issuerKeyRef); } }; diff --git a/pki/src/test/java/zeroecho/pki/e2e/PkiCoreE2eTest.java b/pki/src/test/java/zeroecho/pki/e2e/PkiCoreE2eTest.java index 573f480..c552761 100644 --- a/pki/src/test/java/zeroecho/pki/e2e/PkiCoreE2eTest.java +++ b/pki/src/test/java/zeroecho/pki/e2e/PkiCoreE2eTest.java @@ -281,7 +281,7 @@ public final class PkiCoreE2eTest { assertNotNull(bundle); System.out.println("...issuedCredentialId=" + bundle.credential().credentialId().value()); - X509CertificateHolder eeCert = new X509CertificateHolder(bundle.credential().encoded().bytes()); + X509CertificateHolder eeCert = new X509CertificateHolder(runtime.credentialBytes(bundle.credential())); assertEquals("CN=Root", eeCert.getIssuer().toString()); assertEquals("CN=Alice", eeCert.getSubject().toString()); @@ -293,7 +293,8 @@ public final class PkiCoreE2eTest { assertNotNull(crl); System.out.println("...crlId=" + crl.statusObjectId().value()); - X509CRLHolder crlHolder = new X509CRLHolder(crl.encoded().bytes()); + X509CRLHolder crlHolder = new X509CRLHolder( + PkiTestRuntime.readContent(runtime.signingBus(), crl.content())); assertTrue(crlHolder.getRevokedCertificate(eeCert.getSerialNumber()) != null); } @@ -390,7 +391,7 @@ public final class PkiCoreE2eTest { private static Credential copyWithId(Credential source, PkiId id) { return new Credential(id, source.formatId(), source.issuerRef(), source.subjectRef(), source.validity(), source.serialOrUniqueId(), source.publicKeyId(), source.profileBinding(), source.status(), - source.encoded(), source.attributes()); + source.content(), source.attributes()); } private static final class CountingIssuerBackend implements CredentialIssuerBackend { @@ -405,7 +406,7 @@ public final class PkiCoreE2eTest { } @Override - public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, EncodedObject issuerCertificate, + public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, zeroecho.pki.api.content.DurableContentReference issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { endEntityCalls.incrementAndGet(); return delegate.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial); @@ -413,7 +414,7 @@ public final class PkiCoreE2eTest { @Override public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest issuance, - EncodedObject issuerCertificate, KeyRef issuerKeyRef) { + zeroecho.pki.api.content.DurableContentReference issuerCertificate, KeyRef issuerKeyRef) { intermediateCalls.incrementAndGet(); return delegate.issueIntermediateCertificate(issuance, issuerCertificate, issuerKeyRef); } diff --git a/pki/src/test/java/zeroecho/pki/e2e/PkiProofGateE2eTest.java b/pki/src/test/java/zeroecho/pki/e2e/PkiProofGateE2eTest.java index 15d2370..73e443d 100644 --- a/pki/src/test/java/zeroecho/pki/e2e/PkiProofGateE2eTest.java +++ b/pki/src/test/java/zeroecho/pki/e2e/PkiProofGateE2eTest.java @@ -149,9 +149,11 @@ final class PkiProofGateE2eTest { .filter(method -> method.getName().equals("issueEndEntity")).findFirst().orElseThrow(); Method intermediate = java.util.Arrays.stream(CredentialIssuerBackend.class.getMethods()) .filter(method -> method.getName().equals("issueIntermediateCertificate")).findFirst().orElseThrow(); - assertArrayEquals(new Class[] { ValidatedCertificateRequest.class, EncodedObject.class, KeyRef.class, - BigInteger.class }, endEntity.getParameterTypes()); - assertArrayEquals(new Class[] { ValidatedCaCertificateRequest.class, EncodedObject.class, KeyRef.class }, + assertArrayEquals(new Class[] { ValidatedCertificateRequest.class, + zeroecho.pki.api.content.DurableContentReference.class, KeyRef.class, BigInteger.class }, + endEntity.getParameterTypes()); + assertArrayEquals(new Class[] { ValidatedCaCertificateRequest.class, + zeroecho.pki.api.content.DurableContentReference.class, KeyRef.class }, intermediate.getParameterTypes()); assertTrue(java.util.Arrays.stream(BcX509CredentialIssuerBackend.class.getMethods()) .filter(method -> method.getName().startsWith("issue")) @@ -192,7 +194,7 @@ final class PkiProofGateE2eTest { .issueEndEntity(new IssueEndEntityCommand(rootCaId, valid, "default", Optional.empty())); assertEquals(1, counting.endEntityCalls.get()); assertArrayEquals(subjectKey.getPublic().getEncoded(), - new X509CertificateHolder(issued.credential().encoded().bytes()).getSubjectPublicKeyInfo() + new X509CertificateHolder(runtime.credentialBytes(issued.credential())).getSubjectPublicKeyInfo() .getEncoded()); CaService caService = runtime.caService(counting); @@ -281,7 +283,7 @@ final class PkiProofGateE2eTest { ParsedCertificationRequest valid = parse(runtime, validCsr); ParsedCertificationRequest pss = parse(runtime, makeCsr(subjectKey, subjectKey, "CN=PssSubject", "SHA256withRSAandMGF1")); - ProofOfPossessionResult pssProof = new BcX509ProofOfPossessionVerifier().verify(pss, + ProofOfPossessionResult pssProof = standaloneBcVerifier().verify(pss, new zeroecho.pki.api.issuance.VerificationPolicy(true, Optional.empty())); assertEquals(ProofOfPossessionStatus.VERIFIED, pssProof.status()); org.bouncycastle.asn1.pkcs.CertificationRequest original = validCsr.toASN1Structure(); @@ -290,7 +292,7 @@ final class PkiProofGateE2eTest { requestInfo, new AlgorithmIdentifier(new ASN1ObjectIdentifier("1.2.3.4.5.6.7")), original.getSignature()); ParsedCertificationRequest unsupported = parse(runtime, new PKCS10CertificationRequest(unknownAlgorithm)); - ProofOfPossessionResult unsupportedProof = new BcX509ProofOfPossessionVerifier().verify(unsupported, + ProofOfPossessionResult unsupportedProof = standaloneBcVerifier().verify(unsupported, new zeroecho.pki.api.issuance.VerificationPolicy(true, Optional.empty())); assertEquals(ProofOfPossessionStatus.FAILED, unsupportedProof.status()); @@ -388,7 +390,7 @@ final class PkiProofGateE2eTest { KeyRef subjectKeyRef = new KeyRef("kref:v1:keyring:test:subject"); AtomicReference mutation = new AtomicReference<>(() -> { }); - BcX509ProofOfPossessionVerifier delegate = new BcX509ProofOfPossessionVerifier(); + BcX509ProofOfPossessionVerifier delegate = standaloneBcVerifier(); Map signingKeys = Map.of(rootKeyRef, rootKey, subjectKeyRef, subjectKey); Map resolvedKeys = Map.of(rootKeyRef, rootKey.getPublic(), subjectKeyRef, @@ -415,7 +417,7 @@ final class PkiProofGateE2eTest { CredentialBundle bundle = runtime.issuanceService() .issueEndEntity(new IssueEndEntityCommand(rootCaId, parsed, "default", Optional.empty())); - X509CertificateHolder issued = new X509CertificateHolder(bundle.credential().encoded().bytes()); + X509CertificateHolder issued = new X509CertificateHolder(runtime.credentialBytes(bundle.credential())); assertEquals("CN=Root", issued.getIssuer().toString()); assertEquals("CN=Subject", issued.getSubject().toString()); assertArrayEquals(subjectKey.getPublic().getEncoded(), issued.getSubjectPublicKeyInfo().getEncoded()); @@ -434,7 +436,7 @@ final class PkiProofGateE2eTest { Path wrongDir = tempDir.resolve("wrong-root"); try (PkiTestRuntime runtime = PkiTestRuntime.create(wrongDir, wrongDir.resolve("bus.log"), Map.of(rootKeyRef, wrongRootSigner), Map.of(rootKeyRef, expectedRoot.getPublic()), - new BcX509ProofOfPossessionVerifier())) { + standaloneBcVerifier())) { assertThrows(PkiException.class, () -> runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), new SubjectRef("CN=Root"), "root-ca", Optional.of(rootKeyRef), new SimpleAttributeSet()))); @@ -446,7 +448,7 @@ final class PkiProofGateE2eTest { } Path failedDir = tempDir.resolve("failed-workflow"); try (PkiTestRuntime runtime = PkiTestRuntime.create(failedDir, failedDir.resolve("bus.log"), Map.of(), - Map.of(rootKeyRef, expectedRoot.getPublic()), new BcX509ProofOfPossessionVerifier())) { + Map.of(rootKeyRef, expectedRoot.getPublic()), standaloneBcVerifier())) { assertThrows(PkiException.class, () -> runtime.caService().createRoot(new CaCreateCommand(runtime.framework().formatId(), new SubjectRef("CN=Root"), "root-ca", Optional.of(rootKeyRef), new SimpleAttributeSet()))); @@ -471,7 +473,7 @@ final class PkiProofGateE2eTest { approved)); Credential first = runtime.caService().getCa(intermediateCaId).caCredentials().get(0); - X509CertificateHolder firstHolder = new X509CertificateHolder(first.encoded().bytes()); + X509CertificateHolder firstHolder = new X509CertificateHolder(runtime.credentialBytes(first)); assertEquals("CN=Root", firstHolder.getIssuer().toString()); assertEquals("CN=Intermediate", firstHolder.getSubject().toString()); assertArrayEquals(intermediateKey.getPublic().getEncoded(), @@ -480,7 +482,7 @@ final class PkiProofGateE2eTest { Credential additional = runtime.caService() .issueIntermediateCertificate(new IntermediateCertIssueCommand(runtime.framework().formatId(), rootCaId, intermediateCaId, "intermediate-ca", Optional.empty(), approved)); - X509CertificateHolder additionalHolder = new X509CertificateHolder(additional.encoded().bytes()); + X509CertificateHolder additionalHolder = new X509CertificateHolder(runtime.credentialBytes(additional)); assertEquals("CN=Intermediate", additionalHolder.getSubject().toString()); assertArrayEquals(intermediateKey.getPublic().getEncoded(), additionalHolder.getSubjectPublicKeyInfo().getEncoded()); @@ -548,23 +550,24 @@ final class PkiProofGateE2eTest { Map.of(rootKeyRef, rootKey, intermediateKeyRef, intermediateKey))) { PkiId rootCaId = source.caService().createRoot(new CaCreateCommand(source.framework().formatId(), new SubjectRef("CN=Root"), "root-ca", Optional.of(rootKeyRef), new SimpleAttributeSet())); - rootCertificate = source.caService().getCa(rootCaId).caCredentials().get(0).encoded().bytes().clone(); + rootCertificate = source + .credentialBytes(source.caService().getCa(rootCaId).caCredentials().get(0)).clone(); PkiId intermediateCaId = source.caService() .createIntermediate(new IntermediateCreateCommand(source.framework().formatId(), rootCaId, new SubjectRef("CN=Intermediate"), "intermediate-ca", Optional.of(intermediateKeyRef), new SimpleAttributeSet())); - intermediateCertificate = source.caService().getCa(intermediateCaId).caCredentials().get(0).encoded() - .bytes().clone(); + intermediateCertificate = source + .credentialBytes(source.caService().getCa(intermediateCaId).caCredentials().get(0)).clone(); ParsedCertificationRequest leaf = parse(source, makeCsr(subjectKey, subjectKey, "CN=Leaf")); - leafCertificate = source.issuanceService() - .issueEndEntity(new IssueEndEntityCommand(rootCaId, leaf, "default", Optional.empty())).credential() - .encoded().bytes().clone(); + leafCertificate = source.credentialBytes(source.issuanceService() + .issueEndEntity(new IssueEndEntityCommand(rootCaId, leaf, "default", Optional.empty())) + .credential()).clone(); } Path importDir = tempDir.resolve("import-mismatch"); try (PkiTestRuntime target = PkiTestRuntime.create(importDir, importDir.resolve("bus.log"), Map.of(rootKeyRef, replacementRootKey))) { CaImportCommand command = new CaImportCommand(target.framework().formatId(), new SubjectRef("CN=Root"), - "root-ca", rootKeyRef, new EncodedObject(Encoding.DER, rootCertificate), new SimpleAttributeSet()); + "root-ca", rootKeyRef, target.stageCredential(rootCertificate), new SimpleAttributeSet()); assertThrows(PkiException.class, () -> target.caService().importRoot(command)); assertTrue(target.store().listCas().isEmpty()); assertTrue(target.store().listWorkflowStates().isEmpty()); @@ -580,12 +583,12 @@ final class PkiProofGateE2eTest { target.onPublicKeyResolve(() -> callerOwnedCertificate[callerOwnedCertificate.length - 1] ^= 0x01); PkiId importedCaId = target.caService() .importRoot(new CaImportCommand(target.framework().formatId(), new SubjectRef("CN=Root"), "root-ca", - rootKeyRef, new EncodedObject(Encoding.DER, callerOwnedCertificate), + rootKeyRef, target.stageCredential(callerOwnedCertificate), new SimpleAttributeSet())); assertTrue(target.caService().getCa(importedCaId).caCredentials().get(0) .profileBinding() instanceof CaProfileBinding); assertArrayEquals(expectedImportedCertificate, - target.caService().getCa(importedCaId).caCredentials().get(0).encoded().bytes()); + target.credentialBytes(target.caService().getCa(importedCaId).caCredentials().get(0))); } assertInvalidRootImport(tempDir.resolve("import-leaf"), rootKeyRef, rootKey, leafCertificate, "CN=Leaf"); @@ -623,7 +626,7 @@ final class PkiProofGateE2eTest { try (PkiTestRuntime runtime = PkiTestRuntime.create(rootDir, rootDir.resolve("bus.log"), Map.of(keyRef, keyPair))) { CaImportCommand command = new CaImportCommand(runtime.framework().formatId(), new SubjectRef(subject), - "root-ca", keyRef, new EncodedObject(Encoding.DER, certificate), new SimpleAttributeSet()); + "root-ca", keyRef, runtime.stageCredential(certificate), new SimpleAttributeSet()); assertThrows(PkiException.class, () -> runtime.caService().importRoot(command)); assertTrue(runtime.store().listCas().isEmpty()); assertEquals(0, runtime.submittedSignCount()); @@ -651,13 +654,13 @@ final class PkiProofGateE2eTest { CredentialIssuerBackend throwingBackend = new CredentialIssuerBackend() { @Override public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, - EncodedObject issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { + zeroecho.pki.api.content.DurableContentReference issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { throw new IllegalStateException("DO_NOT_LOG_SIGNATURE_SENTINEL"); } @Override public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest issuance, - EncodedObject issuerCertificate, KeyRef issuerKeyRef) { + zeroecho.pki.api.content.DurableContentReference issuerCertificate, KeyRef issuerKeyRef) { throw new IllegalStateException("DO_NOT_LOG_SIGNATURE_SENTINEL"); } }; @@ -671,13 +674,13 @@ final class PkiProofGateE2eTest { CredentialIssuerBackend maliciousBackend = new CredentialIssuerBackend() { @Override public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, - EncodedObject issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { + zeroecho.pki.api.content.DurableContentReference issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { CredentialBundle bundle = delegateBackend.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial); Credential raw = bundle.credential(); Credential forgedMetadata = new Credential(raw.credentialId(), raw.formatId(), raw.issuerRef(), substitute.subjectRef(), raw.validity(), raw.serialOrUniqueId(), raw.publicKeyId(), - raw.profileBinding(), raw.status(), raw.encoded(), raw.attributes()); + raw.profileBinding(), raw.status(), raw.content(), raw.attributes()); bundle = new CredentialBundle(forgedMetadata, bundle.supportingObjects()); substitutedBundle.set(bundle); return bundle; @@ -685,7 +688,7 @@ final class PkiProofGateE2eTest { @Override public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest issuance, - EncodedObject issuerCertificate, KeyRef issuerKeyRef) { + zeroecho.pki.api.content.DurableContentReference issuerCertificate, KeyRef issuerKeyRef) { return delegateBackend.issueIntermediateCertificate(issuance, issuerCertificate, issuerKeyRef); } }; @@ -703,7 +706,7 @@ final class PkiProofGateE2eTest { CredentialIssuerBackend wrongEndEntityBindingBackend = new CredentialIssuerBackend() { @Override public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, - EncodedObject issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { + zeroecho.pki.api.content.DurableContentReference issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { CredentialBundle rawBundle = delegateBackend.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial); Credential raw = rawBundle.credential(); @@ -714,7 +717,7 @@ final class PkiProofGateE2eTest { @Override public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest issuance, - EncodedObject issuerCertificate, KeyRef issuerKeyRef) { + zeroecho.pki.api.content.DurableContentReference issuerCertificate, KeyRef issuerKeyRef) { return delegateBackend.issueIntermediateCertificate(issuance, issuerCertificate, issuerKeyRef); } }; @@ -729,22 +732,22 @@ final class PkiProofGateE2eTest { CredentialIssuerBackend invalidSignatureBackend = new CredentialIssuerBackend() { @Override public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, - EncodedObject issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { + zeroecho.pki.api.content.DurableContentReference issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { CredentialBundle rawBundle = delegateBackend.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial); Credential raw = rawBundle.credential(); - byte[] invalid = raw.encoded().bytes().clone(); + byte[] invalid = credentialBytes(runtime, raw).clone(); invalid[invalid.length - 1] ^= 0x01; Credential invalidCredential = new Credential(raw.credentialId(), raw.formatId(), raw.issuerRef(), raw.subjectRef(), raw.validity(), raw.serialOrUniqueId(), raw.publicKeyId(), - raw.profileBinding(), raw.status(), new EncodedObject(Encoding.DER, invalid), + raw.profileBinding(), raw.status(), stageCredential(runtime, invalid), raw.attributes()); return new CredentialBundle(invalidCredential, rawBundle.supportingObjects()); } @Override public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest issuance, - EncodedObject issuerCertificate, KeyRef issuerKeyRef) { + zeroecho.pki.api.content.DurableContentReference issuerCertificate, KeyRef issuerKeyRef) { return delegateBackend.issueIntermediateCertificate(issuance, issuerCertificate, issuerKeyRef); } }; @@ -758,7 +761,7 @@ final class PkiProofGateE2eTest { CredentialIssuerBackend mutableBackend = new CredentialIssuerBackend() { @Override public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, - EncodedObject issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { + zeroecho.pki.api.content.DurableContentReference issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { CredentialBundle raw = delegateBackend.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial); rawBundle.set(raw); @@ -767,7 +770,7 @@ final class PkiProofGateE2eTest { @Override public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest issuance, - EncodedObject issuerCertificate, KeyRef issuerKeyRef) { + zeroecho.pki.api.content.DurableContentReference issuerCertificate, KeyRef issuerKeyRef) { return delegateBackend.issueIntermediateCertificate(issuance, issuerCertificate, issuerKeyRef); } }; @@ -776,18 +779,18 @@ final class PkiProofGateE2eTest { Clock.systemUTC()); CredentialBundle returned = snapshotService .issueEndEntity(new IssueEndEntityCommand(rootCaId, subject, "default", Optional.empty())); - byte[] expectedLeaf = returned.credential().encoded().bytes().clone(); - rawBundle.get().credential().encoded().bytes()[0] ^= 0x01; - rawBundle.get().supportingObjects().get(0).bytes()[0] ^= 0x01; - assertArrayEquals(expectedLeaf, returned.credential().encoded().bytes()); - assertArrayEquals(expectedLeaf, runtime.store().getCredential(returned.credential().credentialId()) - .orElseThrow().encoded().bytes()); + byte[] expectedLeaf = runtime.credentialBytes(returned.credential()).clone(); + assertEquals(rawBundle.get().credential().content(), returned.credential().content()); + assertEquals(rawBundle.get().supportingObjects(), returned.supportingObjects()); + assertArrayEquals(expectedLeaf, runtime.credentialBytes(returned.credential())); + assertArrayEquals(expectedLeaf, runtime.credentialBytes( + runtime.store().getCredential(returned.credential().credentialId()).orElseThrow())); CaRecord root = runtime.caService().getCa(rootCaId); Credential original = root.caCredentials().get(0); Credential revoked = new Credential(original.credentialId(), original.formatId(), original.issuerRef(), original.subjectRef(), original.validity(), original.serialOrUniqueId(), original.publicKeyId(), - original.profileBinding(), CredentialStatus.REVOKED, original.encoded(), original.attributes()); + original.profileBinding(), CredentialStatus.REVOKED, original.content(), original.attributes()); runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(), root.subjectRef(), List.of(revoked))); int before = runtime.submittedSignCount(); @@ -799,7 +802,7 @@ final class PkiProofGateE2eTest { Instant.now().minus(Duration.ofDays(1))); Credential expired = new Credential(original.credentialId(), original.formatId(), original.issuerRef(), original.subjectRef(), expiredValidity, original.serialOrUniqueId(), original.publicKeyId(), - original.profileBinding(), CredentialStatus.ISSUED, original.encoded(), original.attributes()); + original.profileBinding(), CredentialStatus.ISSUED, original.content(), original.attributes()); runtime.store().putCa(new CaRecord(root.caId(), root.kind(), root.state(), root.issuerKeyRef(), root.subjectRef(), List.of(expired))); assertThrows(PkiException.class, () -> runtime.issuanceService() @@ -875,15 +878,15 @@ final class PkiProofGateE2eTest { CredentialIssuerBackend wrongKeyBackend = new CredentialIssuerBackend() { @Override public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, - EncodedObject issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { + zeroecho.pki.api.content.DurableContentReference issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { return delegate.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial); } @Override public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest issuance, - EncodedObject issuerCertificate, KeyRef issuerKeyRef) { + zeroecho.pki.api.content.DurableContentReference issuerCertificate, KeyRef issuerKeyRef) { Credential raw = delegate.issueIntermediateCertificate(issuance, issuerCertificate, issuerKeyRef); - return rebuildIntermediateIdentity(raw, rootKey, Optional.of(wrongKey.getPublic()), + return rebuildIntermediateIdentity(runtime, raw, rootKey, Optional.of(wrongKey.getPublic()), Optional.empty()); } }; @@ -915,15 +918,15 @@ final class PkiProofGateE2eTest { CredentialIssuerBackend wrongSubjectBackend = new CredentialIssuerBackend() { @Override public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, - EncodedObject issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { + zeroecho.pki.api.content.DurableContentReference issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { return delegate.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial); } @Override public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest issuance, - EncodedObject issuerCertificate, KeyRef issuerKeyRef) { + zeroecho.pki.api.content.DurableContentReference issuerCertificate, KeyRef issuerKeyRef) { Credential raw = delegate.issueIntermediateCertificate(issuance, issuerCertificate, issuerKeyRef); - return rebuildIntermediateIdentity(raw, rootKey, Optional.empty(), + return rebuildIntermediateIdentity(runtime, raw, rootKey, Optional.empty(), Optional.of("CN=WrongIntermediate")); } }; @@ -937,19 +940,19 @@ final class PkiProofGateE2eTest { CredentialIssuerBackend invalidSignatureBackend = new CredentialIssuerBackend() { @Override public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, - EncodedObject issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { + zeroecho.pki.api.content.DurableContentReference issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { return delegate.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial); } @Override public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest issuance, - EncodedObject issuerCertificate, KeyRef issuerKeyRef) { + zeroecho.pki.api.content.DurableContentReference issuerCertificate, KeyRef issuerKeyRef) { Credential raw = delegate.issueIntermediateCertificate(issuance, issuerCertificate, issuerKeyRef); - byte[] invalid = raw.encoded().bytes().clone(); + byte[] invalid = credentialBytes(runtime, raw).clone(); invalid[invalid.length - 1] ^= 0x01; return new Credential(raw.credentialId(), raw.formatId(), raw.issuerRef(), raw.subjectRef(), raw.validity(), raw.serialOrUniqueId(), raw.publicKeyId(), raw.profileBinding(), - raw.status(), new EncodedObject(Encoding.DER, invalid), raw.attributes()); + raw.status(), stageCredential(runtime, invalid), raw.attributes()); } }; CaService invalidSignatureService = runtime.caService(invalidSignatureBackend); @@ -961,7 +964,7 @@ final class PkiProofGateE2eTest { for (IntermediateExtensionVariant variant : IntermediateExtensionVariant.values()) { CaService maliciousExtensionService = runtime - .caService(extensionVariantBackend(delegate, rootKey, variant)); + .caService(extensionVariantBackend(runtime, delegate, rootKey, variant)); assertThrows(PkiException.class, () -> maliciousExtensionService.issueIntermediateCertificate(new IntermediateCertIssueCommand( runtime.framework().formatId(), rootCaId, intermediateCaId, "intermediate-ca", @@ -974,13 +977,13 @@ final class PkiProofGateE2eTest { CredentialIssuerBackend mutableBackend = new CredentialIssuerBackend() { @Override public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, - EncodedObject issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { + zeroecho.pki.api.content.DurableContentReference issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { return delegate.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial); } @Override public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest issuance, - EncodedObject issuerCertificate, KeyRef issuerKeyRef) { + zeroecho.pki.api.content.DurableContentReference issuerCertificate, KeyRef issuerKeyRef) { Credential raw = delegate.issueIntermediateCertificate(issuance, issuerCertificate, issuerKeyRef); rawCredential.set(raw); return raw; @@ -990,11 +993,11 @@ final class PkiProofGateE2eTest { Credential returned = snapshotService .issueIntermediateCertificate(new IntermediateCertIssueCommand(runtime.framework().formatId(), rootCaId, intermediateCaId, "intermediate-ca", Optional.empty(), new SimpleAttributeSet())); - byte[] expected = returned.encoded().bytes().clone(); - rawCredential.get().encoded().bytes()[0] ^= 0x01; - assertArrayEquals(expected, returned.encoded().bytes()); + byte[] expected = runtime.credentialBytes(returned).clone(); + assertEquals(rawCredential.get().content(), returned.content()); + assertArrayEquals(expected, runtime.credentialBytes(returned)); assertArrayEquals(expected, - runtime.store().getCredential(returned.credentialId()).orElseThrow().encoded().bytes()); + runtime.credentialBytes(runtime.store().getCredential(returned.credentialId()).orElseThrow())); } } @@ -1020,7 +1023,7 @@ final class PkiProofGateE2eTest { } @Override - public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, EncodedObject issuerCertificate, + public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, zeroecho.pki.api.content.DurableContentReference issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { endEntityCalls.incrementAndGet(); return delegate.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial); @@ -1028,27 +1031,27 @@ final class PkiProofGateE2eTest { @Override public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest issuance, - EncodedObject issuerCertificate, KeyRef issuerKeyRef) { + zeroecho.pki.api.content.DurableContentReference issuerCertificate, KeyRef issuerKeyRef) { intermediateCalls.incrementAndGet(); return delegate.issueIntermediateCertificate(issuance, issuerCertificate, issuerKeyRef); } } - private static CredentialIssuerBackend extensionVariantBackend(CredentialIssuerBackend delegate, KeyPair issuerKey, - IntermediateExtensionVariant variant) { + private static CredentialIssuerBackend extensionVariantBackend(PkiTestRuntime runtime, + CredentialIssuerBackend delegate, KeyPair issuerKey, IntermediateExtensionVariant variant) { return new CredentialIssuerBackend() { @Override public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, - EncodedObject issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { + zeroecho.pki.api.content.DurableContentReference issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { return delegate.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial); } @Override public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest issuance, - EncodedObject issuerCertificate, KeyRef issuerKeyRef) { + zeroecho.pki.api.content.DurableContentReference issuerCertificate, KeyRef issuerKeyRef) { Credential credential = delegate.issueIntermediateCertificate(issuance, issuerCertificate, issuerKeyRef); - return rebuildIntermediateExtensions(credential, issuerKey, variant); + return rebuildIntermediateExtensions(runtime, credential, issuerKey, variant); } }; } @@ -1058,13 +1061,13 @@ final class PkiProofGateE2eTest { return new CredentialIssuerBackend() { @Override public CredentialBundle issueEndEntity(ValidatedCertificateRequest candidate, - EncodedObject issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { + zeroecho.pki.api.content.DurableContentReference issuerCertificate, KeyRef issuerKeyRef, BigInteger serial) { return delegate.issueEndEntity(candidate, issuerCertificate, issuerKeyRef, serial); } @Override public Credential issueIntermediateCertificate(ValidatedCaCertificateRequest issuance, - EncodedObject issuerCertificate, KeyRef issuerKeyRef) { + zeroecho.pki.api.content.DurableContentReference issuerCertificate, KeyRef issuerKeyRef) { Credential raw = delegate.issueIntermediateCertificate(issuance, issuerCertificate, issuerKeyRef); produced.set(raw); if (mutation == BindingVariantMutation.NULL_CREDENTIAL) { @@ -1092,13 +1095,13 @@ final class PkiProofGateE2eTest { private static Credential copyWithBinding(Credential credential, CredentialProfileBinding binding) { return new Credential(credential.credentialId(), credential.formatId(), credential.issuerRef(), credential.subjectRef(), credential.validity(), credential.serialOrUniqueId(), credential.publicKeyId(), - binding, credential.status(), credential.encoded(), credential.attributes()); + binding, credential.status(), credential.content(), credential.attributes()); } - private static Credential rebuildIntermediateIdentity(Credential credential, KeyPair issuerKey, - Optional subjectPublicKey, Optional subjectName) { + private static Credential rebuildIntermediateIdentity(PkiTestRuntime runtime, Credential credential, + KeyPair issuerKey, Optional subjectPublicKey, Optional subjectName) { try { - X509CertificateHolder original = new X509CertificateHolder(credential.encoded().bytes()); + X509CertificateHolder original = new X509CertificateHolder(runtime.credentialBytes(credential)); X500Name subject = subjectName.map(X500Name::new).orElse(original.getSubject()); org.bouncycastle.asn1.x509.SubjectPublicKeyInfo publicKeyInfo = subjectPublicKey .map(key -> org.bouncycastle.asn1.x509.SubjectPublicKeyInfo.getInstance(key.getEncoded())) @@ -1113,17 +1116,17 @@ final class PkiProofGateE2eTest { return new Credential(new PkiId("x509:" + sha256Hex(encoded)), credential.formatId(), credential.issuerRef(), new SubjectRef(subject.toString()), credential.validity(), credential.serialOrUniqueId(), new PkiId("spki:" + sha256Hex(publicKeyInfo.getEncoded())), - credential.profileBinding(), credential.status(), new EncodedObject(Encoding.DER, encoded), + credential.profileBinding(), credential.status(), runtime.stageCredential(encoded), credential.attributes()); } catch (Exception ex) { throw new PkiException("Failed to create adversarial intermediate identity", ex); } } - private static Credential rebuildIntermediateExtensions(Credential credential, KeyPair issuerKey, - IntermediateExtensionVariant variant) { + private static Credential rebuildIntermediateExtensions(PkiTestRuntime runtime, Credential credential, + KeyPair issuerKey, IntermediateExtensionVariant variant) { try { - X509CertificateHolder original = new X509CertificateHolder(credential.encoded().bytes()); + X509CertificateHolder original = new X509CertificateHolder(runtime.credentialBytes(credential)); X509v3CertificateBuilder builder = new X509v3CertificateBuilder(original.getIssuer(), original.getSerialNumber(), original.getNotBefore(), original.getNotAfter(), original.getSubject(), original.getSubjectPublicKeyInfo()); @@ -1147,12 +1150,29 @@ final class PkiProofGateE2eTest { return new Credential(new PkiId("x509:" + sha256Hex(encoded)), credential.formatId(), credential.issuerRef(), credential.subjectRef(), credential.validity(), credential.serialOrUniqueId(), credential.publicKeyId(), credential.profileBinding(), - credential.status(), new EncodedObject(Encoding.DER, encoded), credential.attributes()); + credential.status(), runtime.stageCredential(encoded), credential.attributes()); } catch (Exception ex) { throw new PkiException("Failed to create adversarial intermediate certificate", ex); } } + private static byte[] credentialBytes(PkiTestRuntime runtime, Credential credential) { + try { + return runtime.credentialBytes(credential); + } catch (java.io.IOException exception) { + throw new PkiException("Failed to read adversarial credential fixture", exception); + } + } + + private static zeroecho.pki.api.content.DurableContentReference stageCredential(PkiTestRuntime runtime, + byte[] encoded) { + try { + return runtime.stageCredential(encoded); + } catch (java.io.IOException exception) { + throw new PkiException("Failed to stage adversarial credential fixture", exception); + } + } + private static ParsedCertificationRequest parse(PkiTestRuntime runtime, PKCS10CertificationRequest csr) throws Exception { return runtime.certificationRequestService().parse(new CertificationRequest(runtime.framework().formatId(), @@ -1194,6 +1214,19 @@ final class PkiProofGateE2eTest { return ((AttributeValue.BytesValue) request.attributes().get(BcX509Attributes.CSR_DER).orElseThrow()).value(); } + private static BcX509ProofOfPossessionVerifier standaloneBcVerifier() { + zeroecho.pki.impl.framework.x509.bc.BcX509VerificationExecutor executor = + new zeroecho.pki.impl.framework.x509.bc.BcX509VerificationExecutor(); + zeroecho.pki.impl.framework.x509.X509AlgorithmResolver.Policy policy = (suite, direction) -> true; + zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot authority = + zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot.compose(List.of(), List.of(executor), + List.of(zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot.bindExecutor( + zeroecho.pki.impl.framework.x509.bc.BcX509VerificationExecutor.IMPLEMENTATION_ID, + zeroecho.core.spi.AlgorithmExecutionCapability.Direction.VERIFY, executor)), + policy); + return new BcX509ProofOfPossessionVerifier(authority, executor); + } + private static ParsedCertificationRequest withCsr(ParsedCertificationRequest source, byte[] csrDer) { AttributeSet attributes = SimpleAttributeSet.builder() .put(BcX509Attributes.CSR_DER, new AttributeValue.BytesValue(csrDer)).build(); diff --git a/pki/src/test/java/zeroecho/pki/impl/core/DefaultStatusObjectServiceCrlTest.java b/pki/src/test/java/zeroecho/pki/impl/core/DefaultStatusObjectServiceCrlTest.java index 47d0bc2..3ebbc27 100644 --- a/pki/src/test/java/zeroecho/pki/impl/core/DefaultStatusObjectServiceCrlTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/core/DefaultStatusObjectServiceCrlTest.java @@ -71,6 +71,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import zeroecho.pki.api.EncodedObject; +import zeroecho.pki.api.content.DurableContentReference; import zeroecho.pki.api.Encoding; import zeroecho.pki.api.FormatId; import zeroecho.pki.api.IssuanceService; @@ -100,10 +101,12 @@ import zeroecho.pki.api.status.StatusObject; import zeroecho.pki.api.status.StatusObjectGenerateCommand; import zeroecho.pki.api.status.StatusObjectType; import zeroecho.pki.impl.core.attr.SimpleAttributeSet; +import zeroecho.pki.impl.framework.x509.X509SignedObjectCompletion; import zeroecho.pki.impl.framework.x509.bc.BcX509Attributes; import zeroecho.pki.impl.framework.x509.bc.BcX509CredentialFramework; import zeroecho.pki.spi.framework.CredentialFramework; import zeroecho.pki.spi.framework.CrlEntry; +import zeroecho.pki.spi.framework.CrlEntrySource; import zeroecho.pki.spi.framework.StatusObjectGenerator; import zeroecho.pki.spi.store.PkiStore; import zeroecho.pki.testkit.PkiTestRuntime; @@ -120,7 +123,7 @@ final class DefaultStatusObjectServiceCrlTest { Map.of(rootKeyRef, rootKey))) { PkiId caId = createRoot(runtime, rootKeyRef, "CRL Generator Root"); Credential issuer = runtime.caService().getCa(caId).caCredentials().get(0); - StatusObjectGenerateCommand command = crlCommand(caId, issuer, rootKeyRef); + StatusObjectGenerateCommand command = crlCommand(runtime, caId, issuer, rootKeyRef); List reasons = activeReasons(); List serials = serials(reasons.size()); List entries = new ArrayList<>(); @@ -129,8 +132,10 @@ final class DefaultStatusObjectServiceCrlTest { EVALUATION_TIME.minusSeconds(index + 1L).plusNanos(987_654_321L), reasons.get(index))); } - StatusObject status = runtime.framework().statusObjectGenerator().generate(command, entries); - X509CRLHolder crl = new X509CRLHolder(status.encoded().bytes()); + X509SignedObjectCompletion completion = runtime.framework().statusObjectGenerator().generate(command, + entrySource(entries)); + StatusObject status = runtime.signingBus().authority().requireStatusCompletion(completion); + X509CRLHolder crl = new X509CRLHolder(PkiTestRuntime.readContent(runtime.signingBus(), status.content())); for (CrlEntry entry : entries) { X509CRLEntryHolder encoded = crl.getRevokedCertificate(entry.serialNumber()); assertTrue(encoded != null, () -> "missing serial " + entry.serialNumber().bitLength()); @@ -165,10 +170,10 @@ final class DefaultStatusObjectServiceCrlTest { StatusObject status = runtime.statusObjectService().generate(new StatusObjectGenerateCommand(caId, StatusObjectType.CRL, runtime.framework().formatId(), emptyAttributes())); - X509CRLHolder crl = new X509CRLHolder(status.encoded().bytes()); - X509CertificateHolderView heldCertificate = certificate(held); - X509CertificateHolderView permanentCertificate = certificate(permanent); - X509CertificateHolderView clearCertificate = certificate(clear); + X509CRLHolder crl = new X509CRLHolder(PkiTestRuntime.readContent(runtime.signingBus(), status.content())); + X509CertificateHolderView heldCertificate = certificate(runtime, held); + X509CertificateHolderView permanentCertificate = certificate(runtime, permanent); + X509CertificateHolderView clearCertificate = certificate(runtime, clear); assertEntry(crl, heldCertificate.serial(), heldJournal.latest().time(), RevocationReason.CERTIFICATE_HOLD); assertEntry(crl, permanentCertificate.serial(), permanentJournal.latest().time(), RevocationReason.AA_COMPROMISE); @@ -190,30 +195,33 @@ final class DefaultStatusObjectServiceCrlTest { assertCrlFailure(runtime, command, List.of(journal(new PkiId("credential:missing"), RevocationState.HELD, EVALUATION_TIME.minusSeconds(1), Optional.empty())), Map.of(), false); - Credential wrongFormat = copy(template, "wrong-format", new FormatId("not-x509"), template.encoded()); + Credential wrongFormat = copy(template, "wrong-format", new FormatId("not-x509"), template.content()); assertCrlFailure( runtime, command, List.of(journal(wrongFormat.credentialId(), RevocationState.HELD, EVALUATION_TIME.minusSeconds(1), Optional.empty())), Map.of(wrongFormat.credentialId(), wrongFormat), false); + zeroecho.pki.api.content.DurableContentReference originalContent = template.content(); + zeroecho.pki.api.content.DurableContentReference wrongEncodingContent = + zeroecho.pki.testkit.PkiTestRuntime.untrustedReference(originalContent, Encoding.PEM); Credential wrongEncoding = copy(template, "wrong-encoding", BcX509CredentialFramework.FORMAT_ID, - new EncodedObject(Encoding.PEM, template.encoded().bytes())); + wrongEncodingContent); assertCrlFailure( runtime, command, List.of(journal(wrongEncoding.credentialId(), RevocationState.HELD, EVALUATION_TIME.minusSeconds(1), Optional.empty())), Map.of(wrongEncoding.credentialId(), wrongEncoding), false); Credential malformed = copy(template, "malformed", BcX509CredentialFramework.FORMAT_ID, - new EncodedObject(Encoding.DER, new byte[] { 1, 2, 3 })); + runtime.stageCredential(new byte[] { 1, 2, 3 })); assertCrlFailure( runtime, command, List.of(journal(malformed.credentialId(), RevocationState.HELD, EVALUATION_TIME.minusSeconds(1), Optional.empty())), Map.of(malformed.credentialId(), malformed), false); Credential duplicateOne = copy(template, "duplicate-one", BcX509CredentialFramework.FORMAT_ID, - template.encoded()); + template.content()); Credential duplicateTwo = copy(template, "duplicate-two", BcX509CredentialFramework.FORMAT_ID, - template.encoded()); + template.content()); assertCrlFailure(runtime, command, List.of(journal(duplicateOne.credentialId(), RevocationState.HELD, EVALUATION_TIME.minusSeconds(1), Optional.empty()), @@ -222,7 +230,7 @@ final class DefaultStatusObjectServiceCrlTest { Map.of(duplicateOne.credentialId(), duplicateOne, duplicateTwo.credentialId(), duplicateTwo), false); - Credential future = copy(template, "future", BcX509CredentialFramework.FORMAT_ID, template.encoded()); + Credential future = copy(template, "future", BcX509CredentialFramework.FORMAT_ID, template.content()); assertCrlFailure( runtime, command, List.of(journal(future.credentialId(), RevocationState.HELD, EVALUATION_TIME.plusSeconds(1), Optional.empty())), @@ -250,7 +258,8 @@ final class DefaultStatusObjectServiceCrlTest { throw new IllegalStateException(SENTINEL); }; DefaultStatusObjectService generatorFailureService = new DefaultStatusObjectService(runtime.store(), - frameworkView(runtime.framework(), hostileGenerator), runtime.auditSink(), usableResolver()); + frameworkView(runtime.framework(), hostileGenerator), runtime.auditSink(), usableResolver(), + runtime.signingBus().authority()); AtomicReference generatorResult = new AtomicReference<>(); PkiException generatorFailure = assertThrows(PkiException.class, () -> generatorResult.set(generatorFailureService.generate(command))); @@ -261,13 +270,11 @@ final class DefaultStatusObjectServiceCrlTest { assertEquals(signCount, runtime.submittedSignCount()); AtomicInteger persistenceCalls = new AtomicInteger(); - StatusObject generated = new StatusObject(new PkiId("status:controlled-crl"), command.formatId(), caId, - StatusObjectType.CRL, EVALUATION_TIME, Optional.empty(), - new EncodedObject(Encoding.DER, new byte[] { 1 }), emptyAttributes()); - StatusObjectGenerator controlledGenerator = (ignoredCommand, ignoredEntries) -> generated; + StatusObjectGenerator controlledGenerator = runtime.framework().statusObjectGenerator(); DefaultStatusObjectService persistenceFailureService = new DefaultStatusObjectService( failingPersistenceStore(runtime.store(), persistenceCalls), - frameworkView(runtime.framework(), controlledGenerator), runtime.auditSink(), usableResolver()); + frameworkView(runtime.framework(), controlledGenerator), runtime.auditSink(), usableResolver(), + runtime.signingBus().authority()); AtomicReference persistenceResult = new AtomicReference<>(); PkiException persistenceFailure = assertThrows(PkiException.class, () -> persistenceResult.set(persistenceFailureService.generate(command))); @@ -275,7 +282,7 @@ final class DefaultStatusObjectServiceCrlTest { assertNull(persistenceResult.get()); assertEquals(1, persistenceCalls.get()); assertEquals(statusCount, runtime.store().listStatusObjects(caId).size()); - assertEquals(signCount, runtime.submittedSignCount()); + assertEquals(signCount + 1, runtime.submittedSignCount()); } } @@ -285,7 +292,7 @@ final class DefaultStatusObjectServiceCrlTest { int statusCount = runtime.store().listStatusObjects(command.issuerCaId()).size(); PkiStore view = storeView(runtime.store(), journals, credentials, failListing); DefaultStatusObjectService service = new DefaultStatusObjectService(view, runtime.framework(), - runtime.auditSink(), usableResolver()); + runtime.auditSink(), usableResolver(), runtime.signingBus().authority()); PkiException failure = assertThrows(PkiException.class, () -> service.generate(command)); assertTrue(failure.getMessage().contains("code=CRL_GENERATION_FAILED")); @@ -299,11 +306,55 @@ final class DefaultStatusObjectServiceCrlTest { Map credentials, boolean failListing) { return (PkiStore) Proxy.newProxyInstance(PkiStore.class.getClassLoader(), new Class[] { PkiStore.class }, (proxy, method, arguments) -> { - if (method.getName().equals("listRevocationJournals")) { + if (method.getName().equals("openRevocationSnapshot")) { if (failListing) { throw new IllegalStateException(SENTINEL); } - return journals; + List snapshot = List.copyOf(journals); + return new zeroecho.pki.spi.store.RevocationSnapshot() { + @Override + public String snapshotId() { + return "test-snapshot"; + } + + @Override + public java.util.OptionalLong count() { + return java.util.OptionalLong.of(snapshot.size()); + } + + @Override + public Cursor openCursor() { + return new Cursor() { + private int index = -1; + + @Override + public boolean next() { + index++; + return index < snapshot.size(); + } + + @Override + public RevocationJournal current() { + return snapshot.get(index); + } + + @Override + public long ordinal() { + return index; + } + + @Override + public void close() { + // No resources. + } + }; + } + + @Override + public void close() { + // No resources. + } + }; } if (method.getName().equals("getCredential")) { PkiId id = (PkiId) arguments[0]; @@ -380,16 +431,19 @@ final class DefaultStatusObjectServiceCrlTest { List.of(new RevocationTransition(1L, state, time, reason, emptyAttributes()))); } - private static Credential copy(Credential template, String suffix, FormatId formatId, EncodedObject encoded) { + private static Credential copy(Credential template, String suffix, FormatId formatId, + zeroecho.pki.api.content.DurableContentReference content) { CaProfileBinding binding = assertInstanceOf(CaProfileBinding.class, template.profileBinding()); return new Credential(new PkiId("credential:" + suffix), formatId, template.issuerRef(), template.subjectRef(), template.validity(), template.serialOrUniqueId(), template.publicKeyId(), - new CaProfileBinding(binding.reference()), CredentialStatus.ISSUED, encoded, template.attributes()); + new CaProfileBinding(binding.reference()), CredentialStatus.ISSUED, content, template.attributes()); } - private static StatusObjectGenerateCommand crlCommand(PkiId caId, Credential issuer, KeyRef keyRef) { + private static StatusObjectGenerateCommand crlCommand(PkiTestRuntime runtime, PkiId caId, Credential issuer, + KeyRef keyRef) throws Exception { AttributeSet attributes = SimpleAttributeSet.builder() - .put(BcX509Attributes.ISSUER_CERT_DER, new AttributeValue.BytesValue(issuer.encoded().bytes())) + .put(BcX509Attributes.ISSUER_CERT_DER, + new AttributeValue.BytesValue(runtime.credentialBytes(issuer))) .put(BcX509Attributes.ISSUER_KEYREF, new AttributeValue.StringValue(keyRef.value())).build(); return new StatusObjectGenerateCommand(caId, StatusObjectType.CRL, BcX509CredentialFramework.FORMAT_ID, attributes); @@ -440,6 +494,49 @@ final class DefaultStatusObjectServiceCrlTest { return List.copyOf(values); } + private static CrlEntrySource entrySource(List entries) { + List snapshot = List.copyOf(entries); + return new CrlEntrySource() { + @Override + public Cursor openCursor() { + return new Cursor() { + private int index = -1; + + @Override + public boolean next() { + index++; + return index < snapshot.size(); + } + + @Override + public CrlEntry current() { + return snapshot.get(index); + } + + @Override + public long ordinal() { + return index; + } + + @Override + public void close() { + // No resources. + } + }; + } + + @Override + public java.util.OptionalLong count() { + return java.util.OptionalLong.of(snapshot.size()); + } + + @Override + public void close() { + // No resources. + } + }; + } + private static int encodedReason(X509CRLEntryHolder entry) { org.bouncycastle.asn1.x509.Extension extension = entry.getExtension(Extension.reasonCode); if (extension == null) { @@ -470,8 +567,9 @@ final class DefaultStatusObjectServiceCrlTest { assertEquals(reasonCode(reason), encodedReason(entry)); } - private static X509CertificateHolderView certificate(Credential credential) throws Exception { - byte[] encoded = credential.encoded().bytes(); + private static X509CertificateHolderView certificate(PkiTestRuntime runtime, Credential credential) + throws Exception { + byte[] encoded = runtime.credentialBytes(credential); try { return new X509CertificateHolderView( new org.bouncycastle.cert.X509CertificateHolder(encoded).getSerialNumber()); diff --git a/pki/src/test/java/zeroecho/pki/impl/core/H7ProfileEnforcementTest.java b/pki/src/test/java/zeroecho/pki/impl/core/H7ProfileEnforcementTest.java index 46b1f50..39281ef 100644 --- a/pki/src/test/java/zeroecho/pki/impl/core/H7ProfileEnforcementTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/core/H7ProfileEnforcementTest.java @@ -139,6 +139,9 @@ import zeroecho.pki.testkit.PkiTestRuntime; final class H7ProfileEnforcementTest { private static final Instant NOW = Instant.parse("2026-06-01T00:00:00Z"); + @TempDir + private Path fixtureDirectory; + @Test void sanCanonicalizationPreservesTypedIdentityAndRawUriEscapes() { assertEquals("www.example.com", new SubjectAlternativeName.DnsName("WWW.Example.COM").value()); @@ -372,7 +375,8 @@ final class H7ProfileEnforcementTest { } } - private static ValidatedCertificateRequest validate(byte[] encoded, String algorithm, CertificateProfile profile) { + private ValidatedCertificateRequest validate(byte[] encoded, String algorithm, CertificateProfile profile) + throws Exception { ParsedCertificationRequest request = parsedRequest(encoded); IssueEndEntityCommand command = new IssueEndEntityCommand(new PkiId("ca:h7"), request, profile.profileId(), Optional.empty()); @@ -380,7 +384,11 @@ final class H7ProfileEnforcementTest { request.publicKeyInfo(), ProofOfPossessionStatus.VERIFIED, command); CertificateProfileRef reference = new CertificateProfileRef(profile.profileId(), 1, new byte[CertificateProfileRef.HASH_BYTES]); - return CertificateProfileValidator.validate(candidate, profile, reference, issuerCredential(), NOW); + zeroecho.pki.impl.framework.x509.X509AlgorithmResolver.Policy policy = (suite, direction) -> true; + zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot authority = + zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot.compose(List.of(), List.of(), policy); + return CertificateProfileValidator.validate(candidate, profile, reference, issuerCredential(), NOW, + authority); } private static ParsedCertificationRequest parsedRequest(byte[] encoded) { @@ -391,12 +399,17 @@ final class H7ProfileEnforcementTest { List.of(new SubjectRdn(SubjectRdnType.COMMON_NAME, "Leaf")), List.of(), false, attributes); } - private static Credential issuerCredential() { + private Credential issuerCredential() throws Exception { + zeroecho.pki.impl.fs.FilesystemStagedContentStore staged = + new zeroecho.pki.impl.fs.FilesystemStagedContentStore(fixtureDirectory.resolve("references"), + "0123456789abcdef0123456789abcdef"); return new Credential(new PkiId("credential:issuer"), BcX509CredentialFramework.FORMAT_ID, new IssuerRef(new PkiId("ca:issuer")), new SubjectRef("CN=Issuer"), new Validity(NOW.minus(Duration.ofDays(1)), NOW.plus(Duration.ofDays(1000))), "1", new PkiId("spki:issuer"), new CaProfileBinding(new CertificateProfileRef("root", 1, new byte[32])), - CredentialStatus.ISSUED, new EncodedObject(Encoding.DER, new byte[] { 1 }), new SimpleAttributeSet()); + CredentialStatus.ISSUED, + zeroecho.pki.testkit.PkiTestRuntime.fixtureReference(staged, Encoding.DER, new byte[] { 1 }), + new SimpleAttributeSet()); } private static CertificateProfile policyWithFixedOrganization(String algorithm) { diff --git a/pki/src/test/java/zeroecho/pki/impl/core/StoreBackedEffectiveCredentialStatusResolverTest.java b/pki/src/test/java/zeroecho/pki/impl/core/StoreBackedEffectiveCredentialStatusResolverTest.java index 75fd940..db48289 100644 --- a/pki/src/test/java/zeroecho/pki/impl/core/StoreBackedEffectiveCredentialStatusResolverTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/core/StoreBackedEffectiveCredentialStatusResolverTest.java @@ -39,6 +39,8 @@ import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import java.lang.reflect.Proxy; +import java.io.IOException; +import java.nio.file.Path; import java.time.Clock; import java.time.Instant; import java.time.ZoneId; @@ -50,6 +52,7 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.Encoding; @@ -71,11 +74,15 @@ import zeroecho.pki.api.revocation.RevocationReason; import zeroecho.pki.api.revocation.RevocationState; import zeroecho.pki.api.revocation.RevocationTransition; import zeroecho.pki.impl.core.attr.SimpleAttributeSet; +import zeroecho.pki.impl.fs.FilesystemStagedContentStore; import zeroecho.pki.spi.store.PkiStore; final class StoreBackedEffectiveCredentialStatusResolverTest { private static final Instant NOW = Instant.parse("2026-06-01T12:00:00Z"); + @TempDir + private Path temporaryDirectory; + @Test void resolvesInventoryValidityAndCurrentRevocationPrecedence() { Credential usable = credential("usable", CredentialStatus.ISSUED, NOW.minusSeconds(60), NOW.plusSeconds(60)); @@ -171,7 +178,9 @@ final class StoreBackedEffectiveCredentialStatusResolverTest { new IssuerRef(new PkiId("ca:issuer")), new SubjectRef("CN=Audit"), new Validity(NOW.minusSeconds(60), NOW.plusSeconds(60)), "audit", new PkiId("key:audit"), new CaProfileBinding(new zeroecho.pki.api.profile.CertificateProfileRef("default", 1, new byte[32])), - CredentialStatus.ISSUED, new EncodedObject(Encoding.DER, new byte[] { 1 }), new SimpleAttributeSet()); + CredentialStatus.ISSUED, + fixtureReference(), + new SimpleAttributeSet()); AtomicReference recorded = new AtomicReference<>(); CredentialTrustAudit.rejected(recorded::set, NOW, credential, CredentialUse.END_ENTITY_ISSUER, @@ -228,12 +237,24 @@ final class StoreBackedEffectiveCredentialStatusResolverTest { List.of(new RevocationTransition(1L, state, time, permanentReason, new SimpleAttributeSet())))); } - private static Credential credential(String suffix, CredentialStatus status, Instant notBefore, Instant notAfter) { + private Credential credential(String suffix, CredentialStatus status, Instant notBefore, Instant notAfter) { return new Credential(new PkiId("credential:" + suffix), new FormatId("x509"), new IssuerRef(new PkiId("ca:issuer")), new SubjectRef("CN=" + suffix), new Validity(notBefore, notAfter), suffix, new PkiId("key:" + suffix), new CaProfileBinding(new zeroecho.pki.api.profile.CertificateProfileRef("default", 1, new byte[32])), - status, new EncodedObject(Encoding.DER, new byte[] { 1, 2, 3 }), new SimpleAttributeSet()); + status, fixtureReference(), + new SimpleAttributeSet()); + } + + private zeroecho.pki.api.content.DurableContentReference fixtureReference() { + try { + FilesystemStagedContentStore staged = new FilesystemStagedContentStore( + temporaryDirectory.resolve("references"), "0123456789abcdef0123456789abcdef"); + return zeroecho.pki.testkit.PkiTestRuntime.fixtureReference(staged, Encoding.DER, + new byte[] { 1, 2, 3 }); + } catch (IOException exception) { + throw new AssertionError("Unable to stage credential fixture", exception); + } } private static String messages(Throwable throwable) { diff --git a/pki/src/test/java/zeroecho/pki/impl/core/async/PkiSigningBusFailureTest.java b/pki/src/test/java/zeroecho/pki/impl/core/async/PkiSigningBusFailureTest.java index 862c5b5..1a76c42 100644 --- a/pki/src/test/java/zeroecho/pki/impl/core/async/PkiSigningBusFailureTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/core/async/PkiSigningBusFailureTest.java @@ -37,9 +37,13 @@ import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static zeroecho.pki.testkit.PkiTestRuntime.signingAuthority; +import static zeroecho.pki.testkit.PkiTestRuntime.stage; +import java.lang.reflect.Constructor; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Proxy; import java.nio.file.Path; @@ -49,6 +53,8 @@ import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.time.ZoneId; +import java.util.Arrays; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; @@ -64,7 +70,12 @@ import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import zeroecho.core.alg.BootstrapAlgorithmIdentities; +import zeroecho.core.spec.AlgorithmIdentity; +import zeroecho.core.spec.AlgorithmSuite; +import zeroecho.core.spi.AlgorithmExecutionCapability; import zeroecho.pki.api.EncodedObject; +import zeroecho.pki.api.content.DurableContentReference; import zeroecho.pki.api.Encoding; import zeroecho.pki.api.KeyRef; import zeroecho.pki.api.PkiException; @@ -76,6 +87,8 @@ import zeroecho.pki.api.orch.OrchestrationDurabilityPolicy; import zeroecho.pki.api.orch.WorkflowStateRecord; import zeroecho.pki.impl.fs.FilesystemPkiStore; import zeroecho.pki.impl.fs.FsPkiStoreOptions; +import zeroecho.pki.impl.framework.x509.X509AlgorithmResolver; +import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot; import zeroecho.pki.spi.crypto.SignatureWorkflow; import zeroecho.pki.spi.store.PkiStore; import zeroecho.pki.spi.store.SignWorkflowStore; @@ -84,11 +97,74 @@ import zeroecho.pki.util.async.AsyncState; final class PkiSigningBusFailureTest { + @Test + void constructorsRequireExplicitOwningSignAuthority(@TempDir Path tempDir) throws Exception { + System.out.println("constructorsRequireExplicitOwningSignAuthority"); + Constructor[] constructors = PkiSigningBus.class.getConstructors(); + assertEquals(2, constructors.length); + assertTrue(Arrays.stream(constructors) + .allMatch(constructor -> constructor.getParameterTypes()[constructor.getParameterCount() - 1] + == X509AuthoritySnapshot.class)); + + InMemorySignatureWorkflow signer = new InMemorySignatureWorkflow(Map.of(), false); + InMemorySignatureWorkflow foreignSigner = new InMemorySignatureWorkflow(Map.of(), false); + X509AuthoritySnapshot owning = signingAuthority(signer); + try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"), + FsPkiStoreOptions.defaults()); + PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("owning.log"), owning)) { + assertSame(owning, bus.authority()); + assertThrows(NullPointerException.class, + () -> new PkiSigningBus(store, signer, tempDir.resolve("null.log"), null)); + + X509AuthoritySnapshot foreign = signingAuthority(foreignSigner); + assertThrows(IllegalArgumentException.class, + () -> new PkiSigningBus(store, signer, tempDir.resolve("foreign.log"), foreign)); + + X509AuthoritySnapshot missing = X509AuthoritySnapshot.compose(List.of(), List.of(), + allowPolicy("constructor-missing")); + assertThrows(IllegalArgumentException.class, + () -> new PkiSigningBus(store, signer, tempDir.resolve("missing.log"), missing)); + + String implementationId = "workflow." + signer.id(); + AlgorithmExecutionCapability verifyOnly = exactWorkflowCapability(implementationId, + AlgorithmExecutionCapability.Direction.VERIFY); + X509AuthoritySnapshot verifyAuthority = X509AuthoritySnapshot.compose(List.of(), + List.of(() -> List.of(verifyOnly)), + List.of(X509AuthoritySnapshot.bindExecutor(implementationId, + AlgorithmExecutionCapability.Direction.VERIFY, signer)), + allowPolicy("constructor-verify-only")); + assertThrows(IllegalArgumentException.class, + () -> new PkiSigningBus(store, signer, tempDir.resolve("verify.log"), verifyAuthority)); + + AlgorithmExecutionCapability mismatched = exactWorkflowCapability("workflow.other", + AlgorithmExecutionCapability.Direction.SIGN); + X509AuthoritySnapshot mismatchedAuthority = X509AuthoritySnapshot.compose(List.of(), + List.of(() -> List.of(mismatched)), + List.of(X509AuthoritySnapshot.bindExecutor("workflow.other", + AlgorithmExecutionCapability.Direction.SIGN, signer)), + allowPolicy("constructor-mismatch")); + assertThrows(IllegalArgumentException.class, + () -> new PkiSigningBus(store, signer, tempDir.resolve("mismatch.log"), mismatchedAuthority)); + + assertThrows(X509AlgorithmResolver.ResolutionException.class, + () -> owning.plan(BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256, + BootstrapAlgorithmIdentities.RSA_PUBLIC_KEY, + AlgorithmExecutionCapability.Direction.VERIFY, Optional.of(implementationId), "test", + SignatureWorkflow.class)); + } finally { + foreignSigner.close(); + signer.close(); + } + System.out.println("...constructors=" + constructors.length); + System.out.println("...ok"); + } + @Test void acceptedCancellationWaitsForObservedTerminalProviderState(@TempDir Path tempDir) throws Exception { AcceptedDelayedCancellationWorkflow signer = new AcceptedDelayedCancellationWorkflow(); try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"), FsPkiStoreOptions.defaults()); - PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log"))) { + PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log"), + signingAuthority(signer))) { PkiId id = submit(bus); assertEquals(AsyncState.RUNNING, bus.status(id).orElseThrow().state()); @@ -114,7 +190,8 @@ final class PkiSigningBusFailureTest { MutableClock clock = new MutableClock(createdAt); ControlledWorkflow signer = new ControlledWorkflow(clock); try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"), FsPkiStoreOptions.defaults(), - clock); PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log"))) { + clock); PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log"), + signingAuthority(signer))) { PkiId id = submit(bus, Duration.ofSeconds(10)); assertEquals(AsyncState.RUNNING, bus.status(id).orElseThrow().state()); clock.set(createdAt.plusSeconds(10)); @@ -140,7 +217,7 @@ final class PkiSigningBusFailureTest { PkiId exact; PkiId late; try (FilesystemPkiStore store = new FilesystemPkiStore(storeRoot, FsPkiStoreOptions.defaults(), clock); - PkiSigningBus bus = new PkiSigningBus(store, signer, busLog)) { + PkiSigningBus bus = new PkiSigningBus(store, signer, busLog, signingAuthority(signer))) { PkiId onTime = submit(bus, Duration.ofSeconds(10)); assertEquals(AsyncState.RUNNING, bus.status(onTime).orElseThrow().state()); signer.succeedAt(onTime, deadline.minusNanos(1), (byte) 31); @@ -162,7 +239,7 @@ final class PkiSigningBusFailureTest { } try (FilesystemPkiStore reopened = new FilesystemPkiStore(storeRoot, FsPkiStoreOptions.defaults(), clock); - PkiSigningBus replayed = new PkiSigningBus(reopened, signer, busLog)) { + PkiSigningBus replayed = new PkiSigningBus(reopened, signer, busLog, signingAuthority(signer))) { assertEquals(AsyncState.EXPIRED, replayed.status(exact).orElseThrow().state()); assertTrue(replayed.consumeResult(exact).isEmpty()); assertEquals(AsyncState.EXPIRED, replayed.status(late).orElseThrow().state()); @@ -179,7 +256,7 @@ final class PkiSigningBusFailureTest { Path busLog = tempDir.resolve("bus.log"); PkiId id; try (FilesystemPkiStore store = new FilesystemPkiStore(storeRoot, FsPkiStoreOptions.defaults()); - PkiSigningBus bus = new PkiSigningBus(store, signer, busLog)) { + PkiSigningBus bus = new PkiSigningBus(store, signer, busLog, signingAuthority(signer))) { id = submit(bus); assertEquals(AsyncState.RUNNING, bus.status(id).orElseThrow().state()); bus.retireSignOperation(id, "provider completed"); @@ -189,7 +266,7 @@ final class PkiSigningBusFailureTest { assertEquals(1, signer.cancellations.get()); } try (FilesystemPkiStore reopened = new FilesystemPkiStore(storeRoot, FsPkiStoreOptions.defaults()); - PkiSigningBus replayed = new PkiSigningBus(reopened, signer, busLog)) { + PkiSigningBus replayed = new PkiSigningBus(reopened, signer, busLog, signingAuthority(signer))) { assertEquals(AsyncState.SUCCEEDED, replayed.status(id).orElseThrow().state()); assertArrayEquals(new byte[] { 11, 12 }, replayed.consumeResult(id).orElseThrow().bytes()); } @@ -199,7 +276,8 @@ final class PkiSigningBusFailureTest { void providerCallbackReturnsWhileSameOperationSubmissionIsBlocked(@TempDir Path tempDir) throws Exception { BlockingWorkflow signer = new BlockingWorkflow(); try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"), FsPkiStoreOptions.defaults()); - PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log")); + PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log"), + signingAuthority(signer)); ExecutorService executor = Executors.newFixedThreadPool(2)) { PkiId id = submit(bus); Future submission = executor.submit(() -> bus.status(id)); @@ -220,7 +298,7 @@ final class PkiSigningBusFailureTest { Path busLog = tempDir.resolve("bus.log"); PkiId id; try (FilesystemPkiStore store = new FilesystemPkiStore(storeRoot, FsPkiStoreOptions.defaults()); - PkiSigningBus bus = new PkiSigningBus(store, signer, busLog)) { + PkiSigningBus bus = new PkiSigningBus(store, signer, busLog, signingAuthority(signer))) { id = submit(bus); assertEquals(AsyncState.RUNNING, bus.status(id).orElseThrow().state()); SignWorkflowStore.Record dispatched = store.getSignRecord(id).orElseThrow(); @@ -228,7 +306,7 @@ final class PkiSigningBusFailureTest { Optional.of("CANCEL_REQUESTED"), Optional.empty(), Optional.empty()).orElseThrow(); } try (FilesystemPkiStore reopened = new FilesystemPkiStore(storeRoot, FsPkiStoreOptions.defaults()); - PkiSigningBus replayed = new PkiSigningBus(reopened, signer, busLog)) { + PkiSigningBus replayed = new PkiSigningBus(reopened, signer, busLog, signingAuthority(signer))) { assertEquals(AsyncState.RUNNING, replayed.status(id).orElseThrow().state()); replayed.retireSignOperation(id, "restart cancellation"); assertEquals(SignWorkflowStore.State.RETIRED, reopened.getSignRecord(id).orElseThrow().state()); @@ -240,7 +318,8 @@ final class PkiSigningBusFailureTest { void retirementReconcilesCompletionAndAdvisoryCallbackWithoutResultLoss(@TempDir Path tempDir) throws Exception { RetirementRaceWorkflow signer = new RetirementRaceWorkflow(); try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"), FsPkiStoreOptions.defaults()); - PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log")); + PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log"), + signingAuthority(signer)); ExecutorService executor = Executors.newFixedThreadPool(2)) { PkiId id = submit(bus); assertEquals(AsyncState.RUNNING, bus.status(id).orElseThrow().state()); @@ -265,7 +344,8 @@ final class PkiSigningBusFailureTest { void pollAndRetirementSerializeAndPreserveProviderCompletion(@TempDir Path tempDir) throws Exception { RetirementRaceWorkflow signer = new RetirementRaceWorkflow(); try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"), FsPkiStoreOptions.defaults()); - PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log")); + PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log"), + signingAuthority(signer)); ExecutorService executor = Executors.newFixedThreadPool(2)) { PkiId id = submit(bus); assertEquals(AsyncState.RUNNING, bus.status(id).orElseThrow().state()); @@ -288,7 +368,8 @@ final class PkiSigningBusFailureTest { void consumeAndRetirementShareCoordinatorWithoutResultLoss(@TempDir Path tempDir) throws Exception { RetirementRaceWorkflow signer = new RetirementRaceWorkflow(); try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"), FsPkiStoreOptions.defaults()); - PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log")); + PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log"), + signingAuthority(signer)); ExecutorService executor = Executors.newFixedThreadPool(2)) { PkiId id = submit(bus); assertEquals(AsyncState.RUNNING, bus.status(id).orElseThrow().state()); @@ -318,7 +399,8 @@ final class PkiSigningBusFailureTest { MutableClock clock = new MutableClock(createdAt); ControlledWorkflow signer = new ControlledWorkflow(clock); try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"), FsPkiStoreOptions.defaults(), - clock); PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log"))) { + clock); PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log"), + signingAuthority(signer))) { PkiId id = submit(bus, Duration.ofSeconds(10)); assertEquals(1, store.listWorkflowStates().size()); clock.set(createdAt.plusSeconds(10)); @@ -341,7 +423,8 @@ final class PkiSigningBusFailureTest { MutableClock clock = new MutableClock(createdAt); AcceptedDelayedCancellationWorkflow signer = new AcceptedDelayedCancellationWorkflow(clock); try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"), FsPkiStoreOptions.defaults(), - clock); PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log"))) { + clock); PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log"), + signingAuthority(signer))) { PkiId id = submit(bus, Duration.ofSeconds(10)); assertEquals(AsyncState.RUNNING, bus.status(id).orElseThrow().state()); assertEquals(1, signer.submissions.get()); @@ -376,15 +459,17 @@ final class PkiSigningBusFailureTest { BlockingWorkflow signer = new BlockingWorkflow(); try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"), FsPkiStoreOptions.defaults(), clock); - PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log")); + PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log"), + signingAuthority(signer)); ExecutorService executor = Executors.newFixedThreadPool(2)) { Principal owner = new Principal("TEST", "owner"); PkiId id = bus.newSubmissionId(); EncodedObject payload = new EncodedObject(Encoding.BINARY, new byte[] { 1 }); + DurableContentReference content = stage(bus, payload); AccessContext access = new AccessContext(owner, new Purpose("TEST"), Optional.empty(), Optional.empty()); PkiSigningBus.SignContinuation continuation = new PkiSigningBus.SignContinuation(access, "SHA256withRSA", - payload, new KeyRef("test"), Encoding.BINARY, Optional.empty()); - bus.submitSign(id, owner, new KeyRef("test"), "SHA256withRSA", payload, Duration.ofMinutes(5), + content, new KeyRef("test"), Encoding.BINARY, Optional.empty()); + bus.submitSign(id, owner, new KeyRef("test"), "SHA256withRSA", content, Duration.ofMinutes(5), Optional.of(continuation.encode())); Future first = executor.submit(() -> bus.status(id)); assertTrue(signer.entered.await(5, TimeUnit.SECONDS)); @@ -407,7 +492,8 @@ final class PkiSigningBusFailureTest { void providerCallsPermitCrossThreadReentryOutsideOperationLock(@TempDir Path tempDir) throws Exception { CrossThreadReentrantWorkflow signer = new CrossThreadReentrantWorkflow(); try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"), FsPkiStoreOptions.defaults()); - PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log"))) { + PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log"), + signingAuthority(signer))) { signer.attach(bus); PkiId id = submit(bus); assertEquals(AsyncState.RUNNING, bus.status(id).orElseThrow().state()); @@ -425,7 +511,8 @@ final class PkiSigningBusFailureTest { void blockedSubmissionDoesNotBlockAnotherOperation(@TempDir Path tempDir) throws Exception { SelectiveBlockingWorkflow signer = new SelectiveBlockingWorkflow(); try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"), FsPkiStoreOptions.defaults()); - PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log")); + PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log"), + signingAuthority(signer)); ExecutorService executor = Executors.newFixedThreadPool(2)) { PkiId blocked = submit(bus); Future first = executor.submit(() -> bus.status(blocked)); @@ -446,7 +533,8 @@ final class PkiSigningBusFailureTest { void providerStatusExceptionReleasesSingleFlightReservation(@TempDir Path tempDir) throws Exception { ThrowOnceStatusWorkflow signer = new ThrowOnceStatusWorkflow(); try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"), FsPkiStoreOptions.defaults()); - PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log"))) { + PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log"), + signingAuthority(signer))) { PkiId id = submit(bus); PkiException sanitized = assertThrows(PkiException.class, () -> bus.status(id)); assertTrue(sanitized.getMessage().contains("PROVIDER_STATUS_FAILED")); @@ -471,7 +559,8 @@ final class PkiSigningBusFailureTest { InMemorySignatureWorkflow signer = new InMemorySignatureWorkflow(Map.of(), false); try { assertThrows(PkiException.class, - () -> new PkiSigningBus(store, signer, tempDir.resolve("unsupported-bus.log"))); + () -> new PkiSigningBus(store, signer, tempDir.resolve("unsupported-bus.log"), + signingAuthority(signer))); } finally { signer.close(); } @@ -500,13 +589,14 @@ final class PkiSigningBusFailureTest { Path busLog = tempDir.resolve("bus.log"); Principal owner = new Principal("SYSTEM", "pki"); PkiId operationId; - try (PkiSigningBus bus = new PkiSigningBus(failingStore, signer, busLog)) { + try (PkiSigningBus bus = new PkiSigningBus(failingStore, signer, busLog, signingAuthority(signer))) { operationId = bus.canonicalizeOperationId(new PkiId("sign:failure"), owner); EncodedObject payload = new EncodedObject(Encoding.BINARY, new byte[] { 1, 2, 3 }); + DurableContentReference content = stage(bus, payload); AccessContext access = new AccessContext(owner, new Purpose("TEST"), Optional.empty(), Optional.empty()); PkiSigningBus.SignContinuation continuation = new PkiSigningBus.SignContinuation(access, "SHA256withRSA", - payload, keyRef, Encoding.BINARY, Optional.empty()); - bus.submitSign(operationId, owner, keyRef, "SHA256withRSA", payload, Duration.ofSeconds(5), + content, keyRef, Encoding.BINARY, Optional.empty()); + bus.submitSign(operationId, owner, keyRef, "SHA256withRSA", content, Duration.ofSeconds(5), Optional.of(continuation.encode())); bus.sweep(java.time.Instant.now()); @@ -517,7 +607,7 @@ final class PkiSigningBusFailureTest { assertFalse(signer.hasRunningOperations()); } - try (PkiSigningBus replayed = new PkiSigningBus(delegate, signer, busLog)) { + try (PkiSigningBus replayed = new PkiSigningBus(delegate, signer, busLog, signingAuthority(signer))) { assertEquals(AsyncState.CANCELLED, replayed.status(operationId).orElseThrow().state()); } finally { signer.close(); @@ -533,14 +623,56 @@ final class PkiSigningBusFailureTest { Principal owner = new Principal("TEST", "owner"); PkiId id = bus.newSubmissionId(); EncodedObject payload = new EncodedObject(Encoding.BINARY, new byte[] { 1 }); + DurableContentReference content; + try { + content = stage(bus, payload); + } catch (java.io.IOException exception) { + throw new IllegalStateException("Test content staging failed", exception); + } AccessContext access = new AccessContext(owner, new Purpose("TEST"), Optional.empty(), Optional.empty()); PkiSigningBus.SignContinuation continuation = new PkiSigningBus.SignContinuation(access, "SHA256withRSA", - payload, new KeyRef("test"), Encoding.BINARY, Optional.empty()); - bus.submitSign(id, owner, new KeyRef("test"), "SHA256withRSA", payload, ttl, + content, new KeyRef("test"), Encoding.BINARY, Optional.empty()); + bus.submitSign(id, owner, new KeyRef("test"), "SHA256withRSA", content, ttl, Optional.of(continuation.encode())); return id; } + private static AlgorithmExecutionCapability exactWorkflowCapability(String implementationId, + AlgorithmExecutionCapability.Direction supportedDirection) { + return new AlgorithmExecutionCapability() { + @Override + public String implementationId() { + return implementationId; + } + + @Override + public String domainFingerprint() { + return "constructor-test-v1:" + supportedDirection; + } + + @Override + public boolean supports(AlgorithmIdentity identity, AlgorithmSuite suite, Direction direction) { + return direction == supportedDirection + && BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256.equals(identity) + && BootstrapAlgorithmIdentities.RSA_PUBLIC_KEY.equals(suite.publicKey()); + } + }; + } + + private static X509AlgorithmResolver.Policy allowPolicy(String fingerprint) { + return new X509AlgorithmResolver.Policy() { + @Override + public boolean permits(AlgorithmSuite suite, AlgorithmExecutionCapability.Direction direction) { + return true; + } + + @Override + public String semanticFingerprint() { + return fingerprint; + } + }; + } + private static final class AcceptedDelayedCancellationWorkflow implements SignatureWorkflow { private final Clock clock; private final Map statuses = new java.util.concurrent.ConcurrentHashMap<>(); diff --git a/pki/src/test/java/zeroecho/pki/impl/core/async/PkiSigningBusOperatorApprovalTest.java b/pki/src/test/java/zeroecho/pki/impl/core/async/PkiSigningBusOperatorApprovalTest.java index 6dd3e5f..201dad2 100644 --- a/pki/src/test/java/zeroecho/pki/impl/core/async/PkiSigningBusOperatorApprovalTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/core/async/PkiSigningBusOperatorApprovalTest.java @@ -35,6 +35,8 @@ package zeroecho.pki.impl.core.async; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import static zeroecho.pki.testkit.PkiTestRuntime.signingAuthority; +import static zeroecho.pki.testkit.PkiTestRuntime.stage; import java.nio.file.Path; import java.security.KeyPair; @@ -59,6 +61,8 @@ import zeroecho.pki.api.audit.Purpose; import zeroecho.pki.impl.fs.FilesystemPkiStore; import zeroecho.pki.impl.fs.FsPkiStoreOptions; import zeroecho.pki.spi.crypto.SignatureWorkflow; +import zeroecho.core.io.ImmutableByteContent; +import zeroecho.pki.api.content.DurableContentReference; import zeroecho.pki.testkit.DurableOperatorApprovalSignatureWorkflow; import zeroecho.pki.util.async.AsyncState; import zeroecho.pki.util.async.AsyncStatus; @@ -78,7 +82,7 @@ public final class PkiSigningBusOperatorApprovalTest { AccessContext access = new AccessContext(new Principal("USER", "late"), new Purpose("ISSUANCE"), Optional.empty(), Optional.empty()); SignatureWorkflow.SignRequest request = SignatureWorkflow.SignRequest.create(new PkiId("late-approval"), - "test", 1L, access, keyRef, "SHA256withRSA", new EncodedObject(Encoding.BINARY, new byte[] { 1 }), + "test", 1L, access, keyRef, "SHA256withRSA", new ImmutableByteContent(new byte[] { 1 }), Optional.of(Encoding.BINARY), Optional.of(Instant.EPOCH)); PkiId operationId = signer.submitSign(request); @@ -106,7 +110,7 @@ public final class PkiSigningBusOperatorApprovalTest { try (FilesystemPkiStore store = new FilesystemPkiStore(storeRoot, options); DurableOperatorApprovalSignatureWorkflow signer = new DurableOperatorApprovalSignatureWorkflow(wfRoot, Duration.ofSeconds(10), Duration.ofMillis(100), Map.of(keyRef.value(), kp.getPrivate())); - PkiSigningBus bus = new PkiSigningBus(store, signer, busFile)) { + PkiSigningBus bus = new PkiSigningBus(store, signer, busFile, signingAuthority(signer))) { Principal owner = new Principal("USER", "alice"); PkiId clientOpId = new PkiId("approve-1"); @@ -115,13 +119,14 @@ public final class PkiSigningBusOperatorApprovalTest { byte[] payload = "approval".getBytes(java.nio.charset.StandardCharsets.UTF_8); EncodedObject payloadObj = new EncodedObject(Encoding.BINARY, payload); + DurableContentReference content = stage(bus, payloadObj); AccessContext ac = new AccessContext(owner, new Purpose("ISSUANCE"), Optional.empty(), Optional.empty()); - PkiSigningBus.SignContinuation cont = new PkiSigningBus.SignContinuation(ac, "SHA256withRSA", payloadObj, + PkiSigningBus.SignContinuation cont = new PkiSigningBus.SignContinuation(ac, "SHA256withRSA", content, keyRef, Encoding.BINARY, Optional.empty()); Instant t0 = Instant.now(); - bus.submitSign(opId, owner, keyRef, "SHA256withRSA", payloadObj, Duration.ofSeconds(30), + bus.submitSign(opId, owner, keyRef, "SHA256withRSA", content, Duration.ofSeconds(30), Optional.of(cont.encode())); long submitMs = Duration.between(t0, Instant.now()).toMillis(); System.out.println("...submitMs=" + submitMs); @@ -167,7 +172,7 @@ public final class PkiSigningBusOperatorApprovalTest { try (FilesystemPkiStore store = new FilesystemPkiStore(storeRoot, options); DurableOperatorApprovalSignatureWorkflow signer = new DurableOperatorApprovalSignatureWorkflow(wfRoot, Duration.ofSeconds(10), Duration.ofMillis(0), Map.of(keyRef.value(), kp.getPrivate())); - PkiSigningBus bus = new PkiSigningBus(store, signer, busFile)) { + PkiSigningBus bus = new PkiSigningBus(store, signer, busFile, signingAuthority(signer))) { Principal owner = new Principal("USER", "bob"); PkiId clientOpId = new PkiId("deny-1"); @@ -176,12 +181,13 @@ public final class PkiSigningBusOperatorApprovalTest { byte[] payload = "deny".getBytes(java.nio.charset.StandardCharsets.UTF_8); EncodedObject payloadObj = new EncodedObject(Encoding.BINARY, payload); + DurableContentReference content = stage(bus, payloadObj); AccessContext ac = new AccessContext(owner, new Purpose("ISSUANCE"), Optional.empty(), Optional.empty()); - PkiSigningBus.SignContinuation cont = new PkiSigningBus.SignContinuation(ac, "SHA256withRSA", payloadObj, + PkiSigningBus.SignContinuation cont = new PkiSigningBus.SignContinuation(ac, "SHA256withRSA", content, keyRef, Encoding.BINARY, Optional.empty()); - bus.submitSign(opId, owner, keyRef, "SHA256withRSA", payloadObj, Duration.ofSeconds(30), + bus.submitSign(opId, owner, keyRef, "SHA256withRSA", content, Duration.ofSeconds(30), Optional.of(cont.encode())); PkiId signerOpId = waitSignerOpId(store, bus, opId); @@ -237,7 +243,7 @@ public final class PkiSigningBusOperatorApprovalTest { Optional wsOpt = store.getWorkflowState(new PkiId(base)); if (wsOpt.isPresent() && wsOpt.get().payload().isPresent()) { PkiSigningBus.SignContinuation cont = PkiSigningBus.SignContinuation - .decode(wsOpt.get().payload().get()); + .decode(wsOpt.get().payload().get(), store.stagedContent()); if (cont.signerOpId().isPresent()) { return cont.signerOpId().get(); } diff --git a/pki/src/test/java/zeroecho/pki/impl/core/async/PkiSigningBusResilienceTest.java b/pki/src/test/java/zeroecho/pki/impl/core/async/PkiSigningBusResilienceTest.java index d865d39..7517035 100644 --- a/pki/src/test/java/zeroecho/pki/impl/core/async/PkiSigningBusResilienceTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/core/async/PkiSigningBusResilienceTest.java @@ -34,6 +34,8 @@ package zeroecho.pki.impl.core.async; import static org.junit.jupiter.api.Assertions.assertTrue; +import static zeroecho.pki.testkit.PkiTestRuntime.signingAuthority; +import static zeroecho.pki.testkit.PkiTestRuntime.stage; import java.nio.file.Path; import java.security.KeyPair; @@ -50,6 +52,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import zeroecho.pki.api.EncodedObject; +import zeroecho.pki.api.content.DurableContentReference; import zeroecho.pki.api.Encoding; import zeroecho.pki.api.KeyRef; import zeroecho.pki.api.PkiId; @@ -96,17 +99,18 @@ public final class PkiSigningBusResilienceTest { try (FilesystemPkiStore store = new FilesystemPkiStore(storeRoot, options); DurableOperatorApprovalSignatureWorkflow signer = new DurableOperatorApprovalSignatureWorkflow(wfRoot, Duration.ofSeconds(10), Duration.ofMillis(100), Map.of(keyRef.value(), kp.getPrivate())); - PkiSigningBus bus = new PkiSigningBus(store, signer, busFile)) { + PkiSigningBus bus = new PkiSigningBus(store, signer, busFile, signingAuthority(signer))) { opId = bus.canonicalizeOperationId(clientOpId, owner); System.out.println("...opId=" + opId.value()); + DurableContentReference content = stage(bus, payloadObj); AccessContext ac = new AccessContext(owner, new Purpose("ISSUANCE"), Optional.empty(), Optional.empty()); - PkiSigningBus.SignContinuation cont = new PkiSigningBus.SignContinuation(ac, "SHA256withRSA", payloadObj, + PkiSigningBus.SignContinuation cont = new PkiSigningBus.SignContinuation(ac, "SHA256withRSA", content, keyRef, Encoding.BINARY, Optional.empty()); Instant t0 = Instant.now(); - bus.submitSign(opId, owner, keyRef, "SHA256withRSA", payloadObj, Duration.ofSeconds(30), + bus.submitSign(opId, owner, keyRef, "SHA256withRSA", content, Duration.ofSeconds(30), Optional.of(cont.encode())); long submitMs = Duration.between(t0, Instant.now()).toMillis(); System.out.println("...submitMs=" + submitMs); @@ -135,7 +139,7 @@ public final class PkiSigningBusResilienceTest { try (FilesystemPkiStore store2 = new FilesystemPkiStore(storeRoot, options); DurableOperatorApprovalSignatureWorkflow signer2 = new DurableOperatorApprovalSignatureWorkflow(wfRoot, Duration.ofSeconds(10), Duration.ofMillis(0), Map.of(keyRef.value(), kp.getPrivate())); - PkiSigningBus bus2 = new PkiSigningBus(store2, signer2, busFile)) { + PkiSigningBus bus2 = new PkiSigningBus(store2, signer2, busFile, signingAuthority(signer2))) { PkiId opId2 = opId; System.out.println("...opIdReattach=" + opId2.value()); @@ -181,7 +185,7 @@ public final class PkiSigningBusResilienceTest { .getWorkflowState(new PkiId(opId.value().split("#")[0])); if (wsOpt.isPresent() && wsOpt.get().payload().isPresent()) { PkiSigningBus.SignContinuation cont = PkiSigningBus.SignContinuation - .decode(wsOpt.get().payload().get()); + .decode(wsOpt.get().payload().get(), store.stagedContent()); if (cont.signerOpId().isPresent()) { return cont.signerOpId().get(); } diff --git a/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibKeyRefParsingTest.java b/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibKeyRefParsingTest.java index 48c3de1..1504d9c 100644 --- a/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibKeyRefParsingTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibKeyRefParsingTest.java @@ -45,6 +45,7 @@ import java.util.Optional; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import zeroecho.core.io.CancellationSignal; import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.Encoding; import zeroecho.pki.api.KeyRef; @@ -54,6 +55,7 @@ import zeroecho.pki.api.audit.Principal; import zeroecho.pki.api.audit.Purpose; import zeroecho.pki.api.orch.SigningSubmissionId; import zeroecho.pki.spi.crypto.SignatureWorkflow; +import zeroecho.core.io.ImmutableByteContent; public final class ZeroEchoLibKeyRefParsingTest { @@ -75,7 +77,7 @@ public final class ZeroEchoLibKeyRefParsingTest { PkiId submissionId = SigningSubmissionId.create(NAMESPACE, Instant.now(), new SecureRandom()).id(); SignatureWorkflow.SignRequest req = SignatureWorkflow.SignRequest.create(submissionId, NAMESPACE, 1L, ctx, - new KeyRef("zeroecho-lib:abc"), "ECDSA", new EncodedObject(Encoding.BINARY, new byte[] { 0x01 }), + new KeyRef("zeroecho-lib:abc"), "ECDSA", new ImmutableByteContent(new byte[] { 0x01 }), Optional.of(Encoding.BINARY), Optional.of(Instant.now())); PkiId opId = wf.submitSign(req); @@ -102,10 +104,10 @@ public final class ZeroEchoLibKeyRefParsingTest { Optional.empty(), Optional.empty()); SignatureWorkflow.VerifyRequest req = new SignatureWorkflow.VerifyRequest(ctx, "ECDSA", - new EncodedObject(Encoding.BINARY, new byte[] { 0x01 }), + new ImmutableByteContent(new byte[] { 0x01 }), new EncodedObject(Encoding.BINARY, new byte[] { 0x02 }), Optional.empty(), Optional.of(new EncodedObject(Encoding.BINARY, new byte[] { 0x03 })), // unsupported form - Optional.of(Instant.now())); + Optional.of(Instant.now()), CancellationSignal.NONE); PkiId opId = wf.submitVerify(req); SignatureWorkflow.OperationStatus st = wf.status(opId); diff --git a/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowPersistenceTest.java b/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowPersistenceTest.java index a3b9575..f035332 100644 --- a/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowPersistenceTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowPersistenceTest.java @@ -68,6 +68,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import zeroecho.core.storage.KeyringStore; +import zeroecho.core.io.CancellationSignal; +import zeroecho.core.io.ImmutableByteContent; import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.Encoding; import zeroecho.pki.api.KeyRef; @@ -134,23 +136,21 @@ final class ZeroEchoLibSignatureWorkflowPersistenceTest { AccessContext access = signRequest.accessContext(); SignatureWorkflow.VerifyRequest verifyRequest = new SignatureWorkflow.VerifyRequest(access, "SHA256withRSA", - new EncodedObject(Encoding.BINARY, message), signature, - Optional.of(new KeyRef("zeroecho-lib:test.pub")), Optional.empty(), Optional.empty()); + new ImmutableByteContent(message), signature, Optional.of(new KeyRef("zeroecho-lib:test.pub")), + Optional.empty(), Optional.empty(), CancellationSignal.NONE); PkiId verifyId = workflow.submitVerify(verifyRequest); assertEquals(Optional.of(true), workflow.status(verifyId).result().orElseThrow().verified()); byte[] invalidBytes = signature.bytes(); invalidBytes[0] ^= 0x01; SignatureWorkflow.VerifyRequest invalidRequest = new SignatureWorkflow.VerifyRequest(access, - "SHA256withRSA", new EncodedObject(Encoding.BINARY, message), + "SHA256withRSA", new ImmutableByteContent(message), new EncodedObject(Encoding.BINARY, invalidBytes), Optional.of(new KeyRef("zeroecho-lib:test.pub")), - Optional.empty(), Optional.empty()); + Optional.empty(), Optional.empty(), CancellationSignal.NONE); PkiId invalidId = workflow.submitVerify(invalidRequest); assertEquals(Optional.of(false), workflow.status(invalidId).result().orElseThrow().verified()); - assertTrue(cleared.stream().anyMatch(value -> "sign-payload".equals(value.category()))); assertTrue(cleared.stream().anyMatch(value -> "sign-result-copy".equals(value.category()))); - assertTrue(cleared.stream().anyMatch(value -> "verify-payload".equals(value.category()))); assertTrue(cleared.stream().anyMatch(value -> "verify-signature".equals(value.category()))); assertTrue(cleared.stream().anyMatch(value -> "persisted-operation-buffer".equals(value.category()))); assertTrue(cleared.stream().allMatch(value -> isCleared(value.bytes()))); @@ -248,12 +248,12 @@ final class ZeroEchoLibSignatureWorkflowPersistenceTest { PkiId id = SigningSubmissionId.create(NAMESPACE, Instant.parse("2026-02-03T04:05:06Z"), new SecureRandom()) .id(); SignatureWorkflow.SignRequest request = request(id, 1L, encoded.bytes()); - byte[] requestBytes = request.payload().bytes(); + byte[] requestBytes = ((ImmutableByteContent) request.content()).copyBytes(); requestBytes[2] = 9; - assertEquals(3, request.payload().bytes()[2]); + assertEquals(3, ((ImmutableByteContent) request.content()).copyBytes()[2]); assertEquals(request.semanticFingerprint(), SignatureWorkflow.SignRequest.fingerprint(NAMESPACE, request.accessContext(), request.keyRef(), - request.algorithmId(), request.payload(), request.preferredSignatureEncoding(), + request.algorithmId(), request.content(), request.preferredSignatureEncoding(), request.deadline())); } @@ -372,7 +372,7 @@ final class ZeroEchoLibSignatureWorkflowPersistenceTest { AccessContext access = new AccessContext(new Principal("TEST", "owner"), new Purpose("TEST"), Optional.empty(), Optional.empty()); return SignatureWorkflow.SignRequest.create(id, NAMESPACE, fence, access, keyRef, "SHA256withRSA", - new EncodedObject(Encoding.BINARY, payload), Optional.of(Encoding.BINARY), deadline); + new ImmutableByteContent(payload), Optional.of(Encoding.BINARY), deadline); } private static void forcePersistedStateCode(Path operations, int from, int to) { diff --git a/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowVerifyEncodedEcdsaTest.java b/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowVerifyEncodedEcdsaTest.java index 06528af..42c5f95 100644 --- a/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowVerifyEncodedEcdsaTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowVerifyEncodedEcdsaTest.java @@ -56,6 +56,8 @@ import zeroecho.pki.api.audit.AccessContext; import zeroecho.pki.api.audit.Principal; import zeroecho.pki.api.audit.Purpose; import zeroecho.pki.spi.crypto.SignatureWorkflow; +import zeroecho.core.io.CancellationSignal; +import zeroecho.core.io.ImmutableByteContent; /** * Verifies that workflow-based signature verification accepts standard X.509 @@ -96,8 +98,9 @@ public final class ZeroEchoLibSignatureWorkflowVerifyEncodedEcdsaTest { AccessContext ac = new AccessContext(new Principal("TEST", "verify-ecdsa"), new Purpose("UNIT"), Optional.empty(), Optional.empty()); - SignatureWorkflow.VerifyRequest vr = new SignatureWorkflow.VerifyRequest(ac, "SHA256withECDSA", payloadObj, - sigObj, Optional.empty(), Optional.of(spkiObj), Optional.of(Instant.now().plusSeconds(5))); + SignatureWorkflow.VerifyRequest vr = new SignatureWorkflow.VerifyRequest(ac, "SHA256withECDSA", + new ImmutableByteContent(payload), sigObj, Optional.empty(), Optional.of(spkiObj), + Optional.of(Instant.now().plusSeconds(5)), CancellationSignal.NONE); PkiId opId = wf.submitVerify(vr); SignatureWorkflow.OperationStatus st = wf.status(opId); diff --git a/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowVerifyEncodedTest.java b/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowVerifyEncodedTest.java index 521475a..1c51608 100644 --- a/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowVerifyEncodedTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowVerifyEncodedTest.java @@ -55,6 +55,8 @@ import zeroecho.pki.api.audit.AccessContext; import zeroecho.pki.api.audit.Principal; import zeroecho.pki.api.audit.Purpose; import zeroecho.pki.spi.crypto.SignatureWorkflow; +import zeroecho.core.io.CancellationSignal; +import zeroecho.core.io.ImmutableByteContent; public final class ZeroEchoLibSignatureWorkflowVerifyEncodedTest { @@ -90,8 +92,9 @@ public final class ZeroEchoLibSignatureWorkflowVerifyEncodedTest { AccessContext ac = new AccessContext(new Principal("TEST", "verify"), new Purpose("UNIT"), Optional.empty(), Optional.empty()); - SignatureWorkflow.VerifyRequest vr = new SignatureWorkflow.VerifyRequest(ac, "SHA256withRSA", payloadObj, - sigObj, Optional.empty(), Optional.of(spkiObj), Optional.of(Instant.now().plusSeconds(5))); + SignatureWorkflow.VerifyRequest vr = new SignatureWorkflow.VerifyRequest(ac, "SHA256withRSA", + new ImmutableByteContent(payload), sigObj, Optional.empty(), Optional.of(spkiObj), + Optional.of(Instant.now().plusSeconds(5)), CancellationSignal.NONE); PkiId opId = wf.submitVerify(vr); SignatureWorkflow.OperationStatus st = wf.status(opId); diff --git a/pki/src/test/java/zeroecho/pki/impl/framework/x509/StreamingDerReaderTest.java b/pki/src/test/java/zeroecho/pki/impl/framework/x509/StreamingDerReaderTest.java new file mode 100644 index 0000000..f2065fd --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/impl/framework/x509/StreamingDerReaderTest.java @@ -0,0 +1,207 @@ +/******************************************************************************* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.ByteArrayInputStream; +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import java.util.OptionalLong; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; + +import zeroecho.core.io.CancellationSignal; +import zeroecho.core.io.ImmutableByteContent; +import zeroecho.core.io.RepeatableContent; +import zeroecho.pki.impl.framework.x509.StreamingDerReader.SignedObjectKind; +import zeroecho.pki.impl.framework.x509.StreamingDerReader.SignedObjectLayout; + +final class StreamingDerReaderTest { + + private static final byte[] CRL = { + 0x30, 0x26, + 0x30, 0x1a, + 0x02, 0x01, 0x01, + 0x30, 0x04, 0x06, 0x02, 0x2a, 0x03, + 0x30, 0x00, + 0x17, 0x0d, 0x32, 0x36, 0x30, 0x31, 0x30, 0x31, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x5a, + 0x30, 0x04, 0x06, 0x02, 0x2a, 0x03, + 0x03, 0x02, 0x00, (byte) 0xaa + }; + + @Test + void validatesCanonicalCrlAndLocatesSignedFields() throws Exception { + System.out.println("validatesCanonicalCrlAndLocatesSignedFields"); + StreamingDerReader reader = new StreamingDerReader(); + ImmutableByteContent content = new ImmutableByteContent(CRL); + SignedObjectLayout layout = reader.inspectSignedObject(content, SignedObjectKind.CRL, + CancellationSignal.NONE); + System.out.println("...encodedLength=" + reader.validate(content, CancellationSignal.NONE)); + assertEquals(2L, layout.tbsOffset()); + assertEquals(28L, layout.tbsLength()); + assertEquals(7L, layout.tbsAlgorithmOffset()); + assertEquals(6L, layout.tbsAlgorithmLength()); + assertEquals(30L, layout.outerAlgorithmOffset()); + assertEquals(6L, layout.outerAlgorithmLength()); + assertEquals(39L, layout.signatureOffset()); + assertEquals(1L, layout.signatureLength()); + System.out.println("validatesCanonicalCrlAndLocatesSignedFields...ok"); + } + + @Test + void rejectsTrailingIndefiniteAndNonMinimalLength() { + System.out.println("rejectsTrailingIndefiniteAndNonMinimalLength"); + StreamingDerReader reader = new StreamingDerReader(); + assertThrows(IOException.class, () -> reader.validate( + new ImmutableByteContent(Arrays.copyOf(CRL, CRL.length + 1)), CancellationSignal.NONE)); + byte[] indefinite = { 0x30, (byte) 0x80, 0x00, 0x00 }; + assertThrows(IOException.class, + () -> reader.validate(new ImmutableByteContent(indefinite), CancellationSignal.NONE)); + byte[] nonMinimal = { 0x30, (byte) 0x81, 0x00 }; + assertThrows(IOException.class, + () -> reader.validate(new ImmutableByteContent(nonMinimal), CancellationSignal.NONE)); + System.out.println("...rejected=3"); + System.out.println("rejectsTrailingIndefiniteAndNonMinimalLength...ok"); + } + + @Test + void rejectsNonCanonicalPrimitiveForms() { + System.out.println("rejectsNonCanonicalPrimitiveForms"); + StreamingDerReader reader = new StreamingDerReader(); + byte[] integer = { 0x02, 0x02, 0x00, 0x01 }; + byte[] oid = { 0x06, 0x01, (byte) 0x80 }; + byte[] bitString = { 0x03, 0x02, 0x01, 0x01 }; + assertThrows(IOException.class, + () -> reader.validate(new ImmutableByteContent(integer), CancellationSignal.NONE)); + assertThrows(IOException.class, () -> reader.validate(new ImmutableByteContent(oid), CancellationSignal.NONE)); + assertThrows(IOException.class, + () -> reader.validate(new ImmutableByteContent(bitString), CancellationSignal.NONE)); + System.out.println("...rejected=3"); + System.out.println("rejectsNonCanonicalPrimitiveForms...ok"); + } + + @Test + void enforcesCanonicalSetOrdering() throws Exception { + System.out.println("enforcesCanonicalSetOrdering"); + StreamingDerReader reader = new StreamingDerReader(); + byte[] canonical = { 0x31, 0x06, 0x02, 0x01, 0x01, 0x02, 0x01, 0x02 }; + byte[] reversed = { 0x31, 0x06, 0x02, 0x01, 0x02, 0x02, 0x01, 0x01 }; + assertEquals(canonical.length, + reader.validate(new ImmutableByteContent(canonical), CancellationSignal.NONE)); + assertThrows(IOException.class, + () -> reader.validate(new ImmutableByteContent(reversed), CancellationSignal.NONE)); + System.out.println("...rejected=reversed SET"); + System.out.println("enforcesCanonicalSetOrdering...ok"); + } + + @Test + void setOrderingUsesFixedSourcePasses() throws Exception { + System.out.println("setOrderingUsesFixedSourcePasses"); + byte[] encoded = repeatedIntegerSet(128); + CountingContent content = new CountingContent(encoded); + StreamingDerReader reader = new StreamingDerReader(); + + assertEquals(encoded.length, reader.validate(content, CancellationSignal.NONE)); + assertEquals(3, content.openCount()); + assertEquals(3, content.closeCount()); + System.out.println("...sourcePasses=" + content.openCount()); + System.out.println("setOrderingUsesFixedSourcePasses...ok"); + } + + private static byte[] repeatedIntegerSet(int count) { + int valueLength = Math.multiplyExact(count, 3); + byte[] encoded = new byte[Math.addExact(valueLength, 4)]; + encoded[0] = 0x31; + encoded[1] = (byte) 0x82; + encoded[2] = (byte) (valueLength >>> 8); + encoded[3] = (byte) valueLength; + for (int index = 4; index < encoded.length; index += 3) { + encoded[index] = 0x02; + encoded[index + 1] = 0x01; + encoded[index + 2] = 0x01; + } + return encoded; + } + + /** Repeatable test content that accounts for every fixed comparison pass. */ + private static final class CountingContent implements RepeatableContent { + private final byte[] encoded; + private final AtomicInteger opens = new AtomicInteger(); + private final AtomicInteger closes = new AtomicInteger(); + + private CountingContent(byte[] encoded) { + this.encoded = encoded.clone(); + } + + @Override + public InputStream openStream() { + opens.incrementAndGet(); + return new FilterInputStream(new ByteArrayInputStream(encoded)) { + @Override + public void close() throws IOException { + super.close(); + closes.incrementAndGet(); + } + }; + } + + @Override + public OptionalLong length() { + return OptionalLong.of(encoded.length); + } + + @Override + public String contentId() { + return "test:counting-set"; + } + + @Override + public void close() { + // The immutable test value owns no external resource. + } + + private int openCount() { + return opens.get(); + } + + private int closeCount() { + return closes.get(); + } + } +} diff --git a/pki/src/test/java/zeroecho/pki/impl/framework/x509/X509BindingPhaseATest.java b/pki/src/test/java/zeroecho/pki/impl/framework/x509/X509BindingPhaseATest.java new file mode 100644 index 0000000..4e3dbf7 --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/impl/framework/x509/X509BindingPhaseATest.java @@ -0,0 +1,422 @@ +/******************************************************************************* + * 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 static org.junit.jupiter.api.Assertions.assertArrayEquals; +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 java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.bouncycastle.asn1.ASN1ObjectIdentifier; +import org.bouncycastle.asn1.DERNull; +import org.bouncycastle.asn1.x509.AlgorithmIdentifier; +import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; +import org.junit.jupiter.api.Test; + +import zeroecho.core.alg.BootstrapAlgorithmIdentities; +import zeroecho.core.spec.AlgorithmIdentity; +import zeroecho.core.io.ImmutableByteContent; +import zeroecho.core.spec.AlgorithmSuite; +import zeroecho.core.spi.AlgorithmExecutionCapability; +import zeroecho.core.spi.AlgorithmExecutionCapabilityProvider; +import zeroecho.pki.impl.framework.x509.bc.BcX509AlgorithmAdapter; +import zeroecho.pki.impl.framework.x509.bc.BcX509VerificationExecutor; + +/** + * Phase A regression tests for immutable role-specific X.509 authority. + */ +public final class X509BindingPhaseATest { + + @Test + void bootstrapMatrixIsExactAndSymmetric() { + System.out.println("bootstrapMatrixIsExactAndSymmetric"); + Map signatures = Map.ofEntries( + Map.entry(BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256, + X509AlgorithmIdentifier.derNull("1.2.840.113549.1.1.11")), + Map.entry(BootstrapAlgorithmIdentities.RSA_PKCS1_SHA384, + X509AlgorithmIdentifier.derNull("1.2.840.113549.1.1.12")), + Map.entry(BootstrapAlgorithmIdentities.RSA_PKCS1_SHA512, + X509AlgorithmIdentifier.derNull("1.2.840.113549.1.1.13")), + Map.entry(BootstrapAlgorithmIdentities.ECDSA_SHA256, + X509AlgorithmIdentifier.absent("1.2.840.10045.4.3.2")), + Map.entry(BootstrapAlgorithmIdentities.ECDSA_SHA384, + X509AlgorithmIdentifier.absent("1.2.840.10045.4.3.3")), + Map.entry(BootstrapAlgorithmIdentities.ECDSA_SHA512, + X509AlgorithmIdentifier.absent("1.2.840.10045.4.3.4")), + Map.entry(BootstrapAlgorithmIdentities.ED25519_SIGNATURE, + X509AlgorithmIdentifier.absent("1.3.101.112")), + Map.entry(BootstrapAlgorithmIdentities.ED448_SIGNATURE, + X509AlgorithmIdentifier.absent("1.3.101.113"))); + X509BindingCatalog catalog = StandardX509Bindings.catalog(); + for (Map.Entry entry : signatures.entrySet()) { + assertEquals(entry.getValue(), catalog.resolve(entry.getKey(), X509AlgorithmRole.SIGNATURE_ALGORITHM)); + assertEquals(entry.getKey(), + catalog.reverse(entry.getValue(), X509AlgorithmRole.SIGNATURE_ALGORITHM)); + } + assertEquals(BootstrapAlgorithmIdentities.RSA_PUBLIC_KEY, + catalog.reverse(X509AlgorithmIdentifier.derNull("1.2.840.113549.1.1.1"), + X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM)); + System.out.println("...signature-bindings=" + signatures.size()); + System.out.println("...ok"); + } + + @Test + void parameterizedRsaPssRoundTripsAndRejectsWrongParameters() { + System.out.println("parameterizedRsaPssRoundTripsAndRejectsWrongParameters"); + AlgorithmIdentity identity = BootstrapAlgorithmIdentities.rsaPss(BootstrapAlgorithmIdentities.SHA384, + BootstrapAlgorithmIdentities.SHA512, 40); + X509BindingCatalog catalog = StandardX509Bindings.catalog(); + X509AlgorithmIdentifier identifier = catalog.resolve(identity, X509AlgorithmRole.SIGNATURE_ALGORITHM); + byte[] copy = identifier.parameters(); + copy[0] ^= 0x01; + + assertEquals("1.2.840.113549.1.1.10", identifier.oid()); + assertEquals(identity, catalog.reverse(identifier, X509AlgorithmRole.SIGNATURE_ALGORITHM)); + assertNotEquals(copy[0], identifier.parameters()[0]); + assertThrows(IllegalArgumentException.class, + () -> catalog.reverse(X509AlgorithmIdentifier.absent("1.2.840.113549.1.1.10"), + X509AlgorithmRole.SIGNATURE_ALGORITHM)); + byte[] trailing = java.util.Arrays.copyOf(identifier.parameters(), identifier.parameters().length + 1); + assertThrows(IllegalArgumentException.class, + () -> catalog.reverse(X509AlgorithmIdentifier.exact("1.2.840.113549.1.1.10", trailing), + X509AlgorithmRole.SIGNATURE_ALGORITHM)); + System.out.println("...pss-der-bytes=" + identifier.parameters().length); + System.out.println("...ok"); + } + + @Test + void ecdsaSignatureAndCurveResolveContextually() { + System.out.println("ecdsaSignatureAndCurveResolveContextually"); + X509BindingCatalog catalog = StandardX509Bindings.catalog(); + AlgorithmIdentity signature = catalog.reverse(X509AlgorithmIdentifier.absent("1.2.840.10045.4.3.2"), + X509AlgorithmRole.SIGNATURE_ALGORITHM); + X509AlgorithmIdentifier spki = catalog.resolve(BootstrapAlgorithmIdentities.EC_P384_PUBLIC_KEY, + X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM); + AlgorithmIdentity key = catalog.reverse(spki, X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM); + AlgorithmSuite suite = X509SuiteCompatibility.requireCompatible(signature, key); + + assertEquals(BootstrapAlgorithmIdentities.ECDSA_SHA256, suite.signature()); + assertEquals(BootstrapAlgorithmIdentities.EC_P384_PUBLIC_KEY, suite.publicKey()); + assertThrows(IllegalArgumentException.class, + () -> X509SuiteCompatibility.requireCompatible(signature, BootstrapAlgorithmIdentities.RSA_PUBLIC_KEY)); + System.out.println("...curve=" + key.parameters().canonicalForm()); + System.out.println("...ok"); + } + + @Test + void defaultsAndCatalogConflictsAreImmutable() { + System.out.println("defaultsAndCatalogConflictsAreImmutable"); + Map before = X509BuiltInDefaults.snapshot(); + X509BindingRule collision = new TestCollisionRule(); + X509BindingCatalog extension = X509BindingCatalog.extension(List.of(collision)); + + assertThrows(IllegalArgumentException.class, + () -> StandardX509Bindings.catalog().merge(List.of(extension))); + assertEquals(before, X509BuiltInDefaults.snapshot()); + assertEquals(BootstrapAlgorithmIdentities.PKI_SIGNATURE_DEFAULT_V1, + X509BuiltInDefaults.resolve(X509BuiltInDefaults.PKI_SIGNATURE_DEFAULT_V1)); + assertThrows(IllegalArgumentException.class, () -> X509BuiltInDefaults.resolve("extension.default")); + System.out.println("...defaults=" + before.size()); + System.out.println("...ok"); + } + + @Test + void bcBoundaryPreservesExactParametersAndRejectsSha1() { + System.out.println("bcBoundaryPreservesExactParametersAndRejectsSha1"); + BcX509AlgorithmAdapter adapter = new BcX509AlgorithmAdapter(StandardX509Bindings.catalog()); + AlgorithmIdentifier rsa = adapter.encode(BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256, + X509AlgorithmRole.SIGNATURE_ALGORITHM); + assertEquals(DERNull.INSTANCE, rsa.getParameters()); + assertEquals(BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256, + adapter.decode(rsa, X509AlgorithmRole.SIGNATURE_ALGORITHM)); + assertThrows(IllegalArgumentException.class, + () -> adapter.decode(new AlgorithmIdentifier( + new org.bouncycastle.asn1.ASN1ObjectIdentifier("1.2.840.113549.1.1.5"), DERNull.INSTANCE), + X509AlgorithmRole.SIGNATURE_ALGORITHM)); + assertArrayEquals(new byte[] { 0x05, 0x00 }, + BcX509AlgorithmAdapter.fromBc(rsa).parameters()); + System.out.println("...oid=" + rsa.getAlgorithm().getId()); + System.out.println("...ok"); + } + + @Test + void universalSnapshotComposesExtensionsAndRejectsCrossSnapshotUse() { + System.out.println("universalSnapshotComposesExtensionsAndRejectsCrossSnapshotUse"); + AlgorithmIdentity digest = new AlgorithmIdentity(AlgorithmIdentity.Kind.DIGEST, + new AlgorithmIdentity.Family("example", "digest-384"), AlgorithmIdentity.NoParameters.INSTANCE); + AlgorithmIdentity pss = BootstrapAlgorithmIdentities.rsaPss(digest, digest, 40); + AlgorithmIdentity curve = new AlgorithmIdentity(AlgorithmIdentity.Kind.PUBLIC_KEY, + new AlgorithmIdentity.Family("zeroecho", "ec"), + new AlgorithmIdentity.NamedParameters(new AlgorithmIdentity.Family("example", "curve-384"))); + X509BindingRuleProvider bindings = new X509BindingRuleProvider() { + @Override + public List identities() { + return List.of(digest, pss, curve); + } + + @Override + public List components() { + return List.of( + new X509ComponentCatalog.Component("example.digest.384", X509ComponentCatalog.Kind.DIGEST, + digest, "1.3.6.1.4.1.55555.1", false), + new X509ComponentCatalog.Component("example.curve.384", + X509ComponentCatalog.Kind.NAMED_CURVE, curve, "1.3.6.1.4.1.55555.2", false)); + } + }; + AlgorithmExecutionCapability capability = new AlgorithmExecutionCapability() { + @Override + public String implementationId() { + return "example.synthetic"; + } + + @Override + public String domainFingerprint() { + return "example-pss-v1"; + } + + @Override + public boolean supports(AlgorithmIdentity identity, AlgorithmSuite suite, Direction direction) { + return direction == Direction.SIGN && pss.equals(identity) + && BootstrapAlgorithmIdentities.RSA_PUBLIC_KEY.equals(suite.publicKey()); + } + }; + AlgorithmExecutionCapabilityProvider execution = () -> List.of(capability); + X509AlgorithmResolver.Policy allow = policy(true, "example-policy:allow"); + X509AuthoritySnapshot first = X509AuthoritySnapshot.compose(List.of(bindings), List.of(execution), allow); + X509AuthoritySnapshot equivalent = X509AuthoritySnapshot.compose(List.of(bindings), List.of(execution), allow); + X509AlgorithmResolver.Selection selection = first.resolve(pss, BootstrapAlgorithmIdentities.RSA_PUBLIC_KEY, + AlgorithmExecutionCapability.Direction.SIGN, Optional.empty(), "explicit"); + assertThrows(X509AlgorithmResolver.ResolutionException.class, + () -> first.plan(pss, BootstrapAlgorithmIdentities.RSA_PUBLIC_KEY, + AlgorithmExecutionCapability.Direction.SIGN, Optional.empty(), "explicit", Object.class)); + + Object executor = new Object(); + X509AuthoritySnapshot owning = X509AuthoritySnapshot.compose(List.of(bindings), List.of(execution), + List.of(X509AuthoritySnapshot.bindExecutor("example.synthetic", + AlgorithmExecutionCapability.Direction.SIGN, executor)), + allow); + X509AuthoritySnapshot foreign = X509AuthoritySnapshot.compose(List.of(bindings), List.of(execution), + List.of(X509AuthoritySnapshot.bindExecutor("example.synthetic", + AlgorithmExecutionCapability.Direction.SIGN, executor)), + allow); + X509ExecutionPlan plan = owning.plan(pss, BootstrapAlgorithmIdentities.RSA_PUBLIC_KEY, + AlgorithmExecutionCapability.Direction.SIGN, Optional.empty(), "explicit", Object.class); + owning.authorize(plan, executor, AlgorithmExecutionCapability.Direction.SIGN); + assertThrows(IllegalArgumentException.class, + () -> foreign.authorize(plan, executor, AlgorithmExecutionCapability.Direction.SIGN)); + assertThrows(IllegalArgumentException.class, + () -> owning.authorize(plan, executor, AlgorithmExecutionCapability.Direction.VERIFY)); + + assertEquals(pss, first.resolveIdentity(pss.canonicalForm())); + assertEquals(pss, first.bindings().reverse( + first.bindings().resolve(pss, X509AlgorithmRole.SIGNATURE_ALGORITHM), + X509AlgorithmRole.SIGNATURE_ALGORITHM)); + assertEquals(curve, first.bindings().reverse( + first.bindings().resolve(curve, X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM), + X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM)); + assertEquals(first.semanticFingerprint(), equivalent.semanticFingerprint()); + equivalent.requireAuthority(selection); + + X509AuthoritySnapshot denied = X509AuthoritySnapshot.compose(List.of(bindings), List.of(execution), + policy(false, "example-policy:deny")); + assertNotEquals(first.semanticFingerprint(), denied.semanticFingerprint()); + assertThrows(IllegalArgumentException.class, () -> denied.requireAuthority(selection)); + assertThrows(X509AlgorithmResolver.ResolutionException.class, + () -> denied.resolve(pss, BootstrapAlgorithmIdentities.RSA_PUBLIC_KEY, + AlgorithmExecutionCapability.Direction.SIGN, Optional.empty(), "explicit")); + System.out.println("...snapshot=" + first.semanticFingerprint().substring(0, 16)); + System.out.println("...ok"); + } + + @Test + void reconstructedPlanCannotRecoverAuthorityProvenance() { + System.out.println("reconstructedPlanCannotRecoverAuthorityProvenance"); + Object executor = new Object(); + AlgorithmExecutionCapability capability = exactCapability("example.plan", executor); + X509AuthoritySnapshot authority = X509AuthoritySnapshot.compose(List.of(), + List.of(() -> List.of(capability)), + List.of(X509AuthoritySnapshot.bindExecutor("example.plan", + AlgorithmExecutionCapability.Direction.SIGN, executor)), + policy(true, "plan-policy")); + X509ExecutionPlan plan = authority.plan(BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256, + BootstrapAlgorithmIdentities.RSA_PUBLIC_KEY, AlgorithmExecutionCapability.Direction.SIGN, + Optional.of("example.plan"), "explicit", Object.class); + X509ExecutionPlan reconstructed = new X509ExecutionPlan<>(plan.selection(), plan.executor(), + new Object()); + + authority.authorize(plan, executor, AlgorithmExecutionCapability.Direction.SIGN); + assertThrows(IllegalArgumentException.class, + () -> authority.authorize(reconstructed, executor, AlgorithmExecutionCapability.Direction.SIGN)); + assertFalse(java.util.Arrays.stream(X509ExecutionPlan.class.getDeclaredMethods()) + .anyMatch(method -> "authorityToken".equals(method.getName()))); + System.out.println("...methods=" + X509ExecutionPlan.class.getDeclaredMethods().length); + System.out.println("...ok"); + } + + @Test + void bcExecutorRejectsSignatureAndSpkiSubstitutionBeforeExecution() throws Exception { + System.out.println("bcExecutorRejectsSignatureAndSpkiSubstitutionBeforeExecution"); + BcX509VerificationExecutor executor = new BcX509VerificationExecutor(); + X509AuthoritySnapshot authority = X509AuthoritySnapshot.compose(List.of(), List.of(executor), + List.of(X509AuthoritySnapshot.bindExecutor(BcX509VerificationExecutor.IMPLEMENTATION_ID, + AlgorithmExecutionCapability.Direction.VERIFY, executor)), + policy(true, "bc-executor-policy")); + X509ExecutionPlan plan = authority.plan( + BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256, BootstrapAlgorithmIdentities.RSA_PUBLIC_KEY, + AlgorithmExecutionCapability.Direction.VERIFY, + Optional.of(BcX509VerificationExecutor.IMPLEMENTATION_ID), "test", + BcX509VerificationExecutor.class); + KeyPairGenerator rsaGenerator = KeyPairGenerator.getInstance("RSA"); + rsaGenerator.initialize(2048); + KeyPair rsa = rsaGenerator.generateKeyPair(); + SubjectPublicKeyInfo rsaSpki = SubjectPublicKeyInfo.getInstance(rsa.getPublic().getEncoded()); + BcX509AlgorithmAdapter adapter = new BcX509AlgorithmAdapter(authority.bindings()); + AlgorithmIdentifier correct = adapter.encode(BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256, + X509AlgorithmRole.SIGNATURE_ALGORITHM); + AlgorithmIdentifier wrongSignature = adapter.encode(BootstrapAlgorithmIdentities.RSA_PKCS1_SHA384, + X509AlgorithmRole.SIGNATURE_ALGORITHM); + AlgorithmIdentifier wrongParameters = new AlgorithmIdentifier( + new ASN1ObjectIdentifier(StandardX509Bindings.OID_RSA_SHA256)); + AlgorithmIdentifier sha1 = new AlgorithmIdentifier( + new ASN1ObjectIdentifier("1.2.840.113549.1.1.5"), DERNull.INSTANCE); + + assertThrows(IllegalArgumentException.class, + () -> executor.verify(authority, plan, rsaSpki, wrongSignature, + new ImmutableByteContent(new byte[0]), new byte[0])); + assertThrows(IllegalArgumentException.class, + () -> executor.verify(authority, plan, rsaSpki, wrongParameters, + new ImmutableByteContent(new byte[0]), new byte[0])); + assertThrows(IllegalArgumentException.class, + () -> executor.verify(authority, plan, rsaSpki, sha1, new ImmutableByteContent(new byte[0]), + new byte[0])); + + KeyPairGenerator ecGenerator = KeyPairGenerator.getInstance("EC"); + ecGenerator.initialize(256); + SubjectPublicKeyInfo ecSpki = SubjectPublicKeyInfo + .getInstance(ecGenerator.generateKeyPair().getPublic().getEncoded()); + assertThrows(IllegalArgumentException.class, + () -> executor.verify(authority, plan, ecSpki, correct, new ImmutableByteContent(new byte[0]), + new byte[0])); + System.out.println("...mismatches=4"); + System.out.println("...ok"); + } + + private static AlgorithmExecutionCapability exactCapability(String implementationId, Object executor) { + return new AlgorithmExecutionCapability() { + @Override + public String implementationId() { + return implementationId; + } + + @Override + public String domainFingerprint() { + return "test-plan-capability-v1:" + executor.getClass().getName(); + } + + @Override + public boolean supports(AlgorithmIdentity identity, AlgorithmSuite suite, Direction direction) { + return direction == Direction.SIGN + && BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256.equals(identity) + && BootstrapAlgorithmIdentities.RSA_PUBLIC_KEY.equals(suite.publicKey()); + } + }; + } + + private static X509AlgorithmResolver.Policy policy(boolean permitted, String fingerprint) { + return new X509AlgorithmResolver.Policy() { + @Override + public boolean permits(AlgorithmSuite suite, AlgorithmExecutionCapability.Direction direction) { + return permitted; + } + + @Override + public String semanticFingerprint() { + return fingerprint; + } + }; + } + + /** + * Test-only conflicting extension rule. + */ + private static final class TestCollisionRule implements X509BindingRule { + + @Override + public String id() { + return "example.conflict"; + } + + @Override + public X509AlgorithmRole role() { + return X509AlgorithmRole.SIGNATURE_ALGORITHM; + } + + @Override + public String oid() { + return StandardX509Bindings.OID_RSA_SHA256; + } + + @Override + public String semanticFingerprint() { + return "test-conflict"; + } + + @Override + public SignatureEncoding signatureEncoding() { + return SignatureEncoding.OPAQUE; + } + + @Override + public PublicKeyEncoding publicKeyEncoding() { + return PublicKeyEncoding.NOT_APPLICABLE; + } + + @Override + public java.util.Optional encode(AlgorithmIdentity identity) { + return java.util.Optional.empty(); + } + + @Override + public java.util.Optional decode(X509AlgorithmIdentifier identifier) { + return java.util.Optional.empty(); + } + } +} diff --git a/pki/src/test/java/zeroecho/pki/impl/framework/x509/bc/PkiBusContentSignerCleanupTest.java b/pki/src/test/java/zeroecho/pki/impl/framework/x509/bc/PkiBusContentSignerCleanupTest.java index d63d441..d435b0c 100644 --- a/pki/src/test/java/zeroecho/pki/impl/framework/x509/bc/PkiBusContentSignerCleanupTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/framework/x509/bc/PkiBusContentSignerCleanupTest.java @@ -37,6 +37,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static zeroecho.pki.testkit.PkiTestRuntime.signingAuthority; import java.nio.file.Files; import java.nio.file.Path; @@ -49,12 +50,16 @@ import java.util.Map; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import zeroecho.core.alg.BootstrapAlgorithmIdentities; import zeroecho.pki.api.KeyRef; import zeroecho.pki.api.PkiException; import zeroecho.pki.api.PkiId; import zeroecho.pki.impl.core.async.PkiSigningBus; +import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot; +import zeroecho.pki.impl.framework.x509.X509ExecutionPlan; import zeroecho.pki.impl.fs.FilesystemPkiStore; import zeroecho.pki.impl.fs.FsPkiStoreOptions; +import zeroecho.pki.spi.crypto.SignatureWorkflow; import zeroecho.pki.testkit.InMemorySignatureWorkflow; final class PkiBusContentSignerCleanupTest { @@ -69,6 +74,39 @@ final class PkiBusContentSignerCleanupTest { assertCleanup(tempDir, true); } + @Test + void crlPostconditionRejectsEqualFingerprintForeignPlan(@TempDir Path tempDir) throws Exception { + System.out.println("crlPostconditionRejectsEqualFingerprintForeignPlan"); + KeyRef issuerKeyRef = new KeyRef("kref:v1:keyring:test:issuer"); + InMemorySignatureWorkflow signer = new InMemorySignatureWorkflow( + Map.of(issuerKeyRef.value(), generateRsa())); + X509AuthoritySnapshot owning = signingAuthority(signer); + X509AuthoritySnapshot foreign = signingAuthority(signer); + try (FilesystemPkiStore store = new FilesystemPkiStore(tempDir.resolve("store"), + FsPkiStoreOptions.defaults()); + PkiSigningBus bus = new PkiSigningBus(store, signer, tempDir.resolve("bus.log"), owning)) { + PkiBusContentSigner contentSigner = new PkiBusContentSigner(bus, issuerKeyRef, "SHA256withRSA", + Duration.ofSeconds(5)); + X509ExecutionPlan plan = contentSigner.executionPlan(); + + BcX509StatusObjectGenerator.requireAuthorizedSigningPlan(owning, plan, + plan.selection().requested()); + assertEquals(owning.semanticFingerprint(), foreign.semanticFingerprint()); + assertThrows(IllegalArgumentException.class, + () -> BcX509StatusObjectGenerator.requireAuthorizedSigningPlan(foreign, plan, + plan.selection().requested())); + assertThrows(PkiException.class, + () -> BcX509StatusObjectGenerator.requireAuthorizedSigningPlan(owning, plan, + BootstrapAlgorithmIdentities.RSA_PKCS1_SHA384)); + contentSigner.getOutputStream().write(1); + contentSigner.getSignature(); + System.out.println("...fingerprint=" + owning.semanticFingerprint().substring(0, 16)); + System.out.println("crlPostconditionRejectsEqualFingerprintForeignPlan...ok"); + } finally { + signer.close(); + } + } + private static void assertCleanup(Path tempDir, boolean intermediate) throws Exception { KeyPair subjectKey = generateRsa(); KeyRef issuerKeyRef = new KeyRef("kref:v1:keyring:test:issuer"); @@ -77,7 +115,7 @@ final class PkiBusContentSignerCleanupTest { Path busLog = tempDir.resolve("bus.log"); try (FilesystemPkiStore store = new FilesystemPkiStore(storeRoot, FsPkiStoreOptions.defaults()); - PkiSigningBus bus = new PkiSigningBus(store, signer, busLog)) { + PkiSigningBus bus = new PkiSigningBus(store, signer, busLog, signingAuthority(signer))) { PkiBusContentSigner contentSigner = new PkiBusContentSigner(bus, issuerKeyRef, "SHA256withRSA", Duration.ofSeconds(5)); contentSigner.getOutputStream() @@ -91,7 +129,7 @@ final class PkiBusContentSignerCleanupTest { assertFalse(Files.exists(storeRoot.resolve("credentials"))); PkiId operationId = submittedOperation(busLog); - try (PkiSigningBus replayed = new PkiSigningBus(store, signer, busLog)) { + try (PkiSigningBus replayed = new PkiSigningBus(store, signer, busLog, signingAuthority(signer))) { assertEquals(zeroecho.pki.util.async.AsyncState.CANCELLED, replayed.status(operationId).orElseThrow().state()); } diff --git a/pki/src/test/java/zeroecho/pki/impl/framework/x509/bc/WorkflowProofOfPossessionVerifierTest.java b/pki/src/test/java/zeroecho/pki/impl/framework/x509/bc/WorkflowProofOfPossessionVerifierTest.java index fb7b941..6256bc4 100644 --- a/pki/src/test/java/zeroecho/pki/impl/framework/x509/bc/WorkflowProofOfPossessionVerifierTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/framework/x509/bc/WorkflowProofOfPossessionVerifierTest.java @@ -39,6 +39,7 @@ import java.nio.file.Path; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.util.Map; +import java.util.List; import java.util.Optional; import org.bouncycastle.asn1.x500.X500Name; @@ -97,7 +98,15 @@ public final class WorkflowProofOfPossessionVerifierTest { "operationRoot", tempDir.resolve("signing-operations").toString())); SignatureWorkflow wf = provider.allocate(cfg); - WorkflowProofOfPossessionVerifier verifier = new WorkflowProofOfPossessionVerifier(wf); + zeroecho.pki.impl.framework.x509.X509AlgorithmResolver.Policy algorithmPolicy = + (suite, direction) -> true; + zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot authority = + zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot.compose(List.of(), List.of(provider), + List.of(zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot.bindExecutor( + "zeroecho-lib.signature-workflow", + zeroecho.core.spi.AlgorithmExecutionCapability.Direction.VERIFY, wf)), + algorithmPolicy); + WorkflowProofOfPossessionVerifier verifier = new WorkflowProofOfPossessionVerifier(wf, authority); VerificationPolicy policy = new VerificationPolicy(true, Optional.empty()); ProofOfPossessionResult res = verifier.verify(parsed, policy); diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/DurableMetadataFilesTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/DurableMetadataFilesTest.java new file mode 100644 index 0000000..ecf75a9 --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/impl/fs/DurableMetadataFilesTest.java @@ -0,0 +1,118 @@ +/******************************************************************************* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** Tests bounded durable metadata mutation and failure classification. */ +@SuppressWarnings("PMD.UnitTestContainsTooManyAsserts") +final class DurableMetadataFilesTest { + + @AfterEach + void clearFault() { + DurableMetadataFiles.clearFault(); + } + + @Test + void moveAndDeleteDirectoryForceFailuresAreUncertain(@TempDir Path directory) throws Exception { + System.out.println("moveAndDeleteDirectoryForceFailuresAreUncertain"); + Path record = directory.resolve("record.bin"); + failAt(DurableMetadataFiles.FaultPoint.DIRECTORY_FORCE_AFTER_MOVE); + DurableMetadataFiles.UncertainAfterMutationException moved = assertThrows( + DurableMetadataFiles.UncertainAfterMutationException.class, + () -> DurableMetadataFiles.create(record, 32L, output -> output.writeInt(7))); + assertEquals(DurableMetadataFiles.Mutation.MOVE, moved.mutation(), "move must be classified explicitly"); + DurableMetadataFiles.clearFault(); + assertEquals(Integer.valueOf(7), DurableMetadataFiles.read(record, 32L, input -> input.readInt()), + "record must remain committed after uncertain move"); + + failAt(DurableMetadataFiles.FaultPoint.DIRECTORY_FORCE_AFTER_DELETE); + DurableMetadataFiles.UncertainAfterMutationException deleted = assertThrows( + DurableMetadataFiles.UncertainAfterMutationException.class, + () -> DurableMetadataFiles.delete(record)); + assertEquals(DurableMetadataFiles.Mutation.DELETE, deleted.mutation(), + "delete must be classified explicitly"); + assertFalse(Files.exists(record), "record must be absent after uncertain delete"); + System.out.println("moveAndDeleteDirectoryForceFailuresAreUncertain...ok"); + } + + @Test + void preMoveFailureCleansTemporaryAndBoundedDecodeRejectsOversize(@TempDir Path directory) throws Exception { + System.out.println("preMoveFailureCleansTemporaryAndBoundedDecodeRejectsOversize"); + Path record = directory.resolve("record.bin"); + failAt(DurableMetadataFiles.FaultPoint.FILE_FORCE); + assertThrows(IOException.class, + () -> DurableMetadataFiles.create(record, 32L, output -> output.writeLong(9L))); + DurableMetadataFiles.clearFault(); + assertFalse(Files.exists(record), "pre-move failure must not publish the record"); + assertTrue(DurableMetadataFiles.list(directory, ".tmp").isEmpty(), "temporary metadata must be cleaned"); + + DurableMetadataFiles.create(record, 32L, output -> output.writeLong(9L)); + assertThrows(IOException.class, () -> DurableMetadataFiles.read(record, 4L, input -> input.readInt())); + System.out.println("preMoveFailureCleansTemporaryAndBoundedDecodeRejectsOversize...ok"); + } + + @Test + void secureOpenRejectsSymlinkAndFaultsExplicitly(@TempDir Path directory) throws Exception { + System.out.println("secureOpenRejectsSymlinkAndFaultsExplicitly"); + Path external = directory.resolve("external.bin"); + DurableMetadataFiles.create(external, 32L, output -> output.writeInt(3)); + Path link = directory.resolve("link.bin"); + Files.createSymbolicLink(link, external.getFileName()); + assertThrows(IOException.class, () -> DurableMetadataFiles.read(link, 32L, input -> input.readInt())); + + failAt(DurableMetadataFiles.FaultPoint.SECURE_OPEN); + assertThrows(IOException.class, () -> DurableMetadataFiles.read(external, 32L, input -> input.readInt())); + System.out.println("secureOpenRejectsSymlinkAndFaultsExplicitly...ok"); + } + + private static void failAt(DurableMetadataFiles.FaultPoint expected) { + DurableMetadataFiles.installFault(actual -> { + if (actual == expected) { + throw new IOException("injected metadata fault"); + } + }); + } +} diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemCredentialContentTransactionTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemCredentialContentTransactionTest.java new file mode 100644 index 0000000..10522bc --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemCredentialContentTransactionTest.java @@ -0,0 +1,239 @@ +/******************************************************************************* + * 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 static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Arrays; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import zeroecho.pki.api.content.DurableContentOwner; +import zeroecho.pki.api.credential.Credential; + +/** Tests transactional credential ownership and restart reconciliation. */ +@SuppressWarnings("PMD.UnitTestContainsTooManyAsserts") +final class FilesystemCredentialContentTransactionTest { + + private static final String STORE_DIRECTORY = "store"; + + @Test + void intentKeysDoNotCollapseSanitizedCredentialIds() { + System.out.println("intentKeysDoNotCollapseSanitizedCredentialIds"); + String punctuation = CredentialContentTransaction.intentFileName(new zeroecho.pki.api.PkiId("credential:a")); + String underscore = CredentialContentTransaction.intentFileName(new zeroecho.pki.api.PkiId("credential_a")); + assertFalse(punctuation.equals(underscore), "full credential identifiers must produce distinct keys"); + assertTrue(punctuation.matches("[0-9a-f]{64}\\.intent"), "intent key must be fixed lowercase hex"); + System.out.println("intentKeysDoNotCollapseSanitizedCredentialIds...ok"); + } + + @Test + void credentialOwnerPersistsAcrossRestart(@TempDir Path tempDir) throws Exception { + System.out.println("credentialOwnerPersistsAcrossRestart"); + Path root = tempDir.resolve(STORE_DIRECTORY); + Credential credential; + DurableContentOwner owner; + try (FilesystemPkiStore store = open(root)) { + credential = credential(store, "SERIAL-OWNER"); + owner = DurableContentOwner.credentialRecord(credential.credentialId()); + assertFalse(store.stagedContent().contentOwners(credential.content()).contains(owner), + "owner must be absent before publication"); + store.putCredential(credential); + assertTrue(store.stagedContent().contentOwners(credential.content()).contains(owner), + "publication must retain the exact owner"); + } + + try (FilesystemPkiStore reopened = open(root)) { + assertTrue(reopened.getCredential(credential.credentialId()).isPresent(), + "credential must remain readable after restart"); + assertTrue(reopened.stagedContent().contentOwners(credential.content()).contains(owner), + "credential owner must survive recovery"); + } + System.out.println("credentialOwnerPersistsAcrossRestart...ok"); + } + + @Test + void exactCredentialOwnerIsRequiredWithoutMutation(@TempDir Path tempDir) throws Exception { + System.out.println("exactCredentialOwnerIsRequiredWithoutMutation"); + Path root = tempDir.resolve(STORE_DIRECTORY); + Credential credential; + DurableContentOwner wrong = DurableContentOwner.credentialRecord( + new zeroecho.pki.api.PkiId("wrong-credential")); + try (FilesystemPkiStore store = open(root)) { + credential = credential(store, "SERIAL-MISSING-OWNER"); + store.putCredential(credential); + DurableContentOwner exact = DurableContentOwner.credentialRecord(credential.credentialId()); + store.stagedContent().retainContent(credential.content(), wrong); + store.stagedContent().releaseContent(credential.content(), exact); + assertThrows(IllegalStateException.class, () -> store.getCredential(credential.credentialId())); + assertTrue(store.stagedContent().contentOwners(credential.content()).contains(wrong), + "read validation must not remove an unrelated owner"); + assertFalse(store.stagedContent().contentOwners(credential.content()).contains(exact), + "read validation must not mint the missing exact owner"); + } + + assertThrows(IllegalStateException.class, () -> open(root)); + System.out.println("exactCredentialOwnerIsRequiredWithoutMutation...ok"); + } + + @Test + void preparedOwnerWithoutPublishedRecordRollsBackOnRestart(@TempDir Path tempDir) throws Exception { + System.out.println("preparedOwnerWithoutPublishedRecordRollsBackOnRestart"); + Path root = tempDir.resolve(STORE_DIRECTORY); + Credential credential; + Path intentPath; + try (FilesystemPkiStore store = open(root)) { + credential = credential(store, "SERIAL-PREPARED"); + DurableContentOwner owner = DurableContentOwner.credentialRecord(credential.credentialId()); + assertTrue(store.stagedContent().retainContent(credential.content(), owner), + "test setup must create the prepared owner"); + intentPath = writePreparedIntent(root, credential); + } + + try (FilesystemPkiStore reopened = open(root)) { + assertFalse(reopened.getCredential(credential.credentialId()).isPresent(), + "recovery must not publish a record"); + assertFalse(Files.exists(intentPath), "rolled-back intent must be removed"); + assertThrows(IOException.class, () -> reopened.stagedContent().openContent(credential.content())); + } + System.out.println("preparedOwnerWithoutPublishedRecordRollsBackOnRestart...ok"); + } + + @Test + void intentWithoutOwnerOrPublishedRecordIsRemovedOnRestart(@TempDir Path tempDir) throws Exception { + System.out.println("intentWithoutOwnerOrPublishedRecordIsRemovedOnRestart"); + Path root = tempDir.resolve(STORE_DIRECTORY); + Credential credential; + Path intentPath; + try (FilesystemPkiStore store = open(root)) { + credential = credential(store, "SERIAL-INTENT-ONLY"); + intentPath = writePreparedIntent(root, credential); + } + + try (FilesystemPkiStore reopened = open(root)) { + assertFalse(Files.exists(intentPath), "ownerless intent must be removed"); + assertThrows(IOException.class, () -> reopened.stagedContent().openContent(credential.content())); + } + System.out.println("intentWithoutOwnerOrPublishedRecordIsRemovedOnRestart...ok"); + } + + @Test + void publishedRecordWithPreparedIntentCompletesOnRestart(@TempDir Path tempDir) throws Exception { + System.out.println("publishedRecordWithPreparedIntentCompletesOnRestart"); + Path root = tempDir.resolve(STORE_DIRECTORY); + Credential credential; + Path intentPath; + try (FilesystemPkiStore store = open(root)) { + credential = credential(store, "SERIAL-PUBLISHED"); + store.putCredential(credential); + intentPath = writePreparedIntent(root, credential); + } + + try (FilesystemPkiStore reopened = open(root)) { + assertTrue(reopened.getCredential(credential.credentialId()).isPresent(), + "published credential must remain readable"); + assertFalse(Files.exists(intentPath), "completed intent must be removed"); + assertTrue(reopened.stagedContent().contentOwners(credential.content()) + .contains(DurableContentOwner.credentialRecord(credential.credentialId())), + "completed recovery must preserve the exact owner"); + } + System.out.println("publishedRecordWithPreparedIntentCompletesOnRestart...ok"); + } + + @Test + void handoffIntentRejectsTrailingData(@TempDir Path tempDir) throws Exception { + System.out.println("handoffIntentRejectsTrailingData"); + Path root = tempDir.resolve(STORE_DIRECTORY); + Path intentPath; + try (FilesystemPkiStore store = open(root)) { + Credential credential = credential(store, "SERIAL-STRICT"); + intentPath = writePreparedIntent(root, credential); + byte[] exact = Files.readAllBytes(intentPath); + Files.write(intentPath, Arrays.copyOf(exact, exact.length + 1), StandardOpenOption.TRUNCATE_EXISTING); + } + + assertThrows(IllegalStateException.class, () -> open(root)); + assertTrue(Files.exists(intentPath), "invalid intent must not be silently discarded"); + System.out.println("handoffIntentRejectsTrailingData...ok"); + } + + @Test + void corruptedContentIsRejectedBeforeOwnershipHandoff(@TempDir Path tempDir) throws Exception { + System.out.println("corruptedContentIsRejectedBeforeOwnershipHandoff"); + Path root = tempDir.resolve(STORE_DIRECTORY); + try (FilesystemPkiStore store = open(root)) { + Credential credential = credential(store, "SERIAL-CORRUPT"); + Path contentPath = root.resolve("staged-content").resolve(credential.content().contentId() + ".content"); + byte[] original = Files.readAllBytes(contentPath); + byte[] corrupted = original.clone(); + corrupted[0] ^= 0x7f; + Files.write(contentPath, corrupted, StandardOpenOption.TRUNCATE_EXISTING); + + assertThrows(IllegalStateException.class, () -> store.putCredential(credential)); + assertFalse(Files.exists(new FsPaths(root).credentialPath(credential.credentialId())), + "corrupt content must not make the record visible"); + assertTrue(store.stagedContent().contentOwners(credential.content()).isEmpty(), + "corrupt content must not gain an owner"); + assertArrayEquals(corrupted, Files.readAllBytes(contentPath), + "failed validation must not rewrite the supplied content"); + } + System.out.println("corruptedContentIsRejectedBeforeOwnershipHandoff...ok"); + } + + private static FilesystemPkiStore open(Path root) { + return new FilesystemPkiStore(root, FsPkiStoreOptions.defaults()); + } + + private static Credential credential(FilesystemPkiStore store, String serial) throws IOException { + return FilesystemPkiStoreTest.TestObjects.minimalCredential(store, serial, "credential-profile"); + } + + private static Path writePreparedIntent(Path root, Credential credential) throws IOException { + CredentialContentTransaction.Intent intent = CredentialContentTransaction.Intent.from(credential, + CredentialContentTransaction.State.PREPARED); + Path path = root.resolve("credential-handoffs") + .resolve(CredentialContentTransaction.intentFileName(credential.credentialId())); + DurableMetadataFiles.create(path, 64 * 1024, + output -> output.write(CredentialContentTransaction.encode(intent))); + return path; + } +} diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java index a8bd49a..b5ee4e0 100644 --- a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemPkiStoreTest.java @@ -56,6 +56,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import zeroecho.pki.api.EncodedObject; +import zeroecho.pki.api.content.DurableContentReference; import zeroecho.pki.api.Encoding; import zeroecho.pki.api.FormatId; import zeroecho.pki.api.IssuerRef; @@ -100,6 +101,7 @@ import zeroecho.pki.api.revocation.RevocationJournal; import zeroecho.pki.api.revocation.RevocationReason; import zeroecho.pki.api.status.StatusObject; import zeroecho.pki.api.status.StatusObjectType; +import zeroecho.pki.spi.store.PkiStore; /** * Tests for {@link FilesystemPkiStore}. @@ -130,18 +132,10 @@ public final class FilesystemPkiStoreTest { Path root = tmp.resolve("store-all-schemas"); Instant now = Instant.parse("2026-01-02T03:04:05Z"); AttributeSet attributes = TestObjects.emptyAttributes(); - CaRecord ca = TestObjects.minimalCaRecord("ca-all", CaState.ACTIVE); - Credential credential = TestObjects.minimalCredential("SERIAL-ALL", "profile-all"); ParsedCertificationRequest request = new ParsedCertificationRequest(new PkiId("request-all"), new FormatId("fmt-x509"), new SubjectRef("CN=request-all"), new EncodedObject(Encoding.DER, new byte[] { 4, 5, 6 }), Optional.empty(), Optional.of("profile-all"), attributes); - StatusObject status = new StatusObject(new PkiId("status-all"), new FormatId("fmt-x509"), ca.caId(), - StatusObjectType.CRL, now, Optional.of(now.plusSeconds(60L)), - new EncodedObject(Encoding.DER, new byte[] { 7, 8 }), attributes); - PublicationRecord publication = new PublicationRecord(new PkiId("publication-all"), now, - new PublicationTarget(PublicationTargetType.FILESYSTEM, "target-all", attributes), - credential.credentialId(), "CREDENTIAL", PublicationStatus.PUBLISHED); CertificateProfile profile = TestObjects.minimalProfile("profile-all"); PolicyTrace trace = new PolicyTrace(new PkiId("decision-all"), List.of(new PolicyTraceStep("rule-all", "ALLOW", List.of("approved")))); @@ -151,6 +145,16 @@ public final class FilesystemPkiStoreTest { Optional.of(new EncodedObject(Encoding.BINARY, new byte[] { 9 }))); try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults())) { + CaRecord ca = TestObjects.minimalCaRecord(store, "ca-all", CaState.ACTIVE); + Credential credential = TestObjects.minimalCredential(store, "SERIAL-ALL", "profile-all"); + StatusObject status = new StatusObject(new PkiId("status-all"), new FormatId("fmt-x509"), ca.caId(), + StatusObjectType.CRL, now, Optional.of(now.plusSeconds(60L)), + zeroecho.pki.testkit.PkiTestRuntime.fixtureReference(store.stagedContent(), Encoding.DER, + new byte[] { 1, 2 }), + attributes); + PublicationRecord publication = new PublicationRecord(new PkiId("publication-all"), now, + new PublicationTarget(PublicationTargetType.FILESYSTEM, "target-all", attributes), + credential.credentialId(), "CREDENTIAL", PublicationStatus.PUBLISHED); store.putCa(ca); store.putCredential(credential); store.putRequest(request); @@ -189,7 +193,7 @@ public final class FilesystemPkiStoreTest { FsPkiStoreOptions options = FsPkiStoreOptions.defaults(); try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) { - Credential c1 = TestObjects.minimalCredential("SERIAL-1", "profile-1"); + Credential c1 = TestObjects.minimalCredential(store, "SERIAL-1", "profile-1"); store.putCredential(c1); RuntimeException ex = assertThrows(RuntimeException.class, () -> store.putCredential(c1)); @@ -210,7 +214,7 @@ public final class FilesystemPkiStoreTest { FsPkiStoreOptions options = FsPkiStoreOptions.defaults(); try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) { - CaRecord ca1 = TestObjects.minimalCaRecord("ca-1", CaState.ACTIVE); + CaRecord ca1 = TestObjects.minimalCaRecord(store, "ca-1", CaState.ACTIVE); store.putCa(ca1); CaRecord ca2 = new CaRecord(ca1.caId(), ca1.kind(), CaState.DISABLED, ca1.issuerKeyRef(), ca1.subjectRef(), @@ -236,7 +240,7 @@ public final class FilesystemPkiStoreTest { FsPkiStoreOptions options = FsPkiStoreOptions.defaults(); try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) { - Credential credential = TestObjects.minimalCredential("SERIAL-REV", "profile-rev"); + Credential credential = TestObjects.minimalCredential(store, "SERIAL-REV", "profile-rev"); store.putCredential(credential); store.transitionRevocation( new RevocationCommand.Hold(credential.credentialId(), TestObjects.emptyAttributes()), @@ -266,7 +270,7 @@ public final class FilesystemPkiStoreTest { FsPkiStoreOptions options = nonStrictSnapshotOptions(); try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) { - CaRecord ca1 = TestObjects.minimalCaRecord("ca-snap-1", CaState.ACTIVE); + CaRecord ca1 = TestObjects.minimalCaRecord(store, "ca-snap-1", CaState.ACTIVE); store.putCa(ca1); Instant at = Instant.now(); @@ -297,9 +301,6 @@ public final class FilesystemPkiStoreTest { FsPkiStoreOptions options = nonStrictSnapshotOptions(); - CaRecord caA = TestObjects.minimalCaRecord("ca-a", CaState.ACTIVE); - CaRecord caB = TestObjects.minimalCaRecord("ca-b", CaState.ACTIVE); - CertificateProfile pA = TestObjects.minimalProfile("profile-a"); CertificateProfile pB = TestObjects.minimalProfile("profile-b"); @@ -307,6 +308,8 @@ public final class FilesystemPkiStoreTest { Instant at2; try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) { + CaRecord caA = TestObjects.minimalCaRecord(store, "ca-a", CaState.ACTIVE); + CaRecord caB = TestObjects.minimalCaRecord(store, "ca-b", CaState.ACTIVE); store.putCa(caA); at1 = Instant.now(); @@ -364,7 +367,7 @@ public final class FilesystemPkiStoreTest { try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) { // Create CA first. - store.putCa(TestObjects.minimalCaRecord("ca-strict-1", CaState.ACTIVE)); + store.putCa(TestObjects.minimalCaRecord(store, "ca-strict-1", CaState.ACTIVE)); // "at" is before the profile exists. Instant at = Instant.now(); @@ -393,7 +396,7 @@ public final class FilesystemPkiStoreTest { FsPkiStoreOptions options = strictSnapshotOptions(); try (FilesystemPkiStore store = new FilesystemPkiStore(root, options)) { - store.putCa(TestObjects.minimalCaRecord("ca-strict-ok", CaState.ACTIVE)); + store.putCa(TestObjects.minimalCaRecord(store, "ca-strict-ok", CaState.ACTIVE)); importProfile(store, TestObjects.minimalProfile("profile-strict-ok"), Instant.now()); // "at" after all writes: strict export should succeed. @@ -543,13 +546,13 @@ public final class FilesystemPkiStoreTest { private static final AtomicLong SEQ = new AtomicLong(1L); - static CaRecord minimalCaRecord(String caId, CaState state) { + static CaRecord minimalCaRecord(PkiStore store, String caId, CaState state) throws IOException { PkiId id = new PkiId(caId); KeyRef issuerKeyRef = new KeyRef("issuer-key-" + caId); SubjectRef subjectRef = new SubjectRef("CN=" + caId); - Credential cred = minimalCredential("CA-" + caId, "profile-ca"); + Credential cred = minimalCredential(store, "CA-" + caId, "profile-ca"); List caCredentials = List.of(cred); return new CaRecord(id, CaKind.ROOT, state, issuerKeyRef, subjectRef, caCredentials); @@ -568,7 +571,7 @@ public final class FilesystemPkiStoreTest { leaf); } - static Credential minimalCredential(String serial, String profileId) { + static Credential minimalCredential(PkiStore store, String serial, String profileId) throws IOException { PkiId credentialId = new PkiId("cred-" + nextSeq()); FormatId formatId = new FormatId("fmt-x509"); @@ -583,7 +586,7 @@ public final class FilesystemPkiStoreTest { CredentialStatus status = CredentialStatus.ISSUED; - EncodedObject encoded = minimalEncodedObject(); + zeroecho.pki.api.content.DurableContentReference encoded = minimalEncodedObject(store); AttributeSet attrs = emptyAttributes(); return new Credential(credentialId, formatId, issuerRef, subjectRef, validity, serial, publicKeyId, @@ -596,8 +599,10 @@ public final class FilesystemPkiStoreTest { return new TestAttributeSet(List.of()); } - private static EncodedObject minimalEncodedObject() { - return new EncodedObject(Encoding.DER, new byte[] { 0x01, 0x02, 0x03 }); + private static zeroecho.pki.api.content.DurableContentReference minimalEncodedObject(PkiStore store) + throws IOException { + return zeroecho.pki.testkit.PkiTestRuntime.fixtureReference(store.stagedContent(), Encoding.DER, + new byte[] { 0x01, 0x02, 0x03 }); } private static String nextSeq() { diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationJournalTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationJournalTest.java index e5efb76..3b3c671 100644 --- a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationJournalTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemRevocationJournalTest.java @@ -493,15 +493,27 @@ final class FilesystemRevocationJournalTest { return new FilesystemPkiStore(root, FsPkiStoreOptions.defaults()); } - private static Credential credential(String suffix) { + private Credential credential(String suffix) { return new Credential(new PkiId("credential:" + suffix), new FormatId("x509"), new IssuerRef(new PkiId("ca:issuer")), new SubjectRef("CN=" + suffix), new Validity(TIME.minusSeconds(60), TIME.plusSeconds(60)), suffix, new PkiId("key:" + suffix), new CaProfileBinding(new zeroecho.pki.api.profile.CertificateProfileRef("default", 1, new byte[32])), - CredentialStatus.ISSUED, new EncodedObject(Encoding.DER, new byte[] { 1, 2, 3 }), + CredentialStatus.ISSUED, + fixtureReference(), new SimpleAttributeSet()); } + private zeroecho.pki.api.content.DurableContentReference fixtureReference() { + try { + FilesystemStagedContentStore staged = new FilesystemStagedContentStore( + temporaryDirectory.resolve("reference-fixtures"), "0123456789abcdef0123456789abcdef"); + return zeroecho.pki.testkit.PkiTestRuntime.fixtureReference(staged, Encoding.DER, + new byte[] { 1, 2, 3 }); + } catch (IOException exception) { + throw new AssertionError("Unable to stage credential fixture", exception); + } + } + private static byte[] journalPayload(PkiId credentialId, long journalVersion, RawTransition... transitions) throws IOException { ByteArrayOutputStream output = envelope(); diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemSignWorkflowStoreTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemSignWorkflowStoreTest.java index a392a53..6143ac0 100644 --- a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemSignWorkflowStoreTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemSignWorkflowStoreTest.java @@ -37,6 +37,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static zeroecho.pki.testkit.PkiTestRuntime.signingAuthority; import java.nio.ByteBuffer; import java.nio.file.Files; @@ -62,6 +63,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import zeroecho.pki.api.EncodedObject; +import zeroecho.pki.api.content.DurableContentReference; import zeroecho.pki.api.Encoding; import zeroecho.pki.api.KeyRef; import zeroecho.pki.api.PkiId; @@ -82,17 +84,19 @@ final class FilesystemSignWorkflowStoreTest { private static final String TEST_ALGORITHM = "SHA256withRSA"; @Test - void currentRecordCodecRequiresExactShapeAndRedactsStructuralFailures() throws Exception { + void currentRecordCodecRequiresExactShapeAndRedactsStructuralFailures(@TempDir Path root) throws Exception { System.out.println("currentRecordCodecRequiresExactShapeAndRedactsStructuralFailures"); Instant createdAt = Instant.parse("2026-01-02T03:04:05.123Z"); PkiId id = SigningSubmissionId .create("0123456789abcdef0123456789abcdef.test-signer", createdAt, new SecureRandom()).id(); - SignWorkflowStore.Record current = intent(id, createdAt, new EncodedObject(Encoding.BINARY, new byte[] { 7 }), - TEST_ALGORITHM); + try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults())) { + SignWorkflowStore.Record current = intent(store, id, createdAt, + new EncodedObject(Encoding.BINARY, new byte[] { 7 }), TEST_ALGORITHM); String fingerprint = current.fingerprint(); byte[] encoded = FsCodec.encode(FsCodec.SIGN_WORKFLOW_RECORD, current); - SignWorkflowStore.Record decoded = FsCodec.decode(FsCodec.SIGN_WORKFLOW_RECORD, encoded); + SignWorkflowStore.Record decoded = FsCodec.decode(FsCodec.SIGN_WORKFLOW_RECORD, encoded, + store.stagedContent()); assertEquals(current.submissionId(), decoded.submissionId()); assertEquals(current.providerUpdatedAt(), decoded.providerUpdatedAt()); @@ -105,6 +109,7 @@ final class FilesystemSignWorkflowStoreTest { byte[] missingProviderUpdatedAt = Arrays.copyOf(encoded, encoded.length - 3); assertRedactedStructuralFailure(missingProviderUpdatedAt, fingerprint); + } System.out.println("...ok"); } @@ -118,7 +123,7 @@ final class FilesystemSignWorkflowStoreTest { try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock)) { String namespace = store.signingNamespace() + ".test-signer"; id = SigningSubmissionId.create(namespace, createdAt, new SecureRandom()).id(); - intent = intent(id, createdAt, new EncodedObject(Encoding.BINARY, new byte[] { 9 }), TEST_ALGORITHM); + intent = intent(store, id, createdAt, new EncodedObject(Encoding.BINARY, new byte[] { 9 }), TEST_ALGORITHM); store.createSignIntent(intent); } String fingerprint = intent.fingerprint(); @@ -130,7 +135,7 @@ final class FilesystemSignWorkflowStoreTest { try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock); InMemorySignatureWorkflow signer = new InMemorySignatureWorkflow(Map.of(), false)) { RuntimeException activationFailure = assertThrows(RuntimeException.class, - () -> new PkiSigningBus(reopened, signer, root.resolve("bus.log"))); + () -> new PkiSigningBus(reopened, signer, root.resolve("bus.log"), signingAuthority(signer))); assertFalse(activationFailure.toString().contains(fingerprint)); RuntimeException attachFailure = assertThrows(RuntimeException.class, () -> reopened.createSignIntent(intent)); @@ -150,7 +155,7 @@ final class FilesystemSignWorkflowStoreTest { try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock)) { String namespace = store.signingNamespace() + ".test-signer"; id = SigningSubmissionId.create(namespace, createdAt, new SecureRandom()).id(); - SignWorkflowStore.Record current = intent(id, createdAt, + SignWorkflowStore.Record current = intent(store, id, createdAt, new EncodedObject(Encoding.BINARY, new byte[] { 10 }), TEST_ALGORITHM); fingerprint = current.fingerprint(); store.createSignIntent(current); @@ -164,7 +169,8 @@ final class FilesystemSignWorkflowStoreTest { try (FilesystemPkiStore reopened = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock); InMemorySignatureWorkflow signer = new InMemorySignatureWorkflow(Map.of(), false)) { RuntimeException failure = assertThrows(RuntimeException.class, - () -> new PkiSigningBus(reopened, signer, root.resolve("unsupported-version-bus.log"))); + () -> new PkiSigningBus(reopened, signer, root.resolve("unsupported-version-bus.log"), + signingAuthority(signer))); assertTrue(failure.toString().contains("Unsupported signing record version")); assertFalse(failure.toString().contains(fingerprint)); assertFalse(failure.toString().contains(id.value())); @@ -186,11 +192,11 @@ final class FilesystemSignWorkflowStoreTest { try (FilesystemPkiStore store = new FilesystemPkiStore(root, options, clock)) { String namespace = store.signingNamespace() + ".test-signer"; id = SigningSubmissionId.create(namespace, createdAt, new SecureRandom()).id(); - intent = intent(id, createdAt, request, TEST_ALGORITHM); + intent = intent(store, id, createdAt, request, TEST_ALGORITHM); assertEquals(SignWorkflowStore.CreateResult.CREATED, store.createSignIntent(intent)); assertEquals(SignWorkflowStore.CreateResult.ATTACHED, store.createSignIntent(intent)); assertEquals(SignWorkflowStore.CreateResult.CONFLICT, - store.createSignIntent(intent(id, createdAt, request, "SHA512withRSA"))); + store.createSignIntent(intent(store, id, createdAt, request, "SHA512withRSA"))); SignWorkflowStore.Record claimed = store.tryClaimSign(id, 0L, Duration.ofSeconds(30)).orElseThrow(); assertEquals(1L, claimed.revision()); @@ -230,7 +236,7 @@ final class FilesystemSignWorkflowStoreTest { try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock)) { namespace = store.signingNamespace() + ".test-signer"; id = SigningSubmissionId.create(namespace, createdAt, new SecureRandom()).id(); - SignWorkflowStore.Record created = intent(id, createdAt, + SignWorkflowStore.Record created = intent(store, id, createdAt, new EncodedObject(Encoding.BINARY, new byte[] { 4 }), TEST_ALGORITHM); store.createSignIntent(created); SignWorkflowStore.Record claimed = store.tryClaimSign(id, 0L, Duration.ofSeconds(10)).orElseThrow(); @@ -271,8 +277,8 @@ final class FilesystemSignWorkflowStoreTest { String namespace = store.signingNamespace() + ".test-signer"; PkiId first = SigningSubmissionId.create(namespace, createdAt, new SecureRandom()).id(); PkiId second = SigningSubmissionId.create(namespace, createdAt, new SecureRandom()).id(); - store.createSignIntent(intent(first, createdAt, request, TEST_ALGORITHM)); - store.createSignIntent(intent(second, createdAt, request, TEST_ALGORITHM)); + store.createSignIntent(intent(store, first, createdAt, request, TEST_ALGORITHM)); + store.createSignIntent(intent(store, second, createdAt, request, TEST_ALGORITHM)); Callable claim = () -> store.tryClaimSign(first, 0L, Duration.ofSeconds(30)).isPresent(); long winners = executor.invokeAll(java.util.Collections.nCopies(32, claim)).stream().filter(future -> { try { @@ -317,7 +323,7 @@ final class FilesystemSignWorkflowStoreTest { String namespace = store.signingNamespace() + ".test-signer"; id = SigningSubmissionId.create(namespace, createdAt, new SecureRandom()).id(); store.createSignIntent( - intent(id, createdAt, new EncodedObject(Encoding.BINARY, new byte[] { 8 }), TEST_ALGORITHM)); + intent(store, id, createdAt, new EncodedObject(Encoding.BINARY, new byte[] { 8 }), TEST_ALGORITHM)); SignWorkflowStore.Record claimed = store.tryClaimSign(id, 0L, Duration.ofSeconds(30)).orElseThrow(); SignWorkflowStore.Record dispatched = store.transitionSign(id, claimed.revision(), claimed.fence(), SignWorkflowStore.State.DISPATCHED, Optional.of("DISPATCHED"), Optional.empty(), Optional.empty()) @@ -416,83 +422,84 @@ final class FilesystemSignWorkflowStoreTest { System.out.println("semanticCorruptionCannotActivateRecoverOrExposeInjectedResult"); List cases = List.of( new CorruptionCase("request-changed", - record -> copy(record, request(record.submissionId(), new byte[] { 99 }, TEST_ALGORITHM), + (record, store) -> copy(record, + request(store, record.submissionId(), new byte[] { 99 }, TEST_ALGORITHM), record.fingerprint(), record.state(), record.revision(), record.fence(), record.leaseUntil(), record.detailCode(), record.result(), record.providerUpdatedAt()), "FINGERPRINT_MISMATCH"), new CorruptionCase("fingerprint-changed", - record -> copy(record, record.request(), flipFingerprint(record.fingerprint()), record.state(), + (record, store) -> copy(record, record.request(), flipFingerprint(record.fingerprint()), record.state(), record.revision(), record.fence(), record.leaseUntil(), record.detailCode(), record.result(), record.providerUpdatedAt()), "FINGERPRINT_MISMATCH"), new CorruptionCase("fingerprint-length", - record -> copy(record, record.request(), "signfp:v1:00", record.state(), record.revision(), + (record, store) -> copy(record, record.request(), "signfp:v1:00", record.state(), record.revision(), record.fence(), record.leaseUntil(), record.detailCode(), record.result(), record.providerUpdatedAt()), "FINGERPRINT_FORMAT_INVALID"), new CorruptionCase("success-no-result", - record -> copy(record, record.request(), record.fingerprint(), record.state(), + (record, store) -> copy(record, record.request(), record.fingerprint(), record.state(), record.revision(), record.fence(), record.leaseUntil(), record.detailCode(), Optional.empty(), record.providerUpdatedAt()), "SUCCESS_EVIDENCE_MISSING"), new CorruptionCase("success-no-provider-time", - record -> copy(record, record.request(), record.fingerprint(), record.state(), + (record, store) -> copy(record, record.request(), record.fingerprint(), record.state(), record.revision(), record.fence(), record.leaseUntil(), record.detailCode(), record.result(), Optional.empty()), "SUCCESS_EVIDENCE_MISSING"), new CorruptionCase("success-at-deadline", - record -> copy(record, record.request(), record.fingerprint(), record.state(), + (record, store) -> copy(record, record.request(), record.fingerprint(), record.state(), record.revision(), record.fence(), record.leaseUntil(), record.detailCode(), record.result(), Optional.of(record.deadline())), "PROVIDER_TIME_INVALID"), new CorruptionCase("success-after-deadline", - record -> copy(record, record.request(), record.fingerprint(), record.state(), + (record, store) -> copy(record, record.request(), record.fingerprint(), record.state(), record.revision(), record.fence(), record.leaseUntil(), record.detailCode(), record.result(), Optional.of(record.deadline().plusNanos(1))), "PROVIDER_TIME_INVALID"), new CorruptionCase("success-active-lease", - record -> copy(record, record.request(), record.fingerprint(), record.state(), + (record, store) -> copy(record, record.request(), record.fingerprint(), record.state(), record.revision(), record.fence(), Optional.of(record.createdAt().plusSeconds(30)), record.detailCode(), record.result(), record.providerUpdatedAt()), "SUCCESS_EVIDENCE_MISSING"), new CorruptionCase("success-expiry-code", - record -> copy(record, record.request(), record.fingerprint(), record.state(), + (record, store) -> copy(record, record.request(), record.fingerprint(), record.state(), record.revision(), record.fence(), record.leaseUntil(), Optional.of("EXPIRED"), record.result(), record.providerUpdatedAt()), "SUCCESS_DETAIL_CONTRADICTORY"), new CorruptionCase("success-stale-fence", - record -> copy(record, record.request(), record.fingerprint(), record.state(), + (record, store) -> copy(record, record.request(), record.fingerprint(), record.state(), record.revision(), record.revision(), record.leaseUntil(), record.detailCode(), record.result(), record.providerUpdatedAt()), "SUCCESS_REVISION_INVALID"), new CorruptionCase("injected-result-fingerprint-mismatch", - record -> copy(record, record.request(), flipFingerprint(record.fingerprint()), record.state(), + (record, store) -> copy(record, record.request(), flipFingerprint(record.fingerprint()), record.state(), record.revision(), record.fence(), record.leaseUntil(), record.detailCode(), Optional.of(new EncodedObject(Encoding.BINARY, "injected-signature".getBytes(java.nio.charset.StandardCharsets.UTF_8))), record.providerUpdatedAt()), "FINGERPRINT_MISMATCH"), new CorruptionCase("failed-with-result", - record -> copy(record, record.request(), record.fingerprint(), SignWorkflowStore.State.FAILED, + (record, store) -> copy(record, record.request(), record.fingerprint(), SignWorkflowStore.State.FAILED, record.revision(), record.fence(), record.leaseUntil(), Optional.of("FAILED"), record.result(), record.providerUpdatedAt()), "FAILURE_RESULT_OR_LEASE_PRESENT"), new CorruptionCase("claim-without-lease", - record -> copy(record, record.request(), record.fingerprint(), SignWorkflowStore.State.INTENT, + (record, store) -> copy(record, record.request(), record.fingerprint(), SignWorkflowStore.State.INTENT, 1L, 1L, Optional.empty(), Optional.of("INTENT"), Optional.empty(), Optional.empty()), "INTENT_CLAIM_INVALID"), new CorruptionCase("terminal-with-lease", - record -> copy(record, record.request(), record.fingerprint(), record.state(), + (record, store) -> copy(record, record.request(), record.fingerprint(), record.state(), record.revision(), record.fence(), Optional.of(record.createdAt().plusSeconds(30)), record.detailCode(), record.result(), record.providerUpdatedAt()), "SUCCESS_EVIDENCE_MISSING"), new CorruptionCase("lease-before-creation", - record -> copy(record, record.request(), record.fingerprint(), SignWorkflowStore.State.INTENT, + (record, store) -> copy(record, record.request(), record.fingerprint(), SignWorkflowStore.State.INTENT, 1L, 1L, Optional.of(record.createdAt().minusNanos(1)), Optional.of("INTENT"), Optional.empty(), Optional.empty()), "LEASE_TIME_INVALID"), new CorruptionCase("dispatched-zero-fence", - record -> copy(record, record.request(), record.fingerprint(), + (record, store) -> copy(record, record.request(), record.fingerprint(), SignWorkflowStore.State.DISPATCHED, 1L, 0L, Optional.empty(), Optional.of("DISPATCHED"), Optional.empty(), Optional.empty()), "DISPATCH_REVISION_INVALID")); @@ -506,10 +513,11 @@ final class FilesystemSignWorkflowStoreTest { System.out.println("...ok"); } - private static SignWorkflowStore.Record intent(PkiId id, Instant createdAt, EncodedObject request, + private static SignWorkflowStore.Record intent(FilesystemPkiStore store, PkiId id, Instant createdAt, + EncodedObject request, String algorithmId) { Instant deadline = createdAt.plusSeconds(60); - PkiSigningBus.SignContinuation continuation = continuation(id, request, algorithmId); + PkiSigningBus.SignContinuation continuation = continuation(store, id, request, algorithmId); String namespace = SigningSubmissionId.parse(id).namespace(); String fingerprint = continuation.semanticFingerprint(namespace, deadline); return new SignWorkflowStore.Record(id, namespace, fingerprint, TEST_OWNER, createdAt, deadline, @@ -520,8 +528,8 @@ final class FilesystemSignWorkflowStoreTest { private static PkiId persistIntent(FilesystemPkiStore store, Instant createdAt, int marker) { String namespace = store.signingNamespace() + ".test-signer"; PkiId id = SigningSubmissionId.create(namespace, createdAt, new SecureRandom()).id(); - store.createSignIntent(intent(id, createdAt, new EncodedObject(Encoding.BINARY, new byte[] { (byte) marker }), - TEST_ALGORITHM)); + store.createSignIntent(intent(store, id, createdAt, + new EncodedObject(Encoding.BINARY, new byte[] { (byte) marker }), TEST_ALGORITHM)); return id; } @@ -539,13 +547,29 @@ final class FilesystemSignWorkflowStoreTest { return id; } - private static PkiSigningBus.SignContinuation continuation(PkiId id, EncodedObject payload, String algorithmId) { - return new PkiSigningBus.SignContinuation(TEST_ACCESS, algorithmId, payload, TEST_KEY, Encoding.BINARY, + private static PkiSigningBus.SignContinuation continuation(FilesystemPkiStore store, PkiId id, + EncodedObject payload, String algorithmId) { + DurableContentReference content; + try { + content = zeroecho.pki.testkit.PkiTestRuntime.fixtureReference(store.stagedContent(), payload.encoding(), + payload.bytes()); + } catch (java.io.IOException exception) { + throw new AssertionError("Unable to stage signing fixture", exception); + } + return new PkiSigningBus.SignContinuation(TEST_ACCESS, algorithmId, content, TEST_KEY, Encoding.BINARY, Optional.of(id)); } - private static EncodedObject request(PkiId id, byte[] payload, String algorithmId) { - return continuation(id, new EncodedObject(Encoding.BINARY, payload), algorithmId).encode(); + private static String sha256(byte[] bytes) { + try { + return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(bytes)); + } catch (java.security.NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 unavailable", exception); + } + } + + private static EncodedObject request(FilesystemPkiStore store, PkiId id, byte[] payload, String algorithmId) { + return continuation(store, id, new EncodedObject(Encoding.BINARY, payload), algorithmId).encode(); } private static SignWorkflowStore.Record copy(SignWorkflowStore.Record source, EncodedObject request, @@ -566,19 +590,18 @@ final class FilesystemSignWorkflowStoreTest { Instant createdAt = Instant.parse("2026-01-02T03:04:05.123Z"); MutableClock clock = new MutableClock(createdAt); PkiId id; - SignWorkflowStore.Record succeeded; + SignWorkflowStore.Record corrupted; try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), clock)) { id = persistDispatched(store, createdAt, 42); SignWorkflowStore.Record dispatched = store.getSignRecord(id).orElseThrow(); clock.set(createdAt.plusSeconds(1)); - succeeded = store.transitionSign(id, dispatched.revision(), dispatched.fence(), + SignWorkflowStore.Record succeeded = store.transitionSign(id, dispatched.revision(), dispatched.fence(), SignWorkflowStore.State.SUCCEEDED, Optional.of("SIGNED"), Optional.of(new EncodedObject(Encoding.BINARY, "valid-signature".getBytes(java.nio.charset.StandardCharsets.UTF_8))), Optional.of(createdAt.plusSeconds(1))).orElseThrow(); + corrupted = corruption.mutation().apply(succeeded, store); } - - SignWorkflowStore.Record corrupted = corruption.mutation().apply(succeeded); writeRawCurrentRecord(root, id, corrupted); String sensitiveRequest = "injected-signature"; String sensitiveFingerprint = corrupted.fingerprint(); @@ -592,7 +615,7 @@ final class FilesystemSignWorkflowStoreTest { assertFalse(readFailure.toString().contains(sensitiveFingerprint)); IllegalStateException activationFailure = assertThrows(IllegalStateException.class, - () -> new PkiSigningBus(reopened, signer, busLog)); + () -> new PkiSigningBus(reopened, signer, busLog, signingAuthority(signer))); assertTrue(activationFailure.getMessage().contains("code=" + corruption.expectedCode())); assertEquals(0, signer.submittedSignCount()); assertThrows(IllegalStateException.class, () -> reopened.listSignRecords()); @@ -616,7 +639,8 @@ final class FilesystemSignWorkflowStoreTest { assertFalse(failure.toString().contains(sensitiveFingerprint)); } - private record CorruptionCase(String name, java.util.function.UnaryOperator mutation, + private record CorruptionCase(String name, + java.util.function.BiFunction mutation, String expectedCode) { } diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemStagedContentStoreTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemStagedContentStoreTest.java new file mode 100644 index 0000000..df99d0a --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/impl/fs/FilesystemStagedContentStoreTest.java @@ -0,0 +1,411 @@ +/******************************************************************************* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.OutputStream; +import java.io.DataOutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HexFormat; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.CyclicBarrier; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +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.spi.store.ContentSink; +import zeroecho.pki.spi.store.TemporaryUniqueIndex; + +final class FilesystemStagedContentStoreTest { + private static final String STORE_ID = "0123456789abcdef0123456789abcdef"; + private static final String FOREIGN_STORE_ID = "fedcba9876543210fedcba9876543210"; + + @Test + void temporaryIndexClassifiesCommitForceFailureAndCleansPreMoveFailure(@TempDir Path directory) throws Exception { + System.out.println("temporaryIndexClassifiesCommitForceFailureAndCleansPreMoveFailure"); + FilesystemStagedContentStore store = new FilesystemStagedContentStore(directory, STORE_ID); + byte[] value = "index-value".getBytes(StandardCharsets.US_ASCII); + try (TemporaryUniqueIndex index = store.beginUniqueIndex()) { + DurableMetadataFiles.installFault(point -> { + if (point == DurableMetadataFiles.FaultPoint.FILE_FORCE) { + throw new IOException("injected file force"); + } + }); + assertThrows(IOException.class, () -> index.add(value)); + DurableMetadataFiles.clearFault(); + assertFalse(index.contains(value)); + + DurableMetadataFiles.installFault(point -> { + if (point == DurableMetadataFiles.FaultPoint.DIRECTORY_FORCE_AFTER_MOVE) { + throw new IOException("injected directory force"); + } + }); + DurableMetadataFiles.UncertainAfterMutationException uncertain = assertThrows( + DurableMetadataFiles.UncertainAfterMutationException.class, () -> index.add(value)); + DurableMetadataFiles.clearFault(); + assertEquals(DurableMetadataFiles.Mutation.MOVE, uncertain.mutation()); + assertTrue(index.contains(value)); + } finally { + DurableMetadataFiles.clearFault(); + } + System.out.println("temporaryIndexClassifiesCommitForceFailureAndCleansPreMoveFailure...ok"); + } + + @Test + void ownerRecordRejectsContentIdentitySubstitution(@TempDir Path directory) throws Exception { + System.out.println("ownerRecordRejectsContentIdentitySubstitution"); + FilesystemStagedContentStore store = new FilesystemStagedContentStore(directory, STORE_ID); + DurableContentReference reference = stage(store, DurableContentReference.Lifecycle.PERSISTED, "owned"); + DurableContentOwner owner = new DurableContentOwner(DurableContentOwner.Category.CREDENTIAL_RECORD, + "credential-owner"); + assertTrue(store.retainContent(reference, owner)); + Path owners = directory.resolve(reference.contentId() + ".owners"); + try (DataOutputStream output = new DataOutputStream(Files.newOutputStream(owners))) { + output.writeByte(2); + output.writeUTF("00000000-0000-4000-8000-000000000000"); + output.writeInt(1); + output.writeUTF(owner.canonicalForm()); + } + assertThrows(IOException.class, () -> store.contentOwners(reference)); + System.out.println("ownerRecordRejectsContentIdentitySubstitution...ok"); + } + + @Test + void recoveryRetainsOnlyDurablyReferencedCompletedContent(@TempDir Path directory) throws Exception { + System.out.println("recoveryRetainsOnlyDurablyReferencedCompletedContent"); + FilesystemStagedContentStore store = new FilesystemStagedContentStore(directory, STORE_ID); + DurableContentReference retained = stage(store, DurableContentReference.Lifecycle.OPERATION, "retained"); + DurableContentReference abandoned = stage(store, DurableContentReference.Lifecycle.PERSISTED, "abandoned"); + DurableContentReference temporary = stage(store, DurableContentReference.Lifecycle.TEMPORARY, "temporary"); + + try (TemporaryUniqueIndex references = store.beginUniqueIndex(); + TemporaryUniqueIndex owners = store.beginUniqueIndex()) { + references.add(retained.contentId().getBytes(StandardCharsets.US_ASCII)); + store.recoverContent(references, owners); + } + + try (RepeatableContent content = store.openContent(retained)) { + assertEquals(retained.length(), content.length().orElseThrow()); + } + assertThrows(IOException.class, () -> store.openContent(abandoned)); + assertThrows(IOException.class, () -> store.openContent(temporary)); + System.out.println("...retainedLength=" + retained.length()); + System.out.println("recoveryRetainsOnlyDurablyReferencedCompletedContent...ok"); + } + + @Test + void rejectsTraversalForeignMetadataAndSymlinkContent(@TempDir Path directory) throws Exception { + System.out.println("rejectsTraversalForeignMetadataAndSymlinkContent"); + String storeId = STORE_ID; + FilesystemStagedContentStore store = new FilesystemStagedContentStore(directory, storeId); + assertThrows(IllegalArgumentException.class, + () -> store.openContent(new UntrustedReference(storeId, "../outside", Encoding.BINARY, 0L, + "0000000000000000000000000000000000000000000000000000000000000000", + DurableContentReference.Lifecycle.TEMPORARY))); + DurableContentReference reference = stage(store, DurableContentReference.Lifecycle.PERSISTED, "content"); + DurableContentReference foreign = new UntrustedReference(FOREIGN_STORE_ID, + reference.contentId(), reference.encoding(), reference.length(), reference.sha256(), + reference.lifecycle()); + assertThrows(IllegalArgumentException.class, () -> store.openContent(foreign)); + + Path content = directory.resolve(reference.contentId() + ".content"); + Path external = directory.resolve("external.bin"); + Files.writeString(external, "content", StandardCharsets.US_ASCII); + Files.delete(content); + Files.createSymbolicLink(content, external.getFileName()); + assertThrows(IOException.class, () -> store.openContent(reference)); + System.out.println("...rejected=traversal,foreign,symlink"); + System.out.println("rejectsTraversalForeignMetadataAndSymlinkContent...ok"); + } + + @Test + void ownerIndexKeysAreFixedDeterministicAndDomainComplete() { + System.out.println("ownerIndexKeysAreFixedDeterministicAndDomainComplete"); + DurableContentOwner shortOwner = signingOwner("short"); + DurableContentOwner longOwner = signingOwner("a".repeat(128)); + DurableContentOwner otherOperation = signingOwner("b".repeat(128)); + DurableContentOwner otherCategory = new DurableContentOwner( + DurableContentOwner.Category.CREDENTIAL_RECORD, longOwner.identifier()); + + String shortKey = FilesystemStagedContentStore.ownerIndexKey(shortOwner); + String longKey = FilesystemStagedContentStore.ownerIndexKey(longOwner); + assertEquals(shortKey, FilesystemStagedContentStore.ownerIndexKey(shortOwner)); + assertEquals(64, shortKey.length()); + assertEquals(64, longKey.length()); + assertTrue(shortKey.matches("[0-9a-f]{64}")); + assertFalse(longKey.equals(FilesystemStagedContentStore.ownerIndexKey(otherOperation))); + assertFalse(longKey.equals(FilesystemStagedContentStore.ownerIndexKey(otherCategory))); + System.out.println("...keyLength=" + longKey.length()); + System.out.println("ownerIndexKeysAreFixedDeterministicAndDomainComplete...ok"); + } + + @Test + void longOwnerIndexSurvivesLookupAndRejectsSubstitution(@TempDir Path directory) throws Exception { + System.out.println("longOwnerIndexSurvivesLookupAndRejectsSubstitution"); + FilesystemStagedContentStore store = new FilesystemStagedContentStore(directory, STORE_ID); + DurableContentOwner owner = signingOwner("a".repeat(128)); + byte[] ownerBytes = owner.canonicalForm().getBytes(StandardCharsets.UTF_8); + try (TemporaryUniqueIndex index = store.beginOwnerIndex()) { + assertTrue(index.add(ownerBytes)); + assertTrue(index.contains(ownerBytes)); + Path indexDirectory = uniqueIndexDirectory(directory); + List entries; + try (java.util.stream.Stream paths = Files.list(indexDirectory)) { + entries = paths.filter(path -> !path.getFileName().toString().contains(".incomplete-")).toList(); + } + assertEquals(1, entries.size()); + assertEquals(64, entries.get(0).getFileName().toString().length()); + DurableContentOwner substituted = signingOwner("b".repeat(128)); + writeRawIndexRecord(entries.get(0), substituted.canonicalForm().getBytes(StandardCharsets.UTF_8)); + assertThrows(IOException.class, () -> index.contains(ownerBytes)); + } + System.out.println("...ownerBytes=" + ownerBytes.length); + System.out.println("longOwnerIndexSurvivesLookupAndRejectsSubstitution...ok"); + } + + @Test + void ownerIndexConcurrentRetainHasOnePhysicalRecord(@TempDir Path directory) throws Exception { + System.out.println("ownerIndexConcurrentRetainHasOnePhysicalRecord"); + FilesystemStagedContentStore store = new FilesystemStagedContentStore(directory, STORE_ID); + byte[] owner = signingOwner("c".repeat(128)).canonicalForm().getBytes(StandardCharsets.UTF_8); + try (TemporaryUniqueIndex index = store.beginOwnerIndex()) { + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future first = executor.submit(() -> index.add(owner)); + Future second = executor.submit(() -> index.add(owner)); + assertTrue(first.get() ^ second.get()); + assertTrue(index.contains(owner)); + } finally { + executor.shutdownNow(); + } + try (java.util.stream.Stream paths = Files.list(uniqueIndexDirectory(directory))) { + assertEquals(1L, paths.count()); + } + } + System.out.println("...physicalRecords=1"); + System.out.println("ownerIndexConcurrentRetainHasOnePhysicalRecord...ok"); + } + + @Test + void preChangeRawOwnerFilenameIsNotAccepted(@TempDir Path directory) throws Exception { + System.out.println("preChangeRawOwnerFilenameIsNotAccepted"); + FilesystemStagedContentStore store = new FilesystemStagedContentStore(directory, STORE_ID); + byte[] owner = signingOwner("old").canonicalForm().getBytes(StandardCharsets.UTF_8); + try (TemporaryUniqueIndex index = store.beginOwnerIndex()) { + Path oldEntry = uniqueIndexDirectory(directory).resolve(HexFormat.of().formatHex(owner)); + Files.createFile(oldEntry); + assertFalse(index.contains(owner)); + } + System.out.println("...oldLayoutRejected=true"); + System.out.println("preChangeRawOwnerFilenameIsNotAccepted...ok"); + } + + @Test + void ownerIndexValidatesCompleteCommittedNamespace(@TempDir Path directory) throws Exception { + System.out.println("ownerIndexValidatesCompleteCommittedNamespace"); + FilesystemStagedContentStore store = new FilesystemStagedContentStore(directory, STORE_ID); + byte[] owner = signingOwner("namespace").canonicalForm().getBytes(StandardCharsets.UTF_8); + try (TemporaryUniqueIndex index = store.beginOwnerIndex()) { + assertTrue(index.add(owner)); + Path indexDirectory = uniqueIndexDirectory(directory); + Path expected = onlyEntry(indexDirectory); + index.validateNamespace(); + + Path moved = indexDirectory.resolve("f".repeat(64)); + Files.move(expected, moved); + assertThrows(IOException.class, index::validateNamespace); + Files.move(moved, expected); + + Path copied = indexDirectory.resolve("e".repeat(64)); + Files.copy(expected, copied); + assertThrows(IOException.class, index::validateNamespace); + Files.delete(copied); + + for (String invalid : List.of("hidden", ".hidden", "A".repeat(64), "0".repeat(63))) { + Path hostile = indexDirectory.resolve(invalid); + Files.createFile(hostile); + assertThrows(IOException.class, index::validateNamespace); + Files.delete(hostile); + } + + Path subdirectory = Files.createDirectory(indexDirectory.resolve("subdirectory")); + assertThrows(IOException.class, index::validateNamespace); + Files.delete(subdirectory); + Path link = indexDirectory.resolve("0".repeat(64)); + Files.createSymbolicLink(link, expected.getFileName()); + assertThrows(IOException.class, index::validateNamespace); + Files.delete(link); + index.validateNamespace(); + assertTrue(index.contains(owner)); + } + System.out.println("...hostileEntries=8"); + System.out.println("ownerIndexValidatesCompleteCommittedNamespace...ok"); + } + + @Test + void ownerIndexRejectsMalformedAndSubstitutedRecords(@TempDir Path directory) throws Exception { + System.out.println("ownerIndexRejectsMalformedAndSubstitutedRecords"); + FilesystemStagedContentStore store = new FilesystemStagedContentStore(directory, STORE_ID); + byte[] owner = signingOwner("record").canonicalForm().getBytes(StandardCharsets.UTF_8); + byte[] other = signingOwner("other").canonicalForm().getBytes(StandardCharsets.UTF_8); + try (TemporaryUniqueIndex index = store.beginOwnerIndex()) { + assertTrue(index.add(owner)); + Path entry = onlyEntry(uniqueIndexDirectory(directory)); + byte[] record = Files.readAllBytes(entry); + + writeRawIndexRecord(entry, other); + assertThrows(IOException.class, index::validateNamespace); + Files.write(entry, record); + + Files.write(entry, new byte[] {1, 0, 0}); + assertThrows(IOException.class, index::validateNamespace); + Files.write(entry, record); + + byte[] trailing = java.util.Arrays.copyOf(record, record.length + 1); + Files.write(entry, trailing); + assertThrows(IOException.class, index::validateNamespace); + Files.write(entry, record); + + byte[] unknownVersion = record.clone(); + unknownVersion[0] = 2; + Files.write(entry, unknownVersion); + assertThrows(IOException.class, index::validateNamespace); + Files.write(entry, record); + index.validateNamespace(); + } + System.out.println("...recordFailures=4"); + System.out.println("ownerIndexRejectsMalformedAndSubstitutedRecords...ok"); + } + + @Test + void ownerIndexConcurrentDifferentOwnersAndRemoveAreSerializable(@TempDir Path directory) throws Exception { + System.out.println("ownerIndexConcurrentDifferentOwnersAndRemoveAreSerializable"); + FilesystemStagedContentStore store = new FilesystemStagedContentStore(directory, STORE_ID); + byte[] firstOwner = signingOwner("first").canonicalForm().getBytes(StandardCharsets.UTF_8); + byte[] secondOwner = signingOwner("second").canonicalForm().getBytes(StandardCharsets.UTF_8); + try (TemporaryUniqueIndex index = store.beginOwnerIndex()) { + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + CyclicBarrier insertBarrier = new CyclicBarrier(2); + Future first = executor.submit(() -> { + insertBarrier.await(); + return index.add(firstOwner); + }); + Future second = executor.submit(() -> { + insertBarrier.await(); + return index.add(secondOwner); + }); + assertTrue(first.get()); + assertTrue(second.get()); + index.validateNamespace(); + assertTrue(index.contains(firstOwner)); + assertTrue(index.contains(secondOwner)); + + CyclicBarrier mutationBarrier = new CyclicBarrier(2); + Future add = executor.submit(() -> { + mutationBarrier.await(); + return index.add(firstOwner); + }); + Future remove = executor.submit(() -> { + mutationBarrier.await(); + return index.remove(firstOwner); + }); + boolean added = add.get(); + boolean removed = remove.get(); + assertTrue(removed); + index.validateNamespace(); + assertEquals(added, index.contains(firstOwner)); + assertTrue(index.contains(secondOwner)); + } finally { + executor.shutdownNow(); + } + } + System.out.println("...serializable=true"); + System.out.println("ownerIndexConcurrentDifferentOwnersAndRemoveAreSerializable...ok"); + } + + private static DurableContentReference stage(FilesystemStagedContentStore store, + DurableContentReference.Lifecycle lifecycle, String value) throws IOException { + try (ContentSink sink = store.beginContent(Encoding.BINARY, lifecycle); + OutputStream output = sink.outputStream()) { + output.write(value.getBytes(StandardCharsets.US_ASCII)); + return sink.complete(); + } + } + + private static DurableContentOwner signingOwner(String namespace) { + return DurableContentOwner.signingOperation(new PkiId( + "zsign:v1:" + namespace + ":1:0123456789abcdef0123456789abcdef")); + } + + private static Path uniqueIndexDirectory(Path root) throws IOException { + try (java.util.stream.Stream paths = Files.list(root)) { + return paths.filter(path -> path.getFileName().toString().endsWith(".unique-index")).findFirst() + .orElseThrow(); + } + } + + private static Path onlyEntry(Path directory) throws IOException { + try (java.util.stream.Stream paths = Files.list(directory)) { + return paths.findFirst().orElseThrow(); + } + } + + private static void writeRawIndexRecord(Path path, byte[] value) throws IOException { + try (DataOutputStream output = new DataOutputStream(Files.newOutputStream(path))) { + output.writeByte(1); + output.writeInt(value.length); + output.write(value); + } + } + + private record UntrustedReference(String storeId, String contentId, Encoding encoding, long length, String sha256, + DurableContentReference.Lifecycle lifecycle) implements DurableContentReference { + } +} diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/FsCodecTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/FsCodecTest.java index fe67460..a0f5583 100644 --- a/pki/src/test/java/zeroecho/pki/impl/fs/FsCodecTest.java +++ b/pki/src/test/java/zeroecho/pki/impl/fs/FsCodecTest.java @@ -50,8 +50,10 @@ import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.atomic.AtomicBoolean; +import java.nio.file.Path; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.Encoding; @@ -93,6 +95,9 @@ final class FsCodecTest { private static final int TYPE_OFFSET = VERSION_OFFSET + 1; private static final int FIRST_FIELD_TYPE_OFFSET = TYPE_OFFSET + 1; + @TempDir + private Path temporaryDirectory; + @Test void approvedSchemaRoundTripAndTypeBinding() { System.out.println("approvedSchemaRoundTripAndTypeBinding"); @@ -210,23 +215,26 @@ final class FsCodecTest { } @Test - void credentialProfileBindingDiscriminatorRoundTripsExactly() { + void credentialProfileBindingDiscriminatorRoundTripsExactly() throws Exception { + FilesystemStagedContentStore staged = referenceStore("binding"); CertificateProfileRef reference = new CertificateProfileRef("profile-a", 1, new byte[CertificateProfileRef.HASH_BYTES]); List bindings = List.of(new EndEntityProfileBinding(reference), new CaProfileBinding(reference)); for (CredentialProfileBinding binding : bindings) { - Credential original = credential(binding); - Credential decoded = FsCodec.decode(FsCodec.CREDENTIAL, FsCodec.encode(FsCodec.CREDENTIAL, original)); + Credential original = credential(staged, binding); + Credential decoded = FsCodec.decode(FsCodec.CREDENTIAL, FsCodec.encode(FsCodec.CREDENTIAL, original), + staged); assertEquals(binding, decoded.profileBinding()); assertEquals(binding.getClass(), decoded.profileBinding().getClass()); } } @Test - void obsoleteBareStringCaBindingFailsStrictDecode() { - byte[] encoded = FsCodec.encode(FsCodec.CREDENTIAL, credential(new CaProfileBinding( + void obsoleteBareStringCaBindingFailsStrictDecode() throws Exception { + FilesystemStagedContentStore staged = referenceStore("obsolete-binding"); + byte[] encoded = FsCodec.encode(FsCodec.CREDENTIAL, credential(staged, new CaProfileBinding( new CertificateProfileRef("profile-a", 1, new byte[CertificateProfileRef.HASH_BYTES])))); int binding = indexOf(encoded, new byte[] { 73, 2, 72 }); assertTrue(binding >= 0); @@ -275,11 +283,18 @@ final class FsCodecTest { subject, leaf); } - private static Credential credential(CredentialProfileBinding binding) { + private static Credential credential(FilesystemStagedContentStore staged, CredentialProfileBinding binding) + throws Exception { return new Credential(new PkiId("credential-1"), new FormatId("x509"), new IssuerRef(new PkiId("ca-1")), new SubjectRef("subject-1"), new Validity(Instant.EPOCH, Instant.EPOCH.plusSeconds(1)), "1", new PkiId("spki-1"), binding, CredentialStatus.ISSUED, - new EncodedObject(Encoding.DER, new byte[] { 1 }), new SimpleAttributeSet()); + zeroecho.pki.testkit.PkiTestRuntime.fixtureReference(staged, Encoding.DER, new byte[] { 1 }), + new SimpleAttributeSet()); + } + + private FilesystemStagedContentStore referenceStore(String name) throws Exception { + return new FilesystemStagedContentStore(temporaryDirectory.resolve(name), + "0123456789abcdef0123456789abcdef"); } private static ImportedCertificateProfileVersion profileVersion() { diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/MetadataFrameCodecTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/MetadataFrameCodecTest.java new file mode 100644 index 0000000..b0cc200 --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/impl/fs/MetadataFrameCodecTest.java @@ -0,0 +1,669 @@ +/******************************************************************************* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InterruptedIOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.channels.FileChannel; +import java.nio.channels.SeekableByteChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.OptionalLong; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import zeroecho.core.io.CancellationSignal; +import zeroecho.core.io.RepeatableContent; + +final class MetadataFrameCodecTest { + + private static final String TOKEN = "00112233445566778899aabbccddeeff"; + private static final int HEADER_BYTES = 72; + private static final int HEADER_FIELDS_BYTES = 40; + private static final int PAYLOAD_DIGEST_BYTES = 32; + private static final int VERSION_OFFSET = 4; + private static final int TYPE_OFFSET = 6; + private static final int FLAGS_OFFSET = 7; + private static final int SEQUENCE_OFFSET = 24; + private static final int PAYLOAD_LENGTH_OFFSET = 32; + private static final int HEADER_DIGEST_OFFSET = 40; + + @TempDir + Path temporaryDirectory; + + @Test + void allFrameTypesUseOneCanonicalRoundTrip() throws IOException { + System.out.print("allFrameTypesUseOneCanonicalRoundTrip "); + MetadataFrameCodec codec = new MetadataFrameCodec(); + Path path = temporaryDirectory.resolve("all-types.frames"); + long offset = 0L; + try (FileChannel channel = open(path)) { + for (MetadataFrameCodec.FrameType frameType : MetadataFrameCodec.FrameType.values()) { + long sequence = frameType.ordinal(); + GeneratedContent content = GeneratedContent.known(3L, frameType.ordinal()); + MetadataFrameCodec.FrameMetadata written = codec.write( + channel, frameType, TOKEN, sequence, 3L, content, CancellationSignal.NONE); + assertEquals(offset, written.frameOffset()); + MetadataFrameCodec.ReadResult result = codec.read(channel, offset); + assertEquals(MetadataFrameCodec.ReadClassification.COMPLETE_FRAME, result.classification()); + assertEquals(frameType, result.metadata().frameType()); + assertEquals(TOKEN, result.metadata().transactionToken()); + assertEquals(sequence, result.metadata().sequence()); + assertEquals(3L, result.metadata().payloadLength()); + offset = result.metadata().frameEndOffset(); + channel.position(offset); + } + assertEquals(offset, channel.size()); + } + System.out.println("...ok"); + } + + @Test + void zeroAndMultiBufferPayloadsRemainStreaming() throws IOException { + System.out.print("zeroAndMultiBufferPayloadsRemainStreaming "); + MetadataFrameCodec codec = new MetadataFrameCodec(); + Path path = temporaryDirectory.resolve("streaming.frames"); + try (FileChannel channel = open(path)) { + MetadataFrameCodec.FrameMetadata zero = codec.write( + channel, + MetadataFrameCodec.FrameType.STORE_HEADER, + TOKEN, + 0L, + 0L, + GeneratedContent.known(0L, 0), + CancellationSignal.NONE); + assertEquals(HEADER_BYTES, zero.payloadOffset()); + channel.position(zero.frameEndOffset()); + long largeLength = 3L * 16L * 1024L + 37L; + MetadataFrameCodec.FrameMetadata large = codec.write( + channel, + MetadataFrameCodec.FrameType.MUTATION_CREATE, + TOKEN, + 1L, + largeLength, + GeneratedContent.unknown(largeLength, 91), + CancellationSignal.NONE); + assertEquals( + MetadataFrameCodec.ReadClassification.COMPLETE_FRAME, + codec.read(channel, large.frameOffset()).classification()); + assertTrue(largeLength > 16L * 1024L); + } + System.out.println("...ok"); + } + + @Test + void partialHeaderAtEveryBoundaryIsIncompleteTail() throws IOException { + System.out.print("partialHeaderAtEveryBoundaryIsIncompleteTail "); + byte[] complete = encodedFrame(GeneratedContent.known(1L, 7), 1L); + Path path = temporaryDirectory.resolve("partial-header.frame"); + MetadataFrameCodec codec = new MetadataFrameCodec(); + writeBytes(path, new byte[0]); + assertClassification(codec, path, MetadataFrameCodec.ReadClassification.END_OF_INPUT); + for (int length = 1; length < HEADER_BYTES; length++) { + writeBytes(path, Arrays.copyOf(complete, length)); + assertClassification(codec, path, MetadataFrameCodec.ReadClassification.INCOMPLETE_TAIL); + } + System.out.println("...ok"); + } + + @Test + void hostileAuthenticatedHeaderFieldsFailClosed() throws IOException { + System.out.print("hostileAuthenticatedHeaderFieldsFailClosed "); + byte[] canonical = encodedFrame(GeneratedContent.known(2L, 5), 2L); + assertCorruptAfterMutation(canonical, bytes -> bytes[0] ^= 1, false, "magic"); + assertCorruptAfterMutation(canonical, bytes -> bytes[HEADER_DIGEST_OFFSET] ^= 1, false, "digest"); + assertCorruptAfterMutation(canonical, bytes -> putShort(bytes, VERSION_OFFSET, (short) 2), true, "version"); + assertCorruptAfterMutation(canonical, bytes -> bytes[TYPE_OFFSET] = 99, true, "type"); + assertCorruptAfterMutation(canonical, bytes -> bytes[FLAGS_OFFSET] = 1, true, "flags"); + assertCorruptAfterMutation(canonical, bytes -> putLong(bytes, SEQUENCE_OFFSET, -1L), true, "sequence"); + assertCorruptAfterMutation(canonical, bytes -> putLong(bytes, PAYLOAD_LENGTH_OFFSET, -1L), true, "length"); + assertCorruptAfterMutation( + canonical, bytes -> putLong(bytes, PAYLOAD_LENGTH_OFFSET, Long.MAX_VALUE), true, "overflow"); + System.out.println("...ok"); + } + + @Test + void unauthenticatedLengthCorruptionIsNeverIncompleteTail() throws IOException { + System.out.print("unauthenticatedLengthCorruptionIsNeverIncompleteTail "); + byte[] encoded = encodedFrame(GeneratedContent.known(4L, 3), 4L); + putLong(encoded, PAYLOAD_LENGTH_OFFSET, Long.MAX_VALUE); + Path path = temporaryDirectory.resolve("tampered-length.frame"); + writeBytes(path, encoded); + MetadataFrameCodec.ReadResult result = read(new MetadataFrameCodec(), path, 0L); + assertEquals(MetadataFrameCodec.ReadClassification.CORRUPT_FRAME, result.classification()); + assertFalse(result.classification() == MetadataFrameCodec.ReadClassification.INCOMPLETE_TAIL); + System.out.println("...ok"); + } + + @Test + void payloadTruncationAndDigestMismatchAreDistinguished() throws IOException { + System.out.print("payloadTruncationAndDigestMismatchAreDistinguished "); + byte[] encoded = encodedFrame(GeneratedContent.known(5L, 29), 5L); + Path path = temporaryDirectory.resolve("payload-integrity.frame"); + writeBytes(path, Arrays.copyOf(encoded, HEADER_BYTES + 4)); + assertClassification(new MetadataFrameCodec(), path, MetadataFrameCodec.ReadClassification.INCOMPLETE_TAIL); + writeBytes(path, Arrays.copyOf(encoded, encoded.length - 1)); + assertClassification(new MetadataFrameCodec(), path, MetadataFrameCodec.ReadClassification.INCOMPLETE_TAIL); + byte[] corrupt = encoded.clone(); + corrupt[HEADER_BYTES + 2] ^= 1; + writeBytes(path, corrupt); + assertClassification(new MetadataFrameCodec(), path, MetadataFrameCodec.ReadClassification.CORRUPT_FRAME); + System.out.println("...ok"); + } + + @Test + void technicalLimitIsAdapterControlled() throws IOException { + System.out.print("technicalLimitIsAdapterControlled "); + MetadataFrameCodec limited = new MetadataFrameCodec(OptionalLong.of(4L)); + Path boundary = temporaryDirectory.resolve("limit-boundary.frame"); + try (FileChannel channel = open(boundary)) { + limited.write( + channel, + MetadataFrameCodec.FrameType.MUTATION_REPLACE, + TOKEN, + 2L, + 4L, + GeneratedContent.unknown(4L, 1), + CancellationSignal.NONE); + } + Path over = temporaryDirectory.resolve("limit-over.frame"); + try (FileChannel channel = open(over)) { + assertThrows( + IllegalArgumentException.class, + () -> limited.write( + channel, + MetadataFrameCodec.FrameType.MUTATION_REPLACE, + TOKEN, + 3L, + 5L, + GeneratedContent.unknown(5L, 1), + CancellationSignal.NONE)); + } + byte[] encodedOver = encodedFrame(GeneratedContent.known(5L, 1), 5L); + writeBytes(over, encodedOver); + assertClassification(limited, over, MetadataFrameCodec.ReadClassification.CORRUPT_FRAME); + System.out.println("...ok"); + } + + @Test + void writerRejectsLengthMismatchAndObservesCancellation() throws IOException { + System.out.print("writerRejectsLengthMismatchAndObservesCancellation "); + MetadataFrameCodec codec = new MetadataFrameCodec(); + Path path = temporaryDirectory.resolve("writer-validation.frame"); + try (FileChannel channel = open(path)) { + assertThrows( + IOException.class, + () -> codec.write( + channel, + MetadataFrameCodec.FrameType.MUTATION_CREATE, + TOKEN, + 0L, + 2L, + GeneratedContent.unknown(3L, 4), + CancellationSignal.NONE)); + } + try (FileChannel channel = open(path)) { + assertThrows( + IOException.class, + () -> codec.write( + channel, + MetadataFrameCodec.FrameType.MUTATION_CREATE, + TOKEN, + 0L, + 4L, + GeneratedContent.unknown(3L, 4), + CancellationSignal.NONE)); + } + GeneratedContent unopened = GeneratedContent.unknown(1L, 2); + try (FileChannel channel = open(path)) { + assertThrows( + InterruptedIOException.class, + () -> codec.write( + channel, + MetadataFrameCodec.FrameType.MUTATION_CREATE, + TOKEN, + 0L, + 1L, + unopened, + () -> true)); + } + assertEquals(0, unopened.openCount()); + + GeneratedContent cancellable = GeneratedContent.unknown(40_000L, 8); + AtomicInteger checks = new AtomicInteger(); + try (FileChannel channel = open(path)) { + assertThrows( + InterruptedIOException.class, + () -> codec.write( + channel, + MetadataFrameCodec.FrameType.MUTATION_CREATE, + TOKEN, + 0L, + 40_000L, + cancellable, + () -> checks.incrementAndGet() > 2)); + } + assertTrue(cancellable.closedStream()); + System.out.println("...ok"); + } + + @Test + void decoderLeavesNextFrameUnreadAndIsDeterministic() throws IOException { + System.out.print("decoderLeavesNextFrameUnreadAndIsDeterministic "); + MetadataFrameCodec codec = new MetadataFrameCodec(); + Path path = temporaryDirectory.resolve("two.frames"); + MetadataFrameCodec.FrameMetadata first; + MetadataFrameCodec.FrameMetadata second; + try (FileChannel channel = open(path)) { + first = codec.write( + channel, + MetadataFrameCodec.FrameType.TRANSACTION_ISSUED, + TOKEN, + 0L, + 2L, + GeneratedContent.known(2L, 11), + CancellationSignal.NONE); + second = codec.write( + channel, + MetadataFrameCodec.FrameType.TERMINAL_COMMITTED, + TOKEN, + 1L, + 1L, + GeneratedContent.known(1L, 12), + CancellationSignal.NONE); + + MetadataFrameCodec.ReadResult firstRead = codec.read(channel, first.frameOffset()); + assertEquals(first.frameEndOffset(), channel.position()); + assertEquals(second.frameOffset(), channel.position()); + MetadataFrameCodec.ReadResult repeated = codec.read(channel, first.frameOffset()); + assertEquals(firstRead, repeated); + MetadataFrameCodec.ReadResult secondRead = codec.read(channel, second.frameOffset()); + assertEquals(MetadataFrameCodec.ReadClassification.COMPLETE_FRAME, secondRead.classification()); + assertEquals(second.frameEndOffset(), channel.position()); + assertEquals( + MetadataFrameCodec.ReadClassification.END_OF_INPUT, + codec.read(channel, second.frameEndOffset()).classification()); + } + System.out.println("...ok"); + } + + @Test + void partialProgressWorksAndZeroProgressFails() throws IOException { + System.out.print("partialProgressWorksAndZeroProgressFails "); + MetadataFrameCodec codec = new MetadataFrameCodec(); + Path shortProgress = temporaryDirectory.resolve("short-progress.frame"); + try (FileChannel file = open(shortProgress); + ControlledChannel channel = new ControlledChannel(file, 3, 2, 0, 0, null)) { + codec.write( + channel, + MetadataFrameCodec.FrameType.MUTATION_CREATE, + TOKEN, + 0L, + 19L, + GeneratedContent.known(19L, 31), + CancellationSignal.NONE); + assertEquals( + MetadataFrameCodec.ReadClassification.COMPLETE_FRAME, + codec.read(channel, 0L).classification()); + } + + Path zeroWrite = temporaryDirectory.resolve("zero-write.frame"); + try (FileChannel file = open(zeroWrite); + ControlledChannel channel = new ControlledChannel(file, 8, 8, 0, 1, null)) { + assertThrows( + IOException.class, + () -> codec.write( + channel, + MetadataFrameCodec.FrameType.MUTATION_CREATE, + TOKEN, + 0L, + 1L, + GeneratedContent.known(1L, 1), + CancellationSignal.NONE)); + } + + Path zeroRead = temporaryDirectory.resolve("zero-read.frame"); + writeBytes(zeroRead, encodedFrame(GeneratedContent.known(1L, 2), 1L)); + try (FileChannel file = FileChannel.open(zeroRead, StandardOpenOption.READ); + ControlledChannel channel = new ControlledChannel(file, 8, 8, 1, 0, null)) { + assertThrows(IOException.class, () -> codec.read(channel, 0L)); + } + System.out.println("...ok"); + } + + @Test + void cancellationDuringHeaderWritePreventsContentOpen() throws IOException { + System.out.print("cancellationDuringHeaderWritePreventsContentOpen "); + MetadataFrameCodec codec = new MetadataFrameCodec(); + GeneratedContent content = GeneratedContent.known(1L, 1); + AtomicInteger cancellationState = new AtomicInteger(); + Path path = temporaryDirectory.resolve("cancel-before-open.frame"); + try (FileChannel file = open(path); + ControlledChannel channel = new ControlledChannel( + file, HEADER_BYTES, HEADER_BYTES, 0, 0, () -> cancellationState.set(1))) { + assertThrows( + InterruptedIOException.class, + () -> codec.write( + channel, + MetadataFrameCodec.FrameType.MUTATION_CREATE, + TOKEN, + 0L, + 1L, + content, + () -> cancellationState.get() != 0)); + } + assertEquals(0, content.openCount()); + System.out.println("...ok"); + } + + private byte[] encodedFrame(GeneratedContent content, long declaredLength) throws IOException { + Path path = temporaryDirectory.resolve("source-" + System.nanoTime() + ".frame"); + MetadataFrameCodec codec = new MetadataFrameCodec(); + try (FileChannel channel = open(path)) { + codec.write( + channel, + MetadataFrameCodec.FrameType.MUTATION_CREATE, + TOKEN, + 0L, + declaredLength, + content, + CancellationSignal.NONE); + } + long size = Files.size(path); + byte[] result = new byte[Math.toIntExact(size)]; + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) { + ByteBuffer target = ByteBuffer.wrap(result); + while (target.hasRemaining()) { + channel.read(target); + } + } + return result; + } + + private void assertCorruptAfterMutation( + byte[] canonical, ByteMutation mutation, boolean refreshDigest, String name) throws IOException { + byte[] changed = canonical.clone(); + mutation.apply(changed); + if (refreshDigest) { + refreshHeaderDigest(changed); + } + Path path = temporaryDirectory.resolve("hostile-" + name + ".frame"); + writeBytes(path, changed); + assertClassification(new MetadataFrameCodec(), path, MetadataFrameCodec.ReadClassification.CORRUPT_FRAME); + } + + private static MetadataFrameCodec.ReadResult read(MetadataFrameCodec codec, Path path, long offset) + throws IOException { + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) { + return codec.read(channel, offset); + } + } + + private static void assertClassification( + MetadataFrameCodec codec, Path path, MetadataFrameCodec.ReadClassification expected) throws IOException { + assertEquals(expected, read(codec, path, 0L).classification()); + } + + private static FileChannel open(Path path) throws IOException { + return FileChannel.open( + path, + StandardOpenOption.CREATE, + StandardOpenOption.READ, + StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING); + } + + private static void writeBytes(Path path, byte[] bytes) throws IOException { + try (FileChannel channel = open(path)) { + ByteBuffer source = ByteBuffer.wrap(bytes); + while (source.hasRemaining()) { + channel.write(source); + } + } + } + + private static void refreshHeaderDigest(byte[] frame) { + MessageDigest digest; + try { + digest = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException failure) { + throw new IllegalStateException("SHA-256 is unavailable", failure); + } + byte[] authenticated = digest.digest(Arrays.copyOf(frame, HEADER_FIELDS_BYTES)); + System.arraycopy(authenticated, 0, frame, HEADER_DIGEST_OFFSET, PAYLOAD_DIGEST_BYTES); + } + + private static void putShort(byte[] bytes, int offset, short value) { + ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN).putShort(offset, value); + } + + private static void putLong(byte[] bytes, int offset, long value) { + ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN).putLong(offset, value); + } + + @FunctionalInterface + private interface ByteMutation { + void apply(byte[] bytes); + } + + private static final class ControlledChannel implements SeekableByteChannel { + private final SeekableByteChannel delegate; + private final int maximumWrite; + private final int maximumRead; + private final int zeroReadCall; + private final int zeroWriteCall; + private final Runnable afterFirstWrite; + private int readCalls; + private int writeCalls; + + private ControlledChannel( + SeekableByteChannel delegate, + int maximumWrite, + int maximumRead, + int zeroReadCall, + int zeroWriteCall, + Runnable afterFirstWrite) { + this.delegate = delegate; + this.maximumWrite = maximumWrite; + this.maximumRead = maximumRead; + this.zeroReadCall = zeroReadCall; + this.zeroWriteCall = zeroWriteCall; + this.afterFirstWrite = afterFirstWrite; + } + + @Override + public int read(ByteBuffer destination) throws IOException { + readCalls++; + if (readCalls == zeroReadCall) { + return 0; + } + int originalLimit = destination.limit(); + destination.limit(destination.position() + Math.min(destination.remaining(), maximumRead)); + try { + return delegate.read(destination); + } finally { + destination.limit(originalLimit); + } + } + + @Override + public int write(ByteBuffer source) throws IOException { + writeCalls++; + if (writeCalls == zeroWriteCall) { + return 0; + } + int originalLimit = source.limit(); + source.limit(source.position() + Math.min(source.remaining(), maximumWrite)); + int written; + try { + written = delegate.write(source); + } finally { + source.limit(originalLimit); + } + if (writeCalls == 1 && afterFirstWrite != null) { + afterFirstWrite.run(); + } + return written; + } + + @Override + public long position() throws IOException { + return delegate.position(); + } + + @Override + public SeekableByteChannel position(long newPosition) throws IOException { + delegate.position(newPosition); + return this; + } + + @Override + public long size() throws IOException { + return delegate.size(); + } + + @Override + public SeekableByteChannel truncate(long size) throws IOException { + delegate.truncate(size); + return this; + } + + @Override + public boolean isOpen() { + return delegate.isOpen(); + } + + @Override + public void close() throws IOException { + delegate.close(); + } + } + + private static final class GeneratedContent implements RepeatableContent { + private final long length; + private final int seed; + private final boolean knownLength; + private final AtomicInteger openCount = new AtomicInteger(); + private volatile boolean closedStream; + + private GeneratedContent(long length, int seed, boolean knownLength) { + this.length = length; + this.seed = seed; + this.knownLength = knownLength; + } + + private static GeneratedContent known(long length, int seed) { + return new GeneratedContent(length, seed, true); + } + + private static GeneratedContent unknown(long length, int seed) { + return new GeneratedContent(length, seed, false); + } + + @Override + public InputStream openStream() { + openCount.incrementAndGet(); + return new InputStream() { + private long position; + private boolean closed; + + @Override + public int read() { + if (position >= length) { + return -1; + } + int value = (int) ((position + seed) & 0xFFL); + position++; + return value; + } + + @Override + public int read(byte[] bytes, int offset, int requested) { + if (position >= length) { + return -1; + } + int actual = (int) Math.min((long) requested, length - position); + for (int index = 0; index < actual; index++) { + bytes[offset + index] = (byte) ((position + index + seed) & 0xFFL); + } + position += actual; + return actual; + } + + @Override + public void close() { + if (!closed) { + closed = true; + closedStream = true; + } + } + }; + } + + @Override + public OptionalLong length() { + return knownLength ? OptionalLong.of(length) : OptionalLong.empty(); + } + + @Override + public String contentId() { + return "generated-test-content"; + } + + @Override + public void close() { + // The caller retains the repeatable-content wrapper. + } + + private int openCount() { + return openCount.get(); + } + + private boolean closedStream() { + return closedStream; + } + } +} diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/MetadataMutationPayloadCodecTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/MetadataMutationPayloadCodecTest.java new file mode 100644 index 0000000..5197fed --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/impl/fs/MetadataMutationPayloadCodecTest.java @@ -0,0 +1,469 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.impl.fs; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.channels.FileChannel; +import java.nio.channels.SeekableByteChannel; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Arrays; +import java.util.OptionalLong; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +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.MetadataKey; +import zeroecho.pki.spi.store.MetadataStoreException; + +final class MetadataMutationPayloadCodecTest { + private static final String TOKEN = "00000000000000000000000000000001"; + private static final int COMMON_BYTES = 8; + private static final int CREATE_SCALAR_BYTES = Long.BYTES; + private final AtomicInteger fixtureNumber = new AtomicInteger(); + + @TempDir + Path temporaryDirectory; + + @Test + void createReplaceAndDeleteDescriptorsRoundTripWithoutReadingValues() throws IOException { + System.out.print("createReplaceAndDeleteDescriptorsRoundTripWithoutReadingValues "); + MetadataKey key = new MetadataKey("example.audit", "record-1"); + try (ImmutableByteContent value = new ImmutableByteContent(new byte[] { 1, 2, 3, 4 })) { + assertDescriptor( + MetadataMutationPayloadCodec.create(key, value, CancellationSignal.NONE), + MetadataFrameCodec.FrameType.MUTATION_CREATE, + MetadataMutationPayloadCodec.MutationKind.CREATE, + key, + OptionalLong.empty(), + 4L); + assertDescriptor( + MetadataMutationPayloadCodec.replace(key, 7L, value, CancellationSignal.NONE), + MetadataFrameCodec.FrameType.MUTATION_REPLACE, + MetadataMutationPayloadCodec.MutationKind.REPLACE, + key, + OptionalLong.of(7L), + 4L); + } + MetadataMutationPayloadCodec.Descriptor deleted = decode( + bytes(MetadataMutationPayloadCodec.delete(key, 9L)), + MetadataFrameCodec.FrameType.MUTATION_DELETE); + assertEquals(MetadataMutationPayloadCodec.MutationKind.DELETE, deleted.kind()); + assertEquals(OptionalLong.of(9L), deleted.expectedRevision()); + assertTrue(deleted.valueOffset().isEmpty()); + assertTrue(deleted.valueLength().isEmpty()); + System.out.println("...ok"); + } + + @Test + void identityBoundariesAndStrictDescriptorSchemasFailClosed() throws IOException { + System.out.print("identityBoundariesAndStrictDescriptorSchemasFailClosed "); + MetadataKey boundary = new MetadataKey("n".repeat(255), "k".repeat(4096)); + try (ImmutableByteContent empty = new ImmutableByteContent(new byte[0])) { + MetadataMutationPayloadCodec.Descriptor descriptor = decode( + bytes(MetadataMutationPayloadCodec.create(boundary, empty, CancellationSignal.NONE)), + MetadataFrameCodec.FrameType.MUTATION_CREATE); + assertEquals(boundary, descriptor.key()); + } + byte[] valid = createBytes(new MetadataKey("example.audit", "key"), new byte[] { 5 }); + assertIntegrity(change(valid, 0, (byte) 2), MetadataFrameCodec.FrameType.MUTATION_CREATE); + assertIntegrity(change(valid, 2, (byte) 99), MetadataFrameCodec.FrameType.MUTATION_CREATE); + assertIntegrity(change(valid, 3, (byte) 1), MetadataFrameCodec.FrameType.MUTATION_CREATE); + byte[] oversizedNamespace = valid.clone(); + ByteBuffer.wrap(oversizedNamespace).order(ByteOrder.BIG_ENDIAN).putShort(4, (short) 256); + assertIntegrity(oversizedNamespace, MetadataFrameCodec.FrameType.MUTATION_CREATE); + byte[] malformedUtf8 = valid.clone(); + malformedUtf8[COMMON_BYTES + CREATE_SCALAR_BYTES] = (byte) 0xc3; + assertIntegrity(malformedUtf8, MetadataFrameCodec.FrameType.MUTATION_CREATE); + byte[] negativeLength = valid.clone(); + ByteBuffer.wrap(negativeLength).order(ByteOrder.BIG_ENDIAN).putLong(COMMON_BYTES, -1L); + assertIntegrity(negativeLength, MetadataFrameCodec.FrameType.MUTATION_CREATE); + assertIntegrity(Arrays.copyOf(valid, valid.length + 1), MetadataFrameCodec.FrameType.MUTATION_CREATE); + assertIntegrity(valid, MetadataFrameCodec.FrameType.MUTATION_REPLACE); + assertIntegrityAtOverflow(valid); + System.out.println("...ok"); + } + + @Test + void compositeStreamingDetachesNoCallerAuthorityAndChecksExactLength() throws IOException { + System.out.print("compositeStreamingDetachesNoCallerAuthorityAndChecksExactLength "); + MetadataKey key = new MetadataKey("example.audit", "stream"); + byte[] generated = new byte[25_000]; + Arrays.fill(generated, (byte) 7); + ProbeContent exact = new ProbeContent(generated, OptionalLong.of(generated.length)); + RepeatableContent encoded = MetadataMutationPayloadCodec.create(key, exact, CancellationSignal.NONE); + byte[] payload = bytes(encoded); + assertEquals(1, exact.openCount.get()); + assertTrue(exact.streamClosed); + encoded.close(); + assertFalse(exact.contentClosed); + MetadataMutationPayloadCodec.Descriptor descriptor = decode( + payload, MetadataFrameCodec.FrameType.MUTATION_CREATE); + assertEquals(generated.length, descriptor.valueLength().orElseThrow()); + assertEquals(16L + key.namespace().length() + key.key().length(), + descriptor.valueOffset().orElseThrow()); + + ProbeContent shortValue = new ProbeContent(new byte[] { 1 }, OptionalLong.of(2L)); + assertThrows(IOException.class, () -> bytes( + MetadataMutationPayloadCodec.create(key, shortValue, CancellationSignal.NONE))); + ProbeContent excessValue = new ProbeContent(new byte[] { 1, 2 }, OptionalLong.of(1L)); + assertThrows(IOException.class, () -> bytes( + MetadataMutationPayloadCodec.create(key, excessValue, CancellationSignal.NONE))); + ProbeContent unknown = new ProbeContent(new byte[0], OptionalLong.empty()); + MetadataStoreException unsupported = assertThrows(MetadataStoreException.class, + () -> MetadataMutationPayloadCodec.create(key, unknown, CancellationSignal.NONE)); + assertEquals(MetadataCommitResult.FailureCategory.UNSUPPORTED_CAPABILITY, unsupported.category()); + ProbeContent overflow = new ProbeContent(new byte[0], OptionalLong.of(Long.MAX_VALUE)); + MetadataStoreException limited = assertThrows(MetadataStoreException.class, + () -> MetadataMutationPayloadCodec.create(key, overflow, CancellationSignal.NONE)); + assertEquals(MetadataCommitResult.FailureCategory.LIMIT_EXCEEDED, limited.category()); + System.out.println("...ok"); + } + + @Test + void cancellationIsObservedBeforeOpenAndBetweenBoundedReads() throws IOException { + System.out.print("cancellationIsObservedBeforeOpenAndBetweenBoundedReads "); + MetadataKey key = new MetadataKey("example.audit", "cancel"); + ProbeContent before = new ProbeContent(new byte[] { 1 }, OptionalLong.of(1L)); + RepeatableContent cancelled = MetadataMutationPayloadCodec.create(key, before, () -> true); + assertThrows(IOException.class, cancelled::openStream); + assertEquals(0, before.openCount.get()); + + ProbeContent during = new ProbeContent(new byte[30_000], OptionalLong.of(30_000L)); + AtomicInteger checks = new AtomicInteger(); + CancellationSignal signal = () -> checks.incrementAndGet() > 4; + RepeatableContent interrupted = MetadataMutationPayloadCodec.create(key, during, signal); + assertThrows(IOException.class, () -> bytes(interrupted)); + assertTrue(during.streamClosed); + assertTrue(during.bytesRead < 30_000); + System.out.println("...ok"); + } + + @Test + void zeroProgressFromCallerContentFailsOnceAndClosesTheStream() throws IOException { + System.out.print("zeroProgressFromCallerContentFailsOnceAndClosesTheStream "); + MetadataKey key = new MetadataKey("example.audit", "zero-progress"); + ZeroProgressContent beforeData = new ZeroProgressContent(false); + MetadataStoreException beforeFailure = assertThrows( + MetadataStoreException.class, + () -> bytes(MetadataMutationPayloadCodec.create( + key, beforeData, CancellationSignal.NONE))); + assertEquals(MetadataCommitResult.FailureCategory.STORAGE_FAILURE, beforeFailure.category()); + assertEquals(1, beforeData.readCalls); + assertTrue(beforeData.streamClosed); + + ZeroProgressContent afterData = new ZeroProgressContent(true); + MetadataStoreException afterFailure = assertThrows( + MetadataStoreException.class, + () -> bytes(MetadataMutationPayloadCodec.create( + key, afterData, CancellationSignal.NONE))); + assertEquals(MetadataCommitResult.FailureCategory.STORAGE_FAILURE, afterFailure.category()); + assertEquals(2, afterData.readCalls); + assertTrue(afterData.streamClosed); + System.out.println("...ok"); + } + + @Test + void boundedDecodeRejectsZeroProgressAndInvalidRegionsWithoutClosingChannel() throws IOException { + System.out.print("boundedDecodeRejectsZeroProgressAndInvalidRegionsWithoutClosingChannel "); + byte[] payload = createBytes(new MetadataKey("example.audit", "bounded"), new byte[] { 1 }); + Path path = temporaryDirectory.resolve("zero-progress-channel"); + java.nio.file.Files.write(path, payload); + try (FileChannel delegate = FileChannel.open(path, StandardOpenOption.READ)) { + ZeroProgressChannel channel = new ZeroProgressChannel(delegate); + MetadataStoreException zeroProgress = assertThrows( + MetadataStoreException.class, + () -> MetadataMutationPayloadCodec.decode( + channel, + metadata(MetadataFrameCodec.FrameType.MUTATION_CREATE, payload.length, 0L))); + assertEquals(MetadataCommitResult.FailureCategory.INTEGRITY_FAILURE, zeroProgress.category()); + assertEquals(1, channel.readCalls); + assertTrue(channel.isOpen()); + } + + assertIntegrityWithMetadata(payload, metadata( + MetadataFrameCodec.FrameType.MUTATION_CREATE, payload.length, -1L)); + assertIntegrityWithMetadata(payload, new MetadataFrameCodec.FrameMetadata( + MetadataFrameCodec.FrameType.MUTATION_CREATE, + TOKEN, + 1L, + 0L, + 0L, + -1L, + -1L, + -1L)); + assertIntegrityWithMetadata(payload, metadata( + MetadataFrameCodec.FrameType.MUTATION_CREATE, payload.length - 1L, 0L)); + assertIntegrityWithMetadata(payload, metadata( + MetadataFrameCodec.FrameType.MUTATION_CREATE, payload.length + 1L, 0L)); + assertIntegrityAtOverflow(payload); + System.out.println("...ok"); + } + + private void assertDescriptor( + RepeatableContent content, + MetadataFrameCodec.FrameType frameType, + MetadataMutationPayloadCodec.MutationKind kind, + MetadataKey key, + OptionalLong expectedRevision, + long valueLength) throws IOException { + byte[] payload = bytes(content); + MetadataMutationPayloadCodec.Descriptor descriptor = decode(payload, frameType); + assertEquals(kind, descriptor.kind()); + assertEquals(key, descriptor.key()); + assertEquals(expectedRevision, descriptor.expectedRevision()); + assertEquals(valueLength, descriptor.valueLength().orElseThrow()); + assertTrue(descriptor.valueOffset().orElseThrow() < payload.length); + } + + private byte[] createBytes(MetadataKey key, byte[] value) throws IOException { + try (ImmutableByteContent content = new ImmutableByteContent(value)) { + return bytes(MetadataMutationPayloadCodec.create(key, content, CancellationSignal.NONE)); + } + } + + private MetadataMutationPayloadCodec.Descriptor decode( + byte[] payload, MetadataFrameCodec.FrameType frameType) throws IOException { + Path path = temporaryDirectory.resolve("payload-" + fixtureNumber.incrementAndGet()); + java.nio.file.Files.write(path, payload); + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) { + MetadataMutationPayloadCodec.Descriptor descriptor = MetadataMutationPayloadCodec.decode( + channel, metadata(frameType, payload.length, 0L)); + if (descriptor.valueOffset().isPresent()) { + assertEquals(descriptor.valueOffset().getAsLong(), channel.position()); + } + return descriptor; + } + } + + private void assertIntegrity(byte[] payload, MetadataFrameCodec.FrameType frameType) { + MetadataStoreException failure = assertThrows( + MetadataStoreException.class, () -> decode(payload, frameType)); + assertEquals(MetadataCommitResult.FailureCategory.INTEGRITY_FAILURE, failure.category()); + } + + private void assertIntegrityAtOverflow(byte[] payload) throws IOException { + Path path = temporaryDirectory.resolve("overflow"); + java.nio.file.Files.write(path, payload); + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) { + MetadataFrameCodec.FrameMetadata overflow = metadata( + MetadataFrameCodec.FrameType.MUTATION_CREATE, + payload.length, + Long.MAX_VALUE - 2L); + MetadataStoreException failure = assertThrows( + MetadataStoreException.class, + () -> MetadataMutationPayloadCodec.decode(channel, overflow)); + assertEquals(MetadataCommitResult.FailureCategory.INTEGRITY_FAILURE, failure.category()); + } + } + + private void assertIntegrityWithMetadata( + byte[] payload, MetadataFrameCodec.FrameMetadata frame) throws IOException { + Path path = temporaryDirectory.resolve("invalid-region-" + fixtureNumber.incrementAndGet()); + java.nio.file.Files.write(path, payload); + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) { + MetadataStoreException failure = assertThrows( + MetadataStoreException.class, + () -> MetadataMutationPayloadCodec.decode(channel, frame)); + assertEquals(MetadataCommitResult.FailureCategory.INTEGRITY_FAILURE, failure.category()); + } + } + + private static MetadataFrameCodec.FrameMetadata metadata( + MetadataFrameCodec.FrameType type, long payloadLength, long payloadOffset) { + long digestOffset; + try { + digestOffset = Math.addExact(payloadOffset, payloadLength); + } catch (ArithmeticException failure) { + digestOffset = Long.MAX_VALUE; + } + return new MetadataFrameCodec.FrameMetadata( + type, TOKEN, 1L, 0L, payloadOffset, payloadLength, digestOffset, digestOffset); + } + + private static byte[] bytes(RepeatableContent content) throws IOException { + try (InputStream input = content.openStream(); ByteArrayOutputStream output = new ByteArrayOutputStream()) { + byte[] buffer = new byte[1024]; + while (true) { + int count = input.read(buffer); + if (count < 0) { + return output.toByteArray(); + } + output.write(buffer, 0, count); + } + } + } + + private static byte[] change(byte[] source, int offset, byte value) { + byte[] result = source.clone(); + result[offset] = value; + return result; + } + + private static final class ProbeContent implements RepeatableContent { + private final byte[] value; + private final OptionalLong declaredLength; + private final AtomicInteger openCount = new AtomicInteger(); + private boolean streamClosed; + private boolean contentClosed; + private int bytesRead; + + private ProbeContent(byte[] value, OptionalLong declaredLength) { + this.value = value.clone(); + this.declaredLength = declaredLength; + } + + @Override + public InputStream openStream() { + openCount.incrementAndGet(); + return new ByteArrayInputStream(value) { + @Override + public synchronized int read(byte[] target, int offset, int length) { + int count = super.read(target, offset, length); + if (count > 0) { + bytesRead += count; + } + return count; + } + + @Override + public void close() throws IOException { + streamClosed = true; + super.close(); + } + }; + } + + @Override + public OptionalLong length() { + return declaredLength; + } + + @Override + public String contentId() { + return "probe-content"; + } + + @Override + public void close() { + contentClosed = true; + } + } + + private static final class ZeroProgressContent implements RepeatableContent { + private final boolean emitDataFirst; + private int readCalls; + private boolean streamClosed; + + private ZeroProgressContent(boolean emitDataFirst) { + this.emitDataFirst = emitDataFirst; + } + + @Override + public InputStream openStream() { + return new InputStream() { + @Override + public int read() { + return -1; + } + + @Override + public int read(byte[] target, int offset, int length) { + readCalls++; + if (emitDataFirst && readCalls == 1) { + target[offset] = 1; + return 1; + } + return 0; + } + + @Override + public void close() { + streamClosed = true; + } + }; + } + + @Override + public OptionalLong length() { + return OptionalLong.of(emitDataFirst ? 2L : 1L); + } + + @Override + public String contentId() { + return "zero-progress-content"; + } + + @Override + public void close() { + // The probe owns no resource outside each stream it opens. + } + } + + private static final class ZeroProgressChannel implements SeekableByteChannel { + private final SeekableByteChannel delegate; + private int readCalls; + + private ZeroProgressChannel(SeekableByteChannel delegate) { + this.delegate = delegate; + } + + @Override + public int read(ByteBuffer destination) { + readCalls++; + return 0; + } + + @Override + public int write(ByteBuffer source) throws IOException { + return delegate.write(source); + } + + @Override + public long position() throws IOException { + return delegate.position(); + } + + @Override + public SeekableByteChannel position(long newPosition) throws IOException { + delegate.position(newPosition); + return this; + } + + @Override + public long size() throws IOException { + return delegate.size(); + } + + @Override + public SeekableByteChannel truncate(long size) throws IOException { + delegate.truncate(size); + return this; + } + + @Override + public boolean isOpen() { + return delegate.isOpen(); + } + + @Override + public void close() throws IOException { + delegate.close(); + } + } +} diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/MetadataStateIndexTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/MetadataStateIndexTest.java new file mode 100644 index 0000000..b5d954b --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/impl/fs/MetadataStateIndexTest.java @@ -0,0 +1,220 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + ******************************************************************************/ +package zeroecho.pki.impl.fs; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.OptionalLong; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.Test; +import zeroecho.pki.spi.store.MetadataCommitResult; +import zeroecho.pki.spi.store.MetadataKey; +import zeroecho.pki.spi.store.MetadataStoreException; + +final class MetadataStateIndexTest { + + @Test + void committedRevisionIsTheRecordRevisionAndEmptyCommitAdvancesOnce() throws MetadataStoreException { + System.out.print("committedRevisionIsTheRecordRevisionAndEmptyCommitAdvancesOnce "); + MetadataStateIndex index = new MetadataStateIndex(); + MetadataKey key = key("b"); + index.applyCommitted(1L, List.of(create(key, 100L, 4L))); + assertEquals(1L, index.storeRevision()); + assertEquals(1L, index.lookup(key).orElseThrow().revision()); + index.applyCommitted(2L, List.of()); + assertEquals(2L, index.storeRevision()); + assertEquals(1L, index.lookup(key).orElseThrow().revision()); + index.applyCommitted(3L, List.of(replace(key, 1L, 200L, 5L))); + assertEquals(3L, index.lookup(key).orElseThrow().revision()); + assertEquals(200L, index.lookup(key).orElseThrow().valueOffset()); + index.applyCommitted(4L, List.of(delete(key, 3L))); + assertEquals(4L, index.storeRevision()); + assertTrue(index.lookup(key).isEmpty()); + System.out.println("...ok"); + } + + @Test + void conflictsAndDuplicateKeysPublishNoPartialDelta() throws MetadataStoreException { + System.out.print("conflictsAndDuplicateKeysPublishNoPartialDelta "); + MetadataStateIndex index = new MetadataStateIndex(); + MetadataKey existing = key("existing"); + MetadataKey added = key("added"); + index.applyCommitted(1L, List.of(create(existing, 10L, 2L))); + + assertConflict(() -> index.applyCommitted(2L, List.of( + create(added, 20L, 2L), replace(existing, 0L, 30L, 3L)))); + assertEquals(1L, index.storeRevision()); + assertTrue(index.lookup(added).isEmpty()); + assertEquals(10L, index.lookup(existing).orElseThrow().valueOffset()); + + assertConflict(() -> index.applyCommitted(2L, List.of(create(existing, 40L, 4L)))); + assertConflict(() -> index.applyCommitted(2L, List.of(delete(existing, 0L)))); + assertConflict(() -> index.applyCommitted(2L, List.of( + replace(existing, 1L, 50L, 5L), delete(existing, 1L)))); + assertEquals(1L, index.storeRevision()); + System.out.println("...ok"); + } + + @Test + void multiKeyPublicationAndIterationAreDeterministicAndImmutable() throws MetadataStoreException { + System.out.print("multiKeyPublicationAndIterationAreDeterministicAndImmutable "); + MetadataStateIndex index = new MetadataStateIndex(); + MetadataKey first = key("a"); + MetadataKey second = key("b"); + index.applyCommitted(1L, List.of(create(second, 20L, 2L), create(first, 10L, 1L))); + List records = index.records(); + assertEquals(List.of(first, second), records.stream().map(MetadataStateIndex.CurrentRecord::key).toList()); + assertThrows(UnsupportedOperationException.class, + () -> records.add(new MetadataStateIndex.CurrentRecord(key("c"), 1L, 30L, 3L))); + assertEquals(records, index.records()); + System.out.println("...ok"); + } + + @Test + void invalidRevisionAndDescriptorShapesFailWithStableSafeCategories() throws MetadataStoreException { + System.out.print("invalidRevisionAndDescriptorShapesFailWithStableSafeCategories "); + MetadataStateIndex index = new MetadataStateIndex(); + MetadataStoreException skipped = assertThrows( + MetadataStoreException.class, () -> index.applyCommitted(2L, List.of())); + assertEquals(MetadataCommitResult.FailureCategory.INTEGRITY_FAILURE, skipped.category()); + MetadataStoreException malformed = assertThrows( + MetadataStoreException.class, + () -> index.applyCommitted(1L, List.of(new MetadataMutationPayloadCodec.Descriptor( + MetadataMutationPayloadCodec.MutationKind.CREATE, + key("bad"), + OptionalLong.empty(), + OptionalLong.empty(), + OptionalLong.empty())))); + assertEquals(MetadataCommitResult.FailureCategory.INTEGRITY_FAILURE, malformed.category()); + assertEquals(0L, index.storeRevision()); + assertTrue(index.records().isEmpty()); + System.out.println("...ok"); + } + + @Test + void malformedValueRegionsFailCheckedAndPublishNoState() throws MetadataStoreException { + System.out.print("malformedValueRegionsFailCheckedAndPublishNoState "); + MetadataStateIndex index = new MetadataStateIndex(); + MetadataKey existing = key("existing-region"); + index.applyCommitted(1L, List.of(create(existing, 10L, 2L))); + List before = index.records(); + + assertIntegrity(() -> index.applyCommitted(2L, List.of(create(key("negative-offset"), -1L, 1L)))); + assertIntegrity(() -> index.applyCommitted(2L, List.of(create(key("negative-length"), 1L, -1L)))); + assertIntegrity(() -> index.applyCommitted( + 2L, List.of(create(key("overflow"), Long.MAX_VALUE, 1L)))); + assertIntegrity(() -> index.applyCommitted(2L, List.of(new MetadataMutationPayloadCodec.Descriptor( + MetadataMutationPayloadCodec.MutationKind.DELETE, + existing, + OptionalLong.of(1L), + OptionalLong.of(20L), + OptionalLong.of(1L))))); + assertIntegrity(() -> index.applyCommitted(2L, List.of(new MetadataMutationPayloadCodec.Descriptor( + MetadataMutationPayloadCodec.MutationKind.CREATE, + key("unexpected-revision"), + OptionalLong.of(1L), + OptionalLong.of(20L), + OptionalLong.of(1L))))); + assertIntegrity(() -> index.applyCommitted(2L, java.util.Collections.singletonList(null))); + + assertEquals(1L, index.storeRevision()); + assertEquals(before, index.records()); + System.out.println("...ok"); + } + + @Test + void concurrentReadersObserveOnlyAtomicImmutableStates() throws Exception { + System.out.print("concurrentReadersObserveOnlyAtomicImmutableStates "); + MetadataStateIndex index = new MetadataStateIndex(); + index.applyCommitted(1L, List.of(create(key("initial"), 1L, 1L))); + CountDownLatch start = new CountDownLatch(1); + AtomicBoolean writeComplete = new AtomicBoolean(); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future> observations = executor.submit(() -> { + java.util.ArrayList sizes = new java.util.ArrayList<>(); + start.await(); + do { + sizes.add(index.records().size()); + } while (!writeComplete.get()); + sizes.add(index.records().size()); + return List.copyOf(sizes); + }); + Future writer = executor.submit(() -> { + start.await(); + index.applyCommitted(2L, List.of( + create(key("second"), 2L, 1L), + create(key("third"), 3L, 1L))); + writeComplete.set(true); + return null; + }); + start.countDown(); + writer.get(); + List sizes = observations.get(); + assertTrue(sizes.stream().allMatch(size -> size == 1 || size == 3)); + assertEquals(3, sizes.get(sizes.size() - 1)); + List records = index.records(); + assertThrows(UnsupportedOperationException.class, records::clear); + } finally { + executor.shutdownNow(); + } + System.out.println("...ok"); + } + + private static MetadataMutationPayloadCodec.Descriptor create( + MetadataKey key, long offset, long length) { + return new MetadataMutationPayloadCodec.Descriptor( + MetadataMutationPayloadCodec.MutationKind.CREATE, + key, + OptionalLong.empty(), + OptionalLong.of(offset), + OptionalLong.of(length)); + } + + private static MetadataMutationPayloadCodec.Descriptor replace( + MetadataKey key, long expectedRevision, long offset, long length) { + return new MetadataMutationPayloadCodec.Descriptor( + MetadataMutationPayloadCodec.MutationKind.REPLACE, + key, + OptionalLong.of(expectedRevision), + OptionalLong.of(offset), + OptionalLong.of(length)); + } + + private static MetadataMutationPayloadCodec.Descriptor delete(MetadataKey key, long expectedRevision) { + return new MetadataMutationPayloadCodec.Descriptor( + MetadataMutationPayloadCodec.MutationKind.DELETE, + key, + OptionalLong.of(expectedRevision), + OptionalLong.empty(), + OptionalLong.empty()); + } + + private static MetadataKey key(String key) { + return new MetadataKey("example.index", key); + } + + private static void assertConflict(CheckedOperation operation) { + MetadataStoreException conflict = assertThrows(MetadataStoreException.class, operation::run); + assertEquals(MetadataCommitResult.FailureCategory.CONFLICT, conflict.category()); + } + + private static void assertIntegrity(CheckedOperation operation) { + MetadataStoreException failure = assertThrows(MetadataStoreException.class, operation::run); + assertEquals(MetadataCommitResult.FailureCategory.INTEGRITY_FAILURE, failure.category()); + } + + @FunctionalInterface + private interface CheckedOperation { + void run() throws MetadataStoreException; + } +} diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/PosixMetadataLogScannerTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/PosixMetadataLogScannerTest.java new file mode 100644 index 0000000..ae0d3d5 --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/impl/fs/PosixMetadataLogScannerTest.java @@ -0,0 +1,579 @@ +/******************************************************************************* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +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.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import zeroecho.core.io.CancellationSignal; +import zeroecho.core.io.ImmutableByteContent; +import zeroecho.pki.spi.store.MetadataCommitResult; +import zeroecho.pki.spi.store.MetadataStoreException; +import zeroecho.pki.spi.store.MetadataStoreId; +import zeroecho.pki.spi.store.MetadataTransactionId; + +final class PosixMetadataLogScannerTest { + + private static final MetadataStoreId STORE_ID = + new MetadataStoreId("0123456789abcdeffedcba9876543210"); + private static final MetadataTransactionId TRANSACTION_ID = + new MetadataTransactionId(STORE_ID, "00000000000000000000000000000001"); + private static final int HEADER_PAYLOAD_LENGTH_OFFSET = 32; + private static final int DIGEST_BYTES = 32; + + @TempDir + Path temporaryDirectory; + + @Test + void validRecoveryIsDeterministicAcrossTerminalAndIssuedTransactions() throws IOException { + System.out.print("validRecoveryIsDeterministicAcrossTerminalAndIssuedTransactions "); + Path path = temporaryDirectory.resolve("valid.log"); + MetadataTransactionId committed; + MetadataTransactionId rejected; + MetadataTransactionId issued; + try (PosixMetadataLog log = PosixMetadataLog.create( + path, STORE_ID, supportedProfile(), PosixMetadataLog.FaultInjector.NONE)) { + committed = log.issue(); + rejected = log.issue(); + try (ImmutableByteContent value = new ImmutableByteContent(new byte[] { 1, 2, 3 })) { + log.appendMutation(committed, MetadataFrameCodec.FrameType.MUTATION_CREATE, + 3L, value, CancellationSignal.NONE); + } + log.commit(committed, 6L); + log.reject(rejected, 91); + issued = log.issue(); + } + PosixMetadataLogScanner.RecoveryResult first = scan(path); + PosixMetadataLogScanner.RecoveryResult second = scan(path); + assertEquals(first, second); + assertEquals(PosixMetadataLogScanner.Tail.CLEAN_END, first.tail()); + assertEquals(Files.size(path), first.lastCompleteFrameBoundary()); + assertEquals(Files.size(path), first.physicalEnd()); + assertEquals(3, first.transactions().size()); + assertEquals(6L, first.transactions().get(committed).terminal() + .orElseThrow().committedRevision().orElseThrow()); + assertEquals(91, first.transactions().get(rejected).terminal() + .orElseThrow().failureCode().orElseThrow()); + assertTrue(first.transactions().get(issued).terminal().isEmpty()); + assertEquals("00000000000000000000000000000004", first.nextTransactionToken().orElseThrow()); + assertTrue(!first.transactionTokensExhausted()); + System.out.println("...ok"); + } + + @Test + void incompleteTailReportsExactBoundaryAndWritableOpenRepairsIt() throws IOException { + System.out.print("incompleteTailReportsExactBoundaryAndWritableOpenRepairsIt "); + Path canonical = completeLog("tail-source.log"); + List frames = frames(canonical); + int caseNumber = 0; + for (int index = 1; index < frames.size(); index++) { + MetadataFrameCodec.FrameMetadata frame = frames.get(index); + long[] cuts = { + frame.frameOffset() + 1L, + frame.payloadOffset() + Math.max(1L, frame.payloadLength() / 2L), + frame.payloadDigestOffset() + 1L + }; + for (long cut : cuts) { + Path truncated = copy(canonical, "tail-" + caseNumber++ + ".log"); + try (FileChannel channel = FileChannel.open(truncated, StandardOpenOption.WRITE)) { + channel.truncate(Math.min(cut, frame.frameEndOffset() - 1L)); + } + PosixMetadataLogScanner.RecoveryResult recovered = scan(truncated); + assertEquals(PosixMetadataLogScanner.Tail.INCOMPLETE_TAIL, recovered.tail()); + assertEquals(frame.frameOffset(), recovered.lastCompleteFrameBoundary()); + assertEquals(Files.size(truncated), recovered.physicalEnd()); + assertTrue(recovered.lastCompleteFrameBoundary() < recovered.physicalEnd()); + if (frame.frameType() == MetadataFrameCodec.FrameType.TRANSACTION_ISSUED) { + assertEquals( + "00000000000000000000000000000001", + recovered.nextTransactionToken().orElseThrow()); + assertTrue(!recovered.transactionTokensExhausted()); + assertTrue(recovered.transactions().isEmpty()); + } + try (PosixMetadataLog ignored = PosixMetadataLog.open( + truncated, supportedProfile(), PosixMetadataLog.FaultInjector.NONE)) { + assertTrue(Files.size(truncated) > frame.frameOffset()); + } + PosixMetadataLogScanner.RecoveryResult repaired = scan(truncated); + assertEquals(PosixMetadataLogScanner.Tail.CLEAN_END, repaired.tail()); + assertEquals(repaired.lastCompleteFrameBoundary(), repaired.physicalEnd()); + assertEquals(1L, repaired.recoveryEpoch()); + assertEquals(0L, repaired.openTransactionCount()); + } + } + System.out.println("...ok"); + } + + @Test + void restartEpochsAreStrictAndAbandonOnlyCurrentEpochTransactions() throws IOException { + System.out.print("restartEpochsAreStrictAndAbandonOnlyCurrentEpochTransactions "); + Path valid = temporaryDirectory.resolve("restart-valid.log"); + writeHeader(valid); + try (FileChannel channel = FileChannel.open(valid, StandardOpenOption.READ, StandardOpenOption.WRITE)) { + channel.position(channel.size()); + writeFrame(channel, MetadataFrameCodec.FrameType.TRANSACTION_ISSUED, + TRANSACTION_ID.token(), 0L, PosixMetadataLogScanner.encodeIssuance(TRANSACTION_ID)); + writeFrame(channel, MetadataFrameCodec.FrameType.RECOVERY_RESTART, + PosixMetadataLogScanner.zeroToken(), 1L, + PosixMetadataLogScanner.encodeRestart(1L, 1L, 0L)); + } + PosixMetadataLogScanner.RecoveryResult recovered = scan(valid); + assertEquals(1L, recovered.recoveryEpoch()); + assertEquals(0L, recovered.openTransactionCount()); + assertEquals("00000000000000000000000000000002", + recovered.nextTransactionToken().orElseThrow()); + + Path skipped = copy(valid, "restart-skipped.log"); + try (FileChannel channel = FileChannel.open(skipped, StandardOpenOption.READ, StandardOpenOption.WRITE)) { + channel.position(channel.size()); + writeFrame(channel, MetadataFrameCodec.FrameType.RECOVERY_RESTART, + PosixMetadataLogScanner.zeroToken(), 3L, + PosixMetadataLogScanner.encodeRestart(3L, 0L, 0L)); + } + assertIntegrity(skipped); + + Path invalidFirst = temporaryDirectory.resolve("restart-first.log"); + writeHeader(invalidFirst); + try (FileChannel channel = FileChannel.open( + invalidFirst, StandardOpenOption.READ, StandardOpenOption.WRITE)) { + channel.position(channel.size()); + writeFrame(channel, MetadataFrameCodec.FrameType.RECOVERY_RESTART, + PosixMetadataLogScanner.zeroToken(), 2L, + PosixMetadataLogScanner.encodeRestart(2L, 0L, 0L)); + } + assertIntegrity(invalidFirst); + + Path trailing = temporaryDirectory.resolve("restart-trailing.log"); + writeHeader(trailing); + byte[] restart = PosixMetadataLogScanner.encodeRestart(1L, 0L, 0L); + byte[] oversized = java.util.Arrays.copyOf(restart, restart.length + 1); + try (FileChannel channel = FileChannel.open( + trailing, StandardOpenOption.READ, StandardOpenOption.WRITE)) { + channel.position(channel.size()); + writeFrame(channel, MetadataFrameCodec.FrameType.RECOVERY_RESTART, + PosixMetadataLogScanner.zeroToken(), 1L, oversized); + } + assertIntegrity(trailing); + + Path oldEpoch = copy(valid, "restart-old-epoch.log"); + try (FileChannel channel = FileChannel.open(oldEpoch, StandardOpenOption.READ, StandardOpenOption.WRITE)) { + channel.position(channel.size()); + writeFrame(channel, MetadataFrameCodec.FrameType.MUTATION_DELETE, + TRANSACTION_ID.token(), 1L, new byte[0]); + } + assertIntegrity(oldEpoch); + System.out.println("...ok"); + } + + @Test + void restartRetainsExactAbandonedOutcomeLedger() throws IOException { + System.out.print("restartRetainsExactAbandonedOutcomeLedger "); + Path path = temporaryDirectory.resolve("restart-ledger.log"); + MetadataTransactionId terminalized; + MetadataTransactionId firstOpen; + MetadataTransactionId secondOpen; + try (PosixMetadataLog log = PosixMetadataLog.create( + path, STORE_ID, supportedProfile(), PosixMetadataLog.FaultInjector.NONE)) { + terminalized = log.issue(); + log.commit(terminalized, 1L); + firstOpen = log.issue(); + secondOpen = log.issue(); + } + assertEquals(2L, scan(path).openTransactionCount()); + try (PosixMetadataLog ignored = PosixMetadataLog.open( + path, supportedProfile(), PosixMetadataLog.FaultInjector.NONE)) { + // Opening publishes the recovery restart before exposing the writer. + } + PosixMetadataLogScanner.RecoveryResult recovered = scan(path); + assertEquals(0L, recovered.openTransactionCount()); + assertEquals(3, recovered.transactions().size()); + assertEquals(PosixMetadataLogScanner.TerminalKind.COMMITTED, + recovered.transactions().get(terminalized).terminal().orElseThrow().kind()); + assertAbandoned(recovered, firstOpen); + assertAbandoned(recovered, secondOpen); + + Path oldFrame = copy(path, "restart-ledger-old-frame.log"); + try (FileChannel channel = FileChannel.open( + oldFrame, StandardOpenOption.READ, StandardOpenOption.WRITE)) { + channel.position(channel.size()); + writeFrame(channel, MetadataFrameCodec.FrameType.MUTATION_DELETE, + firstOpen.token(), 1L, new byte[0]); + } + assertIntegrity(oldFrame); + System.out.println("...ok"); + } + + @Test + void recoveryEpochExhaustionAndInvalidTransitionsFailChecked() throws IOException { + System.out.print("recoveryEpochExhaustionAndInvalidTransitionsFailChecked "); + Path path = temporaryDirectory.resolve("epoch-boundary.log"); + writeHeader(path); + long size = Files.size(path); + MetadataStoreException writerFailure = assertThrows( + MetadataStoreException.class, + () -> PosixMetadataLog.checkedWritableRecoveryEpoch(Long.MAX_VALUE)); + assertEquals(MetadataCommitResult.FailureCategory.LIMIT_EXCEEDED, writerFailure.category()); + assertEquals(size, Files.size(path)); + + MetadataStoreException scannerFailure = assertThrows( + MetadataStoreException.class, + () -> PosixMetadataLogScanner.checkedNextRecoveryEpoch(Long.MAX_VALUE)); + assertEquals(MetadataCommitResult.FailureCategory.INTEGRITY_FAILURE, scannerFailure.category()); + assertTrue(scannerFailure.getCause() instanceof ArithmeticException); + + Path negative = temporaryDirectory.resolve("restart-negative.log"); + writeHeader(negative); + ByteBuffer payload = ByteBuffer.allocate(28).order(ByteOrder.BIG_ENDIAN); + payload.putShort((short) 1).putLong(-1L).putLong(0L).putLong(0L).putShort((short) 0); + try (FileChannel channel = FileChannel.open( + negative, StandardOpenOption.READ, StandardOpenOption.WRITE)) { + channel.position(channel.size()); + writeFrame(channel, MetadataFrameCodec.FrameType.RECOVERY_RESTART, + PosixMetadataLogScanner.zeroToken(), 1L, payload.array()); + } + assertIntegrity(negative); + System.out.println("...ok"); + } + + @Test + void authenticatedLengthAndFrameChecksumCorruptionAlwaysFailClosed() throws IOException { + System.out.print("authenticatedLengthAndFrameChecksumCorruptionAlwaysFailClosed "); + Path source = completeLog("corrupt-source.log"); + List frames = frames(source); + Path length = copy(source, "length.log"); + putLong(length, frames.get(1).frameOffset() + HEADER_PAYLOAD_LENGTH_OFFSET, Long.MAX_VALUE); + assertIntegrity(length); + Path header = copy(source, "header.log"); + xorByte(header, frames.get(0).frameOffset(), (byte) 1); + assertIntegrity(header); + Path payload = copy(source, "payload.log"); + xorByte(payload, frames.get(1).payloadOffset(), (byte) 1); + assertIntegrity(payload); + Path finalFrame = copy(source, "final.log"); + MetadataFrameCodec.FrameMetadata terminal = frames.get(frames.size() - 1); + xorByte(finalFrame, terminal.payloadDigestOffset(), (byte) 1); + assertIntegrity(finalFrame); + System.out.println("...ok"); + } + + @Test + void semanticVersionLengthAndTerminalChainMismatchFailClosed() throws IOException { + System.out.print("semanticVersionLengthAndTerminalChainMismatchFailClosed "); + Path source = completeLog("semantic-source.log"); + List frames = frames(source); + MetadataFrameCodec.FrameMetadata issuance = frames.get(1); + + Path version = copy(source, "version.log"); + putByte(version, issuance.payloadOffset() + 1L, (byte) 2); + refreshPayloadDigest(version, issuance); + assertIntegrity(version); + + Path chain = copy(source, "chain.log"); + MetadataFrameCodec.FrameMetadata terminal = frames.get(frames.size() - 1); + xorByte(chain, terminal.payloadDigestOffset() - 1L, (byte) 1); + refreshPayloadDigest(chain, terminal); + assertIntegrity(chain); + + Path trailing = temporaryDirectory.resolve("trailing-semantic.log"); + writeHeader(trailing); + byte[] issuancePayload = PosixMetadataLogScanner.encodeIssuance(TRANSACTION_ID); + byte[] oversized = java.util.Arrays.copyOf(issuancePayload, issuancePayload.length + 1); + try (FileChannel channel = FileChannel.open(trailing, StandardOpenOption.READ, StandardOpenOption.WRITE)) { + channel.position(channel.size()); + writeFrame(channel, MetadataFrameCodec.FrameType.TRANSACTION_ISSUED, + TRANSACTION_ID.token(), 0L, oversized); + } + assertIntegrity(trailing); + System.out.println("...ok"); + } + + @Test + void sequenceUnknownTransactionDuplicateAndStoreMismatchFailClosed() throws IOException { + System.out.print("sequenceUnknownTransactionDuplicateAndStoreMismatchFailClosed "); + Path gap = structuralLog("gap.log", 2L, false); + assertIntegrity(gap); + Path duplicate = structuralLog("duplicate.log", 1L, true); + assertIntegrity(duplicate); + + Path unknown = temporaryDirectory.resolve("unknown.log"); + writeHeader(unknown); + try (FileChannel channel = FileChannel.open(unknown, StandardOpenOption.READ, StandardOpenOption.WRITE)) { + channel.position(channel.size()); + writeFrame(channel, MetadataFrameCodec.FrameType.MUTATION_DELETE, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 1L, new byte[0]); + } + assertIntegrity(unknown); + + Path mismatch = temporaryDirectory.resolve("store-mismatch.log"); + writeHeader(mismatch); + MetadataTransactionId foreign = new MetadataTransactionId( + new MetadataStoreId("ffeeddccbbaa99887766554433221100"), TRANSACTION_ID.token()); + try (FileChannel channel = FileChannel.open(mismatch, StandardOpenOption.READ, StandardOpenOption.WRITE)) { + channel.position(channel.size()); + writeFrame(channel, MetadataFrameCodec.FrameType.TRANSACTION_ISSUED, + TRANSACTION_ID.token(), 0L, PosixMetadataLogScanner.encodeIssuance(foreign)); + } + assertIntegrity(mismatch); + + Path zero = issuanceLog("zero-token.log", "00000000000000000000000000000000"); + assertIntegrity(zero); + Path skippedFirst = issuanceLog("skipped-first.log", "00000000000000000000000000000002"); + assertIntegrity(skippedFirst); + Path duplicateToken = issuanceLog( + "duplicate-token.log", + "00000000000000000000000000000001", + "00000000000000000000000000000001"); + assertIntegrity(duplicateToken); + Path decreased = issuanceLog( + "decreased-token.log", + "00000000000000000000000000000001", + "00000000000000000000000000000002", + "00000000000000000000000000000001"); + assertIntegrity(decreased); + Path skipped = issuanceLog( + "skipped-token.log", + "00000000000000000000000000000001", + "00000000000000000000000000000003"); + assertIntegrity(skipped); + System.out.println("...ok"); + } + + private Path completeLog(String name) throws IOException { + Path path = temporaryDirectory.resolve(name); + try (PosixMetadataLog log = PosixMetadataLog.create( + path, STORE_ID, supportedProfile(), PosixMetadataLog.FaultInjector.NONE)) { + MetadataTransactionId transactionId = log.issue(); + try (ImmutableByteContent content = new ImmutableByteContent(new byte[] { 4, 5, 6 })) { + log.appendMutation(transactionId, MetadataFrameCodec.FrameType.MUTATION_REPLACE, + 3L, content, CancellationSignal.NONE); + } + log.commit(transactionId, 3L); + } + return path; + } + + private Path structuralLog(String name, long sequence, boolean duplicate) throws IOException { + Path path = temporaryDirectory.resolve(name); + writeHeader(path); + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ, StandardOpenOption.WRITE)) { + channel.position(channel.size()); + writeFrame(channel, MetadataFrameCodec.FrameType.TRANSACTION_ISSUED, + TRANSACTION_ID.token(), 0L, PosixMetadataLogScanner.encodeIssuance(TRANSACTION_ID)); + writeFrame(channel, MetadataFrameCodec.FrameType.MUTATION_CREATE, + TRANSACTION_ID.token(), sequence, new byte[] { 1 }); + if (duplicate) { + writeFrame(channel, MetadataFrameCodec.FrameType.MUTATION_DELETE, + TRANSACTION_ID.token(), sequence, new byte[0]); + } + } + return path; + } + + private Path issuanceLog(String name, String... tokens) throws IOException { + Path path = temporaryDirectory.resolve(name); + writeHeader(path); + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ, StandardOpenOption.WRITE)) { + channel.position(channel.size()); + for (String token : tokens) { + MetadataTransactionId transactionId = transaction(token); + writeFrame(channel, MetadataFrameCodec.FrameType.TRANSACTION_ISSUED, + token, 0L, PosixMetadataLogScanner.encodeIssuance(transactionId)); + } + } + return path; + } + + private void writeHeader(Path path) throws IOException { + try (FileChannel channel = FileChannel.open(path, + StandardOpenOption.CREATE_NEW, StandardOpenOption.READ, StandardOpenOption.WRITE)) { + writeFrame(channel, MetadataFrameCodec.FrameType.STORE_HEADER, + PosixMetadataLogScanner.zeroToken(), 0L, + PosixMetadataLogScanner.encodeStoreHeader(STORE_ID)); + } + } + + private static void writeFrame( + FileChannel channel, + MetadataFrameCodec.FrameType type, + String token, + long sequence, + byte[] payload) throws IOException { + try (ImmutableByteContent content = new ImmutableByteContent(payload)) { + new MetadataFrameCodec().write( + channel, type, token, sequence, payload.length, content, CancellationSignal.NONE); + } + } + + private static PosixMetadataLogScanner.RecoveryResult scan(Path path) throws IOException { + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) { + return PosixMetadataLogScanner.scan(channel); + } + } + + private static List frames(Path path) throws IOException { + List result = new ArrayList<>(); + MetadataFrameCodec codec = new MetadataFrameCodec(); + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) { + long offset = 0L; + while (true) { + MetadataFrameCodec.ReadResult read = codec.read(channel, offset); + if (read.classification() == MetadataFrameCodec.ReadClassification.END_OF_INPUT) { + return result; + } + assertEquals(MetadataFrameCodec.ReadClassification.COMPLETE_FRAME, read.classification()); + result.add(read.metadata()); + offset = read.metadata().frameEndOffset(); + } + } + } + + private Path copy(Path source, String name) throws IOException { + Path target = temporaryDirectory.resolve(name); + Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING); + return target; + } + + private static void assertIntegrity(Path path) { + MetadataStoreException failure = assertThrows(MetadataStoreException.class, () -> scan(path)); + assertEquals(MetadataCommitResult.FailureCategory.INTEGRITY_FAILURE, failure.category()); + } + + private static void assertAbandoned( + PosixMetadataLogScanner.RecoveryResult recovered, + MetadataTransactionId transactionId) { + PosixMetadataLogScanner.Terminal terminal = + recovered.transactions().get(transactionId).terminal().orElseThrow(); + assertEquals(PosixMetadataLogScanner.TerminalKind.NOT_COMMITTED, terminal.kind()); + assertEquals( + PosixMetadataLogScanner.FailureReason.ABANDONED_BY_RECOVERY.code(), + terminal.failureCode().orElseThrow()); + } + + private static void refreshPayloadDigest(Path path, MetadataFrameCodec.FrameMetadata frame) throws IOException { + byte[] payload = new byte[Math.toIntExact(frame.payloadLength())]; + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ, StandardOpenOption.WRITE)) { + channel.position(frame.payloadOffset()); + readFully(channel, ByteBuffer.wrap(payload)); + channel.position(frame.payloadDigestOffset()); + writeFully(channel, ByteBuffer.wrap(sha256(payload))); + } + } + + private static void putLong(Path path, long offset, long value) throws IOException { + ByteBuffer encoded = ByteBuffer.allocate(Long.BYTES).order(ByteOrder.BIG_ENDIAN).putLong(value); + encoded.flip(); + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.WRITE)) { + channel.position(offset); + writeFully(channel, encoded); + } + } + + private static void putByte(Path path, long offset, byte value) throws IOException { + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.WRITE)) { + channel.position(offset); + writeFully(channel, ByteBuffer.wrap(new byte[] { value })); + } + } + + private static void xorByte(Path path, long offset, byte mask) throws IOException { + ByteBuffer value = ByteBuffer.allocate(1); + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ, StandardOpenOption.WRITE)) { + channel.position(offset); + readFully(channel, value); + value.flip(); + channel.position(offset); + writeFully(channel, ByteBuffer.wrap(new byte[] { (byte) (value.get() ^ mask) })); + } + } + + private static void readFully(FileChannel channel, ByteBuffer target) throws IOException { + while (target.hasRemaining()) { + if (channel.read(target) < 0) { + throw new IOException("Unexpected test fixture truncation"); + } + } + } + + private static void writeFully(FileChannel channel, ByteBuffer source) throws IOException { + while (source.hasRemaining()) { + channel.write(source); + } + } + + private static byte[] sha256(byte[] payload) { + try { + return MessageDigest.getInstance("SHA-256").digest(payload); + } catch (NoSuchAlgorithmException failure) { + throw new IllegalStateException("SHA-256 is unavailable", failure); + } + } + + private static PosixMetadataLog.CapabilityProfile supportedProfile() { + return new PosixMetadataLog.CapabilityProfile() { + @Override + public boolean posixAvailable(Path parent) { + return true; + } + + @Override + public boolean localFileSystem(Path parent) { + return true; + } + + @Override + public void forceParent(FileChannel parentDirectory) throws IOException { + parentDirectory.force(true); + } + }; + } + + private static MetadataTransactionId transaction(String token) { + return new MetadataTransactionId(STORE_ID, token); + } +} diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/PosixMetadataLogTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/PosixMetadataLogTest.java new file mode 100644 index 0000000..a387779 --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/impl/fs/PosixMetadataLogTest.java @@ -0,0 +1,409 @@ +/******************************************************************************* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.channels.FileChannel; +import java.nio.file.Path; +import java.util.OptionalLong; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import zeroecho.core.io.CancellationSignal; +import zeroecho.core.io.ImmutableByteContent; +import zeroecho.core.io.RepeatableContent; +import zeroecho.pki.spi.store.MetadataStoreException; +import zeroecho.pki.spi.store.MetadataStoreId; +import zeroecho.pki.spi.store.MetadataTransactionId; + +final class PosixMetadataLogTest { + + private static final MetadataStoreId STORE_ID = + new MetadataStoreId("00112233445566778899aabbccddeeff"); + + @TempDir + Path temporaryDirectory; + + @Test + void createReopenAndExclusiveWriterLifecycleAreStrict() throws IOException { + System.out.print("createReopenAndExclusiveWriterLifecycleAreStrict "); + Path path = temporaryDirectory.resolve("lifecycle.log"); + PosixMetadataLog created = PosixMetadataLog.create(path, STORE_ID, supportedProfile(), + PosixMetadataLog.FaultInjector.NONE); + assertEquals(STORE_ID, created.storeId()); + assertThrows(MetadataStoreException.class, + () -> PosixMetadataLog.open(path, supportedProfile(), PosixMetadataLog.FaultInjector.NONE)); + created.close(); + created.close(); + assertThrows(IllegalStateException.class, created::scan); + try (PosixMetadataLog reopened = PosixMetadataLog.open( + path, supportedProfile(), PosixMetadataLog.FaultInjector.NONE)) { + assertEquals(STORE_ID, reopened.storeId()); + assertEquals(PosixMetadataLogScanner.Tail.CLEAN_END, reopened.scan().tail()); + assertEquals(1L, reopened.scan().recoveryEpoch()); + } + try (PosixMetadataLog reopened = PosixMetadataLog.open( + path, supportedProfile(), PosixMetadataLog.FaultInjector.NONE)) { + assertEquals(STORE_ID, reopened.storeId()); + assertEquals(2L, reopened.scan().recoveryEpoch()); + } + System.out.println("...ok"); + } + + @Test + void capabilityLimitationsIncludingDirectoryIOExceptionEmitOneSafeWarning() throws IOException { + System.out.print("capabilityLimitationsIncludingDirectoryIOExceptionEmitOneSafeWarning "); + Path path = temporaryDirectory.resolve("advisory.log"); + Logger logger = Logger.getLogger(PosixMetadataLog.class.getName()); + AtomicInteger warnings = new AtomicInteger(); + Handler handler = new Handler() { + @Override + public void publish(LogRecord record) { + if (record.getLevel().intValue() >= Level.WARNING.intValue()) { + assertEquals( + "POSIX metadata-log durability capabilities are limited; " + + "continuing in documented best-effort mode", + record.getMessage()); + assertEquals(0, record.getParameters() == null ? 0 : record.getParameters().length); + assertEquals(null, record.getThrown()); + warnings.incrementAndGet(); + } + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + }; + logger.addHandler(handler); + try { + PosixMetadataLog.CapabilityProfile limited = new PosixMetadataLog.CapabilityProfile() { + @Override + public boolean posixAvailable(Path parent) { + return false; + } + + @Override + public boolean localFileSystem(Path parent) { + return false; + } + + @Override + public void forceParent(FileChannel parentDirectory) throws IOException { + throw new IOException("injected"); + } + }; + try (PosixMetadataLog log = PosixMetadataLog.create( + path, STORE_ID, limited, PosixMetadataLog.FaultInjector.NONE)) { + assertEquals(STORE_ID, log.storeId()); + } + assertEquals(1, warnings.get()); + } finally { + logger.removeHandler(handler); + } + System.out.println("...ok"); + } + + @Test + void terminalizedTransactionStateIsBoundedBySimultaneousActivity() throws IOException { + System.out.print("terminalizedTransactionStateIsBoundedBySimultaneousActivity "); + PosixMetadataLogScanner.TransactionCounter counter = + PosixMetadataLogScanner.TransactionCounter.first(); + MetadataTransactionId counterValue = null; + for (int index = 0; index < 100_000; index++) { + counterValue = counter.issue(STORE_ID); + } + assertEquals("000000000000000000000000000186a0", counterValue.token()); + PosixMetadataLogScanner.TransactionCounter carry = + PosixMetadataLogScanner.TransactionCounter.startingAt(0L, -1L); + assertEquals("0000000000000000ffffffffffffffff", carry.issue(STORE_ID).token()); + assertEquals("00000000000000010000000000000000", carry.issue(STORE_ID).token()); + PosixMetadataLogScanner.TransactionCounter maximum = + PosixMetadataLogScanner.TransactionCounter.startingAt(-1L, -1L); + assertEquals("ffffffffffffffffffffffffffffffff", maximum.issue(STORE_ID).token()); + assertThrows(IllegalStateException.class, () -> maximum.issue(STORE_ID)); + assertThrows(IllegalArgumentException.class, + () -> PosixMetadataLogScanner.TransactionCounter.startingAt(0L, 0L)); + + Path path = temporaryDirectory.resolve("bounded-active.log"); + try (PosixMetadataLog log = PosixMetadataLog.create( + path, STORE_ID, supportedProfile(), PosixMetadataLog.FaultInjector.NONE)) { + for (int index = 0; index < 128; index++) { + MetadataTransactionId transactionId = log.issue(); + log.commit(transactionId, index); + assertEquals(0, log.activeTransactionCount()); + } + MetadataTransactionId first = log.issue(); + MetadataTransactionId second = log.issue(); + assertEquals(2, log.activeTransactionCount()); + log.reject(first, 1); + assertEquals(1, log.activeTransactionCount()); + log.commit(second, 10_002L); + assertEquals(0, log.activeTransactionCount()); + } + System.out.println("...ok"); + } + + @Test + void issuanceMutationsAndEmptyTerminalOutcomesRecoverExactly() throws IOException { + System.out.print("issuanceMutationsAndEmptyTerminalOutcomesRecoverExactly "); + Path path = temporaryDirectory.resolve("transactions.log"); + MetadataTransactionId committed; + MetadataTransactionId rejected; + try (PosixMetadataLog log = PosixMetadataLog.create( + path, STORE_ID, supportedProfile(), PosixMetadataLog.FaultInjector.NONE)) { + committed = log.issue(); + try (GeneratedContent content = new GeneratedContent(40_000)) { + assertEquals(1L, log.appendMutation( + committed, MetadataFrameCodec.FrameType.MUTATION_CREATE, + 40_000L, content, CancellationSignal.NONE).sequence()); + assertTrue(content.closedStream); + } + PosixMetadataLogScanner.Terminal committedResult = log.commit(committed, 9L); + assertEquals(PosixMetadataLogScanner.TerminalKind.COMMITTED, committedResult.kind()); + assertThrows(IllegalArgumentException.class, () -> log.commit(committed, 10L)); + rejected = log.issue(); + PosixMetadataLogScanner.Terminal rejectedResult = log.reject(rejected, 47); + assertEquals(47, rejectedResult.failureCode().orElseThrow()); + } + try (PosixMetadataLog reopened = PosixMetadataLog.open( + path, supportedProfile(), PosixMetadataLog.FaultInjector.NONE)) { + MetadataTransactionId continued = reopened.issue(); + assertEquals("00000000000000000000000000000003", continued.token()); + reopened.reject(continued, 48); + PosixMetadataLogScanner.RecoveryResult recovered = reopened.scan(); + assertEquals(1L, recovered.transactions().get(committed).mutationCount()); + assertEquals(9L, recovered.transactions().get(committed).terminal() + .orElseThrow().committedRevision().orElseThrow()); + assertEquals(0L, recovered.transactions().get(rejected).mutationCount()); + assertEquals(47, recovered.transactions().get(rejected).terminal() + .orElseThrow().failureCode().orElseThrow()); + assertEquals(48, recovered.transactions().get(continued).terminal() + .orElseThrow().failureCode().orElseThrow()); + } + System.out.println("...ok"); + } + + @Test + void issuanceAndMutationAppendFaultsRequireRecoveryWithoutInventingOutcomes() throws IOException { + System.out.print("issuanceAndMutationAppendFaultsRequireRecoveryWithoutInventingOutcomes "); + Path issuancePath = temporaryDirectory.resolve("issuance-fault.log"); + PosixMetadataLog.FaultInjector issuanceFault = point -> { + if (point == PosixMetadataLog.FaultPoint.ISSUANCE_APPEND) { + throw new IOException("injected"); + } + }; + try (PosixMetadataLog log = PosixMetadataLog.create( + issuancePath, STORE_ID, supportedProfile(), issuanceFault)) { + assertThrows(IOException.class, log::issue); + assertEquals(0, log.activeTransactionCount()); + assertThrows(IOException.class, log::issue); + } + assertTrue(scan(issuancePath).transactions().isEmpty()); + try (PosixMetadataLog reopened = PosixMetadataLog.open( + issuancePath, supportedProfile(), PosixMetadataLog.FaultInjector.NONE)) { + MetadataTransactionId first = reopened.issue(); + assertEquals("00000000000000000000000000000001", first.token()); + reopened.reject(first, 1); + } + + Path uncertainPath = temporaryDirectory.resolve("issuance-force-uncertain.log"); + AtomicInteger issuanceForceCount = new AtomicInteger(); + PosixMetadataLog.FaultInjector issuanceForceFault = point -> { + if (point == PosixMetadataLog.FaultPoint.FILE_FORCE + && issuanceForceCount.incrementAndGet() == 2) { + throw new IOException("injected"); + } + }; + try (PosixMetadataLog log = PosixMetadataLog.create( + uncertainPath, STORE_ID, supportedProfile(), issuanceForceFault)) { + assertThrows(IOException.class, log::issue); + assertThrows(IOException.class, log::issue); + } + PosixMetadataLogScanner.RecoveryResult uncertainRecovery = scan(uncertainPath); + assertTrue(uncertainRecovery.transactions().containsKey( + new MetadataTransactionId(STORE_ID, "00000000000000000000000000000001"))); + try (PosixMetadataLog reopened = PosixMetadataLog.open( + uncertainPath, supportedProfile(), PosixMetadataLog.FaultInjector.NONE)) { + assertEquals("00000000000000000000000000000002", reopened.issue().token()); + } + + Path path = temporaryDirectory.resolve("append-fault.log"); + AtomicInteger mutationFault = new AtomicInteger(1); + PosixMetadataLog.FaultInjector faults = point -> { + if (point == PosixMetadataLog.FaultPoint.MUTATION_APPEND + && mutationFault.getAndDecrement() > 0) { + throw new IOException("injected"); + } + }; + try (PosixMetadataLog log = PosixMetadataLog.create(path, STORE_ID, supportedProfile(), faults)) { + MetadataTransactionId transactionId = log.issue(); + try (ImmutableByteContent content = new ImmutableByteContent(new byte[] { 1 })) { + assertThrows(IOException.class, () -> log.appendMutation( + transactionId, MetadataFrameCodec.FrameType.MUTATION_CREATE, + 1L, content, CancellationSignal.NONE)); + } + assertThrows(IOException.class, log::issue); + } + PosixMetadataLogScanner.RecoveryResult recovered = scan(path); + assertEquals(1, recovered.transactions().size()); + assertTrue(recovered.transactions().values().iterator().next().terminal().isEmpty()); + System.out.println("...ok"); + } + + @Test + void terminalForceAndPostForceUncertaintyResolveOnlyByScanning() throws IOException { + System.out.print("terminalForceAndPostForceUncertaintyResolveOnlyByScanning "); + Path appendPath = temporaryDirectory.resolve("terminal-append.log"); + MetadataTransactionId appendId; + PosixMetadataLog.FaultInjector appendFault = point -> { + if (point == PosixMetadataLog.FaultPoint.TERMINAL_APPEND) { + throw new IOException("injected"); + } + }; + try (PosixMetadataLog log = PosixMetadataLog.create( + appendPath, STORE_ID, supportedProfile(), appendFault)) { + appendId = log.issue(); + assertThrows(PosixMetadataLog.OutcomeUnknownException.class, () -> log.commit(appendId, 3L)); + assertEquals(1, log.activeTransactionCount()); + assertThrows(IOException.class, log::issue); + } + assertTrue(scan(appendPath).transactions().get(appendId).terminal().isEmpty()); + + Path forcePath = temporaryDirectory.resolve("terminal-force.log"); + MetadataTransactionId forceId; + AtomicInteger forceCount = new AtomicInteger(); + PosixMetadataLog.FaultInjector forceFault = point -> { + if (point == PosixMetadataLog.FaultPoint.FILE_FORCE + && forceCount.incrementAndGet() == 3) { + throw new IOException("injected"); + } + }; + try (PosixMetadataLog log = PosixMetadataLog.create( + forcePath, STORE_ID, supportedProfile(), forceFault)) { + forceId = log.issue(); + assertThrows(PosixMetadataLog.OutcomeUnknownException.class, () -> log.commit(forceId, 4L)); + } + assertEquals(PosixMetadataLogScanner.TerminalKind.COMMITTED, + scan(forcePath).transactions().get(forceId).terminal().orElseThrow().kind()); + + Path hiddenPath = temporaryDirectory.resolve("post-force.log"); + MetadataTransactionId hiddenId; + PosixMetadataLog.FaultInjector hiddenFault = point -> { + if (point == PosixMetadataLog.FaultPoint.POST_FORCE_UNCERTAINTY) { + throw new IOException("injected"); + } + }; + try (PosixMetadataLog log = PosixMetadataLog.create( + hiddenPath, STORE_ID, supportedProfile(), hiddenFault)) { + hiddenId = log.issue(); + assertThrows(PosixMetadataLog.OutcomeUnknownException.class, () -> log.reject(hiddenId, 8)); + } + assertEquals(8, scan(hiddenPath).transactions().get(hiddenId).terminal() + .orElseThrow().failureCode().orElseThrow()); + System.out.println("...ok"); + } + + private static PosixMetadataLog.CapabilityProfile supportedProfile() { + return new PosixMetadataLog.CapabilityProfile() { + @Override + public boolean posixAvailable(Path parent) { + return true; + } + + @Override + public boolean localFileSystem(Path parent) { + return true; + } + + @Override + public void forceParent(FileChannel parentDirectory) throws IOException { + parentDirectory.force(true); + } + }; + } + + private static PosixMetadataLogScanner.RecoveryResult scan(Path path) throws IOException { + try (FileChannel channel = FileChannel.open(path, java.nio.file.StandardOpenOption.READ)) { + return PosixMetadataLogScanner.scan(channel); + } + } + + private static final class GeneratedContent implements RepeatableContent { + private final int length; + private boolean closedStream; + + private GeneratedContent(int length) { + this.length = length; + } + + @Override + public InputStream openStream() { + return new ByteArrayInputStream(new byte[length]) { + @Override + public void close() throws IOException { + closedStream = true; + super.close(); + } + }; + } + + @Override + public OptionalLong length() { + return OptionalLong.empty(); + } + + @Override + public String contentId() { + return "generated:test"; + } + + @Override + public void close() { + } + } +} diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/PosixMetadataStoreEngineTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/PosixMetadataStoreEngineTest.java new file mode 100644 index 0000000..c04cdd0 --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/impl/fs/PosixMetadataStoreEngineTest.java @@ -0,0 +1,633 @@ +/******************************************************************************* + * 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 static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.List; +import java.util.OptionalLong; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import zeroecho.core.io.CancellationSignal; +import zeroecho.core.io.ImmutableByteContent; +import zeroecho.core.io.RepeatableContent; +import zeroecho.pki.spi.store.MetadataKey; +import zeroecho.pki.spi.store.MetadataStoreException; +import zeroecho.pki.spi.store.MetadataStoreId; +import zeroecho.pki.spi.store.MetadataTransactionId; + +final class PosixMetadataStoreEngineTest { + private static final MetadataStoreId STORE_ID = + new MetadataStoreId("00112233445566778899aabbccddeeff"); + + @TempDir + Path temporaryDirectory; + + @Test + void createReplaceDeleteAndEmptyCommitsUseContiguousRevisions() throws IOException { + System.out.print("createReplaceDeleteAndEmptyCommitsUseContiguousRevisions "); + Path path = temporaryDirectory.resolve("lifecycle.log"); + MetadataKey key = key("record"); + try (PosixMetadataStoreEngine engine = PosixMetadataStoreEngine.create(path, STORE_ID)) { + MetadataTransactionId create = engine.issue(); + assertEquals(1L, engine.commit(create, List.of(create(key, "one"))) + .revision().orElseThrow()); + assertEquals(1L, engine.lookup(key).orElseThrow().revision()); + + MetadataTransactionId replace = engine.issue(); + assertEquals(2L, engine.commit(replace, List.of(replace(key, 1L, "two"))) + .revision().orElseThrow()); + assertEquals(2L, engine.lookup(key).orElseThrow().revision()); + + assertEquals(3L, engine.commit(engine.issue(), List.of()).revision().orElseThrow()); + assertEquals(3L, engine.storeRevision()); + assertTrue(engine.commit(engine.issue(), List.of(delete(key, 2L))).committed()); + assertTrue(engine.lookup(key).isEmpty()); + } + try (PosixMetadataStoreEngine reopened = PosixMetadataStoreEngine.open(path)) { + assertEquals(4L, reopened.storeRevision()); + assertTrue(reopened.lookup(key).isEmpty()); + } + System.out.println("...ok"); + } + + @Test + void multiKeyCommitIsAtomicAndConflictWritesOnlyForcedRejection() throws IOException { + System.out.print("multiKeyCommitIsAtomicAndConflictWritesOnlyForcedRejection "); + Path path = temporaryDirectory.resolve("atomic.log"); + MetadataKey first = key("first"); + MetadataKey second = new MetadataKey("example.other", "second"); + MetadataTransactionId conflict; + try (PosixMetadataStoreEngine engine = PosixMetadataStoreEngine.create(path, STORE_ID)) { + MetadataTransactionId firstIssued = engine.issue(); + MetadataTransactionId secondIssued = engine.issue(); + assertTrue(engine.commit(secondIssued, List.of(create(first, "a"), create(second, "b"))) + .committed()); + assertEquals(2, List.of(engine.lookup(first), engine.lookup(second)).stream() + .filter(java.util.Optional::isPresent).count()); + assertTrue(engine.commit(firstIssued, List.of()).committed()); + + conflict = engine.issue(); + PosixMetadataStoreEngine.CommitResult rejected = + engine.commit(conflict, List.of(create(first, "duplicate"))); + assertFalse(rejected.committed()); + assertEquals(2L, engine.storeRevision()); + } + PosixMetadataLogScanner.RecoveryResult recovered = scan(path); + PosixMetadataLogScanner.RecoveredTransaction rejected = recovered.transactions().get(conflict); + assertEquals(0L, rejected.mutationCount()); + assertEquals(PosixMetadataLogScanner.TerminalKind.NOT_COMMITTED, + rejected.terminal().orElseThrow().kind()); + System.out.println("...ok"); + } + + @Test + void durableValueOffsetsReadExactCommittedBytesAfterReopen() throws IOException { + System.out.print("durableValueOffsetsReadExactCommittedBytesAfterReopen "); + Path path = temporaryDirectory.resolve("offset.log"); + MetadataKey key = key("offset"); + byte[] expected = "durable-value".getBytes(java.nio.charset.StandardCharsets.UTF_8); + MetadataStateIndex.CurrentRecord record; + try (PosixMetadataStoreEngine engine = PosixMetadataStoreEngine.create(path, STORE_ID)) { + try (ImmutableByteContent value = new ImmutableByteContent(expected)) { + engine.commit(engine.issue(), List.of( + PosixMetadataStoreEngine.PreparedMutation.create( + key, value, CancellationSignal.NONE))); + } + record = engine.lookup(key).orElseThrow(); + } + byte[] observed = new byte[Math.toIntExact(record.valueLength())]; + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) { + channel.position(record.valueOffset()); + ByteBuffer target = ByteBuffer.wrap(observed); + while (target.hasRemaining()) { + assertTrue(channel.read(target) > 0); + } + } + assertArrayEquals(expected, observed); + try (PosixMetadataStoreEngine reopened = PosixMetadataStoreEngine.open(path)) { + assertEquals(record, reopened.lookup(key).orElseThrow()); + } + System.out.println("...ok"); + } + + @Test + void postForcePublicationFailureRequiresRecoveryWithoutLivePublication() throws IOException { + System.out.print("postForcePublicationFailureRequiresRecoveryWithoutLivePublication "); + Path path = temporaryDirectory.resolve("uncertain.log"); + MetadataKey key = key("uncertain"); + PosixMetadataStoreEngine.FaultInjector fault = point -> { + if (point == PosixMetadataStoreEngine.FaultPoint.POST_FORCE_PUBLICATION) { + throw new IOException("injected"); + } + }; + PosixMetadataStoreEngine engine = PosixMetadataStoreEngine.create( + path, STORE_ID, PosixMetadataLog.FaultInjector.NONE, fault); + assertThrows(PosixMetadataStoreEngine.OutcomeUnknownException.class, + () -> engine.commit(engine.issue(), List.of(create(key, "fixed")))); + assertThrows(IllegalStateException.class, engine::storeRevision); + engine.close(); + try (PosixMetadataStoreEngine reopened = PosixMetadataStoreEngine.open(path)) { + assertEquals(1L, reopened.storeRevision()); + assertTrue(reopened.lookup(key).isPresent()); + } + System.out.println("...ok"); + } + + @Test + void incompleteTailIsRepairedAndPriorCommittedStateIsPreserved() throws IOException { + System.out.print("incompleteTailIsRepairedAndPriorCommittedStateIsPreserved "); + Path path = temporaryDirectory.resolve("tail.log"); + MetadataKey key = key("tail"); + long cleanLength; + try (PosixMetadataStoreEngine engine = PosixMetadataStoreEngine.create(path, STORE_ID)) { + engine.commit(engine.issue(), List.of(create(key, "kept"))); + } + cleanLength = Files.size(path); + Files.write(path, new byte[] {1, 2, 3}, StandardOpenOption.APPEND); + assertTrue(Files.size(path) > cleanLength); + try (PosixMetadataStoreEngine reopened = PosixMetadataStoreEngine.open(path)) { + assertEquals(1L, reopened.storeRevision()); + assertTrue(reopened.lookup(key).isPresent()); + } + PosixMetadataLogScanner.RecoveryResult recovered = scan(path); + assertTrue(Files.size(path) > cleanLength); + assertEquals(PosixMetadataLogScanner.Tail.CLEAN_END, recovered.tail()); + assertEquals(1L, recovered.recoveryEpoch()); + assertEquals(0L, recovered.openTransactionCount()); + System.out.println("...ok"); + } + + @Test + void repeatedWritableRecoveryPreservesStateRevisionAndTransactionAuthority() throws IOException { + System.out.print("repeatedWritableRecoveryPreservesStateRevisionAndTransactionAuthority "); + Path path = temporaryDirectory.resolve("epochs.log"); + MetadataKey first = key("first-epoch"); + try (PosixMetadataStoreEngine engine = PosixMetadataStoreEngine.create(path, STORE_ID)) { + engine.commit(engine.issue(), List.of(create(first, "one"))); + } + assertEquals(0L, scan(path).recoveryEpoch()); + try (PosixMetadataStoreEngine reopened = PosixMetadataStoreEngine.open(path)) { + assertEquals(1L, reopened.storeRevision()); + assertTrue(reopened.lookup(first).isPresent()); + MetadataTransactionId next = reopened.issue(); + assertEquals("00000000000000000000000000000002", next.token()); + reopened.commit(next, List.of()); + } + assertEquals(1L, scan(path).recoveryEpoch()); + try (PosixMetadataStoreEngine reopened = PosixMetadataStoreEngine.open(path)) { + assertEquals(2L, reopened.storeRevision()); + assertTrue(reopened.lookup(first).isPresent()); + } + PosixMetadataLogScanner.ReplayResult replay = replay(path); + assertEquals(2L, replay.recoveryEpoch()); + assertEquals(0L, replay.openTransactionCount()); + assertEquals(0L, replay.recoveryFullMapCopies()); + assertEquals(1L, replay.recoveryFinalInstalls()); + assertTrue(replay.discardedDescriptorCount() >= 1L); + System.out.println("...ok"); + } + + @Test + void terminalForceUncertaintyReplaysAuthoritativeBytes() throws IOException { + System.out.print("terminalForceUncertaintyReplaysAuthoritativeBytes "); + Path path = temporaryDirectory.resolve("force.log"); + MetadataKey key = key("force"); + AtomicInteger forces = new AtomicInteger(); + PosixMetadataLog.FaultInjector faults = point -> { + if (point == PosixMetadataLog.FaultPoint.FILE_FORCE + && forces.incrementAndGet() == 3) { + throw new IOException("injected"); + } + }; + PosixMetadataStoreEngine engine = PosixMetadataStoreEngine.create( + path, STORE_ID, faults, PosixMetadataStoreEngine.FaultInjector.NONE); + assertThrows(PosixMetadataStoreEngine.OutcomeUnknownException.class, + () -> engine.commit(engine.issue(), List.of(create(key, "forced")))); + assertThrows(IllegalStateException.class, engine::storeRevision); + engine.close(); + try (PosixMetadataStoreEngine reopened = PosixMetadataStoreEngine.open(path)) { + assertEquals(1L, reopened.storeRevision()); + assertTrue(reopened.lookup(key).isPresent()); + } + System.out.println("...ok"); + } + + @Test + void preparedContentIsClosedExactlyOnceAcrossTerminalPaths() throws IOException { + System.out.print("preparedContentIsClosedExactlyOnceAcrossTerminalPaths "); + Path successPath = temporaryDirectory.resolve("cleanup-success.log"); + CountingContent success = new CountingContent(new byte[] {1}); + try (PosixMetadataStoreEngine engine = PosixMetadataStoreEngine.create(successPath, STORE_ID)) { + engine.commit(engine.issue(), List.of( + PosixMetadataStoreEngine.PreparedMutation.create( + key("cleanup"), success, CancellationSignal.NONE))); + } + assertEquals(1, success.closeCount()); + + Path conflictPath = temporaryDirectory.resolve("cleanup-conflict.log"); + CountingContent conflict = new CountingContent(new byte[] {2}); + try (PosixMetadataStoreEngine engine = PosixMetadataStoreEngine.create(conflictPath, STORE_ID)) { + engine.commit(engine.issue(), List.of(create(key("cleanup"), "present"))); + assertFalse(engine.commit(engine.issue(), List.of( + PosixMetadataStoreEngine.PreparedMutation.create( + key("cleanup"), conflict, CancellationSignal.NONE))).committed()); + } + assertEquals(1, conflict.closeCount()); + + Path failurePath = temporaryDirectory.resolve("cleanup-failure.log"); + CountingContent failure = new CountingContent(new byte[] {3}); + PosixMetadataLog.FaultInjector fault = point -> { + if (point == PosixMetadataLog.FaultPoint.MUTATION_APPEND) { + throw new IOException("injected"); + } + }; + try (PosixMetadataStoreEngine engine = PosixMetadataStoreEngine.create( + failurePath, STORE_ID, fault, PosixMetadataStoreEngine.FaultInjector.NONE)) { + assertThrows(IOException.class, () -> engine.commit(engine.issue(), List.of( + PosixMetadataStoreEngine.PreparedMutation.create( + key("cleanup"), failure, CancellationSignal.NONE)))); + } + assertEquals(1, failure.closeCount()); + System.out.println("...ok"); + } + + @Test + void restartAbandonsOrphanBatchWithoutChangingRecoveredState() throws IOException { + System.out.print("restartAbandonsOrphanBatchWithoutChangingRecoveredState "); + Path path = temporaryDirectory.resolve("orphan.log"); + MetadataKey orphan = key("orphan"); + MetadataTransactionId transactionId; + try (PosixMetadataLog log = PosixMetadataLog.create(path, STORE_ID)) { + transactionId = log.issue(); + try (ImmutableByteContent value = new ImmutableByteContent(new byte[] {9}); + RepeatableContent payload = MetadataMutationPayloadCodec.create( + orphan, value, CancellationSignal.NONE)) { + log.appendMutation( + transactionId, + MetadataFrameCodec.FrameType.MUTATION_CREATE, + payload.length().orElseThrow(), + payload, + CancellationSignal.NONE); + } + } + assertEquals(1L, scan(path).openTransactionCount()); + try (PosixMetadataStoreEngine engine = PosixMetadataStoreEngine.open(path)) { + assertEquals(0L, engine.storeRevision()); + assertTrue(engine.lookup(orphan).isEmpty()); + PosixMetadataStoreEngine.CommitResult abandoned = engine.resolve(transactionId); + assertFalse(abandoned.committed()); + assertEquals( + PosixMetadataLogScanner.FailureReason.ABANDONED_BY_RECOVERY, + abandoned.failureReason().orElseThrow()); + assertEquals(abandoned, engine.resolve(transactionId)); + MetadataStoreException unissued = assertThrows( + MetadataStoreException.class, + () -> engine.resolve(new MetadataTransactionId( + STORE_ID, "00000000000000000000000000000009"))); + assertEquals( + zeroecho.pki.spi.store.MetadataCommitResult.FailureCategory.TRANSACTION_NOT_ISSUED, + unissued.category()); + assertEquals("00000000000000000000000000000002", engine.issue().token()); + } + PosixMetadataLogScanner.RecoveryResult recovered = scan(path); + assertEquals(1L, recovered.recoveryEpoch()); + assertEquals(2, recovered.transactions().size()); + assertEquals( + PosixMetadataLogScanner.FailureReason.ABANDONED_BY_RECOVERY.code(), + recovered.transactions().get(transactionId).terminal().orElseThrow() + .failureCode().orElseThrow()); + assertEquals(1L, recovered.openTransactionCount()); + System.out.println("...ok"); + } + + @Test + void committedOutcomeSurvivesOwnedContentCleanupFailure() throws IOException { + System.out.print("committedOutcomeSurvivesOwnedContentCleanupFailure "); + Path path = temporaryDirectory.resolve("cleanup-committed.log"); + MetadataKey key = key("cleanup-committed"); + CountingContent content = new CountingContent(new byte[] {4}, true); + AtomicInteger warnings = new AtomicInteger(); + Logger logger = Logger.getLogger(PosixMetadataStoreEngine.class.getName()); + Handler handler = cleanupWarningHandler(warnings); + logger.addHandler(handler); + try (PosixMetadataStoreEngine engine = PosixMetadataStoreEngine.create(path, STORE_ID)) { + MetadataTransactionId transactionId = engine.issue(); + PosixMetadataStoreEngine.CommitResult committed = engine.commit( + transactionId, + List.of(PosixMetadataStoreEngine.PreparedMutation.create( + key, content, CancellationSignal.NONE))); + assertTrue(committed.committed()); + assertEquals(committed, engine.resolve(transactionId)); + assertEquals(committed, engine.resolve(transactionId)); + assertTrue(engine.lookup(key).isPresent()); + } finally { + logger.removeHandler(handler); + } + assertEquals(1, content.closeCount()); + assertEquals(1, warnings.get()); + System.out.println("...ok"); + } + + @Test + void rejectedOutcomeSurvivesOwnedContentCleanupFailure() throws IOException { + System.out.print("rejectedOutcomeSurvivesOwnedContentCleanupFailure "); + Path path = temporaryDirectory.resolve("cleanup-rejected.log"); + MetadataKey key = key("cleanup-rejected"); + CountingContent content = new CountingContent(new byte[] {5}, true); + AtomicInteger warnings = new AtomicInteger(); + Logger logger = Logger.getLogger(PosixMetadataStoreEngine.class.getName()); + Handler handler = cleanupWarningHandler(warnings); + logger.addHandler(handler); + try (PosixMetadataStoreEngine engine = PosixMetadataStoreEngine.create(path, STORE_ID)) { + engine.commit(engine.issue(), List.of(create(key, "existing"))); + MetadataTransactionId transactionId = engine.issue(); + PosixMetadataStoreEngine.CommitResult rejected = engine.commit( + transactionId, + List.of(PosixMetadataStoreEngine.PreparedMutation.create( + key, content, CancellationSignal.NONE))); + assertFalse(rejected.committed()); + assertEquals( + PosixMetadataLogScanner.FailureReason.TRANSACTION_CONFLICT, + rejected.failureReason().orElseThrow()); + assertEquals(rejected, engine.resolve(transactionId)); + assertEquals(rejected, engine.resolve(transactionId)); + assertEquals(1L, engine.storeRevision()); + assertTrue(engine.lookup(key).isPresent()); + } finally { + logger.removeHandler(handler); + } + assertEquals(1, content.closeCount()); + assertEquals(1, warnings.get()); + System.out.println("...ok"); + } + + @Test + void committedOutcomeSurvivesRuntimeCleanupAndClosesRemainingResources() throws IOException { + System.out.print("committedOutcomeSurvivesRuntimeCleanupAndClosesRemainingResources "); + Path path = temporaryDirectory.resolve("cleanup-runtime-committed.log"); + MetadataKey firstKey = key("runtime-first"); + MetadataKey secondKey = key("runtime-second"); + CountingContent failing = new CountingContent(new byte[] {6}, CloseFailure.RUNTIME); + CountingContent remaining = new CountingContent(new byte[] {7}); + AtomicInteger warnings = new AtomicInteger(); + Logger logger = Logger.getLogger(PosixMetadataStoreEngine.class.getName()); + Handler handler = cleanupWarningHandler(warnings); + logger.addHandler(handler); + try (PosixMetadataStoreEngine engine = PosixMetadataStoreEngine.create(path, STORE_ID)) { + MetadataTransactionId transactionId = engine.issue(); + PosixMetadataStoreEngine.CommitResult committed = engine.commit( + transactionId, + List.of( + PosixMetadataStoreEngine.PreparedMutation.create( + firstKey, failing, CancellationSignal.NONE), + PosixMetadataStoreEngine.PreparedMutation.create( + secondKey, remaining, CancellationSignal.NONE))); + assertTrue(committed.committed()); + assertEquals(committed, engine.resolve(transactionId)); + assertTrue(engine.lookup(firstKey).isPresent()); + assertTrue(engine.lookup(secondKey).isPresent()); + } finally { + logger.removeHandler(handler); + } + assertEquals(1, failing.closeCount()); + assertEquals(1, remaining.closeCount()); + assertEquals(1, warnings.get()); + System.out.println("...ok"); + } + + @Test + void rejectedOutcomeSurvivesRuntimeCleanupAndClosesRemainingResources() throws IOException { + System.out.print("rejectedOutcomeSurvivesRuntimeCleanupAndClosesRemainingResources "); + Path path = temporaryDirectory.resolve("cleanup-runtime-rejected.log"); + MetadataKey existing = key("runtime-existing"); + MetadataKey untouched = key("runtime-untouched"); + CountingContent failing = new CountingContent(new byte[] {8}, CloseFailure.RUNTIME); + CountingContent remaining = new CountingContent(new byte[] {9}); + AtomicInteger warnings = new AtomicInteger(); + Logger logger = Logger.getLogger(PosixMetadataStoreEngine.class.getName()); + Handler handler = cleanupWarningHandler(warnings); + logger.addHandler(handler); + try (PosixMetadataStoreEngine engine = PosixMetadataStoreEngine.create(path, STORE_ID)) { + engine.commit(engine.issue(), List.of(create(existing, "existing"))); + MetadataTransactionId transactionId = engine.issue(); + PosixMetadataStoreEngine.CommitResult rejected = engine.commit( + transactionId, + List.of( + PosixMetadataStoreEngine.PreparedMutation.create( + existing, failing, CancellationSignal.NONE), + PosixMetadataStoreEngine.PreparedMutation.create( + untouched, remaining, CancellationSignal.NONE))); + assertFalse(rejected.committed()); + assertEquals(rejected, engine.resolve(transactionId)); + assertTrue(engine.lookup(existing).isPresent()); + assertTrue(engine.lookup(untouched).isEmpty()); + } finally { + logger.removeHandler(handler); + } + assertEquals(1, failing.closeCount()); + assertEquals(1, remaining.closeCount()); + assertEquals(1, warnings.get()); + System.out.println("...ok"); + } + + @Test + void throwingWarningHandlerCannotReplaceAuthoritativeOutcome() throws IOException { + System.out.print("throwingWarningHandlerCannotReplaceAuthoritativeOutcome "); + Path path = temporaryDirectory.resolve("cleanup-logging.log"); + CountingContent content = new CountingContent(new byte[] {10}, true); + Logger logger = Logger.getLogger(PosixMetadataStoreEngine.class.getName()); + Handler throwing = new Handler() { + @Override + public void publish(LogRecord record) { + throw new IllegalStateException("injected logging failure"); + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + }; + logger.addHandler(throwing); + try (PosixMetadataStoreEngine engine = PosixMetadataStoreEngine.create(path, STORE_ID)) { + MetadataTransactionId transactionId = engine.issue(); + PosixMetadataStoreEngine.CommitResult committed = engine.commit( + transactionId, + List.of(PosixMetadataStoreEngine.PreparedMutation.create( + key("logging"), content, CancellationSignal.NONE))); + assertTrue(committed.committed()); + assertEquals(committed, engine.resolve(transactionId)); + } finally { + logger.removeHandler(throwing); + } + assertEquals(1, content.closeCount()); + System.out.println("...ok"); + } + + private static MetadataKey key(String logicalKey) { + return new MetadataKey("example.engine", logicalKey); + } + + private static PosixMetadataStoreEngine.PreparedMutation create(MetadataKey key, String value) + throws IOException { + return PosixMetadataStoreEngine.PreparedMutation.create( + key, + new ImmutableByteContent(value.getBytes(java.nio.charset.StandardCharsets.UTF_8)), + CancellationSignal.NONE); + } + + private static PosixMetadataStoreEngine.PreparedMutation replace( + MetadataKey key, long revision, String value) throws IOException { + return PosixMetadataStoreEngine.PreparedMutation.replace( + key, + revision, + new ImmutableByteContent(value.getBytes(java.nio.charset.StandardCharsets.UTF_8)), + CancellationSignal.NONE); + } + + private static PosixMetadataStoreEngine.PreparedMutation delete(MetadataKey key, long revision) { + return PosixMetadataStoreEngine.PreparedMutation.delete(key, revision); + } + + private static PosixMetadataLogScanner.RecoveryResult scan(Path path) throws IOException { + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) { + return PosixMetadataLogScanner.scan(channel); + } + } + + private static PosixMetadataLogScanner.ReplayResult replay(Path path) throws IOException { + try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) { + return PosixMetadataLogScanner.replay(channel); + } + } + + private static Handler cleanupWarningHandler(AtomicInteger warnings) { + return new Handler() { + @Override + public void publish(LogRecord record) { + if (record.getLevel().intValue() >= Level.WARNING.intValue()) { + assertEquals( + "POSIX metadata transaction resources could not be fully retired after a known outcome", + record.getMessage()); + assertEquals(null, record.getThrown()); + assertEquals(null, record.getParameters()); + warnings.incrementAndGet(); + } + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + }; + } + + private static final class CountingContent implements RepeatableContent { + private final byte[] bytes; + private final CloseFailure closeFailure; + private final AtomicInteger closes = new AtomicInteger(); + + private CountingContent(byte[] bytes) { + this(bytes, CloseFailure.NONE); + } + + private CountingContent(byte[] bytes, boolean failOnClose) { + this(bytes, failOnClose ? CloseFailure.IO : CloseFailure.NONE); + } + + private CountingContent(byte[] bytes, CloseFailure closeFailure) { + this.bytes = bytes.clone(); + this.closeFailure = closeFailure; + } + + @Override + public InputStream openStream() { + return new ByteArrayInputStream(bytes); + } + + @Override + public OptionalLong length() { + return OptionalLong.of(bytes.length); + } + + @Override + public String contentId() { + return "counting:test"; + } + + @Override + public void close() throws IOException { + closes.incrementAndGet(); + if (closeFailure == CloseFailure.IO) { + throw new IOException("injected cleanup detail"); + } + if (closeFailure == CloseFailure.RUNTIME) { + throw new IllegalStateException("injected runtime cleanup detail"); + } + } + + private int closeCount() { + return closes.get(); + } + } + + private enum CloseFailure { + NONE, + IO, + RUNTIME + } +} diff --git a/pki/src/test/java/zeroecho/pki/impl/fs/PosixTransactionalMetadataStoreTest.java b/pki/src/test/java/zeroecho/pki/impl/fs/PosixTransactionalMetadataStoreTest.java new file mode 100644 index 0000000..a120d15 --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/impl/fs/PosixTransactionalMetadataStoreTest.java @@ -0,0 +1,919 @@ +/******************************************************************************* + * 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 static org.junit.jupiter.api.Assertions.assertArrayEquals; +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.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +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.List; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Handler; +import java.util.logging.LogRecord; +import java.util.logging.Logger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import zeroecho.core.io.CancellationSignal; +import zeroecho.core.io.RepeatableContent; +import zeroecho.pki.spi.store.MetadataCommitResult; +import zeroecho.pki.spi.store.MetadataCursor; +import zeroecho.pki.spi.store.MetadataKey; +import zeroecho.pki.spi.store.MetadataSnapshot; +import zeroecho.pki.spi.store.MetadataStoreCapabilities; +import zeroecho.pki.spi.store.MetadataStoreException; +import zeroecho.pki.spi.store.MetadataStoreId; +import zeroecho.pki.spi.store.MetadataTransaction; +import zeroecho.pki.spi.store.MetadataTransactionId; + +final class PosixTransactionalMetadataStoreTest { + private static final MetadataStoreId STORE_ID = + new MetadataStoreId("00112233445566778899aabbccddeeff"); + private static final MetadataKey FIRST = new MetadataKey("example.records", "a"); + private static final MetadataKey SECOND = new MetadataKey("example.records", "b"); + + @TempDir + Path temporaryDirectory; + + @Test + void capabilitiesLifecycleAuthorityAndThreadConfinement() throws Exception { + System.out.print("capabilitiesLifecycleAuthorityAndThreadConfinement "); + Path path = temporaryDirectory.resolve("authority.log"); + MetadataTransactionId issued; + try (PosixTransactionalMetadataStore store = + PosixTransactionalMetadataStore.create(path, STORE_ID)) { + assertEquals(STORE_ID, store.id()); + MetadataStoreCapabilities capabilities = store.capabilities(); + assertEquals(MetadataStoreCapabilities.WriterModel.EXCLUSIVE_WRITER, + capabilities.writerModel()); + assertEquals(MetadataStoreCapabilities.ContentLengthModel.KNOWN_LENGTH_ONLY, + capabilities.contentLengthModel()); + assertEquals(MetadataStoreCapabilities.OutcomeRetention.INDEFINITE, + capabilities.outcomeRetention()); + assertEquals(Set.of(MetadataStoreCapabilities.OptionalFeature.CROSS_PROCESS_COORDINATION), + capabilities.optionalFeatures()); + assertTrue(capabilities.maximumIndividualRecordBytes().isEmpty()); + + MetadataTransaction transaction = store.beginTransaction(); + issued = transaction.id(); + try (ExecutorService executor = Executors.newSingleThreadExecutor()) { + Future foreignThread = executor.submit(() -> { + transaction.delete(FIRST, 0L); + return null; + }); + assertTrue(assertThrows(ExecutionException.class, foreignThread::get) + .getCause() instanceof IllegalStateException); + } + transaction.abort(); + assertThrows(IllegalStateException.class, transaction::commit); + transaction.close(); + + MetadataTransactionId unissued = new MetadataTransactionId( + STORE_ID, "ffffffffffffffffffffffffffffffff"); + MetadataStoreException absent = assertThrows( + MetadataStoreException.class, () -> store.resolve(unissued)); + assertEquals(MetadataCommitResult.FailureCategory.TRANSACTION_NOT_ISSUED, + absent.category()); + MetadataStoreException foreign = assertThrows( + MetadataStoreException.class, + () -> store.resolve(new MetadataTransactionId( + new MetadataStoreId("ffeeddccbbaa99887766554433221100"), + issued.token()))); + assertEquals(MetadataCommitResult.FailureCategory.FOREIGN_TRANSACTION, + foreign.category()); + } + try (PosixTransactionalMetadataStore reopened = + PosixTransactionalMetadataStore.open(path)) { + assertEquals(STORE_ID, reopened.id()); + assertCategory(reopened.resolve(issued), MetadataCommitResult.Outcome.NOT_COMMITTED, + MetadataCommitResult.FailureCategory.ABANDONED_BY_RECOVERY); + } + PosixTransactionalMetadataStore closed = PosixTransactionalMetadataStore.open(path); + closed.close(); + closed.close(); + assertThrows(IllegalStateException.class, closed::beginTransaction); + System.out.println("...ok"); + } + + @Test + void admissionDetachesKnownContentAndRejectsUnsupportedOrInvalidSources() throws Exception { + System.out.print("admissionDetachesKnownContentAndRejectsUnsupportedOrInvalidSources "); + Path path = temporaryDirectory.resolve("admission.log"); + try (PosixTransactionalMetadataStore store = PosixTransactionalMetadataStore.create( + path, STORE_ID, OptionalLong.of(5L)); + MetadataTransaction transaction = store.beginTransaction()) { + ProbeContent known = new ProbeContent("five", OptionalLong.of(4L)); + transaction.create(FIRST, known, CancellationSignal.NONE); + known.invalidate(); + assertEquals(1, known.opens.get()); + + ProbeContent unknown = new ProbeContent("x", OptionalLong.empty()); + MetadataStoreException unsupported = assertThrows( + MetadataStoreException.class, + () -> transaction.create(SECOND, unknown, CancellationSignal.NONE)); + assertEquals(MetadataCommitResult.FailureCategory.UNSUPPORTED_CAPABILITY, + unsupported.category()); + assertEquals(0, unknown.opens.get()); + + MetadataStoreException limit = assertThrows( + MetadataStoreException.class, + () -> transaction.create( + new MetadataKey("example.records", "limit"), + new ProbeContent("123456", OptionalLong.of(6L)), + CancellationSignal.NONE)); + assertEquals(MetadataCommitResult.FailureCategory.LIMIT_EXCEEDED, limit.category()); + + ProbeContent cancelled = new ProbeContent("x", OptionalLong.of(1L)); + MetadataStoreException cancellation = assertThrows( + MetadataStoreException.class, + () -> transaction.create( + new MetadataKey("example.records", "cancel"), + cancelled, + () -> true)); + assertEquals(MetadataCommitResult.FailureCategory.CANCELLED, cancellation.category()); + assertEquals(0, cancelled.opens.get()); + + MetadataStoreException shortValue = assertThrows( + MetadataStoreException.class, + () -> transaction.create( + new MetadataKey("example.records", "short"), + new ProbeContent("x", OptionalLong.of(2L)), + CancellationSignal.NONE)); + assertEquals(MetadataCommitResult.FailureCategory.STORAGE_FAILURE, + shortValue.category()); + MetadataStoreException excess = assertThrows( + MetadataStoreException.class, + () -> transaction.create( + new MetadataKey("example.records", "excess"), + new ProbeContent("xx", OptionalLong.of(1L)), + CancellationSignal.NONE)); + assertEquals(MetadataCommitResult.FailureCategory.STORAGE_FAILURE, excess.category()); + + MetadataCommitResult committed = transaction.commit(); + assertEquals(MetadataCommitResult.Outcome.COMMITTED, committed.outcome()); + assertTrue(committed.failureCategory().isEmpty()); + assertEquals("five", read(store.snapshot(), FIRST)); + } + System.out.println("...ok"); + } + + @Test + void createReplaceDeleteMultiKeyAndEmptyCommitUseExactRevisions() throws Exception { + System.out.print("createReplaceDeleteMultiKeyAndEmptyCommitUseExactRevisions "); + Path path = temporaryDirectory.resolve("transactions.log"); + try (PosixTransactionalMetadataStore store = + PosixTransactionalMetadataStore.create(path, STORE_ID)) { + try (MetadataTransaction transaction = store.beginTransaction()) { + transaction.create(FIRST, content("one"), CancellationSignal.NONE); + transaction.create(new MetadataKey("example.other", "two"), + content("two"), CancellationSignal.NONE); + assertEquals(1L, transaction.commit().committedRevision().orElseThrow()); + } + try (MetadataSnapshot snapshot = store.snapshot()) { + assertEquals(1L, snapshot.revision()); + assertEquals(1L, snapshot.get(FIRST).orElseThrow().recordRevision()); + } + try (MetadataTransaction transaction = store.beginTransaction()) { + transaction.replace(FIRST, 1L, content("updated"), CancellationSignal.NONE); + transaction.delete(new MetadataKey("example.other", "two"), 1L); + assertEquals(2L, transaction.commit().committedRevision().orElseThrow()); + } + try (MetadataTransaction transaction = store.beginTransaction()) { + assertEquals(3L, transaction.commit().committedRevision().orElseThrow()); + } + try (MetadataSnapshot snapshot = store.snapshot()) { + assertEquals(3L, snapshot.revision()); + assertEquals("updated", read(snapshot, FIRST)); + assertTrue(snapshot.get(new MetadataKey("example.other", "two")).isEmpty()); + } + } + System.out.println("...ok"); + } + + @Test + void conflictIsAtomicTerminalResolvableAndDistinct() throws Exception { + System.out.print("conflictIsAtomicTerminalResolvableAndDistinct "); + Path path = temporaryDirectory.resolve("conflict.log"); + try (PosixTransactionalMetadataStore store = + PosixTransactionalMetadataStore.create(path, STORE_ID)) { + create(store, FIRST, "original"); + MetadataTransaction transaction = store.beginTransaction(); + transaction.create(FIRST, content("conflict"), CancellationSignal.NONE); + transaction.create(SECOND, content("hidden"), CancellationSignal.NONE); + MetadataCommitResult rejected = transaction.commit(); + assertCategory(rejected, MetadataCommitResult.Outcome.NOT_COMMITTED, + MetadataCommitResult.FailureCategory.CONFLICT); + assertEquals(rejected, store.resolve(transaction.id())); + assertEquals(rejected, store.resolve(transaction.id())); + store.acknowledge(transaction.id()); + assertThrows(IllegalStateException.class, transaction::commit); + try (MetadataSnapshot snapshot = store.snapshot()) { + assertEquals("original", read(snapshot, FIRST)); + assertTrue(snapshot.get(SECOND).isEmpty()); + } + transaction.close(); + } + System.out.println("...ok"); + } + + @Test + void abortAndConflictRetireEveryDetachedSpool() throws Exception { + System.out.print("abortAndConflictRetireEveryDetachedSpool "); + Path path = temporaryDirectory.resolve("cleanup.log"); + CountingStaging staging = new CountingStaging(); + try (PosixTransactionalMetadataStore store = + PosixTransactionalMetadataStore.createForTest( + path, STORE_ID, OptionalLong.empty(), staging, + PosixMetadataLog.FaultInjector.NONE, + PosixMetadataStoreEngine.FaultInjector.NONE)) { + try (MetadataTransaction aborted = store.beginTransaction()) { + aborted.create(FIRST, content("aborted"), CancellationSignal.NONE); + aborted.abort(); + } + assertEquals(0, staging.liveFiles()); + create(store, FIRST, "existing"); + try (MetadataTransaction conflict = store.beginTransaction()) { + conflict.create(FIRST, content("conflict"), CancellationSignal.NONE); + assertEquals(MetadataCommitResult.Outcome.NOT_COMMITTED, + conflict.commit().outcome()); + } + assertEquals(0, staging.liveFiles()); + } + assertEquals(0, staging.liveFiles()); + System.out.println("...ok"); + } + + @Test + void cleanupFailureCannotReplaceAuthoritativeOutcome() throws Exception { + System.out.print("cleanupFailureCannotReplaceAuthoritativeOutcome "); + Path path = temporaryDirectory.resolve("cleanup-failure.log"); + CountingStaging staging = new CountingStaging(); + staging.failNextDelete.set(true); + MetadataTransactionId transactionId; + try (PosixTransactionalMetadataStore store = + PosixTransactionalMetadataStore.createForTest( + path, STORE_ID, OptionalLong.empty(), staging, + PosixMetadataLog.FaultInjector.NONE, + PosixMetadataStoreEngine.FaultInjector.NONE)) { + MetadataTransaction transaction = store.beginTransaction(); + transactionId = transaction.id(); + transaction.create(FIRST, content("committed"), CancellationSignal.NONE); + MetadataCommitResult committed = transaction.commit(); + assertEquals(MetadataCommitResult.Outcome.COMMITTED, committed.outcome()); + assertEquals(committed, store.resolve(transactionId)); + assertEquals("committed", read(store.snapshot(), FIRST)); + transaction.close(); + } + staging.removeResidue(); + assertEquals(0, staging.liveFiles()); + System.out.println("...ok"); + } + + @Test + void snapshotsRemainStableAndRecordSlicesNeverReadLaterBytes() throws Exception { + System.out.print("snapshotsRemainStableAndRecordSlicesNeverReadLaterBytes "); + Path path = temporaryDirectory.resolve("snapshots.log"); + try (PosixTransactionalMetadataStore store = + PosixTransactionalMetadataStore.create(path, STORE_ID)) { + create(store, FIRST, "old"); + MetadataSnapshot old = store.snapshot(); + MetadataSnapshot.Record oldRecord = old.get(FIRST).orElseThrow(); + replace(store, FIRST, 1L, "new-value"); + create(store, SECOND, "later"); + assertEquals("old", read(oldRecord)); + assertEquals("old", read(oldRecord)); + assertEquals(3L, oldRecord.length().orElseThrow()); + try (MetadataSnapshot current = store.snapshot()) { + assertEquals("new-value", read(current, FIRST)); + assertEquals("later", read(current, SECOND)); + } + old.close(); + assertThrows(IllegalStateException.class, oldRecord::openStream); + } + System.out.println("...ok"); + } + + @Test + void cursorIsLazyOrderedRangeBoundedAndInvalidatesCurrentRecord() throws Exception { + System.out.print("cursorIsLazyOrderedRangeBoundedAndInvalidatesCurrentRecord "); + Path path = temporaryDirectory.resolve("cursor.log"); + try (PosixTransactionalMetadataStore store = + PosixTransactionalMetadataStore.create(path, STORE_ID)) { + try (MetadataTransaction transaction = store.beginTransaction()) { + transaction.create(new MetadataKey("example.records", "a"), + content("a"), CancellationSignal.NONE); + transaction.create(new MetadataKey("example.records", "ab"), + content("ab"), CancellationSignal.NONE); + transaction.create(new MetadataKey("example.records", "b"), + content("b"), CancellationSignal.NONE); + transaction.create(new MetadataKey("example.other", "a"), + content("other"), CancellationSignal.NONE); + transaction.commit(); + } + MetadataSnapshot snapshot = store.snapshot(); + MetadataCursor cursor = snapshot.scan( + MetadataSnapshot.KeyRange.prefix("example.records", "a"), + CancellationSignal.NONE); + MetadataSnapshot.Record first = cursor.next(CancellationSignal.NONE).orElseThrow(); + assertEquals("a", first.key().key()); + MetadataSnapshot.Record second = cursor.next(CancellationSignal.NONE).orElseThrow(); + assertThrows(IllegalStateException.class, first::key); + assertEquals("ab", second.key().key()); + assertTrue(cursor.next(CancellationSignal.NONE).isEmpty()); + assertThrows(IOException.class, () -> cursor.next(() -> true)); + cursor.close(); + cursor.close(); + assertThrows(IllegalStateException.class, + () -> cursor.next(CancellationSignal.NONE)); + MetadataCursor child = snapshot.scan( + MetadataSnapshot.KeyRange.all("example.records"), + CancellationSignal.NONE); + snapshot.close(); + assertThrows(IllegalStateException.class, + () -> child.next(CancellationSignal.NONE)); + } + System.out.println("...ok"); + } + + @Test + void uncertainCommitResolvesAfterReopenWithoutReexecution() throws Exception { + System.out.print("uncertainCommitResolvesAfterReopenWithoutReexecution "); + Path path = temporaryDirectory.resolve("unknown.log"); + PosixMetadataStoreEngine.FaultInjector fault = point -> { + if (point == PosixMetadataStoreEngine.FaultPoint.POST_FORCE_PUBLICATION) { + throw new IOException("injected"); + } + }; + MetadataTransactionId transactionId; + PosixTransactionalMetadataStore store = PosixTransactionalMetadataStore.createForTest( + path, STORE_ID, OptionalLong.empty(), new CountingStaging(), + PosixMetadataLog.FaultInjector.NONE, fault); + try { + MetadataTransaction transaction = store.beginTransaction(); + transactionId = transaction.id(); + transaction.create(FIRST, content("fixed"), CancellationSignal.NONE); + MetadataCommitResult immediate = transaction.commit(); + assertCategory(immediate, MetadataCommitResult.Outcome.UNKNOWN, + MetadataCommitResult.FailureCategory.STORAGE_FAILURE); + assertThrows(IllegalStateException.class, store::snapshot); + transaction.close(); + } finally { + store.close(); + } + try (PosixTransactionalMetadataStore reopened = + PosixTransactionalMetadataStore.open(path)) { + MetadataCommitResult resolved = reopened.resolve(transactionId); + assertEquals(MetadataCommitResult.Outcome.COMMITTED, resolved.outcome()); + assertEquals(resolved, reopened.resolve(transactionId)); + assertEquals("fixed", read(reopened.snapshot(), FIRST)); + } + System.out.println("...ok"); + } + + @Test + void recoveryAbandonmentConflictStorageAndUnknownCategoriesRemainDistinct() throws Exception { + System.out.print("recoveryAbandonmentConflictStorageAndUnknownCategoriesRemainDistinct "); + Path path = temporaryDirectory.resolve("categories.log"); + MetadataTransactionId abandonedId; + try (PosixTransactionalMetadataStore store = + PosixTransactionalMetadataStore.create(path, STORE_ID)) { + create(store, FIRST, "existing"); + MetadataTransaction abandoned = store.beginTransaction(); + abandonedId = abandoned.id(); + } + try (PosixTransactionalMetadataStore reopened = + PosixTransactionalMetadataStore.open(path)) { + MetadataCommitResult abandoned = reopened.resolve(abandonedId); + assertCategory(abandoned, MetadataCommitResult.Outcome.NOT_COMMITTED, + MetadataCommitResult.FailureCategory.ABANDONED_BY_RECOVERY); + assertEquals(abandoned, reopened.resolve(abandonedId)); + + MetadataTransaction conflict = reopened.beginTransaction(); + conflict.create(FIRST, content("duplicate"), CancellationSignal.NONE); + MetadataCommitResult rejected = conflict.commit(); + assertCategory(rejected, MetadataCommitResult.Outcome.NOT_COMMITTED, + MetadataCommitResult.FailureCategory.CONFLICT); + assertNotEquals(abandoned.failureCategory(), rejected.failureCategory()); + conflict.close(); + + MetadataTransaction storage = reopened.beginTransaction(); + MetadataStoreException storageFailure = assertThrows( + MetadataStoreException.class, + () -> storage.create(SECOND, new FailingContent(), CancellationSignal.NONE)); + assertEquals(MetadataCommitResult.FailureCategory.STORAGE_FAILURE, + storageFailure.category()); + storage.abort(); + } + System.out.println("...ok"); + } + + @Test + void closeRejectsBlockedAdmissionAndConsumesReservationExactlyOnce() throws Exception { + System.out.print("closeRejectsBlockedAdmissionAndConsumesReservationExactlyOnce "); + Path path = temporaryDirectory.resolve("admission-close.log"); + CountingStaging staging = new CountingStaging(); + BlockingContent content = new BlockingContent("blocked"); + PosixTransactionalMetadataStore store = PosixTransactionalMetadataStore.createForTest( + path, STORE_ID, OptionalLong.empty(), staging, + PosixMetadataLog.FaultInjector.NONE, + PosixMetadataStoreEngine.FaultInjector.NONE); + try (ExecutorService executor = Executors.newFixedThreadPool(2)) { + Future admission = executor.submit(() -> { + try (MetadataTransaction transaction = store.beginTransaction()) { + transaction.create(FIRST, content, CancellationSignal.NONE); + } + return null; + }); + content.awaitRead(); + Future closing = executor.submit(() -> { + store.close(); + return null; + }); + store.awaitClosingForTest(); + content.release(); + ExecutionException rejected = assertThrows(ExecutionException.class, admission::get); + assertTrue(rejected.getCause() instanceof IllegalStateException); + closing.get(); + } + assertEquals(0, store.activeOperationsForTest()); + assertEquals(1, staging.deleteAttempts.get()); + assertEquals(0, staging.liveFiles()); + assertEquals(1, content.closeCount.get()); + System.out.println("...ok"); + } + + @Test + void closeRacesRejectDeleteAndCommitBeginFailureLeavesCleanupPossible() throws Exception { + System.out.print("closeRacesRejectDeleteAndCommitBeginFailureLeavesCleanupPossible "); + Path closePath = temporaryDirectory.resolve("delete-close.log"); + PosixTransactionalMetadataStore closingStore = + PosixTransactionalMetadataStore.create(closePath, STORE_ID); + MetadataTransaction deleting = closingStore.beginTransaction(); + try (ExecutorService executor = Executors.newSingleThreadExecutor()) { + Future closing = executor.submit(() -> { + closingStore.close(); + return null; + }); + closingStore.awaitClosingForTest(); + assertThrows(IllegalStateException.class, () -> deleting.delete(FIRST, 0L)); + closing.get(); + } + deleting.close(); + assertEquals(0, closingStore.activeOperationsForTest()); + + Path recoveryPath = temporaryDirectory.resolve("commit-recovery.log"); + CountingStaging staging = new CountingStaging(); + PosixMetadataStoreEngine.FaultInjector uncertainty = point -> { + if (point == PosixMetadataStoreEngine.FaultPoint.POST_FORCE_PUBLICATION) { + throw new IOException("injected publication uncertainty"); + } + }; + PosixTransactionalMetadataStore recoveryStore = + PosixTransactionalMetadataStore.createForTest( + recoveryPath, STORE_ID, OptionalLong.empty(), staging, + PosixMetadataLog.FaultInjector.NONE, uncertainty); + try { + MetadataTransaction uncertain = recoveryStore.beginTransaction(); + MetadataTransaction pending = recoveryStore.beginTransaction(); + uncertain.create(FIRST, content("one"), CancellationSignal.NONE); + pending.create(SECOND, content("two"), CancellationSignal.NONE); + assertEquals(MetadataCommitResult.Outcome.UNKNOWN, uncertain.commit().outcome()); + assertThrows(IllegalStateException.class, pending::commit); + pending.abort(); + uncertain.close(); + pending.close(); + assertEquals(0, staging.liveFiles()); + } finally { + recoveryStore.close(); + } + System.out.println("...ok"); + } + + @Test + void stagingCleanupFailureIsSuppressedBehindSourceAndWriteFailures() throws Exception { + System.out.print("stagingCleanupFailureIsSuppressedBehindSourceAndWriteFailures "); + Path path = temporaryDirectory.resolve("staging-suppression.log"); + CountingStaging staging = new CountingStaging(); + try (PosixTransactionalMetadataStore store = + PosixTransactionalMetadataStore.createForTest( + path, STORE_ID, OptionalLong.empty(), staging, + PosixMetadataLog.FaultInjector.NONE, + PosixMetadataStoreEngine.FaultInjector.NONE)) { + MetadataTransaction transaction = store.beginTransaction(); + staging.failNextDelete.set(true); + MetadataStoreException sourceFailure = assertThrows( + MetadataStoreException.class, + () -> transaction.create( + FIRST, new FailingContent(), CancellationSignal.NONE)); + assertEquals(1, sourceFailure.getSuppressed().length); + + staging.failNextOpenWrite.set(true); + staging.failNextDelete.set(true); + MetadataStoreException writeFailure = assertThrows( + MetadataStoreException.class, + () -> transaction.create(SECOND, content("write"), CancellationSignal.NONE)); + assertEquals(1, writeFailure.getSuppressed().length); + transaction.abort(); + } + assertEquals(2, staging.deleteAttempts.get()); + staging.removeResidue(); + assertEquals(0, staging.liveFiles()); + System.out.println("...ok"); + } + + @Test + void storeCloseContinuesAcrossThrowingChildrenAndReleasesWriter() throws Exception { + System.out.print("storeCloseContinuesAcrossThrowingChildrenAndReleasesWriter "); + Path path = temporaryDirectory.resolve("continued-close.log"); + PosixMetadataStoreEngine engine = PosixMetadataStoreEngine.create(path, STORE_ID); + PosixMetadataAdapterLifecycle lifecycle = new PosixMetadataAdapterLifecycle(); + AtomicInteger attempts = new AtomicInteger(); + lifecycle.openManaged(() -> () -> { + attempts.incrementAndGet(); + return new IOException("first cleanup failure"); + }); + lifecycle.openManaged(() -> () -> { + attempts.incrementAndGet(); + return new IOException("second cleanup failure"); + }); + lifecycle.openManaged(() -> () -> { + attempts.incrementAndGet(); + return new IOException("later cleanup failure"); + }); + IOException closeFailure = assertThrows(IOException.class, () -> lifecycle.close(engine)); + assertEquals(3, attempts.get()); + assertEquals(2, closeFailure.getSuppressed().length); + try (PosixMetadataStoreEngine reopened = PosixMetadataStoreEngine.open(path)) { + assertEquals(STORE_ID, reopened.storeId()); + } + System.out.println("...ok"); + } + + @Test + void snapshotCloseContinuesAfterChildFailuresAndContainsLoggerFailure() throws Exception { + System.out.print("snapshotCloseContinuesAfterChildFailuresAndContainsLoggerFailure "); + Path path = temporaryDirectory.resolve("snapshot-close-failure.log"); + AtomicInteger closeAttempts = new AtomicInteger(); + PosixMetadataSnapshotSupport.SliceOpener opener = (offset, length) -> + new ThrowingCloseInputStream(closeAttempts); + Logger logger = Logger.getLogger(PosixMetadataSnapshotSupport.class.getName()); + Handler throwingHandler = new ThrowingHandler(); + try (PosixTransactionalMetadataStore store = + PosixTransactionalMetadataStore.createForTest( + path, STORE_ID, OptionalLong.empty(), new CountingStaging(), + PosixMetadataLog.FaultInjector.NONE, + PosixMetadataStoreEngine.FaultInjector.NONE, + opener)) { + create(store, FIRST, "value"); + MetadataSnapshot snapshot = store.snapshot(); + MetadataSnapshot.Record record = snapshot.get(FIRST).orElseThrow(); + record.openStream(); + record.openStream(); + logger.addHandler(throwingHandler); + try { + snapshot.close(); + } finally { + logger.removeHandler(throwingHandler); + } + assertEquals(2, closeAttempts.get()); + assertThrows(IllegalStateException.class, record::openStream); + } + System.out.println("...ok"); + } + + @Test + void cursorCancellationRunsBeforeSnapshotLockAndRevalidatesClose() throws Exception { + System.out.print("cursorCancellationRunsBeforeSnapshotLockAndRevalidatesClose "); + Path path = temporaryDirectory.resolve("cursor-cancellation-lock.log"); + try (PosixTransactionalMetadataStore store = + PosixTransactionalMetadataStore.create(path, STORE_ID)) { + create(store, FIRST, "value"); + MetadataSnapshot snapshot = store.snapshot(); + MetadataCursor cursor = snapshot.scan( + MetadataSnapshot.KeyRange.all("example.records"), + CancellationSignal.NONE); + CancellationSignal closesSnapshot = () -> { + snapshot.close(); + return false; + }; + assertThrows(IllegalStateException.class, () -> cursor.next(closesSnapshot)); + cursor.close(); + } + System.out.println("...ok"); + } + + private static void create( + PosixTransactionalMetadataStore store, MetadataKey key, String value) throws Exception { + try (MetadataTransaction transaction = store.beginTransaction()) { + transaction.create(key, content(value), CancellationSignal.NONE); + assertEquals(MetadataCommitResult.Outcome.COMMITTED, transaction.commit().outcome()); + } + } + + private static void replace( + PosixTransactionalMetadataStore store, + MetadataKey key, + long expectedRevision, + String value) throws Exception { + try (MetadataTransaction transaction = store.beginTransaction()) { + transaction.replace(key, expectedRevision, content(value), CancellationSignal.NONE); + assertEquals(MetadataCommitResult.Outcome.COMMITTED, transaction.commit().outcome()); + } + } + + private static RepeatableContent content(String value) { + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + return new ProbeContent(bytes, OptionalLong.of(bytes.length)); + } + + private static String read(MetadataSnapshot snapshot, MetadataKey key) throws Exception { + try (MetadataSnapshot.Record record = snapshot.get(key).orElseThrow()) { + return read(record); + } + } + + private static String read(MetadataSnapshot.Record record) throws Exception { + try (InputStream input = record.openStream()) { + return new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private static void assertCategory( + MetadataCommitResult result, + MetadataCommitResult.Outcome outcome, + MetadataCommitResult.FailureCategory category) { + assertEquals(outcome, result.outcome()); + assertEquals(Optional.of(category), result.failureCategory()); + } + + private static final class ProbeContent implements RepeatableContent { + private final byte[] bytes; + private final OptionalLong length; + private final AtomicBoolean valid = new AtomicBoolean(true); + private final AtomicInteger opens = new AtomicInteger(); + + private ProbeContent(String value, OptionalLong length) { + this(value.getBytes(StandardCharsets.UTF_8), length); + } + + private ProbeContent(byte[] bytes, OptionalLong length) { + this.bytes = bytes.clone(); + this.length = length; + } + + @Override + public InputStream openStream() throws IOException { + if (!valid.get()) { + throw new IOException("invalidated probe"); + } + opens.incrementAndGet(); + return new ByteArrayInputStream(bytes); + } + + @Override + public OptionalLong length() { + return length; + } + + @Override + public String contentId() { + return "probe"; + } + + @Override + public void close() { + valid.set(false); + } + + private void invalidate() { + valid.set(false); + } + } + + private static final class FailingContent implements RepeatableContent { + @Override + public InputStream openStream() throws IOException { + throw new IOException("injected source failure"); + } + + @Override + public OptionalLong length() { + return OptionalLong.of(1L); + } + + @Override + public String contentId() { + return "failing"; + } + + @Override + public void close() { + } + } + + private static final class BlockingContent implements RepeatableContent { + private final byte[] value; + private final CountDownLatch readStarted = new CountDownLatch(1); + private final CountDownLatch releaseRead = new CountDownLatch(1); + private final AtomicInteger closeCount = new AtomicInteger(); + + private BlockingContent(String value) { + this.value = value.getBytes(StandardCharsets.UTF_8); + } + + @Override + public InputStream openStream() { + return new InputStream() { + private int position; + + @Override + public int read() throws IOException { + byte[] single = new byte[1]; + int count = read(single, 0, 1); + return count < 0 ? -1 : Byte.toUnsignedInt(single[0]); + } + + @Override + public int read(byte[] target, int offset, int length) throws IOException { + if (position == value.length) { + return -1; + } + readStarted.countDown(); + await(releaseRead); + int count = Math.min(length, value.length - position); + System.arraycopy(value, position, target, offset, count); + position += count; + return count; + } + + @Override + public void close() { + closeCount.incrementAndGet(); + } + }; + } + + @Override + public OptionalLong length() { + return OptionalLong.of(value.length); + } + + @Override + public String contentId() { + return "blocking-probe"; + } + + @Override + public void close() { + } + + private void awaitRead() throws InterruptedException { + readStarted.await(); + } + + private void release() { + releaseRead.countDown(); + } + + private static void await(CountDownLatch latch) throws IOException { + try { + latch.await(); + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IOException("Blocking content probe was interrupted", failure); + } + } + } + + private static final class ThrowingCloseInputStream extends ByteArrayInputStream { + private final AtomicInteger closeAttempts; + + private ThrowingCloseInputStream(AtomicInteger closeAttempts) { + super(new byte[] {1}); + this.closeAttempts = closeAttempts; + } + + @Override + public void close() throws IOException { + closeAttempts.incrementAndGet(); + throw new IOException("injected child close failure"); + } + } + + private static final class ThrowingHandler extends Handler { + @Override + public void publish(LogRecord record) { + throw new IllegalStateException("injected JUL handler failure"); + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + } + + private final class CountingStaging + implements PosixMetadataTransactionSupport.StagingOperations { + private final List created = new ArrayList<>(); + private final AtomicBoolean failNextDelete = new AtomicBoolean(); + private final AtomicBoolean failNextOpenWrite = new AtomicBoolean(); + private final AtomicInteger deleteAttempts = new AtomicInteger(); + + @Override + public Path create(Path parent) throws IOException { + Path path = Files.createTempFile(parent, ".adapter-test-", ".stage"); + created.add(path); + return path; + } + + @Override + public FileChannel openWrite(Path path) throws IOException { + if (failNextOpenWrite.compareAndSet(true, false)) { + throw new IOException("injected staging write failure"); + } + 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 { + deleteAttempts.incrementAndGet(); + if (failNextDelete.compareAndSet(true, false)) { + throw new IOException("injected cleanup failure"); + } + Files.deleteIfExists(path); + } + + private int liveFiles() { + return (int) created.stream().filter(Files::exists).count(); + } + + private void removeResidue() throws IOException { + for (Path path : created) { + Files.deleteIfExists(path); + } + } + } +} diff --git a/pki/src/test/java/zeroecho/pki/spi/bootstrap/PkiBootstrapTest.java b/pki/src/test/java/zeroecho/pki/spi/bootstrap/PkiBootstrapTest.java index e3b3ab9..d634075 100644 --- a/pki/src/test/java/zeroecho/pki/spi/bootstrap/PkiBootstrapTest.java +++ b/pki/src/test/java/zeroecho/pki/spi/bootstrap/PkiBootstrapTest.java @@ -80,6 +80,7 @@ import zeroecho.pki.spi.ProviderConfig; import zeroecho.pki.spi.audit.AuditSink; import zeroecho.pki.spi.crypto.SignatureWorkflow; import zeroecho.pki.spi.crypto.SignatureWorkflowRuntimeDependencies; +import zeroecho.core.io.ImmutableByteContent; import zeroecho.pki.spi.framework.CredentialFramework; import zeroecho.pki.spi.store.PkiStore; import zeroecho.pki.util.async.AsyncBus; @@ -330,7 +331,7 @@ public final class PkiBootstrapTest { PkiId submissionId = SigningSubmissionId.create(SIGNING_NAMESPACE, Instant.now(), new SecureRandom()).id(); SignatureWorkflow.SignRequest request = SignatureWorkflow.SignRequest.create(submissionId, SIGNING_NAMESPACE, 1L, access, new KeyRef("test-prefix:bootstrap"), "SHA256withRSA", - new EncodedObject(Encoding.BINARY, payload), Optional.of(Encoding.BINARY), Optional.empty()); + new ImmutableByteContent(payload), Optional.of(Encoding.BINARY), Optional.empty()); workflow.submitSign(request); assertEquals(SignatureWorkflow.State.SUCCEEDED, workflow.status(submissionId).state()); assertTrue( diff --git a/pki/src/test/java/zeroecho/pki/spi/crypto/SignRequestCleanupTest.java b/pki/src/test/java/zeroecho/pki/spi/crypto/SignRequestCleanupTest.java index 9c21717..44928e5 100644 --- a/pki/src/test/java/zeroecho/pki/spi/crypto/SignRequestCleanupTest.java +++ b/pki/src/test/java/zeroecho/pki/spi/crypto/SignRequestCleanupTest.java @@ -45,7 +45,7 @@ import java.util.Optional; import org.junit.jupiter.api.Test; -import zeroecho.pki.api.EncodedObject; +import zeroecho.core.io.ImmutableByteContent; import zeroecho.pki.api.Encoding; import zeroecho.pki.api.KeyRef; import zeroecho.pki.api.PkiId; @@ -56,27 +56,26 @@ import zeroecho.pki.api.audit.Purpose; final class SignRequestCleanupTest { @Test - void fingerprintClearsPayloadAndDigestCopiesAfterSuccessAndRepeatedUse() throws Exception { - System.out.println("fingerprintClearsPayloadAndDigestCopiesAfterSuccessAndRepeatedUse"); + void fingerprintClearsDigestCopiesWithoutMaterializingContent() throws Exception { + System.out.println("fingerprintClearsDigestCopiesWithoutMaterializingContent"); List cleared = new ArrayList<>(); String first = fingerprint(MessageDigest.getInstance("SHA-256"), cleared); String second = fingerprint(MessageDigest.getInstance("SHA-256"), cleared); System.out.println("...fingerprint=" + first.substring(0, 20) + "..."); assertEquals(first, second); - assertEquals(4, cleared.size()); + assertEquals(2, cleared.size()); assertTrue(cleared.stream().allMatch(SignRequestCleanupTest::isCleared)); - System.out.println("fingerprintClearsPayloadAndDigestCopiesAfterSuccessAndRepeatedUse...ok"); + System.out.println("fingerprintClearsDigestCopiesWithoutMaterializingContent...ok"); } @Test - void fingerprintClearsPayloadCopyAfterDigestFailure() { - System.out.println("fingerprintClearsPayloadCopyAfterDigestFailure"); + void fingerprintFailureCreatesNoPayloadCopy() { + System.out.println("fingerprintFailureCreatesNoPayloadCopy"); List cleared = new ArrayList<>(); assertThrows(IllegalStateException.class, () -> fingerprint(new FailingDigest(), cleared)); - assertEquals(1, cleared.size()); - assertTrue(isCleared(cleared.get(0))); + assertEquals(0, cleared.size()); System.out.println("...clearedBuffers=" + cleared.size()); - System.out.println("fingerprintClearsPayloadCopyAfterDigestFailure...ok"); + System.out.println("fingerprintFailureCreatesNoPayloadCopy...ok"); } private static String fingerprint(MessageDigest digest, List cleared) { @@ -84,7 +83,7 @@ final class SignRequestCleanupTest { Optional.of(new PkiId("cleanup-operation")), Optional.empty()); return SignatureWorkflow.SignRequest.fingerprintWithDigest("cleanup.namespace", context, new KeyRef("provider:key"), "SHA256withRSA", - new EncodedObject(Encoding.BINARY, new byte[] { 1, 2, 3, 4 }), Optional.of(Encoding.BINARY), + new ImmutableByteContent(new byte[] { 1, 2, 3, 4 }), Optional.of(Encoding.BINARY), Optional.of(Instant.parse("2026-08-01T00:00:00Z")), digest, cleared::add); } diff --git a/pki/src/test/java/zeroecho/pki/spi/store/CommitOutcomeTest.java b/pki/src/test/java/zeroecho/pki/spi/store/CommitOutcomeTest.java new file mode 100644 index 0000000..283d5f3 --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/spi/store/CommitOutcomeTest.java @@ -0,0 +1,54 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Optional; +import java.util.OptionalLong; +import org.junit.jupiter.api.Test; + +final class CommitOutcomeTest { + + @Test + void outcomesEnforceRevisionAndFailureShape() { + System.out.print("outcomesEnforceRevisionAndFailureShape "); + MetadataTransactionId transactionId = new MetadataTransactionId( + new MetadataStoreId("0123456789abcdef0123456789abcdef"), + "11111111111111111111111111111111"); + MetadataCommitResult committed = new MetadataCommitResult( + transactionId, + MetadataCommitResult.Outcome.COMMITTED, + OptionalLong.of(3L), + Optional.empty()); + assertEquals(3L, committed.committedRevision().orElseThrow()); + MetadataCommitResult unknown = new MetadataCommitResult( + transactionId, + MetadataCommitResult.Outcome.UNKNOWN, + OptionalLong.empty(), + Optional.empty()); + assertEquals(MetadataCommitResult.Outcome.UNKNOWN, unknown.outcome()); + assertThrows( + IllegalArgumentException.class, + () -> new MetadataCommitResult( + transactionId, + MetadataCommitResult.Outcome.COMMITTED, + OptionalLong.empty(), + Optional.empty())); + System.out.println("...ok"); + } +} diff --git a/pki/src/test/java/zeroecho/pki/spi/store/InMemoryTransactionalMetadataStore.java b/pki/src/test/java/zeroecho/pki/spi/store/InMemoryTransactionalMetadataStore.java new file mode 100644 index 0000000..4bd13f3 --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/spi/store/InMemoryTransactionalMetadataStore.java @@ -0,0 +1,746 @@ +/* + * 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.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; +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.TreeMap; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicInteger; +import zeroecho.core.io.CancellationSignal; +import zeroecho.core.io.RepeatableContent; + +/** + * Deterministic test-only contract oracle. Values are detached to files with a + * bounded transfer buffer; this class is not production persistence. + */ +final class InMemoryTransactionalMetadataStore implements TransactionalMetadataStore { + private static final int TRANSFER_BUFFER_SIZE = 8192; + + private final Object authority = new Object(); + private final MetadataStoreId storeId; + private final MetadataStoreCapabilities capabilities; + private final Path root; + private final NavigableMap current = new TreeMap<>(); + private final Map outcomes = new HashMap<>(); + private final Map issued = new HashMap<>(); + private final List ownedFiles = new ArrayList<>(); + private final AtomicInteger openedSources = new AtomicInteger(); + private final CountDownLatch closingStarted = new CountDownLatch(1); + private long storeRevision; + private long transactionSequence; + private int activeAdmissions; + private boolean closing; + private boolean closed; + private boolean hideNextCommittedOutcome; + + InMemoryTransactionalMetadataStore( + Path root, + MetadataStoreCapabilities.ContentLengthModel lengthModel, + OptionalLong maximumRecordSize) throws IOException { + this( + root, + new MetadataStoreId(UUID.randomUUID().toString().replace("-", "")), + lengthModel, + maximumRecordSize); + } + + InMemoryTransactionalMetadataStore( + Path root, + MetadataStoreId storeId, + MetadataStoreCapabilities.ContentLengthModel lengthModel, + OptionalLong maximumRecordSize) throws IOException { + this.root = Files.createDirectories(root); + this.storeId = storeId; + this.capabilities = new MetadataStoreCapabilities( + MetadataStoreCapabilities.WriterModel.SERIALIZABLE_MULTI_WRITER, + lengthModel, + MetadataStoreCapabilities.OutcomeRetention.INDEFINITE, + Collections.unmodifiableSet(EnumSet.noneOf( + MetadataStoreCapabilities.OptionalFeature.class)), + maximumRecordSize); + } + + void hideNextCommittedOutcome() { + hideNextCommittedOutcome = true; + } + + int openedSourceCount() { + return openedSources.get(); + } + + synchronized int activeAdmissionCount() { + return activeAdmissions; + } + + void awaitClosingStarted() throws InterruptedException { + closingStarted.await(); + } + + @Override + public synchronized MetadataStoreId id() { + return storeId; + } + + @Override + public MetadataStoreCapabilities capabilities() { + return capabilities; + } + + @Override + public synchronized MetadataTransaction beginTransaction() { + requireOpen(); + transactionSequence = Math.addExact(transactionSequence, 1L); + MetadataTransactionId transactionId = + new MetadataTransactionId(storeId, String.format("%032x", transactionSequence)); + issued.put(transactionId, Boolean.TRUE); + return new Transaction(transactionId); + } + + @Override + public synchronized MetadataCommitResult resolve(MetadataTransactionId transactionId) + throws MetadataStoreException { + requireOpen(); + validateTransactionIdentity(transactionId); + MetadataCommitResult result = outcomes.get(transactionId); + if (result == null) { + throw failure( + MetadataCommitResult.FailureCategory.TRANSACTION_NOT_ISSUED, + "Transaction has no retained authoritative result"); + } + return result; + } + + @Override + public synchronized void acknowledge(MetadataTransactionId transactionId) + throws MetadataStoreException { + requireOpen(); + validateTransactionIdentity(transactionId); + if (!outcomes.containsKey(transactionId)) { + throw failure( + MetadataCommitResult.FailureCategory.TRANSACTION_NOT_ISSUED, + "Transaction has no retained authoritative result"); + } + } + + @Override + public synchronized MetadataSnapshot snapshot() { + requireOpen(); + NavigableMap view = + Collections.unmodifiableNavigableMap(new TreeMap<>(current)); + return new Snapshot(authority, storeRevision, view); + } + + @Override + public void close() throws IOException { + List files; + synchronized (this) { + if (closed) { + return; + } + closing = true; + closingStarted.countDown(); + while (activeAdmissions != 0) { + awaitLifecycleChange(); + } + if (closed) { + return; + } + closed = true; + files = List.copyOf(ownedFiles); + ownedFiles.clear(); + notifyAll(); + } + IOException failure = null; + for (Path path : files) { + try { + Files.deleteIfExists(path); + } catch (IOException exception) { + failure = exception; + } + } + try { + Files.deleteIfExists(root); + } catch (IOException exception) { + failure = exception; + } + if (failure != null) { + throw failure; + } + } + + private void awaitLifecycleChange() throws IOException { + try { + wait(); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while closing metadata test store", exception); + } + } + + private StagedValue stageDetached( + RepeatableContent source, + CancellationSignal cancellation) throws IOException { + Objects.requireNonNull(source, "source"); + Objects.requireNonNull(cancellation, "cancellation"); + cancellation.throwIfCancelled(); + OptionalLong declaredLength = source.length(); + if (declaredLength.isEmpty() + && capabilities.contentLengthModel() + == MetadataStoreCapabilities.ContentLengthModel.KNOWN_LENGTH_ONLY) { + throw failure( + MetadataCommitResult.FailureCategory.UNSUPPORTED_CAPABILITY, + "Adapter requires known metadata length"); + } + if (declaredLength.isPresent()) { + checkTechnicalLimit(declaredLength.getAsLong()); + } + Path spool = Files.createTempFile(root, "metadata-", ".spool"); + long count = 0L; + boolean complete = false; + try (InputStream input = source.openStream(); + java.io.OutputStream output = Files.newOutputStream( + spool, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) { + openedSources.incrementAndGet(); + byte[] buffer = new byte[TRANSFER_BUFFER_SIZE]; + while (true) { + cancellation.throwIfCancelled(); + int read = input.read(buffer); + if (read < 0) { + break; + } + if (read == 0) { + continue; + } + count = Math.addExact(count, read); + checkTechnicalLimit(count); + output.write(buffer, 0, read); + } + cancellation.throwIfCancelled(); + if (declaredLength.isPresent() && declaredLength.getAsLong() != count) { + throw new IOException("Declared metadata length differs from streamed length"); + } + complete = true; + return new StagedValue(spool, count); + } catch (ArithmeticException exception) { + throw new IOException("Metadata length is not representable", exception); + } finally { + if (!complete) { + Files.deleteIfExists(spool); + } + } + } + + private void checkTechnicalLimit(long length) throws MetadataStoreException { + OptionalLong maximum = capabilities.maximumIndividualRecordBytes(); + if (maximum.isPresent() && length > maximum.getAsLong()) { + throw failure( + MetadataCommitResult.FailureCategory.LIMIT_EXCEEDED, + "Metadata exceeds adapter technical record limit"); + } + } + + private synchronized MetadataCommitResult commit(Transaction transaction) { + requireOpen(); + for (StagedMutation mutation : transaction.mutations.values()) { + StoredRecord record = current.get(mutation.key); + if (mutation.kind == MutationKind.CREATE && record != null + || mutation.kind != MutationKind.CREATE + && (record == null || record.recordRevision != mutation.expectedRevision)) { + MetadataCommitResult conflict = new MetadataCommitResult( + transaction.id, + MetadataCommitResult.Outcome.NOT_COMMITTED, + OptionalLong.empty(), + Optional.of(MetadataCommitResult.FailureCategory.CONFLICT)); + outcomes.put(transaction.id, conflict); + transaction.retireStagedValues(); + return conflict; + } + } + storeRevision = Math.addExact(storeRevision, 1L); + for (StagedMutation mutation : transaction.mutations.values()) { + StoredRecord previous = current.get(mutation.key); + if (mutation.kind == MutationKind.DELETE) { + current.remove(mutation.key); + } else { + long recordRevision = previous == null + ? 0L + : Math.addExact(previous.recordRevision, 1L); + current.put( + mutation.key, + new StoredRecord( + mutation.key, + recordRevision, + storeRevision, + mutation.value.orElseThrow())); + } + } + MetadataCommitResult committed = new MetadataCommitResult( + transaction.id, + MetadataCommitResult.Outcome.COMMITTED, + OptionalLong.of(storeRevision), + Optional.empty()); + outcomes.put(transaction.id, committed); + transaction.releaseTransferredValues(); + if (hideNextCommittedOutcome) { + hideNextCommittedOutcome = false; + return new MetadataCommitResult( + transaction.id, + MetadataCommitResult.Outcome.UNKNOWN, + OptionalLong.empty(), + Optional.empty()); + } + return committed; + } + + private void validateTransactionIdentity(MetadataTransactionId transactionId) + throws MetadataStoreException { + Objects.requireNonNull(transactionId, "transactionId"); + if (!storeId.equals(transactionId.storeId())) { + throw failure( + MetadataCommitResult.FailureCategory.FOREIGN_TRANSACTION, + "Transaction belongs to another store"); + } + if (!issued.containsKey(transactionId)) { + throw failure( + MetadataCommitResult.FailureCategory.TRANSACTION_NOT_ISSUED, + "Transaction was not issued by this store instance"); + } + } + + private void requireOpen() { + if (closed || closing) { + throw new IllegalStateException("Metadata store is closed"); + } + } + + private synchronized void releaseAdmission() { + if (activeAdmissions <= 0) { + throw new IllegalStateException("Metadata admission reservation underflow"); + } + activeAdmissions--; + notifyAll(); + } + + private static MetadataStoreException failure( + MetadataCommitResult.FailureCategory category, + String message) { + return new MetadataStoreException(category, message); + } + + private enum MutationKind { + CREATE, + REPLACE, + DELETE + } + + private record StagedValue(Path path, long length) { + } + + private record StoredRecord( + MetadataKey key, + long recordRevision, + long commitRevision, + StagedValue value) { + } + + private record StagedMutation( + MutationKind kind, + MetadataKey key, + long expectedRevision, + Optional value) { + } + + private final class Transaction implements MetadataTransaction { + private final MetadataTransactionId id; + private final Thread owner = Thread.currentThread(); + private final Map mutations = new LinkedHashMap<>(); + private boolean terminal; + + private Transaction(MetadataTransactionId id) { + this.id = id; + } + + @Override + public MetadataTransactionId id() { + return id; + } + + @Override + public void create( + MetadataKey key, + RepeatableContent content, + CancellationSignal cancellation) throws IOException { + admit(MutationKind.CREATE, key, 0L, content, cancellation); + } + + @Override + public void replace( + MetadataKey key, + long expectedRevision, + RepeatableContent content, + CancellationSignal cancellation) throws IOException { + validateRevision(expectedRevision); + admit(MutationKind.REPLACE, key, expectedRevision, content, cancellation); + } + + @Override + public void delete(MetadataKey key, long expectedRevision) { + checkMutable(); + validateRevision(expectedRevision); + addMutation(new StagedMutation( + MutationKind.DELETE, + Objects.requireNonNull(key, "key"), + expectedRevision, + Optional.empty())); + } + + @Override + public MetadataCommitResult commit() { + checkMutable(); + terminal = true; + return InMemoryTransactionalMetadataStore.this.commit(this); + } + + @Override + public void abort() throws IOException { + checkThread(); + if (!terminal) { + terminal = true; + retireStagedValues(); + } + } + + @Override + public void close() throws IOException { + if (!terminal) { + abort(); + } + } + + private void admit( + MutationKind kind, + MetadataKey key, + long expectedRevision, + RepeatableContent content, + CancellationSignal cancellation) throws IOException { + MetadataKey checkedKey = Objects.requireNonNull(key, "key"); + synchronized (InMemoryTransactionalMetadataStore.this) { + checkMutable(); + rejectDuplicateKey(checkedKey); + activeAdmissions = Math.addExact(activeAdmissions, 1); + } + StagedValue stagedValue = null; + boolean installed = false; + try { + stagedValue = InMemoryTransactionalMetadataStore.this.stageDetached( + content, cancellation); + synchronized (InMemoryTransactionalMetadataStore.this) { + checkMutable(); + rejectDuplicateKey(checkedKey); + addMutation(new StagedMutation( + kind, + checkedKey, + expectedRevision, + Optional.of(stagedValue))); + ownedFiles.add(stagedValue.path); + installed = true; + } + } finally { + try { + if (!installed && stagedValue != null) { + Files.deleteIfExists(stagedValue.path); + } + } finally { + releaseAdmission(); + } + } + } + + private void rejectDuplicateKey(MetadataKey checkedKey) { + if (mutations.containsKey(checkedKey)) { + throw new IllegalArgumentException("A transaction may mutate a key only once"); + } + } + + private void addMutation(StagedMutation mutation) { + if (mutations.putIfAbsent(mutation.key, mutation) != null) { + throw new IllegalArgumentException("A transaction may mutate a key only once"); + } + } + + private void checkMutable() { + checkThread(); + if (terminal) { + throw new IllegalStateException("Metadata transaction is terminal"); + } + requireOpen(); + } + + private void checkThread() { + if (owner != Thread.currentThread()) { + throw new IllegalStateException("Metadata transaction is thread-confined"); + } + } + + private void retireStagedValues() { + for (StagedMutation mutation : mutations.values()) { + mutation.value.ifPresent(value -> { + try { + Files.deleteIfExists(value.path); + ownedFiles.remove(value.path); + } catch (IOException exception) { + throw new IllegalStateException("Test spool retirement failed", exception); + } + }); + } + mutations.clear(); + } + + private void releaseTransferredValues() { + mutations.clear(); + } + } + + private static void validateRevision(long revision) { + if (revision < 0L) { + throw new IllegalArgumentException("Expected revision must be non-negative"); + } + } + + private final class Snapshot implements MetadataSnapshot { + private final Object issuingAuthority; + private final long revision; + private final NavigableMap view; + private final List cursors = new ArrayList<>(); + private boolean snapshotClosed; + + private Snapshot( + Object issuingAuthority, + long revision, + NavigableMap view) { + this.issuingAuthority = issuingAuthority; + this.revision = revision; + this.view = view; + } + + @Override + public MetadataStoreId storeId() { + checkOpen(); + return storeId; + } + + @Override + public long revision() { + checkOpen(); + return revision; + } + + @Override + public Optional get(MetadataKey key) { + checkOpen(); + return Optional.ofNullable(view.get(Objects.requireNonNull(key, "key"))) + .map(SnapshotRecord::new); + } + + @Override + public MetadataCursor scan(KeyRange range, CancellationSignal cancellation) + throws IOException { + checkOpen(); + Objects.requireNonNull(range, "range"); + Objects.requireNonNull(cancellation, "cancellation").throwIfCancelled(); + Iterator> iterator = + view.entrySet().iterator(); + Cursor cursor = new Cursor(this, range, iterator); + cursors.add(cursor); + return cursor; + } + + @Override + public void close() { + if (snapshotClosed) { + return; + } + snapshotClosed = true; + for (Cursor cursor : List.copyOf(cursors)) { + cursor.close(); + } + cursors.clear(); + } + + private void checkOpen() { + if (snapshotClosed || issuingAuthority != authority) { + throw new IllegalStateException("Metadata snapshot is closed or foreign"); + } + } + } + + private final class SnapshotRecord implements MetadataSnapshot.Record { + private final StoredRecord record; + private boolean recordClosed; + + private SnapshotRecord(StoredRecord record) { + this.record = record; + } + + @Override + public MetadataKey key() { + checkOpen(); + return record.key; + } + + @Override + public long recordRevision() { + checkOpen(); + return record.recordRevision; + } + + @Override + public long commitRevision() { + checkOpen(); + return record.commitRevision; + } + + @Override + public Optional integrity() { + checkOpen(); + return Optional.empty(); + } + + @Override + public InputStream openStream() throws IOException { + checkOpen(); + InputStream delegate = Files.newInputStream(record.value.path); + return new FilterInputStream(delegate) { + private long remaining = record.value.length; + + @Override + public int read() throws IOException { + if (remaining == 0L) { + return -1; + } + int value = super.read(); + if (value >= 0) { + remaining--; + } + return value; + } + + @Override + public int read(byte[] data, int offset, int length) throws IOException { + if (remaining == 0L) { + return -1; + } + int boundedLength = (int) Math.min(length, remaining); + int count = super.read(data, offset, boundedLength); + if (count > 0) { + remaining -= count; + } + return count; + } + }; + } + + @Override + public OptionalLong length() { + checkOpen(); + return OptionalLong.of(record.value.length); + } + + @Override + public String contentId() { + checkOpen(); + return "test-metadata:" + record.key.namespace() + ':' + record.key.key(); + } + + @Override + public void close() { + recordClosed = true; + } + + private void checkOpen() { + if (recordClosed) { + throw new IllegalStateException("Metadata record is closed"); + } + } + } + + private final class Cursor implements MetadataCursor { + private final Snapshot snapshot; + private final MetadataSnapshot.KeyRange range; + private Iterator> iterator; + private boolean cursorClosed; + + private Cursor( + Snapshot snapshot, + MetadataSnapshot.KeyRange range, + Iterator> iterator) { + this.snapshot = snapshot; + this.range = range; + this.iterator = iterator; + } + + @Override + public Optional next(CancellationSignal cancellation) + throws IOException { + checkOpen(); + CancellationSignal checkedCancellation = + Objects.requireNonNull(cancellation, "cancellation"); + while (iterator.hasNext()) { + checkedCancellation.throwIfCancelled(); + Map.Entry entry = iterator.next(); + if (range.contains(entry.getKey())) { + return Optional.of(new SnapshotRecord(entry.getValue())); + } + } + return Optional.empty(); + } + + @Override + public void close() { + if (cursorClosed) { + return; + } + cursorClosed = true; + iterator = Collections.emptyIterator(); + snapshot.cursors.remove(this); + } + + private void checkOpen() { + snapshot.checkOpen(); + if (cursorClosed) { + throw new IllegalStateException("Metadata cursor is closed"); + } + } + } +} diff --git a/pki/src/test/java/zeroecho/pki/spi/store/MetadataKeyTest.java b/pki/src/test/java/zeroecho/pki/spi/store/MetadataKeyTest.java new file mode 100644 index 0000000..f70c6a1 --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/spi/store/MetadataKeyTest.java @@ -0,0 +1,116 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +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; + +final class MetadataKeyTest { + + @Test + void canonicalExtensionIdentityRoundTrips() { + System.out.print("canonicalExtensionIdentityRoundTrips "); + MetadataKey key = MetadataKey.parse("example.audit:Record-01"); + assertEquals("example.audit", key.namespace()); + assertEquals("Record-01", key.key()); + assertEquals(key, MetadataKey.parse(key.canonical())); + System.out.println("...ok"); + } + + @Test + void reservedAndAmbiguousIdentitiesFail() { + System.out.print("reservedAndAmbiguousIdentitiesFail "); + assertThrows(IllegalArgumentException.class, () -> new MetadataKey("zeroecho", "a")); + assertThrows(IllegalArgumentException.class, () -> new MetadataKey("zeroecho.audit", "a")); + assertThrows(IllegalArgumentException.class, () -> MetadataKey.parse("Example.audit:a")); + assertThrows(IllegalArgumentException.class, () -> new MetadataKey("example.audit", "a/b")); + assertThrows(IllegalArgumentException.class, () -> new MetadataKey("example.audit", "é")); + assertThrows(IllegalArgumentException.class, () -> MetadataKey.parse("example.audit:a:b")); + System.out.println("...ok"); + } + + @Test + void orderingAndPrefixRangesAreTotal() { + System.out.print("orderingAndPrefixRangesAreTotal "); + List sorted = List.of( + new MetadataKey("example.audit", "a~"), + new MetadataKey("example.audit", "abc"), + new MetadataKey("example.audit", "~")) + .stream() + .sorted() + .toList(); + assertEquals("abc", sorted.get(0).key()); + MetadataSnapshot.KeyRange ordinary = + MetadataSnapshot.KeyRange.prefix("example.audit", "abc"); + assertEquals("abd", ordinary.upperExclusive().orElseThrow()); + MetadataSnapshot.KeyRange trailing = + MetadataSnapshot.KeyRange.prefix("example.audit", "a~"); + assertEquals("b", trailing.upperExclusive().orElseThrow()); + assertEquals( + "a/", + MetadataSnapshot.KeyRange.prefix("example.audit", "a.") + .upperExclusive() + .orElseThrow()); + assertEquals( + "a\\", + MetadataSnapshot.KeyRange.prefix("example.audit", "a[") + .upperExclusive() + .orElseThrow()); + assertEquals( + "b", + MetadataSnapshot.KeyRange.prefix("example.audit", "a~~") + .upperExclusive() + .orElseThrow()); + assertTrue(MetadataSnapshot.KeyRange.prefix("example.audit", "~") + .upperExclusive() + .isEmpty()); + assertTrue(MetadataSnapshot.KeyRange.prefix("example.audit", "") + .lowerInclusive() + .isEmpty()); + assertFalse(ordinary.contains(new MetadataKey("example.audit", "abd"))); + MetadataSnapshot.KeyRange exactAndDescendants = + MetadataSnapshot.KeyRange.prefix("example.audit", "a"); + assertTrue(exactAndDescendants.contains(new MetadataKey("example.audit", "a"))); + assertTrue(exactAndDescendants.contains(new MetadataKey("example.audit", "a.child"))); + assertFalse(exactAndDescendants.contains(new MetadataKey("example.audit", "b"))); + System.out.println("...ok"); + } + + @Test + void canonicalUtf8LimitsAreExactAcrossConstructionAndParsing() { + System.out.print("canonicalUtf8LimitsAreExactAcrossConstructionAndParsing "); + String maximumNamespace = "n".repeat(255); + String maximumKey = "k".repeat(4096); + MetadataKey boundary = new MetadataKey(maximumNamespace, maximumKey); + assertEquals(boundary, MetadataKey.parse(boundary.canonical())); + String oversizedNamespace = "n".repeat(256); + String oversizedKey = "k".repeat(4097); + assertThrows(IllegalArgumentException.class, + () -> new MetadataKey(oversizedNamespace, "key")); + assertThrows(IllegalArgumentException.class, + () -> MetadataKey.parse(oversizedNamespace + ":key")); + assertThrows(IllegalArgumentException.class, + () -> new MetadataKey("example.audit", oversizedKey)); + assertThrows(IllegalArgumentException.class, + () -> MetadataKey.parse("example.audit:" + oversizedKey)); + System.out.println("...ok"); + } +} diff --git a/pki/src/test/java/zeroecho/pki/spi/store/TransactionalMetadataStoreContractTest.java b/pki/src/test/java/zeroecho/pki/spi/store/TransactionalMetadataStoreContractTest.java new file mode 100644 index 0000000..c9e3e25 --- /dev/null +++ b/pki/src/test/java/zeroecho/pki/spi/store/TransactionalMetadataStoreContractTest.java @@ -0,0 +1,565 @@ +/* + * 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 static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import zeroecho.core.io.CancellationSignal; +import zeroecho.core.io.RepeatableContent; + +final class TransactionalMetadataStoreContractTest { + private static final MetadataKey FIRST = new MetadataKey("example.records", "first"); + private static final MetadataKey SECOND = new MetadataKey("example.other", "second"); + + @TempDir + private Path temporaryDirectory; + + @Test + void createReplaceAndDeleteAreAtomicAcrossNamespaces() throws Exception { + System.out.print("createReplaceAndDeleteAreAtomicAcrossNamespaces "); + try (InMemoryTransactionalMetadataStore store = store()) { + try (MetadataTransaction transaction = store.beginTransaction()) { + transaction.create(FIRST, content("one"), CancellationSignal.NONE); + transaction.create(SECOND, content("two"), CancellationSignal.NONE); + assertEquals(MetadataCommitResult.Outcome.COMMITTED, transaction.commit().outcome()); + } + try (MetadataSnapshot snapshot = store.snapshot()) { + assertEquals("one", read(snapshot, FIRST)); + assertEquals("two", read(snapshot, SECOND)); + } + try (MetadataTransaction transaction = store.beginTransaction()) { + transaction.replace(FIRST, 0L, content("updated"), CancellationSignal.NONE); + transaction.delete(SECOND, 0L); + assertEquals(MetadataCommitResult.Outcome.COMMITTED, transaction.commit().outcome()); + } + try (MetadataSnapshot snapshot = store.snapshot()) { + assertEquals("updated", read(snapshot, FIRST)); + assertTrue(snapshot.get(SECOND).isEmpty()); + } + } + System.out.println("...ok"); + } + + @Test + void conflictIsTerminalResolvableAndHasNoPartialVisibility() throws Exception { + System.out.print("conflictIsTerminalResolvableAndHasNoPartialVisibility "); + try (InMemoryTransactionalMetadataStore store = store()) { + create(store, FIRST, "original"); + MetadataTransaction transaction = store.beginTransaction(); + transaction.create(FIRST, content("conflict"), CancellationSignal.NONE); + transaction.create(SECOND, content("hidden"), CancellationSignal.NONE); + MetadataCommitResult result = transaction.commit(); + assertEquals(MetadataCommitResult.Outcome.NOT_COMMITTED, result.outcome()); + assertEquals(result, store.resolve(transaction.id())); + assertThrows(IllegalStateException.class, transaction::commit); + try (MetadataSnapshot snapshot = store.snapshot()) { + assertEquals("original", read(snapshot, FIRST)); + assertTrue(snapshot.get(SECOND).isEmpty()); + } + transaction.close(); + } + System.out.println("...ok"); + } + + @Test + void unknownOnlyHidesAnAlreadyFixedOutcome() throws Exception { + System.out.print("unknownOnlyHidesAnAlreadyFixedOutcome "); + try (InMemoryTransactionalMetadataStore store = store()) { + store.hideNextCommittedOutcome(); + MetadataTransaction transaction = store.beginTransaction(); + transaction.create(FIRST, content("fixed"), CancellationSignal.NONE); + MetadataCommitResult immediate = transaction.commit(); + assertEquals(MetadataCommitResult.Outcome.UNKNOWN, immediate.outcome()); + try (MetadataSnapshot snapshot = store.snapshot()) { + assertEquals("fixed", read(snapshot, FIRST)); + } + assertEquals( + MetadataCommitResult.Outcome.COMMITTED, + store.resolve(transaction.id()).outcome()); + assertEquals(store.resolve(transaction.id()), store.resolve(transaction.id())); + transaction.close(); + } + System.out.println("...ok"); + } + + @Test + void foreignAndUnissuedTransactionIdentitiesFailClosed() throws Exception { + System.out.print("foreignAndUnissuedTransactionIdentitiesFailClosed "); + MetadataStoreId shared = new MetadataStoreId("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + try (InMemoryTransactionalMetadataStore first = new InMemoryTransactionalMetadataStore( + temporaryDirectory.resolve("first"), + shared, + MetadataStoreCapabilities.ContentLengthModel.KNOWN_OR_UNKNOWN_LENGTH, + OptionalLong.empty()); + InMemoryTransactionalMetadataStore second = new InMemoryTransactionalMetadataStore( + temporaryDirectory.resolve("second"), + shared, + MetadataStoreCapabilities.ContentLengthModel.KNOWN_OR_UNKNOWN_LENGTH, + OptionalLong.empty())) { + MetadataTransaction issued = first.beginTransaction(); + assertThrows(MetadataStoreException.class, () -> second.resolve(issued.id())); + MetadataTransactionId unissued = + new MetadataTransactionId(shared, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); + assertThrows(MetadataStoreException.class, () -> first.resolve(unissued)); + issued.abort(); + } + System.out.println("...ok"); + } + + @Test + void knownAndUnknownContentDetachSynchronously() throws Exception { + System.out.print("knownAndUnknownContentDetachSynchronously "); + try (InMemoryTransactionalMetadataStore store = store()) { + ProbeContent known = new ProbeContent("known", true); + ProbeContent unknown = new ProbeContent("unknown", false); + try (MetadataTransaction transaction = store.beginTransaction()) { + transaction.create(FIRST, known, CancellationSignal.NONE); + transaction.create(SECOND, unknown, CancellationSignal.NONE); + known.invalidate(); + unknown.invalidate(); + assertEquals(MetadataCommitResult.Outcome.COMMITTED, transaction.commit().outcome()); + } + assertEquals(1, known.openCount()); + assertEquals(1, unknown.openCount()); + try (MetadataSnapshot snapshot = store.snapshot()) { + assertEquals("known", read(snapshot, FIRST)); + assertEquals("unknown", read(snapshot, SECOND)); + } + } + System.out.println("...ok"); + } + + @Test + void callerContentStagingDoesNotHoldStoreMonitor() throws Exception { + System.out.print("callerContentStagingDoesNotHoldStoreMonitor "); + try (InMemoryTransactionalMetadataStore store = store(); + ExecutorService executor = Executors.newSingleThreadExecutor()) { + BlockingContent content = new BlockingContent(store); + Future staging = executor.submit(() -> { + try (MetadataTransaction transaction = store.beginTransaction()) { + transaction.create(FIRST, content, CancellationSignal.NONE); + transaction.abort(); + } + return null; + }); + content.awaitReadStarted(); + assertEquals(1, store.activeAdmissionCount()); + try (MetadataSnapshot snapshot = store.snapshot()) { + assertEquals(0L, snapshot.revision()); + } + assertFalse(content.monitorObserved()); + content.releaseRead(); + staging.get(); + assertEquals(0, store.activeAdmissionCount()); + } + System.out.println("...ok"); + } + + @Test + void interruptedClosePreservesClosingAdmissionBarrier() throws Exception { + System.out.print("interruptedClosePreservesClosingAdmissionBarrier "); + InMemoryTransactionalMetadataStore store = store(); + try (ExecutorService executor = Executors.newSingleThreadExecutor()) { + BlockingContent content = new BlockingContent(store); + Future staging = executor.submit(() -> { + try (MetadataTransaction transaction = store.beginTransaction()) { + transaction.create(FIRST, content, CancellationSignal.NONE); + } + return null; + }); + content.awaitReadStarted(); + AtomicReference closeFailure = new AtomicReference<>(); + Thread closingThread = Thread.ofPlatform().unstarted(() -> { + try { + store.close(); + } catch (Throwable failure) { + closeFailure.set(failure); + } + }); + closingThread.start(); + store.awaitClosingStarted(); + closingThread.interrupt(); + closingThread.join(); + assertTrue(closeFailure.get() instanceof IOException); + content.releaseRead(); + assertThrows(java.util.concurrent.ExecutionException.class, staging::get); + assertEquals(0, store.activeAdmissionCount()); + store.close(); + } finally { + store.close(); + } + System.out.println("...ok"); + } + + @Test + void contentLengthCapabilityAndTechnicalLimitAreEnforced() throws Exception { + System.out.print("contentLengthCapabilityAndTechnicalLimitAreEnforced "); + try (InMemoryTransactionalMetadataStore knownOnly = + new InMemoryTransactionalMetadataStore( + temporaryDirectory.resolve("known-only"), + MetadataStoreCapabilities.ContentLengthModel.KNOWN_LENGTH_ONLY, + OptionalLong.of(4L)); + MetadataTransaction transaction = knownOnly.beginTransaction()) { + transaction.create(FIRST, content("four"), CancellationSignal.NONE); + assertThrows( + MetadataStoreException.class, + () -> transaction.create( + SECOND, new ProbeContent("x", false), CancellationSignal.NONE)); + } + try (InMemoryTransactionalMetadataStore limited = + new InMemoryTransactionalMetadataStore( + temporaryDirectory.resolve("limited"), + MetadataStoreCapabilities.ContentLengthModel.KNOWN_OR_UNKNOWN_LENGTH, + OptionalLong.of(4L)); + MetadataTransaction transaction = limited.beginTransaction()) { + assertThrows( + MetadataStoreException.class, + () -> transaction.create(FIRST, content("five!"), CancellationSignal.NONE)); + } + System.out.println("...ok"); + } + + @Test + void snapshotsAreStableAndScansAreLazyAndOrdered() throws Exception { + System.out.print("snapshotsAreStableAndScansAreLazyAndOrdered "); + try (InMemoryTransactionalMetadataStore store = store()) { + create(store, new MetadataKey("example.records", "a"), "a"); + create(store, new MetadataKey("example.records", "b"), "b"); + try (MetadataSnapshot old = store.snapshot()) { + replace(store, new MetadataKey("example.records", "a"), 0L, "new-a"); + assertEquals("a", read(old, new MetadataKey("example.records", "a"))); + List keys = new ArrayList<>(); + try (MetadataCursor cursor = old.scan( + MetadataSnapshot.KeyRange.prefix("example.records", ""), + CancellationSignal.NONE)) { + Optional next; + while ((next = cursor.next(CancellationSignal.NONE)).isPresent()) { + try (MetadataSnapshot.Record record = next.orElseThrow()) { + keys.add(record.key().key()); + } + } + } + assertEquals(List.of("a", "b"), keys); + } + try (MetadataSnapshot current = store.snapshot()) { + assertEquals("new-a", read(current, new MetadataKey("example.records", "a"))); + } + } + System.out.println("...ok"); + } + + @Test + void cancellationStopsAdmissionAndLeavesTransactionReusable() throws Exception { + System.out.print("cancellationStopsAdmissionAndLeavesTransactionReusable "); + try (InMemoryTransactionalMetadataStore store = store(); + MetadataTransaction transaction = store.beginTransaction()) { + AtomicInteger checks = new AtomicInteger(); + CancellationSignal cancellation = () -> checks.incrementAndGet() > 3; + assertThrows( + IOException.class, + () -> transaction.create( + FIRST, new GeneratedContent(32768L, false), cancellation)); + transaction.create(FIRST, content("retry"), CancellationSignal.NONE); + assertEquals(MetadataCommitResult.Outcome.COMMITTED, transaction.commit().outcome()); + } + System.out.println("...ok"); + } + + @Test + void largeUnknownContentUsesBoundedStreamingAndRemainsRepeatable() throws Exception { + System.out.print("largeUnknownContentUsesBoundedStreamingAndRemainsRepeatable "); + try (InMemoryTransactionalMetadataStore store = store()) { + GeneratedContent generated = new GeneratedContent(131_072L, false); + try (MetadataTransaction transaction = store.beginTransaction()) { + transaction.create(FIRST, generated, CancellationSignal.NONE); + assertEquals(MetadataCommitResult.Outcome.COMMITTED, transaction.commit().outcome()); + } + assertEquals(1, generated.openCount()); + try (MetadataSnapshot snapshot = store.snapshot(); + MetadataSnapshot.Record record = snapshot.get(FIRST).orElseThrow(); + InputStream first = record.openStream(); + InputStream second = record.openStream()) { + assertArrayEquals(first.readNBytes(1024), second.readNBytes(1024)); + assertEquals(131_072L, record.length().orElseThrow()); + } + } + System.out.println("...ok"); + } + + @Test + void lifecycleRejectsUseAfterClose() throws Exception { + System.out.print("lifecycleRejectsUseAfterClose "); + InMemoryTransactionalMetadataStore store = store(); + MetadataTransaction transaction = store.beginTransaction(); + transaction.abort(); + assertThrows(IllegalStateException.class, transaction::commit); + MetadataSnapshot snapshot = store.snapshot(); + MetadataCursor cursor = snapshot.scan( + MetadataSnapshot.KeyRange.all("example.records"), + CancellationSignal.NONE); + cursor.close(); + assertThrows( + IllegalStateException.class, + () -> cursor.next(CancellationSignal.NONE)); + snapshot.close(); + assertThrows(IllegalStateException.class, () -> snapshot.get(FIRST)); + store.close(); + store.close(); + assertThrows(IllegalStateException.class, store::beginTransaction); + System.out.println("...ok"); + } + + private InMemoryTransactionalMetadataStore store() throws IOException { + return new InMemoryTransactionalMetadataStore( + temporaryDirectory.resolve("store-" + System.nanoTime()), + MetadataStoreCapabilities.ContentLengthModel.KNOWN_OR_UNKNOWN_LENGTH, + OptionalLong.empty()); + } + + private static void create( + TransactionalMetadataStore store, MetadataKey key, String value) throws Exception { + try (MetadataTransaction transaction = store.beginTransaction()) { + transaction.create(key, content(value), CancellationSignal.NONE); + assertEquals(MetadataCommitResult.Outcome.COMMITTED, transaction.commit().outcome()); + } + } + + private static void replace( + TransactionalMetadataStore store, + MetadataKey key, + long expectedRevision, + String value) throws Exception { + try (MetadataTransaction transaction = store.beginTransaction()) { + transaction.replace(key, expectedRevision, content(value), CancellationSignal.NONE); + assertEquals(MetadataCommitResult.Outcome.COMMITTED, transaction.commit().outcome()); + } + } + + private static String read(MetadataSnapshot snapshot, MetadataKey key) throws Exception { + try (MetadataSnapshot.Record record = snapshot.get(key).orElseThrow(); + InputStream input = record.openStream()) { + return new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private static RepeatableContent content(String value) { + return new ProbeContent(value, true); + } + + private static final class ProbeContent implements RepeatableContent { + private final byte[] value; + private final boolean knownLength; + private final AtomicBoolean valid = new AtomicBoolean(true); + private final AtomicInteger opens = new AtomicInteger(); + + private ProbeContent(String value, boolean knownLength) { + this.value = value.getBytes(StandardCharsets.UTF_8); + this.knownLength = knownLength; + } + + @Override + public InputStream openStream() throws IOException { + if (!valid.get()) { + throw new IOException("Probe source invalidated"); + } + opens.incrementAndGet(); + return new ByteArrayInputStream(value); + } + + @Override + public OptionalLong length() { + return knownLength ? OptionalLong.of(value.length) : OptionalLong.empty(); + } + + @Override + public String contentId() { + return "probe"; + } + + @Override + public void close() { + valid.set(false); + } + + private void invalidate() { + valid.set(false); + } + + private int openCount() { + return opens.get(); + } + } + + private static final class GeneratedContent implements RepeatableContent { + private final long length; + private final boolean knownLength; + private final AtomicInteger opens = new AtomicInteger(); + + private GeneratedContent(long length, boolean knownLength) { + this.length = length; + this.knownLength = knownLength; + } + + @Override + public InputStream openStream() { + opens.incrementAndGet(); + return new InputStream() { + private long remaining = length; + + @Override + public int read() { + if (remaining == 0L) { + return -1; + } + remaining--; + return 0x5a; + } + + @Override + public int read(byte[] buffer, int offset, int requested) { + if (remaining == 0L) { + return -1; + } + int count = (int) Math.min(requested, remaining); + java.util.Arrays.fill(buffer, offset, offset + count, (byte) 0x5a); + remaining -= count; + return count; + } + }; + } + + @Override + public OptionalLong length() { + return knownLength ? OptionalLong.of(length) : OptionalLong.empty(); + } + + @Override + public String contentId() { + return "generated"; + } + + @Override + public void close() { + } + + private int openCount() { + return opens.get(); + } + } + + private static final class BlockingContent implements RepeatableContent { + private final InMemoryTransactionalMetadataStore store; + private final CountDownLatch readStarted = new CountDownLatch(1); + private final CountDownLatch readReleased = new CountDownLatch(1); + private final AtomicBoolean monitorObserved = new AtomicBoolean(); + + private BlockingContent(InMemoryTransactionalMetadataStore store) { + this.store = store; + } + + @Override + public InputStream openStream() { + recordMonitorState(); + return new InputStream() { + private boolean delivered; + + @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[] buffer, int offset, int length) throws IOException { + recordMonitorState(); + if (delivered) { + return -1; + } + readStarted.countDown(); + awaitReadRelease(); + buffer[offset] = 0x41; + delivered = true; + return 1; + } + }; + } + + @Override + public OptionalLong length() { + recordMonitorState(); + return OptionalLong.of(1L); + } + + @Override + public String contentId() { + return "blocking-probe"; + } + + @Override + public void close() { + } + + private void awaitReadStarted() throws InterruptedException { + readStarted.await(); + } + + private void releaseRead() { + readReleased.countDown(); + } + + private boolean monitorObserved() { + return monitorObserved.get(); + } + + private void recordMonitorState() { + if (Thread.holdsLock(store)) { + monitorObserved.set(true); + } + } + + private void awaitReadRelease() throws IOException { + try { + readReleased.await(); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IOException("Blocking content read interrupted", exception); + } + } + } +} diff --git a/pki/src/test/java/zeroecho/pki/testkit/DurableDelayedSignatureWorkflow.java b/pki/src/test/java/zeroecho/pki/testkit/DurableDelayedSignatureWorkflow.java index 9b2b491..358937d 100644 --- a/pki/src/test/java/zeroecho/pki/testkit/DurableDelayedSignatureWorkflow.java +++ b/pki/src/test/java/zeroecho/pki/testkit/DurableDelayedSignatureWorkflow.java @@ -228,7 +228,8 @@ public final class DurableDelayedSignatureWorkflow implements SignatureWorkflow, if (key == null) { return new OperationStatus(State.FAILED, Instant.now(), Optional.of("UNKNOWN_KEY"), Optional.empty()); } - if (!supportedAlgorithms().contains(req.algorithmId)) { + String jcaAlgorithm = InMemorySignatureWorkflow.jcaName(req.algorithmId); + if (!supportedAlgorithms().contains(jcaAlgorithm)) { return new OperationStatus(State.FAILED, Instant.now(), Optional.of("UNSUPPORTED_ALG"), Optional.empty()); } @@ -242,7 +243,7 @@ public final class DurableDelayedSignatureWorkflow implements SignatureWorkflow, } try { - Signature sig = Signature.getInstance(req.algorithmId); + Signature sig = Signature.getInstance(jcaAlgorithm); sig.initSign(key); sig.update(req.payload); byte[] signature = sig.sign(); @@ -280,7 +281,7 @@ public final class DurableDelayedSignatureWorkflow implements SignatureWorkflow, try { Files.createDirectories(dir); String line = req.keyRef().value() + "\n" + req.algorithmId() + "\n" - + Base64.getEncoder().encodeToString(req.payload().bytes()) + "\n" + + Base64.getEncoder().encodeToString(readContent(req.content())) + "\n" + req.deadline().map(Instant::toString).orElse(""); Files.writeString(dir.resolve(FILE_REQUEST), line, StandardCharsets.UTF_8); } catch (IOException ex) { @@ -288,6 +289,12 @@ public final class DurableDelayedSignatureWorkflow implements SignatureWorkflow, } } + private static byte[] readContent(zeroecho.core.io.RepeatableContent content) throws IOException { + try (java.io.InputStream input = content.openStream()) { + return input.readAllBytes(); + } + } + private PersistedSignRequest loadRequest(PkiId opId) { Path f = opDir(opId).resolve(FILE_REQUEST); try { diff --git a/pki/src/test/java/zeroecho/pki/testkit/DurableOperatorApprovalSignatureWorkflow.java b/pki/src/test/java/zeroecho/pki/testkit/DurableOperatorApprovalSignatureWorkflow.java index fd8719c..aa57929 100644 --- a/pki/src/test/java/zeroecho/pki/testkit/DurableOperatorApprovalSignatureWorkflow.java +++ b/pki/src/test/java/zeroecho/pki/testkit/DurableOperatorApprovalSignatureWorkflow.java @@ -310,7 +310,8 @@ public final class DurableOperatorApprovalSignatureWorkflow implements Signature if (key == null) { return new OperationStatus(State.FAILED, Instant.now(), Optional.of("UNKNOWN_KEY"), Optional.empty()); } - if (!supportedAlgorithms().contains(req.algorithmId)) { + String jcaAlgorithm = InMemorySignatureWorkflow.jcaName(req.algorithmId); + if (!supportedAlgorithms().contains(jcaAlgorithm)) { return new OperationStatus(State.FAILED, Instant.now(), Optional.of("UNSUPPORTED_ALG"), Optional.empty()); } @@ -324,7 +325,7 @@ public final class DurableOperatorApprovalSignatureWorkflow implements Signature } try { - Signature sig = Signature.getInstance(req.algorithmId); + Signature sig = Signature.getInstance(jcaAlgorithm); sig.initSign(key); sig.update(req.payload); byte[] signature = sig.sign(); @@ -362,13 +363,19 @@ public final class DurableOperatorApprovalSignatureWorkflow implements Signature try { Files.createDirectories(dir); String line = req.keyRef().value() + "\n" + req.algorithmId() + "\n" - + Base64.getEncoder().encodeToString(req.payload().bytes()); + + Base64.getEncoder().encodeToString(readContent(req.content())); Files.writeString(dir.resolve(FILE_REQUEST), line, StandardCharsets.UTF_8); } catch (IOException ex) { throw new IllegalStateException("Cannot persist request", ex); } } + private static byte[] readContent(zeroecho.core.io.RepeatableContent content) throws IOException { + try (java.io.InputStream input = content.openStream()) { + return input.readAllBytes(); + } + } + private PersistedSignRequest loadRequest(PkiId opId) { Path f = opDir(opId).resolve(FILE_REQUEST); try { diff --git a/pki/src/test/java/zeroecho/pki/testkit/InMemorySignatureWorkflow.java b/pki/src/test/java/zeroecho/pki/testkit/InMemorySignatureWorkflow.java index 3abb298..3de329a 100644 --- a/pki/src/test/java/zeroecho/pki/testkit/InMemorySignatureWorkflow.java +++ b/pki/src/test/java/zeroecho/pki/testkit/InMemorySignatureWorkflow.java @@ -123,9 +123,17 @@ public final class InMemorySignatureWorkflow implements SignatureWorkflow { return opId; } - java.security.Signature sig = java.security.Signature.getInstance(request.algorithmId()); + java.security.Signature sig = java.security.Signature.getInstance(jcaName(request.algorithmId())); sig.initSign(kp.getPrivate()); - sig.update(request.payload().bytes()); + try (java.io.InputStream input = request.content().openStream()) { + byte[] buffer = new byte[8192]; + int read; + while ((read = input.read(buffer)) >= 0) { + if (read != 0) { + sig.update(buffer, 0, read); + } + } + } byte[] s = sig.sign(); Instant completedAt = Instant.now(); @@ -147,6 +155,22 @@ public final class InMemorySignatureWorkflow implements SignatureWorkflow { } } + static String jcaName(String identityOrAlias) { + return zeroecho.core.alg.BootstrapAlgorithmIdentities.resolve(identityOrAlias) + .map(identity -> { + if (identity.equals(zeroecho.core.alg.BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256)) { + return "SHA256withRSA"; + } + if (identity.equals(zeroecho.core.alg.BootstrapAlgorithmIdentities.RSA_PKCS1_SHA384)) { + return "SHA384withRSA"; + } + if (identity.equals(zeroecho.core.alg.BootstrapAlgorithmIdentities.RSA_PKCS1_SHA512)) { + return "SHA512withRSA"; + } + throw new IllegalArgumentException("Unsupported test signature identity"); + }).orElse(identityOrAlias); + } + private void complete(SignRequest request, OperationStatus terminal) { Object lock = operationLocks.get(request.submissionId()); synchronized (lock) { diff --git a/pki/src/test/java/zeroecho/pki/testkit/OperatorApprovalSignatureWorkflow.java b/pki/src/test/java/zeroecho/pki/testkit/OperatorApprovalSignatureWorkflow.java index 1fe6dea..12f4db7 100644 --- a/pki/src/test/java/zeroecho/pki/testkit/OperatorApprovalSignatureWorkflow.java +++ b/pki/src/test/java/zeroecho/pki/testkit/OperatorApprovalSignatureWorkflow.java @@ -153,7 +153,10 @@ public final class OperatorApprovalSignatureWorkflow implements SignatureWorkflo try { Files.createDirectories(dir); - Files.write(dir.resolve(FILE_REQUEST), request.payload().bytes()); + try (java.io.InputStream input = request.content().openStream(); + java.io.OutputStream output = Files.newOutputStream(dir.resolve(FILE_REQUEST))) { + input.transferTo(output); + } java.util.Properties p = new java.util.Properties(); Instant now = Instant.now(); diff --git a/pki/src/test/java/zeroecho/pki/testkit/PkiTestRuntime.java b/pki/src/test/java/zeroecho/pki/testkit/PkiTestRuntime.java index d415e53..fcebb40 100644 --- a/pki/src/test/java/zeroecho/pki/testkit/PkiTestRuntime.java +++ b/pki/src/test/java/zeroecho/pki/testkit/PkiTestRuntime.java @@ -34,16 +34,25 @@ package zeroecho.pki.testkit; import java.io.IOException; +import java.io.OutputStream; import java.nio.file.Path; import java.security.KeyPair; import java.security.PublicKey; import java.time.Clock; import java.time.Duration; +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.Set; +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.pki.api.CaService; import zeroecho.pki.api.CertificationRequestService; import zeroecho.pki.api.EncodedObject; @@ -53,6 +62,7 @@ import zeroecho.pki.api.KeyRef; import zeroecho.pki.api.ProfileService; import zeroecho.pki.api.RevocationService; import zeroecho.pki.api.StatusObjectService; +import zeroecho.pki.api.content.DurableContentReference; import zeroecho.pki.api.credential.EffectiveCredentialStatusResolver; import zeroecho.pki.api.profile.BuiltInCertificateProfileCatalog; import zeroecho.pki.impl.audit.InMemoryAuditSink; @@ -65,9 +75,13 @@ import zeroecho.pki.impl.core.DefaultStatusObjectService; import zeroecho.pki.impl.core.StoreBackedEffectiveCredentialStatusResolver; import zeroecho.pki.impl.core.async.PkiSigningBus; import zeroecho.pki.impl.core.attr.SimpleAttributeSet; +import zeroecho.pki.impl.framework.x509.X509AlgorithmResolver; +import zeroecho.pki.impl.framework.x509.X509AuthoritySnapshot; import zeroecho.pki.impl.framework.x509.bc.BcX509CredentialFramework; import zeroecho.pki.impl.framework.x509.bc.BcX509CredentialIssuerBackend; import zeroecho.pki.impl.framework.x509.bc.BcX509StatusObjectGenerator; +import zeroecho.pki.impl.framework.x509.bc.BcX509ProofOfPossessionVerifier; +import zeroecho.pki.impl.framework.x509.bc.BcX509VerificationExecutor; import zeroecho.pki.impl.fs.FilesystemPkiStore; import zeroecho.pki.impl.fs.FsPkiStoreOptions; import zeroecho.pki.spi.crypto.SignatureWorkflow; @@ -75,6 +89,8 @@ import zeroecho.pki.spi.framework.CredentialFramework; import zeroecho.pki.spi.framework.CredentialIssuerBackend; import zeroecho.pki.spi.framework.ProofOfPossessionVerifier; import zeroecho.pki.spi.store.PkiStore; +import zeroecho.pki.spi.store.ContentSink; +import zeroecho.pki.spi.store.StagedContentStore; /** * Test-only PKI runtime wiring helper. @@ -87,6 +103,113 @@ import zeroecho.pki.spi.store.PkiStore; */ public final class PkiTestRuntime implements AutoCloseable { + /** + * Stages one small test payload through the production streaming boundary. + * + * @param bus owning signing bus + * @param payload small test payload + * @return durable operation-content reference + * @throws IOException if staging fails + */ + public static DurableContentReference stage(PkiSigningBus bus, EncodedObject payload) throws IOException { + try (ContentSink sink = bus.beginSigningContent(payload.encoding()); + OutputStream output = sink.outputStream()) { + output.write(payload.bytes()); + return sink.complete(); + } + } + + /** + * Materializes one test fixture from runtime-owned staged content. + * + * @param bus owning signing bus + * @param reference content reference + * @return fixture bytes + * @throws IOException if reading fails + */ + public static byte[] readContent(PkiSigningBus bus, DurableContentReference reference) throws IOException { + try (zeroecho.core.io.RepeatableContent content = bus.openContent(reference); + java.io.InputStream input = content.openStream()) { + return input.readAllBytes(); + } + } + + /** + * Stages one small credential fixture in the supplied test store. + * + * @param store owning test store + * @param encoded encoded fixture bytes + * @return durable credential-content reference + * @throws IOException if staging fails + */ + public static DurableContentReference stageCredential(PkiStore store, byte[] encoded) throws IOException { + try (ContentSink sink = store.stagedContent().beginContent(Encoding.DER, DurableContentReference.Lifecycle.PERSISTED); + OutputStream output = sink.outputStream()) { + output.write(encoded); + return sink.complete(); + } + } + + /** + * Creates payload-free metadata for tests that exercise value semantics only. + * + *

        + * The returned reference is intentionally not resolvable. Tests that read + * content must stage it through an owning store instead. + *

        + * + * @param encoding fixture encoding + * @param bytes fixture bytes used only to derive immutable metadata + * @return non-resolvable fixture reference + */ + public static DurableContentReference fixtureReference(StagedContentStore store, Encoding encoding, byte[] bytes) + throws IOException { + try (ContentSink sink = store.beginContent(encoding, DurableContentReference.Lifecycle.PERSISTED); + OutputStream output = sink.outputStream()) { + output.write(bytes); + return sink.complete(); + } + } + + /** + * Creates an explicitly untrusted reference implementation for hostile tests. + * + * @param source source metadata + * @param encoding substituted encoding + * @return non-store-issued reference that an owning store must reject + */ + public static DurableContentReference untrustedReference(DurableContentReference source, Encoding encoding) { + return new UntrustedReference(source.storeId(), source.contentId(), encoding, source.length(), source.sha256(), + source.lifecycle()); + } + + /** + * Materializes one small fixture from store-owned content. + * + * @param store owning test store + * @param reference content reference + * @return fixture bytes + * @throws IOException if reading fails + */ + public static byte[] readContent(PkiStore store, DurableContentReference reference) throws IOException { + try (zeroecho.core.io.RepeatableContent content = store.stagedContent().openContent(reference); + java.io.InputStream input = content.openStream()) { + return input.readAllBytes(); + } + } + + public DurableContentReference stageCredential(byte[] encoded) throws IOException { + return stageCredential(store, encoded); + } + + public byte[] credentialBytes(zeroecho.pki.api.credential.Credential credential) throws IOException { + return readContent(store, credential.content()); + } + + private record UntrustedReference(String storeId, String contentId, Encoding encoding, long length, String sha256, + DurableContentReference.Lifecycle lifecycle) implements DurableContentReference { + } + private final FilesystemPkiStore store; private final PkiSigningBus signingBus; private final SignatureWorkflow signatureWorkflow; @@ -128,7 +251,8 @@ public final class PkiTestRuntime implements AutoCloseable { this.issuanceService = new DefaultIssuanceService(store, framework, issuerBackend, auditSink, statusResolver, profileService, clock); this.revocationService = new DefaultRevocationService(store, clock, auditSink); - this.statusObjectService = new DefaultStatusObjectService(store, framework, auditSink, statusResolver); + this.statusObjectService = new DefaultStatusObjectService(store, framework, auditSink, statusResolver, + signingBus.authority()); this.caService = new DefaultCaService(store, framework, issuerBackend, this::resolvePublicKeyInfo, signingBus, auditSink, statusResolver, profileService, clock, "SHA256withRSA", signingTtl); @@ -189,15 +313,20 @@ public final class PkiTestRuntime implements AutoCloseable { } SignatureWorkflow signer = new InMemorySignatureWorkflow(byRef); - PkiSigningBus signingBus = new PkiSigningBus(store, signer, busFile); + BcX509VerificationExecutor verificationExecutor = new BcX509VerificationExecutor(); + X509AuthoritySnapshot authority = signingAuthority(signer, List.of(verificationExecutor), + List.of(X509AuthoritySnapshot.bindExecutor(BcX509VerificationExecutor.IMPLEMENTATION_ID, + AlgorithmExecutionCapability.Direction.VERIFY, verificationExecutor))); + PkiSigningBus signingBus = new PkiSigningBus(store, signer, busFile, authority); BcX509CredentialIssuerBackend issuerBackend = new BcX509CredentialIssuerBackend(signingBus, "SHA256withRSA", Duration.ofSeconds(2)); BcX509StatusObjectGenerator statusGen = new BcX509StatusObjectGenerator(signingBus, "SHA256withRSA", Duration.ofSeconds(2)); - BcX509CredentialFramework baseFramework = new BcX509CredentialFramework(); - CredentialFramework framework = proofVerifier.map(verifier -> baseFramework.wired(statusGen, verifier)) - .orElseGet(() -> baseFramework.wired(statusGen)); + BcX509CredentialFramework baseFramework = new BcX509CredentialFramework(authority, verificationExecutor); + ProofOfPossessionVerifier effectiveVerifier = proofVerifier + .orElseGet(() -> new BcX509ProofOfPossessionVerifier(authority, verificationExecutor)); + CredentialFramework framework = baseFramework.wired(statusGen, effectiveVerifier); return new PkiTestRuntime(store, signingBus, signer, framework, issuerBackend, publicByRef, Duration.ofSeconds(2)); @@ -215,15 +344,96 @@ public final class PkiTestRuntime implements AutoCloseable { publicByRef.put(entry.getKey().value(), entry.getValue().getPublic()); } SignatureWorkflow signer = new InMemorySignatureWorkflow(byRef, false); - PkiSigningBus signingBus = new PkiSigningBus(store, signer, busFile); + BcX509VerificationExecutor verificationExecutor = new BcX509VerificationExecutor(); + X509AuthoritySnapshot authority = signingAuthority(signer, List.of(verificationExecutor), + List.of(X509AuthoritySnapshot.bindExecutor(BcX509VerificationExecutor.IMPLEMENTATION_ID, + AlgorithmExecutionCapability.Direction.VERIFY, verificationExecutor))); + PkiSigningBus signingBus = new PkiSigningBus(store, signer, busFile, authority); BcX509CredentialIssuerBackend issuerBackend = new BcX509CredentialIssuerBackend(signingBus, "SHA256withRSA", signingTtl); BcX509StatusObjectGenerator statusGen = new BcX509StatusObjectGenerator(signingBus, "SHA256withRSA", signingTtl); - CredentialFramework framework = new BcX509CredentialFramework().wired(statusGen); + CredentialFramework framework = new BcX509CredentialFramework(authority, verificationExecutor).wired(statusGen, + new BcX509ProofOfPossessionVerifier(authority, verificationExecutor)); return new PkiTestRuntime(store, signingBus, signer, framework, issuerBackend, publicByRef, signingTtl); } + /** + * Composes a test authority that owns the exact supplied signing workflow. + * + *

        + * This test helper makes runtime composition explicit. The returned snapshot + * binds only the signing identities declared by {@code workflow}, contains no + * key material, and cannot authorize a different workflow instance. + *

        + * + * @param workflow exact test signing workflow + * @return immutable authority snapshot owning {@code workflow} + * @throws NullPointerException if {@code workflow} or its supported-algorithm + * set is {@code null} + */ + public static X509AuthoritySnapshot signingAuthority(SignatureWorkflow workflow) { + return signingAuthority(workflow, List.of(), List.of()); + } + + private static X509AuthoritySnapshot signingAuthority(SignatureWorkflow workflow, + List additionalCapabilities, + List additionalExecutors) { + Objects.requireNonNull(workflow, "workflow"); + Objects.requireNonNull(additionalCapabilities, "additionalCapabilities"); + Objects.requireNonNull(additionalExecutors, "additionalExecutors"); + Set supported = Set.copyOf( + Objects.requireNonNull(workflow.supportedAlgorithms(), "workflow.supportedAlgorithms")); + Set exact = BootstrapAlgorithmIdentities.catalog().identities().stream() + .filter(identity -> identity.kind() == AlgorithmIdentity.Kind.SIGNATURE) + .filter(identity -> supported.contains(identity.canonicalForm()) + || BootstrapAlgorithmIdentities.compatibilityAliases().entrySet().stream() + .anyMatch(entry -> entry.getValue().equals(identity) + && supported.contains(entry.getKey()))) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + AlgorithmExecutionCapability capability = new AlgorithmExecutionCapability() { + @Override + public String implementationId() { + return workflowImplementationId(workflow); + } + + @Override + public String domainFingerprint() { + return "test-workflow-v1:" + workflow.id() + ":" + exact.stream() + .map(AlgorithmIdentity::canonicalForm).sorted().reduce("", (left, right) -> left + right); + } + + @Override + public boolean supports(AlgorithmIdentity identity, AlgorithmSuite suite, Direction direction) { + return direction == Direction.SIGN && exact.contains(identity) && identity.equals(suite.signature()); + } + }; + AlgorithmExecutionCapabilityProvider provider = () -> List.of(capability); + X509AlgorithmResolver.Policy policy = new X509AlgorithmResolver.Policy() { + @Override + public boolean permits(AlgorithmSuite suite, AlgorithmExecutionCapability.Direction direction) { + return true; + } + + @Override + public String semanticFingerprint() { + return "pki-test-runtime-policy-v1:allow-authorized-capability"; + } + }; + List providers = new ArrayList<>(); + providers.add(provider); + providers.addAll(additionalCapabilities); + List executors = new ArrayList<>(); + executors.add(X509AuthoritySnapshot.bindExecutor(capability.implementationId(), + AlgorithmExecutionCapability.Direction.SIGN, workflow)); + executors.addAll(additionalExecutors); + return X509AuthoritySnapshot.compose(List.of(), providers, executors, policy); + } + + private static String workflowImplementationId(SignatureWorkflow workflow) { + return "workflow." + workflow.id(); + } + private EncodedObject resolvePublicKeyInfo(KeyRef keyRef) { publicKeyResolveHook.run(); PublicKey publicKey = publicKeysByKeyRef.get(keyRef.value()); @@ -377,7 +587,7 @@ public final class PkiTestRuntime implements AutoCloseable { public StatusObjectService statusObjectService(EffectiveCredentialStatusResolver resolver) { return new DefaultStatusObjectService(store, framework, auditSink, - Objects.requireNonNull(resolver, "resolver")); + Objects.requireNonNull(resolver, "resolver"), signingBus.authority()); } /**