feat(pki): add extensible X.509 algorithm bindings

Add an immutable X.509 binding registry for sealed standard mappings,
versioned ZeroEcho private OIDs and explicitly enabled deployer bindings.

Integrate binding commitments with profiles, issuance, verification, CRLs,
PKI sessions and typed CLI operations.
This commit is contained in:
2026-08-04 18:22:00 +02:00
parent 5c66a2b25b
commit f0dfb133f2
63 changed files with 2879 additions and 112 deletions

View File

@@ -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

View File

@@ -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 <operation> --config <file> [--output human|json] [arguments]");
writer.println(" zeroecho pki run <plan-file> --config <file> [--output human|json]");
writer.println("Operations:");
writer.println(" algorithm.binding.list --limit <1..1000> [--origin <origin>] [--role <role>]");
writer.println(" algorithm.binding.inspect --binding-id <id>");
writer.println(" algorithm.binding.validate [--binding-id <id> --binding-commitment <commitment>]");
writer.println(" configuration.validate");
writer.println(" profile.validate --profile-file <file>");
writer.println(" profile.register --profile-file <file>");

View File

@@ -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<String> ROOT_FIELDS_V1 = Set.of("version", "store", "audit");
private static final Set<String> ROOT_FIELDS_V2 = Set.of("version", "store", "audit", "signing", "publishers");
private static final Set<String> ROOT_FIELDS_V3 = Set.of("version", "store", "audit", "signing", "publishers",
"bindingProviders");
private static final Set<String> PROVIDER_FIELDS = Set.of("provider", "properties");
private static final Set<String> SIGNING_FIELDS = Set.of("workflow", "framework", "busPath",
"signatureAlgorithm", "signingTtlSeconds", "unlockEnvironmentVariable",
"certificateSignatureBinding", "crlSignatureBinding", "subjectPublicKeyBinding");
private static final Set<String> 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<ProviderConfig> 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<PkiSessionConfiguration.BindingProviderConfiguration> 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<String, PkiOperationValue> actual) {
if (!actual.keySet().containsAll(ROOT_FIELDS_V1) || !ROOT_FIELDS_V2.containsAll(actual.keySet())) {
private static void requireFields(Map<String, PkiOperationValue> actual, Set<String> 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<String> 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<String> 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<String> 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();

View File

@@ -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<String, Binding> bindings;
/* default */ PkiOperationRegistry() {
Map<String, Binding> 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<String> 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<String> 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<String> 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));
}

View File

@@ -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<zeroecho.pki.api.algorithm.X509AlgorithmBinding> bindings() {
return java.util.List.of();
}
@Override
public java.util.Optional<zeroecho.pki.api.algorithm.X509AlgorithmBinding> find(
String bindingId) {
return java.util.Optional.empty();
}
@Override
public java.util.Optional<zeroecho.pki.api.algorithm.X509AlgorithmBinding> 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);

View File

@@ -1,5 +1,5 @@
{
"version": 2,
"version": 3,
"store": {
"provider": "fs",
"properties": {

View File

@@ -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",

View File

@@ -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+"]}}

View File

@@ -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<AlgorithmIdentity> IDENTITIES = List.of(SHA256, SHA384, SHA512, MGF1, RSA_PKCS1_SHA256,
RSA_PKCS1_SHA384, RSA_PKCS1_SHA512, RSA_PSS_SHA256, ECDSA_SHA256, ECDSA_SHA384, ECDSA_SHA512,
ED25519_SIGNATURE, ED448_SIGNATURE, RSA_PUBLIC_KEY, EC_P256_PUBLIC_KEY, EC_P384_PUBLIC_KEY,
EC_P521_PUBLIC_KEY, ED25519_PUBLIC_KEY, ED448_PUBLIC_KEY);
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() {
}

View File

@@ -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<String, SignatureInteropProfile> 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() {
}

View File

@@ -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.
*
* <p>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.</p>
*
* @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<String> 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;
}
}

View File

@@ -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.
*
* <p>Implementations provide deterministic constant-time identifier lookup and
* linear-time ordered listing. Returned collections are immutable snapshots.</p>
*/
public interface X509AlgorithmBindingRegistry {
/** @return deterministic immutable binding order */
List<X509AlgorithmBinding> bindings();
/**
* Resolves a stable binding identifier.
*
* @param bindingId stable identifier
* @return exact descriptor, or empty when inactive
*/
Optional<X509AlgorithmBinding> 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<X509AlgorithmBinding> 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);
}

