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

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