diff --git a/README.md b/README.md index 6220aa2..2e01597 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,53 @@ Whether you're working on secure file storage, encrypted communication, or priva - CLI Tools & Keystore Management +## PKI administration CLI + +The application distribution provides one synchronous PKI administration entry +point. Direct commands and JSON plans use the same typed backend operation +executor and one lifecycle-owned `PkiSession`: + +```text +zeroecho pki --help +zeroecho pki configuration.validate --config pki-config.json +zeroecho pki run administration-plan.json --config pki-config.json --output json +``` + +The administration surface covers every administratively meaningful capability +implemented by the current backend: configuration validation; persisted profile +validation/import/inspection/activation; CA creation, inspection, listing, and +lifecycle transitions; strict CSR import and proof verification; credential +issuance and inspection; revocation inspection and bounded history/snapshot +presentation; signed status/CRL generation and inspection; and explicit +publication registration, processing, retry, reconciliation, inspection, and +listing. Presentation limits bound terminal output only; backend revocation and +publication cursors remain streaming and do not acquire an aggregate population +limit. + +Plans are versioned JSON documents with ordered, uniquely named operations. +References use exact whole values from earlier successful steps. Plans support +only `FAIL_FAST` and `CONTINUE_INDEPENDENT`; they have no scripts, expressions, +loops, implicit retries, or rollback across already committed operations. Binary +artifacts and secrets are never emitted in terminal JSON. + +Version-two session configuration enables capabilities explicitly. A signing +section selects one signature-workflow provider, one X.509 framework, a durable +signing-bus location, an algorithm, a finite signing deadline, and the name of an +environment variable supplying the process-local keyring unlock capability. +Publisher entries select configured destinations independently, so publication +can be administered without enabling signing. Values of unlock variables, +private keys, and publisher credentials are never operation arguments or output. +Read-only version-one configurations remain valid and allocate neither signing +nor publisher capabilities. + +The CLI and a future server share the same transport-neutral `PkiSession` and +typed operations; batch execution opens that service graph once. Backup and +import/export commands remain unavailable because the current backend has no +production service implementation for them. Final backend release readiness is +intentionally deferred until the planned extensible X.509 algorithm/OID binding +work and its CLI integration are complete; this CLI does not anticipate or add +placeholders for that future work. + ## Development Status diff --git a/app/src/main/java/zeroecho/pki/cli/PkiCli.java b/app/src/main/java/zeroecho/pki/cli/PkiCli.java index f4c2c4e..2059985 100644 --- a/app/src/main/java/zeroecho/pki/cli/PkiCli.java +++ b/app/src/main/java/zeroecho/pki/cli/PkiCli.java @@ -56,6 +56,18 @@ public final class PkiCli { private static final String CONFIG = "--config"; private static final String OUTPUT = "--output"; private static final String RUN = "run"; + private static final Map OPERATION_ARGUMENT_NAMES = Map.ofEntries( + Map.entry("--profile-file", "profileFile"), Map.entry("--profile-id", "profileId"), + Map.entry("--profile-version", "profileVersion"), Map.entry("--ca-id", "caId"), + Map.entry("--issuer-ca-id", "issuerCaId"), Map.entry("--format-id", "formatId"), + Map.entry("--subject-ref", "subjectRef"), Map.entry("--key-ref", "keyRef"), + Map.entry("--state", "state"), Map.entry("--request-id", "requestId"), + Map.entry("--request-file", "requestFile"), Map.entry("--encoding", "encoding"), + Map.entry("--credential-id", "credentialId"), Map.entry("--status-object-id", "statusObjectId"), + Map.entry("--type", "type"), Map.entry("--publication-id", "publicationId"), + Map.entry("--source-type", "sourceType"), Map.entry("--source-id", "sourceId"), + Map.entry("--target-type", "targetType"), Map.entry("--target-id", "targetId"), + Map.entry("--reason", "reason"), Map.entry("--limit", "limit")); private static final int RUN_TOKEN_COUNT = 2; private PkiCli() { @@ -195,8 +207,8 @@ public final class PkiCli { for (int index = 0; index < tokens.size(); index += 2) { String name = argumentName(tokens.get(index)); String raw = tokens.get(index + 1); - PkiOperationValue value = "limit".equals(name) ? new PkiOperationValue.IntegerValue(parseLong(raw)) - : new PkiOperationValue.Text(raw); + PkiOperationValue value = "limit".equals(name) || "profileVersion".equals(name) + ? new PkiOperationValue.IntegerValue(parseLong(raw)) : new PkiOperationValue.Text(raw); if (arguments.putIfAbsent(name, value) != null) { throw new IllegalArgumentException("Operation argument is duplicated"); } @@ -205,14 +217,11 @@ public final class PkiCli { } private static String argumentName(String option) { - return switch (option) { - case "--profile-file" -> "profileFile"; - case "--credential-id" -> "credentialId"; - case "--publication-id" -> "publicationId"; - case "--reason" -> "reason"; - case "--limit" -> "limit"; - default -> throw new IllegalArgumentException("Unknown PKI operation argument"); - }; + String name = OPERATION_ARGUMENT_NAMES.get(option); + if (name == null) { + throw new IllegalArgumentException("Unknown PKI operation argument"); + } + return name; } private static PkiPlan singlePlan(Invocation invocation, PkiOperationRegistry registry) throws IOException { @@ -256,10 +265,33 @@ public final class PkiCli { writer.println("Operations:"); writer.println(" configuration.validate"); writer.println(" profile.validate --profile-file "); + writer.println(" profile.register --profile-file "); + writer.println(" profile.inspect --profile-id --profile-version "); + writer.println(" profile.list --profile-id "); + writer.println(" profile.activate --profile-id --profile-version "); + writer.println(" ca.inspect --ca-id "); + writer.println(" ca.list --limit <1..1000>"); + writer.println(" ca.create --format-id --subject-ref --profile-id --key-ref "); + writer.println(" ca.transition --ca-id --state --reason "); + writer.println(" request.inspect --request-id "); + writer.println(" request.import --format-id --encoding DER --request-file "); + writer.println(" request.verify --request-id "); writer.println(" credential.inspect --credential-id "); + writer.println(" credential.issue --issuer-ca-id --request-id --profile-id "); writer.println(" credential.revoke --credential-id --reason "); + writer.println(" revocation.inspect --credential-id "); writer.println(" revocation.history --credential-id --limit <1..1000>"); + writer.println(" revocation.snapshot --limit <1..1000>"); + writer.println(" status.inspect --status-object-id "); + writer.println(" status.generate --issuer-ca-id --type --format-id "); + writer.println(" inventory.status --issuer-ca-id --limit <1..1000>"); writer.println(" publication.inspect --publication-id "); + writer.println(" publication.list --limit <1..1000>"); + writer.println(" publication.register --publication-id --source-type --source-id "); + writer.println(" --target-type --target-id "); + writer.println(" publication.process --publication-id "); + writer.println(" publication.retry --publication-id "); + writer.println(" publication.reconcile --publication-id "); writer.flush(); } diff --git a/app/src/main/java/zeroecho/pki/cli/PkiCliConfiguration.java b/app/src/main/java/zeroecho/pki/cli/PkiCliConfiguration.java index a263752..cb40775 100644 --- a/app/src/main/java/zeroecho/pki/cli/PkiCliConfiguration.java +++ b/app/src/main/java/zeroecho/pki/cli/PkiCliConfiguration.java @@ -34,6 +34,8 @@ package zeroecho.pki.cli; import java.nio.file.Path; +import java.time.Duration; +import java.util.List; import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; @@ -45,8 +47,12 @@ import zeroecho.pki.spi.ProviderConfig; /** Strict versioned CLI configuration loader. */ final class PkiCliConfiguration { - private static final Set ROOT_FIELDS = Set.of("version", "store", "audit"); + private static final int VERSION_ONE = 1; + private static final Set ROOT_FIELDS_V1 = Set.of("version", "store", "audit"); + private static final Set ROOT_FIELDS_V2 = Set.of("version", "store", "audit", "signing", "publishers"); private static final Set PROVIDER_FIELDS = Set.of("provider", "properties"); + private static final Set SIGNING_FIELDS = Set.of("workflow", "framework", "busPath", + "signatureAlgorithm", "signingTtlSeconds", "unlockEnvironmentVariable"); private static final String STDOUT_PROVIDER = "stdout"; private PkiCliConfiguration() { @@ -55,14 +61,42 @@ final class PkiCliConfiguration { /* default */ static PkiSessionConfiguration read(Path path) throws java.io.IOException { byte[] document = PkiCliFiles.readRegularFile(path, PkiCliJson.MAXIMUM_DOCUMENT_BYTES); PkiOperationValue.ObjectValue root = object(PkiCliJson.parse(document)); - requireExactFields(root.fields(), ROOT_FIELDS); int version = Math.toIntExact(integer(required(root, "version"))); + if (version == VERSION_ONE) { + requireExactFields(root.fields(), ROOT_FIELDS_V1); + } else { + requireVersionTwoFields(root.fields()); + } ProviderConfig store = provider(required(root, "store")); ProviderConfig audit = provider(required(root, "audit")); if (STDOUT_PROVIDER.equals(audit.backendId())) { throw new IllegalArgumentException("CLI audit provider must not write to standard output"); } - return new PkiSessionConfiguration(version, store, audit); + if (version == VERSION_ONE) { + return new PkiSessionConfiguration(version, store, audit); + } + java.util.Optional signing = java.util.Optional + .ofNullable(root.fields().get("signing")).map(PkiCliConfiguration::signing); + PkiOperationValue publisherValue = root.fields().get("publishers"); + List publishers = publisherValue == null ? List.of() + : list(publisherValue).values().stream().map(PkiCliConfiguration::provider).toList(); + return new PkiSessionConfiguration(version, store, audit, signing, publishers); + } + + private static void requireVersionTwoFields(Map actual) { + if (!actual.keySet().containsAll(ROOT_FIELDS_V1) || !ROOT_FIELDS_V2.containsAll(actual.keySet())) { + throw new IllegalArgumentException("CLI document fields are invalid"); + } + } + + private static PkiSessionConfiguration.SigningConfiguration signing(PkiOperationValue value) { + PkiOperationValue.ObjectValue object = object(value); + requireExactFields(object.fields(), SIGNING_FIELDS); + return new PkiSessionConfiguration.SigningConfiguration(provider(required(object, "workflow")), + provider(required(object, "framework")), text(required(object, "busPath")), + text(required(object, "signatureAlgorithm")), + Duration.ofSeconds(integer(required(object, "signingTtlSeconds"))), + java.util.Optional.of(text(required(object, "unlockEnvironmentVariable")))); } private static ProviderConfig provider(PkiOperationValue value) { @@ -92,6 +126,13 @@ final class PkiCliConfiguration { throw new IllegalArgumentException("CLI field has the wrong type"); } + private static PkiOperationValue.ListValue list(PkiOperationValue value) { + if (value instanceof PkiOperationValue.ListValue list) { + return list; + } + throw new IllegalArgumentException("CLI field has the wrong type"); + } + /* default */ static String text(PkiOperationValue value) { if (value instanceof PkiOperationValue.Text text) { return text.value(); diff --git a/app/src/main/java/zeroecho/pki/cli/PkiOperationRegistry.java b/app/src/main/java/zeroecho/pki/cli/PkiOperationRegistry.java index 39ea0b9..d60f223 100644 --- a/app/src/main/java/zeroecho/pki/cli/PkiOperationRegistry.java +++ b/app/src/main/java/zeroecho/pki/cli/PkiOperationRegistry.java @@ -39,9 +39,19 @@ import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; +import zeroecho.pki.api.EncodedObject; +import zeroecho.pki.api.Encoding; +import zeroecho.pki.api.FormatId; +import zeroecho.pki.api.KeyRef; import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.SubjectRef; +import zeroecho.pki.api.ca.CaState; import zeroecho.pki.api.profile.CertificateProfileDocumentCodec; +import zeroecho.pki.api.publication.PublicationSourceType; +import zeroecho.pki.api.publication.PublicationTarget; +import zeroecho.pki.api.publication.PublicationTargetType; import zeroecho.pki.api.revocation.RevocationReason; +import zeroecho.pki.api.status.StatusObjectType; import zeroecho.pki.application.PkiOperation; import zeroecho.pki.application.PkiOperationValue; @@ -50,6 +60,15 @@ import zeroecho.pki.application.PkiOperationValue; final class PkiOperationRegistry { private static final String CREDENTIAL_ID = "credentialId"; + private static final String FORMAT_ID = "formatId"; + private static final String ISSUER_CA_ID = "issuerCaId"; + private static final String LIMIT = "limit"; + private static final String PUBLICATION_ID = "publicationId"; + private static final String PROFILE_FILE = "profileFile"; + private static final String PROFILE_ID = "profileId"; + private static final String PROFILE_VERSION = "profileVersion"; + private static final String REQUEST_ID = "requestId"; + private static final int MAXIMUM_REQUEST_BYTES = 1024 * 1024; private final Map bindings; @@ -57,10 +76,32 @@ final class PkiOperationRegistry { Map configured = new LinkedHashMap<>(); add(configured, PkiOperation.ValidateConfiguration.NAME, this::configuration); add(configured, PkiOperation.ValidateProfile.NAME, this::profile); + add(configured, PkiOperation.RegisterProfile.NAME, this::registerProfile); + add(configured, PkiOperation.InspectProfile.NAME, this::inspectProfile); + add(configured, PkiOperation.ListProfileVersions.NAME, this::listProfiles); + add(configured, PkiOperation.ActivateProfile.NAME, this::activateProfile); + add(configured, PkiOperation.InspectAuthority.NAME, this::inspectAuthority); + add(configured, PkiOperation.ListAuthorities.NAME, this::listAuthorities); + add(configured, PkiOperation.CreateAuthority.NAME, this::createAuthority); + add(configured, PkiOperation.TransitionAuthority.NAME, this::transitionAuthority); + add(configured, PkiOperation.InspectRequest.NAME, this::inspectRequest); + add(configured, PkiOperation.ImportRequest.NAME, this::importRequest); + add(configured, PkiOperation.VerifyRequest.NAME, this::verifyRequest); add(configured, PkiOperation.InspectCredential.NAME, this::credential); + add(configured, PkiOperation.IssueCredential.NAME, this::issueCredential); add(configured, PkiOperation.RevokeCredential.NAME, this::revoke); + add(configured, PkiOperation.InspectRevocation.NAME, this::inspectRevocation); add(configured, PkiOperation.ReadRevocationHistory.NAME, this::history); + add(configured, PkiOperation.SnapshotRevocations.NAME, this::snapshotRevocations); + add(configured, PkiOperation.InspectStatus.NAME, this::inspectStatus); + add(configured, PkiOperation.ListStatus.NAME, this::listStatus); + add(configured, PkiOperation.GenerateStatus.NAME, this::generateStatus); add(configured, PkiOperation.InspectPublication.NAME, this::publication); + add(configured, PkiOperation.ListPublications.NAME, this::listPublications); + add(configured, PkiOperation.RegisterPublication.NAME, this::registerPublication); + add(configured, PkiOperation.ProcessPublication.NAME, this::processPublication); + add(configured, PkiOperation.RetryPublication.NAME, this::retryPublication); + add(configured, PkiOperation.ReconcilePublication.NAME, this::reconcilePublication); bindings = Map.copyOf(configured); } @@ -83,17 +124,88 @@ final class PkiOperationRegistry { } private PkiOperation profile(PkiOperationValue.ObjectValue arguments, Path baseDirectory) throws IOException { - requireFields(arguments, Set.of("profileFile")); - Path path = resolve(baseDirectory, text(arguments, "profileFile")); + requireFields(arguments, Set.of(PROFILE_FILE)); + Path path = resolve(baseDirectory, text(arguments, PROFILE_FILE)); byte[] document = PkiCliFiles.readRegularFile(path, CertificateProfileDocumentCodec.MAXIMUM_DOCUMENT_BYTES); return new PkiOperation.ValidateProfile(document); } + private PkiOperation registerProfile(PkiOperationValue.ObjectValue arguments, Path baseDirectory) + throws IOException { + requireFields(arguments, Set.of(PROFILE_FILE)); + Path path = resolve(baseDirectory, text(arguments, PROFILE_FILE)); + byte[] document = PkiCliFiles.readRegularFile(path, CertificateProfileDocumentCodec.MAXIMUM_DOCUMENT_BYTES); + return new PkiOperation.RegisterProfile(document); + } + + private PkiOperation inspectProfile(PkiOperationValue.ObjectValue arguments, Path baseDirectory) { + requireFields(arguments, Set.of(PROFILE_ID, PROFILE_VERSION)); + return new PkiOperation.InspectProfile(text(arguments, PROFILE_ID), integer(arguments, PROFILE_VERSION)); + } + + private PkiOperation listProfiles(PkiOperationValue.ObjectValue arguments, Path baseDirectory) { + requireFields(arguments, Set.of(PROFILE_ID)); + return new PkiOperation.ListProfileVersions(text(arguments, PROFILE_ID)); + } + + private PkiOperation activateProfile(PkiOperationValue.ObjectValue arguments, Path baseDirectory) { + requireFields(arguments, Set.of(PROFILE_ID, PROFILE_VERSION)); + return new PkiOperation.ActivateProfile(text(arguments, PROFILE_ID), integer(arguments, PROFILE_VERSION)); + } + + private PkiOperation inspectAuthority(PkiOperationValue.ObjectValue arguments, Path baseDirectory) { + requireFields(arguments, Set.of("caId")); + return new PkiOperation.InspectAuthority(new PkiId(text(arguments, "caId"))); + } + + private PkiOperation listAuthorities(PkiOperationValue.ObjectValue arguments, Path baseDirectory) { + requireFields(arguments, Set.of(LIMIT)); + return new PkiOperation.ListAuthorities(limit(arguments)); + } + + private PkiOperation createAuthority(PkiOperationValue.ObjectValue arguments, Path baseDirectory) { + requireFields(arguments, Set.of(FORMAT_ID, "subjectRef", PROFILE_ID, "keyRef")); + return new PkiOperation.CreateAuthority(new FormatId(text(arguments, FORMAT_ID)), + new SubjectRef(text(arguments, "subjectRef")), text(arguments, PROFILE_ID), + new KeyRef(text(arguments, "keyRef"))); + } + + private PkiOperation transitionAuthority(PkiOperationValue.ObjectValue arguments, Path baseDirectory) { + requireFields(arguments, Set.of("caId", "state", "reason")); + return new PkiOperation.TransitionAuthority(new PkiId(text(arguments, "caId")), + CaState.valueOf(text(arguments, "state")), text(arguments, "reason")); + } + + private PkiOperation inspectRequest(PkiOperationValue.ObjectValue arguments, Path baseDirectory) { + requireFields(arguments, Set.of(REQUEST_ID)); + return new PkiOperation.InspectRequest(new PkiId(text(arguments, REQUEST_ID))); + } + + private PkiOperation importRequest(PkiOperationValue.ObjectValue arguments, Path baseDirectory) + throws IOException { + requireFields(arguments, Set.of(FORMAT_ID, "encoding", "requestFile")); + Path path = resolve(baseDirectory, text(arguments, "requestFile")); + byte[] document = PkiCliFiles.readRegularFile(path, MAXIMUM_REQUEST_BYTES); + return new PkiOperation.ImportRequest(new FormatId(text(arguments, FORMAT_ID)), + new EncodedObject(Encoding.valueOf(text(arguments, "encoding")), document)); + } + + private PkiOperation verifyRequest(PkiOperationValue.ObjectValue arguments, Path baseDirectory) { + requireFields(arguments, Set.of(REQUEST_ID)); + return new PkiOperation.VerifyRequest(new PkiId(text(arguments, REQUEST_ID))); + } + private PkiOperation credential(PkiOperationValue.ObjectValue arguments, Path baseDirectory) { requireFields(arguments, Set.of(CREDENTIAL_ID)); return new PkiOperation.InspectCredential(new PkiId(text(arguments, CREDENTIAL_ID))); } + private PkiOperation issueCredential(PkiOperationValue.ObjectValue arguments, Path baseDirectory) { + requireFields(arguments, Set.of(ISSUER_CA_ID, REQUEST_ID, PROFILE_ID)); + return new PkiOperation.IssueCredential(new PkiId(text(arguments, ISSUER_CA_ID)), + new PkiId(text(arguments, REQUEST_ID)), text(arguments, PROFILE_ID)); + } + private PkiOperation revoke(PkiOperationValue.ObjectValue arguments, Path baseDirectory) { requireFields(arguments, Set.of(CREDENTIAL_ID, "reason")); RevocationReason reason = RevocationReason.valueOf(text(arguments, "reason")); @@ -101,14 +213,71 @@ final class PkiOperationRegistry { } private PkiOperation history(PkiOperationValue.ObjectValue arguments, Path baseDirectory) { - requireFields(arguments, Set.of(CREDENTIAL_ID, "limit")); - int limit = Math.toIntExact(integer(arguments, "limit")); - return new PkiOperation.ReadRevocationHistory(new PkiId(text(arguments, CREDENTIAL_ID)), limit); + requireFields(arguments, Set.of(CREDENTIAL_ID, LIMIT)); + return new PkiOperation.ReadRevocationHistory(new PkiId(text(arguments, CREDENTIAL_ID)), limit(arguments)); + } + + private PkiOperation inspectRevocation(PkiOperationValue.ObjectValue arguments, Path baseDirectory) { + requireFields(arguments, Set.of(CREDENTIAL_ID)); + return new PkiOperation.InspectRevocation(new PkiId(text(arguments, CREDENTIAL_ID))); + } + + private PkiOperation snapshotRevocations(PkiOperationValue.ObjectValue arguments, Path baseDirectory) { + requireFields(arguments, Set.of(LIMIT)); + return new PkiOperation.SnapshotRevocations(limit(arguments)); + } + + private PkiOperation inspectStatus(PkiOperationValue.ObjectValue arguments, Path baseDirectory) { + requireFields(arguments, Set.of("statusObjectId")); + return new PkiOperation.InspectStatus(new PkiId(text(arguments, "statusObjectId"))); + } + + private PkiOperation listStatus(PkiOperationValue.ObjectValue arguments, Path baseDirectory) { + requireFields(arguments, Set.of(ISSUER_CA_ID, LIMIT)); + return new PkiOperation.ListStatus(new PkiId(text(arguments, ISSUER_CA_ID)), limit(arguments)); + } + + private PkiOperation generateStatus(PkiOperationValue.ObjectValue arguments, Path baseDirectory) { + requireFields(arguments, Set.of(ISSUER_CA_ID, "type", FORMAT_ID)); + return new PkiOperation.GenerateStatus(new PkiId(text(arguments, ISSUER_CA_ID)), + StatusObjectType.valueOf(text(arguments, "type")), new FormatId(text(arguments, FORMAT_ID))); } private PkiOperation publication(PkiOperationValue.ObjectValue arguments, Path baseDirectory) { - requireFields(arguments, Set.of("publicationId")); - return new PkiOperation.InspectPublication(new PkiId(text(arguments, "publicationId"))); + requireFields(arguments, Set.of(PUBLICATION_ID)); + return new PkiOperation.InspectPublication(new PkiId(text(arguments, PUBLICATION_ID))); + } + + private PkiOperation listPublications(PkiOperationValue.ObjectValue arguments, Path baseDirectory) { + requireFields(arguments, Set.of(LIMIT)); + return new PkiOperation.ListPublications(limit(arguments)); + } + + private PkiOperation registerPublication(PkiOperationValue.ObjectValue arguments, Path baseDirectory) { + requireFields(arguments, + Set.of(PUBLICATION_ID, "sourceType", "sourceId", "targetType", "targetId")); + PublicationTarget target = new PublicationTarget(PublicationTargetType.valueOf(text(arguments, "targetType")), + text(arguments, "targetId")); + return new PkiOperation.RegisterPublication(new PkiId(text(arguments, PUBLICATION_ID)), + PublicationSourceType.valueOf(text(arguments, "sourceType")), + new PkiId(text(arguments, "sourceId")), target); + } + + private PkiOperation processPublication(PkiOperationValue.ObjectValue arguments, Path baseDirectory) { + return new PkiOperation.ProcessPublication(publicationId(arguments)); + } + + private PkiOperation retryPublication(PkiOperationValue.ObjectValue arguments, Path baseDirectory) { + return new PkiOperation.RetryPublication(publicationId(arguments)); + } + + private PkiOperation reconcilePublication(PkiOperationValue.ObjectValue arguments, Path baseDirectory) { + return new PkiOperation.ReconcilePublication(publicationId(arguments)); + } + + private static PkiId publicationId(PkiOperationValue.ObjectValue arguments) { + requireFields(arguments, Set.of(PUBLICATION_ID)); + return new PkiId(text(arguments, PUBLICATION_ID)); } private static void add(Map target, String name, Binding binding) { @@ -131,6 +300,10 @@ final class PkiOperationRegistry { return PkiCliConfiguration.integer(PkiCliConfiguration.required(arguments, name)); } + private static int limit(PkiOperationValue.ObjectValue arguments) { + return Math.toIntExact(integer(arguments, LIMIT)); + } + private static Path resolve(Path baseDirectory, String value) { Path supplied = Path.of(value); return (supplied.isAbsolute() ? supplied : baseDirectory.resolve(supplied)).toAbsolutePath().normalize(); diff --git a/app/src/test/java/zeroecho/pki/cli/PkiCliTest.java b/app/src/test/java/zeroecho/pki/cli/PkiCliTest.java index f71d2ae..448681d 100644 --- a/app/src/test/java/zeroecho/pki/cli/PkiCliTest.java +++ b/app/src/test/java/zeroecho/pki/cli/PkiCliTest.java @@ -51,6 +51,8 @@ import org.junit.jupiter.api.io.TempDir; import zeroecho.pki.api.ProfileService; import zeroecho.pki.api.RevocationService; +import zeroecho.pki.api.profile.BuiltInCertificateProfileCatalog; +import zeroecho.pki.api.profile.BuiltInCertificateProfileTemplate; import zeroecho.pki.application.PkiOperation; import zeroecho.pki.application.PkiOperationFailure; import zeroecho.pki.application.PkiOperationOutcome; @@ -64,6 +66,88 @@ class PkiCliTest { @TempDir Path temporaryDirectory; + @Test + void helpListsCompletedSafeAdministrationFamilies() { + System.out.println("helpListsCompletedSafeAdministrationFamilies"); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + int code = PkiCli.execute(new String[] { "--help" }, output); + String help = output.toString(StandardCharsets.UTF_8); + System.out.println("...helpBytes=" + help.length()); + assertEquals(PkiExitCodes.SUCCESS, code); + assertTrue(help.contains("profile.register")); + assertTrue(help.contains("ca.list")); + assertTrue(help.contains("request.inspect")); + assertTrue(help.contains("revocation.snapshot")); + assertTrue(help.contains("inventory.status")); + assertTrue(help.contains("publication.list")); + assertTrue(help.contains("ca.create")); + assertTrue(help.contains("credential.issue")); + assertTrue(help.contains("status.generate")); + assertTrue(help.contains("publication.process")); + System.out.println("...ok"); + } + + @Test + void signingAndPublicationFamiliesUseOneTypedBatchSession() throws IOException { + System.out.println("signingAndPublicationFamiliesUseOneTypedBatchSession"); + Path configuration = configuration("capability-workflow"); + Path request = temporaryDirectory.resolve("request.der"); + Files.write(request, new byte[] { 0x30, 0x00 }); + Path workflow = plan("capability-workflow.json", """ + {"version":1,"failurePolicy":"FAIL_FAST","operations":[ + {"id":"ca","operation":"ca.create","arguments":{"formatId":"x509","subjectRef":"CN=Root","profileId":"root-ca","keyRef":"managed:root.prv"}}, + {"id":"request","operation":"request.import","arguments":{"formatId":"x509","encoding":"DER","requestFile":"request.der"}}, + {"id":"issue","operation":"credential.issue","arguments":{"issuerCaId":"${ca.caId}","requestId":"${request.requestId}","profileId":"server"}}, + {"id":"publish","operation":"publication.register","arguments":{"publicationId":"publication-1","sourceType":"CREDENTIAL","sourceId":"${issue.credentialId}","targetType":"FILESYSTEM","targetId":"local"}}, + {"id":"process","operation":"publication.process","arguments":{"publicationId":"${publish.publicationId}"}} + ]} + """); + RecordingOpener opener = new RecordingOpener(); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + int code = PkiCli.execute(new String[] { "run", workflow.toString(), "--config", configuration.toString(), + "--output", "json" }, output, opener, () -> false); + System.out.println("...operations=" + opener.operationTypes.size()); + assertEquals(PkiExitCodes.SUCCESS, code); + assertEquals(1, opener.sessions.get()); + assertEquals(List.of(PkiOperation.CreateAuthority.class, PkiOperation.ImportRequest.class, + PkiOperation.IssueCredential.class, PkiOperation.RegisterPublication.class, + PkiOperation.ProcessPublication.class), opener.operationTypes); + assertTrue(output.toString(StandardCharsets.UTF_8).contains("\"operation\":\"publication.process\"")); + System.out.println("...ok"); + } + + @Test + void productionProfileWorkflowUsesTypedOperationsAndDurableStore() throws IOException { + System.out.println("productionProfileWorkflowUsesTypedOperationsAndDurableStore"); + Path configuration = configuration("profile-workflow"); + BuiltInCertificateProfileTemplate template = BuiltInCertificateProfileCatalog + .load(getClass().getClassLoader()).get(0); + Path profile = temporaryDirectory.resolve("profile.json"); + Files.write(profile, template.canonicalJson()); + ByteArrayOutputStream registered = new ByteArrayOutputStream(); + int registerCode = PkiCli.execute(new String[] { "profile.register", "--profile-file", profile.toString(), + "--config", configuration.toString(), "--output", "json" }, registered); + ByteArrayOutputStream activated = new ByteArrayOutputStream(); + int activateCode = PkiCli.execute(new String[] { "profile.activate", "--profile-id", + template.definition().profileId(), "--profile-version", + Long.toString(template.definition().profileVersion()), "--config", configuration.toString(), + "--output", "json" }, activated); + ByteArrayOutputStream inspected = new ByteArrayOutputStream(); + int inspectCode = PkiCli.execute(new String[] { "profile.inspect", "--profile-id", + template.definition().profileId(), "--profile-version", + Long.toString(template.definition().profileVersion()), "--config", configuration.toString(), + "--output", "json" }, inspected); + String rendered = registered.toString(StandardCharsets.UTF_8) + + activated.toString(StandardCharsets.UTF_8) + inspected.toString(StandardCharsets.UTF_8); + System.out.println("...profileId=" + template.definition().profileId()); + assertEquals(PkiExitCodes.SUCCESS, registerCode); + assertEquals(PkiExitCodes.SUCCESS, activateCode); + assertEquals(PkiExitCodes.SUCCESS, inspectCode); + assertTrue(rendered.contains("\"profileVersion\":" + template.definition().profileVersion())); + assertFalse(rendered.contains("canonicalJson")); + System.out.println("...ok"); + } + @Test void productionSessionExecutesMachineAndHumanSingleCommands() throws IOException { System.out.println("productionSessionExecutesMachineAndHumanSingleCommands"); @@ -84,6 +168,27 @@ class PkiCliTest { System.out.println("...ok"); } + @Test + void versionTwoConfigurationKeepsCapabilitiesExplicit() throws IOException { + System.out.println("versionTwoConfigurationKeepsCapabilitiesExplicit"); + Path configuration = temporaryDirectory.resolve("capabilities.json"); + Files.writeString(configuration, """ + {"version":2, + "store":{"provider":"fs","properties":{"root":"store"}}, + "audit":{"provider":"memory","properties":{"size":"16"}}, + "signing":{"workflow":{"provider":"zeroecho-lib","properties":{"keyringPath":"keys.zek","operationRoot":"signing-operations"}}, + "framework":{"provider":"x509-bc","properties":{}}, + "busPath":"signing-bus","signatureAlgorithm":"SHA256withRSA","signingTtlSeconds":30, + "unlockEnvironmentVariable":"ZEROECHO_TEST_UNLOCK"}, + "publishers":[{"provider":"filesystem","properties":{"root":"published","targetId":"local"}}]} + """); + PkiSessionConfiguration parsed = PkiCliConfiguration.read(configuration); + System.out.println("...publishers=" + parsed.publishers().size()); + assertTrue(parsed.signing().isPresent()); + assertEquals(1, parsed.publishers().size()); + System.out.println("...ok"); + } + @Test void singleAndBatchUseTheSameTypedExecutorAndSessionLifecycle() throws IOException { System.out.println("singleAndBatchUseTheSameTypedExecutorAndSessionLifecycle"); @@ -128,6 +233,22 @@ class PkiCliTest { System.out.println("...ok"); } + @Test + void directSigningDependentCommandUsesTheSharedTypedExecutor() throws IOException { + System.out.println("directSigningDependentCommandUsesTheSharedTypedExecutor"); + Path configuration = configuration("direct-ca"); + RecordingOpener opener = new RecordingOpener(); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + int code = PkiCli.execute(new String[] { "ca.create", "--format-id", "x509", "--subject-ref", + "CN=Root", "--profile-id", "root-ca", "--key-ref", "managed:root.prv", "--config", + configuration.toString(), "--output", "json" }, output, opener, () -> false); + System.out.println("...exit=" + code); + assertEquals(PkiExitCodes.SUCCESS, code); + assertEquals(List.of(PkiOperation.CreateAuthority.class), opener.operationTypes); + assertTrue(output.toString(StandardCharsets.UTF_8).contains("\"caId\":\"ca-1\"")); + System.out.println("...ok"); + } + @Test void resolvesEarlierFieldsAndContinuesOnlyIndependentSteps() throws IOException { System.out.println("resolvesEarlierFieldsAndContinuesOnlyIndependentSteps"); @@ -302,6 +423,17 @@ class PkiCliTest { } else if (operation instanceof PkiOperation.RevokeCredential revoke) { fields.put("credentialId", new PkiOperationValue.Text(revoke.credentialId().value())); fields.put("state", new PkiOperationValue.Text("PERMANENTLY_REVOKED")); + } else if (operation instanceof PkiOperation.CreateAuthority) { + fields.put("caId", new PkiOperationValue.Text("ca-1")); + } else if (operation instanceof PkiOperation.ImportRequest) { + fields.put("requestId", new PkiOperationValue.Text("request-1")); + } else if (operation instanceof PkiOperation.IssueCredential) { + fields.put("credentialId", new PkiOperationValue.Text("credential-1")); + } else if (operation instanceof PkiOperation.RegisterPublication register) { + fields.put("publicationId", new PkiOperationValue.Text(register.publicationId().value())); + } else if (operation instanceof PkiOperation.ProcessPublication process) { + fields.put("publicationId", new PkiOperationValue.Text(process.publicationId().value())); + fields.put("status", new PkiOperationValue.Text("SUCCEEDED")); } else { fields.put("status", new PkiOperationValue.Text("OK")); } diff --git a/app/src/test/resources/pki-administration-config.json b/app/src/test/resources/pki-administration-config.json new file mode 100644 index 0000000..9787fbc --- /dev/null +++ b/app/src/test/resources/pki-administration-config.json @@ -0,0 +1,24 @@ +{ + "version": 2, + "store": { + "provider": "fs", + "properties": { + "root": "build/packaged-cli-store" + } + }, + "audit": { + "provider": "memory", + "properties": { + "size": "64" + } + }, + "publishers": [ + { + "provider": "filesystem", + "properties": { + "root": "build/packaged-cli-publication", + "targetId": "local-artifacts" + } + } + ] +} diff --git a/app/src/test/resources/pki-administration-plan.json b/app/src/test/resources/pki-administration-plan.json new file mode 100644 index 0000000..e1eee8c --- /dev/null +++ b/app/src/test/resources/pki-administration-plan.json @@ -0,0 +1,18 @@ +{ + "version": 1, + "failurePolicy": "FAIL_FAST", + "operations": [ + { + "id": "validate-configuration", + "operation": "configuration.validate", + "arguments": {} + }, + { + "id": "list-publications", + "operation": "publication.list", + "arguments": { + "limit": 25 + } + } + ] +} diff --git a/pki/src/main/java/zeroecho/pki/application/DefaultPkiOperationExecutor.java b/pki/src/main/java/zeroecho/pki/application/DefaultPkiOperationExecutor.java index 5b6182e..d92f6b2 100644 --- a/pki/src/main/java/zeroecho/pki/application/DefaultPkiOperationExecutor.java +++ b/pki/src/main/java/zeroecho/pki/application/DefaultPkiOperationExecutor.java @@ -44,34 +44,85 @@ import java.util.Optional; import zeroecho.core.io.CancellationSignal; import zeroecho.pki.api.PkiException; +import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.ca.CaCreateCommand; +import zeroecho.pki.api.CaService; +import zeroecho.pki.api.CertificationRequestService; +import zeroecho.pki.api.IssuanceService; +import zeroecho.pki.api.PublicationService; +import zeroecho.pki.api.ProfileService; import zeroecho.pki.api.RevocationService; +import zeroecho.pki.api.StatusObjectService; +import zeroecho.pki.api.ca.CaRecord; import zeroecho.pki.api.credential.CaProfileBinding; import zeroecho.pki.api.credential.Credential; +import zeroecho.pki.api.credential.CredentialBundle; import zeroecho.pki.api.credential.EndEntityProfileBinding; import zeroecho.pki.api.profile.CertificateProfileDefinition; import zeroecho.pki.api.profile.CertificateProfileDocumentCodec; import zeroecho.pki.api.profile.CertificateProfileRef; +import zeroecho.pki.api.profile.ImportedCertificateProfileVersion; +import zeroecho.pki.api.issuance.IssueEndEntityCommand; +import zeroecho.pki.api.issuance.VerificationPolicy; +import zeroecho.pki.api.publication.PublicationRequest; +import zeroecho.pki.api.publication.PublicationResult; +import zeroecho.pki.api.publication.PublicationStatus; +import zeroecho.pki.api.publication.PublicationCursor; import zeroecho.pki.api.publication.PublicationRecord; +import zeroecho.pki.api.request.CertificationRequest; +import zeroecho.pki.api.request.ParsedCertificationRequest; +import zeroecho.pki.api.request.ProofOfPossessionResult; +import zeroecho.pki.api.request.RequestStorePolicy; import zeroecho.pki.api.revocation.RevocationCommand; import zeroecho.pki.api.revocation.RevocationRecord; +import zeroecho.pki.api.revocation.RevocationQuery; import zeroecho.pki.api.revocation.RevocationTransition; +import zeroecho.pki.api.status.StatusObject; +import zeroecho.pki.api.status.StatusObjectGenerateCommand; import zeroecho.pki.impl.core.attr.SimpleAttributeSet; import zeroecho.pki.spi.store.PkiStore; import zeroecho.pki.spi.store.RevocationHistory; +import zeroecho.pki.spi.store.RevocationSnapshot; /** Explicit non-reflective executor used by one backend session. */ +@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.CyclomaticComplexity", "PMD.ExcessiveParameterList", + "PMD.TooManyMethods" }) final class DefaultPkiOperationExecutor implements PkiOperationExecutor { + private static final String COUNT = "count"; + private static final String FORMAT_ID = "formatId"; + private final PkiSessionConfiguration configuration; private final PkiStore store; + private final ProfileService profiles; private final RevocationService revocations; + private final Optional authorities; + private final Optional requests; + private final Optional issuance; + private final Optional statusObjects; + private final Optional publications; private final Runnable openCheck; /* default */ DefaultPkiOperationExecutor(PkiSessionConfiguration configuration, PkiStore store, - RevocationService revocations, Runnable openCheck) { + ProfileService profiles, RevocationService revocations, Runnable openCheck) { + this(configuration, store, profiles, revocations, Optional.empty(), Optional.empty(), Optional.empty(), + Optional.empty(), Optional.empty(), openCheck); + } + + /* default */ DefaultPkiOperationExecutor(PkiSessionConfiguration configuration, PkiStore store, + ProfileService profiles, RevocationService revocations, Optional authorities, + Optional requests, Optional issuance, + Optional statusObjects, Optional publications, + Runnable openCheck) { this.configuration = Objects.requireNonNull(configuration, "configuration"); this.store = Objects.requireNonNull(store, "store"); + this.profiles = Objects.requireNonNull(profiles, "profiles"); this.revocations = Objects.requireNonNull(revocations, "revocations"); + this.authorities = Objects.requireNonNull(authorities, "authorities"); + this.requests = Objects.requireNonNull(requests, "requests"); + this.issuance = Objects.requireNonNull(issuance, "issuance"); + this.statusObjects = Objects.requireNonNull(statusObjects, "statusObjects"); + this.publications = Objects.requireNonNull(publications, "publications"); this.openCheck = Objects.requireNonNull(openCheck, "openCheck"); } @@ -85,10 +136,32 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor { PkiOperationResult result = switch (exact) { case PkiOperation.ValidateConfiguration ignored -> validateConfiguration(); case PkiOperation.ValidateProfile request -> validateProfile(request); + case PkiOperation.RegisterProfile request -> registerProfile(request); + case PkiOperation.InspectProfile request -> inspectProfile(request); + case PkiOperation.ListProfileVersions request -> listProfileVersions(request); + case PkiOperation.ActivateProfile request -> activateProfile(request); + case PkiOperation.InspectAuthority request -> inspectAuthority(request); + case PkiOperation.ListAuthorities request -> listAuthorities(request); + case PkiOperation.CreateAuthority request -> createAuthority(request); + case PkiOperation.TransitionAuthority request -> transitionAuthority(request); + case PkiOperation.InspectRequest request -> inspectRequest(request); + case PkiOperation.ImportRequest request -> importRequest(request); + case PkiOperation.VerifyRequest request -> verifyRequest(request); case PkiOperation.InspectCredential request -> inspectCredential(request); + case PkiOperation.IssueCredential request -> issueCredential(request); case PkiOperation.RevokeCredential request -> revokeCredential(request); + case PkiOperation.InspectRevocation request -> inspectRevocation(request); case PkiOperation.ReadRevocationHistory request -> readHistory(request, signal); + case PkiOperation.SnapshotRevocations request -> snapshotRevocations(request, signal); + case PkiOperation.InspectStatus request -> inspectStatus(request); + case PkiOperation.ListStatus request -> listStatus(request); + case PkiOperation.GenerateStatus request -> generateStatus(request); case PkiOperation.InspectPublication request -> inspectPublication(request); + case PkiOperation.ListPublications request -> listPublications(request, signal); + case PkiOperation.RegisterPublication request -> registerPublication(request); + case PkiOperation.ProcessPublication request -> processPublication(request); + case PkiOperation.RetryPublication request -> retryPublication(request); + case PkiOperation.ReconcilePublication request -> reconcilePublication(request); }; return new PkiOperationOutcome.Success(result); } catch (InterruptedIOException failure) { @@ -114,7 +187,101 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor { fields.put("profileId", text(definition.profileId())); fields.put("profileVersion", integer(definition.profileVersion())); fields.put("certificateType", text(definition.certificateType().name())); - fields.put("formatId", text(definition.formatId().value())); + fields.put(FORMAT_ID, text(definition.formatId().value())); + return result(request.name(), fields); + } + + private PkiOperationResult registerProfile(PkiOperation.RegisterProfile request) { + CertificateProfileRef reference = profiles.importProfile(request.document()); + return profileReferenceResult(request.name(), reference); + } + + private PkiOperationResult inspectProfile(PkiOperation.InspectProfile request) { + ImportedCertificateProfileVersion version = profiles + .getImportedVersion(request.profileId(), request.profileVersion()) + .orElseThrow(MissingObjectException::new); + return profileVersionResult(request.name(), version); + } + + private PkiOperationResult listProfileVersions(PkiOperation.ListProfileVersions request) { + List versions = profiles.listImportedVersions(request.profileId()).stream() + .map(DefaultPkiOperationExecutor::profileVersionValue).toList(); + Map fields = fields(); + fields.put("profileId", text(request.profileId())); + fields.put(COUNT, integer(versions.size())); + profiles.getActiveReference(request.profileId()) + .ifPresent(reference -> fields.put("activeVersion", integer(reference.profileVersion()))); + fields.put("versions", new PkiOperationValue.ListValue(versions)); + return result(request.name(), fields); + } + + private PkiOperationResult activateProfile(PkiOperation.ActivateProfile request) { + CertificateProfileRef reference = profiles.activateProfile(request.profileId(), request.profileVersion()); + return profileReferenceResult(request.name(), reference); + } + + private PkiOperationResult inspectAuthority(PkiOperation.InspectAuthority request) { + return authorityResult(request.name(), store.getCa(request.caId()).orElseThrow(MissingObjectException::new)); + } + + private PkiOperationResult listAuthorities(PkiOperation.ListAuthorities request) { + List records = store.listCas(); + int count = Math.min(records.size(), request.limit()); + List values = records.subList(0, count).stream() + .map(DefaultPkiOperationExecutor::authorityValue).toList(); + Map fields = fields(); + fields.put(COUNT, integer(count)); + fields.put("truncated", bool(records.size() > count)); + fields.put("authorities", new PkiOperationValue.ListValue(values)); + return result(request.name(), fields); + } + + private PkiOperationResult createAuthority(PkiOperation.CreateAuthority request) { + CaCreateCommand command = new CaCreateCommand(request.formatId(), request.subjectRef(), request.profileId(), + Optional.of(request.keyRef()), new SimpleAttributeSet()); + PkiId caId = requireCapability(authorities, "authority").createRoot(command); + Map fields = fields(); + fields.put("caId", text(caId.value())); + fields.put("state", text(store.getCa(caId).orElseThrow(MissingObjectException::new).state().name())); + return result(request.name(), fields); + } + + private PkiOperationResult transitionAuthority(PkiOperation.TransitionAuthority request) { + requireCapability(authorities, "authority").setCaState(request.caId(), request.state(), request.reason()); + return authorityResult(request.name(), store.getCa(request.caId()).orElseThrow(MissingObjectException::new)); + } + + private PkiOperationResult inspectRequest(PkiOperation.InspectRequest request) { + ParsedCertificationRequest parsed = store.getRequest(request.requestId()) + .orElseThrow(MissingObjectException::new); + Map fields = fields(); + fields.put("requestId", text(parsed.requestId().value())); + fields.put(FORMAT_ID, text(parsed.formatId().value())); + fields.put("publicKeyBytes", integer(parsed.publicKeyInfo().bytes().length)); + parsed.requestedProfileId().ifPresent(value -> fields.put("requestedProfileId", text(value))); + fields.put("subjectAlternativeNameCount", integer(parsed.subjectAlternativeNames().size())); + return result(request.name(), fields); + } + + private PkiOperationResult importRequest(PkiOperation.ImportRequest request) { + CertificationRequest encoded = new CertificationRequest(request.formatId(), request.encoded()); + CertificationRequestService service = requireCapability(requests, "certification-request"); + ParsedCertificationRequest parsed = service.parse(encoded); + PkiId requestId = service.store(parsed, RequestStorePolicy.STORE_ALWAYS); + Map fields = fields(); + fields.put("requestId", text(requestId.value())); + fields.put(FORMAT_ID, text(parsed.formatId().value())); + return result(request.name(), fields); + } + + private PkiOperationResult verifyRequest(PkiOperation.VerifyRequest request) { + CertificationRequestService service = requireCapability(requests, "certification-request"); + ParsedCertificationRequest parsed = service.get(request.requestId()).orElseThrow(MissingObjectException::new); + ProofOfPossessionResult proof = service.verifyProofOfPossession(parsed, + new VerificationPolicy(true, Optional.empty())); + Map fields = fields(); + fields.put("requestId", text(request.requestId().value())); + fields.put("proofStatus", text(proof.status().name())); return result(request.name(), fields); } @@ -124,7 +291,7 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor { CertificateProfileRef profile = profileReference(credential); Map fields = fields(); fields.put("credentialId", text(credential.credentialId().value())); - fields.put("formatId", text(credential.formatId().value())); + fields.put(FORMAT_ID, text(credential.formatId().value())); fields.put("issuerId", text(credential.issuerRef().caId().value())); fields.put("publicKeyId", text(credential.publicKeyId().value())); fields.put("profileId", text(profile.profileId())); @@ -135,6 +302,19 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor { return result(request.name(), fields); } + private PkiOperationResult issueCredential(PkiOperation.IssueCredential request) { + ParsedCertificationRequest parsed = requireCapability(requests, "certification-request") + .get(request.requestId()).orElseThrow(MissingObjectException::new); + IssueEndEntityCommand command = new IssueEndEntityCommand(request.issuerCaId(), parsed, request.profileId(), + Optional.empty()); + CredentialBundle bundle = requireCapability(issuance, "issuance").issueEndEntity(command); + Map fields = fields(); + fields.put("credentialId", text(bundle.credential().credentialId().value())); + fields.put("issuerCaId", text(bundle.credential().issuerRef().caId().value())); + fields.put("supportingObjectCount", integer(bundle.supportingObjects().size())); + return result(request.name(), fields); + } + private PkiOperationResult revokeCredential(PkiOperation.RevokeCredential request) { RevocationCommand.RevokePermanently command = new RevocationCommand.RevokePermanently(request.credentialId(), request.reason(), new SimpleAttributeSet()); @@ -142,6 +322,11 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor { return revocationResult(request.name(), record); } + private PkiOperationResult inspectRevocation(PkiOperation.InspectRevocation request) { + RevocationRecord record = revocations.get(request.credentialId()).orElseThrow(MissingObjectException::new); + return revocationResult(request.name(), record); + } + private PkiOperationResult readHistory(PkiOperation.ReadRevocationHistory request, CancellationSignal signal) throws IOException { List transitions = new ArrayList<>(); @@ -154,7 +339,7 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor { } Map fields = fields(); fields.put("credentialId", text(request.credentialId().value())); - fields.put("count", integer(transitions.size())); + fields.put(COUNT, integer(transitions.size())); fields.put("truncated", bool(truncated)); fields.put("transitions", new PkiOperationValue.ListValue(transitions)); return result(request.name(), fields); @@ -176,6 +361,120 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor { return result(request.name(), fields); } + private PkiOperationResult snapshotRevocations(PkiOperation.SnapshotRevocations request, + CancellationSignal signal) throws IOException { + List records = new ArrayList<>(); + boolean truncated; + Map fields = fields(); + RevocationQuery query = new RevocationQuery(Optional.empty(), Optional.empty(), Optional.empty(), + Optional.empty()); + try (RevocationSnapshot snapshot = revocations.search(query); + RevocationSnapshot.Cursor cursor = snapshot.openCursor()) { + while (records.size() < request.limit() && cursor.next(signal)) { + records.add(revocationValue(cursor.current())); + } + truncated = records.size() == request.limit() && cursor.next(signal); + fields.put("snapshotId", text(snapshot.snapshotId())); + fields.put("revision", integer(snapshot.revision())); + fields.put("boundary", integer(snapshot.boundary())); + fields.put("commitment", text(snapshot.commitment())); + } + fields.put(COUNT, integer(records.size())); + fields.put("truncated", bool(truncated)); + fields.put("records", new PkiOperationValue.ListValue(records)); + return result(request.name(), fields); + } + + private PkiOperationResult inspectStatus(PkiOperation.InspectStatus request) { + StatusObject object = store.getStatusObject(request.statusObjectId()) + .orElseThrow(MissingObjectException::new); + return statusResult(request.name(), object); + } + + private PkiOperationResult generateStatus(PkiOperation.GenerateStatus request) { + StatusObjectGenerateCommand command = new StatusObjectGenerateCommand(request.issuerCaId(), request.type(), + request.formatId(), new SimpleAttributeSet()); + StatusObject object = requireCapability(statusObjects, "status-object").generate(command); + return statusResult(request.name(), object); + } + + private PkiOperationResult listStatus(PkiOperation.ListStatus request) { + List records = store.listStatusObjects(request.issuerCaId()); + int count = Math.min(records.size(), request.limit()); + List values = records.subList(0, count).stream() + .map(DefaultPkiOperationExecutor::statusValue).toList(); + Map fields = fields(); + fields.put("issuerCaId", text(request.issuerCaId().value())); + fields.put(COUNT, integer(count)); + fields.put("truncated", bool(records.size() > count)); + fields.put("statusObjects", new PkiOperationValue.ListValue(values)); + return result(request.name(), fields); + } + + private PkiOperationResult listPublications(PkiOperation.ListPublications request, CancellationSignal signal) + throws IOException { + List records = new ArrayList<>(); + boolean truncated; + try (PublicationCursor cursor = store.openPublicationRecords()) { + Optional next = cursor.next(signal); + while (records.size() < request.limit() && next.isPresent()) { + records.add(publicationValue(next.orElseThrow())); + next = cursor.next(signal); + } + truncated = next.isPresent(); + } + Map fields = fields(); + fields.put(COUNT, integer(records.size())); + fields.put("truncated", bool(truncated)); + fields.put("publications", new PkiOperationValue.ListValue(records)); + return result(request.name(), fields); + } + + private PkiOperationResult registerPublication(PkiOperation.RegisterPublication request) { + PublicationRequest registration = new PublicationRequest(request.publicationId(), request.sourceType(), + request.sourceId(), request.target()); + PublicationRecord record = requireCapability(publications, "publication").register(registration); + return publicationRecordResult(request.name(), record); + } + + private PkiOperationResult processPublication(PkiOperation.ProcessPublication request) { + return publicationResult(request.name(), + requireCapability(publications, "publication").process(request.publicationId())); + } + + private PkiOperationResult retryPublication(PkiOperation.RetryPublication request) { + return publicationResult(request.name(), + requireCapability(publications, "publication").retry(request.publicationId())); + } + + private PkiOperationResult reconcilePublication(PkiOperation.ReconcilePublication request) { + return publicationResult(request.name(), + requireCapability(publications, "publication").reconcile(request.publicationId())); + } + + private static PkiOperationResult publicationResult(String operation, PublicationResult publication) { + if (publication.status() == PublicationStatus.OUTCOME_UNKNOWN) { + throw new ExternalOutcomeUnknownException(); + } + Map fields = fields(); + fields.put("publicationId", text(publication.publicationId().value())); + fields.put("status", text(publication.status().name())); + fields.put("attemptNumber", integer(publication.attemptNumber())); + return result(operation, fields); + } + + private static PkiOperationResult publicationRecordResult(String operation, PublicationRecord publication) { + Map fields = fields(); + fields.put("publicationId", text(publication.publicationId().value())); + fields.put("status", text(publication.status().name())); + fields.put("attemptNumber", integer(publication.attemptNumber())); + return result(operation, fields); + } + + private static T requireCapability(Optional service, String capability) { + return service.orElseThrow(() -> new CapabilityUnavailableException(capability)); + } + private static PkiOperationResult revocationResult(String operation, RevocationRecord record) { Map fields = fields(); fields.put("credentialId", text(record.credentialId().value())); @@ -183,6 +482,95 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor { return result(operation, fields); } + private static PkiOperationResult profileReferenceResult(String operation, CertificateProfileRef reference) { + Map fields = fields(); + appendProfileReference(fields, reference); + return result(operation, fields); + } + + private static PkiOperationResult profileVersionResult(String operation, + ImportedCertificateProfileVersion version) { + Map fields = fields(); + appendProfileVersion(fields, version); + return result(operation, fields); + } + + private static PkiOperationValue profileVersionValue(ImportedCertificateProfileVersion version) { + Map fields = fields(); + appendProfileVersion(fields, version); + return new PkiOperationValue.ObjectValue(fields); + } + + private static void appendProfileVersion(Map fields, + ImportedCertificateProfileVersion version) { + appendProfileReference(fields, version.reference()); + fields.put("certificateType", text(version.definition().certificateType().name())); + fields.put(FORMAT_ID, text(version.definition().formatId().value())); + fields.put("importedAt", text(version.importedAt().toString())); + } + + private static void appendProfileReference(Map fields, + CertificateProfileRef reference) { + fields.put("profileId", text(reference.profileId())); + fields.put("profileVersion", integer(reference.profileVersion())); + fields.put("fingerprint", text(reference.shortFingerprint())); + } + + private static PkiOperationResult authorityResult(String operation, CaRecord record) { + return result(operation, authorityFields(record)); + } + + private static PkiOperationValue authorityValue(CaRecord record) { + return new PkiOperationValue.ObjectValue(authorityFields(record)); + } + + private static Map authorityFields(CaRecord record) { + Map fields = fields(); + fields.put("caId", text(record.caId().value())); + fields.put("kind", text(record.kind().name())); + fields.put("state", text(record.state().name())); + fields.put("credentialCount", integer(record.credentialIds().size())); + return fields; + } + + private static PkiOperationValue revocationValue(RevocationRecord record) { + Map fields = fields(); + fields.put("credentialId", text(record.credentialId().value())); + appendTransition(fields, record.transition()); + return new PkiOperationValue.ObjectValue(fields); + } + + private static PkiOperationResult statusResult(String operation, StatusObject object) { + return result(operation, statusFields(object)); + } + + private static PkiOperationValue statusValue(StatusObject object) { + return new PkiOperationValue.ObjectValue(statusFields(object)); + } + + private static Map statusFields(StatusObject object) { + Map fields = fields(); + fields.put("statusObjectId", text(object.statusObjectId().value())); + fields.put("issuerCaId", text(object.issuerCaId().value())); + fields.put("type", text(object.type().name())); + fields.put(FORMAT_ID, text(object.formatId().value())); + fields.put("thisUpdate", text(object.thisUpdate().toString())); + object.nextUpdate().ifPresent(value -> fields.put("nextUpdate", text(value.toString()))); + fields.put("contentLength", integer(object.content().length())); + fields.put("contentSha256", text(object.content().sha256())); + return fields; + } + + private static PkiOperationValue publicationValue(PublicationRecord record) { + Map fields = fields(); + fields.put("publicationId", text(record.publicationId().value())); + fields.put("sourceType", text(record.sourceType().name())); + fields.put("sourceId", text(record.sourceId().value())); + fields.put("status", text(record.status().name())); + fields.put("attemptNumber", integer(record.attemptNumber())); + return new PkiOperationValue.ObjectValue(fields); + } + private static PkiOperationValue transitionValue(RevocationTransition transition) { Map fields = fields(); appendTransition(fields, transition); @@ -207,6 +595,12 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor { } private static PkiOperationOutcome classify(String operation, RuntimeException failure) { + if (failure instanceof CapabilityUnavailableException) { + return failure(operation, PkiOperationFailure.VALIDATION_FAILURE, "CAPABILITY_NOT_CONFIGURED"); + } + if (failure instanceof ExternalOutcomeUnknownException) { + return failure(operation, PkiOperationFailure.EXTERNAL_OUTCOME_UNKNOWN, "EXTERNAL_OUTCOME_UNKNOWN"); + } if (failure instanceof MissingObjectException) { return failure(operation, PkiOperationFailure.NOT_FOUND, "OBJECT_NOT_FOUND"); } @@ -220,6 +614,10 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor { if (message.contains("CREDENTIAL_NOT_FOUND")) { return failure(operation, PkiOperationFailure.NOT_FOUND, "OBJECT_NOT_FOUND"); } + String lowerMessage = message.toLowerCase(java.util.Locale.ROOT); + if (lowerMessage.contains("not found") || lowerMessage.contains("does not exist")) { + return failure(operation, PkiOperationFailure.NOT_FOUND, "OBJECT_NOT_FOUND"); + } if (message.contains("CONFLICT") || message.contains("changed concurrently")) { return failure(operation, PkiOperationFailure.CONFLICT, "OPERATION_CONFLICT"); } @@ -266,4 +664,18 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor { private static final class MissingObjectException extends RuntimeException { private static final long serialVersionUID = 1L; } + + /** Internal marker for an operation whose explicitly configured service is unavailable. */ + private static final class CapabilityUnavailableException extends RuntimeException { + private static final long serialVersionUID = 1L; + + private CapabilityUnavailableException(String capability) { + super(capability); + } + } + + /** Internal marker preserving an unresolved external publication outcome. */ + private static final class ExternalOutcomeUnknownException extends RuntimeException { + private static final long serialVersionUID = 1L; + } } diff --git a/pki/src/main/java/zeroecho/pki/application/DefaultPkiSession.java b/pki/src/main/java/zeroecho/pki/application/DefaultPkiSession.java index 71eb528..ff28ce4 100644 --- a/pki/src/main/java/zeroecho/pki/application/DefaultPkiSession.java +++ b/pki/src/main/java/zeroecho/pki/application/DefaultPkiSession.java @@ -33,17 +33,53 @@ ******************************************************************************/ package zeroecho.pki.application; +import java.nio.file.Path; import java.time.Clock; +import java.util.ArrayList; +import java.util.List; import java.util.Objects; +import java.util.Optional; +import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; +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.pki.api.CaService; +import zeroecho.pki.api.CertificationRequestService; +import zeroecho.pki.api.IssuanceService; +import zeroecho.pki.api.PublicationService; import zeroecho.pki.api.ProfileService; import zeroecho.pki.api.RevocationService; +import zeroecho.pki.api.StatusObjectService; +import zeroecho.pki.api.credential.EffectiveCredentialStatusResolver; import zeroecho.pki.impl.core.DefaultProfileService; import zeroecho.pki.impl.core.DefaultRevocationService; +import zeroecho.pki.impl.core.DefaultCaService; +import zeroecho.pki.impl.core.DefaultCertificationRequestService; +import zeroecho.pki.impl.core.DefaultIssuanceService; +import zeroecho.pki.impl.core.DefaultPublicationService; +import zeroecho.pki.impl.core.DefaultStatusObjectService; +import zeroecho.pki.impl.core.StoreBackedEffectiveCredentialStatusResolver; +import zeroecho.pki.impl.core.async.PkiSigningBus; +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.BcX509ProofOfPossessionVerifier; +import zeroecho.pki.impl.framework.x509.bc.BcX509StatusObjectGenerator; +import zeroecho.pki.impl.framework.x509.bc.BcX509VerificationExecutor; import zeroecho.pki.spi.ProviderConfig; import zeroecho.pki.spi.audit.AuditSink; import zeroecho.pki.spi.bootstrap.PkiBootstrap; +import zeroecho.pki.spi.crypto.PublicKeyInfoSource; +import zeroecho.pki.spi.crypto.SignatureWorkflow; +import zeroecho.pki.spi.crypto.SignatureWorkflowRuntimeDependencies; +import zeroecho.pki.spi.publish.Publisher; import zeroecho.pki.spi.store.PkiStore; /** Default synchronous session composition. */ @@ -53,37 +89,76 @@ final class DefaultPkiSession implements PkiSession { private final AuditSink audit; private final ProfileService profiles; private final RevocationService revocations; + private final Optional authorities; + private final Optional requests; + private final Optional issuance; + private final Optional statusObjects; + private final Optional publications; + private final Optional signingBus; + private final Optional signatureWorkflow; private final PkiOperationExecutor operations; private final AtomicBoolean closed = new AtomicBoolean(); - private DefaultPkiSession(PkiSessionConfiguration configuration, PkiStore store, AuditSink audit, Clock clock) { + private DefaultPkiSession(PkiSessionConfiguration configuration, PkiStore store, AuditSink audit, + ProfileService profiles, RevocationService revocations, ServiceGraph graph) { this.store = store; this.audit = audit; - this.profiles = new DefaultProfileService(store, clock, audit); - this.revocations = new DefaultRevocationService(store, clock, audit); - this.operations = new DefaultPkiOperationExecutor(configuration, store, revocations, - this::requireOpen); + this.profiles = Objects.requireNonNull(profiles, "profiles"); + this.revocations = Objects.requireNonNull(revocations, "revocations"); + this.authorities = graph.authorities(); + this.requests = graph.requests(); + this.issuance = graph.issuance(); + this.statusObjects = graph.statusObjects(); + this.publications = graph.publications(); + this.signingBus = graph.signingBus(); + this.signatureWorkflow = graph.signatureWorkflow(); + this.operations = new DefaultPkiOperationExecutor(configuration, store, profiles, revocations, authorities, + requests, issuance, statusObjects, publications, this::requireOpen); } /* default */ static PkiSession open(PkiSessionConfiguration configuration) { - return open(configuration, Clock.systemUTC(), ProductionBootstrap.INSTANCE); + return open(configuration, runtimeDependencies(configuration), Clock.systemUTC(), ProductionBootstrap.INSTANCE); + } + + /* default */ static PkiSession open(PkiSessionConfiguration configuration, + PkiSessionRuntimeDependencies dependencies) { + return open(configuration, dependencies, Clock.systemUTC(), ProductionBootstrap.INSTANCE); + } + + /* default */ static PkiSession open(PkiSessionConfiguration configuration, Clock clock, Bootstrap bootstrap) { + return open(configuration, PkiSessionRuntimeDependencies.none(), clock, bootstrap); } @SuppressWarnings("PMD.AvoidCatchingGenericException") - /* default */ static PkiSession open(PkiSessionConfiguration configuration, Clock clock, Bootstrap bootstrap) { + /* default */ static PkiSession open(PkiSessionConfiguration configuration, + PkiSessionRuntimeDependencies dependencies, Clock clock, Bootstrap bootstrap) { PkiSessionConfiguration exact = Objects.requireNonNull(configuration, "configuration"); Objects.requireNonNull(clock, "clock"); Objects.requireNonNull(bootstrap, "bootstrap"); + Objects.requireNonNull(dependencies, "dependencies"); bootstrap.validateStore(exact.store()); bootstrap.validateAudit(exact.audit()); + exact.signing().ifPresent(signing -> { + bootstrap.validateSignatureWorkflow(signing.workflow()); + bootstrap.validateCredentialFramework(signing.framework()); + if (!"x509-bc".equals(signing.framework().backendId())) { + throw new IllegalArgumentException("Configured framework cannot compose the X.509 service graph"); + } + }); + exact.publishers().forEach(bootstrap::validatePublisher); PkiStore store = null; AuditSink audit = null; + ServiceGraph graph = ServiceGraph.empty(); try { store = Objects.requireNonNull(bootstrap.openStore(exact.store()), "opened store"); audit = Objects.requireNonNull(bootstrap.openAudit(exact.audit()), "opened audit sink"); - return new DefaultPkiSession(exact, store, audit, clock); + ProfileService profiles = new DefaultProfileService(store, clock, audit); + RevocationService revocations = new DefaultRevocationService(store, clock, audit); + graph = composeGraph(exact, dependencies, store, audit, profiles, clock, bootstrap); + return new DefaultPkiSession(exact, store, audit, profiles, revocations, graph); } catch (RuntimeException | Error primary) { + closeGraphAfterConstructionFailure(graph, primary); closeAfterConstructionFailure(audit, store, primary); throw primary; } @@ -101,6 +176,36 @@ final class DefaultPkiSession implements PkiSession { return revocations; } + @Override + public Optional authorities() { + requireOpen(); + return authorities; + } + + @Override + public Optional requests() { + requireOpen(); + return requests; + } + + @Override + public Optional issuance() { + requireOpen(); + return issuance; + } + + @Override + public Optional statusObjects() { + requireOpen(); + return statusObjects; + } + + @Override + public Optional publications() { + requireOpen(); + return publications; + } + @Override public PkiOperationExecutor operations() { requireOpen(); @@ -113,6 +218,9 @@ final class DefaultPkiSession implements PkiSession { return; } Throwable primary = null; + primary = closeResource(publications.orElse(null), primary); + primary = closeResource(signingBus.orElse(null), primary); + primary = closeResource(signatureWorkflow.orElse(null), primary); try { audit.close(); } catch (Throwable failure) { // NOPMD - preserve Error and checked close failures. @@ -136,6 +244,154 @@ final class DefaultPkiSession implements PkiSession { } } + @SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.CloseResource", "PMD.ExceptionAsFlowControl" }) + private static ServiceGraph composeGraph(PkiSessionConfiguration configuration, + PkiSessionRuntimeDependencies dependencies, PkiStore store, AuditSink audit, ProfileService profiles, + Clock clock, Bootstrap bootstrap) { + Optional workflow = Optional.empty(); + Optional bus = Optional.empty(); + Optional authorities = Optional.empty(); + Optional requests = Optional.empty(); + Optional issuance = Optional.empty(); + Optional statuses = Optional.empty(); + Optional publications = Optional.empty(); + try { + if (configuration.signing().isPresent()) { + PkiSessionConfiguration.SigningConfiguration signing = configuration.signing().orElseThrow(); + KeyringUnlockProvider unlock = dependencies.keyringUnlockProvider().orElseThrow( + () -> new IllegalStateException("Configured signing requires key-unlock capability")); + SignatureWorkflow openedWorkflow = bootstrap.openSignatureWorkflow(signing.workflow(), + SignatureWorkflowRuntimeDependencies.withKeyringUnlockProvider(unlock)); + workflow = Optional.of(openedWorkflow); + if (!(openedWorkflow instanceof PublicKeyInfoSource publicKeys)) { + throw new IllegalStateException("Signature workflow lacks managed public-key capability"); + } + BcX509VerificationExecutor verification = new BcX509VerificationExecutor(); + X509AuthoritySnapshot authority = authority(openedWorkflow, verification); + PkiSigningBus openedBus = new PkiSigningBus(store, openedWorkflow, Path.of(signing.busPath()), + authority); + bus = Optional.of(openedBus); + BcX509CredentialIssuerBackend issuerBackend = new BcX509CredentialIssuerBackend(openedBus, + signing.signatureAlgorithm(), signing.signingTtl()); + BcX509StatusObjectGenerator statusGenerator = new BcX509StatusObjectGenerator(openedBus, + signing.signatureAlgorithm(), signing.signingTtl()); + BcX509CredentialFramework framework = new BcX509CredentialFramework(authority, verification) + .wired(statusGenerator, new BcX509ProofOfPossessionVerifier(authority, verification)); + EffectiveCredentialStatusResolver statusResolver = new StoreBackedEffectiveCredentialStatusResolver( + store, clock); + requests = Optional.of(new DefaultCertificationRequestService(store, framework)); + issuance = Optional.of(new DefaultIssuanceService(store, framework, issuerBackend, audit, + statusResolver, profiles, clock)); + statuses = Optional.of(new DefaultStatusObjectService(store, framework, audit, statusResolver, + authority)); + authorities = Optional.of(new DefaultCaService(store, framework, issuerBackend, + publicKeys::resolvePublicKeyInfo, openedBus, audit, statusResolver, profiles, clock, + signing.signatureAlgorithm(), signing.signingTtl())); + } + List configuredPublishers = new ArrayList<>(); + for (ProviderConfig publisher : configuration.publishers()) { + configuredPublishers.add(bootstrap.openPublisher(publisher)); + } + publications = configuredPublishers.isEmpty() ? Optional.empty() + : Optional.of(new DefaultPublicationService(store, clock, configuredPublishers)); + return new ServiceGraph(authorities, requests, issuance, statuses, publications, bus, workflow); + } catch (RuntimeException | Error primary) { + Throwable cleanup = closeResource(publications.orElse(null), primary); + cleanup = closeResource(bus.orElse(null), cleanup); + closeResource(workflow.orElse(null), cleanup); + throw primary; + } + } + + private static X509AuthoritySnapshot authority(SignatureWorkflow workflow, + BcX509VerificationExecutor verification) { + Set supported = Set.copyOf(workflow.supportedAlgorithms()); + Set identities = 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()); + String implementationId = "workflow." + workflow.id(); + AlgorithmExecutionCapability signing = new AlgorithmExecutionCapability() { + @Override + public String implementationId() { + return implementationId; + } + + @Override + public String domainFingerprint() { + return "session-signing-v1:" + workflow.id() + ":" + identities.stream() + .map(AlgorithmIdentity::canonicalForm).sorted().reduce("", String::concat); + } + + @Override + public boolean supports(AlgorithmIdentity identity, AlgorithmSuite suite, Direction direction) { + return direction == Direction.SIGN && identities.contains(identity) && identity.equals(suite.signature()); + } + }; + AlgorithmExecutionCapabilityProvider signingProvider = () -> List.of(signing); + X509AlgorithmResolver.Policy policy = new X509AlgorithmResolver.Policy() { + @Override + public boolean permits(AlgorithmSuite suite, AlgorithmExecutionCapability.Direction direction) { + return true; + } + + @Override + public String semanticFingerprint() { + return "pki-session-bootstrap-policy-v1:configured-capabilities-only"; + } + }; + return X509AuthoritySnapshot.compose(List.of(), List.of(signingProvider, verification), + List.of(X509AuthoritySnapshot.bindExecutor(implementationId, + AlgorithmExecutionCapability.Direction.SIGN, workflow), + X509AuthoritySnapshot.bindExecutor(BcX509VerificationExecutor.IMPLEMENTATION_ID, + AlgorithmExecutionCapability.Direction.VERIFY, verification)), policy); + } + + private static PkiSessionRuntimeDependencies runtimeDependencies(PkiSessionConfiguration configuration) { + Optional environment = configuration.signing() + .flatMap(PkiSessionConfiguration.SigningConfiguration::unlockEnvironmentVariable); + if (environment.isEmpty()) { + return PkiSessionRuntimeDependencies.none(); + } + KeyringUnlockProvider provider = () -> { + String value = System.getenv(environment.orElseThrow()); + if (value == null || value.isEmpty()) { + throw new java.io.IOException("Configured key-unlock source is unavailable"); + } + char[] password = value.toCharArray(); + try { + return new KeyringPassword(password); + } finally { + java.util.Arrays.fill(password, '\0'); + } + }; + return PkiSessionRuntimeDependencies.withKeyringUnlockProvider(provider); + } + + @SuppressWarnings("PMD.AvoidCatchingGenericException") + private static Throwable closeResource(AutoCloseable resource, Throwable primary) { + if (resource == null) { + return primary; + } + try { + resource.close(); + } catch (Throwable failure) { + if (primary == null) { + return failure; + } + primary.addSuppressed(failure); + } + return primary; + } + + private static void closeGraphAfterConstructionFailure(ServiceGraph graph, Throwable primary) { + Throwable ignored = closeResource(graph.publications().orElse(null), primary); + ignored = closeResource(graph.signingBus().orElse(null), ignored); + closeResource(graph.signatureWorkflow().orElse(null), ignored); + } + private static void closeAfterConstructionFailure(AuditSink audit, PkiStore store, Throwable primary) { if (audit != null) { try { @@ -180,6 +436,42 @@ final class DefaultPkiSession implements PkiSession { /** Opens the configured audit sink. */ AuditSink openAudit(ProviderConfig configuration); + + /** Validates signing-workflow configuration. */ + default void validateSignatureWorkflow(ProviderConfig configuration) { + PkiBootstrap.validateSignatureWorkflowConfiguration(configuration); + } + + /** Validates credential-framework configuration. */ + default void validateCredentialFramework(ProviderConfig configuration) { + PkiBootstrap.validateCredentialFrameworkConfiguration(configuration); + } + + /** Validates publisher configuration. */ + default void validatePublisher(ProviderConfig configuration) { + PkiBootstrap.validatePublisherConfiguration(configuration); + } + + /** Opens the configured signature workflow. */ + default SignatureWorkflow openSignatureWorkflow(ProviderConfig configuration, + SignatureWorkflowRuntimeDependencies dependencies) { + return PkiBootstrap.openConfiguredSignatureWorkflow(configuration, dependencies); + } + + /** Opens one configured publisher. */ + default Publisher openPublisher(ProviderConfig configuration) { + return PkiBootstrap.openPublisher(configuration); + } + } + + private record ServiceGraph(Optional authorities, Optional requests, + Optional issuance, Optional statusObjects, + Optional publications, Optional signingBus, + Optional signatureWorkflow) { + private static ServiceGraph empty() { + return new ServiceGraph(Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), + Optional.empty(), Optional.empty(), Optional.empty()); + } } /** Production provider bootstrap implementation. */ diff --git a/pki/src/main/java/zeroecho/pki/application/PkiOperation.java b/pki/src/main/java/zeroecho/pki/application/PkiOperation.java index f35f9cd..b00c932 100644 --- a/pki/src/main/java/zeroecho/pki/application/PkiOperation.java +++ b/pki/src/main/java/zeroecho/pki/application/PkiOperation.java @@ -35,8 +35,16 @@ package zeroecho.pki.application; import java.util.Objects; +import zeroecho.pki.api.EncodedObject; +import zeroecho.pki.api.FormatId; +import zeroecho.pki.api.KeyRef; import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.SubjectRef; +import zeroecho.pki.api.ca.CaState; +import zeroecho.pki.api.publication.PublicationSourceType; +import zeroecho.pki.api.publication.PublicationTarget; import zeroecho.pki.api.revocation.RevocationReason; +import zeroecho.pki.api.status.StatusObjectType; /** * Closed set of typed synchronous PKI operations available to transports. @@ -45,8 +53,18 @@ import zeroecho.pki.api.revocation.RevocationReason; * explicit type and never by reflection or Java class name.