View File

@@ -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<String> SUPPORTED_SUBJECT_KEY_ALGORITHMS = Set.of("RSA", "ECDSA", "Ed25519", "Ed448");
private static final Set<String> SUPPORTED_SUBJECT_KEY_ALGORITHMS = Set.of("RSA", "ECDSA", "Ed25519", "Ed448",
"SPHINCS+");
/** Validates and constructs the CA policy. */
public CaCertificatePolicy {

View File

@@ -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());
}
}

View File

@@ -41,8 +41,9 @@ import zeroecho.pki.api.FormatId;
* Immutable, versioned certificate-profile configuration.
*
* <p>
* 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.
* </p>
*
@@ -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;

View File

@@ -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<X509AlgorithmBindingPolicy.BindingReference> subject = Optional.empty();
Optional<X509AlgorithmBindingPolicy.BindingReference> csr = Optional.empty();
Optional<X509AlgorithmBindingPolicy.BindingReference> certificate = Optional.empty();
Optional<X509AlgorithmBindingPolicy.BindingReference> 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 extends Enum<E>> E readEnum(JsonParser parser, Class<E> 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<SubjectRdnRule> readSubjectRules(JsonParser parser, String path) throws JacksonException {
requireToken(parser.currentToken(), JsonToken.START_ARRAY, path);
List<SubjectRdnRule> 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<X509AlgorithmBindingPolicy.BindingReference> 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;

View File

@@ -52,7 +52,8 @@ public record LeafCertificatePolicy(SubjectAlternativeNamePolicy subjectAlternat
boolean extendedKeyUsageCritical, boolean basicConstraintsCritical, Set<String> allowedSubjectKeyAlgorithmIds)
implements CertificatePolicy {
private static final Set<String> SUPPORTED_SUBJECT_KEY_ALGORITHMS = Set.of("RSA", "ECDSA", "Ed25519", "Ed448");
private static final Set<String> SUPPORTED_SUBJECT_KEY_ALGORITHMS = Set.of("RSA", "ECDSA", "Ed25519", "Ed448",
"SPHINCS+");
/**
* Validates and constructs the policy.

View File

@@ -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.
*
* <p>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.</p>
*
* @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<BindingReference> subjectPublicKey,
Optional<BindingReference> csrSignature, Optional<BindingReference> certificateSignature,
Optional<BindingReference> 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());
}
}

View File

@@ -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<IssuanceService> issuance;
private final Optional<StatusObjectService> statusObjects;
private final Optional<PublicationService> 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<CertificationRequestService> requests, Optional<IssuanceService> issuance,
Optional<StatusObjectService> statusObjects, Optional<PublicationService> 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<CaService> authorities,
Optional<CertificationRequestService> requests, Optional<IssuanceService> issuance,
Optional<StatusObjectService> statusObjects, Optional<PublicationService> 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<PkiOperationValue> 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<String, PkiOperationValue> 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<String, PkiOperationValue> 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<String, PkiOperationValue> 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<String, PkiOperationValue> 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<X509AlgorithmBinding> 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<String, PkiOperationValue> 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<String, PkiOperationValue> 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<X509AlgorithmBindingPolicy.BindingReference> 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);

View File

@@ -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<PublicationService> publications;
private final Optional<PkiSigningBus> signingBus;
private final Optional<SignatureWorkflow> 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<X509BindingRuleProvider> bindingProviders) {
Optional<SignatureWorkflow> workflow = Optional.empty();
Optional<PkiSigningBus> bus = Optional.empty();
Optional<CaService> 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<Publisher> 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<X509BindingRuleProvider> bindingProviders) {
Set<String> supported = Set.copyOf(workflow.supportedAlgorithms());
Set<AlgorithmIdentity> 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<X509AlgorithmBindingProvider> discovered = ServiceLoader.load(X509AlgorithmBindingProvider.class)
.stream().map(ServiceLoader.Provider::get)
.sorted(Comparator.comparing(X509AlgorithmBindingProvider::providerId)).toList();
Map<String, X509AlgorithmBindingProvider> 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<X509AlgorithmBinding> descriptors = new ArrayList<>(StandardX509Bindings.descriptors());
descriptors.addAll(ZeroEchoPrivateX509Bindings.descriptors());
List<X509BindingRuleProvider> 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<X509AlgorithmBinding> 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<zeroecho.pki.impl.framework.x509.X509BindingRule> rules = List.copyOf(provider.rules());
Set<String> descriptorIds = additions.stream().map(X509AlgorithmBinding::bindingId)
.collect(java.util.stream.Collectors.toUnmodifiableSet());
Set<String> 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<zeroecho.pki.impl.framework.x509.X509BindingRule> 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<String> 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<String> environment = configuration.signing()
.flatMap(PkiSessionConfiguration.SigningConfiguration::unlockEnvironmentVariable);
@@ -474,6 +649,14 @@ final class DefaultPkiSession implements PkiSession {
}
}
private record BindingComposition(X509AlgorithmBindingRegistry registry,
List<X509BindingRuleProvider> providers) {
private BindingComposition {
Objects.requireNonNull(registry, "registry");
providers = List.copyOf(providers);
}
}
/** Production provider bootstrap implementation. */
private enum ProductionBootstrap implements Bootstrap {
INSTANCE;

View File

@@ -53,6 +53,8 @@ import zeroecho.pki.api.status.StatusObjectType;
* explicit type and never by reflection or Java class name.</p>
*/
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<String> origin, java.util.Optional<String> 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<String> bindingId,
java.util.Optional<String> 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. */

View File

@@ -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();

View File

@@ -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<SigningConfiguration> signing, List<ProviderConfig> publishers) {
Optional<SigningConfiguration> signing, List<ProviderConfig> publishers,
List<BindingProviderConfiguration> 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<SigningConfiguration> signing, List<ProviderConfig> 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<String> unlockEnvironmentVariable) {
String signatureAlgorithm, Duration signingTtl, Optional<String> unlockEnvironmentVariable,
Optional<String> certificateSignatureBinding, Optional<String> crlSignatureBinding,
Optional<String> subjectPublicKeyBinding) {
/** Creates standard-mode signing configuration. */
public SigningConfiguration(ProviderConfig workflow, ProviderConfig framework, String busPath,
String signatureAlgorithm, Duration signingTtl, Optional<String> 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<String> binding(Optional<String> 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<String> authorizedOidRoots,
Optional<String> 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;
});
}
}
}

View File

@@ -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,

View File

@@ -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<String> 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<String> 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<String> 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) {

View File

@@ -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");
}

