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