*/ public sealed interface PkiOperation permits PkiOperation.ValidateConfiguration, PkiOperation.ValidateProfile, - PkiOperation.InspectCredential, PkiOperation.RevokeCredential, PkiOperation.ReadRevocationHistory, - PkiOperation.InspectPublication { + PkiOperation.RegisterProfile, PkiOperation.InspectProfile, PkiOperation.ListProfileVersions, + PkiOperation.ActivateProfile, PkiOperation.InspectAuthority, PkiOperation.ListAuthorities, + PkiOperation.CreateAuthority, PkiOperation.TransitionAuthority, PkiOperation.InspectRequest, + PkiOperation.ImportRequest, PkiOperation.VerifyRequest, PkiOperation.InspectCredential, + PkiOperation.IssueCredential, PkiOperation.RevokeCredential, + PkiOperation.InspectRevocation, PkiOperation.ReadRevocationHistory, PkiOperation.SnapshotRevocations, + PkiOperation.InspectStatus, PkiOperation.ListStatus, PkiOperation.GenerateStatus, + PkiOperation.InspectPublication, PkiOperation.ListPublications, PkiOperation.RegisterPublication, + PkiOperation.ProcessPublication, PkiOperation.RetryPublication, PkiOperation.ReconcilePublication { + + /** Maximum finite entries returned by one presentation operation. */ + int MAXIMUM_RESULT_ENTRIES = 1_000; /** @return stable semantic operation name */ String name(); @@ -83,6 +101,204 @@ public sealed interface PkiOperation permits PkiOperation.ValidateConfiguration, } } + /** Imports one bounded validated certificate-profile document. */ + record RegisterProfile(byte[] document) implements PkiOperation { + /** Stable operation name. */ + public static final String NAME = "profile.register"; + + /** Defensively snapshots the document. */ + public RegisterProfile { + document = Objects.requireNonNull(document, "document").clone(); + } + + @Override + public byte[] document() { + return document.clone(); + } + + @Override + public String name() { + return NAME; + } + } + + /** Reads one exact imported certificate-profile version. */ + record InspectProfile(String profileId, long profileVersion) implements PkiOperation { + /** Stable operation name. */ + public static final String NAME = "profile.inspect"; + + /** Validates the exact profile identity. */ + public InspectProfile { + if (profileId == null || profileId.isBlank() || profileVersion <= 0) { + throw new IllegalArgumentException("profile identity is invalid"); + } + } + + @Override + public String name() { + return NAME; + } + } + + /** Lists the finite imported versions of one logical profile. */ + record ListProfileVersions(String profileId) implements PkiOperation { + /** Stable operation name. */ + public static final String NAME = "profile.list"; + + /** Validates the logical profile identity. */ + public ListProfileVersions { + if (profileId == null || profileId.isBlank()) { + throw new IllegalArgumentException("profileId must not be blank"); + } + } + + @Override + public String name() { + return NAME; + } + } + + /** Activates one already imported certificate-profile version. */ + record ActivateProfile(String profileId, long profileVersion) implements PkiOperation { + /** Stable operation name. */ + public static final String NAME = "profile.activate"; + + /** Validates the exact profile identity. */ + public ActivateProfile { + if (profileId == null || profileId.isBlank() || profileVersion <= 0) { + throw new IllegalArgumentException("profile identity is invalid"); + } + } + + @Override + public String name() { + return NAME; + } + } + + /** Reads safe metadata for one committed certificate authority. */ + record InspectAuthority(PkiId caId) implements PkiOperation { + /** Stable operation name. */ + public static final String NAME = "ca.inspect"; + + /** Validates the authority identity. */ + public InspectAuthority { + Objects.requireNonNull(caId, "caId"); + } + + @Override + public String name() { + return NAME; + } + } + + /** Lists committed certificate authorities with a finite presentation limit. */ + record ListAuthorities(int limit) implements PkiOperation { + /** Stable operation name. */ + public static final String NAME = "ca.list"; + + /** Validates the presentation limit. */ + public ListAuthorities { + requireLimit(limit); + } + + @Override + public String name() { + return NAME; + } + } + + /** Creates one root certificate authority using an existing managed key reference. */ + record CreateAuthority(FormatId formatId, SubjectRef subjectRef, String profileId, KeyRef keyRef) + implements PkiOperation { + /** Stable operation name. */ + public static final String NAME = "ca.create"; + + /** Validates the closed authority-creation input. */ + public CreateAuthority { + Objects.requireNonNull(formatId, "formatId"); + Objects.requireNonNull(subjectRef, "subjectRef"); + Objects.requireNonNull(keyRef, "keyRef"); + if (profileId == null || profileId.isBlank()) { + throw new IllegalArgumentException("profileId must not be blank"); + } + } + + @Override + public String name() { + return NAME; + } + } + + /** Applies one explicit lifecycle transition to an existing authority. */ + record TransitionAuthority(PkiId caId, CaState state, String reason) implements PkiOperation { + /** Stable operation name. */ + public static final String NAME = "ca.transition"; + + /** Validates the lifecycle-transition input. */ + public TransitionAuthority { + Objects.requireNonNull(caId, "caId"); + Objects.requireNonNull(state, "state"); + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException("reason must not be blank"); + } + } + + @Override + public String name() { + return NAME; + } + } + + /** Reads safe metadata for one stored certification request. */ + record InspectRequest(PkiId requestId) implements PkiOperation { + /** Stable operation name. */ + public static final String NAME = "request.inspect"; + + /** Validates the request identity. */ + public InspectRequest { + Objects.requireNonNull(requestId, "requestId"); + } + + @Override + public String name() { + return NAME; + } + } + + /** Strictly parses and durably imports one certification request. */ + record ImportRequest(FormatId formatId, EncodedObject encoded) implements PkiOperation { + /** Stable operation name. */ + public static final String NAME = "request.import"; + + /** Validates the finite request input. */ + public ImportRequest { + Objects.requireNonNull(formatId, "formatId"); + Objects.requireNonNull(encoded, "encoded"); + } + + @Override + public String name() { + return NAME; + } + } + + /** Verifies proof of possession for one stored request. */ + record VerifyRequest(PkiId requestId) implements PkiOperation { + /** Stable operation name. */ + public static final String NAME = "request.verify"; + + /** Validates the request identity. */ + public VerifyRequest { + Objects.requireNonNull(requestId, "requestId"); + } + + @Override + public String name() { + return NAME; + } + } + /** Reads safe metadata for one committed credential. */ record InspectCredential(PkiId credentialId) implements PkiOperation { /** Stable operation name. */ @@ -99,6 +315,26 @@ public sealed interface PkiOperation permits PkiOperation.ValidateConfiguration, } } + /** Issues one end-entity credential from an already imported request. */ + record IssueCredential(PkiId issuerCaId, PkiId requestId, String profileId) implements PkiOperation { + /** Stable operation name. */ + public static final String NAME = "credential.issue"; + + /** Validates the issuance input. */ + public IssueCredential { + Objects.requireNonNull(issuerCaId, "issuerCaId"); + Objects.requireNonNull(requestId, "requestId"); + if (profileId == null || profileId.isBlank()) { + throw new IllegalArgumentException("profileId must not be blank"); + } + } + + @Override + public String name() { + return NAME; + } + } + /** Permanently revokes one committed credential. */ record RevokeCredential(PkiId credentialId, RevocationReason reason) implements PkiOperation { /** Stable operation name. */ @@ -140,6 +376,89 @@ public sealed interface PkiOperation permits PkiOperation.ValidateConfiguration, } } + /** Reads the validated current revocation state for one credential. */ + record InspectRevocation(PkiId credentialId) implements PkiOperation { + /** Stable operation name. */ + public static final String NAME = "revocation.inspect"; + + /** Validates the credential identity. */ + public InspectRevocation { + Objects.requireNonNull(credentialId, "credentialId"); + } + + @Override + public String name() { + return NAME; + } + } + + /** Reads a bounded page from one immutable current-revocation snapshot. */ + record SnapshotRevocations(int limit) implements PkiOperation { + /** Stable operation name. */ + public static final String NAME = "revocation.snapshot"; + + /** Validates the presentation limit. */ + public SnapshotRevocations { + requireLimit(limit); + } + + @Override + public String name() { + return NAME; + } + } + + /** Reads safe metadata for one committed status object. */ + record InspectStatus(PkiId statusObjectId) implements PkiOperation { + /** Stable operation name. */ + public static final String NAME = "status.inspect"; + + /** Validates the status-object identity. */ + public InspectStatus { + Objects.requireNonNull(statusObjectId, "statusObjectId"); + } + + @Override + public String name() { + return NAME; + } + } + + /** Lists status objects for one issuer with a finite presentation limit. */ + record ListStatus(PkiId issuerCaId, int limit) implements PkiOperation { + /** Stable operation name. */ + public static final String NAME = "inventory.status"; + + /** Validates the issuer identity and presentation limit. */ + public ListStatus { + Objects.requireNonNull(issuerCaId, "issuerCaId"); + requireLimit(limit); + } + + @Override + public String name() { + return NAME; + } + } + + /** Generates one signed status object from a stable revocation view. */ + record GenerateStatus(PkiId issuerCaId, StatusObjectType type, FormatId formatId) implements PkiOperation { + /** Stable operation name. */ + public static final String NAME = "status.generate"; + + /** Validates the generation input. */ + public GenerateStatus { + Objects.requireNonNull(issuerCaId, "issuerCaId"); + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(formatId, "formatId"); + } + + @Override + public String name() { + return NAME; + } + } + /** Reads safe durable state for one publication operation. */ record InspectPublication(PkiId publicationId) implements PkiOperation { /** Stable operation name. */ @@ -155,4 +474,94 @@ public sealed interface PkiOperation permits PkiOperation.ValidateConfiguration, return NAME; } } + + /** Lists publication records using a bounded-memory cursor and finite result page. */ + record ListPublications(int limit) implements PkiOperation { + /** Stable operation name. */ + public static final String NAME = "publication.list"; + + /** Validates the presentation limit. */ + public ListPublications { + requireLimit(limit); + } + + @Override + public String name() { + return NAME; + } + } + + /** Registers post-commit publication of one authoritative source object. */ + record RegisterPublication(PkiId publicationId, PublicationSourceType sourceType, PkiId sourceId, + PublicationTarget target) implements PkiOperation { + /** Stable operation name. */ + public static final String NAME = "publication.register"; + + /** Validates the immutable publication registration fields. */ + public RegisterPublication { + Objects.requireNonNull(publicationId, "publicationId"); + Objects.requireNonNull(sourceType, "sourceType"); + Objects.requireNonNull(sourceId, "sourceId"); + Objects.requireNonNull(target, "target"); + } + + @Override + public String name() { + return NAME; + } + } + + /** Processes one explicitly selected pending publication operation. */ + record ProcessPublication(PkiId publicationId) implements PkiOperation { + /** Stable operation name. */ + public static final String NAME = "publication.process"; + + /** Validates the publication identity. */ + public ProcessPublication { + Objects.requireNonNull(publicationId, "publicationId"); + } + + @Override + public String name() { + return NAME; + } + } + + /** Explicitly retries one eligible publication operation. */ + record RetryPublication(PkiId publicationId) implements PkiOperation { + /** Stable operation name. */ + public static final String NAME = "publication.retry"; + + /** Validates the publication identity. */ + public RetryPublication { + Objects.requireNonNull(publicationId, "publicationId"); + } + + @Override + public String name() { + return NAME; + } + } + + /** Reconciles one publication operation whose external outcome is unknown. */ + record ReconcilePublication(PkiId publicationId) implements PkiOperation { + /** Stable operation name. */ + public static final String NAME = "publication.reconcile"; + + /** Validates the publication identity. */ + public ReconcilePublication { + Objects.requireNonNull(publicationId, "publicationId"); + } + + @Override + public String name() { + return NAME; + } + } + + private static void requireLimit(int limit) { + if (limit < 1 || limit > MAXIMUM_RESULT_ENTRIES) { + throw new IllegalArgumentException("limit must be between 1 and 1000"); + } + } } diff --git a/pki/src/main/java/zeroecho/pki/application/PkiSession.java b/pki/src/main/java/zeroecho/pki/application/PkiSession.java index a376c0f..786e9fe 100644 --- a/pki/src/main/java/zeroecho/pki/application/PkiSession.java +++ b/pki/src/main/java/zeroecho/pki/application/PkiSession.java @@ -33,8 +33,15 @@ ******************************************************************************/ package zeroecho.pki.application; +import java.util.Optional; + +import zeroecho.pki.api.CaService; +import zeroecho.pki.api.CertificationRequestService; +import zeroecho.pki.api.IssuanceService; +import zeroecho.pki.api.PublicationService; import zeroecho.pki.api.ProfileService; import zeroecho.pki.api.RevocationService; +import zeroecho.pki.api.StatusObjectService; /** * Lifecycle-owned synchronous PKI backend session. @@ -58,12 +65,48 @@ public interface PkiSession extends AutoCloseable { return DefaultPkiSession.open(configuration); } + /** + * Opens a production session with explicit process-local security capabilities. + * + * @param configuration validated immutable provider configuration + * @param dependencies process-local key-access capabilities + * @return opened lifecycle-owned session + */ + static PkiSession open(PkiSessionConfiguration configuration, PkiSessionRuntimeDependencies dependencies) { + return DefaultPkiSession.open(configuration, dependencies); + } + /** @return profile lifecycle service owned by this session */ ProfileService profiles(); /** @return revocation service owned by this session */ RevocationService revocations(); + /** @return configured CA service, or empty for a read-only session */ + default Optional authorities() { + return Optional.empty(); + } + + /** @return configured certification-request service, or empty when X.509 is unavailable */ + default Optional requests() { + return Optional.empty(); + } + + /** @return configured issuance service, or empty when signing is unavailable */ + default Optional issuance() { + return Optional.empty(); + } + + /** @return configured status-object service, or empty when signing is unavailable */ + default Optional statusObjects() { + return Optional.empty(); + } + + /** @return configured publication service, or empty when no destination is enabled */ + default Optional publications() { + return Optional.empty(); + } + /** @return shared typed operation executor owned by this session */ PkiOperationExecutor operations(); diff --git a/pki/src/main/java/zeroecho/pki/application/PkiSessionConfiguration.java b/pki/src/main/java/zeroecho/pki/application/PkiSessionConfiguration.java index f130d45..87cc55b 100644 --- a/pki/src/main/java/zeroecho/pki/application/PkiSessionConfiguration.java +++ b/pki/src/main/java/zeroecho/pki/application/PkiSessionConfiguration.java @@ -33,7 +33,10 @@ ******************************************************************************/ package zeroecho.pki.application; +import java.time.Duration; +import java.util.List; import java.util.Objects; +import java.util.Optional; import zeroecho.pki.spi.ProviderConfig; @@ -47,22 +50,75 @@ import zeroecho.pki.spi.ProviderConfig; * @param version configuration schema version * @param store store-provider configuration * @param audit audit-provider configuration + * @param signing optional signing and X.509 service composition + * @param publishers explicitly enabled publication destinations */ -public record PkiSessionConfiguration(int version, ProviderConfig store, ProviderConfig audit) { +public record PkiSessionConfiguration(int version, ProviderConfig store, ProviderConfig audit, + Optional signing, List publishers) { /** Current configuration schema version. */ - public static final int CURRENT_VERSION = 1; + public static final int CURRENT_VERSION = 2; + + /** Creates a read-only-compatible version-one configuration. */ + public PkiSessionConfiguration(int version, ProviderConfig store, ProviderConfig audit) { + this(version, store, audit, Optional.empty(), List.of()); + } /** Validates and snapshots the configuration. */ public PkiSessionConfiguration { - if (version != CURRENT_VERSION) { + if (version < 1 || version > CURRENT_VERSION) { throw new IllegalArgumentException("Unsupported PKI session configuration version"); } store = snapshot(Objects.requireNonNull(store, "store")); audit = snapshot(Objects.requireNonNull(audit, "audit")); + signing = Objects.requireNonNull(signing, "signing").map(SigningConfiguration::snapshot); + publishers = Objects.requireNonNull(publishers, "publishers").stream() + .map(PkiSessionConfiguration::snapshot).toList(); + if (version == 1 && (signing.isPresent() || !publishers.isEmpty())) { + throw new IllegalArgumentException("Version-one configuration cannot enable optional capabilities"); + } } private static ProviderConfig snapshot(ProviderConfig config) { return new ProviderConfig(config.backendId(), config.properties()); } + + /** + * Explicit signing and X.509 service-graph configuration. + * + * @param workflow signature-workflow provider configuration + * @param framework credential-framework provider configuration + * @param busPath durable signing-bus path + * @param signatureAlgorithm canonical configured signing algorithm + * @param signingTtl positive synchronous signing deadline + * @param unlockEnvironmentVariable optional environment-variable reference used + * only by the default process composition + */ + public record SigningConfiguration(ProviderConfig workflow, ProviderConfig framework, String busPath, + String signatureAlgorithm, Duration signingTtl, Optional unlockEnvironmentVariable) { + + /** Validates and snapshots the signing configuration. */ + public SigningConfiguration { + workflow = PkiSessionConfiguration.snapshot(Objects.requireNonNull(workflow, "workflow")); + framework = PkiSessionConfiguration.snapshot(Objects.requireNonNull(framework, "framework")); + if (busPath == null || busPath.isBlank() || signatureAlgorithm == null || signatureAlgorithm.isBlank()) { + throw new IllegalArgumentException("Signing paths and algorithm must not be blank"); + } + if (signingTtl == null || signingTtl.isZero() || signingTtl.isNegative()) { + throw new IllegalArgumentException("signingTtl must be positive"); + } + unlockEnvironmentVariable = Objects.requireNonNull(unlockEnvironmentVariable, + "unlockEnvironmentVariable"); + unlockEnvironmentVariable.ifPresent(value -> { + if (!value.matches("[A-Z][A-Z0-9_]{0,127}")) { + throw new IllegalArgumentException("Unlock environment-variable reference is invalid"); + } + }); + } + + private static SigningConfiguration snapshot(SigningConfiguration source) { + return new SigningConfiguration(source.workflow, source.framework, source.busPath, + source.signatureAlgorithm, source.signingTtl, source.unlockEnvironmentVariable); + } + } } diff --git a/pki/src/main/java/zeroecho/pki/application/PkiSessionRuntimeDependencies.java b/pki/src/main/java/zeroecho/pki/application/PkiSessionRuntimeDependencies.java new file mode 100644 index 0000000..9cadb11 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/application/PkiSessionRuntimeDependencies.java @@ -0,0 +1,63 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE 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.application; + +import java.util.Objects; +import java.util.Optional; + +import zeroecho.core.spi.KeyringUnlockProvider; + +/** Immutable process-local capabilities for one PKI session composition. */ +public record PkiSessionRuntimeDependencies(Optional keyringUnlockProvider) { + + /** Validates the optional capability container. */ + public PkiSessionRuntimeDependencies { + keyringUnlockProvider = Objects.requireNonNull(keyringUnlockProvider, "keyringUnlockProvider"); + } + + /** @return an immutable dependency set without key-unlock access */ + public static PkiSessionRuntimeDependencies none() { + return new PkiSessionRuntimeDependencies(Optional.empty()); + } + + /** + * Creates dependencies containing one explicit process-local unlock provider. + * + * @param provider secure unlock capability + * @return immutable runtime dependencies + */ + public static PkiSessionRuntimeDependencies withKeyringUnlockProvider(KeyringUnlockProvider provider) { + return new PkiSessionRuntimeDependencies(Optional.of(Objects.requireNonNull(provider, "provider"))); + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/core/DefaultPublicationService.java b/pki/src/main/java/zeroecho/pki/impl/core/DefaultPublicationService.java index 8f79947..55d695e 100644 --- a/pki/src/main/java/zeroecho/pki/impl/core/DefaultPublicationService.java +++ b/pki/src/main/java/zeroecho/pki/impl/core/DefaultPublicationService.java @@ -1,7 +1,36 @@ /******************************************************************************* * Copyright (C) 2026, Leo Galambos * All rights reserved. - *******************************************************************************/ + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE 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; 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 79e13b6..3548243 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 @@ -60,6 +60,7 @@ import java.util.Base64; import java.util.Collections; import java.util.HexFormat; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -97,9 +98,11 @@ import zeroecho.core.storage.KeyringStore; 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.orch.SigningSubmissionId; import zeroecho.pki.spi.crypto.SignatureWorkflow; +import zeroecho.pki.spi.crypto.PublicKeyInfoSource; import zeroecho.sdk.ZeroEchoSession; /** @@ -238,7 +241,7 @@ import zeroecho.sdk.ZeroEchoSession; */ // The provider deliberately centralizes operation lifecycle and cleanup in one implementation. @SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.CyclomaticComplexity", "PMD.TooManyMethods" }) -public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow { +public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow, PublicKeyInfoSource { private static final Logger LOG = Logger.getLogger(ZeroEchoLibSignatureWorkflow.class.getName()); @@ -306,7 +309,7 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow { KeyringUnlockProvider keyringUnlockProvider, KeyringStore keyring) { this(id, keyringPath, operationRoot, clock, operationHorizon, keyRefPrefix, requireComponentSuffix, keyringUnlockProvider); - this.keyringOrNull = java.util.Objects.requireNonNull(keyring, "keyring must not be null"); + this.keyringOrNull = Objects.requireNonNull(keyring, "keyring must not be null"); } /* default */ ZeroEchoLibSignatureWorkflow(String id, Path keyringPath, Path operationRoot, Clock clock, @@ -362,7 +365,7 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow { this.requireComponentSuffix = requireComponentSuffix; this.cleanupObserver = cleanupObserver; this.keyringUnlockProvider = keyringUnlockProvider; - this.session = java.util.Objects.requireNonNull(session, "session must not be null"); + this.session = Objects.requireNonNull(session, "session must not be null"); this.statuses = new ConcurrentHashMap<>(); this.fingerprints = new ConcurrentHashMap<>(); @@ -1004,6 +1007,21 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow { throw new InvalidRequestException(DC_UNSUPPORTED_PUBLICKEY_FORM); } + @Override + public EncodedObject resolvePublicKeyInfo(KeyRef keyRef) { + Objects.requireNonNull(keyRef, "keyRef"); + try { + KeyRefParts parts = parseKeyRefOrThrow(keyRef, true); + String privateAlias = parts.privateAlias; + String logicalAlias = privateAlias.endsWith(".prv") + ? privateAlias.substring(0, privateAlias.length() - ".prv".length()) : privateAlias; + PublicKey publicKey = requireKeyringOrThrow().getPublic(logicalAlias); + return new EncodedObject(Encoding.DER, publicKey.getEncoded()); + } catch (InvalidRequestException | IOException | GeneralSecurityException failure) { + throw new PkiException("Managed public key resolution failed: code=PUBLIC_KEY_RESOLUTION_FAILED", failure); + } + } + private PublicKey importPublic(String algorithmId, S spec) throws GeneralSecurityException { @SuppressWarnings("unchecked") @@ -1642,8 +1660,8 @@ public final class ZeroEchoLibSignatureWorkflow implements SignatureWorkflow { /** Package-local dependencies used to exercise provider failure boundaries. */ /* default */ record SigningDependencies(KeyringStore keyring, ZeroEchoSession session) { SigningDependencies { - java.util.Objects.requireNonNull(keyring, "keyring must not be null"); - java.util.Objects.requireNonNull(session, "session must not be null"); + Objects.requireNonNull(keyring, "keyring must not be null"); + Objects.requireNonNull(session, "session must not be null"); } } 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 169e0bc..cd7adfa 100644 --- a/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java +++ b/pki/src/main/java/zeroecho/pki/impl/fs/FilesystemPkiStore.java @@ -303,7 +303,7 @@ public final class FilesystemPkiStore implements PkiStore, Closeable { } } - LOG.log(Level.INFO, "running in {0}", root); + LOG.log(Level.INFO, "Filesystem PKI store opened"); this.ownership = acquiredOwnership; ownershipTransferred = true; } catch (IOException e) { diff --git a/pki/src/main/java/zeroecho/pki/impl/publish/FilesystemPublisher.java b/pki/src/main/java/zeroecho/pki/impl/publish/FilesystemPublisher.java new file mode 100644 index 0000000..4e885d5 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/publish/FilesystemPublisher.java @@ -0,0 +1,188 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE 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.publish; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Objects; +import java.util.Optional; + +import zeroecho.core.io.RepeatableContent; +import zeroecho.pki.api.publication.PublicationTarget; +import zeroecho.pki.spi.publish.PublicationAttempt; +import zeroecho.pki.spi.publish.PublicationOutcome; +import zeroecho.pki.spi.publish.Publisher; + +/** Bounded-memory, idempotent publisher for one administrator-owned directory. */ +final class FilesystemPublisher implements Publisher { + private static final int BUFFER_BYTES = 16 * 1024; + + private final PublicationTarget target; + private final Path root; + + /* default */ FilesystemPublisher(PublicationTarget target, Path root) { + this.target = Objects.requireNonNull(target, "target"); + this.root = Objects.requireNonNull(root, "root").toAbsolutePath().normalize(); + } + + @Override + public PublicationTarget target() { + return target; + } + + @Override + public PublicationOutcome publish(PublicationAttempt attempt, RepeatableContent payload) { + Objects.requireNonNull(attempt, "attempt"); + Objects.requireNonNull(payload, "payload"); + Path destination = destination(attempt); + Path temporary = destination.resolveSibling(destination.getFileName() + ".tmp-" + attempt.attemptToken()); + try { + Files.createDirectories(root); + if (Files.exists(destination)) { + return matches(destination, attempt) ? PublicationOutcome.SUCCESS : PublicationOutcome.TERMINAL_FAILURE; + } + writeValidated(temporary, payload, attempt); + try { + Files.move(temporary, destination, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException failure) { + return PublicationOutcome.RETRYABLE_FAILURE; + } + forceDirectory(); + return PublicationOutcome.SUCCESS; + } catch (IOException failure) { + return Files.exists(destination) && matchesUnchecked(destination, attempt) ? PublicationOutcome.SUCCESS + : PublicationOutcome.RETRYABLE_FAILURE; + } finally { + try { + Files.deleteIfExists(temporary); + } catch (IOException ignored) { + // Definite publication outcome is not changed by temporary cleanup. + } + } + } + + @Override + public Optional reconcile(PublicationAttempt attempt) { + Objects.requireNonNull(attempt, "attempt"); + Path destination = destination(attempt); + if (!Files.exists(destination)) { + return Optional.of(PublicationOutcome.RETRYABLE_FAILURE); + } + return Optional.of(matchesUnchecked(destination, attempt) ? PublicationOutcome.SUCCESS + : PublicationOutcome.TERMINAL_FAILURE); + } + + private Path destination(PublicationAttempt attempt) { + byte[] identity = attempt.publicationId().value().getBytes(StandardCharsets.UTF_8); + return root.resolve(HexFormat.of().formatHex(sha256().digest(identity)) + ".bin"); + } + + private static void writeValidated(Path path, RepeatableContent payload, PublicationAttempt attempt) + throws IOException { + MessageDigest digest = sha256(); + long count = 0L; + ByteBuffer buffer = ByteBuffer.allocate(BUFFER_BYTES); + try (InputStream input = payload.openStream(); FileChannel output = FileChannel.open(path, + StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) { + byte[] bytes = buffer.array(); + int read; + while ((read = input.read(bytes)) >= 0) { + if (read == 0) { + continue; + } + count = Math.addExact(count, read); + digest.update(bytes, 0, read); + buffer.clear().limit(read); + while (buffer.hasRemaining()) { + output.write(buffer); + } + } + output.force(true); + } + String actual = HexFormat.of().formatHex(digest.digest()); + if (count != attempt.length() || !actual.equals(attempt.sha256())) { + throw new IOException("Publication content commitment mismatch"); + } + } + + private static boolean matches(Path path, PublicationAttempt attempt) throws IOException { + if (Files.size(path) != attempt.length()) { + return false; + } + MessageDigest digest = sha256(); + byte[] buffer = new byte[BUFFER_BYTES]; + try (InputStream input = Files.newInputStream(path)) { + int read; + while ((read = input.read(buffer)) >= 0) { + if (read > 0) { + digest.update(buffer, 0, read); + } + } + } + return HexFormat.of().formatHex(digest.digest()).equals(attempt.sha256()); + } + + private static boolean matchesUnchecked(Path path, PublicationAttempt attempt) { + try { + return matches(path, attempt); + } catch (IOException failure) { + return false; + } + } + + private void forceDirectory() throws IOException { + try (FileChannel directory = FileChannel.open(root, StandardOpenOption.READ)) { + directory.force(true); + } + } + + private static MessageDigest sha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException failure) { + throw new IllegalStateException("SHA-256 is unavailable", failure); + } + } +} diff --git a/pki/src/main/java/zeroecho/pki/impl/publish/FilesystemPublisherProvider.java b/pki/src/main/java/zeroecho/pki/impl/publish/FilesystemPublisherProvider.java new file mode 100644 index 0000000..e17fa15 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/impl/publish/FilesystemPublisherProvider.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.impl.publish; + +import java.nio.file.Path; +import java.util.Set; + +import zeroecho.pki.api.publication.PublicationTarget; +import zeroecho.pki.api.publication.PublicationTargetType; +import zeroecho.pki.spi.ProviderConfig; +import zeroecho.pki.spi.publish.Publisher; +import zeroecho.pki.spi.publish.PublisherProvider; + +/** Explicit filesystem publication-destination provider. */ +public final class FilesystemPublisherProvider implements PublisherProvider { + private static final String ROOT = "root"; + private static final String TARGET_ID = "targetId"; + + @Override + public String id() { + return "filesystem"; + } + + @Override + public Set supportedKeys() { + return Set.of(ROOT, TARGET_ID); + } + + @Override + public void validateConfig(ProviderConfig config) { + PublisherProvider.super.validateConfig(config); + Path.of(config.require(ROOT)); + new PublicationTarget(PublicationTargetType.FILESYSTEM, config.require(TARGET_ID)); + } + + @Override + public Publisher allocate(ProviderConfig config) { + validateConfig(config); + PublicationTarget target = new PublicationTarget(PublicationTargetType.FILESYSTEM, + config.require(TARGET_ID)); + return new FilesystemPublisher(target, Path.of(config.require(ROOT))); + } +} diff --git a/pki/src/main/java/zeroecho/pki/spi/bootstrap/PkiBootstrap.java b/pki/src/main/java/zeroecho/pki/spi/bootstrap/PkiBootstrap.java index aab4d61..ce2508b 100644 --- a/pki/src/main/java/zeroecho/pki/spi/bootstrap/PkiBootstrap.java +++ b/pki/src/main/java/zeroecho/pki/spi/bootstrap/PkiBootstrap.java @@ -51,6 +51,8 @@ import zeroecho.pki.spi.crypto.SignatureWorkflowProvider; import zeroecho.pki.spi.crypto.SignatureWorkflowRuntimeDependencies; import zeroecho.pki.spi.framework.CredentialFramework; import zeroecho.pki.spi.framework.CredentialFrameworkProvider; +import zeroecho.pki.spi.publish.Publisher; +import zeroecho.pki.spi.publish.PublisherProvider; import zeroecho.pki.spi.store.PkiStore; import zeroecho.pki.spi.store.PkiStoreProvider; import zeroecho.pki.util.async.AsyncBus; @@ -286,6 +288,38 @@ public final class PkiBootstrap { return provider.allocate(config, dependencies); } + /** + * Validates one explicit signature-workflow provider configuration without + * allocating key access or workflow resources. + * + * @param config immutable workflow-provider configuration + * @throws RuntimeException if the provider identity, property set, or + * provider-specific values are invalid + */ + public static void validateSignatureWorkflowConfiguration(ProviderConfig config) { + SignatureWorkflowProvider provider = selectSignatureWorkflowProvider( + Objects.requireNonNull(config, "config").backendId()); + requireKnownKeys(provider, config); + provider.validateConfig(config); + } + + /** + * Opens one explicitly configured signature workflow with process-local key + * access supplied outside persistent configuration. + * + * @param config immutable workflow-provider configuration + * @param dependencies process-local security capabilities + * @return opened workflow owned by the caller + * @throws IllegalArgumentException if configuration or capabilities are invalid + * @throws RuntimeException if secure workflow allocation fails + */ + public static SignatureWorkflow openConfiguredSignatureWorkflow(ProviderConfig config, + SignatureWorkflowRuntimeDependencies dependencies) { + validateSignatureWorkflowConfiguration(config); + return selectSignatureWorkflowProvider(config.backendId()).allocate(config, + Objects.requireNonNull(dependencies, "dependencies")); + } + /** * Opens an async operation bus using {@link AsyncBusProvider} discovered via * ServiceLoader. @@ -380,6 +414,44 @@ public final class PkiBootstrap { return provider.allocate(config); } + /** + * Validates one explicit credential-framework provider configuration without + * constructing a framework instance. + * + * @param config immutable framework-provider configuration + * @throws RuntimeException if provider selection or configuration is invalid + */ + public static void validateCredentialFrameworkConfiguration(ProviderConfig config) { + CredentialFrameworkProvider provider = selectCredentialFrameworkProvider( + Objects.requireNonNull(config, "config").backendId()); + requireKnownKeys(provider, config); + provider.validateConfig(config); + } + + /** + * Validates one explicitly enabled publication destination without opening it. + * + * @param config immutable publisher-provider configuration + * @throws RuntimeException if provider selection or configuration is invalid + */ + public static void validatePublisherConfiguration(ProviderConfig config) { + PublisherProvider provider = selectPublisherProvider(Objects.requireNonNull(config, "config").backendId()); + requireKnownKeys(provider, config); + provider.validateConfig(config); + } + + /** + * Opens one explicitly configured publication destination. + * + * @param config immutable publisher-provider configuration + * @return configured publisher selected by stable provider identity + * @throws RuntimeException if provider selection, configuration, or allocation fails + */ + public static Publisher openPublisher(ProviderConfig config) { + validatePublisherConfiguration(config); + return selectPublisherProvider(config.backendId()).allocate(config); + } + /** * Logs provider help information (supported keys) for diagnostics. * @@ -414,6 +486,33 @@ public final class PkiBootstrap { }); } + private static SignatureWorkflowProvider selectSignatureWorkflowProvider(String requestedId) { + return SpiSelector.select(SignatureWorkflowProvider.class, requestedId, new SpiSelector.ProviderId<>() { + @Override + public String id(SignatureWorkflowProvider provider) { + return provider.id(); + } + }); + } + + private static CredentialFrameworkProvider selectCredentialFrameworkProvider(String requestedId) { + return SpiSelector.select(CredentialFrameworkProvider.class, requestedId, new SpiSelector.ProviderId<>() { + @Override + public String id(CredentialFrameworkProvider provider) { + return provider.id(); + } + }); + } + + private static PublisherProvider selectPublisherProvider(String requestedId) { + return SpiSelector.select(PublisherProvider.class, requestedId, new SpiSelector.ProviderId<>() { + @Override + public String id(PublisherProvider provider) { + return provider.id(); + } + }); + } + private static void requireKnownKeys(ConfigurableProvider provider, ProviderConfig config) { for (String key : config.properties().keySet()) { if (!provider.supportedKeys().contains(key)) { diff --git a/pki/src/main/java/zeroecho/pki/spi/crypto/PublicKeyInfoSource.java b/pki/src/main/java/zeroecho/pki/spi/crypto/PublicKeyInfoSource.java new file mode 100644 index 0000000..c2588c9 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/spi/crypto/PublicKeyInfoSource.java @@ -0,0 +1,58 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE 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.crypto; + +import zeroecho.pki.api.EncodedObject; +import zeroecho.pki.api.KeyRef; + +/** + * Resolves exportable public-key information from the same managed-key authority + * used by a signature workflow. + * + *

This capability exposes only canonical DER SubjectPublicKeyInfo. It must + * never return private, secret, seed, unlock, or provider credential material.

+ */ +@FunctionalInterface +public interface PublicKeyInfoSource { + + /** + * Resolves one managed key reference to canonical DER SubjectPublicKeyInfo. + * + * @param keyRef managed signing or public-key reference + * @return immutable DER-encoded public-key information + * @throws IllegalArgumentException if the reference is malformed or unknown + * @throws RuntimeException if secure key-store access fails + */ + EncodedObject resolvePublicKeyInfo(KeyRef keyRef); +} diff --git a/pki/src/main/java/zeroecho/pki/spi/publish/PublisherProvider.java b/pki/src/main/java/zeroecho/pki/spi/publish/PublisherProvider.java new file mode 100644 index 0000000..2e28df4 --- /dev/null +++ b/pki/src/main/java/zeroecho/pki/spi/publish/PublisherProvider.java @@ -0,0 +1,40 @@ +/******************************************************************************* + * Copyright (C) 2026, Leo Galambos + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without modification, + * are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. All advertising materials mentioning features or use of this software must + * display the following acknowledgement: + * This product includes software developed by the Egothor project. + * + * 4. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE 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.publish; + +import zeroecho.pki.spi.ConfigurableProvider; + +/** Service-provider contract for explicitly configured publication destinations. */ +public interface PublisherProvider extends ConfigurableProvider { +} diff --git a/pki/src/main/resources/META-INF/services/zeroecho.pki.spi.publish.PublisherProvider b/pki/src/main/resources/META-INF/services/zeroecho.pki.spi.publish.PublisherProvider new file mode 100644 index 0000000..5c4ebd9 --- /dev/null +++ b/pki/src/main/resources/META-INF/services/zeroecho.pki.spi.publish.PublisherProvider @@ -0,0 +1 @@ +zeroecho.pki.impl.publish.FilesystemPublisherProvider diff --git a/pki/src/test/java/zeroecho/pki/application/PkiOperationExecutorTest.java b/pki/src/test/java/zeroecho/pki/application/PkiOperationExecutorTest.java index 0fefa9e..3d6217b 100644 --- a/pki/src/test/java/zeroecho/pki/application/PkiOperationExecutorTest.java +++ b/pki/src/test/java/zeroecho/pki/application/PkiOperationExecutorTest.java @@ -49,8 +49,19 @@ import org.junit.jupiter.api.Test; import zeroecho.core.io.CancellationSignal; import zeroecho.pki.api.PkiId; +import zeroecho.pki.api.FormatId; +import zeroecho.pki.api.KeyRef; +import zeroecho.pki.api.ProfileService; +import zeroecho.pki.api.PublicationService; import zeroecho.pki.api.RevocationService; +import zeroecho.pki.api.SubjectRef; import zeroecho.pki.api.profile.BuiltInCertificateProfileCatalog; +import zeroecho.pki.api.publication.PublicationCursor; +import zeroecho.pki.api.publication.PublicationQuery; +import zeroecho.pki.api.publication.PublicationRecord; +import zeroecho.pki.api.publication.PublicationRequest; +import zeroecho.pki.api.publication.PublicationResult; +import zeroecho.pki.api.publication.PublicationStatus; import zeroecho.pki.api.revocation.RevocationCommand; import zeroecho.pki.api.revocation.RevocationQuery; import zeroecho.pki.api.revocation.RevocationReason; @@ -120,6 +131,36 @@ class PkiOperationExecutorTest { System.out.println("...ok"); } + @Test + void rejectsSigningDependentOperationWhenCapabilityIsNotConfigured() { + System.out.println("rejectsSigningDependentOperationWhenCapabilityIsNotConfigured"); + DefaultPkiOperationExecutor executor = executor(new AtomicInteger()); + PkiOperation operation = new PkiOperation.CreateAuthority(new FormatId("x509"), + new SubjectRef("CN=Unavailable"), "root-ca", new KeyRef("managed:root.prv")); + PkiOperationOutcome.Failure failure = (PkiOperationOutcome.Failure) executor.execute(operation, + CancellationSignal.NONE); + System.out.println("...classification=" + failure.classification()); + assertEquals(PkiOperationFailure.VALIDATION_FAILURE, failure.classification()); + assertEquals("CAPABILITY_NOT_CONFIGURED", failure.code()); + System.out.println("...ok"); + } + + @Test + void preservesExplicitPublicationResultsAndUnknownOutcome() { + System.out.println("preservesExplicitPublicationResultsAndUnknownOutcome"); + PkiId publicationId = new PkiId("publication:test"); + DefaultPkiOperationExecutor executor = publicationExecutor(); + PkiOperationOutcome processed = executor.execute(new PkiOperation.ProcessPublication(publicationId), + CancellationSignal.NONE); + PkiOperationOutcome reconciled = executor.execute(new PkiOperation.ReconcilePublication(publicationId), + CancellationSignal.NONE); + System.out.println("...process=" + processed.getClass().getSimpleName()); + assertInstanceOf(PkiOperationOutcome.Success.class, processed); + assertEquals(PkiOperationFailure.EXTERNAL_OUTCOME_UNKNOWN, + ((PkiOperationOutcome.Failure) reconciled).classification()); + System.out.println("...ok"); + } + private static DefaultPkiOperationExecutor executor(AtomicInteger mutations) { PkiSessionConfiguration configuration = new PkiSessionConfiguration(1, new ProviderConfig("fs", Map.of("root", "unused")), new ProviderConfig("memory", Map.of())); @@ -129,10 +170,67 @@ class PkiOperationExecutorTest { case "close" -> null; default -> throw new UnsupportedOperationException(method.getName()); }); - return new DefaultPkiOperationExecutor(configuration, store, revocations(mutations), () -> { + ProfileService profiles = (ProfileService) Proxy.newProxyInstance(ProfileService.class.getClassLoader(), + new Class[] { ProfileService.class }, (proxy, method, arguments) -> { + throw new UnsupportedOperationException(method.getName()); + }); + return new DefaultPkiOperationExecutor(configuration, store, profiles, revocations(mutations), () -> { }); } + private static DefaultPkiOperationExecutor publicationExecutor() { + PkiSessionConfiguration configuration = new PkiSessionConfiguration(1, + new ProviderConfig("fs", Map.of("root", "unused")), new ProviderConfig("memory", Map.of())); + PkiStore store = (PkiStore) Proxy.newProxyInstance(PkiStore.class.getClassLoader(), + new Class[] { PkiStore.class }, (proxy, method, arguments) -> { + throw new UnsupportedOperationException(method.getName()); + }); + ProfileService profiles = (ProfileService) Proxy.newProxyInstance(ProfileService.class.getClassLoader(), + new Class[] { ProfileService.class }, (proxy, method, arguments) -> { + throw new UnsupportedOperationException(method.getName()); + }); + PublicationService publications = new PublicationService() { + @Override + public PublicationRecord register(PublicationRequest request) { + throw new UnsupportedOperationException(); + } + + @Override + public PublicationResult process(PkiId selected) { + return new PublicationResult(selected, PublicationStatus.SUCCEEDED, 1L); + } + + @Override + public PublicationResult retry(PkiId selected) { + return new PublicationResult(selected, PublicationStatus.SUCCEEDED, 2L); + } + + @Override + public PublicationResult reconcile(PkiId selected) { + return new PublicationResult(selected, PublicationStatus.OUTCOME_UNKNOWN, 1L); + } + + @Override + public Optional find(PkiId selected) { + return Optional.empty(); + } + + @Override + public PublicationCursor openPublications(PublicationQuery query) { + throw new UnsupportedOperationException(); + } + + @Override + public void close() { + // no-op + } + }; + return new DefaultPkiOperationExecutor(configuration, store, profiles, revocations(new AtomicInteger()), + Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.of(publications), + () -> { + }); + } + private static RevocationService revocations(AtomicInteger mutations) { return new RevocationService() { @Override diff --git a/pki/src/test/java/zeroecho/pki/application/PkiSessionLifecycleTest.java b/pki/src/test/java/zeroecho/pki/application/PkiSessionLifecycleTest.java index 11327a4..3b0b748 100644 --- a/pki/src/test/java/zeroecho/pki/application/PkiSessionLifecycleTest.java +++ b/pki/src/test/java/zeroecho/pki/application/PkiSessionLifecycleTest.java @@ -36,23 +36,38 @@ package zeroecho.pki.application; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; import java.lang.reflect.Proxy; import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; import java.time.Clock; +import java.time.Duration; import java.time.Instant; import java.time.ZoneOffset; import java.util.Map; +import java.util.Optional; 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.storage.KeyringPassword; +import zeroecho.pki.api.KeyRef; +import zeroecho.pki.api.FormatId; +import zeroecho.pki.api.SubjectRef; +import zeroecho.pki.api.profile.BuiltInCertificateProfileCatalog; +import zeroecho.pki.api.profile.BuiltInCertificateProfileTemplate; +import zeroecho.pki.spi.bootstrap.PkiBootstrap; +import zeroecho.pki.spi.crypto.SignatureWorkflow; +import zeroecho.pki.spi.crypto.SignatureWorkflowRuntimeDependencies; import zeroecho.pki.spi.ProviderConfig; import zeroecho.pki.spi.audit.AuditSink; import zeroecho.pki.spi.store.PkiStore; +import zeroecho.pki.testkit.InMemorySignatureWorkflow; class PkiSessionLifecycleTest { @@ -116,6 +131,99 @@ class PkiSessionLifecycleTest { System.out.println("...ok"); } + @Test + void configuredSigningRequiresExplicitUnlockAndCleansAllocatedResources() { + System.out.println("configuredSigningRequiresExplicitUnlockAndCleansAllocatedResources"); + AtomicInteger storesOpened = new AtomicInteger(); + AtomicInteger storesClosed = new AtomicInteger(); + DefaultPkiSession.Bootstrap bootstrap = bootstrap(storesOpened, storesClosed, false, false, false, false); + ProviderConfig placeholder = new ProviderConfig("test", Map.of()); + PkiSessionConfiguration.SigningConfiguration signing = new PkiSessionConfiguration.SigningConfiguration( + placeholder, new ProviderConfig("x509-bc", Map.of()), + temporaryDirectory.resolve("signing-bus").toString(), "RSA-2048-PSS-SHA256", Duration.ofSeconds(5), + Optional.empty()); + PkiSessionConfiguration configuration = new PkiSessionConfiguration(2, + new ProviderConfig("fs", Map.of("root", temporaryDirectory.resolve("store-signing").toString())), + new ProviderConfig("memory", Map.of("size", "16")), Optional.of(signing), java.util.List.of()); + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> DefaultPkiSession.open(configuration, PkiSessionRuntimeDependencies.none(), CLOCK, bootstrap)); + System.out.println("...storesOpened=" + storesOpened.get() + ", storesClosed=" + storesClosed.get()); + assertEquals("Configured signing requires key-unlock capability", failure.getMessage()); + assertEquals(1, storesOpened.get()); + assertEquals(1, storesClosed.get()); + System.out.println("...ok"); + } + + @Test + void opensExplicitPublisherCapabilityWithoutSigning() throws Exception { + System.out.println("opensExplicitPublisherCapabilityWithoutSigning"); + ProviderConfig publisher = new ProviderConfig("filesystem", + Map.of("root", temporaryDirectory.resolve("published").toString(), "targetId", "local-crls")); + PkiSessionConfiguration configuration = new PkiSessionConfiguration(2, + new ProviderConfig("fs", Map.of("root", temporaryDirectory.resolve("store-publisher").toString())), + new ProviderConfig("memory", Map.of("size", "16")), Optional.empty(), java.util.List.of(publisher)); + try (PkiSession session = PkiSession.open(configuration)) { + System.out.println("...publicationConfigured=" + session.publications().isPresent()); + assertTrue(session.publications().isPresent()); + assertTrue(session.authorities().isEmpty()); + } + System.out.println("...ok"); + } + + @Test + void rejectsUnknownPublisherBeforeStoreAllocation() { + System.out.println("rejectsUnknownPublisherBeforeStoreAllocation"); + Path storeRoot = temporaryDirectory.resolve("must-not-open"); + PkiSessionConfiguration configuration = new PkiSessionConfiguration(2, + new ProviderConfig("fs", Map.of("root", storeRoot.toString())), + new ProviderConfig("memory", Map.of("size", "16")), Optional.empty(), + java.util.List.of(new ProviderConfig("unknown-publisher", Map.of()))); + assertThrows(IllegalStateException.class, () -> PkiSession.open(configuration)); + System.out.println("...storeAllocated=" + java.nio.file.Files.exists(storeRoot)); + assertTrue(java.nio.file.Files.notExists(storeRoot)); + System.out.println("...ok"); + } + + @Test + void composesOneSharedSigningAndX509ServiceGraph() throws Exception { + System.out.println("composesOneSharedSigningAndX509ServiceGraph"); + KeyRef keyRef = new KeyRef("managed:test-root"); + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + KeyPair keyPair = generator.generateKeyPair(); + InMemorySignatureWorkflow workflow = new InMemorySignatureWorkflow(Map.of(keyRef.value(), keyPair)); + ProviderConfig placeholder = new ProviderConfig("test", Map.of()); + PkiSessionConfiguration.SigningConfiguration signing = new PkiSessionConfiguration.SigningConfiguration( + placeholder, new ProviderConfig("x509-bc", Map.of()), + temporaryDirectory.resolve("composed-signing-bus").toString(), "SHA256withRSA", + Duration.ofSeconds(5), Optional.empty()); + PkiSessionConfiguration configuration = new PkiSessionConfiguration(2, + new ProviderConfig("fs", Map.of("root", temporaryDirectory.resolve("composed-store").toString())), + new ProviderConfig("memory", Map.of("size", "16")), Optional.of(signing), java.util.List.of()); + DefaultPkiSession.Bootstrap bootstrap = signingBootstrap(workflow); + PkiSessionRuntimeDependencies dependencies = PkiSessionRuntimeDependencies.withKeyringUnlockProvider( + () -> new KeyringPassword("unused-test-unlock".toCharArray())); + try (PkiSession session = DefaultPkiSession.open(configuration, dependencies, CLOCK, bootstrap)) { + System.out.println("...services=ca,request,issuance,status"); + assertTrue(session.authorities().isPresent()); + assertTrue(session.requests().isPresent()); + assertTrue(session.issuance().isPresent()); + assertTrue(session.statusObjects().isPresent()); + assertTrue(session.publications().isEmpty()); + BuiltInCertificateProfileTemplate root = BuiltInCertificateProfileCatalog + .load(getClass().getClassLoader()).stream() + .filter(candidate -> "root-ca".equals(candidate.definition().profileId())).findFirst() + .orElseThrow(); + session.profiles().importProfile(root.canonicalJson()); + session.profiles().activateProfile("root-ca", root.definition().profileVersion()); + PkiOperationOutcome outcome = session.operations().execute(new PkiOperation.CreateAuthority( + new FormatId("x509"), new SubjectRef("CN=Composed Root"), "root-ca", keyRef), + CancellationSignal.NONE); + assertInstanceOf(PkiOperationOutcome.Success.class, outcome); + } + System.out.println("...ok"); + } + private static PkiSessionConfiguration configuration(Path storeRoot) { return new PkiSessionConfiguration(1, new ProviderConfig("fs", Map.of("root", storeRoot.toString())), new ProviderConfig("memory", Map.of("size", "16"))); @@ -136,6 +244,16 @@ class PkiSessionLifecycleTest { } } + @Override + public void validateSignatureWorkflow(ProviderConfig configuration) { + // valid test provider + } + + @Override + public void validateCredentialFramework(ProviderConfig configuration) { + // valid test framework + } + @Override public PkiStore openStore(ProviderConfig configuration) { storesOpened.incrementAndGet(); @@ -169,6 +287,46 @@ class PkiSessionLifecycleTest { }; } + private static DefaultPkiSession.Bootstrap signingBootstrap(SignatureWorkflow workflow) { + return new DefaultPkiSession.Bootstrap() { + @Override + public void validateStore(ProviderConfig configuration) { + PkiBootstrap.validateStoreConfiguration(configuration); + } + + @Override + public void validateAudit(ProviderConfig configuration) { + PkiBootstrap.validateAuditConfiguration(configuration); + } + + @Override + public PkiStore openStore(ProviderConfig configuration) { + return PkiBootstrap.openStore(configuration); + } + + @Override + public AuditSink openAudit(ProviderConfig configuration) { + return PkiBootstrap.openAudit(configuration); + } + + @Override + public void validateSignatureWorkflow(ProviderConfig configuration) { + // Explicit deterministic test provider. + } + + @Override + public void validateCredentialFramework(ProviderConfig configuration) { + PkiBootstrap.validateCredentialFrameworkConfiguration(configuration); + } + + @Override + public SignatureWorkflow openSignatureWorkflow(ProviderConfig configuration, + SignatureWorkflowRuntimeDependencies dependencies) { + return workflow; + } + }; + } + private static PkiStore proxyStore(CloseAction close) { return (PkiStore) Proxy.newProxyInstance(PkiStore.class.getClassLoader(), new Class[] { PkiStore.class }, (proxy, method, arguments) -> { diff --git a/pki/src/test/java/zeroecho/pki/testkit/InMemorySignatureWorkflow.java b/pki/src/test/java/zeroecho/pki/testkit/InMemorySignatureWorkflow.java index 3de329a..0c022df 100644 --- a/pki/src/test/java/zeroecho/pki/testkit/InMemorySignatureWorkflow.java +++ b/pki/src/test/java/zeroecho/pki/testkit/InMemorySignatureWorkflow.java @@ -48,12 +48,13 @@ import zeroecho.pki.api.EncodedObject; import zeroecho.pki.api.Encoding; import zeroecho.pki.api.KeyRef; import zeroecho.pki.api.PkiId; +import zeroecho.pki.spi.crypto.PublicKeyInfoSource; import zeroecho.pki.spi.crypto.SignatureWorkflow; /** * Test-only in-memory signature workflow. */ -public final class InMemorySignatureWorkflow implements SignatureWorkflow { +public final class InMemorySignatureWorkflow implements SignatureWorkflow, PublicKeyInfoSource { private final Map keys; private final Map status; @@ -245,6 +246,15 @@ public final class InMemorySignatureWorkflow implements SignatureWorkflow { return Set.of("SHA256withRSA"); } + @Override + public EncodedObject resolvePublicKeyInfo(KeyRef keyRef) { + KeyPair keyPair = keys.get(Objects.requireNonNull(keyRef, "keyRef").value()); + if (keyPair == null) { + throw new IllegalArgumentException("Managed test key is unavailable"); + } + return new EncodedObject(Encoding.DER, keyPair.getPublic().getEncoded()); + } + public int submittedSignCount() { return submittedSignCount.get(); }