View File

@@ -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<String> 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<SignatureWorkflow> plan = signingBus.authority()
.planSigning(signatureIdentity.canonicalForm(), SignatureWorkflow.class);
X509ExecutionPlan<SignatureWorkflow> 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);

View File

@@ -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<String> certificateBinding;
private final Optional<String> crlBinding;
private final Optional<String> 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<String> certificateBinding, Optional<String> crlBinding, Optional<String> 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<X509AlgorithmBindingPolicy.BindingReference> 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<X509AlgorithmBindingPolicy.BindingReference> selected,
Optional<String> 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<X509AlgorithmBinding> 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.

View File

@@ -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<SubjectRdn> 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<SubjectRdn> 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;
}
}

View File

@@ -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<SubjectRdn> subjectRdns, List<SubjectAlternativeName> subjectAlternativeNames,
boolean subjectAlternativeNameCritical, EncodedObject exactPublicKey, Validity validity,
Set<LeafKeyUsage> keyUsages, Set<ExtendedKeyUsageId> 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<SubjectRdn> subjectRdns, List<SubjectAlternativeName> subjectAlternativeNames,
boolean subjectAlternativeNameCritical, EncodedObject exactPublicKey, Validity validity,
Set<LeafKeyUsage> keyUsages, Set<ExtendedKeyUsageId> 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;
}
}

