From f0dfb133f2d2663279d2cfd03ecc1caa0e24d833 Mon Sep 17 00:00:00 2001
From: Leo Galambos
Date: Tue, 4 Aug 2026 18:22:00 +0200
Subject: [PATCH] feat(pki): add extensible X.509 algorithm bindings
Add an immutable X.509 binding registry for sealed standard mappings,
versioned ZeroEcho private OIDs and explicitly enabled deployer bindings.
Integrate binding commitments with profiles, issuance, verification, CRLs,
PKI sessions and typed CLI operations.
---
README.md | 26 +-
.../main/java/zeroecho/pki/cli/PkiCli.java | 8 +-
.../zeroecho/pki/cli/PkiCliConfiguration.java | 51 +++-
.../pki/cli/PkiOperationRegistry.java | 32 ++
.../java/zeroecho/pki/cli/PkiCliTest.java | 61 ++++
.../resources/pki-administration-config.json | 2 +-
.../resources/pki-administration-plan.json | 15 +
.../pki-private-sphincs-profile.json | 1 +
.../alg/BootstrapAlgorithmIdentities.java | 15 +-
.../common/sig/SignatureInteropProfiles.java | 7 +-
.../api/algorithm/X509AlgorithmBinding.java | 167 +++++++++++
.../X509AlgorithmBindingRegistry.java | 81 +++++
.../pki/api/profile/CaCertificatePolicy.java | 3 +-
.../pki/api/profile/CertificateProfile.java | 16 +-
.../profile/CertificateProfileDefinition.java | 21 +-
.../CertificateProfileDocumentCodec.java | 131 ++++++++-
.../api/profile/LeafCertificatePolicy.java | 3 +-
.../profile/X509AlgorithmBindingPolicy.java | 100 +++++++
.../DefaultPkiOperationExecutor.java | 100 ++++++-
.../pki/application/DefaultPkiSession.java | 209 ++++++++++++-
.../pki/application/PkiOperation.java | 77 +++++
.../zeroecho/pki/application/PkiSession.java | 8 +
.../application/PkiSessionConfiguration.java | 84 +++++-
.../core/CaCertificateProfileValidator.java | 2 +-
.../zeroecho/pki/impl/core/CaProofGate.java | 25 +-
.../core/CertificateProfileValidator.java | 18 +-
.../pki/impl/core/DefaultCaService.java | 35 ++-
.../pki/impl/core/DefaultProfileService.java | 101 ++++++-
.../core/ValidatedCaCertificateRequest.java | 17 ++
.../core/ValidatedCertificateRequest.java | 20 ++
.../ZeroEchoLibSignatureWorkflowProvider.java | 3 +-
...ImmutableX509AlgorithmBindingRegistry.java | 176 +++++++++++
.../framework/x509/StandardX509Bindings.java | 70 +++++
.../framework/x509/X509AlgorithmResolver.java | 12 +-
.../framework/x509/X509AuthoritySnapshot.java | 36 ++-
.../framework/x509/X509BindingCatalog.java | 46 +++
.../impl/framework/x509/X509BindingRule.java | 17 ++
.../x509/X509SuiteCompatibility.java | 1 +
.../x509/ZeroEchoPrivateX509Bindings.java | 277 ++++++++++++++++++
.../bc/BcX509CredentialIssuerBackend.java | 21 +-
.../bc/BcX509ProofOfPossessionVerifier.java | 2 +
.../x509/bc/BcX509PublicKeyAdapter.java | 107 +++++++
.../x509/bc/BcX509SignedObjectValidator.java | 2 +
.../x509/bc/BcX509StatusObjectGenerator.java | 16 +-
.../x509/bc/BcX509VerificationExecutor.java | 64 +++-
.../x509/bc/PkiBusContentSigner.java | 29 +-
.../bc/WorkflowProofOfPossessionVerifier.java | 5 +-
.../X509AlgorithmBindingProvider.java | 61 ++++
.../pki/profiles/v1/email-signing.json | 2 +-
.../pki/profiles/v1/intermediate-ca.json | 2 +-
.../zeroecho/pki/profiles/v1/root-ca.json | 2 +-
.../zeroecho/pki/profiles/v1/server-tls.json | 2 +-
.../zeroecho/pki/profiles/v1/vpn-client.json | 2 +-
.../zeroecho/pki/profiles/v1/vpn-server.json | 2 +-
.../x509/zeroecho-private-bindings-v1.json | 49 ++++
.../BuiltInCertificateProfileCatalogTest.java | 8 +-
.../CertificateProfileDocumentCodecTest.java | 21 +-
.../application/PkiSessionLifecycleTest.java | 65 +++-
.../impl/core/DefaultProfileServiceTest.java | 77 +++++
.../framework/x509/X509BindingPhaseATest.java | 232 +++++++++++++++
.../pki/testkit/H7ProfileDocuments.java | 3 +-
.../TestX509AlgorithmBindingProvider.java | 142 +++++++++
...spi.algorithm.X509AlgorithmBindingProvider | 1 +
63 files changed, 2879 insertions(+), 112 deletions(-)
create mode 100644 app/src/test/resources/pki-private-sphincs-profile.json
create mode 100644 pki/src/main/java/zeroecho/pki/api/algorithm/X509AlgorithmBinding.java
create mode 100644 pki/src/main/java/zeroecho/pki/api/algorithm/X509AlgorithmBindingRegistry.java
create mode 100644 pki/src/main/java/zeroecho/pki/api/profile/X509AlgorithmBindingPolicy.java
create mode 100644 pki/src/main/java/zeroecho/pki/impl/framework/x509/ImmutableX509AlgorithmBindingRegistry.java
create mode 100644 pki/src/main/java/zeroecho/pki/impl/framework/x509/ZeroEchoPrivateX509Bindings.java
create mode 100644 pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509PublicKeyAdapter.java
create mode 100644 pki/src/main/java/zeroecho/pki/spi/algorithm/X509AlgorithmBindingProvider.java
create mode 100644 pki/src/main/resources/zeroecho/pki/x509/zeroecho-private-bindings-v1.json
create mode 100644 pki/src/test/java/zeroecho/pki/testkit/TestX509AlgorithmBindingProvider.java
create mode 100644 pki/src/test/resources/META-INF/services/zeroecho.pki.spi.algorithm.X509AlgorithmBindingProvider
diff --git a/README.md b/README.md
index 2e01597..7dcab81 100644
--- a/README.md
+++ b/README.md
@@ -60,7 +60,8 @@ 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
+listing. It also exposes immutable X.509 algorithm-binding listing, inspection,
+and commitment validation. Presentation limits bound terminal output only; backend revocation and
publication cursors remain streaming and do not acquire an aggregate population
limit.
@@ -70,7 +71,7 @@ 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
+Version-three 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.
@@ -80,13 +81,26 @@ 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.
+X.509 algorithm identity is independent of its role-specific OID representation.
+Published standard bindings remain sealed. ZeroEcho private assignments are
+frozen below the Egothor PEN branch `1.3.6.1.4.1.31424.1.1`; explicitly enabled
+deployer providers may contribute immutable bindings only below configuration-
+authorized roots, including deployment-local arcs below
+`1.3.6.1.4.1.31424.1.2`. Profiles default to standard-only mode and pin both a
+binding ID and its semantic commitment when private mode is deliberately chosen.
+Private-OID certificates and CRLs require matching binding and cryptographic
+support at every relying party; generic PKI software may reject them.
+
+```text
+zeroecho pki algorithm.binding.list --limit 100 --config pki-config.json
+zeroecho pki algorithm.binding.inspect --binding-id zeroecho.private.sphincs-plus-default.certificate-signature.v1 --config pki-config.json
+zeroecho pki algorithm.binding.validate --config pki-config.json
+```
+
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.
+production service implementation for them.
## 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 2059985..8846a2e 100644
--- a/app/src/main/java/zeroecho/pki/cli/PkiCli.java
+++ b/app/src/main/java/zeroecho/pki/cli/PkiCli.java
@@ -67,7 +67,10 @@ public final class PkiCli {
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"));
+ Map.entry("--reason", "reason"), Map.entry("--limit", "limit"),
+ Map.entry("--binding-id", "bindingId"),
+ Map.entry("--binding-commitment", "bindingCommitment"),
+ Map.entry("--origin", "origin"), Map.entry("--role", "role"));
private static final int RUN_TOKEN_COUNT = 2;
private PkiCli() {
@@ -263,6 +266,9 @@ public final class PkiCli {
writer.println("Usage: zeroecho pki --config [--output human|json] [arguments]");
writer.println(" zeroecho pki run --config [--output human|json]");
writer.println("Operations:");
+ writer.println(" algorithm.binding.list --limit <1..1000> [--origin ] [--role ]");
+ writer.println(" algorithm.binding.inspect --binding-id ");
+ writer.println(" algorithm.binding.validate [--binding-id --binding-commitment ]");
writer.println(" configuration.validate");
writer.println(" profile.validate --profile-file ");
writer.println(" profile.register --profile-file ");
diff --git a/app/src/main/java/zeroecho/pki/cli/PkiCliConfiguration.java b/app/src/main/java/zeroecho/pki/cli/PkiCliConfiguration.java
index cb40775..d47fe43 100644
--- a/app/src/main/java/zeroecho/pki/cli/PkiCliConfiguration.java
+++ b/app/src/main/java/zeroecho/pki/cli/PkiCliConfiguration.java
@@ -48,10 +48,17 @@ import zeroecho.pki.spi.ProviderConfig;
final class PkiCliConfiguration {
private static final int VERSION_ONE = 1;
+ private static final int VERSION_TWO = 2;
+ private static final int VERSION_THREE = 3;
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 ROOT_FIELDS_V3 = Set.of("version", "store", "audit", "signing", "publishers",
+ "bindingProviders");
private static final Set PROVIDER_FIELDS = Set.of("provider", "properties");
private static final Set SIGNING_FIELDS = Set.of("workflow", "framework", "busPath",
+ "signatureAlgorithm", "signingTtlSeconds", "unlockEnvironmentVariable",
+ "certificateSignatureBinding", "crlSignatureBinding", "subjectPublicKeyBinding");
+ private static final Set SIGNING_REQUIRED_FIELDS = Set.of("workflow", "framework", "busPath",
"signatureAlgorithm", "signingTtlSeconds", "unlockEnvironmentVariable");
private static final String STDOUT_PROVIDER = "stdout";
@@ -64,8 +71,12 @@ final class PkiCliConfiguration {
int version = Math.toIntExact(integer(required(root, "version")));
if (version == VERSION_ONE) {
requireExactFields(root.fields(), ROOT_FIELDS_V1);
+ } else if (version == VERSION_TWO) {
+ requireFields(root.fields(), ROOT_FIELDS_V2);
+ } else if (version == VERSION_THREE) {
+ requireFields(root.fields(), ROOT_FIELDS_V3);
} else {
- requireVersionTwoFields(root.fields());
+ throw new IllegalArgumentException("Unsupported CLI configuration version");
}
ProviderConfig store = provider(required(root, "store"));
ProviderConfig audit = provider(required(root, "audit"));
@@ -80,23 +91,46 @@ final class PkiCliConfiguration {
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);
+ PkiOperationValue bindingProviderValue = root.fields().get("bindingProviders");
+ List bindingProviders = bindingProviderValue == null
+ ? List.of() : list(bindingProviderValue).values().stream()
+ .map(PkiCliConfiguration::bindingProvider).toList();
+ return new PkiSessionConfiguration(version, store, audit, signing, publishers, bindingProviders);
}
- private static void requireVersionTwoFields(Map actual) {
- if (!actual.keySet().containsAll(ROOT_FIELDS_V1) || !ROOT_FIELDS_V2.containsAll(actual.keySet())) {
+ private static void requireFields(Map actual, Set allowed) {
+ if (!actual.keySet().containsAll(ROOT_FIELDS_V1) || !allowed.containsAll(actual.keySet())) {
throw new IllegalArgumentException("CLI document fields are invalid");
}
}
+ private static PkiSessionConfiguration.BindingProviderConfiguration bindingProvider(PkiOperationValue value) {
+ PkiOperationValue.ObjectValue object = object(value);
+ Set fields = object.fields().keySet();
+ if (!fields.containsAll(Set.of("providerId", "authorizedOidRoots"))
+ || !Set.of("providerId", "authorizedOidRoots", "expectedBindingSetVersion").containsAll(fields)) {
+ throw new IllegalArgumentException("Binding-provider configuration fields are invalid");
+ }
+ List roots = list(required(object, "authorizedOidRoots")).values().stream()
+ .map(PkiCliConfiguration::text).toList();
+ PkiOperationValue expected = object.fields().get("expectedBindingSetVersion");
+ return new PkiSessionConfiguration.BindingProviderConfiguration(text(required(object, "providerId")), roots,
+ expected == null ? java.util.Optional.empty() : java.util.Optional.of(text(expected)));
+ }
+
private static PkiSessionConfiguration.SigningConfiguration signing(PkiOperationValue value) {
PkiOperationValue.ObjectValue object = object(value);
- requireExactFields(object.fields(), SIGNING_FIELDS);
+ if (!object.fields().keySet().containsAll(SIGNING_REQUIRED_FIELDS)
+ || !SIGNING_FIELDS.containsAll(object.fields().keySet())) {
+ throw new IllegalArgumentException("Signing configuration fields are invalid");
+ }
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"))));
+ java.util.Optional.of(text(required(object, "unlockEnvironmentVariable"))),
+ optionalText(object, "certificateSignatureBinding"), optionalText(object, "crlSignatureBinding"),
+ optionalText(object, "subjectPublicKeyBinding"));
}
private static ProviderConfig provider(PkiOperationValue value) {
@@ -140,6 +174,11 @@ final class PkiCliConfiguration {
throw new IllegalArgumentException("CLI field has the wrong type");
}
+ private static java.util.Optional optionalText(PkiOperationValue.ObjectValue object, String field) {
+ PkiOperationValue value = object.fields().get(field);
+ return value == null ? java.util.Optional.empty() : java.util.Optional.of(text(value));
+ }
+
/* default */ static long integer(PkiOperationValue value) {
if (value instanceof PkiOperationValue.IntegerValue integer) {
return integer.value();
diff --git a/app/src/main/java/zeroecho/pki/cli/PkiOperationRegistry.java b/app/src/main/java/zeroecho/pki/cli/PkiOperationRegistry.java
index d60f223..1796892 100644
--- a/app/src/main/java/zeroecho/pki/cli/PkiOperationRegistry.java
+++ b/app/src/main/java/zeroecho/pki/cli/PkiOperationRegistry.java
@@ -68,12 +68,16 @@ final class PkiOperationRegistry {
private static final String PROFILE_ID = "profileId";
private static final String PROFILE_VERSION = "profileVersion";
private static final String REQUEST_ID = "requestId";
+ private static final String BINDING_ID = "bindingId";
private static final int MAXIMUM_REQUEST_BYTES = 1024 * 1024;
private final Map bindings;
/* default */ PkiOperationRegistry() {
Map configured = new LinkedHashMap<>();
+ add(configured, PkiOperation.ListAlgorithmBindings.NAME, this::listAlgorithmBindings);
+ add(configured, PkiOperation.InspectAlgorithmBinding.NAME, this::inspectAlgorithmBinding);
+ add(configured, PkiOperation.ValidateAlgorithmBindings.NAME, this::validateAlgorithmBindings);
add(configured, PkiOperation.ValidateConfiguration.NAME, this::configuration);
add(configured, PkiOperation.ValidateProfile.NAME, this::profile);
add(configured, PkiOperation.RegisterProfile.NAME, this::registerProfile);
@@ -123,6 +127,29 @@ final class PkiOperationRegistry {
return new PkiOperation.ValidateConfiguration();
}
+ private PkiOperation listAlgorithmBindings(PkiOperationValue.ObjectValue arguments, Path baseDirectory) {
+ Set supplied = arguments.fields().keySet();
+ if (!supplied.contains(LIMIT) || !Set.of(LIMIT, "origin", "role").containsAll(supplied)) {
+ throw new IllegalArgumentException("PKI operation arguments are invalid");
+ }
+ return new PkiOperation.ListAlgorithmBindings(optionalText(arguments, "origin"),
+ optionalText(arguments, "role"), limit(arguments));
+ }
+
+ private PkiOperation inspectAlgorithmBinding(PkiOperationValue.ObjectValue arguments, Path baseDirectory) {
+ requireFields(arguments, Set.of(BINDING_ID));
+ return new PkiOperation.InspectAlgorithmBinding(text(arguments, BINDING_ID));
+ }
+
+ private PkiOperation validateAlgorithmBindings(PkiOperationValue.ObjectValue arguments, Path baseDirectory) {
+ Set supplied = arguments.fields().keySet();
+ if (!(supplied.isEmpty() || supplied.equals(Set.of(BINDING_ID, "bindingCommitment")))) {
+ throw new IllegalArgumentException("PKI operation arguments are invalid");
+ }
+ return new PkiOperation.ValidateAlgorithmBindings(optionalText(arguments, BINDING_ID),
+ optionalText(arguments, "bindingCommitment"));
+ }
+
private PkiOperation profile(PkiOperationValue.ObjectValue arguments, Path baseDirectory) throws IOException {
requireFields(arguments, Set.of(PROFILE_FILE));
Path path = resolve(baseDirectory, text(arguments, PROFILE_FILE));
@@ -296,6 +323,11 @@ final class PkiOperationRegistry {
return PkiCliConfiguration.text(PkiCliConfiguration.required(arguments, name));
}
+ private static java.util.Optional optionalText(PkiOperationValue.ObjectValue arguments, String name) {
+ PkiOperationValue value = arguments.fields().get(name);
+ return value == null ? java.util.Optional.empty() : java.util.Optional.of(PkiCliConfiguration.text(value));
+ }
+
private static long integer(PkiOperationValue.ObjectValue arguments, String name) {
return PkiCliConfiguration.integer(PkiCliConfiguration.required(arguments, name));
}
diff --git a/app/src/test/java/zeroecho/pki/cli/PkiCliTest.java b/app/src/test/java/zeroecho/pki/cli/PkiCliTest.java
index 448681d..ddee736 100644
--- a/app/src/test/java/zeroecho/pki/cli/PkiCliTest.java
+++ b/app/src/test/java/zeroecho/pki/cli/PkiCliTest.java
@@ -168,6 +168,33 @@ class PkiCliTest {
System.out.println("...ok");
}
+ @Test
+ void bindingAdministrationUsesDirectAndBatchTypedExecution() throws IOException {
+ System.out.println("bindingAdministrationUsesDirectAndBatchTypedExecution");
+ Path configuration = configuration("bindings");
+ ByteArrayOutputStream listed = new ByteArrayOutputStream();
+ int listCode = PkiCli.execute(new String[] { "algorithm.binding.list", "--limit", "64", "--config",
+ configuration.toString(), "--output", "json" }, listed);
+ String bindingId = "zeroecho.private.sphincs-plus-default.certificate-signature.v1";
+ Path workflow = plan("binding-workflow.json", """
+ {"version":1,"failurePolicy":"FAIL_FAST","operations":[
+ {"id":"binding","operation":"algorithm.binding.inspect","arguments":{"bindingId":"%s"}},
+ {"id":"validate","operation":"algorithm.binding.validate","arguments":{"bindingId":"${binding.bindingId}","bindingCommitment":"${binding.bindingCommitment}"}}
+ ]}
+ """.formatted(bindingId));
+ ByteArrayOutputStream batch = new ByteArrayOutputStream();
+ int batchCode = PkiCli.execute(new String[] { "run", workflow.toString(), "--config",
+ configuration.toString(), "--output", "json" }, batch);
+ String output = listed.toString(StandardCharsets.UTF_8) + batch.toString(StandardCharsets.UTF_8);
+ System.out.println("...outputBytes=" + output.length());
+ assertEquals(PkiExitCodes.SUCCESS, listCode);
+ assertEquals(PkiExitCodes.SUCCESS, batchCode);
+ assertTrue(output.contains("1.3.6.1.4.1.31424.1.1.3"));
+ assertTrue(output.contains("ZEROECHO_PRIVATE"));
+ assertFalse(output.contains("codecClass"));
+ System.out.println("...ok");
+ }
+
@Test
void versionTwoConfigurationKeepsCapabilitiesExplicit() throws IOException {
System.out.println("versionTwoConfigurationKeepsCapabilitiesExplicit");
@@ -393,6 +420,40 @@ class PkiCliTest {
throw new UnsupportedOperationException();
}
+ @Override
+ public zeroecho.pki.api.algorithm.X509AlgorithmBindingRegistry algorithmBindings() {
+ return new zeroecho.pki.api.algorithm.X509AlgorithmBindingRegistry() {
+ @Override
+ public java.util.List bindings() {
+ return java.util.List.of();
+ }
+
+ @Override
+ public java.util.Optional find(
+ String bindingId) {
+ return java.util.Optional.empty();
+ }
+
+ @Override
+ public java.util.Optional standard(
+ zeroecho.core.spec.AlgorithmIdentity identity,
+ zeroecho.pki.api.algorithm.X509AlgorithmBinding.Role role) {
+ return java.util.Optional.empty();
+ }
+
+ @Override
+ public String commitment() {
+ return "test-registry";
+ }
+
+ @Override
+ public zeroecho.pki.api.algorithm.X509AlgorithmBinding require(String bindingId,
+ String expectedCommitment) {
+ throw new IllegalArgumentException("inactive test binding");
+ }
+ };
+ }
+
@Override
public zeroecho.pki.application.PkiOperationExecutor operations() {
return (operation, cancellation) -> outcome(operation);
diff --git a/app/src/test/resources/pki-administration-config.json b/app/src/test/resources/pki-administration-config.json
index 9787fbc..1afee31 100644
--- a/app/src/test/resources/pki-administration-config.json
+++ b/app/src/test/resources/pki-administration-config.json
@@ -1,5 +1,5 @@
{
- "version": 2,
+ "version": 3,
"store": {
"provider": "fs",
"properties": {
diff --git a/app/src/test/resources/pki-administration-plan.json b/app/src/test/resources/pki-administration-plan.json
index e1eee8c..6ef8319 100644
--- a/app/src/test/resources/pki-administration-plan.json
+++ b/app/src/test/resources/pki-administration-plan.json
@@ -7,6 +7,21 @@
"operation": "configuration.validate",
"arguments": {}
},
+ {
+ "id": "inspect-binding",
+ "operation": "algorithm.binding.inspect",
+ "arguments": {
+ "bindingId": "zeroecho.private.sphincs-plus-default.certificate-signature.v1"
+ }
+ },
+ {
+ "id": "validate-binding",
+ "operation": "algorithm.binding.validate",
+ "arguments": {
+ "bindingId": "${inspect-binding.bindingId}",
+ "bindingCommitment": "${inspect-binding.bindingCommitment}"
+ }
+ },
{
"id": "list-publications",
"operation": "publication.list",
diff --git a/app/src/test/resources/pki-private-sphincs-profile.json b/app/src/test/resources/pki-private-sphincs-profile.json
new file mode 100644
index 0000000..33feef5
--- /dev/null
+++ b/app/src/test/resources/pki-private-sphincs-profile.json
@@ -0,0 +1 @@
+{"schemaVersion":3,"certificateType":"END_ENTITY","profileId":"server-tls-sphincs","profileVersion":1,"formatId":"x509","displayName":"Server TLS SPHINCS+ private binding","maxValidity":"PT8760H","algorithmBindings":{"mode":"EXPLICIT","subjectPublicKey":{"bindingId":"zeroecho.private.sphincs-plus-default.spki.v1","semanticCommitment":"zeroecho.private.sphincs-plus-default.spki.v1|zealg:2:10:public_key8:zeroecho12:sphincs-plus16:zeroecho.builtin6:bm9uZQ|SUBJECT_PUBLIC_KEY|1.3.6.1.4.1.31424.1.1.1|1|ABSENT|NESTED_SPKI_DER|NOT_APPLICABLE|ZEROECHO_ECOSYSTEM"},"csrSignature":{"bindingId":"zeroecho.private.sphincs-plus-default.csr-signature.v1","semanticCommitment":"zeroecho.private.sphincs-plus-default.csr-signature.v1|zealg:2:9:signature8:zeroecho12:sphincs-plus16:zeroecho.builtin6:bm9uZQ|CSR_SIGNATURE|1.3.6.1.4.1.31424.1.1.2|1|ABSENT|NOT_APPLICABLE|OPAQUE|ZEROECHO_ECOSYSTEM"},"certificateSignature":{"bindingId":"zeroecho.private.sphincs-plus-default.certificate-signature.v1","semanticCommitment":"zeroecho.private.sphincs-plus-default.certificate-signature.v1|zealg:2:9:signature8:zeroecho12:sphincs-plus16:zeroecho.builtin6:bm9uZQ|CERTIFICATE_SIGNATURE|1.3.6.1.4.1.31424.1.1.3|1|ABSENT|NOT_APPLICABLE|OPAQUE|ZEROECHO_ECOSYSTEM"}},"subject":{"allowEmpty":true,"rules":[{"oid":"2.5.4.3","source":"REQUESTER","minimumOccurrences":0,"maximumOccurrences":1,"maximumUtf8Bytes":253}]},"subjectAlternativeNames":{"minimumTotal":1,"maximumTotal":64,"serviceIdentityRequired":true,"emailIdentityRequired":false,"criticalWhenSubjectNonEmpty":false,"rules":[{"type":"DNS_NAME","minimumOccurrences":0,"maximumOccurrences":64,"wildcardAllowed":false},{"type":"IP_ADDRESS","minimumOccurrences":0,"maximumOccurrences":16,"ipv4Allowed":true,"ipv6Allowed":true}]},"leafCertificate":{"basicConstraintsCritical":true,"keyUsageCritical":true,"keyUsage":["DIGITAL_SIGNATURE"],"extendedKeyUsageCritical":false,"extendedKeyUsage":["1.3.6.1.5.5.7.3.1"],"allowedKeyAlgorithms":["SPHINCS+"]}}
diff --git a/lib/src/main/java/zeroecho/core/alg/BootstrapAlgorithmIdentities.java b/lib/src/main/java/zeroecho/core/alg/BootstrapAlgorithmIdentities.java
index 2d92151..eb6d2e7 100644
--- a/lib/src/main/java/zeroecho/core/alg/BootstrapAlgorithmIdentities.java
+++ b/lib/src/main/java/zeroecho/core/alg/BootstrapAlgorithmIdentities.java
@@ -63,6 +63,7 @@ public final class BootstrapAlgorithmIdentities {
private static final AlgorithmIdentity.Family ECDSA = family("ecdsa");
private static final AlgorithmIdentity.Family ED25519_FAMILY = family("ed25519");
private static final AlgorithmIdentity.Family ED448_FAMILY = family("ed448");
+ private static final AlgorithmIdentity.Family SPHINCS_PLUS_FAMILY = family("sphincs-plus");
private static final AlgorithmIdentity.Family RSA_KEY = family("rsa");
private static final AlgorithmIdentity.Family EC_KEY = family("ec");
@@ -99,6 +100,9 @@ public final class BootstrapAlgorithmIdentities {
/** Ed448 signature identity. */
public static final AlgorithmIdentity ED448_SIGNATURE = identity(AlgorithmIdentity.Kind.SIGNATURE, ED448_FAMILY,
AlgorithmIdentity.NoParameters.INSTANCE);
+ /** ZeroEcho SPHINCS+ default signature identity. */
+ public static final AlgorithmIdentity SPHINCS_PLUS_SIGNATURE = identity(AlgorithmIdentity.Kind.SIGNATURE,
+ SPHINCS_PLUS_FAMILY, AlgorithmIdentity.NoParameters.INSTANCE);
/** RSA public-key identity. */
public static final AlgorithmIdentity RSA_PUBLIC_KEY = identity(AlgorithmIdentity.Kind.PUBLIC_KEY, RSA_KEY,
@@ -115,6 +119,9 @@ public final class BootstrapAlgorithmIdentities {
/** Ed448 public-key identity. */
public static final AlgorithmIdentity ED448_PUBLIC_KEY = identity(AlgorithmIdentity.Kind.PUBLIC_KEY, ED448_FAMILY,
AlgorithmIdentity.NoParameters.INSTANCE);
+ /** ZeroEcho SPHINCS+ default public-key identity. */
+ public static final AlgorithmIdentity SPHINCS_PLUS_PUBLIC_KEY = identity(AlgorithmIdentity.Kind.PUBLIC_KEY,
+ SPHINCS_PLUS_FAMILY, AlgorithmIdentity.NoParameters.INSTANCE);
/** Current ECDSA P-256 signing suite. */
public static final AlgorithmSuite ECDSA_SHA256_P256 = new AlgorithmSuite(ECDSA_SHA256, EC_P256_PUBLIC_KEY);
@@ -127,8 +134,9 @@ public final class BootstrapAlgorithmIdentities {
private static final List IDENTITIES = List.of(SHA256, SHA384, SHA512, MGF1, RSA_PKCS1_SHA256,
RSA_PKCS1_SHA384, RSA_PKCS1_SHA512, RSA_PSS_SHA256, ECDSA_SHA256, ECDSA_SHA384, ECDSA_SHA512,
- ED25519_SIGNATURE, ED448_SIGNATURE, RSA_PUBLIC_KEY, EC_P256_PUBLIC_KEY, EC_P384_PUBLIC_KEY,
- EC_P521_PUBLIC_KEY, ED25519_PUBLIC_KEY, ED448_PUBLIC_KEY);
+ ED25519_SIGNATURE, ED448_SIGNATURE, SPHINCS_PLUS_SIGNATURE, RSA_PUBLIC_KEY, EC_P256_PUBLIC_KEY,
+ EC_P384_PUBLIC_KEY, EC_P521_PUBLIC_KEY, ED25519_PUBLIC_KEY, ED448_PUBLIC_KEY,
+ SPHINCS_PLUS_PUBLIC_KEY);
private static final AlgorithmIdentityCatalog CATALOG = AlgorithmIdentityCatalog.builtIn(IDENTITIES);
@@ -141,7 +149,8 @@ public final class BootstrapAlgorithmIdentities {
Map.entry("SHA384withECDSA", ECDSA_SHA384),
Map.entry("SHA512withECDSA", ECDSA_SHA512),
Map.entry("Ed25519", ED25519_SIGNATURE),
- Map.entry("Ed448", ED448_SIGNATURE));
+ Map.entry("Ed448", ED448_SIGNATURE),
+ Map.entry("SPHINCS+", SPHINCS_PLUS_SIGNATURE));
private BootstrapAlgorithmIdentities() {
}
diff --git a/lib/src/main/java/zeroecho/core/alg/common/sig/SignatureInteropProfiles.java b/lib/src/main/java/zeroecho/core/alg/common/sig/SignatureInteropProfiles.java
index b6cd0a9..895383d 100644
--- a/lib/src/main/java/zeroecho/core/alg/common/sig/SignatureInteropProfiles.java
+++ b/lib/src/main/java/zeroecho/core/alg/common/sig/SignatureInteropProfiles.java
@@ -118,7 +118,9 @@ public final class SignatureInteropProfiles {
new SignatureInteropProfile("Ed25519", "Ed25519", "Ed25519", VoidSpec.INSTANCE,
SignatureInteropProfile.SignatureRepresentation.IDENTITY, 0)),
Map.entry("Ed448", new SignatureInteropProfile("Ed448", "Ed448", "Ed448", VoidSpec.INSTANCE,
- SignatureInteropProfile.SignatureRepresentation.IDENTITY, 0)));
+ SignatureInteropProfile.SignatureRepresentation.IDENTITY, 0)),
+ Map.entry("SPHINCS+", new SignatureInteropProfile("SPHINCS+", "SPHINCS+", "SPHINCS+",
+ VoidSpec.INSTANCE, SignatureInteropProfile.SignatureRepresentation.IDENTITY, 0)));
private static final Map CANONICAL_PROFILES = Map.ofEntries(
canonical(BootstrapAlgorithmIdentities.RSA_PKCS1_SHA256, "SHA256withRSA"),
@@ -129,7 +131,8 @@ public final class SignatureInteropProfiles {
canonical(BootstrapAlgorithmIdentities.ECDSA_SHA384, "SHA384withECDSA"),
canonical(BootstrapAlgorithmIdentities.ECDSA_SHA512, "SHA512withECDSA"),
canonical(BootstrapAlgorithmIdentities.ED25519_SIGNATURE, "Ed25519"),
- canonical(BootstrapAlgorithmIdentities.ED448_SIGNATURE, "Ed448"));
+ canonical(BootstrapAlgorithmIdentities.ED448_SIGNATURE, "Ed448"),
+ canonical(BootstrapAlgorithmIdentities.SPHINCS_PLUS_SIGNATURE, "SPHINCS+"));
private SignatureInteropProfiles() {
}
diff --git a/pki/src/main/java/zeroecho/pki/api/algorithm/X509AlgorithmBinding.java b/pki/src/main/java/zeroecho/pki/api/algorithm/X509AlgorithmBinding.java
new file mode 100644
index 0000000..7151a90
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/api/algorithm/X509AlgorithmBinding.java
@@ -0,0 +1,167 @@
+/*******************************************************************************
+ * Copyright (C) 2026, Leo Galambos
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without modification,
+ * are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. All advertising materials mentioning features or use of this software must
+ * display the following acknowledgement:
+ * This product includes software developed by the Egothor project.
+ *
+ * 4. Neither the name of the copyright holder nor the names of its contributors
+ * may be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ ******************************************************************************/
+package zeroecho.pki.api.algorithm;
+
+import java.util.Objects;
+import java.util.Optional;
+
+import zeroecho.core.spec.AlgorithmIdentity;
+
+/**
+ * Immutable safe descriptor of one role-specific X.509 algorithm binding.
+ *
+ * The descriptor separates cryptographic identity, ASN.1 identity, namespace
+ * ownership, encoding rules, and provider implementation. It contains no codec
+ * class, key material, provider secret, or caller-supplied DER.
+ *
+ * @param bindingId stable semantic binding identifier
+ * @param algorithmIdentity exact provider-independent cryptographic identity
+ * @param role exact X.509 use role
+ * @param oid complete dotted-decimal object identifier
+ * @param origin namespace ownership
+ * @param encodingVersion positive immutable encoding-contract version
+ * @param parameterRule canonical AlgorithmIdentifier parameter rule
+ * @param publicKeyEncoding public-key BIT STRING contract
+ * @param signatureEncoding signature BIT STRING contract
+ * @param interoperability finite interoperability classification
+ * @param providerId contributing provider for deployer bindings
+ * @param semanticCommitment deterministic commitment to the complete contract
+ */
+public record X509AlgorithmBinding(String bindingId, AlgorithmIdentity algorithmIdentity, Role role, String oid,
+ Origin origin, int encodingVersion, ParameterRule parameterRule, PublicKeyEncoding publicKeyEncoding,
+ SignatureEncoding signatureEncoding, Interoperability interoperability, Optional providerId,
+ String semanticCommitment) {
+
+ /** X.509 locations whose representations are independently authorized. */
+ public enum Role {
+ /** SubjectPublicKeyInfo algorithm and public-key BIT STRING. */
+ SUBJECT_PUBLIC_KEY,
+ /** PKCS#10 certification-request signature. */
+ CSR_SIGNATURE,
+ /** Certificate signature. */
+ CERTIFICATE_SIGNATURE,
+ /** Certificate-revocation-list signature. */
+ CRL_SIGNATURE
+ }
+
+ /** Authority that owns the OID assignment. */
+ public enum Origin {
+ /** Published standards assignment. */
+ STANDARD,
+ /** Frozen ZeroEcho assignment under the Egothor PEN. */
+ ZEROECHO_PRIVATE,
+ /** Explicitly enabled deployment-owned assignment. */
+ DEPLOYER_PRIVATE
+ }
+
+ /** Canonical AlgorithmIdentifier parameter representation. */
+ public enum ParameterRule {
+ /** Parameters must be absent. */
+ ABSENT,
+ /** Parameters must be canonical DER NULL. */
+ DER_NULL,
+ /** Parameters are a binding-owned canonical DER structure. */
+ STRUCTURED_DER
+ }
+
+ /** Subject-public-key BIT STRING representation. */
+ public enum PublicKeyEncoding {
+ /** This binding is not an SPKI binding. */
+ NOT_APPLICABLE,
+ /** Standard RSA PKCS#1 public-key DER. */
+ RSA_PKCS1_DER,
+ /** SEC1 elliptic-curve point. */
+ EC_POINT,
+ /** Algorithm-defined raw public-key bytes. */
+ RAW,
+ /** Canonical native SPKI DER nested as the BIT STRING payload. */
+ NESTED_SPKI_DER
+ }
+
+ /** Signature BIT STRING representation. */
+ public enum SignatureEncoding {
+ /** This binding is not a signature binding. */
+ NOT_APPLICABLE,
+ /** Algorithm-defined opaque signature bytes. */
+ OPAQUE,
+ /** Canonical DER sequence of positive ECDSA integers. */
+ ECDSA_DER
+ }
+
+ /** Relying-party interoperability expectation. */
+ public enum Interoperability {
+ /** Published standards ecosystems. */
+ STANDARD_INTEROPERABLE,
+ /** Relying parties with matching ZeroEcho bindings. */
+ ZEROECHO_ECOSYSTEM,
+ /** One explicitly configured deployment ecosystem. */
+ DEPLOYER_ECOSYSTEM,
+ /** Experimental representation without general interoperability. */
+ EXPERIMENTAL
+ }
+
+ /** Validates and snapshots one descriptor. */
+ public X509AlgorithmBinding {
+ bindingId = requireText(bindingId, "bindingId");
+ Objects.requireNonNull(algorithmIdentity, "algorithmIdentity");
+ Objects.requireNonNull(role, "role");
+ oid = requireText(oid, "oid");
+ Objects.requireNonNull(origin, "origin");
+ if (encodingVersion <= 0) {
+ throw new IllegalArgumentException("encodingVersion must be positive");
+ }
+ Objects.requireNonNull(parameterRule, "parameterRule");
+ Objects.requireNonNull(publicKeyEncoding, "publicKeyEncoding");
+ Objects.requireNonNull(signatureEncoding, "signatureEncoding");
+ Objects.requireNonNull(interoperability, "interoperability");
+ providerId = Objects.requireNonNull(providerId, "providerId").map(value -> requireText(value, "providerId"));
+ semanticCommitment = requireText(semanticCommitment, "semanticCommitment");
+ if (origin == Origin.DEPLOYER_PRIVATE != providerId.isPresent()) {
+ throw new IllegalArgumentException("Only deployer bindings declare a providerId");
+ }
+ if ((role == Role.SUBJECT_PUBLIC_KEY) != (signatureEncoding == SignatureEncoding.NOT_APPLICABLE)) {
+ throw new IllegalArgumentException("Binding role and signature encoding disagree");
+ }
+ if ((role != Role.SUBJECT_PUBLIC_KEY) != (publicKeyEncoding == PublicKeyEncoding.NOT_APPLICABLE)) {
+ throw new IllegalArgumentException("Binding role and public-key encoding disagree");
+ }
+ }
+
+ private static String requireText(String value, String name) {
+ if (value == null || value.isBlank()) {
+ throw new IllegalArgumentException(name + " must not be blank");
+ }
+ return value;
+ }
+}
diff --git a/pki/src/main/java/zeroecho/pki/api/algorithm/X509AlgorithmBindingRegistry.java b/pki/src/main/java/zeroecho/pki/api/algorithm/X509AlgorithmBindingRegistry.java
new file mode 100644
index 0000000..0003fed
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/api/algorithm/X509AlgorithmBindingRegistry.java
@@ -0,0 +1,81 @@
+/*******************************************************************************
+ * Copyright (C) 2026, Leo Galambos
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without modification,
+ * are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. All advertising materials mentioning features or use of this software must
+ * display the following acknowledgement:
+ * This product includes software developed by the Egothor project.
+ *
+ * 4. Neither the name of the copyright holder nor the names of its contributors
+ * may be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ ******************************************************************************/
+package zeroecho.pki.api.algorithm;
+
+import java.util.List;
+import java.util.Optional;
+
+import zeroecho.core.spec.AlgorithmIdentity;
+
+/**
+ * Immutable active X.509 algorithm-binding registry shared by one PKI session.
+ *
+ * Implementations provide deterministic constant-time identifier lookup and
+ * linear-time ordered listing. Returned collections are immutable snapshots.
+ */
+public interface X509AlgorithmBindingRegistry {
+
+ /** @return deterministic immutable binding order */
+ List bindings();
+
+ /**
+ * Resolves a stable binding identifier.
+ *
+ * @param bindingId stable identifier
+ * @return exact descriptor, or empty when inactive
+ */
+ Optional find(String bindingId);
+
+ /**
+ * Resolves the standard default for one identity and role.
+ *
+ * @param identity exact cryptographic identity
+ * @param role X.509 role
+ * @return official binding, or empty when no official binding exists
+ */
+ Optional standard(AlgorithmIdentity identity, X509AlgorithmBinding.Role role);
+
+ /** @return deterministic SHA-256 commitment to the complete active set */
+ String commitment();
+
+ /**
+ * Requires that one explicit binding remains active with an exact commitment.
+ *
+ * @param bindingId stable binding identifier
+ * @param expectedCommitment persisted binding-contract commitment
+ * @return validated binding
+ * @throws IllegalArgumentException if the binding is absent or changed
+ */
+ X509AlgorithmBinding require(String bindingId, String expectedCommitment);
+}
diff --git a/pki/src/main/java/zeroecho/pki/api/profile/CaCertificatePolicy.java b/pki/src/main/java/zeroecho/pki/api/profile/CaCertificatePolicy.java
index c0a020a..238a4f0 100644
--- a/pki/src/main/java/zeroecho/pki/api/profile/CaCertificatePolicy.java
+++ b/pki/src/main/java/zeroecho/pki/api/profile/CaCertificatePolicy.java
@@ -60,7 +60,8 @@ public record CaCertificatePolicy(boolean basicConstraintsCritical, int pathLeng
/** Maximum supported path-length constraint. */
public static final int MAXIMUM_PATH_LENGTH = 32;
- private static final Set SUPPORTED_SUBJECT_KEY_ALGORITHMS = Set.of("RSA", "ECDSA", "Ed25519", "Ed448");
+ private static final Set SUPPORTED_SUBJECT_KEY_ALGORITHMS = Set.of("RSA", "ECDSA", "Ed25519", "Ed448",
+ "SPHINCS+");
/** Validates and constructs the CA policy. */
public CaCertificatePolicy {
diff --git a/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfile.java b/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfile.java
index a3953be..e4233d4 100644
--- a/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfile.java
+++ b/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfile.java
@@ -56,9 +56,18 @@ import zeroecho.pki.api.FormatId;
* @param maximumValidity positive maximum validity
* @param subjectPolicy complete end-entity subject policy
* @param leafPolicy complete end-entity extension policy
+ * @param algorithmBindingPolicy explicit role-specific X.509 representation policy
*/
public record CertificateProfile(String profileId, FormatId formatId, String displayName, Duration maximumValidity,
- SubjectPolicy subjectPolicy, LeafCertificatePolicy leafPolicy) {
+ SubjectPolicy subjectPolicy, LeafCertificatePolicy leafPolicy,
+ X509AlgorithmBindingPolicy algorithmBindingPolicy) {
+
+ /** Creates a standard-only runtime profile. */
+ public CertificateProfile(String profileId, FormatId formatId, String displayName, Duration maximumValidity,
+ SubjectPolicy subjectPolicy, LeafCertificatePolicy leafPolicy) {
+ this(profileId, formatId, displayName, maximumValidity, subjectPolicy, leafPolicy,
+ X509AlgorithmBindingPolicy.standardOnly());
+ }
/**
* Creates a certificate profile.
@@ -77,7 +86,7 @@ public record CertificateProfile(String profileId, FormatId formatId, String dis
throw new IllegalArgumentException("displayName must not be null/blank");
}
if (maximumValidity == null || maximumValidity.isZero() || maximumValidity.isNegative() || subjectPolicy == null
- || leafPolicy == null) {
+ || leafPolicy == null || algorithmBindingPolicy == null) {
throw new IllegalArgumentException("profile policies and maximum validity must be valid");
}
}
@@ -96,6 +105,7 @@ public record CertificateProfile(String profileId, FormatId formatId, String dis
throw new IllegalArgumentException("Only end-entity definitions have a runtime issuance projection");
}
return new CertificateProfile(definition.profileId(), definition.formatId(), definition.displayName(),
- definition.maximumValidity(), definition.subjectPolicy(), definition.leafPolicy());
+ definition.maximumValidity(), definition.subjectPolicy(), definition.leafPolicy(),
+ definition.algorithmBindingPolicy());
}
}
diff --git a/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfileDefinition.java b/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfileDefinition.java
index 0a8ad0c..c899489 100644
--- a/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfileDefinition.java
+++ b/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfileDefinition.java
@@ -41,8 +41,9 @@ import zeroecho.pki.api.FormatId;
* Immutable, versioned certificate-profile configuration.
*
*
- * Schema version 2 replaces the pre-release version 1 shape. The certificate
- * kind and its closed policy variant are immutable across the definition.
+ * Schema version 3 adds explicit immutable X.509 algorithm-binding selection to
+ * the closed pre-release profile shape. The certificate kind and its closed
+ * policy variant are immutable across the definition.
* Runtime activation state is deliberately excluded.
*
*
@@ -54,13 +55,23 @@ import zeroecho.pki.api.FormatId;
* @param maximumValidity positive maximum validity
* @param subjectPolicy complete subject policy
* @param certificatePolicy closed certificate-specific policy variant
+ * @param algorithmBindingPolicy explicit X.509 representation policy
*/
public record CertificateProfileDefinition(CertificateProfileKind certificateType, String profileId,
long profileVersion, FormatId formatId, String displayName, Duration maximumValidity,
- SubjectPolicy subjectPolicy, CertificatePolicy certificatePolicy) {
+ SubjectPolicy subjectPolicy, CertificatePolicy certificatePolicy,
+ X509AlgorithmBindingPolicy algorithmBindingPolicy) {
/** Current certificate-profile document schema version. */
- public static final int SCHEMA_VERSION = 2;
+ public static final int SCHEMA_VERSION = 3;
+
+ /** Creates a standard-only profile definition. */
+ public CertificateProfileDefinition(CertificateProfileKind certificateType, String profileId,
+ long profileVersion, FormatId formatId, String displayName, Duration maximumValidity,
+ SubjectPolicy subjectPolicy, CertificatePolicy certificatePolicy) {
+ this(certificateType, profileId, profileVersion, formatId, displayName, maximumValidity, subjectPolicy,
+ certificatePolicy, X509AlgorithmBindingPolicy.standardOnly());
+ }
/**
* Creates a certificate-profile definition.
@@ -87,7 +98,7 @@ public record CertificateProfileDefinition(CertificateProfileKind certificateTyp
if (maximumValidity == null || maximumValidity.isZero() || maximumValidity.isNegative()) {
throw new IllegalArgumentException("maximumValidity must be positive");
}
- if (subjectPolicy == null || certificatePolicy == null) {
+ if (subjectPolicy == null || certificatePolicy == null || algorithmBindingPolicy == null) {
throw new IllegalArgumentException("profile policies must not be null");
}
boolean leaf = certificatePolicy instanceof LeafCertificatePolicy;
diff --git a/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfileDocumentCodec.java b/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfileDocumentCodec.java
index f8a4925..cb6ef2b 100644
--- a/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfileDocumentCodec.java
+++ b/pki/src/main/java/zeroecho/pki/api/profile/CertificateProfileDocumentCodec.java
@@ -237,7 +237,7 @@ public final class CertificateProfileDocumentCodec {
requireValue(parser, "$." + field);
readDocumentField(parser, field, document);
}
- requireAll(document.seen, 8, "$");
+ requireAll(document.seen, 9, "$");
if (document.schemaVersion != CertificateProfileDefinition.SCHEMA_VERSION) {
throw failure("SCHEMA_VERSION_UNSUPPORTED", "$.schemaVersion");
}
@@ -286,6 +286,10 @@ public final class CertificateProfileDocumentCodec {
document.seen = mark(document.seen, 7, "$.subject");
document.subject = readSubject(parser, "$.subject");
}
+ case "algorithmBindings" -> {
+ document.seen = mark(document.seen, 8, "$.algorithmBindings");
+ document.algorithmBindings = readAlgorithmBindings(parser, "$.algorithmBindings");
+ }
case "subjectAlternativeNames" -> readDocumentSan(parser, document);
case "leafCertificate" -> readDocumentLeaf(parser, document);
case "caCertificate" -> readDocumentCa(parser, document);
@@ -297,7 +301,7 @@ public final class CertificateProfileDocumentCodec {
if (document.certificateType != null && document.certificateType != CertificateProfileKind.END_ENTITY) {
throw failure("FIELD_FORBIDDEN_FOR_CERTIFICATE_TYPE", "$.subjectAlternativeNames");
}
- document.seen = mark(document.seen, 8, "$.subjectAlternativeNames");
+ document.seen = mark(document.seen, 9, "$.subjectAlternativeNames");
document.san = readSan(parser, "$.subjectAlternativeNames");
}
@@ -305,7 +309,7 @@ public final class CertificateProfileDocumentCodec {
if (document.certificateType != null && document.certificateType != CertificateProfileKind.END_ENTITY) {
throw failure("FIELD_FORBIDDEN_FOR_CERTIFICATE_TYPE", "$.leafCertificate");
}
- document.seen = mark(document.seen, 9, "$.leafCertificate");
+ document.seen = mark(document.seen, 10, "$.leafCertificate");
document.leaf = readLeaf(parser, "$.leafCertificate");
}
@@ -313,14 +317,14 @@ public final class CertificateProfileDocumentCodec {
if (document.certificateType == CertificateProfileKind.END_ENTITY) {
throw failure("FIELD_FORBIDDEN_FOR_CERTIFICATE_TYPE", "$.caCertificate");
}
- document.seen = mark(document.seen, 10, "$.caCertificate");
+ document.seen = mark(document.seen, 11, "$.caCertificate");
document.ca = readCa(parser, "$.caCertificate");
}
private static void requirePolicyFields(CertificateProfileKind certificateType, long seen) {
- boolean hasSan = isSeen(seen, 8);
- boolean hasLeaf = isSeen(seen, 9);
- boolean hasCa = isSeen(seen, 10);
+ boolean hasSan = isSeen(seen, 9);
+ boolean hasLeaf = isSeen(seen, 10);
+ boolean hasCa = isSeen(seen, 11);
if (certificateType == CertificateProfileKind.END_ENTITY) {
if (hasCa) {
throw failure("FIELD_FORBIDDEN_FOR_CERTIFICATE_TYPE", "$.caCertificate");
@@ -366,7 +370,7 @@ public final class CertificateProfileDocumentCodec {
}
return new CertificateProfileDefinition(document.certificateType, document.profileId,
document.profileVersion, new FormatId(document.formatId), document.displayName,
- document.maximumValidity, subjectPolicy, policy);
+ document.maximumValidity, subjectPolicy, policy, document.algorithmBindings);
} catch (IllegalArgumentException | ArithmeticException ex) {
throw failure("SEMANTIC_INVALID", "$");
}
@@ -397,6 +401,89 @@ public final class CertificateProfileDocumentCodec {
return new SubjectSection(allowEmpty, rules);
}
+ private static X509AlgorithmBindingPolicy readAlgorithmBindings(JsonParser parser, String path)
+ throws JacksonException {
+ requireToken(parser.currentToken(), JsonToken.START_OBJECT, path);
+ long seen = 0;
+ X509AlgorithmBindingPolicy.Mode mode = null;
+ Optional subject = Optional.empty();
+ Optional csr = Optional.empty();
+ Optional certificate = Optional.empty();
+ Optional crl = Optional.empty();
+ while (parser.nextToken() != JsonToken.END_OBJECT) {
+ requireToken(parser.currentToken(), JsonToken.PROPERTY_NAME, path);
+ String field = parser.currentName();
+ requireValue(parser, path + "." + field);
+ switch (field) {
+ case "mode" -> {
+ seen = mark(seen, 0, path + ".mode");
+ mode = readEnum(parser, X509AlgorithmBindingPolicy.Mode.class, path + ".mode");
+ }
+ case "subjectPublicKey" -> {
+ seen = mark(seen, 1, path + ".subjectPublicKey");
+ subject = Optional.of(readBindingReference(parser, path + ".subjectPublicKey"));
+ }
+ case "csrSignature" -> {
+ seen = mark(seen, 2, path + ".csrSignature");
+ csr = Optional.of(readBindingReference(parser, path + ".csrSignature"));
+ }
+ case "certificateSignature" -> {
+ seen = mark(seen, 3, path + ".certificateSignature");
+ certificate = Optional.of(readBindingReference(parser, path + ".certificateSignature"));
+ }
+ case "crlSignature" -> {
+ seen = mark(seen, 4, path + ".crlSignature");
+ crl = Optional.of(readBindingReference(parser, path + ".crlSignature"));
+ }
+ default -> throw failure("UNKNOWN_FIELD", path + ".?");
+ }
+ }
+ if (!isSeen(seen, 0)) {
+ throw failure("MISSING_FIELD", path + ".mode");
+ }
+ try {
+ return new X509AlgorithmBindingPolicy(mode, subject, csr, certificate, crl);
+ } catch (IllegalArgumentException exception) {
+ throw failure("SEMANTIC_INVALID", path);
+ }
+ }
+
+ private static X509AlgorithmBindingPolicy.BindingReference readBindingReference(JsonParser parser, String path)
+ throws JacksonException {
+ requireToken(parser.currentToken(), JsonToken.START_OBJECT, path);
+ long seen = 0;
+ String bindingId = null;
+ String commitment = null;
+ while (parser.nextToken() != JsonToken.END_OBJECT) {
+ requireToken(parser.currentToken(), JsonToken.PROPERTY_NAME, path);
+ String field = parser.currentName();
+ requireValue(parser, path + "." + field);
+ switch (field) {
+ case "bindingId" -> {
+ seen = mark(seen, 0, path + ".bindingId");
+ bindingId = readBoundedString(parser, path + ".bindingId", 512);
+ }
+ case "semanticCommitment" -> {
+ seen = mark(seen, 1, path + ".semanticCommitment");
+ commitment = readBoundedString(parser, path + ".semanticCommitment", 1024);
+ }
+ default -> throw failure("UNKNOWN_FIELD", path + ".?");
+ }
+ }
+ requireAll(seen, 2, path);
+ return new X509AlgorithmBindingPolicy.BindingReference(bindingId, commitment);
+ }
+
+ private static > E readEnum(JsonParser parser, Class type, String path)
+ throws JacksonException {
+ String value = readString(parser, path);
+ try {
+ return Enum.valueOf(type, value);
+ } catch (IllegalArgumentException exception) {
+ throw failure("TOKEN_INVALID", path);
+ }
+ }
+
private static List readSubjectRules(JsonParser parser, String path) throws JacksonException {
requireToken(parser.currentToken(), JsonToken.START_ARRAY, path);
List rules = new ArrayList<>();
@@ -785,6 +872,7 @@ public final class CertificateProfileDocumentCodec {
generator.writeStringProperty("formatId", definition.formatId().value());
generator.writeStringProperty("displayName", definition.displayName());
generator.writeStringProperty("maxValidity", definition.maximumValidity().toString());
+ writeAlgorithmBindings(generator, definition.algorithmBindingPolicy());
writeSubject(generator, definition.subjectPolicy());
if (definition.certificateType() == CertificateProfileKind.END_ENTITY) {
LeafCertificatePolicy leaf = definition.leafPolicy();
@@ -816,6 +904,29 @@ public final class CertificateProfileDocumentCodec {
generator.writeEndObject();
}
+ private static void writeAlgorithmBindings(JsonGenerator generator, X509AlgorithmBindingPolicy policy)
+ throws JacksonException {
+ generator.writeObjectPropertyStart("algorithmBindings");
+ generator.writeStringProperty("mode", policy.mode().name());
+ writeBindingReference(generator, "subjectPublicKey", policy.subjectPublicKey());
+ writeBindingReference(generator, "csrSignature", policy.csrSignature());
+ writeBindingReference(generator, "certificateSignature", policy.certificateSignature());
+ writeBindingReference(generator, "crlSignature", policy.crlSignature());
+ generator.writeEndObject();
+ }
+
+ private static void writeBindingReference(JsonGenerator generator, String field,
+ Optional reference) throws JacksonException {
+ if (reference.isEmpty()) {
+ return;
+ }
+ X509AlgorithmBindingPolicy.BindingReference value = reference.orElseThrow();
+ generator.writeObjectPropertyStart(field);
+ generator.writeStringProperty("bindingId", value.bindingId());
+ generator.writeStringProperty("semanticCommitment", value.semanticCommitment());
+ generator.writeEndObject();
+ }
+
private static void writeSan(JsonGenerator generator, SubjectAlternativeNamePolicy policy) throws JacksonException {
generator.writeObjectPropertyStart("subjectAlternativeNames");
generator.writeNumberProperty("minimumTotal", policy.minimumTotal());
@@ -1098,7 +1209,8 @@ public final class CertificateProfileDocumentCodec {
}
private static boolean isAllowedAlgorithm(String value) {
- return "RSA".equals(value) || "ECDSA".equals(value) || "Ed25519".equals(value) || "Ed448".equals(value);
+ return "RSA".equals(value) || "ECDSA".equals(value) || "Ed25519".equals(value) || "Ed448".equals(value)
+ || "SPHINCS+".equals(value);
}
private static boolean hasUtf8Bom(byte[] value) {
@@ -1124,6 +1236,7 @@ public final class CertificateProfileDocumentCodec {
private String formatId;
private String displayName;
private Duration maximumValidity;
+ private X509AlgorithmBindingPolicy algorithmBindings;
private SubjectSection subject;
private SanSection san;
private LeafSection leaf;
diff --git a/pki/src/main/java/zeroecho/pki/api/profile/LeafCertificatePolicy.java b/pki/src/main/java/zeroecho/pki/api/profile/LeafCertificatePolicy.java
index 207da37..3eca65d 100644
--- a/pki/src/main/java/zeroecho/pki/api/profile/LeafCertificatePolicy.java
+++ b/pki/src/main/java/zeroecho/pki/api/profile/LeafCertificatePolicy.java
@@ -52,7 +52,8 @@ public record LeafCertificatePolicy(SubjectAlternativeNamePolicy subjectAlternat
boolean extendedKeyUsageCritical, boolean basicConstraintsCritical, Set allowedSubjectKeyAlgorithmIds)
implements CertificatePolicy {
- private static final Set SUPPORTED_SUBJECT_KEY_ALGORITHMS = Set.of("RSA", "ECDSA", "Ed25519", "Ed448");
+ private static final Set SUPPORTED_SUBJECT_KEY_ALGORITHMS = Set.of("RSA", "ECDSA", "Ed25519", "Ed448",
+ "SPHINCS+");
/**
* Validates and constructs the policy.
diff --git a/pki/src/main/java/zeroecho/pki/api/profile/X509AlgorithmBindingPolicy.java b/pki/src/main/java/zeroecho/pki/api/profile/X509AlgorithmBindingPolicy.java
new file mode 100644
index 0000000..d833662
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/api/profile/X509AlgorithmBindingPolicy.java
@@ -0,0 +1,100 @@
+/*******************************************************************************
+ * Copyright (C) 2026, Leo Galambos
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without modification,
+ * are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. All advertising materials mentioning features or use of this software must
+ * display the following acknowledgement:
+ * This product includes software developed by the Egothor project.
+ *
+ * 4. Neither the name of the copyright holder nor the names of its contributors
+ * may be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ ******************************************************************************/
+package zeroecho.pki.api.profile;
+
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * Immutable explicit X.509 representation policy persisted with a certificate
+ * profile.
+ *
+ * Standard mode never falls back to a private OID. Explicit mode pins stable
+ * binding identifiers and their immutable semantic commitments. Raw OIDs and DER
+ * parameters are deliberately absent from this model.
+ *
+ * @param mode standard-only or explicit binding selection
+ * @param subjectPublicKey optional SPKI binding
+ * @param csrSignature optional CSR-signature binding
+ * @param certificateSignature optional certificate-signature binding
+ * @param crlSignature optional CRL-signature binding
+ */
+public record X509AlgorithmBindingPolicy(Mode mode, Optional subjectPublicKey,
+ Optional csrSignature, Optional certificateSignature,
+ Optional crlSignature) {
+
+ /** Selection mode. */
+ public enum Mode {
+ /** Only sealed official bindings may be selected. */
+ STANDARD_ONLY,
+ /** Every custom representation is selected by stable binding identity. */
+ EXPLICIT
+ }
+
+ /**
+ * Stable reference to one immutable binding contract.
+ *
+ * @param bindingId stable binding identity
+ * @param semanticCommitment exact contract commitment
+ */
+ public record BindingReference(String bindingId, String semanticCommitment) {
+ /** Validates one binding reference. */
+ public BindingReference {
+ if (bindingId == null || bindingId.isBlank() || semanticCommitment == null
+ || semanticCommitment.isBlank() || semanticCommitment.length() > 1024) {
+ throw new IllegalArgumentException("X.509 binding reference is invalid");
+ }
+ }
+ }
+
+ /** Validates and snapshots the policy. */
+ public X509AlgorithmBindingPolicy {
+ Objects.requireNonNull(mode, "mode");
+ subjectPublicKey = Objects.requireNonNull(subjectPublicKey, "subjectPublicKey");
+ csrSignature = Objects.requireNonNull(csrSignature, "csrSignature");
+ certificateSignature = Objects.requireNonNull(certificateSignature, "certificateSignature");
+ crlSignature = Objects.requireNonNull(crlSignature, "crlSignature");
+ boolean selected = subjectPublicKey.isPresent() || csrSignature.isPresent()
+ || certificateSignature.isPresent() || crlSignature.isPresent();
+ if (mode == Mode.STANDARD_ONLY == selected) {
+ throw new IllegalArgumentException("X.509 binding mode and selections disagree");
+ }
+ }
+
+ /** @return standard-only policy with no private fallback */
+ public static X509AlgorithmBindingPolicy standardOnly() {
+ return new X509AlgorithmBindingPolicy(Mode.STANDARD_ONLY, Optional.empty(), Optional.empty(),
+ Optional.empty(), Optional.empty());
+ }
+}
diff --git a/pki/src/main/java/zeroecho/pki/application/DefaultPkiOperationExecutor.java b/pki/src/main/java/zeroecho/pki/application/DefaultPkiOperationExecutor.java
index d92f6b2..9bc7229 100644
--- a/pki/src/main/java/zeroecho/pki/application/DefaultPkiOperationExecutor.java
+++ b/pki/src/main/java/zeroecho/pki/application/DefaultPkiOperationExecutor.java
@@ -53,6 +53,8 @@ import zeroecho.pki.api.PublicationService;
import zeroecho.pki.api.ProfileService;
import zeroecho.pki.api.RevocationService;
import zeroecho.pki.api.StatusObjectService;
+import zeroecho.pki.api.algorithm.X509AlgorithmBinding;
+import zeroecho.pki.api.algorithm.X509AlgorithmBindingRegistry;
import zeroecho.pki.api.ca.CaRecord;
import zeroecho.pki.api.credential.CaProfileBinding;
import zeroecho.pki.api.credential.Credential;
@@ -62,6 +64,7 @@ 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.profile.X509AlgorithmBindingPolicy;
import zeroecho.pki.api.issuance.IssueEndEntityCommand;
import zeroecho.pki.api.issuance.VerificationPolicy;
import zeroecho.pki.api.publication.PublicationRequest;
@@ -101,12 +104,13 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor {
private final Optional issuance;
private final Optional statusObjects;
private final Optional publications;
+ private final X509AlgorithmBindingRegistry algorithmBindings;
private final Runnable openCheck;
/* default */ DefaultPkiOperationExecutor(PkiSessionConfiguration configuration, PkiStore store,
ProfileService profiles, RevocationService revocations, Runnable openCheck) {
this(configuration, store, profiles, revocations, Optional.empty(), Optional.empty(), Optional.empty(),
- Optional.empty(), Optional.empty(), openCheck);
+ Optional.empty(), Optional.empty(), defaultBindings(), openCheck);
}
/* default */ DefaultPkiOperationExecutor(PkiSessionConfiguration configuration, PkiStore store,
@@ -114,6 +118,16 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor {
Optional requests, Optional issuance,
Optional statusObjects, Optional publications,
Runnable openCheck) {
+ this(configuration, store, profiles, revocations, authorities, requests, issuance, statusObjects,
+ publications, defaultBindings(), openCheck);
+ }
+
+ /* default */ DefaultPkiOperationExecutor(PkiSessionConfiguration configuration, PkiStore store,
+ ProfileService profiles, RevocationService revocations, Optional authorities,
+ Optional requests, Optional issuance,
+ Optional statusObjects, Optional publications,
+ X509AlgorithmBindingRegistry algorithmBindings,
+ Runnable openCheck) {
this.configuration = Objects.requireNonNull(configuration, "configuration");
this.store = Objects.requireNonNull(store, "store");
this.profiles = Objects.requireNonNull(profiles, "profiles");
@@ -123,6 +137,7 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor {
this.issuance = Objects.requireNonNull(issuance, "issuance");
this.statusObjects = Objects.requireNonNull(statusObjects, "statusObjects");
this.publications = Objects.requireNonNull(publications, "publications");
+ this.algorithmBindings = Objects.requireNonNull(algorithmBindings, "algorithmBindings");
this.openCheck = Objects.requireNonNull(openCheck, "openCheck");
}
@@ -134,6 +149,9 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor {
try {
signal.throwIfCancelled();
PkiOperationResult result = switch (exact) {
+ case PkiOperation.ListAlgorithmBindings request -> listAlgorithmBindings(request);
+ case PkiOperation.InspectAlgorithmBinding request -> inspectAlgorithmBinding(request);
+ case PkiOperation.ValidateAlgorithmBindings request -> validateAlgorithmBindings(request);
case PkiOperation.ValidateConfiguration ignored -> validateConfiguration();
case PkiOperation.ValidateProfile request -> validateProfile(request);
case PkiOperation.RegisterProfile request -> registerProfile(request);
@@ -162,7 +180,7 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor {
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) {
return failure(exact.name(), PkiOperationFailure.CANCELLED, "OPERATION_CANCELLED");
@@ -173,6 +191,61 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor {
}
}
+ private PkiOperationResult listAlgorithmBindings(PkiOperation.ListAlgorithmBindings request) {
+ List values = algorithmBindings.bindings().stream()
+ .filter(binding -> request.origin().isEmpty() || binding.origin().name().equals(request.origin().get()))
+ .filter(binding -> request.role().isEmpty() || binding.role().name().equals(request.role().get()))
+ .limit(request.limit()).map(binding -> (PkiOperationValue) bindingValue(binding)).toList();
+ Map fields = fields();
+ fields.put(COUNT, integer(values.size()));
+ fields.put("registryCommitment", text(algorithmBindings.commitment()));
+ fields.put("bindings", new PkiOperationValue.ListValue(values));
+ return result(request.name(), fields);
+ }
+
+ private PkiOperationResult inspectAlgorithmBinding(PkiOperation.InspectAlgorithmBinding request) {
+ X509AlgorithmBinding binding = algorithmBindings.find(request.bindingId())
+ .orElseThrow(MissingObjectException::new);
+ Map fields = fields();
+ fields.putAll(bindingValue(binding).fields());
+ fields.put("registryCommitment", text(algorithmBindings.commitment()));
+ return result(request.name(), fields);
+ }
+
+ private PkiOperationResult validateAlgorithmBindings(PkiOperation.ValidateAlgorithmBindings request) {
+ request.bindingId().ifPresent(bindingId -> algorithmBindings.require(bindingId,
+ request.expectedCommitment().orElseThrow()));
+ Map fields = fields();
+ fields.put("valid", bool(true));
+ fields.put(COUNT, integer(algorithmBindings.bindings().size()));
+ fields.put("registryCommitment", text(algorithmBindings.commitment()));
+ return result(request.name(), fields);
+ }
+
+ private static PkiOperationValue.ObjectValue bindingValue(X509AlgorithmBinding binding) {
+ Map fields = fields();
+ fields.put("bindingId", text(binding.bindingId()));
+ fields.put("algorithmId", text(binding.algorithmIdentity().canonicalForm()));
+ fields.put("role", text(binding.role().name()));
+ fields.put("oid", text(binding.oid()));
+ fields.put("origin", text(binding.origin().name()));
+ fields.put("encodingVersion", integer(binding.encodingVersion()));
+ fields.put("parameterRule", text(binding.parameterRule().name()));
+ fields.put("publicKeyEncoding", text(binding.publicKeyEncoding().name()));
+ fields.put("signatureEncoding", text(binding.signatureEncoding().name()));
+ fields.put("interoperability", text(binding.interoperability().name()));
+ fields.put("bindingCommitment", text(binding.semanticCommitment()));
+ binding.providerId().ifPresent(value -> fields.put("providerId", text(value)));
+ return new PkiOperationValue.ObjectValue(fields);
+ }
+
+ private static X509AlgorithmBindingRegistry defaultBindings() {
+ List descriptors = new ArrayList<>(
+ zeroecho.pki.impl.framework.x509.StandardX509Bindings.descriptors());
+ descriptors.addAll(zeroecho.pki.impl.framework.x509.ZeroEchoPrivateX509Bindings.descriptors());
+ return zeroecho.pki.impl.framework.x509.ImmutableX509AlgorithmBindingRegistry.create(descriptors);
+ }
+
private PkiOperationResult validateConfiguration() {
Map fields = fields();
fields.put("version", integer(configuration.version()));
@@ -181,8 +254,9 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor {
return result(PkiOperation.ValidateConfiguration.NAME, fields);
}
- private static PkiOperationResult validateProfile(PkiOperation.ValidateProfile request) {
+ private PkiOperationResult validateProfile(PkiOperation.ValidateProfile request) {
CertificateProfileDefinition definition = CertificateProfileDocumentCodec.parse(request.document());
+ validateProfileBindings(definition.algorithmBindingPolicy());
Map fields = fields();
fields.put("profileId", text(definition.profileId()));
fields.put("profileVersion", integer(definition.profileVersion()));
@@ -191,6 +265,26 @@ final class DefaultPkiOperationExecutor implements PkiOperationExecutor {
return result(request.name(), fields);
}
+ private void validateProfileBindings(X509AlgorithmBindingPolicy policy) {
+ if (policy.mode() == X509AlgorithmBindingPolicy.Mode.STANDARD_ONLY) {
+ return;
+ }
+ validateProfileBinding(policy.subjectPublicKey(), X509AlgorithmBinding.Role.SUBJECT_PUBLIC_KEY);
+ validateProfileBinding(policy.csrSignature(), X509AlgorithmBinding.Role.CSR_SIGNATURE);
+ validateProfileBinding(policy.certificateSignature(), X509AlgorithmBinding.Role.CERTIFICATE_SIGNATURE);
+ validateProfileBinding(policy.crlSignature(), X509AlgorithmBinding.Role.CRL_SIGNATURE);
+ }
+
+ private void validateProfileBinding(Optional reference,
+ X509AlgorithmBinding.Role role) {
+ reference.ifPresent(value -> {
+ X509AlgorithmBinding binding = algorithmBindings.require(value.bindingId(), value.semanticCommitment());
+ if (binding.role() != role || binding.origin() == X509AlgorithmBinding.Origin.STANDARD) {
+ throw new IllegalArgumentException("Explicit profile binding is invalid for its role");
+ }
+ });
+ }
+
private PkiOperationResult registerProfile(PkiOperation.RegisterProfile request) {
CertificateProfileRef reference = profiles.importProfile(request.document());
return profileReferenceResult(request.name(), reference);
diff --git a/pki/src/main/java/zeroecho/pki/application/DefaultPkiSession.java b/pki/src/main/java/zeroecho/pki/application/DefaultPkiSession.java
index ff28ce4..d231362 100644
--- a/pki/src/main/java/zeroecho/pki/application/DefaultPkiSession.java
+++ b/pki/src/main/java/zeroecho/pki/application/DefaultPkiSession.java
@@ -36,9 +36,13 @@ package zeroecho.pki.application;
import java.nio.file.Path;
import java.time.Clock;
import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
import java.util.Objects;
import java.util.Optional;
+import java.util.ServiceLoader;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -51,11 +55,16 @@ import zeroecho.core.spi.KeyringUnlockProvider;
import zeroecho.core.storage.KeyringPassword;
import zeroecho.pki.api.CaService;
import zeroecho.pki.api.CertificationRequestService;
+import zeroecho.pki.api.EncodedObject;
+import zeroecho.pki.api.Encoding;
+import zeroecho.pki.api.PkiException;
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.algorithm.X509AlgorithmBinding;
+import zeroecho.pki.api.algorithm.X509AlgorithmBindingRegistry;
import zeroecho.pki.api.credential.EffectiveCredentialStatusResolver;
import zeroecho.pki.impl.core.DefaultProfileService;
import zeroecho.pki.impl.core.DefaultRevocationService;
@@ -65,15 +74,22 @@ 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.PublicKeyInfoResolver;
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.ImmutableX509AlgorithmBindingRegistry;
+import zeroecho.pki.impl.framework.x509.StandardX509Bindings;
+import zeroecho.pki.impl.framework.x509.ZeroEchoPrivateX509Bindings;
+import zeroecho.pki.impl.framework.x509.X509BindingRuleProvider;
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.BcX509PublicKeyAdapter;
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.algorithm.X509AlgorithmBindingProvider;
import zeroecho.pki.spi.audit.AuditSink;
import zeroecho.pki.spi.bootstrap.PkiBootstrap;
import zeroecho.pki.spi.crypto.PublicKeyInfoSource;
@@ -83,6 +99,7 @@ import zeroecho.pki.spi.publish.Publisher;
import zeroecho.pki.spi.store.PkiStore;
/** Default synchronous session composition. */
+@SuppressWarnings({ "PMD.CouplingBetweenObjects", "PMD.CyclomaticComplexity" })
final class DefaultPkiSession implements PkiSession {
private final PkiStore store;
@@ -96,11 +113,13 @@ final class DefaultPkiSession implements PkiSession {
private final Optional publications;
private final Optional signingBus;
private final Optional signatureWorkflow;
+ private final X509AlgorithmBindingRegistry algorithmBindings;
private final PkiOperationExecutor operations;
private final AtomicBoolean closed = new AtomicBoolean();
private DefaultPkiSession(PkiSessionConfiguration configuration, PkiStore store, AuditSink audit,
- ProfileService profiles, RevocationService revocations, ServiceGraph graph) {
+ ProfileService profiles, RevocationService revocations, X509AlgorithmBindingRegistry algorithmBindings,
+ ServiceGraph graph) {
this.store = store;
this.audit = audit;
this.profiles = Objects.requireNonNull(profiles, "profiles");
@@ -112,8 +131,9 @@ final class DefaultPkiSession implements PkiSession {
this.publications = graph.publications();
this.signingBus = graph.signingBus();
this.signatureWorkflow = graph.signatureWorkflow();
+ this.algorithmBindings = Objects.requireNonNull(algorithmBindings, "algorithmBindings");
this.operations = new DefaultPkiOperationExecutor(configuration, store, profiles, revocations, authorities,
- requests, issuance, statusObjects, publications, this::requireOpen);
+ requests, issuance, statusObjects, publications, algorithmBindings, this::requireOpen);
}
/* default */ static PkiSession open(PkiSessionConfiguration configuration) {
@@ -146,6 +166,9 @@ final class DefaultPkiSession implements PkiSession {
}
});
exact.publishers().forEach(bootstrap::validatePublisher);
+ BindingComposition bindingComposition = composeBindings(exact);
+ X509AlgorithmBindingRegistry algorithmBindings = bindingComposition.registry();
+ exact.signing().ifPresent(signing -> validateConfiguredBindings(signing, algorithmBindings));
PkiStore store = null;
AuditSink audit = null;
@@ -153,10 +176,15 @@ final class DefaultPkiSession implements PkiSession {
try {
store = Objects.requireNonNull(bootstrap.openStore(exact.store()), "opened store");
audit = Objects.requireNonNull(bootstrap.openAudit(exact.audit()), "opened audit sink");
- ProfileService profiles = new DefaultProfileService(store, clock, audit);
+ ProfileService profiles = new DefaultProfileService(store, clock, audit, algorithmBindings,
+ exact.signing().isPresent(),
+ exact.signing().flatMap(PkiSessionConfiguration.SigningConfiguration::certificateSignatureBinding),
+ exact.signing().flatMap(PkiSessionConfiguration.SigningConfiguration::crlSignatureBinding),
+ exact.signing().flatMap(PkiSessionConfiguration.SigningConfiguration::subjectPublicKeyBinding));
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);
+ graph = composeGraph(exact, dependencies, store, audit, profiles, clock, bootstrap,
+ bindingComposition.providers());
+ return new DefaultPkiSession(exact, store, audit, profiles, revocations, algorithmBindings, graph);
} catch (RuntimeException | Error primary) {
closeGraphAfterConstructionFailure(graph, primary);
closeAfterConstructionFailure(audit, store, primary);
@@ -206,6 +234,12 @@ final class DefaultPkiSession implements PkiSession {
return publications;
}
+ @Override
+ public X509AlgorithmBindingRegistry algorithmBindings() {
+ requireOpen();
+ return algorithmBindings;
+ }
+
@Override
public PkiOperationExecutor operations() {
requireOpen();
@@ -247,7 +281,7 @@ 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) {
+ Clock clock, Bootstrap bootstrap, List bindingProviders) {
Optional workflow = Optional.empty();
Optional bus = Optional.empty();
Optional authorities = Optional.empty();
@@ -267,14 +301,15 @@ final class DefaultPkiSession implements PkiSession {
throw new IllegalStateException("Signature workflow lacks managed public-key capability");
}
BcX509VerificationExecutor verification = new BcX509VerificationExecutor();
- X509AuthoritySnapshot authority = authority(openedWorkflow, verification);
+ X509AuthoritySnapshot authority = authority(openedWorkflow, verification, bindingProviders);
PkiSigningBus openedBus = new PkiSigningBus(store, openedWorkflow, Path.of(signing.busPath()),
authority);
bus = Optional.of(openedBus);
+ AlgorithmIdentity signatureIdentity = authority.resolveIdentity(signing.signatureAlgorithm());
BcX509CredentialIssuerBackend issuerBackend = new BcX509CredentialIssuerBackend(openedBus,
- signing.signatureAlgorithm(), signing.signingTtl());
+ signatureIdentity, signing.certificateSignatureBinding(), signing.signingTtl());
BcX509StatusObjectGenerator statusGenerator = new BcX509StatusObjectGenerator(openedBus,
- signing.signatureAlgorithm(), signing.signingTtl());
+ signatureIdentity, signing.crlSignatureBinding(), signing.signingTtl());
BcX509CredentialFramework framework = new BcX509CredentialFramework(authority, verification)
.wired(statusGenerator, new BcX509ProofOfPossessionVerifier(authority, verification));
EffectiveCredentialStatusResolver statusResolver = new StoreBackedEffectiveCredentialStatusResolver(
@@ -285,8 +320,8 @@ final class DefaultPkiSession implements PkiSession {
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()));
+ publicKeySource(publicKeys, authority, signing), openedBus, audit, statusResolver, profiles, clock,
+ signing.signatureAlgorithm(), signing.certificateSignatureBinding(), signing.signingTtl()));
}
List configuredPublishers = new ArrayList<>();
for (ProviderConfig publisher : configuration.publishers()) {
@@ -304,7 +339,7 @@ final class DefaultPkiSession implements PkiSession {
}
private static X509AuthoritySnapshot authority(SignatureWorkflow workflow,
- BcX509VerificationExecutor verification) {
+ BcX509VerificationExecutor verification, List bindingProviders) {
Set supported = Set.copyOf(workflow.supportedAlgorithms());
Set identities = BootstrapAlgorithmIdentities.catalog().identities().stream()
.filter(identity -> identity.kind() == AlgorithmIdentity.Kind.SIGNATURE)
@@ -342,13 +377,153 @@ final class DefaultPkiSession implements PkiSession {
return "pki-session-bootstrap-policy-v1:configured-capabilities-only";
}
};
- return X509AuthoritySnapshot.compose(List.of(), List.of(signingProvider, verification),
+ return X509AuthoritySnapshot.compose(bindingProviders, 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 BindingComposition composeBindings(PkiSessionConfiguration configuration) {
+ List discovered = ServiceLoader.load(X509AlgorithmBindingProvider.class)
+ .stream().map(ServiceLoader.Provider::get)
+ .sorted(Comparator.comparing(X509AlgorithmBindingProvider::providerId)).toList();
+ Map byId = new LinkedHashMap<>();
+ for (X509AlgorithmBindingProvider provider : discovered) {
+ if (provider.providerId() == null || provider.providerId().isBlank()
+ || byId.putIfAbsent(provider.providerId(), provider) != null) {
+ throw new IllegalArgumentException("Duplicate or invalid X.509 binding-provider identity");
+ }
+ }
+ List descriptors = new ArrayList<>(StandardX509Bindings.descriptors());
+ descriptors.addAll(ZeroEchoPrivateX509Bindings.descriptors());
+ List ruleProviders = new ArrayList<>();
+ for (PkiSessionConfiguration.BindingProviderConfiguration activation : configuration.bindingProviders()) {
+ X509AlgorithmBindingProvider provider = byId.get(activation.providerId());
+ if (provider == null) {
+ throw new IllegalArgumentException("Configured X.509 binding provider is unavailable");
+ }
+ activation.expectedBindingSetVersion().ifPresent(expected -> {
+ if (!expected.equals(provider.bindingSetVersion())) {
+ throw new IllegalArgumentException("X.509 binding-provider version changed");
+ }
+ });
+ List additions = List.copyOf(provider.bindings());
+ for (X509AlgorithmBinding binding : additions) {
+ if (binding.origin() != X509AlgorithmBinding.Origin.DEPLOYER_PRIVATE
+ || !binding.providerId().orElse("").equals(provider.providerId())
+ || BootstrapAlgorithmIdentities.catalog()
+ .resolve(binding.algorithmIdentity().canonicalForm()).isEmpty()
+ || activation.authorizedOidRoots().stream()
+ .noneMatch(root -> authorizedRoot(root, binding.oid()))) {
+ throw new IllegalArgumentException("Unauthorized deployer X.509 binding");
+ }
+ }
+ descriptors.addAll(additions);
+ List rules = List.copyOf(provider.rules());
+ Set descriptorIds = additions.stream().map(X509AlgorithmBinding::bindingId)
+ .collect(java.util.stream.Collectors.toUnmodifiableSet());
+ Set ruleIds = rules.stream().flatMap(rule -> rule.bindings().stream())
+ .map(X509AlgorithmBinding::bindingId).collect(java.util.stream.Collectors.toUnmodifiableSet());
+ if (rules.isEmpty() || !ruleIds.equals(descriptorIds)) {
+ throw new IllegalArgumentException("Deployer binding provider has inconsistent codecs");
+ }
+ ruleProviders.add(new X509BindingRuleProvider() {
+ @Override
+ public List rules() {
+ return rules;
+ }
+ });
+ }
+ return new BindingComposition(ImmutableX509AlgorithmBindingRegistry.create(descriptors),
+ List.copyOf(ruleProviders));
+ }
+
+ private static void validateConfiguredBindings(PkiSessionConfiguration.SigningConfiguration signing,
+ X509AlgorithmBindingRegistry registry) {
+ if (signing.certificateSignatureBinding().isEmpty() && signing.crlSignatureBinding().isEmpty()
+ && signing.subjectPublicKeyBinding().isEmpty()) {
+ return;
+ }
+ AlgorithmIdentity identity = BootstrapAlgorithmIdentities.resolve(signing.signatureAlgorithm())
+ .orElseThrow(() -> new IllegalArgumentException("Configured signature identity is unknown"));
+ validateConfiguredBinding(registry, signing.certificateSignatureBinding(), identity,
+ X509AlgorithmBinding.Role.CERTIFICATE_SIGNATURE);
+ validateConfiguredBinding(registry, signing.crlSignatureBinding(), identity,
+ X509AlgorithmBinding.Role.CRL_SIGNATURE);
+ validateConfiguredBinding(registry, signing.subjectPublicKeyBinding(),
+ BootstrapAlgorithmIdentities.SPHINCS_PLUS_SIGNATURE.equals(identity)
+ ? BootstrapAlgorithmIdentities.SPHINCS_PLUS_PUBLIC_KEY
+ : bootstrapPublicKey(identity), X509AlgorithmBinding.Role.SUBJECT_PUBLIC_KEY);
+ boolean privateAlgorithm = identity.equals(BootstrapAlgorithmIdentities.SPHINCS_PLUS_SIGNATURE);
+ if (privateAlgorithm && (signing.certificateSignatureBinding().isEmpty()
+ || signing.crlSignatureBinding().isEmpty() || signing.subjectPublicKeyBinding().isEmpty())) {
+ throw new IllegalArgumentException("Private X.509 signature identity requires explicit bindings");
+ }
+ }
+
+ private static AlgorithmIdentity bootstrapPublicKey(AlgorithmIdentity signature) {
+ if (signature.equals(BootstrapAlgorithmIdentities.ECDSA_SHA256)) {
+ return BootstrapAlgorithmIdentities.EC_P256_PUBLIC_KEY;
+ }
+ if (signature.equals(BootstrapAlgorithmIdentities.ECDSA_SHA384)) {
+ return BootstrapAlgorithmIdentities.EC_P384_PUBLIC_KEY;
+ }
+ if (signature.equals(BootstrapAlgorithmIdentities.ECDSA_SHA512)) {
+ return BootstrapAlgorithmIdentities.EC_P521_PUBLIC_KEY;
+ }
+ if (signature.equals(BootstrapAlgorithmIdentities.ED25519_SIGNATURE)) {
+ return BootstrapAlgorithmIdentities.ED25519_PUBLIC_KEY;
+ }
+ if (signature.equals(BootstrapAlgorithmIdentities.ED448_SIGNATURE)) {
+ return BootstrapAlgorithmIdentities.ED448_PUBLIC_KEY;
+ }
+ return BootstrapAlgorithmIdentities.RSA_PUBLIC_KEY;
+ }
+
+ @SuppressWarnings("PMD.PreserveStackTrace")
+ private static PublicKeyInfoResolver publicKeySource(PublicKeyInfoSource source, X509AuthoritySnapshot authority,
+ PkiSessionConfiguration.SigningConfiguration signing) {
+ if (signing.subjectPublicKeyBinding().isEmpty()) {
+ return source::resolvePublicKeyInfo;
+ }
+ return keyRef -> {
+ EncodedObject nativeKey = source.resolvePublicKeyInfo(keyRef);
+ if (nativeKey.encoding() != Encoding.DER) {
+ throw new PkiException("Managed public key is not canonical DER: code=PUBLIC_KEY_ENCODING_INVALID");
+ }
+ try {
+ byte[] wrapped = new BcX509PublicKeyAdapter(authority.bindings())
+ .wrap(nativeKey.bytes(), BootstrapAlgorithmIdentities.SPHINCS_PLUS_PUBLIC_KEY,
+ signing.subjectPublicKeyBinding().orElseThrow())
+ .getEncoded(org.bouncycastle.asn1.ASN1Encoding.DER);
+ return new EncodedObject(Encoding.DER, wrapped);
+ } catch (java.io.IOException | IllegalArgumentException failure) {
+ throw new PkiException("Managed public key binding failed: code=PUBLIC_KEY_BINDING_FAILED");
+ }
+ };
+ }
+
+ private static void validateConfiguredBinding(X509AlgorithmBindingRegistry registry,
+ Optional bindingId, AlgorithmIdentity identity, X509AlgorithmBinding.Role role) {
+ bindingId.ifPresent(value -> {
+ X509AlgorithmBinding binding = registry.find(value)
+ .orElseThrow(() -> new IllegalArgumentException("Configured X.509 binding is inactive"));
+ if (binding.role() != role || !binding.algorithmIdentity().equals(identity)) {
+ throw new IllegalArgumentException("Configured X.509 binding does not match the signing role");
+ }
+ });
+ }
+
+ private static boolean authorizedRoot(String root, String oid) {
+ boolean egothorDeployment = ImmutableX509AlgorithmBindingRegistry.isWithin(root,
+ ImmutableX509AlgorithmBindingRegistry.ZEROECHO_DEPLOYMENT_ROOT)
+ && !ImmutableX509AlgorithmBindingRegistry.ZEROECHO_DEPLOYMENT_ROOT.equals(root);
+ boolean externalPen = root.matches("1\\.3\\.6\\.1\\.4\\.1\\.(?!31424(?:\\.|$))[0-9]+(?:\\.[0-9]+)*");
+ return (egothorDeployment || externalPen)
+ && ImmutableX509AlgorithmBindingRegistry.isWithin(oid, root);
+ }
+
private static PkiSessionRuntimeDependencies runtimeDependencies(PkiSessionConfiguration configuration) {
Optional environment = configuration.signing()
.flatMap(PkiSessionConfiguration.SigningConfiguration::unlockEnvironmentVariable);
@@ -474,6 +649,14 @@ final class DefaultPkiSession implements PkiSession {
}
}
+ private record BindingComposition(X509AlgorithmBindingRegistry registry,
+ List providers) {
+ private BindingComposition {
+ Objects.requireNonNull(registry, "registry");
+ providers = List.copyOf(providers);
+ }
+ }
+
/** Production provider bootstrap implementation. */
private enum ProductionBootstrap implements Bootstrap {
INSTANCE;
diff --git a/pki/src/main/java/zeroecho/pki/application/PkiOperation.java b/pki/src/main/java/zeroecho/pki/application/PkiOperation.java
index b00c932..d6858d4 100644
--- a/pki/src/main/java/zeroecho/pki/application/PkiOperation.java
+++ b/pki/src/main/java/zeroecho/pki/application/PkiOperation.java
@@ -53,6 +53,8 @@ import zeroecho.pki.api.status.StatusObjectType;
* explicit type and never by reflection or Java class name.
*/
public sealed interface PkiOperation permits PkiOperation.ValidateConfiguration, PkiOperation.ValidateProfile,
+ PkiOperation.ListAlgorithmBindings, PkiOperation.InspectAlgorithmBinding,
+ PkiOperation.ValidateAlgorithmBindings,
PkiOperation.RegisterProfile, PkiOperation.InspectProfile, PkiOperation.ListProfileVersions,
PkiOperation.ActivateProfile, PkiOperation.InspectAuthority, PkiOperation.ListAuthorities,
PkiOperation.CreateAuthority, PkiOperation.TransitionAuthority, PkiOperation.InspectRequest,
@@ -69,6 +71,81 @@ public sealed interface PkiOperation permits PkiOperation.ValidateConfiguration,
/** @return stable semantic operation name */
String name();
+ /** Lists active X.509 algorithm bindings with optional finite filters. */
+ record ListAlgorithmBindings(java.util.Optional origin, java.util.Optional role, int limit)
+ implements PkiOperation {
+ /** Stable operation name. */
+ public static final String NAME = "algorithm.binding.list";
+
+ /** Validates immutable filters and the presentation limit. */
+ public ListAlgorithmBindings {
+ origin = Objects.requireNonNull(origin, "origin").map(ListAlgorithmBindings::requireText);
+ role = Objects.requireNonNull(role, "role").map(ListAlgorithmBindings::requireText);
+ requireLimit(limit);
+ }
+
+ @Override
+ public String name() {
+ return NAME;
+ }
+
+ private static String requireText(String value) {
+ if (value == null || value.isBlank()) {
+ throw new IllegalArgumentException("Binding filter must not be blank");
+ }
+ return value;
+ }
+ }
+
+ /** Inspects one active X.509 algorithm binding by stable identifier. */
+ record InspectAlgorithmBinding(String bindingId) implements PkiOperation {
+ /** Stable operation name. */
+ public static final String NAME = "algorithm.binding.inspect";
+
+ /** Validates the stable identifier. */
+ public InspectAlgorithmBinding {
+ if (bindingId == null || bindingId.isBlank()) {
+ throw new IllegalArgumentException("bindingId must not be blank");
+ }
+ }
+
+ @Override
+ public String name() {
+ return NAME;
+ }
+ }
+
+ /** Validates the immutable active registry and optionally one exact binding. */
+ record ValidateAlgorithmBindings(java.util.Optional bindingId,
+ java.util.Optional expectedCommitment) implements PkiOperation {
+ /** Stable operation name. */
+ public static final String NAME = "algorithm.binding.validate";
+
+ /** Validates paired optional fields. */
+ public ValidateAlgorithmBindings {
+ bindingId = Objects.requireNonNull(bindingId, "bindingId");
+ expectedCommitment = Objects.requireNonNull(expectedCommitment, "expectedCommitment");
+ if (bindingId.isPresent() != expectedCommitment.isPresent()) {
+ throw new IllegalArgumentException("Binding validation fields must be supplied together");
+ }
+ bindingId.ifPresent(value -> {
+ if (value.isBlank()) {
+ throw new IllegalArgumentException("bindingId must not be blank");
+ }
+ });
+ expectedCommitment.ifPresent(value -> {
+ if (!value.matches("[0-9a-f]{64}|[^\\s]{1,512}")) {
+ throw new IllegalArgumentException("Binding commitment is invalid");
+ }
+ });
+ }
+
+ @Override
+ public String name() {
+ return NAME;
+ }
+ }
+
/** Validates the already-open session configuration. */
record ValidateConfiguration() implements PkiOperation {
/** Stable operation name. */
diff --git a/pki/src/main/java/zeroecho/pki/application/PkiSession.java b/pki/src/main/java/zeroecho/pki/application/PkiSession.java
index 786e9fe..371de80 100644
--- a/pki/src/main/java/zeroecho/pki/application/PkiSession.java
+++ b/pki/src/main/java/zeroecho/pki/application/PkiSession.java
@@ -42,6 +42,7 @@ import zeroecho.pki.api.PublicationService;
import zeroecho.pki.api.ProfileService;
import zeroecho.pki.api.RevocationService;
import zeroecho.pki.api.StatusObjectService;
+import zeroecho.pki.api.algorithm.X509AlgorithmBindingRegistry;
/**
* Lifecycle-owned synchronous PKI backend session.
@@ -107,6 +108,13 @@ public interface PkiSession extends AutoCloseable {
return Optional.empty();
}
+ /**
+ * Returns the immutable active X.509 algorithm-binding registry.
+ *
+ * @return session-owned immutable registry, available to read-only sessions
+ */
+ X509AlgorithmBindingRegistry algorithmBindings();
+
/** @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 87cc55b..d694d6b 100644
--- a/pki/src/main/java/zeroecho/pki/application/PkiSessionConfiguration.java
+++ b/pki/src/main/java/zeroecho/pki/application/PkiSessionConfiguration.java
@@ -54,14 +54,21 @@ import zeroecho.pki.spi.ProviderConfig;
* @param publishers explicitly enabled publication destinations
*/
public record PkiSessionConfiguration(int version, ProviderConfig store, ProviderConfig audit,
- Optional signing, List publishers) {
+ Optional signing, List publishers,
+ List bindingProviders) {
/** Current configuration schema version. */
- public static final int CURRENT_VERSION = 2;
+ public static final int CURRENT_VERSION = 3;
/** Creates a read-only-compatible version-one configuration. */
public PkiSessionConfiguration(int version, ProviderConfig store, ProviderConfig audit) {
- this(version, store, audit, Optional.empty(), List.of());
+ this(version, store, audit, Optional.empty(), List.of(), List.of());
+ }
+
+ /** Creates a version-one or version-two configuration without custom bindings. */
+ public PkiSessionConfiguration(int version, ProviderConfig store, ProviderConfig audit,
+ Optional signing, List publishers) {
+ this(version, store, audit, signing, publishers, List.of());
}
/** Validates and snapshots the configuration. */
@@ -74,9 +81,17 @@ public record PkiSessionConfiguration(int version, ProviderConfig store, Provide
signing = Objects.requireNonNull(signing, "signing").map(SigningConfiguration::snapshot);
publishers = Objects.requireNonNull(publishers, "publishers").stream()
.map(PkiSessionConfiguration::snapshot).toList();
+ bindingProviders = List.copyOf(Objects.requireNonNull(bindingProviders, "bindingProviders"));
if (version == 1 && (signing.isPresent() || !publishers.isEmpty())) {
throw new IllegalArgumentException("Version-one configuration cannot enable optional capabilities");
}
+ if (version < 3 && !bindingProviders.isEmpty()) {
+ throw new IllegalArgumentException("Custom X.509 bindings require configuration version three");
+ }
+ if (bindingProviders.stream().map(BindingProviderConfiguration::providerId).distinct().count()
+ != bindingProviders.size()) {
+ throw new IllegalArgumentException("Duplicate X.509 binding-provider identity");
+ }
}
private static ProviderConfig snapshot(ProviderConfig config) {
@@ -93,9 +108,21 @@ public record PkiSessionConfiguration(int version, ProviderConfig store, Provide
* @param signingTtl positive synchronous signing deadline
* @param unlockEnvironmentVariable optional environment-variable reference used
* only by the default process composition
+ * @param certificateSignatureBinding optional explicit certificate signature binding
+ * @param crlSignatureBinding optional explicit CRL signature binding
+ * @param subjectPublicKeyBinding optional explicit SPKI binding
*/
public record SigningConfiguration(ProviderConfig workflow, ProviderConfig framework, String busPath,
- String signatureAlgorithm, Duration signingTtl, Optional unlockEnvironmentVariable) {
+ String signatureAlgorithm, Duration signingTtl, Optional unlockEnvironmentVariable,
+ Optional certificateSignatureBinding, Optional crlSignatureBinding,
+ Optional subjectPublicKeyBinding) {
+
+ /** Creates standard-mode signing configuration. */
+ public SigningConfiguration(ProviderConfig workflow, ProviderConfig framework, String busPath,
+ String signatureAlgorithm, Duration signingTtl, Optional unlockEnvironmentVariable) {
+ this(workflow, framework, busPath, signatureAlgorithm, signingTtl, unlockEnvironmentVariable,
+ Optional.empty(), Optional.empty(), Optional.empty());
+ }
/** Validates and snapshots the signing configuration. */
public SigningConfiguration {
@@ -109,6 +136,9 @@ public record PkiSessionConfiguration(int version, ProviderConfig store, Provide
}
unlockEnvironmentVariable = Objects.requireNonNull(unlockEnvironmentVariable,
"unlockEnvironmentVariable");
+ certificateSignatureBinding = binding(certificateSignatureBinding, "certificateSignatureBinding");
+ crlSignatureBinding = binding(crlSignatureBinding, "crlSignatureBinding");
+ subjectPublicKeyBinding = binding(subjectPublicKeyBinding, "subjectPublicKeyBinding");
unlockEnvironmentVariable.ifPresent(value -> {
if (!value.matches("[A-Z][A-Z0-9_]{0,127}")) {
throw new IllegalArgumentException("Unlock environment-variable reference is invalid");
@@ -118,7 +148,51 @@ public record PkiSessionConfiguration(int version, ProviderConfig store, Provide
private static SigningConfiguration snapshot(SigningConfiguration source) {
return new SigningConfiguration(source.workflow, source.framework, source.busPath,
- source.signatureAlgorithm, source.signingTtl, source.unlockEnvironmentVariable);
+ source.signatureAlgorithm, source.signingTtl, source.unlockEnvironmentVariable,
+ source.certificateSignatureBinding, source.crlSignatureBinding, source.subjectPublicKeyBinding);
+ }
+
+ private static Optional binding(Optional source, String name) {
+ return Objects.requireNonNull(source, name).map(value -> {
+ if (value.isBlank() || value.length() > 512) {
+ throw new IllegalArgumentException(name + " is invalid");
+ }
+ return value;
+ });
+ }
+ }
+
+ /**
+ * Explicit activation and namespace authorization for one installed binding
+ * provider.
+ *
+ * @param providerId stable installed provider identity
+ * @param authorizedOidRoots finite roots owned by the deployment
+ * @param expectedBindingSetVersion optional pinned provider binding-set version
+ */
+ public record BindingProviderConfiguration(String providerId, List authorizedOidRoots,
+ Optional expectedBindingSetVersion) {
+
+ /** Validates and snapshots the activation. */
+ public BindingProviderConfiguration {
+ if (providerId == null || providerId.isBlank()) {
+ throw new IllegalArgumentException("binding providerId must not be blank");
+ }
+ authorizedOidRoots = List.copyOf(Objects.requireNonNull(authorizedOidRoots, "authorizedOidRoots"));
+ if (authorizedOidRoots.isEmpty() || authorizedOidRoots.stream().anyMatch(root -> root == null
+ || !root.matches("[0-2](?:\\.[0-9]+)+"))) {
+ throw new IllegalArgumentException("Authorized OID roots are invalid");
+ }
+ if (authorizedOidRoots.stream().distinct().count() != authorizedOidRoots.size()) {
+ throw new IllegalArgumentException("Authorized OID roots are duplicated");
+ }
+ expectedBindingSetVersion = Objects.requireNonNull(expectedBindingSetVersion,
+ "expectedBindingSetVersion").map(value -> {
+ if (value.isBlank() || value.length() > 512) {
+ throw new IllegalArgumentException("Expected binding-set version is invalid");
+ }
+ return value;
+ });
}
}
}
diff --git a/pki/src/main/java/zeroecho/pki/impl/core/CaCertificateProfileValidator.java b/pki/src/main/java/zeroecho/pki/impl/core/CaCertificateProfileValidator.java
index 1b2bbd3..db7afd5 100644
--- a/pki/src/main/java/zeroecho/pki/impl/core/CaCertificateProfileValidator.java
+++ b/pki/src/main/java/zeroecho/pki/impl/core/CaCertificateProfileValidator.java
@@ -109,7 +109,7 @@ final class CaCertificateProfileValidator {
SubjectRef canonicalSubject = new SubjectRef(BcX509ProfileSupport.subject(subjectSnapshot).toString());
return new ValidatedCaCertificateRequest(operation, formatId, issuerCaId, subjectCaId,
activeProfile.reference(), expectedKind, canonicalSubject, subjectSnapshot, exactPublicKey, validity,
- serial, definition.caPolicy());
+ serial, definition.caPolicy(), definition.algorithmBindingPolicy());
}
/* package */ static void requireProfileShape(ActiveCertificateProfile activeProfile,
diff --git a/pki/src/main/java/zeroecho/pki/impl/core/CaProofGate.java b/pki/src/main/java/zeroecho/pki/impl/core/CaProofGate.java
index e625e1b..9be8184 100644
--- a/pki/src/main/java/zeroecho/pki/impl/core/CaProofGate.java
+++ b/pki/src/main/java/zeroecho/pki/impl/core/CaProofGate.java
@@ -69,6 +69,7 @@ import zeroecho.pki.impl.framework.x509.X509ExecutionPlan;
import zeroecho.pki.impl.framework.x509.StreamingDerReader;
import zeroecho.pki.impl.framework.x509.bc.BcX509VerificationExecutor;
import zeroecho.pki.impl.framework.x509.bc.BcX509AlgorithmAdapter;
+import zeroecho.pki.impl.framework.x509.bc.PkiBusContentSigner;
import zeroecho.pki.spi.audit.AuditSink;
import zeroecho.pki.util.async.AsyncState;
import zeroecho.pki.util.async.AsyncStatus;
@@ -93,26 +94,46 @@ final class CaProofGate {
private final PkiSigningBus signingBus;
private final AuditSink auditSink;
private final AlgorithmIdentity signatureIdentity;
+ private final Optional signatureBindingId;
private final Duration signingTtl;
/* default */ CaProofGate(PublicKeyInfoResolver publicKeyResolver, PkiSigningBus signingBus, AuditSink auditSink,
String signatureAlgorithmId, Duration signingTtl) {
this(publicKeyResolver, signingBus, auditSink,
signingBus.authority().resolveIdentity(Objects.requireNonNull(signatureAlgorithmId,
- "signatureAlgorithmId")), signingTtl);
+ "signatureAlgorithmId")), Optional.empty(), signingTtl);
}
/* default */ CaProofGate(PublicKeyInfoResolver publicKeyResolver, PkiSigningBus signingBus, AuditSink auditSink,
AlgorithmIdentity signatureIdentity, Duration signingTtl) {
+ this(publicKeyResolver, signingBus, auditSink, signatureIdentity, Optional.empty(), signingTtl);
+ }
+
+ /* default */ CaProofGate(PublicKeyInfoResolver publicKeyResolver, PkiSigningBus signingBus, AuditSink auditSink,
+ AlgorithmIdentity signatureIdentity, Optional signatureBindingId, Duration signingTtl) {
this.publicKeyResolver = Objects.requireNonNull(publicKeyResolver, "publicKeyResolver");
this.signingBus = Objects.requireNonNull(signingBus, "signingBus");
this.auditSink = Objects.requireNonNull(auditSink, "auditSink");
this.signatureIdentity = Objects.requireNonNull(signatureIdentity, "signatureIdentity");
+ this.signatureBindingId = Objects.requireNonNull(signatureBindingId, "signatureBindingId");
this.signingTtl = Objects.requireNonNull(signingTtl, "signingTtl");
}
/* default */ ContentSigner signer(ManagedKeyProof proof) {
- return new BusBackedContentSigner(signingBus, proof.keyRef(), signatureIdentity, signingTtl);
+ return signatureBindingId.isPresent()
+ ? new PkiBusContentSigner(signingBus, proof.keyRef(), signatureIdentity,
+ signatureBindingId.orElseThrow(), signingTtl)
+ : new BusBackedContentSigner(signingBus, proof.keyRef(), signatureIdentity, signingTtl);
+ }
+
+ /* default */ ContentSigner signer(ManagedKeyProof proof,
+ zeroecho.pki.api.profile.X509AlgorithmBindingPolicy bindingPolicy) {
+ Optional bindingId = bindingPolicy.certificateSignature()
+ .map(zeroecho.pki.api.profile.X509AlgorithmBindingPolicy.BindingReference::bindingId);
+ return bindingId.isPresent()
+ ? new PkiBusContentSigner(signingBus, proof.keyRef(), signatureIdentity, bindingId.orElseThrow(),
+ signingTtl)
+ : new BusBackedContentSigner(signingBus, proof.keyRef(), signatureIdentity, signingTtl);
}
/* default */ SubjectPublicKeyInfo parseRootSpki(EncodedObject spki, FormatId formatId) {
diff --git a/pki/src/main/java/zeroecho/pki/impl/core/CertificateProfileValidator.java b/pki/src/main/java/zeroecho/pki/impl/core/CertificateProfileValidator.java
index 9b992ff..6e8decb 100644
--- a/pki/src/main/java/zeroecho/pki/impl/core/CertificateProfileValidator.java
+++ b/pki/src/main/java/zeroecho/pki/impl/core/CertificateProfileValidator.java
@@ -108,7 +108,7 @@ final class CertificateProfileValidator {
return new ValidatedCertificateRequest(candidate.issuerCaId(), profileReference, approvedSubjectRef,
approvedSubject, approvedSans, sanCritical, candidate.exactPublicKey(), validity, policy.keyUsages(),
policy.extendedKeyUsages(), policy.keyUsageCritical(), policy.extendedKeyUsageCritical(),
- policy.basicConstraintsCritical());
+ policy.basicConstraintsCritical(), profile.algorithmBindingPolicy());
}
private static void requireCanonicalRequestAttributes(ParsedCertificationRequest request) {
@@ -252,14 +252,21 @@ final class CertificateProfileValidator {
if (!allowedAlgorithms.contains(algorithm.profileId())) {
throw reject("SUBJECT_KEY_ALGORITHM_FORBIDDEN");
}
- PublicKey reconstructed = KeyFactory.getInstance(algorithm.jcaName())
- .generatePublic(new X509EncodedKeySpec(encoded));
+ SubjectPublicKeyInfo nativeSpki = new zeroecho.pki.impl.framework.x509.bc.BcX509PublicKeyAdapter(
+ authority.bindings()).unwrap(spki);
+ byte[] nativeEncoded = nativeSpki.getEncoded();
+ KeyFactory keyFactory = BootstrapAlgorithmIdentities.SPHINCS_PLUS_PUBLIC_KEY.equals(identity)
+ ? KeyFactory.getInstance(algorithm.jcaName(),
+ new org.bouncycastle.pqc.jcajce.provider.BouncyCastlePQCProvider())
+ : KeyFactory.getInstance(algorithm.jcaName());
+ PublicKey reconstructed = keyFactory.generatePublic(new X509EncodedKeySpec(nativeEncoded));
byte[] canonical = reconstructed.getEncoded();
try {
- if (canonical == null || !MessageDigest.isEqual(encoded, canonical)) {
+ if (canonical == null || !MessageDigest.isEqual(nativeEncoded, canonical)) {
throw reject("SUBJECT_KEY_NOT_CANONICAL");
}
} finally {
+ java.util.Arrays.fill(nativeEncoded, (byte) 0);
if (canonical != null) {
java.util.Arrays.fill(canonical, (byte) 0);
}
@@ -286,6 +293,9 @@ final class CertificateProfileValidator {
if (identity.equals(BootstrapAlgorithmIdentities.ED448_PUBLIC_KEY)) {
return new SubjectKeyAlgorithm("Ed448", "Ed448");
}
+ if (identity.equals(BootstrapAlgorithmIdentities.SPHINCS_PLUS_PUBLIC_KEY)) {
+ return new SubjectKeyAlgorithm("SPHINCS+", "SPHINCS+");
+ }
throw reject("SUBJECT_KEY_ALGORITHM_UNKNOWN");
}
diff --git a/pki/src/main/java/zeroecho/pki/impl/core/DefaultCaService.java b/pki/src/main/java/zeroecho/pki/impl/core/DefaultCaService.java
index fee1734..46bffd4 100644
--- a/pki/src/main/java/zeroecho/pki/impl/core/DefaultCaService.java
+++ b/pki/src/main/java/zeroecho/pki/impl/core/DefaultCaService.java
@@ -232,6 +232,30 @@ public final class DefaultCaService implements CaService {
PublicKeyInfoResolver publicKeyResolver, PkiSigningBus signingBus, AuditSink auditSink,
EffectiveCredentialStatusResolver statusResolver, ProfileService profileService, Clock clock,
String signatureAlgorithmId, Duration signingTtl) {
+ this(store, framework, issuerBackend, publicKeyResolver, signingBus, auditSink, statusResolver, profileService,
+ clock, signatureAlgorithmId, Optional.empty(), signingTtl);
+ }
+
+ /**
+ * Creates a CA service with an optional explicit X.509 signature binding.
+ *
+ * @param store persistent PKI store
+ * @param framework credential framework
+ * @param issuerBackend issuer backend
+ * @param publicKeyResolver managed public-key resolver
+ * @param signingBus signing bus
+ * @param auditSink audit sink
+ * @param statusResolver effective status resolver
+ * @param profileService profile service
+ * @param clock injected clock
+ * @param signatureAlgorithmId cryptographic signature identity
+ * @param signatureBindingId explicit binding identity, when private mode is selected
+ * @param signingTtl signing operation time-to-live
+ */
+ public DefaultCaService(PkiStore store, CredentialFramework framework, CredentialIssuerBackend issuerBackend,
+ PublicKeyInfoResolver publicKeyResolver, PkiSigningBus signingBus, AuditSink auditSink,
+ EffectiveCredentialStatusResolver statusResolver, ProfileService profileService, Clock clock,
+ String signatureAlgorithmId, Optional signatureBindingId, Duration signingTtl) {
this.store = Objects.requireNonNull(store, "store");
this.framework = Objects.requireNonNull(framework, "framework");
@@ -250,11 +274,14 @@ public final class DefaultCaService implements CaService {
throw new IllegalArgumentException("signingTtl must be positive");
}
AlgorithmIdentity signatureIdentity = signingBus.authority().resolveIdentity(signatureAlgorithmId);
- X509ExecutionPlan plan = signingBus.authority()
- .planSigning(signatureIdentity.canonicalForm(), SignatureWorkflow.class);
+ X509ExecutionPlan plan = signatureBindingId.isPresent()
+ ? signingBus.authority().planSigningWithBinding(signatureIdentity.canonicalForm(),
+ signatureBindingId.orElseThrow(), SignatureWorkflow.class)
+ : signingBus.authority().planSigning(signatureIdentity.canonicalForm(), SignatureWorkflow.class);
signingBus.authority().authorize(plan, plan.executor(), AlgorithmExecutionCapability.Direction.SIGN);
this.signatureIdentity = signatureIdentity;
- this.proofGate = new CaProofGate(publicKeyResolver, signingBus, auditSink, signatureIdentity, signingTtl);
+ this.proofGate = new CaProofGate(publicKeyResolver, signingBus, auditSink, signatureIdentity,
+ signatureBindingId, signingTtl);
}
/**
@@ -326,7 +353,7 @@ public final class DefaultCaService implements CaService {
throw new PkiException("Root extension construction failed: code=ROOT_EXTENSION_BUILD_FAILED");
}
- ContentSigner signer = proofGate.signer(proof);
+ ContentSigner signer = proofGate.signer(proof, request.algorithmBindingPolicy());
X509CertificateHolder cert;
try {
cert = b.build(signer);
diff --git a/pki/src/main/java/zeroecho/pki/impl/core/DefaultProfileService.java b/pki/src/main/java/zeroecho/pki/impl/core/DefaultProfileService.java
index d9933a8..bc4d3f0 100644
--- a/pki/src/main/java/zeroecho/pki/impl/core/DefaultProfileService.java
+++ b/pki/src/main/java/zeroecho/pki/impl/core/DefaultProfileService.java
@@ -55,6 +55,9 @@ 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.profile.X509AlgorithmBindingPolicy;
+import zeroecho.pki.api.algorithm.X509AlgorithmBinding;
+import zeroecho.pki.api.algorithm.X509AlgorithmBindingRegistry;
import zeroecho.pki.impl.ProfileLifecycleFailure;
import zeroecho.pki.impl.ProfileLifecycleFailure.Code;
import zeroecho.pki.spi.audit.AuditSink;
@@ -70,12 +73,42 @@ public final class DefaultProfileService implements ProfileService {
private final PkiStore store;
private final Clock clock;
private final AuditSink auditSink;
+ private final X509AlgorithmBindingRegistry algorithmBindings;
+ private final boolean signingConfigured;
+ private final Optional certificateBinding;
+ private final Optional crlBinding;
+ private final Optional publicKeyBinding;
/** Creates a profile service using one authoritative clock. */
public DefaultProfileService(PkiStore store, Clock clock, AuditSink auditSink) {
+ this(store, clock, auditSink, defaultBindings());
+ }
+
+ /**
+ * Creates a profile service bound to one immutable algorithm registry.
+ *
+ * @param store authoritative PKI store
+ * @param clock authoritative clock
+ * @param auditSink audit sink
+ * @param algorithmBindings active immutable binding registry
+ */
+ public DefaultProfileService(PkiStore store, Clock clock, AuditSink auditSink,
+ X509AlgorithmBindingRegistry algorithmBindings) {
+ this(store, clock, auditSink, algorithmBindings, false, Optional.empty(), Optional.empty(), Optional.empty());
+ }
+
+ /** Creates a profile service with the session's explicit signing bindings. */
+ public DefaultProfileService(PkiStore store, Clock clock, AuditSink auditSink,
+ X509AlgorithmBindingRegistry algorithmBindings, boolean signingConfigured,
+ Optional certificateBinding, Optional crlBinding, Optional publicKeyBinding) {
this.store = Objects.requireNonNull(store, "store");
this.clock = Objects.requireNonNull(clock, "clock");
this.auditSink = Objects.requireNonNull(auditSink, "auditSink");
+ this.algorithmBindings = Objects.requireNonNull(algorithmBindings, "algorithmBindings");
+ this.signingConfigured = signingConfigured;
+ this.certificateBinding = Objects.requireNonNull(certificateBinding, "certificateBinding");
+ this.crlBinding = Objects.requireNonNull(crlBinding, "crlBinding");
+ this.publicKeyBinding = Objects.requireNonNull(publicKeyBinding, "publicKeyBinding");
}
@Override
@@ -83,6 +116,7 @@ public final class DefaultProfileService implements ProfileService {
Objects.requireNonNull(jsonDocument, "jsonDocument");
return executeSanitized(() -> {
CertificateProfileDefinition definition = CertificateProfileDocumentCodec.parse(jsonDocument);
+ validateBindings(definition);
byte[] canonical = CertificateProfileDocumentCodec.writeCanonical(definition);
return importCanonical(definition, canonical, clock.instant());
}, Code.PROFILE_IMPORT_VALIDATION_FAILED);
@@ -93,6 +127,7 @@ public final class DefaultProfileService implements ProfileService {
Objects.requireNonNull(jsonDocument, "jsonDocument");
return executeSanitized(() -> {
CertificateProfileDefinition definition = CertificateProfileDocumentCodec.parse(jsonDocument);
+ validateBindings(definition);
byte[] canonical = CertificateProfileDocumentCodec.writeCanonical(definition);
return importCanonical(definition, canonical, clock.instant());
}, Code.PROFILE_IMPORT_VALIDATION_FAILED);
@@ -122,6 +157,10 @@ public final class DefaultProfileService implements ProfileService {
throw new IllegalArgumentException("profileVersion must be positive");
}
return executeAudited("PROFILE_ACTIVATE", profileId, profileVersion, Code.PROFILE_ACTIVATION_FAILED, () -> {
+ ImportedCertificateProfileVersion version = store.getProfileVersion(profileId, profileVersion)
+ .orElseThrow(() -> new ProfileLifecycleFailure(Code.PROFILE_VERSION_NOT_FOUND));
+ validateBindings(version.definition());
+ validateRuntimeBindings(version.definition());
CertificateProfileRef result = store.activateProfile(profileId, profileVersion);
audit("PROFILE_ACTIVATE", result, "SUCCESS");
return result;
@@ -131,8 +170,12 @@ public final class DefaultProfileService implements ProfileService {
@Override
public ActiveCertificateProfile requireActiveProfile(String profileId) {
requireProfileId(profileId);
- return executeAudited("PROFILE_RESOLVE", profileId, 0L, Code.PROFILE_STORE_FAILURE,
- () -> store.requireActiveProfile(profileId));
+ return executeAudited("PROFILE_RESOLVE", profileId, 0L, Code.PROFILE_STORE_FAILURE, () -> {
+ ActiveCertificateProfile active = store.requireActiveProfile(profileId);
+ validateBindings(active.definition());
+ validateRuntimeBindings(active.definition());
+ return active;
+ });
}
@Override
@@ -217,6 +260,60 @@ public final class DefaultProfileService implements ProfileService {
return new ProfileLifecycleFailure(fallback);
}
+ private void validateBindings(CertificateProfileDefinition definition) {
+ X509AlgorithmBindingPolicy policy = definition.algorithmBindingPolicy();
+ if (policy.mode() == X509AlgorithmBindingPolicy.Mode.STANDARD_ONLY) {
+ return;
+ }
+ validateBinding(policy.subjectPublicKey(), X509AlgorithmBinding.Role.SUBJECT_PUBLIC_KEY);
+ validateBinding(policy.csrSignature(), X509AlgorithmBinding.Role.CSR_SIGNATURE);
+ validateBinding(policy.certificateSignature(), X509AlgorithmBinding.Role.CERTIFICATE_SIGNATURE);
+ validateBinding(policy.crlSignature(), X509AlgorithmBinding.Role.CRL_SIGNATURE);
+ }
+
+ private void validateBinding(Optional reference,
+ X509AlgorithmBinding.Role role) {
+ reference.ifPresent(value -> {
+ X509AlgorithmBinding binding = algorithmBindings.require(value.bindingId(), value.semanticCommitment());
+ if (binding.role() != role || binding.origin() == X509AlgorithmBinding.Origin.STANDARD) {
+ throw new IllegalArgumentException("Explicit profile binding is invalid for its role");
+ }
+ });
+ }
+
+ private void validateRuntimeBindings(CertificateProfileDefinition definition) {
+ if (!signingConfigured) {
+ return;
+ }
+ X509AlgorithmBindingPolicy policy = definition.algorithmBindingPolicy();
+ if (policy.mode() == X509AlgorithmBindingPolicy.Mode.STANDARD_ONLY) {
+ if (certificateBinding.isPresent() || crlBinding.isPresent() || publicKeyBinding.isPresent()) {
+ throw new IllegalArgumentException("Standard profile cannot use private runtime bindings");
+ }
+ return;
+ }
+ requireRuntimeBinding(policy.certificateSignature(), certificateBinding);
+ requireRuntimeBinding(policy.subjectPublicKey(), publicKeyBinding);
+ if (policy.crlSignature().isPresent()) {
+ requireRuntimeBinding(policy.crlSignature(), crlBinding);
+ }
+ }
+
+ private static void requireRuntimeBinding(Optional selected,
+ Optional configured) {
+ if (selected.isEmpty() || configured.isEmpty()
+ || !selected.orElseThrow().bindingId().equals(configured.orElseThrow())) {
+ throw new IllegalArgumentException("Profile binding does not match the signing runtime");
+ }
+ }
+
+ private static X509AlgorithmBindingRegistry defaultBindings() {
+ List descriptors = new java.util.ArrayList<>(
+ zeroecho.pki.impl.framework.x509.StandardX509Bindings.descriptors());
+ descriptors.addAll(zeroecho.pki.impl.framework.x509.ZeroEchoPrivateX509Bindings.descriptors());
+ return zeroecho.pki.impl.framework.x509.ImmutableX509AlgorithmBindingRegistry.create(descriptors);
+ }
+
/*
* Attacker-controlled parser and store causes are intentionally replaced by
* stable, cause-free lifecycle failures.
diff --git a/pki/src/main/java/zeroecho/pki/impl/core/ValidatedCaCertificateRequest.java b/pki/src/main/java/zeroecho/pki/impl/core/ValidatedCaCertificateRequest.java
index 7dbd222..3b11de0 100644
--- a/pki/src/main/java/zeroecho/pki/impl/core/ValidatedCaCertificateRequest.java
+++ b/pki/src/main/java/zeroecho/pki/impl/core/ValidatedCaCertificateRequest.java
@@ -45,6 +45,7 @@ import zeroecho.pki.api.Validity;
import zeroecho.pki.api.profile.CaCertificatePolicy;
import zeroecho.pki.api.profile.CertificateProfileKind;
import zeroecho.pki.api.profile.CertificateProfileRef;
+import zeroecho.pki.api.profile.X509AlgorithmBindingPolicy;
import zeroecho.pki.api.request.SubjectRdn;
/**
@@ -83,6 +84,7 @@ public final class ValidatedCaCertificateRequest {
private final Validity validity;
private final BigInteger serial;
private final CaCertificatePolicy policy;
+ private final X509AlgorithmBindingPolicy algorithmBindingPolicy;
// The constructor is the single cohesive gate output boundary.
@SuppressWarnings("PMD.ExcessiveParameterList")
@@ -90,6 +92,15 @@ public final class ValidatedCaCertificateRequest {
PkiId subjectCaId, CertificateProfileRef profileReference, CertificateProfileKind certificateType,
SubjectRef subjectRef, List subjectRdns, EncodedObject exactPublicKey, Validity validity,
BigInteger serial, CaCertificatePolicy policy) {
+ this(operation, formatId, issuerCaId, subjectCaId, profileReference, certificateType, subjectRef, subjectRdns,
+ exactPublicKey, validity, serial, policy, X509AlgorithmBindingPolicy.standardOnly());
+ }
+
+ @SuppressWarnings("PMD.ExcessiveParameterList")
+ /* package */ ValidatedCaCertificateRequest(Operation operation, FormatId formatId, PkiId issuerCaId,
+ PkiId subjectCaId, CertificateProfileRef profileReference, CertificateProfileKind certificateType,
+ SubjectRef subjectRef, List subjectRdns, EncodedObject exactPublicKey, Validity validity,
+ BigInteger serial, CaCertificatePolicy policy, X509AlgorithmBindingPolicy algorithmBindingPolicy) {
this.operation = Objects.requireNonNull(operation, "operation");
this.formatId = Objects.requireNonNull(formatId, "formatId");
this.issuerCaId = Objects.requireNonNull(issuerCaId, "issuerCaId");
@@ -103,6 +114,7 @@ public final class ValidatedCaCertificateRequest {
this.validity = Objects.requireNonNull(validity, "validity");
this.serial = Objects.requireNonNull(serial, "serial");
this.policy = Objects.requireNonNull(policy, "policy");
+ this.algorithmBindingPolicy = Objects.requireNonNull(algorithmBindingPolicy, "algorithmBindingPolicy");
if (serial.signum() <= 0 || serial.toByteArray().length > 20) {
throw new IllegalArgumentException("serial must be positive and at most 20 bytes");
}
@@ -167,4 +179,9 @@ public final class ValidatedCaCertificateRequest {
public CaCertificatePolicy policy() {
return policy;
}
+
+ /** @return exact immutable X.509 representation selection */
+ public X509AlgorithmBindingPolicy algorithmBindingPolicy() {
+ return algorithmBindingPolicy;
+ }
}
diff --git a/pki/src/main/java/zeroecho/pki/impl/core/ValidatedCertificateRequest.java b/pki/src/main/java/zeroecho/pki/impl/core/ValidatedCertificateRequest.java
index 2680bf0..f5e85f6 100644
--- a/pki/src/main/java/zeroecho/pki/impl/core/ValidatedCertificateRequest.java
+++ b/pki/src/main/java/zeroecho/pki/impl/core/ValidatedCertificateRequest.java
@@ -44,6 +44,7 @@ import zeroecho.pki.api.Validity;
import zeroecho.pki.api.profile.CertificateProfileRef;
import zeroecho.pki.api.profile.ExtendedKeyUsageId;
import zeroecho.pki.api.profile.LeafKeyUsage;
+import zeroecho.pki.api.profile.X509AlgorithmBindingPolicy;
import zeroecho.pki.api.request.SubjectAlternativeName;
import zeroecho.pki.api.request.SubjectRdn;
@@ -71,12 +72,25 @@ public final class ValidatedCertificateRequest {
private final boolean keyUsageCritical;
private final boolean extendedKeyUsageCritical;
private final boolean basicConstraintsCritical;
+ private final X509AlgorithmBindingPolicy algorithmBindingPolicy;
/* default */ ValidatedCertificateRequest(PkiId issuerCaId, CertificateProfileRef profileReference,
SubjectRef subjectRef, List subjectRdns, List subjectAlternativeNames,
boolean subjectAlternativeNameCritical, EncodedObject exactPublicKey, Validity validity,
Set keyUsages, Set extendedKeyUsages, boolean keyUsageCritical,
boolean extendedKeyUsageCritical, boolean basicConstraintsCritical) {
+ this(issuerCaId, profileReference, subjectRef, subjectRdns, subjectAlternativeNames,
+ subjectAlternativeNameCritical, exactPublicKey, validity, keyUsages, extendedKeyUsages,
+ keyUsageCritical, extendedKeyUsageCritical, basicConstraintsCritical,
+ X509AlgorithmBindingPolicy.standardOnly());
+ }
+
+ /* default */ ValidatedCertificateRequest(PkiId issuerCaId, CertificateProfileRef profileReference,
+ SubjectRef subjectRef, List subjectRdns, List subjectAlternativeNames,
+ boolean subjectAlternativeNameCritical, EncodedObject exactPublicKey, Validity validity,
+ Set keyUsages, Set extendedKeyUsages, boolean keyUsageCritical,
+ boolean extendedKeyUsageCritical, boolean basicConstraintsCritical,
+ X509AlgorithmBindingPolicy algorithmBindingPolicy) {
this.issuerCaId = Objects.requireNonNull(issuerCaId, "issuerCaId");
this.profileReference = Objects.requireNonNull(profileReference, "profileReference");
this.subjectRef = Objects.requireNonNull(subjectRef, "subjectRef");
@@ -90,6 +104,7 @@ public final class ValidatedCertificateRequest {
this.keyUsageCritical = keyUsageCritical;
this.extendedKeyUsageCritical = extendedKeyUsageCritical;
this.basicConstraintsCritical = basicConstraintsCritical;
+ this.algorithmBindingPolicy = Objects.requireNonNull(algorithmBindingPolicy, "algorithmBindingPolicy");
}
/** @return authoritative issuer CA identifier */
@@ -161,4 +176,9 @@ public final class ValidatedCertificateRequest {
public boolean basicConstraintsCritical() {
return basicConstraintsCritical;
}
+
+ /** @return exact immutable X.509 representation selection */
+ public X509AlgorithmBindingPolicy algorithmBindingPolicy() {
+ return algorithmBindingPolicy;
+ }
}
diff --git a/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowProvider.java b/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowProvider.java
index 7760325..2ff92ff 100644
--- a/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowProvider.java
+++ b/pki/src/main/java/zeroecho/pki/impl/crypto/zeroecholib/ZeroEchoLibSignatureWorkflowProvider.java
@@ -150,7 +150,8 @@ public final class ZeroEchoLibSignatureWorkflowProvider
BootstrapAlgorithmIdentities.RSA_PKCS1_SHA384, BootstrapAlgorithmIdentities.RSA_PKCS1_SHA512,
BootstrapAlgorithmIdentities.RSA_PSS_SHA256, BootstrapAlgorithmIdentities.ECDSA_SHA256,
BootstrapAlgorithmIdentities.ECDSA_SHA384, BootstrapAlgorithmIdentities.ECDSA_SHA512,
- BootstrapAlgorithmIdentities.ED25519_SIGNATURE, BootstrapAlgorithmIdentities.ED448_SIGNATURE);
+ BootstrapAlgorithmIdentities.ED25519_SIGNATURE, BootstrapAlgorithmIdentities.ED448_SIGNATURE,
+ BootstrapAlgorithmIdentities.SPHINCS_PLUS_SIGNATURE);
return java.util.List.of(new AlgorithmExecutionCapability() {
@Override
public String implementationId() {
diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/ImmutableX509AlgorithmBindingRegistry.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/ImmutableX509AlgorithmBindingRegistry.java
new file mode 100644
index 0000000..4232d78
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/ImmutableX509AlgorithmBindingRegistry.java
@@ -0,0 +1,176 @@
+/*******************************************************************************
+ * Copyright (C) 2026, Leo Galambos
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without modification,
+ * are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. All advertising materials mentioning features or use of this software must
+ * display the following acknowledgement:
+ * This product includes software developed by the Egothor project.
+ *
+ * 4. Neither the name of the copyright holder nor the names of its contributors
+ * may be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ ******************************************************************************/
+package zeroecho.pki.impl.framework.x509;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HexFormat;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+
+import zeroecho.core.spec.AlgorithmIdentity;
+import zeroecho.pki.api.algorithm.X509AlgorithmBinding;
+import zeroecho.pki.api.algorithm.X509AlgorithmBindingRegistry;
+
+/** Package-owned immutable registry implementation used by session bootstrap. */
+public final class ImmutableX509AlgorithmBindingRegistry implements X509AlgorithmBindingRegistry {
+
+ /** Egothor Private Enterprise Number root. */
+ public static final String EGOTHOR_PEN = "1.3.6.1.4.1.31424";
+ /** Frozen ZeroEcho private assignment branch. */
+ public static final String ZEROECHO_ASSIGNMENT_ROOT = EGOTHOR_PEN + ".1.1";
+ /** Deployment-local experimental branch. */
+ public static final String ZEROECHO_DEPLOYMENT_ROOT = EGOTHOR_PEN + ".1.2";
+
+ private final List bindings;
+ private final Map byId;
+ private final Map standards;
+ private final String commitment;
+
+ private ImmutableX509AlgorithmBindingRegistry(List source) {
+ this.bindings = source;
+ Map identifiers = new LinkedHashMap<>();
+ Map official = new LinkedHashMap<>();
+ Map reverse = new LinkedHashMap<>();
+ for (X509AlgorithmBinding binding : source) {
+ if (identifiers.putIfAbsent(binding.bindingId(), binding) != null) {
+ throw new IllegalArgumentException("Duplicate X.509 binding identifier");
+ }
+ String reverseKey = binding.role() + "|" + binding.oid() + "|"
+ + (binding.parameterRule() == X509AlgorithmBinding.ParameterRule.STRUCTURED_DER
+ ? binding.algorithmIdentity().canonicalForm() : "fixed");
+ if (reverse.putIfAbsent(reverseKey, binding) != null) {
+ throw new IllegalArgumentException("Ambiguous X.509 role and OID");
+ }
+ if (binding.origin() == X509AlgorithmBinding.Origin.STANDARD
+ && official.putIfAbsent(key(binding.algorithmIdentity(), binding.role()), binding) != null) {
+ throw new IllegalArgumentException("Ambiguous standard X.509 binding");
+ }
+ }
+ this.byId = Map.copyOf(identifiers);
+ this.standards = Map.copyOf(official);
+ this.commitment = digest(source);
+ }
+
+ /**
+ * Constructs and validates one deterministic registry snapshot.
+ *
+ * @param bindings complete explicitly selected binding set
+ * @return immutable registry
+ */
+ public static ImmutableX509AlgorithmBindingRegistry create(List bindings) {
+ Objects.requireNonNull(bindings, "bindings");
+ List ordered = new ArrayList<>(bindings);
+ ordered.sort(Comparator.comparing(X509AlgorithmBinding::bindingId));
+ for (X509AlgorithmBinding binding : ordered) {
+ Objects.requireNonNull(binding, "binding");
+ if (binding.origin() == X509AlgorithmBinding.Origin.ZEROECHO_PRIVATE
+ && !isWithin(binding.oid(), ZEROECHO_ASSIGNMENT_ROOT)) {
+ throw new IllegalArgumentException("ZeroEcho binding is outside the frozen assignment branch");
+ }
+ if (binding.origin() == X509AlgorithmBinding.Origin.STANDARD
+ && isWithin(binding.oid(), EGOTHOR_PEN)) {
+ throw new IllegalArgumentException("Standard binding cannot use the Egothor private namespace");
+ }
+ }
+ return new ImmutableX509AlgorithmBindingRegistry(List.copyOf(ordered));
+ }
+
+ /** Tests whether one OID is strictly below or exactly at an authorized root. */
+ public static boolean isWithin(String oid, String root) {
+ Objects.requireNonNull(oid, "oid");
+ Objects.requireNonNull(root, "root");
+ return oid.equals(root) || oid.startsWith(root + ".");
+ }
+
+ @Override
+ public List bindings() {
+ return bindings;
+ }
+
+ @Override
+ public Optional find(String bindingId) {
+ return Optional.ofNullable(byId.get(Objects.requireNonNull(bindingId, "bindingId")));
+ }
+
+ @Override
+ public Optional standard(AlgorithmIdentity identity, X509AlgorithmBinding.Role role) {
+ return Optional.ofNullable(standards.get(key(Objects.requireNonNull(identity, "identity"),
+ Objects.requireNonNull(role, "role"))));
+ }
+
+ @Override
+ public String commitment() {
+ return commitment;
+ }
+
+ @Override
+ public X509AlgorithmBinding require(String bindingId, String expectedCommitment) {
+ X509AlgorithmBinding binding = find(bindingId)
+ .orElseThrow(() -> new IllegalArgumentException("X.509 binding is not active"));
+ if (!binding.semanticCommitment().equals(Objects.requireNonNull(expectedCommitment, "expectedCommitment"))) {
+ throw new IllegalArgumentException("X.509 binding contract has changed");
+ }
+ return binding;
+ }
+
+ private static String key(AlgorithmIdentity identity, X509AlgorithmBinding.Role role) {
+ return identity.canonicalForm() + "|" + role;
+ }
+
+ private static String digest(List bindings) {
+ StringBuilder semantic = new StringBuilder();
+ for (X509AlgorithmBinding binding : bindings) {
+ semantic.append(binding.bindingId()).append('|').append(binding.algorithmIdentity().canonicalForm())
+ .append('|').append(binding.role()).append('|').append(binding.oid()).append('|')
+ .append(binding.origin()).append('|').append(binding.encodingVersion()).append('|')
+ .append(binding.parameterRule()).append('|').append(binding.publicKeyEncoding()).append('|')
+ .append(binding.signatureEncoding()).append('|').append(binding.interoperability()).append('|')
+ .append(binding.providerId().orElse("")).append('|').append(binding.semanticCommitment())
+ .append('\n');
+ }
+ try {
+ return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
+ .digest(semantic.toString().getBytes(StandardCharsets.UTF_8)));
+ } catch (NoSuchAlgorithmException impossible) {
+ throw new IllegalStateException("SHA-256 unavailable", impossible);
+ }
+ }
+}
diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/StandardX509Bindings.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/StandardX509Bindings.java
index eaf0d9f..4bf776e 100644
--- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/StandardX509Bindings.java
+++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/StandardX509Bindings.java
@@ -42,6 +42,7 @@ import java.util.Optional;
import zeroecho.core.alg.BootstrapAlgorithmIdentities;
import zeroecho.core.spec.AlgorithmIdentity;
+import zeroecho.pki.api.algorithm.X509AlgorithmBinding;
/**
* Immutable authoritative standard X.509 bootstrap bindings.
@@ -150,6 +151,16 @@ public final class StandardX509Bindings {
return createCatalog(components);
}
+ /**
+ * Returns deterministic safe descriptors for every sealed standard binding.
+ *
+ * @return immutable standard binding descriptors
+ */
+ public static List descriptors() {
+ return CATALOG.rules().stream().flatMap(rule -> rule.bindings().stream())
+ .sorted(java.util.Comparator.comparing(X509AlgorithmBinding::bindingId)).toList();
+ }
+
private static X509BindingRule fixed(String id, X509AlgorithmRole role, AlgorithmIdentity identity,
X509AlgorithmIdentifier identifier, X509BindingRule.SignatureEncoding signatureEncoding,
X509BindingRule.PublicKeyEncoding publicKeyEncoding) {
@@ -180,6 +191,12 @@ public final class StandardX509Bindings {
+ signatureEncoding + "|" + publicKeyEncoding;
}
+ @Override
+ public List bindings() {
+ return descriptors(id, role, identity, identifier, signatureEncoding, publicKeyEncoding,
+ semanticFingerprint());
+ }
+
@Override
public Optional encode(AlgorithmIdentity candidate) {
return identity.equals(candidate) ? Optional.of(identifier) : Optional.empty();
@@ -236,6 +253,13 @@ public final class StandardX509Bindings {
return PublicKeyEncoding.NOT_APPLICABLE;
}
+ @Override
+ public List bindings() {
+ X509AlgorithmIdentifier identifier = encode(BootstrapAlgorithmIdentities.RSA_PSS_SHA256).orElseThrow();
+ return descriptors(id(), role(), BootstrapAlgorithmIdentities.RSA_PSS_SHA256, identifier,
+ signatureEncoding(), publicKeyEncoding(), semanticFingerprint());
+ }
+
@Override
public Optional encode(AlgorithmIdentity identity) {
if (identity.kind() != AlgorithmIdentity.Kind.SIGNATURE
@@ -302,6 +326,16 @@ public final class StandardX509Bindings {
return PublicKeyEncoding.EC_POINT;
}
+ @Override
+ public List bindings() {
+ return List.of(BootstrapAlgorithmIdentities.EC_P256_PUBLIC_KEY,
+ BootstrapAlgorithmIdentities.EC_P384_PUBLIC_KEY,
+ BootstrapAlgorithmIdentities.EC_P521_PUBLIC_KEY).stream()
+ .flatMap(identity -> descriptors(id() + "." + identity.parameters().canonicalForm(), role(),
+ identity, encode(identity).orElseThrow(), signatureEncoding(), publicKeyEncoding(),
+ semanticFingerprint()).stream()).toList();
+ }
+
@Override
public Optional encode(AlgorithmIdentity identity) {
if (identity.kind() != AlgorithmIdentity.Kind.PUBLIC_KEY
@@ -341,6 +375,42 @@ public final class StandardX509Bindings {
StrictDer.explicit(2, StrictDer.integer(parameters.saltLength())));
}
+ private static List descriptors(String id, X509AlgorithmRole role,
+ AlgorithmIdentity identity, X509AlgorithmIdentifier identifier, X509BindingRule.SignatureEncoding signature,
+ X509BindingRule.PublicKeyEncoding publicKey, String commitment) {
+ X509AlgorithmBinding.ParameterRule parameters = switch (identifier.parameterForm()) {
+ case ABSENT -> X509AlgorithmBinding.ParameterRule.ABSENT;
+ case DER_NULL -> X509AlgorithmBinding.ParameterRule.DER_NULL;
+ case EXACT_DER -> X509AlgorithmBinding.ParameterRule.STRUCTURED_DER;
+ };
+ X509AlgorithmBinding.PublicKeyEncoding keyEncoding = switch (publicKey) {
+ case RSA_PKCS1_DER -> X509AlgorithmBinding.PublicKeyEncoding.RSA_PKCS1_DER;
+ case EC_POINT -> X509AlgorithmBinding.PublicKeyEncoding.EC_POINT;
+ case RAW -> X509AlgorithmBinding.PublicKeyEncoding.RAW;
+ case NESTED_SPKI_DER -> X509AlgorithmBinding.PublicKeyEncoding.NESTED_SPKI_DER;
+ case NOT_APPLICABLE -> X509AlgorithmBinding.PublicKeyEncoding.NOT_APPLICABLE;
+ };
+ X509AlgorithmBinding.SignatureEncoding signatureEncoding = switch (signature) {
+ case OPAQUE -> X509AlgorithmBinding.SignatureEncoding.OPAQUE;
+ case ECDSA_DER -> X509AlgorithmBinding.SignatureEncoding.ECDSA_DER;
+ case NOT_APPLICABLE -> X509AlgorithmBinding.SignatureEncoding.NOT_APPLICABLE;
+ };
+ if (role == X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM) {
+ return List.of(new X509AlgorithmBinding(id, identity, X509AlgorithmBinding.Role.SUBJECT_PUBLIC_KEY,
+ identifier.oid(), X509AlgorithmBinding.Origin.STANDARD, 1, parameters, keyEncoding,
+ signatureEncoding, X509AlgorithmBinding.Interoperability.STANDARD_INTEROPERABLE,
+ Optional.empty(), commitment));
+ }
+ return List.of(X509AlgorithmBinding.Role.CSR_SIGNATURE, X509AlgorithmBinding.Role.CERTIFICATE_SIGNATURE,
+ X509AlgorithmBinding.Role.CRL_SIGNATURE).stream()
+ .map(bindingRole -> new X509AlgorithmBinding(id + "."
+ + bindingRole.name().toLowerCase(java.util.Locale.ROOT), identity,
+ bindingRole, identifier.oid(), X509AlgorithmBinding.Origin.STANDARD, 1, parameters, keyEncoding,
+ signatureEncoding, X509AlgorithmBinding.Interoperability.STANDARD_INTEROPERABLE,
+ Optional.empty(), commitment))
+ .toList();
+ }
+
private static AlgorithmIdentity.RsaPssParameters decodePss(byte[] encoded, X509ComponentCatalog components) {
StrictDer.Reader sequence = StrictDer.reader(encoded).readConstructed(0x30);
byte[] hashAlgorithm = sequence.readConstructed(0xa0).readOnlyValue(0x30);
diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509AlgorithmResolver.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509AlgorithmResolver.java
index 12d87f1..ec84ae4 100644
--- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509AlgorithmResolver.java
+++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509AlgorithmResolver.java
@@ -194,6 +194,14 @@ public final class X509AlgorithmResolver {
public Selection resolve(AlgorithmIdentity requested, AlgorithmIdentity key,
AlgorithmExecutionCapability.Direction direction, Optional implementation, String provenance,
String authorityFingerprint) {
+ return resolve(requested, key, direction, implementation, provenance, authorityFingerprint,
+ Optional.empty());
+ }
+
+ /** Resolves an operation with an optional explicit X.509 binding identity. */
+ public Selection resolve(AlgorithmIdentity requested, AlgorithmIdentity key,
+ AlgorithmExecutionCapability.Direction direction, Optional implementation, String provenance,
+ String authorityFingerprint, Optional bindingId) {
Objects.requireNonNull(requested, "requested");
Objects.requireNonNull(key, "key");
Objects.requireNonNull(direction, "direction");
@@ -213,7 +221,9 @@ public final class X509AlgorithmResolver {
}
X509AlgorithmIdentifier binding;
try {
- binding = bindings.resolve(requested, X509AlgorithmRole.SIGNATURE_ALGORITHM);
+ binding = bindingId.isPresent()
+ ? bindings.resolve(bindingId.orElseThrow(), requested, X509AlgorithmRole.SIGNATURE_ALGORITHM)
+ : bindings.resolve(requested, X509AlgorithmRole.SIGNATURE_ALGORITHM);
} catch (IllegalArgumentException missing) {
throw new ResolutionException(Failure.NO_BINDING, missing);
}
diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509AuthoritySnapshot.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509AuthoritySnapshot.java
index 4186d1f..6e3cdcf 100644
--- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509AuthoritySnapshot.java
+++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509AuthoritySnapshot.java
@@ -188,7 +188,8 @@ public final class X509AuthoritySnapshot {
mergeDefaults(defaults, provider.defaults());
}
X509ComponentCatalog components = X509ComponentCatalog.builtIn().merge(componentExtensions);
- X509BindingCatalog builtInBindings = StandardX509Bindings.catalog(components);
+ X509BindingCatalog builtInBindings = StandardX509Bindings.catalog(components)
+ .merge(List.of(ZeroEchoPrivateX509Bindings.catalog()));
for (X509BindingRuleProvider provider : ordered) {
List rules = List.copyOf(provider.rules());
if (!rules.isEmpty()) {
@@ -248,6 +249,14 @@ public final class X509AuthoritySnapshot {
return resolver.resolve(signature, key, direction, implementation, provenance, semanticFingerprint);
}
+ /** Resolves one operation through an explicitly selected role-specific binding. */
+ public X509AlgorithmResolver.Selection resolve(AlgorithmIdentity signature, AlgorithmIdentity key,
+ AlgorithmExecutionCapability.Direction direction, Optional implementation, String provenance,
+ String bindingId) {
+ return resolver.resolve(signature, key, direction, implementation, provenance, semanticFingerprint,
+ Optional.of(Objects.requireNonNull(bindingId, "bindingId")));
+ }
+
/**
* Resolves and authorizes one process-local execution plan.
*
@@ -272,6 +281,20 @@ public final class X509AuthoritySnapshot {
return new X509ExecutionPlan<>(selection, executorType.cast(executor), provenanceToken);
}
+ /** Resolves and authorizes one plan through an explicit binding identity. */
+ public X509ExecutionPlan plan(AlgorithmIdentity signature, AlgorithmIdentity key,
+ AlgorithmExecutionCapability.Direction direction, Optional implementation, String provenance,
+ String bindingId, Class executorType) {
+ X509AlgorithmResolver.Selection selection = resolve(signature, key, direction, implementation, provenance,
+ bindingId);
+ ExecutorKey executorKey = new ExecutorKey(selection.implementation().implementationId(), direction);
+ Object executor = executors.get(executorKey);
+ if (executor == null || !executorType.isInstance(executor)) {
+ throw new X509AlgorithmResolver.ResolutionException(X509AlgorithmResolver.Failure.NO_EXECUTOR);
+ }
+ return new X509ExecutionPlan<>(selection, executorType.cast(executor), provenanceToken);
+ }
+
/**
* Resolves a signing plan for a legacy upper API that carries only the
* signature identity.
@@ -304,6 +327,14 @@ public final class X509AuthoritySnapshot {
executorType);
}
+ /** Resolves a signing plan through one explicit binding. */
+ public X509ExecutionPlan planSigningWithBinding(String value, String bindingId, Class executorType) {
+ AlgorithmIdentity signature = resolveIdentity(value);
+ AlgorithmIdentity key = bootstrapKeyFor(signature);
+ return plan(signature, key, AlgorithmExecutionCapability.Direction.SIGN, Optional.empty(), "explicit",
+ bindingId, executorType);
+ }
+
/**
* Validates an execution plan immediately before invoking its executor.
*
@@ -562,6 +593,9 @@ public final class X509AuthoritySnapshot {
if (signature.equals(BootstrapAlgorithmIdentities.ED448_SIGNATURE)) {
return BootstrapAlgorithmIdentities.ED448_PUBLIC_KEY;
}
+ if (signature.equals(BootstrapAlgorithmIdentities.SPHINCS_PLUS_SIGNATURE)) {
+ return BootstrapAlgorithmIdentities.SPHINCS_PLUS_PUBLIC_KEY;
+ }
if ("rsa-pkcs1-v1_5".equals(signature.family().name())
|| "rsa-pss".equals(signature.family().name())) {
return BootstrapAlgorithmIdentities.RSA_PUBLIC_KEY;
diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509BindingCatalog.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509BindingCatalog.java
index fd3a3dd..23bc969 100644
--- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509BindingCatalog.java
+++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509BindingCatalog.java
@@ -130,6 +130,11 @@ public final class X509BindingCatalog {
if (rule.role() != role) {
continue;
}
+ if (!rule.bindings().isEmpty() && rule.bindings().stream()
+ .noneMatch(binding -> binding.origin()
+ == zeroecho.pki.api.algorithm.X509AlgorithmBinding.Origin.STANDARD)) {
+ continue;
+ }
Optional candidate = rule.encode(identity);
if (candidate.isPresent()) {
if (resolved != null) {
@@ -144,6 +149,30 @@ public final class X509BindingCatalog {
return resolved;
}
+ /**
+ * Resolves one exact identity through an explicitly selected binding.
+ *
+ * @param bindingId stable binding identifier
+ * @param identity exact cryptographic identity
+ * @param role internal structural role
+ * @return canonical identifier
+ * @throws IllegalArgumentException if the binding is absent, has the wrong
+ * role, or cannot encode the identity
+ */
+ public X509AlgorithmIdentifier resolve(String bindingId, AlgorithmIdentity identity, X509AlgorithmRole role) {
+ Objects.requireNonNull(bindingId, "bindingId");
+ Objects.requireNonNull(identity, "identity");
+ Objects.requireNonNull(role, "role");
+ X509BindingRule rule = rules.stream()
+ .filter(candidate -> candidate.id().equals(bindingId)
+ || candidate.bindings().stream().anyMatch(binding -> binding.bindingId().equals(bindingId)))
+ .findFirst().orElseThrow(() -> new IllegalArgumentException("Unknown X.509 binding identifier"));
+ if (rule.role() != role) {
+ throw new IllegalArgumentException("X.509 binding role mismatch");
+ }
+ return rule.encode(identity).orElseThrow(() -> new IllegalArgumentException("X.509 binding identity mismatch"));
+ }
+
/**
* Reverse-resolves an exact role-specific representation.
*
@@ -167,6 +196,23 @@ public final class X509BindingCatalog {
return identity;
}
+ /**
+ * Returns the stable binding identifier owning an exact representation.
+ *
+ * @param identifier canonical representation
+ * @param role structural role
+ * @return stable binding identifier
+ */
+ public String bindingId(X509AlgorithmIdentifier identifier, X509AlgorithmRole role) {
+ Objects.requireNonNull(identifier, "identifier");
+ Objects.requireNonNull(role, "role");
+ X509BindingRule rule = byRoleAndOid.get(key(role, identifier.oid()));
+ if (rule == null || rule.decode(identifier).isEmpty()) {
+ throw new IllegalArgumentException("Unknown X.509 algorithm identifier");
+ }
+ return rule.id();
+ }
+
/**
* Returns deterministic immutable binding rules.
*
diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509BindingRule.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509BindingRule.java
index 70803b6..17002d7 100644
--- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509BindingRule.java
+++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509BindingRule.java
@@ -34,8 +34,10 @@
package zeroecho.pki.impl.framework.x509;
import java.util.Optional;
+import java.util.List;
import zeroecho.core.spec.AlgorithmIdentity;
+import zeroecho.pki.api.algorithm.X509AlgorithmBinding;
/**
* Immutable trusted-code rule between exact ZeroEcho identities and X.509
@@ -49,6 +51,19 @@ import zeroecho.core.spec.AlgorithmIdentity;
*/
public interface X509BindingRule {
+ /**
+ * Returns the finite safe descriptors owned by this encoding rule.
+ *
+ * A parameterized rule may expose one descriptor for every currently
+ * registered exact identity. Descriptors do not expose implementation
+ * classes or mutable codec state.
+ *
+ * @return immutable descriptors
+ */
+ default List bindings() {
+ return List.of();
+ }
+
/**
* Signature byte representation owned by a signature rule.
*/
@@ -71,6 +86,8 @@ public interface X509BindingRule {
EC_POINT,
/** Algorithm-defined raw public-key bytes. */
RAW,
+ /** Canonical native SubjectPublicKeyInfo DER nested in the BIT STRING. */
+ NESTED_SPKI_DER,
/** Not applicable to a signature-only rule. */
NOT_APPLICABLE
}
diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509SuiteCompatibility.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509SuiteCompatibility.java
index 861866f..653b948 100644
--- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509SuiteCompatibility.java
+++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/X509SuiteCompatibility.java
@@ -71,6 +71,7 @@ public final class X509SuiteCompatibility {
case "ecdsa" -> "ec".equals(keyFamily);
case "ed25519" -> "ed25519".equals(keyFamily);
case "ed448" -> "ed448".equals(keyFamily);
+ case "sphincs-plus" -> "sphincs-plus".equals(keyFamily);
default -> false;
};
if (!compatible) {
diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/ZeroEchoPrivateX509Bindings.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/ZeroEchoPrivateX509Bindings.java
new file mode 100644
index 0000000..9812be4
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/ZeroEchoPrivateX509Bindings.java
@@ -0,0 +1,277 @@
+/*******************************************************************************
+ * Copyright (C) 2026, Leo Galambos
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without modification,
+ * are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. All advertising materials mentioning features or use of this software must
+ * display the following acknowledgement:
+ * This product includes software developed by the Egothor project.
+ *
+ * 4. Neither the name of the copyright holder nor the names of its contributors
+ * may be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ ******************************************************************************/
+package zeroecho.pki.impl.framework.x509;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+
+import tools.jackson.core.JsonParser;
+import tools.jackson.core.JsonToken;
+import tools.jackson.core.ObjectReadContext;
+import tools.jackson.core.StreamReadConstraints;
+import tools.jackson.core.json.JsonFactory;
+import tools.jackson.core.json.JsonFactoryBuilder;
+import zeroecho.core.alg.BootstrapAlgorithmIdentities;
+import zeroecho.core.spec.AlgorithmIdentity;
+import zeroecho.pki.api.algorithm.X509AlgorithmBinding;
+
+/** Loads and locks the version-one ZeroEcho-owned private X.509 assignments. */
+public final class ZeroEchoPrivateX509Bindings {
+
+ /** Versioned assignment resource. */
+ public static final String RESOURCE = "/zeroecho/pki/x509/zeroecho-private-bindings-v1.json";
+ private static final int MAXIMUM_RESOURCE_BYTES = 64 * 1024;
+ private static final Set ROOT_FIELDS = Set.of("schemaVersion", "bindings");
+ private static final Set BINDING_FIELDS = Set.of("bindingId", "algorithmIdentity", "role", "oid",
+ "encodingVersion", "parameterRule", "publicKeyEncoding", "signatureEncoding", "interoperability");
+ private static final JsonFactory JSON = new JsonFactoryBuilder()
+ .streamReadConstraints(StreamReadConstraints.builder().maxDocumentLength(MAXIMUM_RESOURCE_BYTES)
+ .maxNestingDepth(8).maxStringLength(512).build())
+ .build();
+ private static final AssignmentSet ASSIGNMENTS = loadResource();
+
+ private ZeroEchoPrivateX509Bindings() {
+ }
+
+ /** @return frozen safe descriptors in resource order */
+ public static List descriptors() {
+ return ASSIGNMENTS.descriptors();
+ }
+
+ /** @return trusted immutable encoding-rule catalog */
+ public static X509BindingCatalog catalog() {
+ return ASSIGNMENTS.catalog();
+ }
+
+ @SuppressWarnings({ "PMD.AvoidCatchingGenericException", "PMD.ExceptionAsFlowControl",
+ "PMD.AvoidLiteralsInIfCondition" })
+ private static AssignmentSet loadResource() {
+ try (InputStream input = ZeroEchoPrivateX509Bindings.class.getResourceAsStream(RESOURCE)) {
+ if (input == null) {
+ throw new IllegalStateException("ZeroEcho X.509 assignment resource is missing");
+ }
+ try (JsonParser parser = JSON.createParser(ObjectReadContext.empty(), input)) {
+ require(parser.nextToken(), JsonToken.START_OBJECT);
+ Set seen = new HashSet<>();
+ int schema = 0;
+ List bindings = null;
+ while (parser.nextToken() != JsonToken.END_OBJECT) {
+ String field = parser.currentName();
+ if (!ROOT_FIELDS.contains(field) || !seen.add(field)) {
+ throw invalid();
+ }
+ parser.nextToken();
+ if ("schemaVersion".equals(field)) {
+ schema = parser.getIntValue();
+ } else {
+ bindings = readBindings(parser);
+ }
+ }
+ if (parser.nextToken() != null || schema != 1 || bindings == null || seen.size() != ROOT_FIELDS.size()) {
+ throw invalid();
+ }
+ return validate(bindings);
+ }
+ } catch (IOException | RuntimeException failure) {
+ throw new IllegalStateException("ZeroEcho X.509 assignment resource is invalid", failure);
+ }
+ }
+
+ @SuppressWarnings({ "PMD.CyclomaticComplexity", "PMD.AvoidInstantiatingObjectsInLoops",
+ "PMD.AvoidLiteralsInIfCondition" })
+ private static List readBindings(JsonParser parser) throws IOException {
+ require(parser.currentToken(), JsonToken.START_ARRAY);
+ List bindings = new ArrayList<>();
+ while (parser.nextToken() != JsonToken.END_ARRAY) {
+ require(parser.currentToken(), JsonToken.START_OBJECT);
+ Set seen = new HashSet<>();
+ String id = null;
+ String identity = null;
+ String role = null;
+ String oid = null;
+ int version = 0;
+ String parameters = null;
+ String publicKey = null;
+ String signature = null;
+ String interoperability = null;
+ while (parser.nextToken() != JsonToken.END_OBJECT) {
+ String field = parser.currentName();
+ if (!BINDING_FIELDS.contains(field) || !seen.add(field)) {
+ throw invalid();
+ }
+ parser.nextToken();
+ if ("encodingVersion".equals(field)) {
+ version = parser.getIntValue();
+ } else {
+ String value = parser.getValueAsString();
+ switch (field) {
+ case "bindingId" -> id = value;
+ case "algorithmIdentity" -> identity = value;
+ case "role" -> role = value;
+ case "oid" -> oid = value;
+ case "parameterRule" -> parameters = value;
+ case "publicKeyEncoding" -> publicKey = value;
+ case "signatureEncoding" -> signature = value;
+ case "interoperability" -> interoperability = value;
+ default -> throw invalid();
+ }
+ }
+ }
+ if (seen.size() != BINDING_FIELDS.size()) {
+ throw invalid();
+ }
+ AlgorithmIdentity exactIdentity = identity(identity);
+ String commitment = String.join("|", id, exactIdentity.canonicalForm(), role, oid,
+ Integer.toString(version), parameters, publicKey, signature, interoperability);
+ bindings.add(new X509AlgorithmBinding(id, exactIdentity, X509AlgorithmBinding.Role.valueOf(role), oid,
+ X509AlgorithmBinding.Origin.ZEROECHO_PRIVATE, version,
+ X509AlgorithmBinding.ParameterRule.valueOf(parameters),
+ X509AlgorithmBinding.PublicKeyEncoding.valueOf(publicKey),
+ X509AlgorithmBinding.SignatureEncoding.valueOf(signature),
+ X509AlgorithmBinding.Interoperability.valueOf(interoperability), Optional.empty(), commitment));
+ }
+ return List.copyOf(bindings);
+ }
+
+ @SuppressWarnings("PMD.AvoidLiteralsInIfCondition")
+ private static AssignmentSet validate(List descriptors) {
+ if (descriptors.size() != 4) {
+ throw invalid();
+ }
+ ImmutableX509AlgorithmBindingRegistry.create(descriptors);
+ List rules = descriptors.stream().map(PrivateRule::new).map(X509BindingRule.class::cast)
+ .toList();
+ return new AssignmentSet(descriptors, X509BindingCatalog.builtIn(rules));
+ }
+
+ private static AlgorithmIdentity identity(String value) {
+ return switch (Objects.requireNonNull(value, "algorithmIdentity")) {
+ case "SPHINCS_PLUS_SIGNATURE" -> BootstrapAlgorithmIdentities.SPHINCS_PLUS_SIGNATURE;
+ case "SPHINCS_PLUS_PUBLIC_KEY" -> BootstrapAlgorithmIdentities.SPHINCS_PLUS_PUBLIC_KEY;
+ default -> throw invalid();
+ };
+ }
+
+ private static void require(JsonToken actual, JsonToken expected) {
+ if (actual != expected) {
+ throw invalid();
+ }
+ }
+
+ private static IllegalArgumentException invalid() {
+ return new IllegalArgumentException("Invalid ZeroEcho X.509 assignment schema");
+ }
+
+ private record AssignmentSet(List descriptors, X509BindingCatalog catalog) {
+ private AssignmentSet {
+ descriptors = List.copyOf(descriptors);
+ Objects.requireNonNull(catalog, "catalog");
+ }
+ }
+
+ private record PrivateRule(X509AlgorithmBinding binding) implements X509BindingRule {
+ private PrivateRule {
+ Objects.requireNonNull(binding, "binding");
+ }
+
+ @Override
+ public String id() {
+ return binding.bindingId();
+ }
+
+ @Override
+ public X509AlgorithmRole role() {
+ return binding.role() == X509AlgorithmBinding.Role.SUBJECT_PUBLIC_KEY
+ ? X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM : X509AlgorithmRole.SIGNATURE_ALGORITHM;
+ }
+
+ @Override
+ public String oid() {
+ return binding.oid();
+ }
+
+ @Override
+ public String semanticFingerprint() {
+ return binding.semanticCommitment();
+ }
+
+ @Override
+ public SignatureEncoding signatureEncoding() {
+ return switch (binding.signatureEncoding()) {
+ case OPAQUE -> SignatureEncoding.OPAQUE;
+ case ECDSA_DER -> SignatureEncoding.ECDSA_DER;
+ case NOT_APPLICABLE -> SignatureEncoding.NOT_APPLICABLE;
+ };
+ }
+
+ @Override
+ public PublicKeyEncoding publicKeyEncoding() {
+ return switch (binding.publicKeyEncoding()) {
+ case NESTED_SPKI_DER -> PublicKeyEncoding.NESTED_SPKI_DER;
+ case RAW -> PublicKeyEncoding.RAW;
+ case RSA_PKCS1_DER -> PublicKeyEncoding.RSA_PKCS1_DER;
+ case EC_POINT -> PublicKeyEncoding.EC_POINT;
+ case NOT_APPLICABLE -> PublicKeyEncoding.NOT_APPLICABLE;
+ };
+ }
+
+ @Override
+ public List bindings() {
+ return List.of(binding);
+ }
+
+ @Override
+ public Optional encode(AlgorithmIdentity identity) {
+ return binding.algorithmIdentity().equals(identity)
+ ? Optional.of(X509AlgorithmIdentifier.absent(binding.oid())) : Optional.empty();
+ }
+
+ @Override
+ public Optional decode(X509AlgorithmIdentifier identifier) {
+ if (!binding.oid().equals(identifier.oid())) {
+ return Optional.empty();
+ }
+ if (identifier.parameterForm() != X509AlgorithmIdentifier.ParameterForm.ABSENT) {
+ throw new IllegalArgumentException("Private X.509 binding parameters must be absent");
+ }
+ return Optional.of(binding.algorithmIdentity());
+ }
+ }
+}
diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialIssuerBackend.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialIssuerBackend.java
index 92be442..79a0e5c 100644
--- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialIssuerBackend.java
+++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509CredentialIssuerBackend.java
@@ -165,6 +165,12 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
*/
public BcX509CredentialIssuerBackend(PkiSigningBus signingBus, AlgorithmIdentity signatureIdentity,
Duration signingTtl) {
+ this(signingBus, signatureIdentity, Optional.empty(), signingTtl);
+ }
+
+ /** Creates an issuance backend with an optional explicit certificate binding. */
+ public BcX509CredentialIssuerBackend(PkiSigningBus signingBus, AlgorithmIdentity signatureIdentity,
+ Optional signatureBindingId, Duration signingTtl) {
if (signingBus == null) {
throw new IllegalArgumentException("signingBus must not be null");
}
@@ -176,6 +182,7 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
}
this.signingBus = signingBus;
this.signatureIdentity = signatureIdentity;
+ java.util.Objects.requireNonNull(signatureBindingId, "signatureBindingId");
this.signingTtl = signingTtl;
}
@@ -222,7 +229,7 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
Date.from(request.validity().notBefore()), Date.from(request.validity().notAfter()), subjectDn, spki);
addLeafExtensions(builder, request);
- PkiBusContentSigner signer = new PkiBusContentSigner(signingBus, issuerKeyRef, signatureIdentity, signingTtl);
+ PkiBusContentSigner signer = signer(issuerKeyRef, request.algorithmBindingPolicy());
X509CertificateHolder leaf;
try {
leaf = builder.build(signer);
@@ -279,6 +286,16 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
}
}
+ private PkiBusContentSigner signer(KeyRef keyRef,
+ zeroecho.pki.api.profile.X509AlgorithmBindingPolicy bindingPolicy) {
+ Optional bindingId = bindingPolicy.certificateSignature()
+ .map(zeroecho.pki.api.profile.X509AlgorithmBindingPolicy.BindingReference::bindingId);
+ return bindingId.isPresent()
+ ? new PkiBusContentSigner(signingBus, keyRef, signatureIdentity,
+ bindingId.orElseThrow(), signingTtl)
+ : new PkiBusContentSigner(signingBus, keyRef, signatureIdentity, signingTtl);
+ }
+
private static int toKeyUsageBits(java.util.Set usages) {
int bits = 0;
for (LeafKeyUsage usage : usages) {
@@ -372,7 +389,7 @@ public final class BcX509CredentialIssuerBackend implements CredentialIssuerBack
throw new PkiException("X.509 extension construction failed: code=EXTENSION_BUILD_FAILED");
}
- PkiBusContentSigner signer = new PkiBusContentSigner(signingBus, issuerKeyRef, signatureIdentity, signingTtl);
+ PkiBusContentSigner signer = signer(issuerKeyRef, request.algorithmBindingPolicy());
X509CertificateHolder certificate;
try {
certificate = builder.build(signer);
diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509ProofOfPossessionVerifier.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509ProofOfPossessionVerifier.java
index 3ddacd2..022b0aa 100644
--- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509ProofOfPossessionVerifier.java
+++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509ProofOfPossessionVerifier.java
@@ -215,6 +215,8 @@ public final class BcX509ProofOfPossessionVerifier implements ProofOfPossessionV
X509ExecutionPlan plan = authority.plan(signatureIdentity, keyIdentity,
AlgorithmExecutionCapability.Direction.VERIFY,
Optional.of(BcX509VerificationExecutor.IMPLEMENTATION_ID), "csr-proof",
+ authority.bindings().bindingId(BcX509AlgorithmAdapter.fromBc(csr.getSignatureAlgorithm()),
+ X509AlgorithmRole.SIGNATURE_ALGORITHM),
BcX509VerificationExecutor.class);
byte[] signedBytes = csr.toASN1Structure().getCertificationRequestInfo().getEncoded();
boolean valid = executor.verify(authority, plan, csr.getSubjectPublicKeyInfo(),
diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509PublicKeyAdapter.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509PublicKeyAdapter.java
new file mode 100644
index 0000000..81c22a9
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509PublicKeyAdapter.java
@@ -0,0 +1,107 @@
+/*******************************************************************************
+ * Copyright (C) 2026, Leo Galambos
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without modification,
+ * are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * 3. All advertising materials mentioning features or use of this software must
+ * display the following acknowledgement:
+ * This product includes software developed by the Egothor project.
+ *
+ * 4. Neither the name of the copyright holder nor the names of its contributors
+ * may be used to endorse or promote products derived from this software without
+ * specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ ******************************************************************************/
+package zeroecho.pki.impl.framework.x509.bc;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Objects;
+
+import org.bouncycastle.asn1.ASN1Encoding;
+import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
+
+import zeroecho.core.spec.AlgorithmIdentity;
+import zeroecho.pki.impl.framework.x509.StreamingDerReader;
+import zeroecho.pki.impl.framework.x509.X509AlgorithmRole;
+import zeroecho.pki.impl.framework.x509.X509BindingCatalog;
+import zeroecho.pki.impl.framework.x509.X509BindingRule;
+
+/** Bouncy Castle adapter for binding-owned SubjectPublicKeyInfo encodings. */
+public final class BcX509PublicKeyAdapter {
+
+ private static final int MAXIMUM_SPKI_BYTES = 1024 * 1024;
+ private final X509BindingCatalog catalog;
+
+ /** Creates an adapter for one immutable binding catalog. */
+ public BcX509PublicKeyAdapter(X509BindingCatalog catalog) {
+ this.catalog = Objects.requireNonNull(catalog, "catalog");
+ }
+
+ /**
+ * Wraps canonical native SPKI DER under one explicit binding.
+ *
+ * @param nativeSpki canonical provider-native SPKI
+ * @param identity exact public-key identity
+ * @param bindingId explicit SPKI binding
+ * @return canonical bound SPKI
+ * @throws IOException if either SPKI cannot be represented canonically
+ */
+ public SubjectPublicKeyInfo wrap(byte[] nativeSpki, AlgorithmIdentity identity, String bindingId)
+ throws IOException {
+ requireCanonical(nativeSpki);
+ org.bouncycastle.asn1.x509.AlgorithmIdentifier algorithm = BcX509AlgorithmAdapter.toBc(
+ catalog.resolve(bindingId, identity, X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM));
+ SubjectPublicKeyInfo wrapped = new SubjectPublicKeyInfo(algorithm, nativeSpki);
+ requireCanonical(wrapped.getEncoded(ASN1Encoding.DER));
+ return wrapped;
+ }
+
+ /**
+ * Returns provider-native SPKI for verification or key import.
+ *
+ * @param input exact active SPKI
+ * @return canonical native SPKI; the input itself for standard encodings
+ * @throws IOException if nested SPKI bytes are malformed or non-canonical
+ */
+ public SubjectPublicKeyInfo unwrap(SubjectPublicKeyInfo input) throws IOException {
+ Objects.requireNonNull(input, "input");
+ String bindingId = catalog.bindingId(BcX509AlgorithmAdapter.fromBc(input.getAlgorithm()),
+ X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM);
+ X509BindingRule rule = catalog.rules().stream().filter(candidate -> candidate.id().equals(bindingId))
+ .findFirst().orElseThrow(() -> new IllegalArgumentException("SPKI binding rule is unavailable"));
+ if (rule.publicKeyEncoding() != X509BindingRule.PublicKeyEncoding.NESTED_SPKI_DER) {
+ return input;
+ }
+ byte[] nested = input.getPublicKeyData().getBytes();
+ requireCanonical(nested);
+ SubjectPublicKeyInfo nativeSpki = SubjectPublicKeyInfo.getInstance(nested);
+ if (!Arrays.equals(nested, nativeSpki.getEncoded(ASN1Encoding.DER))) {
+ throw new IOException("Nested SubjectPublicKeyInfo is not canonical DER");
+ }
+ return nativeSpki;
+ }
+
+ private static void requireCanonical(byte[] encoded) throws IOException {
+ new StreamingDerReader().validateSubjectPublicKeyInfo(encoded, MAXIMUM_SPKI_BYTES);
+ }
+}
diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509SignedObjectValidator.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509SignedObjectValidator.java
index c5daf28..b8a6588 100644
--- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509SignedObjectValidator.java
+++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509SignedObjectValidator.java
@@ -209,6 +209,8 @@ public final class BcX509SignedObjectValidator {
X509ExecutionPlan verification = authority.plan(signatureIdentity, issuerKey,
AlgorithmExecutionCapability.Direction.VERIFY,
Optional.of(BcX509VerificationExecutor.IMPLEMENTATION_ID), "status-postcondition",
+ authority.bindings().bindingId(BcX509AlgorithmAdapter.fromBc(identifier),
+ X509AlgorithmRole.SIGNATURE_ALGORITHM),
BcX509VerificationExecutor.class);
try (RepeatableContent tbs = new ContentSlice(content, layout.tbsOffset(), layout.tbsLength())) {
if (!verification.executor().verify(authority, verification, issuerPublicKey, identifier, tbs,
diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509StatusObjectGenerator.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509StatusObjectGenerator.java
index c7d7a53..f82b592 100644
--- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509StatusObjectGenerator.java
+++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509StatusObjectGenerator.java
@@ -169,6 +169,7 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator
private final PkiSigningBus signingBus;
private final AlgorithmIdentity signatureIdentity;
private final Duration signingTtl;
+ private final Optional signatureBindingId;
/**
* Creates the X.509 status object generator.
@@ -196,6 +197,12 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator
*/
public BcX509StatusObjectGenerator(PkiSigningBus signingBus, AlgorithmIdentity signatureIdentity,
Duration signingTtl) {
+ this(signingBus, signatureIdentity, Optional.empty(), signingTtl);
+ }
+
+ /** Creates a generator with an optional explicit CRL-signature binding. */
+ public BcX509StatusObjectGenerator(PkiSigningBus signingBus, AlgorithmIdentity signatureIdentity,
+ Optional signatureBindingId, Duration signingTtl) {
if (signingBus == null) {
throw new IllegalArgumentException("signingBus must not be null");
}
@@ -207,6 +214,7 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator
}
this.signingBus = signingBus;
this.signatureIdentity = signatureIdentity;
+ this.signatureBindingId = java.util.Objects.requireNonNull(signatureBindingId, "signatureBindingId");
this.signingTtl = signingTtl;
}
@@ -262,8 +270,10 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator
boolean accepted = false;
try {
entries = encodeEntries(crlEntries);
- PkiBusContentSigner signer = new PkiBusContentSigner(signingBus, issuerMaterial.keyRef(),
- signatureIdentity, signingTtl);
+ PkiBusContentSigner signer = signatureBindingId.isPresent()
+ ? new PkiBusContentSigner(signingBus, issuerMaterial.keyRef(), signatureIdentity,
+ signatureBindingId.orElseThrow(), signingTtl)
+ : new PkiBusContentSigner(signingBus, issuerMaterial.keyRef(), signatureIdentity, signingTtl);
byte[] algorithm = signer.getAlgorithmIdentifier().getEncoded();
tbs = encodeTbs(issuerMaterial.issuerHolder(), thisUpdate, nextUpdate, entries, algorithm);
copyToSigner(tbs, signer);
@@ -321,6 +331,8 @@ public final class BcX509StatusObjectGenerator implements StatusObjectGenerator
X509ExecutionPlan plan = signingBus.authority().plan(actualSignature,
issuerKey, AlgorithmExecutionCapability.Direction.VERIFY,
Optional.of(BcX509VerificationExecutor.IMPLEMENTATION_ID), "crl-postcondition",
+ signingBus.authority().bindings().bindingId(BcX509AlgorithmAdapter.fromBc(algorithm),
+ X509AlgorithmRole.SIGNATURE_ALGORITHM),
BcX509VerificationExecutor.class);
try (RepeatableContent signedContent = new ContentSlice(complete, layout.tbsOffset(),
layout.tbsLength())) {
diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509VerificationExecutor.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509VerificationExecutor.java
index 6487bf6..bec4b17 100644
--- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509VerificationExecutor.java
+++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/BcX509VerificationExecutor.java
@@ -37,12 +37,17 @@ import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.security.GeneralSecurityException;
+import java.security.KeyFactory;
+import java.security.PublicKey;
+import java.security.Signature;
+import java.security.spec.X509EncodedKeySpec;
import java.util.List;
import java.util.Set;
import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.bouncycastle.pqc.jcajce.provider.BouncyCastlePQCProvider;
import org.bouncycastle.operator.ContentVerifier;
import org.bouncycastle.operator.OperatorCreationException;
import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder;
@@ -78,7 +83,8 @@ public final class BcX509VerificationExecutor implements AlgorithmExecutionCapab
BootstrapAlgorithmIdentities.RSA_PKCS1_SHA512, BootstrapAlgorithmIdentities.RSA_PSS_SHA256,
BootstrapAlgorithmIdentities.ECDSA_SHA256, BootstrapAlgorithmIdentities.ECDSA_SHA384,
BootstrapAlgorithmIdentities.ECDSA_SHA512, BootstrapAlgorithmIdentities.ED25519_SIGNATURE,
- BootstrapAlgorithmIdentities.ED448_SIGNATURE);
+ BootstrapAlgorithmIdentities.ED448_SIGNATURE, BootstrapAlgorithmIdentities.SPHINCS_PLUS_SIGNATURE);
+ private static final BouncyCastlePQCProvider BC_PQC_PROVIDER = new BouncyCastlePQCProvider();
@Override
public List capabilities() {
@@ -134,8 +140,16 @@ public final class BcX509VerificationExecutor implements AlgorithmExecutionCapab
if (!plan.selection().suite().publicKey().equals(publicKeyIdentity)) {
throw new IllegalArgumentException("SubjectPublicKeyInfo does not match execution plan");
}
+ SubjectPublicKeyInfo nativePublicKey = new BcX509PublicKeyAdapter(authority.bindings()).unwrap(publicKeyInfo);
+ if (BootstrapAlgorithmIdentities.SPHINCS_PLUS_SIGNATURE.equals(signatureIdentity)) {
+ return verifySphincs(nativePublicKey, signedContent, signature);
+ }
+ if (!authority.bindings().resolve(signatureIdentity, X509AlgorithmRole.SIGNATURE_ALGORITHM)
+ .equals(BcX509AlgorithmAdapter.fromBc(algorithmIdentifier))) {
+ return verifyCustomClassic(signatureIdentity, nativePublicKey, signedContent, signature);
+ }
ContentVerifier verifier = new JcaContentVerifierProviderBuilder().setProvider(BC_PROVIDER)
- .build(publicKeyInfo).get(algorithmIdentifier);
+ .build(nativePublicKey).get(algorithmIdentifier);
try (InputStream input = signedContent.openStream(); OutputStream output = verifier.getOutputStream()) {
byte[] buffer = new byte[16 * 1024];
int read;
@@ -148,4 +162,50 @@ public final class BcX509VerificationExecutor implements AlgorithmExecutionCapab
}
return verifier.verify(signature);
}
+
+ private static boolean verifySphincs(SubjectPublicKeyInfo publicKeyInfo, RepeatableContent signedContent,
+ byte[] signatureBytes) throws GeneralSecurityException, IOException {
+ PublicKey publicKey = KeyFactory.getInstance("SPHINCS+", BC_PQC_PROVIDER)
+ .generatePublic(new X509EncodedKeySpec(publicKeyInfo.getEncoded()));
+ Signature verifier = Signature.getInstance("SPHINCS+", BC_PQC_PROVIDER);
+ verifier.initVerify(publicKey);
+ try (InputStream input = signedContent.openStream()) {
+ byte[] buffer = new byte[16 * 1024];
+ int read;
+ while ((read = input.read(buffer)) >= 0) {
+ CancellationSignal.NONE.throwIfCancelled();
+ if (read != 0) {
+ verifier.update(buffer, 0, read);
+ }
+ }
+ }
+ return verifier.verify(signatureBytes);
+ }
+
+ private static boolean verifyCustomClassic(AlgorithmIdentity identity, SubjectPublicKeyInfo publicKeyInfo,
+ RepeatableContent signedContent, byte[] signatureBytes) throws GeneralSecurityException, IOException {
+ String algorithm;
+ if (BootstrapAlgorithmIdentities.ED25519_SIGNATURE.equals(identity)) {
+ algorithm = "Ed25519";
+ } else if (BootstrapAlgorithmIdentities.ED448_SIGNATURE.equals(identity)) {
+ algorithm = "Ed448";
+ } else {
+ throw new GeneralSecurityException("Custom classic binding lacks an exact JCA verification contract");
+ }
+ PublicKey publicKey = KeyFactory.getInstance(algorithm, BC_PROVIDER)
+ .generatePublic(new X509EncodedKeySpec(publicKeyInfo.getEncoded()));
+ Signature verifier = Signature.getInstance(algorithm, BC_PROVIDER);
+ verifier.initVerify(publicKey);
+ try (InputStream input = signedContent.openStream()) {
+ byte[] buffer = new byte[16 * 1024];
+ int read;
+ while ((read = input.read(buffer)) >= 0) {
+ CancellationSignal.NONE.throwIfCancelled();
+ if (read != 0) {
+ verifier.update(buffer, 0, read);
+ }
+ }
+ }
+ return verifier.verify(signatureBytes);
+ }
}
diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/PkiBusContentSigner.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/PkiBusContentSigner.java
index f69d953..b2e0e1a 100644
--- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/PkiBusContentSigner.java
+++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/PkiBusContentSigner.java
@@ -54,7 +54,6 @@ import zeroecho.pki.api.audit.Principal;
import zeroecho.pki.api.audit.Purpose;
import zeroecho.pki.impl.core.async.PkiSigningBus;
import zeroecho.pki.impl.core.async.PkiSigningBus.SignContinuation;
-import zeroecho.pki.impl.framework.x509.X509AlgorithmRole;
import zeroecho.pki.impl.framework.x509.X509ExecutionPlan;
import zeroecho.pki.spi.crypto.SignatureWorkflow;
import zeroecho.pki.spi.store.ContentSink;
@@ -156,6 +155,25 @@ public final class PkiBusContentSigner implements ContentSigner {
* authoritative signature binding exists
*/
public PkiBusContentSigner(PkiSigningBus bus, KeyRef keyRef, AlgorithmIdentity algorithmIdentity, Duration ttl) {
+ this(bus, keyRef, algorithmIdentity, Optional.empty(), ttl);
+ }
+
+ /**
+ * Creates a signer using one explicitly selected X.509 binding.
+ *
+ * @param bus signing bus
+ * @param keyRef managed signing-key reference
+ * @param algorithmIdentity exact cryptographic identity
+ * @param bindingId stable active binding identity
+ * @param ttl positive workflow time-to-live
+ */
+ public PkiBusContentSigner(PkiSigningBus bus, KeyRef keyRef, AlgorithmIdentity algorithmIdentity,
+ String bindingId, Duration ttl) {
+ this(bus, keyRef, algorithmIdentity, Optional.of(bindingId), ttl);
+ }
+
+ private PkiBusContentSigner(PkiSigningBus bus, KeyRef keyRef, AlgorithmIdentity algorithmIdentity,
+ Optional bindingId, Duration ttl) {
if (bus == null) {
throw new IllegalArgumentException("bus must not be null");
}
@@ -170,8 +188,10 @@ public final class PkiBusContentSigner implements ContentSigner {
}
this.bus = bus;
this.keyRef = keyRef;
- X509ExecutionPlan plan = bus.authority()
- .planSigning(algorithmIdentity.canonicalForm(), SignatureWorkflow.class);
+ X509ExecutionPlan plan = bindingId.isPresent()
+ ? bus.authority().planSigningWithBinding(algorithmIdentity.canonicalForm(), bindingId.orElseThrow(),
+ SignatureWorkflow.class)
+ : bus.authority().planSigning(algorithmIdentity.canonicalForm(), SignatureWorkflow.class);
bus.authority().authorize(plan, plan.executor(),
zeroecho.core.spi.AlgorithmExecutionCapability.Direction.SIGN);
this.executionPlan = plan;
@@ -205,8 +225,7 @@ public final class PkiBusContentSigner implements ContentSigner {
*/
@Override
public AlgorithmIdentifier getAlgorithmIdentifier() {
- return new BcX509AlgorithmAdapter(bus.authority().bindings()).encode(algorithmIdentity,
- X509AlgorithmRole.SIGNATURE_ALGORITHM);
+ return BcX509AlgorithmAdapter.toBc(executionPlan.selection().binding());
}
/**
diff --git a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/WorkflowProofOfPossessionVerifier.java b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/WorkflowProofOfPossessionVerifier.java
index 35f3023..9316ec4 100644
--- a/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/WorkflowProofOfPossessionVerifier.java
+++ b/pki/src/main/java/zeroecho/pki/impl/framework/x509/bc/WorkflowProofOfPossessionVerifier.java
@@ -225,7 +225,10 @@ public final class WorkflowProofOfPossessionVerifier implements ProofOfPossessio
X509AlgorithmRole.SUBJECT_PUBLIC_KEY_ALGORITHM);
executionPlan = authority.plan(signatureIdentity, keyIdentity,
zeroecho.core.spi.AlgorithmExecutionCapability.Direction.VERIFY,
- Optional.empty(), "csr-proof", SignatureWorkflow.class);
+ Optional.empty(), "csr-proof",
+ authority.bindings().bindingId(BcX509AlgorithmAdapter.fromBc(
+ csrAsn1.getSignatureAlgorithm()), X509AlgorithmRole.SIGNATURE_ALGORITHM),
+ SignatureWorkflow.class);
} catch (IllegalArgumentException unsupported) {
return new ProofOfPossessionResult(ProofOfPossessionStatus.NOT_SUPPORTED,
Optional.of("Unsupported CSR algorithm"));
diff --git a/pki/src/main/java/zeroecho/pki/spi/algorithm/X509AlgorithmBindingProvider.java b/pki/src/main/java/zeroecho/pki/spi/algorithm/X509AlgorithmBindingProvider.java
new file mode 100644
index 0000000..c7b563e
--- /dev/null
+++ b/pki/src/main/java/zeroecho/pki/spi/algorithm/X509AlgorithmBindingProvider.java
@@ -0,0 +1,61 @@
+/*******************************************************************************
+ * 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.algorithm;
+
+import java.util.List;
+
+import zeroecho.pki.api.algorithm.X509AlgorithmBinding;
+import zeroecho.pki.impl.framework.x509.X509BindingRule;
+
+/**
+ * Installed-code provider of deployer-owned immutable X.509 bindings.
+ *
+ * Discovery does not activate a provider. A session must explicitly enable
+ * {@link #providerId()} and authorize every contributed OID root. Provider
+ * implementations must be stateless and must not contain secrets.
+ */
+public interface X509AlgorithmBindingProvider {
+
+ /** @return stable provider identity used by explicit configuration */
+ String providerId();
+
+ /** @return stable version or commitment of the complete contributed set */
+ String bindingSetVersion();
+
+ /** @return finite immutable safe descriptors */
+ List bindings();
+
+ /** @return finite immutable role-specific encoding rules */
+ List rules();
+}
diff --git a/pki/src/main/resources/zeroecho/pki/profiles/v1/email-signing.json b/pki/src/main/resources/zeroecho/pki/profiles/v1/email-signing.json
index d14a2e8..2723726 100644
--- a/pki/src/main/resources/zeroecho/pki/profiles/v1/email-signing.json
+++ b/pki/src/main/resources/zeroecho/pki/profiles/v1/email-signing.json
@@ -1 +1 @@
-{"schemaVersion":2,"certificateType":"END_ENTITY","profileId":"email-signing","profileVersion":1,"formatId":"x509","displayName":"Email Signing","maxValidity":"PT8760H","subject":{"allowEmpty":true,"rules":[{"oid":"2.5.4.3","source":"REQUESTER","minimumOccurrences":0,"maximumOccurrences":1,"maximumUtf8Bytes":253}]},"subjectAlternativeNames":{"minimumTotal":1,"maximumTotal":16,"serviceIdentityRequired":false,"emailIdentityRequired":true,"criticalWhenSubjectNonEmpty":false,"rules":[{"type":"RFC822_NAME","minimumOccurrences":1,"maximumOccurrences":16}]},"leafCertificate":{"basicConstraintsCritical":true,"keyUsageCritical":true,"keyUsage":["DIGITAL_SIGNATURE"],"extendedKeyUsageCritical":false,"extendedKeyUsage":["1.3.6.1.5.5.7.3.4"],"allowedKeyAlgorithms":["ECDSA","Ed25519","RSA"]}}
\ No newline at end of file
+{"schemaVersion":3,"certificateType":"END_ENTITY","profileId":"email-signing","profileVersion":1,"formatId":"x509","displayName":"Email Signing","maxValidity":"PT8760H","algorithmBindings":{"mode":"STANDARD_ONLY"},"subject":{"allowEmpty":true,"rules":[{"oid":"2.5.4.3","source":"REQUESTER","minimumOccurrences":0,"maximumOccurrences":1,"maximumUtf8Bytes":253}]},"subjectAlternativeNames":{"minimumTotal":1,"maximumTotal":16,"serviceIdentityRequired":false,"emailIdentityRequired":true,"criticalWhenSubjectNonEmpty":false,"rules":[{"type":"RFC822_NAME","minimumOccurrences":1,"maximumOccurrences":16}]},"leafCertificate":{"basicConstraintsCritical":true,"keyUsageCritical":true,"keyUsage":["DIGITAL_SIGNATURE"],"extendedKeyUsageCritical":false,"extendedKeyUsage":["1.3.6.1.5.5.7.3.4"],"allowedKeyAlgorithms":["ECDSA","Ed25519","RSA"]}}
\ No newline at end of file
diff --git a/pki/src/main/resources/zeroecho/pki/profiles/v1/intermediate-ca.json b/pki/src/main/resources/zeroecho/pki/profiles/v1/intermediate-ca.json
index 18110e6..48cb31a 100644
--- a/pki/src/main/resources/zeroecho/pki/profiles/v1/intermediate-ca.json
+++ b/pki/src/main/resources/zeroecho/pki/profiles/v1/intermediate-ca.json
@@ -1 +1 @@
-{"schemaVersion":2,"certificateType":"INTERMEDIATE_CA","profileId":"intermediate-ca","profileVersion":1,"formatId":"x509","displayName":"Intermediate CA","maxValidity":"PT43800H","subject":{"allowEmpty":false,"rules":[{"oid":"2.5.4.3","source":"REQUESTER","minimumOccurrences":1,"maximumOccurrences":1,"maximumUtf8Bytes":253}]},"caCertificate":{"basicConstraintsCritical":true,"pathLengthConstraint":0,"keyUsageCritical":true,"keyUsages":["CRL_SIGN","KEY_CERT_SIGN"],"allowedSubjectKeyAlgorithms":["ECDSA","Ed25519","RSA"]}}
\ No newline at end of file
+{"schemaVersion":3,"certificateType":"INTERMEDIATE_CA","profileId":"intermediate-ca","profileVersion":1,"formatId":"x509","displayName":"Intermediate CA","maxValidity":"PT43800H","algorithmBindings":{"mode":"STANDARD_ONLY"},"subject":{"allowEmpty":false,"rules":[{"oid":"2.5.4.3","source":"REQUESTER","minimumOccurrences":1,"maximumOccurrences":1,"maximumUtf8Bytes":253}]},"caCertificate":{"basicConstraintsCritical":true,"pathLengthConstraint":0,"keyUsageCritical":true,"keyUsages":["CRL_SIGN","KEY_CERT_SIGN"],"allowedSubjectKeyAlgorithms":["ECDSA","Ed25519","RSA"]}}
\ No newline at end of file
diff --git a/pki/src/main/resources/zeroecho/pki/profiles/v1/root-ca.json b/pki/src/main/resources/zeroecho/pki/profiles/v1/root-ca.json
index 388a908..096b067 100644
--- a/pki/src/main/resources/zeroecho/pki/profiles/v1/root-ca.json
+++ b/pki/src/main/resources/zeroecho/pki/profiles/v1/root-ca.json
@@ -1 +1 @@
-{"schemaVersion":2,"certificateType":"ROOT_CA","profileId":"root-ca","profileVersion":1,"formatId":"x509","displayName":"Root CA","maxValidity":"PT87600H","subject":{"allowEmpty":false,"rules":[{"oid":"2.5.4.3","source":"REQUESTER","minimumOccurrences":1,"maximumOccurrences":1,"maximumUtf8Bytes":253}]},"caCertificate":{"basicConstraintsCritical":true,"pathLengthConstraint":1,"keyUsageCritical":true,"keyUsages":["CRL_SIGN","KEY_CERT_SIGN"],"allowedSubjectKeyAlgorithms":["ECDSA","Ed25519","RSA"]}}
\ No newline at end of file
+{"schemaVersion":3,"certificateType":"ROOT_CA","profileId":"root-ca","profileVersion":1,"formatId":"x509","displayName":"Root CA","maxValidity":"PT87600H","algorithmBindings":{"mode":"STANDARD_ONLY"},"subject":{"allowEmpty":false,"rules":[{"oid":"2.5.4.3","source":"REQUESTER","minimumOccurrences":1,"maximumOccurrences":1,"maximumUtf8Bytes":253}]},"caCertificate":{"basicConstraintsCritical":true,"pathLengthConstraint":1,"keyUsageCritical":true,"keyUsages":["CRL_SIGN","KEY_CERT_SIGN"],"allowedSubjectKeyAlgorithms":["ECDSA","Ed25519","RSA"]}}
\ No newline at end of file
diff --git a/pki/src/main/resources/zeroecho/pki/profiles/v1/server-tls.json b/pki/src/main/resources/zeroecho/pki/profiles/v1/server-tls.json
index 0bfb195..172cb05 100644
--- a/pki/src/main/resources/zeroecho/pki/profiles/v1/server-tls.json
+++ b/pki/src/main/resources/zeroecho/pki/profiles/v1/server-tls.json
@@ -1 +1 @@
-{"schemaVersion":2,"certificateType":"END_ENTITY","profileId":"server-tls","profileVersion":1,"formatId":"x509","displayName":"Server TLS","maxValidity":"PT8760H","subject":{"allowEmpty":true,"rules":[{"oid":"2.5.4.3","source":"REQUESTER","minimumOccurrences":0,"maximumOccurrences":1,"maximumUtf8Bytes":253}]},"subjectAlternativeNames":{"minimumTotal":1,"maximumTotal":64,"serviceIdentityRequired":true,"emailIdentityRequired":false,"criticalWhenSubjectNonEmpty":false,"rules":[{"type":"DNS_NAME","minimumOccurrences":0,"maximumOccurrences":64,"wildcardAllowed":false},{"type":"IP_ADDRESS","minimumOccurrences":0,"maximumOccurrences":16,"ipv4Allowed":true,"ipv6Allowed":true}]},"leafCertificate":{"basicConstraintsCritical":true,"keyUsageCritical":true,"keyUsage":["DIGITAL_SIGNATURE"],"extendedKeyUsageCritical":false,"extendedKeyUsage":["1.3.6.1.5.5.7.3.1"],"allowedKeyAlgorithms":["ECDSA","Ed25519","RSA"]}}
\ No newline at end of file
+{"schemaVersion":3,"certificateType":"END_ENTITY","profileId":"server-tls","profileVersion":1,"formatId":"x509","displayName":"Server TLS","maxValidity":"PT8760H","algorithmBindings":{"mode":"STANDARD_ONLY"},"subject":{"allowEmpty":true,"rules":[{"oid":"2.5.4.3","source":"REQUESTER","minimumOccurrences":0,"maximumOccurrences":1,"maximumUtf8Bytes":253}]},"subjectAlternativeNames":{"minimumTotal":1,"maximumTotal":64,"serviceIdentityRequired":true,"emailIdentityRequired":false,"criticalWhenSubjectNonEmpty":false,"rules":[{"type":"DNS_NAME","minimumOccurrences":0,"maximumOccurrences":64,"wildcardAllowed":false},{"type":"IP_ADDRESS","minimumOccurrences":0,"maximumOccurrences":16,"ipv4Allowed":true,"ipv6Allowed":true}]},"leafCertificate":{"basicConstraintsCritical":true,"keyUsageCritical":true,"keyUsage":["DIGITAL_SIGNATURE"],"extendedKeyUsageCritical":false,"extendedKeyUsage":["1.3.6.1.5.5.7.3.1"],"allowedKeyAlgorithms":["ECDSA","Ed25519","RSA"]}}
\ No newline at end of file
diff --git a/pki/src/main/resources/zeroecho/pki/profiles/v1/vpn-client.json b/pki/src/main/resources/zeroecho/pki/profiles/v1/vpn-client.json
index aded624..e471284 100644
--- a/pki/src/main/resources/zeroecho/pki/profiles/v1/vpn-client.json
+++ b/pki/src/main/resources/zeroecho/pki/profiles/v1/vpn-client.json
@@ -1 +1 @@
-{"schemaVersion":2,"certificateType":"END_ENTITY","profileId":"vpn-client","profileVersion":1,"formatId":"x509","displayName":"VPN Client","maxValidity":"PT8760H","subject":{"allowEmpty":true,"rules":[{"oid":"2.5.4.3","source":"REQUESTER","minimumOccurrences":0,"maximumOccurrences":1,"maximumUtf8Bytes":253}]},"subjectAlternativeNames":{"minimumTotal":1,"maximumTotal":16,"serviceIdentityRequired":false,"emailIdentityRequired":false,"criticalWhenSubjectNonEmpty":false,"rules":[{"type":"RFC822_NAME","minimumOccurrences":0,"maximumOccurrences":16},{"type":"URI","minimumOccurrences":0,"maximumOccurrences":16,"allowedSchemes":["spiffe"]}]},"leafCertificate":{"basicConstraintsCritical":true,"keyUsageCritical":true,"keyUsage":["DIGITAL_SIGNATURE"],"extendedKeyUsageCritical":false,"extendedKeyUsage":["1.3.6.1.5.5.7.3.2"],"allowedKeyAlgorithms":["ECDSA","Ed25519","RSA"]}}
\ No newline at end of file
+{"schemaVersion":3,"certificateType":"END_ENTITY","profileId":"vpn-client","profileVersion":1,"formatId":"x509","displayName":"VPN Client","maxValidity":"PT8760H","algorithmBindings":{"mode":"STANDARD_ONLY"},"subject":{"allowEmpty":true,"rules":[{"oid":"2.5.4.3","source":"REQUESTER","minimumOccurrences":0,"maximumOccurrences":1,"maximumUtf8Bytes":253}]},"subjectAlternativeNames":{"minimumTotal":1,"maximumTotal":16,"serviceIdentityRequired":false,"emailIdentityRequired":false,"criticalWhenSubjectNonEmpty":false,"rules":[{"type":"RFC822_NAME","minimumOccurrences":0,"maximumOccurrences":16},{"type":"URI","minimumOccurrences":0,"maximumOccurrences":16,"allowedSchemes":["spiffe"]}]},"leafCertificate":{"basicConstraintsCritical":true,"keyUsageCritical":true,"keyUsage":["DIGITAL_SIGNATURE"],"extendedKeyUsageCritical":false,"extendedKeyUsage":["1.3.6.1.5.5.7.3.2"],"allowedKeyAlgorithms":["ECDSA","Ed25519","RSA"]}}
\ No newline at end of file
diff --git a/pki/src/main/resources/zeroecho/pki/profiles/v1/vpn-server.json b/pki/src/main/resources/zeroecho/pki/profiles/v1/vpn-server.json
index 1c9a714..7636e7c 100644
--- a/pki/src/main/resources/zeroecho/pki/profiles/v1/vpn-server.json
+++ b/pki/src/main/resources/zeroecho/pki/profiles/v1/vpn-server.json
@@ -1 +1 @@
-{"schemaVersion":2,"certificateType":"END_ENTITY","profileId":"vpn-server","profileVersion":1,"formatId":"x509","displayName":"VPN Server","maxValidity":"PT8760H","subject":{"allowEmpty":true,"rules":[{"oid":"2.5.4.3","source":"REQUESTER","minimumOccurrences":0,"maximumOccurrences":1,"maximumUtf8Bytes":253}]},"subjectAlternativeNames":{"minimumTotal":1,"maximumTotal":64,"serviceIdentityRequired":true,"emailIdentityRequired":false,"criticalWhenSubjectNonEmpty":false,"rules":[{"type":"DNS_NAME","minimumOccurrences":0,"maximumOccurrences":64,"wildcardAllowed":false},{"type":"IP_ADDRESS","minimumOccurrences":0,"maximumOccurrences":16,"ipv4Allowed":true,"ipv6Allowed":true}]},"leafCertificate":{"basicConstraintsCritical":true,"keyUsageCritical":true,"keyUsage":["DIGITAL_SIGNATURE"],"extendedKeyUsageCritical":false,"extendedKeyUsage":["1.3.6.1.5.5.7.3.1"],"allowedKeyAlgorithms":["ECDSA","Ed25519","RSA"]}}
\ No newline at end of file
+{"schemaVersion":3,"certificateType":"END_ENTITY","profileId":"vpn-server","profileVersion":1,"formatId":"x509","displayName":"VPN Server","maxValidity":"PT8760H","algorithmBindings":{"mode":"STANDARD_ONLY"},"subject":{"allowEmpty":true,"rules":[{"oid":"2.5.4.3","source":"REQUESTER","minimumOccurrences":0,"maximumOccurrences":1,"maximumUtf8Bytes":253}]},"subjectAlternativeNames":{"minimumTotal":1,"maximumTotal":64,"serviceIdentityRequired":true,"emailIdentityRequired":false,"criticalWhenSubjectNonEmpty":false,"rules":[{"type":"DNS_NAME","minimumOccurrences":0,"maximumOccurrences":64,"wildcardAllowed":false},{"type":"IP_ADDRESS","minimumOccurrences":0,"maximumOccurrences":16,"ipv4Allowed":true,"ipv6Allowed":true}]},"leafCertificate":{"basicConstraintsCritical":true,"keyUsageCritical":true,"keyUsage":["DIGITAL_SIGNATURE"],"extendedKeyUsageCritical":false,"extendedKeyUsage":["1.3.6.1.5.5.7.3.1"],"allowedKeyAlgorithms":["ECDSA","Ed25519","RSA"]}}
\ No newline at end of file
diff --git a/pki/src/main/resources/zeroecho/pki/x509/zeroecho-private-bindings-v1.json b/pki/src/main/resources/zeroecho/pki/x509/zeroecho-private-bindings-v1.json
new file mode 100644
index 0000000..1bc6441
--- /dev/null
+++ b/pki/src/main/resources/zeroecho/pki/x509/zeroecho-private-bindings-v1.json
@@ -0,0 +1,49 @@
+{
+ "schemaVersion": 1,
+ "bindings": [
+ {
+ "bindingId": "zeroecho.private.sphincs-plus-default.spki.v1",
+ "algorithmIdentity": "SPHINCS_PLUS_PUBLIC_KEY",
+ "role": "SUBJECT_PUBLIC_KEY",
+ "oid": "1.3.6.1.4.1.31424.1.1.1",
+ "encodingVersion": 1,
+ "parameterRule": "ABSENT",
+ "publicKeyEncoding": "NESTED_SPKI_DER",
+ "signatureEncoding": "NOT_APPLICABLE",
+ "interoperability": "ZEROECHO_ECOSYSTEM"
+ },
+ {
+ "bindingId": "zeroecho.private.sphincs-plus-default.csr-signature.v1",
+ "algorithmIdentity": "SPHINCS_PLUS_SIGNATURE",
+ "role": "CSR_SIGNATURE",
+ "oid": "1.3.6.1.4.1.31424.1.1.2",
+ "encodingVersion": 1,
+ "parameterRule": "ABSENT",
+ "publicKeyEncoding": "NOT_APPLICABLE",
+ "signatureEncoding": "OPAQUE",
+ "interoperability": "ZEROECHO_ECOSYSTEM"
+ },
+ {
+ "bindingId": "zeroecho.private.sphincs-plus-default.certificate-signature.v1",
+ "algorithmIdentity": "SPHINCS_PLUS_SIGNATURE",
+ "role": "CERTIFICATE_SIGNATURE",
+ "oid": "1.3.6.1.4.1.31424.1.1.3",
+ "encodingVersion": 1,
+ "parameterRule": "ABSENT",
+ "publicKeyEncoding": "NOT_APPLICABLE",
+ "signatureEncoding": "OPAQUE",
+ "interoperability": "ZEROECHO_ECOSYSTEM"
+ },
+ {
+ "bindingId": "zeroecho.private.sphincs-plus-default.crl-signature.v1",
+ "algorithmIdentity": "SPHINCS_PLUS_SIGNATURE",
+ "role": "CRL_SIGNATURE",
+ "oid": "1.3.6.1.4.1.31424.1.1.4",
+ "encodingVersion": 1,
+ "parameterRule": "ABSENT",
+ "publicKeyEncoding": "NOT_APPLICABLE",
+ "signatureEncoding": "OPAQUE",
+ "interoperability": "ZEROECHO_ECOSYSTEM"
+ }
+ ]
+}
diff --git a/pki/src/test/java/zeroecho/pki/api/profile/BuiltInCertificateProfileCatalogTest.java b/pki/src/test/java/zeroecho/pki/api/profile/BuiltInCertificateProfileCatalogTest.java
index 9294bd4..52aea3d 100644
--- a/pki/src/test/java/zeroecho/pki/api/profile/BuiltInCertificateProfileCatalogTest.java
+++ b/pki/src/test/java/zeroecho/pki/api/profile/BuiltInCertificateProfileCatalogTest.java
@@ -245,10 +245,10 @@ final class BuiltInCertificateProfileCatalogTest {
@Test
void profileDocumentsFailClosedForInvalidSchemaFieldsEncodingAndClassMetadata() {
List invalidDocuments = List.of(
- replace(mainResource(SERVER), "\"schemaVersion\":2", "\"schemaVersion\":1"),
- replace(mainResource(SERVER), "\"schemaVersion\":2,", "\"schemaVersion\":2,\"active\":true,"),
- replace(mainResource(SERVER), "\"schemaVersion\":2,",
- "\"schemaVersion\":2,\"@class\":\"" + InitializationSentinel.CLASS_NAME + "\","),
+ replace(mainResource(SERVER), "\"schemaVersion\":3", "\"schemaVersion\":1"),
+ replace(mainResource(SERVER), "\"schemaVersion\":3,", "\"schemaVersion\":3,\"active\":true,"),
+ replace(mainResource(SERVER), "\"schemaVersion\":3,",
+ "\"schemaVersion\":3,\"@class\":\"" + InitializationSentinel.CLASS_NAME + "\","),
malformedUtf8(mainResource(SERVER)));
for (byte[] invalid : invalidDocuments) {
Map> resources = baseResources();
diff --git a/pki/src/test/java/zeroecho/pki/api/profile/CertificateProfileDocumentCodecTest.java b/pki/src/test/java/zeroecho/pki/api/profile/CertificateProfileDocumentCodecTest.java
index eb37e45..c60718a 100644
--- a/pki/src/test/java/zeroecho/pki/api/profile/CertificateProfileDocumentCodecTest.java
+++ b/pki/src/test/java/zeroecho/pki/api/profile/CertificateProfileDocumentCodecTest.java
@@ -63,13 +63,14 @@ final class CertificateProfileDocumentCodecTest {
private static final String VALID_DOCUMENT = """
{
- "schemaVersion": 2,
+ "schemaVersion": 3,
"certificateType": "END_ENTITY",
"profileId": "tls-service",
"profileVersion": 7,
"formatId": "x509",
"displayName": "TLS service",
"maxValidity": "PT24H",
+ "algorithmBindings": {"mode":"STANDARD_ONLY"},
"subject": {
"allowEmpty": false,
"rules": [
@@ -138,7 +139,7 @@ final class CertificateProfileDocumentCodecTest {
void parsesEverySupportedRuleShapeIntoAuthoritativeTypedPolicies() {
CertificateProfileDefinition definition = parse(VALID_DOCUMENT);
- assertEquals(2, CertificateProfileDefinition.SCHEMA_VERSION);
+ assertEquals(3, CertificateProfileDefinition.SCHEMA_VERSION);
assertEquals(CertificateProfileKind.END_ENTITY, definition.certificateType());
assertEquals("tls-service", definition.profileId());
assertEquals(7, definition.profileVersion());
@@ -311,10 +312,10 @@ final class CertificateProfileDocumentCodecTest {
@Test
void rejectsDuplicateUnknownMissingNullWrongAndNonintegralFields() {
- assertCode(VALID_DOCUMENT.replace("\"schemaVersion\": 2,", "\"schemaVersion\": 2,\"schemaVersion\": 2,"),
+ assertCode(VALID_DOCUMENT.replace("\"schemaVersion\": 3,", "\"schemaVersion\": 3,\"schemaVersion\": 3,"),
"DUPLICATE_FIELD");
for (String document : List.of(
- VALID_DOCUMENT.replace("\"schemaVersion\": 2,", "\"secret-field\": true,\"schemaVersion\": 2,"),
+ VALID_DOCUMENT.replace("\"schemaVersion\": 3,", "\"secret-field\": true,\"schemaVersion\": 3,"),
VALID_DOCUMENT.replace("\"allowEmpty\": false,", "\"unknown\": true,\"allowEmpty\": false,"),
VALID_DOCUMENT.replace("\"oid\": \"2.5.4.3\",", "\"unknown\": true,\"oid\": \"2.5.4.3\","),
VALID_DOCUMENT.replace("\"minimumTotal\": 1,", "\"unknown\": true,\"minimumTotal\": 1,"),
@@ -330,7 +331,7 @@ final class CertificateProfileDocumentCodecTest {
"\"keyUsageCritical\": true,\"keyUsageCritical\": true,"))) {
assertCode(document, "DUPLICATE_FIELD");
}
- for (String document : List.of(VALID_DOCUMENT.replace("\"schemaVersion\": 2,\n", ""),
+ for (String document : List.of(VALID_DOCUMENT.replace("\"schemaVersion\": 3,\n", ""),
VALID_DOCUMENT.replace("\"allowEmpty\": false,\n", ""),
VALID_DOCUMENT.replace("\"oid\": \"2.5.4.3\",\n", ""),
VALID_DOCUMENT.replace("\"minimumTotal\": 1,\n", ""),
@@ -355,7 +356,7 @@ final class CertificateProfileDocumentCodecTest {
@Test
void rejectsUnsupportedVersionsTokensCaseWhitespaceAndNoncanonicalDuration() {
- assertCode(VALID_DOCUMENT.replace("\"schemaVersion\": 2", "\"schemaVersion\": 1"),
+ assertCode(VALID_DOCUMENT.replace("\"schemaVersion\": 3", "\"schemaVersion\": 1"),
"SCHEMA_VERSION_UNSUPPORTED");
assertCode(VALID_DOCUMENT.replace("\"certificateType\": \"END_ENTITY\"", "\"certificateType\": \"end_entity\""),
"CERTIFICATE_TYPE_UNSUPPORTED");
@@ -473,8 +474,8 @@ final class CertificateProfileDocumentCodecTest {
@Test
void redactsHostileInputAndParserDetailsFromFailures() {
String secret = "do-not-disclose-credential";
- String hostile = VALID_DOCUMENT.replace("\"schemaVersion\": 2,",
- "\"" + secret + "\": true,\"schemaVersion\": 2,");
+ String hostile = VALID_DOCUMENT.replace("\"schemaVersion\": 3,",
+ "\"" + secret + "\": true,\"schemaVersion\": 3,");
PkiException exception = assertThrows(PkiException.class, () -> parse(hostile));
@@ -508,8 +509,8 @@ final class CertificateProfileDocumentCodecTest {
logger.addHandler(handler);
try {
for (String field : hostileFields) {
- String document = VALID_DOCUMENT.replace("\"schemaVersion\": 2,",
- "\"" + field + "\":\"" + probeName + "\",\"schemaVersion\": 2,");
+ String document = VALID_DOCUMENT.replace("\"schemaVersion\": 3,",
+ "\"" + field + "\":\"" + probeName + "\",\"schemaVersion\": 3,");
PkiException exception = assertThrows(PkiException.class, () -> parse(document));
assertTrue(exception.getMessage().contains("code=UNKNOWN_FIELD "));
assertFalse(exception.getMessage().contains(field));
diff --git a/pki/src/test/java/zeroecho/pki/application/PkiSessionLifecycleTest.java b/pki/src/test/java/zeroecho/pki/application/PkiSessionLifecycleTest.java
index 3b0b748..52a594c 100644
--- a/pki/src/test/java/zeroecho/pki/application/PkiSessionLifecycleTest.java
+++ b/pki/src/test/java/zeroecho/pki/application/PkiSessionLifecycleTest.java
@@ -40,6 +40,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.lang.reflect.Proxy;
+import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
@@ -48,6 +49,7 @@ import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Map;
+import java.util.List;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
@@ -68,6 +70,7 @@ import zeroecho.pki.spi.ProviderConfig;
import zeroecho.pki.spi.audit.AuditSink;
import zeroecho.pki.spi.store.PkiStore;
import zeroecho.pki.testkit.InMemorySignatureWorkflow;
+import zeroecho.pki.testkit.TestX509AlgorithmBindingProvider;
class PkiSessionLifecycleTest {
@@ -96,6 +99,60 @@ class PkiSessionLifecycleTest {
System.out.println("...ok");
}
+ @Test
+ void explicitlyActivatesAuthorizedDeployerBindingProvider() throws Exception {
+ System.out.println("explicitlyActivatesAuthorizedDeployerBindingProvider");
+ PkiSessionConfiguration.BindingProviderConfiguration bindings =
+ new PkiSessionConfiguration.BindingProviderConfiguration(TestX509AlgorithmBindingProvider.ID,
+ List.of(TestX509AlgorithmBindingProvider.PEN_ROOT,
+ TestX509AlgorithmBindingProvider.EGOTHOR_ROOT),
+ Optional.of("test-bindings-v1"));
+ PkiSessionConfiguration configuration = new PkiSessionConfiguration(3,
+ new ProviderConfig("fs", Map.of("root", temporaryDirectory.resolve("binding-store").toString())),
+ new ProviderConfig("memory", Map.of("size", "16")), Optional.empty(), List.of(), List.of(bindings));
+ try (PkiSession session = PkiSession.open(configuration)) {
+ assertTrue(session.algorithmBindings().find("test.ed25519.certificate.v1").isPresent());
+ assertTrue(session.algorithmBindings().find("test.ed25519.crl.v1").isPresent());
+ System.out.println("...bindings=" + session.algorithmBindings().bindings().size());
+ }
+ System.out.println("...ok");
+ }
+
+ @Test
+ void profileValidationRejectsBindingOutsideActiveRegistry() throws Exception {
+ System.out.println("profileValidationRejectsBindingOutsideActiveRegistry");
+ PkiSessionConfiguration configuration = configuration(temporaryDirectory.resolve("profile-binding-store"));
+ BuiltInCertificateProfileTemplate template = BuiltInCertificateProfileCatalog
+ .load(getClass().getClassLoader()).stream().findFirst().orElseThrow();
+ String document = new String(template.canonicalJson(), StandardCharsets.UTF_8).replace(
+ "\"algorithmBindings\":{\"mode\":\"STANDARD_ONLY\"}",
+ "\"algorithmBindings\":{\"mode\":\"EXPLICIT\",\"certificateSignature\":"
+ + "{\"bindingId\":\"deployer.inactive.certificate.v1\","
+ + "\"commitment\":\"inactive-binding-commitment\"}}");
+ try (PkiSession session = PkiSession.open(configuration)) {
+ PkiOperationOutcome outcome = session.operations().execute(
+ new PkiOperation.ValidateProfile(document.getBytes(StandardCharsets.UTF_8)),
+ CancellationSignal.NONE);
+ PkiOperationOutcome.Failure failure = assertInstanceOf(PkiOperationOutcome.Failure.class, outcome);
+ System.out.println("...classification=" + failure.classification());
+ assertEquals(PkiOperationFailure.POLICY_REJECTION, failure.classification());
+ }
+ System.out.println("...ok");
+ }
+
+ @Test
+ void rejectsUnauthorizedDeployerBindingRootBeforeStoreAllocation() {
+ System.out.println("rejectsUnauthorizedDeployerBindingRootBeforeStoreAllocation");
+ PkiSessionConfiguration.BindingProviderConfiguration bindings =
+ new PkiSessionConfiguration.BindingProviderConfiguration(TestX509AlgorithmBindingProvider.ID,
+ List.of(TestX509AlgorithmBindingProvider.PEN_ROOT), Optional.of("test-bindings-v1"));
+ PkiSessionConfiguration configuration = new PkiSessionConfiguration(3,
+ new ProviderConfig("fs", Map.of("root", temporaryDirectory.resolve("unused-binding").toString())),
+ new ProviderConfig("memory", Map.of("size", "16")), Optional.empty(), List.of(), List.of(bindings));
+ assertThrows(IllegalArgumentException.class, () -> PkiSession.open(configuration));
+ System.out.println("...ok");
+ }
+
@Test
void validatesBeforeAllocationAndCleansPartialConstruction() {
System.out.println("validatesBeforeAllocationAndCleansPartialConstruction");
@@ -142,7 +199,7 @@ class PkiSessionLifecycleTest {
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,
+ PkiSessionConfiguration configuration = new PkiSessionConfiguration(3,
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,
@@ -159,7 +216,7 @@ class PkiSessionLifecycleTest {
System.out.println("opensExplicitPublisherCapabilityWithoutSigning");
ProviderConfig publisher = new ProviderConfig("filesystem",
Map.of("root", temporaryDirectory.resolve("published").toString(), "targetId", "local-crls"));
- PkiSessionConfiguration configuration = new PkiSessionConfiguration(2,
+ PkiSessionConfiguration configuration = new PkiSessionConfiguration(3,
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)) {
@@ -174,7 +231,7 @@ class PkiSessionLifecycleTest {
void rejectsUnknownPublisherBeforeStoreAllocation() {
System.out.println("rejectsUnknownPublisherBeforeStoreAllocation");
Path storeRoot = temporaryDirectory.resolve("must-not-open");
- PkiSessionConfiguration configuration = new PkiSessionConfiguration(2,
+ PkiSessionConfiguration configuration = new PkiSessionConfiguration(3,
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())));
@@ -197,7 +254,7 @@ class PkiSessionLifecycleTest {
placeholder, new ProviderConfig("x509-bc", Map.of()),
temporaryDirectory.resolve("composed-signing-bus").toString(), "SHA256withRSA",
Duration.ofSeconds(5), Optional.empty());
- PkiSessionConfiguration configuration = new PkiSessionConfiguration(2,
+ PkiSessionConfiguration configuration = new PkiSessionConfiguration(3,
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);
diff --git a/pki/src/test/java/zeroecho/pki/impl/core/DefaultProfileServiceTest.java b/pki/src/test/java/zeroecho/pki/impl/core/DefaultProfileServiceTest.java
index 5233fc9..b8376ec 100644
--- a/pki/src/test/java/zeroecho/pki/impl/core/DefaultProfileServiceTest.java
+++ b/pki/src/test/java/zeroecho/pki/impl/core/DefaultProfileServiceTest.java
@@ -46,6 +46,7 @@ import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
+import java.util.Optional;
import java.util.concurrent.Callable;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.ExecutionException;
@@ -60,13 +61,22 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import zeroecho.pki.api.PkiException;
+import zeroecho.pki.api.algorithm.X509AlgorithmBinding;
+import zeroecho.pki.api.algorithm.X509AlgorithmBindingRegistry;
import zeroecho.pki.api.profile.BuiltInCertificateProfileCatalog;
import zeroecho.pki.api.profile.BuiltInCertificateProfileTemplate;
import zeroecho.pki.api.profile.CertificateProfileRef;
+import zeroecho.pki.api.profile.CertificateProfileDefinition;
+import zeroecho.pki.api.profile.CertificateProfileDocumentCodec;
+import zeroecho.pki.api.profile.X509AlgorithmBindingPolicy;
+import zeroecho.pki.impl.framework.x509.ZeroEchoPrivateX509Bindings;
+import zeroecho.pki.impl.framework.x509.ImmutableX509AlgorithmBindingRegistry;
+import zeroecho.pki.impl.framework.x509.StandardX509Bindings;
import zeroecho.pki.impl.audit.InMemoryAuditSink;
import zeroecho.pki.impl.fs.FilesystemPkiStore;
import zeroecho.pki.impl.fs.FsPkiStoreOptions;
import zeroecho.pki.spi.store.PkiStore;
+import zeroecho.pki.testkit.TestX509AlgorithmBindingProvider;
/**
* Focused persisted profile lifecycle tests.
@@ -217,6 +227,68 @@ final class DefaultProfileServiceTest {
}
}
+ @Test
+ void explicitPrivateBindingIsPinnedAndChangedCommitmentFailsClosed(@TempDir Path directory) throws Exception {
+ System.out.println("explicitPrivateBindingIsPinnedAndChangedCommitmentFailsClosed");
+ CertificateProfileDefinition base = builtIn("server-tls").definition();
+ zeroecho.pki.api.algorithm.X509AlgorithmBinding binding = ZeroEchoPrivateX509Bindings.descriptors().stream()
+ .filter(candidate -> candidate.role()
+ == zeroecho.pki.api.algorithm.X509AlgorithmBinding.Role.CERTIFICATE_SIGNATURE)
+ .findFirst().orElseThrow();
+ X509AlgorithmBindingPolicy.BindingReference reference = new X509AlgorithmBindingPolicy.BindingReference(
+ binding.bindingId(), binding.semanticCommitment());
+ X509AlgorithmBindingPolicy policy = new X509AlgorithmBindingPolicy(X509AlgorithmBindingPolicy.Mode.EXPLICIT,
+ Optional.empty(), Optional.empty(), Optional.of(reference), Optional.empty());
+ CertificateProfileDefinition explicit = new CertificateProfileDefinition(base.certificateType(),
+ "server-tls-private", base.profileVersion(), base.formatId(), "Private server TLS",
+ base.maximumValidity(), base.subjectPolicy(), base.certificatePolicy(), policy);
+ try (FilesystemPkiStore store = new FilesystemPkiStore(directory.resolve("store"), FsPkiStoreOptions.defaults(),
+ CLOCK)) {
+ DefaultProfileService service = service(store);
+ service.importProfile(CertificateProfileDocumentCodec.writeCanonical(explicit));
+ X509AlgorithmBindingPolicy changed = new X509AlgorithmBindingPolicy(X509AlgorithmBindingPolicy.Mode.EXPLICIT,
+ Optional.empty(), Optional.empty(), Optional.of(new X509AlgorithmBindingPolicy.BindingReference(
+ binding.bindingId(), binding.semanticCommitment() + "-changed")), Optional.empty());
+ CertificateProfileDefinition invalid = new CertificateProfileDefinition(base.certificateType(),
+ "server-tls-changed", base.profileVersion(), base.formatId(), "Changed server TLS",
+ base.maximumValidity(), base.subjectPolicy(), base.certificatePolicy(), changed);
+ assertThrows(PkiException.class,
+ () -> service.importProfile(CertificateProfileDocumentCodec.writeCanonical(invalid)));
+ }
+ System.out.println("...binding=" + binding.bindingId());
+ System.out.println("...ok");
+ }
+
+ @Test
+ void persistedDeployerBindingRequiresIdenticalRegistryAfterReopen(@TempDir Path directory) throws Exception {
+ System.out.println("persistedDeployerBindingRequiresIdenticalRegistryAfterReopen");
+ List descriptors = new java.util.ArrayList<>(StandardX509Bindings.descriptors());
+ descriptors.addAll(ZeroEchoPrivateX509Bindings.descriptors());
+ descriptors.addAll(new TestX509AlgorithmBindingProvider().bindings());
+ X509AlgorithmBindingRegistry deployerRegistry = ImmutableX509AlgorithmBindingRegistry.create(descriptors);
+ X509AlgorithmBinding binding = deployerRegistry.find("test.ed25519.certificate.v1").orElseThrow();
+ CertificateProfileDefinition base = builtIn("server-tls").definition();
+ X509AlgorithmBindingPolicy policy = new X509AlgorithmBindingPolicy(X509AlgorithmBindingPolicy.Mode.EXPLICIT,
+ Optional.empty(), Optional.empty(), Optional.of(new X509AlgorithmBindingPolicy.BindingReference(
+ binding.bindingId(), binding.semanticCommitment())), Optional.empty());
+ CertificateProfileDefinition explicit = new CertificateProfileDefinition(base.certificateType(),
+ "server-tls-deployer", base.profileVersion(), base.formatId(), "Deployer server TLS",
+ base.maximumValidity(), base.subjectPolicy(), base.certificatePolicy(), policy);
+ Path root = directory.resolve("store");
+ try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), CLOCK)) {
+ DefaultProfileService service = service(store, deployerRegistry);
+ service.importProfile(CertificateProfileDocumentCodec.writeCanonical(explicit));
+ service.activateProfile(explicit.profileId(), explicit.profileVersion());
+ }
+ try (FilesystemPkiStore store = new FilesystemPkiStore(root, FsPkiStoreOptions.defaults(), CLOCK)) {
+ assertEquals(explicit.profileId(), service(store, deployerRegistry)
+ .requireActiveProfile(explicit.profileId()).reference().profileId());
+ assertThrows(PkiException.class, () -> service(store).requireActiveProfile(explicit.profileId()));
+ }
+ System.out.println("...binding=" + binding.bindingId());
+ System.out.println("...ok");
+ }
+
@Test
void lifecycleOperationsSanitizeStoreDiagnosticsAndAuditCodes() {
String sentinel = "unsafe-profile-store-sentinel";
@@ -248,6 +320,11 @@ final class DefaultProfileServiceTest {
return new DefaultProfileService(store, CLOCK, new InMemoryAuditSink());
}
+ private static DefaultProfileService service(FilesystemPkiStore store,
+ X509AlgorithmBindingRegistry algorithmBindings) {
+ return new DefaultProfileService(store, CLOCK, new InMemoryAuditSink(), algorithmBindings);
+ }
+
private static BuiltInCertificateProfileTemplate builtIn(String profileId) {
return BuiltInCertificateProfileCatalog.load(DefaultProfileServiceTest.class.getClassLoader()).stream()
.filter(template -> profileId.equals(template.definition().profileId())).findFirst().orElseThrow();
diff --git a/pki/src/test/java/zeroecho/pki/impl/framework/x509/X509BindingPhaseATest.java b/pki/src/test/java/zeroecho/pki/impl/framework/x509/X509BindingPhaseATest.java
index 4e3dbf7..ccb2e81 100644
--- a/pki/src/test/java/zeroecho/pki/impl/framework/x509/X509BindingPhaseATest.java
+++ b/pki/src/test/java/zeroecho/pki/impl/framework/x509/X509BindingPhaseATest.java
@@ -38,22 +38,59 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
+import java.security.Signature;
+import java.security.Security;
+import java.security.PrivateKey;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.math.BigInteger;
+import java.time.Instant;
+import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.OptionalLong;
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
import org.bouncycastle.asn1.DERNull;
import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
+import org.bouncycastle.asn1.x500.X500Name;
+import org.bouncycastle.cert.X509CRLHolder;
+import org.bouncycastle.cert.X509CertificateHolder;
+import org.bouncycastle.cert.X509v2CRLBuilder;
+import org.bouncycastle.cert.X509v3CertificateBuilder;
+import org.bouncycastle.operator.ContentSigner;
+import org.bouncycastle.pkcs.PKCS10CertificationRequest;
+import org.bouncycastle.pkcs.PKCS10CertificationRequestBuilder;
+import org.bouncycastle.pqc.jcajce.provider.BouncyCastlePQCProvider;
import org.junit.jupiter.api.Test;
import zeroecho.core.alg.BootstrapAlgorithmIdentities;
import zeroecho.core.spec.AlgorithmIdentity;
import zeroecho.core.io.ImmutableByteContent;
+import zeroecho.core.io.RepeatableContent;
+import zeroecho.core.alg.sphincsplus.SphincsPlusKeyGenSpec;
+import zeroecho.sdk.ZeroEchoSession;
+import zeroecho.pki.api.algorithm.X509AlgorithmBinding;
+import zeroecho.pki.api.EncodedObject;
+import zeroecho.pki.api.Encoding;
+import zeroecho.pki.api.FormatId;
+import zeroecho.pki.api.issuance.VerificationPolicy;
+import zeroecho.pki.api.request.CertificationRequest;
+import zeroecho.pki.api.request.ParsedCertificationRequest;
+import zeroecho.pki.api.request.ProofOfPossessionStatus;
+import zeroecho.pki.impl.framework.x509.bc.BcX509CertificationRequestParser;
+import zeroecho.pki.impl.framework.x509.bc.BcX509ProofOfPossessionVerifier;
+import zeroecho.pki.impl.framework.x509.bc.BcX509SignedObjectValidator;
+import zeroecho.pki.impl.framework.x509.bc.BcX509PublicKeyAdapter;
+import zeroecho.pki.testkit.TestX509AlgorithmBindingProvider;
import zeroecho.core.spec.AlgorithmSuite;
import zeroecho.core.spi.AlgorithmExecutionCapability;
import zeroecho.core.spi.AlgorithmExecutionCapabilityProvider;
@@ -65,6 +102,136 @@ import zeroecho.pki.impl.framework.x509.bc.BcX509VerificationExecutor;
*/
public final class X509BindingPhaseATest {
+ @Test
+ void zeroEchoPrivateAssignmentsAreExactAndLocked() {
+ System.out.println("zeroEchoPrivateAssignmentsAreExactAndLocked");
+ List bindings = ZeroEchoPrivateX509Bindings.descriptors();
+ assertEquals(List.of("1.3.6.1.4.1.31424.1.1.1", "1.3.6.1.4.1.31424.1.1.2",
+ "1.3.6.1.4.1.31424.1.1.3", "1.3.6.1.4.1.31424.1.1.4"),
+ bindings.stream().map(X509AlgorithmBinding::oid).toList());
+ assertEquals(4, bindings.stream().map(X509AlgorithmBinding::bindingId).distinct().count());
+ assertThrows(UnsupportedOperationException.class, () -> bindings.add(bindings.get(0)));
+ assertThrows(IllegalArgumentException.class,
+ () -> ImmutableX509AlgorithmBindingRegistry.create(List.of(bindings.get(0), bindings.get(0))));
+ System.out.println("...assignment-count=" + bindings.size());
+ System.out.println("...ok");
+ }
+
+ @Test
+ void productionSphincsPrivateBindingSignsAndVerifies() throws Exception {
+ System.out.println("productionSphincsPrivateBindingSignsAndVerifies");
+ Security.addProvider(new BouncyCastlePQCProvider());
+ KeyPair keyPair = new ZeroEchoSession().keyBuilders().asymmetric().generateKeyPair("SPHINCS+",
+ SphincsPlusKeyGenSpec.defaultSpec());
+ BcX509VerificationExecutor verifier = new BcX509VerificationExecutor();
+ X509AuthoritySnapshot authority = X509AuthoritySnapshot.compose(List.of(), List.of(verifier),
+ List.of(X509AuthoritySnapshot.bindExecutor(BcX509VerificationExecutor.IMPLEMENTATION_ID,
+ AlgorithmExecutionCapability.Direction.VERIFY, verifier)), policy(true, "private-test-v1"));
+ String signatureBinding = "zeroecho.private.sphincs-plus-default.certificate-signature.v1";
+ String publicKeyBinding = "zeroecho.private.sphincs-plus-default.spki.v1";
+ X509ExecutionPlan plan = authority.plan(
+ BootstrapAlgorithmIdentities.SPHINCS_PLUS_SIGNATURE,
+ BootstrapAlgorithmIdentities.SPHINCS_PLUS_PUBLIC_KEY,
+ AlgorithmExecutionCapability.Direction.VERIFY, Optional.empty(), "explicit", signatureBinding,
+ BcX509VerificationExecutor.class);
+ SubjectPublicKeyInfo wrapped = new BcX509PublicKeyAdapter(authority.bindings()).wrap(
+ keyPair.getPublic().getEncoded(), BootstrapAlgorithmIdentities.SPHINCS_PLUS_PUBLIC_KEY,
+ publicKeyBinding);
+ AlgorithmIdentifier identifier = BcX509AlgorithmAdapter.toBc(authority.bindings().resolve(signatureBinding,
+ BootstrapAlgorithmIdentities.SPHINCS_PLUS_SIGNATURE, X509AlgorithmRole.SIGNATURE_ALGORITHM));
+ byte[] payload = "ZeroEcho private X.509 binding".getBytes(java.nio.charset.StandardCharsets.UTF_8);
+ Signature signer = Signature.getInstance("SPHINCS+", new BouncyCastlePQCProvider());
+ signer.initSign(keyPair.getPrivate());
+ signer.update(payload);
+ byte[] signature = signer.sign();
+ try (RepeatableContent content = content(payload)) {
+ assertTrue(verifier.verify(authority, plan, wrapped, identifier, content, signature));
+ }
+
+ AlgorithmIdentifier csrIdentifier = privateIdentifier(authority,
+ "zeroecho.private.sphincs-plus-default.csr-signature.v1");
+ PKCS10CertificationRequest csr = new PKCS10CertificationRequestBuilder(new X500Name("CN=Private Request"),
+ wrapped).build(contentSigner(keyPair.getPrivate(), csrIdentifier));
+ ParsedCertificationRequest parsed = new BcX509CertificationRequestParser().parse(new CertificationRequest(
+ new FormatId("x509"), new EncodedObject(Encoding.DER, csr.getEncoded())));
+ assertEquals(ProofOfPossessionStatus.VERIFIED,
+ new BcX509ProofOfPossessionVerifier(authority, verifier)
+ .verify(parsed, new VerificationPolicy(true, Optional.empty())).status());
+
+ Instant notBefore = Instant.parse("2026-08-04T00:00:00Z");
+ X500Name issuer = new X500Name("CN=Private Root");
+ X509CertificateHolder certificate = new X509v3CertificateBuilder(issuer, BigInteger.ONE,
+ Date.from(notBefore), Date.from(notBefore.plusSeconds(3600)), issuer, wrapped)
+ .build(contentSigner(keyPair.getPrivate(), identifier));
+ try (RepeatableContent certificateContent = content(certificate.getEncoded())) {
+ BcX509SignedObjectValidator.CertificateBindings validated = new BcX509SignedObjectValidator(authority)
+ .validateCertificate(certificateContent,
+ Optional.of(BootstrapAlgorithmIdentities.SPHINCS_PLUS_SIGNATURE),
+ zeroecho.core.io.CancellationSignal.NONE);
+ assertEquals(BootstrapAlgorithmIdentities.SPHINCS_PLUS_PUBLIC_KEY, validated.subjectPublicKey());
+ }
+ try (RepeatableContent tbs = content(certificate.toASN1Structure().getTBSCertificate().getEncoded())) {
+ assertTrue(verifier.verify(authority, plan, wrapped, identifier, tbs, certificate.getSignature()));
+ }
+
+ AlgorithmIdentifier crlIdentifier = privateIdentifier(authority,
+ "zeroecho.private.sphincs-plus-default.crl-signature.v1");
+ X509CRLHolder crl = new X509v2CRLBuilder(issuer, Date.from(notBefore))
+ .setNextUpdate(Date.from(notBefore.plusSeconds(1800)))
+ .build(contentSigner(keyPair.getPrivate(), crlIdentifier));
+ X509ExecutionPlan crlPlan = authority.plan(
+ BootstrapAlgorithmIdentities.SPHINCS_PLUS_SIGNATURE,
+ BootstrapAlgorithmIdentities.SPHINCS_PLUS_PUBLIC_KEY,
+ AlgorithmExecutionCapability.Direction.VERIFY, Optional.empty(), "explicit",
+ "zeroecho.private.sphincs-plus-default.crl-signature.v1", BcX509VerificationExecutor.class);
+ try (RepeatableContent tbs = content(crl.toASN1Structure().getTBSCertList().getEncoded())) {
+ assertTrue(verifier.verify(authority, crlPlan, wrapped, crlIdentifier, tbs,
+ crl.toASN1Structure().getSignature().getOctets()));
+ }
+ assertThrows(IllegalArgumentException.class,
+ () -> new BcX509AlgorithmAdapter(authority.bindings()).decode(
+ new AlgorithmIdentifier(identifier.getAlgorithm(), DERNull.INSTANCE),
+ X509AlgorithmRole.SIGNATURE_ALGORITHM));
+ System.out.println("...oid=" + identifier.getAlgorithm().getId() + ", signature-bytes=" + signature.length);
+ System.out.println("...ok");
+ }
+
+ @Test
+ void deployerProviderBindingSignsAndVerifiesEndToEnd() throws Exception {
+ System.out.println("deployerProviderBindingSignsAndVerifiesEndToEnd");
+ TestX509AlgorithmBindingProvider deployer = new TestX509AlgorithmBindingProvider();
+ X509BindingRuleProvider provider = new X509BindingRuleProvider() {
+ @Override
+ public List rules() {
+ return deployer.rules();
+ }
+ };
+ BcX509VerificationExecutor verifier = new BcX509VerificationExecutor();
+ X509AuthoritySnapshot authority = X509AuthoritySnapshot.compose(List.of(provider), List.of(verifier),
+ List.of(X509AuthoritySnapshot.bindExecutor(BcX509VerificationExecutor.IMPLEMENTATION_ID,
+ AlgorithmExecutionCapability.Direction.VERIFY, verifier)), policy(true, "deployer-test-v1"));
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("Ed25519");
+ KeyPair pair = generator.generateKeyPair();
+ SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(pair.getPublic().getEncoded());
+ String bindingId = "test.ed25519.certificate.v1";
+ AlgorithmIdentifier identifier = BcX509AlgorithmAdapter.toBc(authority.bindings().resolve(bindingId,
+ BootstrapAlgorithmIdentities.ED25519_SIGNATURE, X509AlgorithmRole.SIGNATURE_ALGORITHM));
+ X500Name name = new X500Name("CN=Deployer Binding");
+ Instant start = Instant.parse("2026-08-04T00:00:00Z");
+ X509CertificateHolder certificate = new X509v3CertificateBuilder(name, BigInteger.TWO, Date.from(start),
+ Date.from(start.plusSeconds(3600)), name, spki)
+ .build(contentSigner(pair.getPrivate(), identifier, "Ed25519"));
+ X509ExecutionPlan plan = authority.plan(
+ BootstrapAlgorithmIdentities.ED25519_SIGNATURE, BootstrapAlgorithmIdentities.ED25519_PUBLIC_KEY,
+ AlgorithmExecutionCapability.Direction.VERIFY, Optional.empty(), "explicit", bindingId,
+ BcX509VerificationExecutor.class);
+ try (RepeatableContent tbs = content(certificate.toASN1Structure().getTBSCertificate().getEncoded())) {
+ assertTrue(verifier.verify(authority, plan, spki, identifier, tbs, certificate.getSignature()));
+ }
+ System.out.println("...oid=" + identifier.getAlgorithm().getId());
+ System.out.println("...ok");
+ }
+
@Test
void bootstrapMatrixIsExactAndSymmetric() {
System.out.println("bootstrapMatrixIsExactAndSymmetric");
@@ -374,6 +541,71 @@ public final class X509BindingPhaseATest {
};
}
+ private static RepeatableContent content(byte[] bytes) {
+ byte[] snapshot = bytes.clone();
+ return new RepeatableContent() {
+ @Override
+ public ByteArrayInputStream openStream() {
+ return new ByteArrayInputStream(snapshot);
+ }
+
+ @Override
+ public OptionalLong length() {
+ return OptionalLong.of(snapshot.length);
+ }
+
+ @Override
+ public String contentId() {
+ return "private-binding-test-content";
+ }
+
+ @Override
+ public void close() throws IOException {
+ // immutable in-memory test content owns no external resource
+ }
+ };
+ }
+
+ private static AlgorithmIdentifier privateIdentifier(X509AuthoritySnapshot authority, String bindingId) {
+ return BcX509AlgorithmAdapter.toBc(authority.bindings().resolve(bindingId,
+ BootstrapAlgorithmIdentities.SPHINCS_PLUS_SIGNATURE, X509AlgorithmRole.SIGNATURE_ALGORITHM));
+ }
+
+ private static ContentSigner contentSigner(PrivateKey privateKey, AlgorithmIdentifier identifier) {
+ return contentSigner(privateKey, identifier, "SPHINCS+");
+ }
+
+ private static ContentSigner contentSigner(PrivateKey privateKey, AlgorithmIdentifier identifier,
+ String jcaAlgorithm) {
+ return new ContentSigner() {
+ private final ByteArrayOutputStream output = new ByteArrayOutputStream();
+
+ @Override
+ public AlgorithmIdentifier getAlgorithmIdentifier() {
+ return identifier;
+ }
+
+ @Override
+ public OutputStream getOutputStream() {
+ return output;
+ }
+
+ @Override
+ public byte[] getSignature() {
+ try {
+ Signature signature = "SPHINCS+".equals(jcaAlgorithm)
+ ? Signature.getInstance(jcaAlgorithm, new BouncyCastlePQCProvider())
+ : Signature.getInstance(jcaAlgorithm);
+ signature.initSign(privateKey);
+ signature.update(output.toByteArray());
+ return signature.sign();
+ } catch (java.security.GeneralSecurityException failure) {
+ throw new IllegalStateException("Test signing failed", failure);
+ }
+ }
+ };
+ }
+
/**
* Test-only conflicting extension rule.
*/
diff --git a/pki/src/test/java/zeroecho/pki/testkit/H7ProfileDocuments.java b/pki/src/test/java/zeroecho/pki/testkit/H7ProfileDocuments.java
index 8112cde..e3c41a1 100644
--- a/pki/src/test/java/zeroecho/pki/testkit/H7ProfileDocuments.java
+++ b/pki/src/test/java/zeroecho/pki/testkit/H7ProfileDocuments.java
@@ -229,8 +229,9 @@ public final class H7ProfileDocuments {
List sanRules, boolean noKeyEncipherment, List eku) {
String keyUsage = noKeyEncipherment ? "[\"DIGITAL_SIGNATURE\"]"
: "[\"DIGITAL_SIGNATURE\",\"KEY_ENCIPHERMENT\"]";
- String json = "{\"schemaVersion\":2,\"certificateType\":\"END_ENTITY\",\"profileId\":\"" + id
+ String json = "{\"schemaVersion\":3,\"certificateType\":\"END_ENTITY\",\"profileId\":\"" + id
+ "\",\"profileVersion\":1,\"formatId\":\"" + formatId + "\",\"displayName\":\"H7 Test Profile\","
+ + "\"algorithmBindings\":{\"mode\":\"STANDARD_ONLY\"},"
+ "\"maxValidity\":\"PT8760H\",\"subject\":{\"allowEmpty\":" + allowEmpty + ",\"rules\":["
+ String.join(",", subjectRules) + "]}," + "\"subjectAlternativeNames\":{\"minimumTotal\":"
+ minimumTotal + ",\"maximumTotal\":" + maximumTotal + ",\"serviceIdentityRequired\":" + serviceIdentity
diff --git a/pki/src/test/java/zeroecho/pki/testkit/TestX509AlgorithmBindingProvider.java b/pki/src/test/java/zeroecho/pki/testkit/TestX509AlgorithmBindingProvider.java
new file mode 100644
index 0000000..f80b0f5
--- /dev/null
+++ b/pki/src/test/java/zeroecho/pki/testkit/TestX509AlgorithmBindingProvider.java
@@ -0,0 +1,142 @@
+/*******************************************************************************
+ * 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.testkit;
+
+import java.util.List;
+import java.util.Optional;
+
+import zeroecho.core.alg.BootstrapAlgorithmIdentities;
+import zeroecho.core.spec.AlgorithmIdentity;
+import zeroecho.pki.api.algorithm.X509AlgorithmBinding;
+import zeroecho.pki.impl.framework.x509.X509AlgorithmIdentifier;
+import zeroecho.pki.impl.framework.x509.X509AlgorithmRole;
+import zeroecho.pki.impl.framework.x509.X509BindingRule;
+import zeroecho.pki.spi.algorithm.X509AlgorithmBindingProvider;
+
+/** Deterministic deployer binding provider used only by service-loading tests. */
+public final class TestX509AlgorithmBindingProvider implements X509AlgorithmBindingProvider {
+ /** Stable provider identity. */
+ public static final String ID = "test.deployer.bindings";
+ /** Deployer-owned PEN test root. */
+ public static final String PEN_ROOT = "1.3.6.1.4.1.55555.42";
+ /** Egothor deployment-local test root. */
+ public static final String EGOTHOR_ROOT = "1.3.6.1.4.1.31424.1.2.4242";
+
+ private static final X509AlgorithmBinding CERTIFICATE = binding("test.ed25519.certificate.v1",
+ X509AlgorithmBinding.Role.CERTIFICATE_SIGNATURE, PEN_ROOT + ".1");
+ private static final X509AlgorithmBinding CRL = binding("test.ed25519.crl.v1",
+ X509AlgorithmBinding.Role.CRL_SIGNATURE, EGOTHOR_ROOT + ".1");
+
+ @Override
+ public String providerId() {
+ return ID;
+ }
+
+ @Override
+ public String bindingSetVersion() {
+ return "test-bindings-v1";
+ }
+
+ @Override
+ public List bindings() {
+ return List.of(CERTIFICATE, CRL);
+ }
+
+ @Override
+ public List rules() {
+ return List.of(new Rule(CERTIFICATE), new Rule(CRL));
+ }
+
+ private static X509AlgorithmBinding binding(String id, X509AlgorithmBinding.Role role, String oid) {
+ return new X509AlgorithmBinding(id, BootstrapAlgorithmIdentities.ED25519_SIGNATURE, role, oid,
+ X509AlgorithmBinding.Origin.DEPLOYER_PRIVATE, 1, X509AlgorithmBinding.ParameterRule.ABSENT,
+ X509AlgorithmBinding.PublicKeyEncoding.NOT_APPLICABLE,
+ X509AlgorithmBinding.SignatureEncoding.OPAQUE,
+ X509AlgorithmBinding.Interoperability.DEPLOYER_ECOSYSTEM, Optional.of(ID), id + "|v1");
+ }
+
+ private record Rule(X509AlgorithmBinding binding) implements X509BindingRule {
+ @Override
+ public String id() {
+ return binding.bindingId();
+ }
+
+ @Override
+ public X509AlgorithmRole role() {
+ return X509AlgorithmRole.SIGNATURE_ALGORITHM;
+ }
+
+ @Override
+ public String oid() {
+ return binding.oid();
+ }
+
+ @Override
+ public String semanticFingerprint() {
+ return binding.semanticCommitment();
+ }
+
+ @Override
+ public SignatureEncoding signatureEncoding() {
+ return SignatureEncoding.OPAQUE;
+ }
+
+ @Override
+ public PublicKeyEncoding publicKeyEncoding() {
+ return PublicKeyEncoding.NOT_APPLICABLE;
+ }
+
+ @Override
+ public List bindings() {
+ return List.of(binding);
+ }
+
+ @Override
+ public Optional encode(AlgorithmIdentity identity) {
+ return binding.algorithmIdentity().equals(identity)
+ ? Optional.of(X509AlgorithmIdentifier.absent(binding.oid())) : Optional.empty();
+ }
+
+ @Override
+ public Optional decode(X509AlgorithmIdentifier identifier) {
+ if (!binding.oid().equals(identifier.oid())) {
+ return Optional.empty();
+ }
+ if (identifier.parameterForm() != X509AlgorithmIdentifier.ParameterForm.ABSENT) {
+ throw new IllegalArgumentException("Test binding parameters must be absent");
+ }
+ return Optional.of(binding.algorithmIdentity());
+ }
+ }
+}
diff --git a/pki/src/test/resources/META-INF/services/zeroecho.pki.spi.algorithm.X509AlgorithmBindingProvider b/pki/src/test/resources/META-INF/services/zeroecho.pki.spi.algorithm.X509AlgorithmBindingProvider
new file mode 100644
index 0000000..ecc38bc
--- /dev/null
+++ b/pki/src/test/resources/META-INF/services/zeroecho.pki.spi.algorithm.X509AlgorithmBindingProvider
@@ -0,0 +1 @@
+zeroecho.pki.testkit.TestX509AlgorithmBindingProvider