View File

@@ -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() {

View File

@@ -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<X509AlgorithmBinding> bindings;
private final Map<String, X509AlgorithmBinding> byId;
private final Map<String, X509AlgorithmBinding> standards;
private final String commitment;
private ImmutableX509AlgorithmBindingRegistry(List<X509AlgorithmBinding> source) {
this.bindings = source;
Map<String, X509AlgorithmBinding> identifiers = new LinkedHashMap<>();
Map<String, X509AlgorithmBinding> official = new LinkedHashMap<>();
Map<String, X509AlgorithmBinding> 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<X509AlgorithmBinding> bindings) {
Objects.requireNonNull(bindings, "bindings");
List<X509AlgorithmBinding> 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<X509AlgorithmBinding> bindings() {
return bindings;
}
@Override
public Optional<X509AlgorithmBinding> find(String bindingId) {
return Optional.ofNullable(byId.get(Objects.requireNonNull(bindingId, "bindingId")));
}
@Override
public Optional<X509AlgorithmBinding> 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<X509AlgorithmBinding> 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);
}
}
}

View File

@@ -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<X509AlgorithmBinding> 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<X509AlgorithmBinding> bindings() {
return descriptors(id, role, identity, identifier, signatureEncoding, publicKeyEncoding,
semanticFingerprint());
}
@Override
public Optional<X509AlgorithmIdentifier> 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<X509AlgorithmBinding> bindings() {
X509AlgorithmIdentifier identifier = encode(BootstrapAlgorithmIdentities.RSA_PSS_SHA256).orElseThrow();
return descriptors(id(), role(), BootstrapAlgorithmIdentities.RSA_PSS_SHA256, identifier,
signatureEncoding(), publicKeyEncoding(), semanticFingerprint());
}
@Override
public Optional<X509AlgorithmIdentifier> encode(AlgorithmIdentity identity) {
if (identity.kind() != AlgorithmIdentity.Kind.SIGNATURE
@@ -302,6 +326,16 @@ public final class StandardX509Bindings {
return PublicKeyEncoding.EC_POINT;
}
@Override
public List<X509AlgorithmBinding> 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<X509AlgorithmIdentifier> 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<X509AlgorithmBinding> 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);

View File

@@ -194,6 +194,14 @@ public final class X509AlgorithmResolver {
public Selection resolve(AlgorithmIdentity requested, AlgorithmIdentity key,
AlgorithmExecutionCapability.Direction direction, Optional<String> 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<String> implementation, String provenance,
String authorityFingerprint, Optional<String> 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);
}

View File

@@ -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<X509BindingRule> 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<String> 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 <E> X509ExecutionPlan<E> plan(AlgorithmIdentity signature, AlgorithmIdentity key,
AlgorithmExecutionCapability.Direction direction, Optional<String> implementation, String provenance,
String bindingId, Class<E> 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 <E> X509ExecutionPlan<E> planSigningWithBinding(String value, String bindingId, Class<E> 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;

View File

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

View File

@@ -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.
*
* <p>A parameterized rule may expose one descriptor for every currently
* registered exact identity. Descriptors do not expose implementation
* classes or mutable codec state.</p>
*
* @return immutable descriptors
*/
default List<X509AlgorithmBinding> 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
}

View File

@@ -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) {

View File

@@ -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<String> ROOT_FIELDS = Set.of("schemaVersion", "bindings");
private static final Set<String> 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<X509AlgorithmBinding> 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<String> seen = new HashSet<>();
int schema = 0;
List<X509AlgorithmBinding> 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<X509AlgorithmBinding> readBindings(JsonParser parser) throws IOException {
require(parser.currentToken(), JsonToken.START_ARRAY);
List<X509AlgorithmBinding> bindings = new ArrayList<>();
while (parser.nextToken() != JsonToken.END_ARRAY) {
require(parser.currentToken(), JsonToken.START_OBJECT);
Set<String> 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<X509AlgorithmBinding> descriptors) {
if (descriptors.size() != 4) {
throw invalid();
}
ImmutableX509AlgorithmBindingRegistry.create(descriptors);
List<X509BindingRule> 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<X509AlgorithmBinding> 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<X509AlgorithmBinding> bindings() {
return List.of(binding);
}
@Override
public Optional<X509AlgorithmIdentifier> encode(AlgorithmIdentity identity) {
return binding.algorithmIdentity().equals(identity)
? Optional.of(X509AlgorithmIdentifier.absent(binding.oid())) : Optional.empty();
}
@Override
public Optional<AlgorithmIdentity> 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());
}
}
}

View File

@@ -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<String> 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<String> 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<LeafKeyUsage> 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);

View File

@@ -215,6 +215,8 @@ public final class BcX509ProofOfPossessionVerifier implements ProofOfPossessionV
X509ExecutionPlan<BcX509VerificationExecutor> 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(),

View File

@@ -0,0 +1,107 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.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);
}
}

View File

@@ -209,6 +209,8 @@ public final class BcX509SignedObjectValidator {
X509ExecutionPlan<BcX509VerificationExecutor> 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,

View File

@@ -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<String> 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<String> 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<BcX509VerificationExecutor> 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())) {

View File

@@ -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<AlgorithmExecutionCapability> 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);
}
}

View File

@@ -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<String> 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<SignatureWorkflow> plan = bus.authority()
.planSigning(algorithmIdentity.canonicalForm(), SignatureWorkflow.class);
X509ExecutionPlan<SignatureWorkflow> 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());
}
/**

View File

@@ -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"));

View File

@@ -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.
*
* <p>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.</p>
*/
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<X509AlgorithmBinding> bindings();
/** @return finite immutable role-specific encoding rules */
List<X509BindingRule> rules();
}

View File

@@ -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"]}}
{"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"]}}

View File

@@ -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"]}}
{"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"]}}

View File

@@ -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"]}}
{"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"]}}

View File

@@ -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"]}}
{"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"]}}

View File

@@ -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"]}}
{"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"]}}

View File

@@ -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"]}}
{"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"]}}

View File

@@ -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"
}
]
}

View File

@@ -245,10 +245,10 @@ final class BuiltInCertificateProfileCatalogTest {
@Test
void profileDocumentsFailClosedForInvalidSchemaFieldsEncodingAndClassMetadata() {
List<byte[]> 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<String, List<byte[]>> resources = baseResources();

View File

@@ -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));

View File

@@ -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);

View File

@@ -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<X509AlgorithmBinding> 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();

View File

@@ -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<X509AlgorithmBinding> 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<BcX509VerificationExecutor> 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<BcX509VerificationExecutor> 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<X509BindingRule> 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<BcX509VerificationExecutor> 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.
*/

View File

@@ -229,8 +229,9 @@ public final class H7ProfileDocuments {
List<String> sanRules, boolean noKeyEncipherment, List<String> 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

View File

@@ -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<X509AlgorithmBinding> bindings() {
return List.of(CERTIFICATE, CRL);
}
@Override
public List<X509BindingRule> 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<X509AlgorithmBinding> bindings() {
return List.of(binding);
}
@Override
public Optional<X509AlgorithmIdentifier> encode(AlgorithmIdentity identity) {
return binding.algorithmIdentity().equals(identity)
? Optional.of(X509AlgorithmIdentifier.absent(binding.oid())) : Optional.empty();
}
@Override
public Optional<AlgorithmIdentity> 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());
}
}
}

View File

@@ -0,0 +1 @@
zeroecho.pki.testkit.TestX509AlgorithmBindingProvider